@quenty/metricutils 2.4.4 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +80 -2
  3. package/deploy.nevermore.json +10 -0
  4. package/package.json +22 -4
  5. package/src/Shared/Definitions/MetricUnitDefinitions.lua +1082 -0
  6. package/src/Shared/Definitions/MetricUnitDefinitions.spec.lua +55 -0
  7. package/src/Shared/Definitions/MetricUnitDimensionUtils.lua +164 -0
  8. package/src/Shared/Definitions/MetricUnitDimensionUtils.spec.lua +49 -0
  9. package/src/Shared/Definitions/MetricUnitKinds.lua +237 -0
  10. package/src/Shared/Definitions/MetricUnitKinds.spec.lua +51 -0
  11. package/src/Shared/Formatting/MetricUnitDisplayUnitUtils.lua +129 -0
  12. package/src/Shared/Formatting/MetricUnitFormatUtils.lua +924 -0
  13. package/src/Shared/Formatting/MetricUnitFormatUtils.spec.lua +280 -0
  14. package/src/Shared/Formatting/RxMetricUnitFormatUtils.lua +62 -0
  15. package/src/Shared/Localization/MetricUnitFallbackStrings.lua +289 -0
  16. package/src/Shared/Localization/MetricUnitFallbackStrings.spec.lua +82 -0
  17. package/src/Shared/Localization/MetricUnitTranslator/ar.json.txt +182 -0
  18. package/src/Shared/Localization/MetricUnitTranslator/de.json.txt +182 -0
  19. package/src/Shared/Localization/MetricUnitTranslator/en.json.txt +182 -0
  20. package/src/Shared/Localization/MetricUnitTranslator/es.json.txt +182 -0
  21. package/src/Shared/Localization/MetricUnitTranslator/fr.json.txt +182 -0
  22. package/src/Shared/Localization/MetricUnitTranslator/id.json.txt +182 -0
  23. package/src/Shared/Localization/MetricUnitTranslator/init.lua +92 -0
  24. package/src/Shared/Localization/MetricUnitTranslator/it.json.txt +182 -0
  25. package/src/Shared/Localization/MetricUnitTranslator/ja.json.txt +182 -0
  26. package/src/Shared/Localization/MetricUnitTranslator/ko.json.txt +182 -0
  27. package/src/Shared/Localization/MetricUnitTranslator/pl.json.txt +182 -0
  28. package/src/Shared/Localization/MetricUnitTranslator/pt.json.txt +182 -0
  29. package/src/Shared/Localization/MetricUnitTranslator/ru.json.txt +182 -0
  30. package/src/Shared/Localization/MetricUnitTranslator/th.json.txt +182 -0
  31. package/src/Shared/Localization/MetricUnitTranslator/tr.json.txt +182 -0
  32. package/src/Shared/Localization/MetricUnitTranslator/vi.json.txt +182 -0
  33. package/src/Shared/Localization/MetricUnitTranslator/zh-cn.json.txt +182 -0
  34. package/src/Shared/Localization/MetricUnitTranslator/zh-tw.json.txt +182 -0
  35. package/src/Shared/Localization/MetricUnitTranslator.spec.lua +309 -0
  36. package/src/Shared/Localization/PluralCategoryUtils.lua +103 -0
  37. package/src/Shared/Localization/PluralCategoryUtils.spec.lua +77 -0
  38. package/src/Shared/Logarithmic/DecibelUtils.lua +72 -0
  39. package/src/Shared/Logarithmic/DecibelUtils.spec.lua +35 -0
  40. package/src/Shared/MetricUnit.Angles.spec.lua +80 -0
  41. package/src/Shared/MetricUnit.Customary.spec.lua +104 -0
  42. package/src/Shared/MetricUnit.Derived.spec.lua +173 -0
  43. package/src/Shared/MetricUnit.Display.spec.lua +153 -0
  44. package/src/Shared/MetricUnit.Electrical.spec.lua +114 -0
  45. package/src/Shared/MetricUnit.Math.spec.lua +112 -0
  46. package/src/Shared/MetricUnit.Roblox.spec.lua +34 -0
  47. package/src/Shared/MetricUnit.SI.spec.lua +127 -0
  48. package/src/Shared/MetricUnit.Spring.spec.lua +94 -0
  49. package/src/Shared/MetricUnit.Temperature.spec.lua +212 -0
  50. package/src/Shared/MetricUnit.Time.spec.lua +202 -0
  51. package/src/Shared/MetricUnit.Units.spec.lua +210 -0
  52. package/src/Shared/MetricUnit.lua +1730 -0
  53. package/src/Shared/MetricUnit.spec.lua +362 -0
  54. package/src/Shared/MetricUtils.lua +46 -15
  55. package/src/Shared/MetricUtils.spec.lua +76 -0
  56. package/src/Shared/Parsing/MetricUnitParseUtils.lua +799 -0
  57. package/src/Shared/Parsing/MetricUnitParseUtils.spec.lua +189 -0
  58. package/src/Shared/Systems/MetricUnitSystem.lua +322 -0
  59. package/src/Shared/Systems/MetricUnitSystem.spec.lua +181 -0
  60. package/src/Shared/Systems/MetricUnitSystems.lua +225 -0
  61. package/src/jest.config.lua +3 -0
  62. package/src/node_modules.project.json +7 -0
  63. package/test/default.project.json +25 -0
  64. package/test/scripts/Server/ServerMain.server.lua +14 -0
@@ -0,0 +1,103 @@
1
+ --!strict
2
+ --[=[
3
+ CLDR cardinal plural rules: which form of a word (`one`, `few`, `many`, `other`, …) a number takes in a language.
4
+ Nothing here is specific to units; [MetricUnitFormatUtils] uses it to pick a translated unit name's form.
5
+
6
+ @class PluralCategoryUtils
7
+ ]=]
8
+
9
+ local require = require(script.Parent.loader).load(script)
10
+
11
+ local ResolveLocaleUtils = require("ResolveLocaleUtils")
12
+
13
+ local PluralCategoryUtils = {}
14
+
15
+ local DEFAULT_LOCALE = "en-us"
16
+
17
+ --[=[
18
+ A CLDR plural category. A translated name has a key for each category its language uses.
19
+
20
+ @type PluralCategory "zero" | "one" | "two" | "few" | "many" | "other"
21
+ @within PluralCategoryUtils
22
+ ]=]
23
+ export type PluralCategory = "zero" | "one" | "two" | "few" | "many" | "other"
24
+
25
+ --[=[
26
+ The CLDR plural category of a number as written in `locale`, from CLDR's cardinal plural rules for every language
27
+ [MetricUnitTranslator] ships. `amount` is the rounded value shown and `fraction` the decimals written after the
28
+ decimal mark (`"50"` for "1.50"), since English writes "1 meter" but "1.0 meters".
29
+
30
+ - `one` only: English, German (`i = 1 and v = 0`); Spanish and Turkish (`n = 1`); Vietnamese (`i = 0 or n = 1`).
31
+ - `one` and `many`: French and Portuguese (`one` for `i = 0..1`), Italian (`one` for `i = 1 and v = 0`); `many`
32
+ is a whole multiple of a million, as Spanish also has.
33
+ - `one`, `few`, `many`: Russian and Polish, for whole numbers; their fractions are `other`.
34
+ - `zero`, `one`, `two`, `few`, `many`: Arabic.
35
+ - `other` only: Chinese, Japanese, Korean, Thai and Indonesian.
36
+
37
+ ```lua
38
+ PluralCategoryUtils.getPluralCategory("en-us", 1) -- "one"
39
+ PluralCategoryUtils.getPluralCategory("en-us", 1, "0") -- "other", as in "1.0 meters"
40
+ PluralCategoryUtils.getPluralCategory("fr-fr", 1.5, "5") -- "one"
41
+ PluralCategoryUtils.getPluralCategory("ru-ru", 3) -- "few"
42
+ PluralCategoryUtils.getPluralCategory("ar", 11) -- "many"
43
+ ```
44
+
45
+ @param locale string?
46
+ @param amount number
47
+ @param fraction string? -- the decimals written, if any
48
+ @return PluralCategory
49
+ ]=]
50
+ function PluralCategoryUtils.getPluralCategory(locale: string?, amount: number, fraction: string?): PluralCategory
51
+ local language = ResolveLocaleUtils.getLanguageSubtag(locale or DEFAULT_LOCALE) or "en"
52
+
53
+ local absoluteValue = math.abs(amount)
54
+ local integerPart = math.floor(absoluteValue)
55
+ local decimalsWritten = if fraction then #fraction elseif absoluteValue ~= integerPart then 1 else 0
56
+ local isWholeMillion = decimalsWritten == 0 and integerPart ~= 0 and integerPart % 1_000_000 == 0
57
+
58
+ if language == "en" or language == "de" then
59
+ return if integerPart == 1 and decimalsWritten == 0 then "one" else "other"
60
+ elseif language == "es" then
61
+ return if absoluteValue == 1 then "one" elseif isWholeMillion then "many" else "other"
62
+ elseif language == "fr" or language == "pt" then
63
+ return if integerPart == 0 or integerPart == 1 then "one" elseif isWholeMillion then "many" else "other"
64
+ elseif language == "it" then
65
+ return if integerPart == 1 and decimalsWritten == 0 then "one" elseif isWholeMillion then "many" else "other"
66
+ elseif language == "tr" then
67
+ return if absoluteValue == 1 then "one" else "other"
68
+ elseif language == "vi" then
69
+ return if integerPart == 0 or absoluteValue == 1 then "one" else "other"
70
+ elseif language == "ru" or language == "pl" then
71
+ if decimalsWritten ~= 0 then
72
+ return "other"
73
+ end
74
+ local ones, tens = integerPart % 10, integerPart % 100
75
+ if language == "pl" and integerPart == 1 then
76
+ return "one"
77
+ elseif language == "ru" and ones == 1 and tens ~= 11 then
78
+ return "one"
79
+ elseif ones >= 2 and ones <= 4 and (tens < 12 or tens > 14) then
80
+ return "few"
81
+ end
82
+ return "many"
83
+ elseif language == "ar" then
84
+ if absoluteValue == 0 then
85
+ return "zero"
86
+ elseif absoluteValue == 1 then
87
+ return "one"
88
+ elseif absoluteValue == 2 then
89
+ return "two"
90
+ elseif absoluteValue == integerPart then
91
+ local lastTwoDigits = integerPart % 100
92
+ if lastTwoDigits >= 3 and lastTwoDigits <= 10 then
93
+ return "few"
94
+ elseif lastTwoDigits >= 11 then
95
+ return "many"
96
+ end
97
+ end
98
+ return "other"
99
+ end
100
+ return "other"
101
+ end
102
+
103
+ return table.freeze(PluralCategoryUtils)
@@ -0,0 +1,77 @@
1
+ --!strict
2
+ --[[
3
+ @class PluralCategoryUtils.spec.lua
4
+ ]]
5
+
6
+ local require = require(script.Parent.loader).load(script)
7
+
8
+ local Jest = require("Jest")
9
+ local PluralCategoryUtils = require("PluralCategoryUtils")
10
+
11
+ local describe = Jest.Globals.describe
12
+ local it = Jest.Globals.it
13
+
14
+ describe("PluralCategoryUtils.getPluralCategory", function()
15
+ -- CLDR's samples for each category, as fraction digits written ("" for a whole number)
16
+ local SAMPLES: { [string]: { { any } } } = {
17
+ en = { { 1, "", "one" }, { 0, "", "other" }, { 2, "", "other" }, { 1, "0", "other" }, { 1.5, "5", "other" } },
18
+ de = { { 1, "", "one" }, { 2, "", "other" }, { 1, "0", "other" } },
19
+ es = { { 1, "", "one" }, { 1, "0", "one" }, { 2, "", "other" }, { 1000000, "", "many" }, { 0.5, "5", "other" } },
20
+ fr = { { 0, "", "one" }, { 1, "", "one" }, { 1.5, "5", "one" }, { 2, "", "other" }, { 1000000, "", "many" } },
21
+ pt = { { 0, "", "one" }, { 1.5, "5", "one" }, { 2, "", "other" }, { 2000000, "", "many" } },
22
+ it = { { 1, "", "one" }, { 1, "0", "other" }, { 2, "", "other" }, { 1000000, "", "many" } },
23
+ ru = {
24
+ { 1, "", "one" },
25
+ { 21, "", "one" },
26
+ { 2, "", "few" },
27
+ { 24, "", "few" },
28
+ { 0, "", "many" },
29
+ { 5, "", "many" },
30
+ { 11, "", "many" },
31
+ { 12, "", "many" },
32
+ { 1.5, "5", "other" },
33
+ },
34
+ pl = {
35
+ { 1, "", "one" },
36
+ { 2, "", "few" },
37
+ { 22, "", "few" },
38
+ { 0, "", "many" },
39
+ { 5, "", "many" },
40
+ { 21, "", "many" },
41
+ { 12, "", "many" },
42
+ { 1.5, "5", "other" },
43
+ },
44
+ tr = { { 1, "", "one" }, { 2, "", "other" }, { 1, "0", "one" } },
45
+ vi = { { 0, "", "one" }, { 1, "", "one" }, { 0.5, "5", "one" }, { 2, "", "other" } },
46
+ ar = {
47
+ { 0, "", "zero" },
48
+ { 1, "", "one" },
49
+ { 2, "", "two" },
50
+ { 3, "", "few" },
51
+ { 103, "", "few" },
52
+ { 11, "", "many" },
53
+ { 99, "", "many" },
54
+ { 100, "", "other" },
55
+ { 102, "", "other" },
56
+ { 1.5, "5", "other" },
57
+ },
58
+ id = { { 1, "", "other" } },
59
+ th = { { 1, "", "other" } },
60
+ ja = { { 1, "", "other" } },
61
+ ko = { { 1, "", "other" } },
62
+ ["zh-cn"] = { { 1, "", "other" } },
63
+ ["zh-tw"] = { { 1, "", "other" } },
64
+ }
65
+
66
+ it("follows CLDR's rule for every shipped language", function()
67
+ for locale, samples in SAMPLES do
68
+ for _, sample in samples do
69
+ local fraction = if sample[2] == "" then nil else sample[2]
70
+ local category = PluralCategoryUtils.getPluralCategory(locale, sample[1], fraction)
71
+ if category ~= sample[3] then
72
+ error(string.format("%s %s: expected %s, got %s", locale, tostring(sample[1]), sample[3], category))
73
+ end
74
+ end
75
+ end
76
+ end)
77
+ end)
@@ -0,0 +1,72 @@
1
+ --!strict
2
+ --[=[
3
+ Converts between linear ratios and decibels. A decibel value is a logarithm, so it is a plain number here rather
4
+ than a [MetricUnit]: decibel gains add where linear ratios multiply, which does not fit MetricUnit's arithmetic.
5
+
6
+ Amplitude ratios (a signal's level, such as a voltage or a sample value) use 20 × log10; power ratios use
7
+ 10 × log10. Halving the amplitude is about -6 dB; halving the power is about -3 dB.
8
+
9
+ ```lua
10
+ DecibelUtils.amplitudeToDecibels(0.5) -- -6.0206
11
+ DecibelUtils.decibelsToAmplitude(-6) -- 0.5012
12
+ DecibelUtils.powerToDecibels(2) -- 3.0103
13
+ ```
14
+
15
+ Check which kind a property is before converting: a volume that scales a signal is an amplitude ratio.
16
+
17
+ @class DecibelUtils
18
+ ]=]
19
+
20
+ local DecibelUtils = {}
21
+
22
+ --[=[
23
+ The decibels of an amplitude ratio: `20 × log10(ratio)`. A ratio of 0 is negative infinity (silence).
24
+
25
+ @param ratio number -- 1 is unchanged
26
+ @return number
27
+ ]=]
28
+ function DecibelUtils.amplitudeToDecibels(ratio: number): number
29
+ assert(type(ratio) == "number" and ratio >= 0, "Bad ratio")
30
+ if ratio == 0 then
31
+ return -math.huge
32
+ end
33
+ return 20 * math.log10(ratio)
34
+ end
35
+
36
+ --[=[
37
+ The amplitude ratio of a decibel gain: `10 ^ (decibels / 20)`.
38
+
39
+ @param decibels number -- 0 is unchanged
40
+ @return number
41
+ ]=]
42
+ function DecibelUtils.decibelsToAmplitude(decibels: number): number
43
+ assert(type(decibels) == "number", "Bad decibels")
44
+ return 10 ^ (decibels / 20)
45
+ end
46
+
47
+ --[=[
48
+ The decibels of a power ratio: `10 × log10(ratio)`. A ratio of 0 is negative infinity.
49
+
50
+ @param ratio number -- 1 is unchanged
51
+ @return number
52
+ ]=]
53
+ function DecibelUtils.powerToDecibels(ratio: number): number
54
+ assert(type(ratio) == "number" and ratio >= 0, "Bad ratio")
55
+ if ratio == 0 then
56
+ return -math.huge
57
+ end
58
+ return 10 * math.log10(ratio)
59
+ end
60
+
61
+ --[=[
62
+ The power ratio of a decibel gain: `10 ^ (decibels / 10)`.
63
+
64
+ @param decibels number -- 0 is unchanged
65
+ @return number
66
+ ]=]
67
+ function DecibelUtils.decibelsToPower(decibels: number): number
68
+ assert(type(decibels) == "number", "Bad decibels")
69
+ return 10 ^ (decibels / 10)
70
+ end
71
+
72
+ return table.freeze(DecibelUtils)
@@ -0,0 +1,35 @@
1
+ --!strict
2
+ --[[
3
+ @class DecibelUtils.spec.lua
4
+ ]]
5
+
6
+ local require = require(script.Parent.loader).load(script)
7
+
8
+ local DecibelUtils = require("DecibelUtils")
9
+ local Jest = require("Jest")
10
+
11
+ local describe = Jest.Globals.describe
12
+ local expect = Jest.Globals.expect
13
+ local it = Jest.Globals.it
14
+
15
+ describe("DecibelUtils", function()
16
+ it("uses 20 log10 for amplitude ratios", function()
17
+ expect(DecibelUtils.amplitudeToDecibels(1)).toBe(0)
18
+ expect(DecibelUtils.amplitudeToDecibels(10)).toBeCloseTo(20, 9)
19
+ expect(DecibelUtils.amplitudeToDecibels(0.5)).toBeCloseTo(-6.0206, 4)
20
+ expect(DecibelUtils.decibelsToAmplitude(-20)).toBeCloseTo(0.1, 12)
21
+ expect(DecibelUtils.amplitudeToDecibels(0)).toBe(-math.huge)
22
+ end)
23
+
24
+ it("uses 10 log10 for power ratios", function()
25
+ expect(DecibelUtils.powerToDecibels(10)).toBeCloseTo(10, 9)
26
+ expect(DecibelUtils.powerToDecibels(2)).toBeCloseTo(3.0103, 4)
27
+ expect(DecibelUtils.decibelsToPower(-10)).toBeCloseTo(0.1, 12)
28
+ end)
29
+
30
+ it("round trips, and adds gains where ratios multiply", function()
31
+ expect(DecibelUtils.decibelsToAmplitude(DecibelUtils.amplitudeToDecibels(0.37))).toBeCloseTo(0.37, 12)
32
+ local gain = DecibelUtils.amplitudeToDecibels(0.5) + DecibelUtils.amplitudeToDecibels(0.5)
33
+ expect(DecibelUtils.decibelsToAmplitude(gain)).toBeCloseTo(0.25, 12)
34
+ end)
35
+ end)
@@ -0,0 +1,80 @@
1
+ --!strict
2
+ --[[
3
+ @class MetricUnit.Angles.spec.lua
4
+ ]]
5
+
6
+ local require = require(script.Parent.loader).load(script)
7
+
8
+ local Jest = require("Jest")
9
+ local MetricUnit = require("MetricUnit")
10
+
11
+ local describe = Jest.Globals.describe
12
+ local expect = Jest.Globals.expect
13
+ local it = Jest.Globals.it
14
+
15
+ -- Formatting puts a no-break space between a number and its unit; these compare it as a plain space
16
+ local function plain(text: string): string
17
+ return (string.gsub(text, "\u{00A0}", " "))
18
+ end
19
+
20
+ local function format(value: any, options: MetricUnit.FormatOptions?): string
21
+ return plain(MetricUnit.format(value, options))
22
+ end
23
+
24
+ describe("MetricUnit angles", function()
25
+ it("converts between radians, degrees and revolutions", function()
26
+ expect(MetricUnit.degrees(180):In("radian")).toBeCloseTo(math.pi, 12)
27
+ expect(MetricUnit.new(1, "revolution"):In("degree")).toBeCloseTo(360, 9)
28
+ expect(MetricUnit.fuzzyEq(MetricUnit.radians(math.pi), MetricUnit.degrees(180))).toBe(true)
29
+ -- The bridge to CFrame.Angles and math.sin is reading radians
30
+ expect(MetricUnit.degrees(90):In("radian")).toBeCloseTo(math.rad(90), 12)
31
+ expect(MetricUnit.radians(math.rad(30)):In("degree")).toBeCloseTo(30, 9)
32
+ expect(MetricUnit.degrees(1):GetKind()).toBe("angle")
33
+ end)
34
+
35
+ it("keeps angles apart from lengths", function()
36
+ expect(function()
37
+ local _ = MetricUnit.degrees(1) + MetricUnit.meters(1)
38
+ end).toThrow("cannot add angle and length")
39
+ expect(MetricUnit.sameDimension(MetricUnit.radians(1), MetricUnit.none(1))).toBe(false)
40
+ end)
41
+
42
+ it("reads angular velocity and acceleration", function()
43
+ expect(MetricUnit.new(60, "revolutionPerMinute"):In("radianPerSecond")).toBeCloseTo(2 * math.pi, 9)
44
+ expect(MetricUnit.new(360, "degreePerSecond"):In("revolutionPerMinute")).toBeCloseTo(60, 9)
45
+ expect((MetricUnit.degrees(90) / MetricUnit.seconds(1)):GetKind()).toBe("angularVelocity")
46
+ local spinUp = MetricUnit.new(4, "radianPerSecond") / MetricUnit.seconds(2)
47
+ expect(spinUp:GetKind()).toBe("angularAcceleration")
48
+ expect(spinUp:In("radianPerSecondSquared")).toBe(2)
49
+ end)
50
+
51
+ it("turns an angle into an arc length, and an angular speed into a speed", function()
52
+ expect(MetricUnit.arcLength(MetricUnit.degrees(180), MetricUnit.meters(2)):In("meter")).toBeCloseTo(
53
+ 2 * math.pi,
54
+ 9
55
+ )
56
+ local rim = MetricUnit.arcLength(MetricUnit.new(60, "revolutionPerMinute"), MetricUnit.meters(1))
57
+ expect(rim:GetKind()).toBe("speed")
58
+ expect(rim:In("meterPerSecond")).toBeCloseTo(2 * math.pi, 9)
59
+ expect(function()
60
+ MetricUnit.arcLength(MetricUnit.meters(1), MetricUnit.meters(1))
61
+ end).toThrow("takes an angle or an angle per time, not length")
62
+ end)
63
+
64
+ it("formats angles in degrees by default", function()
65
+ expect(format(MetricUnit.degrees(45))).toBe("45°")
66
+ expect(format(MetricUnit.radians(1))).toBe("57.296°")
67
+ expect(format(MetricUnit.radians(1), { unit = "radian" })).toBe("1 rad")
68
+ expect(format(MetricUnit.degrees(2), { unitDisplay = "long" })).toBe("2 degrees")
69
+ expect(format(MetricUnit.degrees(1), { unitDisplay = "long" })).toBe("1 degree")
70
+ expect(format(MetricUnit.new(60, "revolutionPerMinute"))).toBe("360 °/s")
71
+ expect(format(MetricUnit.new(60, "revolutionPerMinute"), { unit = "revolutionPerMinute" })).toBe("60 rpm")
72
+ expect(tostring(MetricUnit.degrees(45))).toBe("45°")
73
+ end)
74
+ end)
75
+
76
+ describe("MetricUnit.arcLength with a plain zero radius", function()
77
+ it("is zero", function()
78
+ expect(MetricUnit.arcLength(MetricUnit.degrees(90), 0 :: any):In("meter")).toBe(0)
79
+ end)
80
+ end)
@@ -0,0 +1,104 @@
1
+ --!strict
2
+ --[[
3
+ @class MetricUnit.Customary.spec.lua
4
+ ]]
5
+
6
+ local require = require(script.Parent.loader).load(script)
7
+
8
+ local Jest = require("Jest")
9
+ local MetricUnit = require("MetricUnit")
10
+ local MetricUnitSystems = require("MetricUnitSystems")
11
+
12
+ local describe = Jest.Globals.describe
13
+ local expect = Jest.Globals.expect
14
+ local it = Jest.Globals.it
15
+
16
+ local function format(value: any, options: any?): string
17
+ return (string.gsub(MetricUnit.format(value, options), "\u{00A0}", " "))
18
+ end
19
+
20
+ describe("MetricUnit US customary and British imperial", function()
21
+ it("keeps US and imperial volumes apart", function()
22
+ expect(MetricUnit.new(1, "usGallon"):In("liter")).toBeCloseTo(3.785411784, 9)
23
+ expect(MetricUnit.new(1, "imperialGallon"):In("liter")).toBeCloseTo(4.54609, 9)
24
+ expect(MetricUnit.new(1, "imperialPint"):In("usPint")).toBeCloseTo(1.20095, 5)
25
+ expect(MetricUnit.new(1, "usCup"):In("usTablespoon")).toBeCloseTo(16, 9)
26
+ expect(MetricUnit.new(1, "usTablespoon"):In("usTeaspoon")).toBeCloseTo(3, 9)
27
+ expect(MetricUnit.new(1, "imperialPint"):In("imperialFluidOunce")).toBeCloseTo(20, 9)
28
+ end)
29
+
30
+ it("shows each system's units for a value that asks for it", function()
31
+ local volume = MetricUnit.new(2, "liter")
32
+ expect(format(volume:ToUSCustomary())).toBe("2.113 US qt")
33
+ expect(format(volume:ToImperial())).toBe("3.52 imp pt")
34
+ expect(format(MetricUnit.new(250, "milliliter"):ToUSCustomary())).toBe("1.057 cups")
35
+ expect(format(MetricUnit.new(5, "milliliter"):ToUSCustomary())).toBe("1.014 tsp")
36
+
37
+ local body = MetricUnit.kilograms(80)
38
+ expect(format(body:ToUSCustomary())).toBe("176.37 lb")
39
+ expect(format(body:ToImperial())).toBe("12.598 st")
40
+ expect(format(MetricUnit.new(2, "tonne"):ToUSCustomary())).toBe("2.205 tn")
41
+
42
+ local distance = MetricUnit.kilometers(5)
43
+ expect(format(distance:ToUSCustomary())).toBe("3.107 mi")
44
+ expect(format(distance:ToImperial())).toBe("3.107 mi")
45
+ expect(distance:ToImperial():GetDisplaySystem()).toBe(MetricUnitSystems.IMPERIAL)
46
+ end)
47
+
48
+ it("covers areas, volumes and densities in feet and inches", function()
49
+ expect(MetricUnit.new(1, "squareMile"):In("acre")).toBeCloseTo(640, 9)
50
+ expect(MetricUnit.new(1, "cubicFoot"):In("cubicInch")).toBeCloseTo(1728, 9)
51
+ expect(MetricUnit.new(1, "cubicYard"):In("cubicFoot")).toBeCloseTo(27, 9)
52
+ expect(MetricUnit.new(1, "usGallon"):In("cubicInch")).toBeCloseTo(231, 9)
53
+ expect(MetricUnit.new(62.428, "poundPerCubicFoot"):In("kilogramPerCubicMeter")).toBeCloseTo(1000, 2)
54
+ expect(MetricUnit.new(1, "nauticalMile"):In("meter")).toBe(1852)
55
+ end)
56
+ end)
57
+
58
+ describe("MetricUnit for cars", function()
59
+ it("converts horsepower, torque and fuel economy", function()
60
+ expect(MetricUnit.new(1, "horsepower"):In("watt")).toBeCloseTo(745.6999, 4)
61
+ expect(MetricUnit.new(1, "metricHorsepower"):In("watt")).toBeCloseTo(735.49875, 9)
62
+ expect(format(MetricUnit.new(100, "kilowatt"):ToUSCustomary())).toBe("134.102 hp")
63
+
64
+ local torque = MetricUnit.new(300, "poundFoot")
65
+ expect(torque:In("newtonMeter")).toBeCloseTo(406.7454, 4)
66
+ expect(format(torque:As("newtonMeter"))).toBe("406.745 N·m")
67
+
68
+ local economy = MetricUnit.new(30, "milePerUsGallon")
69
+ expect(economy:GetKind()).toBe("fuelEconomy")
70
+ expect(economy:In("kilometerPerLiter")).toBeCloseTo(12.7543, 4)
71
+ expect(economy:In("milePerImperialGallon")).toBeCloseTo(36.0285, 4)
72
+ expect((1 / economy):In("literPer100Kilometers")).toBeCloseTo(7.8405, 4)
73
+ expect(format(economy:ToImperial())).toBe("36.028 mpg (UK)")
74
+ expect(format(economy)).toBe("30 mpg (US)")
75
+ expect(format(economy:ToMetric())).toBe("12.754 km/L")
76
+ end)
77
+
78
+ it("derives fuel economy from a distance and the fuel it used", function()
79
+ local economy = MetricUnit.new(300, "mile") / MetricUnit.new(10, "usGallon")
80
+ expect(economy:In("milePerUsGallon")).toBeCloseTo(30, 9)
81
+ expect(format(economy:ToUSCustomary())).toBe("30 mpg (US)")
82
+ end)
83
+
84
+ it("converts BTU, psi, inches of mercury and Rankine", function()
85
+ expect(MetricUnit.new(1, "britishThermalUnit"):In("joule")).toBeCloseTo(1055.05585262, 9)
86
+ expect(MetricUnit.new(29.92, "inchOfMercury"):In("kilopascal")).toBeCloseTo(101.32, 2)
87
+ expect(MetricUnit.new(32, "poundForcePerSquareInch"):In("kilopascal")).toBeCloseTo(220.632, 3)
88
+ expect(MetricUnit.point(491.67, "rankine"):In("celsius")).toBeCloseTo(0, 9)
89
+ expect(MetricUnit.new(10, "footPerSecondSquared"):In("meterPerSecondSquared")).toBeCloseTo(3.048, 9)
90
+ end)
91
+
92
+ it("parses car units as they are written", function()
93
+ local function parse(text: string): MetricUnit.MetricUnit
94
+ return assert(MetricUnit.parse(text)) :: any
95
+ end
96
+ expect(parse("300 hp"):In("horsepower")).toBe(300)
97
+ expect(parse("400 Nm"):In("newtonMeter")).toBe(400)
98
+ expect(parse("300 lb-ft"):In("poundFoot")).toBe(300)
99
+ expect(parse("30 mpg"):In("milePerUsGallon")).toBe(30)
100
+ expect(parse("12000 BTU"):In("britishThermalUnit")).toBe(12000)
101
+ expect(parse("2 cups"):In("usCup")).toBe(2)
102
+ expect(parse("150 PS"):In("metricHorsepower")).toBe(150)
103
+ end)
104
+ end)
@@ -0,0 +1,173 @@
1
+ --!strict
2
+ --[[
3
+ @class MetricUnit.Derived.spec.lua
4
+ ]]
5
+
6
+ local require = require(script.Parent.loader).load(script)
7
+
8
+ local Jest = require("Jest")
9
+ local MetricUnit = require("MetricUnit")
10
+
11
+ local describe = Jest.Globals.describe
12
+ local expect = Jest.Globals.expect
13
+ local it = Jest.Globals.it
14
+
15
+ describe("MetricUnit prefixed base units", function()
16
+ it("reads milliseconds and microseconds back", function()
17
+ expect(MetricUnit.milliseconds(250):In("millisecond")).toBeCloseTo(250, 9)
18
+ expect(MetricUnit.new(40, "microsecond"):In("microsecond")).toBeCloseTo(40, 9)
19
+ expect(MetricUnit.milliseconds(1_500):InSeconds()).toBeCloseTo(1.5, 9)
20
+ expect(MetricUnit.new(1_000, "microsecond"):In("millisecond")).toBeCloseTo(1, 9)
21
+ end)
22
+
23
+ it("keeps decimal and binary memory apart", function()
24
+ expect(MetricUnit.new(1, "gigabyte"):In("megabyte")).toBe(1_000)
25
+ expect(MetricUnit.new(1, "kibibyte"):In("byte")).toBe(1_024)
26
+ expect(MetricUnit.new(4, "mebibyte"):In("byte")).toBe(4_194_304)
27
+ expect(MetricUnit.new(1, "gibibyte"):In("mebibyte")).toBe(1_024)
28
+ expect(MetricUnit.new(1, "mebibyte"):In("kibibyte")).toBe(1_024)
29
+ expect(MetricUnit.new(1, "gigabyte"):In("gibibyte")).toBeCloseTo(0.9313, 4)
30
+ expect(MetricUnit.new(1, "kibibyte"):In("kilobyte")).toBeCloseTo(1.024, 9)
31
+ end)
32
+
33
+ it("reads milligrams back", function()
34
+ expect(MetricUnit.new(500, "milligram"):In("gram")).toBeCloseTo(0.5, 9)
35
+ expect(MetricUnit.grams(2):In("milligram")).toBeCloseTo(2_000, 9)
36
+ end)
37
+
38
+ it("reads a rate per minute and a frequency in hertz", function()
39
+ expect(MetricUnit.new(120, "perMinute"):In("perSecond")).toBeCloseTo(2, 9)
40
+ expect(MetricUnit.perSecond(1):In("perMinute")).toBeCloseTo(60, 9)
41
+ expect(MetricUnit.new(60, "hertz"):In("hertz")).toBe(60)
42
+ expect(MetricUnit.new(1, "hertz") == MetricUnit.perSecond(1)).toBe(true)
43
+ expect((1 / MetricUnit.milliseconds(20)):In("hertz")).toBeCloseTo(50, 9)
44
+ end)
45
+ end)
46
+
47
+ describe("MetricUnit area and volume", function()
48
+ it("reads areas back, from lengths multiplied", function()
49
+ expect(MetricUnit.new(50, "squareMeter"):In("squareMeter")).toBe(50)
50
+ expect(MetricUnit.new(1, "hectare"):In("squareMeter")).toBe(10_000)
51
+ expect(MetricUnit.squareKilometers(1):In("hectare")).toBe(100)
52
+ expect((MetricUnit.meters(100) * MetricUnit.meters(100)):In("hectare")).toBeCloseTo(1, 9)
53
+ expect(MetricUnit.isKind(MetricUnit.kilometers(2) * MetricUnit.kilometers(3), "area")).toBe(true)
54
+ expect((MetricUnit.kilometers(2) * MetricUnit.kilometers(3)):In("squareKilometer")).toBeCloseTo(6, 9)
55
+ end)
56
+
57
+ it("reads volumes back, from areas and lengths multiplied", function()
58
+ expect(MetricUnit.new(1, "liter"):In("milliliter")).toBeCloseTo(1_000, 9)
59
+ expect(MetricUnit.new(1, "cubicMeter"):In("liter")).toBeCloseTo(1_000, 9)
60
+ expect(MetricUnit.new(250, "milliliter"):In("liter")).toBeCloseTo(0.25, 9)
61
+ expect(MetricUnit.isKind(MetricUnit.pow(MetricUnit.new(10, "centimeter"), 3), "volume")).toBe(true)
62
+ expect(MetricUnit.pow(MetricUnit.new(10, "centimeter"), 3):In("liter")).toBeCloseTo(1, 9)
63
+ expect(MetricUnit.isKind(MetricUnit.new(2, "squareMeter") * MetricUnit.meters(3), "volume")).toBe(true)
64
+ end)
65
+ end)
66
+
67
+ describe("MetricUnit mechanics", function()
68
+ it("reads speeds in m/s and km/h", function()
69
+ expect(MetricUnit.new(10, "meterPerSecond"):In("kilometerPerHour")).toBeCloseTo(36, 9)
70
+ expect(MetricUnit.new(36, "kilometerPerHour"):In("meterPerSecond")).toBeCloseTo(10, 9)
71
+ expect((MetricUnit.kilometers(36) / MetricUnit.hours(1)):In("meterPerSecond")).toBeCloseTo(10, 9)
72
+ expect(MetricUnit.isKind(MetricUnit.new(1, "kilometerPerHour"), "speed")).toBe(true)
73
+ end)
74
+
75
+ it("makes an acceleration from a speed over a time", function()
76
+ local acceleration = MetricUnit.new(20, "meterPerSecond") / MetricUnit.seconds(2)
77
+ expect(MetricUnit.isKind(acceleration, "acceleration")).toBe(true)
78
+ expect(acceleration:In("meterPerSecondSquared")).toBeCloseTo(10, 9)
79
+ expect(MetricUnit.new(9.81, "meterPerSecondSquared"):In("meterPerSecondSquared")).toBe(9.81)
80
+ end)
81
+
82
+ it("makes a newton from a mass times an acceleration", function()
83
+ local force = MetricUnit.kilograms(2) * MetricUnit.new(3, "meterPerSecondSquared")
84
+ expect(MetricUnit.isKind(force, "force")).toBe(true)
85
+ expect(force:In("newton")).toBeCloseTo(6, 9)
86
+ expect(force == MetricUnit.new(6, "newton")).toBe(true)
87
+ end)
88
+
89
+ it("makes energy from a force over a length, and power from energy over time", function()
90
+ local work = MetricUnit.new(10, "newton") * MetricUnit.meters(5)
91
+ expect(MetricUnit.isKind(work, "energy")).toBe(true)
92
+ expect(work:In("joule")).toBeCloseTo(50, 9)
93
+
94
+ local power = work / MetricUnit.seconds(2)
95
+ expect(MetricUnit.isKind(power, "power")).toBe(true)
96
+ expect(power:In("watt")).toBeCloseTo(25, 9)
97
+
98
+ expect((MetricUnit.new(2, "kilowatt") * MetricUnit.hours(3)):In("kilowattHour")).toBeCloseTo(6, 9)
99
+ expect((MetricUnit.new(3, "kilowattHour") / MetricUnit.hours(1)):In("kilowatt")).toBeCloseTo(3, 9)
100
+ end)
101
+
102
+ it("converts between energy units", function()
103
+ expect(MetricUnit.new(1, "kilojoule"):In("joule")).toBe(1_000)
104
+ expect(MetricUnit.new(1, "kilowattHour"):In("kilojoule")).toBe(3_600)
105
+ expect(MetricUnit.new(1, "kilocalorie"):In("joule")).toBe(4_184)
106
+ expect(MetricUnit.new(4_184, "joule"):In("kilocalorie")).toBe(1)
107
+ end)
108
+
109
+ it("makes pressure from a force over an area", function()
110
+ local pressure = MetricUnit.new(1_000, "newton") / MetricUnit.new(2, "squareMeter")
111
+ expect(MetricUnit.isKind(pressure, "pressure")).toBe(true)
112
+ expect(pressure:In("pascal")).toBeCloseTo(500, 9)
113
+ expect(MetricUnit.new(1, "bar"):In("kilopascal")).toBe(100)
114
+ expect(MetricUnit.new(250, "kilopascal"):In("bar")).toBeCloseTo(2.5, 9)
115
+ end)
116
+
117
+ it("makes density from a mass over a volume", function()
118
+ local density = MetricUnit.kilograms(1) / MetricUnit.new(1, "liter")
119
+ expect(MetricUnit.isKind(density, "density")).toBe(true)
120
+ expect(density:In("kilogramPerCubicMeter")).toBeCloseTo(1_000, 9)
121
+ expect(density:In("gramPerCubicCentimeter")).toBeCloseTo(1, 9)
122
+ expect(MetricUnit.new(7.8, "gramPerCubicCentimeter"):In("kilogramPerCubicMeter")).toBeCloseTo(7_800, 9)
123
+ expect((MetricUnit.new(1_000, "kilogramPerCubicMeter") * MetricUnit.new(2, "liter")):In("kilogram")).toBeCloseTo(
124
+ 2,
125
+ 9
126
+ )
127
+ end)
128
+ end)
129
+
130
+ describe("MetricUnit data rates", function()
131
+ it("makes a data rate from memory over time", function()
132
+ local rate = MetricUnit.kilobytes(50) / MetricUnit.seconds(1)
133
+ expect(MetricUnit.isKind(rate, "dataRate")).toBe(true)
134
+ expect(rate:In("kilobytePerSecond")).toBeCloseTo(50, 9)
135
+ expect(MetricUnit.new(2_000, "bytePerSecond"):In("kilobytePerSecond")).toBe(2)
136
+ expect((MetricUnit.new(8, "kilobytePerSecond") * MetricUnit.minutes(1)):In("kilobyte")).toBeCloseTo(480, 9)
137
+ end)
138
+ end)
139
+
140
+ describe("MetricUnit derived boundaries", function()
141
+ it("tells derived dimensions apart", function()
142
+ expect(MetricUnit.isKind(MetricUnit.new(1, "newton"), "energy")).toBe(false)
143
+ expect(MetricUnit.isKind(MetricUnit.new(1, "joule"), "force")).toBe(false)
144
+ expect(MetricUnit.isKind(MetricUnit.new(1, "joule"), "power")).toBe(false)
145
+ expect(MetricUnit.isKind(MetricUnit.new(1, "liter"), "area")).toBe(false)
146
+ expect(MetricUnit.isKind(MetricUnit.perSecond(1), "dataRate")).toBe(false)
147
+ expect(MetricUnit.isKind(MetricUnit.new(1, "meterPerSecondSquared"), "speed")).toBe(false)
148
+ end)
149
+
150
+ it("errors when a derived reader meets another dimension, naming both", function()
151
+ expect(function()
152
+ MetricUnit.new(1, "joule"):In("watt")
153
+ end).toThrow('In("watt") reads length^2·mass/time^3, not length^2·mass/time^2')
154
+ expect(function()
155
+ MetricUnit.new(1, "newton"):In("pascal")
156
+ end).toThrow('In("pascal") reads mass/length/time^2, not length·mass/time^2')
157
+ end)
158
+
159
+ it("expects each derived dimension by name", function()
160
+ expect(MetricUnit.expect(MetricUnit.new(1, "hectare"), "area", "plot")).toBe(10_000)
161
+ expect(MetricUnit.expect(MetricUnit.new(1, "liter"), "volume", "tank")).toBeCloseTo(1e-3, 12)
162
+ expect(MetricUnit.expect(MetricUnit.new(9.81, "meterPerSecondSquared"), "acceleration", "gravity")).toBe(9.81)
163
+ expect(MetricUnit.expect(MetricUnit.new(5, "newton"), "force", "thrust")).toBe(5)
164
+ expect(MetricUnit.expect(MetricUnit.new(2, "kilojoule"), "energy", "charge")).toBe(2_000)
165
+ expect(MetricUnit.expect(MetricUnit.new(1, "kilowatt"), "power", "output")).toBe(1_000)
166
+ expect(MetricUnit.expect(MetricUnit.new(2, "bar"), "pressure", "tyre")).toBe(200_000)
167
+ expect(MetricUnit.expect(MetricUnit.new(997, "kilogramPerCubicMeter"), "density", "water")).toBe(997)
168
+ expect(MetricUnit.expect(MetricUnit.new(1, "kilobytePerSecond"), "dataRate", "budget")).toBe(1_000)
169
+ expect(function()
170
+ MetricUnit.expect(MetricUnit.new(1, "joule"), "power", "output")
171
+ end).toThrow("MetricUnit.expect: output expects length^2·mass/time^3, got length^2·mass/time^2")
172
+ end)
173
+ end)