204 lines
6.2 KiB
TypeScript
204 lines
6.2 KiB
TypeScript
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
import {
|
|
readUiUrlState,
|
|
buildUiUrlStateSearch,
|
|
paramsEqualExcept,
|
|
COLUMN_LABELS,
|
|
COLUMN_LIST,
|
|
type UiUrlState,
|
|
} from './urlState';
|
|
|
|
const DEFAULT_STATE: UiUrlState = {
|
|
page: 'tasks',
|
|
repo: '',
|
|
status: 'all',
|
|
search: '',
|
|
sort: 'updated',
|
|
scope: 'mine',
|
|
detailTab: 'overview',
|
|
mobileTab: 'chat',
|
|
taskId: null,
|
|
};
|
|
|
|
function stubLocation(search: string) {
|
|
vi.stubGlobal('window', { location: { search } });
|
|
}
|
|
|
|
afterEach(() => vi.unstubAllGlobals());
|
|
|
|
describe('readUiUrlState', () => {
|
|
it('returns defaults when window is undefined (SSR/node)', () => {
|
|
// node environment: no window global by default
|
|
expect(readUiUrlState()).toEqual(DEFAULT_STATE);
|
|
});
|
|
|
|
it('returns defaults for an empty query string', () => {
|
|
stubLocation('');
|
|
const state = readUiUrlState();
|
|
expect(state).toEqual({
|
|
...DEFAULT_STATE,
|
|
section: undefined,
|
|
piece: undefined,
|
|
});
|
|
});
|
|
|
|
it('parses every valid parameter', () => {
|
|
stubLocation(
|
|
'?page=settings&repo=local%2Ftask-1&status=running&q=hello&sort=title' +
|
|
'&tab=files&mobileTab=activity&task=42§ion=auth&piece=chat' +
|
|
'&pieceSource=user-custom&dashboardWidget=gpu&help=intro',
|
|
);
|
|
const state = readUiUrlState();
|
|
expect(state.page).toBe('settings');
|
|
expect(state.repo).toBe('local/task-1');
|
|
expect(state.status).toBe('running');
|
|
expect(state.search).toBe('hello');
|
|
expect(state.sort).toBe('title');
|
|
expect(state.detailTab).toBe('files');
|
|
expect(state.mobileTab).toBe('activity');
|
|
expect(state.taskId).toBe(42);
|
|
expect(state.section).toBe('auth');
|
|
expect(state.piece).toBe('chat');
|
|
expect(state.pieceSource).toBe('user-custom');
|
|
expect(state.dashboardWidget).toBe('gpu');
|
|
expect(state.help).toBe('intro');
|
|
});
|
|
|
|
it('falls back to defaults on invalid enum values', () => {
|
|
stubLocation('?page=bogus&status=bogus&sort=bogus&tab=bogus&mobileTab=bogus§ion=bogus&pieceSource=bogus');
|
|
const state = readUiUrlState();
|
|
expect(state.page).toBe('tasks');
|
|
expect(state.status).toBe('all');
|
|
expect(state.sort).toBe('updated');
|
|
expect(state.detailTab).toBe('overview');
|
|
expect(state.mobileTab).toBe('chat');
|
|
expect(state.section).toBeUndefined();
|
|
expect(state.pieceSource).toBeUndefined();
|
|
});
|
|
|
|
it('rejects non-positive and non-numeric task ids', () => {
|
|
for (const task of ['0', '-5', 'abc', '']) {
|
|
stubLocation(`?task=${task}`);
|
|
expect(readUiUrlState().taskId).toBeNull();
|
|
}
|
|
stubLocation('?task=7');
|
|
expect(readUiUrlState().taskId).toBe(7);
|
|
});
|
|
|
|
it('still parses legacy settings section ids (soft bookmark landing)', () => {
|
|
for (const legacy of ['gateway-keys', 'provider', 'workspace', 'browser-sessions']) {
|
|
stubLocation(`?section=${legacy}`);
|
|
expect(readUiUrlState().section).toBe(legacy);
|
|
}
|
|
});
|
|
|
|
it('treats an empty piece param as undefined', () => {
|
|
stubLocation('?piece=');
|
|
expect(readUiUrlState().piece).toBeUndefined();
|
|
});
|
|
|
|
it('omits optional spread keys entirely when their params are absent', () => {
|
|
stubLocation('?page=tasks');
|
|
const state = readUiUrlState();
|
|
expect('pieceSource' in state).toBe(false);
|
|
expect('dashboardWidget' in state).toBe(false);
|
|
expect('help' in state).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('buildUiUrlStateSearch', () => {
|
|
it('produces an empty string for the default state', () => {
|
|
expect(buildUiUrlStateSearch(DEFAULT_STATE)).toBe('');
|
|
});
|
|
|
|
it('only serializes non-default values', () => {
|
|
const search = buildUiUrlStateSearch({
|
|
...DEFAULT_STATE,
|
|
status: 'failed',
|
|
taskId: 12,
|
|
});
|
|
const params = new URLSearchParams(search);
|
|
expect(params.get('status')).toBe('failed');
|
|
expect(params.get('task')).toBe('12');
|
|
expect(Array.from(params.keys()).sort()).toEqual(['status', 'task']);
|
|
});
|
|
|
|
it('omits the default dashboardWidget (worker-status) but keeps others', () => {
|
|
expect(
|
|
buildUiUrlStateSearch({ ...DEFAULT_STATE, dashboardWidget: 'worker-status' }),
|
|
).toBe('');
|
|
expect(
|
|
buildUiUrlStateSearch({ ...DEFAULT_STATE, dashboardWidget: 'gpu' }),
|
|
).toBe('dashboardWidget=gpu');
|
|
});
|
|
|
|
it('round-trips through readUiUrlState', () => {
|
|
const state: UiUrlState = {
|
|
page: 'help',
|
|
repo: 'local/task-9',
|
|
status: 'waiting_human',
|
|
search: 'foo bar',
|
|
sort: 'status',
|
|
scope: 'all',
|
|
detailTab: 'trace',
|
|
mobileTab: 'files',
|
|
taskId: 99,
|
|
section: 'reflection',
|
|
piece: 'research',
|
|
pieceSource: 'builtin',
|
|
dashboardWidget: 'queue',
|
|
help: 'pieces',
|
|
};
|
|
stubLocation(`?${buildUiUrlStateSearch(state)}`);
|
|
expect(readUiUrlState()).toEqual(state);
|
|
});
|
|
});
|
|
|
|
describe('paramsEqualExcept', () => {
|
|
it('considers identical params equal', () => {
|
|
const a = new URLSearchParams('a=1&b=2');
|
|
const b = new URLSearchParams('a=1&b=2');
|
|
expect(paramsEqualExcept(a, b, [])).toBe(true);
|
|
});
|
|
|
|
it('ignores key order', () => {
|
|
const a = new URLSearchParams('b=2&a=1');
|
|
const b = new URLSearchParams('a=1&b=2');
|
|
expect(paramsEqualExcept(a, b, [])).toBe(true);
|
|
});
|
|
|
|
it('detects value differences', () => {
|
|
const a = new URLSearchParams('a=1');
|
|
const b = new URLSearchParams('a=2');
|
|
expect(paramsEqualExcept(a, b, [])).toBe(false);
|
|
});
|
|
|
|
it('skips ignored keys on either side', () => {
|
|
const a = new URLSearchParams('a=1&tab=files');
|
|
const b = new URLSearchParams('a=1&tab=trace');
|
|
expect(paramsEqualExcept(a, b, ['tab'])).toBe(true);
|
|
const c = new URLSearchParams('a=1');
|
|
expect(paramsEqualExcept(a, c, ['tab'])).toBe(true);
|
|
});
|
|
|
|
it('detects extra non-ignored keys', () => {
|
|
const a = new URLSearchParams('a=1&extra=x');
|
|
const b = new URLSearchParams('a=1');
|
|
expect(paramsEqualExcept(a, b, [])).toBe(false);
|
|
});
|
|
|
|
it('handles repeated keys deterministically', () => {
|
|
const a = new URLSearchParams('k=2&k=1');
|
|
const b = new URLSearchParams('k=1&k=2');
|
|
expect(paramsEqualExcept(a, b, [])).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('column constants', () => {
|
|
it('every status column has a label', () => {
|
|
for (const col of COLUMN_LIST) {
|
|
expect(COLUMN_LABELS[col], `label for ${col}`).toBeTruthy();
|
|
}
|
|
});
|
|
});
|