Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
89 changes: 89 additions & 0 deletions src/components/features/ConsentDialog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 The resumelint Authors

import { describe, it, expect } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { ConsentDialog } from "./ConsentDialog.tsx";
import type { ModelMetadata } from "../../lib/webllm/models.ts";

const gemma: ModelMetadata = {
id: "gemma-2-2b-it-q4f16_1-MLC",
name: "Gemma 2 (2B)",
licenseType: "Restricted-Community",
tier: "Standard",
licenseUrl: "https://ai.google.dev/gemma/terms",
downloadSizeMb: 1895,
};

const llama: ModelMetadata = {
id: "Llama-3.2-3B-Instruct-q4f16_1-MLC",
name: "Llama 3.2 (3B)",
licenseType: "Restricted-Community",
tier: "High",
licenseUrl: "https://www.llama.com/llama3_2/license/",
downloadSizeMb: 2264,
};

function render(props: Parameters<typeof ConsentDialog>[0]): string {
return renderToStaticMarkup(createElement(ConsentDialog, props));
}

describe("ConsentDialog", () => {
it("names the specific model in the title (consent is per-licenseType but disclosure is per-model)", () => {
const html = render({
model: gemma,
open: true,
onAccept: () => {},
onDecline: () => {},
});
expect(html).toContain("Gemma 2 (2B)");
});

it("shows the vendor's licenseUrl with safe link attributes (target=_blank, rel=noopener noreferrer)", () => {
const html = render({
model: gemma,
open: true,
onAccept: () => {},
onDecline: () => {},
});
expect(html).toContain("https://ai.google.dev/gemma/terms");
expect(html).toContain('target="_blank"');
expect(html).toContain('rel="noopener noreferrer"');
});

it("renders different vendor link per model — Llama gets the Llama license URL, not the Gemma one", () => {
const html = render({
model: llama,
open: true,
onAccept: () => {},
onDecline: () => {},
});
expect(html).toContain("https://www.llama.com/llama3_2/license/");
expect(html).not.toContain("ai.google.dev/gemma/terms");
});

it("renders both Accept and Decline buttons", () => {
const html = render({
model: gemma,
open: true,
onAccept: () => {},
onDecline: () => {},
});
expect(html).toMatch(/Accept/);
expect(html).toContain("Decline");
});

it("mentions that accept applies to the license type, not just this one model", () => {
// Forward guard against a future copy change that loses the
// 'one accept covers the whole license type' nuance — which is the
// load-bearing UX promise behind the per-licenseType persistence.
const html = render({
model: gemma,
open: true,
onAccept: () => {},
onDecline: () => {},
});
expect(html.toLowerCase()).toContain("once per license type");
});
});
100 changes: 100 additions & 0 deletions src/components/features/ConsentDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 The resumelint Authors

/**
* ConsentDialog — modal shown before any Restricted-Community model begins
* downloading. Built on the shared `Dialog` primitive from `@design-system`.
*
* Per the #64 spec:
* - Fires before `loadEngine` is called for a Restricted-Community model
* when consent has not already been recorded.
* - Persistence is per-`licenseType` (handled by `useModelSelection`),
* not per-model — accepting Gemma's terms also covers Llama if both
* are tagged Restricted-Community.
* - The modal DISPLAYS the per-model `licenseUrl` so the user reads the
* specific vendor's terms before accepting. Type-level consent +
* model-level link disclosure.
* - Decline must revert to the previously cached model (or
* `DEFAULT_MODEL_ID` if none) and not start any download.
*
* The dialog owns no persistence — it's a controlled component. The caller
* (ModelSelector) handles `recordConsent` on accept and "revert selection"
* on decline.
*
* Reuse analysis (CLAUDE.md 3-tier rule):
* - Primitive: `Dialog` from `@design-system` owns the modal chrome,
* focus trap, Esc handling, and ARIA wiring. No raw `<dialog>` here.
* - Primitive: `Button` for both Accept and Decline.
* - No `Card`: the dialog itself is the surface; nesting Card would
* double the border + padding.
*/

import { Button, Dialog } from "@design-system";
import type { ModelMetadata } from "../../lib/webllm/models.ts";

interface ConsentDialogProps {
/** The model the user is trying to load. Must be Restricted-Community. */
model: ModelMetadata;
open: boolean;
onAccept: () => void;
onDecline: () => void;
}

export function ConsentDialog({
model,
open,
onAccept,
onDecline,
}: ConsentDialogProps) {
return (
<Dialog
open={open}
onClose={onDecline}
title={`Review the ${model.name} license before downloading`}
className="max-w-md"
>
<div className="flex flex-col gap-3">
<p className="text-xs leading-relaxed text-content-secondary">
<strong className="text-content-primary">{model.name}</strong> is
released under the{" "}
{model.licenseUrl ? (
<a
href={model.licenseUrl}
target="_blank"
rel="noopener noreferrer"
className="text-brand-amber underline underline-offset-2 hover:text-brand-amber-light"
>
vendor's terms of use
</a>
) : (
"vendor's terms of use"
)}
, which differ from the Apache-2.0 default. The model weights stay
on your device, but downloading the model means accepting those
terms.
</p>
<p className="text-[11px] leading-relaxed text-content-tertiary">
You only need to accept once per license type — switching to
another model under the same license won't re-prompt you.
</p>
<div className="mt-1 flex flex-wrap items-center justify-end gap-2">
<Button
variant="link"
size="sm"
onClick={onDecline}
className="text-content-tertiary"
>
Decline
</Button>
{/* Initial focus deliberately defaults to Decline (first
focusable child in DOM order). Consent UX convention: the
safe option gets keyboard focus so a roll-through Enter
doesn't accidentally accept terms the user hasn't read. */}
<Button variant="primary" size="sm" onClick={onAccept}>
Accept &amp; download
</Button>
</div>
</div>
</Dialog>
);
}
162 changes: 162 additions & 0 deletions src/components/features/ModelSelector.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 The resumelint Authors

/**
* Smoke tests for `ModelSelector`'s presentational surface.
*
* The top-level component returns `null` until `detectWebGpu()` resolves,
* which the Node-env `renderToStaticMarkup` harness can't drive. So the
* testable surface is `ModelRow` (each registry entry's display branching)
* and the pure `licenseLabel` helper. The interactive flow (click →
* consent → load) is covered by manual test in the PR test plan.
*/

import { describe, it, expect } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { ModelRow, licenseLabel } from "./ModelSelector.tsx";
import { MODEL_REGISTRY } from "../../lib/webllm/models.ts";

const qwen = MODEL_REGISTRY.find((m) => m.licenseType === "Apache-2.0")!;
const gemma = MODEL_REGISTRY.find(
(m) => m.id === "gemma-2-2b-it-q4f16_1-MLC",
)!;
const llama = MODEL_REGISTRY.find(
(m) => m.id === "Llama-3.2-3B-Instruct-q4f16_1-MLC",
)!;

function render(props: Parameters<typeof ModelRow>[0]): string {
return renderToStaticMarkup(createElement(ModelRow, props));
}

const baseRow = {
selected: false,
cached: false,
disabled: false,
error: null,
loadingProgress: null,
onPick: () => {},
};

describe("licenseLabel", () => {
it("renders 'Apache-2.0' for Apache-2.0 entries", () => {
expect(licenseLabel(qwen)).toBe("Apache-2.0");
});

it("renders 'Vendor license' for Restricted-Community entries (specific vendor name lives in the consent dialog)", () => {
expect(licenseLabel(gemma)).toBe("Vendor license");
expect(licenseLabel(llama)).toBe("Vendor license");
});
});

describe("ModelRow — cached vs fresh-download labels", () => {
it("labels a cached row as 'Downloaded · runs offline'", () => {
const html = render({ ...baseRow, model: qwen, cached: true });
expect(html).toContain("Downloaded");
expect(html).toContain("runs offline");
});

it("labels an uncached row with the download size in GB (the size-warning surface)", () => {
// 1630 MB → 1.6 GB
const html = render({ ...baseRow, model: qwen, cached: false });
expect(html).toContain("Will download");
expect(html).toContain("1.6 GB");
expect(html).toContain("one-time");
});

it("uses the right size for Gemma 2 (1895 MB → 1.9 GB)", () => {
const html = render({ ...baseRow, model: gemma, cached: false });
expect(html).toContain("1.9 GB");
});

it("uses the right size for Llama 3.2 (2264 MB → 2.2 GB)", () => {
const html = render({ ...baseRow, model: llama, cached: false });
expect(html).toContain("2.2 GB");
});
});

describe("ModelRow — selection + tier + license display", () => {
it("shows the model's name + tier + license label", () => {
const html = render({ ...baseRow, model: qwen });
expect(html).toContain(qwen.name);
expect(html).toContain(qwen.tier);
expect(html).toContain("Apache-2.0");
});

it("marks the default model with a 'default' annotation", () => {
const html = render({ ...baseRow, model: qwen });
expect(html).toContain("default");
});

it("does NOT mark non-default models with the 'default' annotation", () => {
const html = render({ ...baseRow, model: gemma });
expect(html).not.toMatch(/>default</);
});

it("renders `aria-pressed=true` on the selected row (toggle semantics for SR users)", () => {
expect(render({ ...baseRow, model: qwen, selected: true })).toContain(
'aria-pressed="true"',
);
expect(render({ ...baseRow, model: qwen, selected: false })).toContain(
'aria-pressed="false"',
);
});

it("shows the '✓ Selected' badge on the selected row, not on others", () => {
expect(
render({ ...baseRow, model: qwen, selected: true }),
).toContain("Selected");
expect(
render({ ...baseRow, model: qwen, selected: false }),
).not.toContain("Selected");
});
});

describe("ModelRow — load progress + error states", () => {
it("renders the inline progress panel when loadingProgress is provided", () => {
const html = render({
...baseRow,
model: qwen,
loadingProgress: { progress: 0.4, text: "fetching weights" },
});
// ModelLoadProgress label is "Loading <name> (one-time download)".
expect(html).toContain(qwen.name);
expect(html).toContain("40%");
expect(html).toContain("fetching weights");
expect(html).toContain('role="progressbar"');
});

it("renders the per-model friendly-summary error message with role=alert when error is provided", () => {
const html = render({
...baseRow,
model: gemma,
error: {
message:
"Gemma 2 (2B) needs more GPU memory than your device can spare. Try a smaller model.",
},
});
expect(html).toContain('role="alert"');
expect(html).toContain("more GPU memory");
// No Technical details disclosure when `detail` is omitted.
expect(html).not.toContain("Technical details");
});

it("renders the optional Technical details disclosure when raw error detail is provided", () => {
const html = render({
...baseRow,
model: gemma,
error: {
message: "Couldn't load Gemma 2 (2B).",
detail: "Cannot find adapter that matches the request",
},
});
expect(html).toContain("Technical details");
expect(html).toContain("Cannot find adapter");
});

it("disables the row when `disabled` is true (another model is loading)", () => {
expect(render({ ...baseRow, model: qwen, disabled: true })).toContain(
"disabled",
);
});
});
Loading
Loading