diff --git a/.changeset/cli-i18n-extract-bulk-validation-dataset.md b/.changeset/cli-i18n-extract-bulk-validation-dataset.md new file mode 100644 index 0000000000..0395d15c85 --- /dev/null +++ b/.changeset/cli-i18n-extract-bulk-validation-dataset.md @@ -0,0 +1,37 @@ +--- +'@objectstack/cli': minor +--- + +`os lint` and `os i18n extract` walk the three key families #14253 added — bulk +actions, validation messages and datasets — so the coverage ratchet can see them + +#14253 gave three authored display surfaces their first bundle keys and a +resolver for each. Nothing on the CLI side walked them, and that costs twice: + +1. `os i18n extract` scaffolded none of them, so a translator had to know the + keys existed and hand-write them. +2. **`check:i18n-coverage` could not see them.** That ratchet measures against + what `collectExpectedEntries` produces, so a family the walk never visits + contributes nothing to it — the number stays green while the surface it + claims to describe grows. Third instance of the same shape (#11485 after + #11287, #13109 after `translatePage` learned nested children). + +The three families, each emitted at the address its resolver reads: + +| family | keys | resolver | +| --- | --- | --- | +| bulk actions | `objects.._views..bulkActions..{label,confirmText,confirmLabel,params.

.{label,help,placeholder}}` | `translateView` → `translateBulkActionDefs` | +| validation messages | `objects.._validations..message` | the ObjectQL rule evaluator, via `objectValidationMessageKey` | +| datasets | `datasets..{label,description,dimensions..label,measures..label}` | `translateDataset` | + +Three exclusions are measured rather than assumed, because the schema declares +no slot for them and `.strict()` would reject a key: a bulk def's +`successMessage` and `description`, and per-param `options`. A bulk param's hint +is spelled `help` (an ACTION param spells the same idea `helpText`). A +`conditional` validation rule contributes no key of its own — `checkConditional` +returns the BRANCH's violation, so the wrapper's `message` never reaches a user. + +`datasets` gets its own coverage bucket, so a gap reports as +`i18n/missing-dataset` rather than folding into a neighbouring noun; bulk-action +copy reports under `view` and a rule message under `object`, the buckets whose +namespace each key lives in. diff --git a/packages/cli/src/utils/i18n-coverage.ts b/packages/cli/src/utils/i18n-coverage.ts index d94828ff0b..bdeb5c685d 100644 --- a/packages/cli/src/utils/i18n-coverage.ts +++ b/packages/cli/src/utils/i18n-coverage.ts @@ -55,6 +55,7 @@ export interface CoverageIssue { | 'navigation' | 'dashboard' | 'widget' + | 'dataset' | 'page' | 'flow' | 'metadataForm'; @@ -205,6 +206,15 @@ const COVERAGE_SOURCE: Record navigation: 'navigation', dashboard: 'dashboard', widget: 'widget', + // Analytics dataset copy (`datasets..label`, `.description`, and each + // dimension's / measure's `label`) — the author's own semantic layer, drawn + // under every metric tile and on every chart axis, so it keeps its own + // bucket and reports as `i18n/missing-dataset` rather than folding away with + // `--include-platform`. A dataset is bound BY REFERENCE from N widgets + // across M dashboards (ADR-0021 D1), which is also why it is not folded into + // the `dashboard` bucket: the string is defined once, not once per + // presentation. + dataset: 'dataset', page: 'page', // Screen-flow copy (`flows..label`, `flows..screens..title`, and // the per-field `label` / `placeholder`) — the author's own wizard text, so @@ -231,6 +241,7 @@ const SOURCE_NOUN: Record = { navigation: 'Navigation item', dashboard: 'Dashboard', widget: 'Widget', + dataset: 'Dataset', page: 'Page', flow: 'Flow', metadataForm: 'Metadata form', diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index e27c884fb1..13dc74a6d4 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -34,6 +34,13 @@ * objects.._views..label * objects.._views..description * objects.._views..emptyState.title / .message + * objects.._views..bulkActions..label / .confirmText + * / .confirmLabel + * objects.._views..bulkActions..params..label + * / .help / .placeholder + * ^ a bulk param spells its hint `help`; an ACTION param spells the same + * idea `helpText` (`ui/bulk-action.zod.ts`'s known divergence) + * objects.._validations..message * objects.._actions..label * objects.._actions..description * objects.._actions..confirmText @@ -48,6 +55,9 @@ * apps..navigation..label * dashboards..label / .description * dashboards..widgets..title / .description + * datasets..label / .description + * datasets..dimensions..label + * datasets..measures..label * pages..label / .description * pages..title / .subtitle (from the page's `page:header` component) * pages..components.. (per-component copy, #6080) @@ -125,6 +135,7 @@ export interface ExpectedEntry { | 'navigation' | 'dashboard' | 'widget' + | 'dataset' | 'page' | 'flow' | 'metadataType' @@ -274,6 +285,81 @@ function pushViewEntries(out: ExpectedEntry[], objectName: string, viewName: str pushDerived(out, [...root, 'label'], view?.label ?? viewName, inlineText(view?.label), 'view', { objectName }); pushOptional(out, [...root, 'description'], view?.description, 'view', { objectName }); pushViewEmptyState(out, root, view, objectName); + pushBulkActionDefs(out, root, view, objectName); +} + +/** + * Emit `_views..bulkActions..*` for a list view's authored + * `bulkActionDefs[]` (#14253's resolver, #14376's walk). + * + * **Why this hangs off the VIEW and not the action pass.** A `bulkActionDefs` + * entry is authored inside the view and is not an action document, so it never + * reaches `translateAction` and no other pass here would ever see it. That is + * the same reason `translateView` — not `translateAction` — is where the + * resolver overlays it, and the reason `ObjectTranslationDataSchema` puts the + * group under `_views.` rather than beside `_actions`. + * + * **The view key is the caller's, deliberately.** `translateBulkActionDefs` is + * called by `translateView` with `viewTranslationKey(view, objectName)` — the + * bare `_views` key — so emitting under the same `root` this function already + * built for `label` / `description` keeps the two halves keyed by construction + * rather than by a second derivation (the #5164 lesson one surface over). + * + * **Read from the AUTHORED address.** The resolver reads + * `config.bulkActionDefs` because a SERVED `ViewItem` nests the whole + * `ListViewSchema` under `config`; this walker is handed the authored stack + * config, where the defs sit on the list view itself — the same authored + * addresses the rest of this file reads (`view.list.data.object`, + * `obj.listViews`). Accepting the served spelling here as well would be a + * tolerant alias for a shape this walk is never given. + * + * Three deliberate exclusions, each measured against `BulkActionDefSchema` + * rather than mirrored from the report: + * + * - `successMessage` — a def declares none (the run reports a per-record + * outcome summary the console words from its own catalog); + * - `description` — a def declares none either; the sentence above the + * affected-record summary IS `confirmText`; + * - per-param `options` — `BulkActionParamTranslationSchema` carries + * `guidance` against them instead of a key, so scaffolding them would + * write keys `.strict()` then rejects. + * + * ⚠️ `help`, not `helpText`. A bulk param spells its hint `help` + * (`BulkActionParamSchema.help`) where an ACTION param spells it `helpText` — + * the known divergence `ui/bulk-action.zod.ts` names, and the one spelling the + * translation face declares. + */ +function pushBulkActionDefs(out: ExpectedEntry[], viewRoot: string[], view: any, objectName: string): void { + const defs = view?.bulkActionDefs; + if (!Array.isArray(defs)) return; + for (const def of defs) { + if (!def || typeof def !== 'object') continue; + const defName = def.name; + if (typeof defName !== 'string' || defName.length === 0) continue; + const base = [...viewRoot, 'bulkActions', defName]; + // The selection bar renders `def.label ?? formatActionLabel(def.name)` + // (objectui `BulkActionBar.tsx`), so the humanized name is what a reader + // actually sees when the author omitted a label — a usable seed, with + // `inline` left unset so coverage never demands a translation of a string + // nobody wrote. + const authoredLabel = inlineText(def.label); + pushDerived(out, [...base, 'label'], authoredLabel ?? humanizeFieldPath(defName), authoredLabel, 'view', { objectName }); + pushOptional(out, [...base, 'confirmText'], def.confirmText, 'view', { objectName }); + pushOptional(out, [...base, 'confirmLabel'], def.confirmLabel, 'view', { objectName }); + if (!Array.isArray(def.params)) continue; + for (const param of def.params) { + if (!param || typeof param !== 'object') continue; + const pname = param.name; + if (typeof pname !== 'string' || pname.length === 0) continue; + const pbase = [...base, 'params', pname]; + // The dialog renders `param.label ?? param.name` — the bare name, the + // same fallback `pushActionParams` seeds an inline action param from. + const literalLabel = inlineText(param.label); + pushDerived(out, [...pbase, 'label'], literalLabel ?? pname, literalLabel, 'view', { objectName }); + pushOptional(out, [...pbase, 'help'], param.help, 'view', { objectName }); + pushOptional(out, [...pbase, 'placeholder'], param.placeholder, 'view', { objectName }); + } + } } /** @@ -424,6 +510,65 @@ function pushActionResultDialog( } } +/** + * How deep a `conditional` chain is followed. `ValidationRuleSchema` is + * recursive with no declared bound, and this walker is handed hand-authored + * TypeScript — a shared branch object appearing under its own ancestor would + * otherwise loop forever. Real nesting is two or three deep (the schema's own + * worked examples stop at two). + */ +const MAX_VALIDATION_DEPTH = 10; + +/** + * Emit `objects.._validations..message` for an object's custom + * validation rules (#14253's resolver, #14376's walk). + * + * `object.validations[].message` is the sentence a rejected write returns, and + * the ObjectQL rule evaluator now resolves it through the engine's existing + * `i18nService` channel at exactly this address + * (`objectValidationMessageKey`, `spec/system/i18n-resolver.ts`). Without this + * pass the address has a reader and a schema slot but nothing writes the + * skeleton, so a deployment gets platform-generated refusals in the caller's + * language and author-written ones in the source language, side by side in one + * error envelope. + * + * **A `conditional` wrapper contributes no key of its own.** `checkConditional` + * evaluates `when` and then returns `evaluateRule(branch, …)` — the BRANCH + * supplies the violation, so the wrapper's own `message` never reaches a user. + * Scaffolding it would offer a translator a string no rejected write can ever + * show. The branches carry their own `name` and are addressed by it, which is + * what both the resolver's JSDoc and `_validations`' schema note state. + * + * **`active: false` is not a reason to skip a rule.** It is a toggle on a + * surface that exists, not the absence of one, and no other family in this + * walker consults a runtime toggle — this walk reports what a config + * DECLARES. Flipping the toggle back on must not silently owe a translation. + */ +function pushValidationMessages( + out: ExpectedEntry[], + objectName: string, + rules: unknown, + depth: number, +): void { + if (!Array.isArray(rules) || depth >= MAX_VALIDATION_DEPTH) return; + for (const rule of rules) { + if (!rule || typeof rule !== 'object') continue; + const ruleName = (rule as any).name; + if (typeof ruleName !== 'string' || ruleName.length === 0) continue; + if ((rule as any).type === 'conditional') { + pushValidationMessages(out, objectName, [(rule as any).then, (rule as any).otherwise], depth + 1); + continue; + } + pushEntry( + out, + ['objects', objectName, '_validations', ruleName, 'message'], + inlineText((rule as any).message), + 'object', + { objectName }, + ); + } +} + // ─── Object sections (`objects.._sections.
.label`) ───────── // // A section heading is authored in TWO independent places and rendered from @@ -862,6 +1007,9 @@ export function collectExpectedEntries( pushActionResultDialog(out, ['objects', objectName, '_actions', aname], action, 'action', objectName); } } + + // Custom validation-rule rejection messages (`_validations..message`). + pushValidationMessages(out, objectName, obj.validations, 0); } // ── Top-level views ────────────────────────────────────────────── @@ -974,6 +1122,9 @@ export function collectExpectedEntries( } } + // ── Analytics datasets (`datasets..…`) ───────────────────── + walkDatasets(config, out); + // ── Pages + their `page:header` copy ────────────────────────────── const pages: any[] = Array.isArray(config?.pages) ? config.pages : []; for (const page of pages) { @@ -1041,6 +1192,63 @@ export function collectExpectedEntries( return out.filter((entry) => !warnedGroups.has(entry.path[0])); } +// ─── Analytics datasets (`datasets..…`) ────────────────────────── + +/** + * Emit the dataset copy surface (#14253's resolver, #14376's walk): + * + * datasets..label + * datasets..description + * datasets..dimensions..label + * datasets..measures..label + * + * **Why a dataset is a display surface at all.** It reads like a back-office + * definition, but a measure label is drawn ON THE DASHBOARD — under every + * metric tile and on every chart axis. `translateDataset` is registered in + * `METADATA_DOCUMENT_TRANSLATORS`, so a served dataset is already localized at + * the REST boundary; this pass is the half that writes the skeleton. + * + * **Top level, not under `dashboards`.** A dataset is the one definition every + * presentation binds to BY REFERENCE (ADR-0021 D1): the same measure is drawn + * by N widgets across M dashboards, so addressing it under a dashboard would + * ask for the same string once per presentation and leave a dataset no + * dashboard references unaddressable. + * + * **`pushOptional`, not `pushDerived`, for every key here.** These four are + * `I18nLabelSchema` at the authoring site, so a value may already be an inline + * `{ en, 'zh-CN' }` map (#5728) — not source text to scaffold from, and + * `inlineText` narrows it away. And no renderer fallback is measured for a + * member that declares no `label` at all, so there is no reader-visible string + * to seed one from: recording the key without an `inline` keeps the coverage + * gate quiet about a string nobody wrote while still noticing a bundle that + * authors it. It is the same posture the resolver takes — `translateDataset` + * writes only where the bundle answered. + * + * The face stops at `label` below the dataset: `DatasetDimensionSchema` and + * `DatasetMeasureSchema` declare no `description` and say so in their own + * authoring guidance, so a `dimensions..description` key would parse clean + * and translate nothing. + */ +function walkDatasets(config: any, out: ExpectedEntry[]): void { + const datasets: any[] = Array.isArray(config?.datasets) ? config.datasets : []; + for (const dataset of datasets) { + if (!dataset || typeof dataset !== 'object') continue; + const name = dataset.name; + if (typeof name !== 'string' || name.length === 0) continue; + pushOptional(out, ['datasets', name, 'label'], dataset.label, 'dataset'); + pushOptional(out, ['datasets', name, 'description'], dataset.description, 'dataset'); + for (const group of ['dimensions', 'measures'] as const) { + const members: any[] = Array.isArray(dataset[group]) ? dataset[group] : []; + for (const member of members) { + if (!member || typeof member !== 'object') continue; + const memberName = member.name; + if (typeof memberName !== 'string' || memberName.length === 0) continue; + pushOptional(out, ['datasets', name, group, memberName, 'label'], member.label, 'dataset'); + } + } + } +} + // ─── Screen flows (`flows..screens..…`) ───────────────── /** diff --git a/packages/cli/test/i18n-bulk-action-coverage.test.ts b/packages/cli/test/i18n-bulk-action-coverage.test.ts new file mode 100644 index 0000000000..3db1a21c65 --- /dev/null +++ b/packages/cli/test/i18n-bulk-action-coverage.test.ts @@ -0,0 +1,274 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// objectstack#14376, family 1 of 3 — a list view's `bulkActionDefs[]`. +// +// #14253 gave the selection bar its first bundle address: `translateView` +// overlays `objects.._views..bulkActions..*` onto the authored defs, +// and `ObjectTranslationDataSchema._views` declares the slot. The EXTRACTOR did +// not walk it, which costs twice over — `os i18n extract` scaffolds nothing, and +// `check:i18n-coverage` measures against what this walk produces, so the family +// contributed nothing to the ratchet at all. This file pins the walk. +// +// A def is authored INSIDE the view and is not an action document, so no other +// pass in the extractor would ever reach it — the same reason the resolver +// overlays it in `translateView` rather than `translateAction`. +// +// ⚠️ `help`, not `helpText`: a bulk param spells its hint `help` +// (`BulkActionParamSchema`), an action param spells the same idea `helpText`. +// The translation face follows the authored key, so a walk that emitted +// `helpText` here would scaffold keys `.strict()` rejects. + +import { describe, it, expect } from 'vitest'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract.js'; +import { computeI18nCoverage } from '../src/utils/i18n-coverage.js'; +import { ObjectTranslationDataSchema } from '@objectstack/spec/system'; + +/** Every `…_views..bulkActions.*` path the walker emits, as dot-paths. */ +const bulkKeys = (config: any): string[] => + collectExpectedEntries(config) + .filter((e) => e.path.includes('bulkActions')) + .map((e) => e.path.join('.')); + +const bulkEntries = (config: any) => + collectExpectedEntries(config).filter((e) => e.path.includes('bulkActions')); + +/** The `examples/app-showcase` project view shape: defs with params. */ +const defs = (): any[] => [ + { + name: 'set_labels', + label: 'Set Labels', + operation: 'update', + confirmText: 'Set these labels on every selected project?', + confirmLabel: 'Apply labels', + params: [ + { + name: 'labels', + label: 'Labels', + help: 'Applied to every selected project', + placeholder: 'Pick one or more', + type: 'select', + multiple: true, + options: [ + { label: 'Frontend', value: 'frontend' }, + { label: 'Backend', value: 'backend' }, + ], + }, + ], + }, + // A def with no authored copy at all — the bar renders its humanized name. + { name: 'archive_selected', operation: 'update' }, +]; + +/** An object-nested list view (the `project.object.ts` shape). */ +const objectConfig = (overrides: Record = {}) => ({ + objects: [ + { + name: 'showcase_project', + label: 'Project', + listViews: { + all: { label: 'All Projects', type: 'grid', bulkActionDefs: defs(), ...overrides }, + }, + }, + ], +}); + +describe('the extractor scaffolds a key for every string the selection bar draws', () => { + it('emits `objects.._views..bulkActions..*`', () => { + // `confirmText` / `confirmLabel` are recorded for BOTH defs even though + // only one authors them: `pushOptional` records an optional key with no + // seed and no `inline`, exactly as `_views..description` does one level + // up, so the coverage gate can still notice a bundle that authors it. + expect(bulkKeys(objectConfig()).sort()).toEqual([ + 'objects.showcase_project._views.all.bulkActions.archive_selected.confirmLabel', + 'objects.showcase_project._views.all.bulkActions.archive_selected.confirmText', + 'objects.showcase_project._views.all.bulkActions.archive_selected.label', + 'objects.showcase_project._views.all.bulkActions.set_labels.confirmLabel', + 'objects.showcase_project._views.all.bulkActions.set_labels.confirmText', + 'objects.showcase_project._views.all.bulkActions.set_labels.label', + 'objects.showcase_project._views.all.bulkActions.set_labels.params.labels.help', + 'objects.showcase_project._views.all.bulkActions.set_labels.params.labels.label', + 'objects.showcase_project._views.all.bulkActions.set_labels.params.labels.placeholder', + ]); + }); + + it('seeds each entry with the authored literal', () => { + const entries = bulkEntries(objectConfig()); + const label = entries.find((e) => e.path.at(-1) === 'label' && e.path.includes('set_labels')); + expect(label?.sourceValue).toBe('Set Labels'); + expect(label?.inline).toBe('Set Labels'); + expect(label?.objectName).toBe('showcase_project'); + + const confirm = entries.find((e) => e.path.at(-1) === 'confirmText'); + expect(confirm?.inline).toBe('Set these labels on every selected project?'); + }); + + it('records an unauthored optional key without seeding it', () => { + // The other half of the contract above: the key exists so a bundle that + // authors it is recognised, but nothing is scaffolded and coverage demands + // nothing — there is no source string to translate. + const confirm = bulkEntries(objectConfig()) + .find((e) => e.path.join('.').endsWith('archive_selected.confirmText')); + expect(confirm).toBeDefined(); + expect(confirm?.sourceValue).toBeUndefined(); + expect(confirm?.inline).toBeUndefined(); + }); + + it('falls back to the humanized def name, and leaves `inline` unset', () => { + // `BulkActionBar` renders `def.label ?? formatActionLabel(def.name)`, so the + // humanized name is what a reader sees — a usable seed. Coverage must not + // demand a translation of a string nobody authored. + const archive = bulkEntries(objectConfig()).find((e) => e.path.includes('archive_selected')); + expect(archive?.sourceValue).toBe('Archive Selected'); + expect(archive?.inline).toBeUndefined(); + }); + + it('falls back to the bare param name for a param with no label', () => { + // The dialog renders `param.label ?? param.name` — the bare name, exactly + // the fallback an inline ACTION param is seeded from. + const config = objectConfig(); + delete config.objects[0].listViews.all.bulkActionDefs[0].params[0].label; + const param = bulkEntries(config).find((e) => e.path.at(-1) === 'label' && e.path.includes('params')); + expect(param?.sourceValue).toBe('labels'); + expect(param?.inline).toBeUndefined(); + }); + + it('spells a param hint `help`, never `helpText`', () => { + const keys = bulkKeys(objectConfig()); + expect(keys).toContain('objects.showcase_project._views.all.bulkActions.set_labels.params.labels.help'); + expect(keys.some((k) => k.endsWith('.helpText'))).toBe(false); + }); + + it('does not scaffold the three keys the def surface deliberately excludes', () => { + // Measured against `BulkActionDefSchema`, not mirrored from the report: + // a def declares no `successMessage` and no `description`, and the + // translation face carries `guidance` against per-param `options` instead + // of a key. Emitting any of them would write keys `.strict()` rejects. + const config = objectConfig(); + config.objects[0].listViews.all.bulkActionDefs[0].successMessage = 'Done'; + config.objects[0].listViews.all.bulkActionDefs[0].description = 'Sets labels'; + const keys = bulkKeys(config); + expect(keys.some((k) => k.includes('.successMessage'))).toBe(false); + expect(keys.some((k) => k.endsWith('.bulkActions.set_labels.description'))).toBe(false); + expect(keys.some((k) => k.includes('.params.labels.options'))).toBe(false); + }); + + it('emits nothing for a view that declares no defs', () => { + expect(bulkKeys({ objects: [{ name: 'showcase_project', listViews: { all: { label: 'All' } } }] })).toEqual([]); + }); + + it('skips a def with no `name` — the key it would be addressed by', () => { + const config = objectConfig(); + config.objects[0].listViews.all.bulkActionDefs = [{ label: 'Nameless', operation: 'update' }]; + expect(bulkKeys(config)).toEqual([]); + }); +}); + +describe('the def keys share the view key `label` is emitted under', () => { + it('a container-authored list view keys both under the runtime view identity', () => { + // `translateBulkActionDefs` is called by `translateView` with + // `viewTranslationKey(view, objectName)` — the same bare `_views` key the + // view's own label resolves under. Deriving it twice is the #5164 defect + // one surface over, so the walk emits both under one root. + const container = { + views: [ + { + list: { data: { object: 'showcase_project' }, type: 'grid', bulkActionDefs: defs() }, + listViews: {}, + }, + ], + }; + const paths = collectExpectedEntries(container) + .filter((e) => e.path[1] === 'showcase_project' && e.path[2] === '_views') + .map((e) => e.path.join('.')); + + const viewKeys = new Set(paths.map((p) => p.split('.')[3])); + expect(viewKeys.size).toBe(1); + expect(paths).toContain(`objects.showcase_project._views.${[...viewKeys][0]}.label`); + expect(paths).toContain( + `objects.showcase_project._views.${[...viewKeys][0]}.bulkActions.set_labels.label`, + ); + }); +}); + +describe('the emitted key is the key the schema declares', () => { + it('`ObjectTranslationDataSchema` accepts a bundle written at the extracted paths', () => { + // Extractor and schema agreeing is the whole contract: a key `os i18n + // extract` writes that `.strict()` then rejects is worse than no key. + expect(() => + ObjectTranslationDataSchema.parse({ + _views: { + all: { + bulkActions: { + set_labels: { + label: '设置标签', + confirmText: '确认?', + confirmLabel: '应用', + params: { labels: { label: '标签', help: '帮助', placeholder: '选择' } }, + }, + }, + }, + }, + }), + ).not.toThrow(); + }); + + it('rejects the neighbouring `helpText` spelling — the slot did not go open', () => { + expect(() => + ObjectTranslationDataSchema.parse({ + _views: { all: { bulkActions: { set_labels: { params: { labels: { helpText: 'x' } } } } } }, + }), + ).toThrow(); + }); +}); + +describe('coverage', () => { + const config = () => ({ + ...objectConfig(), + i18n: { supportedLocales: ['en', 'zh-CN'] }, + translations: [ + { + 'zh-CN': { + objects: { + showcase_project: { + _views: { all: { bulkActions: { set_labels: { label: '设置标签' } } } }, + }, + }, + }, + }, + ], + }); + + const bulkIssues = (report: { issues: Array<{ key: string; source: string }> }) => + report.issues.filter((i) => i.key.includes('.bulkActions.')); + + it('reports the untranslated def copy and stays quiet about the translated label', () => { + const keys = bulkIssues(computeI18nCoverage(config())).map((i) => i.key); + expect(keys).not.toContain('objects.showcase_project._views.all.bulkActions.set_labels.label'); + expect(keys).toContain('objects.showcase_project._views.all.bulkActions.set_labels.confirmText'); + }); + + it('files the finding under the view bucket', () => { + // A bulk def is view copy: it lives under `_views.` in the schema and is + // overlaid by `translateView`, so it reports as `i18n/missing-view`. + expect(bulkIssues(computeI18nCoverage(config())).every((i) => i.source === 'view')).toBe(true); + }); + + it('demands nothing for the def nobody wrote copy for', () => { + // `archive_selected` has a derived seed and no `inline` — there is no + // source string to translate, so it is `required/label`'s business if any. + expect(bulkIssues(computeI18nCoverage(config())).some((i) => i.key.includes('archive_selected'))) + .toBe(false); + }); + + it('a project that declares no locales still reports nothing', () => { + expect(bulkIssues(computeI18nCoverage(objectConfig()))).toEqual([]); + }); + + it('`extractTranslations` writes the def keys into the skeleton', () => { + const out = extractTranslations(objectConfig(), { locales: ['zh-CN'] }); + const zh = out.bundles['zh-CN'] as any; + expect(zh?.objects?.showcase_project?._views?.all?.bulkActions?.set_labels).toBeDefined(); + expect(zh?.objects?.showcase_project?._views?.all?.bulkActions?.set_labels?.params?.labels) + .toBeDefined(); + }); +}); diff --git a/packages/cli/test/i18n-dataset-coverage.test.ts b/packages/cli/test/i18n-dataset-coverage.test.ts new file mode 100644 index 0000000000..de9cf099e2 --- /dev/null +++ b/packages/cli/test/i18n-dataset-coverage.test.ts @@ -0,0 +1,193 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// objectstack#14376, family 3 of 3 — `datasets..…`. +// +// #14253 registered `translateDataset` in `METADATA_DOCUMENT_TRANSLATORS` (which +// is what `TRANSLATABLE_METADATA_TYPES` is derived from, so the REST boundary +// followed with nothing to remember) and declared the `datasets` group. The +// EXTRACTOR did not walk it, so `os i18n extract` scaffolded nothing and +// `check:i18n-coverage` — which measures against this walk — was blind to the +// family. +// +// A dataset reads like a back-office definition, but a measure label is drawn ON +// THE DASHBOARD, under every metric tile and on every chart axis: the #14253 +// report is a dashboard rendering Chinese tile titles with `Untouched > 14 days` +// directly beneath them. +// +// Top level, not under `dashboards`: a dataset is the ONE definition every +// presentation binds to by reference (ADR-0021 D1), so the same measure is drawn +// by N widgets across M dashboards and a dataset no dashboard references would +// otherwise be unaddressable. + +import { describe, it, expect } from 'vitest'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract.js'; +import { computeI18nCoverage } from '../src/utils/i18n-coverage.js'; +import { TranslationDataSchema } from '@objectstack/spec/system'; + +/** Every `datasets.*` path the walker emits, as dot-paths. */ +const datasetKeys = (config: any): string[] => + collectExpectedEntries(config) + .filter((e) => e.path[0] === 'datasets') + .map((e) => e.path.join('.')); + +const datasetEntries = (config: any) => + collectExpectedEntries(config).filter((e) => e.path[0] === 'datasets'); + +/** The `examples/app-todo` dataset shape, trimmed to two members per group. */ +const config = (overrides: Record = {}) => ({ + datasets: [ + { + name: 'task_metrics', + label: 'Task Metrics', + description: 'Semantic layer for task counts and time-tracking measures', + object: 'todo_task', + dimensions: [ + { name: 'status', label: 'Status', field: 'status', type: 'string' }, + { name: 'due_date', label: 'Due Date', field: 'due_date', type: 'date' }, + ], + measures: [ + { name: 'task_count', label: 'Tasks', aggregate: 'count' }, + { name: 'est_hours', label: 'Estimated Hours', aggregate: 'sum', field: 'estimated_hours' }, + ], + ...overrides, + }, + ], +}); + +describe('the extractor scaffolds a key for every string a dataset draws', () => { + it('emits label, description and one `label` per dimension and measure', () => { + expect(datasetKeys(config()).sort()).toEqual([ + 'datasets.task_metrics.description', + 'datasets.task_metrics.dimensions.due_date.label', + 'datasets.task_metrics.dimensions.status.label', + 'datasets.task_metrics.label', + 'datasets.task_metrics.measures.est_hours.label', + 'datasets.task_metrics.measures.task_count.label', + ]); + }); + + it('seeds each entry with the authored literal', () => { + const entries = datasetEntries(config()); + const measure = entries.find((e) => e.path.join('.') === 'datasets.task_metrics.measures.task_count.label'); + expect(measure?.sourceValue).toBe('Tasks'); + expect(measure?.inline).toBe('Tasks'); + expect(measure?.source).toBe('dataset'); + }); + + it('records the key but seeds nothing for a member with no label', () => { + // No renderer fallback is measured for an unlabelled member, so there is no + // reader-visible string to seed one from. Recording the key still lets the + // coverage gate notice a bundle that authors it. + const entries = datasetEntries( + config({ measures: [{ name: 'task_count', aggregate: 'count' }] }), + ); + const measure = entries.find((e) => e.path.includes('task_count')); + expect(measure).toBeDefined(); + expect(measure?.sourceValue).toBeUndefined(); + expect(measure?.inline).toBeUndefined(); + }); + + it('treats an inline locale map as already multilingual (#5728)', () => { + // `Dataset.label` and each member's `label` are `I18nLabelSchema`, so an + // author may have written the map form. It is not plain source text: there + // is nothing to scaffold and nothing to demand, and `translateDataset` + // leaves such a value intact rather than flattening it to one language. + const entries = datasetEntries(config({ label: { en: 'Task Metrics', 'zh-CN': '任务指标' } })); + const label = entries.find((e) => e.path.join('.') === 'datasets.task_metrics.label'); + expect(label).toBeDefined(); + expect(label?.inline).toBeUndefined(); + }); + + it('emits no `description` below the dataset', () => { + // `DatasetDimensionSchema` / `DatasetMeasureSchema` declare none and say so + // in their own authoring guidance — a `dimensions..description` key + // would parse clean and translate nothing. + const keys = datasetKeys( + config({ dimensions: [{ name: 'status', label: 'Status', description: 'ignored' }] }), + ); + expect(keys.some((k) => k.startsWith('datasets.task_metrics.dimensions.') && k.endsWith('.description'))) + .toBe(false); + expect(keys).toContain('datasets.task_metrics.description'); + }); + + it('skips a dataset or member with no `name` — the key it would be addressed by', () => { + expect(datasetKeys({ datasets: [{ label: 'Nameless' }] })).toEqual([]); + expect(datasetKeys(config({ measures: [{ label: 'Nameless' }] })).some((k) => k.includes('.measures.'))) + .toBe(false); + }); + + it('emits nothing for a stack with no datasets', () => { + expect(datasetKeys({ objects: [{ name: 'todo_task' }] })).toEqual([]); + }); +}); + +describe('the emitted key is the key the schema declares', () => { + it('`TranslationDataSchema` accepts a bundle written at the extracted paths', () => { + expect(() => + TranslationDataSchema.parse({ + datasets: { + task_metrics: { + label: '任务指标', + description: '任务计数与工时的语义层', + dimensions: { status: { label: '状态' } }, + measures: { task_count: { label: '任务数' } }, + }, + }, + }), + ).not.toThrow(); + }); + + it('a member `description` is still rejected — the slot did not go open', () => { + expect(() => + TranslationDataSchema.parse({ + datasets: { task_metrics: { measures: { task_count: { label: '任务数', description: 'x' } } } }, + }), + ).toThrow(); + }); +}); + +describe('coverage', () => { + const withLocales = () => ({ + ...config(), + i18n: { supportedLocales: ['en', 'zh-CN'] }, + translations: [ + { 'zh-CN': { datasets: { task_metrics: { measures: { task_count: { label: '任务数' } } } } } }, + ], + }); + + const datasetIssues = (report: { issues: Array<{ key: string; source: string; message: string }> }) => + report.issues.filter((i) => i.key.startsWith('datasets.')); + + it('reports the untranslated measure and stays quiet about the translated one', () => { + const keys = datasetIssues(computeI18nCoverage(withLocales())).map((i) => i.key); + expect(keys).not.toContain('datasets.task_metrics.measures.task_count.label'); + expect(keys).toContain('datasets.task_metrics.measures.est_hours.label'); + expect(keys).toContain('datasets.task_metrics.dimensions.status.label'); + }); + + it('files the finding under its own bucket, so it reports as `i18n/missing-dataset`', () => { + const issues = datasetIssues(computeI18nCoverage(withLocales())); + expect(issues.length).toBeGreaterThan(0); + expect(issues.every((i) => i.source === 'dataset')).toBe(true); + expect(issues[0].message.startsWith('Dataset datasets.task_metrics.')).toBe(true); + }); + + it('demands nothing for a member label nobody wrote', () => { + const report = computeI18nCoverage({ + ...config({ measures: [{ name: 'task_count', aggregate: 'count' }] }), + i18n: { supportedLocales: ['en', 'zh-CN'] }, + }); + expect(datasetIssues(report).some((i) => i.key.includes('task_count'))).toBe(false); + }); + + it('a project that declares no locales still reports nothing', () => { + expect(datasetIssues(computeI18nCoverage(config()))).toEqual([]); + }); + + it('`extractTranslations` writes the dataset keys into the skeleton', () => { + const out = extractTranslations(config(), { locales: ['zh-CN'] }); + const zh = out.bundles['zh-CN'] as any; + expect(zh?.datasets?.task_metrics?.label).toBeDefined(); + expect(zh?.datasets?.task_metrics?.measures?.task_count?.label).toBeDefined(); + }); +}); diff --git a/packages/cli/test/i18n-validation-message-coverage.test.ts b/packages/cli/test/i18n-validation-message-coverage.test.ts new file mode 100644 index 0000000000..e52c3efd9e --- /dev/null +++ b/packages/cli/test/i18n-validation-message-coverage.test.ts @@ -0,0 +1,218 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// objectstack#14376, family 2 of 3 — `objects.._validations..message`. +// +// #14253 gave an author-written `validations[].message` its first bundle +// address: `objectValidationMessageKey` spells it, and the ObjectQL rule +// evaluator resolves it through the engine's EXISTING `i18nService` channel, so +// a rejected write returns the author's sentence in the caller's language the +// way the built-in field catalog already does. The EXTRACTOR did not walk it, so +// `os i18n extract` scaffolded nothing and `check:i18n-coverage` — which +// measures against this walk — could not see the family at all. +// +// The shape that decides this file: a `conditional` rule contributes NO key of +// its own. `checkConditional` evaluates `when` and returns the BRANCH's +// violation, so the wrapper's `message` never reaches a user; the branches carry +// their own `name` and are addressed by it. + +import { describe, it, expect } from 'vitest'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract.js'; +import { computeI18nCoverage } from '../src/utils/i18n-coverage.js'; +import { ObjectTranslationDataSchema } from '@objectstack/spec/system'; + +/** Every `objects.._validations.*` path the walker emits, as dot-paths. */ +const ruleKeys = (config: any): string[] => + collectExpectedEntries(config) + .filter((e) => e.path[2] === '_validations') + .map((e) => e.path.join('.')); + +const ruleEntries = (config: any) => + collectExpectedEntries(config).filter((e) => e.path[2] === '_validations'); + +/** The `examples/app-crm` opportunity shape: a script rule with admin prose. */ +const objectConfig = (validations?: unknown[]) => ({ + objects: [ + { + name: 'crm_opportunity', + label: 'Opportunity', + validations: validations ?? [ + { + type: 'script', + name: 'discount_cap', + label: 'Discount Cap 40%', + description: 'Discounts over 40% require special approval.', + condition: 'record.discount_percent > 40', + message: 'Discount cannot exceed 40% without an approved exception.', + severity: 'error', + }, + { + type: 'cross_field', + name: 'opp_close_date_not_past', + fields: ['close_date'], + condition: 'record.close_date < now()', + message: 'Close Date must be today or a future date.', + }, + ], + }, + ], +}); + +describe('the extractor scaffolds a key for every authored rejection sentence', () => { + it('emits `objects.._validations..message`', () => { + expect(ruleKeys(objectConfig()).sort()).toEqual([ + 'objects.crm_opportunity._validations.discount_cap.message', + 'objects.crm_opportunity._validations.opp_close_date_not_past.message', + ]); + }); + + it('seeds the entry with the authored sentence', () => { + const entry = ruleEntries(objectConfig()).find((e) => e.path[3] === 'discount_cap'); + expect(entry?.sourceValue).toBe('Discount cannot exceed 40% without an approved exception.'); + expect(entry?.inline).toBe('Discount cannot exceed 40% without an approved exception.'); + expect(entry?.objectName).toBe('crm_opportunity'); + }); + + it('emits `message` and nothing else — `label` and `description` are not user copy', () => { + // A rule's `label` is its entry in the admin rule listing and `description` + // is the maintainer's note; neither reaches a rejected caller, so a key for + // either would parse clean and translate nothing. The schema carries + // `guidance` against both rather than a slot. + const keys = ruleKeys(objectConfig()); + expect(keys.every((k) => k.endsWith('.message'))).toBe(true); + }); + + it('addresses a conditional rule by its BRANCH, never by the wrapper', () => { + // `checkConditional` returns `evaluateRule(branch, …)` — the branch supplies + // the violation the caller sees. Scaffolding the wrapper's own `message` + // would offer a translator a string no rejected write can show. + const keys = ruleKeys( + objectConfig([ + { + type: 'conditional', + name: 'enterprise_approval_required', + when: 'record.tier = "enterprise"', + message: 'Enterprise validation', + then: { + type: 'script', + name: 'require_approval', + message: 'Enterprise accounts require manager approval', + }, + otherwise: { + type: 'script', + name: 'standard_approval', + message: 'Standard accounts need a reviewer', + }, + }, + ]), + ); + expect(keys.sort()).toEqual([ + 'objects.crm_opportunity._validations.require_approval.message', + 'objects.crm_opportunity._validations.standard_approval.message', + ]); + }); + + it('follows a nested conditional down to the branch that speaks', () => { + const keys = ruleKeys( + objectConfig([ + { + type: 'conditional', + name: 'country_state_validation', + when: 'record.country = "US"', + message: 'US-specific validation', + then: { + type: 'conditional', + name: 'california_validation', + when: 'record.state = "CA"', + message: 'California-specific validation', + then: { + type: 'script', + name: 'ca_tax_id_required', + message: 'California requires a valid tax ID', + }, + }, + }, + ]), + ); + expect(keys).toEqual(['objects.crm_opportunity._validations.ca_tax_id_required.message']); + }); + + it('still emits for a rule switched off', () => { + // `active: false` is a toggle on a surface that exists, not the absence of + // one, and no other family in this walker consults a runtime toggle. + // Flipping it back on must not silently owe a translation. + const keys = ruleKeys( + objectConfig([ + { type: 'script', name: 'paused_rule', active: false, message: 'Paused but declared.' }, + ]), + ); + expect(keys).toEqual(['objects.crm_opportunity._validations.paused_rule.message']); + }); + + it('skips a rule with no `name` — the key it would be addressed by', () => { + expect(ruleKeys(objectConfig([{ type: 'script', message: 'Nameless.' }]))).toEqual([]); + }); + + it('emits nothing for an object that declares no validations', () => { + expect(ruleKeys({ objects: [{ name: 'crm_opportunity', label: 'Opportunity' }] })).toEqual([]); + }); +}); + +describe('the emitted key is the key the schema declares', () => { + it('`ObjectTranslationDataSchema` accepts a bundle written at the extracted path', () => { + const keys = ruleKeys(objectConfig()); + expect(keys.length).toBeGreaterThan(0); + const data: Record = {}; + for (const key of keys) data[key.split('.')[3]] = { message: '折扣不能超过 40%。' }; + + expect(() => ObjectTranslationDataSchema.parse({ _validations: data })).not.toThrow(); + }); + + it('a rule `label` is still rejected there — the slot did not go open', () => { + expect(() => + ObjectTranslationDataSchema.parse({ + _validations: { discount_cap: { message: 'ok', label: 'Discount Cap' } }, + }), + ).toThrow(); + }); +}); + +describe('coverage', () => { + const config = () => ({ + ...objectConfig(), + i18n: { supportedLocales: ['en', 'zh-CN'] }, + translations: [ + { + 'zh-CN': { + objects: { + crm_opportunity: { _validations: { discount_cap: { message: '折扣不能超过 40%。' } } }, + }, + }, + }, + ], + }); + + const ruleIssues = (report: { issues: Array<{ key: string; source: string }> }) => + report.issues.filter((i) => i.key.includes('._validations.')); + + it('reports the untranslated rule and stays quiet about the translated one', () => { + expect(ruleIssues(computeI18nCoverage(config())).map((i) => i.key)).toEqual([ + 'objects.crm_opportunity._validations.opp_close_date_not_past.message', + ]); + }); + + it('files the finding under the object bucket', () => { + // The address is object-scoped (`objects.._validations.…`), beside + // `_views` / `_actions` / `_tabs`, so it reports as `i18n/missing-object`. + expect(ruleIssues(computeI18nCoverage(config())).every((i) => i.source === 'object')).toBe(true); + }); + + it('a project that declares no locales still reports nothing', () => { + expect(ruleIssues(computeI18nCoverage(objectConfig()))).toEqual([]); + }); + + it('`extractTranslations` writes the rule keys into the skeleton', () => { + const out = extractTranslations(objectConfig(), { locales: ['zh-CN'] }); + const zh = out.bundles['zh-CN'] as any; + expect(zh?.objects?.crm_opportunity?._validations?.discount_cap?.message).toBeDefined(); + }); +}); diff --git a/scripts/i18n-coverage-baseline.json b/scripts/i18n-coverage-baseline.json index 285cc3e550..6365dbb7ea 100644 --- a/scripts/i18n-coverage-baseline.json +++ b/scripts/i18n-coverage-baseline.json @@ -1,8 +1,8 @@ { - "examples/app-crm/objectstack.config.ts": 89, + "examples/app-crm/objectstack.config.ts": 102, "examples/app-multi-package/objectstack.config.ts": 0, - "examples/app-showcase/objectstack.config.ts": 393, - "examples/app-todo/objectstack.config.ts": 120, + "examples/app-showcase/objectstack.config.ts": 443, + "examples/app-todo/objectstack.config.ts": 146, "packages/platform-objects/scripts/i18n-extract.config.ts": 0, "packages/plugins/plugin-approvals/scripts/i18n-extract.config.ts": 0, "packages/plugins/plugin-audit/scripts/i18n-extract.config.ts": 0,