Files
TREK/client/tests/unit/hooks/usePlannerHistory.test.ts
Maurice 302101b186 fix(dayplan): the delete question names every date and night it changes
The question before a day goes now also says which day without a date takes over the last date, which stay running across the day loses a night and when it checks out, and what the cancelled booking costs, or that it has no expense at all; further bookings on the same stay are counted. Road trip boundaries follow their own days when the numbering still has a hole from the old delete, and ones left on no day are dropped.

A long list scrolls inside the confirm card on both shells with the buttons always in reach, Escape closes only the sheet on top and takes the trip dialog back from its review step to the form, and the panel of a deleted day closes, also when a fellow traveller deletes it. Undo steps on a deleted day are dropped, a restored place skips days that are gone, and an undo that fails says so. The day cache is written without holding up the answer once the server has it, the tip under a new start date points at the dated add button, and two Collab tests find the confirm button by its role instead of an old colour class.
2026-09-23 22:40:47 +02:00

134 lines
5.2 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { usePlannerHistory } from '../../../src/hooks/usePlannerHistory';
// FE-HOOK-HIST-001 onwards
describe('usePlannerHistory', () => {
it('FE-HOOK-HIST-001: starts with canUndo=false and lastActionLabel=null', () => {
const { result } = renderHook(() => usePlannerHistory());
expect(result.current.canUndo).toBe(false);
expect(result.current.lastActionLabel).toBeNull();
});
it('FE-HOOK-HIST-002: pushing an entry sets canUndo=true and lastActionLabel', () => {
const { result } = renderHook(() => usePlannerHistory());
act(() => {
result.current.pushUndo('Delete place', vi.fn());
});
expect(result.current.canUndo).toBe(true);
expect(result.current.lastActionLabel).toBe('Delete place');
});
it('FE-HOOK-HIST-003: calling undo fires the undo function and sets canUndo=false', async () => {
const { result } = renderHook(() => usePlannerHistory());
const undoFn = vi.fn();
act(() => {
result.current.pushUndo('Add place', undoFn);
});
await act(async () => {
await result.current.undo();
});
expect(undoFn).toHaveBeenCalledOnce();
expect(result.current.canUndo).toBe(false);
});
it('FE-HOOK-HIST-004: multiple entries stack in LIFO order', () => {
const { result } = renderHook(() => usePlannerHistory());
act(() => {
result.current.pushUndo('First', vi.fn());
result.current.pushUndo('Second', vi.fn());
result.current.pushUndo('Third', vi.fn());
});
expect(result.current.lastActionLabel).toBe('Third');
});
it('FE-HOOK-HIST-005: undo consumes entries in LIFO order', async () => {
const { result } = renderHook(() => usePlannerHistory());
const fn1 = vi.fn();
const fn2 = vi.fn();
act(() => {
result.current.pushUndo('First', fn1);
result.current.pushUndo('Second', fn2);
});
await act(async () => { await result.current.undo(); });
expect(fn2).toHaveBeenCalledOnce();
expect(fn1).not.toHaveBeenCalled();
expect(result.current.lastActionLabel).toBe('First');
await act(async () => { await result.current.undo(); });
expect(fn1).toHaveBeenCalledOnce();
expect(result.current.canUndo).toBe(false);
});
it('FE-HOOK-HIST-006: caps history at 30 entries', () => {
const { result } = renderHook(() => usePlannerHistory());
act(() => {
for (let i = 0; i < 31; i++) {
result.current.pushUndo(`Action ${i}`, vi.fn());
}
});
// After 31 pushes with cap=30, the oldest entry (Action 0) should be dropped.
// canUndo must be true and the stack should not exceed 30.
expect(result.current.canUndo).toBe(true);
expect(result.current.lastActionLabel).toBe('Action 30');
});
it('FE-HOOK-HIST-007: undo on an empty stack does not throw', async () => {
const { result } = renderHook(() => usePlannerHistory());
await expect(
act(async () => { await result.current.undo(); })
).resolves.not.toThrow();
expect(result.current.canUndo).toBe(false);
});
it('FE-HOOK-HIST-008: undo still sets canUndo=false after consuming the last entry', async () => {
const { result } = renderHook(() => usePlannerHistory());
act(() => { result.current.pushUndo('Only', vi.fn()); });
await act(async () => { await result.current.undo(); });
expect(result.current.canUndo).toBe(false);
expect(result.current.lastActionLabel).toBeNull();
});
it('FE-HOOK-HIST-009: undo tells whether the step went through, failed, or there was none', async () => {
const { result } = renderHook(() => usePlannerHistory());
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
act(() => {
result.current.pushUndo('Works', vi.fn());
result.current.pushUndo('Fails', vi.fn().mockRejectedValue(new Error('Not found')));
});
let outcome: boolean | null = null;
await act(async () => { outcome = await result.current.undo(); });
expect(outcome).toBe(false);
await act(async () => { outcome = await result.current.undo(); });
expect(outcome).toBe(true);
await act(async () => { outcome = await result.current.undo(); });
expect(outcome).toBeNull();
spy.mockRestore();
});
it('FE-HOOK-HIST-010: forgetting a day drops the steps tagged with it and keeps the rest in order', async () => {
const { result } = renderHook(() => usePlannerHistory());
const untagged = vi.fn();
const otherDay = vi.fn();
act(() => {
result.current.pushUndo('Untagged', untagged);
result.current.pushUndo('Day 3', otherDay, [3]);
result.current.pushUndo('Day 2', vi.fn(), [2]);
result.current.pushUndo('Move 2 to 3', vi.fn(), [3, 2]);
});
act(() => { result.current.forgetDay(2); });
expect(result.current.lastActionLabel).toBe('Day 3');
// A day no step is tagged with changes nothing.
act(() => { result.current.forgetDay(99); });
expect(result.current.lastActionLabel).toBe('Day 3');
await act(async () => { await result.current.undo(); });
expect(otherDay).toHaveBeenCalledOnce();
await act(async () => { await result.current.undo(); });
expect(untagged).toHaveBeenCalledOnce();
expect(result.current.canUndo).toBe(false);
});
});