diff --git a/frontend/src/components/sidebar.js b/frontend/src/components/sidebar.js index fbec50e..71190a3 100644 --- a/frontend/src/components/sidebar.js +++ b/frontend/src/components/sidebar.js @@ -17,8 +17,8 @@ function LevelCheck({ levelNum = 0, levelDesc = "", theme = "dark" }) { ? [ "text-green-400", "hover:text-green-300", - "text-[1rem]", - "leading-8", + "text-sm", + "leading-6", "font-medium", "transition", "duration-300", @@ -27,8 +27,8 @@ function LevelCheck({ levelNum = 0, levelDesc = "", theme = "dark" }) { : [ "text-green-600", "hover:text-green-700", - "text-[1rem]", - "leading-8", + "text-sm", + "leading-6", "font-medium", "transition", "duration-300", @@ -46,7 +46,7 @@ function LevelCheck({ levelNum = 0, levelDesc = "", theme = "dark" }) { ? [ "text-white", "hover:text-gray-300", - "text-[1rem]", + "text-sm", "leading-8", "font-medium", "transition", @@ -56,8 +56,8 @@ function LevelCheck({ levelNum = 0, levelDesc = "", theme = "dark" }) { : [ "text-slate-800", "hover:text-slate-600", - "text-[1rem]", - "leading-8", + "text-sm", + "leading-6", "font-medium", "transition", "duration-300", @@ -90,7 +90,7 @@ export default function Sidebar() { theme === "dark" ? [ "w-full", - "h-[96vh]", + "max-h-[96dvh]", "rounded-3xl", "bg-gray-950", "border", @@ -200,14 +200,14 @@ export default function Sidebar() { theme === "dark" ? [ "pl-4", - "text-2xl", + "text-lg", "font-semibold", "text-white", "tracking-wide" ].join(" ") : [ "pl-4", - "text-2xl", + "text-lg", "font-semibold", "text-slate-900", "tracking-wide" @@ -241,6 +241,28 @@ export default function Sidebar() { "ease-in-out" ].join(" "); + const bottomClass = + theme === "dark" + ? [ + "absolute", + "left-5", + "bottom-5", + "flex", + "items-center", + "gap-3", + "text-2xl", + "text-white" + ].join(" ") + : [ + "absolute", + "left-5", + "bottom-5", + "flex", + "items-center", + "gap-3", + "text-lg", + "text-slate-900" + ].join(" "); return (
@@ -356,7 +378,7 @@ export default function Sidebar() { > Challenges {challengesOpen ? "▾" : "▸"} -
+ {challengesOpen && (

@@ -375,7 +397,7 @@ export default function Sidebar() { className="hover:text-red-400 transition" > Logout - +
diff --git a/frontend/src/editor/vimEditor.test.js b/frontend/src/editor/vimEditor.test.js deleted file mode 100644 index 62ee0a4..0000000 --- a/frontend/src/editor/vimEditor.test.js +++ /dev/null @@ -1,515 +0,0 @@ -/** - * @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()); - }); -});