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
163 changes: 154 additions & 9 deletions web/src/app/pattern-lab/HardwareModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@
// hears the Esc meant for them, and it closes the one on top.
//
// The modal is mounted the way the lab mounts it, behind an open flag that
// onClose clears, so a stray close really unmounts it and the pasted header
// is really gone.
// onClose clears, so a stray close really unmounts it. The pasted header is
// kept for the tab, and rendering Lab again is opening the window again.

import { useState } from "react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import HardwareModal from "./HardwareModal";
import { useLabStore } from "@/lib/lab/store";
import { PROJECT_STORAGE, serializeProject } from "@/lib/lab/serialize";

vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }) }));
vi.mock("@/lib/community/auth-client", () => ({
Expand All @@ -26,6 +27,10 @@ vi.mock("@/lib/community/auth-client", () => ({
const HEADER = "#pragma once\nnamespace Lab { void draw() {} }\n";
const CODE = "export function draw(display) { display.setPixel(0, 0, 255, 0, 0); }";

/** What a model sends back for one layer of a stack. */
const unitBlock = (index: number) =>
`namespace L${index} {\n void setup() {}\n void update(float dt, const InputFrame& input) {}\n void draw() {}\n}`;

function Lab() {
const [open, setOpen] = useState(true);
return open ? (
Expand All @@ -39,11 +44,17 @@ function hardware() {
return screen.getByRole("dialog", { name: "Hardware" });
}

function headerBox() {
return screen.getByPlaceholderText(/Paste the \.h the model gives you/);
}

function pasteHeader() {
fireEvent.change(headerBox(), { target: { value: HEADER } });
}

/** Paste a header, press Next, and land on the "On to hardware" stage. */
function pasteHeaderAndContinue() {
fireEvent.change(screen.getByPlaceholderText(/Paste the \.h the model gives you/), {
target: { value: HEADER },
});
pasteHeader();
fireEvent.click(screen.getByText("Next →"));
expect(screen.getByText("On to hardware")).toBeTruthy();
}
Expand All @@ -57,6 +68,8 @@ function expectHeaderKept() {

beforeEach(() => {
useLabStore.getState().discardProject();
// The modal keeps what was pasted for the tab, and jsdom's tab is the whole file.
sessionStorage.clear();
vi.stubEnv("NEXT_PUBLIC_BUILD_ENABLED", "1");
vi.stubEnv("NEXT_PUBLIC_COMMUNITY_URL", "https://patternflow.work");
// Nothing here should reach the network; a request that does stays unanswered.
Expand Down Expand Up @@ -283,8 +296,6 @@ describe("HardwareModal", () => {
it("a stack of two code layers waits for one translation, then names the stub left", () => {
useLabStore.getState().addCodeLayer();
const bottom = useLabStore.getState().layers[1];
const unit = (index: number) =>
`namespace L${index} {\n void setup() {}\n void update(float dt, const InputFrame& input) {}\n void draw() {}\n}`;
render(<Lab />);

expect(screen.getByText(/2 layers to translate — 0 done/)).toHaveTextContent(
Expand All @@ -294,7 +305,7 @@ describe("HardwareModal", () => {
fireEvent.click(screen.getByText("Next →"));
expect(screen.queryByText("On to hardware")).toBeNull();

fireEvent.change(screen.getByPlaceholderText(/Paste the namespace L1 \{/), { target: { value: unit(1) } });
fireEvent.change(screen.getByPlaceholderText(/Paste the namespace L1 \{/), { target: { value: unitBlock(1) } });
expect(screen.getByText(/1 done/)).not.toHaveTextContent("Next opens");
fireEvent.click(screen.getByText("Next →"));
expect(screen.getByText("On to hardware")).toBeTruthy();
Expand All @@ -305,7 +316,7 @@ describe("HardwareModal", () => {
);

fireEvent.click(screen.getByText("← Back to the header"));
fireEvent.change(screen.getByPlaceholderText(/Paste the namespace L0 \{/), { target: { value: unit(0) } });
fireEvent.change(screen.getByPlaceholderText(/Paste the namespace L0 \{/), { target: { value: unitBlock(0) } });
fireEvent.click(screen.getByText("Next →"));
expect(screen.getByText(/Header ready \([\d.]+ KB\)\./)).toBeTruthy();
expect(screen.queryByText(/Not translated/)).toBeNull();
Expand Down Expand Up @@ -382,4 +393,138 @@ describe("HardwareModal", () => {
await waitFor(() => expect(screen.queryByText("Share to Community")).toBeNull());
expectHeaderKept();
});

// The header a model translated lived in this modal's state alone. Closing
// the window any way at all, or reloading the page, emptied the box, and
// the way back was to ask the model again.
it.each([
["Esc", () => fireEvent.keyDown(document.activeElement!, { key: "Escape" })],
["a press outside", () => fireEvent.click(hardware())],
["×", () => fireEvent.click(screen.getByRole("button", { name: "Close" }))],
])("opens again with the header it held before %s", (_, close) => {
render(<Lab />);
pasteHeader();
close();
expect(screen.getByText("closed")).toBeTruthy();
cleanup();

render(<Lab />);
expect(headerBox()).toHaveValue(HEADER);
expect(screen.getByText("Looks like a header ✓")).toBeTruthy();
expect(screen.getByText("Next →")).toBeEnabled();
});

it("opens again with the header after a reload", () => {
const lab = useLabStore.getState();
const starter = lab.layers[0].id;
lab.addCodeLayerFromCode(CODE, "Dot");
useLabStore.getState().removeLayer(starter);
render(<Lab />);
pasteHeader();
cleanup();

// What the lab's autosave writes, read back the way an open reads it.
const state = useLabStore.getState();
const saved = serializeProject({
name: state.name,
matrix: state.matrix,
layers: state.layers,
activeLayerId: state.activeLayerId,
knobs: state.knobs,
ranges: state.ranges,
knobLabels: state.knobLabels,
forkOf: state.forkOf,
editOf: state.editOf,
gen: state.gen,
director: state.director,
});
state.discardProject();
localStorage.setItem(PROJECT_STORAGE, saved!);
useLabStore.setState({ hydrated: false });
useLabStore.getState().hydrate();
expect(useLabStore.getState().layers.map((layer) => layer.name)).toEqual(["Dot"]);

render(<Lab />);
expect(headerBox()).toHaveValue(HEADER);
});

it("opens a stack again with the layers already translated", () => {
useLabStore.getState().addCodeLayer();
render(<Lab />);
fireEvent.change(screen.getByPlaceholderText(/Paste the namespace L1 \{/), { target: { value: unitBlock(1) } });
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
cleanup();

render(<Lab />);
expect(screen.getByText(/2 layers to translate — 1 done/)).toBeTruthy();
expect(screen.getByPlaceholderText(/Paste the namespace L1 \{/)).toHaveValue(unitBlock(1));
expect(screen.getByPlaceholderText(/Paste the namespace L0 \{/)).toHaveValue("");
expect(screen.getByText("Next →")).toBeEnabled();
});

// A knob position only says where the board starts that knob. Asking the
// model again for it would cost more than it fixes.
const turnKnob = () => {
const [min, max] = useLabStore.getState().ranges[0];
const knob = useLabStore.getState().knobs[0];
useLabStore.getState().setKnob(0, knob === max ? min : max);
expect(useLabStore.getState().knobs[0]).not.toBe(knob);
};

it("keeps the header when a knob turns", () => {
render(<Lab />);
pasteHeader();
cleanup();

turnKnob();
render(<Lab />);
expect(headerBox()).toHaveValue(HEADER);
});

// Each layer's prompt in a stack lists the knobs with where they sit now.
it("keeps a stack's translations when a knob turns", () => {
useLabStore.getState().addCodeLayer();
render(<Lab />);
fireEvent.change(screen.getByPlaceholderText(/Paste the namespace L1 \{/), { target: { value: unitBlock(1) } });
cleanup();

turnKnob();
render(<Lab />);
expect(screen.getByPlaceholderText(/Paste the namespace L1 \{/)).toHaveValue(unitBlock(1));
});

// The header answers one prompt. Once the lab asks another, it would put
// the old pattern, frame or colours on the board while saying ready.
it.each([
["the code", (id: string) => {
const layer = useLabStore.getState().layers.find((each) => each.id === id);
if (layer?.type !== "code") throw new Error("not code");
useLabStore.getState().updateLayerCode(id, `${layer.code}\n// tuned`);
}],
["the ramp mode", (id: string) => {
const layer = useLabStore.getState().layers.find((each) => each.id === id);
if (layer?.type !== "code") throw new Error("not code");
useLabStore.getState().setLayerRampMode(id, layer.ramp.mode === "step" ? "linear" : "step");
}],
["the matrix", () => useLabStore.getState().setMatrix({ width: 64, height: 64 })],
])("opens empty once %s changes, and keeps nothing for it", (_, change) => {
render(<Lab />);
pasteHeader();
cleanup();

change(useLabStore.getState().layers[0].id);
render(<Lab />);
expect(headerBox()).toHaveValue("");
expect(screen.getByText("Next →")).toBeDisabled();
});

it("forgets a header the box was emptied of", () => {
render(<Lab />);
pasteHeader();
fireEvent.change(headerBox(), { target: { value: "" } });
cleanup();

render(<Lab />);
expect(headerBox()).toHaveValue("");
});
});
72 changes: 64 additions & 8 deletions web/src/app/pattern-lab/HardwareModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
enforcePatternName,
} from "@/lib/lab/hExport";
import { buildCppPrompt } from "@/lib/lab/cppPrompt";
import { promptKey, readPastes, writePastes } from "@/lib/lab/hardwarePastes";
import { currentPerformanceJson } from "@/lib/lab/director/publish";
import { loneCodeLayer } from "@/lib/lab/flatten";
import { labPatternName, useLabStore } from "@/lib/lab/store";
Expand Down Expand Up @@ -53,8 +54,6 @@ export default function HardwareModal({
// Seeded from the lab's pattern name so the .h NAME, the .pfm slug and the
// Director's opening pattern cue all agree without retyping.
const [name, setName] = useState(() => labPatternName(useLabStore.getState()));
const [pastes, setPastes] = useState<Record<number, string>>({});
const [single, setSingle] = useState("");
const [copied, flashCopied] = useResettingState<{ key: string; ok: boolean } | null>(null, 1200);
const [stage, setStage] = useState<Stage>("convert");

Expand Down Expand Up @@ -91,6 +90,58 @@ export default function HardwareModal({
});
}, [lone, matrix, knobs, ranges, knobLabels, name]);

// What the model answered is kept for the tab under the prompt it answers
// (hardwarePastes.ts), so Esc, a press outside or a reload no longer sends
// the person back to ask again. The single key is the prompt without the
// name, which assembly stamps on again, and without the knob positions,
// which the key leaves out anyway.
const singleKey = useMemo(
() =>
lone
? promptKey(
buildCppPrompt({
code: lone.code,
matrix,
knobs: [],
ranges,
knobLabels,
ramp: lone.ramp,
recolor: lone.recolor,
}),
)
: "",
[lone, matrix, ranges, knobLabels],
);
const unitKeys = useMemo(
() => exportData.units.map((unit) => [unit.index, promptKey(unit.prompt)] as const),
[exportData.units],
);

const [single, setSingle] = useState(() => (lone ? (readPastes()[singleKey] ?? "") : ""));
const [pastes, setPastes] = useState<Record<number, string>>(() => {
const kept = readPastes();
const restored: Record<number, string> = {};
for (const [index, key] of unitKeys) {
if (kept[key]) restored[index] = kept[key];
}
return restored;
});

// Keeps what the boxes hold now and nothing else, so a paste whose prompt
// the lab no longer asks goes with the next edit.
const keep = (nextSingle: string, nextPastes: Record<number, string>) => {
const kept: Record<string, string> = {};
if (lone) {
if (nextSingle.trim()) kept[singleKey] = nextSingle;
} else {
for (const [index, key] of unitKeys) {
const text = nextPastes[index] ?? "";
if (text.trim()) kept[key] = text;
}
}
writePastes(kept);
};

// A chat answer pasted whole used to go into the .h with the model's words
// and the ``` fence, and the header test below passed it.
const singleHeader = useMemo(() => cleanPastedHeader(single), [single]);
Expand Down Expand Up @@ -160,8 +211,8 @@ export default function HardwareModal({
const label = lone?.name ?? name;

// Only a press on this backdrop itself closes. Send and Publish open inside
// it, so a press on their backdrop bubbles up here too, and the header
// pasted above lives in this component's state and would go with it.
// it, so a press on their backdrop bubbles up here too, and closing one of
// them would close this window under it.
const closeOnBackdrop = (event: MouseEvent<HTMLDivElement>) => {
if (event.target === event.currentTarget) onClose();
};
Expand Down Expand Up @@ -270,9 +321,11 @@ export default function HardwareModal({
<textarea
className={local.hwPaste}
value={pastes[unit.index] ?? ""}
onChange={(event) =>
setPastes((current) => ({ ...current, [unit.index]: event.target.value }))
}
onChange={(event) => {
const next = { ...pastes, [unit.index]: event.target.value };
setPastes(next);
keep(single, next);
}}
placeholder={`Paste the namespace L${unit.index} { … } block here`}
spellCheck={false}
rows={5}
Expand All @@ -290,7 +343,10 @@ export default function HardwareModal({
<textarea
className={local.hwPaste}
value={single}
onChange={(event) => setSingle(event.target.value)}
onChange={(event) => {
setSingle(event.target.value);
keep(event.target.value, pastes);
}}
placeholder="Paste the .h the model gives you — it starts with #pragma once"
spellCheck={false}
rows={12}
Expand Down
60 changes: 60 additions & 0 deletions web/src/lib/lab/hardwarePastes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// ── The answers pasted into To hardware ─────────────────────────────────────
// A header is the slow part of getting a pattern onto the board: copy the
// prompt, wait on a model, paste what comes back. The paste lived in the
// modal's state, so Esc, a press outside it or a reload threw it away, and
// the way back was to ask the model again.
//
// It is kept for the tab now, in sessionStorage, filed under the prompt it
// answers. The knob positions are left out of that key. They say where the
// board starts each knob, and turning one in the Knobs panel is no reason to
// ask again. Anything else that changes the prompt (an edit to the code, a
// resize, another ramp) leaves the paste answering a question nobody is
// asking now. It is not offered back, and the next edit in the modal drops it.
//
// It is sessionStorage, not the lab's localStorage (persist.ts). Each write
// replaces the record with what the modal shows, so two lab tabs sharing one
// record would wipe each other's pastes. A tab's record ends with the tab,
// and nothing has to prune it.

const KEY = "patternflow_lab_hardware_pastes_v1";

/**
* The prompt as a short key, with each knob's "current" value taken out.
* Both prompts write the positions as ", current <value>" on the knob lines.
*/
export function promptKey(prompt: string): string {
const asked = prompt.replace(/, current [^,\n]*/g, "");
let h = 2166136261;
for (let i = 0; i < asked.length; i++) {
h ^= asked.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return `${asked.length}:${(h >>> 0).toString(36)}`;
}

/** Every paste this tab kept, by the key of the prompt it answers. */
export function readPastes(): Record<string, string> {
if (typeof window === "undefined") return {};
try {
const parsed: unknown = JSON.parse(window.sessionStorage.getItem(KEY) ?? "{}");
if (!parsed || typeof parsed !== "object") return {};
const pastes: Record<string, string> = {};
for (const [key, text] of Object.entries(parsed)) {
if (typeof text === "string") pastes[key] = text;
}
return pastes;
} catch {
return {};
}
}

/** Keeps these and nothing else. Without storage the modal keeps them while it is open. */
export function writePastes(pastes: Record<string, string>): void {
if (typeof window === "undefined") return;
try {
if (Object.keys(pastes).length === 0) window.sessionStorage.removeItem(KEY);
else window.sessionStorage.setItem(KEY, JSON.stringify(pastes));
} catch {
// Private mode or a full quota.
}
}
Loading
Loading