feat: maximize sorter demo realism and add safe agent MVP
Wire live classifyItem into continuous playback, add jam/E-stop cases, presenter controls (seek/speed/hotkeys), quality modes, demo scripts, audit docs, and a verify-only autonomous agent CLI with hard safety limits. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* Continuous Playback Engine tests + playlist ↔ classifier consistency.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
createPlaybackState,
|
||||
@@ -6,48 +10,44 @@ import {
|
||||
resumePlayback,
|
||||
stopPlayback,
|
||||
updatePlayback,
|
||||
seekToCase,
|
||||
setPlaybackSpeed,
|
||||
CASE_DURATION_MS,
|
||||
assertPlaylistClassifierConsistency,
|
||||
type ContinuousPlaybackState,
|
||||
} from './continuousPlayback';
|
||||
import { DEMO_PLAYLIST, PLAYLIST_LENGTH } from './demoPlaylist';
|
||||
import { classifyItem } from './classifier';
|
||||
import { resolveItem } from '../data/resolveItem';
|
||||
|
||||
describe('demoPlaylist', () => {
|
||||
it('has exactly 8 cases', () => {
|
||||
expect(PLAYLIST_LENGTH).toBe(8);
|
||||
expect(DEMO_PLAYLIST.length).toBe(8);
|
||||
it('has classification + safety cases', () => {
|
||||
expect(PLAYLIST_LENGTH).toBe(10);
|
||||
expect(DEMO_PLAYLIST.length).toBe(10);
|
||||
});
|
||||
|
||||
it('has no duplicate case ids', () => {
|
||||
const ids = DEMO_PLAYLIST.map(c => c.id);
|
||||
const uniqueIds = new Set(ids);
|
||||
expect(uniqueIds.size).toBe(ids.length);
|
||||
const ids = DEMO_PLAYLIST.map((c) => c.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it('has no mixed category (all cases have specific B/C/D)', () => {
|
||||
const validCategories = ['B', 'C', 'D'];
|
||||
DEMO_PLAYLIST.forEach(c => {
|
||||
expect(validCategories).toContain(c.expectedCategory);
|
||||
});
|
||||
it('all non-fault cases match live classifyItem', () => {
|
||||
const mismatches = assertPlaylistClassifierConsistency();
|
||||
expect(mismatches).toEqual([]);
|
||||
});
|
||||
|
||||
it('all commands would be ROUTE_TO_B/C/D', () => {
|
||||
DEMO_PLAYLIST.forEach(c => {
|
||||
const expectedCommand = `ROUTE_TO_${c.expectedCategory}`;
|
||||
expect(['ROUTE_TO_B', 'ROUTE_TO_C', 'ROUTE_TO_D']).toContain(expectedCommand);
|
||||
});
|
||||
});
|
||||
|
||||
it('low_confidence case has warning but final category is B/C/D', () => {
|
||||
const lowConfidence = DEMO_PLAYLIST.find(c => c.id === 'low_confidence');
|
||||
it('low_confidence case has warning but category from rules', () => {
|
||||
const lowConfidence = DEMO_PLAYLIST.find((c) => c.id === 'low_confidence');
|
||||
expect(lowConfidence).toBeDefined();
|
||||
expect(lowConfidence!.warning).toBeDefined();
|
||||
expect(['B', 'C', 'D']).toContain(lowConfidence!.expectedCategory);
|
||||
const result = classifyItem(resolveItem(lowConfidence!.itemId));
|
||||
expect(result.category).toBe('B');
|
||||
expect(result.warnings.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('has correct case order: B, B, C, C, D, D, C, B', () => {
|
||||
const expectedOrder = ['B', 'B', 'C', 'C', 'D', 'D', 'C', 'B'];
|
||||
const actualOrder = DEMO_PLAYLIST.map(c => c.expectedCategory);
|
||||
expect(actualOrder).toEqual(expectedOrder);
|
||||
it('includes jam and emergency_stop safety cases', () => {
|
||||
expect(DEMO_PLAYLIST.some((c) => c.faultType === 'jam')).toBe(true);
|
||||
expect(DEMO_PLAYLIST.some((c) => c.faultType === 'emergency_stop')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,112 +58,75 @@ describe('continuousPlayback', () => {
|
||||
expect(state.currentCaseIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('startPlayback sets status to running', () => {
|
||||
const state = createPlaybackState();
|
||||
const running = startPlayback(state);
|
||||
expect(running.status).toBe('running');
|
||||
expect(running.currentCaseIndex).toBe(0);
|
||||
it('startPlayback sets status to running and classifies via classifyItem', () => {
|
||||
const state = startPlayback(createPlaybackState());
|
||||
expect(state.status).toBe('running');
|
||||
expect(state.classification).not.toBeNull();
|
||||
expect(state.targetCategory).toBe(state.classification!.category);
|
||||
});
|
||||
|
||||
it('pausePlayback sets status to paused', () => {
|
||||
let state = createPlaybackState();
|
||||
state = startPlayback(state);
|
||||
let state = startPlayback(createPlaybackState());
|
||||
state = pausePlayback(state);
|
||||
expect(state.status).toBe('paused');
|
||||
});
|
||||
|
||||
it('resumePlayback sets status back to running', () => {
|
||||
let state = createPlaybackState();
|
||||
state = startPlayback(state);
|
||||
let state = startPlayback(createPlaybackState());
|
||||
state = pausePlayback(state);
|
||||
state = resumePlayback(state);
|
||||
expect(state.status).toBe('running');
|
||||
});
|
||||
|
||||
it('stopPlayback resets to idle', () => {
|
||||
let state = createPlaybackState();
|
||||
state = startPlayback(state);
|
||||
state = updatePlayback(state, 1000);
|
||||
let state = startPlayback(createPlaybackState());
|
||||
state = stopPlayback(state);
|
||||
expect(state.status).toBe('idle');
|
||||
expect(state.currentCaseIndex).toBe(0);
|
||||
expect(state.totalElapsedMs).toBe(0);
|
||||
});
|
||||
|
||||
it('advances from case 1 to case 2 after case duration', () => {
|
||||
let state = createPlaybackState();
|
||||
state = startPlayback(state);
|
||||
expect(state.currentCaseIndex).toBe(0);
|
||||
|
||||
// Simulate passing entire case duration by updating in steps
|
||||
// Each update processes one phase at a time
|
||||
it('advances to next case after case duration', () => {
|
||||
let state = startPlayback(createPlaybackState());
|
||||
let totalTime = 0;
|
||||
while (totalTime < CASE_DURATION_MS * 1.5 && state.currentCaseIndex === 0) {
|
||||
state = updatePlayback(state, 100);
|
||||
totalTime += 100;
|
||||
}
|
||||
expect(state.currentCaseIndex).toBe(1);
|
||||
expect(state.currentCaseIndex).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('status becomes finished after case 8 (without loop)', () => {
|
||||
let state = createPlaybackState();
|
||||
state = startPlayback(state);
|
||||
state = { ...state, loopMode: false };
|
||||
|
||||
// Simulate passing all 8 cases by updating in steps
|
||||
let totalTime = 0;
|
||||
const maxTime = CASE_DURATION_MS * 10; // Safety limit
|
||||
while (state.status === 'running' && totalTime < maxTime) {
|
||||
it('seekToCase jumps to jam case and sets FAULT command later', () => {
|
||||
const jamIndex = DEMO_PLAYLIST.findIndex((c) => c.faultType === 'jam');
|
||||
let state = seekToCase(createPlaybackState(), jamIndex);
|
||||
expect(state.currentCase.faultType).toBe('jam');
|
||||
for (let i = 0; i < 80; i++) {
|
||||
state = updatePlayback(state, 100);
|
||||
totalTime += 100;
|
||||
if (state.command === 'FAULT') break;
|
||||
}
|
||||
expect(state.command).toBe('FAULT');
|
||||
});
|
||||
|
||||
it('setPlaybackSpeed changes multiplier', () => {
|
||||
let state = startPlayback(createPlaybackState());
|
||||
state = setPlaybackSpeed(state, 2);
|
||||
expect(state.speed).toBe(2);
|
||||
});
|
||||
|
||||
it('records classification events in journal', () => {
|
||||
let state = startPlayback(createPlaybackState());
|
||||
for (let i = 0; i < 60; i++) {
|
||||
state = updatePlayback(state, 100);
|
||||
}
|
||||
expect(state.events.some((e) => e.type === 'classification' || e.type === 'system')).toBe(true);
|
||||
});
|
||||
|
||||
it('completes playlist', () => {
|
||||
let state = startPlayback(createPlaybackState());
|
||||
const maxTime = CASE_DURATION_MS * 20;
|
||||
let elapsed = 0;
|
||||
while (state.status === 'running' && elapsed < maxTime) {
|
||||
state = updatePlayback(state, 200);
|
||||
elapsed += 200;
|
||||
}
|
||||
|
||||
expect(state.status).toBe('finished');
|
||||
});
|
||||
|
||||
it('loops back to case 1 after case 8 with loopMode', () => {
|
||||
let state = createPlaybackState();
|
||||
state = startPlayback(state);
|
||||
state = { ...state, loopMode: true };
|
||||
|
||||
// Simulate passing all 8 cases in steps
|
||||
let casesPassed = 0;
|
||||
let totalTime = 0;
|
||||
const maxTime = CASE_DURATION_MS * 10;
|
||||
|
||||
while (casesPassed < 8 && totalTime < maxTime) {
|
||||
const prevIndex = state.currentCaseIndex;
|
||||
state = updatePlayback(state, 100);
|
||||
totalTime += 100;
|
||||
if (state.currentCaseIndex !== prevIndex || (prevIndex === 7 && state.currentCaseIndex === 0)) {
|
||||
casesPassed++;
|
||||
}
|
||||
}
|
||||
|
||||
expect(state.status).toBe('running');
|
||||
expect(state.currentCaseIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('updates command during routing phase', () => {
|
||||
let state = createPlaybackState();
|
||||
state = startPlayback(state);
|
||||
|
||||
// Advance to routing phase (roughly halfway through case)
|
||||
state = updatePlayback(state, CASE_DURATION_MS * 0.7);
|
||||
|
||||
// Command should be ROUTE_TO_* based on category
|
||||
expect(state.command).toMatch(/^ROUTE_TO_[BCD]$|^MOVING_TO_CAMERA$|^DETECTING$|^MEASURING$|^CLASSIFYING$|^RETURN_HOME$/);
|
||||
});
|
||||
|
||||
it('does not update when paused', () => {
|
||||
let state = createPlaybackState();
|
||||
state = startPlayback(state);
|
||||
state = updatePlayback(state, 1000);
|
||||
const elapsed1 = state.totalElapsedMs;
|
||||
|
||||
state = pausePlayback(state);
|
||||
state = updatePlayback(state, 1000);
|
||||
|
||||
expect(state.totalElapsedMs).toBe(elapsed1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user