Skip to content

Commit 578a5dc

Browse files
committed
test(cli): pin one-key-one-demand across the walk and the coverage report
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
1 parent 8f96d3e commit 578a5dc

2 files changed

Lines changed: 152 additions & 3 deletions

File tree

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
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+
});

scripts/i18n-coverage-baseline.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
{
2-
"examples/app-crm/objectstack.config.ts": 102,
2+
"examples/app-crm/objectstack.config.ts": 101,
33
"examples/app-multi-package/objectstack.config.ts": 0,
4-
"examples/app-showcase/objectstack.config.ts": 443,
5-
"examples/app-todo/objectstack.config.ts": 146,
4+
"examples/app-showcase/objectstack.config.ts": 414,
5+
"examples/app-todo/objectstack.config.ts": 106,
66
"packages/platform-objects/scripts/i18n-extract.config.ts": 0,
77
"packages/plugins/plugin-approvals/scripts/i18n-extract.config.ts": 0,
88
"packages/plugins/plugin-audit/scripts/i18n-extract.config.ts": 0,

0 commit comments

Comments
 (0)