diff --git a/.husky/pre-commit b/.husky/pre-commit index 3081b0d..dddf873 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -9,6 +9,11 @@ if [ "$BRANCH" = "main" ]; then exit 1 fi -npm run lint +# Run lint-staged (includes linting and formatting on changed files) +npx lint-staged + +# Run typecheck on the entire project npm run typecheck + +# Run tests with coverage to catch any coverage threshold violations npm run test:unit -- --coverage \ No newline at end of file diff --git a/.opencode/docs/DEVELOPMENT_GUIDELINES.md b/.opencode/docs/DEVELOPMENT_GUIDELINES.md index 07b3448..094e923 100644 --- a/.opencode/docs/DEVELOPMENT_GUIDELINES.md +++ b/.opencode/docs/DEVELOPMENT_GUIDELINES.md @@ -7,6 +7,29 @@ - **Pure Logic**: Keep timer logic in `TimerStateMachine` and verify with unit tests in `src/__tests__/`. - **Debugging**: Use `Shift+D` for fast-forwarding timers for quick testing of transitions. +## Testing Guidelines - AI-First DAMP Principles + +### Test Structure +- **DAMP over DRY**: Tests should be Descriptive And Meaningful, not overly DRY. Favor readability over code reuse. +- **AAA Pattern**: Structure tests with clear Arrange/Act/Assert phases using comments when helpful. +- **Factory Functions**: Use centralized factories from `src/__tests__/utils/factories.ts` for creating test data: + - `createMockState()` - For TimerState objects + - `createMockConfig()` - For TimerConfig objects + - `createMockSettings()` - For Settings objects + - `createQuickTestConfig()` - For unit tests with short durations + +### Mock Management +- **Centralized Setup**: Use utilities from `src/__tests__/utils/mocks.ts` for common mocking patterns. +- **Event Helpers**: Use `createKeyEvent()`, `createMouseEvent()`, and `createResizeEvent()` for input testing. +- **Audio Mocks**: Use `createMockChildProcess()` for audio player tests. +- **Top-Level Mocks**: Keep `jest.mock()` calls at the top level of test files for proper hoisting. + +### AI-First Considerations +- **Consistent Patterns**: Follow established patterns so AI tools can easily extend tests. +- **Clear Intent**: Make test purpose obvious through naming and structure. +- **Minimal Boilerplate**: Use utilities to reduce repetitive setup code. +- **Type Safety**: Ensure all test helpers are properly typed. + ## Debugging Workflow - When investigating CI/CD failures, always start by checking the output of the latest GitHub Actions run using the `gh` CLI tool before attempting to reproduce the issue locally. For example: `gh run view --log`. diff --git a/_plans/active/refactor-tests.md b/_plans/active/refactor-tests.md new file mode 100644 index 0000000..f0c6c4b --- /dev/null +++ b/_plans/active/refactor-tests.md @@ -0,0 +1,44 @@ +# Test Refactoring Strategy & Implementation Guide + +## Objective +Refactor the test suite in `src/__tests__/` to align with AI-first and DAMP (Descriptive and Meaningful Phrases) principles. The goal is to reduce file size, abstract repetitive setup/mocking logic, and preserve the readability of the "Act" and "Assert" phases. + +## Phase 1: Establish the Testing Utility Layer +Instead of repeating `jest.mock()` or setting up default state objects in every file, we will create a centralized utility layer. + +### 1. Create `src/__tests__/utils/factories.ts` +**Goal:** Abstract the creation of standard data objects. +**Implementation Steps:** +* Create a `createMockState(overrides?: Partial)` function to generate default state machine objects used by `stateMachine.test.ts` and `app.test.ts`[cite: 3]. +* Create a `createMockConfig(overrides?: Partial)` function to generate configurations used by `config.test.ts`[cite: 3]. + +### 2. Create `src/__tests__/utils/mocks.ts` +**Goal:** Abstract repetitive side-effect mocks (audio, console, timers). +**Implementation Steps:** +* Move `jest.mock()` calls for external dependencies (e.g., terminal UI renderers, file system access) here. +* Create helper functions like `setupAudioMocks()` to wrap the setup of dummy audio players used in `audio.test.ts` and `player.test.ts`[cite: 3]. +* Create `setupInputMocks()` to simulate stdin/mouse events for `input.test.ts` and `mouse.test.ts`[cite: 3]. + +## Phase 2: Refactor Core Test Suites + +### 1. Refactor Logic & State (`stateMachine.test.ts`, `app.test.ts`) +* **Action:** Replace manual state initialization in `beforeEach` or inside test blocks with the `createMockState()` factory. +* **Validation:** Ensure each test block clearly shows the *Arrange* (using the factory), *Act* (triggering a transition), and *Assert* (checking the new state). + +### 2. Refactor Side Effects (`audio.test.ts`, `player.test.ts`, `update.test.ts`) +* **Action:** Import and invoke `setupAudioMocks()` or `setupNetworkMocks()` at the top of the file. +* **Validation:** Remove all inline `jest.spyOn()` and `jest.mock()` boilerplate from these files[cite: 3] unless a test requires a highly specific, one-off override. + +### 3. Refactor UI & Input (`ui.test.ts`, `input.test.ts`, `mouse.test.ts`) +* **Action:** Utilize the new input mocks to simulate keystrokes or clicks. +* **Validation:** These files[cite: 3] should read like user stories (e.g., "When user presses space, timer pauses"), hiding the messy stream manipulation behind the utility layer. + +## Phase 3: Update AI Context Guidelines +To ensure AI tools (like GitHub Copilot or Gemini) maintain this new standard, update the `.opencode/docs/DEVELOPMENT_GUIDELINES.md`[cite: 3] file. + +**Add the following section to `DEVELOPMENT_GUIDELINES.md`:** +```markdown +### Writing Tests (AI-First DAMP Principles) +* **Do not duplicate setup logic:** Always use the factories and mocks located in `src/__tests__/utils/`. +* **Keep tests DAMP:** The logic inside `it()` or `test()` blocks must clearly show the Arrange, Act, and Assert steps. +* **Abstract Mocks, not Assertions:** Hide `jest.mock()` and boilerplate configuration in the utils folder, but keep assertions explicitly visible in the test file. diff --git a/jest.config.cjs b/jest.config.cjs index 8f46483..cf43e47 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -3,6 +3,7 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', testMatch: ['**/__tests__/**/*.test.ts'], + collectCoverage: !!process.env.CI || process.argv.includes('--coverage'), collectCoverageFrom: ['src/**/*.ts', '!src/cli.ts'], coverageThreshold: { global: { @@ -10,13 +11,24 @@ module.exports = { functions: 90, lines: 80, statements: 80 + }, + // Per-file thresholds to catch individual files with poor coverage + './src/**/!(*.test|*.spec|cli).ts': { + branches: 60, + functions: 60, + lines: 60, + statements: 60 } }, transform: { - '^.+\\.tsx?$': ['ts-jest', { useESM: true }] + '^.+\\.tsx?$': ['ts-jest', { useESM: true }], + '^.+\\.js$': ['ts-jest', { useESM: true }] }, extensionsToTreatAsEsm: ['.ts'], moduleNameMapper: { '^(\\.{1,2}/.*)\\.js$': '$1' - } + }, + transformIgnorePatterns: [ + 'node_modules/(?!(env-paths|is-safe-filename)/)' + ] }; diff --git a/package-lock.json b/package-lock.json index 2f93422..3177f4b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "globals": "^17.5.0", "husky": "^9.1.7", "jest": "^30.3.0", + "lint-staged": "^16.4.0", "node-pty": "^1.1.0", "prettier": "^3.8.3", "release-it": "^20.0.1", @@ -3946,6 +3947,40 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-truncate": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cli-width": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", @@ -4069,6 +4104,13 @@ "dev": true, "license": "MIT" }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, "node_modules/commander": { "version": "9.5.0", "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", @@ -4428,6 +4470,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -4800,6 +4855,13 @@ "url": "https://github.com/bgub/eta?sponsor=1" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, "node_modules/events-universal": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", @@ -6686,6 +6748,114 @@ "dev": true, "license": "MIT" }, + "node_modules/lint-staged": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.4.0.tgz", + "integrity": "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^14.0.3", + "listr2": "^9.0.5", + "picomatch": "^4.0.3", + "string-argv": "^0.3.2", + "tinyexec": "^1.0.4", + "yaml": "^2.8.2" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=20.17" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + } + }, + "node_modules/lint-staged/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/listr2": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", + "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^5.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/listr2/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/listr2/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -6768,6 +6938,131 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-update/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -8271,6 +8566,13 @@ "node": ">= 4" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, "node_modules/run-applescript": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", @@ -8418,6 +8720,52 @@ "node": ">=8" } }, + "node_modules/slice-ansi": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -8598,6 +8946,16 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -9594,6 +9952,22 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz", + "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "16.2.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", diff --git a/package.json b/package.json index 353c3bf..59b6335 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "globals": "^17.5.0", "husky": "^9.1.7", "jest": "^30.3.0", + "lint-staged": "^16.4.0", "node-pty": "^1.1.0", "prettier": "^3.8.3", "release-it": "^20.0.1", @@ -81,5 +82,11 @@ }, "browserslist": [ "maintained node versions" - ] + ], + "lint-staged": { + "src/**/*.{ts,tsx}": [ + "eslint --fix", + "prettier --write" + ] + } } diff --git a/src/__tests__/app.test.ts b/src/__tests__/app.test.ts index 17653f0..16322da 100644 --- a/src/__tests__/app.test.ts +++ b/src/__tests__/app.test.ts @@ -4,6 +4,19 @@ jest.mock('env-paths', () => { config: '/mock/config/path' }); }); + +// Mock dependencies +jest.mock('../stateMachine'); +jest.mock('../ui'); +jest.mock('../audio/player'); +jest.mock('../audio/synth'); +jest.mock('../constants'); +jest.mock('../input'); +jest.mock('../config'); +jest.mock('../update'); + +import { setupTimerMocks, setupProcessExitMock } from './utils/mocks'; +import { createMockState, createMockConfig, createMockSettings } from './utils/factories'; import { DoroApp } from '../app'; import { TimerStateMachine } from '../stateMachine'; import { DoroUi } from '../ui'; @@ -20,21 +33,9 @@ import { resolveControlCommand, isUpdatePromptEvent, isPromptConfirmEvent } from import { saveSettings, resetSettings, loadSettings } from '../config'; import { checkForUpdates, isCheckDue, shouldPromptForVersion, copyToClipboard } from '../update'; -// Mock dependencies -jest.mock('../stateMachine'); -jest.mock('../ui'); -jest.mock('../audio/player'); -jest.mock('../audio/synth'); -jest.mock('../constants'); -jest.mock('../input'); -jest.mock('../config'); -jest.mock('../update'); - -// Mock `process.exit` to prevent tests from terminating the process -const mockExit = jest.spyOn(process, 'exit').mockImplementation((() => {}) as never); - -// Mock `setInterval` and `clearInterval` -jest.useFakeTimers(); +// Setup timers and process mocks +const mockExit = setupProcessExitMock(); +setupTimerMocks(); let spySetInterval: jest.SpyInstance; let spyClearInterval: jest.SpyInstance; @@ -51,6 +52,11 @@ describe('DoroApp', () => { spySetInterval = jest.spyOn(global, 'setInterval'); spyClearInterval = jest.spyOn(global, 'clearInterval'); + // Arrange: Setup mock instances with factory data + const defaultState = createMockState(); + const defaultConfig = createMockConfig(); + const defaultSettings = createMockSettings(); + // Mock methods for TimerStateMachine instance mockTimerStateMachine = { startMode: jest.fn(), @@ -91,33 +97,13 @@ describe('DoroApp', () => { (createCompletionBeepClip as jest.Mock).mockReturnValue(Buffer.from('complete')); (createResetBeepClip as jest.Mock).mockReturnValue(Buffer.from('reset')); - // Default mock implementations for methods - mockTimerStateMachine.getState.mockReturnValue({ - mode: 'work', - status: 'paused', - remainingSeconds: 0, - isLocked: false, - switchPrompt: null, - completedWorkSessions: 0 - }); - mockTimerStateMachine.getConfig.mockReturnValue({ - workSeconds: 25 * 60, - shortRestSeconds: 5 * 60, - longRestSeconds: 15 * 60, - longRestEveryWorkSessions: 4, - switchConfirmSeconds: 5 - }); - (getDurationForMode as jest.Mock).mockReturnValue(25 * 60); // Default duration + // Default mock implementations for methods using factory data + mockTimerStateMachine.getState.mockReturnValue(defaultState); + mockTimerStateMachine.getConfig.mockReturnValue(defaultConfig); + (getDurationForMode as jest.Mock).mockReturnValue(defaultConfig.workSeconds); // Default duration mockTimerStateMachine.tick.mockReturnValue({ - state: { - mode: 'work', - status: 'running', - remainingSeconds: 10, - isLocked: false, - switchPrompt: null, - completedWorkSessions: 0 - }, + state: { ...defaultState, status: 'running', remainingSeconds: 10 }, startedPrompt: false, switchedRunning: false, switchedToMode: null, @@ -125,14 +111,8 @@ describe('DoroApp', () => { }); (saveSettings as jest.Mock).mockResolvedValue(undefined); - (resetSettings as jest.Mock).mockResolvedValue({ - volumeMode: 'normal', - colorScheme: 'modern' - }); - (loadSettings as jest.Mock).mockResolvedValue({ - volumeMode: 'normal', - colorScheme: 'modern' - }); + (resetSettings as jest.Mock).mockResolvedValue(defaultSettings); + (loadSettings as jest.Mock).mockResolvedValue(defaultSettings); (checkForUpdates as jest.Mock).mockResolvedValue({ isAvailable: false, currentVersion: '1.0.0' @@ -146,21 +126,11 @@ describe('DoroApp', () => { // Mock resetCurrentAndRun to return a valid result mockTimerStateMachine.resetCurrentAndRun.mockReturnValue({ - state: { - mode: 'work', - status: 'running', - remainingSeconds: 1500, - isLocked: false, - switchPrompt: null, - completedWorkSessions: 0 - }, + state: { ...defaultState, status: 'running', remainingSeconds: 1500 }, switchedToMode: 'work' }); - app = new DoroApp({ - volumeMode: 'normal', - colorScheme: 'modern' - }); + app = new DoroApp(defaultSettings); }); afterAll(() => { diff --git a/src/__tests__/input.test.ts b/src/__tests__/input.test.ts index b8be704..dfe2887 100644 --- a/src/__tests__/input.test.ts +++ b/src/__tests__/input.test.ts @@ -1,54 +1,37 @@ import { describe, expect, it } from '@jest/globals'; +import { createKeyEvent } from './utils/mocks'; import { - isAllowedWhenLocked, - isPromptConfirmEvent, - isUpdatePromptEvent, resolveControlCommand, - type InputEvent + isUpdatePromptEvent, + isPromptConfirmEvent, + isAllowedWhenLocked } from '../input'; -function keyEvent( - ch: string, - keyName: string, - keyFull = keyName, - shift = false, - ctrl = false -): InputEvent { - return { - type: 'key', - ch, - keyName, - keyFull, - shift, - ctrl - }; -} - describe('input mapping', () => { it('maps commands correctly', () => { - expect(resolveControlCommand(keyEvent('q', 'q'))).toBe('quit'); - expect(resolveControlCommand(keyEvent('p', 'p'))).toBe('pauseResume'); - expect(resolveControlCommand(keyEvent(' ', 'space'))).toBe('pauseResume'); - expect(resolveControlCommand(keyEvent('c', 'c'))).toBe('toggleColorScheme'); - expect(resolveControlCommand(keyEvent('m', 'm'))).toBe('toggleMute'); - expect(resolveControlCommand(keyEvent('D', 'd', 'S-d', true))).toBe('debugNearEnd'); - expect(resolveControlCommand(keyEvent('d', 'd'))).toBe('none'); - expect(resolveControlCommand(keyEvent('r', 'r'))).toBe('resetRun'); - expect(resolveControlCommand(keyEvent('R', 'r', 'S-r', true))).toBe('resetSettings'); - expect(resolveControlCommand(keyEvent('w', 'w'))).toBe('startWork'); - expect(resolveControlCommand(keyEvent('s', 's'))).toBe('startShort'); - expect(resolveControlCommand(keyEvent('l', 'l'))).toBe('startLong'); - expect(resolveControlCommand(keyEvent('L', 'l', 'S-l', true))).toBe('toggleLock'); - expect(resolveControlCommand(keyEvent('\u0003', 'c', 'C-c', false, true))).toBe('quit'); + expect(resolveControlCommand(createKeyEvent('q', 'q'))).toBe('quit'); + expect(resolveControlCommand(createKeyEvent('p', 'p'))).toBe('pauseResume'); + expect(resolveControlCommand(createKeyEvent(' ', 'space'))).toBe('pauseResume'); + expect(resolveControlCommand(createKeyEvent('c', 'c'))).toBe('toggleColorScheme'); + expect(resolveControlCommand(createKeyEvent('m', 'm'))).toBe('toggleMute'); + expect(resolveControlCommand(createKeyEvent('D', 'd', 'S-d', true))).toBe('debugNearEnd'); + expect(resolveControlCommand(createKeyEvent('d', 'd'))).toBe('none'); + expect(resolveControlCommand(createKeyEvent('r', 'r'))).toBe('resetRun'); + expect(resolveControlCommand(createKeyEvent('R', 'r', 'S-r', true))).toBe('resetSettings'); + expect(resolveControlCommand(createKeyEvent('w', 'w'))).toBe('startWork'); + expect(resolveControlCommand(createKeyEvent('s', 's'))).toBe('startShort'); + expect(resolveControlCommand(createKeyEvent('l', 'l'))).toBe('startLong'); + expect(resolveControlCommand(createKeyEvent('L', 'l', 'S-l', true))).toBe('toggleLock'); + expect(resolveControlCommand(createKeyEvent('\u0003', 'c', 'C-c', false, true))).toBe('quit'); }); it('maps update commands correctly', () => { - expect(resolveControlCommand(keyEvent('U', 'u', 'S-u', true))).toBe('checkUpdate'); - expect(resolveControlCommand(keyEvent('u', 'u'))).toBe('none'); - expect(resolveControlCommand(keyEvent('y', 'y'))).toBe('updateYes'); - expect(resolveControlCommand(keyEvent('Y', 'y'))).toBe('updateYes'); - expect(resolveControlCommand(keyEvent('n', 'n'))).toBe('updateNo'); - expect(resolveControlCommand(keyEvent('N', 'n'))).toBe('updateNo'); + expect(resolveControlCommand(createKeyEvent('U', 'u', 'S-u', true))).toBe('checkUpdate'); + expect(resolveControlCommand(createKeyEvent('u', 'u'))).toBe('none'); + expect(resolveControlCommand(createKeyEvent('y', 'y'))).toBe('updateYes'); + expect(resolveControlCommand(createKeyEvent('Y', 'y'))).toBe('updateYes'); + expect(resolveControlCommand(createKeyEvent('n', 'n'))).toBe('updateNo'); + expect(resolveControlCommand(createKeyEvent('N', 'n'))).toBe('updateNo'); }); it('allows only quit, pause, toggle lock, and update check when locked', () => { @@ -66,10 +49,10 @@ describe('input mapping', () => { }); it('treats any non-quit key and mouse as prompt confirm', () => { - expect(isPromptConfirmEvent(keyEvent('q', 'q'), 'quit')).toBe(false); - expect(isPromptConfirmEvent(keyEvent('p', 'p'), 'pauseResume')).toBe(true); + expect(isPromptConfirmEvent(createKeyEvent('q', 'q'), 'quit')).toBe(false); + expect(isPromptConfirmEvent(createKeyEvent('p', 'p'), 'pauseResume')).toBe(true); expect(isPromptConfirmEvent({ type: 'mouse' }, 'none')).toBe(true); - expect(isPromptConfirmEvent(keyEvent('D', 'd', 'S-d', true), 'debugNearEnd')).toBe(false); + expect(isPromptConfirmEvent(createKeyEvent('D', 'd', 'S-d', true), 'debugNearEnd')).toBe(false); }); it('identifies update prompt events correctly', () => { @@ -88,10 +71,10 @@ describe('input mapping', () => { const originalEnv = process.env.DORO_TEST_MODE; process.env.DORO_TEST_MODE = '1'; - expect(resolveControlCommand(keyEvent('1', '1'))).toBe('testUpdateAvailable'); - expect(resolveControlCommand(keyEvent('2', '2'))).toBe('testUpdateCopySuccess'); - expect(resolveControlCommand(keyEvent('3', '3'))).toBe('testUpdateCopyFallback'); - expect(resolveControlCommand(keyEvent('4', '4'))).toBe('testUpdateSkipped'); + expect(resolveControlCommand(createKeyEvent('1', '1'))).toBe('testUpdateAvailable'); + expect(resolveControlCommand(createKeyEvent('2', '2'))).toBe('testUpdateCopySuccess'); + expect(resolveControlCommand(createKeyEvent('3', '3'))).toBe('testUpdateCopyFallback'); + expect(resolveControlCommand(createKeyEvent('4', '4'))).toBe('testUpdateSkipped'); process.env.DORO_TEST_MODE = originalEnv; }); diff --git a/src/__tests__/mocks.test.ts b/src/__tests__/mocks.test.ts new file mode 100644 index 0000000..af87412 --- /dev/null +++ b/src/__tests__/mocks.test.ts @@ -0,0 +1,182 @@ +import { + createKeyEvent, + createMockChildProcess, + createMouseEvent, + createResizeEvent, + setupAllCommonMocks, + setupAppMocks, + setupAudioMocks, + setupChildProcessMocks, + setupEnvPathsMock, + setupFetchMock, + setupFsMocks, + setupInputMocks, + setupProcessExitMock, + setupTimerMocks +} from './utils/mocks'; + +describe('test mock helpers', () => { + const originalFetch = global.fetch; + const originalExit = process.exit; + const stdinDescriptor = Object.getOwnPropertyDescriptor(process, 'stdin'); + const stdoutDescriptor = Object.getOwnPropertyDescriptor(process, 'stdout'); + + afterEach(() => { + jest.useRealTimers(); + jest.resetModules(); + jest.clearAllMocks(); + jest.restoreAllMocks(); + + jest.unmock('env-paths'); + jest.unmock('node:fs'); + jest.unmock('node:child_process'); + jest.unmock('../stateMachine'); + jest.unmock('../ui'); + jest.unmock('../audio/player'); + jest.unmock('../audio/synth'); + jest.unmock('../constants'); + jest.unmock('../input'); + jest.unmock('../config'); + jest.unmock('../update'); + + if (stdinDescriptor) { + Object.defineProperty(process, 'stdin', stdinDescriptor); + } + + if (stdoutDescriptor) { + Object.defineProperty(process, 'stdout', stdoutDescriptor); + } + + process.exit = originalExit; + global.fetch = originalFetch; + }); + + it('mocks env-paths with a stable config directory', () => { + setupEnvPathsMock(); + + // eslint-disable-next-line @typescript-eslint/no-require-imports + const envPathsModule = require('env-paths'); + const envPaths = envPathsModule.default ?? envPathsModule; + + expect(jest.isMockFunction(envPaths)).toBe(true); + expect(envPaths('doro-cli')).toEqual({ config: '/mock/config/path' }); + }); + + it('mocks fs and child process modules', () => { + setupFsMocks(); + setupChildProcessMocks(); + + // eslint-disable-next-line @typescript-eslint/no-require-imports + const fs = require('node:fs'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const childProcess = require('node:child_process'); + + expect(jest.isMockFunction(fs.existsSync)).toBe(true); + expect(jest.isMockFunction(fs.promises.readFile)).toBe(true); + expect(jest.isMockFunction(fs.promises.writeFile)).toBe(true); + expect(jest.isMockFunction(fs.promises.mkdir)).toBe(true); + expect(jest.isMockFunction(fs.promises.rm)).toBe(true); + expect(jest.isMockFunction(childProcess.spawn)).toBe(true); + }); + + it('mocks fetch and process streams', () => { + setupFetchMock(); + setupInputMocks(); + + expect(jest.isMockFunction(global.fetch)).toBe(true); + expect(jest.isMockFunction(process.stdin.setRawMode)).toBe(true); + expect(jest.isMockFunction(process.stdin.resume)).toBe(true); + expect(jest.isMockFunction(process.stdout.write)).toBe(true); + expect(process.stdout.columns).toBe(80); + expect(process.stdout.rows).toBe(24); + }); + + it('enables fake timers and prevents process exit', () => { + const callback = jest.fn(); + + setupTimerMocks(); + setTimeout(callback, 100); + jest.advanceTimersByTime(100); + + const exitSpy = setupProcessExitMock(); + process.exit(0 as never); + + expect(callback).toHaveBeenCalledTimes(1); + expect(exitSpy).toHaveBeenCalledWith(0); + + exitSpy.mockRestore(); + }); + + it('sets up grouped audio and app mocks', () => { + setupAudioMocks(); + setupAppMocks(); + + // eslint-disable-next-line @typescript-eslint/no-require-imports + const fs = require('node:fs'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const childProcess = require('node:child_process'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { TimerStateMachine } = require('../stateMachine'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { DoroUi } = require('../ui'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { stopPlayback } = require('../audio/player'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { createWorkStartClip } = require('../audio/synth'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { DEFAULT_TIMER_CONFIG } = require('../constants'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { resolveControlCommand } = require('../input'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { loadSettings } = require('../config'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { getCurrentVersion } = require('../update'); + + expect(jest.isMockFunction(fs.existsSync)).toBe(true); + expect(jest.isMockFunction(childProcess.spawn)).toBe(true); + expect(jest.isMockFunction(TimerStateMachine)).toBe(true); + expect(jest.isMockFunction(DoroUi)).toBe(true); + expect(jest.isMockFunction(stopPlayback)).toBe(true); + expect(jest.isMockFunction(createWorkStartClip)).toBe(true); + expect(jest.isMockFunction(resolveControlCommand)).toBe(true); + expect(jest.isMockFunction(loadSettings)).toBe(true); + expect(jest.isMockFunction(getCurrentVersion)).toBe(true); + expect(DEFAULT_TIMER_CONFIG).toBeDefined(); + }); + + it('creates typed key, mouse, and resize events', () => { + expect(createKeyEvent('U', 'u', 'S-u', true)).toEqual({ + type: 'key', + ch: 'U', + keyName: 'u', + keyFull: 'S-u', + shift: true, + ctrl: false + }); + expect(createMouseEvent()).toEqual({ type: 'mouse', source: 'mouse' }); + expect(createMouseEvent('click')).toEqual({ type: 'mouse', source: 'click' }); + expect(createResizeEvent()).toEqual({ type: 'resize' }); + }); + + it('sets up the common helper bundle', () => { + setupAllCommonMocks(); + + expect(jest.isMockFunction(global.fetch)).toBe(true); + expect(jest.isMockFunction(process.stdin.on)).toBe(true); + expect(jest.isMockFunction(process.exit)).toBe(true); + }); + + it('creates a mock child process that emits close events', () => { + setupTimerMocks(); + + const child = createMockChildProcess(7); + const closeCallback = jest.fn(); + + child.on('close', closeCallback); + jest.runAllTimers(); + + expect(jest.isMockFunction(child.kill)).toBe(true); + expect(closeCallback).toHaveBeenCalledWith(7, null); + expect(child.killed).toBe(false); + }); +}); diff --git a/src/__tests__/player.test.ts b/src/__tests__/player.test.ts index 0218fb9..ade8a98 100644 --- a/src/__tests__/player.test.ts +++ b/src/__tests__/player.test.ts @@ -1,29 +1,21 @@ +import { setupChildProcessMocks, setupFsMocks, createMockChildProcess } from './utils/mocks'; + +// Setup centralized mocks +setupChildProcessMocks(); +setupFsMocks(); + import { playClip, stopPlayback } from '../audio/player'; import { spawn } from 'node:child_process'; import { promises as fs } from 'node:fs'; -jest.mock('node:child_process'); -jest.mock('node:fs', () => ({ - promises: { - writeFile: jest.fn(), - rm: jest.fn() - } -})); - describe('Audio Player', () => { - let mockSpawn: jest.Mock; - let mockFsWriteFile: jest.Mock; - let mockFsRm: jest.Mock; - beforeEach(() => { + // Clear all mocks before each test jest.clearAllMocks(); - mockSpawn = spawn as unknown as jest.Mock; - mockFsWriteFile = fs.writeFile as jest.Mock; - mockFsRm = fs.rm as jest.Mock; - - mockFsWriteFile.mockResolvedValue(undefined); - mockFsRm.mockResolvedValue(undefined); + // Setup default mock behavior + (fs.writeFile as jest.Mock).mockResolvedValue(undefined); + (fs.rm as jest.Mock).mockResolvedValue(undefined); }); afterEach(() => { @@ -31,34 +23,28 @@ describe('Audio Player', () => { }); it('should write buffer to temp file, spawn child process, and clean up', async () => { - // Setup a mock child process that successfully exits immediately - const mockChild = { - kill: jest.fn(), - on: jest.fn().mockImplementation((event, cb) => { - if (event === 'close') { - setTimeout(() => cb(0, null), 0); // exit code 0 - } - }), - killed: false - }; - mockSpawn.mockReturnValue(mockChild); - + // Arrange + const mockChild = createMockChildProcess(0); // Successfully exits with code 0 + (spawn as jest.Mock).mockReturnValue(mockChild); const dummyBuffer = Buffer.from('dummy-audio-data'); + // Act await playClip(dummyBuffer); - expect(mockFsWriteFile).toHaveBeenCalledWith( + // Assert + expect(fs.writeFile).toHaveBeenCalledWith( expect.stringMatching(/doro-[0-9a-f]+\.wav/), dummyBuffer ); - expect(mockSpawn).toHaveBeenCalled(); + expect(spawn).toHaveBeenCalled(); // Verify cleanup - expect(mockFsRm).toHaveBeenCalledWith(expect.stringMatching(/doro-[0-9a-f]+\.wav/), { + expect(fs.rm).toHaveBeenCalledWith(expect.stringMatching(/doro-[0-9a-f]+\.wav/), { force: true }); }); it('should stop playback and kill child process if stopPlayback is called', async () => { + // Arrange let closeCb: (code: number, signal: string) => void; const mockChild = { @@ -75,10 +61,10 @@ describe('Audio Player', () => { killed: false }; - mockSpawn.mockReturnValue(mockChild); - + (spawn as jest.Mock).mockReturnValue(mockChild); const dummyBuffer = Buffer.from('dummy'); + // Act const playPromise = playClip(dummyBuffer); // Give it a tick to start @@ -88,33 +74,29 @@ describe('Audio Player', () => { await playPromise; + // Assert expect(mockChild.kill).toHaveBeenCalledWith('SIGTERM'); }); it('should fallback to next candidate if spawn fails or exits with error', async () => { - // We mock spawn to fail for the first candidate and succeed for the second + // Arrange: Mock spawn to fail for the first candidate and succeed for the second let spawnCount = 0; - mockSpawn.mockImplementation(() => { + (spawn as jest.Mock).mockImplementation(() => { spawnCount++; const isFirst = spawnCount === 1; - return { - kill: jest.fn(), - on: jest.fn().mockImplementation((event, cb) => { - if (event === 'close') { - setTimeout(() => cb(isFirst ? 1 : 0, null), 0); // fail 1st, succeed 2nd - } - }), - killed: false - }; + return createMockChildProcess(isFirst ? 1 : 0); // Fail first, succeed second }); + // Act const dummyBuffer = Buffer.from('dummy'); await playClip(dummyBuffer); - expect(mockSpawn).toHaveBeenCalledTimes(2); + // Assert + expect(spawn).toHaveBeenCalledTimes(2); }); it('should handle early cancellation after spawn but before child setup', async () => { + // Arrange const mockChild = { kill: jest.fn(), on: jest.fn().mockImplementation((event, cb) => { @@ -126,11 +108,10 @@ describe('Audio Player', () => { killed: false }; - mockSpawn.mockReturnValue(mockChild); - + (spawn as jest.Mock).mockReturnValue(mockChild); const dummyBuffer = Buffer.from('dummy'); - // Start playback + // Act const playPromise = playClip(dummyBuffer); // Cancel after a tiny delay to let spawn happen @@ -138,7 +119,8 @@ describe('Audio Player', () => { await playPromise; - expect(mockSpawn).toHaveBeenCalled(); + // Assert + expect(spawn).toHaveBeenCalled(); expect(mockChild.kill).toHaveBeenCalledWith('SIGTERM'); }); @@ -146,7 +128,7 @@ describe('Audio Player', () => { let errorCb: () => void; let spawnCount = 0; - mockSpawn.mockImplementation(() => { + (spawn as jest.Mock).mockImplementation(() => { spawnCount++; const isFirst = spawnCount === 1; @@ -171,14 +153,14 @@ describe('Audio Player', () => { const dummyBuffer = Buffer.from('dummy'); await playClip(dummyBuffer); - expect(mockSpawn).toHaveBeenCalledTimes(2); + expect(spawn as jest.Mock).toHaveBeenCalledTimes(2); }); it('should fall back to terminal bell when all candidates fail', async () => { const mockStdoutWrite = jest.spyOn(process.stdout, 'write').mockImplementation(); // Make all spawn attempts fail - mockSpawn.mockImplementation(() => ({ + (spawn as jest.Mock).mockImplementation(() => ({ kill: jest.fn(), on: jest.fn().mockImplementation((event, cb) => { if (event === 'close') { @@ -198,7 +180,7 @@ describe('Audio Player', () => { it('should stop immediately if stopPlayback is called before spawn', async () => { let callCount = 0; - mockSpawn.mockImplementation(() => { + (spawn as jest.Mock).mockImplementation(() => { callCount++; return { kill: jest.fn(), @@ -233,7 +215,7 @@ describe('Audio Player', () => { it('should handle concurrent playClip calls by cancelling previous', async () => { let spawnCount = 0; - mockSpawn.mockImplementation(() => { + (spawn as jest.Mock).mockImplementation(() => { spawnCount++; const isFirst = spawnCount === 1; @@ -275,7 +257,7 @@ describe('Audio Player', () => { // Mock a cancelled state that gets checked early let mockChild: { kill: jest.Mock; on: jest.Mock; killed: boolean } | undefined; - mockSpawn.mockImplementation(() => { + (spawn as jest.Mock).mockImplementation(() => { mockChild = { kill: jest.fn(), on: jest.fn().mockImplementation((event, cb) => { @@ -304,7 +286,7 @@ describe('Audio Player', () => { // Track if playback gets cancelled during error handling let errorCb: () => void; - mockSpawn.mockImplementation(() => ({ + (spawn as jest.Mock).mockImplementation(() => ({ kill: jest.fn(), on: jest.fn().mockImplementation((event, cb) => { if (event === 'error') { @@ -330,8 +312,8 @@ describe('Audio Player', () => { await playClip(dummyBuffer); // Should not spawn any processes in test mode - expect(mockSpawn).not.toHaveBeenCalled(); - expect(mockFsWriteFile).not.toHaveBeenCalled(); + expect(spawn as jest.Mock).not.toHaveBeenCalled(); + expect(fs.writeFile).not.toHaveBeenCalled(); process.env.DORO_TEST_MODE = originalTestMode; }); diff --git a/src/__tests__/stateMachine.test.ts b/src/__tests__/stateMachine.test.ts index 4b1eda9..ade27b4 100644 --- a/src/__tests__/stateMachine.test.ts +++ b/src/__tests__/stateMachine.test.ts @@ -1,52 +1,48 @@ import { describe, expect, it } from '@jest/globals'; import { TimerStateMachine } from '../stateMachine'; +import { createQuickTestConfig } from './utils/factories'; describe('TimerStateMachine', () => { it('starts in paused work mode', () => { - const machine = new TimerStateMachine({ - workSeconds: 10, - shortRestSeconds: 3, - longRestSeconds: 6, - longRestEveryWorkSessions: 3, - switchConfirmSeconds: 5 - }); - + // Arrange + const config = createQuickTestConfig(); + const machine = new TimerStateMachine(config); + // Act const state = machine.getState(); + + // Assert expect(state.mode).toBe('work'); expect(state.status).toBe('paused'); expect(state.remainingSeconds).toBe(10); }); it('resets current mode and runs', () => { - const machine = new TimerStateMachine({ - workSeconds: 10, - shortRestSeconds: 3, - longRestSeconds: 6, - longRestEveryWorkSessions: 3, - switchConfirmSeconds: 5 - }); + // Arrange + const config = createQuickTestConfig(); + const machine = new TimerStateMachine(config); + // Act machine.startMode('short'); machine.tick(Date.now()); const result = machine.resetCurrentAndRun(); + + // Assert expect(result.state.mode).toBe('short'); expect(result.state.remainingSeconds).toBe(3); expect(result.state.status).toBe('running'); }); it('enters switch prompt when running timer reaches zero', () => { - const machine = new TimerStateMachine({ - workSeconds: 2, - shortRestSeconds: 3, - longRestSeconds: 6, - longRestEveryWorkSessions: 3, - switchConfirmSeconds: 5 - }); + // Arrange + const config = createQuickTestConfig({ workSeconds: 2 }); + const machine = new TimerStateMachine(config); + // Act machine.startMode('work'); machine.tick(1000); const result = machine.tick(2000); + // Assert expect(result.startedPrompt).toBe(true); expect(result.completedMode).toBe('work'); expect(result.state.status).toBe('switchPrompt'); @@ -55,14 +51,15 @@ describe('TimerStateMachine', () => { }); it('switches to long rest every third completed work session', () => { - const machine = new TimerStateMachine({ + // Arrange + const config = createQuickTestConfig({ workSeconds: 1, shortRestSeconds: 1, - longRestSeconds: 2, - longRestEveryWorkSessions: 3, - switchConfirmSeconds: 5 + longRestSeconds: 2 }); + const machine = new TimerStateMachine(config); + // Act machine.startMode('work'); machine.tick(1000); @@ -77,24 +74,24 @@ describe('TimerStateMachine', () => { machine.tick(5000); const state = machine.getState(); + + // Assert expect(state.status).toBe('switchPrompt'); expect(state.switchPrompt?.nextMode).toBe('long'); expect(state.completedWorkSessions).toBe(3); }); it('confirm switches immediately and runs', () => { - const machine = new TimerStateMachine({ - workSeconds: 1, - shortRestSeconds: 3, - longRestSeconds: 6, - longRestEveryWorkSessions: 3, - switchConfirmSeconds: 5 - }); + // Arrange + const config = createQuickTestConfig({ workSeconds: 1 }); + const machine = new TimerStateMachine(config); + // Act machine.startMode('work'); machine.tick(1000); const result = machine.confirmPromptAndSwitch(); + // Assert expect(result.switchedToMode).toBe('short'); expect(result.state.mode).toBe('short'); expect(result.state.status).toBe('running'); @@ -102,18 +99,19 @@ describe('TimerStateMachine', () => { }); it('auto-switches and starts next mode after prompt timeout', () => { - const machine = new TimerStateMachine({ + // Arrange + const config = createQuickTestConfig({ workSeconds: 1, - shortRestSeconds: 3, - longRestSeconds: 6, - longRestEveryWorkSessions: 3, switchConfirmSeconds: 2 }); + const machine = new TimerStateMachine(config); + // Act machine.startMode('work'); machine.tick(1000); const result = machine.tick(3000); + // Assert expect(result.switchedRunning).toBe(true); expect(result.switchedToMode).toBe('short'); expect(result.state.mode).toBe('short'); @@ -122,46 +120,42 @@ describe('TimerStateMachine', () => { }); it('debug jump pushes running timer to 3 seconds', () => { - const machine = new TimerStateMachine({ - workSeconds: 10, - shortRestSeconds: 3, - longRestSeconds: 6, - longRestEveryWorkSessions: 3, - switchConfirmSeconds: 5 - }); + // Arrange + const config = createQuickTestConfig(); + const machine = new TimerStateMachine(config); + // Act machine.startMode('work'); const state = machine.debugJumpToNearEnd(3); + // Assert expect(state.status).toBe('running'); expect(state.remainingSeconds).toBe(3); }); it('debug jump does nothing while paused', () => { - const machine = new TimerStateMachine({ - workSeconds: 10, - shortRestSeconds: 3, - longRestSeconds: 6, - longRestEveryWorkSessions: 3, - switchConfirmSeconds: 5 - }); + // Arrange + const config = createQuickTestConfig(); + const machine = new TimerStateMachine(config); + // Act const before = machine.getState(); const after = machine.debugJumpToNearEnd(3); + // Assert expect(after.status).toBe('paused'); expect(after.remainingSeconds).toBe(before.remainingSeconds); }); it('debug jump during switch prompt shortens confirm window', () => { - const machine = new TimerStateMachine({ + // Arrange + const config = createQuickTestConfig({ workSeconds: 2, - shortRestSeconds: 3, - longRestSeconds: 6, - longRestEveryWorkSessions: 3, switchConfirmSeconds: 60 }); + const machine = new TimerStateMachine(config); + // Act machine.startMode('work'); machine.tick(1000); machine.tick(2000); @@ -169,6 +163,8 @@ describe('TimerStateMachine', () => { expect(before.status).toBe('switchPrompt'); const after = machine.debugJumpToNearEnd(3); + + // Assert expect(after.status).toBe('switchPrompt'); expect(after.switchPrompt).not.toBeNull(); const remainingMs = (after.switchPrompt as { deadlineTs: number }).deadlineTs - Date.now(); @@ -178,14 +174,11 @@ describe('TimerStateMachine', () => { describe('toggleLock', () => { it('toggles isLocked state', () => { - const machine = new TimerStateMachine({ - workSeconds: 10, - shortRestSeconds: 3, - longRestSeconds: 6, - longRestEveryWorkSessions: 3, - switchConfirmSeconds: 5 - }); + // Arrange + const config = createQuickTestConfig(); + const machine = new TimerStateMachine(config); + // Act & Assert const initialState = machine.getState(); expect(initialState.isLocked).toBe(false); @@ -199,14 +192,11 @@ describe('TimerStateMachine', () => { describe('togglePause', () => { it('toggles between running and paused states', () => { - const machine = new TimerStateMachine({ - workSeconds: 10, - shortRestSeconds: 3, - longRestSeconds: 6, - longRestEveryWorkSessions: 3, - switchConfirmSeconds: 5 - }); + // Arrange + const config = createQuickTestConfig(); + const machine = new TimerStateMachine(config); + // Act & Assert machine.startMode('work'); const runningState = machine.getState(); expect(runningState.status).toBe('running'); @@ -219,14 +209,11 @@ describe('TimerStateMachine', () => { }); it('does nothing when in switchPrompt status', () => { - const machine = new TimerStateMachine({ - workSeconds: 1, - shortRestSeconds: 3, - longRestSeconds: 6, - longRestEveryWorkSessions: 3, - switchConfirmSeconds: 5 - }); + // Arrange + const config = createQuickTestConfig({ workSeconds: 1 }); + const machine = new TimerStateMachine(config); + // Act machine.startMode('work'); machine.tick(1000); // Complete work, enter switchPrompt @@ -234,23 +221,23 @@ describe('TimerStateMachine', () => { expect(beforeState.status).toBe('switchPrompt'); const afterState = machine.togglePause(); + + // Assert expect(afterState.status).toBe('switchPrompt'); }); }); describe('forceQuitState', () => { it('returns current state without modification', () => { - const machine = new TimerStateMachine({ - workSeconds: 10, - shortRestSeconds: 3, - longRestSeconds: 6, - longRestEveryWorkSessions: 3, - switchConfirmSeconds: 5 - }); + // Arrange + const config = createQuickTestConfig(); + const machine = new TimerStateMachine(config); + // Act const beforeState = machine.getState(); const afterState = machine.forceQuitState(); + // Assert expect(afterState).toEqual(beforeState); expect(afterState.mode).toBe('work'); expect(afterState.status).toBe('paused'); @@ -259,34 +246,34 @@ describe('TimerStateMachine', () => { describe('getConfig', () => { it('returns the timer configuration', () => { - const config = { + // Arrange + const config = createQuickTestConfig({ workSeconds: 15, shortRestSeconds: 4, longRestSeconds: 8, longRestEveryWorkSessions: 2, switchConfirmSeconds: 3 - }; + }); + // Act const machine = new TimerStateMachine(config); const returnedConfig = machine.getConfig(); + // Assert expect(returnedConfig).toEqual(config); }); }); describe('tick edge cases', () => { it('returns unchanged state when not running and not in switchPrompt', () => { - const machine = new TimerStateMachine({ - workSeconds: 10, - shortRestSeconds: 3, - longRestSeconds: 6, - longRestEveryWorkSessions: 3, - switchConfirmSeconds: 5 - }); + // Arrange + const config = createQuickTestConfig(); + const machine = new TimerStateMachine(config); - // Machine starts paused + // Act (Machine starts paused) const result = machine.tick(Date.now()); + // Assert expect(result.startedPrompt).toBe(false); expect(result.switchedRunning).toBe(false); expect(result.switchedToMode).toBeNull(); @@ -295,14 +282,14 @@ describe('TimerStateMachine', () => { }); it('returns unchanged state when in switchPrompt but deadline not reached', () => { - const machine = new TimerStateMachine({ + // Arrange + const config = createQuickTestConfig({ workSeconds: 1, - shortRestSeconds: 3, - longRestSeconds: 6, - longRestEveryWorkSessions: 3, switchConfirmSeconds: 60 // Long timeout }); + const machine = new TimerStateMachine(config); + // Act machine.startMode('work'); const firstTick = machine.tick(1000); // Complete work, enter switchPrompt expect(firstTick.state.status).toBe('switchPrompt'); @@ -311,6 +298,7 @@ describe('TimerStateMachine', () => { // Deadline will be 1000 + 60*1000 = 61000, so tick at 2000 is safe const result = machine.tick(2000); + // Assert expect(result.startedPrompt).toBe(false); expect(result.switchedRunning).toBe(false); expect(result.switchedToMode).toBeNull(); @@ -321,17 +309,14 @@ describe('TimerStateMachine', () => { describe('confirmPromptAndSwitch edge cases', () => { it('returns null switchedToMode when no prompt exists', () => { - const machine = new TimerStateMachine({ - workSeconds: 10, - shortRestSeconds: 3, - longRestSeconds: 6, - longRestEveryWorkSessions: 3, - switchConfirmSeconds: 5 - }); + // Arrange + const config = createQuickTestConfig(); + const machine = new TimerStateMachine(config); - // No switchPrompt active + // Act (No switchPrompt active) const result = machine.confirmPromptAndSwitch(); + // Assert expect(result.switchedToMode).toBeNull(); expect(result.state.status).toBe('paused'); }); diff --git a/src/__tests__/update.test.ts b/src/__tests__/update.test.ts index 1c0dc2f..c16610f 100644 --- a/src/__tests__/update.test.ts +++ b/src/__tests__/update.test.ts @@ -1,3 +1,8 @@ +import { setupFetchMock } from './utils/mocks'; + +// Setup centralized mocks +setupFetchMock(); + import { getCurrentVersion, fetchLatestVersion, @@ -8,9 +13,6 @@ import { getUpdateCommand } from '../update'; -// Mock fetch globally -global.fetch = jest.fn(); - describe('update functionality', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/src/__tests__/utils/factories.ts b/src/__tests__/utils/factories.ts new file mode 100644 index 0000000..e3e682d --- /dev/null +++ b/src/__tests__/utils/factories.ts @@ -0,0 +1,63 @@ +import type { TimerState } from '../../stateMachine'; +import type { TimerConfig } from '../../constants'; +import type { Settings } from '../../config'; +import { DEFAULT_TIMER_CONFIG } from '../../constants'; + +/** + * Creates a mock TimerState object for testing purposes. + * @param overrides Partial state properties to override defaults + * @returns A complete TimerState object + */ +export function createMockState(overrides?: Partial): TimerState { + return { + mode: 'work', + status: 'paused', + remainingSeconds: 1320, // 22 minutes default work time + isLocked: false, + completedWorkSessions: 0, + switchPrompt: null, + ...overrides + }; +} + +/** + * Creates a mock TimerConfig object for testing purposes. + * @param overrides Partial config properties to override defaults + * @returns A complete TimerConfig object + */ +export function createMockConfig(overrides?: Partial): TimerConfig { + return { + ...DEFAULT_TIMER_CONFIG, + ...overrides + }; +} + +/** + * Creates a mock Settings object for testing purposes. + * @param overrides Partial settings properties to override defaults + * @returns A complete Settings object + */ +export function createMockSettings(overrides?: Partial): Settings { + return { + volumeMode: 'normal', + colorScheme: 'modern', + checkIntervalHours: 24, + ...overrides + }; +} + +/** + * Creates a simple TimerConfig for quick testing (shorter durations). + * @param overrides Partial config properties to override defaults + * @returns A complete TimerConfig object with short durations + */ +export function createQuickTestConfig(overrides?: Partial): TimerConfig { + return { + workSeconds: 10, + shortRestSeconds: 3, + longRestSeconds: 6, + longRestEveryWorkSessions: 3, + switchConfirmSeconds: 5, + ...overrides + }; +} diff --git a/src/__tests__/utils/mocks.ts b/src/__tests__/utils/mocks.ts new file mode 100644 index 0000000..f25b7f2 --- /dev/null +++ b/src/__tests__/utils/mocks.ts @@ -0,0 +1,199 @@ +import type { InputEvent } from '../../input'; + +/** + * Mock setup for external dependencies used across multiple tests + */ + +/** + * Sets up env-paths mock to return a consistent config path + */ +export function setupEnvPathsMock() { + jest.mock('env-paths', () => { + return jest.fn().mockReturnValue({ + config: '/mock/config/path' + }); + }); +} + +/** + * Sets up Node.js fs module mocks for file operations + */ +export function setupFsMocks() { + jest.mock('node:fs', () => ({ + existsSync: jest.fn(), + promises: { + readFile: jest.fn(), + writeFile: jest.fn(), + mkdir: jest.fn(), + rm: jest.fn() + } + })); +} + +/** + * Sets up Node.js child_process module mocks for audio playback + */ +export function setupChildProcessMocks() { + jest.mock('node:child_process', () => ({ + spawn: jest.fn() + })); +} + +/** + * Sets up global fetch mock for network requests (like update checks) + */ +export function setupFetchMock() { + global.fetch = jest.fn(); +} + +/** + * Sets up comprehensive audio-related mocks including player and synthesizer modules + */ +export function setupAudioMocks() { + // These mocks need to be set up before the modules are imported + // So this is mainly for documentation - actual mocks should be at top level + setupChildProcessMocks(); + setupFsMocks(); +} + +/** + * Sets up mocks for core application modules + * NOTE: These mocks need to be set up before imports, so should be called at top level + */ +export function setupAppMocks() { + jest.mock('../../stateMachine'); + jest.mock('../../ui'); + jest.mock('../../audio/player'); + jest.mock('../../audio/synth'); + jest.mock('../../constants'); + jest.mock('../../input'); + jest.mock('../../config'); + jest.mock('../../update'); +} + +/** + * Sets up input-related mocks for simulating user interactions + */ +export function setupInputMocks() { + // Mock process.stdin for input handling + const mockStdin = { + setRawMode: jest.fn(), + resume: jest.fn(), + pause: jest.fn(), + on: jest.fn(), + off: jest.fn(), + removeAllListeners: jest.fn() + }; + + Object.defineProperty(process, 'stdin', { + value: mockStdin, + writable: true + }); + + // Mock process.stdout for terminal output + const mockStdout = { + write: jest.fn(), + columns: 80, + rows: 24 + }; + + Object.defineProperty(process, 'stdout', { + value: mockStdout, + writable: true + }); +} + +/** + * Sets up timer mocks using Jest's fake timers + */ +export function setupTimerMocks() { + jest.useFakeTimers(); +} + +/** + * Sets up process.exit mock to prevent tests from terminating + */ +export function setupProcessExitMock() { + return jest.spyOn(process, 'exit').mockImplementation((() => { + // Intentionally empty - prevents process.exit from terminating test runner + }) as never); +} + +/** + * Creates a mock input event for keyboard testing + * @param ch Character pressed + * @param keyName Key name (e.g., 'space', 'enter') + * @param keyFull Full key name including modifiers (e.g., 'S-d') + * @param shift Whether shift was pressed + * @param ctrl Whether ctrl was pressed + * @returns InputEvent object + */ +export function createKeyEvent( + ch: string, + keyName: string, + keyFull = keyName, + shift = false, + ctrl = false +): InputEvent { + return { + type: 'key', + ch, + keyName, + keyFull, + shift, + ctrl + }; +} + +/** + * Creates a mock mouse event for mouse testing + * @param source Mouse source (e.g., 'mouse', 'click', 'mousedown') + * @returns InputEvent object + */ +export function createMouseEvent(source: 'mouse' | 'click' | 'mousedown' = 'mouse'): InputEvent { + return { + type: 'mouse', + source + }; +} + +/** + * Creates a mock resize event for terminal resize testing + * @returns InputEvent object + */ +export function createResizeEvent(): InputEvent { + return { + type: 'resize' + }; +} + +/** + * Comprehensive setup for all common mocks used across test files + * Call this in beforeEach for tests that need multiple mock types + */ +export function setupAllCommonMocks() { + setupEnvPathsMock(); + setupFsMocks(); + setupChildProcessMocks(); + setupFetchMock(); + setupTimerMocks(); + setupInputMocks(); + setupProcessExitMock(); +} + +/** + * Helper to create a mock child process for audio player tests + */ +export function createMockChildProcess(exitCode = 0) { + return { + kill: jest.fn(), + on: jest + .fn() + .mockImplementation((event: string, cb: (code: number, signal: string | null) => void) => { + if (event === 'close') { + setTimeout(() => cb(exitCode, null), 0); + } + }), + killed: false + }; +} diff --git a/src/constants.ts b/src/constants.ts index 65ae0fd..b9da35e 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -53,3 +53,4 @@ export function getNextModeAfterCompletion( return 'work'; } +// Test comment