20aed903ba
- Voice pipeline: ESP32 WebSocket client → voice bridge → LLM → Piper TTS (Tower :8001) - Hints engine: 3 puzzles (LA_440, LEFOU_PIANO, QR_FINALE), anti-cheat, 3 hint levels - MCP hardware server: 6 tools (puzzle, audio, LED, camera, scenario, status), stdio transport - Analytics: ESP32 module + 6 web endpoints + Dashboard UI with chat interface - Security: auth middleware (Bearer NVS), rate limiting, input validation on 30 endpoints - Frontend: code-split (1.1MB → 210KB initial), ErrorBoundary, API timeout, WS reconnect - Tests: 24 Python + 38 TypeScript + 18 MCP = 80 project tests (+ 19 mascarade) - Specs: AI_INTEGRATION_SPEC, MCP_HARDWARE_SERVER_SPEC, QA_TEST_MATRIX_SPEC - Docs: SECURITY, DEPLOYMENT_RUNBOOK, voice pipeline guide, AI architecture map - 6 AI agent definitions (.github/agents/ai_*.md) - TUI orchestration script (tools/dev/zacus_tui.py) - Docker compose TTS for Tower + KXKM-AI - CHANGELOG, README, mkdocs.yml updated - Cycle detection (DFS) in runtime3 validator - Sprint plan: plans/SPRINT_AI_INTEGRATION.md Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
138 lines
4.1 KiB
TypeScript
138 lines
4.1 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
|
|
// We need to test the internal jsonFetch + the exported helpers.
|
|
// Since jsonFetch is not exported, we test it indirectly through storyList / storyStatus
|
|
// and also test setApiBase / getApiBase directly.
|
|
|
|
import {
|
|
setApiBase,
|
|
getApiBase,
|
|
storyList,
|
|
storyStatus,
|
|
ApiError,
|
|
} from '../lib/api';
|
|
|
|
// ─── Mock globalThis.fetch ───
|
|
|
|
const mockFetch = vi.fn<(...args: unknown[]) => Promise<Response>>();
|
|
|
|
beforeEach(() => {
|
|
vi.stubGlobal('fetch', mockFetch);
|
|
setApiBase('http://test-esp:8080');
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
// ─── Helpers ───
|
|
|
|
function jsonResponse(body: unknown, status = 200): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
// ─── Tests ───
|
|
|
|
describe('setApiBase / getApiBase', () => {
|
|
it('stores and returns the base URL', () => {
|
|
setApiBase('http://192.168.0.42:8080');
|
|
expect(getApiBase()).toBe('http://192.168.0.42:8080');
|
|
});
|
|
|
|
it('strips trailing slashes', () => {
|
|
setApiBase('http://host:9000///');
|
|
expect(getApiBase()).toBe('http://host:9000');
|
|
});
|
|
});
|
|
|
|
describe('jsonFetch (via storyList)', () => {
|
|
it('returns parsed JSON on 200', async () => {
|
|
const payload = { scenarios: [{ id: 'demo', version: 1, estimated_duration_s: 300 }] };
|
|
mockFetch.mockResolvedValueOnce(jsonResponse(payload));
|
|
|
|
const result = await storyList();
|
|
expect(result).toEqual(payload.scenarios);
|
|
expect(mockFetch).toHaveBeenCalledOnce();
|
|
|
|
const [url] = mockFetch.mock.calls[0] as [string];
|
|
expect(url).toBe('http://test-esp:8080/api/story/list');
|
|
});
|
|
|
|
it('throws ApiError on HTTP 500', async () => {
|
|
mockFetch.mockResolvedValueOnce(
|
|
jsonResponse({ error: 'internal failure' }, 500),
|
|
);
|
|
|
|
try {
|
|
await storyList();
|
|
expect.unreachable('should have thrown');
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(ApiError);
|
|
expect((err as ApiError).status).toBe(500);
|
|
expect((err as ApiError).message).toBe('internal failure');
|
|
}
|
|
});
|
|
|
|
it('throws on timeout (AbortError path)', async () => {
|
|
// Simulate a request that never resolves — the AbortController will fire
|
|
mockFetch.mockImplementation(
|
|
(...args: unknown[]) =>
|
|
new Promise((_resolve, reject) => {
|
|
const init = args[1] as { signal?: AbortSignal } | undefined;
|
|
if (init?.signal) {
|
|
init.signal.addEventListener('abort', () => {
|
|
const err = new DOMException('The operation was aborted.', 'AbortError');
|
|
reject(err);
|
|
});
|
|
}
|
|
}),
|
|
);
|
|
|
|
// storyStatus uses the default 5000ms timeout — too long for tests.
|
|
// Instead we test indirectly: the AbortController fires, the promise rejects.
|
|
// We reduce the wait by calling storyStatus which has default timeout.
|
|
// For speed we rely on the abort signal being set up correctly.
|
|
const promise = storyStatus();
|
|
await expect(promise).rejects.toThrow(/timed out/);
|
|
}, 10_000);
|
|
});
|
|
|
|
describe('storyList', () => {
|
|
it('calls /api/story/list and unwraps scenarios', async () => {
|
|
const scenarios = [
|
|
{ id: 'a', version: 2, estimated_duration_s: 600 },
|
|
{ id: 'b', version: 1, estimated_duration_s: 120 },
|
|
];
|
|
mockFetch.mockResolvedValueOnce(jsonResponse({ scenarios }));
|
|
|
|
const result = await storyList();
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0].id).toBe('a');
|
|
});
|
|
});
|
|
|
|
describe('storyStatus', () => {
|
|
it('calls /api/story/status and returns status object', async () => {
|
|
const status = {
|
|
status: 'running',
|
|
scenario_id: 'demo',
|
|
current_step: 'STEP_1',
|
|
progress_pct: 42,
|
|
started_at_ms: 1700000000000,
|
|
selected: 'demo',
|
|
queue_depth: 0,
|
|
};
|
|
mockFetch.mockResolvedValueOnce(jsonResponse(status));
|
|
|
|
const result = await storyStatus();
|
|
expect(result.status).toBe('running');
|
|
expect(result.current_step).toBe('STEP_1');
|
|
|
|
const [url] = mockFetch.mock.calls[0] as [string];
|
|
expect(url).toBe('http://test-esp:8080/api/story/status');
|
|
});
|
|
});
|