@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,189 @@
1
+ --!strict
2
+ --[[
3
+ @class MetricUnitParseUtils.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
+ type MetricUnit = MetricUnit.MetricUnit
16
+
17
+ local function parse(text: string, kind: any?, options: any?): MetricUnit
18
+ local value, err = MetricUnit.parse(text, kind, options)
19
+ if not value then
20
+ error(string.format("could not parse %q: %s", text, tostring(err)))
21
+ end
22
+ return value
23
+ end
24
+
25
+ local function parseError(text: string, kind: any?, options: any?): string?
26
+ local value, err = MetricUnit.parse(text, kind, options)
27
+ expect(value).toBeNil()
28
+ return err
29
+ end
30
+
31
+ describe("MetricUnit.parse", function()
32
+ it("reads a number and a unit, with or without a space", function()
33
+ expect(parse("5 km"):In("meter")).toBe(5000)
34
+ expect(parse("5km"):In("meter")).toBe(5000)
35
+ expect(parse("2.5 kilometers"):In("meter")).toBe(2500)
36
+ expect(parse("2.5 Kilometres"):In("meter")).toBe(2500)
37
+ expect(parse("1.5 MiB"):In("byte")).toBe(1572864)
38
+ expect(parse("1 KB"):In("byte")).toBe(1000)
39
+ expect(parse("60 mph"):In("milePerHour")).toBeCloseTo(60, 9)
40
+ expect(parse("100 kbps"):In("kilobitPerSecond")).toBeCloseTo(100, 9)
41
+ expect(parse("45%"):In("percent")).toBeCloseTo(45, 9)
42
+ expect(parse("45°"):In("degree")).toBeCloseTo(45, 9)
43
+ expect(parse("100 studs"):In("meter")).toBeCloseTo(28, 9)
44
+ end)
45
+
46
+ it("reads composed units", function()
47
+ expect(parse("5 m/s"):In("meterPerSecond")).toBe(5)
48
+ expect(parse("9.81 m/s²"):In("meterPerSecondSquared")).toBeCloseTo(9.81, 9)
49
+ expect(parse("9.81 m/s^2"):In("meterPerSecondSquared")).toBeCloseTo(9.81, 9)
50
+ expect(parse("1.6 MB/s"):In("kilobytePerSecond")).toBeCloseTo(1600, 6)
51
+ expect(parse("5/d"):In("perDay")).toBeCloseTo(5, 9)
52
+ local momentum = parse("6 m·kg/s")
53
+ expect(momentum:GetDimensions()).toEqual({ length = 1, mass = 1, time = -1 })
54
+ expect(momentum:GetBaseAmount()).toBe(6)
55
+ expect(parse("2 m^4"):GetDimensions()).toEqual({ length = 4 })
56
+ expect(parse("5 ft/s²"):In("meterPerSecondSquared")).toBeCloseTo(5 * 0.3048, 9)
57
+ expect(parse("3 ft·lb"):GetDimensions()).toEqual({ length = 1, mass = 1 })
58
+ expect(parse("2 kg*m/s"):GetDimensions()).toEqual({ length = 1, mass = 1, time = -1 })
59
+ end)
60
+
61
+ it("reads a temperature scale as absolute unless a difference is expected", function()
62
+ local warm = parse("20 °C")
63
+ expect(warm:IsAbsolute()).toBe(true)
64
+ expect(warm:In("celsius")).toBeCloseTo(20, 9)
65
+ expect(parse("20C"):In("celsius")).toBeCloseTo(20, 9)
66
+ expect(parse("-3 °F"):In("fahrenheit")).toBeCloseTo(-3, 9)
67
+ local rise = parse("20 C", "temperatureDifference")
68
+ expect(rise:IsAbsolute()).toBe(false)
69
+ expect(rise:In("kelvin")).toBeCloseTo(20, 9)
70
+ end)
71
+
72
+ it("adds up several parts of one dimension", function()
73
+ expect(parse("1h30m"):In("minute")).toBe(90)
74
+ expect(parse("1 hour, 30 minutes"):In("minute")).toBe(90)
75
+ expect(parse("1 hour and 30 minutes"):In("minute")).toBe(90)
76
+ expect(parse("5 ft 3 in"):In("inch")).toBeCloseTo(63, 9)
77
+ expect(function()
78
+ parse("20 C 5 F")
79
+ end).toThrow("Write a temperature as one number and unit")
80
+ end)
81
+
82
+ it("reads clock text and ISO durations", function()
83
+ expect(parse("1:30"):InSeconds()).toBe(90)
84
+ expect(parse("1:30:00"):InSeconds()).toBe(5400)
85
+ expect(parse("-0:45"):InSeconds()).toBe(-45)
86
+ expect(parse("PT1H30M"):InSeconds()).toBe(5400)
87
+ expect(string.find(parseError("P1Y") or "", "calendar spans", 1, true) ~= nil).toBe(true)
88
+ end)
89
+
90
+ it("resolves ambiguous units by kind, then by the other parts, then by SI symbol", function()
91
+ expect(parse("5 m"):In("meter")).toBe(5)
92
+ expect(parse("5 m", "time"):In("minute")).toBe(5)
93
+ expect(parse("1m30s"):In("second")).toBe(90)
94
+ expect(parse("2 M"):In("month")).toBe(2)
95
+ expect(parseError("5 Mb")).toBe('Unknown unit "Mb"')
96
+ expect(parse("5 MB"):In("megabyte")).toBe(5)
97
+ end)
98
+
99
+ it("reads bare numbers by kind and default unit", function()
100
+ expect(parse("5"):GetKind()).toBe("none")
101
+ expect(parse("5", "none"):GetBaseAmount()).toBe(5)
102
+ expect(parseError("5", "length")).toBe('Add a unit, such as "5 m"')
103
+ expect(parseError("45", "angle")).toBe('Add a unit, such as "45°"')
104
+ expect(parse("5", "length", { defaultUnit = "meter" }):In("meter")).toBe(5)
105
+ expect(parse("5", "instant", { defaultUnit = "second" }):IsAbsolute()).toBe(true)
106
+ end)
107
+
108
+ it("returns a message to show instead of erroring", function()
109
+ expect(parseError("")).toBe("Enter a value")
110
+ expect(parseError("abc")).toBe('Could not read "abc"')
111
+ expect(parseError("5 klicks")).toBe('Unknown unit "klicks"')
112
+ expect(parseError("5 kg", "length")).toBe('Expected length, but "5 kg" is mass')
113
+ expect(parseError("5 m", "temperature")).toBe('Expected temperature, but "5 m" is length')
114
+ end)
115
+
116
+ it("reads numbers with the locale's decimal mark", function()
117
+ expect(parse("1,5 km", nil, { locale = "de-de" }):In("meter")).toBe(1500)
118
+ expect(parse("1 234,5 m", nil, { locale = "de-de" }):In("meter")).toBeCloseTo(1234.5, 9)
119
+ expect(parse("1,234.5 m"):In("meter")).toBeCloseTo(1234.5, 9)
120
+ end)
121
+
122
+ it("reads back what short formatting writes", function()
123
+ local values: { MetricUnit } = {
124
+ MetricUnit.meters(2500),
125
+ MetricUnit.kilometers(0.75),
126
+ MetricUnit.millimeters(3),
127
+ MetricUnit.meters(1234.5),
128
+ MetricUnit.new(50, "squareMeter"),
129
+ MetricUnit.new(0.25, "liter"),
130
+ MetricUnit.new(3, "cubicMeter"),
131
+ MetricUnit.bytes(512),
132
+ MetricUnit.megabytes(1.6),
133
+ MetricUnit.new(8, "bit"),
134
+ MetricUnit.kilograms(80),
135
+ MetricUnit.tonnes(2),
136
+ MetricUnit.grams(20),
137
+ MetricUnit.new(1500, "newton"),
138
+ MetricUnit.new(4184, "joule"),
139
+ MetricUnit.new(2500, "watt"),
140
+ MetricUnit.new(101325, "pascal"),
141
+ MetricUnit.new(997, "kilogramPerCubicMeter"),
142
+ MetricUnit.new(5, "meterPerSecond"),
143
+ MetricUnit.new(36, "kilometerPerHour"),
144
+ MetricUnit.new(1600, "kilobytePerSecond"),
145
+ MetricUnit.perDay(5),
146
+ MetricUnit.perSecond(2),
147
+ MetricUnit.new(60, "hertz"),
148
+ MetricUnit.new(9.81, "meterPerSecondSquared"),
149
+ MetricUnit.celsius(21.5),
150
+ MetricUnit.celsius(-3),
151
+ MetricUnit.degrees(45),
152
+ MetricUnit.new(90, "degreePerSecond"),
153
+ MetricUnit.new(2, "radianPerSecondSquared"),
154
+ MetricUnit.none(0.45),
155
+ MetricUnit.minutes(90),
156
+ MetricUnit.years(1),
157
+ MetricUnit.days(1.5),
158
+ MetricUnit.milliseconds(250),
159
+ MetricUnit.new(40, "microsecond"),
160
+ MetricUnit.seconds(0),
161
+ MetricUnit.kilograms(2) * MetricUnit.new(3, "meterPerSecond"),
162
+ MetricUnit.pow(MetricUnit.meters(2), 4),
163
+ }
164
+ for _, value in values do
165
+ local text = MetricUnit.format(value)
166
+ local parsed = parse(text)
167
+ expect(MetricUnit.format(parsed)).toBe(text)
168
+ expect(parsed:GetKind()).toBe(value:GetKind())
169
+ end
170
+
171
+ -- A temperature difference reads back with its kind, since "5 K" alone is an absolute temperature
172
+ local rise = MetricUnit.difference(5, "celsius")
173
+ expect(MetricUnit.format(parse(MetricUnit.format(rise), "temperatureDifference"))).toBe("5\u{00A0}K")
174
+ end)
175
+ end)
176
+
177
+ describe("MetricUnit.parse negative text in several parts", function()
178
+ it("applies the leading minus sign to every part", function()
179
+ expect(parse("-1 hour, 30 minutes"):In("minute")).toBeCloseTo(-90, 9)
180
+ expect(parse("-5 ft 3 in"):In("inch")).toBeCloseTo(-63, 9)
181
+ expect(parse("-1h30m"):In("second")).toBeCloseTo(-5400, 9)
182
+ expect(parse("1 hour, 30 minutes"):In("minute")).toBeCloseTo(90, 9)
183
+ end)
184
+
185
+ it("reads back what the formatter writes for a negative duration", function()
186
+ local written = MetricUnit.format(-MetricUnit.minutes(90))
187
+ expect(parse(written):In("minute")).toBeCloseTo(-90, 9)
188
+ end)
189
+ end)
@@ -0,0 +1,322 @@
1
+ --!strict
2
+ --[=[
3
+ A system of units: the units it declares, the units a value is shown in when it prefers this system, and the
4
+ system it falls back to for everything else. Metric is the base: every unit is sized in SI units, and a value
5
+ made from a metric unit prefers nothing. US customary, British imperial and Roblox studs are systems
6
+ ([MetricUnitSystems]), and a game can declare its own, such as studs at another scale.
7
+
8
+ A value made from a system's unit shows itself in that system, so studs read back as studs. To show any value
9
+ in a system, convert it with [MetricUnit.ToSystem] or ask the system to [MetricUnitSystem.Format] it.
10
+
11
+ ```lua
12
+ local MINI = MetricUnitSystems.roblox({ name = "robloxMini", metersPerStud = 0.028 })
13
+
14
+ local height = MINI:New(5, "stud") -- 14 cm
15
+ MetricUnit.format(height) -- "5 studs"
16
+ MINI:In(height, "stud") -- 5
17
+ height:In("meter") -- 0.14
18
+
19
+ MetricUnitSystems.IMPERIAL:Format(MetricUnit.new(2, "liter")) -- "3.52 imp pt"
20
+ ```
21
+
22
+ Every amount is held in SI units, so values made through different systems add, compare and convert freely.
23
+
24
+ @class MetricUnitSystem
25
+ ]=]
26
+
27
+ local require = require(script.Parent.loader).load(script)
28
+
29
+ local MetricUnitDefinitions = require("MetricUnitDefinitions")
30
+ local MetricUnitDimensionUtils = require("MetricUnitDimensionUtils")
31
+
32
+ type UnitDefinition = MetricUnitDefinitions.UnitDefinition
33
+
34
+ local MetricUnitSystem = {}
35
+ MetricUnitSystem.ClassName = "MetricUnitSystem"
36
+ MetricUnitSystem.__index = MetricUnitSystem
37
+
38
+ -- MetricUnit and its formatter require the well-known systems, so this requires them back only when called
39
+ local function requireWhenCalled(moduleName: string): any
40
+ return (require :: any)(moduleName)
41
+ end
42
+
43
+ local SYSTEM_BY_OWN_UNIT: { [UnitDefinition]: MetricUnitSystem } = setmetatable({} :: any, { __mode = "kv" })
44
+
45
+ --[=[
46
+ What a system is made from.
47
+
48
+ - `units` are the units it declares, from [MetricUnitDefinitions.createUnits]. They take the place of a
49
+ well-known unit of the same id, so a system can rescale `stud` and keep its names and translations.
50
+ - `displayUnits` lists, for each dimension, the unit ids to choose from by size, largest first.
51
+ - `pointDisplayUnits` does the same for absolute values, such as `{ "celsius" }` for temperatures.
52
+ - `fallback` is the system asked for any dimension this one does not list.
53
+
54
+ @interface MetricUnitSystemConfig
55
+ .name string
56
+ .units { UnitDefinition }?
57
+ .displayUnits { { string } }?
58
+ .pointDisplayUnits { { string } }?
59
+ .fallback MetricUnitSystem?
60
+ @within MetricUnitSystem
61
+ ]=]
62
+ export type MetricUnitSystemConfig = {
63
+ name: string,
64
+ units: { UnitDefinition }?,
65
+ displayUnits: { { string } }?,
66
+ pointDisplayUnits: { { string } }?,
67
+ fallback: MetricUnitSystem?,
68
+ }
69
+
70
+ export type MetricUnitSystem = typeof(setmetatable(
71
+ {} :: {
72
+ _name: string,
73
+ _units: { UnitDefinition },
74
+ _unitsById: { [string]: UnitDefinition },
75
+ _displayUnitIds: { { string } },
76
+ _pointDisplayUnitIds: { { string } },
77
+ _resolved: { displayUnits: { { UnitDefinition } }?, pointDisplayUnits: { { UnitDefinition } }? },
78
+ _fallback: MetricUnitSystem?,
79
+ },
80
+ {} :: typeof({ __index = MetricUnitSystem })
81
+ ))
82
+
83
+ --[=[
84
+ A system from `config`. See [MetricUnitSystemConfig].
85
+
86
+ @param config MetricUnitSystemConfig
87
+ @return MetricUnitSystem
88
+ ]=]
89
+ function MetricUnitSystem.new(config: MetricUnitSystemConfig): MetricUnitSystem
90
+ assert(type(config) == "table", "Bad config")
91
+ assert(type(config.name) == "string", "Bad config.name")
92
+ assert(config.fallback == nil or MetricUnitSystem.isSystem(config.fallback), "Bad config.fallback")
93
+
94
+ local units: { UnitDefinition } = config.units or {}
95
+ local unitsById: { [string]: UnitDefinition } = {}
96
+ for _, unit in units do
97
+ unitsById[unit.id] = unit
98
+ end
99
+
100
+ local self: MetricUnitSystem = setmetatable({} :: any, MetricUnitSystem)
101
+ for _, unit in units do
102
+ SYSTEM_BY_OWN_UNIT[unit] = self
103
+ end
104
+ self._name = config.name
105
+ self._units = table.freeze(table.clone(units))
106
+ self._unitsById = table.freeze(unitsById)
107
+ self._fallback = config.fallback
108
+ self._displayUnitIds = table.freeze(table.clone(config.displayUnits or {}))
109
+ self._pointDisplayUnitIds = table.freeze(table.clone(config.pointDisplayUnits or {}))
110
+ self._resolved = {}
111
+ return table.freeze(self :: any)
112
+ end
113
+
114
+ --[=[
115
+ The system that declared `unit` in its config, or nil for a well-known unit.
116
+
117
+ @param unit UnitDefinition
118
+ @return MetricUnitSystem?
119
+ ]=]
120
+ function MetricUnitSystem.getSystemDeclaring(unit: UnitDefinition): MetricUnitSystem?
121
+ return SYSTEM_BY_OWN_UNIT[unit]
122
+ end
123
+
124
+ --[=[
125
+ Whether `value` is a [MetricUnitSystem].
126
+
127
+ @param value any
128
+ @return boolean
129
+ ]=]
130
+ function MetricUnitSystem.isSystem(value: any): boolean
131
+ return type(value) == "table" and getmetatable(value) == MetricUnitSystem
132
+ end
133
+
134
+ --[=[
135
+ The system's name, such as `"imperial"`.
136
+
137
+ @return string
138
+ ]=]
139
+ function MetricUnitSystem.GetName(self: MetricUnitSystem): string
140
+ return self._name
141
+ end
142
+
143
+ --[=[
144
+ The system asked for the dimensions this one does not list, or nil.
145
+
146
+ @return MetricUnitSystem?
147
+ ]=]
148
+ function MetricUnitSystem.GetFallback(self: MetricUnitSystem): MetricUnitSystem?
149
+ return self._fallback
150
+ end
151
+
152
+ --[=[
153
+ The units this system declares itself, not counting its fallbacks.
154
+
155
+ @return { UnitDefinition }
156
+ ]=]
157
+ function MetricUnitSystem.GetUnits(self: MetricUnitSystem): { UnitDefinition }
158
+ return self._units
159
+ end
160
+
161
+ --[=[
162
+ The unit `unit` names in this system: its own, then its fallbacks', then the well-known one. A unit given
163
+ itself is returned as it is. The result can be passed anywhere a unit is named, and keeps this system's scale.
164
+
165
+ ```lua
166
+ height:As(MINI:GetUnit("stud"))
167
+ ```
168
+
169
+ @param unit UnitLike
170
+ @return UnitDefinition?
171
+ ]=]
172
+ function MetricUnitSystem.GetUnit(self: MetricUnitSystem, unit: string | UnitDefinition): UnitDefinition?
173
+ if type(unit) ~= "string" then
174
+ return MetricUnitDefinitions.getUnit(unit)
175
+ end
176
+ local system: MetricUnitSystem? = self
177
+ while system do
178
+ local own = system._unitsById[unit]
179
+ if own then
180
+ return own
181
+ end
182
+ system = system._fallback
183
+ end
184
+ return MetricUnitDefinitions.getUnit(unit)
185
+ end
186
+
187
+ --[=[
188
+ The units a value of `dimensions` is shown in when it prefers this system, largest first: this system's, then
189
+ its fallbacks', or nil when none lists the dimension. An absolute value takes the point display units first.
190
+
191
+ @param dimensions Dimensions
192
+ @param absolute boolean?
193
+ @return { UnitDefinition }?
194
+ ]=]
195
+ function MetricUnitSystem.GetDisplayUnits(
196
+ self: MetricUnitSystem,
197
+ dimensions: MetricUnitDimensionUtils.Dimensions,
198
+ absolute: boolean?
199
+ ): { UnitDefinition }?
200
+ local system: MetricUnitSystem? = self
201
+ while system do
202
+ local displayUnits, pointDisplayUnits = system:_getDisplayUnitLists()
203
+ local found = (absolute and MetricUnitSystem._findByDimensions(pointDisplayUnits, dimensions))
204
+ or MetricUnitSystem._findByDimensions(displayUnits, dimensions)
205
+ if found then
206
+ return found
207
+ end
208
+ system = system._fallback
209
+ end
210
+ return nil
211
+ end
212
+
213
+ --[=[
214
+ `amount` of this system's `unit`, such as `MINI:New(5, "stud")`.
215
+
216
+ @param amount number
217
+ @param unit UnitLike
218
+ @return MetricUnit
219
+ ]=]
220
+ function MetricUnitSystem.New(self: MetricUnitSystem, amount: number, unit: string | UnitDefinition): any
221
+ local MetricUnit = requireWhenCalled("MetricUnit")
222
+ return MetricUnit.new(amount, self:_requireUnit(unit, "New"))
223
+ end
224
+
225
+ --[=[
226
+ How many of this system's `unit` `value` is, such as `MINI:In(height, "stud")`.
227
+
228
+ @param value MetricUnit
229
+ @param unit UnitLike
230
+ @return number
231
+ ]=]
232
+ function MetricUnitSystem.In(self: MetricUnitSystem, value: any, unit: string | UnitDefinition): number
233
+ return value:In(self:_requireUnit(unit, "In"))
234
+ end
235
+
236
+ --[=[
237
+ Text for players with `value` in this system's units where it has them, as [MetricUnit.format] writes it.
238
+
239
+ ```lua
240
+ MetricUnitSystems.IMPERIAL:Format(MetricUnit.kilograms(80)) -- "12.598 st"
241
+ ```
242
+
243
+ @param value MetricUnit | MetricUnitData
244
+ @param options MetricUnitFormatOptions?
245
+ @return string
246
+ ]=]
247
+ function MetricUnitSystem.Format(self: MetricUnitSystem, value: any, options: any?): string
248
+ local MetricUnitFormatUtils = requireWhenCalled("MetricUnitFormatUtils")
249
+ return MetricUnitFormatUtils._formatInSystem(value, self, options)
250
+ end
251
+
252
+ --[=[
253
+ Reads `text` as [MetricUnit.parse] does, with this system's units: `MINI:Parse("5 studs")` is five of its
254
+ studs.
255
+
256
+ @param text string
257
+ @param kind (KindName | Dimensions)?
258
+ @param options ParseOptions?
259
+ @return MetricUnit?
260
+ @return string? -- why it could not be read
261
+ ]=]
262
+ function MetricUnitSystem.Parse(self: MetricUnitSystem, text: string, kind: any?, options: any?): (any?, string?)
263
+ local MetricUnit = requireWhenCalled("MetricUnit")
264
+ return MetricUnit._parseInSystem(text, kind, options, self)
265
+ end
266
+
267
+ function MetricUnitSystem._requireUnit(
268
+ self: MetricUnitSystem,
269
+ unit: string | UnitDefinition,
270
+ verb: string
271
+ ): UnitDefinition
272
+ local definition = self:GetUnit(unit)
273
+ if not definition then
274
+ error(string.format("MetricUnitSystem:%s: %q knows no unit %q", verb, self._name, tostring(unit)), 3)
275
+ end
276
+ return definition
277
+ end
278
+
279
+ function MetricUnitSystem._findByDimensions(
280
+ lists: { { UnitDefinition } },
281
+ dimensions: MetricUnitDimensionUtils.Dimensions
282
+ ): { UnitDefinition }?
283
+ for _, units in lists do
284
+ if MetricUnitDimensionUtils.equal(units[1].dimensions, dimensions) then
285
+ return units
286
+ end
287
+ end
288
+ return nil
289
+ end
290
+
291
+ function MetricUnitSystem._getDisplayUnitLists(self: MetricUnitSystem): ({ { UnitDefinition } }, { { UnitDefinition } })
292
+ local resolved = self._resolved
293
+ local displayUnits = resolved.displayUnits
294
+ local pointDisplayUnits = resolved.pointDisplayUnits
295
+ if displayUnits and pointDisplayUnits then
296
+ return displayUnits, pointDisplayUnits
297
+ end
298
+ local newDisplayUnits = self:_resolveDisplayUnits(self._displayUnitIds)
299
+ local newPointDisplayUnits = self:_resolveDisplayUnits(self._pointDisplayUnitIds)
300
+ resolved.displayUnits = newDisplayUnits
301
+ resolved.pointDisplayUnits = newPointDisplayUnits
302
+ return newDisplayUnits, newPointDisplayUnits
303
+ end
304
+
305
+ function MetricUnitSystem._resolveDisplayUnits(self: MetricUnitSystem, lists: { { string } }): { { UnitDefinition } }
306
+ local resolved: { { UnitDefinition } } = {}
307
+ for _, ids in lists do
308
+ local units: { UnitDefinition } = {}
309
+ for _, id in ids do
310
+ local unit = self:GetUnit(id)
311
+ if not unit then
312
+ error(string.format("MetricUnitSystem %q knows no unit %q", self._name, id), 4)
313
+ end
314
+ table.insert(units, unit)
315
+ end
316
+ assert(#units > 0, "Empty display unit list")
317
+ table.insert(resolved, table.freeze(units))
318
+ end
319
+ return table.freeze(resolved)
320
+ end
321
+
322
+ return MetricUnitSystem
@@ -0,0 +1,181 @@
1
+ --!strict
2
+ --[[
3
+ @class MetricUnitSystem.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 MetricUnitDefinitions = require("MetricUnitDefinitions")
11
+ local MetricUnitSystem = require("MetricUnitSystem")
12
+ local MetricUnitSystems = require("MetricUnitSystems")
13
+
14
+ local describe = Jest.Globals.describe
15
+ local expect = Jest.Globals.expect
16
+ local it = Jest.Globals.it
17
+
18
+ local MINI = MetricUnitSystems.roblox({ name = "robloxMini", metersPerStud = 0.028 })
19
+
20
+ local function plain(text: string): string
21
+ return (string.gsub(text, "\u{00A0}", " "))
22
+ end
23
+
24
+ local function format(value: any, options: any?): string
25
+ return plain(MetricUnit.format(value, options))
26
+ end
27
+
28
+ describe("MetricUnitSystems", function()
29
+ it("names the well-known systems and chains their fallbacks", function()
30
+ expect(MetricUnitSystems.METRIC:GetName()).toBe("metric")
31
+ expect(MetricUnitSystems.US_CUSTOMARY:GetFallback()).toBe(MetricUnitSystems.US_CUSTOMARY_AND_IMPERIAL)
32
+ expect(MetricUnitSystems.IMPERIAL:GetFallback()).toBe(MetricUnitSystems.US_CUSTOMARY_AND_IMPERIAL)
33
+ expect(MetricUnitSystems.US_CUSTOMARY_AND_IMPERIAL:GetFallback()).toBe(MetricUnitSystems.METRIC)
34
+ expect(MetricUnitSystems.ROBLOX:GetFallback()).toBe(MetricUnitSystems.METRIC)
35
+ expect(MetricUnitSystem.isSystem(MetricUnitSystems.ROBLOX)).toBe(true)
36
+ expect(MetricUnitSystem.isSystem({})).toBe(false)
37
+ end)
38
+
39
+ it("finds the system that declares a unit", function()
40
+ expect(MetricUnitSystems.getSystemOfUnit(assert(MetricUnitDefinitions.getUnit("imperialPint")))).toBe(
41
+ MetricUnitSystems.IMPERIAL
42
+ )
43
+ expect(MetricUnitSystems.getSystemOfUnit(assert(MINI:GetUnit("stud")))).toBe(MINI)
44
+ expect(MetricUnitSystems.getSystemOfUnit(assert(MetricUnitDefinitions.getUnit("second")))).toBeNil()
45
+ end)
46
+ end)
47
+
48
+ describe("a value shows itself in the system of the unit it was made from", function()
49
+ it("prefers nothing when made from a metric unit", function()
50
+ expect(MetricUnit.kilometers(8):GetDisplaySystem()).toBeNil()
51
+ expect(format(MetricUnit.kilometers(8))).toBe("8 km")
52
+ end)
53
+
54
+ it("prefers the unit's system otherwise", function()
55
+ expect(format(MetricUnit.new(5, "mile"))).toBe("5 mi")
56
+ expect(format(MetricUnit.new(30, "milePerUsGallon"))).toBe("30 mpg (US)")
57
+ expect(format(MetricUnit.new(2, "imperialPint"))).toBe("2 imp pt")
58
+ expect(format(MetricUnit.studs(10))).toBe("10 studs")
59
+ end)
60
+
61
+ it("converts to another system for display", function()
62
+ local distance = MetricUnit.new(5, "mile")
63
+ expect(format(distance:ToMetric())).toBe("8.047 km")
64
+ expect(format(MetricUnit.kilometers(8):ToImperial())).toBe("4.971 mi")
65
+ end)
66
+ end)
67
+
68
+ describe("MetricUnitSystem:Format", function()
69
+ it("shows any value in the system", function()
70
+ expect(plain(MetricUnitSystems.IMPERIAL:Format(MetricUnit.new(2, "liter")))).toBe("3.52 imp pt")
71
+ expect(plain(MetricUnitSystems.US_CUSTOMARY:Format(MetricUnit.new(2, "liter")))).toBe("2.113 US qt")
72
+ expect(plain(MetricUnitSystems.METRIC:Format(MetricUnit.new(5, "mile")))).toBe("8.047 km")
73
+ expect(plain(MINI:Format(MetricUnit.meters(0.14)))).toBe("5 studs")
74
+ end)
75
+
76
+ it("falls back for dimensions the system does not list", function()
77
+ expect(plain(MINI:Format(MetricUnit.seconds(90)))).toBe("1 minute, 30 seconds")
78
+ expect(plain(MetricUnitSystems.IMPERIAL:Format(MetricUnit.new(5, "volt")))).toBe("5 V")
79
+ end)
80
+ end)
81
+
82
+ describe("MetricUnitSystems.roblox", function()
83
+ it("reads stud amounts in and out at a game's own scale", function()
84
+ local height = MINI:New(5, "stud")
85
+ expect(height:In("meter")).toBeCloseTo(0.14, 9)
86
+ expect(MINI:In(height, "stud")).toBeCloseTo(5, 9)
87
+ expect(format(height)).toBe("5 studs")
88
+ expect(format(height:ToMetric())).toBe("14 cm")
89
+ end)
90
+
91
+ it("scales speeds, accelerations and the mass unit with the stud", function()
92
+ expect(MINI:New(10, "studPerSecond"):In("meterPerSecond")).toBeCloseTo(0.28, 9)
93
+ expect(MINI:New(1, "studPerSecondSquared"):In("meterPerSecondSquared")).toBeCloseTo(0.028, 9)
94
+ expect(MINI:New(1, "robloxMassUnit"):In("gram")).toBeCloseTo(21.952, 9)
95
+ if typeof(Vector3) == "userdata" or typeof(Vector3) == "table" then
96
+ local speed = MINI:New(Vector3.new(30, 40, 0).Magnitude, "studPerSecond")
97
+ expect(speed:In("meterPerSecond")).toBeCloseTo(1.4, 9)
98
+ end
99
+ end)
100
+
101
+ it("keeps Roblox's own studs apart from the game's", function()
102
+ local height = MINI:New(5, "stud")
103
+ expect(height:In("stud")).toBeCloseTo(0.5, 9)
104
+ expect((MetricUnit.studs(10) + MINI:New(100, "stud")):In("meter")).toBeCloseTo(5.6, 9)
105
+ end)
106
+
107
+ it("keeps its studs through arithmetic", function()
108
+ local height = MINI:New(5, "stud")
109
+ expect(format(height * 2)).toBe("10 studs")
110
+ expect(format(height / MetricUnit.seconds(1))).toBe("5 studs/s")
111
+ expect(format(MetricUnit.round(MINI:New(5.4, "stud"), "stud"))).toBe("5 studs")
112
+ end)
113
+
114
+ it("pins a value to one of its units", function()
115
+ local stud = assert(MINI:GetUnit("stud"))
116
+ expect(format(MetricUnit.meters(0.14):As(stud))).toBe("5 studs")
117
+ expect(MetricUnit.meters(0.14):As(stud):GetDisplayUnit()).toBe(stud)
118
+ expect(MetricUnit.meters(0.14):In(stud)).toBeCloseTo(5, 9)
119
+ end)
120
+
121
+ it("parses studs at its scale", function()
122
+ local value = assert(MINI:Parse("5 studs", "length"))
123
+ expect(value:In("meter")).toBeCloseTo(0.14, 9)
124
+ expect(format(value)).toBe("5 studs")
125
+ local plainStuds = assert(MetricUnit.parse("5 studs", "length"))
126
+ expect(plainStuds:In("meter")).toBeCloseTo(1.4, 9)
127
+ local bare = assert(MINI:Parse("5", "length", { defaultUnit = "stud" }))
128
+ expect(bare:In("meter")).toBeCloseTo(0.14, 9)
129
+ end)
130
+
131
+ it("shows the game's stud amounts in a player's chosen system", function()
132
+ local distance = MINI:New(10000, "stud")
133
+ expect(plain(MetricUnitSystems.METRIC:Format(distance))).toBe("280 m")
134
+ expect(plain(MetricUnitSystems.US_CUSTOMARY:Format(distance))).toBe("918.635 ft")
135
+ expect(format(distance)).toBe("10,000 studs")
136
+ end)
137
+ end)
138
+
139
+ describe("MetricUnitSystem.new", function()
140
+ local SPACE = MetricUnitSystem.new({
141
+ name = "space",
142
+ units = MetricUnitDefinitions.createUnits("length", "space", {
143
+ { id = "lightYear" :: any, factor = 9.4607e15, symbol = "ly", name = "light-year" },
144
+ { id = "parsec" :: any, factor = 3.0857e16, symbol = "pc", name = "parsec", prefixes = "si" },
145
+ }),
146
+ displayUnits = { { "parsec", "lightYear", "kilometer" } },
147
+ fallback = MetricUnitSystems.METRIC,
148
+ })
149
+
150
+ it("declares units only it knows", function()
151
+ expect(MetricUnitDefinitions.getUnit("lightYear" :: any)).toBeNil()
152
+ expect(SPACE:New(2, "lightYear"):In("kilometer")).toBeCloseTo(1.89214e13, -8)
153
+ expect(SPACE:In(SPACE:New(1, "kiloparsec"), "parsec")).toBeCloseTo(1000, 9)
154
+ expect(SPACE:New(3, "meter"):In("meter")).toBe(3)
155
+ expect(function()
156
+ MetricUnit.new(1, "lightYear" :: any)
157
+ end).toThrow('MetricUnit.new knows no unit "lightYear"')
158
+ expect(function()
159
+ SPACE:New(1, "furlong")
160
+ end).toThrow('"space" knows no unit "furlong"')
161
+ end)
162
+
163
+ it("shows and writes its own units in English", function()
164
+ local distance = SPACE:New(4.2, "lightYear")
165
+ expect(format(distance)).toBe("1.288 pc")
166
+ expect(format(distance, { unit = "lightYear", unitDisplay = "long" })).toBe("4.2 light-years")
167
+ expect(plain(SPACE:Format(MetricUnit.kilograms(3)))).toBe("3 kg")
168
+ end)
169
+
170
+ it("parses its own units", function()
171
+ local value = assert(SPACE:Parse("4.2 ly", "length"))
172
+ expect(SPACE:In(value, "lightYear")).toBeCloseTo(4.2, 9)
173
+ end)
174
+
175
+ it("errors on a display unit it cannot find, when it first shows a value", function()
176
+ local broken = MetricUnitSystem.new({ name = "broken", displayUnits = { { "furlong" } } })
177
+ expect(function()
178
+ broken:Format(MetricUnit.meters(1))
179
+ end).toThrow('MetricUnitSystem "broken" knows no unit "furlong"')
180
+ end)
181
+ end)