|
| 1 | +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * One translation key is ONE demand — the property, not the counts. |
| 5 | + * |
| 6 | + * `pnpm check:i18n-coverage` ratchets `countI18nRuleIssues`, which is `.length` |
| 7 | + * over the `i18n/`-prefixed findings, while its report calls that number |
| 8 | + * "untranslated declared strings". Those are the same number only if the |
| 9 | + * population holds each key once. It did not: two places in the walk addressed |
| 10 | + * one bundle slot, so 70 of 691 baselined units were one string counted twice |
| 11 | + * and translating ONE key moved the ratchet by TWO. |
| 12 | + * |
| 13 | + * These pins deliberately assert NO COUNT. A pin on 621, or on the per-config |
| 14 | + * 101 / 414 / 106, goes green again the day a third carrier is added to the |
| 15 | + * walk — the exact regression it would exist to catch. The property is what |
| 16 | + * cannot regress silently, so the property is what is pinned, at both seams the |
| 17 | + * defect was visible from: |
| 18 | + * |
| 19 | + * 1. `collectExpectedEntries` emits each path at most once (production), and |
| 20 | + * 2. no report carries two findings with the same `path` for one locale |
| 21 | + * (reporting — `os lint` spells that path `translations.LOCALE.KEY`, in |
| 22 | + * `commands/lint.ts`). |
| 23 | + * |
| 24 | + * Both measured duplicate families are exercised below, because they have |
| 25 | + * different causes and only one of them is visible in a report at all: |
| 26 | + * |
| 27 | + * - Two carriers, one action. The normalizer attaches an object's actions to |
| 28 | + * `obj.actions` AND to top-level `config.actions` — the same object |
| 29 | + * reference, measured on all three baselined example configs — so both |
| 30 | + * action branches emit `objects.OBJECT._actions.ACTION.*`. |
| 31 | + * - Two declarations, one form field. `deleteBehavior` is declared twice in |
| 32 | + * each of `field.form.ts` / `object.form.ts`, gated on `visibleWhen`; both |
| 33 | + * render into one key. Config-independent — it duplicates six entries on an |
| 34 | + * EMPTY config, and it never reaches `os lint`'s report, which hides the |
| 35 | + * `metadataForms` bucket unless `--include-platform` is passed. A |
| 36 | + * de-duplication at the reporting seam would have left this family |
| 37 | + * duplicated in perpetuity, which is why the fix lives in the walker. |
| 38 | + */ |
| 39 | + |
| 40 | +import { describe, it, expect } from 'vitest'; |
| 41 | +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract.js'; |
| 42 | +import { computeI18nCoverage } from '../src/utils/i18n-coverage.js'; |
| 43 | + |
| 44 | +/** Repeated paths in a walk, as `[path, occurrences]`, occurrences > 1 only. */ |
| 45 | +function repeatedPaths(entries: ReadonlyArray<{ path: string[] }>): Array<[string, number]> { |
| 46 | + const counts = new Map<string, number>(); |
| 47 | + for (const entry of entries) { |
| 48 | + const key = entry.path.join('.'); |
| 49 | + counts.set(key, (counts.get(key) ?? 0) + 1); |
| 50 | + } |
| 51 | + return [...counts].filter(([, n]) => n > 1); |
| 52 | +} |
| 53 | + |
| 54 | +/** |
| 55 | + * A config shaped the way the normalizer really emits one: the action object is |
| 56 | + * carried by BOTH the object and the top-level list, by reference. Sharing the |
| 57 | + * reference is the point — a copy would not reproduce the defect faithfully, |
| 58 | + * and a shared reference is what was measured on the real configs. |
| 59 | + */ |
| 60 | +function dualCarrierConfig(): any { |
| 61 | + const action = { |
| 62 | + name: 'convert_lead', |
| 63 | + label: 'Convert Lead', |
| 64 | + objectName: 'lead', |
| 65 | + confirmText: 'Convert?', |
| 66 | + successMessage: 'Converted.', |
| 67 | + params: [{ name: 'owner', label: 'New Owner' }], |
| 68 | + }; |
| 69 | + return { |
| 70 | + i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] }, |
| 71 | + objects: [{ name: 'lead', label: 'Lead', fields: { name: { label: 'Name' } }, actions: [action] }], |
| 72 | + actions: [action], |
| 73 | + translations: [{ en: { objects: { lead: { label: 'Lead' } } } }], |
| 74 | + }; |
| 75 | +} |
| 76 | + |
| 77 | +describe('one key is one demand: the i18n walk', () => { |
| 78 | + it('emits each expected path at most once, for a dual-carrier action', () => { |
| 79 | + expect(repeatedPaths(collectExpectedEntries(dualCarrierConfig()))).toEqual([]); |
| 80 | + }); |
| 81 | + |
| 82 | + it('emits each expected path at most once on a config that declares nothing', () => { |
| 83 | + // Guards the registry-driven family (`metadataForms.*.fields.deleteBehavior.*`), |
| 84 | + // which is reached with no author metadata at all. |
| 85 | + expect(repeatedPaths(collectExpectedEntries({}))).toEqual([]); |
| 86 | + }); |
| 87 | + |
| 88 | + it('still emits the action keys it collapsed: de-duplication drops copies, never demands', () => { |
| 89 | + const paths = collectExpectedEntries(dualCarrierConfig()).map((e) => e.path.join('.')); |
| 90 | + for (const key of [ |
| 91 | + 'objects.lead._actions.convert_lead.label', |
| 92 | + 'objects.lead._actions.convert_lead.confirmText', |
| 93 | + 'objects.lead._actions.convert_lead.successMessage', |
| 94 | + 'objects.lead._actions.convert_lead.params.owner.label', |
| 95 | + ]) { |
| 96 | + expect(paths).toContain(key); |
| 97 | + } |
| 98 | + }); |
| 99 | + |
| 100 | + it('keeps the FIRST emission when two declarations disagree about one path', () => { |
| 101 | + // Not reachable through the normalizer today (both carriers hold one |
| 102 | + // reference), so this pins the documented rule rather than a measurement. |
| 103 | + const config: any = { |
| 104 | + objects: [{ name: 'lead', label: 'Lead', actions: [{ name: 'act', label: 'From the object' }] }], |
| 105 | + actions: [{ name: 'act', objectName: 'lead', label: 'From the top level' }], |
| 106 | + }; |
| 107 | + const entry = collectExpectedEntries(config).find( |
| 108 | + (e) => e.path.join('.') === 'objects.lead._actions.act.label', |
| 109 | + ); |
| 110 | + expect(entry?.sourceValue).toBe('From the object'); |
| 111 | + }); |
| 112 | + |
| 113 | + it('reports the number of keys it actually wrote into the skeleton', () => { |
| 114 | + // The second consumer the duplicates lied to: `setDeep` collapsed them on |
| 115 | + // the way into the bundle while `counts` kept counting emissions, so |
| 116 | + // `os i18n extract` over-reported (measured: 1632 claimed against 1531 |
| 117 | + // written, app-showcase). |
| 118 | + const result = extractTranslations(dualCarrierConfig(), { locales: ['en', 'zh-CN'] }); |
| 119 | + const leaves = (node: any): number => |
| 120 | + Object.values(node ?? {}).reduce<number>( |
| 121 | + (n, v) => n + (v !== null && typeof v === 'object' ? leaves(v) : 1), |
| 122 | + 0, |
| 123 | + ); |
| 124 | + expect(result.counts.en).toBe(leaves(result.bundles.en)); |
| 125 | + expect(result.totalExpected).toBe(leaves(result.bundles.en)); |
| 126 | + }); |
| 127 | +}); |
| 128 | + |
| 129 | +describe('one key is one demand: the coverage report', () => { |
| 130 | + it('carries no two findings with the same path for the same locale', () => { |
| 131 | + const report = computeI18nCoverage(dualCarrierConfig()); |
| 132 | + // `os lint --json` spells a finding's path exactly this way. |
| 133 | + const paths = report.issues.map((i) => `translations.${i.locale}.${i.key}`); |
| 134 | + expect(paths.length).toBeGreaterThan(0); |
| 135 | + expect(new Set(paths).size).toBe(paths.length); |
| 136 | + }); |
| 137 | + |
| 138 | + it('carries no duplicate path per locale over the platform bucket either', () => { |
| 139 | + // The `metadataForms` family is only reportable with `--include-platform`, |
| 140 | + // and it is the family a reporting-seam de-duplication could not see. |
| 141 | + const report = computeI18nCoverage( |
| 142 | + { i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] } }, |
| 143 | + { locales: ['zh-CN'] }, |
| 144 | + ); |
| 145 | + const paths = report.issues.map((i) => `translations.${i.locale}.${i.key}`); |
| 146 | + expect(paths.length).toBeGreaterThan(0); |
| 147 | + expect(new Set(paths).size).toBe(paths.length); |
| 148 | + }); |
| 149 | +}); |
0 commit comments