From 0f4a4ea256ea8ec37bf76cf1353eb9256b5be093 Mon Sep 17 00:00:00 2001 From: Reed Es Date: Thu, 20 Apr 2023 20:55:52 -0600 Subject: [PATCH] Support Decimal Type #2 --- README.md | 22 +-- Sources/NPDecimalConfig.swift | 89 ++++++++++ Tests/DecimalTests.swift | 324 ++++++++++++++++++++++++++++++++++ 3 files changed, 417 insertions(+), 18 deletions(-) create mode 100644 Sources/NPDecimalConfig.swift create mode 100644 Tests/DecimalTests.swift diff --git a/README.md b/README.md index e9dc265..3d78d41 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ _SwiftNumberPad_ is part of the [OpenAlloc](https://github.com/openalloc) family ## Features -* Support for both integer and floating point types +* Support for integer, floating point, and Decimal types * Presently targeting .macOS(.v13), .iOS(.v16), .watchOS(.v9) * No external dependencies! @@ -67,27 +67,13 @@ Please submit pull requests if you'd like to tackle any of these. Thanks! * SwiftUI Preview not reliably working on macOS * See if earlier versions of platforms can be supported * Support for negative values -* Support for Decimal input ## See Also -* [SwiftSideways](https://github.com/openalloc/SwiftSideways) - multi-platform SwiftUI component for the horizontal scrolling of tabular data in compact areas -* [SwiftDetailer](https://github.com/openalloc/SwiftDetailer) - multi-platform SwiftUI component for editing fielded data -* [SwiftDetailerMenu](https://github.com/openalloc/SwiftDetailerMenu) - optional menu support for _SwiftDetailer_ -* [SwiftCompactor](https://github.com/openalloc/SwiftCompactor) - formatters for the concise display of Numbers, Currency, and Time Intervals -* [SwiftModifiedDietz](https://github.com/openalloc/SwiftModifiedDietz) - A tool for calculating portfolio performance using the Modified Dietz method -* [SwiftNiceScale](https://github.com/openalloc/SwiftNiceScale) - generate 'nice' numbers for label ticks over a range, such as for y-axis on a chart -* [SwiftRegressor](https://github.com/openalloc/SwiftRegressor) - a linear regression tool that’s flexible and easy to use -* [SwiftSeriesResampler](https://github.com/openalloc/SwiftSeriesResampler) - transform a series of coordinate values into a new series with uniform intervals -* [SwiftSimpleTree](https://github.com/openalloc/SwiftSimpleTree) - a nested data structure that’s flexible and easy to use +This library is a member of the _OpenAlloc Project_. -And open source apps using this library (by the same author): - -* [Gym Routine Tracker](https://open-trackers.github.io/grt/) - minimalist workout tracker, for the Apple Watch, iPhone, and iPad -* [Daily Calorie Tracker](https://open-trackers.github.io/dct/) - minimalist calorie tracker, for the Apple Watch, iPhone, and iPad - -* [FlowAllocator](https://openalloc.github.io/FlowAllocator/index.html) - portfolio rebalancing tool for macOS -* [FlowWorth](https://openalloc.github.io/FlowWorth/index.html) - a new portfolio performance and valuation tracking tool for macOS +* [_OpenAlloc_](https://openalloc.github.io) - product website for all the _OpenAlloc_ apps and libraries +* [_OpenAlloc Project_](https://github.com/openalloc) - Github site for the development project, including full source code ## License diff --git a/Sources/NPDecimalConfig.swift b/Sources/NPDecimalConfig.swift new file mode 100644 index 0000000..efd5b81 --- /dev/null +++ b/Sources/NPDecimalConfig.swift @@ -0,0 +1,89 @@ +// +// NPDecimalConfig.swift +// +// Copyright 2023 OpenAlloc LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + + +public final class NPDecimalConfig: NPBaseConfig +{ + // MARK: - Parameters + + public let precision: Int + + public init(_ val: Decimal, + precision: Int = NumberPadEnum.defaultPrecision, + upperBound: Decimal = Decimal.greatestFiniteMagnitude) + { + self.precision = precision + + let formatter = { + let nf = NumberFormatter() + nf.locale = Locale.current + nf.numberStyle = .decimal + nf.usesGroupingSeparator = false + nf.isLenient = true + nf.minimumFractionDigits = 0 + nf.maximumFractionDigits = precision + nf.generatesDecimalNumbers = true + return nf + }() + + let sVal = Self.toString(val, upperBound: upperBound, formatter: formatter) + + super.init(sValue: sVal, upperBound: upperBound, formatter: formatter) + } + + // MARK: - Type-specific Actions + + override public var showDecimalPoint: Bool { precision > 0 } + + override public func decimalPointAction() -> Bool { + guard decimalPointIndex == nil else { return false } + sValue.append(".") + + return true + } + + // MARK: - Internal + + internal static func toString(_ val: Decimal, upperBound: Decimal, formatter: NumberFormatter) -> String { + let clampedValue = max(0, min(val, upperBound)) + return formatter.string(from: clampedValue as NSDecimalNumber) ?? "0" + } + + override internal func validateDigit(_: NumberPadEnum) -> Bool { + let cp = currentPrecision + if cp > 0, cp == precision { return false } // ignore additional input + return true + } + + override internal func toValue(_ str: String) -> Decimal? { + guard let val: NSNumber = formatter.number(from: str) + else { return nil } + return Decimal(val.doubleValue) + } + + internal var currentPrecision: Int { + guard let di = decimalPointIndex else { return 0 } + return sValue.distance(from: di, to: sValue.endIndex) - 1 + } + + internal var decimalPointIndex: String.Index? { + sValue.firstIndex(of: ".") + } +} diff --git a/Tests/DecimalTests.swift b/Tests/DecimalTests.swift new file mode 100644 index 0000000..20d7607 --- /dev/null +++ b/Tests/DecimalTests.swift @@ -0,0 +1,324 @@ +// +// DecimalTests.swift +// +// Copyright 2023 OpenAlloc LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest + +@testable import NumberPad + +class DecimalTests: XCTestCase { + func testNoArgs() throws { + let x = NPDecimalConfig(0.0) + XCTAssertEqual("0", x.stringValue) + XCTAssertEqual(0, x.value) + } + + func testInitZero() throws { + let x = NPDecimalConfig(0) + XCTAssertEqual("0", x.stringValue) + XCTAssertEqual(0, x.value) + } + + func testInit1() throws { + let x = NPDecimalConfig(1) + XCTAssertEqual("1", x.stringValue) + XCTAssertEqual(1, x.value) + } + + func testInit1_0() throws { + let x = NPDecimalConfig(1.0) + XCTAssertEqual("1", x.stringValue) + XCTAssertEqual(1, x.value) + } + + func testInit1_1() throws { + let x = NPDecimalConfig(1.1) + XCTAssertEqual("1.1", x.stringValue) + XCTAssertEqual(1.1, x.value) + } + + func testInitBad() throws { + let x = NPDecimalConfig(-1, upperBound: 1) + XCTAssertEqual("0", x.stringValue) + XCTAssertEqual(0, x.value) + } + + func testValueLargeWithinPrecision() throws { + let x = NPDecimalConfig(2_348_938.93, precision: 2) + XCTAssertEqual("2348938.93", x.stringValue) + XCTAssertEqual(2_348_938.93, x.value) + } + + func testValueLargeBeyondPrecisionRounds() throws { + let x = NPDecimalConfig(2_348_938.936, precision: 2) + XCTAssertEqual("2348938.94", x.stringValue) + XCTAssertEqual(2_348_938.94, x.value) + } + + func testBadDigits() throws { + let x = NPDecimalConfig(34.3) + let r1 = x.digitAction(.backspace) + XCTAssertFalse(r1) + XCTAssertEqual("34.3", x.stringValue) + XCTAssertEqual(34.3, x.value) + + let r2 = x.digitAction(.decimalPoint) + XCTAssertFalse(r2) + XCTAssertEqual("34.3", x.stringValue) + XCTAssertEqual(34.3, x.value) + } + + func testAddAndRemoveDigits() throws { + let x = NPDecimalConfig(0.0) + let r1 = x.digitAction(.d1) + XCTAssertTrue(r1) + XCTAssertEqual("1", x.stringValue) + XCTAssertEqual(1, x.value) + + let r2 = x.digitAction(.d2) + XCTAssertTrue(r2) + XCTAssertEqual("12", x.stringValue) + XCTAssertEqual(12, x.value) + + x.backspaceAction() + XCTAssertEqual("1", x.stringValue) + XCTAssertEqual(1, x.value) + + x.backspaceAction() + XCTAssertEqual("0", x.stringValue) + XCTAssertEqual(0, x.value) + } + + func testRedundantZero() throws { + let x = NPDecimalConfig(0.0) + XCTAssertEqual("0", x.stringValue) + XCTAssertEqual(0, x.value) + + let r = x.digitAction(.d0) + XCTAssertTrue(r) + XCTAssertEqual("0", x.stringValue) + XCTAssertEqual(0, x.value) + } + + func testAddDigitAndDecimalPoint() throws { + let x = NPDecimalConfig(0.0) + let r1 = x.digitAction(.d1) + XCTAssertTrue(r1) + XCTAssertEqual("1", x.stringValue) + XCTAssertEqual(1, x.value) + + let d = x.decimalPointAction() + XCTAssertTrue(d) + XCTAssertEqual("1.", x.stringValue) + XCTAssertEqual(1, x.value) + + let r2 = x.digitAction(.d2) + XCTAssertTrue(r2) + XCTAssertEqual("1.2", x.stringValue) + XCTAssertEqual(1.2, x.value) + } + + func testBackspaceDecimalPoint() throws { + let x = NPDecimalConfig(1.2) + XCTAssertEqual("1.2", x.stringValue) + XCTAssertEqual(1.2, x.value) + + x.backspaceAction() + XCTAssertEqual("1.", x.stringValue) + XCTAssertEqual(1, x.value) + + x.backspaceAction() + XCTAssertEqual("1", x.stringValue) + XCTAssertEqual(1, x.value) + + x.backspaceAction() + XCTAssertEqual("0", x.stringValue) + XCTAssertEqual(0, x.value) + } + + func testRedundantBackspace() throws { + let x = NPDecimalConfig(1) + XCTAssertEqual("1", x.stringValue) + XCTAssertEqual(1, x.value) + + x.backspaceAction() + XCTAssertEqual("0", x.stringValue) + XCTAssertEqual(0, x.value) + + x.backspaceAction() + XCTAssertEqual("0", x.stringValue) + XCTAssertEqual(0, x.value) + } + + func testRedundantAdjacentDecimalPoint() throws { + let x = NPDecimalConfig(0.0) + let r = x.digitAction(.d1) + XCTAssertTrue(r) + XCTAssertEqual("1", x.stringValue) + XCTAssertEqual(1, x.value) + + let d1 = x.decimalPointAction() + XCTAssertTrue(d1) + XCTAssertEqual("1.", x.stringValue) + XCTAssertEqual(1, x.value) + + let d2 = x.decimalPointAction() + XCTAssertFalse(d2) + XCTAssertEqual("1.", x.stringValue) + XCTAssertEqual(1, x.value) + } + + func testRedundantSecondDecimalPoint() throws { + let x = NPDecimalConfig(1.2) + + XCTAssertEqual("1.2", x.stringValue) + XCTAssertEqual(1.2, x.value) + + let d = x.decimalPointAction() + XCTAssertFalse(d) + XCTAssertEqual("1.2", x.stringValue) + XCTAssertEqual(1.2, x.value) + } + + func testDecimalPointBackspace() throws { + let x = NPDecimalConfig(0.0) + let r = x.digitAction(.d1) + XCTAssertTrue(r) + XCTAssertEqual("1", x.stringValue) + XCTAssertEqual(1, x.value) + + let d1 = x.decimalPointAction() + XCTAssertTrue(d1) + XCTAssertEqual("1.", x.stringValue) + XCTAssertEqual(1, x.value) + + let d2 = x.decimalPointAction() + XCTAssertFalse(d2) + XCTAssertEqual("1.", x.stringValue) + XCTAssertEqual(1, x.value) + } + + func testPenny() throws { + let x = NPDecimalConfig(0.0, precision: 2) + XCTAssertEqual("0", x.stringValue) + XCTAssertEqual(0, x.value) + XCTAssertEqual(0, x.currentPrecision) + + let d = x.decimalPointAction() + XCTAssertTrue(d) + XCTAssertEqual("0.", x.stringValue) + XCTAssertEqual(0, x.value) + XCTAssertEqual(0, x.currentPrecision) + + let r1 = x.digitAction(.d0) + XCTAssertTrue(r1) + XCTAssertEqual("0.0", x.stringValue) + XCTAssertEqual(0, x.value) + XCTAssertEqual(1, x.currentPrecision) + + let r2 = x.digitAction(.d1) + XCTAssertTrue(r2) + XCTAssertEqual("0.01", x.stringValue) + XCTAssertEqual(0.01, x.value) + XCTAssertEqual(2, x.currentPrecision) + } + + func testIgnoreIfBeyondPrecision() throws { + let x = NPDecimalConfig(0.01, precision: 2) + + XCTAssertEqual("0.01", x.stringValue) + XCTAssertEqual(0.01, x.value) + XCTAssertEqual(2, x.currentPrecision) + + let r = x.digitAction(.d9) + XCTAssertFalse(r) + XCTAssertEqual("0.01", x.stringValue) + XCTAssertEqual(0.01, x.value) + XCTAssertEqual(2, x.currentPrecision) + } + + func testInitializeOutsidePrecision() throws { + let x = NPDecimalConfig(10.18, precision: 1) + XCTAssertEqual("10.2", x.stringValue) + XCTAssertEqual(10.2, x.value) + } + + func testInitializeInsideRange() throws { + let x = NPDecimalConfig(10, upperBound: 10) + XCTAssertEqual("10", x.stringValue) + XCTAssertEqual(10, x.value) + } + + func testInitializeOutsideRange() throws { + let x = NPDecimalConfig(10.01, precision: 2, upperBound: 10) + XCTAssertEqual("10", x.stringValue) + XCTAssertEqual(10, x.value) + } + + func testIgnoreIfOutsideRange() throws { + let x = NPDecimalConfig(10, precision: 2, upperBound: 10) + XCTAssertEqual("10", x.stringValue) + XCTAssertEqual(10, x.value) + + let d = x.decimalPointAction() + XCTAssertTrue(d) + XCTAssertEqual("10.", x.stringValue) + XCTAssertEqual(10, x.value) + XCTAssertEqual(0, x.currentPrecision) + + let r1 = x.digitAction(.d0) + XCTAssertTrue(r1) + XCTAssertEqual("10.0", x.stringValue) + XCTAssertEqual(10, x.value) + XCTAssertEqual(1, x.currentPrecision) + + let r2 = x.digitAction(.d1) + XCTAssertFalse(r2) + XCTAssertEqual("10.0", x.stringValue) + XCTAssertEqual(10.0, x.value) + XCTAssertEqual(1, x.currentPrecision) + + let r3 = x.digitAction(.d0) + XCTAssertTrue(r3) + XCTAssertEqual("10.00", x.stringValue) + XCTAssertEqual(10, x.value) + XCTAssertEqual(2, x.currentPrecision) + } + + // test to reproduce observed failing behavior + func testAddDecimalOnDecimal() throws { + let x = NPDecimalConfig(Decimal(30), precision: 1, upperBound: Decimal(500)) + XCTAssertEqual("30", x.stringValue) + XCTAssertEqual(30, x.value) + + let d = x.decimalPointAction() + XCTAssertTrue(d) + XCTAssertEqual("30.", x.stringValue) + XCTAssertEqual(30, x.value) + + let r2 = x.digitAction(.d2) + XCTAssertTrue(r2) + XCTAssertEqual("30.2", x.stringValue) + XCTAssertEqual(30.2, x.value) + } + + // test to reproduce observed failing behavior + func testToValue() throws { + let x = NPDecimalConfig(Decimal(0), precision: 1, upperBound: Decimal(500)) + XCTAssertEqual(30.2, x.toValue("30.2")) + } +}