mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
448 lines
14 KiB
TypeScript
448 lines
14 KiB
TypeScript
import React from 'react';
|
|
import { renderHook, act } from '@testing-library/react';
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { http, HttpResponse } from 'msw';
|
|
import { useDayNotes } from '../../../src/hooks/useDayNotes';
|
|
import { useTripStore } from '../../../src/store/tripStore';
|
|
import { TranslationProvider } from '../../../src/i18n/TranslationContext';
|
|
import { server } from '../../helpers/msw/server';
|
|
import { buildDayNote } from '../../helpers/factories';
|
|
import { resetAllStores } from '../../helpers/store';
|
|
|
|
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
|
React.createElement(TranslationProvider, null, children);
|
|
|
|
const TRIP_ID = 1;
|
|
const DAY_ID = 10;
|
|
|
|
describe('useDayNotes', () => {
|
|
beforeEach(() => {
|
|
resetAllStores();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('FE-HOOK-DAYNOTES-001: initial noteUi state is empty', () => {
|
|
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
|
|
expect(result.current.noteUi).toEqual({});
|
|
});
|
|
|
|
it('FE-HOOK-DAYNOTES-002: initial dayNotes comes from tripStore', () => {
|
|
const note = buildDayNote({ day_id: DAY_ID });
|
|
useTripStore.setState({ dayNotes: { [String(DAY_ID)]: [note] } });
|
|
|
|
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
|
|
expect(result.current.dayNotes[String(DAY_ID)]).toEqual([note]);
|
|
});
|
|
|
|
it('FE-HOOK-DAYNOTES-003: openAddNote sets mode=add and default sort order', () => {
|
|
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
|
|
|
|
act(() => {
|
|
result.current.openAddNote(DAY_ID, () => []);
|
|
});
|
|
|
|
expect(result.current.noteUi[DAY_ID]).toMatchObject({
|
|
mode: 'add',
|
|
text: '',
|
|
sortOrder: 0, // maxKey(-1) + 1 = 0
|
|
});
|
|
});
|
|
|
|
it('FE-HOOK-DAYNOTES-004: openAddNote calculates sortOrder as max(sortKey) + 1 from merged items', () => {
|
|
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
|
|
|
|
const getMergedItems = () => [
|
|
{ type: 'note' as const, sortKey: 5, data: buildDayNote() },
|
|
{ type: 'note' as const, sortKey: 10, data: buildDayNote() },
|
|
];
|
|
|
|
act(() => {
|
|
result.current.openAddNote(DAY_ID, getMergedItems);
|
|
});
|
|
|
|
expect(result.current.noteUi[DAY_ID]).toMatchObject({
|
|
mode: 'add',
|
|
sortOrder: 11, // max(5,10) + 1
|
|
});
|
|
});
|
|
|
|
it('FE-HOOK-DAYNOTES-005: openEditNote sets mode=edit with note data', () => {
|
|
const note = buildDayNote({ id: 99, text: 'Hello', time: '10:00', icon: 'Star' });
|
|
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
|
|
|
|
act(() => {
|
|
result.current.openEditNote(DAY_ID, note);
|
|
});
|
|
|
|
expect(result.current.noteUi[DAY_ID]).toMatchObject({
|
|
mode: 'edit',
|
|
noteId: 99,
|
|
text: 'Hello',
|
|
time: '10:00',
|
|
icon: 'Star',
|
|
});
|
|
});
|
|
|
|
it('FE-HOOK-DAYNOTES-006: cancelNote removes the UI entry for that day', () => {
|
|
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
|
|
|
|
act(() => {
|
|
result.current.openAddNote(DAY_ID, () => []);
|
|
});
|
|
expect(result.current.noteUi[DAY_ID]).toBeDefined();
|
|
|
|
act(() => {
|
|
result.current.cancelNote(DAY_ID);
|
|
});
|
|
expect(result.current.noteUi[DAY_ID]).toBeUndefined();
|
|
});
|
|
|
|
it('FE-HOOK-DAYNOTES-007: saveNote with empty text is a no-op', async () => {
|
|
const spy = vi.fn();
|
|
server.use(
|
|
http.post('/api/trips/:id/days/:dayId/notes', () => {
|
|
spy();
|
|
return HttpResponse.json({ note: buildDayNote() });
|
|
})
|
|
);
|
|
|
|
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
|
|
|
|
act(() => {
|
|
result.current.setNoteUi({ [DAY_ID]: { mode: 'add', text: '', time: '', icon: 'FileText', sortOrder: 0 } });
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.saveNote(DAY_ID);
|
|
});
|
|
|
|
expect(spy).not.toHaveBeenCalled();
|
|
// noteUi remains set (no cancelNote was called)
|
|
expect(result.current.noteUi[DAY_ID]).toBeDefined();
|
|
});
|
|
|
|
it('FE-HOOK-DAYNOTES-008: saveNote in add mode calls addDayNote and clears UI', async () => {
|
|
const createdNote = buildDayNote({ day_id: DAY_ID, text: 'New note' });
|
|
server.use(
|
|
http.post('/api/trips/:id/days/:dayId/notes', async () => {
|
|
return HttpResponse.json({ note: createdNote });
|
|
})
|
|
);
|
|
|
|
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
|
|
|
|
act(() => {
|
|
result.current.setNoteUi({
|
|
[DAY_ID]: { mode: 'add', text: 'New note', time: '', icon: 'FileText', sortOrder: 0 },
|
|
});
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.saveNote(DAY_ID);
|
|
});
|
|
|
|
// UI should be cleared after successful save
|
|
expect(result.current.noteUi[DAY_ID]).toBeUndefined();
|
|
});
|
|
|
|
it('FE-HOOK-DAYNOTES-009: saveNote in edit mode calls updateDayNote and clears UI', async () => {
|
|
const noteId = 55;
|
|
const updatedNote = buildDayNote({ id: noteId, day_id: DAY_ID, text: 'Updated' });
|
|
server.use(
|
|
http.put('/api/trips/:id/days/:dayId/notes/:noteId', async () => {
|
|
return HttpResponse.json({ note: updatedNote });
|
|
})
|
|
);
|
|
|
|
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
|
|
|
|
act(() => {
|
|
result.current.setNoteUi({
|
|
[DAY_ID]: { mode: 'edit', noteId, text: 'Updated', time: '', icon: 'FileText' },
|
|
});
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.saveNote(DAY_ID);
|
|
});
|
|
|
|
expect(result.current.noteUi[DAY_ID]).toBeUndefined();
|
|
});
|
|
|
|
it('FE-HOOK-DAYNOTES-010: deleteNote calls deleteDayNote on the store', async () => {
|
|
const note = buildDayNote({ id: 77, day_id: DAY_ID });
|
|
useTripStore.setState({ dayNotes: { [String(DAY_ID)]: [note] } });
|
|
|
|
server.use(
|
|
http.delete('/api/trips/:id/days/:dayId/notes/:noteId', () => {
|
|
return HttpResponse.json({ success: true });
|
|
})
|
|
);
|
|
|
|
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
|
|
|
|
await act(async () => {
|
|
await result.current.deleteNote(DAY_ID, 77);
|
|
});
|
|
|
|
// Note should be removed from the store
|
|
const dayNotes = useTripStore.getState().dayNotes[String(DAY_ID)] || [];
|
|
expect(dayNotes.find((n) => n.id === 77)).toBeUndefined();
|
|
});
|
|
|
|
it('FE-HOOK-DAYNOTES-011: saveNote on API error shows toast', async () => {
|
|
const toastSpy = vi.fn();
|
|
window.__addToast = toastSpy;
|
|
|
|
server.use(
|
|
http.post('/api/trips/:id/days/:dayId/notes', () => {
|
|
return HttpResponse.json({ error: 'Server error' }, { status: 500 });
|
|
})
|
|
);
|
|
|
|
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
|
|
|
|
act(() => {
|
|
result.current.setNoteUi({
|
|
[DAY_ID]: { mode: 'add', text: 'Test note', time: '', icon: 'FileText', sortOrder: 0 },
|
|
});
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.saveNote(DAY_ID);
|
|
});
|
|
|
|
expect(toastSpy).toHaveBeenCalledWith(expect.any(String), 'error', undefined);
|
|
delete window.__addToast;
|
|
});
|
|
|
|
it('FE-HOOK-DAYNOTES-012: deleteNote on API error shows toast', async () => {
|
|
const toastSpy = vi.fn();
|
|
window.__addToast = toastSpy;
|
|
|
|
const note = buildDayNote({ id: 88, day_id: DAY_ID });
|
|
useTripStore.setState({ dayNotes: { [String(DAY_ID)]: [note] } });
|
|
|
|
server.use(
|
|
http.delete('/api/trips/:id/days/:dayId/notes/:noteId', () => {
|
|
return HttpResponse.json({ error: 'Server error' }, { status: 500 });
|
|
})
|
|
);
|
|
|
|
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
|
|
|
|
await act(async () => {
|
|
await result.current.deleteNote(DAY_ID, 88);
|
|
});
|
|
|
|
expect(toastSpy).toHaveBeenCalledWith(expect.any(String), 'error', undefined);
|
|
delete window.__addToast;
|
|
});
|
|
|
|
it('FE-HOOK-DAYNOTES-013: moveNote up calculates midpoint sort order', async () => {
|
|
let capturedBody: Record<string, unknown> = {};
|
|
server.use(
|
|
http.put('/api/trips/:id/days/:dayId/notes/:noteId', async ({ request }) => {
|
|
capturedBody = await request.json() as Record<string, unknown>;
|
|
return HttpResponse.json({ note: buildDayNote() });
|
|
})
|
|
);
|
|
|
|
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
|
|
|
|
const noteA = buildDayNote({ id: 1 });
|
|
const noteB = buildDayNote({ id: 2 });
|
|
const noteC = buildDayNote({ id: 3 });
|
|
|
|
// merged items with sortKeys 0, 2, 4
|
|
const getMergedItems = () => [
|
|
{ type: 'note' as const, sortKey: 0, data: noteA },
|
|
{ type: 'note' as const, sortKey: 2, data: noteB },
|
|
{ type: 'note' as const, sortKey: 4, data: noteC },
|
|
];
|
|
|
|
// Move noteC (idx=2) up → new order should be between idx=0 and idx=1 → (0+2)/2 = 1
|
|
await act(async () => {
|
|
await result.current.moveNote(DAY_ID, noteC.id, 'up', getMergedItems);
|
|
});
|
|
|
|
expect(capturedBody.sort_order).toBe(1); // (sortKey[0] + sortKey[1]) / 2 = (0+2)/2
|
|
});
|
|
|
|
it('FE-HOOK-DAYNOTES-014: moveNote down calculates midpoint sort order', async () => {
|
|
let capturedBody: Record<string, unknown> = {};
|
|
server.use(
|
|
http.put('/api/trips/:id/days/:dayId/notes/:noteId', async ({ request }) => {
|
|
capturedBody = await request.json() as Record<string, unknown>;
|
|
return HttpResponse.json({ note: buildDayNote() });
|
|
})
|
|
);
|
|
|
|
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
|
|
|
|
const noteA = buildDayNote({ id: 1 });
|
|
const noteB = buildDayNote({ id: 2 });
|
|
const noteC = buildDayNote({ id: 3 });
|
|
|
|
const getMergedItems = () => [
|
|
{ type: 'note' as const, sortKey: 0, data: noteA },
|
|
{ type: 'note' as const, sortKey: 2, data: noteB },
|
|
{ type: 'note' as const, sortKey: 4, data: noteC },
|
|
];
|
|
|
|
// Move noteA (idx=0) down → new order between idx=1 and idx=2 → (2+4)/2 = 3
|
|
await act(async () => {
|
|
await result.current.moveNote(DAY_ID, noteA.id, 'down', getMergedItems);
|
|
});
|
|
|
|
expect(capturedBody.sort_order).toBe(3); // (sortKey[1] + sortKey[2]) / 2 = (2+4)/2
|
|
});
|
|
|
|
it('FE-HOOK-DAYNOTES-015: moveNote up at index 0 is a no-op', async () => {
|
|
const spy = vi.fn();
|
|
server.use(
|
|
http.put('/api/trips/:id/days/:dayId/notes/:noteId', () => {
|
|
spy();
|
|
return HttpResponse.json({ note: buildDayNote() });
|
|
})
|
|
);
|
|
|
|
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
|
|
|
|
const noteA = buildDayNote({ id: 1 });
|
|
const getMergedItems = () => [
|
|
{ type: 'note' as const, sortKey: 0, data: noteA },
|
|
];
|
|
|
|
await act(async () => {
|
|
await result.current.moveNote(DAY_ID, noteA.id, 'up', getMergedItems);
|
|
});
|
|
|
|
expect(spy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('FE-HOOK-DAYNOTES-016: moveNote down at last index is a no-op', async () => {
|
|
const spy = vi.fn();
|
|
server.use(
|
|
http.put('/api/trips/:id/days/:dayId/notes/:noteId', () => {
|
|
spy();
|
|
return HttpResponse.json({ note: buildDayNote() });
|
|
})
|
|
);
|
|
|
|
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
|
|
|
|
const noteA = buildDayNote({ id: 1 });
|
|
const getMergedItems = () => [
|
|
{ type: 'note' as const, sortKey: 0, data: noteA },
|
|
];
|
|
|
|
await act(async () => {
|
|
await result.current.moveNote(DAY_ID, noteA.id, 'down', getMergedItems);
|
|
});
|
|
|
|
expect(spy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('FE-HOOK-DAYNOTES-017: moveNote down at last item uses sortKey + 1', async () => {
|
|
let capturedBody: Record<string, unknown> = {};
|
|
server.use(
|
|
http.put('/api/trips/:id/days/:dayId/notes/:noteId', async ({ request }) => {
|
|
capturedBody = await request.json() as Record<string, unknown>;
|
|
return HttpResponse.json({ note: buildDayNote() });
|
|
})
|
|
);
|
|
|
|
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
|
|
|
|
const noteA = buildDayNote({ id: 1 });
|
|
const noteB = buildDayNote({ id: 2 });
|
|
|
|
const getMergedItems = () => [
|
|
{ type: 'note' as const, sortKey: 5, data: noteA },
|
|
{ type: 'note' as const, sortKey: 10, data: noteB },
|
|
];
|
|
|
|
// Move noteA (idx=0) down — only 2 items, so idx < length-1 is false after going down
|
|
// direction=down, idx=0, length=2, idx < length-2 is false (0 < 0), so newSortOrder = sortKey[1]+1 = 11
|
|
await act(async () => {
|
|
await result.current.moveNote(DAY_ID, noteA.id, 'down', getMergedItems);
|
|
});
|
|
|
|
expect(capturedBody.sort_order).toBe(11); // sortKey[idx+1] + 1 = 10 + 1
|
|
});
|
|
|
|
it('FE-HOOK-DAYNOTES-018: moveNote on error shows toast', async () => {
|
|
const toastSpy = vi.fn();
|
|
window.__addToast = toastSpy;
|
|
|
|
server.use(
|
|
http.put('/api/trips/:id/days/:dayId/notes/:noteId', () => {
|
|
return HttpResponse.json({ error: 'Server error' }, { status: 500 });
|
|
})
|
|
);
|
|
|
|
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
|
|
|
|
const noteA = buildDayNote({ id: 1 });
|
|
const noteB = buildDayNote({ id: 2 });
|
|
|
|
const getMergedItems = () => [
|
|
{ type: 'note' as const, sortKey: 0, data: noteA },
|
|
{ type: 'note' as const, sortKey: 1, data: noteB },
|
|
];
|
|
|
|
await act(async () => {
|
|
await result.current.moveNote(DAY_ID, noteA.id, 'down', getMergedItems);
|
|
});
|
|
|
|
expect(toastSpy).toHaveBeenCalledWith(expect.any(String), 'error', undefined);
|
|
delete window.__addToast;
|
|
});
|
|
|
|
it('FE-HOOK-DAYNOTES-019: moveNote up with only 1 item before uses sortKey - 1', async () => {
|
|
let capturedBody: Record<string, unknown> = {};
|
|
server.use(
|
|
http.put('/api/trips/:id/days/:dayId/notes/:noteId', async ({ request }) => {
|
|
capturedBody = await request.json() as Record<string, unknown>;
|
|
return HttpResponse.json({ note: buildDayNote() });
|
|
})
|
|
);
|
|
|
|
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
|
|
|
|
const noteA = buildDayNote({ id: 1 });
|
|
const noteB = buildDayNote({ id: 2 });
|
|
|
|
const getMergedItems = () => [
|
|
{ type: 'note' as const, sortKey: 5, data: noteA },
|
|
{ type: 'note' as const, sortKey: 10, data: noteB },
|
|
];
|
|
|
|
// Move noteB (idx=1) up — idx >= 2 is false, so newSortOrder = sortKey[idx-1] - 1 = 5-1 = 4
|
|
await act(async () => {
|
|
await result.current.moveNote(DAY_ID, noteB.id, 'up', getMergedItems);
|
|
});
|
|
|
|
expect(capturedBody.sort_order).toBe(4); // sortKey[0] - 1 = 5 - 1
|
|
});
|
|
|
|
it('FE-HOOK-DAYNOTES-020: openAddNote calls expandDay if provided', () => {
|
|
const expandDay = vi.fn();
|
|
const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
|
|
|
|
act(() => {
|
|
result.current.openAddNote(DAY_ID, () => [], expandDay);
|
|
});
|
|
|
|
expect(expandDay).toHaveBeenCalledWith(DAY_ID);
|
|
});
|
|
});
|
|
|
|
// Type augment for window.__addToast
|
|
declare global {
|
|
interface Window {
|
|
__addToast?: (message: string, type: string, duration?: number) => void;
|
|
}
|
|
}
|