mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
The badge alone left the row in NEEDS YOU, which is the thing the issue was about. The fix is the alert that does not fire. An idle prompt from a session that is watching its own background work now opens ALREADY acknowledged. `hook-event-routes` passes `Session.watching` to `notePrompt()`, which sets `acknowledgedAt` and records why in a new `acknowledgedReason`. Nothing new suppresses anything: `acknowledge()` has always meant "the alert this prompt armed is spent", and the prompt itself stays pending, answerable and available as Read My Mind context. A wrong label therefore costs a card that does not blink, never an alert that was never created. Every surface follows from that. The broadcast carries the reason, so a live page declines to arm the tab alert and raises no desktop notification. The push is skipped, since a false alarm is hardest to ignore on a phone. A reloading page reads `acknowledgedAt` in `seedApprovals()`, which it already did. And `classifySession()` now reads it too, which is a pre-existing bug fixed here: acknowledging on one device cleared the alert everywhere except `codeman tui`. It re-arms for free, because the next idle prompt supersedes the item and is built fresh. Only `idle` is eligible, so a dialog that blocks the agent still goes red whatever else it started. The label is pane-derived and therefore prompt-injectable, so it is now read from the last two rows of the screen only, with Claude's pattern anchored on the `·` its footer joins items with, ANSI-stripped and length-capped at the source. An agent that prints `· 1 monitor ·` into its own output finds no match. Verified on an isolated beta: a session that armed a monitor took its idle prompt acknowledged with no alert on any surface, wore the badge, and showed "quiet, watching 1 monitor" on its still-answerable card; the same session with the monitor killed alerted normally on the next prompt. `test/watching-no-alert.test.ts` pins both directions across all four surfaces. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
469 lines
20 KiB
TypeScript
469 lines
20 KiB
TypeScript
/**
|
|
* @fileoverview Unit tests for TUI classification, grouping and the cursor.
|
|
*
|
|
* Rows are built in the shape `GET /api/sessions/unified` really returns
|
|
* (`UnifiedSessionItem`, `sources` and all), and approvals in the shape the
|
|
* approvals inbox really emits, so a change to either surface breaks these
|
|
* tests rather than the dashboard.
|
|
*/
|
|
import { describe, it, expect } from 'vitest';
|
|
import type { ApprovalItem } from '../../src/web/approval-inbox.js';
|
|
import type { SearchResultGroup } from '../../src/types/search.js';
|
|
import { createComposer } from '../../src/tui/tui-composer.js';
|
|
import {
|
|
buildRows,
|
|
buildSearchEntries,
|
|
classifySession,
|
|
createTuiModel,
|
|
firstSearchIndex,
|
|
flattenRows,
|
|
groupSessions,
|
|
mergeSessionRow,
|
|
moveSearchIndex,
|
|
} from '../../src/tui/tui-model.js';
|
|
import type { TuiSessionRow } from '../../src/tui/tui-types.js';
|
|
|
|
const NOW = 1_700_000_000_000;
|
|
|
|
function session(overrides: Partial<TuiSessionRow> & { sessionId: string }): TuiSessionRow {
|
|
return {
|
|
sources: ['live'],
|
|
name: overrides.sessionId,
|
|
mode: 'claude',
|
|
status: 'idle',
|
|
workingDir: '/home/dev/case',
|
|
createdAt: NOW - 60_000,
|
|
lastActivityAt: NOW - 60_000,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function approval(overrides: Partial<ApprovalItem> & { sessionId: string }): ApprovalItem {
|
|
return {
|
|
id: `${overrides.sessionId}:1`,
|
|
sessionName: overrides.sessionId,
|
|
kind: 'permission',
|
|
createdAt: NOW - 30_000,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function approvalMap(items: ApprovalItem[]): Map<string, ApprovalItem> {
|
|
return new Map(items.map((item) => [item.sessionId, item]));
|
|
}
|
|
|
|
describe('classifySession', () => {
|
|
it('classifies live sessions by status', () => {
|
|
expect(classifySession(session({ sessionId: 'a', status: 'busy' }))).toBe('working');
|
|
expect(classifySession(session({ sessionId: 'a', status: 'idle', isWorking: true }))).toBe('working');
|
|
expect(classifySession(session({ sessionId: 'a', status: 'idle' }))).toBe('idle');
|
|
expect(classifySession(session({ sessionId: 'a', status: 'stopped' }))).toBe('idle');
|
|
});
|
|
|
|
it('puts an errored session in the needs-you tier', () => {
|
|
expect(classifySession(session({ sessionId: 'a', status: 'error' }))).toBe('waiting');
|
|
});
|
|
|
|
it('classifies by the pending prompt, which outranks a stale busy status', () => {
|
|
const row = session({ sessionId: 'a', status: 'busy' });
|
|
expect(classifySession(row, approval({ sessionId: 'a', kind: 'permission' }))).toBe('blocked-permission');
|
|
expect(classifySession(row, approval({ sessionId: 'a', kind: 'question' }))).toBe('blocked-question');
|
|
expect(classifySession(row, approval({ sessionId: 'a', kind: 'idle' }))).toBe('waiting');
|
|
});
|
|
|
|
it('stops an ACKNOWLEDGED prompt deciding the row', () => {
|
|
// Acknowledgement means the alert this prompt armed has been spent, either because
|
|
// a human opened the session elsewhere or because the inbox opened the item that way
|
|
// for a session watching its own background work. The web has honoured that since
|
|
// acknowledgement existed; this gate used to read past it, so an alert cleared on a
|
|
// phone stayed lit here alone.
|
|
const quiet = session({ sessionId: 'a', status: 'idle' });
|
|
const seen = approval({ sessionId: 'a', kind: 'idle', acknowledgedAt: NOW - 1_000 });
|
|
expect(classifySession(quiet, seen)).toBe('idle');
|
|
expect(classifySession(session({ sessionId: 'a', status: 'busy' }), seen)).toBe('working');
|
|
});
|
|
|
|
it('keeps a blocking dialog lit whatever its acknowledgement says', () => {
|
|
// `acknowledge()` is idle-only by construction, so this cannot happen through the
|
|
// routes. It is pinned because the cost of the two being wired together later is a
|
|
// permission dialog that stops asking.
|
|
const row = session({ sessionId: 'a' });
|
|
const at = NOW - 1_000;
|
|
expect(classifySession(row, approval({ sessionId: 'a', kind: 'permission', acknowledgedAt: at }))).toBe(
|
|
'blocked-permission'
|
|
);
|
|
expect(classifySession(row, approval({ sessionId: 'a', kind: 'question', acknowledgedAt: at }))).toBe(
|
|
'blocked-question'
|
|
);
|
|
});
|
|
|
|
it('classifies a row the server no longer has live as history', () => {
|
|
expect(classifySession(session({ sessionId: 'a', sources: ['history'], status: 'busy' }))).toBe('recent');
|
|
expect(classifySession(session({ sessionId: 'a', sources: ['persisted', 'lifecycle'] }))).toBe('recent');
|
|
expect(classifySession(session({ sessionId: 'a', sources: ['history', 'live'] }))).toBe('idle');
|
|
});
|
|
});
|
|
|
|
describe('an acknowledged prompt on a watching session', () => {
|
|
const sessions = [session({ sessionId: 'watcher', status: 'idle', lastActivityAt: NOW - 120_000 })];
|
|
const approvals = approvalMap([
|
|
approval({
|
|
sessionId: 'watcher',
|
|
kind: 'idle',
|
|
createdAt: NOW - 30_000,
|
|
acknowledgedAt: NOW - 30_000,
|
|
acknowledgedReason: 'watching 1 monitor',
|
|
}),
|
|
]);
|
|
|
|
it('raises no alert: the row leaves NEEDS YOU entirely', () => {
|
|
const groups = groupSessions(buildRows(sessions, approvals));
|
|
const byKey = Object.fromEntries(groups.map((group) => [group.key, group.rows.map((r) => r.session.sessionId)]));
|
|
expect(byKey['needs-you']).toEqual([]);
|
|
expect(byKey['idle']).toEqual(['watcher']);
|
|
});
|
|
|
|
it('keeps the item on the row, because it is still pending and still answerable', () => {
|
|
const [row] = buildRows(sessions, approvals);
|
|
expect(row.approval?.acknowledgedReason).toBe('watching 1 monitor');
|
|
});
|
|
|
|
it('dates the row from the pane going quiet, not from the prompt', () => {
|
|
// The prompt's age measures the state only while the prompt is what put the row in
|
|
// it. Reading it here would report "idle 30s" for a session quiet for two minutes.
|
|
const [row] = buildRows(sessions, approvals);
|
|
expect(row.since).toBe(NOW - 120_000);
|
|
});
|
|
|
|
it('alerts again once the same session goes quiet for an ordinary reason', () => {
|
|
// The inbox supersedes the acknowledged item and builds a fresh one, so this is the
|
|
// next prompt rather than the same one changing its mind.
|
|
const fresh = approvalMap([approval({ sessionId: 'watcher', kind: 'idle', createdAt: NOW - 1_000 })]);
|
|
const groups = groupSessions(buildRows(sessions, fresh));
|
|
expect(groups[0].rows.map((row) => row.session.sessionId)).toEqual(['watcher']);
|
|
});
|
|
});
|
|
|
|
describe('groupSessions', () => {
|
|
it('always returns the four groups in display order', () => {
|
|
expect(groupSessions([]).map((group) => group.key)).toEqual(['needs-you', 'working', 'idle', 'recent']);
|
|
expect(groupSessions([]).map((group) => group.label)).toEqual(['NEEDS YOU', 'WORKING', 'IDLE', 'RECENT']);
|
|
});
|
|
|
|
it('orders NEEDS YOU by how long each has been blocked, longest first', () => {
|
|
const sessions = [session({ sessionId: 'fresh' }), session({ sessionId: 'old' }), session({ sessionId: 'middle' })];
|
|
const approvals = approvalMap([
|
|
approval({ sessionId: 'fresh', createdAt: NOW - 5_000 }),
|
|
approval({ sessionId: 'old', createdAt: NOW - 900_000, kind: 'idle' }),
|
|
approval({ sessionId: 'middle', createdAt: NOW - 60_000, kind: 'question' }),
|
|
]);
|
|
const groups = groupSessions(buildRows(sessions, approvals));
|
|
expect(groups[0].rows.map((row) => row.session.sessionId)).toEqual(['old', 'middle', 'fresh']);
|
|
});
|
|
|
|
it('orders WORKING by turn start, longest-running first', () => {
|
|
const sessions = [
|
|
session({ sessionId: 'short', status: 'busy', lastSubmitAt: NOW - 10_000, lastActivityAt: NOW }),
|
|
session({ sessionId: 'long', status: 'busy', lastSubmitAt: NOW - 3_600_000, lastActivityAt: NOW }),
|
|
session({ sessionId: 'nosubmit', status: 'busy', createdAt: NOW - 500, lastActivityAt: NOW }),
|
|
];
|
|
const groups = groupSessions(buildRows(sessions));
|
|
expect(groups[1].rows.map((row) => row.session.sessionId)).toEqual(['long', 'short', 'nosubmit']);
|
|
});
|
|
|
|
it('orders IDLE and RECENT newest first', () => {
|
|
const sessions = [
|
|
session({ sessionId: 'i-old', lastActivityAt: NOW - 900_000 }),
|
|
session({ sessionId: 'i-new', lastActivityAt: NOW - 1_000 }),
|
|
session({ sessionId: 'h-old', sources: ['history'], lastActivityAt: NOW - 86_400_000 }),
|
|
session({ sessionId: 'h-new', sources: ['history'], lastActivityAt: NOW - 3_600_000 }),
|
|
];
|
|
const groups = groupSessions(buildRows(sessions));
|
|
expect(groups[2].rows.map((row) => row.session.sessionId)).toEqual(['i-new', 'i-old']);
|
|
expect(groups[3].rows.map((row) => row.session.sessionId)).toEqual(['h-new', 'h-old']);
|
|
});
|
|
|
|
it('caps RECENT', () => {
|
|
const sessions = Array.from({ length: 20 }, (_, i) =>
|
|
session({ sessionId: `h${i}`, sources: ['history'], lastActivityAt: NOW - i * 1000 })
|
|
);
|
|
expect(groupSessions(buildRows(sessions))[3].rows).toHaveLength(8);
|
|
expect(groupSessions(buildRows(sessions), { recentLimit: 3 })[3].rows.map((r) => r.session.sessionId)).toEqual([
|
|
'h0',
|
|
'h1',
|
|
'h2',
|
|
]);
|
|
expect(groupSessions(buildRows(sessions), { recentLimit: 0 })[3].rows).toEqual([]);
|
|
});
|
|
|
|
it('sorts deterministically when the anchors tie', () => {
|
|
const sessions = [
|
|
session({ sessionId: 'b', lastActivityAt: NOW }),
|
|
session({ sessionId: 'a', lastActivityAt: NOW }),
|
|
];
|
|
expect(groupSessions(buildRows(sessions))[2].rows.map((row) => row.session.sessionId)).toEqual(['a', 'b']);
|
|
});
|
|
|
|
it('sorts an unknown anchor last in both directions', () => {
|
|
const withAnchor = session({ sessionId: 'known', lastActivityAt: NOW - 1000 });
|
|
const without = session({ sessionId: 'unknown', lastActivityAt: undefined, createdAt: undefined });
|
|
const idle = groupSessions(buildRows([without, withAnchor]))[2];
|
|
expect(idle.rows.map((row) => row.session.sessionId)).toEqual(['known', 'unknown']);
|
|
});
|
|
});
|
|
|
|
describe('mergeSessionRow', () => {
|
|
it('keeps fields the incoming row does not carry', () => {
|
|
const existing = session({ sessionId: 'a', firstPrompt: 'hello', inputTokens: 10 });
|
|
const merged = mergeSessionRow(existing, { sessionId: 'a', sources: ['live'], status: 'busy' });
|
|
expect(merged.firstPrompt).toBe('hello');
|
|
expect(merged.inputTokens).toBe(10);
|
|
expect(merged.status).toBe('busy');
|
|
});
|
|
|
|
it('lets a session lose its live source when the server drops it', () => {
|
|
const existing = session({ sessionId: 'a', sources: ['live', 'persisted'] });
|
|
const merged = mergeSessionRow(existing, { sessionId: 'a', sources: ['history'] });
|
|
expect(merged.sources).toEqual(['history']);
|
|
expect(classifySession(merged)).toBe('recent');
|
|
});
|
|
});
|
|
|
|
describe('the store', () => {
|
|
it('selects the first row as soon as there is one', () => {
|
|
const model = createTuiModel();
|
|
expect(model.selectedSession()).toBeNull();
|
|
model.replaceSessions([session({ sessionId: 'a' }), session({ sessionId: 'b' })]);
|
|
expect(model.selectedId).toBe(model.rows()[0].session.sessionId);
|
|
});
|
|
|
|
it('moves the cursor over rows only, wrapping at both ends', () => {
|
|
const model = createTuiModel();
|
|
model.replaceSessions([
|
|
session({ sessionId: 'needs' }),
|
|
session({ sessionId: 'work', status: 'busy', lastSubmitAt: NOW - 1000 }),
|
|
session({ sessionId: 'idle' }),
|
|
session({ sessionId: 'past', sources: ['history'] }),
|
|
]);
|
|
model.setApprovals([approval({ sessionId: 'needs' })]);
|
|
// One row per group: the cursor must cross the group headers without stopping.
|
|
expect(model.rows().map((row) => row.session.sessionId)).toEqual(['needs', 'work', 'idle', 'past']);
|
|
|
|
model.select('needs');
|
|
model.moveCursor(1);
|
|
expect(model.selectedId).toBe('work');
|
|
model.moveCursor(-1);
|
|
expect(model.selectedId).toBe('needs');
|
|
model.moveCursor(-1);
|
|
expect(model.selectedId).toBe('past');
|
|
model.moveCursor(1);
|
|
expect(model.selectedId).toBe('needs');
|
|
});
|
|
|
|
it('jumps by 1-based index and refuses one that is off the list', () => {
|
|
const model = createTuiModel();
|
|
model.replaceSessions([
|
|
session({ sessionId: 'a', lastActivityAt: NOW }),
|
|
session({ sessionId: 'b', lastActivityAt: NOW - 1 }),
|
|
session({ sessionId: 'c', lastActivityAt: NOW - 2 }),
|
|
]);
|
|
expect(model.cursorToIndex(3)).toBe(true);
|
|
expect(model.selectedId).toBe('c');
|
|
expect(model.cursorToIndex(9)).toBe(false);
|
|
expect(model.selectedId).toBe('c');
|
|
expect(model.cursorToIndex(0)).toBe(false);
|
|
});
|
|
|
|
it('keeps the selection on its session when the rows re-sort under it', () => {
|
|
const model = createTuiModel();
|
|
model.replaceSessions([
|
|
session({ sessionId: 'a', lastActivityAt: NOW }),
|
|
session({ sessionId: 'b', lastActivityAt: NOW - 1000 }),
|
|
]);
|
|
model.select('b');
|
|
expect(model.rows()[1].session.sessionId).toBe('b');
|
|
|
|
// b becomes blocked and jumps to the top of the list.
|
|
model.setApprovals([approval({ sessionId: 'b' })]);
|
|
expect(model.rows()[0].session.sessionId).toBe('b');
|
|
expect(model.selectedId).toBe('b');
|
|
expect(model.selectedSession()?.state).toBe('blocked-permission');
|
|
});
|
|
|
|
it('hands the cursor to whatever takes the place of a removed session', () => {
|
|
const model = createTuiModel();
|
|
model.replaceSessions([
|
|
session({ sessionId: 'a', lastActivityAt: NOW }),
|
|
session({ sessionId: 'b', lastActivityAt: NOW - 1 }),
|
|
session({ sessionId: 'c', lastActivityAt: NOW - 2 }),
|
|
]);
|
|
model.select('b');
|
|
model.removeSession('b');
|
|
expect(model.selectedId).toBe('c');
|
|
model.replaceSessions([session({ sessionId: 'a', lastActivityAt: NOW })]);
|
|
expect(model.selectedId).toBe('a');
|
|
model.replaceSessions([]);
|
|
expect(model.selectedId).toBeNull();
|
|
expect(model.selectedSession()).toBeNull();
|
|
});
|
|
|
|
it('merges an SSE update into the row it already has', () => {
|
|
const model = createTuiModel();
|
|
model.replaceSessions([session({ sessionId: 'a', firstPrompt: 'first thing' })]);
|
|
model.upsertSession({ sessionId: 'a', sources: ['live'], status: 'busy', inputTokens: 5 });
|
|
const row = model.rows()[0];
|
|
expect(row.state).toBe('working');
|
|
expect(row.session.firstPrompt).toBe('first thing');
|
|
expect(row.session.inputTokens).toBe(5);
|
|
});
|
|
|
|
it('counts only live sessions', () => {
|
|
const model = createTuiModel();
|
|
model.replaceSessions([
|
|
session({ sessionId: 'a' }),
|
|
session({ sessionId: 'b' }),
|
|
session({ sessionId: 'h', sources: ['history'] }),
|
|
]);
|
|
expect(model.sessionCount).toBe(2);
|
|
expect(flattenRows(model.groups())).toHaveLength(3);
|
|
});
|
|
|
|
// Whether the typed text AUTHORIZES the kill is `confirmAccepts()` in
|
|
// tui-app, tested there; the store only carries what was typed.
|
|
it('tracks the confirm-kill overlay, keyed to the name it showed', () => {
|
|
const model = createTuiModel();
|
|
model.replaceSessions([session({ sessionId: 'a', name: 'w4-api' })]);
|
|
model.beginConfirmKill(model.rows()[0], 'w4-api');
|
|
expect(model.mode).toBe('confirm-kill');
|
|
// The name is the whole payload: it is what the dialog shows so the user
|
|
// knows WHICH session a `y` is about to destroy.
|
|
expect(model.confirm).toEqual({ sessionId: 'a', name: 'w4-api' });
|
|
|
|
// Regression: the label is supplied by the caller. Deriving it here with
|
|
// `name ?? id` let an EMPTY name through, and the dialog read "Kill ?".
|
|
model.replaceSessions([session({ sessionId: 'b', name: '' })]);
|
|
model.beginConfirmKill(model.rows()[0], 'mirofish');
|
|
expect(model.confirm?.name).toBe('mirofish');
|
|
model.closeOverlay();
|
|
expect(model.mode).toBe('list');
|
|
expect(model.confirm).toBeNull();
|
|
});
|
|
|
|
it('drops a session approval along with the session, and never resurrects it', () => {
|
|
const model = createTuiModel();
|
|
model.replaceSessions([session({ sessionId: 'a' })]);
|
|
model.setApprovals([approval({ sessionId: 'a' })]);
|
|
expect(model.rows()[0].approval).toBeDefined();
|
|
model.removeSession('a');
|
|
expect(model.rows()).toHaveLength(0);
|
|
// The same id coming back must not inherit the dead session's dialog.
|
|
model.upsertSession(session({ sessionId: 'a' }));
|
|
expect(model.rows()[0].approval).toBeUndefined();
|
|
expect(model.rows()[0].state).toBe('idle');
|
|
});
|
|
});
|
|
|
|
describe('the phase-2 overlays', () => {
|
|
it('gives one overlay the keyboard at a time and clears them together', () => {
|
|
const model = createTuiModel();
|
|
model.setPrompt({ sessionId: 'a', label: 'w4-api', composer: createComposer('hi') });
|
|
expect(model.mode).toBe('prompt');
|
|
model.setSearch({ composer: createComposer(), query: '', entries: [], index: -1, status: 'idle' });
|
|
expect(model.mode).toBe('search');
|
|
model.setDigest({ title: 'Away digest', lines: ['a', 'b'], offset: 0 });
|
|
expect(model.mode).toBe('digest');
|
|
model.closeOverlay();
|
|
expect(model.mode).toBe('list');
|
|
expect([model.prompt, model.search, model.digest]).toEqual([null, null, null]);
|
|
});
|
|
|
|
it('keeps the composer pointed at its session while the text changes', () => {
|
|
const model = createTuiModel();
|
|
model.setPrompt({ sessionId: 'a', label: 'w4-api', composer: createComposer() });
|
|
const revision = model.revision;
|
|
model.updatePrompt(createComposer('deploy'));
|
|
expect(model.prompt?.sessionId).toBe('a');
|
|
expect(model.revision).toBeGreaterThan(revision);
|
|
});
|
|
|
|
it('scrolls the digest without running off either end', () => {
|
|
const model = createTuiModel();
|
|
model.setDigest({ title: 'Away digest', lines: Array.from({ length: 10 }, (_, i) => `line ${i}`), offset: 0 });
|
|
model.scrollDigest(3, 4);
|
|
expect(model.digest?.offset).toBe(3);
|
|
model.scrollDigest(100, 4);
|
|
expect(model.digest?.offset).toBe(6);
|
|
model.scrollDigest(-100, 4);
|
|
expect(model.digest?.offset).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('search results', () => {
|
|
const groups: SearchResultGroup[] = [
|
|
{
|
|
type: 'session',
|
|
results: [
|
|
{
|
|
type: 'session',
|
|
sessionId: 'live-1',
|
|
sessionName: 'w1-alpha',
|
|
timestamp: NOW,
|
|
snippet: 'w1-alpha — /tmp/alpha',
|
|
exactMatch: true,
|
|
jumpTo: { kind: 'session', sessionId: 'live-1' },
|
|
},
|
|
{
|
|
type: 'session',
|
|
sessionId: 'past-1',
|
|
sessionName: 'w9-old',
|
|
timestamp: NOW - 1000,
|
|
snippet: '/tmp/old',
|
|
exactMatch: false,
|
|
jumpTo: { kind: 'resume-session', sessionId: 'past-1' },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: 'file',
|
|
results: [
|
|
{
|
|
type: 'file',
|
|
sessionId: 'live-1',
|
|
sessionName: 'w1-alpha',
|
|
timestamp: NOW,
|
|
snippet: 'notes.md',
|
|
exactMatch: false,
|
|
jumpTo: { kind: 'file-preview', sessionId: 'live-1', relativePath: 'docs/notes.md' },
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
it('flattens the typed groups into headers and rows', () => {
|
|
const entries = buildSearchEntries(groups, (id) => id === 'live-1');
|
|
expect(entries.map((entry) => entry.kind)).toEqual(['header', 'result', 'result', 'header', 'result']);
|
|
expect(entries[0].text).toBe('SESSIONS');
|
|
expect(entries[1]).toMatchObject({ text: 'w1-alpha', sessionId: 'live-1', live: true });
|
|
// The snippet opens with the session name, which the row already shows.
|
|
expect(entries[1].detail).toBe('/tmp/alpha');
|
|
// A session that is not on the list cannot be selected into.
|
|
expect(entries[2]).toMatchObject({ text: 'w9-old', live: false });
|
|
expect(entries[4]).toMatchObject({ text: 'docs/notes.md', live: false });
|
|
});
|
|
|
|
it('drops an empty group instead of printing a header with nothing under it', () => {
|
|
expect(buildSearchEntries([{ type: 'event', results: [] }], () => false)).toEqual([]);
|
|
});
|
|
|
|
it('starts on the first result and never lands on a header', () => {
|
|
const entries = buildSearchEntries(groups, () => true);
|
|
expect(firstSearchIndex(entries)).toBe(1);
|
|
expect(moveSearchIndex(entries, 1, 1)).toBe(2);
|
|
expect(moveSearchIndex(entries, 2, 1)).toBe(4);
|
|
// Both ends stop rather than wrap: a result list is read, not cycled.
|
|
expect(moveSearchIndex(entries, 4, 1)).toBe(4);
|
|
expect(moveSearchIndex(entries, 1, -1)).toBe(1);
|
|
expect(firstSearchIndex([])).toBe(-1);
|
|
});
|
|
});
|