From a089c38b55ddce696371bd57c165ff04e5463b9b Mon Sep 17 00:00:00 2001 From: Janos Bonic <86970079+janosdebugs@users.noreply.github.com> Date: Mon, 12 Sep 2022 22:02:04 +0200 Subject: [PATCH] Units implemented --- .github/dependabot.yml | 6 + .github/pull_request_template.md | 11 + .github/scripts/gogenerate.sh | 27 ++ .github/workflows/build.yml | 65 +++++ .golangci.yml | 93 +++++++ go.mod | 3 + schema/doc.go | 2 + schema/errors.go | 80 ++++++ schema/types.go | 44 +++ schema/units.go | 457 +++++++++++++++++++++++++++++++ schema/units_test.go | 42 +++ 11 files changed, 830 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/pull_request_template.md create mode 100755 .github/scripts/gogenerate.sh create mode 100644 .github/workflows/build.yml create mode 100644 .golangci.yml create mode 100644 go.mod create mode 100644 schema/doc.go create mode 100644 schema/errors.go create mode 100644 schema/types.go create mode 100644 schema/units.go create mode 100644 schema/units_test.go diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..20e1ef1 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "daily" \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..ebf171a --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,11 @@ +## Please describe the change you are making + +... + +## Are you the owner of the code you are sending in, or do you have permission of the owner? + +... + +## The code will be published under the BSD 3 clause license. Have you read and understood this license? + +... diff --git a/.github/scripts/gogenerate.sh b/.github/scripts/gogenerate.sh new file mode 100755 index 0000000..3bd55c6 --- /dev/null +++ b/.github/scripts/gogenerate.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +export NOLINT=1 +go generate >/tmp/gogenerate.output 2>/tmp/gogenerate.output +if [ $? -ne 0 ]; then + echo -e "::group::\e[0;31m❌ Go generate failed.\e[0m" + cat /tmp/gogenerate.output + echo "::endgroup::" + exit 1 +fi + +echo -e "::group::\e[0;32m✅ Go generate succeeded.\e[0m" +cat /tmp/gogenerate.output +echo "::endgroup::" + +git diff >/tmp/gogenerate.diff +if [ "$(cat /tmp/gogenerate.diff | wc -l)" -ne 0 ]; then + echo -e "::group::\e[0;31m❌ Git changes after go generate.\e[0m" + echo "The following is the diff of files:" + cat /tmp/gogenerate.diff + echo "::endgroup::" + echo -e "\e[0;31m⚙ Please run go generate before pushing your changes.\e[0m" + exit 1 +fi + +echo -e "::group::\e[0;32m✅ No changes after go generate.\e[0m" +echo "::endgroup::" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..33d1ed2 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,65 @@ +name: Build +on: + push: + branches: + - main + pull_request: +jobs: + golangci-lint: + name: golangci-lint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v2 + test: + name: go test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Set up Go + uses: actions/setup-go@v2 + with: + go-version: 1.18 + - name: Set up gotestfmt + uses: haveyoudebuggedit/gotestfmt-action@v2 + - uses: actions/cache@v2 + with: + path: | + ~/go/pkg/mod + ~/.cache/go-build + key: go-test-${{ hashFiles('**/go.sum') }} + restore-keys: go-test- + - name: Run go test + run: | + set -euo pipefail + go generate + go test -json -v ./... 2>&1 | tee /tmp/gotest.log | gotestfmt + - name: Upload test log + uses: actions/upload-artifact@v2 + if: always() + with: + name: test-log + path: /tmp/gotest.log + if-no-files-found: error + generate: + name: go generate + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Set up Go + uses: actions/setup-go@v2 + with: + go-version: 1.16 + - uses: actions/cache@v2 + with: + path: | + ~/go/pkg/mod + ~/.cache/go-build + key: go-test-${{ hashFiles('**/go.sum') }} + restore-keys: go-generate- + - name: Run go generate + run: ./.github/scripts/gogenerate.sh \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..b093175 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,93 @@ +run: + timeout: 5m +linters: + enable: + # region General + + # Add depguard to prevent adding additional dependencies. This is a client library, we really don't want + # additional dependencies. + - depguard + # Prevent improper directives in go.mod. + - gomoddirectives + # Prevent improper nolint directives. + - nolintlint + + # endregion + + + # region Code Quality and Comments + + # Inspect source code for potential security problems. This check has a fairly high false positive rate, + # comment with // nolint:gosec where not relevant. + - gosec + # Replace golint. + - revive + # Complain about deeply nested if cases. + - nestif + # Prevent naked returns in long functions. + - nakedret + # Make Go code more readable. + - gocritic + # We don't want hidden global scope, so disallow global variables. You can disable this with + # Check if comments end in a period. This helps prevent incomplete comment lines, such as half-written sentences. + - godot + # Complain about comments as these indicate incomplete code. + - godox + # Keep the cyclomatic complexity of functions to a reasonable level. + - gocyclo + # Complain about cognitive complexity of functions. + - gocognit + # Find repeated strings that could be converted into constants. + - goconst + # Complain about unnecessary type conversions. + - unconvert + # Complain about unused parameters. These should be replaced with underscores. + - unparam + # Check for non-ASCII identifiers. + - asciicheck + # Check for HTTP response body being closed. Sometimes, you may need to disable this using // nolint:bodyclose. + - bodyclose + # Check for duplicate code. You may want to disable this with // nolint:dupl if the source code is the same, but + # legitimately exists for different reasons. + - dupl + # Check for pointers in loops. This is a typical bug source. + - exportloopref + # Enforce a reasonable function length of 60 lines or 40 instructions. In very rare cases you may want to disable + # this with // nolint:funlen if there is absolutely no way to split the function in question. + - funlen + # Prevent dogsledding (mass-ignoring return values). This typically indicates missing error handling. + - dogsled + # Enforce consistent import aliases across all files. + - importas + # Make code properly formatted. + - gofmt + # Prevent faulty error checks. + - nilerr + # Prevent direct error checks that won't work with wrapped errors. + - errorlint + # Find slice usage that could potentially be preallocated. + - prealloc + # Check for improper duration handling. + - durationcheck + # Enforce tests being in the _test package. + - testpackage + + # endregion +linters-settings: + depguard: + list-type: whitelist + include-go-root: false + packages: + - go.flow.arcalot.io/pluginsdk/schema + govet: + enable-all: true + check-shadowing: false + disable: + # We don't care about variable shadowing. + - shadow + - fieldalignment + stylecheck: + checks: + - all +issues: + exclude-use-default: false \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b382bb2 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module go.flow.arcalot.io/pluginsdk + +go 1.18 diff --git a/schema/doc.go b/schema/doc.go new file mode 100644 index 0000000..68681cd --- /dev/null +++ b/schema/doc.go @@ -0,0 +1,2 @@ +// Package schema contains the Arcaflow schema system. +package schema diff --git a/schema/errors.go b/schema/errors.go new file mode 100644 index 0000000..12d6eae --- /dev/null +++ b/schema/errors.go @@ -0,0 +1,80 @@ +package schema + +import ( + "fmt" + "strings" +) + +// ConstraintError indicates that the passed data violated one or more constraints defined in the schema. +// The message holds the exact path of the problematic field, as well as a message explaining the error. +// If this error is not easily understood, please open an issue on the Arcaflow plugin SDK. +type ConstraintError struct { + Message string + Path []string + Cause error +} + +// Error returns the error message. +func (c ConstraintError) Error() string { + result := fmt.Sprintf("Validation failed for '%s': %s", strings.Join(c.Path, "' -> '"), c.Message) + if c.Cause != nil { + result += " (" + c.Cause.Error() + ")" + } + return result +} + +// Unwrap returns the underlying error if any. +func (c ConstraintError) Unwrap() error { + return c.Cause +} + +// NoSuchStepError indicates that the given step is not supported by the plugin. +type NoSuchStepError struct { + Step string +} + +// Error returns the error message. +func (n NoSuchStepError) Error() string { + return fmt.Sprintf("No such step: %s", n.Step) +} + +// BadArgumentError indicates that an invalid configuration was passed to a schema component. The message will +// explain what exactly the problem is, but may not be able to locate the exact error as the schema may be manually +// built. +type BadArgumentError struct { + Message string + Cause error +} + +// Error returns the error message. +func (b BadArgumentError) Error() string { + result := b.Message + if b.Cause != nil { + result += " (" + b.Cause.Error() + ")" + } + return result +} + +// Unwrap returns the underlying error if any. +func (b BadArgumentError) Unwrap() error { + return b.Cause +} + +// UnitParseError indicates that it failed to parse a unit string. +type UnitParseError struct { + Message string + Cause error +} + +func (u UnitParseError) Error() string { + result := u.Message + if u.Cause != nil { + result += " (" + u.Cause.Error() + ")" + } + return result +} + +// Unwrap returns the underlying error if any. +func (u UnitParseError) Unwrap() error { + return u.Cause +} diff --git a/schema/types.go b/schema/types.go new file mode 100644 index 0000000..f4ac8fe --- /dev/null +++ b/schema/types.go @@ -0,0 +1,44 @@ +package schema + +// AbstractType describes the common methods all types need to implement. +type AbstractType interface { + ValidateSerialized(data any, path []string) error +} + +// DisplayValue holds the data related to displaying fields. +type DisplayValue struct { + Name *string `json:"name" name:"Name" description:"Short text serving as a name or title for this item." examples:"[\"Fruit\"]" min:"1"` + Description *string `json:"description" name:"Description" description:"Description for this item if needed." examples:"[\"Please select the fruit you would like.\"]" min:"1"` + Icon *string `json:"icon" name:"Icon" description:"SVG icon for this item. Must have the declared size of 64x64, must not include additional namespaces, and must not reference external resources." examples:"[\"\"]" min:"1"` +} + +type enumValueType interface { + int | string +} + +type enumType[T enumValueType] struct { +} + +func (e enumType[T]) ValidateSerialized(data any, path []string) error { + return nil +} + +// EnumStringType is an enum type with string values. +type EnumStringType struct { + enumType[string] +} + +// EnumIntType is an enum type with integer values. +type EnumIntType struct { + enumType[int] +} + +// MapKeyType are types that can be used as map keys. +type MapKeyType interface { + int64 | string +} + +// NumberType is a type collection of number types. +type NumberType interface { + int64 | float64 +} diff --git a/schema/units.go b/schema/units.go new file mode 100644 index 0000000..8b4712c --- /dev/null +++ b/schema/units.go @@ -0,0 +1,457 @@ +package schema + +import ( + "fmt" + "math" + "regexp" + "sort" + "strconv" + "strings" +) + +// Unit is a description of a single scale of measurement, such as a "second". If there are multiple scales, such as +// "minute", "second", etc. then multiple of these unit classes can be composed into units. +type Unit struct { + NameShortSingular string `json:"name_short_singular" name:"Short name (singular)" description:"Short name that can be printed in a few characters, singular form." examples:"[\"B\", \"char\"]"` + NameShortPlural string `json:"name_short_plural" name:"Short name (plural)" description:"Short name that can be printed in a few characters, plural form." examples:"[\"B\", \"chars\"]"` + NameLongSingular string `json:"name_long_singular" name:"Long name (singular)" description:"Longer name for this unit in singular form." examples:"[\"byte\", \"character\"]"` + NameLongPlural string `json:"name_long_plural" name:"Long name (plural)" description:"Longer name for this unit in plural form." examples:"[\"bytes\", \"characters\"]"` +} + +// FormatShortInt formats an amount according to this unit. +func (u Unit) FormatShortInt(amount int64, displayZero bool) string { + return FormatNumberUnitShort(amount, u, displayZero) +} + +// FormatShortFloat formats an amount according to this unit. +func (u Unit) FormatShortFloat(amount float64, displayZero bool) string { + return FormatNumberUnitShort(amount, u, displayZero) +} + +// FormatLongInt formats an amount according to this unit. +func (u Unit) FormatLongInt(amount int64, displayZero bool) string { + return FormatNumberUnitLong(amount, u, displayZero) +} + +// FormatLongFloat formats an amount according to this unit. +func (u Unit) FormatLongFloat(amount float64, displayZero bool) string { + return FormatNumberUnitLong(amount, u, displayZero) +} + +// FormatNumberUnitShort formats a number with a single unit. +func FormatNumberUnitShort[T NumberType](amount T, unit Unit, displayZero bool) string { + var formatString string + switch any(amount).(type) { + case int64: + formatString = "%d" + case float64: + formatString = "%f" + } + switch { + case amount == 1 || amount == -1: + return fmt.Sprintf(formatString, amount) + unit.NameShortSingular + case amount != 0: + return fmt.Sprintf(formatString, amount) + unit.NameShortPlural + case displayZero: + return fmt.Sprintf(formatString, amount) + unit.NameShortPlural + default: + return "" + } +} + +// FormatNumberUnitLong formats a number with a single unit. +func FormatNumberUnitLong[T NumberType](amount T, unit Unit, displayZero bool) string { + var formatString string + switch any(amount).(type) { + case int64: + formatString = "%d" + case float64: + formatString = "%f" + } + switch { + case amount == 1 || amount == -1: + return fmt.Sprintf(formatString, amount) + unit.NameLongSingular + case amount != 0: + return fmt.Sprintf(formatString, amount) + unit.NameLongPlural + case displayZero: + return fmt.Sprintf(formatString, amount) + unit.NameLongPlural + default: + return "" + } +} + +// Units holds several scales of magnitude of the same unit, for example 5m30s. +type Units struct { + BaseUnit Unit + Multipliers map[int64]Unit + sortedMultipliersCache []int64 + reCache *regexp.Regexp + reSubExpNames map[string]int +} + +// FormatShortInt formats the passed int according to the unit multipliers. +func (u *Units) FormatShortInt(data int64) string { + return FormatNumberUnitsShort(data, *u) +} + +// FormatShortFloat formats the passed float according to the unit multipliers. +func (u *Units) FormatShortFloat(data float64) string { + return FormatNumberUnitsShort(data, *u) +} + +// FormatLongInt formats the passed int according to the unit multipliers. +func (u *Units) FormatLongInt(data int64) string { + return FormatNumberUnitsLong(data, *u) +} + +// FormatLongFloat formats the passed float according to the unit multipliers. +func (u *Units) FormatLongFloat(data float64) string { + return FormatNumberUnitsLong(data, *u) +} + +// FormatNumberUnitsShort is a generic way to format a number with a unit. +func FormatNumberUnitsShort[T NumberType](data T, units Units) string { + if data == 0 { + return FormatNumberUnitShort(data, units.BaseUnit, true) + } + remainder := data + output := "" + for _, multiplier := range units.getSortedMultipliersCache() { + base := int64(math.Floor(float64(remainder) / float64(multiplier))) + remainder -= T(base * multiplier) + output += FormatNumberUnitShort(base, units.Multipliers[multiplier], false) + } + output += FormatNumberUnitShort(remainder, units.BaseUnit, false) + return output +} + +// FormatNumberUnitsLong is a generic way to format a number with a unit. +func FormatNumberUnitsLong[T NumberType](data T, units Units) string { + if data == 0 { + return FormatNumberUnitLong(data, units.BaseUnit, true) + } + remainder := data + output := "" + for _, multiplier := range units.getSortedMultipliersCache() { + base := int64(math.Floor(float64(remainder) / float64(multiplier))) + remainder -= T(base * multiplier) + output += FormatNumberUnitLong(remainder, units.Multipliers[multiplier], false) + } + output += FormatNumberUnitLong(remainder, units.BaseUnit, false) + return output +} + +func (u *Units) getSortedMultipliersCache() []int64 { + if u.sortedMultipliersCache == nil { + var multipliers []int64 + for multiplier := range u.Multipliers { + multipliers = append(multipliers, multiplier) + } + sort.SliceStable(multipliers, func(i, j int) bool { + return multipliers[i] > multipliers[j] + }) + u.sortedMultipliersCache = multipliers + } + return u.sortedMultipliersCache +} + +func (u *Units) parse(data string) (any, error) { + data = strings.TrimSpace(data) + if data == "" { + return 0, &UnitParseError{ + Message: "Empty string cannot be parsed as " + u.BaseUnit.NameLongPlural, + } + } + if u.reCache == nil { + u.updateReCache() + } + match := u.reCache.FindStringSubmatch(data) + if match == nil { + return u.buildUnitParseError(data) + } + + var isFloat bool + var floatNumber float64 + var intNumber int64 + var err error + for _, multiplier := range u.getSortedMultipliersCache() { + matchGroupID := u.reSubExpNames[fmt.Sprintf("g%d", multiplier)] + result := match[matchGroupID] + + intNumber, floatNumber, isFloat, err = u.handleParseMultiplier( + result, + multiplier, + intNumber, + floatNumber, + isFloat, + ) + if err != nil { + return 0, err + } + } + baseMatchGroup := match[u.reSubExpNames["g1"]] + intNumber, floatNumber, isFloat, err = u.handleParseMultiplier( + baseMatchGroup, + 1, + intNumber, + floatNumber, + isFloat, + ) + if err != nil { + return 0, err + } + if isFloat { + return floatNumber, nil + } + return intNumber, nil +} + +func (u *Units) handleParseMultiplier( + result string, + multiplier int64, + intNumber int64, + floatNumber float64, + isFloat bool, +) (int64, float64, bool, error) { + if result == "" { + return intNumber, floatNumber, isFloat, nil + } + if strings.Contains(result, ".") { + i, err := strconv.ParseFloat(result, 64) + if err != nil { + return intNumber, floatNumber, isFloat, BadArgumentError{ + Message: fmt.Sprintf("Failed to parse number as float: %s", result), + } + } + floatNumber += i * float64(multiplier) + isFloat = true + } else { + i, err := strconv.ParseInt(result, 10, 64) + if err != nil { + return intNumber, floatNumber, isFloat, BadArgumentError{ + Message: fmt.Sprintf("Failed to parse number as int: %s", result), + } + } + floatNumber += float64(i * multiplier) + if !isFloat { + intNumber += i * multiplier + } + } + return intNumber, floatNumber, isFloat, nil +} + +func (u *Units) updateReCache() { + var parts []string + if u.Multipliers != nil { + for _, multiplier := range u.getSortedMultipliersCache() { + unit := u.Multipliers[multiplier] + parts = append(parts, fmt.Sprintf( + "(?:|(?P[0-9]+)\\s*(%s|%s|%s|%s))", + regexp.QuoteMeta(fmt.Sprintf("%d", multiplier)), + regexp.QuoteMeta(unit.NameShortSingular), + regexp.QuoteMeta(unit.NameShortPlural), + regexp.QuoteMeta(unit.NameLongSingular), + regexp.QuoteMeta(unit.NameLongPlural), + )) + } + } + parts = append(parts, fmt.Sprintf( + "(?:|(?P[0-9]+(|.[0-9]+))\\s*(|%s|%s|%s|%s))", + regexp.QuoteMeta(u.BaseUnit.NameShortSingular), + regexp.QuoteMeta(u.BaseUnit.NameShortPlural), + regexp.QuoteMeta(u.BaseUnit.NameLongSingular), + regexp.QuoteMeta(u.BaseUnit.NameLongPlural), + )) + regex := "^\\s*" + strings.Join(parts, "\\s*") + "\\s*$" + u.reCache = regexp.MustCompile(regex) + u.reSubExpNames = map[string]int{} + for i, subExpName := range u.reCache.SubexpNames() { + u.reSubExpNames[subExpName] = i + } +} + +func (u *Units) buildUnitParseError(data string) (any, error) { + validUnits := []string{ + u.BaseUnit.NameShortSingular, + u.BaseUnit.NameShortPlural, + u.BaseUnit.NameLongSingular, + u.BaseUnit.NameLongPlural, + } + for _, multiplier := range u.getSortedMultipliersCache() { + validUnits = append( + validUnits, + u.Multipliers[multiplier].NameShortSingular, + u.Multipliers[multiplier].NameShortPlural, + u.Multipliers[multiplier].NameLongSingular, + u.Multipliers[multiplier].NameLongPlural, + ) + } + return 0, UnitParseError{ + Message: fmt.Sprintf( + "Cannot parse '%s' as '%s': invalid format, valid unit types are: '%s", + data, + u.BaseUnit.NameLongPlural, + strings.Join(validUnits, "', '"), + ), + } +} + +// ParseInt parses a string into an integer. +func (u *Units) ParseInt(data string) (int64, error) { + result, err := u.parse(data) + if err != nil { + return 0, err + } + if i, ok := result.(int64); ok { + return i, nil + } + return 0, BadArgumentError{ + Message: fmt.Sprintf("Failed to parse %s as an integer, float found.", data), + } +} + +// ParseFloat parses a string into a floating point number. +func (u *Units) ParseFloat(data string) (float64, error) { + result, err := u.parse(data) + if err != nil { + return 0, err + } + if i, ok := result.(int64); ok { + return float64(i), nil + } + return result.(float64), nil +} + +// UnitBytes is scaling, byte-based unit. +var UnitBytes = Units{ + BaseUnit: Unit{ + "B", + "B", + "byte", + "bytes", + }, + Multipliers: map[int64]Unit{ + 1024: { + "kB", + "kB", + "kilobyte", + "kilobytes", + }, + 1048576: { + "MB", + "MB", + "megabyte", + "megabytes", + }, + 1073741824: { + "GB", + "GB", + "gigabyte", + "gigabytes", + }, + 1099511627776: { + "TB", + "TB", + "terabyte", + "terabytes", + }, + 1125899906842624: { + "PB", + "PB", + "petabyte", + "petabytes", + }, + }, +} + +// UnitDurationNanoseconds is a nanosecond-based unit for time durations. +var UnitDurationNanoseconds = Units{ + BaseUnit: Unit{ + "ns", + "ns", + "nanosecond", + "nanoseconds", + }, + Multipliers: map[int64]Unit{ + 1000: { + "ms", + "ms", + "microsecond", + "microseconds", + }, + 1000000: { + "s", + "s", + "second", + "seconds", + }, + 60000000: { + "m", + "m", + "minute", + "minutes", + }, + 3600000000: { + "H", + "H", + "hour", + "hours", + }, + 86400000000: { + "d", + "d", + "day", + "days", + }, + }, +} + +// UnitDurationSeconds is a second-based unit for time durations. +var UnitDurationSeconds = Units{ + BaseUnit: Unit{ + "s", + "s", + "second", + "seconds", + }, + Multipliers: map[int64]Unit{ + 60: { + "m", + "m", + "minute", + "minutes", + }, + 3600: { + "H", + "H", + "hour", + "hours", + }, + 86400: { + "d", + "d", + "day", + "days", + }, + }, +} + +// UnitCharacters is a single unit for characters. +var UnitCharacters = Units{ + BaseUnit: Unit{ + "char", + "chars", + "character", + "characters", + }, +} + +// UnitPercentage is a single unit for percentages. +var UnitPercentage = Units{ + BaseUnit: Unit{ + "%", + "%", + "percent", + "percent", + }, +} diff --git a/schema/units_test.go b/schema/units_test.go new file mode 100644 index 0000000..e174ac9 --- /dev/null +++ b/schema/units_test.go @@ -0,0 +1,42 @@ +package schema_test + +import ( + "testing" + + "go.flow.arcalot.io/pluginsdk/schema" +) + +func TestUnitsParseInt(t *testing.T) { + testMatrix := map[string]struct { + input string + units schema.Units + expected int64 + }{ + "5m5s": { + "5m5s", + schema.UnitDurationNanoseconds, + 305000000, + }, + "1%": { + "1%", + schema.UnitPercentage, + 1, + }, + } + + for testCase, testData := range testMatrix { + t.Run(testCase, func(t *testing.T) { + result, err := testData.units.ParseInt(testData.input) + if err != nil { + t.Fatal(err) + } + if result != testData.expected { + t.Fatalf("Result mismatch, expected: %d, got: %d", testData.expected, result) + } + formatted := testData.units.FormatShortInt(result) + if formatted != testData.input { + t.Fatalf("Formatted result doesn't match input, expected: %s, got: %s", testData.input, formatted) + } + }) + } +}