test: pin the \r path stop and the surviving marked-t keys (#437)

- db.test.ts: `\r` joins the path stops of withoutFolders (removing it from
  PATH_STOP now fails).
- The six keys whose "t removed" mutation survived the review of PR #435
  (comment 23499) are asserted in their marked form:
  transactions.transferIcon.ariaLabel (a row linked to a transfer) and
  transactions.notes.placeholder (title of every row's notes button) in
  TransactionTable.render.test.tsx; license.keyPlaceholder and
  license.activate in LicenseKeyForm.render.test.tsx;
  dashboard.kpiSectionTitle in DashboardPage.render.test.tsx;
  balance.priceFetching.consent.accept in PriceFetchControl.test.tsx, the
  consent dialog opened by presetting the control's first harness slot
  (the dialog's title, body and decline texts are pinned with it).
- TransactionTable.wiring.test.ts opens a row's notes through its button
  and pins the textarea placeholder, the second call of the notes key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-10-18 19:46:47 -04:00
parent 8a9d9909f9
commit 3e8c79f057
6 changed files with 105 additions and 14 deletions

View file

@ -11,6 +11,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
// Mocks — declared before imports to satisfy vi.mock hoisting // Mocks — declared before imports to satisfy vi.mock hoisting
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// React's hooks from the shared harness (#409): the consent dialog opens on a
// click (internal state), so the render test below presets its slot (#437).
vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig()));
vi.mock("../../hooks/useIsPremium", () => ({ vi.mock("../../hooks/useIsPremium", () => ({
useIsPremium: vi.fn(), useIsPremium: vi.fn(),
})); }));
@ -40,6 +44,7 @@ vi.mock("lucide-react", () => ({
// Imports (after mock declarations) // Imports (after mock declarations)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
import type { ReactNode } from "react";
import { useIsPremium } from "../../hooks/useIsPremium"; import { useIsPremium } from "../../hooks/useIsPremium";
import { prices } from "../../services/balance.service"; import { prices } from "../../services/balance.service";
import type { PriceResult } from "../../services/balance.service"; import type { PriceResult } from "../../services/balance.service";
@ -48,6 +53,7 @@ import {
setPreference, setPreference,
} from "../../services/userPreferenceService"; } from "../../services/userPreferenceService";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import { hookHarness } from "../../test/hookHarness";
import PriceFetchControl, { import PriceFetchControl, {
__resetBestEffortDismissForTests, __resetBestEffortDismissForTests,
} from "./PriceFetchControl"; } from "./PriceFetchControl";
@ -56,6 +62,7 @@ import PriceFetchControl, {
// Helpers // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const harness = hookHarness();
const mockUseIsPremium = vi.mocked(useIsPremium); const mockUseIsPremium = vi.mocked(useIsPremium);
const mockFetchPrice = vi.mocked(prices.fetchPrice); const mockFetchPrice = vi.mocked(prices.fetchPrice);
const mockGetPreference = vi.mocked(getPreference); const mockGetPreference = vi.mocked(getPreference);
@ -363,18 +370,35 @@ describe("PriceFetchControl — displayed texts", () => {
__resetBestEffortDismissForTests(); __resetBestEffortDismissForTests();
vi.resetAllMocks(); vi.resetAllMocks();
setPremium(true); setPremium(true);
harness.clear();
}); });
it("names the fetch button and the stock best-effort notice through t", () => { /**
const html = renderToStaticMarkup( * One render of a stock's control, its hooks from the harness. `consentOpen`
<PriceFetchControl * presets slot 0, the control's first hook (`showConsentModal`). The slots
symbol="AAPL" * follow the order of the control's hook calls: a hook of any kind added
date="2026-04-25" * before it shifts them without a word, and the preset lands on another hook
categoryKind="priced" * (one slot per hook call: the `slot` helper of `fakeReact`,
assetType="stock" * src/test/hookHarness.ts).
onPriceFetched={vi.fn()} */
/>, function markup(consentOpen = false): string {
if (consentOpen) harness.slots[0] = true;
return renderToStaticMarkup(
harness.render(
() =>
PriceFetchControl({
symbol: "AAPL",
date: "2026-04-25",
categoryKind: "priced",
assetType: "stock",
onPriceFetched: vi.fn(),
}) as ReactNode,
),
); );
}
it("names the fetch button and the stock best-effort notice through t", () => {
const html = markup();
expect(html).toContain('aria-label="t(balance.priceFetching.button)"'); expect(html).toContain('aria-label="t(balance.priceFetching.button)"');
expect(html).toContain(">t(balance.priceFetching.button)<"); expect(html).toContain(">t(balance.priceFetching.button)<");
expect(html).toContain(">t(balance.priceFetching.bestEffortNotice)<"); expect(html).toContain(">t(balance.priceFetching.bestEffortNotice)<");
@ -382,4 +406,15 @@ describe("PriceFetchControl — displayed texts", () => {
expect(html).not.toContain(">best-effort<"); expect(html).not.toContain(">best-effort<");
expect(html).toContain('aria-label="t(common.close)"'); expect(html).toContain('aria-label="t(common.close)"');
}); });
it("names the consent dialog through t once it is open (#437)", () => {
expect(markup()).not.toContain('role="dialog"');
harness.clear();
const html = markup(true);
expect(html).toContain('role="dialog"');
expect(html).toContain(">t(balance.priceFetching.consent.title)<");
expect(html).toContain(">t(balance.priceFetching.consent.body)<");
expect(html).toContain(">t(balance.priceFetching.consent.decline)<");
expect(html).toContain(">t(balance.priceFetching.consent.accept)<");
});
}); });

View file

@ -1,5 +1,6 @@
// Render tests for the key entry form of the License card (#347). No jsdom needed: // Render tests for the key entry form of the License card (#347). No jsdom needed:
// react-dom/server renders in vitest's node environment, and `t` echoes its key. // react-dom/server renders in vitest's node environment, and the marked `t` of
// src/test/i18nMock.ts renders `t(key)`.
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
@ -44,4 +45,10 @@ describe("LicenseKeyForm", () => {
expect(render(null, true)).toMatch(/<button type="submit" disabled=""/); expect(render(null, true)).toMatch(/<button type="submit" disabled=""/);
expect(render(null, false)).not.toMatch(/<button type="submit" disabled=""/); expect(render(null, false)).not.toMatch(/<button type="submit" disabled=""/);
}); });
it("names the key field and the submit button through t (#437)", () => {
const html = render(null);
expect(html).toContain('placeholder="t(license.keyPlaceholder)"');
expect(html).toMatch(/<button type="submit"[^>]*>t\(license\.activate\)<\/button>/);
});
}); });

View file

@ -10,6 +10,7 @@ vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nM
import TransactionTable, { TRANSACTION_CATEGORY_TARGET } from "./TransactionTable"; import TransactionTable, { TRANSACTION_CATEGORY_TARGET } from "./TransactionTable";
import type { Category, TransactionRow } from "../../shared/types"; import type { Category, TransactionRow } from "../../shared/types";
import type { SplitAccess } from "../../shared/splitGate"; import type { SplitAccess } from "../../shared/splitGate";
import type { LinkedTransferTooltipRow } from "../../services/balance.service";
const HIGHLIGHT = 'data-onboarding-highlight="true"'; const HIGHLIGHT = 'data-onboarding-highlight="true"';
const count = (html: string, needle: string) => html.split(needle).length - 1; const count = (html: string, needle: string) => html.split(needle).length - 1;
@ -46,7 +47,12 @@ const row = (
/** The guided entry: uncategorized rows only. */ /** The guided entry: uncategorized rows only. */
const UNCATEGORIZED = [row(1, "IGA 123"), row(2, "METRO 456"), row(3, "SAQ 789")]; const UNCATEGORIZED = [row(1, "IGA 123"), row(2, "METRO 456"), row(3, "SAQ 789")];
const markup = (rows: TransactionRow[], onboardingTarget?: boolean, splitAccess: SplitAccess = "open") => const markup = (
rows: TransactionRow[],
onboardingTarget?: boolean,
splitAccess: SplitAccess = "open",
linkedTransfersByTxId?: Map<number, LinkedTransferTooltipRow[]>,
) =>
renderToStaticMarkup( renderToStaticMarkup(
<TransactionTable <TransactionTable
rows={rows} rows={rows}
@ -60,6 +66,7 @@ const markup = (rows: TransactionRow[], onboardingTarget?: boolean, splitAccess:
onSaveSplit={vi.fn(async () => undefined)} onSaveSplit={vi.fn(async () => undefined)}
onDeleteSplit={vi.fn(async () => undefined)} onDeleteSplit={vi.fn(async () => undefined)}
splitAccess={splitAccess} splitAccess={splitAccess}
linkedTransfersByTxId={linkedTransfersByTxId}
onboardingTarget={onboardingTarget} onboardingTarget={onboardingTarget}
/>, />,
); );
@ -130,3 +137,22 @@ describe("TransactionTable — the Répartition button (#369)", () => {
expect(buttons[0]).toContain("text-orange-500"); expect(buttons[0]).toContain("text-orange-500");
}); });
}); });
describe("TransactionTable — texts that go through t (#437)", () => {
it("names the transfer icon through t, on the linked row only", () => {
const links = new Map<number, LinkedTransferTooltipRow[]>([
[2, [{ transaction_id: 2, account_id: 5, account_name: "CELI", direction: "in" }]],
]);
const html = markup(UNCATEGORIZED, undefined, "open", links);
const icon = 'aria-label="t(transactions.transferIcon.ariaLabel)"';
expect(count(html, icon)).toBe(1);
expect(bodyRows(html)[1]).toContain(icon);
});
it("names every row's notes button through t", () => {
// The textarea that shows the same key renders for an expanded row only
// (internal state): TransactionTable.wiring.test.ts opens one.
const html = markup(UNCATEGORIZED);
expect(count(html, 'title="t(transactions.notes.placeholder)"')).toBe(UNCATEGORIZED.length);
});
});

View file

@ -1,11 +1,13 @@
// TransactionTable — what the Répartition button opens (#369). // TransactionTable — what the Répartition button opens (#369), and the notes
// row its notes button opens (#437).
// //
// A static render never shows a window: they open on a click, through the // A static render never shows a window: they open on a click, through the
// table's own state. React's hooks come from the shared harness // table's own state. React's hooks come from the shared harness
// (src/test/hookHarness.ts, #409): the table runs as a plain function, its // (src/test/hookHarness.ts, #409): the table runs as a plain function, its
// state survives from one call to the next as between two renders of a mounted // state survives from one call to the next as between two renders of a mounted
// component, and the test clicks through the props the table hands to each // component, and the test clicks through the props the table hands to each
// SplitButton, then renders again to read what opened. // SplitButton (or to a row's notes button), then renders again to read what
// opened.
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react"; import { isValidElement, type ReactElement, type ReactNode } from "react";
@ -140,3 +142,18 @@ describe("TransactionTable — the Répartition button's wiring (#369)", () => {
expect(splitWindows(upgraded)).toHaveLength(0); expect(splitWindows(upgraded)).toHaveLength(0);
}); });
}); });
describe("TransactionTable — the notes row (#437)", () => {
/** The rows' notes buttons, named by their title (pinned in TransactionTable.render.test.tsx). */
const notesButtons = (tree: ReactNode) =>
find(tree, "button").filter((b) => b.props.title === "t(transactions.notes.placeholder)");
it("a row's notes button opens one textarea, named through t", () => {
expect(find(render("open"), "textarea")).toHaveLength(0);
(notesButtons(render("open"))[1].props.onClick as () => void)();
const opened = render("open");
const areas = find(opened, "textarea");
expect(areas).toHaveLength(1);
expect(areas[0].props.placeholder).toBe("t(transactions.notes.placeholder)");
});
});

View file

@ -163,3 +163,9 @@ describe("DashboardPage on the shared path (#352, #396, #397)", () => {
expect(html).toContain('data-marker="filters"'); expect(html).toContain('data-marker="filters"');
}); });
}); });
describe("DashboardPage — texts that go through t (#437)", () => {
it("names the KPI section through t", () => {
expect(render()).toContain(">t(dashboard.kpiSectionTitle)<");
});
});

View file

@ -736,7 +736,7 @@ describe("describePreparation (#390)", () => {
expect(withoutFolders("'C:\\a\\b.db' and 'C:\\Users\\Jean Dupont\\d.db'")).toBe("'b.db' and 'd.db'"); expect(withoutFolders("'C:\\a\\b.db' and 'C:\\Users\\Jean Dupont\\d.db'")).toBe("'b.db' and 'd.db'");
}); });
it.each([["\""], [";"], ["|"], ["`"], ["\n"]])("withoutFolders ends the folders at %j", (stop) => { it.each([["\""], [";"], ["|"], ["`"], ["\n"], ["\r"]])("withoutFolders ends the folders at %j", (stop) => {
expect(withoutFolders(`/home/marie claire/x.db${stop} I/O`)).toBe(`x.db${stop} I-O`); expect(withoutFolders(`/home/marie claire/x.db${stop} I/O`)).toBe(`x.db${stop} I-O`);
}); });