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
47 changes: 47 additions & 0 deletions frontend/src/__tests__/circuit-simulation-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -792,6 +792,53 @@ describe('the publish window keeps every state the pads went through', () => {
});
});

describe('a publish window never outlives the deck it was recorded on', () => {
it('a window that closes behind a rebuild is dropped, not solved with the new pad order', async () => {
// XIAO Pod (velxio-prod, 2026-10): "Circuit solver problem: no such
// vector undefined" on Run. A window closed while a rebuild was already
// queued on the solver; it ran after the rebuild, and its levels (indexed
// by the OLD deck's pads) were paired with the NEW deck's sources. The
// new deck had one pad more, so that pad's level was undefined and
// ngspice got `alter V_uno_10 dc undefined`.
const fake = new FakeSolverAdapter({ vectors: { 'v(vcc_rail)': 5 }, solveDelayMs: 40 });
__setSchedulerSolverFactoryForTests(() => fake);
let pads: Record<string, { type: string; v: number }> = { '9': { type: 'digital', v: 0 } };
const sim = makeSimStore({
components: [
{ id: 'ra', metadataId: 'resistor', properties: { value: '1k' } },
{ id: 'rb', metadataId: 'resistor', properties: { value: '1k' } },
],
wires: [
{ id: 'w1', start: { componentId: 'uno', pinName: '9' }, end: { componentId: 'ra', pinName: '1' } },
{ id: 'w2', start: { componentId: 'ra', pinName: '2' }, end: { componentId: 'uno', pinName: 'GND' } },
{ id: 'w3', start: { componentId: 'uno', pinName: '10' }, end: { componentId: 'rb', pinName: '1' } },
{ id: 'w4', start: { componentId: 'rb', pinName: '2' }, end: { componentId: 'uno', pinName: 'GND' } },
],
boards: [{ id: 'uno', boardKind: 'arduino-uno' }],
});
const elec = makeElectricalStore();
const service = new CircuitSimulationService(
sim.port, elec.port,
getMixedModeScheduler() as unknown as MixedModeSchedulerPort,
{ collectBoardPinStates: () => pads },
);
startTracked(service);
await new Promise((r) => setTimeout(r, 150)); // initial deck: one pad

pads = { '9': { type: 'digital', v: 5 }, '10': { type: 'digital', v: 0 } };
void service.handleMcuEdge('uno', '9', true, 5); // opens a window, solves 40 ms
// A canvas change queues a rebuild behind that solve; the window closes
// (33 ms) after it is queued, so its publish runs after the rebuild.
sim.set({ components: [...sim.port.getState().components] });
await new Promise((r) => setTimeout(r, 400));

const bad = fake.calls.alterSource.filter(([, v]) => !Number.isFinite(v));
expect(bad).toEqual([]);
// The new deck really did carry both pads, so the race was on.
expect(fake.calls.loadCircuit.at(-1) ?? '').toMatch(/V_uno_10 /);
});
});

describe('CircuitSimulationService — error handling', () => {
it('logs but does not throw when solver fails', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
Expand Down
64 changes: 64 additions & 0 deletions frontend/src/__tests__/example-sd-media-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* Example SD files named by URL.
*
* A gallery example can pre-load a board's built-in microSD. Inline base64
* is fine for a text file, but a music player's MP3s would ride in the main
* bundle of every visitor; so an entry may name a `url` instead, and the
* loader fetches it when the example opens, storing the bytes on the board in
* the same shape an inline file (or the SD Card panel) produces.
*/
import { describe, expect, it, vi } from 'vitest';
import { resolveExampleSdFiles, b64ToBytes } from '../utils/sdCardFiles';

const respond = (bytes: number[], status = 200) =>
Promise.resolve(new Response(new Uint8Array(bytes), { status }));

describe('resolveExampleSdFiles', () => {
it('passes inline files through untouched', async () => {
const fetcher = vi.fn();
const out = await resolveExampleSdFiles([{ name: 'a.txt', contentB64: 'aGk=' }], fetcher);
expect(out).toEqual([{ name: 'a.txt', contentB64: 'aGk=' }]);
expect(fetcher).not.toHaveBeenCalled();
});

it('fetches a url entry and stores its bytes as base64', async () => {
const fetcher = vi.fn(() => respond([0x49, 0x44, 0x33, 0x00, 0xff]));
const out = await resolveExampleSdFiles([{ name: 'SONG.MP3', url: '/examples-media/x/SONG.MP3' }], fetcher);
expect(fetcher).toHaveBeenCalledWith('/examples-media/x/SONG.MP3');
expect(out).toHaveLength(1);
expect(out[0].name).toBe('SONG.MP3');
expect(Array.from(b64ToBytes(out[0].contentB64))).toEqual([0x49, 0x44, 0x33, 0x00, 0xff]);
});

it('keeps the order of the entries', async () => {
const fetcher = vi.fn((url: string) => respond(url.endsWith('1') ? [1] : [2]));
const out = await resolveExampleSdFiles(
[
{ name: 'one', url: '/m/1' },
{ name: 'inline', contentB64: 'AA==' },
{ name: 'two', url: '/m/2' },
],
fetcher,
);
expect(out.map((f) => f.name)).toEqual(['one', 'inline', 'two']);
});

it('leaves a file that cannot be fetched off the card instead of failing', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const fetcher = vi.fn((url: string) => (url.includes('gone') ? respond([], 404) : respond([7])));
const out = await resolveExampleSdFiles(
[
{ name: 'gone.mp3', url: '/m/gone.mp3' },
{ name: 'ok.mp3', url: '/m/ok.mp3' },
],
fetcher,
);
expect(out.map((f) => f.name)).toEqual(['ok.mp3']);
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});

it('is empty for a board with no SD files', async () => {
expect(await resolveExampleSdFiles(undefined)).toEqual([]);
});
});
15 changes: 12 additions & 3 deletions frontend/src/data/examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,18 @@ export interface ExampleBoard {
vfsFiles?: Record<string, string>;
/** Uploads pre-loaded onto a board's BUILT-IN microSD slot (a ProBoardDef
* with builtInSd, e.g. the XIAO ESP32S3 Sense) — the same shape the
* SD Card panel persists on board.sdFiles. */
sdFiles?: Array<{ name: string; contentB64: string }>;
}
* SD Card panel persists on board.sdFiles. A file too large to ship inside
* the bundle (audio, images) names a `url` instead of `contentB64`: the
* loader fetches it when the example opens and stores the bytes on the
* board exactly like an inline file. */
sdFiles?: ExampleSdFile[];
}

/** One file on an example board's built-in microSD: inline bytes, or a URL
* (same origin, e.g. /examples-media/...) fetched when the example opens. */
export type ExampleSdFile =
| { name: string; contentB64: string; url?: undefined }
| { name: string; url: string; contentB64?: undefined };

export interface ExampleProject {
id: string;
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/lib/onlineOnlyBoards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,15 @@
thumbnailSvg:
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 60 60'><rect x='9' y='12' width='2.4' height='7' fill='#b9c0c6'/><rect x='9' y='41' width='2.4' height='7' fill='#b9c0c6'/><rect x='15' y='12' width='2.4' height='7' fill='#b9c0c6'/><rect x='15' y='41' width='2.4' height='7' fill='#b9c0c6'/><rect x='21' y='12' width='2.4' height='7' fill='#b9c0c6'/><rect x='21' y='41' width='2.4' height='7' fill='#b9c0c6'/><rect x='27' y='12' width='2.4' height='7' fill='#b9c0c6'/><rect x='27' y='41' width='2.4' height='7' fill='#b9c0c6'/><rect x='33' y='12' width='2.4' height='7' fill='#b9c0c6'/><rect x='33' y='41' width='2.4' height='7' fill='#b9c0c6'/><rect x='39' y='12' width='2.4' height='7' fill='#b9c0c6'/><rect x='39' y='41' width='2.4' height='7' fill='#b9c0c6'/><rect x='45' y='12' width='2.4' height='7' fill='#b9c0c6'/><rect x='45' y='41' width='2.4' height='7' fill='#b9c0c6'/><rect x='51' y='12' width='2.4' height='7' fill='#b9c0c6'/><rect x='51' y='41' width='2.4' height='7' fill='#b9c0c6'/><rect x='6' y='18' width='48' height='24' rx='2' fill='#1b1b1e' stroke='#08080a' stroke-width='1'/><path d='M6 26 A4 4 0 0 1 6 34' fill='#2c2c30'/><text x='31' y='33' font-family='monospace' font-size='8' font-weight='600' fill='#e6e6e6' text-anchor='middle'>3008</text></svg>",
},
{
id: 'pro-pcm5102a',
label: 'PCM5102A I2S Stereo DAC',
description:
"Stereo I2S DAC with line out: an ESP32-S3 sketch's audio plays on your computer's speakers - available in the online editor",
category: 'output',
thumbnailSvg:
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 60 60'><rect x='4' y='14' width='52' height='34' rx='3' fill='#4b2370' stroke='#2c1244' stroke-width='1'/><rect x='8' y='9' width='3' height='7' fill='#d6c27a'/><rect x='14' y='9' width='3' height='7' fill='#d6c27a'/><rect x='20' y='9' width='3' height='7' fill='#d6c27a'/><rect x='26' y='9' width='3' height='7' fill='#d6c27a'/><rect x='32' y='9' width='3' height='7' fill='#d6c27a'/><rect x='38' y='9' width='3' height='7' fill='#d6c27a'/><rect x='12' y='24' width='18' height='12' rx='1' fill='#17171a'/><rect x='38' y='24' width='16' height='16' rx='2' fill='#1e1e22'/><circle cx='46' cy='32' r='4.5' fill='#0b0b0d' stroke='#5a5a62' stroke-width='0.8'/><rect x='12' y='40' width='14' height='2.4' rx='1' fill='#3ddc84'/><rect x='12' y='43.5' width='10' height='2.4' rx='1' fill='#3ddc84'/></svg>",

Check warning on line 421 in frontend/src/lib/onlineOnlyBoards.ts

View workflow job for this annotation

GitHub Actions / Diff risk scan

Very long single line (possible minified or encoded payload)
},
{
id: 'pro-pi-camera',
label: 'Raspberry Pi Camera Module (CSI)',
Expand Down
12 changes: 10 additions & 2 deletions frontend/src/simulation/spice/CircuitSimulationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,9 +471,17 @@ export class CircuitSimulationService {
private closeWindow(): void {
this.windowTimer = null;
if (this.stopped || !this.timeline) return;
const w = this.timeline.close(nowMs());
const timeline = this.timeline;
const w = timeline.close(nowMs());
this.windowCount++;
void this.run(() => this.publishWindow(w));
// The publish waits behind whatever the solver queue already holds, and
// that can be a rebuild. A rebuild replaces the pads (loadPads), and the
// window's levels are indexed by the OLD ones: solved against the new
// deck they pair the wrong source with each level, and a pad the new deck
// added gets `undefined` ("no such vector undefined"). The rebuild already
// published the state the pads hold, and anything recorded during it went
// onto the new timeline, so a stale window has nothing left to say.
void this.run(() => (this.timeline === timeline ? this.publishWindow(w) : Promise.resolve()));
}

private async publishWindow(w: WindowResult): Promise<void> {
Expand Down
13 changes: 11 additions & 2 deletions frontend/src/utils/loadExample.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { isBoardComponent } from './boardPinMapping';
import { getInstalledLibraries, installLibrary } from '../services/libraryService';
import { trackOpenExample } from './analytics';
import { stripBrandPrefix } from './exampleToBuildNetlistInput';
import { resolveExampleSdFiles } from './sdCardFiles';

export interface LibraryInstallProgress {
total: number;
Expand Down Expand Up @@ -136,6 +137,13 @@ export async function loadExample(
}
}

// SD files an example names by URL are fetched BEFORE the canvas changes
// (and before the store is read below), so a slow download never leaves the
// user looking at half an example.
const sdFilesByBoard = await Promise.all(
(example.boards ?? []).map((eb) => resolveExampleSdFiles(eb.sdFiles)),
);

const {
setComponents,
setWires,
Expand Down Expand Up @@ -192,8 +200,9 @@ export async function loadExample(

// Built-in SD slot uploads (XIAO Sense): land on the board exactly as
// the SD Card panel would put them, so the Run path finds them.
if (eb.sdFiles?.length) {
useSimulatorStore.getState().updateBoard(boardId, { sdFiles: eb.sdFiles });
const sdFiles = sdFilesByBoard[idx];
if (sdFiles.length) {
useSimulatorStore.getState().updateBoard(boardId, { sdFiles });
}

// `vfsFiles` IS the declaration that this board's code is a guest
Expand Down
29 changes: 29 additions & 0 deletions frontend/src/utils/sdCardFiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,32 @@ export function buildProjectSdImage(
}
return buildFat16Image([...byName.values()], opts);
}

/**
* The SD files an example board ships, as the board stores them. Inline files
* pass through; a file named by `url` is fetched (a binary too big to sit in
* the bundle, like the MP3s of a music player example). A file that cannot be
* fetched is left off the card and reported, so one missing asset costs that
* file and not the whole example.
*/
export async function resolveExampleSdFiles(
files: ReadonlyArray<{ name: string; contentB64?: string; url?: string }> | undefined,
fetcher: (url: string) => Promise<Response> = (url) => fetch(url),
): Promise<Array<{ name: string; contentB64: string }>> {
if (!files?.length) return [];
const out = await Promise.all(
files.map(async (f) => {
if (typeof f.contentB64 === 'string') return { name: f.name, contentB64: f.contentB64 };
if (!f.url) return null;
try {
const r = await fetcher(f.url);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return { name: f.name, contentB64: bytesToB64(new Uint8Array(await r.arrayBuffer())) };
} catch (e) {
console.warn(`[sd] example file ${f.name} (${f.url}) could not be fetched:`, e);
return null;
}
}),
);
return out.filter((f): f is { name: string; contentB64: string } => f !== null);
}
Loading