From e68e2ad7ac0b7fea0e9673edafabc74ea2001b1f Mon Sep 17 00:00:00 2001 From: Sergey Korsik Date: Fri, 24 Apr 2026 16:13:24 +0200 Subject: [PATCH 1/4] chore: Update artifact size investigation and remove pkg configuration Updated .opencode/plans/chore_investigate_artifact_size.md with findings on deprecation, Node.js SEA, Homebrew packaging, and a recommended multi-pronged distribution strategy. Removed script and configuration from package.json as is deprecated and to focus on npm and Homebrew distribution. --- .../plans/chore_investigate_artifact_size.md | 48 +++++++++++++++++++ package.json | 19 +------- 2 files changed, 50 insertions(+), 17 deletions(-) create mode 100644 .opencode/plans/chore_investigate_artifact_size.md diff --git a/.opencode/plans/chore_investigate_artifact_size.md b/.opencode/plans/chore_investigate_artifact_size.md new file mode 100644 index 0000000..dee2425 --- /dev/null +++ b/.opencode/plans/chore_investigate_artifact_size.md @@ -0,0 +1,48 @@ +# Investigation: Optimize Binary Artifact Sizes + +## Goal +To understand the root causes of large binary artifact sizes generated by `pkg` and explore strategies to reduce them, or identify alternative packaging/distribution methods if necessary. + +## Steps: + +1. **Research `pkg` Size Optimization:** + * Investigate `pkg` documentation for flags or configuration options that could reduce binary size (e.g., stripping debug symbols, optimizing Node.js runtime inclusion). + * Explore options for including only necessary Node.js modules or features. + +2. **Explore Alternative Bundling Tools:** + * Research Node.js bundlers like `esbuild`, `ncc`, `webpack` (with Node.js targets), or others known for producing smaller bundles or executables. + * Evaluate their suitability for creating single-file executables for `doro-cli`. + * Perform proof-of-concept builds with promising alternatives and compare artifact sizes. + +3. **Analyze Homebrew Packaging:** + * Research how Node.js CLI tools are typically packaged for Homebrew. + * Determine if Homebrew installation inherently leads to smaller user installations compared to standalone `pkg` binaries. + * Understand the effort involved in creating and maintaining a Homebrew formula for `doro-cli`. + +4. **Re-evaluate Distribution Strategy:** + * Based on the findings from steps 1-3, determine the most viable distribution strategy: + * **Option A**: Continue with `pkg` binaries, applying any found optimizations. + * **Option B**: Distribute primarily as a standard npm package. + * **Option C**: Use an alternative bundling tool that produces smaller executables. + * **Option D**: Rely on Homebrew for distribution, potentially alongside npm. + * Consider the trade-offs for each option (user experience, maintenance effort, size). + +## Verification: +* Document the findings from each research step, including comparative artifact sizes (if possible) and implementation effort. +* Present a clear recommendation for the optimal packaging and distribution strategy for `doro-cli`. + +## Findings and Recommendation + +### Key Findings: +1. **`pkg` (vercel/pkg):** Is deprecated. Optimization efforts for it are no longer recommended. +2. **Node.js SEA (Single Executable Applications):** Official Node.js feature for creating standalone binaries (35-80MB). Recommended replacement for `pkg` for Node.js projects. +3. **Bun/Deno:** Offer smaller executables (40-90MB), but involve switching runtimes, which is outside the scope of this optimization for an existing Node.js project. +4. **Homebrew:** Standard packaging for Node.js CLIs via `Language::Node` (relying on Homebrew-managed Node.js) is robust for macOS. Standalone binaries are possible for third-party taps, but not `homebrew-core`. + +### Recommended Distribution Strategy: + +To balance user experience, artifact size, and maintenance: + +* **Primary:** Distribute as a **standard npm package**. (Lowest effort, typical for Node.js CLIs). +* **Enhanced (Standalone):** Offer **Node.js SEA-generated binaries**. (Replaces `pkg` for users preferring single executables). +* **Enhanced (macOS):** Create a **Homebrew formula** using the standard `Language::Node` approach. (Integrates with macOS package management). diff --git a/package.json b/package.json index 4a7acda..c442191 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ }, "scripts": { "build": "tsc -p tsconfig.json", - "build:binaries": "npm run build && npx @yao-pkg/pkg . --out-path dist/binaries", + "dev": "tsx src/cli.ts", "lint": "eslint . --ext .ts", "lint:local": "eslint . --ext .ts --cache", @@ -24,22 +24,7 @@ "engines": { "node": ">=22" }, - "pkg": { - "scripts": [ - "dist/**/*.js", - "node_modules/blessed/lib/widgets/*.js" - ], - "assets": [ - "node_modules/blessed/usr/**/*" - ], - "targets": [ - "node22-macos-x64", - "node22-macos-arm64", - "node22-linux-x64", - "node22-linux-arm64", - "node22-win-x64" - ] - }, + "dependencies": { "blessed": "npm:@rook2pawn/neo-blessed@^2.0.2" }, From 232769c7d3181e981d9f6477f44b955f230912d0 Mon Sep 17 00:00:00 2001 From: Sergey Korsik Date: Fri, 24 Apr 2026 16:20:35 +0200 Subject: [PATCH 2/4] chore: Remove binary build steps from CI/CD workflows Removed 'build:binaries' step and associated binary handling (GitHub Release asset upload, conceptual Homebrew formula update) from both 'ci.yml' and 'release.yml'. This aligns with the decision to temporarily halt generation of large binary files and focus on npm and Homebrew distribution. --- .github/workflows/ci.yml | 3 --- .github/workflows/release.yml | 21 ++------------------- 2 files changed, 2 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e2b388..488358f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,9 +39,6 @@ jobs: - name: Verify Project Build run: npm run build - - name: Verify Binary Packaging - if: matrix.node-version == '22.x' # Only run on the version used for packaging - run: npm run build:binaries - name: Upload Coverage to Codecov uses: codecov/codecov-action@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cdd499c..23a5c68 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,14 +24,11 @@ jobs: - run: npm run test:unit - run: npm run lint - - name: Build binaries - run: npm run build:binaries + - name: Create GitHub Release uses: softprops/action-gh-release@v2 - with: - files: | - dist/binaries/* + draft: false prerelease: false env: @@ -42,18 +39,4 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: Update Homebrew formula - if: startsWith(github.ref, 'refs/tags/v') - run: | - VERSION=${GITHUB_REF#refs/tags/v} - SHA256_MACOS=$(sha256sum dist/binaries/doro-cli-macos-x64 | awk '{print $1}') - # You would repeat this for arm64 and linux - - # This part is conceptual as it depends on having a tap repo - # and a secret token (e.g., TAP_TOKEN) to push to it. - echo "Updating Homebrew formula for version $VERSION..." - # git clone https://${{ secrets.TAP_TOKEN }}@github.com/your-username/homebrew-doro-cli.git - # cd homebrew-doro-cli - # update doro.rb - # git add . && git commit -m "Update doro to $VERSION" && git push From 5c12286021c67bbbef852f8104b623d8ba4782e2 Mon Sep 17 00:00:00 2001 From: Sergey Korsik Date: Fri, 24 Apr 2026 16:25:43 +0200 Subject: [PATCH 3/4] chore: fix release ci --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 23a5c68..d2f751c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ jobs: - name: Create GitHub Release uses: softprops/action-gh-release@v2 - + with: draft: false prerelease: false env: From 475d4686072ef851bd67b1b8393b719c7bdb69f9 Mon Sep 17 00:00:00 2001 From: Sergey Korsik Date: Fri, 24 Apr 2026 17:09:04 +0200 Subject: [PATCH 4/4] chore: improve test coverage --- src/__tests__/app.test.ts | 209 +++++++++++++++++++++++++++++++++++ src/__tests__/mouse.test.ts | 68 ++++++++++++ src/__tests__/player.test.ts | 111 +++++++++++++++++++ src/__tests__/ui.test.ts | 135 ++++++++++++++++++++++ 4 files changed, 523 insertions(+) create mode 100644 src/__tests__/app.test.ts create mode 100644 src/__tests__/mouse.test.ts create mode 100644 src/__tests__/player.test.ts create mode 100644 src/__tests__/ui.test.ts diff --git a/src/__tests__/app.test.ts b/src/__tests__/app.test.ts new file mode 100644 index 0000000..5fc7237 --- /dev/null +++ b/src/__tests__/app.test.ts @@ -0,0 +1,209 @@ +import { DoroApp } from '../app'; +import { TimerStateMachine } from '../stateMachine'; +import { DoroUi } from '../ui'; +import { playClip, stopPlayback } from '../audio/player'; +import { + createCompletionBeepClip, + createResetBeepClip, + createRestStartClip, + createWorkStartClip +} from '../audio/synth'; +import { getDurationForMode } from '../constants'; +import { isAllowedWhenLocked, isPromptConfirmEvent, resolveControlCommand } from '../input'; + +// Mock dependencies +jest.mock('../stateMachine'); +jest.mock('../ui'); +jest.mock('../audio/player'); +jest.mock('../audio/synth'); +jest.mock('../constants'); +jest.mock('../input'); + +// 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(); +let spySetInterval: jest.SpyInstance; +let spyClearInterval: jest.SpyInstance; + +describe('DoroApp', () => { + let app: DoroApp; + let mockTimerStateMachine: jest.Mocked; + let mockDoroUi: jest.Mocked; + + beforeEach(() => { + // Clear all mocks before each test + jest.clearAllMocks(); + jest.runOnlyPendingTimers(); // Clear any timers from previous tests + + spySetInterval = jest.spyOn(global, 'setInterval'); + spyClearInterval = jest.spyOn(global, 'clearInterval'); + + // Mock methods for TimerStateMachine instance + mockTimerStateMachine = { + startMode: jest.fn(), + getState: jest.fn(), + getConfig: jest.fn(), + tick: jest.fn(), + confirmPromptAndSwitch: jest.fn(), + toggleLock: jest.fn(), + togglePause: jest.fn(), + debugJumpToNearEnd: jest.fn(), + resetCurrentAndRun: jest.fn(), + // Add other methods of TimerStateMachine as they are used + } as jest.Mocked; + + // Initialize mock DoroUi instance + mockDoroUi = { + render: jest.fn(), + destroy: jest.fn(), + toggleColorScheme: jest.fn(), + } as jest.Mocked; + + // Mock constructor implementations + (TimerStateMachine as jest.Mock).mockImplementation(() => mockTimerStateMachine); + (DoroUi as jest.Mock).mockImplementation((options) => { + // Capture the callbacks passed to DoroUi constructor + mockDoroUi.onKey = options.onKey; + mockDoroUi.onAnyClick = options.onAnyClick; + mockDoroUi.onResize = options.onResize; + return mockDoroUi; + }); + + // Mock synth functions to return dummy Buffers + (createWorkStartClip as jest.Mock).mockReturnValue(Buffer.from('work')); + (createRestStartClip as jest.Mock).mockReturnValue(Buffer.from('rest')); + (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: 'idle', + remainingSeconds: 0, + isLocked: false, + switchPrompt: null, + cycleCount: 0 + }); + mockTimerStateMachine.getConfig.mockReturnValue({ + workSeconds: 25 * 60, + shortBreakSeconds: 5 * 60, + longBreakSeconds: 15 * 60, + longBreakInterval: 4, + switchConfirmSeconds: 5 + }); + (getDurationForMode as jest.Mock).mockReturnValue(25 * 60); // Default duration + + mockTimerStateMachine.tick.mockReturnValue({ + state: { + mode: 'work', + status: 'running', + remainingSeconds: 10, + isLocked: false, + switchPrompt: null, + cycleCount: 0 + }, + startedPrompt: false, + switchedRunning: false, + switchedToMode: null + }); + + app = new DoroApp(); + }); + + afterAll(() => { + mockExit.mockRestore(); // Restore original process.exit + spySetInterval.mockRestore(); + spyClearInterval.mockRestore(); + jest.useRealTimers(); // Use real timers after all tests + }); + + it('should initialize correctly', () => { + expect(TimerStateMachine).toHaveBeenCalledTimes(1); + expect(DoroUi).toHaveBeenCalledTimes(1); + expect(createWorkStartClip).toHaveBeenCalledTimes(1); + expect(createRestStartClip).toHaveBeenCalledTimes(1); + expect(createCompletionBeepClip).toHaveBeenCalledTimes(1); + expect(createResetBeepClip).toHaveBeenCalledTimes(1); + }); + + describe('start', () => { + it('should start the timer in work mode, play audio, render, and set up tick interval', () => { + const mockRender = jest.spyOn(app as any, 'render'); // Access private method for spying + const mockPlayModeClip = jest.spyOn(app as any, 'playModeClip'); // Access private method for spying + + app.start(); + + expect(mockTimerStateMachine.startMode).toHaveBeenCalledWith('work'); + expect(mockPlayModeClip).toHaveBeenCalledWith('work'); + expect(mockRender).toHaveBeenCalledTimes(1); + expect(setInterval).toHaveBeenCalledTimes(1); + expect(setInterval).toHaveBeenCalledWith(expect.any(Function), 250); + expect((app as any).lastTickTs).toBeGreaterThan(0); // Check that it's set + }); + }); + + describe('shutdown', () => { + it('should clear interval, stop playback, destroy UI, and exit process', () => { + const mockStopPlayback = jest.mocked(stopPlayback); + const mockDestroyUi = jest.mocked(mockDoroUi.destroy); + + (app as any).isExiting = false; // Ensure it's not already exiting + const intervalId = setInterval(() => {}, 1000); // Simulate an active interval + (app as any).tickInterval = intervalId; + + // Access private method + (app as any).shutdown(); + + expect((app as any).isExiting).toBe(true); + expect(clearInterval).toHaveBeenCalledWith(intervalId); + expect(mockStopPlayback).toHaveBeenCalledTimes(1); + expect(mockDestroyUi).toHaveBeenCalledTimes(1); + expect(mockExit).toHaveBeenCalledWith(0); + }); + + it('should not shutdown if already exiting', () => { + const mockStopPlayback = jest.mocked(stopPlayback); + const mockDestroyUi = jest.mocked(mockDoroUi.destroy); + + (app as any).isExiting = true; // Already exiting + (app as any).tickInterval = setInterval(() => {}, 1000); + + // Access private method + (app as any).shutdown(); + + expect(clearInterval).not.toHaveBeenCalled(); + expect(mockStopPlayback).not.toHaveBeenCalled(); + expect(mockDestroyUi).not.toHaveBeenCalled(); + expect(mockExit).not.toHaveBeenCalled(); + }); + }); + + describe('handleInput', () => { + it('should ignore input if exiting', () => { + (app as any).isExiting = true; + (app as any).handleInput({ type: 'key', ch: 'a', keyName: 'a', keyFull: 'a', shift: false, ctrl: false }); + expect(mockDoroUi.render).not.toHaveBeenCalled(); + }); + + it('should toggle color scheme', () => { + (resolveControlCommand as jest.Mock).mockReturnValue('toggleColorScheme'); + (app as any).handleInput({ type: 'key', ch: 'c', keyName: 'c', keyFull: 'c', shift: false, ctrl: false }); + expect(mockDoroUi.toggleColorScheme).toHaveBeenCalledTimes(1); + }); + + it('should toggle pause', () => { + (resolveControlCommand as jest.Mock).mockReturnValue('pauseResume'); + (app as any).handleInput({ type: 'key', ch: 'p', keyName: 'p', keyFull: 'p', shift: false, ctrl: false }); + expect(mockTimerStateMachine.togglePause).toHaveBeenCalledTimes(1); + }); + }); + + describe('stepClock', () => { + it('should step the timer and render', () => { + (app as any).stepClock(); + expect(mockTimerStateMachine.tick).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/__tests__/mouse.test.ts b/src/__tests__/mouse.test.ts new file mode 100644 index 0000000..41b7af3 --- /dev/null +++ b/src/__tests__/mouse.test.ts @@ -0,0 +1,68 @@ +import { enableMouse, disableMouse } from '../mouse'; + +describe('Mouse Tracking', () => { + let mockStdoutWrite: jest.SpyInstance; + let mockStdinOn: jest.SpyInstance; + let mockStdinOff: jest.SpyInstance; + let mockStdinPrependListener: jest.SpyInstance; + + beforeEach(() => { + mockStdoutWrite = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + mockStdinOn = jest.spyOn(process.stdin, 'on').mockImplementation(() => process.stdin); + mockStdinOff = jest.spyOn(process.stdin, 'off').mockImplementation(() => process.stdin); + mockStdinPrependListener = jest.spyOn(process.stdin, 'prependListener').mockImplementation(() => process.stdin); + jest.useFakeTimers(); + }); + + afterEach(() => { + disableMouse(); + jest.restoreAllMocks(); + jest.useRealTimers(); + }); + + it('should enable mouse tracking and emit escape sequences', () => { + const mockHandler = jest.fn(); + enableMouse(mockHandler); + + expect(mockStdinPrependListener).toHaveBeenCalledWith('data', expect.any(Function)); + + // Process the setImmediate + jest.runAllTimers(); + + expect(mockStdoutWrite).toHaveBeenCalledWith('\x1b[?1000h\x1b[?1006h'); + }); + + it('should disable mouse tracking', () => { + const mockHandler = jest.fn(); + enableMouse(mockHandler); + jest.runAllTimers(); + + disableMouse(); + + expect(mockStdinOff).toHaveBeenCalledWith('data', expect.any(Function)); + expect(mockStdoutWrite).toHaveBeenCalledWith('\x1b[?1006l\x1b[?1000l'); + }); + + it('should call handler on left mouse click SGR sequences', () => { + const mockHandler = jest.fn(); + enableMouse(mockHandler); + + const dataListener = mockStdinPrependListener.mock.calls[0][1]; + + // Left mouse press: \x1b[<0;10;10M + dataListener(Buffer.from('\x1b[<0;10;10M')); + expect(mockHandler).toHaveBeenCalledTimes(1); + + // Left mouse release: \x1b[<0;10;10m (should not trigger) + dataListener(Buffer.from('\x1b[<0;10;10m')); + expect(mockHandler).toHaveBeenCalledTimes(1); + + // Mouse motion: \x1b[<32;10;10M (should not trigger) + dataListener(Buffer.from('\x1b[<32;10;10M')); + expect(mockHandler).toHaveBeenCalledTimes(1); + + // Another left mouse press + dataListener(Buffer.from('\x1b[<0;5;5M')); + expect(mockHandler).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/__tests__/player.test.ts b/src/__tests__/player.test.ts new file mode 100644 index 0000000..74fa9a1 --- /dev/null +++ b/src/__tests__/player.test.ts @@ -0,0 +1,111 @@ +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(() => { + 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); + }); + + afterEach(() => { + stopPlayback(); + }); + + 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); + + const dummyBuffer = Buffer.from('dummy-audio-data'); + + await playClip(dummyBuffer); + + expect(mockFsWriteFile).toHaveBeenCalledWith(expect.stringMatching(/doro-[0-9a-f]+\.wav/), dummyBuffer); + expect(mockSpawn).toHaveBeenCalled(); + // Verify cleanup + expect(mockFsRm).toHaveBeenCalledWith(expect.stringMatching(/doro-[0-9a-f]+\.wav/), { force: true }); + }); + + it('should stop playback and kill child process if stopPlayback is called', async () => { + let closeCb: (code: number, signal: string) => void; + + const mockChild = { + kill: jest.fn().mockImplementation((signal) => { + if (closeCb) { + closeCb(null as any, signal); // Simulate child exiting after kill + } + }), + on: jest.fn().mockImplementation((event, cb) => { + if (event === 'close') { + closeCb = cb; + } + }), + killed: false, + }; + + mockSpawn.mockReturnValue(mockChild); + + const dummyBuffer = Buffer.from('dummy'); + + const playPromise = playClip(dummyBuffer); + + // Give it a tick to start + await new Promise(resolve => setTimeout(resolve, 0)); + + stopPlayback(); + + await playPromise; + + 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 + let spawnCount = 0; + mockSpawn.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, + }; + }); + + const dummyBuffer = Buffer.from('dummy'); + await playClip(dummyBuffer); + + expect(mockSpawn).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/__tests__/ui.test.ts b/src/__tests__/ui.test.ts new file mode 100644 index 0000000..49b8af8 --- /dev/null +++ b/src/__tests__/ui.test.ts @@ -0,0 +1,135 @@ +import { DoroUi } from '../ui'; +import blessed from 'blessed'; +import { enableMouse, disableMouse } from '../mouse'; + +jest.mock('blessed', () => { + const mockScreen = { + on: jest.fn(), + render: jest.fn(), + destroy: jest.fn(), + cols: 80, + rows: 24, + }; + const mockBox = { + style: {}, + setContent: jest.fn(), + hide: jest.fn(), + show: jest.fn(), + }; + + return { + screen: jest.fn(() => mockScreen), + box: jest.fn(() => ({ ...mockBox })), + }; +}); + +jest.mock('../mouse', () => ({ + enableMouse: jest.fn(), + disableMouse: jest.fn(), +})); + +describe('DoroUi', () => { + let handlers: any; + let ui: DoroUi; + + beforeEach(() => { + jest.clearAllMocks(); + handlers = { + onKey: jest.fn(), + onResize: jest.fn(), + onAnyClick: jest.fn(), + }; + }); + + afterEach(() => { + if (ui) { + ui.destroy(); + } + }); + + it('should initialize correctly with blessed elements', () => { + ui = new DoroUi(handlers); + + expect(blessed.screen).toHaveBeenCalledTimes(1); + // 1 root + 1 progress + 1 banner + 1 status + 1 help + 4 prompt overlays = 9 boxes + expect(blessed.box).toHaveBeenCalledTimes(9); + + expect(enableMouse).toHaveBeenCalledTimes(1); + }); + + it('should render work mode state', () => { + ui = new DoroUi(handlers); + const mockScreen = (blessed.screen as jest.Mock).mock.results[0].value; + + ui.render({ + mode: 'work', + status: 'running', + remainingSeconds: 600, + durationSeconds: 1500, + isLocked: false, + isMuted: false, + hasPrompt: false, + promptCountdownSeconds: 0, + promptTotalSeconds: 0, + promptNextMode: null, + }); + + expect(mockScreen.render).toHaveBeenCalledTimes(1); + expect(mockScreen.title).toContain('10:00'); + }); + + it('should render transition prompt state', () => { + ui = new DoroUi(handlers); + const mockScreen = (blessed.screen as jest.Mock).mock.results[0].value; + + ui.render({ + mode: 'work', + status: 'switchPrompt', + remainingSeconds: 0, + durationSeconds: 1500, + isLocked: false, + isMuted: false, + hasPrompt: true, + promptCountdownSeconds: 3, + promptTotalSeconds: 5, + promptNextMode: 'short', + }); + + expect(mockScreen.render).toHaveBeenCalledTimes(1); + }); + + it('should toggle color scheme', () => { + ui = new DoroUi(handlers); + + // Test that the method doesn't throw, and state is preserved internally + ui.toggleColorScheme(); + + ui.render({ + mode: 'work', + status: 'running', + remainingSeconds: 600, + durationSeconds: 1500, + isLocked: false, + isMuted: false, + hasPrompt: false, + promptCountdownSeconds: 0, + promptTotalSeconds: 0, + promptNextMode: null, + }); + + // We expect the styles to change, which internally modifies root.style.bg. + // The specifics are covered by integration / manual tests, but we ensure no crashes. + const mockScreen = (blessed.screen as jest.Mock).mock.results[0].value; + expect(mockScreen.render).toHaveBeenCalledTimes(1); + }); + + it('should destroy and disable mouse', () => { + ui = new DoroUi(handlers); + const mockScreen = (blessed.screen as jest.Mock).mock.results[0].value; + + ui.destroy(); + + expect(disableMouse).toHaveBeenCalledTimes(1); + expect(mockScreen.destroy).toHaveBeenCalledTimes(1); + }); +});