Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/showcase-authored-validation-message-i18n.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
---
"@objectstack/example-showcase": patch
---

fix(showcase): put the remaining eight authored validation messages on the translation channel (#14518)

`showcase_account` declares seven author-written `validations[].message` and
`showcase_task` one, and none of them had an
`objects.OBJECT._validations.RULE.message` entry. An authored message is emitted
VERBATIM without one, so on a `zh-CN` session those refusals arrived in English
beside the platform's own — which have shipped `zh-CN` since #3957 — two
languages inside one `400 VALIDATION_FAILED` envelope. #14311 fixed the same
defect for `showcase_project`; its scope was one wizard, so these were left.

Both nested `conditional` branches get their own entry. `checkConditional`
delegates to the matching branch and renders THAT branch's message, addressed by
the branch's own `name`, so `churn_reason_consistency`'s own sentence is
structurally unreachable — translating only the wrapper would have translated
the one sentence nobody reads. Its entry is kept anyway so the bundle mirrors
the declared rule set 1:1.

The pin is now BUNDLE-WIDE rather than per-object: it walks the composed stack's
objects and object extensions, descends into conditional branches, and asks the
question for every locale `i18n.supportedLocales` claims, so a newly declared
rule without a translation fails instead of rotting. It also pins the
default-locale entry to the authored sentence verbatim — the bundle wins in
every locale, `en` included, so a drifted `en` entry turns the object's own
message into dead text no reader ever sees.
14 changes: 14 additions & 0 deletions examples/app-showcase/src/data/objects/account.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -115,6 +115,13 @@ export const Account = ObjectSchema.create({
// Task/Project: a re-entrant lifecycle (a churned account can be won
// back). Demonstrates the guardrail is just a per-field validation rule
// on the object — no separate metadata type, no separate file.
//
// #14518 — every `message` below is emitted VERBATIM unless the bundle
// carries `objects.showcase_account._validations.<rule>.message` (#14253),
// which is the one way a refusal escapes the caller's language while the
// platform's own refusals arrive translated. All seven are in
// `src/system/translations/index.ts`; the bundle WINS, so rewording a
// sentence here without rewording it there makes this text dead.
validations: [
{
type: 'state_machine' as const,
Expand DownExpand Up@@ -180,6 +187,13 @@ export const Account = ObjectSchema.create({
// non-churned account must NOT carry a stale churn reason. The
// `otherwise` branch only flags an explicitly-set reason (it `has()`-
// guards the absent case), so ordinary non-churned writes are untouched.
//
// The message on THIS rule never reaches a caller: `checkConditional`
// either returns nothing or delegates to the branch, and the branch's
// own `name` is what `objects.<o>._validations.<rule>.message` is keyed
// by. So the two branches below each need their own bundle entry —
// translating `churn_reason_consistency` alone would translate the one
// sentence nobody reads (#14518).
type: 'conditional' as const,
name: 'churn_reason_consistency',
label: 'Churn Reason Consistency',
Expand Down
5 changes: 5 additions & 0 deletions examples/app-showcase/src/data/objects/task.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -105,6 +105,11 @@ export const Task = ObjectSchema.create({
field: 'status',
// Transitions are validated on update; insert sets the initial state.
events: ['update'] as const,
// Update-only, so 'transition' is honest for the single refusal code
// this rule can raise. Translated at
// `objects.showcase_task._validations.task_status_flow.message` (#14253)
// — an authored message is emitted verbatim unless the bundle carries
// that key, and the bundle wins once it does (#14518).
message: 'Invalid task status transition.',
transitions: {
backlog: ['todo'],
Expand Down
65 changes: 65 additions & 0 deletions examples/app-showcase/src/system/translations/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -94,6 +94,17 @@ export const ShowcaseTranslationBundle = {
},
sync_error: { label: 'Sync Error' },
},
// The object's ONE authored rule message, on the #14253 channel for the
// same reason `showcase_project`'s four are (see the note there). The
// `en` entry is the authored sentence VERBATIM: the bundle WINS over
// `rule.message` in every locale, so a bundle entry that has drifted
// from the object turns the object's own sentence into dead text no
// reader ever sees. The pin asserts that equality rather than trusting it.
_validations: {
task_status_flow: {
message: 'Invalid task status transition.',
},
},
// The FIRST `_views` block on the `en` side of this bundle, and
// deliberately not a mirror of the zh-CN one below: view LABELS are
// already English in `ui/views/task.view.ts`, so restating all fifteen
Expand DownExpand Up@@ -129,6 +140,40 @@ export const ShowcaseTranslationBundle = {
support_config: { label: 'Support Config' },
churn_reason: { label: 'Churn Reason' },
},
// An author-written `validations[].message` is emitted VERBATIM unless
// the bundle carries it here (#14253) — see the note on
// `showcase_project` above. All SEVEN names this object declares, and
// the two NESTED ones are the point: `checkConditional` dispatches to
// the matching branch and renders that BRANCH's message, addressed by
// the branch's own `name`, so `churn_reason_consistency`'s own sentence
// is structurally unreachable and translating only it would leave both
// refusals a caller can actually see in English. Its entry is here
// anyway so the bundle mirrors the DECLARED rule set 1:1 — the pin in
// `test/new-project-wizard-initial-status.test.ts` asks for every
// declared name rather than re-deriving objectql's dispatch.
_validations: {
account_lifecycle: {
message: 'Invalid account lifecycle transition.',
},
tax_id_format: {
message: 'Tax ID must look like 12-3456789.',
},
billing_email_format: {
message: 'Billing Email must be a valid email address.',
},
support_config_shape: {
message: 'Support Config must be { tier: standard|premium|enterprise, seats?: >=1 }.',
},
churn_reason_consistency: {
message: 'Churn reason consistency.',
},
churn_reason_present: {
message: 'A churn reason is required when an account is marked churned.',
},
churn_reason_absent: {
message: 'A churn reason should only be set when the account is churned.',
},
},
},
showcase_contact: {
label: 'Contact',
Expand DownExpand Up@@ -368,6 +413,11 @@ export const ShowcaseTranslationBundle = {
},
sync_error: { label: '同步错误' },
},
// 状态 is the field label above and 状态流转 the same idea
// `showcase_project`'s entry uses — one word per idea across the bundle.
_validations: {
task_status_flow: { message: '任务状态流转无效。' },
},
_views: {
// The default list — keyed `default`, see showcase_project above.
default: { label: '全部任务' },
Expand DownExpand Up@@ -466,6 +516,21 @@ export const ShowcaseTranslationBundle = {
support_config: { label: '支持配置' },
churn_reason: { label: '流失原因' },
},
// The zh-CN mirror of the `en` `_validations` block — see the note
// there. Vocabulary is the one this bundle already established:
// 生命周期 / 税号 / 账单邮箱 / 支持配置 / 流失原因 are the field labels
// right above, so a refusal names the field with the same word the form
// does. The `support_config_shape` shape stays in its source spelling —
// it is a machine contract the author must type back, not prose.
_validations: {
account_lifecycle: { message: '客户生命周期的状态流转无效。' },
tax_id_format: { message: '税号格式应为 12-3456789。' },
billing_email_format: { message: '账单邮箱必须是有效的邮箱地址。' },
support_config_shape: { message: '支持配置必须为 { tier: standard|premium|enterprise, seats?: >=1 }。' },
churn_reason_consistency: { message: '流失原因一致性。' },
churn_reason_present: { message: '客户标记为流失时必须填写流失原因。' },
churn_reason_absent: { message: '只有客户已流失时才能填写流失原因。' },
},
},
showcase_contact: {
label: '联系人',
Expand Down
160 changes: 132 additions & 28 deletions examples/app-showcase/test/new-project-wizard-initial-status.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,22 +23,29 @@
* on it. Asserting only "it throws" would pass against a rejection for any
* other reason — including the `required` check, which is what a naive "just
* drop the field" fix would have tripped.
*
* The second `describe` is #14518 and is deliberately NOT wizard-scoped: the
* per-object translation pin that used to live in the first one is replaced by
* a bundle-wide one, because an instance-scoped pin is what left eight authored
* messages behind when #14311 fixed four.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { ObjectQL } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';

import stack from '../objectstack.config.js';
import { Account, Project } from '../src/data/objects/index.js';
import { NewProjectWizardPage } from '../src/ui/pages/new-project-wizard.page.js';
import { ShowcaseTranslationBundle } from '../src/system/translations/index.js';

type Rule = {
type?: string;
name?: string;
field?: string;
initialStates?: string[];
message?: string;
then?: Rule;
otherwise?: Rule;
};

const APP_ID = 'com.objectstack.showcase';
Expand DownExpand Up@@ -137,33 +144,6 @@ describe('#14311 — the New Project wizard and the status state machine', () =>
expect(defaulted).toEqual(statusRule.initialStates);
});

it('EVERY rule on the object is on the translation channel in both shipped locales', () => {
// An authored `validations[].message` is emitted VERBATIM unless the bundle
// carries `objects.<o>._validations.<rule>.message` (#14253). Scoped to the
// whole object rather than to the status rule on purpose: this one wizard
// can also trip `end_after_start` and `spent_within_budget` from its
// budget/schedule step, so pinning only the status rule would let the single
// English sentence move one step later instead of disappearing.
const rules = ((Project as unknown as { validations?: Rule[] }).validations ?? [])
.filter((r) => typeof r?.name === 'string');
expect(rules.length).toBeGreaterThan(1);

for (const rule of rules) {
const name = rule.name!;
for (const locale of ['en', 'zh-CN'] as const) {
const entry = (ShowcaseTranslationBundle as any)[locale]
?.objects?.showcase_project?._validations?.[name];
expect(entry?.message, `${locale} is missing a message for ${name}`).toBeTruthy();
}
// The zh-CN entry must actually BE Chinese — an English copy satisfies
// "a key exists" while reproducing the defect exactly.
const zh = (ShowcaseTranslationBundle as any)['zh-CN']
.objects.showcase_project._validations[name].message as string;
expect(zh, `${name}'s zh-CN message is not Chinese`).toMatch(/[一-龥]/);
expect(zh, `${name}'s zh-CN message is a copy of the authored one`).not.toBe(rule.message);
}
});

it('creates with the wizard payload and refuses the status it used to offer', async () => {
const engine = await bootShowcase();
const account: any = await engine.insert(
Expand DownExpand Up@@ -201,3 +181,127 @@ describe('#14311 — the New Project wizard and the status state machine', () =>
expect(field.value).toBe('active');
}, 30000);
});

/**
* [#14518] EVERY authored `validations[].message` the showcase declares is on
* the #14253 translation channel, in every locale the app claims to support.
*
* Bundle-wide on purpose. #14311 put `showcase_project`'s four rules on the
* channel and stopped there, because its scope was one wizard — which left
* eight (seven on `showcase_account`, one on `showcase_task`) refusing in
* English inside an otherwise zh-CN error envelope. A pin scoped to one object
* polices that object; the NEXT rule to be declared rots the same way. This
* asks the question of the whole registered surface instead, so a new rule
* without a translation fails here rather than shipping.
*
* Read on the COMPOSED stack — `stack.objects`, `stack.objectExtensions`,
* `stack.translations`, `stack.i18n` — the reachability principle `seed.test.ts`
* documents: what the resolver and the lint gates see is the composed stack,
* not the imported modules. The locale list is the app's OWN claim
* (`i18n.supportedLocales`) rather than a literal, so adding a locale to the
* config puts every authored sentence in scope for it instead of silently
* declaring coverage nobody wrote.
*/
describe('#14518 — every authored validation message in the showcase is translated', () => {
interface AuthoredRule { object: string; name: string; message: string }

/**
* Every named rule an object declares, DESCENDING into `conditional`
* branches.
*
* A `then` / `otherwise` branch is a full rule carrying its own `name`, and
* `checkConditional` renders THAT branch's message — the wrapping rule's
* sentence never reaches a caller. So a flat walk of `validations[]` misses
* exactly the messages a user actually reads, which is what the premise test
* below pins by name.
*/
function authoredRules(objectName: string, validations: unknown): AuthoredRule[] {
const out: AuthoredRule[] = [];
const visit = (rule: Rule | undefined): void => {
if (!rule || typeof rule !== 'object') return;
if (typeof rule.name === 'string' && typeof rule.message === 'string' && rule.message !== '') {
out.push({ object: objectName, name: rule.name, message: rule.message });
}
visit(rule.then);
visit(rule.otherwise);
};
for (const rule of Array.isArray(validations) ? validations : []) visit(rule as Rule);
return out;
}

const declaredRules: AuthoredRule[] = [
...((stack.objects ?? []) as Array<{ name?: string; validations?: unknown }>)
.flatMap((o) => (typeof o?.name === 'string' ? authoredRules(o.name, o.validations) : [])),
// An extension's `validations` MERGE into the target object at
// registration (`ObjectExtensionSchema` carries them), so such a rule is
// addressed under `extend` — not under the extension. None declares one
// today; the walk is here so the first one is not a silent hole.
...((stack.objectExtensions ?? []) as Array<{ extend?: string; validations?: unknown }>)
.flatMap((e) => (typeof e?.extend === 'string' ? authoredRules(e.extend, e.validations) : [])),
];

const locales = (stack.i18n?.supportedLocales ?? []) as string[];
const defaultLocale = (stack.i18n?.defaultLocale ?? 'en') as string;

/** What the resolver would find at `objects.<o>._validations.<rule>.message`. */
function bundleMessage(locale: string, objectName: string, ruleName: string): unknown {
for (const bundle of (stack.translations ?? []) as Array<Record<string, any>>) {
const found = bundle?.[locale]?.objects?.[objectName]?._validations?.[ruleName]?.message;
if (found !== undefined) return found;
}
return undefined;
}

it('the premise: the walk sees the registered surface, nested branches included', () => {
// Without these the assertions below pass vacuously — over no locales, no
// objects, or a rule set that stops at the top level of `validations[]`.
expect(locales).toContain(defaultLocale);
expect(locales.filter((l) => l !== defaultLocale).length).toBeGreaterThan(0);
expect(new Set(declaredRules.map((r) => r.object)).size).toBeGreaterThanOrEqual(3);
expect(declaredRules.map((r) => r.name)).toContain('churn_reason_present');
});

it('every authored rule message has a bundle entry in every supported locale', () => {
// Reported as a LIST rather than one failing assertion per rule: the whole
// population is the finding, and #14311 stopping at four is precisely the
// shape a first-failure-only report encourages.
const missing: string[] = [];
for (const rule of declaredRules) {
for (const locale of locales) {
const message = bundleMessage(locale, rule.object, rule.name);
if (typeof message !== 'string' || message.length === 0) {
missing.push(`${locale}: objects.${rule.object}._validations.${rule.name}.message`);
}
}
}
expect(missing, 'authored messages with no bundle entry refuse in the source language').toEqual([]);
});

it('the default-locale entry is the authored sentence verbatim', () => {
// The bundle WINS over `rule.message` in every locale, `en` included, so an
// entry that has drifted from the object turns the sentence authored beside
// the rule into text no reader ever sees — the object file then documents a
// refusal the app does not give.
for (const rule of declaredRules) {
expect(
bundleMessage(defaultLocale, rule.object, rule.name),
`objects.${rule.object}._validations.${rule.name} (${defaultLocale}) has drifted from the authored message`,
).toBe(rule.message);
}
});

it('a non-default locale is actually translated, not a copy of the source', () => {
for (const locale of locales.filter((l) => l !== defaultLocale)) {
for (const rule of declaredRules) {
const message = bundleMessage(locale, rule.object, rule.name) as string;
// A copy of the English satisfies "a key exists" while reproducing the
// defect exactly — which is the failure mode this whole file is about.
expect(message, `${rule.object}.${rule.name} in ${locale} is a copy of the source`)
.not.toBe(rule.message);
if (locale.startsWith('zh')) {
expect(message, `${rule.object}.${rule.name} in ${locale} is not Chinese`).toMatch(/[一-龥]/);
}
}
}
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/showcase-authored-validation-message-i18n.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
---
"@objectstack/example-showcase": patch
---

fix(showcase): put the remaining eight authored validation messages on the translation channel (#14518)

`showcase_account` declares seven author-written `validations[].message` and
`showcase_task` one, and none of them had an
`objects.OBJECT._validations.RULE.message` entry. An authored message is emitted
VERBATIM without one, so on a `zh-CN` session those refusals arrived in English
beside the platform's own — which have shipped `zh-CN` since #3957 — two
languages inside one `400 VALIDATION_FAILED` envelope. #14311 fixed the same
defect for `showcase_project`; its scope was one wizard, so these were left.

Both nested `conditional` branches get their own entry. `checkConditional`
delegates to the matching branch and renders THAT branch's message, addressed by
the branch's own `name`, so `churn_reason_consistency`'s own sentence is
structurally unreachable — translating only the wrapper would have translated
the one sentence nobody reads. Its entry is kept anyway so the bundle mirrors
the declared rule set 1:1.

The pin is now BUNDLE-WIDE rather than per-object: it walks the composed stack's
objects and object extensions, descends into conditional branches, and asks the
question for every locale `i18n.supportedLocales` claims, so a newly declared
rule without a translation fails instead of rotting. It also pins the
default-locale entry to the authored sentence verbatim — the bundle wins in
every locale, `en` included, so a drifted `en` entry turns the object's own
message into dead text no reader ever sees.
14 changes: 14 additions & 0 deletions examples/app-showcase/src/data/objects/account.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -115,6 +115,13 @@ export const Account = ObjectSchema.create({
// Task/Project: a re-entrant lifecycle (a churned account can be won
// back). Demonstrates the guardrail is just a per-field validation rule
// on the object — no separate metadata type, no separate file.
//
// #14518 — every `message` below is emitted VERBATIM unless the bundle
// carries `objects.showcase_account._validations.<rule>.message` (#14253),
// which is the one way a refusal escapes the caller's language while the
// platform's own refusals arrive translated. All seven are in
// `src/system/translations/index.ts`; the bundle WINS, so rewording a
// sentence here without rewording it there makes this text dead.
validations: [
{
type: 'state_machine' as const,
Expand DownExpand Up@@ -180,6 +187,13 @@ export const Account = ObjectSchema.create({
// non-churned account must NOT carry a stale churn reason. The
// `otherwise` branch only flags an explicitly-set reason (it `has()`-
// guards the absent case), so ordinary non-churned writes are untouched.
//
// The message on THIS rule never reaches a caller: `checkConditional`
// either returns nothing or delegates to the branch, and the branch's
// own `name` is what `objects.<o>._validations.<rule>.message` is keyed
// by. So the two branches below each need their own bundle entry —
// translating `churn_reason_consistency` alone would translate the one
// sentence nobody reads (#14518).
type: 'conditional' as const,
name: 'churn_reason_consistency',
label: 'Churn Reason Consistency',
Expand Down
5 changes: 5 additions & 0 deletions examples/app-showcase/src/data/objects/task.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -105,6 +105,11 @@ export const Task = ObjectSchema.create({
field: 'status',
// Transitions are validated on update; insert sets the initial state.
events: ['update'] as const,
// Update-only, so 'transition' is honest for the single refusal code
// this rule can raise. Translated at
// `objects.showcase_task._validations.task_status_flow.message` (#14253)
// — an authored message is emitted verbatim unless the bundle carries
// that key, and the bundle wins once it does (#14518).
message: 'Invalid task status transition.',
transitions: {
backlog: ['todo'],
Expand Down
65 changes: 65 additions & 0 deletions examples/app-showcase/src/system/translations/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -94,6 +94,17 @@ export const ShowcaseTranslationBundle = {
},
sync_error: { label: 'Sync Error' },
},
// The object's ONE authored rule message, on the #14253 channel for the
// same reason `showcase_project`'s four are (see the note there). The
// `en` entry is the authored sentence VERBATIM: the bundle WINS over
// `rule.message` in every locale, so a bundle entry that has drifted
// from the object turns the object's own sentence into dead text no
// reader ever sees. The pin asserts that equality rather than trusting it.
_validations: {
task_status_flow: {
message: 'Invalid task status transition.',
},
},
// The FIRST `_views` block on the `en` side of this bundle, and
// deliberately not a mirror of the zh-CN one below: view LABELS are
// already English in `ui/views/task.view.ts`, so restating all fifteen
Expand DownExpand Up@@ -129,6 +140,40 @@ export const ShowcaseTranslationBundle = {
support_config: { label: 'Support Config' },
churn_reason: { label: 'Churn Reason' },
},
// An author-written `validations[].message` is emitted VERBATIM unless
// the bundle carries it here (#14253) — see the note on
// `showcase_project` above. All SEVEN names this object declares, and
// the two NESTED ones are the point: `checkConditional` dispatches to
// the matching branch and renders that BRANCH's message, addressed by
// the branch's own `name`, so `churn_reason_consistency`'s own sentence
// is structurally unreachable and translating only it would leave both
// refusals a caller can actually see in English. Its entry is here
// anyway so the bundle mirrors the DECLARED rule set 1:1 — the pin in
// `test/new-project-wizard-initial-status.test.ts` asks for every
// declared name rather than re-deriving objectql's dispatch.
_validations: {
account_lifecycle: {
message: 'Invalid account lifecycle transition.',
},
tax_id_format: {
message: 'Tax ID must look like 12-3456789.',
},
billing_email_format: {
message: 'Billing Email must be a valid email address.',
},
support_config_shape: {
message: 'Support Config must be { tier: standard|premium|enterprise, seats?: >=1 }.',
},
churn_reason_consistency: {
message: 'Churn reason consistency.',
},
churn_reason_present: {
message: 'A churn reason is required when an account is marked churned.',
},
churn_reason_absent: {
message: 'A churn reason should only be set when the account is churned.',
},
},
},
showcase_contact: {
label: 'Contact',
Expand DownExpand Up@@ -368,6 +413,11 @@ export const ShowcaseTranslationBundle = {
},
sync_error: { label: '同步错误' },
},
// 状态 is the field label above and 状态流转 the same idea
// `showcase_project`'s entry uses — one word per idea across the bundle.
_validations: {
task_status_flow: { message: '任务状态流转无效。' },
},
_views: {
// The default list — keyed `default`, see showcase_project above.
default: { label: '全部任务' },
Expand DownExpand Up@@ -466,6 +516,21 @@ export const ShowcaseTranslationBundle = {
support_config: { label: '支持配置' },
churn_reason: { label: '流失原因' },
},
// The zh-CN mirror of the `en` `_validations` block — see the note
// there. Vocabulary is the one this bundle already established:
// 生命周期 / 税号 / 账单邮箱 / 支持配置 / 流失原因 are the field labels
// right above, so a refusal names the field with the same word the form
// does. The `support_config_shape` shape stays in its source spelling —
// it is a machine contract the author must type back, not prose.
_validations: {
account_lifecycle: { message: '客户生命周期的状态流转无效。' },
tax_id_format: { message: '税号格式应为 12-3456789。' },
billing_email_format: { message: '账单邮箱必须是有效的邮箱地址。' },
support_config_shape: { message: '支持配置必须为 { tier: standard|premium|enterprise, seats?: >=1 }。' },
churn_reason_consistency: { message: '流失原因一致性。' },
churn_reason_present: { message: '客户标记为流失时必须填写流失原因。' },
churn_reason_absent: { message: '只有客户已流失时才能填写流失原因。' },
},
},
showcase_contact: {
label: '联系人',
Expand Down
160 changes: 132 additions & 28 deletions examples/app-showcase/test/new-project-wizard-initial-status.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,22 +23,29 @@
* on it. Asserting only "it throws" would pass against a rejection for any
* other reason — including the `required` check, which is what a naive "just
* drop the field" fix would have tripped.
*
* The second `describe` is #14518 and is deliberately NOT wizard-scoped: the
* per-object translation pin that used to live in the first one is replaced by
* a bundle-wide one, because an instance-scoped pin is what left eight authored
* messages behind when #14311 fixed four.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { ObjectQL } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';

import stack from '../objectstack.config.js';
import { Account, Project } from '../src/data/objects/index.js';
import { NewProjectWizardPage } from '../src/ui/pages/new-project-wizard.page.js';
import { ShowcaseTranslationBundle } from '../src/system/translations/index.js';

type Rule = {
type?: string;
name?: string;
field?: string;
initialStates?: string[];
message?: string;
then?: Rule;
otherwise?: Rule;
};

const APP_ID = 'com.objectstack.showcase';
Expand DownExpand Up@@ -137,33 +144,6 @@ describe('#14311 — the New Project wizard and the status state machine', () =>
expect(defaulted).toEqual(statusRule.initialStates);
});

it('EVERY rule on the object is on the translation channel in both shipped locales', () => {
// An authored `validations[].message` is emitted VERBATIM unless the bundle
// carries `objects.<o>._validations.<rule>.message` (#14253). Scoped to the
// whole object rather than to the status rule on purpose: this one wizard
// can also trip `end_after_start` and `spent_within_budget` from its
// budget/schedule step, so pinning only the status rule would let the single
// English sentence move one step later instead of disappearing.
const rules = ((Project as unknown as { validations?: Rule[] }).validations ?? [])
.filter((r) => typeof r?.name === 'string');
expect(rules.length).toBeGreaterThan(1);

for (const rule of rules) {
const name = rule.name!;
for (const locale of ['en', 'zh-CN'] as const) {
const entry = (ShowcaseTranslationBundle as any)[locale]
?.objects?.showcase_project?._validations?.[name];
expect(entry?.message, `${locale} is missing a message for ${name}`).toBeTruthy();
}
// The zh-CN entry must actually BE Chinese — an English copy satisfies
// "a key exists" while reproducing the defect exactly.
const zh = (ShowcaseTranslationBundle as any)['zh-CN']
.objects.showcase_project._validations[name].message as string;
expect(zh, `${name}'s zh-CN message is not Chinese`).toMatch(/[一-龥]/);
expect(zh, `${name}'s zh-CN message is a copy of the authored one`).not.toBe(rule.message);
}
});

it('creates with the wizard payload and refuses the status it used to offer', async () => {
const engine = await bootShowcase();
const account: any = await engine.insert(
Expand DownExpand Up@@ -201,3 +181,127 @@ describe('#14311 — the New Project wizard and the status state machine', () =>
expect(field.value).toBe('active');
}, 30000);
});

/**
* [#14518] EVERY authored `validations[].message` the showcase declares is on
* the #14253 translation channel, in every locale the app claims to support.
*
* Bundle-wide on purpose. #14311 put `showcase_project`'s four rules on the
* channel and stopped there, because its scope was one wizard — which left
* eight (seven on `showcase_account`, one on `showcase_task`) refusing in
* English inside an otherwise zh-CN error envelope. A pin scoped to one object
* polices that object; the NEXT rule to be declared rots the same way. This
* asks the question of the whole registered surface instead, so a new rule
* without a translation fails here rather than shipping.
*
* Read on the COMPOSED stack — `stack.objects`, `stack.objectExtensions`,
* `stack.translations`, `stack.i18n` — the reachability principle `seed.test.ts`
* documents: what the resolver and the lint gates see is the composed stack,
* not the imported modules. The locale list is the app's OWN claim
* (`i18n.supportedLocales`) rather than a literal, so adding a locale to the
* config puts every authored sentence in scope for it instead of silently
* declaring coverage nobody wrote.
*/
describe('#14518 — every authored validation message in the showcase is translated', () => {
interface AuthoredRule { object: string; name: string; message: string }

/**
* Every named rule an object declares, DESCENDING into `conditional`
* branches.
*
* A `then` / `otherwise` branch is a full rule carrying its own `name`, and
* `checkConditional` renders THAT branch's message — the wrapping rule's
* sentence never reaches a caller. So a flat walk of `validations[]` misses
* exactly the messages a user actually reads, which is what the premise test
* below pins by name.
*/
function authoredRules(objectName: string, validations: unknown): AuthoredRule[] {
const out: AuthoredRule[] = [];
const visit = (rule: Rule | undefined): void => {
if (!rule || typeof rule !== 'object') return;
if (typeof rule.name === 'string' && typeof rule.message === 'string' && rule.message !== '') {
out.push({ object: objectName, name: rule.name, message: rule.message });
}
visit(rule.then);
visit(rule.otherwise);
};
for (const rule of Array.isArray(validations) ? validations : []) visit(rule as Rule);
return out;
}

const declaredRules: AuthoredRule[] = [
...((stack.objects ?? []) as Array<{ name?: string; validations?: unknown }>)
.flatMap((o) => (typeof o?.name === 'string' ? authoredRules(o.name, o.validations) : [])),
// An extension's `validations` MERGE into the target object at
// registration (`ObjectExtensionSchema` carries them), so such a rule is
// addressed under `extend` — not under the extension. None declares one
// today; the walk is here so the first one is not a silent hole.
...((stack.objectExtensions ?? []) as Array<{ extend?: string; validations?: unknown }>)
.flatMap((e) => (typeof e?.extend === 'string' ? authoredRules(e.extend, e.validations) : [])),
];

const locales = (stack.i18n?.supportedLocales ?? []) as string[];
const defaultLocale = (stack.i18n?.defaultLocale ?? 'en') as string;

/** What the resolver would find at `objects.<o>._validations.<rule>.message`. */
function bundleMessage(locale: string, objectName: string, ruleName: string): unknown {
for (const bundle of (stack.translations ?? []) as Array<Record<string, any>>) {
const found = bundle?.[locale]?.objects?.[objectName]?._validations?.[ruleName]?.message;
if (found !== undefined) return found;
}
return undefined;
}

it('the premise: the walk sees the registered surface, nested branches included', () => {
// Without these the assertions below pass vacuously — over no locales, no
// objects, or a rule set that stops at the top level of `validations[]`.
expect(locales).toContain(defaultLocale);
expect(locales.filter((l) => l !== defaultLocale).length).toBeGreaterThan(0);
expect(new Set(declaredRules.map((r) => r.object)).size).toBeGreaterThanOrEqual(3);
expect(declaredRules.map((r) => r.name)).toContain('churn_reason_present');
});

it('every authored rule message has a bundle entry in every supported locale', () => {
// Reported as a LIST rather than one failing assertion per rule: the whole
// population is the finding, and #14311 stopping at four is precisely the
// shape a first-failure-only report encourages.
const missing: string[] = [];
for (const rule of declaredRules) {
for (const locale of locales) {
const message = bundleMessage(locale, rule.object, rule.name);
if (typeof message !== 'string' || message.length === 0) {
missing.push(`${locale}: objects.${rule.object}._validations.${rule.name}.message`);
}
}
}
expect(missing, 'authored messages with no bundle entry refuse in the source language').toEqual([]);
});

it('the default-locale entry is the authored sentence verbatim', () => {
// The bundle WINS over `rule.message` in every locale, `en` included, so an
// entry that has drifted from the object turns the sentence authored beside
// the rule into text no reader ever sees — the object file then documents a
// refusal the app does not give.
for (const rule of declaredRules) {
expect(
bundleMessage(defaultLocale, rule.object, rule.name),
`objects.${rule.object}._validations.${rule.name} (${defaultLocale}) has drifted from the authored message`,
).toBe(rule.message);
}
});

it('a non-default locale is actually translated, not a copy of the source', () => {
for (const locale of locales.filter((l) => l !== defaultLocale)) {
for (const rule of declaredRules) {
const message = bundleMessage(locale, rule.object, rule.name) as string;
// A copy of the English satisfies "a key exists" while reproducing the
// defect exactly — which is the failure mode this whole file is about.
expect(message, `${rule.object}.${rule.name} in ${locale} is a copy of the source`)
.not.toBe(rule.message);
if (locale.startsWith('zh')) {
expect(message, `${rule.object}.${rule.name} in ${locale} is not Chinese`).toMatch(/[一-龥]/);
}
}
}
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/showcase-authored-validation-message-i18n.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
---
"@objectstack/example-showcase": patch
---

fix(showcase): put the remaining eight authored validation messages on the translation channel (#14518)

`showcase_account` declares seven author-written `validations[].message` and
`showcase_task` one, and none of them had an
`objects.OBJECT._validations.RULE.message` entry. An authored message is emitted
VERBATIM without one, so on a `zh-CN` session those refusals arrived in English
beside the platform's own — which have shipped `zh-CN` since #3957 — two
languages inside one `400 VALIDATION_FAILED` envelope. #14311 fixed the same
defect for `showcase_project`; its scope was one wizard, so these were left.

Both nested `conditional` branches get their own entry. `checkConditional`
delegates to the matching branch and renders THAT branch's message, addressed by
the branch's own `name`, so `churn_reason_consistency`'s own sentence is
structurally unreachable — translating only the wrapper would have translated
the one sentence nobody reads. Its entry is kept anyway so the bundle mirrors
the declared rule set 1:1.

The pin is now BUNDLE-WIDE rather than per-object: it walks the composed stack's
objects and object extensions, descends into conditional branches, and asks the
question for every locale `i18n.supportedLocales` claims, so a newly declared
rule without a translation fails instead of rotting. It also pins the
default-locale entry to the authored sentence verbatim — the bundle wins in
every locale, `en` included, so a drifted `en` entry turns the object's own
message into dead text no reader ever sees.
14 changes: 14 additions & 0 deletions examples/app-showcase/src/data/objects/account.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -115,6 +115,13 @@ export const Account = ObjectSchema.create({
// Task/Project: a re-entrant lifecycle (a churned account can be won
// back). Demonstrates the guardrail is just a per-field validation rule
// on the object — no separate metadata type, no separate file.
//
// #14518 — every `message` below is emitted VERBATIM unless the bundle
// carries `objects.showcase_account._validations.<rule>.message` (#14253),
// which is the one way a refusal escapes the caller's language while the
// platform's own refusals arrive translated. All seven are in
// `src/system/translations/index.ts`; the bundle WINS, so rewording a
// sentence here without rewording it there makes this text dead.
validations: [
{
type: 'state_machine' as const,
Expand DownExpand Up@@ -180,6 +187,13 @@ export const Account = ObjectSchema.create({
// non-churned account must NOT carry a stale churn reason. The
// `otherwise` branch only flags an explicitly-set reason (it `has()`-
// guards the absent case), so ordinary non-churned writes are untouched.
//
// The message on THIS rule never reaches a caller: `checkConditional`
// either returns nothing or delegates to the branch, and the branch's
// own `name` is what `objects.<o>._validations.<rule>.message` is keyed
// by. So the two branches below each need their own bundle entry —
// translating `churn_reason_consistency` alone would translate the one
// sentence nobody reads (#14518).
type: 'conditional' as const,
name: 'churn_reason_consistency',
label: 'Churn Reason Consistency',
Expand Down
5 changes: 5 additions & 0 deletions examples/app-showcase/src/data/objects/task.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -105,6 +105,11 @@ export const Task = ObjectSchema.create({
field: 'status',
// Transitions are validated on update; insert sets the initial state.
events: ['update'] as const,
// Update-only, so 'transition' is honest for the single refusal code
// this rule can raise. Translated at
// `objects.showcase_task._validations.task_status_flow.message` (#14253)
// — an authored message is emitted verbatim unless the bundle carries
// that key, and the bundle wins once it does (#14518).
message: 'Invalid task status transition.',
transitions: {
backlog: ['todo'],
Expand Down
65 changes: 65 additions & 0 deletions examples/app-showcase/src/system/translations/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -94,6 +94,17 @@ export const ShowcaseTranslationBundle = {
},
sync_error: { label: 'Sync Error' },
},
// The object's ONE authored rule message, on the #14253 channel for the
// same reason `showcase_project`'s four are (see the note there). The
// `en` entry is the authored sentence VERBATIM: the bundle WINS over
// `rule.message` in every locale, so a bundle entry that has drifted
// from the object turns the object's own sentence into dead text no
// reader ever sees. The pin asserts that equality rather than trusting it.
_validations: {
task_status_flow: {
message: 'Invalid task status transition.',
},
},
// The FIRST `_views` block on the `en` side of this bundle, and
// deliberately not a mirror of the zh-CN one below: view LABELS are
// already English in `ui/views/task.view.ts`, so restating all fifteen
Expand DownExpand Up@@ -129,6 +140,40 @@ export const ShowcaseTranslationBundle = {
support_config: { label: 'Support Config' },
churn_reason: { label: 'Churn Reason' },
},
// An author-written `validations[].message` is emitted VERBATIM unless
// the bundle carries it here (#14253) — see the note on
// `showcase_project` above. All SEVEN names this object declares, and
// the two NESTED ones are the point: `checkConditional` dispatches to
// the matching branch and renders that BRANCH's message, addressed by
// the branch's own `name`, so `churn_reason_consistency`'s own sentence
// is structurally unreachable and translating only it would leave both
// refusals a caller can actually see in English. Its entry is here
// anyway so the bundle mirrors the DECLARED rule set 1:1 — the pin in
// `test/new-project-wizard-initial-status.test.ts` asks for every
// declared name rather than re-deriving objectql's dispatch.
_validations: {
account_lifecycle: {
message: 'Invalid account lifecycle transition.',
},
tax_id_format: {
message: 'Tax ID must look like 12-3456789.',
},
billing_email_format: {
message: 'Billing Email must be a valid email address.',
},
support_config_shape: {
message: 'Support Config must be { tier: standard|premium|enterprise, seats?: >=1 }.',
},
churn_reason_consistency: {
message: 'Churn reason consistency.',
},
churn_reason_present: {
message: 'A churn reason is required when an account is marked churned.',
},
churn_reason_absent: {
message: 'A churn reason should only be set when the account is churned.',
},
},
},
showcase_contact: {
label: 'Contact',
Expand DownExpand Up@@ -368,6 +413,11 @@ export const ShowcaseTranslationBundle = {
},
sync_error: { label: '同步错误' },
},
// 状态 is the field label above and 状态流转 the same idea
// `showcase_project`'s entry uses — one word per idea across the bundle.
_validations: {
task_status_flow: { message: '任务状态流转无效。' },
},
_views: {
// The default list — keyed `default`, see showcase_project above.
default: { label: '全部任务' },
Expand DownExpand Up@@ -466,6 +516,21 @@ export const ShowcaseTranslationBundle = {
support_config: { label: '支持配置' },
churn_reason: { label: '流失原因' },
},
// The zh-CN mirror of the `en` `_validations` block — see the note
// there. Vocabulary is the one this bundle already established:
// 生命周期 / 税号 / 账单邮箱 / 支持配置 / 流失原因 are the field labels
// right above, so a refusal names the field with the same word the form
// does. The `support_config_shape` shape stays in its source spelling —
// it is a machine contract the author must type back, not prose.
_validations: {
account_lifecycle: { message: '客户生命周期的状态流转无效。' },
tax_id_format: { message: '税号格式应为 12-3456789。' },
billing_email_format: { message: '账单邮箱必须是有效的邮箱地址。' },
support_config_shape: { message: '支持配置必须为 { tier: standard|premium|enterprise, seats?: >=1 }。' },
churn_reason_consistency: { message: '流失原因一致性。' },
churn_reason_present: { message: '客户标记为流失时必须填写流失原因。' },
churn_reason_absent: { message: '只有客户已流失时才能填写流失原因。' },
},
},
showcase_contact: {
label: '联系人',
Expand Down
160 changes: 132 additions & 28 deletions examples/app-showcase/test/new-project-wizard-initial-status.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,22 +23,29 @@
* on it. Asserting only "it throws" would pass against a rejection for any
* other reason — including the `required` check, which is what a naive "just
* drop the field" fix would have tripped.
*
* The second `describe` is #14518 and is deliberately NOT wizard-scoped: the
* per-object translation pin that used to live in the first one is replaced by
* a bundle-wide one, because an instance-scoped pin is what left eight authored
* messages behind when #14311 fixed four.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { ObjectQL } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';

import stack from '../objectstack.config.js';
import { Account, Project } from '../src/data/objects/index.js';
import { NewProjectWizardPage } from '../src/ui/pages/new-project-wizard.page.js';
import { ShowcaseTranslationBundle } from '../src/system/translations/index.js';

type Rule = {
type?: string;
name?: string;
field?: string;
initialStates?: string[];
message?: string;
then?: Rule;
otherwise?: Rule;
};

const APP_ID = 'com.objectstack.showcase';
Expand DownExpand Up@@ -137,33 +144,6 @@ describe('#14311 — the New Project wizard and the status state machine', () =>
expect(defaulted).toEqual(statusRule.initialStates);
});

it('EVERY rule on the object is on the translation channel in both shipped locales', () => {
// An authored `validations[].message` is emitted VERBATIM unless the bundle
// carries `objects.<o>._validations.<rule>.message` (#14253). Scoped to the
// whole object rather than to the status rule on purpose: this one wizard
// can also trip `end_after_start` and `spent_within_budget` from its
// budget/schedule step, so pinning only the status rule would let the single
// English sentence move one step later instead of disappearing.
const rules = ((Project as unknown as { validations?: Rule[] }).validations ?? [])
.filter((r) => typeof r?.name === 'string');
expect(rules.length).toBeGreaterThan(1);

for (const rule of rules) {
const name = rule.name!;
for (const locale of ['en', 'zh-CN'] as const) {
const entry = (ShowcaseTranslationBundle as any)[locale]
?.objects?.showcase_project?._validations?.[name];
expect(entry?.message, `${locale} is missing a message for ${name}`).toBeTruthy();
}
// The zh-CN entry must actually BE Chinese — an English copy satisfies
// "a key exists" while reproducing the defect exactly.
const zh = (ShowcaseTranslationBundle as any)['zh-CN']
.objects.showcase_project._validations[name].message as string;
expect(zh, `${name}'s zh-CN message is not Chinese`).toMatch(/[一-龥]/);
expect(zh, `${name}'s zh-CN message is a copy of the authored one`).not.toBe(rule.message);
}
});

it('creates with the wizard payload and refuses the status it used to offer', async () => {
const engine = await bootShowcase();
const account: any = await engine.insert(
Expand DownExpand Up@@ -201,3 +181,127 @@ describe('#14311 — the New Project wizard and the status state machine', () =>
expect(field.value).toBe('active');
}, 30000);
});

/**
* [#14518] EVERY authored `validations[].message` the showcase declares is on
* the #14253 translation channel, in every locale the app claims to support.
*
* Bundle-wide on purpose. #14311 put `showcase_project`'s four rules on the
* channel and stopped there, because its scope was one wizard — which left
* eight (seven on `showcase_account`, one on `showcase_task`) refusing in
* English inside an otherwise zh-CN error envelope. A pin scoped to one object
* polices that object; the NEXT rule to be declared rots the same way. This
* asks the question of the whole registered surface instead, so a new rule
* without a translation fails here rather than shipping.
*
* Read on the COMPOSED stack — `stack.objects`, `stack.objectExtensions`,
* `stack.translations`, `stack.i18n` — the reachability principle `seed.test.ts`
* documents: what the resolver and the lint gates see is the composed stack,
* not the imported modules. The locale list is the app's OWN claim
* (`i18n.supportedLocales`) rather than a literal, so adding a locale to the
* config puts every authored sentence in scope for it instead of silently
* declaring coverage nobody wrote.
*/
describe('#14518 — every authored validation message in the showcase is translated', () => {
interface AuthoredRule { object: string; name: string; message: string }

/**
* Every named rule an object declares, DESCENDING into `conditional`
* branches.
*
* A `then` / `otherwise` branch is a full rule carrying its own `name`, and
* `checkConditional` renders THAT branch's message — the wrapping rule's
* sentence never reaches a caller. So a flat walk of `validations[]` misses
* exactly the messages a user actually reads, which is what the premise test
* below pins by name.
*/
function authoredRules(objectName: string, validations: unknown): AuthoredRule[] {
const out: AuthoredRule[] = [];
const visit = (rule: Rule | undefined): void => {
if (!rule || typeof rule !== 'object') return;
if (typeof rule.name === 'string' && typeof rule.message === 'string' && rule.message !== '') {
out.push({ object: objectName, name: rule.name, message: rule.message });
}
visit(rule.then);
visit(rule.otherwise);
};
for (const rule of Array.isArray(validations) ? validations : []) visit(rule as Rule);
return out;
}

const declaredRules: AuthoredRule[] = [
...((stack.objects ?? []) as Array<{ name?: string; validations?: unknown }>)
.flatMap((o) => (typeof o?.name === 'string' ? authoredRules(o.name, o.validations) : [])),
// An extension's `validations` MERGE into the target object at
// registration (`ObjectExtensionSchema` carries them), so such a rule is
// addressed under `extend` — not under the extension. None declares one
// today; the walk is here so the first one is not a silent hole.
...((stack.objectExtensions ?? []) as Array<{ extend?: string; validations?: unknown }>)
.flatMap((e) => (typeof e?.extend === 'string' ? authoredRules(e.extend, e.validations) : [])),
];

const locales = (stack.i18n?.supportedLocales ?? []) as string[];
const defaultLocale = (stack.i18n?.defaultLocale ?? 'en') as string;

/** What the resolver would find at `objects.<o>._validations.<rule>.message`. */
function bundleMessage(locale: string, objectName: string, ruleName: string): unknown {
for (const bundle of (stack.translations ?? []) as Array<Record<string, any>>) {
const found = bundle?.[locale]?.objects?.[objectName]?._validations?.[ruleName]?.message;
if (found !== undefined) return found;
}
return undefined;
}

it('the premise: the walk sees the registered surface, nested branches included', () => {
// Without these the assertions below pass vacuously — over no locales, no
// objects, or a rule set that stops at the top level of `validations[]`.
expect(locales).toContain(defaultLocale);
expect(locales.filter((l) => l !== defaultLocale).length).toBeGreaterThan(0);
expect(new Set(declaredRules.map((r) => r.object)).size).toBeGreaterThanOrEqual(3);
expect(declaredRules.map((r) => r.name)).toContain('churn_reason_present');
});

it('every authored rule message has a bundle entry in every supported locale', () => {
// Reported as a LIST rather than one failing assertion per rule: the whole
// population is the finding, and #14311 stopping at four is precisely the
// shape a first-failure-only report encourages.
const missing: string[] = [];
for (const rule of declaredRules) {
for (const locale of locales) {
const message = bundleMessage(locale, rule.object, rule.name);
if (typeof message !== 'string' || message.length === 0) {
missing.push(`${locale}: objects.${rule.object}._validations.${rule.name}.message`);
}
}
}
expect(missing, 'authored messages with no bundle entry refuse in the source language').toEqual([]);
});

it('the default-locale entry is the authored sentence verbatim', () => {
// The bundle WINS over `rule.message` in every locale, `en` included, so an
// entry that has drifted from the object turns the sentence authored beside
// the rule into text no reader ever sees — the object file then documents a
// refusal the app does not give.
for (const rule of declaredRules) {
expect(
bundleMessage(defaultLocale, rule.object, rule.name),
`objects.${rule.object}._validations.${rule.name} (${defaultLocale}) has drifted from the authored message`,
).toBe(rule.message);
}
});

it('a non-default locale is actually translated, not a copy of the source', () => {
for (const locale of locales.filter((l) => l !== defaultLocale)) {
for (const rule of declaredRules) {
const message = bundleMessage(locale, rule.object, rule.name) as string;
// A copy of the English satisfies "a key exists" while reproducing the
// defect exactly — which is the failure mode this whole file is about.
expect(message, `${rule.object}.${rule.name} in ${locale} is a copy of the source`)
.not.toBe(rule.message);
if (locale.startsWith('zh')) {
expect(message, `${rule.object}.${rule.name} in ${locale} is not Chinese`).toMatch(/[一-龥]/);
}
}
}
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/showcase-authored-validation-message-i18n.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
---
"@objectstack/example-showcase": patch
---

fix(showcase): put the remaining eight authored validation messages on the translation channel (#14518)

`showcase_account` declares seven author-written `validations[].message` and
`showcase_task` one, and none of them had an
`objects.OBJECT._validations.RULE.message` entry. An authored message is emitted
VERBATIM without one, so on a `zh-CN` session those refusals arrived in English
beside the platform's own — which have shipped `zh-CN` since #3957 — two
languages inside one `400 VALIDATION_FAILED` envelope. #14311 fixed the same
defect for `showcase_project`; its scope was one wizard, so these were left.

Both nested `conditional` branches get their own entry. `checkConditional`
delegates to the matching branch and renders THAT branch's message, addressed by
the branch's own `name`, so `churn_reason_consistency`'s own sentence is
structurally unreachable — translating only the wrapper would have translated
the one sentence nobody reads. Its entry is kept anyway so the bundle mirrors
the declared rule set 1:1.

The pin is now BUNDLE-WIDE rather than per-object: it walks the composed stack's
objects and object extensions, descends into conditional branches, and asks the
question for every locale `i18n.supportedLocales` claims, so a newly declared
rule without a translation fails instead of rotting. It also pins the
default-locale entry to the authored sentence verbatim — the bundle wins in
every locale, `en` included, so a drifted `en` entry turns the object's own
message into dead text no reader ever sees.
14 changes: 14 additions & 0 deletions examples/app-showcase/src/data/objects/account.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -115,6 +115,13 @@ export const Account = ObjectSchema.create({
// Task/Project: a re-entrant lifecycle (a churned account can be won
// back). Demonstrates the guardrail is just a per-field validation rule
// on the object — no separate metadata type, no separate file.
//
// #14518 — every `message` below is emitted VERBATIM unless the bundle
// carries `objects.showcase_account._validations.<rule>.message` (#14253),
// which is the one way a refusal escapes the caller's language while the
// platform's own refusals arrive translated. All seven are in
// `src/system/translations/index.ts`; the bundle WINS, so rewording a
// sentence here without rewording it there makes this text dead.
validations: [
{
type: 'state_machine' as const,
Expand DownExpand Up@@ -180,6 +187,13 @@ export const Account = ObjectSchema.create({
// non-churned account must NOT carry a stale churn reason. The
// `otherwise` branch only flags an explicitly-set reason (it `has()`-
// guards the absent case), so ordinary non-churned writes are untouched.
//
// The message on THIS rule never reaches a caller: `checkConditional`
// either returns nothing or delegates to the branch, and the branch's
// own `name` is what `objects.<o>._validations.<rule>.message` is keyed
// by. So the two branches below each need their own bundle entry —
// translating `churn_reason_consistency` alone would translate the one
// sentence nobody reads (#14518).
type: 'conditional' as const,
name: 'churn_reason_consistency',
label: 'Churn Reason Consistency',
Expand Down
5 changes: 5 additions & 0 deletions examples/app-showcase/src/data/objects/task.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -105,6 +105,11 @@ export const Task = ObjectSchema.create({
field: 'status',
// Transitions are validated on update; insert sets the initial state.
events: ['update'] as const,
// Update-only, so 'transition' is honest for the single refusal code
// this rule can raise. Translated at
// `objects.showcase_task._validations.task_status_flow.message` (#14253)
// — an authored message is emitted verbatim unless the bundle carries
// that key, and the bundle wins once it does (#14518).
message: 'Invalid task status transition.',
transitions: {
backlog: ['todo'],
Expand Down
65 changes: 65 additions & 0 deletions examples/app-showcase/src/system/translations/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -94,6 +94,17 @@ export const ShowcaseTranslationBundle = {
},
sync_error: { label: 'Sync Error' },
},
// The object's ONE authored rule message, on the #14253 channel for the
// same reason `showcase_project`'s four are (see the note there). The
// `en` entry is the authored sentence VERBATIM: the bundle WINS over
// `rule.message` in every locale, so a bundle entry that has drifted
// from the object turns the object's own sentence into dead text no
// reader ever sees. The pin asserts that equality rather than trusting it.
_validations: {
task_status_flow: {
message: 'Invalid task status transition.',
},
},
// The FIRST `_views` block on the `en` side of this bundle, and
// deliberately not a mirror of the zh-CN one below: view LABELS are
// already English in `ui/views/task.view.ts`, so restating all fifteen
Expand DownExpand Up@@ -129,6 +140,40 @@ export const ShowcaseTranslationBundle = {
support_config: { label: 'Support Config' },
churn_reason: { label: 'Churn Reason' },
},
// An author-written `validations[].message` is emitted VERBATIM unless
// the bundle carries it here (#14253) — see the note on
// `showcase_project` above. All SEVEN names this object declares, and
// the two NESTED ones are the point: `checkConditional` dispatches to
// the matching branch and renders that BRANCH's message, addressed by
// the branch's own `name`, so `churn_reason_consistency`'s own sentence
// is structurally unreachable and translating only it would leave both
// refusals a caller can actually see in English. Its entry is here
// anyway so the bundle mirrors the DECLARED rule set 1:1 — the pin in
// `test/new-project-wizard-initial-status.test.ts` asks for every
// declared name rather than re-deriving objectql's dispatch.
_validations: {
account_lifecycle: {
message: 'Invalid account lifecycle transition.',
},
tax_id_format: {
message: 'Tax ID must look like 12-3456789.',
},
billing_email_format: {
message: 'Billing Email must be a valid email address.',
},
support_config_shape: {
message: 'Support Config must be { tier: standard|premium|enterprise, seats?: >=1 }.',
},
churn_reason_consistency: {
message: 'Churn reason consistency.',
},
churn_reason_present: {
message: 'A churn reason is required when an account is marked churned.',
},
churn_reason_absent: {
message: 'A churn reason should only be set when the account is churned.',
},
},
},
showcase_contact: {
label: 'Contact',
Expand DownExpand Up@@ -368,6 +413,11 @@ export const ShowcaseTranslationBundle = {
},
sync_error: { label: '同步错误' },
},
// 状态 is the field label above and 状态流转 the same idea
// `showcase_project`'s entry uses — one word per idea across the bundle.
_validations: {
task_status_flow: { message: '任务状态流转无效。' },
},
_views: {
// The default list — keyed `default`, see showcase_project above.
default: { label: '全部任务' },
Expand DownExpand Up@@ -466,6 +516,21 @@ export const ShowcaseTranslationBundle = {
support_config: { label: '支持配置' },
churn_reason: { label: '流失原因' },
},
// The zh-CN mirror of the `en` `_validations` block — see the note
// there. Vocabulary is the one this bundle already established:
// 生命周期 / 税号 / 账单邮箱 / 支持配置 / 流失原因 are the field labels
// right above, so a refusal names the field with the same word the form
// does. The `support_config_shape` shape stays in its source spelling —
// it is a machine contract the author must type back, not prose.
_validations: {
account_lifecycle: { message: '客户生命周期的状态流转无效。' },
tax_id_format: { message: '税号格式应为 12-3456789。' },
billing_email_format: { message: '账单邮箱必须是有效的邮箱地址。' },
support_config_shape: { message: '支持配置必须为 { tier: standard|premium|enterprise, seats?: >=1 }。' },
churn_reason_consistency: { message: '流失原因一致性。' },
churn_reason_present: { message: '客户标记为流失时必须填写流失原因。' },
churn_reason_absent: { message: '只有客户已流失时才能填写流失原因。' },
},
},
showcase_contact: {
label: '联系人',
Expand Down
160 changes: 132 additions & 28 deletions examples/app-showcase/test/new-project-wizard-initial-status.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,22 +23,29 @@
* on it. Asserting only "it throws" would pass against a rejection for any
* other reason — including the `required` check, which is what a naive "just
* drop the field" fix would have tripped.
*
* The second `describe` is #14518 and is deliberately NOT wizard-scoped: the
* per-object translation pin that used to live in the first one is replaced by
* a bundle-wide one, because an instance-scoped pin is what left eight authored
* messages behind when #14311 fixed four.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { ObjectQL } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';

import stack from '../objectstack.config.js';
import { Account, Project } from '../src/data/objects/index.js';
import { NewProjectWizardPage } from '../src/ui/pages/new-project-wizard.page.js';
import { ShowcaseTranslationBundle } from '../src/system/translations/index.js';

type Rule = {
type?: string;
name?: string;
field?: string;
initialStates?: string[];
message?: string;
then?: Rule;
otherwise?: Rule;
};

const APP_ID = 'com.objectstack.showcase';
Expand DownExpand Up@@ -137,33 +144,6 @@ describe('#14311 — the New Project wizard and the status state machine', () =>
expect(defaulted).toEqual(statusRule.initialStates);
});

it('EVERY rule on the object is on the translation channel in both shipped locales', () => {
// An authored `validations[].message` is emitted VERBATIM unless the bundle
// carries `objects.<o>._validations.<rule>.message` (#14253). Scoped to the
// whole object rather than to the status rule on purpose: this one wizard
// can also trip `end_after_start` and `spent_within_budget` from its
// budget/schedule step, so pinning only the status rule would let the single
// English sentence move one step later instead of disappearing.
const rules = ((Project as unknown as { validations?: Rule[] }).validations ?? [])
.filter((r) => typeof r?.name === 'string');
expect(rules.length).toBeGreaterThan(1);

for (const rule of rules) {
const name = rule.name!;
for (const locale of ['en', 'zh-CN'] as const) {
const entry = (ShowcaseTranslationBundle as any)[locale]
?.objects?.showcase_project?._validations?.[name];
expect(entry?.message, `${locale} is missing a message for ${name}`).toBeTruthy();
}
// The zh-CN entry must actually BE Chinese — an English copy satisfies
// "a key exists" while reproducing the defect exactly.
const zh = (ShowcaseTranslationBundle as any)['zh-CN']
.objects.showcase_project._validations[name].message as string;
expect(zh, `${name}'s zh-CN message is not Chinese`).toMatch(/[一-龥]/);
expect(zh, `${name}'s zh-CN message is a copy of the authored one`).not.toBe(rule.message);
}
});

it('creates with the wizard payload and refuses the status it used to offer', async () => {
const engine = await bootShowcase();
const account: any = await engine.insert(
Expand DownExpand Up@@ -201,3 +181,127 @@ describe('#14311 — the New Project wizard and the status state machine', () =>
expect(field.value).toBe('active');
}, 30000);
});

/**
* [#14518] EVERY authored `validations[].message` the showcase declares is on
* the #14253 translation channel, in every locale the app claims to support.
*
* Bundle-wide on purpose. #14311 put `showcase_project`'s four rules on the
* channel and stopped there, because its scope was one wizard — which left
* eight (seven on `showcase_account`, one on `showcase_task`) refusing in
* English inside an otherwise zh-CN error envelope. A pin scoped to one object
* polices that object; the NEXT rule to be declared rots the same way. This
* asks the question of the whole registered surface instead, so a new rule
* without a translation fails here rather than shipping.
*
* Read on the COMPOSED stack — `stack.objects`, `stack.objectExtensions`,
* `stack.translations`, `stack.i18n` — the reachability principle `seed.test.ts`
* documents: what the resolver and the lint gates see is the composed stack,
* not the imported modules. The locale list is the app's OWN claim
* (`i18n.supportedLocales`) rather than a literal, so adding a locale to the
* config puts every authored sentence in scope for it instead of silently
* declaring coverage nobody wrote.
*/
describe('#14518 — every authored validation message in the showcase is translated', () => {
interface AuthoredRule { object: string; name: string; message: string }

/**
* Every named rule an object declares, DESCENDING into `conditional`
* branches.
*
* A `then` / `otherwise` branch is a full rule carrying its own `name`, and
* `checkConditional` renders THAT branch's message — the wrapping rule's
* sentence never reaches a caller. So a flat walk of `validations[]` misses
* exactly the messages a user actually reads, which is what the premise test
* below pins by name.
*/
function authoredRules(objectName: string, validations: unknown): AuthoredRule[] {
const out: AuthoredRule[] = [];
const visit = (rule: Rule | undefined): void => {
if (!rule || typeof rule !== 'object') return;
if (typeof rule.name === 'string' && typeof rule.message === 'string' && rule.message !== '') {
out.push({ object: objectName, name: rule.name, message: rule.message });
}
visit(rule.then);
visit(rule.otherwise);
};
for (const rule of Array.isArray(validations) ? validations : []) visit(rule as Rule);
return out;
}

const declaredRules: AuthoredRule[] = [
...((stack.objects ?? []) as Array<{ name?: string; validations?: unknown }>)
.flatMap((o) => (typeof o?.name === 'string' ? authoredRules(o.name, o.validations) : [])),
// An extension's `validations` MERGE into the target object at
// registration (`ObjectExtensionSchema` carries them), so such a rule is
// addressed under `extend` — not under the extension. None declares one
// today; the walk is here so the first one is not a silent hole.
...((stack.objectExtensions ?? []) as Array<{ extend?: string; validations?: unknown }>)
.flatMap((e) => (typeof e?.extend === 'string' ? authoredRules(e.extend, e.validations) : [])),
];

const locales = (stack.i18n?.supportedLocales ?? []) as string[];
const defaultLocale = (stack.i18n?.defaultLocale ?? 'en') as string;

/** What the resolver would find at `objects.<o>._validations.<rule>.message`. */
function bundleMessage(locale: string, objectName: string, ruleName: string): unknown {
for (const bundle of (stack.translations ?? []) as Array<Record<string, any>>) {
const found = bundle?.[locale]?.objects?.[objectName]?._validations?.[ruleName]?.message;
if (found !== undefined) return found;
}
return undefined;
}

it('the premise: the walk sees the registered surface, nested branches included', () => {
// Without these the assertions below pass vacuously — over no locales, no
// objects, or a rule set that stops at the top level of `validations[]`.
expect(locales).toContain(defaultLocale);
expect(locales.filter((l) => l !== defaultLocale).length).toBeGreaterThan(0);
expect(new Set(declaredRules.map((r) => r.object)).size).toBeGreaterThanOrEqual(3);
expect(declaredRules.map((r) => r.name)).toContain('churn_reason_present');
});

it('every authored rule message has a bundle entry in every supported locale', () => {
// Reported as a LIST rather than one failing assertion per rule: the whole
// population is the finding, and #14311 stopping at four is precisely the
// shape a first-failure-only report encourages.
const missing: string[] = [];
for (const rule of declaredRules) {
for (const locale of locales) {
const message = bundleMessage(locale, rule.object, rule.name);
if (typeof message !== 'string' || message.length === 0) {
missing.push(`${locale}: objects.${rule.object}._validations.${rule.name}.message`);
}
}
}
expect(missing, 'authored messages with no bundle entry refuse in the source language').toEqual([]);
});

it('the default-locale entry is the authored sentence verbatim', () => {
// The bundle WINS over `rule.message` in every locale, `en` included, so an
// entry that has drifted from the object turns the sentence authored beside
// the rule into text no reader ever sees — the object file then documents a
// refusal the app does not give.
for (const rule of declaredRules) {
expect(
bundleMessage(defaultLocale, rule.object, rule.name),
`objects.${rule.object}._validations.${rule.name} (${defaultLocale}) has drifted from the authored message`,
).toBe(rule.message);
}
});

it('a non-default locale is actually translated, not a copy of the source', () => {
for (const locale of locales.filter((l) => l !== defaultLocale)) {
for (const rule of declaredRules) {
const message = bundleMessage(locale, rule.object, rule.name) as string;
// A copy of the English satisfies "a key exists" while reproducing the
// defect exactly — which is the failure mode this whole file is about.
expect(message, `${rule.object}.${rule.name} in ${locale} is a copy of the source`)
.not.toBe(rule.message);
if (locale.startsWith('zh')) {
expect(message, `${rule.object}.${rule.name} in ${locale} is not Chinese`).toMatch(/[一-龥]/);
}
}
}
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/showcase-authored-validation-message-i18n.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
---
"@objectstack/example-showcase": patch
---

fix(showcase): put the remaining eight authored validation messages on the translation channel (#14518)

`showcase_account` declares seven author-written `validations[].message` and
`showcase_task` one, and none of them had an
`objects.OBJECT._validations.RULE.message` entry. An authored message is emitted
VERBATIM without one, so on a `zh-CN` session those refusals arrived in English
beside the platform's own — which have shipped `zh-CN` since #3957 — two
languages inside one `400 VALIDATION_FAILED` envelope. #14311 fixed the same
defect for `showcase_project`; its scope was one wizard, so these were left.

Both nested `conditional` branches get their own entry. `checkConditional`
delegates to the matching branch and renders THAT branch's message, addressed by
the branch's own `name`, so `churn_reason_consistency`'s own sentence is
structurally unreachable — translating only the wrapper would have translated
the one sentence nobody reads. Its entry is kept anyway so the bundle mirrors
the declared rule set 1:1.

The pin is now BUNDLE-WIDE rather than per-object: it walks the composed stack's
objects and object extensions, descends into conditional branches, and asks the
question for every locale `i18n.supportedLocales` claims, so a newly declared
rule without a translation fails instead of rotting. It also pins the
default-locale entry to the authored sentence verbatim — the bundle wins in
every locale, `en` included, so a drifted `en` entry turns the object's own
message into dead text no reader ever sees.
14 changes: 14 additions & 0 deletions examples/app-showcase/src/data/objects/account.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -115,6 +115,13 @@ export const Account = ObjectSchema.create({
// Task/Project: a re-entrant lifecycle (a churned account can be won
// back). Demonstrates the guardrail is just a per-field validation rule
// on the object — no separate metadata type, no separate file.
//
// #14518 — every `message` below is emitted VERBATIM unless the bundle
// carries `objects.showcase_account._validations.<rule>.message` (#14253),
// which is the one way a refusal escapes the caller's language while the
// platform's own refusals arrive translated. All seven are in
// `src/system/translations/index.ts`; the bundle WINS, so rewording a
// sentence here without rewording it there makes this text dead.
validations: [
{
type: 'state_machine' as const,
Expand DownExpand Up@@ -180,6 +187,13 @@ export const Account = ObjectSchema.create({
// non-churned account must NOT carry a stale churn reason. The
// `otherwise` branch only flags an explicitly-set reason (it `has()`-
// guards the absent case), so ordinary non-churned writes are untouched.
//
// The message on THIS rule never reaches a caller: `checkConditional`
// either returns nothing or delegates to the branch, and the branch's
// own `name` is what `objects.<o>._validations.<rule>.message` is keyed
// by. So the two branches below each need their own bundle entry —
// translating `churn_reason_consistency` alone would translate the one
// sentence nobody reads (#14518).
type: 'conditional' as const,
name: 'churn_reason_consistency',
label: 'Churn Reason Consistency',
Expand Down
5 changes: 5 additions & 0 deletions examples/app-showcase/src/data/objects/task.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -105,6 +105,11 @@ export const Task = ObjectSchema.create({
field: 'status',
// Transitions are validated on update; insert sets the initial state.
events: ['update'] as const,
// Update-only, so 'transition' is honest for the single refusal code
// this rule can raise. Translated at
// `objects.showcase_task._validations.task_status_flow.message` (#14253)
// — an authored message is emitted verbatim unless the bundle carries
// that key, and the bundle wins once it does (#14518).
message: 'Invalid task status transition.',
transitions: {
backlog: ['todo'],
Expand Down
65 changes: 65 additions & 0 deletions examples/app-showcase/src/system/translations/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -94,6 +94,17 @@ export const ShowcaseTranslationBundle = {
},
sync_error: { label: 'Sync Error' },
},
// The object's ONE authored rule message, on the #14253 channel for the
// same reason `showcase_project`'s four are (see the note there). The
// `en` entry is the authored sentence VERBATIM: the bundle WINS over
// `rule.message` in every locale, so a bundle entry that has drifted
// from the object turns the object's own sentence into dead text no
// reader ever sees. The pin asserts that equality rather than trusting it.
_validations: {
task_status_flow: {
message: 'Invalid task status transition.',
},
},
// The FIRST `_views` block on the `en` side of this bundle, and
// deliberately not a mirror of the zh-CN one below: view LABELS are
// already English in `ui/views/task.view.ts`, so restating all fifteen
Expand DownExpand Up@@ -129,6 +140,40 @@ export const ShowcaseTranslationBundle = {
support_config: { label: 'Support Config' },
churn_reason: { label: 'Churn Reason' },
},
// An author-written `validations[].message` is emitted VERBATIM unless
// the bundle carries it here (#14253) — see the note on
// `showcase_project` above. All SEVEN names this object declares, and
// the two NESTED ones are the point: `checkConditional` dispatches to
// the matching branch and renders that BRANCH's message, addressed by
// the branch's own `name`, so `churn_reason_consistency`'s own sentence
// is structurally unreachable and translating only it would leave both
// refusals a caller can actually see in English. Its entry is here
// anyway so the bundle mirrors the DECLARED rule set 1:1 — the pin in
// `test/new-project-wizard-initial-status.test.ts` asks for every
// declared name rather than re-deriving objectql's dispatch.
_validations: {
account_lifecycle: {
message: 'Invalid account lifecycle transition.',
},
tax_id_format: {
message: 'Tax ID must look like 12-3456789.',
},
billing_email_format: {
message: 'Billing Email must be a valid email address.',
},
support_config_shape: {
message: 'Support Config must be { tier: standard|premium|enterprise, seats?: >=1 }.',
},
churn_reason_consistency: {
message: 'Churn reason consistency.',
},
churn_reason_present: {
message: 'A churn reason is required when an account is marked churned.',
},
churn_reason_absent: {
message: 'A churn reason should only be set when the account is churned.',
},
},
},
showcase_contact: {
label: 'Contact',
Expand DownExpand Up@@ -368,6 +413,11 @@ export const ShowcaseTranslationBundle = {
},
sync_error: { label: '同步错误' },
},
// 状态 is the field label above and 状态流转 the same idea
// `showcase_project`'s entry uses — one word per idea across the bundle.
_validations: {
task_status_flow: { message: '任务状态流转无效。' },
},
_views: {
// The default list — keyed `default`, see showcase_project above.
default: { label: '全部任务' },
Expand DownExpand Up@@ -466,6 +516,21 @@ export const ShowcaseTranslationBundle = {
support_config: { label: '支持配置' },
churn_reason: { label: '流失原因' },
},
// The zh-CN mirror of the `en` `_validations` block — see the note
// there. Vocabulary is the one this bundle already established:
// 生命周期 / 税号 / 账单邮箱 / 支持配置 / 流失原因 are the field labels
// right above, so a refusal names the field with the same word the form
// does. The `support_config_shape` shape stays in its source spelling —
// it is a machine contract the author must type back, not prose.
_validations: {
account_lifecycle: { message: '客户生命周期的状态流转无效。' },
tax_id_format: { message: '税号格式应为 12-3456789。' },
billing_email_format: { message: '账单邮箱必须是有效的邮箱地址。' },
support_config_shape: { message: '支持配置必须为 { tier: standard|premium|enterprise, seats?: >=1 }。' },
churn_reason_consistency: { message: '流失原因一致性。' },
churn_reason_present: { message: '客户标记为流失时必须填写流失原因。' },
churn_reason_absent: { message: '只有客户已流失时才能填写流失原因。' },
},
},
showcase_contact: {
label: '联系人',
Expand Down
160 changes: 132 additions & 28 deletions examples/app-showcase/test/new-project-wizard-initial-status.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,22 +23,29 @@
* on it. Asserting only "it throws" would pass against a rejection for any
* other reason — including the `required` check, which is what a naive "just
* drop the field" fix would have tripped.
*
* The second `describe` is #14518 and is deliberately NOT wizard-scoped: the
* per-object translation pin that used to live in the first one is replaced by
* a bundle-wide one, because an instance-scoped pin is what left eight authored
* messages behind when #14311 fixed four.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { ObjectQL } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';

import stack from '../objectstack.config.js';
import { Account, Project } from '../src/data/objects/index.js';
import { NewProjectWizardPage } from '../src/ui/pages/new-project-wizard.page.js';
import { ShowcaseTranslationBundle } from '../src/system/translations/index.js';

type Rule = {
type?: string;
name?: string;
field?: string;
initialStates?: string[];
message?: string;
then?: Rule;
otherwise?: Rule;
};

const APP_ID = 'com.objectstack.showcase';
Expand DownExpand Up@@ -137,33 +144,6 @@ describe('#14311 — the New Project wizard and the status state machine', () =>
expect(defaulted).toEqual(statusRule.initialStates);
});

it('EVERY rule on the object is on the translation channel in both shipped locales', () => {
// An authored `validations[].message` is emitted VERBATIM unless the bundle
// carries `objects.<o>._validations.<rule>.message` (#14253). Scoped to the
// whole object rather than to the status rule on purpose: this one wizard
// can also trip `end_after_start` and `spent_within_budget` from its
// budget/schedule step, so pinning only the status rule would let the single
// English sentence move one step later instead of disappearing.
const rules = ((Project as unknown as { validations?: Rule[] }).validations ?? [])
.filter((r) => typeof r?.name === 'string');
expect(rules.length).toBeGreaterThan(1);

for (const rule of rules) {
const name = rule.name!;
for (const locale of ['en', 'zh-CN'] as const) {
const entry = (ShowcaseTranslationBundle as any)[locale]
?.objects?.showcase_project?._validations?.[name];
expect(entry?.message, `${locale} is missing a message for ${name}`).toBeTruthy();
}
// The zh-CN entry must actually BE Chinese — an English copy satisfies
// "a key exists" while reproducing the defect exactly.
const zh = (ShowcaseTranslationBundle as any)['zh-CN']
.objects.showcase_project._validations[name].message as string;
expect(zh, `${name}'s zh-CN message is not Chinese`).toMatch(/[一-龥]/);
expect(zh, `${name}'s zh-CN message is a copy of the authored one`).not.toBe(rule.message);
}
});

it('creates with the wizard payload and refuses the status it used to offer', async () => {
const engine = await bootShowcase();
const account: any = await engine.insert(
Expand DownExpand Up@@ -201,3 +181,127 @@ describe('#14311 — the New Project wizard and the status state machine', () =>
expect(field.value).toBe('active');
}, 30000);
});

/**
* [#14518] EVERY authored `validations[].message` the showcase declares is on
* the #14253 translation channel, in every locale the app claims to support.
*
* Bundle-wide on purpose. #14311 put `showcase_project`'s four rules on the
* channel and stopped there, because its scope was one wizard — which left
* eight (seven on `showcase_account`, one on `showcase_task`) refusing in
* English inside an otherwise zh-CN error envelope. A pin scoped to one object
* polices that object; the NEXT rule to be declared rots the same way. This
* asks the question of the whole registered surface instead, so a new rule
* without a translation fails here rather than shipping.
*
* Read on the COMPOSED stack — `stack.objects`, `stack.objectExtensions`,
* `stack.translations`, `stack.i18n` — the reachability principle `seed.test.ts`
* documents: what the resolver and the lint gates see is the composed stack,
* not the imported modules. The locale list is the app's OWN claim
* (`i18n.supportedLocales`) rather than a literal, so adding a locale to the
* config puts every authored sentence in scope for it instead of silently
* declaring coverage nobody wrote.
*/
describe('#14518 — every authored validation message in the showcase is translated', () => {
interface AuthoredRule { object: string; name: string; message: string }

/**
* Every named rule an object declares, DESCENDING into `conditional`
* branches.
*
* A `then` / `otherwise` branch is a full rule carrying its own `name`, and
* `checkConditional` renders THAT branch's message — the wrapping rule's
* sentence never reaches a caller. So a flat walk of `validations[]` misses
* exactly the messages a user actually reads, which is what the premise test
* below pins by name.
*/
function authoredRules(objectName: string, validations: unknown): AuthoredRule[] {
const out: AuthoredRule[] = [];
const visit = (rule: Rule | undefined): void => {
if (!rule || typeof rule !== 'object') return;
if (typeof rule.name === 'string' && typeof rule.message === 'string' && rule.message !== '') {
out.push({ object: objectName, name: rule.name, message: rule.message });
}
visit(rule.then);
visit(rule.otherwise);
};
for (const rule of Array.isArray(validations) ? validations : []) visit(rule as Rule);
return out;
}

const declaredRules: AuthoredRule[] = [
...((stack.objects ?? []) as Array<{ name?: string; validations?: unknown }>)
.flatMap((o) => (typeof o?.name === 'string' ? authoredRules(o.name, o.validations) : [])),
// An extension's `validations` MERGE into the target object at
// registration (`ObjectExtensionSchema` carries them), so such a rule is
// addressed under `extend` — not under the extension. None declares one
// today; the walk is here so the first one is not a silent hole.
...((stack.objectExtensions ?? []) as Array<{ extend?: string; validations?: unknown }>)
.flatMap((e) => (typeof e?.extend === 'string' ? authoredRules(e.extend, e.validations) : [])),
];

const locales = (stack.i18n?.supportedLocales ?? []) as string[];
const defaultLocale = (stack.i18n?.defaultLocale ?? 'en') as string;

/** What the resolver would find at `objects.<o>._validations.<rule>.message`. */
function bundleMessage(locale: string, objectName: string, ruleName: string): unknown {
for (const bundle of (stack.translations ?? []) as Array<Record<string, any>>) {
const found = bundle?.[locale]?.objects?.[objectName]?._validations?.[ruleName]?.message;
if (found !== undefined) return found;
}
return undefined;
}

it('the premise: the walk sees the registered surface, nested branches included', () => {
// Without these the assertions below pass vacuously — over no locales, no
// objects, or a rule set that stops at the top level of `validations[]`.
expect(locales).toContain(defaultLocale);
expect(locales.filter((l) => l !== defaultLocale).length).toBeGreaterThan(0);
expect(new Set(declaredRules.map((r) => r.object)).size).toBeGreaterThanOrEqual(3);
expect(declaredRules.map((r) => r.name)).toContain('churn_reason_present');
});

it('every authored rule message has a bundle entry in every supported locale', () => {
// Reported as a LIST rather than one failing assertion per rule: the whole
// population is the finding, and #14311 stopping at four is precisely the
// shape a first-failure-only report encourages.
const missing: string[] = [];
for (const rule of declaredRules) {
for (const locale of locales) {
const message = bundleMessage(locale, rule.object, rule.name);
if (typeof message !== 'string' || message.length === 0) {
missing.push(`${locale}: objects.${rule.object}._validations.${rule.name}.message`);
}
}
}
expect(missing, 'authored messages with no bundle entry refuse in the source language').toEqual([]);
});

it('the default-locale entry is the authored sentence verbatim', () => {
// The bundle WINS over `rule.message` in every locale, `en` included, so an
// entry that has drifted from the object turns the sentence authored beside
// the rule into text no reader ever sees — the object file then documents a
// refusal the app does not give.
for (const rule of declaredRules) {
expect(
bundleMessage(defaultLocale, rule.object, rule.name),
`objects.${rule.object}._validations.${rule.name} (${defaultLocale}) has drifted from the authored message`,
).toBe(rule.message);
}
});

it('a non-default locale is actually translated, not a copy of the source', () => {
for (const locale of locales.filter((l) => l !== defaultLocale)) {
for (const rule of declaredRules) {
const message = bundleMessage(locale, rule.object, rule.name) as string;
// A copy of the English satisfies "a key exists" while reproducing the
// defect exactly — which is the failure mode this whole file is about.
expect(message, `${rule.object}.${rule.name} in ${locale} is a copy of the source`)
.not.toBe(rule.message);
if (locale.startsWith('zh')) {
expect(message, `${rule.object}.${rule.name} in ${locale} is not Chinese`).toMatch(/[一-龥]/);
}
}
}
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/showcase-authored-validation-message-i18n.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
---
"@objectstack/example-showcase": patch
---

fix(showcase): put the remaining eight authored validation messages on the translation channel (#14518)

`showcase_account` declares seven author-written `validations[].message` and
`showcase_task` one, and none of them had an
`objects.OBJECT._validations.RULE.message` entry. An authored message is emitted
VERBATIM without one, so on a `zh-CN` session those refusals arrived in English
beside the platform's own — which have shipped `zh-CN` since #3957 — two
languages inside one `400 VALIDATION_FAILED` envelope. #14311 fixed the same
defect for `showcase_project`; its scope was one wizard, so these were left.

Both nested `conditional` branches get their own entry. `checkConditional`
delegates to the matching branch and renders THAT branch's message, addressed by
the branch's own `name`, so `churn_reason_consistency`'s own sentence is
structurally unreachable — translating only the wrapper would have translated
the one sentence nobody reads. Its entry is kept anyway so the bundle mirrors
the declared rule set 1:1.

The pin is now BUNDLE-WIDE rather than per-object: it walks the composed stack's
objects and object extensions, descends into conditional branches, and asks the
question for every locale `i18n.supportedLocales` claims, so a newly declared
rule without a translation fails instead of rotting. It also pins the
default-locale entry to the authored sentence verbatim — the bundle wins in
every locale, `en` included, so a drifted `en` entry turns the object's own
message into dead text no reader ever sees.
14 changes: 14 additions & 0 deletions examples/app-showcase/src/data/objects/account.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -115,6 +115,13 @@ export const Account = ObjectSchema.create({
// Task/Project: a re-entrant lifecycle (a churned account can be won
// back). Demonstrates the guardrail is just a per-field validation rule
// on the object — no separate metadata type, no separate file.
//
// #14518 — every `message` below is emitted VERBATIM unless the bundle
// carries `objects.showcase_account._validations.<rule>.message` (#14253),
// which is the one way a refusal escapes the caller's language while the
// platform's own refusals arrive translated. All seven are in
// `src/system/translations/index.ts`; the bundle WINS, so rewording a
// sentence here without rewording it there makes this text dead.
validations: [
{
type: 'state_machine' as const,
Expand DownExpand Up@@ -180,6 +187,13 @@ export const Account = ObjectSchema.create({
// non-churned account must NOT carry a stale churn reason. The
// `otherwise` branch only flags an explicitly-set reason (it `has()`-
// guards the absent case), so ordinary non-churned writes are untouched.
//
// The message on THIS rule never reaches a caller: `checkConditional`
// either returns nothing or delegates to the branch, and the branch's
// own `name` is what `objects.<o>._validations.<rule>.message` is keyed
// by. So the two branches below each need their own bundle entry —
// translating `churn_reason_consistency` alone would translate the one
// sentence nobody reads (#14518).
type: 'conditional' as const,
name: 'churn_reason_consistency',
label: 'Churn Reason Consistency',
Expand Down
5 changes: 5 additions & 0 deletions examples/app-showcase/src/data/objects/task.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -105,6 +105,11 @@ export const Task = ObjectSchema.create({
field: 'status',
// Transitions are validated on update; insert sets the initial state.
events: ['update'] as const,
// Update-only, so 'transition' is honest for the single refusal code
// this rule can raise. Translated at
// `objects.showcase_task._validations.task_status_flow.message` (#14253)
// — an authored message is emitted verbatim unless the bundle carries
// that key, and the bundle wins once it does (#14518).
message: 'Invalid task status transition.',
transitions: {
backlog: ['todo'],
Expand Down
65 changes: 65 additions & 0 deletions examples/app-showcase/src/system/translations/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -94,6 +94,17 @@ export const ShowcaseTranslationBundle = {
},
sync_error: { label: 'Sync Error' },
},
// The object's ONE authored rule message, on the #14253 channel for the
// same reason `showcase_project`'s four are (see the note there). The
// `en` entry is the authored sentence VERBATIM: the bundle WINS over
// `rule.message` in every locale, so a bundle entry that has drifted
// from the object turns the object's own sentence into dead text no
// reader ever sees. The pin asserts that equality rather than trusting it.
_validations: {
task_status_flow: {
message: 'Invalid task status transition.',
},
},
// The FIRST `_views` block on the `en` side of this bundle, and
// deliberately not a mirror of the zh-CN one below: view LABELS are
// already English in `ui/views/task.view.ts`, so restating all fifteen
Expand DownExpand Up@@ -129,6 +140,40 @@ export const ShowcaseTranslationBundle = {
support_config: { label: 'Support Config' },
churn_reason: { label: 'Churn Reason' },
},
// An author-written `validations[].message` is emitted VERBATIM unless
// the bundle carries it here (#14253) — see the note on
// `showcase_project` above. All SEVEN names this object declares, and
// the two NESTED ones are the point: `checkConditional` dispatches to
// the matching branch and renders that BRANCH's message, addressed by
// the branch's own `name`, so `churn_reason_consistency`'s own sentence
// is structurally unreachable and translating only it would leave both
// refusals a caller can actually see in English. Its entry is here
// anyway so the bundle mirrors the DECLARED rule set 1:1 — the pin in
// `test/new-project-wizard-initial-status.test.ts` asks for every
// declared name rather than re-deriving objectql's dispatch.
_validations: {
account_lifecycle: {
message: 'Invalid account lifecycle transition.',
},
tax_id_format: {
message: 'Tax ID must look like 12-3456789.',
},
billing_email_format: {
message: 'Billing Email must be a valid email address.',
},
support_config_shape: {
message: 'Support Config must be { tier: standard|premium|enterprise, seats?: >=1 }.',
},
churn_reason_consistency: {
message: 'Churn reason consistency.',
},
churn_reason_present: {
message: 'A churn reason is required when an account is marked churned.',
},
churn_reason_absent: {
message: 'A churn reason should only be set when the account is churned.',
},
},
},
showcase_contact: {
label: 'Contact',
Expand DownExpand Up@@ -368,6 +413,11 @@ export const ShowcaseTranslationBundle = {
},
sync_error: { label: '同步错误' },
},
// 状态 is the field label above and 状态流转 the same idea
// `showcase_project`'s entry uses — one word per idea across the bundle.
_validations: {
task_status_flow: { message: '任务状态流转无效。' },
},
_views: {
// The default list — keyed `default`, see showcase_project above.
default: { label: '全部任务' },
Expand DownExpand Up@@ -466,6 +516,21 @@ export const ShowcaseTranslationBundle = {
support_config: { label: '支持配置' },
churn_reason: { label: '流失原因' },
},
// The zh-CN mirror of the `en` `_validations` block — see the note
// there. Vocabulary is the one this bundle already established:
// 生命周期 / 税号 / 账单邮箱 / 支持配置 / 流失原因 are the field labels
// right above, so a refusal names the field with the same word the form
// does. The `support_config_shape` shape stays in its source spelling —
// it is a machine contract the author must type back, not prose.
_validations: {
account_lifecycle: { message: '客户生命周期的状态流转无效。' },
tax_id_format: { message: '税号格式应为 12-3456789。' },
billing_email_format: { message: '账单邮箱必须是有效的邮箱地址。' },
support_config_shape: { message: '支持配置必须为 { tier: standard|premium|enterprise, seats?: >=1 }。' },
churn_reason_consistency: { message: '流失原因一致性。' },
churn_reason_present: { message: '客户标记为流失时必须填写流失原因。' },
churn_reason_absent: { message: '只有客户已流失时才能填写流失原因。' },
},
},
showcase_contact: {
label: '联系人',
Expand Down
160 changes: 132 additions & 28 deletions examples/app-showcase/test/new-project-wizard-initial-status.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,22 +23,29 @@
* on it. Asserting only "it throws" would pass against a rejection for any
* other reason — including the `required` check, which is what a naive "just
* drop the field" fix would have tripped.
*
* The second `describe` is #14518 and is deliberately NOT wizard-scoped: the
* per-object translation pin that used to live in the first one is replaced by
* a bundle-wide one, because an instance-scoped pin is what left eight authored
* messages behind when #14311 fixed four.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { ObjectQL } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';

import stack from '../objectstack.config.js';
import { Account, Project } from '../src/data/objects/index.js';
import { NewProjectWizardPage } from '../src/ui/pages/new-project-wizard.page.js';
import { ShowcaseTranslationBundle } from '../src/system/translations/index.js';

type Rule = {
type?: string;
name?: string;
field?: string;
initialStates?: string[];
message?: string;
then?: Rule;
otherwise?: Rule;
};

const APP_ID = 'com.objectstack.showcase';
Expand DownExpand Up@@ -137,33 +144,6 @@ describe('#14311 — the New Project wizard and the status state machine', () =>
expect(defaulted).toEqual(statusRule.initialStates);
});

it('EVERY rule on the object is on the translation channel in both shipped locales', () => {
// An authored `validations[].message` is emitted VERBATIM unless the bundle
// carries `objects.<o>._validations.<rule>.message` (#14253). Scoped to the
// whole object rather than to the status rule on purpose: this one wizard
// can also trip `end_after_start` and `spent_within_budget` from its
// budget/schedule step, so pinning only the status rule would let the single
// English sentence move one step later instead of disappearing.
const rules = ((Project as unknown as { validations?: Rule[] }).validations ?? [])
.filter((r) => typeof r?.name === 'string');
expect(rules.length).toBeGreaterThan(1);

for (const rule of rules) {
const name = rule.name!;
for (const locale of ['en', 'zh-CN'] as const) {
const entry = (ShowcaseTranslationBundle as any)[locale]
?.objects?.showcase_project?._validations?.[name];
expect(entry?.message, `${locale} is missing a message for ${name}`).toBeTruthy();
}
// The zh-CN entry must actually BE Chinese — an English copy satisfies
// "a key exists" while reproducing the defect exactly.
const zh = (ShowcaseTranslationBundle as any)['zh-CN']
.objects.showcase_project._validations[name].message as string;
expect(zh, `${name}'s zh-CN message is not Chinese`).toMatch(/[一-龥]/);
expect(zh, `${name}'s zh-CN message is a copy of the authored one`).not.toBe(rule.message);
}
});

it('creates with the wizard payload and refuses the status it used to offer', async () => {
const engine = await bootShowcase();
const account: any = await engine.insert(
Expand DownExpand Up@@ -201,3 +181,127 @@ describe('#14311 — the New Project wizard and the status state machine', () =>
expect(field.value).toBe('active');
}, 30000);
});

/**
* [#14518] EVERY authored `validations[].message` the showcase declares is on
* the #14253 translation channel, in every locale the app claims to support.
*
* Bundle-wide on purpose. #14311 put `showcase_project`'s four rules on the
* channel and stopped there, because its scope was one wizard — which left
* eight (seven on `showcase_account`, one on `showcase_task`) refusing in
* English inside an otherwise zh-CN error envelope. A pin scoped to one object
* polices that object; the NEXT rule to be declared rots the same way. This
* asks the question of the whole registered surface instead, so a new rule
* without a translation fails here rather than shipping.
*
* Read on the COMPOSED stack — `stack.objects`, `stack.objectExtensions`,
* `stack.translations`, `stack.i18n` — the reachability principle `seed.test.ts`
* documents: what the resolver and the lint gates see is the composed stack,
* not the imported modules. The locale list is the app's OWN claim
* (`i18n.supportedLocales`) rather than a literal, so adding a locale to the
* config puts every authored sentence in scope for it instead of silently
* declaring coverage nobody wrote.
*/
describe('#14518 — every authored validation message in the showcase is translated', () => {
interface AuthoredRule { object: string; name: string; message: string }

/**
* Every named rule an object declares, DESCENDING into `conditional`
* branches.
*
* A `then` / `otherwise` branch is a full rule carrying its own `name`, and
* `checkConditional` renders THAT branch's message — the wrapping rule's
* sentence never reaches a caller. So a flat walk of `validations[]` misses
* exactly the messages a user actually reads, which is what the premise test
* below pins by name.
*/
function authoredRules(objectName: string, validations: unknown): AuthoredRule[] {
const out: AuthoredRule[] = [];
const visit = (rule: Rule | undefined): void => {
if (!rule || typeof rule !== 'object') return;
if (typeof rule.name === 'string' && typeof rule.message === 'string' && rule.message !== '') {
out.push({ object: objectName, name: rule.name, message: rule.message });
}
visit(rule.then);
visit(rule.otherwise);
};
for (const rule of Array.isArray(validations) ? validations : []) visit(rule as Rule);
return out;
}

const declaredRules: AuthoredRule[] = [
...((stack.objects ?? []) as Array<{ name?: string; validations?: unknown }>)
.flatMap((o) => (typeof o?.name === 'string' ? authoredRules(o.name, o.validations) : [])),
// An extension's `validations` MERGE into the target object at
// registration (`ObjectExtensionSchema` carries them), so such a rule is
// addressed under `extend` — not under the extension. None declares one
// today; the walk is here so the first one is not a silent hole.
...((stack.objectExtensions ?? []) as Array<{ extend?: string; validations?: unknown }>)
.flatMap((e) => (typeof e?.extend === 'string' ? authoredRules(e.extend, e.validations) : [])),
];

const locales = (stack.i18n?.supportedLocales ?? []) as string[];
const defaultLocale = (stack.i18n?.defaultLocale ?? 'en') as string;

/** What the resolver would find at `objects.<o>._validations.<rule>.message`. */
function bundleMessage(locale: string, objectName: string, ruleName: string): unknown {
for (const bundle of (stack.translations ?? []) as Array<Record<string, any>>) {
const found = bundle?.[locale]?.objects?.[objectName]?._validations?.[ruleName]?.message;
if (found !== undefined) return found;
}
return undefined;
}

it('the premise: the walk sees the registered surface, nested branches included', () => {
// Without these the assertions below pass vacuously — over no locales, no
// objects, or a rule set that stops at the top level of `validations[]`.
expect(locales).toContain(defaultLocale);
expect(locales.filter((l) => l !== defaultLocale).length).toBeGreaterThan(0);
expect(new Set(declaredRules.map((r) => r.object)).size).toBeGreaterThanOrEqual(3);
expect(declaredRules.map((r) => r.name)).toContain('churn_reason_present');
});

it('every authored rule message has a bundle entry in every supported locale', () => {
// Reported as a LIST rather than one failing assertion per rule: the whole
// population is the finding, and #14311 stopping at four is precisely the
// shape a first-failure-only report encourages.
const missing: string[] = [];
for (const rule of declaredRules) {
for (const locale of locales) {
const message = bundleMessage(locale, rule.object, rule.name);
if (typeof message !== 'string' || message.length === 0) {
missing.push(`${locale}: objects.${rule.object}._validations.${rule.name}.message`);
}
}
}
expect(missing, 'authored messages with no bundle entry refuse in the source language').toEqual([]);
});

it('the default-locale entry is the authored sentence verbatim', () => {
// The bundle WINS over `rule.message` in every locale, `en` included, so an
// entry that has drifted from the object turns the sentence authored beside
// the rule into text no reader ever sees — the object file then documents a
// refusal the app does not give.
for (const rule of declaredRules) {
expect(
bundleMessage(defaultLocale, rule.object, rule.name),
`objects.${rule.object}._validations.${rule.name} (${defaultLocale}) has drifted from the authored message`,
).toBe(rule.message);
}
});

it('a non-default locale is actually translated, not a copy of the source', () => {
for (const locale of locales.filter((l) => l !== defaultLocale)) {
for (const rule of declaredRules) {
const message = bundleMessage(locale, rule.object, rule.name) as string;
// A copy of the English satisfies "a key exists" while reproducing the
// defect exactly — which is the failure mode this whole file is about.
expect(message, `${rule.object}.${rule.name} in ${locale} is a copy of the source`)
.not.toBe(rule.message);
if (locale.startsWith('zh')) {
expect(message, `${rule.object}.${rule.name} in ${locale} is not Chinese`).toMatch(/[一-龥]/);
}
}
}
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/showcase-authored-validation-message-i18n.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
---
"@objectstack/example-showcase": patch
---

fix(showcase): put the remaining eight authored validation messages on the translation channel (#14518)

`showcase_account` declares seven author-written `validations[].message` and
`showcase_task` one, and none of them had an
`objects.OBJECT._validations.RULE.message` entry. An authored message is emitted
VERBATIM without one, so on a `zh-CN` session those refusals arrived in English
beside the platform's own — which have shipped `zh-CN` since #3957 — two
languages inside one `400 VALIDATION_FAILED` envelope. #14311 fixed the same
defect for `showcase_project`; its scope was one wizard, so these were left.

Both nested `conditional` branches get their own entry. `checkConditional`
delegates to the matching branch and renders THAT branch's message, addressed by
the branch's own `name`, so `churn_reason_consistency`'s own sentence is
structurally unreachable — translating only the wrapper would have translated
the one sentence nobody reads. Its entry is kept anyway so the bundle mirrors
the declared rule set 1:1.

The pin is now BUNDLE-WIDE rather than per-object: it walks the composed stack's
objects and object extensions, descends into conditional branches, and asks the
question for every locale `i18n.supportedLocales` claims, so a newly declared
rule without a translation fails instead of rotting. It also pins the
default-locale entry to the authored sentence verbatim — the bundle wins in
every locale, `en` included, so a drifted `en` entry turns the object's own
message into dead text no reader ever sees.
14 changes: 14 additions & 0 deletions examples/app-showcase/src/data/objects/account.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -115,6 +115,13 @@ export const Account = ObjectSchema.create({
// Task/Project: a re-entrant lifecycle (a churned account can be won
// back). Demonstrates the guardrail is just a per-field validation rule
// on the object — no separate metadata type, no separate file.
//
// #14518 — every `message` below is emitted VERBATIM unless the bundle
// carries `objects.showcase_account._validations.<rule>.message` (#14253),
// which is the one way a refusal escapes the caller's language while the
// platform's own refusals arrive translated. All seven are in
// `src/system/translations/index.ts`; the bundle WINS, so rewording a
// sentence here without rewording it there makes this text dead.
validations: [
{
type: 'state_machine' as const,
Expand DownExpand Up@@ -180,6 +187,13 @@ export const Account = ObjectSchema.create({
// non-churned account must NOT carry a stale churn reason. The
// `otherwise` branch only flags an explicitly-set reason (it `has()`-
// guards the absent case), so ordinary non-churned writes are untouched.
//
// The message on THIS rule never reaches a caller: `checkConditional`
// either returns nothing or delegates to the branch, and the branch's
// own `name` is what `objects.<o>._validations.<rule>.message` is keyed
// by. So the two branches below each need their own bundle entry —
// translating `churn_reason_consistency` alone would translate the one
// sentence nobody reads (#14518).
type: 'conditional' as const,
name: 'churn_reason_consistency',
label: 'Churn Reason Consistency',
Expand Down
5 changes: 5 additions & 0 deletions examples/app-showcase/src/data/objects/task.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -105,6 +105,11 @@ export const Task = ObjectSchema.create({
field: 'status',
// Transitions are validated on update; insert sets the initial state.
events: ['update'] as const,
// Update-only, so 'transition' is honest for the single refusal code
// this rule can raise. Translated at
// `objects.showcase_task._validations.task_status_flow.message` (#14253)
// — an authored message is emitted verbatim unless the bundle carries
// that key, and the bundle wins once it does (#14518).
message: 'Invalid task status transition.',
transitions: {
backlog: ['todo'],
Expand Down
65 changes: 65 additions & 0 deletions examples/app-showcase/src/system/translations/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -94,6 +94,17 @@ export const ShowcaseTranslationBundle = {
},
sync_error: { label: 'Sync Error' },
},
// The object's ONE authored rule message, on the #14253 channel for the
// same reason `showcase_project`'s four are (see the note there). The
// `en` entry is the authored sentence VERBATIM: the bundle WINS over
// `rule.message` in every locale, so a bundle entry that has drifted
// from the object turns the object's own sentence into dead text no
// reader ever sees. The pin asserts that equality rather than trusting it.
_validations: {
task_status_flow: {
message: 'Invalid task status transition.',
},
},
// The FIRST `_views` block on the `en` side of this bundle, and
// deliberately not a mirror of the zh-CN one below: view LABELS are
// already English in `ui/views/task.view.ts`, so restating all fifteen
Expand DownExpand Up@@ -129,6 +140,40 @@ export const ShowcaseTranslationBundle = {
support_config: { label: 'Support Config' },
churn_reason: { label: 'Churn Reason' },
},
// An author-written `validations[].message` is emitted VERBATIM unless
// the bundle carries it here (#14253) — see the note on
// `showcase_project` above. All SEVEN names this object declares, and
// the two NESTED ones are the point: `checkConditional` dispatches to
// the matching branch and renders that BRANCH's message, addressed by
// the branch's own `name`, so `churn_reason_consistency`'s own sentence
// is structurally unreachable and translating only it would leave both
// refusals a caller can actually see in English. Its entry is here
// anyway so the bundle mirrors the DECLARED rule set 1:1 — the pin in
// `test/new-project-wizard-initial-status.test.ts` asks for every
// declared name rather than re-deriving objectql's dispatch.
_validations: {
account_lifecycle: {
message: 'Invalid account lifecycle transition.',
},
tax_id_format: {
message: 'Tax ID must look like 12-3456789.',
},
billing_email_format: {
message: 'Billing Email must be a valid email address.',
},
support_config_shape: {
message: 'Support Config must be { tier: standard|premium|enterprise, seats?: >=1 }.',
},
churn_reason_consistency: {
message: 'Churn reason consistency.',
},
churn_reason_present: {
message: 'A churn reason is required when an account is marked churned.',
},
churn_reason_absent: {
message: 'A churn reason should only be set when the account is churned.',
},
},
},
showcase_contact: {
label: 'Contact',
Expand DownExpand Up@@ -368,6 +413,11 @@ export const ShowcaseTranslationBundle = {
},
sync_error: { label: '同步错误' },
},
// 状态 is the field label above and 状态流转 the same idea
// `showcase_project`'s entry uses — one word per idea across the bundle.
_validations: {
task_status_flow: { message: '任务状态流转无效。' },
},
_views: {
// The default list — keyed `default`, see showcase_project above.
default: { label: '全部任务' },
Expand DownExpand Up@@ -466,6 +516,21 @@ export const ShowcaseTranslationBundle = {
support_config: { label: '支持配置' },
churn_reason: { label: '流失原因' },
},
// The zh-CN mirror of the `en` `_validations` block — see the note
// there. Vocabulary is the one this bundle already established:
// 生命周期 / 税号 / 账单邮箱 / 支持配置 / 流失原因 are the field labels
// right above, so a refusal names the field with the same word the form
// does. The `support_config_shape` shape stays in its source spelling —
// it is a machine contract the author must type back, not prose.
_validations: {
account_lifecycle: { message: '客户生命周期的状态流转无效。' },
tax_id_format: { message: '税号格式应为 12-3456789。' },
billing_email_format: { message: '账单邮箱必须是有效的邮箱地址。' },
support_config_shape: { message: '支持配置必须为 { tier: standard|premium|enterprise, seats?: >=1 }。' },
churn_reason_consistency: { message: '流失原因一致性。' },
churn_reason_present: { message: '客户标记为流失时必须填写流失原因。' },
churn_reason_absent: { message: '只有客户已流失时才能填写流失原因。' },
},
},
showcase_contact: {
label: '联系人',
Expand Down
160 changes: 132 additions & 28 deletions examples/app-showcase/test/new-project-wizard-initial-status.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,22 +23,29 @@
* on it. Asserting only "it throws" would pass against a rejection for any
* other reason — including the `required` check, which is what a naive "just
* drop the field" fix would have tripped.
*
* The second `describe` is #14518 and is deliberately NOT wizard-scoped: the
* per-object translation pin that used to live in the first one is replaced by
* a bundle-wide one, because an instance-scoped pin is what left eight authored
* messages behind when #14311 fixed four.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { ObjectQL } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';

import stack from '../objectstack.config.js';
import { Account, Project } from '../src/data/objects/index.js';
import { NewProjectWizardPage } from '../src/ui/pages/new-project-wizard.page.js';
import { ShowcaseTranslationBundle } from '../src/system/translations/index.js';

type Rule = {
type?: string;
name?: string;
field?: string;
initialStates?: string[];
message?: string;
then?: Rule;
otherwise?: Rule;
};

const APP_ID = 'com.objectstack.showcase';
Expand DownExpand Up@@ -137,33 +144,6 @@ describe('#14311 — the New Project wizard and the status state machine', () =>
expect(defaulted).toEqual(statusRule.initialStates);
});

it('EVERY rule on the object is on the translation channel in both shipped locales', () => {
// An authored `validations[].message` is emitted VERBATIM unless the bundle
// carries `objects.<o>._validations.<rule>.message` (#14253). Scoped to the
// whole object rather than to the status rule on purpose: this one wizard
// can also trip `end_after_start` and `spent_within_budget` from its
// budget/schedule step, so pinning only the status rule would let the single
// English sentence move one step later instead of disappearing.
const rules = ((Project as unknown as { validations?: Rule[] }).validations ?? [])
.filter((r) => typeof r?.name === 'string');
expect(rules.length).toBeGreaterThan(1);

for (const rule of rules) {
const name = rule.name!;
for (const locale of ['en', 'zh-CN'] as const) {
const entry = (ShowcaseTranslationBundle as any)[locale]
?.objects?.showcase_project?._validations?.[name];
expect(entry?.message, `${locale} is missing a message for ${name}`).toBeTruthy();
}
// The zh-CN entry must actually BE Chinese — an English copy satisfies
// "a key exists" while reproducing the defect exactly.
const zh = (ShowcaseTranslationBundle as any)['zh-CN']
.objects.showcase_project._validations[name].message as string;
expect(zh, `${name}'s zh-CN message is not Chinese`).toMatch(/[一-龥]/);
expect(zh, `${name}'s zh-CN message is a copy of the authored one`).not.toBe(rule.message);
}
});

it('creates with the wizard payload and refuses the status it used to offer', async () => {
const engine = await bootShowcase();
const account: any = await engine.insert(
Expand DownExpand Up@@ -201,3 +181,127 @@ describe('#14311 — the New Project wizard and the status state machine', () =>
expect(field.value).toBe('active');
}, 30000);
});

/**
* [#14518] EVERY authored `validations[].message` the showcase declares is on
* the #14253 translation channel, in every locale the app claims to support.
*
* Bundle-wide on purpose. #14311 put `showcase_project`'s four rules on the
* channel and stopped there, because its scope was one wizard — which left
* eight (seven on `showcase_account`, one on `showcase_task`) refusing in
* English inside an otherwise zh-CN error envelope. A pin scoped to one object
* polices that object; the NEXT rule to be declared rots the same way. This
* asks the question of the whole registered surface instead, so a new rule
* without a translation fails here rather than shipping.
*
* Read on the COMPOSED stack — `stack.objects`, `stack.objectExtensions`,
* `stack.translations`, `stack.i18n` — the reachability principle `seed.test.ts`
* documents: what the resolver and the lint gates see is the composed stack,
* not the imported modules. The locale list is the app's OWN claim
* (`i18n.supportedLocales`) rather than a literal, so adding a locale to the
* config puts every authored sentence in scope for it instead of silently
* declaring coverage nobody wrote.
*/
describe('#14518 — every authored validation message in the showcase is translated', () => {
interface AuthoredRule { object: string; name: string; message: string }

/**
* Every named rule an object declares, DESCENDING into `conditional`
* branches.
*
* A `then` / `otherwise` branch is a full rule carrying its own `name`, and
* `checkConditional` renders THAT branch's message — the wrapping rule's
* sentence never reaches a caller. So a flat walk of `validations[]` misses
* exactly the messages a user actually reads, which is what the premise test
* below pins by name.
*/
function authoredRules(objectName: string, validations: unknown): AuthoredRule[] {
const out: AuthoredRule[] = [];
const visit = (rule: Rule | undefined): void => {
if (!rule || typeof rule !== 'object') return;
if (typeof rule.name === 'string' && typeof rule.message === 'string' && rule.message !== '') {
out.push({ object: objectName, name: rule.name, message: rule.message });
}
visit(rule.then);
visit(rule.otherwise);
};
for (const rule of Array.isArray(validations) ? validations : []) visit(rule as Rule);
return out;
}

const declaredRules: AuthoredRule[] = [
...((stack.objects ?? []) as Array<{ name?: string; validations?: unknown }>)
.flatMap((o) => (typeof o?.name === 'string' ? authoredRules(o.name, o.validations) : [])),
// An extension's `validations` MERGE into the target object at
// registration (`ObjectExtensionSchema` carries them), so such a rule is
// addressed under `extend` — not under the extension. None declares one
// today; the walk is here so the first one is not a silent hole.
...((stack.objectExtensions ?? []) as Array<{ extend?: string; validations?: unknown }>)
.flatMap((e) => (typeof e?.extend === 'string' ? authoredRules(e.extend, e.validations) : [])),
];

const locales = (stack.i18n?.supportedLocales ?? []) as string[];
const defaultLocale = (stack.i18n?.defaultLocale ?? 'en') as string;

/** What the resolver would find at `objects.<o>._validations.<rule>.message`. */
function bundleMessage(locale: string, objectName: string, ruleName: string): unknown {
for (const bundle of (stack.translations ?? []) as Array<Record<string, any>>) {
const found = bundle?.[locale]?.objects?.[objectName]?._validations?.[ruleName]?.message;
if (found !== undefined) return found;
}
return undefined;
}

it('the premise: the walk sees the registered surface, nested branches included', () => {
// Without these the assertions below pass vacuously — over no locales, no
// objects, or a rule set that stops at the top level of `validations[]`.
expect(locales).toContain(defaultLocale);
expect(locales.filter((l) => l !== defaultLocale).length).toBeGreaterThan(0);
expect(new Set(declaredRules.map((r) => r.object)).size).toBeGreaterThanOrEqual(3);
expect(declaredRules.map((r) => r.name)).toContain('churn_reason_present');
});

it('every authored rule message has a bundle entry in every supported locale', () => {
// Reported as a LIST rather than one failing assertion per rule: the whole
// population is the finding, and #14311 stopping at four is precisely the
// shape a first-failure-only report encourages.
const missing: string[] = [];
for (const rule of declaredRules) {
for (const locale of locales) {
const message = bundleMessage(locale, rule.object, rule.name);
if (typeof message !== 'string' || message.length === 0) {
missing.push(`${locale}: objects.${rule.object}._validations.${rule.name}.message`);
}
}
}
expect(missing, 'authored messages with no bundle entry refuse in the source language').toEqual([]);
});

it('the default-locale entry is the authored sentence verbatim', () => {
// The bundle WINS over `rule.message` in every locale, `en` included, so an
// entry that has drifted from the object turns the sentence authored beside
// the rule into text no reader ever sees — the object file then documents a
// refusal the app does not give.
for (const rule of declaredRules) {
expect(
bundleMessage(defaultLocale, rule.object, rule.name),
`objects.${rule.object}._validations.${rule.name} (${defaultLocale}) has drifted from the authored message`,
).toBe(rule.message);
}
});

it('a non-default locale is actually translated, not a copy of the source', () => {
for (const locale of locales.filter((l) => l !== defaultLocale)) {
for (const rule of declaredRules) {
const message = bundleMessage(locale, rule.object, rule.name) as string;
// A copy of the English satisfies "a key exists" while reproducing the
// defect exactly — which is the failure mode this whole file is about.
expect(message, `${rule.object}.${rule.name} in ${locale} is a copy of the source`)
.not.toBe(rule.message);
if (locale.startsWith('zh')) {
expect(message, `${rule.object}.${rule.name} in ${locale} is not Chinese`).toMatch(/[一-龥]/);
}
}
}
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/showcase-authored-validation-message-i18n.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
---
"@objectstack/example-showcase": patch
---

fix(showcase): put the remaining eight authored validation messages on the translation channel (#14518)

`showcase_account` declares seven author-written `validations[].message` and
`showcase_task` one, and none of them had an
`objects.OBJECT._validations.RULE.message` entry. An authored message is emitted
VERBATIM without one, so on a `zh-CN` session those refusals arrived in English
beside the platform's own — which have shipped `zh-CN` since #3957 — two
languages inside one `400 VALIDATION_FAILED` envelope. #14311 fixed the same
defect for `showcase_project`; its scope was one wizard, so these were left.

Both nested `conditional` branches get their own entry. `checkConditional`
delegates to the matching branch and renders THAT branch's message, addressed by
the branch's own `name`, so `churn_reason_consistency`'s own sentence is
structurally unreachable — translating only the wrapper would have translated
the one sentence nobody reads. Its entry is kept anyway so the bundle mirrors
the declared rule set 1:1.

The pin is now BUNDLE-WIDE rather than per-object: it walks the composed stack's
objects and object extensions, descends into conditional branches, and asks the
question for every locale `i18n.supportedLocales` claims, so a newly declared
rule without a translation fails instead of rotting. It also pins the
default-locale entry to the authored sentence verbatim — the bundle wins in
every locale, `en` included, so a drifted `en` entry turns the object's own
message into dead text no reader ever sees.
14 changes: 14 additions & 0 deletions examples/app-showcase/src/data/objects/account.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -115,6 +115,13 @@ export const Account = ObjectSchema.create({
// Task/Project: a re-entrant lifecycle (a churned account can be won
// back). Demonstrates the guardrail is just a per-field validation rule
// on the object — no separate metadata type, no separate file.
//
// #14518 — every `message` below is emitted VERBATIM unless the bundle
// carries `objects.showcase_account._validations.<rule>.message` (#14253),
// which is the one way a refusal escapes the caller's language while the
// platform's own refusals arrive translated. All seven are in
// `src/system/translations/index.ts`; the bundle WINS, so rewording a
// sentence here without rewording it there makes this text dead.
validations: [
{
type: 'state_machine' as const,
Expand DownExpand Up@@ -180,6 +187,13 @@ export const Account = ObjectSchema.create({
// non-churned account must NOT carry a stale churn reason. The
// `otherwise` branch only flags an explicitly-set reason (it `has()`-
// guards the absent case), so ordinary non-churned writes are untouched.
//
// The message on THIS rule never reaches a caller: `checkConditional`
// either returns nothing or delegates to the branch, and the branch's
// own `name` is what `objects.<o>._validations.<rule>.message` is keyed
// by. So the two branches below each need their own bundle entry —
// translating `churn_reason_consistency` alone would translate the one
// sentence nobody reads (#14518).
type: 'conditional' as const,
name: 'churn_reason_consistency',
label: 'Churn Reason Consistency',
Expand Down
5 changes: 5 additions & 0 deletions examples/app-showcase/src/data/objects/task.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -105,6 +105,11 @@ export const Task = ObjectSchema.create({
field: 'status',
// Transitions are validated on update; insert sets the initial state.
events: ['update'] as const,
// Update-only, so 'transition' is honest for the single refusal code
// this rule can raise. Translated at
// `objects.showcase_task._validations.task_status_flow.message` (#14253)
// — an authored message is emitted verbatim unless the bundle carries
// that key, and the bundle wins once it does (#14518).
message: 'Invalid task status transition.',
transitions: {
backlog: ['todo'],
Expand Down
65 changes: 65 additions & 0 deletions examples/app-showcase/src/system/translations/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -94,6 +94,17 @@ export const ShowcaseTranslationBundle = {
},
sync_error: { label: 'Sync Error' },
},
// The object's ONE authored rule message, on the #14253 channel for the
// same reason `showcase_project`'s four are (see the note there). The
// `en` entry is the authored sentence VERBATIM: the bundle WINS over
// `rule.message` in every locale, so a bundle entry that has drifted
// from the object turns the object's own sentence into dead text no
// reader ever sees. The pin asserts that equality rather than trusting it.
_validations: {
task_status_flow: {
message: 'Invalid task status transition.',
},
},
// The FIRST `_views` block on the `en` side of this bundle, and
// deliberately not a mirror of the zh-CN one below: view LABELS are
// already English in `ui/views/task.view.ts`, so restating all fifteen
Expand DownExpand Up@@ -129,6 +140,40 @@ export const ShowcaseTranslationBundle = {
support_config: { label: 'Support Config' },
churn_reason: { label: 'Churn Reason' },
},
// An author-written `validations[].message` is emitted VERBATIM unless
// the bundle carries it here (#14253) — see the note on
// `showcase_project` above. All SEVEN names this object declares, and
// the two NESTED ones are the point: `checkConditional` dispatches to
// the matching branch and renders that BRANCH's message, addressed by
// the branch's own `name`, so `churn_reason_consistency`'s own sentence
// is structurally unreachable and translating only it would leave both
// refusals a caller can actually see in English. Its entry is here
// anyway so the bundle mirrors the DECLARED rule set 1:1 — the pin in
// `test/new-project-wizard-initial-status.test.ts` asks for every
// declared name rather than re-deriving objectql's dispatch.
_validations: {
account_lifecycle: {
message: 'Invalid account lifecycle transition.',
},
tax_id_format: {
message: 'Tax ID must look like 12-3456789.',
},
billing_email_format: {
message: 'Billing Email must be a valid email address.',
},
support_config_shape: {
message: 'Support Config must be { tier: standard|premium|enterprise, seats?: >=1 }.',
},
churn_reason_consistency: {
message: 'Churn reason consistency.',
},
churn_reason_present: {
message: 'A churn reason is required when an account is marked churned.',
},
churn_reason_absent: {
message: 'A churn reason should only be set when the account is churned.',
},
},
},
showcase_contact: {
label: 'Contact',
Expand DownExpand Up@@ -368,6 +413,11 @@ export const ShowcaseTranslationBundle = {
},
sync_error: { label: '同步错误' },
},
// 状态 is the field label above and 状态流转 the same idea
// `showcase_project`'s entry uses — one word per idea across the bundle.
_validations: {
task_status_flow: { message: '任务状态流转无效。' },
},
_views: {
// The default list — keyed `default`, see showcase_project above.
default: { label: '全部任务' },
Expand DownExpand Up@@ -466,6 +516,21 @@ export const ShowcaseTranslationBundle = {
support_config: { label: '支持配置' },
churn_reason: { label: '流失原因' },
},
// The zh-CN mirror of the `en` `_validations` block — see the note
// there. Vocabulary is the one this bundle already established:
// 生命周期 / 税号 / 账单邮箱 / 支持配置 / 流失原因 are the field labels
// right above, so a refusal names the field with the same word the form
// does. The `support_config_shape` shape stays in its source spelling —
// it is a machine contract the author must type back, not prose.
_validations: {
account_lifecycle: { message: '客户生命周期的状态流转无效。' },
tax_id_format: { message: '税号格式应为 12-3456789。' },
billing_email_format: { message: '账单邮箱必须是有效的邮箱地址。' },
support_config_shape: { message: '支持配置必须为 { tier: standard|premium|enterprise, seats?: >=1 }。' },
churn_reason_consistency: { message: '流失原因一致性。' },
churn_reason_present: { message: '客户标记为流失时必须填写流失原因。' },
churn_reason_absent: { message: '只有客户已流失时才能填写流失原因。' },
},
},
showcase_contact: {
label: '联系人',
Expand Down
160 changes: 132 additions & 28 deletions examples/app-showcase/test/new-project-wizard-initial-status.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,22 +23,29 @@
* on it. Asserting only "it throws" would pass against a rejection for any
* other reason — including the `required` check, which is what a naive "just
* drop the field" fix would have tripped.
*
* The second `describe` is #14518 and is deliberately NOT wizard-scoped: the
* per-object translation pin that used to live in the first one is replaced by
* a bundle-wide one, because an instance-scoped pin is what left eight authored
* messages behind when #14311 fixed four.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { ObjectQL } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';

import stack from '../objectstack.config.js';
import { Account, Project } from '../src/data/objects/index.js';
import { NewProjectWizardPage } from '../src/ui/pages/new-project-wizard.page.js';
import { ShowcaseTranslationBundle } from '../src/system/translations/index.js';

type Rule = {
type?: string;
name?: string;
field?: string;
initialStates?: string[];
message?: string;
then?: Rule;
otherwise?: Rule;
};

const APP_ID = 'com.objectstack.showcase';
Expand DownExpand Up@@ -137,33 +144,6 @@ describe('#14311 — the New Project wizard and the status state machine', () =>
expect(defaulted).toEqual(statusRule.initialStates);
});

it('EVERY rule on the object is on the translation channel in both shipped locales', () => {
// An authored `validations[].message` is emitted VERBATIM unless the bundle
// carries `objects.<o>._validations.<rule>.message` (#14253). Scoped to the
// whole object rather than to the status rule on purpose: this one wizard
// can also trip `end_after_start` and `spent_within_budget` from its
// budget/schedule step, so pinning only the status rule would let the single
// English sentence move one step later instead of disappearing.
const rules = ((Project as unknown as { validations?: Rule[] }).validations ?? [])
.filter((r) => typeof r?.name === 'string');
expect(rules.length).toBeGreaterThan(1);

for (const rule of rules) {
const name = rule.name!;
for (const locale of ['en', 'zh-CN'] as const) {
const entry = (ShowcaseTranslationBundle as any)[locale]
?.objects?.showcase_project?._validations?.[name];
expect(entry?.message, `${locale} is missing a message for ${name}`).toBeTruthy();
}
// The zh-CN entry must actually BE Chinese — an English copy satisfies
// "a key exists" while reproducing the defect exactly.
const zh = (ShowcaseTranslationBundle as any)['zh-CN']
.objects.showcase_project._validations[name].message as string;
expect(zh, `${name}'s zh-CN message is not Chinese`).toMatch(/[一-龥]/);
expect(zh, `${name}'s zh-CN message is a copy of the authored one`).not.toBe(rule.message);
}
});

it('creates with the wizard payload and refuses the status it used to offer', async () => {
const engine = await bootShowcase();
const account: any = await engine.insert(
Expand DownExpand Up@@ -201,3 +181,127 @@ describe('#14311 — the New Project wizard and the status state machine', () =>
expect(field.value).toBe('active');
}, 30000);
});

/**
* [#14518] EVERY authored `validations[].message` the showcase declares is on
* the #14253 translation channel, in every locale the app claims to support.
*
* Bundle-wide on purpose. #14311 put `showcase_project`'s four rules on the
* channel and stopped there, because its scope was one wizard — which left
* eight (seven on `showcase_account`, one on `showcase_task`) refusing in
* English inside an otherwise zh-CN error envelope. A pin scoped to one object
* polices that object; the NEXT rule to be declared rots the same way. This
* asks the question of the whole registered surface instead, so a new rule
* without a translation fails here rather than shipping.
*
* Read on the COMPOSED stack — `stack.objects`, `stack.objectExtensions`,
* `stack.translations`, `stack.i18n` — the reachability principle `seed.test.ts`
* documents: what the resolver and the lint gates see is the composed stack,
* not the imported modules. The locale list is the app's OWN claim
* (`i18n.supportedLocales`) rather than a literal, so adding a locale to the
* config puts every authored sentence in scope for it instead of silently
* declaring coverage nobody wrote.
*/
describe('#14518 — every authored validation message in the showcase is translated', () => {
interface AuthoredRule { object: string; name: string; message: string }

/**
* Every named rule an object declares, DESCENDING into `conditional`
* branches.
*
* A `then` / `otherwise` branch is a full rule carrying its own `name`, and
* `checkConditional` renders THAT branch's message — the wrapping rule's
* sentence never reaches a caller. So a flat walk of `validations[]` misses
* exactly the messages a user actually reads, which is what the premise test
* below pins by name.
*/
function authoredRules(objectName: string, validations: unknown): AuthoredRule[] {
const out: AuthoredRule[] = [];
const visit = (rule: Rule | undefined): void => {
if (!rule || typeof rule !== 'object') return;
if (typeof rule.name === 'string' && typeof rule.message === 'string' && rule.message !== '') {
out.push({ object: objectName, name: rule.name, message: rule.message });
}
visit(rule.then);
visit(rule.otherwise);
};
for (const rule of Array.isArray(validations) ? validations : []) visit(rule as Rule);
return out;
}

const declaredRules: AuthoredRule[] = [
...((stack.objects ?? []) as Array<{ name?: string; validations?: unknown }>)
.flatMap((o) => (typeof o?.name === 'string' ? authoredRules(o.name, o.validations) : [])),
// An extension's `validations` MERGE into the target object at
// registration (`ObjectExtensionSchema` carries them), so such a rule is
// addressed under `extend` — not under the extension. None declares one
// today; the walk is here so the first one is not a silent hole.
...((stack.objectExtensions ?? []) as Array<{ extend?: string; validations?: unknown }>)
.flatMap((e) => (typeof e?.extend === 'string' ? authoredRules(e.extend, e.validations) : [])),
];

const locales = (stack.i18n?.supportedLocales ?? []) as string[];
const defaultLocale = (stack.i18n?.defaultLocale ?? 'en') as string;

/** What the resolver would find at `objects.<o>._validations.<rule>.message`. */
function bundleMessage(locale: string, objectName: string, ruleName: string): unknown {
for (const bundle of (stack.translations ?? []) as Array<Record<string, any>>) {
const found = bundle?.[locale]?.objects?.[objectName]?._validations?.[ruleName]?.message;
if (found !== undefined) return found;
}
return undefined;
}

it('the premise: the walk sees the registered surface, nested branches included', () => {
// Without these the assertions below pass vacuously — over no locales, no
// objects, or a rule set that stops at the top level of `validations[]`.
expect(locales).toContain(defaultLocale);
expect(locales.filter((l) => l !== defaultLocale).length).toBeGreaterThan(0);
expect(new Set(declaredRules.map((r) => r.object)).size).toBeGreaterThanOrEqual(3);
expect(declaredRules.map((r) => r.name)).toContain('churn_reason_present');
});

it('every authored rule message has a bundle entry in every supported locale', () => {
// Reported as a LIST rather than one failing assertion per rule: the whole
// population is the finding, and #14311 stopping at four is precisely the
// shape a first-failure-only report encourages.
const missing: string[] = [];
for (const rule of declaredRules) {
for (const locale of locales) {
const message = bundleMessage(locale, rule.object, rule.name);
if (typeof message !== 'string' || message.length === 0) {
missing.push(`${locale}: objects.${rule.object}._validations.${rule.name}.message`);
}
}
}
expect(missing, 'authored messages with no bundle entry refuse in the source language').toEqual([]);
});

it('the default-locale entry is the authored sentence verbatim', () => {
// The bundle WINS over `rule.message` in every locale, `en` included, so an
// entry that has drifted from the object turns the sentence authored beside
// the rule into text no reader ever sees — the object file then documents a
// refusal the app does not give.
for (const rule of declaredRules) {
expect(
bundleMessage(defaultLocale, rule.object, rule.name),
`objects.${rule.object}._validations.${rule.name} (${defaultLocale}) has drifted from the authored message`,
).toBe(rule.message);
}
});

it('a non-default locale is actually translated, not a copy of the source', () => {
for (const locale of locales.filter((l) => l !== defaultLocale)) {
for (const rule of declaredRules) {
const message = bundleMessage(locale, rule.object, rule.name) as string;
// A copy of the English satisfies "a key exists" while reproducing the
// defect exactly — which is the failure mode this whole file is about.
expect(message, `${rule.object}.${rule.name} in ${locale} is a copy of the source`)
.not.toBe(rule.message);
if (locale.startsWith('zh')) {
expect(message, `${rule.object}.${rule.name} in ${locale} is not Chinese`).toMatch(/[一-龥]/);
}
}
}
});
});
Loading