experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

test(cmd): add unit and integration tests for state machine

Unit tests (76 tests, tests/unit/cmd-state-machine.test.js):
- State enum completeness and immutability
- All state transitions: IDLE, TYPING, RESULTS_OPEN, PARAM_MODE,
EXECUTING, OUTPUT_SELECTION, CHAIN_MODE, ERROR, CLOSING
- Guard evaluation and transition selection
- Escape layering: param -> typing -> idle -> closing
- IZUI handleEscape returns correct handled flag
- Invariant enforcement: index bounds, mutual exclusivity
- Machine reset clears all state
- Edge cases: rapid typing, unknown events, panel re-show

Playwright integration tests (tests/desktop/cmd-state-machine.spec.ts):
- IDLE -> TYPING on character input
- TYPING -> RESULTS_OPEN on ArrowDown
- TYPING -> PARAM_MODE on command+space
- TYPING -> EXECUTING on Enter with committed command
- Escape layering full sequence
- Double Enter blocked by executing guard
- Panel re-show resets state
- Backward-compatible _cmdState proxy
- Basic command execution flow
- Tab completion enters param mode
- URL opening
- Click on result executes via dispatch

+1280
+459
tests/desktop/cmd-state-machine.spec.ts
··· 1 + /** 2 + * Cmd State Machine Integration Tests 3 + * 4 + * Tests the state machine integration in the actual cmd panel UI. 5 + * Uses the shared app fixture pattern from smoke.spec.ts. 6 + * 7 + * Run with: yarn test:electron:bg (or yarn test:electron for foreground) 8 + */ 9 + 10 + import { test, expect, DesktopApp, getSharedApp, closeSharedApp } from '../fixtures/desktop-app'; 11 + import { Page } from '@playwright/test'; 12 + import { waitForCommandResults, waitForResultsWithContent, waitForExtensionsReady, waitForPanelCommandsLoaded, sleep } from '../helpers/window-utils'; 13 + 14 + // Shared app instance 15 + let sharedApp: DesktopApp; 16 + let sharedBgWindow: Page; 17 + 18 + test.beforeAll(async () => { 19 + sharedApp = await getSharedApp(); 20 + sharedBgWindow = await sharedApp.getBackgroundWindow(); 21 + await waitForExtensionsReady(sharedBgWindow); 22 + }); 23 + 24 + test.afterAll(async () => { 25 + await closeSharedApp(); 26 + }); 27 + 28 + /** 29 + * Helper: Open cmd panel and return the cmd window page 30 + */ 31 + async function openCmdPanel(): Promise<{ cmdWindow: Page; windowId: number | null }> { 32 + const openResult = await sharedBgWindow.evaluate(async () => { 33 + return await (window as any).app.window.open('peek://ext/cmd/panel.html', { 34 + modal: true, 35 + width: 600, 36 + height: 50, 37 + frame: false, 38 + transparent: true, 39 + alwaysOnTop: true, 40 + center: true, 41 + keepLive: true, 42 + }); 43 + }); 44 + expect(openResult.success).toBe(true); 45 + 46 + const cmdWindow = await sharedApp.getWindow('cmd/panel.html', 5000); 47 + expect(cmdWindow).toBeTruthy(); 48 + 49 + await cmdWindow.waitForSelector('input', { timeout: 5000 }); 50 + await waitForPanelCommandsLoaded(cmdWindow, 10000); 51 + 52 + return { cmdWindow, windowId: openResult.id || null }; 53 + } 54 + 55 + /** 56 + * Helper: Close cmd panel 57 + */ 58 + async function closeCmdPanel(windowId: number | null) { 59 + try { 60 + if (windowId) { 61 + await sharedBgWindow.evaluate(async (id: number) => { 62 + return await (window as any).app.window.close(id); 63 + }, windowId); 64 + } 65 + } catch { 66 + // Panel may have already closed via shutdown() 67 + } 68 + } 69 + 70 + /** 71 + * Helper: Get current state machine state from cmd window 72 + */ 73 + async function getState(cmdWindow: Page): Promise<string> { 74 + return await cmdWindow.evaluate(() => (window as any)._cmdState.currentState); 75 + } 76 + 77 + /** 78 + * Helper: Wait for a specific state machine state 79 + */ 80 + async function waitForState(cmdWindow: Page, expectedState: string, timeout = 5000) { 81 + await cmdWindow.waitForFunction( 82 + (state: string) => (window as any)._cmdState.currentState === state, 83 + expectedState, 84 + { timeout } 85 + ); 86 + } 87 + 88 + // ============================================================================ 89 + // State Transition Tests 90 + // ============================================================================ 91 + 92 + test.describe('Cmd State Machine @desktop', () => { 93 + 94 + test('IDLE -> TYPING on character input', async () => { 95 + const { cmdWindow, windowId } = await openCmdPanel(); 96 + 97 + try { 98 + // Should start in IDLE 99 + const initialState = await getState(cmdWindow); 100 + expect(initialState).toBe('IDLE'); 101 + 102 + // Type a character 103 + await cmdWindow.fill('input', 's'); 104 + await waitForState(cmdWindow, 'TYPING'); 105 + 106 + const stateAfterTyping = await getState(cmdWindow); 107 + expect(stateAfterTyping).toBe('TYPING'); 108 + } finally { 109 + await closeCmdPanel(windowId); 110 + } 111 + }); 112 + 113 + test('TYPING -> RESULTS_OPEN on ArrowDown', async () => { 114 + const { cmdWindow, windowId } = await openCmdPanel(); 115 + 116 + try { 117 + // Type to get matches 118 + await cmdWindow.fill('input', 'settings'); 119 + await waitForState(cmdWindow, 'TYPING'); 120 + 121 + // Wait for commands to match 122 + await cmdWindow.waitForFunction( 123 + () => (window as any)._cmdState.matches && (window as any)._cmdState.matches.length > 0, 124 + undefined, 125 + { timeout: 10000 } 126 + ); 127 + 128 + // ArrowDown to open results 129 + await cmdWindow.keyboard.press('ArrowDown'); 130 + await waitForState(cmdWindow, 'RESULTS_OPEN'); 131 + 132 + // Results should be visible 133 + const resultsVisible = await cmdWindow.$eval('#results', (el: any) => el.classList.contains('visible')); 134 + expect(resultsVisible).toBe(true); 135 + } finally { 136 + await closeCmdPanel(windowId); 137 + } 138 + }); 139 + 140 + test('TYPING -> PARAM_MODE when typing command with params + space', async () => { 141 + const { cmdWindow, windowId } = await openCmdPanel(); 142 + 143 + try { 144 + // Type "edit " to commit to edit command and enter param mode 145 + await cmdWindow.fill('input', 'edit '); 146 + 147 + // Wait for param mode 148 + await cmdWindow.waitForFunction( 149 + () => { 150 + const state = (window as any)._cmdState; 151 + return state && state.paramMode === true && state.paramCommand === 'edit'; 152 + }, 153 + undefined, 154 + { timeout: 10000 } 155 + ); 156 + 157 + const currentState = await getState(cmdWindow); 158 + expect(currentState).toBe('PARAM_MODE'); 159 + } finally { 160 + await closeCmdPanel(windowId); 161 + } 162 + }); 163 + 164 + test('TYPING -> EXECUTING on Enter with committed command', async () => { 165 + const { cmdWindow, windowId } = await openCmdPanel(); 166 + 167 + try { 168 + // Type a command and show results to commit 169 + await cmdWindow.fill('input', 'settings'); 170 + await cmdWindow.waitForFunction( 171 + () => (window as any)._cmdState.matches && (window as any)._cmdState.matches.length > 0, 172 + undefined, 173 + { timeout: 10000 } 174 + ); 175 + await cmdWindow.keyboard.press('ArrowDown'); 176 + await waitForState(cmdWindow, 'RESULTS_OPEN'); 177 + 178 + // Press Enter to execute 179 + await cmdWindow.keyboard.press('Enter'); 180 + 181 + // Should transition through EXECUTING to CLOSING 182 + // (settings command completes quickly) 183 + await cmdWindow.waitForFunction( 184 + () => { 185 + const state = (window as any)._cmdState.currentState; 186 + return state === 'EXECUTING' || state === 'CLOSING'; 187 + }, 188 + undefined, 189 + { timeout: 10000 } 190 + ); 191 + } finally { 192 + await closeCmdPanel(windowId); 193 + } 194 + }); 195 + 196 + test('Escape layering: param mode -> clear text -> close', async () => { 197 + const { cmdWindow, windowId } = await openCmdPanel(); 198 + 199 + try { 200 + // Enter param mode 201 + await cmdWindow.fill('input', 'edit '); 202 + await cmdWindow.waitForFunction( 203 + () => (window as any)._cmdState.paramMode === true, 204 + undefined, 205 + { timeout: 10000 } 206 + ); 207 + 208 + // Escape 1: exit param mode -> TYPING 209 + await cmdWindow.keyboard.press('Escape'); 210 + await waitForState(cmdWindow, 'TYPING'); 211 + 212 + // Verify param mode exited 213 + const paramMode = await cmdWindow.evaluate(() => (window as any)._cmdState.paramMode); 214 + expect(paramMode).toBe(false); 215 + 216 + // Escape 2: clear text -> IDLE 217 + await cmdWindow.keyboard.press('Escape'); 218 + await waitForState(cmdWindow, 'IDLE'); 219 + 220 + // Verify input cleared 221 + const inputValue = await cmdWindow.$eval('input', (el: any) => el.value); 222 + expect(inputValue).toBe(''); 223 + 224 + // Escape 3: close panel -> CLOSING 225 + await cmdWindow.keyboard.press('Escape'); 226 + await waitForState(cmdWindow, 'CLOSING'); 227 + } finally { 228 + await closeCmdPanel(windowId); 229 + } 230 + }); 231 + 232 + test('double Enter is blocked by executing guard', async () => { 233 + const { cmdWindow, windowId } = await openCmdPanel(); 234 + 235 + try { 236 + // Type a command 237 + await cmdWindow.fill('input', 'settings'); 238 + await cmdWindow.waitForFunction( 239 + () => (window as any)._cmdState.matches && (window as any)._cmdState.matches.length > 0, 240 + undefined, 241 + { timeout: 10000 } 242 + ); 243 + 244 + // Show results and press Enter twice quickly 245 + await cmdWindow.keyboard.press('ArrowDown'); 246 + await waitForState(cmdWindow, 'RESULTS_OPEN'); 247 + 248 + // First Enter triggers execution 249 + await cmdWindow.keyboard.press('Enter'); 250 + // Second Enter should be blocked 251 + await cmdWindow.keyboard.press('Enter'); 252 + 253 + // The second Enter should not cause issues — we should still be in 254 + // EXECUTING or have transitioned to CLOSING normally 255 + await cmdWindow.waitForFunction( 256 + () => { 257 + const state = (window as any)._cmdState.currentState; 258 + return state === 'EXECUTING' || state === 'CLOSING' || state === 'IDLE'; 259 + }, 260 + undefined, 261 + { timeout: 10000 } 262 + ); 263 + } finally { 264 + await closeCmdPanel(windowId); 265 + } 266 + }); 267 + 268 + test('panel re-show resets state', async () => { 269 + const { cmdWindow, windowId } = await openCmdPanel(); 270 + 271 + try { 272 + // Type something to get into TYPING state 273 + await cmdWindow.fill('input', 'test'); 274 + await waitForState(cmdWindow, 'TYPING'); 275 + 276 + // Close and reopen panel (simulates hide/show cycle) 277 + await cmdWindow.evaluate(() => window.close()); 278 + await sleep(200); 279 + 280 + // Reopen 281 + const reopenResult = await sharedBgWindow.evaluate(async () => { 282 + return await (window as any).app.window.open('peek://ext/cmd/panel.html', { 283 + modal: true, 284 + width: 600, 285 + height: 50, 286 + frame: false, 287 + transparent: true, 288 + alwaysOnTop: true, 289 + center: true, 290 + keepLive: true, 291 + }); 292 + }); 293 + 294 + const cmdWindow2 = await sharedApp.getWindow('cmd/panel.html', 5000); 295 + await cmdWindow2.waitForSelector('input', { timeout: 5000 }); 296 + 297 + // Should be reset to IDLE 298 + await waitForState(cmdWindow2, 'IDLE'); 299 + 300 + // Input should be empty 301 + const inputValue = await cmdWindow2.$eval('input', (el: any) => el.value); 302 + expect(inputValue).toBe(''); 303 + 304 + await closeCmdPanel(reopenResult.id); 305 + } finally { 306 + // Original panel already closed 307 + } 308 + }); 309 + 310 + test('backward-compatible _cmdState proxy works', async () => { 311 + const { cmdWindow, windowId } = await openCmdPanel(); 312 + 313 + try { 314 + // Test boolean accessors 315 + const proxyResult = await cmdWindow.evaluate(() => { 316 + const s = (window as any)._cmdState; 317 + return { 318 + paramMode: s.paramMode, 319 + outputSelectionMode: s.outputSelectionMode, 320 + chainMode: s.chainMode, 321 + executing: s.executing, 322 + currentState: s.currentState, 323 + }; 324 + }); 325 + 326 + expect(proxyResult.paramMode).toBe(false); 327 + expect(proxyResult.outputSelectionMode).toBe(false); 328 + expect(proxyResult.chainMode).toBe(false); 329 + expect(proxyResult.executing).toBe(false); 330 + expect(proxyResult.currentState).toBe('IDLE'); 331 + } finally { 332 + await closeCmdPanel(windowId); 333 + } 334 + }); 335 + 336 + test('basic command execution: type -> ArrowDown -> Enter -> panel closes', async () => { 337 + const { cmdWindow, windowId } = await openCmdPanel(); 338 + 339 + try { 340 + // Type a command 341 + await cmdWindow.fill('input', 'settings'); 342 + await cmdWindow.waitForFunction( 343 + () => (window as any)._cmdState.matches && (window as any)._cmdState.matches.length > 0, 344 + undefined, 345 + { timeout: 10000 } 346 + ); 347 + 348 + // ArrowDown to show results 349 + await cmdWindow.keyboard.press('ArrowDown'); 350 + await waitForState(cmdWindow, 'RESULTS_OPEN'); 351 + 352 + // Enter to execute 353 + await cmdWindow.keyboard.press('Enter'); 354 + 355 + // Should end up in CLOSING or already closed 356 + await cmdWindow.waitForFunction( 357 + () => { 358 + const state = (window as any)._cmdState.currentState; 359 + return state === 'CLOSING' || state === 'EXECUTING'; 360 + }, 361 + undefined, 362 + { timeout: 10000 } 363 + ); 364 + } finally { 365 + await closeCmdPanel(windowId); 366 + } 367 + }); 368 + 369 + test('Tab completion enters param mode for commands with params', async () => { 370 + const { cmdWindow, windowId } = await openCmdPanel(); 371 + 372 + try { 373 + // Type partial command name 374 + await cmdWindow.fill('input', 'edi'); 375 + await cmdWindow.waitForFunction( 376 + () => { 377 + const state = (window as any)._cmdState; 378 + return state.matches && state.matches.length > 0 && state.matches.some((m: string) => m === 'edit'); 379 + }, 380 + undefined, 381 + { timeout: 10000 } 382 + ); 383 + 384 + // Tab to complete — should enter param mode since edit has params 385 + await cmdWindow.keyboard.press('Tab'); 386 + 387 + // Wait for param mode to be entered 388 + await cmdWindow.waitForFunction( 389 + () => (window as any)._cmdState.paramMode === true, 390 + undefined, 391 + { timeout: 5000 } 392 + ); 393 + 394 + const state = await getState(cmdWindow); 395 + expect(state).toBe('PARAM_MODE'); 396 + 397 + // Input should be "edit " (completed with space) 398 + const inputValue = await cmdWindow.$eval('input', (el: any) => el.value); 399 + expect(inputValue).toMatch(/^edit\s/i); 400 + } finally { 401 + await closeCmdPanel(windowId); 402 + } 403 + }); 404 + 405 + test('URL opening: type URL -> Enter -> panel closes', async () => { 406 + const { cmdWindow, windowId } = await openCmdPanel(); 407 + 408 + try { 409 + // Type a URL 410 + await cmdWindow.fill('input', 'https://example.com'); 411 + await waitForState(cmdWindow, 'TYPING'); 412 + 413 + // Enter should detect URL and close 414 + await cmdWindow.keyboard.press('Enter'); 415 + 416 + // Should transition to CLOSING 417 + await cmdWindow.waitForFunction( 418 + () => (window as any)._cmdState.currentState === 'CLOSING', 419 + undefined, 420 + { timeout: 5000 } 421 + ); 422 + } finally { 423 + await closeCmdPanel(windowId); 424 + } 425 + }); 426 + 427 + test('click on result item executes via dispatch (no duplicate logic)', async () => { 428 + const { cmdWindow, windowId } = await openCmdPanel(); 429 + 430 + try { 431 + // Type to get matches 432 + await cmdWindow.fill('input', 'settings'); 433 + await cmdWindow.waitForFunction( 434 + () => (window as any)._cmdState.matches && (window as any)._cmdState.matches.length > 0, 435 + undefined, 436 + { timeout: 10000 } 437 + ); 438 + 439 + // Show results 440 + await cmdWindow.keyboard.press('ArrowDown'); 441 + await waitForState(cmdWindow, 'RESULTS_OPEN'); 442 + 443 + // Click first result 444 + await cmdWindow.click('.command-item'); 445 + 446 + // Should transition through EXECUTING 447 + await cmdWindow.waitForFunction( 448 + () => { 449 + const state = (window as any)._cmdState.currentState; 450 + return state === 'EXECUTING' || state === 'CLOSING'; 451 + }, 452 + undefined, 453 + { timeout: 10000 } 454 + ); 455 + } finally { 456 + await closeCmdPanel(windowId); 457 + } 458 + }); 459 + });
+821
tests/unit/cmd-state-machine.test.js
··· 1 + /** 2 + * Unit tests for the cmd panel state machine module. 3 + * 4 + * Tests the transition table and dispatch logic without DOM/IPC dependencies. 5 + * Run via: node --test tests/unit/cmd-state-machine.test.js 6 + */ 7 + import { describe, it, beforeEach } from 'node:test'; 8 + import { strict as assert } from 'node:assert'; 9 + import { join, dirname } from 'path'; 10 + import { fileURLToPath } from 'url'; 11 + 12 + const __dirname = dirname(fileURLToPath(import.meta.url)); 13 + const modulePath = join(__dirname, '..', '..', 'extensions', 'cmd', 'state-machine.js'); 14 + 15 + const { createStateMachine, States, Events } = await import(modulePath); 16 + 17 + // Helper: create a machine with minimal functional actions and default guards. 18 + // Most actions are no-ops (just logging), but navigation actions need to 19 + // actually modify state to be testable. 20 + function makeMachine(overrideActions = {}, overrideGuards = {}) { 21 + const actionLog = []; 22 + 23 + // Actions that must actually mutate state for tests to work 24 + const functionalActions = { 25 + navigateDown(payload, data) { data.matchIndex++; }, 26 + navigateUp(payload, data) { data.matchIndex--; }, 27 + navigateParamDown(payload, data) { data.paramIndex++; }, 28 + navigateParamUp(payload, data) { data.paramIndex--; }, 29 + navigateOutputDown(payload, data) { data.outputItemIndex++; }, 30 + navigateOutputUp(payload, data) { data.outputItemIndex--; }, 31 + setMatchIndexFromClick(payload, data) { 32 + if (payload?.index !== undefined) data.matchIndex = payload.index; 33 + }, 34 + setParamIndexFromClick(payload, data) { 35 + if (payload?.index !== undefined) data.paramIndex = payload.index; 36 + }, 37 + setOutputIndexFromClick(payload, data) { 38 + if (payload?.index !== undefined) data.outputItemIndex = payload.index; 39 + }, 40 + setTyped(payload, data) { 41 + if (payload?.value !== undefined) data.typed = payload.value; 42 + }, 43 + clearInput(payload, data) { 44 + data.typed = ''; 45 + data.matches = []; 46 + data.matchIndex = 0; 47 + }, 48 + clearMatches(payload, data) { 49 + data.matches = []; 50 + data.matchIndex = 0; 51 + }, 52 + showResults(payload, data) { 53 + data.showResults = true; 54 + }, 55 + hideResults(payload, data) { 56 + data.showResults = false; 57 + }, 58 + exitParamMode(payload, data) { 59 + data.paramCommand = null; 60 + data.paramSuggestions = []; 61 + data.paramIndex = -1; 62 + }, 63 + enterParamMode(payload, data) { 64 + const cmdName = payload?.paramCommandName || (data.matches.length > 0 ? data.matches[data.matchIndex] : null); 65 + if (cmdName) { 66 + data.paramCommand = cmdName; 67 + data.paramSuggestions = []; 68 + data.paramIndex = -1; 69 + } 70 + }, 71 + }; 72 + 73 + const actions = new Proxy({ ...functionalActions, ...overrideActions }, { 74 + get(target, prop) { 75 + if (prop in target) return (...args) => { 76 + actionLog.push(prop); 77 + return target[prop](...args); 78 + }; 79 + return (payload, data) => { 80 + actionLog.push(prop); 81 + }; 82 + } 83 + }); 84 + const machine = createStateMachine(actions, overrideGuards); 85 + machine._actionLog = actionLog; 86 + return machine; 87 + } 88 + 89 + // ===== State Enum Tests ===== 90 + 91 + describe('States enum', () => { 92 + it('has all expected states', () => { 93 + assert.ok(States.IDLE); 94 + assert.ok(States.TYPING); 95 + assert.ok(States.RESULTS_OPEN); 96 + assert.ok(States.PARAM_MODE); 97 + assert.ok(States.EXECUTING); 98 + assert.ok(States.OUTPUT_SELECTION); 99 + assert.ok(States.CHAIN_MODE); 100 + assert.ok(States.CHAIN_POPUP); 101 + assert.ok(States.ERROR); 102 + assert.ok(States.CLOSING); 103 + }); 104 + 105 + it('states are frozen', () => { 106 + assert.throws(() => { States.NEW_STATE = 'NEW'; }, TypeError); 107 + }); 108 + }); 109 + 110 + // ===== Initial State ===== 111 + 112 + describe('Initial state', () => { 113 + it('starts in IDLE', () => { 114 + const machine = makeMachine(); 115 + assert.equal(machine.getState(), States.IDLE); 116 + }); 117 + 118 + it('has empty data', () => { 119 + const machine = makeMachine(); 120 + const data = machine.getData(); 121 + assert.equal(data.typed, ''); 122 + assert.equal(data.matches.length, 0); 123 + assert.equal(data.matchIndex, 0); 124 + }); 125 + }); 126 + 127 + // ===== IDLE Transitions ===== 128 + 129 + describe('IDLE transitions', () => { 130 + let machine; 131 + 132 + beforeEach(() => { 133 + machine = makeMachine(); 134 + }); 135 + 136 + it('IDLE + non-empty input -> TYPING', () => { 137 + const result = machine.dispatch(Events.INPUT, { value: 'tags' }); 138 + assert.equal(result.handled, true); 139 + assert.equal(machine.getState(), States.TYPING); 140 + }); 141 + 142 + it('IDLE + empty input -> stays IDLE (no transition)', () => { 143 + const result = machine.dispatch(Events.INPUT, { value: '' }); 144 + assert.equal(result.handled, false); 145 + assert.equal(machine.getState(), States.IDLE); 146 + }); 147 + 148 + it('IDLE + ArrowDown with matches -> RESULTS_OPEN', () => { 149 + machine.getMutableData().matches = ['test']; 150 + const result = machine.dispatch(Events.ARROW_DOWN); 151 + assert.equal(result.handled, true); 152 + assert.equal(machine.getState(), States.RESULTS_OPEN); 153 + }); 154 + 155 + it('IDLE + ArrowDown without matches -> stays IDLE', () => { 156 + const result = machine.dispatch(Events.ARROW_DOWN); 157 + assert.equal(result.handled, false); 158 + assert.equal(machine.getState(), States.IDLE); 159 + }); 160 + 161 + it('IDLE + Escape -> CLOSING', () => { 162 + const result = machine.dispatch(Events.ESCAPE); 163 + assert.equal(result.handled, true); 164 + assert.equal(machine.getState(), States.CLOSING); 165 + }); 166 + 167 + it('IDLE + Enter -> stays IDLE (no-op)', () => { 168 + const result = machine.dispatch(Events.ENTER); 169 + assert.equal(result.handled, false); 170 + assert.equal(machine.getState(), States.IDLE); 171 + }); 172 + 173 + it('IDLE + visibility:hidden -> CLOSING', () => { 174 + const result = machine.dispatch(Events.VISIBILITY_HIDDEN); 175 + assert.equal(result.handled, true); 176 + assert.equal(machine.getState(), States.CLOSING); 177 + }); 178 + }); 179 + 180 + // ===== TYPING Transitions ===== 181 + 182 + describe('TYPING transitions', () => { 183 + let machine; 184 + 185 + beforeEach(() => { 186 + machine = makeMachine(); 187 + machine.setState(States.TYPING); 188 + machine.getMutableData().typed = 'test'; 189 + }); 190 + 191 + it('TYPING + non-empty input -> TYPING', () => { 192 + const result = machine.dispatch(Events.INPUT, { value: 'testing' }); 193 + assert.equal(machine.getState(), States.TYPING); 194 + assert.equal(result.handled, true); 195 + }); 196 + 197 + it('TYPING + empty input -> IDLE', () => { 198 + const result = machine.dispatch(Events.INPUT, { value: '' }); 199 + assert.equal(machine.getState(), States.IDLE); 200 + }); 201 + 202 + it('TYPING + input with enterParamMode -> PARAM_MODE', () => { 203 + const result = machine.dispatch(Events.INPUT, { 204 + value: 'edit test', 205 + enterParamMode: true, 206 + paramCommandName: 'edit' 207 + }); 208 + assert.equal(machine.getState(), States.PARAM_MODE); 209 + }); 210 + 211 + it('TYPING + ArrowDown with matches -> RESULTS_OPEN', () => { 212 + machine.getMutableData().matches = ['test']; 213 + const result = machine.dispatch(Events.ARROW_DOWN); 214 + assert.equal(machine.getState(), States.RESULTS_OPEN); 215 + }); 216 + 217 + it('TYPING + Tab with params -> PARAM_MODE', () => { 218 + machine.getMutableData().matches = ['edit']; 219 + machine.getMutableData().commands = { 220 + edit: { name: 'edit', params: [{ type: 'item' }] } 221 + }; 222 + const result = machine.dispatch(Events.TAB, { 223 + commandName: 'edit', 224 + hasParams: true 225 + }); 226 + assert.equal(machine.getState(), States.PARAM_MODE); 227 + }); 228 + 229 + it('TYPING + Tab without params -> TYPING', () => { 230 + machine.getMutableData().matches = ['tags']; 231 + machine.getMutableData().commands = { tags: { name: 'tags' } }; 232 + const result = machine.dispatch(Events.TAB, { 233 + commandName: 'tags', 234 + hasParams: false 235 + }); 236 + assert.equal(machine.getState(), States.TYPING); 237 + }); 238 + 239 + it('TYPING + Enter with URL -> CLOSING', () => { 240 + const result = machine.dispatch(Events.ENTER, { 241 + value: 'https://example.com', 242 + isURL: true, 243 + committed: false 244 + }); 245 + assert.equal(machine.getState(), States.CLOSING); 246 + }); 247 + 248 + it('TYPING + Enter committed to command -> EXECUTING', () => { 249 + machine.getMutableData().matches = ['tags']; 250 + machine.getMutableData().commands = { tags: { name: 'tags' } }; 251 + const result = machine.dispatch(Events.ENTER, { 252 + value: 'tags', 253 + isURL: false, 254 + committed: true, 255 + commandName: 'tags' 256 + }); 257 + assert.equal(machine.getState(), States.EXECUTING); 258 + }); 259 + 260 + it('TYPING + Enter no match -> CLOSING (search)', () => { 261 + const result = machine.dispatch(Events.ENTER, { 262 + value: 'xyzzy', 263 + isURL: false, 264 + committed: false 265 + }); 266 + assert.equal(machine.getState(), States.CLOSING); 267 + }); 268 + 269 + it('TYPING + Escape with text -> IDLE', () => { 270 + machine.getMutableData().typed = 'something'; 271 + const result = machine.dispatch(Events.ESCAPE); 272 + assert.equal(machine.getState(), States.IDLE); 273 + }); 274 + 275 + it('TYPING + Escape with empty text -> CLOSING', () => { 276 + machine.getMutableData().typed = ''; 277 + const result = machine.dispatch(Events.ESCAPE); 278 + assert.equal(machine.getState(), States.CLOSING); 279 + }); 280 + }); 281 + 282 + // ===== RESULTS_OPEN Transitions ===== 283 + 284 + describe('RESULTS_OPEN transitions', () => { 285 + let machine; 286 + 287 + beforeEach(() => { 288 + machine = makeMachine(); 289 + machine.setState(States.RESULTS_OPEN); 290 + machine.getMutableData().matches = ['tags', 'tag', 'settings']; 291 + machine.getMutableData().matchIndex = 0; 292 + machine.getMutableData().showResults = true; 293 + }); 294 + 295 + it('RESULTS_OPEN + input -> TYPING', () => { 296 + machine.dispatch(Events.INPUT, { value: 'x' }); 297 + assert.equal(machine.getState(), States.TYPING); 298 + }); 299 + 300 + it('RESULTS_OPEN + ArrowDown -> RESULTS_OPEN (index incremented)', () => { 301 + machine.dispatch(Events.ARROW_DOWN); 302 + assert.equal(machine.getState(), States.RESULTS_OPEN); 303 + assert.equal(machine.getData().matchIndex, 1); 304 + }); 305 + 306 + it('RESULTS_OPEN + ArrowDown at end -> stays (no change)', () => { 307 + machine.getMutableData().matchIndex = 2; 308 + const result = machine.dispatch(Events.ARROW_DOWN); 309 + assert.equal(result.handled, false); 310 + assert.equal(machine.getData().matchIndex, 2); 311 + }); 312 + 313 + it('RESULTS_OPEN + ArrowUp -> RESULTS_OPEN (index decremented)', () => { 314 + machine.getMutableData().matchIndex = 1; 315 + machine.dispatch(Events.ARROW_UP); 316 + assert.equal(machine.getData().matchIndex, 0); 317 + }); 318 + 319 + it('RESULTS_OPEN + Enter -> EXECUTING', () => { 320 + machine.getMutableData().commands = { tags: { name: 'tags' } }; 321 + machine.dispatch(Events.ENTER, { 322 + value: 'tags', 323 + isURL: false, 324 + committed: true, 325 + commandName: 'tags' 326 + }); 327 + assert.equal(machine.getState(), States.EXECUTING); 328 + }); 329 + 330 + it('RESULTS_OPEN + click result -> EXECUTING', () => { 331 + machine.getMutableData().commands = { tags: { name: 'tags' } }; 332 + machine.dispatch(Events.CLICK_RESULT, { index: 0, commandName: 'tags' }); 333 + assert.equal(machine.getState(), States.EXECUTING); 334 + }); 335 + 336 + it('RESULTS_OPEN + Escape -> TYPING', () => { 337 + machine.dispatch(Events.ESCAPE); 338 + assert.equal(machine.getState(), States.TYPING); 339 + }); 340 + }); 341 + 342 + // ===== PARAM_MODE Transitions ===== 343 + 344 + describe('PARAM_MODE transitions', () => { 345 + let machine; 346 + 347 + beforeEach(() => { 348 + machine = makeMachine(); 349 + machine.setState(States.PARAM_MODE); 350 + machine.getMutableData().typed = 'edit test'; 351 + machine.getMutableData().paramCommand = 'edit'; 352 + machine.getMutableData().paramSuggestions = [ 353 + { title: 'Test Note', value: 'test-note', _item: { id: '123' } } 354 + ]; 355 + machine.getMutableData().paramIndex = 0; 356 + machine.getMutableData().commands = { 357 + edit: { name: 'edit', params: [{ type: 'item' }] } 358 + }; 359 + }); 360 + 361 + it('PARAM_MODE + input still matches -> PARAM_MODE', () => { 362 + machine.dispatch(Events.INPUT, { 363 + value: 'edit test note', 364 + paramStillMatches: true 365 + }); 366 + assert.equal(machine.getState(), States.PARAM_MODE); 367 + }); 368 + 369 + it('PARAM_MODE + input no longer matches -> TYPING', () => { 370 + machine.dispatch(Events.INPUT, { 371 + value: 'something else', 372 + paramStillMatches: false 373 + }); 374 + assert.equal(machine.getState(), States.TYPING); 375 + }); 376 + 377 + it('PARAM_MODE + empty input -> IDLE', () => { 378 + machine.dispatch(Events.INPUT, { value: '' }); 379 + assert.equal(machine.getState(), States.IDLE); 380 + }); 381 + 382 + it('PARAM_MODE + ArrowDown -> navigates param suggestions', () => { 383 + machine.getMutableData().paramSuggestions = [{ title: 'a' }, { title: 'b' }]; 384 + machine.getMutableData().paramIndex = 0; 385 + machine.dispatch(Events.ARROW_DOWN); 386 + assert.equal(machine.getState(), States.PARAM_MODE); 387 + // Invariant enforcement bounds the index 388 + assert.ok(machine.getData().paramIndex >= 0); 389 + }); 390 + 391 + it('PARAM_MODE + Tab -> fills suggestion (stays in PARAM_MODE)', () => { 392 + machine.dispatch(Events.TAB); 393 + assert.equal(machine.getState(), States.PARAM_MODE); 394 + }); 395 + 396 + it('PARAM_MODE + Enter with item-type param -> EXECUTING', () => { 397 + machine.dispatch(Events.ENTER); 398 + assert.equal(machine.getState(), States.EXECUTING); 399 + }); 400 + 401 + it('PARAM_MODE + Enter with non-item param -> EXECUTING', () => { 402 + machine.getMutableData().commands = { 403 + tag: { name: 'tag', params: [{ type: 'tag' }] } 404 + }; 405 + machine.getMutableData().paramCommand = 'tag'; 406 + machine.getMutableData().paramSuggestions = [{ title: 'todo', value: 'todo' }]; 407 + machine.dispatch(Events.ENTER); 408 + assert.equal(machine.getState(), States.EXECUTING); 409 + }); 410 + 411 + it('PARAM_MODE + Escape -> TYPING', () => { 412 + machine.dispatch(Events.ESCAPE); 413 + assert.equal(machine.getState(), States.TYPING); 414 + }); 415 + 416 + it('PARAM_MODE + click param -> EXECUTING', () => { 417 + machine.dispatch(Events.CLICK_PARAM, { index: 0 }); 418 + assert.equal(machine.getState(), States.EXECUTING); 419 + }); 420 + }); 421 + 422 + // ===== EXECUTING Transitions ===== 423 + 424 + describe('EXECUTING transitions', () => { 425 + let machine; 426 + 427 + beforeEach(() => { 428 + machine = makeMachine(); 429 + machine.setState(States.EXECUTING); 430 + }); 431 + 432 + it('EXECUTING blocks input events', () => { 433 + const result = machine.dispatch(Events.INPUT, { value: 'test' }); 434 + assert.equal(result.handled, false); 435 + assert.equal(machine.getState(), States.EXECUTING); 436 + }); 437 + 438 + it('EXECUTING blocks Enter (double-Enter guard)', () => { 439 + const result = machine.dispatch(Events.ENTER, { value: 'test', committed: true }); 440 + assert.equal(result.handled, false); 441 + assert.equal(machine.getState(), States.EXECUTING); 442 + }); 443 + 444 + it('EXECUTING blocks Tab', () => { 445 + const result = machine.dispatch(Events.TAB); 446 + assert.equal(result.handled, false); 447 + assert.equal(machine.getState(), States.EXECUTING); 448 + }); 449 + 450 + it('EXECUTING + command_complete (no output) -> CLOSING', () => { 451 + machine.dispatch(Events.COMMAND_COMPLETE, { result: {}, name: 'test' }); 452 + assert.equal(machine.getState(), States.CLOSING); 453 + }); 454 + 455 + it('EXECUTING + command_complete (item output, single) -> CLOSING', () => { 456 + machine.dispatch(Events.COMMAND_COMPLETE, { 457 + result: { output: { data: { id: '123' }, mimeType: 'item' } }, 458 + name: 'edit' 459 + }); 460 + assert.equal(machine.getState(), States.CLOSING); 461 + }); 462 + 463 + it('EXECUTING + command_complete (item output, array) -> OUTPUT_SELECTION', () => { 464 + machine.dispatch(Events.COMMAND_COMPLETE, { 465 + result: { output: { data: [{ id: '1' }, { id: '2' }], mimeType: 'item' } }, 466 + name: 'list' 467 + }); 468 + assert.equal(machine.getState(), States.OUTPUT_SELECTION); 469 + }); 470 + 471 + it('EXECUTING + command_complete (chainable output, has downstream) -> CHAIN_MODE', () => { 472 + machine.dispatch(Events.COMMAND_COMPLETE, { 473 + result: { output: { data: [{ id: '1' }], mimeType: 'application/json' } }, 474 + name: 'list', 475 + hasDownstream: true 476 + }); 477 + assert.equal(machine.getState(), States.CHAIN_MODE); 478 + }); 479 + 480 + it('EXECUTING + command_complete (array, no downstream) -> OUTPUT_SELECTION', () => { 481 + machine.dispatch(Events.COMMAND_COMPLETE, { 482 + result: { output: { data: [{ id: '1' }], mimeType: 'application/json' } }, 483 + name: 'list', 484 + hasDownstream: false 485 + }); 486 + assert.equal(machine.getState(), States.OUTPUT_SELECTION); 487 + }); 488 + 489 + it('EXECUTING + command_complete (single chainable) -> CHAIN_MODE', () => { 490 + machine.dispatch(Events.COMMAND_COMPLETE, { 491 + result: { output: { data: 'some text', mimeType: 'text/plain' } }, 492 + name: 'export', 493 + hasDownstream: true 494 + }); 495 + assert.equal(machine.getState(), States.CHAIN_MODE); 496 + }); 497 + 498 + it('EXECUTING + command_complete (action=prompt) -> IDLE', () => { 499 + machine.dispatch(Events.COMMAND_COMPLETE, { 500 + result: { action: 'prompt' }, 501 + name: 'test' 502 + }); 503 + assert.equal(machine.getState(), States.IDLE); 504 + }); 505 + 506 + it('EXECUTING + command_error -> ERROR', () => { 507 + machine.dispatch(Events.COMMAND_ERROR, { name: 'test', error: 'fail' }); 508 + assert.equal(machine.getState(), States.ERROR); 509 + }); 510 + 511 + it('EXECUTING + command_timeout -> ERROR', () => { 512 + machine.dispatch(Events.COMMAND_TIMEOUT, { name: 'test', error: 'timeout' }); 513 + assert.equal(machine.getState(), States.ERROR); 514 + }); 515 + 516 + it('EXECUTING + cancel_click -> IDLE', () => { 517 + machine.dispatch(Events.CLICK_CANCEL); 518 + assert.equal(machine.getState(), States.IDLE); 519 + }); 520 + 521 + it('EXECUTING + Escape -> IDLE', () => { 522 + machine.dispatch(Events.ESCAPE); 523 + assert.equal(machine.getState(), States.IDLE); 524 + }); 525 + }); 526 + 527 + // ===== OUTPUT_SELECTION Transitions ===== 528 + 529 + describe('OUTPUT_SELECTION transitions', () => { 530 + let machine; 531 + 532 + beforeEach(() => { 533 + machine = makeMachine(); 534 + machine.setState(States.OUTPUT_SELECTION); 535 + machine.getMutableData().outputItems = [{ id: '1' }, { id: '2' }, { id: '3' }]; 536 + machine.getMutableData().outputItemIndex = 0; 537 + machine.getMutableData().outputMimeType = 'item'; 538 + }); 539 + 540 + it('OUTPUT_SELECTION + ArrowDown -> navigates down', () => { 541 + machine.dispatch(Events.ARROW_DOWN); 542 + assert.equal(machine.getData().outputItemIndex, 1); 543 + }); 544 + 545 + it('OUTPUT_SELECTION + ArrowUp at 0 -> no change', () => { 546 + const result = machine.dispatch(Events.ARROW_UP); 547 + assert.equal(result.handled, false); 548 + }); 549 + 550 + it('OUTPUT_SELECTION + Escape -> IDLE', () => { 551 + machine.dispatch(Events.ESCAPE); 552 + assert.equal(machine.getState(), States.IDLE); 553 + }); 554 + }); 555 + 556 + // ===== CHAIN_MODE Transitions ===== 557 + 558 + describe('CHAIN_MODE transitions', () => { 559 + let machine; 560 + 561 + beforeEach(() => { 562 + machine = makeMachine(); 563 + machine.setState(States.CHAIN_MODE); 564 + machine.getMutableData().chainContext = { 565 + data: 'test', 566 + mimeType: 'text/plain', 567 + title: 'Test', 568 + sourceCommand: 'export' 569 + }; 570 + machine.getMutableData().chainStack = [ 571 + { data: 'test', mimeType: 'text/plain', title: 'Test', sourceCommand: 'export' } 572 + ]; 573 + machine.getMutableData().matches = ['format']; 574 + machine.getMutableData().matchIndex = 0; 575 + }); 576 + 577 + it('CHAIN_MODE + input -> stays CHAIN_MODE', () => { 578 + machine.dispatch(Events.INPUT, { value: 'form' }); 579 + assert.equal(machine.getState(), States.CHAIN_MODE); 580 + }); 581 + 582 + it('CHAIN_MODE + Enter with matches -> EXECUTING', () => { 583 + machine.dispatch(Events.ENTER, { value: 'format', committed: true }); 584 + assert.equal(machine.getState(), States.EXECUTING); 585 + }); 586 + 587 + it('CHAIN_MODE + Escape with shallow stack -> IDLE', () => { 588 + machine.dispatch(Events.ESCAPE); 589 + assert.equal(machine.getState(), States.IDLE); 590 + }); 591 + 592 + it('CHAIN_MODE + Escape with deep stack -> CHAIN_MODE (undo)', () => { 593 + machine.getMutableData().chainStack.push({ 594 + data: 'more', 595 + mimeType: 'text/plain', 596 + title: 'More', 597 + sourceCommand: 'format' 598 + }); 599 + machine.dispatch(Events.ESCAPE); 600 + assert.equal(machine.getState(), States.CHAIN_MODE); 601 + }); 602 + 603 + it('CHAIN_MODE + chain_cancel_click -> IDLE', () => { 604 + machine.dispatch(Events.CLICK_CHAIN_CANCEL); 605 + assert.equal(machine.getState(), States.IDLE); 606 + }); 607 + 608 + it('CHAIN_MODE + popup_result (done) -> CLOSING', () => { 609 + machine.dispatch(Events.POPUP_RESULT, { done: true }); 610 + assert.equal(machine.getState(), States.CLOSING); 611 + }); 612 + 613 + it('CHAIN_MODE + popup_result (data) -> CHAIN_MODE', () => { 614 + machine.dispatch(Events.POPUP_RESULT, { done: false, data: 'edited' }); 615 + assert.equal(machine.getState(), States.CHAIN_MODE); 616 + }); 617 + }); 618 + 619 + // ===== ERROR Transitions ===== 620 + 621 + describe('ERROR transitions', () => { 622 + let machine; 623 + 624 + beforeEach(() => { 625 + machine = makeMachine(); 626 + machine.setState(States.ERROR); 627 + }); 628 + 629 + it('ERROR + error_timeout -> IDLE', () => { 630 + machine.dispatch(Events.ERROR_TIMEOUT); 631 + assert.equal(machine.getState(), States.IDLE); 632 + }); 633 + 634 + it('ERROR + input -> TYPING', () => { 635 + machine.dispatch(Events.INPUT, { value: 'test' }); 636 + assert.equal(machine.getState(), States.TYPING); 637 + }); 638 + 639 + it('ERROR + Escape -> IDLE', () => { 640 + machine.dispatch(Events.ESCAPE); 641 + assert.equal(machine.getState(), States.IDLE); 642 + }); 643 + }); 644 + 645 + // ===== CLOSING Transitions ===== 646 + 647 + describe('CLOSING transitions', () => { 648 + let machine; 649 + 650 + beforeEach(() => { 651 + machine = makeMachine(); 652 + machine.setState(States.CLOSING); 653 + }); 654 + 655 + it('CLOSING + visibility:shown -> IDLE (reset)', () => { 656 + machine.dispatch(Events.VISIBILITY_SHOWN); 657 + assert.equal(machine.getState(), States.IDLE); 658 + }); 659 + 660 + it('CLOSING absorbs other events', () => { 661 + const result = machine.dispatch(Events.INPUT, { value: 'test' }); 662 + assert.equal(result.handled, false); 663 + assert.equal(machine.getState(), States.CLOSING); 664 + }); 665 + }); 666 + 667 + // ===== Escape Layering ===== 668 + 669 + describe('Escape layering', () => { 670 + it('full escape sequence: param -> typing -> idle -> closing', () => { 671 + const machine = makeMachine(); 672 + machine.setState(States.PARAM_MODE); 673 + machine.getMutableData().typed = 'edit test'; 674 + machine.getMutableData().paramCommand = 'edit'; 675 + 676 + // Escape 1: exit param mode -> TYPING 677 + machine.dispatch(Events.ESCAPE); 678 + assert.equal(machine.getState(), States.TYPING); 679 + 680 + // Escape 2: clear text -> IDLE (text is non-empty) 681 + machine.getMutableData().typed = 'edit test'; 682 + machine.dispatch(Events.ESCAPE); 683 + assert.equal(machine.getState(), States.IDLE); 684 + 685 + // Escape 3: close panel -> CLOSING (text is empty in IDLE) 686 + machine.dispatch(Events.ESCAPE); 687 + assert.equal(machine.getState(), States.CLOSING); 688 + }); 689 + 690 + it('IZUI handleEscape returns handled correctly', () => { 691 + const machine = makeMachine(); 692 + machine.setState(States.PARAM_MODE); 693 + machine.getMutableData().typed = 'edit test'; 694 + machine.getMutableData().paramCommand = 'edit'; 695 + 696 + // Should be handled (exits param mode) 697 + const r1 = machine.handleEscape(); 698 + assert.equal(r1.handled, true); 699 + assert.equal(machine.getState(), States.TYPING); 700 + 701 + // Should be handled (clears text) 702 + machine.getMutableData().typed = 'edit test'; 703 + const r2 = machine.handleEscape(); 704 + assert.equal(r2.handled, true); 705 + assert.equal(machine.getState(), States.IDLE); 706 + 707 + // Should NOT be handled (closing — let backend close window) 708 + const r3 = machine.handleEscape(); 709 + assert.equal(r3.handled, false); 710 + assert.equal(machine.getState(), States.CLOSING); 711 + }); 712 + }); 713 + 714 + // ===== Invariant Enforcement ===== 715 + 716 + describe('Invariant enforcement', () => { 717 + it('matchIndex stays in bounds after transition', () => { 718 + const machine = makeMachine(); 719 + machine.setState(States.TYPING); 720 + machine.getMutableData().matches = ['a', 'b']; 721 + machine.getMutableData().matchIndex = 5; // Out of bounds 722 + machine.getMutableData().typed = 'test'; 723 + 724 + // Dispatch anything to trigger invariant enforcement 725 + machine.dispatch(Events.INPUT, { value: 'x' }); 726 + assert.ok(machine.getData().matchIndex >= 0); 727 + assert.ok(machine.getData().matchIndex < machine.getData().matches.length || machine.getData().matches.length === 0); 728 + }); 729 + 730 + it('paramIndex resets when leaving PARAM_MODE', () => { 731 + const machine = makeMachine(); 732 + machine.setState(States.PARAM_MODE); 733 + machine.getMutableData().paramCommand = 'edit'; 734 + machine.getMutableData().paramSuggestions = [{ title: 'a' }]; 735 + machine.getMutableData().paramIndex = 0; 736 + machine.getMutableData().typed = 'edit test'; 737 + 738 + machine.dispatch(Events.ESCAPE); 739 + assert.equal(machine.getState(), States.TYPING); 740 + // After invariant enforcement, param data is cleared 741 + assert.equal(machine.getData().paramCommand, null); 742 + assert.equal(machine.getData().paramIndex, -1); 743 + }); 744 + }); 745 + 746 + // ===== Reset ===== 747 + 748 + describe('Machine reset', () => { 749 + it('reset() clears all state to defaults', () => { 750 + const machine = makeMachine(); 751 + machine.setState(States.CHAIN_MODE); 752 + machine.getMutableData().typed = 'test'; 753 + machine.getMutableData().chainContext = { data: 'x' }; 754 + machine.getMutableData().chainStack = [{ data: 'x' }]; 755 + machine.getMutableData().outputItems = [1, 2, 3]; 756 + 757 + machine.reset(); 758 + 759 + assert.equal(machine.getState(), States.IDLE); 760 + assert.equal(machine.getData().typed, ''); 761 + assert.equal(machine.getData().chainContext, null); 762 + assert.equal(machine.getData().chainStack.length, 0); 763 + assert.equal(machine.getData().outputItems.length, 0); 764 + assert.equal(machine.getData().paramCommand, null); 765 + }); 766 + }); 767 + 768 + // ===== Edge Cases ===== 769 + 770 + describe('Edge cases', () => { 771 + it('dispatching unknown event is not handled', () => { 772 + const machine = makeMachine(); 773 + const result = machine.dispatch('nonexistent_event'); 774 + assert.equal(result.handled, false); 775 + }); 776 + 777 + it('rapid typing does not break state', () => { 778 + const machine = makeMachine(); 779 + 780 + // Rapid input events 781 + machine.dispatch(Events.INPUT, { value: 't' }); 782 + assert.equal(machine.getState(), States.TYPING); 783 + 784 + machine.dispatch(Events.INPUT, { value: 'ta' }); 785 + assert.equal(machine.getState(), States.TYPING); 786 + 787 + machine.dispatch(Events.INPUT, { value: 'tag' }); 788 + assert.equal(machine.getState(), States.TYPING); 789 + 790 + machine.dispatch(Events.INPUT, { value: 'tags' }); 791 + assert.equal(machine.getState(), States.TYPING); 792 + 793 + // Clear 794 + machine.dispatch(Events.INPUT, { value: '' }); 795 + assert.equal(machine.getState(), States.IDLE); 796 + }); 797 + 798 + it('panel re-show resets state from any state', () => { 799 + const machine = makeMachine(); 800 + 801 + // Go to executing state 802 + machine.setState(States.EXECUTING); 803 + machine.getMutableData().typed = 'test'; 804 + 805 + // Panel hidden + shown cycle 806 + machine.setState(States.CLOSING); 807 + machine.dispatch(Events.VISIBILITY_SHOWN); 808 + 809 + assert.equal(machine.getState(), States.IDLE); 810 + }); 811 + 812 + it('is() helper works correctly', () => { 813 + const machine = makeMachine(); 814 + assert.equal(machine.is(States.IDLE), true); 815 + assert.equal(machine.is(States.TYPING), false); 816 + 817 + machine.setState(States.TYPING); 818 + assert.equal(machine.is(States.TYPING), true); 819 + assert.equal(machine.is(States.IDLE), false); 820 + }); 821 + });