Files
siftlode/frontend/src/i18n/copyRule.test.ts
T
peter 8845d1aa49 fix(downloads): GIF review round 1 — shape, HDR, caps, audio, copy
- A GIF squares its pixels before the size, so an anamorphic source keeps
  its shown shape (a 720x480 DVD at 16:9 was 480x320).
- An editor GIF of an HDR upload is tonemapped, as a clip GIF is.
- The length caps are judged in whole milliseconds: a range exactly at a
  cap is no longer refused for a float hair over it.
- A GIF is made of a video only: an audio file is refused with its reason
  and the editor offers no GIF for it.
- A GIF clip keeps no audio track: the track neither splits its cache key
  nor fails it.
- The editor's GIF notes keep their live region mounted; one size reads
  as one ("about 1 MB"), none is shown with nothing kept; the size choices
  show their unit; a shared picture that fails to load says so.
- Copy: the two-sentence GIF notes keep their last stop (a copy-rule test
  now holds it); the English warns of "only a still picture".
2026-11-12 23:24:25 +01:00

82 lines
3.4 KiB
TypeScript

import { describe, expect, it } from "vitest";
// docs/DESIGN.md "Copy": the parts of the copy rule a machine can read, held over every
// value in both languages. The rest (one name per feature, one verb per meaning, "…" when a command
// asks first) needs a reader and is checked by the DESIGN conformance step.
const files = import.meta.glob("./locales/*/*.json", { eager: true, import: "default" }) as Record<
string,
unknown
>;
function values(): [string, string][] {
const out: [string, string][] = [];
const walk = (o: unknown, path: string) => {
if (typeof o === "string") out.push([path, o]);
else if (o && typeof o === "object")
for (const [k, v] of Object.entries(o)) walk(v, path ? `${path}.${k}` : k);
};
for (const [file, json] of Object.entries(files))
walk(json, file.replace("./locales/", "").replace(".json", ":"));
return out;
}
const offenders = (test: (v: string) => boolean) =>
values()
.filter(([, v]) => test(v))
.map(([k, v]) => `${k} = ${v}`);
describe("the copy rule", () => {
it("quotes a name with curly quotes, never straight ones", () => {
expect(offenders((v) => v.includes('"'))).toEqual([]);
});
it("writes an ellipsis as one character", () => {
expect(offenders((v) => v.includes("..."))).toEqual([]);
});
it("ends no message with a tick", () => {
expect(offenders((v) => v.includes("✓"))).toEqual([]);
});
it("keeps the stops of a message of two sentences, the last one too", () => {
// A sentence break: a stop, a space, then a capital ("kb. {{n}}" is an abbreviation, not one). A
// value ending in a space is the head of a sentence a link or a number completes on screen.
const twoSentences = (v: string) => /[.!?] \p{Lu}/u.test(v) && !v.endsWith(" ");
expect(offenders((v) => twoSentences(v) && !/[.!?…]["”)]?$/u.test(v))).toEqual([]);
});
it("spells no unit as a bare letter after a number ('90m', '24h', '{{count}}d', '10 s')", () => {
expect(offenders((v) => /(\d|\}\})\s?(m|h|d|s)(?![\p{L}\d/])/u.test(v))).toEqual([]);
});
});
describe("one name per thing", () => {
// The options of one choice are told apart by their names alone: two playback classes once shared
// "Közvetlen lejátszás" in the HU filter. A set = a `…Opt` object, or one choice key's
// options under config.choices.
const sets = (): [string, Record<string, unknown>][] => {
const out: [string, Record<string, unknown>][] = [];
const walk = (o: unknown, path: string, parent: string) => {
if (!o || typeof o !== "object") return;
if (/Opt$/.test(path.split(".").pop() ?? "") || parent.endsWith("/config:.choices"))
out.push([path, o as Record<string, unknown>]);
for (const [k, v] of Object.entries(o)) walk(v, `${path}.${k}`, path);
};
for (const [file, json] of Object.entries(files))
walk(json, file.replace("./locales/", "").replace(".json", ":"), "");
return out;
};
it("names the options of one choice differently", () => {
const found = sets();
expect(found.some(([p]) => p.endsWith("playableOpt"))).toBe(true);
expect(found.some(([p]) => p.endsWith("choices.log_level"))).toBe(true);
const clashes = found.flatMap(([path, opts]) => {
const names = Object.values(opts).filter((v): v is string => typeof v === "string");
return names.length === new Set(names).size ? [] : [path];
});
expect(clashes).toEqual([]);
});
});