From ec3bd8cdbc14d64aab821d625e84538179c48e1a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 20:27:13 +0000 Subject: [PATCH 1/2] fix(fields,i18n): key LocationField's residue refusal sentence, answering arity explicitly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectui#6715 added a THIRD refusal arm to LocationField — a half that is only partly a number (`12abc, 34`) — after objectui#6755's ruling had already keyed the other two. All three arms share one `

` and one `refusalError` state, so that single line spoke the reader's language on two arms and English on the third. The arity question the other two sentences never had to answer is answered here rather than defaulted. `verb` was English grammar (`is not a number` / `are not numbers`); a pack whose plural rules differ cannot inflect around an English verb form passed through a hole. So the verb lives inside two SIBLING keys picked at the call site — this repo's own plural convention (`lookup.recordCount`/`recordCountOne` in the same defaults map), not i18next's `_one`/`_other` suffixes, which zh/ja/ko would legitimately omit and `all-locales-key-parity` would read as a missing key. The English conjunction and the coordinate nouns go the same way: `latitude` and `longitude` are keyed once each and interpolated into both arities, leaving only the characters the person typed in holes. English is byte-identical in both arities, so objectui#6715's own strictNumeric pins and plugin-form's ObjectForm.locationResidue suite are untouched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S --- .../6888-location-residue-refusal-keyed.md | 48 +++ .../LocationField.residueI18n-6888.test.tsx | 304 ++++++++++++++++++ packages/fields/src/widgets/LocationField.tsx | 79 ++++- .../fields/src/widgets/useFieldTranslation.ts | 22 ++ .../__tests__/de-quote-pairing-3876.test.ts | 12 +- packages/i18n/src/locales/ar.ts | 6 + packages/i18n/src/locales/de.ts | 6 + packages/i18n/src/locales/en.ts | 6 + packages/i18n/src/locales/es.ts | 6 + packages/i18n/src/locales/fr.ts | 6 + packages/i18n/src/locales/ja.ts | 6 + packages/i18n/src/locales/ko.ts | 6 + packages/i18n/src/locales/pt.ts | 6 + packages/i18n/src/locales/ru.ts | 6 + packages/i18n/src/locales/zh.ts | 6 + 15 files changed, 511 insertions(+), 14 deletions(-) create mode 100644 .changeset/6888-location-residue-refusal-keyed.md create mode 100644 packages/fields/src/__tests__/LocationField.residueI18n-6888.test.tsx diff --git a/.changeset/6888-location-residue-refusal-keyed.md b/.changeset/6888-location-residue-refusal-keyed.md new file mode 100644 index 0000000000..125bddc61c --- /dev/null +++ b/.changeset/6888-location-residue-refusal-keyed.md @@ -0,0 +1,48 @@ +--- +'@object-ui/fields': patch +'@object-ui/i18n': patch +--- + +Key `LocationField`'s THIRD refusal sentence — the residue arm — into the locale +packs (objectui#6888). + +Typing a half that is only PARTLY a number (`12abc, 34`) is refused by +`LocationField` with its own sentence, added by objectui#6715. objectui#6755 had +ruled two weeks earlier that a widget's own refusal sentence goes through +`useFieldTranslation` + `FIELD_DEFAULTS`, and named three sentences — but it was +written on 2026-08-29 14:53 and this arm landed after, so it stayed a hard-coded +English literal while its two siblings were keyed. + +**The consequence was worse than one more English string.** All three refusal arms +render through the SAME `

` and the same `refusalError` state, so after #6755 +landed that one line spoke the reader's language when the format or range arm fired +and English when the residue arm did — objectui#4028's shape ("four Chinese labels +around one English one") compressed into a single sentence position, which reads to +a user as a bug rather than as a missing translation. + +**Arity is answered explicitly, not defaulted.** Unlike the other two sentences, this +one had grammatical number: `verb` was `is not a number` / `are not numbers`, chosen +in TypeScript. Handing a pack an English verb form through a `{{hole}}` gives it a +fragment it cannot inflect around — Arabic has a DUAL, and two halves is exactly that +case. So the verb is not a hole. It lives inside two SIBLING keys picked at the call +site, `fields.location.refusedResidue` and `fields.location.refusedResidueOne`, +following this repo's own plural convention rather than i18next's `_one`/`_other` +suffixes — the same shape `RecordPickerDialog` already uses in this very defaults map +(`lookup.recordCount` / `lookup.recordCountOne`), and for the reason `ReactionPicker` +states in source: zh/ja/ko have no separate singular form, would legitimately omit a +`_one` half, and `all-locales-key-parity` reads that as a missing key. The `ar` pack +now uses its dual (`ليسا رقمين`), which the old implementation could not have produced. + +The English conjunction `' and '` and the coordinate NOUNS go the same way: each pack +writes its own conjunction inside the two-half value, and `latitude` / `longitude` +become `fields.location.latitude` / `fields.location.longitude`, keyed once each and +interpolated into both arities so no locale holds two spellings of the same word. The +only holes carrying untranslated data are `{{text}}` / `{{otherText}}` — the +characters the person actually typed. + +**No behaviour moves.** The English values are byte-identical to the literal they +replace in both arities, verified by `check:i18n-drift` (0 en values changed, 4 added) +and by objectui#6715's own `LocationField.strictNumeric.test.tsx` and `plugin-form`'s +`ObjectForm.locationResidue.test.tsx` passing untouched. Provider-less rendering is +unchanged, the refusal itself is unchanged, and the four new keys are bound from here +on by `check:i18n-keys` and `all-locales-key-parity` like their three siblings. diff --git a/packages/fields/src/__tests__/LocationField.residueI18n-6888.test.tsx b/packages/fields/src/__tests__/LocationField.residueI18n-6888.test.tsx new file mode 100644 index 0000000000..7e695ccee4 --- /dev/null +++ b/packages/fields/src/__tests__/LocationField.residueI18n-6888.test.tsx @@ -0,0 +1,304 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#6888 — `LocationField`'s THIRD refusal sentence reaches the packs. + * + * objectui#6755 ruled that a widget's own refusal sentence goes through + * `useFieldTranslation` + `FIELD_DEFAULTS`, and named three sentences. The + * residue arm (`12abc, 34` — a half that is only PARTLY a number) landed from + * objectui#6715 AFTER that ruling was written, so it was outside the count and + * stayed a literal. Triage ruled applying the same principle to it in-lane + * execution rather than a new adjudication. + * + * The consequence this file pins is not "one more English string": all three + * arms render through the SAME `

` and the same `refusalError`, so one line + * spoke the reader's language on two arms and English on the third — + * objectui#4028's shape ("four Chinese labels around one English one") + * compressed into a single sentence position. + * + * ## ⭐ The arity question this arm had to answer, and what is asserted about it + * + * The other two sentences have no grammatical number. This one did: `verb` was + * `is not a number` / `are not numbers`, chosen in TypeScript. Passing that + * through a `{{hole}}` hands a pack an English verb form it cannot inflect + * around — Arabic has a DUAL, and two halves is exactly the case where it + * applies. + * + * The answer taken, and asserted below, is this repo's OWN plural convention: + * two SIBLING keys picked at the call site (`refusedResidue` / + * `refusedResidueOne`), ⛔ not an i18next `_one`/`_other` family. The precedent + * followed is `RecordPickerDialog`'s `lookup.recordCount` / `recordCountOne` — + * the same package, the same `FIELD_DEFAULTS` map — with + * `list.recordCount`/`recordCountOne` and `detail.reactionCount`/ + * `reactionCountOne` as the same shape elsewhere. `ReactionPicker` states the + * reason in source: zh/ja/ko have no separate singular form, would legitimately + * omit a `_one` half, and `all-locales-key-parity` reads that as a missing key. + * + * ⭐ `ARITY_IS_IN_THE_VALUE` below is the assertion that distinguishes this + * answer from the one that was NOT taken. If the verb were a hole, every pack's + * two sentences would differ only where the widget substituted an English word, + * and `ar`'s dual could not exist at all. So the test demands that each pack's + * one-half and two-half sentences differ in their own script — and pins the + * Arabic dual (`ليسا رقمين`) by name. + * + * ## Shape inherited from `widget-diagnostics-i18n-6755.test.tsx` + * + * - **Positive AND negative together.** `createSafeTranslation` falls back to + * `FIELD_DEFAULTS[key]`, which is byte-identical to the literal it replaced — + * so a pack that resolves NOTHING renders exactly what the hard-coded string + * used to. Only "says the translated thing" plus "no English survives" + * separates keyed from still-hard-coded. + * - **A positive control for the pack read, in this run.** `AddressField`'s + * `fields.address.*` keys were keyed by objectui#4028; if the provider were + * dead, that control fails too and no negative assertion here could be read + * as a pass. + * - **`en` and provider-less are NO-OP pins.** The English values are + * byte-identical to the literal, which is why objectui#6715's own + * `LocationField.strictNumeric.test.tsx` and `plugin-form`'s + * `ObjectForm.locationResidue.test.tsx` are untouched by this card. Asserting + * them here states that as a fact of THIS change rather than a hope. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { I18nProvider, builtInLocales } from '@object-ui/i18n'; + +import { LocationField } from '../widgets/LocationField'; +import { AddressField } from '../widgets/AddressField'; + +const locationField = { name: 'site', label: 'Site', type: 'location' } as any; +const addressField = { name: 'billing_address', type: 'address' } as any; + +/** Text whose FIRST half is a number with residue after it; the second is clean. */ +const ONE_HALF = '12abc, 34'; +/** Text whose SECOND half is the offending one — the other order. */ +const OTHER_HALF = '30.27, 120abc'; +/** Text where BOTH halves carry residue. */ +const BOTH_HALVES = '12abc, 34xyz'; + +/** The English sentences this card keyed — byte-identical to the old literal. */ +const EN_ONE_HALF = + 'Not saved: latitude "12abc" is not a number. Enter plain decimals (example: 30.2741, 120.1551).'; +const EN_OTHER_HALF = + 'Not saved: longitude "120abc" is not a number. Enter plain decimals (example: 30.2741, 120.1551).'; +const EN_BOTH_HALVES = + 'Not saved: latitude "12abc" and longitude "34xyz" are not numbers. ' + + 'Enter plain decimals (example: 30.2741, 120.1551).'; + +/** + * English fragments that must NOT survive a translated render. + * + * The verb and the conjunction are the grammar the old implementation chose in + * TypeScript; the two nouns are the words it never let a pack see. All four are + * the defect, so all four are asserted absent rather than just the sentence. + */ +const ENGLISH_FRAGMENTS = ['is not a number', 'are not numbers', ' and ', 'latitude', 'longitude']; + +function renderIn(language: string, element: React.ReactElement) { + return render( + + {element} + , + ); +} + +/** The widget's own diagnostic line, or `null` when it announces nothing. */ +function diagnostic(container: HTMLElement): string | null { + const p = container.querySelector('p'); + return p ? p.textContent : null; +} + +function typeInto(container: HTMLElement, text: string) { + fireEvent.change(container.querySelector('input') as HTMLElement, { target: { value: text } }); +} + +/** Render `text` into a fresh widget under `language` and read the diagnostic. */ +function refusalIn(language: string | null, text: string): string | null { + const element = ; + const { container } = language === null ? render(element) : renderIn(language, element); + typeInto(container, text); + return diagnostic(container); +} + +beforeEach(() => { + cleanup(); +}); + +/* -------------------------------------------------------------------------- */ +/* The control: a key that ALREADY resolves through this channel. */ +/* -------------------------------------------------------------------------- */ + +describe('the locale channel is live in this run (control for objectui#6888)', () => { + it('resolves fields.address.* — keyed by objectui#4028 — under zh', () => { + // Without this, a blank or English diagnostic below would be a dead + // provider rather than a missing key, and every negative assertion in this + // file would pass for the wrong reason. + renderIn('zh', ); + expect(screen.getByLabelText('街道地址')).toBeInTheDocument(); + expect(screen.getByLabelText('城市')).toBeInTheDocument(); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* English is byte-identical — the claim #6715's and plugin-form's pins rest on. */ +/* -------------------------------------------------------------------------- */ + +describe('the English default is byte-identical after keying (objectui#6888)', () => { + it.each([ + ['one half', ONE_HALF, EN_ONE_HALF], + ['the other half', OTHER_HALF, EN_OTHER_HALF], + ['both halves', BOTH_HALVES, EN_BOTH_HALVES], + ])('renders the same sentence for %s under an en provider', (_label, typed, expected) => { + expect(refusalIn('en', typed)).toBe(expected); + }); + + it.each([ + ['one half', ONE_HALF, EN_ONE_HALF], + ['the other half', OTHER_HALF, EN_OTHER_HALF], + ['both halves', BOTH_HALVES, EN_BOTH_HALVES], + ])('renders the same sentence for %s with NO provider at all', (_label, typed, expected) => { + expect(refusalIn(null, typed)).toBe(expected); + }); + + it('still names only the offending half, as objectui#6715 requires', () => { + // The half-selection rule is the widget's, not the locale's: keying the + // sentence must not start naming a coordinate that parsed cleanly. + expect(refusalIn('en', OTHER_HALF)).not.toContain('latitude'); + expect(refusalIn('en', ONE_HALF)).not.toContain('longitude'); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* The reader's language, on BOTH arities, with no English left behind. */ +/* -------------------------------------------------------------------------- */ + +describe('the residue refusal speaks the reader\'s language (objectui#6888)', () => { + it.each([ + ['zh', '未保存:纬度“12abc”不是数字。请输入普通小数(例如 30.2741, 120.1551)。'], + ['ja', '保存されていません: 緯度「12abc」は数値ではありません。通常の小数で入力してください(例: 30.2741, 120.1551)。'], + ['ar', 'لم يتم الحفظ: خط العرض «12abc» ليس رقمًا. أدخل أرقامًا عشرية عادية (مثال: 30.2741, 120.1551).'], + ])('says the ONE-half refusal in %s', (language, expected) => { + expect(refusalIn(language, ONE_HALF)).toBe(expected); + }); + + it.each([ + ['zh', '未保存:纬度“12abc”和经度“34xyz”不是数字。请输入普通小数(例如 30.2741, 120.1551)。'], + ['ja', '保存されていません: 緯度「12abc」と経度「34xyz」は数値ではありません。通常の小数で入力してください(例: 30.2741, 120.1551)。'], + ['ar', 'لم يتم الحفظ: خط العرض «12abc» وخط الطول «34xyz» ليسا رقمين. أدخل أرقامًا عشرية عادية (مثال: 30.2741, 120.1551).'], + ])('says the TWO-half refusal in %s', (language, expected) => { + expect(refusalIn(language, BOTH_HALVES)).toBe(expected); + }); + + it.each([ + ['zh', ONE_HALF], + ['zh', BOTH_HALVES], + ['ja', ONE_HALF], + ['ja', BOTH_HALVES], + ['ar', ONE_HALF], + ['ar', BOTH_HALVES], + ])('leaves no English grammar behind in %s for %s', (language, typed) => { + const message = refusalIn(language, typed) ?? ''; + // The negative half of the pair. `createSafeTranslation` renders the + // English default when a key does not resolve, so this is what tells a + // keyed sentence from a still-hard-coded one. + for (const fragment of ENGLISH_FRAGMENTS) { + expect(message, `${language} kept the English fragment "${fragment}"`).not.toContain(fragment); + } + // The typed text and the example coordinates DO stay ASCII: one is what + // the person wrote, the other is what the box asks them to type. + expect(message).toContain('30.2741, 120.1551'); + expect(message).toContain('12abc'); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* ⭐ The arity answer itself, asserted against the design NOT taken. */ +/* -------------------------------------------------------------------------- */ + +describe('arity lives in the pack VALUE, not in a hole (objectui#6888)', () => { + const LANGS = ['en', 'zh', 'ja', 'ko', 'de', 'es', 'fr', 'pt', 'ru', 'ar'] as const; + + it('gives every pack two SIBLING keys, not an i18next _one/_other family', () => { + // `_one` would be a key `en` could carry and zh/ja/ko could not, which + // `all-locales-key-parity` fails by design (objectstack#5430); the suffix + // form would also need a base key for the categories no pack enumerates + // (ru few/many, ar two/zero — objectui#3863). + for (const lang of LANGS) { + const location = (builtInLocales as any)[lang].fields.location; + expect(Object.keys(location), `${lang}`).toEqual( + expect.arrayContaining(['refusedResidue', 'refusedResidueOne', 'latitude', 'longitude']), + ); + expect(Object.keys(location).filter((k) => k.includes('_one') || k.includes('_other'))).toEqual([]); + } + }); + + it('lets each pack inflect its own verb — the two arities differ in every language', () => { + // ⭐ THE assertion that separates the answer taken from the rejected one. + // With `verb` as a hole, both values would be one sentence and the + // difference would be an English word the widget substituted. + for (const lang of LANGS) { + const location = (builtInLocales as any)[lang].fields.location; + const one = location.refusedResidueOne.replace(/\{\{\w+\}\}/g, ''); + const two = location.refusedResidue.replace(/\{\{\w+\}\}/g, ''); + expect(one, `${lang} states both arities identically`).not.toBe(two); + } + }); + + it('lets ar use its DUAL, which an English verb hole could not express', () => { + // Arabic distinguishes two from many. Two halves is exactly two, so the + // dual is the correct form and it exists only because the whole sentence — + // verb included — is the pack's to write. + expect((builtInLocales as any).ar.fields.location.refusedResidue).toContain('ليسا رقمين'); + expect((builtInLocales as any).ar.fields.location.refusedResidueOne).toContain('ليس رقمًا'); + }); + + it('keys each coordinate noun ONCE, so no pack holds two spellings of it', () => { + // The nouns are interpolated into both arities from a single key rather + // than written into each sentence, which is why `fields.location.latitude` + // exists at all. + for (const lang of LANGS) { + const location = (builtInLocales as any)[lang].fields.location; + expect(location.refusedResidueOne, `${lang}`).toContain('{{name}}'); + expect(location.refusedResidue, `${lang}`).toContain('{{name}}'); + expect(location.refusedResidue, `${lang}`).toContain('{{otherName}}'); + expect(typeof location.latitude).toBe('string'); + expect(typeof location.longitude).toBe('string'); + } + }); +}); + +/* -------------------------------------------------------------------------- */ +/* Keying the sentence changed the sentence only. */ +/* -------------------------------------------------------------------------- */ + +describe('the refusal itself is unchanged by keying it (objectui#6888)', () => { + it('still refuses the value it announced about, under a translated provider', () => { + const onChange = vi.fn(); + const { container } = renderIn('zh', ); + typeInto(container, BOTH_HALVES); + // objectui#6715's rule, inherited rather than re-decided here. + expect(onChange).not.toHaveBeenCalled(); + expect(container.querySelector('input')).toHaveAttribute('aria-invalid', 'true'); + // The refused text stays in the box so the message has something to point at. + expect(container.querySelector('input')).toHaveValue(BOTH_HALVES); + }); + + it('shares ONE diagnostic element with the other two arms', () => { + // The reason this card exists: a second `

` would have made the mixed + // languages two separate lines rather than one, and far less confusing. + const { container } = renderIn('zh', ); + typeInto(container, 'not a coordinate'); + expect(container.querySelectorAll('p')).toHaveLength(1); + typeInto(container, BOTH_HALVES); + expect(container.querySelectorAll('p')).toHaveLength(1); + // Both arms now speak the same language in that one element. + expect(diagnostic(container)).toContain('未保存:'); + }); +}); diff --git a/packages/fields/src/widgets/LocationField.tsx b/packages/fields/src/widgets/LocationField.tsx index 9ecb878610..046c63c33e 100644 --- a/packages/fields/src/widgets/LocationField.tsx +++ b/packages/fields/src/widgets/LocationField.tsx @@ -191,8 +191,17 @@ const WHOLE_NUMBER_TEXT = /^[+-]?(?:Infinity|(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]? /** The two coordinates, in the order they are typed, named for the diagnostic. */ const COORDINATE_LABELS = ['latitude', 'longitude'] as const; -/** One half of the typed pair that carried non-numeric residue. */ -type ResidueHalf = { label: string; text: string }; +/** Which coordinate a half is — the key half of `COORDINATE_LABELS`. */ +type CoordinateLabel = (typeof COORDINATE_LABELS)[number]; + +/** + * One half of the typed pair that carried non-numeric residue. + * + * `label` is the COORDINATE_LABELS member, not free text: since objectui#6888 + * it selects a locale key, so widening it to `string` would make + * {@link coordinateName}'s branch fall through to `longitude` silently. + */ +type ResidueHalf = { label: CoordinateLabel; text: string }; /** * What the typed text means to this widget, as ONE reading (objectui#6716). @@ -314,16 +323,62 @@ function refusedRangeMessage(t: TranslateFn, candidate: LocationValue): string { * ruling declines that parse, so pointing at it would advertise a route this * widget refuses. * - * ⚠️ Still a LITERAL, alone among the three arms, and deliberately so: - * objectui#6755's ruling locks its scope to the three sentences that existed - * when it was written, and this arm landed after. objectui#6888 carries the - * gap — including the one question the other two did not have to answer, which - * is how `verb` (English grammar, not data) should be keyed. + * objectui#6888 — keyed, closing the gap objectui#6755's scope lock left open: + * that ruling was written before this arm existed, so it named three sentences + * and this is the fourth. All three arms share ONE `

` and one + * `refusalError`, so leaving this one a literal made a single line speak the + * reader's language on two arms and English on the third. + * + * ⭐ ARITY — the one question the other two arms did not have to answer. + * `verb` was English GRAMMAR (`is not a number` / `are not numbers`), and a + * pack whose plural rules differ cannot inflect around an English verb form + * handed to it through a hole. So the verb is not a hole: it lives inside two + * SIBLING KEYS picked here, at the call site. + * + * ⛔ Deliberately NOT i18next's `_one`/`_other` suffixes. This repo's own + * plural convention is a `X` / `XOne` pair branched at the call site — + * `lookup.recordCount`/`recordCountOne` in this very defaults map + * (`RecordPickerDialog`), `list.recordCount`/`recordCountOne`, + * `detail.reactionCount`/`reactionCountOne` — because zh/ja/ko have no separate + * singular form and would legitimately omit a `_one` half, which + * `all-locales-key-parity` reads as a missing key (objectstack#5430). The + * suffix form also needs a base key to cover the categories no pack enumerates + * (`ru` few/many, `ar` two/zero — objectui#3863); the sibling pair needs none. + * + * The arity here is exactly binary and always will be: `residue` is + * `COORDINATE_LABELS` filtered, and a draft that is not two comma-separated + * parts never reaches this arm. So `ar` can use its DUAL in the two-half value, + * which is precisely what an English `verb` hole made impossible. + * + * `named`'s two components are split the same way rather than pre-joined: + * `' and '` was an English conjunction and `latitude`/`longitude` are + * translatable nouns (every pack already spells them inside its own + * `refusedFormat`). The nouns are keyed ONCE each and interpolated into both + * values, so no locale has to keep two spellings of the same word in step. The + * only holes carrying untranslated data are `{{text}}`/`{{otherText}}` — the + * characters the person actually typed, which no pack should touch. + */ +function refusedResidueMessage(t: TranslateFn, residue: readonly ResidueHalf[]): string { + const [first, second] = residue.map(half => ({ name: coordinateName(t, half.label), text: half.text })); + if (!second) return t('fields.location.refusedResidueOne', { name: first.name, text: first.text }); + return t('fields.location.refusedResidue', { + name: first.name, + text: first.text, + otherName: second.name, + otherText: second.text, + }); +} + +/** + * The reader's word for one coordinate. + * + * ⛔ Written as a branch over STRING LITERALS rather than a computed + * `t(`fields.location.${label}`)`: `check:i18n-keys` and `check:i18n-dead-keys` + * both read `t()` literals, so a template key would be invisible to the gate + * that proves it resolves AND to the one that proves it is still used. */ -function refusedResidueMessage(residue: readonly ResidueHalf[]): string { - const named = residue.map(half => `${half.label} "${half.text}"`).join(' and '); - const verb = residue.length > 1 ? 'are not numbers' : 'is not a number'; - return `Not saved: ${named} ${verb}. Enter plain decimals (example: 30.2741, 120.1551).`; +function coordinateName(t: TranslateFn, label: CoordinateLabel): string { + return label === 'latitude' ? t('fields.location.latitude') : t('fields.location.longitude'); } /** @@ -452,7 +507,7 @@ export function LocationField({ value, onChange, field, readonly, error, ...prop // `setRefusalError` the other two arms use — a third SILENT refusal is // precisely the defect objectui#6716 had just finished removing, which is // why this card was held until #6716 landed. - setRefusalError(refusedResidueMessage(parsed.residue)); + setRefusalError(refusedResidueMessage(t as TranslateFn, parsed.residue)); return; } diff --git a/packages/fields/src/widgets/useFieldTranslation.ts b/packages/fields/src/widgets/useFieldTranslation.ts index bb6fe1fdab..9c2b0e3540 100644 --- a/packages/fields/src/widgets/useFieldTranslation.ts +++ b/packages/fields/src/widgets/useFieldTranslation.ts @@ -107,6 +107,28 @@ const FIELD_DEFAULTS: Record = { 'fields.location.refusedFormat': 'Not saved: enter a latitude, longitude pair (example: 30.2741, 120.1551).', 'fields.location.refusedRange': 'Not saved: {{detail}}', + // objectui#6888 — the THIRD refusal arm (`12abc, 34`), added by objectui#6715 + // after #6755's ruling was written and therefore outside the three sentences + // it named. All three arms share one `

`, so a literal here made ONE line + // bilingual rather than one screen. + // + // Two SIBLING keys, not an i18next `_one`/`_other` family: the verb is + // English grammar and cannot be a hole, and this repo's plural convention is + // the `X`/`XOne` pair branched at the call site (`lookup.recordCount` / + // `recordCountOne` above is the same map's own instance). The coordinate + // NOUNS are keyed once each and interpolated into both, so a locale spells + // `latitude` in exactly one place. `{{text}}`/`{{otherText}}` are what the + // person typed and stay untouched by every pack. + // + // Values are byte-identical to the literal they replace, in both arities: + // `Not saved: latitude "12abc" is not a number. …` and + // `… latitude "12abc" and longitude "34xyz" are not numbers. …`. + 'fields.location.latitude': 'latitude', + 'fields.location.longitude': 'longitude', + 'fields.location.refusedResidueOne': + 'Not saved: {{name}} "{{text}}" is not a number. Enter plain decimals (example: 30.2741, 120.1551).', + 'fields.location.refusedResidue': + 'Not saved: {{name}} "{{text}}" and {{otherName}} "{{otherText}}" are not numbers. Enter plain decimals (example: 30.2741, 120.1551).', // objectui#3342 — the tags widget's input hint. Used only when the field // author declared no `placeholder` of their own (author declaration wins). 'fields.tags.placeholder': 'Type and press Enter to add…', diff --git a/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts b/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts index 5b526b346c..79225aeb24 100644 --- a/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts +++ b/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts @@ -279,7 +279,13 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // placeholder suggests („Falsche Datensatz-ID — …“) — one LITERAL span, like // `timeline.unsupported.objectBoundGantt` above rather than the interpolated // ones, because the quoted thing is sample prose this pack authored. - expect(okSpans, 'correctly paired spans').toBe(59); + // 62 once objectui#6888 keyed `LocationField`'s residue refusal: the + // one-half sentence quotes the offending text („{{text}}“) and the two-half + // one quotes both („{{text}}“ / „{{otherText}}“) — three interpolated + // spans, all runtime data, like the `ActivityTimeline` pair above. The two + // new coordinate NOUN keys (`fields.location.latitude`/`longitude`) carry + // no quotes at all: they are interpolated INTO those spans, not around them. + expect(okSpans, 'correctly paired spans').toBe(62); }); it('keeps the count identity that replaces the card’s count(„) === count(“)', () => { @@ -305,7 +311,9 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // each new value added a MATCHED „…“ pair, not a stray closer that would // have made `close === open` true for the wrong reason. 59 / 59 / 0 after // objectui#7173 added `aiApprovals.rejectPlaceholder`, one more matched pair. - expect({ open, close, rdq }).toEqual({ open: 59, close: 59, rdq: 0 }); + // 62 / 62 / 0 after objectui#6888 keyed `LocationField`'s residue refusal — + // three more matched pairs across its two arity siblings, and `rdq` still 0. + expect({ open, close, rdq }).toEqual({ open: 62, close: 62, rdq: 0 }); // The durable shape: every „ closed by a “, every surplus “ an English // opener answered by a ”. Survived translating the two English values. expect(close).toBe(open + rdq); diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 70a769597d..591a972bc9 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -281,6 +281,12 @@ const ar = { refusedFormat: "لم يتم الحفظ: أدخل زوجًا من خط العرض وخط الطول (مثال: 30.2741, 120.1551).", refusedRange: "لم يتم الحفظ: {{detail}}", + latitude: "خط العرض", + longitude: "خط الطول", + refusedResidueOne: + "لم يتم الحفظ: {{name}} «{{text}}» ليس رقمًا. أدخل أرقامًا عشرية عادية (مثال: 30.2741, 120.1551).", + refusedResidue: + "لم يتم الحفظ: {{name}} «{{text}}» و{{otherName}} «{{otherText}}» ليسا رقمين. أدخل أرقامًا عشرية عادية (مثال: 30.2741, 120.1551).", }, tags: { placeholder: "اكتب واضغط Enter للإضافة…", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index cc9e0fae3f..6ca1653c68 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -277,6 +277,12 @@ const de = { refusedFormat: "Nicht gespeichert: Geben Sie ein Paar aus Breitengrad, Längengrad ein (Beispiel: 30.2741, 120.1551).", refusedRange: "Nicht gespeichert: {{detail}}", + latitude: "Breitengrad", + longitude: "Längengrad", + refusedResidueOne: + "Nicht gespeichert: {{name}} „{{text}}“ ist keine Zahl. Geben Sie einfache Dezimalzahlen ein (Beispiel: 30.2741, 120.1551).", + refusedResidue: + "Nicht gespeichert: {{name}} „{{text}}“ und {{otherName}} „{{otherText}}“ sind keine Zahlen. Geben Sie einfache Dezimalzahlen ein (Beispiel: 30.2741, 120.1551).", }, tags: { placeholder: "Tippen und mit der Eingabetaste hinzufügen…", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 165f8ce996..fc01d88b54 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -338,6 +338,12 @@ const en = { refusedFormat: 'Not saved: enter a latitude, longitude pair (example: 30.2741, 120.1551).', refusedRange: 'Not saved: {{detail}}', + latitude: 'latitude', + longitude: 'longitude', + refusedResidueOne: + 'Not saved: {{name}} "{{text}}" is not a number. Enter plain decimals (example: 30.2741, 120.1551).', + refusedResidue: + 'Not saved: {{name}} "{{text}}" and {{otherName}} "{{otherText}}" are not numbers. Enter plain decimals (example: 30.2741, 120.1551).', }, // objectui#3342 — the tags widget's input hint, shown while the tag list // is empty. The author-declared `field.placeholder` always wins over this. diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 9f53385868..ea322ee56f 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -281,6 +281,12 @@ const es = { refusedFormat: "No guardado: introduce un par latitud, longitud (ejemplo: 30.2741, 120.1551).", refusedRange: "No guardado: {{detail}}", + latitude: "latitud", + longitude: "longitud", + refusedResidueOne: + "No guardado: {{name}} «{{text}}» no es un número. Introduce decimales simples (ejemplo: 30.2741, 120.1551).", + refusedResidue: + "No guardado: {{name}} «{{text}}» y {{otherName}} «{{otherText}}» no son números. Introduce decimales simples (ejemplo: 30.2741, 120.1551).", }, tags: { placeholder: "Escriba y pulse Intro para añadir…", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index f9ebeab6a0..10d1387348 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -277,6 +277,12 @@ const fr = { refusedFormat: "Non enregistré : saisissez une paire latitude, longitude (exemple : 30.2741, 120.1551).", refusedRange: "Non enregistré : {{detail}}", + latitude: "latitude", + longitude: "longitude", + refusedResidueOne: + "Non enregistré : {{name}} « {{text}} » n'est pas un nombre. Saisissez des décimales simples (exemple : 30.2741, 120.1551).", + refusedResidue: + "Non enregistré : {{name}} « {{text}} » et {{otherName}} « {{otherText}} » ne sont pas des nombres. Saisissez des décimales simples (exemple : 30.2741, 120.1551).", }, tags: { placeholder: "Saisissez puis appuyez sur Entrée pour ajouter…", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 1ec0420a9b..e38918e0e5 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -277,6 +277,12 @@ const ja = { refusedFormat: "保存されていません: 緯度, 経度 の組で入力してください(例: 30.2741, 120.1551)。", refusedRange: "保存されていません: {{detail}}", + latitude: "緯度", + longitude: "経度", + refusedResidueOne: + "保存されていません: {{name}}「{{text}}」は数値ではありません。通常の小数で入力してください(例: 30.2741, 120.1551)。", + refusedResidue: + "保存されていません: {{name}}「{{text}}」と{{otherName}}「{{otherText}}」は数値ではありません。通常の小数で入力してください(例: 30.2741, 120.1551)。", }, tags: { placeholder: "入力してEnterキーで追加…", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 9afcaeea42..9c0054f2af 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -277,6 +277,12 @@ const ko = { refusedFormat: "저장되지 않았습니다: 위도, 경도 쌍으로 입력하세요(예: 30.2741, 120.1551).", refusedRange: "저장되지 않았습니다: {{detail}}", + latitude: "위도", + longitude: "경도", + refusedResidueOne: + "저장되지 않았습니다: {{name}} “{{text}}”은(는) 숫자가 아닙니다. 일반 소수로 입력하세요(예: 30.2741, 120.1551).", + refusedResidue: + "저장되지 않았습니다: {{name}} “{{text}}”과(와) {{otherName}} “{{otherText}}”은(는) 숫자가 아닙니다. 일반 소수로 입력하세요(예: 30.2741, 120.1551).", }, tags: { placeholder: "입력 후 Enter 키로 추가…", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 84d2c600cf..0a138ba78c 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -276,6 +276,12 @@ const pt = { refusedFormat: "Não salvo: informe um par latitude, longitude (exemplo: 30.2741, 120.1551).", refusedRange: "Não salvo: {{detail}}", + latitude: "latitude", + longitude: "longitude", + refusedResidueOne: + "Não salvo: {{name}} “{{text}}” não é um número. Informe decimais simples (exemplo: 30.2741, 120.1551).", + refusedResidue: + "Não salvo: {{name}} “{{text}}” e {{otherName}} “{{otherText}}” não são números. Informe decimais simples (exemplo: 30.2741, 120.1551).", }, tags: { placeholder: "Digite e pressione Enter para adicionar…", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index dacdb403b6..33ea12739f 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -283,6 +283,12 @@ const ru = { refusedFormat: "Не сохранено: введите пару широта, долгота (например: 30.2741, 120.1551).", refusedRange: "Не сохранено: {{detail}}", + latitude: "широта", + longitude: "долгота", + refusedResidueOne: + "Не сохранено: {{name}} «{{text}}» — не число. Введите обычные десятичные дроби (например: 30.2741, 120.1551).", + refusedResidue: + "Не сохранено: {{name}} «{{text}}» и {{otherName}} «{{otherText}}» — не числа. Введите обычные десятичные дроби (например: 30.2741, 120.1551).", }, tags: { placeholder: "Введите и нажмите Enter, чтобы добавить…", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 9fbfd21dba..c97497c7db 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -284,6 +284,12 @@ const zh = { refusedFormat: '未保存:请输入纬度, 经度坐标对(例如 30.2741, 120.1551)。', refusedRange: '未保存:{{detail}}', + latitude: '纬度', + longitude: '经度', + refusedResidueOne: + '未保存:{{name}}“{{text}}”不是数字。请输入普通小数(例如 30.2741, 120.1551)。', + refusedResidue: + '未保存:{{name}}“{{text}}”和{{otherName}}“{{otherText}}”不是数字。请输入普通小数(例如 30.2741, 120.1551)。', }, tags: { placeholder: '输入后回车添加…', From 0aa5812ac952441de5cb5c809761792b94a59c08 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 20:35:26 +0000 Subject: [PATCH 2/2] test(fields): read the locale packs through their real type in the #6888 pin `builtInLocales` is an `as const` object, so `(builtInLocales as any)[lang]` threw away the one thing that makes a renamed or dropped key fail loudly: with the access typed, `type-check` rejects it at compile time instead of leaving a `toContain` to run against `undefined`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S --- .../LocationField.residueI18n-6888.test.tsx | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/fields/src/__tests__/LocationField.residueI18n-6888.test.tsx b/packages/fields/src/__tests__/LocationField.residueI18n-6888.test.tsx index 7e695ccee4..223bccfbb8 100644 --- a/packages/fields/src/__tests__/LocationField.residueI18n-6888.test.tsx +++ b/packages/fields/src/__tests__/LocationField.residueI18n-6888.test.tsx @@ -225,13 +225,23 @@ describe('the residue refusal speaks the reader\'s language (objectui#6888)', () describe('arity lives in the pack VALUE, not in a hole (objectui#6888)', () => { const LANGS = ['en', 'zh', 'ja', 'ko', 'de', 'es', 'fr', 'pt', 'ru', 'ar'] as const; + /** + * One pack's `fields.location` block, READ THROUGH ITS REAL TYPE. + * + * ⛔ Deliberately not `(builtInLocales as any)[lang]`. `builtInLocales` is an + * `as const` object, so typing the access makes a renamed or dropped key a + * COMPILE error in `type-check` rather than a runtime miss that a `?.` or a + * `toContain` on `undefined` could swallow. + */ + const locationPack = (lang: (typeof LANGS)[number]) => builtInLocales[lang].fields.location; + it('gives every pack two SIBLING keys, not an i18next _one/_other family', () => { // `_one` would be a key `en` could carry and zh/ja/ko could not, which // `all-locales-key-parity` fails by design (objectstack#5430); the suffix // form would also need a base key for the categories no pack enumerates // (ru few/many, ar two/zero — objectui#3863). for (const lang of LANGS) { - const location = (builtInLocales as any)[lang].fields.location; + const location = locationPack(lang); expect(Object.keys(location), `${lang}`).toEqual( expect.arrayContaining(['refusedResidue', 'refusedResidueOne', 'latitude', 'longitude']), ); @@ -244,7 +254,7 @@ describe('arity lives in the pack VALUE, not in a hole (objectui#6888)', () => { // With `verb` as a hole, both values would be one sentence and the // difference would be an English word the widget substituted. for (const lang of LANGS) { - const location = (builtInLocales as any)[lang].fields.location; + const location = locationPack(lang); const one = location.refusedResidueOne.replace(/\{\{\w+\}\}/g, ''); const two = location.refusedResidue.replace(/\{\{\w+\}\}/g, ''); expect(one, `${lang} states both arities identically`).not.toBe(two); @@ -255,8 +265,8 @@ describe('arity lives in the pack VALUE, not in a hole (objectui#6888)', () => { // Arabic distinguishes two from many. Two halves is exactly two, so the // dual is the correct form and it exists only because the whole sentence — // verb included — is the pack's to write. - expect((builtInLocales as any).ar.fields.location.refusedResidue).toContain('ليسا رقمين'); - expect((builtInLocales as any).ar.fields.location.refusedResidueOne).toContain('ليس رقمًا'); + expect(locationPack('ar').refusedResidue).toContain('ليسا رقمين'); + expect(locationPack('ar').refusedResidueOne).toContain('ليس رقمًا'); }); it('keys each coordinate noun ONCE, so no pack holds two spellings of it', () => { @@ -264,7 +274,7 @@ describe('arity lives in the pack VALUE, not in a hole (objectui#6888)', () => { // than written into each sentence, which is why `fields.location.latitude` // exists at all. for (const lang of LANGS) { - const location = (builtInLocales as any)[lang].fields.location; + const location = locationPack(lang); expect(location.refusedResidueOne, `${lang}`).toContain('{{name}}'); expect(location.refusedResidue, `${lang}`).toContain('{{name}}'); expect(location.refusedResidue, `${lang}`).toContain('{{otherName}}');