From d1788bf6ee417660c9c4d9d6e24b5e1c31240264 Mon Sep 17 00:00:00 2001 From: Ryan Meline Date: Wed, 13 May 2026 23:50:59 -0700 Subject: [PATCH 1/2] Added back editor testing --- frontend/src/editor/vimEditor.test.js | 110 ++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 frontend/src/editor/vimEditor.test.js diff --git a/frontend/src/editor/vimEditor.test.js b/frontend/src/editor/vimEditor.test.js new file mode 100644 index 0000000..1c10773 --- /dev/null +++ b/frontend/src/editor/vimEditor.test.js @@ -0,0 +1,110 @@ +/** + * @jest-environment jsdom + */ +const React = require('react'); +const { render, screen } = require('@testing-library/react'); +const { MemoryRouter } = require('react-router-dom'); +const { act } = require('@testing-library/react'); +require('@testing-library/jest-dom'); + +// Mock all heavy dependencies so we're just testing the level page structure +jest.mock('../../components/sidebar', () => () =>
); +jest.mock('../../components/hint', () => () =>
); +jest.mock('../../components/passedLevel', () => () =>
); +jest.mock('../../components/checkLevelPassed', () => ({ + __esModule: true, + default: jest.fn(() => false), +})); +jest.mock('../../ThemeContext', () => ({ + useTheme: jest.fn(() => ({ theme: 'dark' })), +})); +const mockOnWin = jest.fn(); +jest.mock('../../editor/vimEditor', () => (props) => { + if (props.onWin) mockOnWin.mockImplementation(props.onWin); + return
; +}); +beforeEach(() => { + const themeContext = jest.requireMock('../../ThemeContext'); + themeContext.useTheme.mockReturnValue({ theme: 'dark' }); + const checkLevel = jest.requireMock('../../components/checkLevelPassed'); + checkLevel.default.mockReturnValue(false); +}); +jest.mock('../../components/checkLevelPassed', () => ({ + __esModule: true, + default: jest.fn(() => false), + useProgress: jest.fn(() => ({ progress: {} })), +})); +beforeEach(() => { + const checkLevel = jest.requireMock('../../components/checkLevelPassed'); + checkLevel.default.mockReturnValue(false); + checkLevel.useProgress.mockReturnValue({ progress: {} }); + + const themeContext = jest.requireMock('../../ThemeContext'); + themeContext.useTheme.mockReturnValue({ theme: 'dark' }); +}); +describe.each( + Array.from({ length: 27 }, (_, i) => [i + 1]) +)('Level %i passed state', (num) => { + it('renders PassedLevel when level is already passed', () => { + const checkLevel = jest.requireMock('../../components/checkLevelPassed'); + checkLevel.default.mockReturnValue(true); + const Level = require(`./Level${num}`).default; + render(); + expect(screen.getByTestId('passed-level')).toBeInTheDocument(); + checkLevel.default.mockReturnValue(false); + }); + it('calls onWin and shows PassedLevel', () => { + const checkLevel = jest.requireMock('../../components/checkLevelPassed'); + checkLevel.default.mockReturnValue(false); + const Level = require(`./Level${num}`).default; + render(); + act(() => { mockOnWin(); }); + expect(screen.getByTestId('passed-level')).toBeInTheDocument(); + }); + it('renders without crashing', () => { + const Level = require(`./Level${num}`).default; + render(); + }); + + it('shows the level number', () => { + const Level = require(`./Level${num}`).default; + render(); + expect(screen.getByText(`Level ${num}`)).toBeInTheDocument(); + }); + + it('renders the sidebar and vim editor', () => { + const Level = require(`./Level${num}`).default; + render(); + expect(screen.getByTestId('sidebar')).toBeInTheDocument(); + expect(screen.getByTestId('vim-editor')).toBeInTheDocument(); + }); + it('renders correctly in light theme', () => { + const themeContext = jest.requireMock('../../ThemeContext'); + themeContext.useTheme.mockReturnValue({ theme: 'light' }); + const Level = require(`./Level${num}`).default; + render(); + expect(screen.getByText(`Level ${num}`)).toBeInTheDocument(); + themeContext.useTheme.mockReturnValue({ theme: 'dark' }); + }); +}); + +// LevelTest is a dev sandbox — no sidebar, different structure +describe('LevelTest', () => { + it('renders without crashing', () => { + const LevelTest = require('./levelTest').default; + render(); + }); + + it('renders the vim editor', () => { + const LevelTest = require('./levelTest').default; + render(); + expect(screen.getByTestId('vim-editor')).toBeInTheDocument(); + }); + + it('shows win message when onWin is called', () => { + const LevelTest = require('./levelTest').default; + render(); + act(() => { mockOnWin(); }); + expect(screen.getByText('winner winner chicken dinner')).toBeInTheDocument(); + }); +}); \ No newline at end of file From 194526f21cd097c4c37428c90833dbe1b8425927 Mon Sep 17 00:00:00 2001 From: Ryan Meline Date: Wed, 13 May 2026 23:56:38 -0700 Subject: [PATCH 2/2] s --- frontend/src/editor/vimEditor.test.js | 591 ++++++++++++++++++++++---- 1 file changed, 498 insertions(+), 93 deletions(-) diff --git a/frontend/src/editor/vimEditor.test.js b/frontend/src/editor/vimEditor.test.js index 1c10773..143604c 100644 --- a/frontend/src/editor/vimEditor.test.js +++ b/frontend/src/editor/vimEditor.test.js @@ -1,110 +1,515 @@ /** - * @jest-environment jsdom - */ + * @jest-environment jsdom + */ + const React = require('react'); -const { render, screen } = require('@testing-library/react'); -const { MemoryRouter } = require('react-router-dom'); -const { act } = require('@testing-library/react'); +const { render, screen, fireEvent, waitFor, act } = require('@testing-library/react'); require('@testing-library/jest-dom'); -// Mock all heavy dependencies so we're just testing the level page structure -jest.mock('../../components/sidebar', () => () =>
); -jest.mock('../../components/hint', () => () =>
); -jest.mock('../../components/passedLevel', () => () =>
); -jest.mock('../../components/checkLevelPassed', () => ({ - __esModule: true, - default: jest.fn(() => false), -})); -jest.mock('../../ThemeContext', () => ({ - useTheme: jest.fn(() => ({ theme: 'dark' })), +// Mock out all external dependencies +// Factories can't reference variables defined outside them here, so we set up +// the real behavior in beforeEach using jest.requireMock instead + +jest.mock('monaco-vim', () => ({ + __esModule: true, + initVimMode: jest.fn(), + VimMode: { + Vim: { + defineEx: jest.fn(), + }, + }, })); -const mockOnWin = jest.fn(); -jest.mock('../../editor/vimEditor', () => (props) => { - if (props.onWin) mockOnWin.mockImplementation(props.onWin); - return
; -}); -beforeEach(() => { - const themeContext = jest.requireMock('../../ThemeContext'); - themeContext.useTheme.mockReturnValue({ theme: 'dark' }); - const checkLevel = jest.requireMock('../../components/checkLevelPassed'); - checkLevel.default.mockReturnValue(false); + +jest.mock('@monaco-editor/react', () => { + const ReactInner = require('react'); + + // Named function so we can attach ._editor and ._handlers to it + // and read them back in tests via jest.requireMock + const MockEditor = function MockEditor({ onMount, defaultValue }) { + const hostRef = ReactInner.useRef(null); + + ReactInner.useEffect(() => { + const handlers = {}; + let value = defaultValue ?? ''; + + const editor = { + getValue: jest.fn(() => value), + setValue: jest.fn((v) => { value = v; }), + getPosition: jest.fn(() => ({ lineNumber: 1, column: 1 })), + getDomNode: jest.fn(() => hostRef.current), + addCommand: jest.fn(), + onDidChangeCursorSelection: jest.fn((cb) => { handlers.cursor = cb; }), + onDidChangeModelContent: jest.fn((cb) => { handlers.content = cb; }), + onKeyDown: jest.fn((cb) => { handlers.keydown = cb; }), + }; + + MockEditor._editor = editor; + MockEditor._handlers = handlers; + + if (onMount) onMount(editor, { KeyCode: { UpArrow: 16 } }); + }, []); + + return ( +
+ {defaultValue} +
+ ); + }; + + return MockEditor; }); -jest.mock('../../components/checkLevelPassed', () => ({ - __esModule: true, - default: jest.fn(() => false), - useProgress: jest.fn(() => ({ progress: {} })), + + +// Mock Components +jest.mock('../progress.js', () => ({ + saveProgress: jest.fn(() => Promise.resolve()), + loadProgress: jest.fn(() => Promise.resolve({})), +})); + +jest.mock('../ThemeContext.js', () => ({ + useTheme: () => ({ theme: 'light' }), })); + +jest.mock('../components/checkLevelPassed.js', () => ({ + useProgress: () => ({ levelPassed: jest.fn() }), +})); + +// Grab references to the mocked modules +const monacoVimMock = jest.requireMock('monaco-vim'); +const monacoEditorMock = jest.requireMock('@monaco-editor/react'); +const progressMock = jest.requireMock('../progress.js'); + +const VimEditor = require('./vimEditor').default; + +// Shared objects that track vim key/command handlers across tests +const mockVimHandlers = {}; +const mockExHandlers = {}; + +const mockVimMode = { + on: jest.fn((event, cb) => { + mockVimHandlers[event] = cb; + }), +}; + beforeEach(() => { - const checkLevel = jest.requireMock('../../components/checkLevelPassed'); - checkLevel.default.mockReturnValue(false); - checkLevel.useProgress.mockReturnValue({ progress: {} }); - - const themeContext = jest.requireMock('../../ThemeContext'); - themeContext.useTheme.mockReturnValue({ theme: 'dark' }); -}); -describe.each( - Array.from({ length: 27 }, (_, i) => [i + 1]) -)('Level %i passed state', (num) => { - it('renders PassedLevel when level is already passed', () => { - const checkLevel = jest.requireMock('../../components/checkLevelPassed'); - checkLevel.default.mockReturnValue(true); - const Level = require(`./Level${num}`).default; - render(); - expect(screen.getByTestId('passed-level')).toBeInTheDocument(); - checkLevel.default.mockReturnValue(false); - }); - it('calls onWin and shows PassedLevel', () => { - const checkLevel = jest.requireMock('../../components/checkLevelPassed'); - checkLevel.default.mockReturnValue(false); - const Level = require(`./Level${num}`).default; - render(); - act(() => { mockOnWin(); }); - expect(screen.getByTestId('passed-level')).toBeInTheDocument(); - }); - it('renders without crashing', () => { - const Level = require(`./Level${num}`).default; - render(); - }); - - it('shows the level number', () => { - const Level = require(`./Level${num}`).default; - render(); - expect(screen.getByText(`Level ${num}`)).toBeInTheDocument(); - }); - - it('renders the sidebar and vim editor', () => { - const Level = require(`./Level${num}`).default; - render(); - expect(screen.getByTestId('sidebar')).toBeInTheDocument(); - expect(screen.getByTestId('vim-editor')).toBeInTheDocument(); - }); - it('renders correctly in light theme', () => { - const themeContext = jest.requireMock('../../ThemeContext'); - themeContext.useTheme.mockReturnValue({ theme: 'light' }); - const Level = require(`./Level${num}`).default; - render(); - expect(screen.getByText(`Level ${num}`)).toBeInTheDocument(); - themeContext.useTheme.mockReturnValue({ theme: 'dark' }); + // Clear out handlers from the previous test + Object.keys(mockVimHandlers).forEach((k) => delete mockVimHandlers[k]); + Object.keys(mockExHandlers).forEach((k) => delete mockExHandlers[k]); + + // Reset editor references + monacoEditorMock._editor = undefined; + monacoEditorMock._handlers = {}; + + // Make initVimMode return our stub so vimMode.on() works + monacoVimMock.initVimMode.mockReset(); + monacoVimMock.initVimMode.mockReturnValue(mockVimMode); + mockVimMode.on.mockClear(); + mockVimMode.on.mockImplementation((event, cb) => { + mockVimHandlers[event] = cb; }); + + // Make defineEx store callbacks so we can trigger :commands in tests + monacoVimMock.VimMode.Vim.defineEx.mockReset(); + monacoVimMock.VimMode.Vim.defineEx.mockImplementation((name, abbrev, cb) => { + mockExHandlers[`:${abbrev}`] = cb; + }); + + progressMock.saveProgress.mockResolvedValue(undefined); + progressMock.loadProgress.mockResolvedValue({}); }); -// LevelTest is a dev sandbox — no sidebar, different structure -describe('LevelTest', () => { - it('renders without crashing', () => { - const LevelTest = require('./levelTest').default; - render(); +// Renders the editor and returns helpers for triggering editor events +function renderEditor(props = {}) { + const onWin = props.onWin ?? jest.fn(); + render(); + + return { + onWin, + + triggerContentChange() { + act(() => { monacoEditorMock._handlers?.content?.(); }); + }, + + triggerCursor(line, col) { + monacoEditorMock._editor?.getPosition.mockReturnValue({ lineNumber: line, column: col }); + act(() => { + monacoEditorMock._handlers?.cursor?.({ + selection: { positionLineNumber: line, positionColumn: col }, + }); + }); + }, + + triggerVimKey(key) { + act(() => { mockVimHandlers['vim-keypress']?.(key); }); + }, + + triggerCommandDone() { + act(() => { mockVimHandlers['vim-command-done']?.(); }); + }, + + triggerExCommand(cmd) { + act(() => { mockExHandlers[cmd]?.(); }); + }, + + triggerKeydown(key) { + act(() => { + monacoEditorMock._handlers?.keydown?.({ browserEvent: { key } }); + }); + }, + }; +} + +describe('VimEditor', () => { + it('renders the editor', () => { + render(); + expect(screen.getByTestId('mock-editor')).toBeInTheDocument(); + }); + + it('renders the reset button by default', () => { + render(); + expect( + screen.getByRole('button', { name: /reset level/i }) + ).toBeInTheDocument(); + }); + + it('does not render reset button when showResetLevel is false', () => { + render(); + expect( + screen.queryByRole('button', { name: /reset level/i }) + ).not.toBeInTheDocument(); + }); + + it('calls reset when reset button is clicked', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /reset level/i })); + expect(monacoEditorMock._editor.setValue).toHaveBeenCalledWith('starting text'); + }); + + it('allows winning again after a reset', async () => { + const h = renderEditor({ value: 'correct', finalText: 'correct' }); + h.triggerContentChange(); + await waitFor(() => expect(h.onWin).toHaveBeenCalledTimes(1)); + + fireEvent.click(screen.getByRole('button', { name: /reset level/i })); + + h.triggerContentChange(); + await waitFor(() => expect(h.onWin).toHaveBeenCalledTimes(2)); + }); + + it('resets command tracking so the command needs to be used again after reset', async () => { + const h = renderEditor({ value: 'correct', finalText: 'correct', commands: [':w'] }); + h.triggerExCommand(':w'); + await waitFor(() => expect(h.onWin).toHaveBeenCalledTimes(1)); + + fireEvent.click(screen.getByRole('button', { name: /reset level/i })); + + h.triggerContentChange(); + expect(h.onWin).toHaveBeenCalledTimes(1); + + h.triggerExCommand(':w'); + await waitFor(() => expect(h.onWin).toHaveBeenCalledTimes(2)); + }); + + it('calls onWin when finalText matches user input', async () => { + const h = renderEditor({ + value: 'correct answer', + finalText: 'correct answer', + }); + + h.triggerContentChange(); + + await waitFor(() => { + expect(h.onWin).toHaveBeenCalled(); + }); + }); + + it('doesn\'t call onWin when finalText doesn\'t match', () => { + const h = renderEditor({ value: 'wrong answer', finalText: 'correct answer' }); + h.triggerContentChange(); + expect(h.onWin).not.toHaveBeenCalled(); + }); + + it('calls onWin when content contains target string', async () => { + const h = renderEditor({ value: 'hello world', finalTextContains: 'hello' }); + h.triggerContentChange(); + await waitFor(() => expect(h.onWin).toHaveBeenCalled()); + }); + + it('doesnt call onWin when content doesnt contain target string', () => { + const h = renderEditor({ value: 'goodbye world', finalTextContains: 'hello' }); + h.triggerContentChange(); + expect(h.onWin).not.toHaveBeenCalled(); + }); + + it('calls onWin when content matches the regex', async () => { + const h = renderEditor({ value: 'hello world', finalTextRegex: /hello/ }); + h.triggerContentChange(); + await waitFor(() => expect(h.onWin).toHaveBeenCalled()); + }); + + it('does not call onWin when content does not match regex', () => { + const h = renderEditor({ value: 'goodbye world', finalTextRegex: /hello/ }); + h.triggerContentChange(); + expect(h.onWin).not.toHaveBeenCalled(); + }); + + it('calls onWin when cursor reaches the required line and column', async () => { + const h = renderEditor({ cursorLine: 3, cursorCol: 5 }); + h.triggerCursor(3, 5); + await waitFor(() => expect(h.onWin).toHaveBeenCalled()); + }); + + it('does not call onWin when cursor is on the wrong line', () => { + const h = renderEditor({ cursorLine: 3, cursorCol: 5 }); + h.triggerCursor(1, 5); + expect(h.onWin).not.toHaveBeenCalled(); + }); + + it('does not call onWin when cursor is on the wrong column', () => { + const h = renderEditor({ cursorLine: 3, cursorCol: 5 }); + h.triggerCursor(3, 1); + expect(h.onWin).not.toHaveBeenCalled(); }); - it('renders the vim editor', () => { - const LevelTest = require('./levelTest').default; - render(); - expect(screen.getByTestId('vim-editor')).toBeInTheDocument(); + it('calls onWin when the required mode is normal (the default)', async () => { + const h = renderEditor({ value: 'correct', finalText: 'correct', mode: 'normal' }); + h.triggerContentChange(); + await waitFor(() => expect(h.onWin).toHaveBeenCalled()); }); - it('shows win message when onWin is called', () => { - const LevelTest = require('./levelTest').default; - render(); - act(() => { mockOnWin(); }); - expect(screen.getByText('winner winner chicken dinner')).toBeInTheDocument(); + it('does not call onWin when mode requirement is not yet met', () => { + const h = renderEditor({ value: 'correct', finalText: 'correct', mode: 'insert' }); + h.triggerContentChange(); + expect(h.onWin).not.toHaveBeenCalled(); + }); + + it('does not call onWin when canWin is false even if all conditions are met', () => { + const h = renderEditor({ value: 'correct', finalText: 'correct', canWin: false }); + h.triggerContentChange(); + expect(h.onWin).not.toHaveBeenCalled(); + }); + it('does not call onWin until the required command is used', async () => { + const h = renderEditor({ value: 'correct', finalText: 'correct', commands: [':w'] }); + h.triggerContentChange(); + expect(h.onWin).not.toHaveBeenCalled(); + + h.triggerExCommand(':w'); + await waitFor(() => expect(h.onWin).toHaveBeenCalled()); + }); + + it('does not call onWin if none of the possibleCommands have been used', () => { + const h = renderEditor({ + value: 'correct', + finalText: 'correct', + possibleCommands: [':w', ':wq'], + }); + h.triggerContentChange(); + expect(h.onWin).not.toHaveBeenCalled(); + }); + + it('calls onWin once any one of the possibleCommands is used', async () => { + const h = renderEditor({ + value: 'correct', + finalText: 'correct', + possibleCommands: [':w', ':wq'], + }); + h.triggerExCommand(':w'); + await waitFor(() => expect(h.onWin).toHaveBeenCalled()); + }); + + it('does not call onWin until the required keystroke is pressed', async () => { + const keystrokes = ['j']; + const h = renderEditor({ value: 'correct', finalText: 'correct', keystrokes }); + h.triggerContentChange(); + expect(h.onWin).not.toHaveBeenCalled(); + + h.triggerKeydown('j'); + await waitFor(() => expect(h.onWin).toHaveBeenCalled()); + }); + + it('does not call onWin when an unrelated key is pressed', () => { + const keystrokes = ['j']; + const h = renderEditor({ value: 'correct', finalText: 'correct', keystrokes }); + h.triggerKeydown('k'); + expect(h.onWin).not.toHaveBeenCalled(); + }); + it('does not call onWin when finalText does not match', () => { + const h = renderEditor({ value: 'wrong answer', finalText: 'correct answer' }); + h.triggerContentChange(); + expect(h.onWin).not.toHaveBeenCalled(); + }); + + it('calls onWin when content contains the target string', async () => { + const h = renderEditor({ value: 'hello world', finalTextContains: 'hello' }); + h.triggerContentChange(); + await waitFor(() => expect(h.onWin).toHaveBeenCalled()); + }); + + it('does not call onWin when content does not contain the target string', () => { + const h = renderEditor({ value: 'goodbye world', finalTextContains: 'hello' }); + h.triggerContentChange(); + expect(h.onWin).not.toHaveBeenCalled(); + }); + + it('calls onWin when content matches the regex', async () => { + const h = renderEditor({ value: 'hello world', finalTextRegex: /hello/ }); + h.triggerContentChange(); + await waitFor(() => expect(h.onWin).toHaveBeenCalled()); + }); + + it('does not call onWin when content does not match the regex', () => { + const h = renderEditor({ value: 'goodbye world', finalTextRegex: /hello/ }); + h.triggerContentChange(); + expect(h.onWin).not.toHaveBeenCalled(); + }); + + it('calls onWin when cursor reaches the required line and column', async () => { + const h = renderEditor({ cursorLine: 3, cursorCol: 5 }); + h.triggerCursor(3, 5); + await waitFor(() => expect(h.onWin).toHaveBeenCalled()); + }); + + it('does not call onWin when cursor is on the wrong line', () => { + const h = renderEditor({ cursorLine: 3, cursorCol: 5 }); + h.triggerCursor(1, 5); + expect(h.onWin).not.toHaveBeenCalled(); + }); + + it('does not call onWin when cursor is on the wrong column', () => { + const h = renderEditor({ cursorLine: 3, cursorCol: 5 }); + h.triggerCursor(3, 1); + expect(h.onWin).not.toHaveBeenCalled(); + }); + + it('calls onWin when the required mode is normal (the default)', async () => { + const h = renderEditor({ value: 'correct', finalText: 'correct', mode: 'normal' }); + h.triggerContentChange(); + await waitFor(() => expect(h.onWin).toHaveBeenCalled()); + }); + + it('does not call onWin when mode requirement is not yet met', () => { + const h = renderEditor({ value: 'correct', finalText: 'correct', mode: 'insert' }); + h.triggerContentChange(); + expect(h.onWin).not.toHaveBeenCalled(); + }); + + it('does not call onWin when canWin is false even if all conditions are met', () => { + const h = renderEditor({ value: 'correct', finalText: 'correct', canWin: false }); + h.triggerContentChange(); + expect(h.onWin).not.toHaveBeenCalled(); + }); + + it('canWin blocks win even when triggered by cursor move', () => { + const h = renderEditor({ cursorLine: 1, cursorCol: 1, canWin: false }); + h.triggerCursor(1, 1); + expect(h.onWin).not.toHaveBeenCalled(); + }); + + it('does not call onWin until the required command is used', async () => { + const h = renderEditor({ value: 'correct', finalText: 'correct', commands: [':w'] }); + h.triggerContentChange(); + expect(h.onWin).not.toHaveBeenCalled(); + + h.triggerExCommand(':w'); + await waitFor(() => expect(h.onWin).toHaveBeenCalled()); + }); + + it('does not call onWin if none of the possibleCommands have been used', () => { + const h = renderEditor({ + value: 'correct', + finalText: 'correct', + possibleCommands: [':w', ':wq'], + }); + h.triggerContentChange(); + expect(h.onWin).not.toHaveBeenCalled(); + }); + + it('calls onWin once any one of the possibleCommands is used', async () => { + const h = renderEditor({ + value: 'correct', + finalText: 'correct', + possibleCommands: [':w', ':wq'], + }); + h.triggerExCommand(':w'); + await waitFor(() => expect(h.onWin).toHaveBeenCalled()); + }); + + it('does not call onWin until the required keystroke is pressed', async () => { + const keystrokes = ['j']; + const h = renderEditor({ value: 'correct', finalText: 'correct', keystrokes }); + h.triggerContentChange(); + expect(h.onWin).not.toHaveBeenCalled(); + + h.triggerKeydown('j'); + await waitFor(() => expect(h.onWin).toHaveBeenCalled()); + }); + + it('does not call onWin when an unrelated key is pressed', () => { + const keystrokes = ['j']; + const h = renderEditor({ value: 'correct', finalText: 'correct', keystrokes }); + h.triggerKeydown('k'); + expect(h.onWin).not.toHaveBeenCalled(); + }); + + it('allows winning again after a reset', async () => { + const h = renderEditor({ value: 'correct', finalText: 'correct' }); + h.triggerContentChange(); + await waitFor(() => expect(h.onWin).toHaveBeenCalledTimes(1)); + + fireEvent.click(screen.getByRole('button', { name: /reset level/i })); + + h.triggerContentChange(); + await waitFor(() => expect(h.onWin).toHaveBeenCalledTimes(2)); + }); + + it('resets command tracking so the command needs to be used again after reset', async () => { + const h = renderEditor({ value: 'correct', finalText: 'correct', commands: [':w'] }); + h.triggerExCommand(':w'); + await waitFor(() => expect(h.onWin).toHaveBeenCalledTimes(1)); + + fireEvent.click(screen.getByRole('button', { name: /reset level/i })); + + h.triggerContentChange(); + expect(h.onWin).toHaveBeenCalledTimes(1); + + h.triggerExCommand(':w'); + await waitFor(() => expect(h.onWin).toHaveBeenCalledTimes(2)); + }); + + it('command-done after a colon command still checks win conditions', async () => { + const h = renderEditor({ value: 'correct', finalText: 'correct' }); + h.triggerVimKey(':'); + h.triggerCommandDone(); + await waitFor(() => expect(h.onWin).toHaveBeenCalled()); + }); + + it('clears colon flag on escape so command-done goes through normal branch', async () => { + const h = renderEditor({ value: 'correct', finalText: 'correct' }); + h.triggerVimKey(':'); + h.triggerVimKey(''); + h.triggerCommandDone(); + await waitFor(() => expect(h.onWin).toHaveBeenCalled()); + }); + + it('increments stroke count on command done without throwing', async () => { + const h = renderEditor({ value: 'correct', finalText: 'correct' }); + h.triggerCommandDone(); + await waitFor(() => expect(h.onWin).toHaveBeenCalledTimes(1)); + h.triggerCommandDone(); // wonRef is now true, hits the early return on line 174 + await new Promise((r) => setTimeout(r, 50)); + expect(h.onWin).toHaveBeenCalledTimes(1); + }); + + it('updates mode to insert when status bar changes', async () => { + const h = renderEditor({ value: 'correct', finalText: 'correct', mode: 'insert' }); + const editorDiv = screen.getByTestId('mock-editor'); + const statusNode = editorDiv.children[0]; // children[] skips text nodes, firstChild did not + + await act(async () => { + statusNode.textContent = '-- INSERT --'; + await new Promise((r) => setTimeout(r, 0)); + }); + + await waitFor(() => expect(h.onWin).toHaveBeenCalled()); }); }); \ No newline at end of file