@kernhq/module-inventory 0.1.2 → 0.3.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 (213) hide show
  1. package/README.md +134 -9
  2. package/dist/contract/capabilities.d.ts +49 -0
  3. package/dist/contract/capabilities.d.ts.map +1 -0
  4. package/dist/contract/capabilities.js +94 -0
  5. package/dist/contract/capabilities.js.map +1 -0
  6. package/dist/contract/events.d.ts +76 -0
  7. package/dist/contract/events.d.ts.map +1 -0
  8. package/dist/contract/events.js +62 -0
  9. package/dist/contract/events.js.map +1 -0
  10. package/dist/contract/index.d.ts +16 -0
  11. package/dist/contract/index.d.ts.map +1 -0
  12. package/dist/contract/index.js +16 -0
  13. package/dist/contract/index.js.map +1 -0
  14. package/dist/contract/models.d.ts +468 -0
  15. package/dist/contract/models.d.ts.map +1 -0
  16. package/dist/contract/models.js +320 -0
  17. package/dist/contract/models.js.map +1 -0
  18. package/dist/contract/notifications.d.ts +24 -0
  19. package/dist/contract/notifications.d.ts.map +1 -0
  20. package/dist/contract/notifications.js +72 -0
  21. package/dist/contract/notifications.js.map +1 -0
  22. package/dist/contract/permissions.d.ts +72 -0
  23. package/dist/contract/permissions.d.ts.map +1 -0
  24. package/dist/contract/permissions.js +79 -0
  25. package/dist/contract/permissions.js.map +1 -0
  26. package/dist/contract/router.d.ts +1378 -0
  27. package/dist/contract/router.d.ts.map +1 -0
  28. package/dist/contract/router.js +266 -0
  29. package/dist/contract/router.js.map +1 -0
  30. package/dist/contract/settings.d.ts +20 -0
  31. package/dist/contract/settings.d.ts.map +1 -0
  32. package/dist/contract/settings.js +49 -0
  33. package/dist/contract/settings.js.map +1 -0
  34. package/dist/server/index.d.ts +8 -1
  35. package/dist/server/index.d.ts.map +1 -1
  36. package/dist/server/index.js +288 -8
  37. package/dist/server/index.js.map +1 -1
  38. package/dist/server/jobs.d.ts +48 -0
  39. package/dist/server/jobs.d.ts.map +1 -0
  40. package/dist/server/jobs.js +358 -0
  41. package/dist/server/jobs.js.map +1 -0
  42. package/dist/server/router.d.ts +1789 -0
  43. package/dist/server/router.d.ts.map +1 -0
  44. package/dist/server/router.js +439 -0
  45. package/dist/server/router.js.map +1 -0
  46. package/dist/server/schema.d.ts +156 -9
  47. package/dist/server/schema.d.ts.map +1 -1
  48. package/dist/server/schema.js +183 -11
  49. package/dist/server/schema.js.map +1 -1
  50. package/dist/server/services/assets.d.ts +139 -0
  51. package/dist/server/services/assets.d.ts.map +1 -0
  52. package/dist/server/services/assets.js +429 -0
  53. package/dist/server/services/assets.js.map +1 -0
  54. package/dist/server/services/attachments.d.ts +80 -0
  55. package/dist/server/services/attachments.d.ts.map +1 -0
  56. package/dist/server/services/attachments.js +182 -0
  57. package/dist/server/services/attachments.js.map +1 -0
  58. package/dist/server/services/audience.d.ts +15 -0
  59. package/dist/server/services/audience.d.ts.map +1 -0
  60. package/dist/server/services/audience.js +64 -0
  61. package/dist/server/services/audience.js.map +1 -0
  62. package/dist/server/services/categories.d.ts +57 -0
  63. package/dist/server/services/categories.d.ts.map +1 -0
  64. package/dist/server/services/categories.js +124 -0
  65. package/dist/server/services/categories.js.map +1 -0
  66. package/dist/server/services/cursor.d.ts +68 -0
  67. package/dist/server/services/cursor.d.ts.map +1 -0
  68. package/dist/server/services/cursor.js +39 -0
  69. package/dist/server/services/cursor.js.map +1 -0
  70. package/dist/server/services/custody.d.ts +175 -0
  71. package/dist/server/services/custody.d.ts.map +1 -0
  72. package/dist/server/services/custody.js +367 -0
  73. package/dist/server/services/custody.js.map +1 -0
  74. package/dist/server/services/db-errors.d.ts +7 -0
  75. package/dist/server/services/db-errors.d.ts.map +1 -0
  76. package/dist/server/services/db-errors.js +32 -0
  77. package/dist/server/services/db-errors.js.map +1 -0
  78. package/dist/server/services/index.d.ts +26 -0
  79. package/dist/server/services/index.d.ts.map +1 -0
  80. package/dist/server/services/index.js +39 -0
  81. package/dist/server/services/index.js.map +1 -0
  82. package/dist/server/services/members.d.ts +27 -0
  83. package/dist/server/services/members.d.ts.map +1 -0
  84. package/dist/server/services/members.js +39 -0
  85. package/dist/server/services/members.js.map +1 -0
  86. package/dist/server/services/notify.d.ts +105 -0
  87. package/dist/server/services/notify.d.ts.map +1 -0
  88. package/dist/server/services/notify.js +147 -0
  89. package/dist/server/services/notify.js.map +1 -0
  90. package/dist/server/services/offboarding.d.ts +70 -0
  91. package/dist/server/services/offboarding.d.ts.map +1 -0
  92. package/dist/server/services/offboarding.js +116 -0
  93. package/dist/server/services/offboarding.js.map +1 -0
  94. package/dist/server/services/repairs.d.ts +204 -0
  95. package/dist/server/services/repairs.d.ts.map +1 -0
  96. package/dist/server/services/repairs.js +476 -0
  97. package/dist/server/services/repairs.js.map +1 -0
  98. package/dist/server/services/search.d.ts +85 -0
  99. package/dist/server/services/search.d.ts.map +1 -0
  100. package/dist/server/services/search.js +142 -0
  101. package/dist/server/services/search.js.map +1 -0
  102. package/dist/server/services/stats.d.ts +42 -0
  103. package/dist/server/services/stats.d.ts.map +1 -0
  104. package/dist/server/services/stats.js +80 -0
  105. package/dist/server/services/stats.js.map +1 -0
  106. package/dist/server/services/status.d.ts +102 -0
  107. package/dist/server/services/status.d.ts.map +1 -0
  108. package/dist/server/services/status.js +71 -0
  109. package/dist/server/services/status.js.map +1 -0
  110. package/migrations/0000_init.sql +12 -3
  111. package/migrations/0001_rls.sql +24 -0
  112. package/migrations/0002_custody_and_categories.sql +23 -0
  113. package/migrations/0003_repairs.sql +23 -0
  114. package/migrations/0004_platform_surfaces.sql +51 -0
  115. package/migrations/0005_repair_dates.sql +35 -0
  116. package/migrations/0006_workspace_registry_read.sql +50 -0
  117. package/migrations/0007_history_sequence.sql +83 -0
  118. package/migrations/meta/0000_snapshot.json +40 -13
  119. package/migrations/meta/0002_snapshot.json +1054 -0
  120. package/migrations/meta/0003_snapshot.json +1070 -0
  121. package/migrations/meta/0004_snapshot.json +1130 -0
  122. package/migrations/meta/0005_snapshot.json +1135 -0
  123. package/migrations/meta/_journal.json +44 -2
  124. package/package.json +5 -4
  125. package/src/client/api-instance.ts +27 -2
  126. package/src/client/api.ts +1 -1
  127. package/src/client/bidi.test.ts +148 -0
  128. package/src/client/bidi.ts +85 -0
  129. package/src/client/components/AssetDetailPanel.svelte +614 -0
  130. package/src/client/components/AssetFormDialog.svelte +191 -59
  131. package/src/client/components/AssetPhoto.svelte +178 -0
  132. package/src/client/components/AttachmentsSection.svelte +327 -0
  133. package/src/client/components/CustodyDialog.svelte +201 -0
  134. package/src/client/components/RepairDialog.svelte +271 -0
  135. package/src/client/components/RepairsSection.svelte +318 -0
  136. package/src/client/components/Timeline.svelte +347 -0
  137. package/src/client/components/TimelineText.svelte +124 -0
  138. package/src/client/core-api.ts +71 -0
  139. package/src/client/custody.test.ts +31 -0
  140. package/src/client/custody.ts +34 -0
  141. package/src/client/errors.test.ts +365 -0
  142. package/src/client/errors.ts +201 -0
  143. package/src/client/i18n.ts +11 -166
  144. package/src/client/index.ts +8 -1
  145. package/src/client/links.test.ts +74 -0
  146. package/src/client/links.ts +44 -0
  147. package/src/client/members.test.ts +132 -0
  148. package/src/client/members.ts +116 -0
  149. package/src/client/messages.test.ts +296 -0
  150. package/src/client/messages.ts +1424 -0
  151. package/src/client/mock.test.ts +555 -0
  152. package/src/client/mock.ts +1261 -52
  153. package/src/client/module.ts +76 -2
  154. package/src/client/pages/AssetsPage.svelte +638 -145
  155. package/src/client/permissions.ts +8 -1
  156. package/src/client/price.test.ts +254 -0
  157. package/src/client/price.ts +279 -0
  158. package/src/client/query.test.ts +58 -0
  159. package/src/client/query.ts +51 -2
  160. package/src/client/repairs.test.ts +38 -0
  161. package/src/client/repairs.ts +38 -0
  162. package/src/client/settings/CategoriesSettings.svelte +421 -0
  163. package/src/client/settings/GeneralSettings.svelte +403 -0
  164. package/src/client/status.ts +29 -0
  165. package/src/client/timeline.test.ts +175 -0
  166. package/src/client/timeline.ts +206 -0
  167. package/src/client/widgets/OverviewWidget.svelte +140 -26
  168. package/src/client/widgets/RepairsWidget.svelte +124 -0
  169. package/src/contract/capabilities.ts +99 -0
  170. package/src/contract/events.ts +83 -0
  171. package/src/contract/index.ts +16 -0
  172. package/src/contract/models.ts +360 -0
  173. package/src/contract/notifications.ts +73 -0
  174. package/src/contract/permissions.ts +79 -0
  175. package/src/contract/router.ts +300 -0
  176. package/src/contract/settings.ts +50 -0
  177. package/src/module.test.ts +330 -7
  178. package/src/server/index.ts +318 -8
  179. package/src/server/inventory.int.test.ts +4374 -0
  180. package/src/server/jobs.ts +444 -0
  181. package/src/server/migrations.test.ts +251 -0
  182. package/src/server/router.ts +574 -0
  183. package/src/server/schema.ts +184 -10
  184. package/src/server/services/assets.ts +528 -0
  185. package/src/server/services/attachments.ts +215 -0
  186. package/src/server/services/audience.ts +77 -0
  187. package/src/server/services/categories.ts +136 -0
  188. package/src/server/services/cursor.ts +104 -0
  189. package/src/server/services/custody.ts +471 -0
  190. package/src/server/services/db-errors.ts +42 -0
  191. package/src/server/services/index.ts +56 -0
  192. package/src/server/services/members.ts +54 -0
  193. package/src/server/services/notify.ts +196 -0
  194. package/src/server/services/offboarding.ts +150 -0
  195. package/src/server/services/repairs.ts +567 -0
  196. package/src/server/services/search.ts +166 -0
  197. package/src/server/services/stats.ts +88 -0
  198. package/src/server/services/status.test.ts +34 -0
  199. package/src/server/services/status.ts +143 -0
  200. package/tsconfig.base.json +22 -0
  201. package/tsconfig.client.json +1 -1
  202. package/tsconfig.json +1 -1
  203. package/vitest.config.ts +18 -3
  204. package/dist/contract.d.ts +0 -387
  205. package/dist/contract.d.ts.map +0 -1
  206. package/dist/contract.js +0 -119
  207. package/dist/contract.js.map +0 -1
  208. package/dist/server/_impl.d.ts +0 -427
  209. package/dist/server/_impl.d.ts.map +0 -1
  210. package/dist/server/_impl.js +0 -204
  211. package/dist/server/_impl.js.map +0 -1
  212. package/src/contract.ts +0 -143
  213. package/src/server/_impl.ts +0 -275
@@ -0,0 +1,296 @@
1
+ import { readdirSync, readFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { describe, expect, it } from 'vitest'
5
+ import { ar, de, en, fa, inventoryMessageBundles, tr } from './messages.js'
6
+
7
+ /**
8
+ * The bundles, structurally — and the counted messages, behaviourally.
9
+ *
10
+ * Nothing else looks at these. A key present in English and missing in Persian type-checks, builds,
11
+ * lints and ships, and the first person to see it is the one reading `inventory.status_lost` on a
12
+ * screen. Adding a status to `AssetStatus` and forgetting four translations is the exact shape of
13
+ * that mistake, and this file is what makes it fail here instead.
14
+ */
15
+ const BUNDLES = { en, ar, de, fa, tr } as const
16
+ type Locale = keyof typeof BUNDLES
17
+
18
+ /** The count placeholder: the runtime accepts `count` or `n`, and this module's catalogue uses `n`. */
19
+ const COUNT = 'n'
20
+ const placeholders = (s: string) => new Set([...s.matchAll(/\{(\w+)\}/g)].map((m) => m[1]!))
21
+ const forms = (v: unknown): string[] => (typeof v === 'string' ? [v] : Object.values(v as object))
22
+
23
+ describe('bundles', () => {
24
+ it('declares every locale the module claims to ship', () => {
25
+ expect(Object.keys(inventoryMessageBundles).sort()).toEqual(['ar', 'de', 'en', 'fa', 'tr'])
26
+ })
27
+
28
+ it('has the same key set in every locale', () => {
29
+ const keys = Object.keys(en).sort()
30
+ for (const [locale, bundle] of Object.entries(BUNDLES)) {
31
+ expect({ locale, keys: Object.keys(bundle).sort() }).toEqual({ locale, keys })
32
+ }
33
+ })
34
+
35
+ it('namespaces every key, so two modules cannot collide in the merged map', () => {
36
+ for (const key of Object.keys(en)) expect(key.startsWith('inventory.')).toBe(true)
37
+ })
38
+
39
+ /**
40
+ * A plural form may drop the numeral — "one asset" reads better than "1 asset" in some
41
+ * languages — but dropping any *other* placeholder loses information the sentence needed.
42
+ */
43
+ it('keeps every non-count placeholder in every plural form', () => {
44
+ for (const [key, value] of Object.entries(en)) {
45
+ const expected = placeholders(forms(value).join(' '))
46
+ expected.delete(COUNT)
47
+ for (const [locale, bundle] of Object.entries(BUNDLES)) {
48
+ for (const form of forms(bundle[key as keyof typeof bundle])) {
49
+ const got = placeholders(form)
50
+ got.delete(COUNT)
51
+ expect({ key, locale, form, got: [...got].sort() }).toEqual({
52
+ key,
53
+ locale,
54
+ form,
55
+ got: [...expected].sort(),
56
+ })
57
+ }
58
+ }
59
+ }
60
+ })
61
+
62
+ it('never invents a placeholder the English string does not have', () => {
63
+ for (const [key, value] of Object.entries(en)) {
64
+ const allowed = placeholders(forms(value).join(' '))
65
+ for (const [locale, bundle] of Object.entries(BUNDLES))
66
+ for (const form of forms(bundle[key as keyof typeof bundle]))
67
+ for (const name of placeholders(form))
68
+ expect({ key, locale, name, known: allowed.has(name) }).toEqual({
69
+ key,
70
+ locale,
71
+ name,
72
+ known: true,
73
+ })
74
+ }
75
+ })
76
+
77
+ /** Arabic inflects six ways. A bundle with only one/other picks `other` for two, and reads wrong. */
78
+ it('gives every Arabic plural all six CLDR categories', () => {
79
+ const wanted = new Intl.PluralRules('ar').resolvedOptions().pluralCategories.sort()
80
+ for (const [key, value] of Object.entries(ar)) {
81
+ if (typeof value === 'string') continue
82
+ expect({ key, cats: Object.keys(value).sort() }).toEqual({ key, cats: wanted })
83
+ }
84
+ })
85
+
86
+ /**
87
+ * The slip that six forms invite: writing five of them and abbreviating the sixth.
88
+ *
89
+ * `many` is the form for 11–99, where Arabic puts the counted noun in the singular accusative —
90
+ * "{n} أصلاً". Because that form is written *differently* from `other`, it gets written from
91
+ * scratch rather than copied, and a compound noun loses half of itself on the way: the shell's
92
+ * `modules_meta_objects` said "{n} نوعاً" — "{n} type" — where every other form said "نوع كائن",
93
+ * "object type". Nothing catches that. It compiles, it has all six categories, it has the right
94
+ * placeholder, and it is missing the word the sentence is about.
95
+ *
96
+ * Word count against `other` is a heuristic and is named as one: a form with *fewer* words than
97
+ * `other` has dropped one, and in a counted phrase the word it drops is the noun. Inflection
98
+ * changes a word's letters, not how many words there are, so this stays true across all six.
99
+ */
100
+ it('never drops a word from an Arabic plural form that the `other` form has', () => {
101
+ const words = (form: string) => form.split(/\s+/).filter((word) => word && !word.includes('{')).length
102
+ for (const [key, value] of Object.entries(ar)) {
103
+ if (typeof value === 'string') continue
104
+ const other = (value as Record<string, string>).other ?? ''
105
+ for (const [category, form] of Object.entries(value as Record<string, string>)) {
106
+ // `zero` is a negation — "لا أصول" — and is the one form entitled to a different shape.
107
+ if (category === 'zero') continue
108
+ expect({ key, category, form, atLeast: words(other) }).toEqual({
109
+ key,
110
+ category,
111
+ form,
112
+ atLeast: Math.min(words(form), words(other)),
113
+ })
114
+ }
115
+ }
116
+ })
117
+
118
+ it('makes a plural of a key wherever English has one', () => {
119
+ for (const [key, value] of Object.entries(en)) {
120
+ if (typeof value === 'string') continue
121
+ for (const [locale, bundle] of Object.entries(BUNDLES))
122
+ expect({ key, locale, plural: typeof bundle[key as keyof typeof bundle] }).toEqual({
123
+ key,
124
+ locale,
125
+ plural: 'object',
126
+ })
127
+ }
128
+ })
129
+ })
130
+
131
+ // ------------------------------------------------------ keys nothing asks for any more
132
+
133
+ const HERE = dirname(fileURLToPath(import.meta.url))
134
+
135
+ /** Everything in this client that could name a key — components included, catalogue excluded. */
136
+ function clientSource(): string {
137
+ const files = readdirSync(HERE, { recursive: true, encoding: 'utf8' }).filter(
138
+ (file) =>
139
+ (file.endsWith('.ts') || file.endsWith('.svelte')) &&
140
+ !file.endsWith('.test.ts') &&
141
+ !file.endsWith('messages.ts'),
142
+ )
143
+ return files.map((file) => readFileSync(join(HERE, file), 'utf8')).join('\n')
144
+ }
145
+
146
+ /**
147
+ * The two families of key that are *built* rather than written, and so are never literals.
148
+ *
149
+ * `t(`status_${status}`)` and `actionKey()`'s `history_${action}`. Both lists are duplicated here
150
+ * on purpose — the file is data-only and importing the contract to check a catalogue would be a
151
+ * circular sort of proof — and `status labels` below already holds the first one to the contract.
152
+ */
153
+ const BUILT = new Set([
154
+ ...['in_stock', 'assigned', 'reserved', 'under_repair', 'lost', 'retired'].map((s) => `status_${s}`),
155
+ ...[
156
+ 'created',
157
+ 'updated',
158
+ 'assigned',
159
+ 'transferred',
160
+ 'returned',
161
+ 'retired',
162
+ 'restored',
163
+ 'repair_logged',
164
+ 'repair_completed',
165
+ 'attachment_added',
166
+ 'attachment_removed',
167
+ ].map((a) => `history_${a}`),
168
+ ])
169
+
170
+ describe('every key is one something asks for', () => {
171
+ /**
172
+ * `inventory.settings_save_error` — "The settings could not be saved" — sat in all five bundles
173
+ * with nothing reading it: the save bar renders `errorMessage(err, t)`, which names the actual
174
+ * refusal instead. A dead key is five translations somebody maintains, reviews and re-reads for
175
+ * a sentence that cannot appear, and nothing about it looks wrong from inside the catalogue.
176
+ *
177
+ * Literals rather than a runtime trace, because a `.svelte` file cannot be unit-tested here —
178
+ * the same reason `errors.test.ts` reads `src/server` off disk. It misses a key that happens to
179
+ * appear as an unrelated string somewhere, which is a false *pass*; it never fails a key that is
180
+ * really used, which is the direction that matters for a check nobody wants to fight.
181
+ */
182
+ it('has nothing in the catalogue that no screen can reach', () => {
183
+ const source = clientSource()
184
+ const dead = Object.keys(en)
185
+ .map((key) => key.slice('inventory.'.length))
186
+ .filter((name) => !BUILT.has(name))
187
+ .filter((name) => !(source.includes(`'${name}'`) || source.includes(`"${name}"`)))
188
+ expect(dead).toEqual([])
189
+ })
190
+
191
+ it('is reading the client rather than passing on an empty sweep', () => {
192
+ expect(clientSource().length).toBeGreaterThan(10_000)
193
+ })
194
+ })
195
+
196
+ /**
197
+ * Every status the contract can hold has a label, in every language.
198
+ *
199
+ * The screens render a status with `t(`status_${status}`)`, which cannot be type-checked: a status
200
+ * added to the enum and not to the catalogue renders the literal key `inventory.status_lost` in the
201
+ * middle of a table, in every locale at once.
202
+ */
203
+ describe('status labels', () => {
204
+ // Not imported from the contract: this file is data-only on purpose, and a copy of six strings
205
+ // that must match is exactly what a test is for.
206
+ const STATUSES = ['in_stock', 'assigned', 'reserved', 'under_repair', 'lost', 'retired']
207
+
208
+ it('labels every asset status in every locale', () => {
209
+ for (const status of STATUSES)
210
+ for (const [locale, bundle] of Object.entries(BUNDLES))
211
+ expect({ status, locale, has: `inventory.status_${status}` in bundle }).toEqual({
212
+ status,
213
+ locale,
214
+ has: true,
215
+ })
216
+ })
217
+ })
218
+
219
+ /**
220
+ * The counted messages, resolved the way the runtime resolves them.
221
+ *
222
+ * `t()` lives in `@kernhq/ui`, whose entry point pulls in Svelte components this package's test
223
+ * setup cannot transform. So these drive the *data* through the same `Intl.PluralRules` selection
224
+ * `selectPlural` performs, and `t()` itself is tested where it lives.
225
+ */
226
+ describe('counted messages', () => {
227
+ const pick = (locale: Locale, key: string, count: number): string => {
228
+ const value = BUNDLES[locale][key as keyof (typeof BUNDLES)[Locale]] as
229
+ | string
230
+ | Partial<Record<Intl.LDMLPluralRule, string>>
231
+ if (typeof value === 'string') return value
232
+ const category = new Intl.PluralRules(locale).select(count)
233
+ const form = value[category] ?? value.other
234
+ if (form === undefined) throw new Error(`${locale} ${key} has no form for ${count}`)
235
+ return form.replace(/\{n\}/g, new Intl.NumberFormat(locale).format(count))
236
+ }
237
+
238
+ it('says "1 asset" and "2 assets" in English', () => {
239
+ expect(pick('en', 'inventory.count', 1)).toBe('1 asset')
240
+ expect(pick('en', 'inventory.count', 2)).toBe('2 assets')
241
+ })
242
+
243
+ it('says "1 Gegenstand" and "2 Gegenstände" in German', () => {
244
+ expect(pick('de', 'inventory.count', 1)).toBe('1 Gegenstand')
245
+ expect(pick('de', 'inventory.count', 2)).toBe('2 Gegenstände')
246
+ })
247
+
248
+ it('follows Arabic numeral agreement rather than one-or-many', () => {
249
+ expect(pick('ar', 'inventory.count', 0)).toBe('لا أصول')
250
+ expect(pick('ar', 'inventory.count', 1)).toBe('أصل واحد')
251
+ expect(pick('ar', 'inventory.count', 2)).toBe('أصلان')
252
+ expect(pick('ar', 'inventory.count', 5)).toContain('أصول')
253
+ })
254
+
255
+ it('does not inflect the noun after a numeral in Turkish or Persian', () => {
256
+ // "5 demirbaş", not "5 demirbaşlar" — the plural suffix is wrong once a number is present.
257
+ expect(pick('tr', 'inventory.count', 1).replace('1', '')).toBe(
258
+ pick('tr', 'inventory.count', 5).replace('5', ''),
259
+ )
260
+ expect(pick('fa', 'inventory.count', 1).replace('۱', '')).toBe(
261
+ pick('fa', 'inventory.count', 5).replace('۵', ''),
262
+ )
263
+ })
264
+
265
+ it('formats the number in the reader’s own digits', () => {
266
+ expect(pick('fa', 'inventory.count', 5)).toContain('۵')
267
+ expect(pick('en', 'inventory.count', 5)).toContain('5')
268
+ })
269
+ })
270
+
271
+ /**
272
+ * One word per idea, across the whole product and not just across this module.
273
+ *
274
+ * A module is read beside the shell, so a second Turkish word for un-archiving something is a
275
+ * product that says two things for one action. `ws_archive_hint` and `ws_archive_confirm_body` in
276
+ * `shell/messages/tr.json` both say **geri getir** about an archived workspace; this bundle said
277
+ * *geri yükle*, three lines away from "Daha fazla yükle" (load more) and "sayfayı yeniden yükleyin"
278
+ * (reload) — one verb doing three jobs in one panel.
279
+ *
280
+ * Pinned here because it cannot be checked against the shell: a module is built standalone, and
281
+ * `shell/messages/*.json` is not in this package. The word is the decision; the test is the memory.
282
+ */
283
+ describe('the words Kern already uses', () => {
284
+ it('un-archives with the shell’s own Turkish verb, and never overloads “yükle”', () => {
285
+ expect(tr['inventory.restore']).toBe('Geri getir')
286
+ const overloaded = Object.entries(tr)
287
+ .filter(([, value]) => typeof value === 'string' && (value as string).includes('geri yükle'))
288
+ .map(([key]) => key)
289
+ expect(overloaded).toEqual([])
290
+ })
291
+
292
+ /** «إعادة التحميل» is what the shell calls Reload (`pwa_update_reload`), so a reload says that. */
293
+ it('reloads with the shell’s own words in Arabic', () => {
294
+ expect(ar['inventory.error_conflict']).toContain('أعد تحميل الصفحة')
295
+ })
296
+ })