@corbet-labs/ccht 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,191 @@
1
+ /** Browser behavior of the generic per-step config form (StepConfig.svelte).
2
+ *
3
+ * Covers step rendering, backend/model selection callbacks, the account
4
+ * slot branch, extra select/boolean controls, refresh, and error display.
5
+ */
6
+ import { render, fireEvent, cleanup } from '@testing-library/svelte';
7
+ import { afterEach, describe, expect, test, vi } from 'vitest';
8
+ import { createRawSnippet } from 'svelte';
9
+ import StepConfig from './StepConfig.svelte';
10
+ import type { StepData } from './StepConfig.svelte';
11
+
12
+ afterEach(() => cleanup());
13
+
14
+ function step(overrides: Partial<StepData> = {}): StepData {
15
+ return {
16
+ id: 'creator',
17
+ label: 'Creator chat',
18
+ backendValue: '',
19
+ backendOptions: [{ value: 'local', name: 'Local' }],
20
+ backendPlaceholder: 'Configured default',
21
+ accountConnected: true,
22
+ accountBusy: false,
23
+ configurationError: null,
24
+ modelValue: 'qwen',
25
+ modelOptions: [{ value: 'qwen', name: 'Qwen' }],
26
+ refreshable: true,
27
+ extraOptions: [],
28
+ extraValues: {},
29
+ ...overrides,
30
+ };
31
+ }
32
+
33
+ function openSteps(steps: StepData[], props: Record<string, unknown> = {}) {
34
+ return render(StepConfig, {
35
+ steps,
36
+ onSelectBackend: () => {},
37
+ onSelectModel: () => {},
38
+ onSelectOption: () => {},
39
+ onRefresh: () => {},
40
+ ...props,
41
+ });
42
+ }
43
+
44
+ describe('step rendering', () => {
45
+ test('renders one labelled group per step with backend and model selects', () => {
46
+ const { getByRole, getByLabelText } = openSteps([step(), step({ id: 'compile', label: 'Proposal compilation' })]);
47
+ expect(getByRole('group', { name: 'Creator chat' })).toBeTruthy();
48
+ expect(getByRole('group', { name: 'Proposal compilation' })).toBeTruthy();
49
+ expect(getByLabelText('Creator chat assistant')).toBeTruthy();
50
+ expect(getByLabelText('Creator chat model')).toBeTruthy();
51
+ });
52
+
53
+ test('backend and model selections forward step id and value', async () => {
54
+ const onSelectBackend = vi.fn();
55
+ const onSelectModel = vi.fn();
56
+ const { getByLabelText } = openSteps([step()], { onSelectBackend, onSelectModel });
57
+ await fireEvent.change(getByLabelText('Creator chat assistant'), { target: { value: 'local' } });
58
+ expect(onSelectBackend).toHaveBeenCalledWith('creator', 'local');
59
+ await fireEvent.change(getByLabelText('Creator chat model'), { target: { value: 'qwen' } });
60
+ expect(onSelectModel).toHaveBeenCalledWith('creator', 'qwen');
61
+ });
62
+
63
+ test('empty model options render a No model placeholder', () => {
64
+ const { getByLabelText } = openSteps([step({ modelValue: '', modelOptions: [] })]);
65
+ expect(getByLabelText('Creator chat model').textContent).toContain('No model');
66
+ });
67
+ });
68
+
69
+ describe('account branch', () => {
70
+ test('disconnected steps without a slot fall back to model controls', () => {
71
+ const { getByLabelText } = openSteps([step({ accountConnected: false })]);
72
+ expect(getByLabelText('Creator chat model')).toBeTruthy();
73
+ });
74
+
75
+ test('disconnected steps render the account slot with the step id', () => {
76
+ const accountSlot = createRawSnippet<{ step: string }>((getStep) => ({
77
+ render: () => `<button type="button" data-step="${getStep().step}">connect</button>`,
78
+ }));
79
+ const { getByRole, queryByLabelText } = openSteps([step({ accountConnected: false })], { accountSlot });
80
+ expect(queryByLabelText('Creator chat model')).toBeNull();
81
+ expect(getByRole('button', { name: 'connect' }).getAttribute('data-step')).toBe('creator');
82
+ });
83
+
84
+ test('connected steps render model controls even with a slot provided', () => {
85
+ const { getByLabelText } = render(StepConfig, {
86
+ steps: [step({ accountConnected: true })],
87
+ onSelectBackend: () => {},
88
+ onSelectModel: () => {},
89
+ onSelectOption: () => {},
90
+ onRefresh: () => {},
91
+ accountSlot: (() => {}) as never,
92
+ });
93
+ expect(getByLabelText('Creator chat model')).toBeTruthy();
94
+ });
95
+ });
96
+
97
+ describe('extra controls', () => {
98
+ const options = [
99
+ {
100
+ id: 'model',
101
+ name: 'Model',
102
+ category: 'model',
103
+ type: 'select',
104
+ currentValue: 'one',
105
+ options: [{ value: 'one', name: 'One' }],
106
+ },
107
+ {
108
+ id: 'thinking',
109
+ name: 'Thinking',
110
+ type: 'boolean',
111
+ currentValue: true,
112
+ },
113
+ ];
114
+
115
+ test('model-category controls are filtered out; others render', () => {
116
+ const { queryByLabelText, getByLabelText } = openSteps([step({ extraOptions: options as never })]);
117
+ expect(queryByLabelText('Model')).toBeNull();
118
+ expect(getByLabelText('Thinking')).toBeTruthy();
119
+ });
120
+
121
+ test('select and boolean edits forward option and typed value', async () => {
122
+ const onSelectOption = vi.fn();
123
+ const selectOptions = [
124
+ {
125
+ id: 'effort',
126
+ name: 'Effort',
127
+ type: 'select',
128
+ currentValue: 'low',
129
+ options: [{ value: 'low', name: 'Low' }, { value: 'high', name: 'High' }],
130
+ },
131
+ ];
132
+ const { getByLabelText } = openSteps(
133
+ [step({ extraOptions: [...selectOptions, options[1]] as never, extraValues: { effort: 'low', thinking: true } })],
134
+ { onSelectOption },
135
+ );
136
+ await fireEvent.change(getByLabelText('Effort'), { target: { value: 'high' } });
137
+ expect(onSelectOption).toHaveBeenCalledWith('creator', expect.objectContaining({ id: 'effort' }), 'high');
138
+ const thinking = getByLabelText('Thinking') as HTMLInputElement;
139
+ expect(thinking.checked).toBe(true);
140
+ await fireEvent.click(thinking);
141
+ expect(onSelectOption).toHaveBeenCalledWith('creator', expect.objectContaining({ id: 'thinking' }), false);
142
+ });
143
+
144
+ test('grouped select choices flatten in order', async () => {
145
+ const onSelectOption = vi.fn();
146
+ const { getByLabelText } = openSteps(
147
+ [
148
+ step({
149
+ extraOptions: [
150
+ {
151
+ id: 'model2',
152
+ name: 'Second',
153
+ type: 'select',
154
+ currentValue: 'b',
155
+ options: [{ group: 'g', name: 'G', options: [{ value: 'a', name: 'A' }, { value: 'b', name: 'B' }] }],
156
+ },
157
+ ] as never,
158
+ }),
159
+ ],
160
+ { onSelectOption },
161
+ );
162
+ const select = getByLabelText('Second') as HTMLSelectElement;
163
+ expect([...select.options].map((option) => option.value)).toEqual(['a', 'b']);
164
+ });
165
+ });
166
+
167
+ describe('refresh and errors', () => {
168
+ test('refresh button forwards the step id and reflects loading', async () => {
169
+ const onRefresh = vi.fn();
170
+ const idle = openSteps([step()], { onRefresh });
171
+ await fireEvent.click(idle.getByRole('button', { name: 'Refresh models' }));
172
+ expect(onRefresh).toHaveBeenCalledWith('creator');
173
+ const loading = openSteps([step()], { onRefresh, refreshing: true });
174
+ expect(loading.getByRole('button', { name: 'Loading controls…' })).toBeTruthy();
175
+ });
176
+
177
+ test('configuration errors render as alerts', () => {
178
+ const { getByText } = openSteps([step({ configurationError: 'stale options' })]);
179
+ expect(getByText('stale options')).toBeTruthy();
180
+ });
181
+
182
+ test('no refresh row while disconnected or not refreshable', () => {
183
+ const { queryByRole, rerender } = openSteps([step({ accountConnected: false })]);
184
+ expect(queryByRole('button', { name: 'Refresh models' })).toBeNull();
185
+ });
186
+
187
+ test('non-refreshable steps omit refresh even when connected', () => {
188
+ const { queryByRole } = openSteps([step({ refreshable: false })]);
189
+ expect(queryByRole('button', { name: /Refresh models|Loading/ })).toBeNull();
190
+ });
191
+ });
@@ -0,0 +1,248 @@
1
+ <!-- Generic per-step backend/model/options config form (content only).
2
+ The application wraps this in a Dock and owns all effects (loading model
3
+ lists, refreshing controls, connecting accounts); this component only
4
+ renders one fieldset per step and forwards user intent through
5
+ app-supplied callbacks. It never fetches, spawns, or stores anything. -->
6
+ <script module lang="ts">
7
+ import type { SessionConfigOption } from '../../index.js';
8
+
9
+ export interface StepBackendOption {
10
+ value: string;
11
+ name: string;
12
+ }
13
+
14
+ export interface StepModelOption {
15
+ value: string;
16
+ name: string;
17
+ }
18
+
19
+ export interface StepData {
20
+ id: string;
21
+ label: string;
22
+ backendValue: string;
23
+ backendOptions: StepBackendOption[];
24
+ backendPlaceholder: string;
25
+ accountConnected: boolean;
26
+ accountBusy: boolean;
27
+ configurationError?: string | null;
28
+ modelValue: string;
29
+ modelOptions: StepModelOption[];
30
+ refreshable: boolean;
31
+ extraOptions: SessionConfigOption[];
32
+ extraValues: Record<string, string | boolean>;
33
+ }
34
+ </script>
35
+
36
+ <script lang="ts">
37
+ import type { Snippet } from 'svelte';
38
+ import type {
39
+ ConfigChoice,
40
+ ConfigGroup,
41
+ SessionConfigOption
42
+ } from '../../index.js';
43
+
44
+ let {
45
+ steps,
46
+ refreshing = false,
47
+ onSelectBackend,
48
+ onSelectModel,
49
+ onSelectOption,
50
+ onRefresh,
51
+ accountSlot,
52
+ headerCopy,
53
+ footerSlot
54
+ }: {
55
+ steps: StepData[];
56
+ refreshing?: boolean;
57
+ onSelectBackend: (id: string, value: string) => void;
58
+ onSelectModel: (id: string, value: string) => void;
59
+ onSelectOption: (
60
+ id: string,
61
+ option: SessionConfigOption,
62
+ value: string | boolean
63
+ ) => void;
64
+ onRefresh: (id: string) => void;
65
+ accountSlot?: Snippet<[{ step: string }]>;
66
+ headerCopy?: Snippet;
67
+ footerSlot?: Snippet;
68
+ } = $props();
69
+
70
+ // Flatten grouped select choices while preserving order.
71
+ function choices(option: SessionConfigOption | undefined): ConfigChoice[] {
72
+ if (option?.type !== 'select') return [];
73
+ return option.options.flatMap((entry) =>
74
+ 'options' in entry
75
+ ? (entry as ConfigGroup).options
76
+ : [entry as ConfigChoice]
77
+ );
78
+ }
79
+
80
+ // Drop the model control defensively; it has its own dedicated row.
81
+ function extraOptions(step: StepData): SessionConfigOption[] {
82
+ return step.extraOptions.filter(
83
+ (item) => item.category !== 'model' && item.id !== 'model'
84
+ );
85
+ }
86
+
87
+ function selectedValue(
88
+ step: StepData,
89
+ option: SessionConfigOption
90
+ ): string | boolean {
91
+ return step.extraValues[option.id] ?? option.currentValue;
92
+ }
93
+ </script>
94
+
95
+ {#if headerCopy}
96
+ <div class="ccht-steps-copy">{@render headerCopy()}</div>
97
+ {/if}
98
+
99
+ {#each steps as step (step.id)}
100
+ <fieldset class="ccht-step">
101
+ <legend>{step.label}</legend>
102
+ <label class="ccht-step-label" for={`ccht-step-${step.id}-assistant`}>
103
+ {step.label} assistant
104
+ </label>
105
+ <select
106
+ id={`ccht-step-${step.id}-assistant`}
107
+ class="ccht-step-select"
108
+ value={step.backendValue}
109
+ disabled={step.accountBusy}
110
+ onchange={(event) => onSelectBackend(step.id, event.currentTarget.value)}
111
+ >
112
+ <option value="">{step.backendPlaceholder}</option>
113
+ {#each step.backendOptions as backend (backend.value)}
114
+ <option value={backend.value}>{backend.name}</option>
115
+ {/each}
116
+ </select>
117
+ {#if accountSlot && !step.accountConnected}
118
+ {@render accountSlot({ step: step.id })}
119
+ {:else}
120
+ <label class="ccht-step-label" for={`ccht-step-${step.id}-model`}>
121
+ {step.label} model
122
+ </label>
123
+ <select
124
+ id={`ccht-step-${step.id}-model`}
125
+ class="ccht-step-select"
126
+ value={step.modelValue}
127
+ onchange={(event) => onSelectModel(step.id, event.currentTarget.value)}
128
+ >
129
+ {#if step.modelOptions.length}
130
+ {#each step.modelOptions as model (model.value)}
131
+ <option value={model.value}>{model.name}</option>
132
+ {/each}
133
+ {:else if step.modelValue}
134
+ <option value={step.modelValue}>{step.modelValue}</option>
135
+ {:else}
136
+ <option value="" disabled>No model</option>
137
+ {/if}
138
+ </select>
139
+ {#each extraOptions(step) as item (item.id)}
140
+ <label
141
+ class="ccht-step-label"
142
+ for={`ccht-step-${step.id}-option-${item.id}`}
143
+ title={item.description}
144
+ >
145
+ {item.name}
146
+ </label>
147
+ {#if item.type === 'select'}
148
+ <select
149
+ id={`ccht-step-${step.id}-option-${item.id}`}
150
+ class="ccht-step-select"
151
+ value={String(selectedValue(step, item))}
152
+ disabled={refreshing}
153
+ onchange={(event) =>
154
+ onSelectOption(step.id, item, event.currentTarget.value)}
155
+ >
156
+ {#each choices(item) as choice (choice.value)}
157
+ <option value={choice.value}>{choice.name}</option>
158
+ {/each}
159
+ </select>
160
+ {:else if item.type === 'boolean'}
161
+ <input
162
+ id={`ccht-step-${step.id}-option-${item.id}`}
163
+ class="ccht-step-check"
164
+ type="checkbox"
165
+ checked={Boolean(selectedValue(step, item))}
166
+ disabled={refreshing}
167
+ onchange={(event) =>
168
+ onSelectOption(step.id, item, event.currentTarget.checked)}
169
+ />
170
+ {/if}
171
+ {/each}
172
+ {#if step.accountConnected && step.refreshable}
173
+ <button
174
+ type="button"
175
+ class="ccht-step-refresh"
176
+ disabled={refreshing}
177
+ onclick={() => onRefresh(step.id)}
178
+ >
179
+ {refreshing ? 'Loading controls…' : 'Refresh models'}
180
+ </button>
181
+ {#if step.configurationError}
182
+ <span class="ccht-step-error" role="alert">
183
+ {step.configurationError}
184
+ </span>
185
+ {/if}
186
+ {/if}
187
+ {/if}
188
+ </fieldset>
189
+ {/each}
190
+
191
+ {#if footerSlot}
192
+ {@render footerSlot()}
193
+ {/if}
194
+
195
+ <style>
196
+ .ccht-steps-copy {
197
+ margin: 0;
198
+ font-size: 0.85rem;
199
+ line-height: 1.5;
200
+ color: var(--ccht-muted, #8fa0b7);
201
+ }
202
+ .ccht-step {
203
+ display: grid;
204
+ gap: 0.45rem;
205
+ margin: 0;
206
+ border: 1px solid var(--ccht-border, #30405c);
207
+ border-radius: 0.5rem;
208
+ padding: 0.75rem;
209
+ }
210
+ .ccht-step legend {
211
+ font-weight: 700;
212
+ padding: 0 0.4rem;
213
+ }
214
+ .ccht-step-label {
215
+ font-size: 0.75rem;
216
+ color: var(--ccht-muted, #8fa0b7);
217
+ }
218
+ .ccht-step-select {
219
+ min-width: 0;
220
+ width: 100%;
221
+ padding: 0.5rem;
222
+ border: 1px solid var(--ccht-select-border, #405779);
223
+ border-radius: 0.4rem;
224
+ background: var(--ccht-select-bg, #070f1b);
225
+ color: var(--ccht-fg-bright, #e2eaf5);
226
+ }
227
+ .ccht-step-check {
228
+ width: 1rem;
229
+ min-height: 1rem;
230
+ margin: 0;
231
+ accent-color: var(--ccht-accent-check, #34d399);
232
+ }
233
+ .ccht-step-refresh {
234
+ min-height: 2.2rem;
235
+ padding: 0.4rem 0.6rem;
236
+ border: 1px solid var(--ccht-border, #30405c);
237
+ border-radius: 0.4rem;
238
+ background: var(--ccht-tab-bg, #111c2d);
239
+ color: var(--ccht-fg, #bdc8d7);
240
+ font-size: 0.7rem;
241
+ font-weight: 750;
242
+ cursor: pointer;
243
+ }
244
+ .ccht-step-error {
245
+ color: var(--ccht-error, #fda4af);
246
+ font-size: 0.7rem;
247
+ }
248
+ </style>
@@ -0,0 +1,226 @@
1
+ /** Conformance tests for the framework-free dock state (web/src/dock.ts).
2
+ *
3
+ * These mirror the Rust `src/dock.rs` unit tests case-for-case: both
4
+ * implementations share the id rules, error names, open/placement
5
+ * semantics, focus-token lifecycle, and serialization contract. Fixtures
6
+ * only; no DOM, network, or storage.
7
+ */
8
+ import { describe, expect, test } from 'bun:test';
9
+ import { createDockManager, validateDockId } from './dock.ts';
10
+
11
+ describe('validateDockId', () => {
12
+ test('accepts lowercase, digits, dash, underscore', () => {
13
+ for (const id of ['a', 'scope', 'models-panel', 'chat_2', 'a-b_c9']) {
14
+ expect(validateDockId(id)).toBeNull();
15
+ }
16
+ });
17
+
18
+ test('accepts exactly 64 characters', () => {
19
+ expect(validateDockId('a'.repeat(64))).toBeNull();
20
+ });
21
+
22
+ test('rejects empty, non-string, too long, and bad characters', () => {
23
+ expect(validateDockId('')).not.toBeNull();
24
+ expect(validateDockId('a'.repeat(65))).not.toBeNull();
25
+ for (const id of ['Scope', 'has space', 'dot.name', 'slash/x', 'uniçode', 'CAPS'] as unknown[]) {
26
+ expect(validateDockId(id)).not.toBeNull();
27
+ }
28
+ for (const id of [null, undefined, 42, true, {}, []] as unknown[]) {
29
+ expect(validateDockId(id)).not.toBeNull();
30
+ }
31
+ });
32
+
33
+ test('never throws', () => {
34
+ expect(() => validateDockId(Symbol('x') as unknown as string)).not.toThrow();
35
+ });
36
+ });
37
+
38
+ describe('register', () => {
39
+ test('registers closed docks of every kind and placement', () => {
40
+ const docks = createDockManager();
41
+ docks.register('chat', 'chat', 'left');
42
+ docks.register('cfg', 'config', 'right');
43
+ docks.register('misc', 'custom', 'bottom');
44
+ docks.register('inline', 'custom', 'inline');
45
+ expect(docks.isOpen('chat')).toBe(false);
46
+ expect(docks.placement('cfg')).toBe('right');
47
+ });
48
+
49
+ test('rejects duplicates, malformed ids, kinds, and placements', () => {
50
+ const docks = createDockManager();
51
+ docks.register('scope', 'config', 'left');
52
+ expect(() => docks.register('scope', 'config', 'left')).toThrow('DuplicateDock: scope');
53
+ expect(() => docks.register('', 'config', 'left')).toThrow();
54
+ expect(() => docks.register('other', 'bogus' as never, 'left')).toThrow('invalid dock kind');
55
+ expect(() => docks.register('other', 'config', 'top' as never)).toThrow('invalid dock placement');
56
+ });
57
+ });
58
+
59
+ describe('open/close/toggle', () => {
60
+ test('open and close are idempotent; toggle flips and returns state', () => {
61
+ const docks = createDockManager();
62
+ docks.register('a', 'chat', 'left');
63
+ docks.open('a');
64
+ docks.open('a');
65
+ expect(docks.isOpen('a')).toBe(true);
66
+ expect(docks.toggle('a')).toBe(false);
67
+ expect(docks.toggle('a')).toBe(true);
68
+ docks.close('a');
69
+ docks.close('a');
70
+ expect(docks.isOpen('a')).toBe(false);
71
+ });
72
+
73
+ test('unknown ids throw UnknownDock on every accessor', () => {
74
+ const docks = createDockManager();
75
+ for (const fn of [
76
+ () => docks.open('nope'),
77
+ () => docks.openWithFocus('nope', 't'),
78
+ () => docks.close('nope'),
79
+ () => docks.toggle('nope'),
80
+ () => docks.isOpen('nope'),
81
+ () => docks.placement('nope'),
82
+ () => docks.setPlacement('nope', 'left'),
83
+ ]) {
84
+ expect(fn).toThrow('UnknownDock: nope');
85
+ }
86
+ });
87
+
88
+ test('docks are independent and openDocks preserves registration order', () => {
89
+ const docks = createDockManager();
90
+ docks.register('one', 'chat', 'left');
91
+ docks.register('two', 'config', 'right');
92
+ docks.register('three', 'custom', 'inline');
93
+ docks.open('three');
94
+ docks.open('one');
95
+ expect(docks.openDocks()).toEqual(['one', 'three']);
96
+ docks.close('one');
97
+ expect(docks.openDocks()).toEqual(['three']);
98
+ });
99
+
100
+ test('setPlacement moves only the target dock', () => {
101
+ const docks = createDockManager();
102
+ docks.register('a', 'chat', 'left');
103
+ docks.register('b', 'chat', 'left');
104
+ docks.setPlacement('a', 'bottom');
105
+ expect(docks.placement('a')).toBe('bottom');
106
+ expect(docks.placement('b')).toBe('left');
107
+ expect(() => docks.setPlacement('a', 'nowhere' as never)).toThrow('invalid dock placement');
108
+ });
109
+
110
+ test('closeAll closes everything but keeps the staged token', () => {
111
+ const docks = createDockManager();
112
+ docks.register('a', 'chat', 'left');
113
+ docks.register('b', 'config', 'right');
114
+ docks.openWithFocus('a', 'tok');
115
+ docks.open('b');
116
+ docks.closeAll();
117
+ expect(docks.openDocks()).toEqual([]);
118
+ expect(docks.takeFocusToken()).toBe('tok');
119
+ });
120
+ });
121
+
122
+ describe('focus token', () => {
123
+ test('openWithFocus stages, plain open preserves, take consumes once', () => {
124
+ const docks = createDockManager();
125
+ docks.register('a', 'chat', 'left');
126
+ expect(docks.takeFocusToken()).toBeNull();
127
+ docks.openWithFocus('a', 'first');
128
+ docks.open('a');
129
+ expect(docks.takeFocusToken()).toBe('first');
130
+ expect(docks.takeFocusToken()).toBeNull();
131
+ docks.openWithFocus('a', 'second');
132
+ docks.close('a');
133
+ expect(docks.takeFocusToken()).toBe('second');
134
+ });
135
+
136
+ test('empty tokens are rejected before lookup', () => {
137
+ const docks = createDockManager();
138
+ docks.register('a', 'chat', 'left');
139
+ expect(() => docks.openWithFocus('a', '')).toThrow('invalid focus token');
140
+ expect(() => docks.openWithFocus('ghost', '')).toThrow('invalid focus token');
141
+ expect(docks.takeFocusToken()).toBeNull();
142
+ });
143
+
144
+ test('managers are independent', () => {
145
+ const first = createDockManager();
146
+ const second = createDockManager();
147
+ first.register('a', 'chat', 'left');
148
+ second.register('a', 'chat', 'left');
149
+ first.openWithFocus('a', 'tok');
150
+ expect(second.isOpen('a')).toBe(false);
151
+ expect(second.takeFocusToken()).toBeNull();
152
+ });
153
+ });
154
+
155
+ describe('serialize/restore', () => {
156
+ test('round-trips open state and placement without the focus token', () => {
157
+ const docks = createDockManager();
158
+ docks.register('scope', 'config', 'left');
159
+ docks.register('models', 'config', 'right');
160
+ docks.openWithFocus('scope', 'tok');
161
+ docks.setPlacement('models', 'bottom');
162
+ const snapshot = docks.serialize();
163
+ expect(snapshot).not.toContain('tok');
164
+ const revived = createDockManager();
165
+ revived.restore(snapshot);
166
+ expect(revived.isOpen('scope')).toBe(true);
167
+ expect(revived.isOpen('models')).toBe(false);
168
+ expect(revived.placement('models')).toBe('bottom');
169
+ expect(revived.takeFocusToken()).toBeNull();
170
+ });
171
+
172
+ test('empty managers round-trip', () => {
173
+ const revived = createDockManager();
174
+ revived.restore(createDockManager().serialize());
175
+ expect(revived.openDocks()).toEqual([]);
176
+ });
177
+
178
+ test('malformed snapshots throw and leave state untouched', () => {
179
+ const malformed = [
180
+ 'not json',
181
+ '{}',
182
+ '[null]',
183
+ '["scope"]',
184
+ '[{}]',
185
+ '[{"id":"","kind":"config","placement":"left","open":false}]',
186
+ '[{"id":"UPPER","kind":"config","placement":"left","open":false}]',
187
+ '[{"id":"a","kind":"bogus","placement":"left","open":false}]',
188
+ '[{"id":"a","kind":"config","placement":"top","open":false}]',
189
+ '[{"id":"a","kind":"config","placement":"left"}]',
190
+ '[{"id":"a","kind":"config","placement":"left","open":"yes"}]',
191
+ '[{"id":"a","kind":"config","placement":"left","open":false},{"id":"a","kind":"chat","placement":"right","open":true}]',
192
+ ];
193
+ for (const snapshot of malformed) {
194
+ const docks = createDockManager();
195
+ docks.register('keep', 'chat', 'left');
196
+ docks.open('keep');
197
+ expect(() => docks.restore(snapshot)).toThrow(/^invalid dock snapshot/);
198
+ expect(docks.isOpen('keep')).toBe(true);
199
+ expect(docks.openDocks()).toEqual(['keep']);
200
+ }
201
+ });
202
+
203
+ test('restore replaces everything and clears the staged token', () => {
204
+ const docks = createDockManager();
205
+ docks.register('stale', 'chat', 'left');
206
+ docks.openWithFocus('stale', 'tok');
207
+ docks.restore('[{"id":"fresh","kind":"custom","placement":"inline","open":true}]');
208
+ expect(() => docks.isOpen('stale')).toThrow('UnknownDock: stale');
209
+ expect(docks.isOpen('fresh')).toBe(true);
210
+ expect(docks.takeFocusToken()).toBeNull();
211
+ });
212
+
213
+ test('error messages never carry focus tokens', () => {
214
+ const docks = createDockManager();
215
+ docks.register('a', 'chat', 'left');
216
+ docks.openWithFocus('a', 'secret-token');
217
+ for (const fn of [() => docks.open('ghost'), () => docks.toggle('ghost'), () => docks.restore('[1]')]) {
218
+ try {
219
+ fn();
220
+ expect.unreachable();
221
+ } catch (error) {
222
+ expect(String(error)).not.toContain('secret-token');
223
+ }
224
+ }
225
+ });
226
+ });
package/wasm/ccht_bg.wasm CHANGED
Binary file