velxio/frontend/src/__tests__/load-example-transitions.te...

199 lines
7.9 KiB
TypeScript

// @vitest-environment jsdom
/**
* Regression test for the bug where switching between a board-less example
* (analog / digital SPICE-only) and a board-based example (Arduino Uno) left
* the editor showing the OLD file's content (or nothing at all).
*
* Root cause: `loadExample.ts` single-board path called
* `useEditorStore.setCode()`, which writes to whatever file `activeFileId`
* happens to point at. But after a board-less example removed every board,
* its file group was deleted — and `activeFileId` still pointed at the now-
* orphan ID, so `setCode` was a silent no-op against the freshly-recreated
* Arduino file group.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { useEditorStore } from '../store/useEditorStore';
import { useSimulatorStore } from '../store/useSimulatorStore';
import { useElectricalStore } from '../store/useElectricalStore';
import { loadExample } from '../utils/loadExample';
import { exampleProjects, type ExampleProject } from '../data/examples';
import { isProgrammableChip } from '../services/romCompileService';
function resetStores() {
// Clear all boards completely (also clears the file groups they own).
const sim = useSimulatorStore.getState();
const ids = sim.boards.map((b) => b.id);
for (const id of ids) sim.removeBoard(id);
useElectricalStore.getState().setPaused(false);
}
function findExample(id: string) {
const e = exampleProjects.find((x) => x.id === id);
if (!e) throw new Error(`Example not found: ${id}`);
return e;
}
function activeSketchContent(): string | undefined {
const { files, activeFileId } = useEditorStore.getState();
return files.find((f) => f.id === activeFileId)?.content;
}
describe('loadExample — board-less → board-based transition', () => {
beforeEach(() => {
resetStores();
});
it('loads Arduino Uno code into the editor after a board-less ANALOG example', async () => {
// 1. Board-less analog circuit
await loadExample(findExample('an-voltage-divider'));
expect(useSimulatorStore.getState().boards.length).toBe(0);
// 2. Now load an Arduino Uno example
const uno = findExample('blink-led');
await loadExample(uno);
expect(useSimulatorStore.getState().boards.length).toBeGreaterThanOrEqual(1);
const code = activeSketchContent();
expect(code, 'editor content after Uno load').toBeDefined();
expect(code, 'editor must contain the Uno example body').toContain(uno.code.slice(0, 40));
});
it('loads Arduino Uno code into the editor after a board-less DIGITAL example', async () => {
await loadExample(findExample('digital-and-two-switches'));
expect(useSimulatorStore.getState().boards.length).toBe(0);
const uno = findExample('blink-led');
await loadExample(uno);
const code = activeSketchContent();
expect(code, 'editor content after Uno load').toBeDefined();
expect(code, 'editor must contain the Uno example body').toContain(uno.code.slice(0, 40));
});
it('switching between two board-less examples does not break a later board load', async () => {
await loadExample(findExample('an-voltage-divider'));
await loadExample(findExample('digital-xor-difference'));
await loadExample(findExample('an-rc-low-pass'));
const uno = findExample('blink-led');
await loadExample(uno);
const code = activeSketchContent();
expect(code, 'editor content after 3 board-less → Uno load').toBeDefined();
expect(code, 'editor must contain the Uno example body').toContain(uno.code.slice(0, 40));
});
});
describe('loadExample — programmable-chip program lives in its own group', () => {
beforeEach(() => {
resetStores();
});
it('board-less chip example opens the chip program (larson.s) as the active group, editable', async () => {
await loadExample(findExample('z80-larson-no-board'));
const ed = useEditorStore.getState();
expect(useSimulatorStore.getState().boards.length).toBe(0);
// The chip owns a group-chip-<id> group with its program file.
const chipGroupId = 'group-chip-z80cpu';
expect(ed.fileGroups[chipGroupId], 'chip group exists').toBeDefined();
expect(ed.fileGroups[chipGroupId].map((f) => f.name)).toContain('larson.s');
// That group is the active one (program shows on the left, editable).
expect(ed.activeGroupId).toBe(chipGroupId);
const larson = ed.fileGroups[chipGroupId].find((f) => f.name === 'larson.s');
expect(larson?.content.length ?? 0, 'larson.s is non-empty').toBeGreaterThan(0);
expect(activeSketchContent(), 'editor shows the larson.s program').toBe(larson?.content);
});
it('board + chip routing keeps the chip program OUT of the board sketch group', async () => {
// The gallery's chip examples are all board-less now, but the board+chip
// routing path still applies when a user drops a programmable chip onto a
// board project — exercise it with an inline synthetic example.
const boardChip: ExampleProject = {
id: 'test-board-chip',
title: 'Board + chip',
description: '',
category: 'circuits',
difficulty: 'beginner',
boardType: 'arduino-uno',
code: 'void setup(){}\nvoid loop(){}',
files: [
{ name: 'sketch.ino', content: 'void setup(){}\nvoid loop(){}' },
{ name: 'prog.s', content: '; chip program' },
],
components: [
{
type: 'custom-chip',
id: 'tchip',
x: 100,
y: 100,
properties: {
chipName: 'T',
sourceC: '',
chipJson: JSON.stringify({ programTargets: ['z80'] }),
programFile: 'prog.s',
programTarget: 'z80',
},
},
],
wires: [],
} as unknown as ExampleProject;
await loadExample(boardChip);
const ed = useEditorStore.getState();
const sim = useSimulatorStore.getState();
// Board group shows only the sketch — prog.s is NOT a sibling tab.
const board = sim.boards.find((b) => b.id === sim.activeBoardId) ?? sim.boards[0];
const boardFiles = (ed.fileGroups[board.activeFileGroupId] ?? []).map((f) => f.name);
expect(boardFiles).toContain('sketch.ino');
expect(boardFiles, 'prog.s must not pollute the board group').not.toContain('prog.s');
// The chip program lives in its own group instead.
const chipGroupId = 'group-chip-tchip';
expect(ed.fileGroups[chipGroupId]?.map((f) => f.name)).toContain('prog.s');
// With a board present the board sketch stays the active group.
expect(ed.activeGroupId).toBe(board.activeFileGroupId);
});
it('chip groups from a previous example do not leak into the next', async () => {
await loadExample(findExample('z80-larson-scanner'));
expect(useEditorStore.getState().fileGroups['group-chip-z80cpu']).toBeDefined();
// A plain board example with no custom chip must clear the stale chip group.
await loadExample(findExample('blink-led'));
expect(
useEditorStore.getState().fileGroups['group-chip-z80cpu'],
'stale chip group swept on next load',
).toBeUndefined();
});
});
describe('isProgrammableChip — detects ROM-loading CPUs by programTargets', () => {
it('true when chip.json declares programTargets, even with no programFile yet', () => {
// A chip freshly dropped from the gallery: programFile empty, but its
// chip.json marks it a CPU. It must still be treated as programmable so a
// program file gets created for it.
expect(
isProgrammableChip({ chipJson: JSON.stringify({ programTargets: ['z80'] }), programFile: '' }),
).toBe(true);
});
it('true when a programFile is already set', () => {
expect(isProgrammableChip({ chipJson: '{}', programFile: 'larson.s' })).toBe(true);
});
it('false for a behaviour chip (no programTargets, no programFile)', () => {
expect(
isProgrammableChip({ chipJson: JSON.stringify({ name: 'Servo driver' }), programFile: '' }),
).toBe(false);
expect(isProgrammableChip({})).toBe(false);
expect(isProgrammableChip(null)).toBe(false);
});
});