Skip to content

Commit 9c4b756

Browse files
feat(tui): rebuild problem screen with single-column layout and unified drawer
1 parent bcc3f2d commit 9c4b756

6 files changed

Lines changed: 830 additions & 462 deletions

File tree

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
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

Comments
 (0)