Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions scripts/audit-dark-contrast.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ const OUTPUT_PATH = process.env.CONTRAST_AUDIT_OUTPUT || "/tmp/contrast-audit.js

// AGENTS.md 語言代碼: a=# (/), t=#' (/'), h=#: (/:), c=#~ (/~)
const ROUTES = ["/萌", "/生", "/'食", "/'長褲", "/:客", "/~東西", "/=*", "/@", "/@口", "/about"];
// CI audits the seeded Miniflare fixture server, where entry paths that match
// no headword hard-404 (R4): every CI route must be a seeded fixture word
// (tests/helpers/fixtures.ts — 字 is the only Hakka headword, 黃 the second
// Mandarin one), one per language prefix the full list covers.
const CI_FIXTURE_ROUTES = ["/萌", "/黃", "/'食", "/'長褲", "/:字"];
// First in-dictionary link: the 異體字 icon (.variants-link) is an external link
// with no tooltip, and on Hakka entries (no radical box) it comes first.
const HOVER_LINK_SELECTOR = ".result a[href]:not(.variants-link)";

const VIEWPORTS = [
{ name: "desktop", width: 1280, height: 800 },
Expand All @@ -55,7 +63,7 @@ const VIEWPORTS = [

const THEMES = ["light", "dark"];
const CI_MODE = process.argv.includes("--ci") || process.env.CI === "true";
const AUDIT_ROUTES = CI_MODE ? ROUTES.slice(0, 5) : ROUTES;
const AUDIT_ROUTES = CI_MODE ? CI_FIXTURE_ROUTES : ROUTES;
const AUDIT_VIEWPORTS = CI_MODE ? [VIEWPORTS[0], VIEWPORTS[1]] : VIEWPORTS;

// WCAG 2.x contrast thresholds. "Large text" = >=24px, or >=18.66px (14pt)
Expand Down Expand Up @@ -756,7 +764,7 @@ async function auditCombo(
}),
);

// 5. Hover first .result a[href] and wait for .ui-tooltip.
// 5. Hover the first in-dictionary result link and wait for .ui-tooltip.
await withIsolatedState(
browser,
viewport,
Expand All @@ -767,15 +775,15 @@ async function auditCombo(
actionFailures,
collectOpts,
async (page) => {
const link = page.locator(".result a[href]").first();
const link = page.locator(HOVER_LINK_SELECTOR).first();
if ((await link.count()) === 0) {
actionFailures.push({
route,
viewport: viewport.name,
theme,
state: STATE_HOVER_TOOLTIP,
kind: "element-missing",
selector: ".result a[href]",
selector: HOVER_LINK_SELECTOR,
});
return false;
}
Expand Down
2 changes: 1 addition & 1 deletion scripts/lib/contrast-audit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function classifyActionFailure(failure) {
const unavailable = new Set([
'a.iconic-circle.stroke[title="筆順動畫"]',
".entry-actions",
".result a[href]",
".result a[href]:not(.variants-link)",
"#btn-toggle-all-langs",
"#btn-toggle-import",
]);
Expand Down
25 changes: 25 additions & 0 deletions tests/unit/api-dictionary-nfkc-fallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
bucketOf,
handleDictionaryAPI,
lookupDictionaryEntry,
probeDictionaryEntry,
} from "../../src/api/handleDictionaryAPI";

interface R2Stub {
Expand Down Expand Up @@ -76,6 +77,30 @@ describe("bucketOf sanity guard for ⼴ / 广", () => {
});
});

describe("probeDictionaryEntry — NFKC fallback decides the entry route's status (R4)", () => {
it("returns true when only the NFKC-normalized bucket holds the headword", async () => {
const env = makeEnv({
[`pack/${IDEOGRAPH_BUCKET}.txt`]: JSON.stringify({ [IDEOGRAPH_KEY]: makeYanEntry() }),
});
expect(await probeDictionaryEntry(RADICAL_YEN, "a", env)).toBe(true);
});

it("returns false when neither the literal nor the NFKC spelling is a headword", async () => {
const env = makeEnv({ [`pack/${IDEOGRAPH_BUCKET}.txt`]: JSON.stringify({}) });
expect(await probeDictionaryEntry(RADICAL_YEN, "a", env)).toBe(false);
});

it("returns null (fail open) when the literal bucket shard cannot be parsed", async () => {
const env = makeEnv({ [`pack/${RADICAL_BUCKET}.txt`]: "{not json" });
expect(await probeDictionaryEntry(RADICAL_YEN, "a", env)).toBeNull();
});

it("returns null (fail open) when the NFKC bucket shard cannot be parsed", async () => {
const env = makeEnv({ [`pack/${IDEOGRAPH_BUCKET}.txt`]: "{not json" });
expect(await probeDictionaryEntry(RADICAL_YEN, "a", env)).toBeNull();
});
});

describe("lookupDictionaryEntry — Kangxi radical-block fallback", () => {
it("resolves ⼴ (U+2F34) to the 广 (U+5E7F) entry when only the ideograph bucket is seeded", async () => {
const env = makeEnv({
Expand Down
12 changes: 12 additions & 0 deletions tests/unit/dictionary-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,9 @@ describe("resolveLegacyHashRoute (g0v/moedict-webkit#131)", () => {
it('does nothing for an empty or bare hash — leaves navbar `href="#"` toggles untouched', () => {
expect(resolveLegacyHashRoute("/", "")).toBeNull();
expect(resolveLegacyHashRoute("/", "#")).toBeNull();
// A bare language/radical prefix carries no term to route to.
expect(resolveLegacyHashRoute("/", "#'")).toBeNull();
expect(resolveLegacyHashRoute("/", "#:")).toBeNull();
});

it("fails closed to null on malformed percent-encoding instead of throwing", () => {
Expand Down Expand Up @@ -523,6 +526,15 @@ describe("applyLegacyHashRewrite (boot-time shim before React Router mounts)", (
window.history.replaceState(null, "", "/");
});

it("is a no-op without a DOM (module imported outside the browser)", () => {
vi.stubGlobal("window", undefined);
try {
expect(applyLegacyHashRewrite()).toBe(false);
} finally {
vi.unstubAllGlobals();
}
});

it("rewrites /#a=萌 to /萌 via replaceState and clears the hash", () => {
window.history.replaceState(null, "", "/#a=萌");
const spy = vi.spyOn(window.history, "replaceState");
Expand Down
27 changes: 27 additions & 0 deletions tests/unit/worker-dispatch-edges.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,20 @@ describe("dispatch — HTML shell fetch returns non-OK", () => {
expect(res.status).toBe(503);
expect(res.headers.get("X-Moedict-Shell-Source")).toBe("recovery");
});

it("returns a bodyless no-store 404 (not the 503 recovery) for an entry path that misses", async () => {
// R4 during a shell outage: the dictionary probe still answers "no such
// headword", so the status stays a definitive 404 rather than a recovery
// page for a word that does not exist.
const fetcher = {
fetch: vi.fn(async () => new Response("upstream error", { status: 500 })),
};
const env = makeEnv({ SITE_ASSETS: fetcher as unknown as AnyEnv["SITE_ASSETS"] });
const res = await dispatch(req("/%E8%90%8C"), env);
expect(res.status).toBe(404);
expect(res.headers.get("Cache-Control")).toBe("no-store");
expect(await res.text()).toBe("");
});
});

describe("dispatch — radical 青/靑 variant fallback with neither seeded", () => {
Expand Down Expand Up @@ -510,6 +524,19 @@ describe("dispatch — HTML shell metadata injection with dictionary lookup", ()
expect(await res.text()).toContain("<html");
});

it("HEAD on a missed entry path returns the 404 headers without a body", async () => {
const fetcher = shellFetcher();
const env = makeEnv({ SITE_ASSETS: fetcher as unknown as AnyEnv["SITE_ASSETS"] });
const res = await dispatch(
new Request("http://localhost/%27%E9%A3%9F", { method: "HEAD" }),
env,
);
expect(res.status).toBe(404);
expect(res.headers.get("Cache-Control")).toBe("no-store");
expect(res.headers.get("content-type")).toContain("text/html");
expect(res.body).toBeNull();
});

// Regression (R4 fallout): `/privacy` and `/about` are real client routes,
// not headwords. Before classifyRoute learned the `privacy` kind, /privacy
// classified as entry("privacy"), probed as a definitive miss, and a live
Expand Down
Loading