From dd9b1098e9e9f3fc01a93219206ccd042f5391fd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 23:08:57 +0000 Subject: [PATCH] fix(gantt): interpolate the dialog counts through i18next, not a single-brace string replace (#4157) `gantt.conflict.body` was resolved with `t(key).replace('{count}', n)` while all ten packs spell the placeholder `{{count}}`. The replace consumed the inner seven characters and left the outer pair, so every loaded pack rendered a literal `{2}` in the conflict dialog. The call site now passes `{ count }` to i18next, the idiom `gantt.delete.body` already used. The two sibling keys (`autoScheduleDlg.body`, `.skipped`) were not broken -- pack and call site both used single braces -- but they are converted with it: two write-confirmation dialogs three lines apart carrying two interpolation idioms is the mechanism that let `conflict.body` drift in the first place. Only the braces moved; no translation was reworded. `quickFilter.resultSummary` stays single-brace by design and is now the sole key on that idiom. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 --- .changeset/gantt-count-interpolation-4157.md | 14 ++ .../__tests__/all-locales-key-parity.test.ts | 13 +- .../gantt-count-interpolation-4157.test.ts | 92 +++++++++ packages/i18n/src/locales/ar.ts | 4 +- packages/i18n/src/locales/de.ts | 4 +- packages/i18n/src/locales/en.ts | 11 +- packages/i18n/src/locales/es.ts | 4 +- packages/i18n/src/locales/fr.ts | 4 +- packages/i18n/src/locales/ja.ts | 4 +- packages/i18n/src/locales/ko.ts | 4 +- packages/i18n/src/locales/pt.ts | 4 +- packages/i18n/src/locales/ru.ts | 4 +- packages/i18n/src/locales/zh.ts | 4 +- .../src/GanttView.countinterp.i18n.test.tsx | 178 ++++++++++++++++++ packages/plugin-gantt/src/GanttView.tsx | 6 +- .../plugin-gantt/src/useGanttTranslation.ts | 15 +- 16 files changed, 332 insertions(+), 33 deletions(-) create mode 100644 .changeset/gantt-count-interpolation-4157.md create mode 100644 packages/i18n/src/__tests__/gantt-count-interpolation-4157.test.ts create mode 100644 packages/plugin-gantt/src/GanttView.countinterp.i18n.test.tsx diff --git a/.changeset/gantt-count-interpolation-4157.md b/.changeset/gantt-count-interpolation-4157.md new file mode 100644 index 0000000000..20fc1ddb8b --- /dev/null +++ b/.changeset/gantt-count-interpolation-4157.md @@ -0,0 +1,14 @@ +--- +'@object-ui/plugin-gantt': patch +'@object-ui/i18n': patch +--- + +The gantt's conflict dialog shows the number of affected tasks again, not a literal `{2}` + +`gantt.conflict.body` was resolved at the render site with a literal string replace on **single** braces — `t('gantt.conflict.body').replace('{count}', String(n))` — while all ten locale packs spell the placeholder the i18next way, `{{count}}`. `"…{{count}}…".replace("{count}", "2")` consumes the inner seven characters and leaves the outer pair behind, so every user on every loaded pack read "自动重新排程 **{2}** 个受影响的任务?". The dialog now interpolates through i18next (`t('gantt.conflict.body', { count })`), the idiom `gantt.delete.body` already used. + +The two sibling keys three lines away in the same file, `gantt.autoScheduleDlg.body` and `.skipped`, were **not** broken — pack and call site both used single braces, and they rendered correctly. They are converted anyway, because that split is the whole mechanism: two write-confirmation dialogs in one component carried two different interpolation idioms, so `conflict.body` drifting to the i18next spelling in the packs (which is the correct spelling, and matches every other placeholder in the bundle) silently broke the render. Leaving the auto-schedule keys on the literal-replace idiom leaves the same trap armed for the next translator. All ten packs and the plugin's bundled English fallback table now agree on `{{count}}` for all three; only the braces moved, no translation was reworded. + +`gantt.quickFilter.resultSummary` stays deliberately single-brace — its `ObjectGantt` call site really does resolve `{shown}`/`{total}` with a literal replace, and that convention is pinned by its own parity test. It is now the only key in the gantt namespace on that idiom, and the comments at both spellings say so. + +Nothing caught this, and each gate was silent for its own reason: the cross-pack parity check compares en against each pack, and all eleven spellings agreed; the en-drift check compares a pack against its own history, and the packs were born matching. Both are **relative** comparisons, and the defect lived in the **absolute** relationship between a pack's spelling and the syntax the call site resolves. The existing render test asserted the dialog body contains `'1'` — which `{1}` satisfies. The new pin asserts the absolute form directly, under a real loaded pack, for every way a placeholder can survive to the screen. diff --git a/packages/i18n/src/__tests__/all-locales-key-parity.test.ts b/packages/i18n/src/__tests__/all-locales-key-parity.test.ts index 2c9039f15a..e5217689de 100644 --- a/packages/i18n/src/__tests__/all-locales-key-parity.test.ts +++ b/packages/i18n/src/__tests__/all-locales-key-parity.test.ts @@ -98,9 +98,16 @@ describe('all locale packs are at full key parity with en (objectui#2872)', () = it('placeholders match en in every pack', () => { // A translation that drops `{{count}}` renders a sentence with a hole in it - // and no error. Two gantt keys use SINGLE braces on purpose — their call - // site does a literal `.replace('{count}', …)` instead of i18next - // interpolation — so both forms are compared. + // and no error. `gantt.quickFilter.resultSummary` uses SINGLE braces on + // purpose — its call site does a literal `.replace('{shown}', …)` instead + // of i18next interpolation — so both forms are compared. + // + // NOTE this comparison is RELATIVE (en vs pack) and cannot see the defect + // in objectui#4157: every pack agreed with `en` on `{{count}}` while the + // render call site still did `.replace('{count}', …)`, so the shapes + // matched and this stayed green while the dialog showed a literal `{2}`. + // The absolute pack-vs-call-site form is pinned in + // `gantt-count-interpolation-4157.test.ts`. const DOUBLE = /\{\{\w+\}\}/g; const SINGLE = /(? diff --git a/packages/i18n/src/__tests__/gantt-count-interpolation-4157.test.ts b/packages/i18n/src/__tests__/gantt-count-interpolation-4157.test.ts new file mode 100644 index 0000000000..8419cc216b --- /dev/null +++ b/packages/i18n/src/__tests__/gantt-count-interpolation-4157.test.ts @@ -0,0 +1,92 @@ +/** + * The three `{count}` gantt dialog strings use i18next `{{count}}` + * interpolation in every pack (objectui#4157). + * + * ## The defect this pins + * + * `gantt.conflict.body` was authored with SINGLE braces and resolved by a + * literal `t(key).replace('{count}', n)` at the call site. All ten packs were + * later written (correctly, by i18next convention) with `{{count}}` — and + * `"…{{count}}…".replace("{count}", "2")` consumes the INNER seven characters, + * leaving `{2}` on screen. The user-visible symptom was a literal `{2}` in the + * conflict dialog under every loaded locale. + * + * Nothing caught it, and that is the interesting part: + * + * - `all-locales-key-parity`'s placeholder check compares placeholder *shape* + * between packs. All ten packs agreed with each other, so it stayed green. + * - `check:i18n-en-drift` compares an `en` value against its own history — the + * packs never drifted from `en`, they were born matching it. + * - `check:i18n-call-site-keys` reads KEYS, never interpolation syntax. + * + * The invariant no existing gate can express is the **absolute** form: pack + * spelling versus the syntax the render call site actually resolves. This file + * asserts it directly, the same way `gantt-quickfilter-locale-parity.test.ts` + * pins the opposite (deliberately single-brace) convention for + * `gantt.quickFilter.resultSummary`. + * + * The two sibling keys (`autoScheduleDlg.body` / `.skipped`) were NOT broken — + * they were single-brace on both sides and rendered correctly. They are + * converted with the defect so the gantt's two write-confirmation dialogs stop + * carrying two different interpolation idioms three lines apart in + * `GanttView.tsx`, which is how the conflict key drifted in the first place. + */ +import { describe, it, expect } from 'vitest'; +import { builtInLocales } from '../locales'; + +/** Dotted paths under `gantt.` whose call site passes `{ count }` to `t()`. */ +const COUNT_KEYS = [ + 'conflict.body', + 'autoScheduleDlg.body', + 'autoScheduleDlg.skipped', +] as const; + +const LANGS = Object.keys(builtInLocales); + +/** A `{word}` NOT wrapped in a second pair of braces. */ +const SINGLE_BRACE = /(? + dotted + .split('.') + .reduce((n, p) => (n as Record | undefined)?.[p], (builtInLocales as Record)[lang]) as + | string + | undefined; + +describe('gantt count-interpolation spelling (objectui#4157)', () => { + it('covers all ten built-in packs', () => { + expect(LANGS).toHaveLength(10); + }); + + it.each(LANGS)('%s spells every count placeholder as i18next {{count}}', (lang) => { + for (const key of COUNT_KEYS) { + const value = at(lang, `gantt.${key}`); + expect(typeof value, `${lang}.gantt.${key} is missing`).toBe('string'); + expect(value, `${lang}.gantt.${key} lost its {{count}} placeholder`).toContain('{{count}}'); + // The absolute form is the point: a pack respelled to `{count}` renders + // the raw placeholder now that the call site passes `{ count }` to + // i18next instead of doing a literal string replace. + expect( + SINGLE_BRACE.test(value!), + `${lang}.gantt.${key} still carries a single-brace placeholder: ${value}`, + ).toBe(false); + } + }); + + it('the English pack still reads as the source of the bundled defaults', () => { + // Byte-exact: `plugin-gantt`'s standalone fallback map (used when the gantt + // is embedded without an I18nProvider) must agree with the `en` pack, or a + // provider-less embed disagrees with an `en` session. Asserted as literals + // rather than by importing the plugin — `@object-ui/plugin-gantt` depends + // on this package, so reading it back here would invert the dependency. + expect(at('en', 'gantt.conflict.body')).toBe( + 'This move conflicts with dependency constraints. Auto-reschedule {{count}} affected task(s)?', + ); + expect(at('en', 'gantt.autoScheduleDlg.body')).toBe( + 'Shift {{count}} task(s) later to satisfy dependency links?', + ); + expect(at('en', 'gantt.autoScheduleDlg.skipped')).toBe( + '{{count}} locked task(s) also violate links and were skipped.', + ); + }); +}); diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 17a4c9d325..1473dacf1f 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -684,8 +684,8 @@ const ar = { }, autoScheduleDlg: { title: "الجدولة التلقائية", - body: "هل تريد تأخير {count} من المهام لتلبية روابط التبعية؟", - skipped: "{count} من المهام المقفلة تخالف الروابط أيضًا وقد تم تخطيها.", + body: "هل تريد تأخير {{count}} من المهام لتلبية روابط التبعية؟", + skipped: "{{count}} من المهام المقفلة تخالف الروابط أيضًا وقد تم تخطيها.", confirm: "تطبيق", cancel: "إلغاء", none: "جميع التبعيات مستوفاة — لا شيء لإعادة جدولته.", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 04046308e6..e95185f468 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -680,8 +680,8 @@ const de = { }, autoScheduleDlg: { title: "Automatisch planen", - body: "{count} Vorgänge nach hinten verschieben, um die Abhängigkeiten einzuhalten?", - skipped: "{count} gesperrte Vorgänge verletzen die Verknüpfungen ebenfalls und wurden übersprungen.", + body: "{{count}} Vorgänge nach hinten verschieben, um die Abhängigkeiten einzuhalten?", + skipped: "{{count}} gesperrte Vorgänge verletzen die Verknüpfungen ebenfalls und wurden übersprungen.", confirm: "Anwenden", cancel: "Abbrechen", none: "Alle Abhängigkeiten sind erfüllt — nichts neu zu planen.", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 97c44c0305..9e640e04c3 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -757,8 +757,8 @@ const en = { }, autoScheduleDlg: { title: 'Auto-schedule', - body: 'Shift {count} task(s) later to satisfy dependency links?', - skipped: '{count} locked task(s) also violate links and were skipped.', + body: 'Shift {{count}} task(s) later to satisfy dependency links?', + skipped: '{{count}} locked task(s) also violate links and were skipped.', confirm: 'Apply', cancel: 'Cancel', none: 'All dependencies satisfied — nothing to reschedule.', @@ -774,8 +774,11 @@ const en = { clear: 'Clear filters', empty: 'No options', // SINGLE braces on purpose: the ObjectGantt call site resolves these - // with a literal `.replace('{shown}', …)`, not i18next interpolation - // (same convention as `autoScheduleDlg.body` above). + // with a literal `.replace('{shown}', …)`, not i18next interpolation. + // The last key in the gantt namespace on that idiom — `conflict.body` + // and the two `autoScheduleDlg` counts moved to `{{count}}` + i18next + // interpolation in objectui#4157, where the single-brace call site met + // a `{{count}}` pack and rendered a literal `{2}`. resultSummary: 'Showing {shown} / {total} tasks', }, readOnly: 'Read-only', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 8f7ae2d842..c3324efef2 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -684,8 +684,8 @@ const es = { }, autoScheduleDlg: { title: "Programación automática", - body: "¿Retrasar {count} tarea(s) para respetar los vínculos de dependencia?", - skipped: "{count} tarea(s) bloqueada(s) también incumplen los vínculos y se han omitido.", + body: "¿Retrasar {{count}} tarea(s) para respetar los vínculos de dependencia?", + skipped: "{{count}} tarea(s) bloqueada(s) también incumplen los vínculos y se han omitido.", confirm: "Aplicar", cancel: "Cancelar", none: "Todas las dependencias se cumplen: no hay nada que reprogramar.", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index f323806569..29ea22297b 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -680,8 +680,8 @@ const fr = { }, autoScheduleDlg: { title: "Planification automatique", - body: "Décaler {count} tâche(s) plus tard pour respecter les liens de dépendance ?", - skipped: "{count} tâche(s) verrouillée(s) violent aussi les liens et ont été ignorées.", + body: "Décaler {{count}} tâche(s) plus tard pour respecter les liens de dépendance ?", + skipped: "{{count}} tâche(s) verrouillée(s) violent aussi les liens et ont été ignorées.", confirm: "Appliquer", cancel: "Annuler", none: "Toutes les dépendances sont respectées — rien à replanifier.", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 95e14da163..9a0d6d2307 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -680,8 +680,8 @@ const ja = { }, autoScheduleDlg: { title: "自動スケジュール", - body: "依存リンクを満たすため {count} 件のタスクを後ろにずらしますか?", - skipped: "ロックされた {count} 件のタスクもリンクに違反していますが、スキップされました。", + body: "依存リンクを満たすため {{count}} 件のタスクを後ろにずらしますか?", + skipped: "ロックされた {{count}} 件のタスクもリンクに違反していますが、スキップされました。", confirm: "適用", cancel: "キャンセル", none: "依存関係はすべて満たされています — 再スケジュールの必要はありません。", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index c66cf795dd..c7e0939820 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -680,8 +680,8 @@ const ko = { }, autoScheduleDlg: { title: "자동 일정 조정", - body: "종속성 연결을 충족하도록 작업 {count}건을 뒤로 미룰까요?", - skipped: "잠긴 작업 {count}건도 연결을 위반하지만 건너뛰었습니다.", + body: "종속성 연결을 충족하도록 작업 {{count}}건을 뒤로 미룰까요?", + skipped: "잠긴 작업 {{count}}건도 연결을 위반하지만 건너뛰었습니다.", confirm: "적용", cancel: "취소", none: "모든 종속성이 충족되었습니다 — 조정할 일정이 없습니다.", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index c291de0961..d523462f4b 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -679,8 +679,8 @@ const pt = { }, autoScheduleDlg: { title: "Agendamento automático", - body: "Adiar {count} tarefa(s) para atender aos vínculos de dependência?", - skipped: "{count} tarefa(s) bloqueada(s) também violam os vínculos e foram ignoradas.", + body: "Adiar {{count}} tarefa(s) para atender aos vínculos de dependência?", + skipped: "{{count}} tarefa(s) bloqueada(s) também violam os vínculos e foram ignoradas.", confirm: "Aplicar", cancel: "Cancelar", none: "Todas as dependências foram atendidas — nada a reagendar.", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 7adf593a54..926e8b5c40 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -686,8 +686,8 @@ const ru = { }, autoScheduleDlg: { title: "Автопланирование", - body: "Сдвинуть {count} задач(и) на более поздний срок, чтобы соблюсти связи зависимостей?", - skipped: "{count} заблокированных задач(и) также нарушают связи и были пропущены.", + body: "Сдвинуть {{count}} задач(и) на более поздний срок, чтобы соблюсти связи зависимостей?", + skipped: "{{count}} заблокированных задач(и) также нарушают связи и были пропущены.", confirm: "Применить", cancel: "Отмена", none: "Все зависимости соблюдены — перепланировать нечего.", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 79f694f206..52be9eb3dd 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -729,8 +729,8 @@ const zh = { }, autoScheduleDlg: { title: '自动排程', - body: '将顺延 {count} 个任务以满足依赖约束,是否执行?', - skipped: '另有 {count} 项因锁定/无权限跳过。', + body: '将顺延 {{count}} 个任务以满足依赖约束,是否执行?', + skipped: '另有 {{count}} 项因锁定/无权限跳过。', confirm: '执行', cancel: '取消', none: '依赖均满足,无需排程', diff --git a/packages/plugin-gantt/src/GanttView.countinterp.i18n.test.tsx b/packages/plugin-gantt/src/GanttView.countinterp.i18n.test.tsx new file mode 100644 index 0000000000..3ea36fdaf3 --- /dev/null +++ b/packages/plugin-gantt/src/GanttView.countinterp.i18n.test.tsx @@ -0,0 +1,178 @@ +/** + * The gantt's two write-confirmation dialogs interpolate their `{{count}}` + * through i18next, under a real loaded locale pack (objectui#4157). + * + * ## Why this has to render inside an I18nProvider + * + * The reported symptom — a literal `{2}` in the conflict dialog — is invisible + * to a provider-less render. `useGanttTranslation` falls back to the plugin's + * own `GANTT_DEFAULT_TRANSLATIONS` when the host returns the key unchanged, and + * the bundled default was authored with the same SINGLE-brace spelling the old + * `t(key).replace('{count}', n)` call site expected, so the fallback path + * rendered correctly and masked the bug. It only appears once a pack is loaded: + * every pack spells the placeholder `{{count}}` (i18next convention), and + * `"…{{count}}…".replace("{count}", "2")` eats the inner seven characters and + * leaves `{2}`. + * + * So each case here is pinned under BOTH `en` and `zh`. `en` is not redundant + * with the provider-less tests: the `en` *pack* is a different string source + * from the bundled fallback map, and it carried the same defect. + * + * The auto-schedule dialog is the control. Its two keys were never broken — + * pack and call site both used single braces — so these cases were green before + * the fix and stay green after it. They fail only if the packs are converted + * without the call site (or the reverse), which is exactly the half-migration + * that produced the defect. + */ +import React from 'react'; +import { render, fireEvent, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +// Same package `useGanttTranslation` reads `useObjectTranslation` from, so the +// provider's i18next instance is the one GanttView resolves against. +import { I18nProvider } from '@object-ui/react'; +import { GanttView, type GanttTask } from './GanttView'; + +beforeEach(() => { + Object.defineProperty(window, 'innerWidth', { value: 1280, configurable: true }); +}); + +const D = (s: string) => new Date(s); + +function makeTask(id: string, start: string, end: string, extra: Partial = {}): GanttTask { + return { id, title: `Task ${id}`, start: D(start), end: D(end), progress: 0, ...extra }; +} + +/** + * Render pinned to `lang`. `detectBrowserLanguage: false` + `persistLanguage` + * are load-bearing: the provider bootstrap otherwise reads a persisted language + * out of localStorage and overrides `defaultLanguage`, which would make the + * `en` case depend on whether the `zh` case ran first. + * + * `onTaskUpdate` is equally load-bearing and is supplied by default: BOTH + * dialogs are gated on a write handler, and without one they never open, so + * every case here would fail on "the dialog did not open" instead of on the + * placeholder residue it means to pin. The bar only gets an `onPointerDown` + * when it is draggable (`canDrag`, GanttView.tsx), so the drag never commits + * and `maybeFlagConflict` never runs; the toolbar wand is rendered only under + * `autoSchedule && onTaskUpdate`, and `runAutoSchedule` returns early without + * it. Same gating as the sibling harnesses in `GanttView.interactions.test.tsx` + * and `GanttView.autoscheduledlg.test.tsx`. + */ +function renderIn( + lang: string, + tasks: GanttTask[], + props: Partial> = {}, +) { + return render( + +
+ +
+
, + ); +} + +function pointer(type: string, clientX: number, clientY = 100) { + return new PointerEvent(type, { + bubbles: true, + cancelable: true, + clientX, + clientY, + pointerType: 'mouse', + button: 0, + isPrimary: true, + } as PointerEventInit); +} + +/** Drag a bar horizontally by whole day-columns (columnWidth=110 at innerWidth=1280). */ +function dragBar(container: HTMLElement, id: string, deltaCols: number) { + const bar = container.querySelector(`[data-testid="gantt-task-bar-${id}"]`) as HTMLElement; + expect(bar, `no bar for ${id}`).toBeTruthy(); + const originX = 800; + fireEvent.pointerDown(bar, { button: 0, clientX: originX, clientY: 100 }); + act(() => { window.dispatchEvent(pointer('pointermove', originX + deltaCols * 110)); }); + act(() => { window.dispatchEvent(pointer('pointerup', originX + deltaCols * 110)); }); +} + +/** + * Every way an uninterpolated placeholder can reach the screen. `{2}` is the + * exact reported symptom (the single-brace `.replace` biting a `{{count}}` + * pack string); the other two are the failure modes of the opposite + * half-migration. + */ +function expectNoPlaceholderResidue(text: string, count: number) { + expect(text, 'the count never reached the sentence').toContain(String(count)); + expect(text, `rendered the reported literal {${count}}`).not.toContain(`{${count}}`); + expect(text, 'rendered a raw i18next placeholder').not.toContain('{{count}}'); + expect(text, 'rendered a raw single-brace placeholder').not.toContain('{count}'); +} + +describe('gantt conflict dialog interpolates its count under a loaded pack (objectui#4157)', () => { + // B depends on A (FS): A ends 06-13, B starts 06-17 with slack. + const linked = () => [ + makeTask('a', '2024-06-03T00:00:00.000Z', '2024-06-13T00:00:00.000Z', { progress: 50 }), + makeTask('b', '2024-06-17T00:00:00.000Z', '2024-06-21T00:00:00.000Z', { + dependencies: [{ id: 'a', type: 'fs' }], + }), + ]; + + it.each(['en', 'zh'])('renders the number, not a placeholder, under a `%s` session', (lang) => { + const { container } = renderIn(lang, linked(), { rescheduleOnConflict: true }); + + // Drag B 6 days earlier → 06-11, which violates the FS link (A ends 06-13). + dragBar(container, 'b', -6); + + const dialog = container.querySelector('[data-testid="gantt-conflict-dialog"]'); + expect(dialog, 'the conflict dialog did not open').toBeTruthy(); + expectNoPlaceholderResidue(dialog!.textContent ?? '', 1); + }); + + it('serves the localized sentence, not the bundled English fallback, under `zh`', () => { + // Guards the other direction, and it is not hypothetical: `t(key, { count })` + // hands i18next its PLURAL selector, so a pack whose key has no plural form + // could in principle resolve to a miss, land on `useGanttTranslation`'s + // per-key English fallback, and "fix" the residue by un-localizing the + // sentence. This case is what proves the zh pack value is what reaches the + // screen. + const { container } = renderIn('zh', linked(), { rescheduleOnConflict: true }); + dragBar(container, 'b', -6); + + const text = container.querySelector('[data-testid="gantt-conflict-dialog"]')!.textContent ?? ''; + expect(text).toContain('自动重新排程'); + expect(text).not.toContain('Auto-reschedule'); + }); +}); + +describe('gantt auto-schedule dialog keeps interpolating its counts (objectui#4157 control)', () => { + // P is the predecessor; B1/B2 violate the link and will shift; C violates it + // too but is locked, so it is reported as skipped instead of moved. + const violating = (): GanttTask[] => [ + makeTask('p', '2024-06-01T00:00:00.000Z', '2024-06-10T00:00:00.000Z'), + makeTask('b1', '2024-06-05T00:00:00.000Z', '2024-06-08T00:00:00.000Z', { dependencies: ['p'] }), + makeTask('b2', '2024-06-05T00:00:00.000Z', '2024-06-07T00:00:00.000Z', { dependencies: ['p'] }), + makeTask('c', '2024-06-05T00:00:00.000Z', '2024-06-06T00:00:00.000Z', { dependencies: ['p'], locked: true }), + ]; + + it.each(['en', 'zh'])('body and skipped both interpolate under a `%s` session', (lang) => { + const { container, baseElement } = renderIn(lang, violating(), { autoSchedule: true }); + + const wand = container.querySelector('[data-testid="gantt-auto-schedule"]') as HTMLElement; + expect(wand, 'the auto-schedule wand is not in the toolbar').toBeTruthy(); + act(() => { fireEvent.click(wand); }); + + const dialog = baseElement.querySelector('[data-testid="gantt-autoschedule-dialog"]'); + expect(dialog, 'the auto-schedule dialog did not open').toBeTruthy(); + // Two unlocked violators shift; the locked one is reported separately. + expectNoPlaceholderResidue(dialog!.textContent ?? '', 2); + + const skipped = baseElement.querySelector('[data-testid="gantt-autoschedule-skipped"]'); + expect(skipped, 'the skipped notice did not render').toBeTruthy(); + expectNoPlaceholderResidue(skipped!.textContent ?? '', 1); + }); +}); diff --git a/packages/plugin-gantt/src/GanttView.tsx b/packages/plugin-gantt/src/GanttView.tsx index d6af5a1dd2..b876fec4de 100644 --- a/packages/plugin-gantt/src/GanttView.tsx +++ b/packages/plugin-gantt/src/GanttView.tsx @@ -4921,12 +4921,12 @@ export function GanttView({ {t('gantt.autoScheduleDlg.title')}
- {t('gantt.autoScheduleDlg.body').replace('{count}', String(pendingAutoSchedule.changes.length))} + {t('gantt.autoScheduleDlg.body', { count: pendingAutoSchedule.changes.length })} {pendingAutoSchedule.skipped > 0 && ( <> {' '} - {t('gantt.autoScheduleDlg.skipped').replace('{count}', String(pendingAutoSchedule.skipped))} + {t('gantt.autoScheduleDlg.skipped', { count: pendingAutoSchedule.skipped })} )} @@ -4981,7 +4981,7 @@ export function GanttView({ {t('gantt.conflict.title')}
- {t('gantt.conflict.body').replace('{count}', String(pendingConflict.length))} + {t('gantt.conflict.body', { count: pendingConflict.length })}