Skip to content

Commit 74832cb

Browse files
committed
feat(v5-g11): spec save/load UI — JSON Blob download + file input
Closes V5-G11 / cppmega-mlx-4n3. V3-12 was deferred. v5 adds: - SpecAction 'spec.replace' reducer action - TopBar Save button (data-testid=spec-save) → blob download of {projectName, spec, nodes, edges} JSON - TopBar Load file input (data-testid=spec-load-input) hidden + Load button visible; reads file, parses JSON, dispatches replace 3 new vitest. 189/189 vitest regression green.
1 parent ffc5119 commit 74832cb

4 files changed

Lines changed: 79 additions & 1 deletion

File tree

vbgui/src/App.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,33 @@ export function App(): JSX.Element {
353353
onRunPipeline={handleRunPipeline}
354354
trainParquetPath={trainParquetPath}
355355
trainTokenizerPath={trainTokenizerPath}
356+
onSaveSpec={() => {
357+
// G11: serialise full SpecState + canvas (nodes/edges) to JSON
358+
const blob = new Blob([JSON.stringify({
359+
projectName, spec, nodes, edges,
360+
}, null, 2)], { type: "application/json" });
361+
const url = URL.createObjectURL(blob);
362+
const a = document.createElement("a");
363+
a.href = url;
364+
a.download = `${projectName}.spec.json`;
365+
a.click();
366+
URL.revokeObjectURL(url);
367+
}}
368+
onLoadSpec={(file: File) => {
369+
const reader = new FileReader();
370+
reader.onload = () => {
371+
try {
372+
const obj = JSON.parse(String(reader.result));
373+
if (obj.projectName) setProjectName(String(obj.projectName));
374+
if (obj.spec) dispatch({ type: "spec.replace", spec: obj.spec });
375+
if (obj.nodes) setNodes(obj.nodes);
376+
if (obj.edges) setEdges(obj.edges);
377+
} catch (e) {
378+
setRunError(`Load failed: ${String(e)}`);
379+
}
380+
};
381+
reader.readAsText(file);
382+
}}
356383
trainDisabled={
357384
(() => {
358385
// V3-8/V3-9: gate Train on gotcha severity. The verify RPC

vbgui/src/components/TopBar.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ export interface TopBarProps {
2424
* or fall back to synthetic. */
2525
trainParquetPath?: string | null;
2626
trainTokenizerPath?: string | null;
27+
/** G11: save/load callbacks. */
28+
onSaveSpec?: () => void;
29+
onLoadSpec?: (file: File) => void;
2730
}
2831

2932
export function TopBar(p: TopBarProps): JSX.Element {
@@ -71,6 +74,28 @@ export function TopBar(p: TopBarProps): JSX.Element {
7174

7275
<MemoryBar state={p.state} />
7376

77+
{p.onSaveSpec && (
78+
<button data-testid="spec-save" onClick={p.onSaveSpec}>Save</button>
79+
)}
80+
{p.onLoadSpec && (
81+
<>
82+
<input data-testid="spec-load-input"
83+
type="file" accept=".json"
84+
style={{ display: "none" }}
85+
onChange={(e) => {
86+
const f = e.target.files?.[0];
87+
if (f && p.onLoadSpec) p.onLoadSpec(f);
88+
e.currentTarget.value = "";
89+
}} />
90+
<button data-testid="spec-load"
91+
onClick={() => {
92+
const el = document.querySelector<HTMLInputElement>(
93+
"[data-testid='spec-load-input']");
94+
el?.click();
95+
}}>Load</button>
96+
</>
97+
)}
98+
7499
<span data-testid="train-data-source"
75100
style={{ fontSize: 10,
76101
color: p.trainParquetPath ? "#16a34a" : "#9ca3af",

vbgui/src/state/spec.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,8 @@ export type SpecAction =
246246
| { type: "gotchas.set"; gotchas: GotchaState[] }
247247
| { type: "memory.set"; worst_rank_bytes: number; device_hbm_bytes?: number }
248248
| { type: "verify.complete"; elapsed_ms: number; brick_count: number }
249-
| { type: "backend.status"; status: SpecState["backend_status"] };
249+
| { type: "backend.status"; status: SpecState["backend_status"] }
250+
| { type: "spec.replace"; spec: SpecState };
250251

251252
export function specReducer(s: SpecState, a: SpecAction): SpecState {
252253
switch (a.type) {
@@ -270,6 +271,7 @@ export function specReducer(s: SpecState, a: SpecAction): SpecState {
270271
return { ...s, rewriters: out };
271272
}
272273
case "side_channels.set": return { ...s, side_channels: a.side_channels };
274+
case "spec.replace": return a.spec;
273275
case "sharding.set": return { ...s, sharding: a.sharding };
274276
case "gotchas.set": return { ...s, gotchas: a.gotchas };
275277
case "memory.set": return {

vbgui/tests/TopBar.test.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,30 @@ describe("TopBar", () => {
6363
expect(t).toContain("cppmega_tokenizer.json");
6464
});
6565

66+
// G11: Save/Load buttons
67+
it("G11: spec-save + spec-load hidden when callbacks not provided", () => {
68+
render(<TopBar {...defaultTopProps()} />);
69+
expect(screen.queryByTestId("spec-save")).toBeNull();
70+
expect(screen.queryByTestId("spec-load")).toBeNull();
71+
});
72+
73+
it("G11: spec-save fires onSaveSpec callback", () => {
74+
const onSaveSpec = vi.fn();
75+
render(<TopBar {...defaultTopProps({ onSaveSpec })} />);
76+
fireEvent.click(screen.getByTestId("spec-save"));
77+
expect(onSaveSpec).toHaveBeenCalledTimes(1);
78+
});
79+
80+
it("G11: spec-load-input fires onLoadSpec with file", () => {
81+
const onLoadSpec = vi.fn();
82+
render(<TopBar {...defaultTopProps({ onLoadSpec })} />);
83+
const file = new File(['{"spec":{}}'], "test.spec.json",
84+
{ type: "application/json" });
85+
fireEvent.change(screen.getByTestId("spec-load-input"),
86+
{ target: { files: [file] } });
87+
expect(onLoadSpec).toHaveBeenCalledWith(file);
88+
});
89+
6690
it("preset launcher fires onPresetDrop when chosen", () => {
6791
const onPresetDrop = vi.fn();
6892
render(<TopBar {...defaultTopProps({ onPresetDrop })} />);

0 commit comments

Comments
 (0)