|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * Setup copy carries no design-record vocabulary. |
| 5 | + * |
| 6 | + * An administrator reads three kinds of string this package ships: the |
| 7 | + * sentence under a Setup page title (the object's `description`), a field's |
| 8 | + * help tooltip (the field's `description`, extracted as `help`), and the Setup |
| 9 | + * left nav (`SETUP_APP` plus `SETUP_NAV_CONTRIBUTIONS`). The generated bundles |
| 10 | + * carry the same strings into every locale. An ADR number, a decision id |
| 11 | + * ("D2"), a review-finding id ("finding F2"), an API call ("emit()") or a |
| 12 | + * runtime internal ("kernel") means nothing to that reader: the reference |
| 13 | + * belongs in the docblock or comment next to the string, never in the string. |
| 14 | + * |
| 15 | + * Population, all of it read from this checkout's source (no build artifact |
| 16 | + * except `@objectstack/metadata-core`, a registered unaliased pair in |
| 17 | + * `scripts/check-test-source-alias.mjs`): |
| 18 | + * |
| 19 | + * 1. every object schema this package exports, plus every one |
| 20 | + * `@objectstack/metadata-core` exports (`sys_metadata_commit` included, |
| 21 | + * which no bundle carries) — each `label`, `pluralLabel` and |
| 22 | + * `description` anywhere in the definition; |
| 23 | + * 2. every leaf of the four committed `objects` bundles; |
| 24 | + * 3. the type-level `label` / `description` of the four `metadataForms` |
| 25 | + * bundles — the sentence a Setup metadata page (Positions, Capabilities, |
| 26 | + * Permission Sets) shows under its title. Section descriptions and field |
| 27 | + * `helpText` of those forms are the spec's form copy and are not judged |
| 28 | + * here; |
| 29 | + * 4. `SETUP_APP` and this package's `SETUP_NAV_CONTRIBUTIONS`, and the |
| 30 | + * hand-authored `apps.setup` subtree of each locale. |
| 31 | + * |
| 32 | + * Nav entries other packages contribute, and the service-messaging objects, |
| 33 | + * are outside this package's import graph; `service-messaging` pins its own |
| 34 | + * objects in `src/objects/admin-copy-vocabulary.test.ts`. |
| 35 | + */ |
| 36 | + |
| 37 | +import { describe, expect, it } from 'vitest'; |
| 38 | +import * as MetadataCore from '@objectstack/metadata-core'; |
| 39 | +import * as PlatformObjects from '../index.js'; |
| 40 | +import { SETUP_APP } from './setup.app.js'; |
| 41 | +import { SETUP_NAV_CONTRIBUTIONS } from './setup-nav.contributions.js'; |
| 42 | +import { en } from './translations/en.js'; |
| 43 | +import { zhCN } from './translations/zh-CN.js'; |
| 44 | +import { jaJP } from './translations/ja-JP.js'; |
| 45 | +import { esES } from './translations/es-ES.js'; |
| 46 | +import { enObjects } from './translations/en.objects.generated.js'; |
| 47 | +import { zhCNObjects } from './translations/zh-CN.objects.generated.js'; |
| 48 | +import { jaJPObjects } from './translations/ja-JP.objects.generated.js'; |
| 49 | +import { esESObjects } from './translations/es-ES.objects.generated.js'; |
| 50 | +import { enMetadataForms } from './translations/en.metadata-forms.generated.js'; |
| 51 | +import { zhCNMetadataForms } from './translations/zh-CN.metadata-forms.generated.js'; |
| 52 | +import { jaJPMetadataForms } from './translations/ja-JP.metadata-forms.generated.js'; |
| 53 | +import { esESMetadataForms } from './translations/es-ES.metadata-forms.generated.js'; |
| 54 | + |
| 55 | +/** The design-record vocabulary an admin-facing string must not carry. */ |
| 56 | +const DESIGN_RECORD_VOCABULARY: ReadonlyArray<readonly [string, RegExp]> = [ |
| 57 | + ['an ADR number', /ADR-\d/], |
| 58 | + ['a decision id', /\bD\d+\b/], |
| 59 | + ['a review-finding id', /finding F/], |
| 60 | + ['an API call', /\bemit\(\)/], |
| 61 | + ['a runtime internal', /\bkernel\b/i], |
| 62 | +]; |
| 63 | + |
| 64 | +function vocabularyIn(text: string): string[] { |
| 65 | + return DESIGN_RECORD_VOCABULARY.filter(([, re]) => re.test(text)).map(([name]) => name); |
| 66 | +} |
| 67 | + |
| 68 | +const COPY_KEYS = new Set(['label', 'pluralLabel', 'description']); |
| 69 | + |
| 70 | +/** Every `label` / `pluralLabel` / `description` string under `node`, with its path. */ |
| 71 | +function copyStrings(node: unknown, path: string, out: Array<[string, string]>): void { |
| 72 | + if (Array.isArray(node)) { |
| 73 | + node.forEach((v, i) => copyStrings(v, `${path}[${i}]`, out)); |
| 74 | + return; |
| 75 | + } |
| 76 | + if (!node || typeof node !== 'object') return; |
| 77 | + for (const [k, v] of Object.entries(node)) { |
| 78 | + const at = path ? `${path}.${k}` : k; |
| 79 | + if (typeof v === 'string') { |
| 80 | + if (COPY_KEYS.has(k)) out.push([at, v]); |
| 81 | + } else { |
| 82 | + copyStrings(v, at, out); |
| 83 | + } |
| 84 | + } |
| 85 | +} |
| 86 | + |
| 87 | +/** Every string leaf under `node`, with its path. */ |
| 88 | +function leafStrings(node: unknown, path: string, out: Array<[string, string]>): void { |
| 89 | + if (typeof node === 'string') { |
| 90 | + out.push([path, node]); |
| 91 | + return; |
| 92 | + } |
| 93 | + if (!node || typeof node !== 'object') return; |
| 94 | + for (const [k, v] of Object.entries(node)) leafStrings(v, path ? `${path}.${k}` : k, out); |
| 95 | +} |
| 96 | + |
| 97 | +function offenders(strings: Array<[string, string]>): string[] { |
| 98 | + return strings |
| 99 | + .map(([at, text]) => [at, text, vocabularyIn(text)] as const) |
| 100 | + .filter(([, , hits]) => hits.length > 0) |
| 101 | + .map(([at, text, hits]) => `${at} (${hits.join(', ')}): ${text}`); |
| 102 | +} |
| 103 | + |
| 104 | +type ObjectLike = { name: string; fields: Record<string, unknown> }; |
| 105 | +function isObjectSchema(v: unknown): v is ObjectLike { |
| 106 | + return !!v && typeof v === 'object' |
| 107 | + && typeof (v as ObjectLike).name === 'string' |
| 108 | + && !!(v as ObjectLike).fields && typeof (v as ObjectLike).fields === 'object'; |
| 109 | +} |
| 110 | + |
| 111 | +function objectSchemas(...modules: Array<Record<string, unknown>>): Map<string, ObjectLike> { |
| 112 | + const byName = new Map<string, ObjectLike>(); |
| 113 | + for (const mod of modules) { |
| 114 | + for (const v of Object.values(mod)) if (isObjectSchema(v) && !byName.has(v.name)) byName.set(v.name, v); |
| 115 | + } |
| 116 | + return byName; |
| 117 | +} |
| 118 | + |
| 119 | +const LOCALES = { en, 'zh-CN': zhCN, 'ja-JP': jaJP, 'es-ES': esES } as const; |
| 120 | +const OBJECT_BUNDLES = { en: enObjects, 'zh-CN': zhCNObjects, 'ja-JP': jaJPObjects, 'es-ES': esESObjects } as const; |
| 121 | +const FORM_BUNDLES = { |
| 122 | + en: enMetadataForms, 'zh-CN': zhCNMetadataForms, 'ja-JP': jaJPMetadataForms, 'es-ES': esESMetadataForms, |
| 123 | +} as const; |
| 124 | + |
| 125 | +describe('Setup copy carries no design-record vocabulary', () => { |
| 126 | + it('the matcher recognises each kind of vocabulary it exists to keep out', () => { |
| 127 | + // A matcher that matches nothing passes every assertion below; each kind |
| 128 | + // must be seen on a sentence of the shape it was written for. |
| 129 | + expect(vocabularyIn('Business Unit tree (ADR-0057).')).toEqual(['an ADR number']); |
| 130 | + expect(vocabularyIn('Placement intent, decision D12.')).toEqual(['a decision id']); |
| 131 | + expect(vocabularyIn('Bypass rule (finding F2).')).toEqual(['a review-finding id']); |
| 132 | + expect(vocabularyIn('One row per emit().')).toEqual(['an API call']); |
| 133 | + expect(vocabularyIn('The Kernel API is additive.')).toEqual(['a runtime internal']); |
| 134 | + expect(vocabularyIn('Your company structure, used to scope data and reports.')).toEqual([]); |
| 135 | + }); |
| 136 | + |
| 137 | + it('object, field and view copy of every object this package and metadata-core export', () => { |
| 138 | + const objects = objectSchemas( |
| 139 | + PlatformObjects as unknown as Record<string, unknown>, |
| 140 | + MetadataCore as unknown as Record<string, unknown>, |
| 141 | + ); |
| 142 | + // The population is not allowed to shrink to nothing: the objects whose |
| 143 | + // copy this suite was written against must still be in it. |
| 144 | + for (const name of [ |
| 145 | + 'sys_user', 'sys_business_unit', 'sys_notification', 'sys_sso_provider', |
| 146 | + 'sys_metadata', 'sys_metadata_audit', 'sys_metadata_commit', 'sys_metadata_activation', |
| 147 | + ]) { |
| 148 | + expect(objects.has(name), `${name} is in the population`).toBe(true); |
| 149 | + } |
| 150 | + expect(objects.size).toBeGreaterThanOrEqual(50); |
| 151 | + |
| 152 | + const strings: Array<[string, string]> = []; |
| 153 | + for (const [name, def] of objects) copyStrings(def, name, strings); |
| 154 | + expect(offenders(strings)).toEqual([]); |
| 155 | + }); |
| 156 | + |
| 157 | + it.each(Object.keys(OBJECT_BUNDLES))('every leaf of the %s objects bundle', (locale) => { |
| 158 | + const strings: Array<[string, string]> = []; |
| 159 | + leafStrings(OBJECT_BUNDLES[locale as keyof typeof OBJECT_BUNDLES], `objects`, strings); |
| 160 | + expect(strings.length).toBeGreaterThan(1000); |
| 161 | + expect(offenders(strings)).toEqual([]); |
| 162 | + }); |
| 163 | + |
| 164 | + it.each(Object.keys(FORM_BUNDLES))('the type-level label and description of the %s metadataForms bundle', (locale) => { |
| 165 | + const forms = FORM_BUNDLES[locale as keyof typeof FORM_BUNDLES] as Record<string, { label?: string; description?: string }>; |
| 166 | + const strings: Array<[string, string]> = []; |
| 167 | + for (const [type, form] of Object.entries(forms)) { |
| 168 | + if (typeof form.label === 'string') strings.push([`metadataForms.${type}.label`, form.label]); |
| 169 | + if (typeof form.description === 'string') strings.push([`metadataForms.${type}.description`, form.description]); |
| 170 | + } |
| 171 | + expect(forms.capability?.description, 'the Capability page sentence is in the population').toBeTypeOf('string'); |
| 172 | + expect(offenders(strings)).toEqual([]); |
| 173 | + }); |
| 174 | + |
| 175 | + it('the Setup app, its nav contributions, and every locale\'s apps.setup translations', () => { |
| 176 | + const strings: Array<[string, string]> = []; |
| 177 | + copyStrings(SETUP_APP, 'SETUP_APP', strings); |
| 178 | + copyStrings(SETUP_NAV_CONTRIBUTIONS, 'SETUP_NAV_CONTRIBUTIONS', strings); |
| 179 | + for (const [locale, data] of Object.entries(LOCALES)) { |
| 180 | + leafStrings(data.apps?.setup, `${locale}.apps.setup`, strings); |
| 181 | + } |
| 182 | + expect(strings.some(([at]) => at.endsWith('nav_business_units.label'))).toBe(true); |
| 183 | + expect(offenders(strings)).toEqual([]); |
| 184 | + }); |
| 185 | +}); |
0 commit comments