Skip to content
Open
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
199 changes: 199 additions & 0 deletions apps/webapp/src/__tests__/script-parser.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import { describe, expect, it } from 'vitest';

import { type InputSegment, buttonsFromString, parseScript, serializeToScript } from '../scripting';

/** Unwrap a successful parse, failing the test with the errors otherwise. */
function segmentsOf(text: string): InputSegment[] {
const result = parseScript(text);
if (!result.ok) {
throw new Error(`expected parse to succeed, got: ${JSON.stringify(result.errors)}`);
}
return result.segments;
}

describe('buttonsFromString', () => {
it('inverts buttonsToString for single and combined buttons', () => {
expect(buttonsFromString('a')).toEqual([0]);
expect(buttonsFromString('right')).toEqual([4]);
expect(buttonsFromString('a+right')).toEqual([0, 4]);
});

it('sorts by bit position regardless of written order', () => {
expect(buttonsFromString('right+a')).toEqual([0, 4]);
expect(buttonsFromString('down+start+a')).toEqual([0, 3, 7]);
});

it('is case-insensitive and tolerates spaces around +', () => {
expect(buttonsFromString('A + Right')).toEqual([0, 4]);
});

it('returns null for an unknown name', () => {
expect(buttonsFromString('triangle')).toBeNull();
expect(buttonsFromString('a+triangle')).toBeNull();
});
});

describe('parseScript — press/wait form', () => {
it('parses a bare press as a single frame', () => {
expect(segmentsOf("await press('a');")).toEqual([{ buttons: [0], frames: 1 }]);
});

it('parses press with a hold', () => {
expect(segmentsOf("await press('a', { hold: 5 });")).toEqual([{ buttons: [0], frames: 5 }]);
});

it('parses wait as an empty-button segment', () => {
expect(segmentsOf('await wait({ frames: 10 });')).toEqual([{ buttons: [], frames: 10 }]);
});

it('parses a combined-button press', () => {
expect(segmentsOf("press('a+b+right+l+r', { hold: 3 });")).toEqual([{ buttons: [0, 1, 4, 8, 9], frames: 3 }]);
});

it('keeps statement order across many lines', () => {
const text = ['await wait({ frames: 10 });', "await press('a');", "await press('start', { hold: 2 });"].join('\n');
expect(segmentsOf(text)).toEqual([
{ buttons: [], frames: 10 },
{ buttons: [0], frames: 1 },
{ buttons: [3], frames: 2 },
]);
});
});

describe('parseScript — pressSequence form', () => {
it('parses a multi-line pressSequence', () => {
const text = ['await pressSequence([', " ['a+right', 5],", ' [null, 10],', ']);'].join('\n');
expect(segmentsOf(text)).toEqual([
{ buttons: [0, 4], frames: 5 },
{ buttons: [], frames: 10 },
]);
});

it('parses a single-line pressSequence', () => {
expect(segmentsOf("await pressSequence([['a', 1], [null, 2]]);")).toEqual([
{ buttons: [0], frames: 1 },
{ buttons: [], frames: 2 },
]);
});

it('parses several pressSequence calls in order', () => {
const text = ["pressSequence([['a', 1]]);", "pressSequence([['b', 2]]);"].join('\n');
expect(segmentsOf(text)).toEqual([
{ buttons: [0], frames: 1 },
{ buttons: [1], frames: 2 },
]);
});
});

describe('parseScript — hand-editing tolerance', () => {
it('accepts missing await and missing semicolons', () => {
expect(segmentsOf("press('a')")).toEqual([{ buttons: [0], frames: 1 }]);
});

it('accepts double quotes', () => {
expect(segmentsOf('await press("a");')).toEqual([{ buttons: [0], frames: 1 }]);
});

it('ignores line and block comments and blank lines', () => {
const text = ['// boot combo', '/* several', ' lines */', '', "await press('a'); // trailing", ''].join('\n');
expect(segmentsOf(text)).toEqual([{ buttons: [0], frames: 1 }]);
});

it('does not treat a // inside a string as a comment', () => {
const result = parseScript("await press('a//b');");
expect(result.ok).toBe(false);
});

it('parses the empty-recording placeholder as zero segments', () => {
expect(segmentsOf('// No inputs recorded')).toEqual([]);
});
});

describe('parseScript — errors', () => {
it('reports unknown buttons with a line number', () => {
const result = parseScript(["await press('a');", "await press('triangle');"].join('\n'));
expect(result.ok).toBe(false);
if (result.ok) {
return;
}
expect(result.errors).toHaveLength(1);
expect(result.errors[0]!.line).toBe(2);
expect(result.errors[0]!.message).toContain('triangle');
});

it('rejects unsupported commands instead of skipping them', () => {
const result = parseScript("await screenshot({ name: 'x' });");
expect(result.ok).toBe(false);
if (result.ok) {
return;
}
expect(result.errors[0]!.message).toContain('unsupported statement');
});

it('rejects a malformed pressSequence entry rather than dropping it', () => {
const result = parseScript("await pressSequence([['a', 1], ['b']]);");
expect(result.ok).toBe(false);
if (result.ok) {
return;
}
expect(result.errors[0]!.message).toContain('malformed');
});

it('rejects zero-frame durations', () => {
expect(parseScript('await wait({ frames: 0 });').ok).toBe(false);
});

it('reports every bad line, not just the first', () => {
const result = parseScript(["await press('triangle');", 'await frobnicate();'].join('\n'));
expect(result.ok).toBe(false);
if (result.ok) {
return;
}
expect(result.errors.map((e) => e.line)).toEqual([1, 2]);
});
});

describe('parseScript round-trips serializeToScript', () => {
const cases: Record<string, InputSegment[]> = {
'simple form (single buttons only)': [
{ buttons: [], frames: 10 },
{ buttons: [0], frames: 1 },
{ buttons: [], frames: 5 },
{ buttons: [3], frames: 3 },
],
'pressSequence form (has a combined press)': [
{ buttons: [0, 4], frames: 5 },
{ buttons: [], frames: 10 },
{ buttons: [0, 1, 4, 8, 9], frames: 2 },
],
'single segment': [{ buttons: [9], frames: 42 }],
};

for (const [name, segments] of Object.entries(cases)) {
it(`recovers the original segments — ${name}`, () => {
expect(segmentsOf(serializeToScript(segments))).toEqual(segments);
});
}
});

describe('parseScript — segment line mapping', () => {
it('maps each pressSequence entry to its own line', () => {
const text = ['// header', 'await pressSequence([', " ['a', 5],", ' [null, 10],', ']);'].join('\n');
const result = parseScript(text);
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.segmentLines).toEqual([2, 3]);
});

it('maps press/wait statements to their own lines, skipping comments', () => {
const text = ['// header', '', "await press('a');", '// note', 'await wait({ frames: 4 });'].join('\n');
const result = parseScript(text);
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.segmentLines).toEqual([2, 4]);
});
});
118 changes: 114 additions & 4 deletions apps/webapp/src/pages/play/ScriptRecorderPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import {
type ReplayDebugState,
type ReplayMode,
type ScriptMeta,
type ScriptParseError,
type ScriptWithMapping,
deleteScript,
listScriptsByRom,
loadScriptRecord,
parseScript,
replayVisual,
saveScript,
serializeToScript,
Expand Down Expand Up @@ -54,7 +56,10 @@ export function ScriptRecorderPanel({
const [replayMode, setReplayMode] = useState<ReplayMode>('from-start');
const [replayDebug, setReplayDebug] = useState<ReplayDebugState | null>(null);
const [copied, setCopied] = useState(false);
const [loadedFileName, setLoadedFileName] = useState<string | null>(null);
const [parseErrors, setParseErrors] = useState<ScriptParseError[] | null>(null);
const replayCancelRef = useRef<(() => void) | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const saveCountRef = useRef(0);

// Compute ROM hash
Expand Down Expand Up @@ -93,9 +98,55 @@ export function ScriptRecorderPanel({
setSnapshot(snap);
setScriptMapping(null);
setSegments(null);
setLoadedFileName(null);
setParseErrors(null);
onStartRecording();
}, [emulator, onStartRecording]);

/**
* Load a script from disk and make it replayable. The file's own text is
* what gets shown — parseScript reports the line each segment came from, so
* replay can still highlight it without normalising the user's formatting.
*
* A loaded script carries no snapshot, so it replays from the current state.
*/
const handleLoadFile = useCallback(async (file: File) => {
const text = await file.text();
const result = parseScript(text);

if (!result.ok) {
setParseErrors(result.errors);
setScriptMapping(null);
setSegments(null);
setLoadedFileName(file.name);
return;
}

setParseErrors(null);
setLoadedFileName(file.name);
setSnapshot(null);
setReplayMode('from-current');
setReplayDebug(null);
setSegments(result.segments);
setScriptMapping({
text,
lines: text.split('\n'),
segmentToLine: result.segmentLines,
});
}, []);

const handleFileChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
handleLoadFile(file);
}
// Reset so picking the same file again still fires a change event.
event.target.value = '';
},
[handleLoadFile],
);

const handleStopRecording = useCallback(() => {
onStopRecording();
const segs = recorder.segments;
Expand All @@ -108,6 +159,8 @@ export function ScriptRecorderPanel({
setSegments(null);
setSnapshot(null);
setReplayDebug(null);
setLoadedFileName(null);
setParseErrors(null);
}, []);

const handleCopy = useCallback(() => {
Expand Down Expand Up @@ -224,6 +277,35 @@ export function ScriptRecorderPanel({
</button>
)}

{!isRecording && (
<>
<input
ref={fileInputRef}
type="file"
accept=".mjs,.js,.txt,text/javascript,text/plain"
onChange={handleFileChange}
className="hidden"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isReplaying}
title="Load a script file and replay it"
className="flex items-center gap-2 px-4 py-2 bg-slate-700/50 text-slate-300 border border-slate-600 rounded-lg font-medium hover:bg-slate-600/50 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2M12 4v12m0-12l-4 4m4-4l4 4"
/>
</svg>
Load
</button>
</>
)}

{isRecording && (
<div className="text-sm text-slate-400 flex items-center gap-3">
<span>{formatTime(recorder.totalFrames)}</span>
Expand Down Expand Up @@ -257,6 +339,24 @@ export function ScriptRecorderPanel({
</div>
</div>

{/* Parse errors from a loaded file */}
{parseErrors && (
<div className="bg-red-500/10 rounded-lg border border-red-500/30">
<div className="px-3 py-2 border-b border-red-500/30">
<div className="text-red-300 text-[10px] uppercase tracking-wider">
Could not load {loadedFileName ?? 'script'}
</div>
</div>
<div className="p-3 flex flex-col gap-1">
{parseErrors.map((err) => (
<div key={`${err.line}-${err.message}`} className="text-xs text-red-200/90 font-mono">
<span className="text-red-400">line {err.line}:</span> {err.message}
</div>
))}
</div>
</div>
)}

{/* Live Preview (during recording) */}
{isRecording && livePreview && (
<div className="bg-slate-800/50 rounded-lg border border-slate-700">
Expand All @@ -271,8 +371,10 @@ export function ScriptRecorderPanel({
{isStopped && !isRecording && scriptMapping && (
<div className="bg-slate-800/50 rounded-lg border border-slate-700">
<div className="px-3 py-2 border-b border-slate-700 flex items-center justify-between">
<div className="text-slate-500 text-[10px] uppercase tracking-wider">Recorded Script</div>
<div className="text-xs text-slate-500">{segments?.length ?? 0} segments</div>
<div className="text-slate-500 text-[10px] uppercase tracking-wider truncate">
{loadedFileName ? `Loaded — ${loadedFileName}` : 'Recorded Script'}
</div>
<div className="text-xs text-slate-500 shrink-0 ml-2">{segments?.length ?? 0} segments</div>
</div>
<ScriptEditorView value={scriptMapping.text} highlightLine={highlightedLine} />
<div className="px-3 py-2 border-t border-slate-700 flex items-center gap-2">
Expand All @@ -293,7 +395,9 @@ export function ScriptRecorderPanel({
<button
type="button"
onClick={handleSave}
className="px-3 py-1.5 text-xs bg-sky-500/20 text-sky-300 border border-sky-500/30 rounded hover:bg-sky-500/30 transition-all"
disabled={!snapshot}
title={snapshot ? undefined : 'A loaded script has no snapshot to save with it'}
className="px-3 py-1.5 text-xs bg-sky-500/20 text-sky-300 border border-sky-500/30 rounded hover:bg-sky-500/30 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
>
Save
</button>
Expand All @@ -309,12 +413,18 @@ export function ScriptRecorderPanel({
</div>
<div className="p-3">
<div className="flex flex-col gap-2 mb-3">
<label className="flex items-center gap-2 text-sm text-slate-300 cursor-pointer">
<label
className={`flex items-center gap-2 text-sm ${
snapshot ? 'text-slate-300 cursor-pointer' : 'text-slate-500 cursor-not-allowed'
}`}
title={snapshot ? undefined : 'Only a recorded script carries the state it started from'}
>
<input
type="radio"
name="replayMode"
checked={replayMode === 'from-start'}
onChange={() => setReplayMode('from-start')}
disabled={!snapshot}
className="accent-sky-500"
/>
From recording start
Expand Down
Loading
Loading