Skip to content

Commit 8958bb6

Browse files
feat(tests): enhance unit tests for AudioMixer, playbackRouter, songlengths, and source navigation
- Added tests for UltiSID 2 identity change and address handling in AudioMixer. - Introduced tests for tryFetchUltimateSidBlob in playbackRouter, covering path normalization and error handling. - Expanded songlengths tests to cover various edge cases, including empty durations and error handling during resolution. - Enhanced InMemoryTextBackend tests to cover edge cases, including path normalization and duplicate entries. - Improved ftpSourceAdapter tests to handle empty entries and cache expiration scenarios. - Added localSourceAdapter tests for SAF entries and error handling. - Enhanced LocalFsSongSource tests for duration resolution and error handling during metadata scans. - Added fetchTrace and traceSession tests for event suppression and error handling. - Introduced controlType tests for checkbox mapping and control kind inference.
1 parent 5943a0a commit 8958bb6

23 files changed

+2756
-22
lines changed

tests/unit/c64api.branches.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -980,4 +980,104 @@ describe('c64api branches', () => {
980980
);
981981
consoleSpy.mockRestore();
982982
});
983+
984+
// #38: readMemory response not OK
985+
it('throws when readMemory response is not ok', async () => {
986+
const fetchMock = getFetchMock();
987+
fetchMock.mockResolvedValue(
988+
new Response('fail', { status: 404, statusText: 'Not Found' }),
989+
);
990+
991+
const api = new C64API('http://c64u');
992+
await expect(api.readMemory('0400', 4)).rejects.toThrow('readMemory failed: HTTP 404');
993+
});
994+
995+
// #39: readMemory null content-type falls through to JSON path with no data
996+
it('returns empty Uint8Array when readMemory JSON payload has no data field', async () => {
997+
const fetchMock = getFetchMock();
998+
fetchMock.mockResolvedValue(
999+
new Response(JSON.stringify({}), {
1000+
status: 200,
1001+
// Intentionally no content-type header → null → coalesces to ''
1002+
headers: {},
1003+
}),
1004+
);
1005+
1006+
const api = new C64API('http://c64u');
1007+
const result = await api.readMemory('0400', 4);
1008+
expect(result).toBeInstanceOf(Uint8Array);
1009+
expect(result.length).toBe(0);
1010+
});
1011+
1012+
// #40: readMemory JSON payload with base64 string data
1013+
it('decodes base64 string data from readMemory JSON response', async () => {
1014+
const fetchMock = getFetchMock();
1015+
// btoa('\x00\x01\x02') → 'AAEC'
1016+
const encoded = btoa(String.fromCharCode(0, 1, 2));
1017+
fetchMock.mockResolvedValue(
1018+
new Response(JSON.stringify({ data: encoded }), {
1019+
status: 200,
1020+
headers: { 'content-type': 'application/json' },
1021+
}),
1022+
);
1023+
1024+
const api = new C64API('http://c64u');
1025+
const result = await api.readMemory('0400', 3);
1026+
expect(result).toBeInstanceOf(Uint8Array);
1027+
expect(result[0]).toBe(0);
1028+
expect(result[1]).toBe(1);
1029+
expect(result[2]).toBe(2);
1030+
});
1031+
1032+
// #41: readMemory JSON payload with number array data
1033+
it('returns Uint8Array from readMemory JSON number array data', async () => {
1034+
const fetchMock = getFetchMock();
1035+
fetchMock.mockResolvedValue(
1036+
new Response(JSON.stringify({ data: [10, 20, 30] }), {
1037+
status: 200,
1038+
headers: { 'content-type': 'application/json' },
1039+
}),
1040+
);
1041+
1042+
const api = new C64API('http://c64u');
1043+
const result = await api.readMemory('0400', 3);
1044+
expect(result).toBeInstanceOf(Uint8Array);
1045+
expect(result[0]).toBe(10);
1046+
expect(result[1]).toBe(20);
1047+
expect(result[2]).toBe(30);
1048+
});
1049+
1050+
// #42: readMemory octet-stream binary response
1051+
it('returns binary data from readMemory octet-stream response', async () => {
1052+
const fetchMock = getFetchMock();
1053+
const bytes = new Uint8Array([5, 6, 7, 8]);
1054+
fetchMock.mockResolvedValue(
1055+
new Response(bytes.buffer, {
1056+
status: 200,
1057+
headers: { 'content-type': 'application/octet-stream' },
1058+
}),
1059+
);
1060+
1061+
const api = new C64API('http://c64u');
1062+
const result = await api.readMemory('0400', 4);
1063+
expect(result).toBeInstanceOf(Uint8Array);
1064+
expect(Array.from(result)).toEqual([5, 6, 7, 8]);
1065+
});
1066+
1067+
// #43: readMemory application/binary response
1068+
it('returns binary data from readMemory application/binary response', async () => {
1069+
const fetchMock = getFetchMock();
1070+
const bytes = new Uint8Array([11, 22]);
1071+
fetchMock.mockResolvedValue(
1072+
new Response(bytes.buffer, {
1073+
status: 200,
1074+
headers: { 'content-type': 'application/binary' },
1075+
}),
1076+
);
1077+
1078+
const api = new C64API('http://c64u');
1079+
const result = await api.readMemory('0400', 2);
1080+
expect(result).toBeInstanceOf(Uint8Array);
1081+
expect(Array.from(result)).toEqual([11, 22]);
1082+
});
9831083
});

tests/unit/components/AlphabetScrollbar.test.tsx

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,4 +101,258 @@ describe('AlphabetScrollbar', () => {
101101

102102
vi.useRealTimers();
103103
});
104+
105+
it('maps items with numeric/symbol titles to # category', async () => {
106+
const container = createScrollContainer();
107+
setScrollMetrics(container, 1000, 100);
108+
const onLetterSelect = vi.fn();
109+
const items = [
110+
{ id: 'num', title: '1SongWithNumber' },
111+
{ id: 'empty', title: '' },
112+
];
113+
114+
render(
115+
<AlphabetScrollbar
116+
items={items}
117+
scrollContainerRef={{ current: container }}
118+
onLetterSelect={onLetterSelect}
119+
onScrollToIndex={vi.fn()}
120+
/>,
121+
);
122+
123+
const touchArea = await screen.findByTestId('alphabet-touch-area');
124+
Object.defineProperty(touchArea, 'getBoundingClientRect', {
125+
value: () => ({ top: 0, left: 0, right: 0, bottom: 260, width: 20, height: 260, x: 0, y: 0, toJSON: () => '' }),
126+
});
127+
128+
// Touch at position that maps to '#' (first letter at top ~0 index)
129+
fireEvent.touchStart(touchArea, { touches: [{ clientY: 1 }] });
130+
expect(onLetterSelect).toHaveBeenCalledWith('#');
131+
});
132+
133+
it('scrollToLetter returns early when letter has no items', async () => {
134+
const container = createScrollContainer();
135+
setScrollMetrics(container, 1000, 100);
136+
const onLetterSelect = vi.fn();
137+
const items = [
138+
{ id: 'alpha', title: 'Alpha' },
139+
];
140+
141+
render(
142+
<AlphabetScrollbar
143+
items={items}
144+
scrollContainerRef={{ current: container }}
145+
onLetterSelect={onLetterSelect}
146+
onScrollToIndex={vi.fn()}
147+
/>,
148+
);
149+
150+
const touchArea = await screen.findByTestId('alphabet-touch-area');
151+
Object.defineProperty(touchArea, 'getBoundingClientRect', {
152+
value: () => ({ top: 0, left: 0, right: 0, bottom: 260, width: 20, height: 260, x: 0, y: 0, toJSON: () => '' }),
153+
});
154+
155+
// Touch at position that maps to 'Z' (last letter, near bottom)
156+
fireEvent.touchStart(touchArea, { touches: [{ clientY: 255 }] });
157+
// 'Z' is not in indices (only 'Alpha' starting with 'A' exists)
158+
// scrollToLetter should return early with no call
159+
// But note: '#' exists (empty title items don't exist here), and 'A' exists
160+
// 'Z' at index 26 doesn't exist → early return
161+
expect(onLetterSelect).not.toHaveBeenCalledWith('Z');
162+
});
163+
164+
it('handles scroll event that shows and schedules hide', async () => {
165+
vi.useFakeTimers();
166+
const container = createScrollContainer();
167+
setScrollMetrics(container, 1000, 100);
168+
169+
render(
170+
<AlphabetScrollbar
171+
items={[{ id: 'a', title: 'Alpha' }, { id: 'b', title: 'Beta' }]}
172+
scrollContainerRef={{ current: container }}
173+
/>,
174+
);
175+
176+
await act(async () => {
177+
await Promise.resolve();
178+
});
179+
180+
act(() => { container.dispatchEvent(new Event('scroll')); });
181+
182+
const overlay = screen.getByTestId('alphabet-overlay');
183+
expect(overlay.className).toContain('opacity-100');
184+
185+
vi.useRealTimers();
186+
});
187+
188+
it('uses onScrollToIndex callback when provided', async () => {
189+
const container = createScrollContainer();
190+
setScrollMetrics(container, 1000, 100);
191+
const onScrollToIndex = vi.fn();
192+
const items = [
193+
{ id: 'alpha', title: 'Alpha' },
194+
{ id: 'beta', title: 'Beta' },
195+
];
196+
197+
render(
198+
<AlphabetScrollbar
199+
items={items}
200+
scrollContainerRef={{ current: container }}
201+
onScrollToIndex={onScrollToIndex}
202+
/>,
203+
);
204+
205+
const touchArea = await screen.findByTestId('alphabet-touch-area');
206+
Object.defineProperty(touchArea, 'getBoundingClientRect', {
207+
value: () => ({ top: 0, left: 0, right: 0, bottom: 260, width: 20, height: 260, x: 0, y: 0, toJSON: () => '' }),
208+
});
209+
210+
// Touch at position that maps to 'A'
211+
fireEvent.touchStart(touchArea, { touches: [{ clientY: 10 }] });
212+
expect(onScrollToIndex).toHaveBeenCalledWith(0);
213+
});
214+
215+
it('uses querySelector scrollIntoView when onScrollToIndex is not provided', async () => {
216+
const container = createScrollContainer();
217+
setScrollMetrics(container, 1000, 100);
218+
const onLetterSelect = vi.fn();
219+
const items = [
220+
{ id: 'alpha', title: 'Alpha' },
221+
];
222+
223+
render(
224+
<AlphabetScrollbar
225+
items={items}
226+
scrollContainerRef={{ current: container }}
227+
onLetterSelect={onLetterSelect}
228+
/>,
229+
);
230+
231+
const touchArea = await screen.findByTestId('alphabet-touch-area');
232+
Object.defineProperty(touchArea, 'getBoundingClientRect', {
233+
value: () => ({ top: 0, left: 0, right: 0, bottom: 260, width: 20, height: 260, x: 0, y: 0, toJSON: () => '' }),
234+
});
235+
236+
// Touch 'A' zone
237+
fireEvent.touchStart(touchArea, { touches: [{ clientY: 10 }] });
238+
// querySelector would look for [data-row-id="alpha"] in container
239+
const alphaNode = container.querySelector('[data-row-id="alpha"]');
240+
expect(alphaNode).not.toBeNull();
241+
expect(onLetterSelect).toHaveBeenCalledWith('A');
242+
});
243+
244+
it('handles pointer enter and leave events', async () => {
245+
const container = createScrollContainer();
246+
setScrollMetrics(container, 1000, 100);
247+
248+
render(
249+
<AlphabetScrollbar
250+
items={[{ id: 'a', title: 'Alpha' }, { id: 'b', title: 'Beta' }]}
251+
scrollContainerRef={{ current: container }}
252+
/>,
253+
);
254+
255+
const touchArea = await screen.findByTestId('alphabet-touch-area');
256+
// pointer events should not throw
257+
fireEvent.pointerEnter(touchArea);
258+
fireEvent.pointerLeave(touchArea);
259+
260+
// overlay might be visible after pointer enter
261+
const overlay = screen.getByTestId('alphabet-overlay');
262+
expect(overlay).toBeInTheDocument();
263+
});
264+
265+
it('handles touch move and end events', async () => {
266+
const container = createScrollContainer();
267+
setScrollMetrics(container, 1000, 100);
268+
const onLetterSelect = vi.fn();
269+
270+
render(
271+
<AlphabetScrollbar
272+
items={[{ id: 'alpha', title: 'Alpha' }, { id: 'beta', title: 'Beta' }]}
273+
scrollContainerRef={{ current: container }}
274+
onLetterSelect={onLetterSelect}
275+
onScrollToIndex={vi.fn()}
276+
/>,
277+
);
278+
279+
const touchArea = await screen.findByTestId('alphabet-touch-area');
280+
Object.defineProperty(touchArea, 'getBoundingClientRect', {
281+
value: () => ({ top: 0, left: 0, right: 0, bottom: 260, width: 20, height: 260, x: 0, y: 0, toJSON: () => '' }),
282+
});
283+
284+
fireEvent.touchStart(touchArea, { touches: [{ clientY: 10 }] });
285+
fireEvent.touchMove(touchArea, { touches: [{ clientY: 30 }] });
286+
fireEvent.touchEnd(touchArea);
287+
});
288+
289+
it('handleScroll resets visibility when component is not eligible', async () => {
290+
// 0 items + no overflowing container → isEligible stays false
291+
const container = document.createElement('div');
292+
setScrollMetrics(container, 100, 100); // not scrollable
293+
294+
render(
295+
<AlphabetScrollbar
296+
items={[]}
297+
scrollContainerRef={{ current: container }}
298+
/>,
299+
);
300+
301+
await act(async () => {
302+
await Promise.resolve();
303+
});
304+
305+
// touch area is NOT rendered (not eligible)
306+
expect(screen.queryByTestId('alphabet-touch-area')).toBeNull();
307+
308+
// Fire scroll on the container — handleScroll should see isEligible=false
309+
act(() => {
310+
container.dispatchEvent(new Event('scroll'));
311+
});
312+
// No errors; overlay is not rendered
313+
expect(screen.queryByTestId('alphabet-overlay')).toBeNull();
314+
});
315+
316+
it('renders without crashing when ResizeObserver is unavailable', async () => {
317+
const originalResizeObserver = window.ResizeObserver;
318+
// Remove ResizeObserver to simulate unavailable environment (line 171 fallback)
319+
Object.defineProperty(window, 'ResizeObserver', { value: undefined, configurable: true });
320+
321+
const container = createScrollContainer();
322+
setScrollMetrics(container, 1000, 100);
323+
324+
render(
325+
<AlphabetScrollbar
326+
items={[{ id: 'alpha', title: 'Alpha' }]}
327+
scrollContainerRef={{ current: container }}
328+
/>,
329+
);
330+
331+
await act(async () => {
332+
await Promise.resolve();
333+
});
334+
335+
// Component still renders and becomes eligible
336+
const overlay = screen.getByTestId('alphabet-overlay');
337+
expect(overlay).toBeInTheDocument();
338+
339+
// Restore
340+
Object.defineProperty(window, 'ResizeObserver', { value: originalResizeObserver, configurable: true });
341+
});
342+
343+
it('handles null scrollContainerRef gracefully', async () => {
344+
render(
345+
<AlphabetScrollbar
346+
items={[{ id: 'alpha', title: 'Alpha' }]}
347+
scrollContainerRef={{ current: null }}
348+
/>,
349+
);
350+
351+
await act(async () => {
352+
await Promise.resolve();
353+
});
354+
355+
// Component renders without crash; no touch area since not eligible
356+
expect(screen.queryByTestId('alphabet-touch-area')).toBeNull();
357+
});
104358
});

0 commit comments

Comments
 (0)