|
| 1 | +import { beforeEach, describe, expect, it, vi } from 'vitest'; |
| 2 | +import { |
| 3 | + createInitialModel, |
| 4 | + type AppModel, |
| 5 | + type KeyEvent, |
| 6 | + type ProblemDetail, |
| 7 | + type ProblemScreenModel, |
| 8 | +} from '../../tui/types.js'; |
| 9 | +import { update } from '../../tui/update.js'; |
| 10 | +import * as ProblemScreen from '../../tui/screens/problem/index.js'; |
| 11 | +import { view as renderProblemView } from '../../tui/screens/problem/view.js'; |
| 12 | + |
| 13 | +const mockBookmarks = new Set<string>(); |
| 14 | + |
| 15 | +const mockSnapshots = [ |
| 16 | + { |
| 17 | + id: 1, |
| 18 | + name: 'snap-1', |
| 19 | + fileName: '1_snap-1.ts', |
| 20 | + language: 'typescript', |
| 21 | + lines: 10, |
| 22 | + createdAt: '2026-01-01T00:00:00.000Z', |
| 23 | + }, |
| 24 | + { |
| 25 | + id: 2, |
| 26 | + name: 'snap-2', |
| 27 | + fileName: '2_snap-2.ts', |
| 28 | + language: 'typescript', |
| 29 | + lines: 15, |
| 30 | + createdAt: '2026-01-02T00:00:00.000Z', |
| 31 | + }, |
| 32 | +]; |
| 33 | + |
| 34 | +vi.mock('../../storage/bookmarks.js', () => ({ |
| 35 | + bookmarks: { |
| 36 | + has: vi.fn((id: string) => mockBookmarks.has(id)), |
| 37 | + add: vi.fn((id: string) => { |
| 38 | + mockBookmarks.add(id); |
| 39 | + return true; |
| 40 | + }), |
| 41 | + remove: vi.fn((id: string) => { |
| 42 | + mockBookmarks.delete(id); |
| 43 | + return true; |
| 44 | + }), |
| 45 | + list: vi.fn(() => Array.from(mockBookmarks)), |
| 46 | + count: vi.fn(() => mockBookmarks.size), |
| 47 | + clear: vi.fn(() => { |
| 48 | + mockBookmarks.clear(); |
| 49 | + }), |
| 50 | + }, |
| 51 | +})); |
| 52 | + |
| 53 | +vi.mock('../../storage/snapshots.js', () => ({ |
| 54 | + snapshotStorage: { |
| 55 | + list: vi.fn(() => mockSnapshots), |
| 56 | + }, |
| 57 | +})); |
| 58 | + |
| 59 | +function key(name: string, sequence = name): KeyEvent { |
| 60 | + return { |
| 61 | + name, |
| 62 | + sequence, |
| 63 | + ctrl: false, |
| 64 | + meta: false, |
| 65 | + shift: /^[A-Z]$/.test(name), |
| 66 | + }; |
| 67 | +} |
| 68 | + |
| 69 | +function makeProblemDetail(hints: string[] = ['hint-1']): ProblemDetail { |
| 70 | + return { |
| 71 | + questionId: '1', |
| 72 | + questionFrontendId: '1', |
| 73 | + title: 'Two Sum', |
| 74 | + titleSlug: 'two-sum', |
| 75 | + difficulty: 'Easy', |
| 76 | + isPaidOnly: false, |
| 77 | + acRate: 59.1, |
| 78 | + topicTags: [{ name: 'Array', slug: 'array' }], |
| 79 | + status: null, |
| 80 | + content: Array.from({ length: 80 }, (_, i) => `Line ${i + 1}: example statement`).join('\n'), |
| 81 | + codeSnippets: [], |
| 82 | + sampleTestCase: '[2,7,11,15]\n9', |
| 83 | + exampleTestcases: '[2,7,11,15]\n9', |
| 84 | + hints, |
| 85 | + companyTags: [], |
| 86 | + stats: '{}', |
| 87 | + }; |
| 88 | +} |
| 89 | + |
| 90 | +function makeProblemApp(detail: ProblemDetail = makeProblemDetail()): AppModel { |
| 91 | + const [initialProblemModel] = ProblemScreen.init(detail.titleSlug); |
| 92 | + const [loadedProblemModel] = ProblemScreen.update( |
| 93 | + { type: 'PROBLEM_DETAIL_LOADED', detail }, |
| 94 | + initialProblemModel, |
| 95 | + 32, |
| 96 | + 120 |
| 97 | + ); |
| 98 | + |
| 99 | + const model = createInitialModel('tester'); |
| 100 | + return { |
| 101 | + ...model, |
| 102 | + isCheckingAuth: false, |
| 103 | + terminalWidth: 120, |
| 104 | + terminalHeight: 32, |
| 105 | + screenState: { screen: 'problem', model: loadedProblemModel }, |
| 106 | + history: [{ screen: 'home', model: { menuIndex: 0 } }], |
| 107 | + needsRender: false, |
| 108 | + }; |
| 109 | +} |
| 110 | + |
| 111 | +function getProblemModel(model: AppModel): ProblemScreenModel { |
| 112 | + if (model.screenState.screen !== 'problem') { |
| 113 | + throw new Error('Expected problem screen'); |
| 114 | + } |
| 115 | + return model.screenState.model; |
| 116 | +} |
| 117 | + |
| 118 | +describe('TUI Problem Screen', () => { |
| 119 | + beforeEach(() => { |
| 120 | + mockBookmarks.clear(); |
| 121 | + }); |
| 122 | + |
| 123 | + it('closes drawer before navigating back on escape', () => { |
| 124 | + const app = makeProblemApp(makeProblemDetail(['hint one'])); |
| 125 | + const [afterOpen] = update({ type: 'KEY_PRESS', key: key('h') }, app); |
| 126 | + expect(afterOpen.screenState.screen).toBe('problem'); |
| 127 | + expect(getProblemModel(afterOpen).drawerMode).toBe('hint'); |
| 128 | + |
| 129 | + const [afterCloseDrawer] = update({ type: 'KEY_PRESS', key: key('escape', '\x1b') }, afterOpen); |
| 130 | + expect(afterCloseDrawer.screenState.screen).toBe('problem'); |
| 131 | + expect(getProblemModel(afterCloseDrawer).drawerMode).toBe('none'); |
| 132 | + |
| 133 | + const [afterBack] = update({ type: 'KEY_PRESS', key: key('escape', '\x1b') }, afterCloseDrawer); |
| 134 | + expect(afterBack.screenState.screen).toBe('home'); |
| 135 | + }); |
| 136 | + |
| 137 | + it('routes scroll input to focused region and toggles focus with Tab', () => { |
| 138 | + const longHint = 'hint '.repeat(500); |
| 139 | + const app = makeProblemApp(makeProblemDetail([longHint])); |
| 140 | + |
| 141 | + const [withHintDrawer] = update({ type: 'KEY_PRESS', key: key('h') }, app); |
| 142 | + expect(getProblemModel(withHintDrawer).focusRegion).toBe('drawer'); |
| 143 | + |
| 144 | + const [drawerScrolled] = update( |
| 145 | + { type: 'KEY_PRESS', key: key('down', '\x1b[B') }, |
| 146 | + withHintDrawer |
| 147 | + ); |
| 148 | + expect(getProblemModel(drawerScrolled).drawerScrollOffset).toBeGreaterThan(0); |
| 149 | + expect(getProblemModel(drawerScrolled).scrollOffset).toBe(0); |
| 150 | + |
| 151 | + const [bodyFocused] = update({ type: 'KEY_PRESS', key: key('tab', '\t') }, drawerScrolled); |
| 152 | + expect(getProblemModel(bodyFocused).focusRegion).toBe('body'); |
| 153 | + |
| 154 | + const [bodyScrolled] = update({ type: 'KEY_PRESS', key: key('down', '\x1b[B') }, bodyFocused); |
| 155 | + expect(getProblemModel(bodyScrolled).scrollOffset).toBeGreaterThan(0); |
| 156 | + |
| 157 | + const [drawerFocusedAgain] = update({ type: 'KEY_PRESS', key: key('tab', '\t') }, bodyScrolled); |
| 158 | + expect(getProblemModel(drawerFocusedAgain).focusRegion).toBe('drawer'); |
| 159 | + }); |
| 160 | + |
| 161 | + it('keeps global action shortcuts active while drawer is open', () => { |
| 162 | + const app = makeProblemApp(); |
| 163 | + const [withHints] = update({ type: 'KEY_PRESS', key: key('h') }, app); |
| 164 | + expect(getProblemModel(withHints).drawerMode).toBe('hint'); |
| 165 | + |
| 166 | + const [withTestStatus, testCmd] = update({ type: 'KEY_PRESS', key: key('t') }, withHints); |
| 167 | + expect(getProblemModel(withTestStatus).drawerMode).toBe('status'); |
| 168 | + expect(testCmd.type).toBe('CMD_TEST_SOLUTION'); |
| 169 | + |
| 170 | + const [withSubmissions, submissionsCmd] = update( |
| 171 | + { type: 'KEY_PRESS', key: key('H') }, |
| 172 | + withTestStatus |
| 173 | + ); |
| 174 | + expect(getProblemModel(withSubmissions).drawerMode).toBe('submissions'); |
| 175 | + expect(submissionsCmd.type).toBe('CMD_FETCH_SUBMISSIONS'); |
| 176 | + }); |
| 177 | + |
| 178 | + it('keeps hint index and snapshot cursor within valid bounds', () => { |
| 179 | + const [initialModel] = ProblemScreen.init('two-sum'); |
| 180 | + const [loaded] = ProblemScreen.update( |
| 181 | + { type: 'PROBLEM_DETAIL_LOADED', detail: makeProblemDetail(['first hint', 'second hint']) }, |
| 182 | + initialModel, |
| 183 | + 32, |
| 184 | + 120 |
| 185 | + ); |
| 186 | + const [hintOpen] = ProblemScreen.update({ type: 'PROBLEM_TOGGLE_HINT' }, loaded, 32, 120); |
| 187 | + |
| 188 | + let hintModel = hintOpen; |
| 189 | + for (let i = 0; i < 10; i++) { |
| 190 | + [hintModel] = ProblemScreen.update({ type: 'PROBLEM_NEXT_HINT' }, hintModel, 32, 120); |
| 191 | + } |
| 192 | + expect(hintModel.activeHintIndex).toBe(1); |
| 193 | + |
| 194 | + for (let i = 0; i < 10; i++) { |
| 195 | + [hintModel] = ProblemScreen.update({ type: 'PROBLEM_PREV_HINT' }, hintModel, 32, 120); |
| 196 | + } |
| 197 | + expect(hintModel.activeHintIndex).toBe(0); |
| 198 | + |
| 199 | + const [snapshotsOpen] = ProblemScreen.update({ type: 'PROBLEM_SHOW_SNAPSHOTS' }, hintModel, 32, 120); |
| 200 | + let snapshotsModel = snapshotsOpen; |
| 201 | + for (let i = 0; i < 10; i++) { |
| 202 | + [snapshotsModel] = ProblemScreen.update({ type: 'PROBLEM_SNAPSHOT_DOWN' }, snapshotsModel, 32, 120); |
| 203 | + } |
| 204 | + expect(snapshotsModel.snapshotCursor).toBe(mockSnapshots.length - 1); |
| 205 | + |
| 206 | + for (let i = 0; i < 10; i++) { |
| 207 | + [snapshotsModel] = ProblemScreen.update({ type: 'PROBLEM_SNAPSHOT_UP' }, snapshotsModel, 32, 120); |
| 208 | + } |
| 209 | + expect(snapshotsModel.snapshotCursor).toBe(0); |
| 210 | + }); |
| 211 | + |
| 212 | + it('renders problem view without side split pane when drawer is closed', () => { |
| 213 | + const [initialModel] = ProblemScreen.init('two-sum'); |
| 214 | + const [loaded] = ProblemScreen.update( |
| 215 | + { type: 'PROBLEM_DETAIL_LOADED', detail: makeProblemDetail(['hint one']) }, |
| 216 | + initialModel, |
| 217 | + 28, |
| 218 | + 100 |
| 219 | + ); |
| 220 | + |
| 221 | + const output = renderProblemView(loaded, 100, 28); |
| 222 | + expect(output).not.toContain('│'); |
| 223 | + }); |
| 224 | +}); |
0 commit comments