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
64 changes: 64 additions & 0 deletions web/src/app/pattern-lab/HardwareModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,70 @@ describe("HardwareModal", () => {
}
});

// A chat answer copied whole carries the model's words and the ``` fence
// around the code. The box used to pass it as a header, because the check
// found #pragma once on any line, and the words went into the .h whole.
it("keeps only the header when a whole chat answer is pasted", async () => {
const header = [
"// SPDX-License-Identifier: CC-BY-SA-4.0",
"// Pattern: Wave",
"#pragma once",
"namespace LabWave {",
' const char* NAME = "Wave";',
" void draw() {}",
"} // namespace LabWave",
].join("\n");
const reply = `Here is the converted header:\n\n\`\`\`cpp\n${header}\n\`\`\`\n\nLet me know if you want the colours warmer.`;
const writeText = vi.fn<(text: string) => Promise<void>>(() => Promise.resolve());
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
try {
render(<Lab />);
fireEvent.change(screen.getByPlaceholderText(/Paste the \.h the model gives you/), {
target: { value: reply },
});
expect(screen.getByText(/Looks like a header ✓/)).toHaveTextContent("The words around it stay out of the .h.");

fireEvent.click(screen.getByText("Copy .h"));
expect(await screen.findByText("Copied ✓")).toBeTruthy();
const name = useLabStore.getState().layers[0].name;
expect(writeText).toHaveBeenCalledWith(header.replace('"Wave"', `"${name}"`));
} finally {
delete (navigator as { clipboard?: unknown }).clipboard;
}
});

it("finds no header in an answer whose #pragma once is in a sentence", () => {
render(<Lab />);
fireEvent.change(screen.getByPlaceholderText(/Paste the \.h the model gives you/), {
target: { value: "Sure. The header starts with #pragma once and ends with the namespace." },
});
expect(screen.getByText("A Patternflow header starts with #pragma once")).toBeTruthy();
expect(screen.getByText("Copy .h")).toBeDisabled();
expect(screen.getByText("Next →")).toBeDisabled();
});

// Copy .h used to be the one button open on an empty box. It put nothing
// on the clipboard and said Copied ✓.
it("keeps Copy .h shut until a header is pasted", () => {
const writeText = vi.fn<(text: string) => Promise<void>>(() => Promise.resolve());
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
try {
render(<Lab />);
expect(screen.getByText("Copy .h")).toBeDisabled();
fireEvent.click(screen.getByText("Copy .h"));
expect(writeText).not.toHaveBeenCalled();
expect(screen.queryByText("Copied ✓")).toBeNull();

fireEvent.change(screen.getByPlaceholderText(/Paste the \.h the model gives you/), {
target: { value: HEADER },
});
expect(screen.getByText("Looks like a header ✓")).toBeTruthy();
expect(screen.getByText("Copy .h")).toBeEnabled();
} finally {
delete (navigator as { clipboard?: unknown }).clipboard;
}
});

// Text and pixel layers are in the scaffold as pixels, so with no code
// layer the header is whole. The window used to count "0 layers to
// translate — 0 done" and send the person to a model anyway.
Expand Down
26 changes: 22 additions & 4 deletions web/src/app/pattern-lab/HardwareModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ import { useEffect, useMemo, useState, type MouseEvent } from "react";
import { PublishModal, SendModuleModal, buildsConfigured, communityConfigured } from "./community";
import { useDialog } from "@/lib/hooks/useDialog";
import { useResettingState } from "@/lib/hooks/useResettingState";
import { assembleH, buildHExport, cleanPastedUnit, enforcePatternName } from "@/lib/lab/hExport";
import {
assembleH,
buildHExport,
cleanPastedHeader,
cleanPastedUnit,
enforcePatternName,
} from "@/lib/lab/hExport";
import { buildCppPrompt } from "@/lib/lab/cppPrompt";
import { currentPerformanceJson } from "@/lib/lab/director/publish";
import { needsFlatten } from "@/lib/lab/flatten";
Expand Down Expand Up @@ -85,14 +91,21 @@ export default function HardwareModal({
});
}, [layers, activeLayerId, codeLayers, matrix, knobs, ranges, knobLabels, name]);

// 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]);
const wordsLeftOut = singleHeader !== null && singleHeader !== single.trim();

// Single path: the model writes NAME itself, so stamp the lab's name back
// on (the scaffold path already carries it — buildHExport bakes it in).
const assembled = useMemo(
() =>
stacked
? assembleH(exportData.scaffold, pastes)
: enforcePatternName(single.trim(), name),
[stacked, exportData.scaffold, pastes, single, name],
: singleHeader === null
? ""
: enforcePatternName(singleHeader, name),
[stacked, exportData.scaffold, pastes, singleHeader, name],
);

// Text and pixel layers go into the scaffold as the pixels they show, so
Expand Down Expand Up @@ -287,13 +300,18 @@ export default function HardwareModal({
{headerLooksReal
? "Looks like a header ✓"
: "A Patternflow header starts with #pragma once"}
{wordsLeftOut ? " The words around it stay out of the .h." : null}
</p>
)}
</>
)}

<div className={styles.variantActions} style={{ marginTop: 10, flexWrap: "wrap" }}>
<button type="button" onClick={() => void copyText("h", assembled)}>
<button
type="button"
onClick={() => void copyText("h", assembled)}
disabled={!headerLooksReal}
>
{copyLabel("h", "Copy .h")}
</button>
<button type="button" onClick={downloadH} disabled={!headerLooksReal}>
Expand Down
20 changes: 20 additions & 0 deletions web/src/lib/lab/hExport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -725,6 +725,26 @@ export function cleanPastedUnit(pasted: string, index: number): string | null {
return text;
}

/**
* Cut a pasted one-layer answer down to the header. A chat answer copied whole
* brings the model's words and the ``` fence along. The header starts at the
* line #pragma once, or at the comment lines just above it (presets open with
* their SPDX line), and ends where a fence closes. null when no line starts
* with #pragma once.
*/
export function cleanPastedHeader(pasted: string): string | null {
const lines = pasted.split("\n");
const pragma = lines.findIndex((line) => /^\s*#pragma\s+once\b/.test(line));
if (pragma < 0) return null;
let start = pragma;
while (start > 0 && /^\s*(\/\/|\/\*|\*|$)/.test(lines[start - 1])) start -= 1;
const fence = lines.findIndex((line, i) => i > pragma && /^\s*```/.test(line));
return lines
.slice(start, fence < 0 ? lines.length : fence)
.join("\n")
.trim();
}

/** Swap pasted layer units into their marked slots. Missing units keep stubs. */
export function assembleH(scaffold: string, units: Record<number, string>): string {
let out = scaffold;
Expand Down
Loading