2026-05-13 02:55:15 +07:00
|
|
|
// @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 } from '../data/examples';
|
|
|
|
|
|
|
|
|
|
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));
|
|
|
|
|
});
|
|
|
|
|
});
|
feat(custom-chip): program lives in its own editor group, not the board sketch
A programmable custom-chip (a CPU emulator that runs a ROM/program, e.g. the
Z80 or 8080) now keeps its program (larson.s, chaser.c, ...) in a dedicated
editor file group — group-chip-<chipId> — rendered as its own collapsible
section in the file explorer, exactly like each board owns its sketch group.
Behaviour/driver chips and predefined chips carry no programFile and get no
group; they stay editable only in the chip designer.
Fixes two reported issues on the Z80 examples:
- /example/z80-larson-no-board: the board-less chip example now opens its
program (larson.s) as the active group, editable on the left — previously
the editor showed but no file appeared.
- /example/z80-led-chaser-c: the chip program (chaser.c) no longer shows as
a sibling tab inside the Arduino sketch group; it sits in its own chip
section instead. The board group shows only sketch.ino.
Details:
- useEditorStore: chipFileGroupId()/CHIP_GROUP_PREFIX helpers.
- loadExample: seedChipProgramGroups() routes each chip's programFile into its
own group (seeded from the example files), sweeps stale chip groups, keeps
the program OUT of the board group, and for a board-less chip example makes
the chip group active so the program is the editable file shown.
- EditorToolbar.prepareCustomChips: resolves the program from the chip's own
group (falls back to board files for older projects) before assembling ROM.
- FileExplorer: renders one collapsible section per programmable chip with an
IC icon; clicking switches the editor to the chip group. Lazy-creates a
group for chips dropped on the canvas.
- projectPayload + vlxFile: serialise chip groups alongside board groups and
include them in the dirty-check hash, so chip-program edits persist on
save / autosave / .vlx export and round-trip via replaceFileGroups on load.
- Regression tests for board-less + board+chip routing and stale-group sweep.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 03:07:56 +07:00
|
|
|
|
|
|
|
|
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 example keeps the chip program OUT of the board sketch group', async () => {
|
|
|
|
|
await loadExample(findExample('z80-led-chaser-c'));
|
|
|
|
|
|
|
|
|
|
const ed = useEditorStore.getState();
|
|
|
|
|
const sim = useSimulatorStore.getState();
|
|
|
|
|
|
|
|
|
|
// Board group shows only the sketch — chaser.c 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, 'chaser.c must not pollute the board group').not.toContain('chaser.c');
|
|
|
|
|
|
|
|
|
|
// The chip program lives in its own group instead.
|
|
|
|
|
const chipGroupId = 'group-chip-z80cpu';
|
|
|
|
|
expect(ed.fileGroups[chipGroupId]?.map((f) => f.name)).toContain('chaser.c');
|
|
|
|
|
|
|
|
|
|
// 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-led-chaser-c'));
|
|
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
});
|