Skip to content

Commit 2852816

Browse files
EtienneLescotclaude
andcommitted
fix(i18n): select the plural form by category, and unbreak the tests typecheck
Two findings from review on #451. `fitClip.test.ts` kept an `AspectRatio` import after the fixtures that used it were deleted, and the Typecheck (tests) job failed on it. I did not catch that because I ran `tsc --noEmit` all along and read it as full coverage: tsconfig.json excludes `**/*.test.ts`, and the test files go through tsconfig.test.json in a separate CI job — a gate its own comment says to keep at zero. So none of the test files added on this branch had ever been typechecked locally. Both configs from here. Plural selection now reads the CLDR category instead of collapsing everything that is not `one`. Russian has four, and 2-4 takes "клипа", so the menu rendered "2 клипов" — wrong, not merely coarse. `few` is authored per locale and falls back to `many`, so adding a form stays a catalog change. Every locale gets the key rather than only Russian, because the loader falls back to English for a missing one and Arabic selects `few` too. The strings other locales get are the ones they were already using for that category, not new grammar. Arabic's `two` and `many` are still wrong — it has six categories, and I could not verify the grammar, so it stays on the fallback instead of being filled in with a guess. That wants a native speaker. Refs #84 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent c8963fb commit 2852816

16 files changed

Lines changed: 59 additions & 11 deletions

File tree

‎src/components/ai-edition/RightPanes.tsx‎

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1484,17 +1484,24 @@ export function fitClipPatch(nativeToken: AspectRatio): EditorSettingsPatch {
14841484
}
14851485

14861486
/**
1487-
* "1 clip" / "2 clips", picked through Intl.PluralRules rather than `count === 1`.
1487+
* The catalog key for a count, by CLDR plural category.
14881488
*
1489-
* The catalog has no plural machinery — `translate` interpolates and nothing else — so the two
1490-
* forms are two keys. Choosing between them by CLDR category instead of by equality is what
1491-
* makes French say "0 clip" and not "0 clips", and it degrades honestly for the locales whose
1492-
* rules need more than two forms rather than pretending English's rules are universal.
1489+
* `translate` interpolates and nothing else, so each form is its own key. Selecting by
1490+
* category rather than by `count === 1` is what makes French say "0 clip" — and, more to the
1491+
* point, what lets a locale carry more than two forms at all: Russian needs "клипа" for 2–4
1492+
* and "клипов" for 5+, so mapping everything that is not `one` onto a single plural produced
1493+
* "2 клипов", which is simply wrong rather than merely coarse.
1494+
*
1495+
* Falls back to `fitClipMany` for any category a locale has not authored, so adding a form is
1496+
* a catalog change and never a code change. Arabic still needs its `two`, `few` and `many`
1497+
* forms — it has six categories and I could not verify the grammar, so it is deliberately
1498+
* left on the fallback rather than filled in with a guess.
14931499
*/
1494-
function pluralKey(locale: string, count: number): "effects.fitClipOne" | "effects.fitClipMany" {
1495-
return new Intl.PluralRules(locale).select(count) === "one"
1500+
function pluralKey(locale: string, count: number): string {
1501+
const category = new Intl.PluralRules(locale).select(count);
1502+
return category === "one"
14961503
? "effects.fitClipOne"
1497-
: "effects.fitClipMany";
1504+
: `effects.fitClip${category === "few" ? "Few" : "Many"}`;
14981505
}
14991506

15001507
// ─── Video Effects ─────────────────────────────────────────────────

‎src/components/ai-edition/fitClip.test.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
// unfixable to someone who already found the padding slider — so it gets pinned here.
55

66
import { describe, expect, it } from "vitest";
7-
import type { AspectRatio } from "@/utils/aspectRatioUtils";
87
import { fitClipPatch } from "./RightPanes";
98

109
describe("fitClipPatch", () => {

‎src/components/ai-edition/fitClipShapes.test.tsx‎

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import "@testing-library/jest-dom";
88
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
99
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
1010
import { I18nProvider } from "@/contexts/I18nContext";
11+
import { LOCALE_STORAGE_KEY } from "@/i18n/config";
1112
import type { AxcutDocument } from "@/lib/ai-edition/schema";
1213
import { createEmptyDocument } from "@/lib/ai-edition/schema";
1314
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
@@ -56,7 +57,8 @@ function documentWithShapes(shapes: Array<[number, number]>): AxcutDocument {
5657
} as unknown as AxcutDocument;
5758
}
5859

59-
function mount(doc: AxcutDocument) {
60+
function mount(doc: AxcutDocument, locale?: string) {
61+
if (locale) localStorage.setItem(LOCALE_STORAGE_KEY, locale);
6062
useProjectStore.setState({ document: doc });
6163
return render(
6264
<I18nProvider>
@@ -79,9 +81,13 @@ function frameSettings() {
7981
}
8082

8183
beforeEach(() => {
84+
localStorage.clear();
8285
useProjectStore.setState({ document: null });
8386
});
84-
afterEach(cleanup);
87+
afterEach(() => {
88+
cleanup();
89+
localStorage.clear();
90+
});
8591

8692
describe("fitting a clip is an action, and a choice only when there is one", () => {
8793
it("acts without asking when the timeline holds one shape", async () => {
@@ -174,6 +180,29 @@ describe("fitting a clip is an action, and a choice only when there is one", ()
174180
).toBeInTheDocument();
175181
});
176182

183+
it("counts in Russian with the form the count actually needs", () => {
184+
// Russian has four plural categories, and 2-4 takes "клипа". Mapping everything that
185+
// is not `one` onto a single plural rendered "2 клипов", which is wrong rather than
186+
// merely coarse — the reason the count goes through Intl.PluralRules and not
187+
// `count === 1`.
188+
mount(
189+
documentWithShapes([
190+
[1920, 1080],
191+
[1920, 1080],
192+
[1080, 1920],
193+
]),
194+
"ru",
195+
);
196+
fireEvent.click(screen.getByRole("button", { name: "Подогнать" }));
197+
const menu = screen.getByRole("menu");
198+
expect(
199+
within(menu).getByRole("menuitem", { name: /1920 × 1080.*2 клипа/ }),
200+
).toBeInTheDocument();
201+
expect(
202+
within(menu).getByRole("menuitem", { name: /1080 × 1920.*1 клип$/ }),
203+
).toBeInTheDocument();
204+
});
205+
177206
it("collapses same-shape clips to one entry, labelled with the biggest", () => {
178207
mount(
179208
documentWithShapes([

‎src/i18n/locales/ar/settings.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
"formatOriginal": "الأصلي",
8080
"fitClip": "ملاءمة",
8181
"fitClipOne": "مقطع واحد",
82+
"fitClipFew": "{{count}} مقاطع",
8283
"fitClipMany": "{{count}} مقاطع",
8384
"motion": "الحركة",
8485
"help": "تنسيق إطار التسجيل: ضبابية الخلفية، والظل، وضبابية الحركة، واستدارة الزوايا، والحشو حول الفيديو."

‎src/i18n/locales/en/settings.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@
8585
"formatOriginal": "Original",
8686
"fitClip": "Fit",
8787
"fitClipOne": "{{count}} clip",
88+
"fitClipFew": "{{count}} clips",
8889
"fitClipMany": "{{count}} clips",
8990
"motion": "Motion",
9091
"help": "Frame styling for the recording: background blur, drop shadow, motion blur, corner radius, and padding around the video."

‎src/i18n/locales/es/settings.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@
7878
"formatOriginal": "Original",
7979
"fitClip": "Ajustar",
8080
"fitClipOne": "{{count}} clip",
81+
"fitClipFew": "{{count}} clips",
8182
"fitClipMany": "{{count}} clips",
8283
"motion": "Movimiento",
8384
"on": "activado",

‎src/i18n/locales/fr/settings.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@
7878
"formatOriginal": "Original",
7979
"fitClip": "Ajuster",
8080
"fitClipOne": "{{count}} clip",
81+
"fitClipFew": "{{count}} clips",
8182
"fitClipMany": "{{count}} clips",
8283
"motion": "Mouvement",
8384
"on": "activé",

‎src/i18n/locales/it/settings.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
"formatOriginal": "Originale",
8080
"fitClip": "Adatta",
8181
"fitClipOne": "{{count}} clip",
82+
"fitClipFew": "{{count}} clip",
8283
"fitClipMany": "{{count}} clip",
8384
"motion": "Movimento",
8485
"help": "Stile della cornice della registrazione: sfocatura dello sfondo, ombra, sfocatura di movimento, raggio degli angoli e margine attorno al video."

‎src/i18n/locales/ja-JP/settings.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@
7878
"formatOriginal": "元のサイズ",
7979
"fitClip": "合わせる",
8080
"fitClipOne": "{{count}} クリップ",
81+
"fitClipFew": "{{count}} クリップ",
8182
"fitClipMany": "{{count}} クリップ",
8283
"motion": "モーション",
8384
"on": "オン",

‎src/i18n/locales/ko-KR/settings.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
"formatOriginal": "원본",
8080
"fitClip": "맞추기",
8181
"fitClipOne": "{{count}}개 클립",
82+
"fitClipFew": "{{count}}개 클립",
8283
"fitClipMany": "{{count}}개 클립",
8384
"motion": "모션",
8485
"help": "녹화 프레임 스타일: 배경 흐림, 그림자, 모션 블러, 모서리 둥글기, 영상 주변 여백."

0 commit comments

Comments
 (0)