Files
fidoca/test/turn-state.test.js
2026-09-26 19:19:48 +02:00

148 lines
5.6 KiB
JavaScript

'use strict';
import test from 'node:test';
import assert from 'node:assert/strict';
import {
TURN_STATES,
TURN_EVENTS,
isTurnState,
nextState,
statusLabel,
} from '../src/turn-state.js';
// Every state the machine can be in when a delta arrives.
const BUSY_STATES = [
TURN_STATES.CONNECTING,
TURN_STATES.THINKING,
TURN_STATES.WORKING,
TURN_STATES.STREAMING,
];
const DELTA_EVENTS = [TURN_EVENTS.REASONING, TURN_EVENTS.TOOL_CALL, TURN_EVENTS.CONTENT];
test('the state set is closed and enumerated', () => {
assert.deepEqual(Object.values(TURN_STATES), [
'idle', 'connecting', 'thinking', 'working', 'streaming',
]);
for (const state of Object.values(TURN_STATES)) {
assert.equal(isTurnState(state), true, `${state} is a known state`);
}
for (const value of ['', 'thinking…', 'IDLE', null, undefined, 3, {}]) {
assert.equal(isTurnState(value), false, `${String(value)} is not a state`);
}
});
test('turn_start always yields connecting, from any state', () => {
for (const current of Object.values(TURN_STATES)) {
assert.equal(
nextState(current, TURN_EVENTS.TURN_START),
TURN_STATES.CONNECTING,
`turn_start from ${current}`,
);
}
});
test('turn end, cancel, and error always resolve to idle', () => {
for (const current of Object.values(TURN_STATES)) {
for (const event of [TURN_EVENTS.TURN_END, TURN_EVENTS.CANCEL, TURN_EVENTS.ERROR]) {
assert.equal(
nextState(current, event),
TURN_STATES.IDLE,
`${event} from ${current}`,
);
}
}
});
test('each delta kind maps to its state from every state', () => {
const expected = {
[TURN_EVENTS.REASONING]: TURN_STATES.THINKING,
[TURN_EVENTS.TOOL_CALL]: TURN_STATES.WORKING,
[TURN_EVENTS.CONTENT]: TURN_STATES.STREAMING,
};
for (const from of [...BUSY_STATES, TURN_STATES.IDLE]) {
for (const event of DELTA_EVENTS) {
assert.equal(
nextState(from, event),
expected[event],
`${event} from ${from}`,
);
}
}
});
test('a later delta moves the state backward, never stalling on the furthest point', () => {
// Streaming back to thinking, and thinking back to working.
assert.equal(
nextState(TURN_STATES.STREAMING, TURN_EVENTS.REASONING),
TURN_STATES.THINKING,
'a reasoning delta after content moves back to thinking',
);
assert.equal(
nextState(TURN_STATES.THINKING, TURN_EVENTS.TOOL_CALL),
TURN_STATES.WORKING,
'a tool-call delta after reasoning moves back to working',
);
assert.equal(
nextState(TURN_STATES.WORKING, TURN_EVENTS.CONTENT),
TURN_STATES.STREAMING,
'content after a tool call moves forward to streaming',
);
});
test('a repeated delta of the same kind still updates the state', () => {
for (const event of DELTA_EVENTS) {
const first = nextState(TURN_STATES.CONNECTING, event);
const again = nextState(first, event);
assert.equal(again, first, `${event} is idempotent on its own state`);
}
});
test('a turn with no reasoning and no tools goes connecting -> streaming', () => {
let state = nextState(TURN_STATES.IDLE, TURN_EVENTS.TURN_START);
assert.equal(state, TURN_STATES.CONNECTING);
for (let i = 0; i < 3; i++) state = nextState(state, TURN_EVENTS.CONTENT);
assert.equal(state, TURN_STATES.STREAMING);
state = nextState(state, TURN_EVENTS.TURN_END);
assert.equal(state, TURN_STATES.IDLE);
});
test('a reasoning-then-tool turn goes connecting -> thinking -> working', () => {
let state = nextState(TURN_STATES.IDLE, TURN_EVENTS.TURN_START);
const states = [state];
for (const event of [TURN_EVENTS.REASONING, TURN_EVENTS.TOOL_CALL, TURN_EVENTS.TURN_END]) {
state = nextState(state, event);
states.push(state);
}
assert.deepEqual(states, ['connecting', 'thinking', 'working', 'idle']);
});
test('no turn ever begins in a busy state and no delta strands the session', () => {
for (const terminal of [TURN_EVENTS.TURN_END, TURN_EVENTS.CANCEL, TURN_EVENTS.ERROR]) {
const state = nextState(TURN_STATES.STREAMING, terminal);
const restarted = nextState(state, TURN_EVENTS.TURN_START);
assert.equal(restarted, TURN_STATES.CONNECTING, 'a new turn always starts at connecting');
}
});
test('the reducer is total: an unknown event never throws and never invents a state', () => {
for (const current of [...Object.values(TURN_STATES), 'bogus', undefined, null]) {
for (const event of ['nope', undefined, null, 42]) {
const next = nextState(current, event);
assert.equal(isTurnState(next), true, `unknown event keeps a real state`);
}
}
// A known current state survives an unknown event; an invalid one resets.
assert.equal(nextState(TURN_STATES.WORKING, 'nope'), TURN_STATES.WORKING);
assert.equal(nextState('bogus', 'nope'), TURN_STATES.IDLE);
});
test('statusLabel names each busy state and leaves idle to the placeholder', () => {
assert.equal(statusLabel(TURN_STATES.CONNECTING), 'connecting…');
assert.equal(statusLabel(TURN_STATES.THINKING), 'thinking…');
assert.equal(statusLabel(TURN_STATES.WORKING), 'working…');
assert.equal(statusLabel(TURN_STATES.STREAMING), 'streaming…');
assert.equal(statusLabel(TURN_STATES.IDLE), '', 'idle is not rendered by the spinner');
assert.equal(statusLabel('bogus'), '', 'an unknown value renders no busy label');
});