diff --git a/frontend/src/editor/vimEditor.test.js b/frontend/src/editor/vimEditor.test.js
new file mode 100644
index 0000000..143604c
--- /dev/null
+++ b/frontend/src/editor/vimEditor.test.js
@@ -0,0 +1,515 @@
+/**
+ * @jest-environment jsdom
+ */
+
+const React = require('react');
+const { render, screen, fireEvent, waitFor, act } = require('@testing-library/react');
+require('@testing-library/jest-dom');
+
+// 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(),
+ },
+ },
+}));
+
+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;
+});
+
+
+// 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(() => {
+ // 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({});
+});
+
+// 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('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('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