test(i18n): one marked fake t for the suite, and a guard (#389) #435
3 changed files with 104 additions and 10 deletions
|
|
@ -35,6 +35,7 @@ import {
|
|||
type MachineList,
|
||||
type TokenDropped,
|
||||
} from "../services/licenseService";
|
||||
import { markedT } from "../test/i18nMock";
|
||||
|
||||
// The hook itself reads the LicenseContext; the pure functions below never do.
|
||||
const context = { refresh: vi.fn(async () => {}), revoked: false };
|
||||
|
|
@ -640,7 +641,7 @@ describe("useLicenseMachines wiring to the LicenseContext", () => {
|
|||
const view = renderHook();
|
||||
expect(view.canRemoveKey).toBe(true);
|
||||
|
||||
await view.removeKey({ confirm, t: echo });
|
||||
await view.removeKey({ confirm, t: markedT });
|
||||
|
||||
expect(confirm).toHaveBeenCalledWith("t(license.removeKey.confirmRevoked)");
|
||||
expect(invoke).toHaveBeenCalledWith("remove_license", { releaseSlot: false });
|
||||
|
|
@ -652,21 +653,15 @@ describe("useLicenseMachines wiring to the LicenseContext", () => {
|
|||
vi.mocked(invoke).mockResolvedValue(null);
|
||||
const confirm = vi.fn(() => false);
|
||||
const view = renderHook();
|
||||
await view.removeKey({ confirm, t: echo });
|
||||
await view.removeKey({ confirm, t: markedT });
|
||||
expect(confirm).toHaveBeenCalledWith("t(license.removeKey.confirmValid)");
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// The removal of the stored key (#370). The fake `t` marks what it translates, `t(key[options])`,
|
||||
// The removal of the stored key (#370). `markedT` marks what it translates, `t(key[options])`,
|
||||
// so each assertion names the message the user would read, and a string that skipped `t` (a raw
|
||||
// key) fails it (#387 review).
|
||||
function echo(key: string, options?: Record<string, unknown>): string {
|
||||
const params = options
|
||||
? `[${Object.entries(options).map(([name, value]) => `${name}=${String(value)}`).join(",")}]`
|
||||
: "";
|
||||
return `t(${key}${params})`;
|
||||
}
|
||||
|
||||
describe("licenseMachinesReducer — removal of the key", () => {
|
||||
it("marks the removal running, clearing the previous failure", () => {
|
||||
|
|
@ -753,7 +748,7 @@ describe("performRemoveLicense", () => {
|
|||
events.push(`confirm ${message}`);
|
||||
return confirmations.shift() ?? false;
|
||||
}),
|
||||
t: echo,
|
||||
t: markedT,
|
||||
removeLicense: vi.fn(async (releaseSlot: boolean) => {
|
||||
events.push(`remove(${releaseSlot})`);
|
||||
return outcomes.shift() ?? removed;
|
||||
|
|
|
|||
41
src/test/i18nMock.test.ts
Normal file
41
src/test/i18nMock.test.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
// The shared fake `t` (#389): its mark is the convention every assertion reads.
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { i18nMock, markedT } from "./i18nMock";
|
||||
|
||||
describe("markedT", () => {
|
||||
it("marks a key without options", () => {
|
||||
expect(markedT("license.removeKey.confirmValid")).toBe("t(license.removeKey.confirmValid)");
|
||||
});
|
||||
|
||||
it("marks a key with its options, in their order", () => {
|
||||
expect(markedT("budget.count", { count: 2, name: "x" })).toBe("t(budget.count[count=2,name=x])");
|
||||
});
|
||||
|
||||
it("never returns the bare key", () => {
|
||||
expect(markedT("a.b")).not.toBe("a.b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("i18nMock", () => {
|
||||
it("hands out the marked t and the French language by default", () => {
|
||||
const { t, i18n } = i18nMock().useTranslation();
|
||||
expect(t("a.b", { n: 1 })).toBe("t(a.b[n=1])");
|
||||
expect(i18n.language).toBe("fr");
|
||||
});
|
||||
|
||||
it("reads a language function at each access", () => {
|
||||
let language = "fr";
|
||||
const { i18n } = i18nMock({ language: () => language }).useTranslation();
|
||||
expect(i18n.language).toBe("fr");
|
||||
language = "en";
|
||||
expect(i18n.language).toBe("en");
|
||||
});
|
||||
|
||||
it("returns the fixture value for a fixture key, the mark for any other", () => {
|
||||
const value = [{ id: "x" }];
|
||||
const { t } = i18nMock({ fixture: { "docs.a.subsections": value } }).useTranslation();
|
||||
expect(t("docs.a.subsections", { returnObjects: true })).toBe(value);
|
||||
expect(t("docs.b.subsections", { returnObjects: true })).toBe("t(docs.b.subsections[returnObjects=true])");
|
||||
expect(t("toString")).toBe("t(toString)");
|
||||
});
|
||||
});
|
||||
58
src/test/i18nMock.ts
Normal file
58
src/test/i18nMock.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// i18nMock — the one fake `t` of the test suite (#389).
|
||||
//
|
||||
// A fake `t` that returns the key as is hides a forgotten call to `t`: a
|
||||
// component that displays `license.removeKey.confirmValid` instead of the
|
||||
// translated text passes every test (#387 review, S1). This fake MARKS what it
|
||||
// translates, `t(key)` or `t(key[a=1,b=2])`, so an assertion written in that
|
||||
// form fails on a string that skipped `t`. A substring assertion on a bare key
|
||||
// still reads the key inside the mark.
|
||||
//
|
||||
// USE — one pattern, at the head of the test module, before the imports of
|
||||
// the code under test (the factory of `vi.mock` is hoisted, hence the dynamic
|
||||
// import inside it):
|
||||
//
|
||||
// vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock());
|
||||
//
|
||||
// Options of the factory:
|
||||
// - `language`: the `i18n.language` the component reads, "fr" by default. A
|
||||
// function is read at each access, for a test that changes the language
|
||||
// between two renders: `i18nMock({ language: () => language })` with a
|
||||
// `let language` of the test module.
|
||||
// - `fixture`: values `t` returns for some keys, as i18next returns what the
|
||||
// locale declares under them (an object with `returnObjects`, for instance).
|
||||
// Every other key goes through `markedT`.
|
||||
//
|
||||
// A test that calls a function taking `t` as a parameter passes `markedT`
|
||||
// itself. `src/__integration__/i18n-mock-guard.test.ts` refuses any other
|
||||
// fake `t` in a test file.
|
||||
|
||||
/** The marked translation: `t(key)`, or `t(key[a=1,b=2])` with options. */
|
||||
export function markedT(key: string, options?: Record<string, unknown>): string {
|
||||
const params = options
|
||||
? `[${Object.entries(options)
|
||||
.map(([name, value]) => `${name}=${String(value)}`)
|
||||
.join(",")}]`
|
||||
: "";
|
||||
return `t(${key}${params})`;
|
||||
}
|
||||
|
||||
export interface I18nMockOptions {
|
||||
/** `i18n.language`, "fr" by default; a function is read at each access. */
|
||||
language?: string | (() => string);
|
||||
/** Values returned as is for these keys, instead of the mark. */
|
||||
fixture?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** The module `react-i18next` for `vi.mock`: `useTranslation` with the marked `t`. */
|
||||
export function i18nMock({ language = "fr", fixture }: I18nMockOptions = {}) {
|
||||
const t = (key: string, options?: Record<string, unknown>): unknown =>
|
||||
fixture && Object.prototype.hasOwnProperty.call(fixture, key)
|
||||
? fixture[key]
|
||||
: markedT(key, options);
|
||||
const i18n = {
|
||||
get language(): string {
|
||||
return typeof language === "function" ? language() : language;
|
||||
},
|
||||
};
|
||||
return { useTranslation: () => ({ t, i18n }) };
|
||||
}
|
||||
Loading…
Reference in a new issue