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..223bccfbb8 --- /dev/null +++ b/packages/fields/src/__tests__/LocationField.residueI18n-6888.test.tsx @@ -0,0 +1,314 @@ +/** + * 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; + + /** + * 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 = locationPack(lang); + 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 = 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); + } + }); + + 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(locationPack('ar').refusedResidue).toContain('ليسا رقمين'); + expect(locationPack('ar').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 = locationPack(lang); + 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: '输入后回车添加…',