From 2672a7a741d3b7745cb4c02908b2e38e423b80b0 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 25 Jul 2026 11:49:58 +0200 Subject: [PATCH] test(vacay): catch the suites up with the comp/flex column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kind column changed four things the tests still asserted the old way: toggleEntry gained a parameter, the toolbar gained the comp modifier, shared calendar rows carry kind, and a multi-person cell is now segment overlays instead of one split gradient. splitBackground went with it — nothing but the test still referenced it. The toolbar tests picked the half-day button by index, which the new button shifted; they select by name now so the next modifier does not break them. --- .../components/Vacay/VacayCalendar.test.tsx | 114 ++++++++++++++++-- .../components/Vacay/VacayMonthCard.test.tsx | 77 +++++++++++- .../src/mobile/screens/vacay/vacayDayModel.ts | 18 +-- .../unit/mobile/vacay/vacayDayModel.test.ts | 48 ++++++-- server/tests/e2e/vacay.e2e.test.ts | 2 +- .../tests/unit/nest/vacay.controller.test.ts | 33 ++++- 6 files changed, 258 insertions(+), 34 deletions(-) diff --git a/client/src/components/Vacay/VacayCalendar.test.tsx b/client/src/components/Vacay/VacayCalendar.test.tsx index aa5638973..30d5a8575 100644 --- a/client/src/components/Vacay/VacayCalendar.test.tsx +++ b/client/src/components/Vacay/VacayCalendar.test.tsx @@ -7,8 +7,8 @@ import { useVacayStore } from '../../store/vacayStore' import VacayCalendar from './VacayCalendar' vi.mock('./VacayMonthCard', () => ({ - default: ({ month, onCellClick }: any) => ( -
+ default: ({ year, month, onCellClick }: any) => ( +
@@ -16,6 +16,8 @@ vi.mock('./VacayMonthCard', () => ({ ), })) +const fiscalJuly = { year_type: 'fiscal' as const, year_start_month: 7, year_start_day: 1, hire_date: null } + const basePlan = { id: 1, holidays_enabled: false, @@ -47,6 +49,51 @@ describe('VacayCalendar', () => { expect(screen.getAllByTestId(/^month-card-/)).toHaveLength(12) }) + it('FE-COMP-VACAYCALENDAR-001a: renders January to December of the selected year by default', () => { + seedStore(useVacayStore, { + selectedYear: 2025, + entries: [], + companyHolidays: [], + holidays: {}, + plan: basePlan, + users: [], + selectedUserId: null, + }) + + render() + + const cards = screen.getAllByTestId(/^month-card-/) + expect(cards[0]).toHaveAttribute('data-testid', 'month-card-0') + expect(cards[0]).toHaveAttribute('data-year', '2025') + expect(cards[11]).toHaveAttribute('data-testid', 'month-card-11') + expect(cards[11]).toHaveAttribute('data-year', '2025') + }) + + it('FE-COMP-VACAYCALENDAR-001b: rolls the grid over the calendar year for a fiscal window (#737)', () => { + seedStore(useVacayStore, { + selectedYear: 2026, + entries: [], + companyHolidays: [], + holidays: {}, + plan: basePlan, + users: [], + selectedUserId: null, + yearSettings: fiscalJuly, + }) + + render() + + const cards = screen.getAllByTestId(/^month-card-/) + expect(cards).toHaveLength(12) + // Jul 2026 first, Jan 2027 in the middle, Jun 2027 last. + expect(cards[0]).toHaveAttribute('data-testid', 'month-card-6') + expect(cards[0]).toHaveAttribute('data-year', '2026') + expect(cards[6]).toHaveAttribute('data-testid', 'month-card-0') + expect(cards[6]).toHaveAttribute('data-year', '2027') + expect(cards[11]).toHaveAttribute('data-testid', 'month-card-5') + expect(cards[11]).toHaveAttribute('data-year', '2027') + }) + it('FE-COMP-VACAYCALENDAR-002: shows vacation mode button by default with username', () => { seedStore(useVacayStore, { selectedYear: 2025, @@ -97,10 +144,10 @@ describe('VacayCalendar', () => { render() - // Vacation + half-day buttons stay; only the company button is hidden. + // Vacation + the comp and half-day modifiers stay; only the company button is hidden. const buttons = screen.getAllByRole('button') const toolbarButtons = buttons.filter(b => !b.textContent?.startsWith('click-')) - expect(toolbarButtons).toHaveLength(2) + expect(toolbarButtons).toHaveLength(3) }) it('FE-COMP-VACAYCALENDAR-005: switching to company mode highlights company button', async () => { @@ -149,7 +196,7 @@ describe('VacayCalendar', () => { // Click the first month card cell button (month 0 → date '2025-01-01') await user.click(screen.getByText('click-0')) - expect(toggleEntry).toHaveBeenCalledWith('2025-01-01', 42, 1) + expect(toggleEntry).toHaveBeenCalledWith('2025-01-01', 42, 1, 'vacation') }) it('FE-COMP-VACAYCALENDAR-006b: half-day mode logs the day as 0.5', async () => { @@ -169,13 +216,58 @@ describe('VacayCalendar', () => { render() - // toolbarButtons[0] = vacation, [1] = half day - const buttons = screen.getAllByRole('button') - const toolbarButtons = buttons.filter(b => !b.textContent?.startsWith('click-')) - await user.click(toolbarButtons[1]) + // Comp and half-day are both modifiers in the toolbar — pick by name, not position. + await user.click(screen.getByRole('button', { name: 'Half day' })) await user.click(screen.getByText('click-0')) - expect(toggleEntry).toHaveBeenCalledWith('2025-01-01', 42, 0.5) + expect(toggleEntry).toHaveBeenCalledWith('2025-01-01', 42, 0.5, 'vacation') + }) + + it('FE-COMP-VACAYCALENDAR-006c: the comp modifier logs the day as a comp/flex day (#1074)', async () => { + const user = userEvent.setup() + const toggleEntry = vi.fn().mockResolvedValue(undefined) + + seedStore(useVacayStore, { + selectedYear: 2025, + entries: [], + companyHolidays: [], + holidays: {}, + plan: { ...basePlan, block_weekends: false, company_holidays_enabled: false }, + users: [], + selectedUserId: 42, + toggleEntry, + }) + + render() + + await user.click(screen.getByRole('button', { name: 'Comp / Flex' })) + await user.click(screen.getByText('click-0')) + + expect(toggleEntry).toHaveBeenCalledWith('2025-01-01', 42, 1, 'comp') + }) + + it('FE-COMP-VACAYCALENDAR-006d: comp and half-day stack into a half comp day (#1074)', async () => { + const user = userEvent.setup() + const toggleEntry = vi.fn().mockResolvedValue(undefined) + + seedStore(useVacayStore, { + selectedYear: 2025, + entries: [], + companyHolidays: [], + holidays: {}, + plan: { ...basePlan, block_weekends: false, company_holidays_enabled: false }, + users: [], + selectedUserId: 42, + toggleEntry, + }) + + render() + + await user.click(screen.getByRole('button', { name: 'Comp / Flex' })) + await user.click(screen.getByRole('button', { name: 'Half day' })) + await user.click(screen.getByText('click-0')) + + expect(toggleEntry).toHaveBeenCalledWith('2025-01-01', 42, 0.5, 'comp') }) it('FE-COMP-VACAYCALENDAR-007: cell click on public holiday toggles vacation entry', async () => { @@ -198,7 +290,7 @@ describe('VacayCalendar', () => { // Month 0, button emits '2025-01-01' which is a holiday — should still toggle vacation await user.click(screen.getByText('click-0')) - expect(toggleEntry).toHaveBeenCalledWith('2025-01-01', undefined, 1) + expect(toggleEntry).toHaveBeenCalledWith('2025-01-01', undefined, 1, 'vacation') }) it('FE-COMP-VACAYCALENDAR-008: cell click in company mode calls toggleCompanyHoliday', async () => { diff --git a/client/src/components/Vacay/VacayMonthCard.test.tsx b/client/src/components/Vacay/VacayMonthCard.test.tsx index 2bfcfe931..b32974749 100644 --- a/client/src/components/Vacay/VacayMonthCard.test.tsx +++ b/client/src/components/Vacay/VacayMonthCard.test.tsx @@ -150,18 +150,87 @@ describe('VacayMonthCard', () => { expect(getFirstWeekLabels()).toEqual(['', '', '', '1', '2', '3', '4']) }) - it('FE-COMP-VACAYMONTHCARD-011: Two vacation entries fill the cell with a split gradient', () => { + it('FE-COMP-VACAYMONTHCARD-011: Two vacation entries split the cell into two clipped halves', () => { const props = { ...baseProps, entryMap: { - '2025-01-15': [{ date: '2025-01-15', user_id: 1, person_color: '#6366f1' }, { date: '2025-01-15', user_id: 1, person_color: '#f43f5e' }], + '2025-01-15': [{ date: '2025-01-15', user_id: 1, person_color: '#6366f1' }, { date: '2025-01-15', user_id: 2, person_color: '#f43f5e' }], }, } render() const daySpan = screen.getByText('15') const cell = daySpan.closest('div') as HTMLElement - // The split gradient is the cell background itself (glass facelift). - expect(cell.style.background).toContain('linear-gradient') + // Each half is its own clipped overlay so it can be solid or hatched on its + // own (#1074) — the diagonal is no longer one gradient on the cell itself. + // jsdom normalises the hex inside color-mix() to rgb(). + const halves = [...cell.querySelectorAll('div')].filter(d => d.style.clipPath) + expect(halves).toHaveLength(2) + expect(halves[0].style.background).toContain('rgb(99, 102, 241)') + expect(halves[1].style.background).toContain('rgb(244, 63, 94)') + expect(halves.map(h => h.style.background).join()).not.toContain('repeating-linear-gradient') + }) + + it('FE-COMP-VACAYMONTHCARD-011a: a comp day hatches only its own half of the split (#1074)', () => { + const props = { + ...baseProps, + entryMap: { + '2025-01-15': [ + { date: '2025-01-15', user_id: 1, person_color: '#6366f1', kind: 'comp' as const }, + { date: '2025-01-15', user_id: 2, person_color: '#f43f5e' }, + ], + }, + } + render() + const cell = screen.getByText('15').closest('div') as HTMLElement + const halves = [...cell.querySelectorAll('div')].filter(d => d.style.clipPath) + expect(halves[0].style.background).toContain('repeating-linear-gradient') + expect(halves[1].style.background).not.toContain('repeating-linear-gradient') + }) + + it('FE-COMP-VACAYMONTHCARD-011b: a single comp day hatches the whole cell (#1074)', () => { + const props = { + ...baseProps, + entryMap: { + '2025-01-15': [{ date: '2025-01-15', user_id: 1, person_color: '#6366f1', kind: 'comp' as const }], + }, + } + render() + const cell = screen.getByText('15').closest('div') as HTMLElement + expect(cell.style.background).toContain('repeating-linear-gradient') + }) + + it('FE-COMP-VACAYMONTHCARD-011c: an all-comp day keeps the white digit and gains a shadow to carry it (#1074)', () => { + const props = { + ...baseProps, + entryMap: { + '2025-01-15': [{ date: '2025-01-15', user_id: 1, person_color: '#6366f1', kind: 'comp' as const }], + // A solid vacation fill needs no shadow — the fill itself is the contrast. + '2025-01-16': [{ date: '2025-01-16', user_id: 1, person_color: '#6366f1' }], + }, + } + render() + const comp = screen.getByText('15') + const vacation = screen.getByText('16') + expect(comp).toHaveStyle({ color: '#fff' }) + // Only that the shadow is there — its exact strength is a design value. + expect(comp.style.textShadow).not.toBe('') + expect(vacation).toHaveStyle({ color: '#fff' }) + expect(vacation.style.textShadow).toBe('') + }) + + it('FE-COMP-VACAYMONTHCARD-011d: a mixed comp/vacation day needs no shadow — a solid half sits under the digit (#1074)', () => { + const props = { + ...baseProps, + entryMap: { + '2025-01-15': [ + { date: '2025-01-15', user_id: 1, person_color: '#6366f1', kind: 'comp' as const }, + { date: '2025-01-15', user_id: 2, person_color: '#f43f5e' }, + ], + }, + } + render() + expect(screen.getByText('15')).toHaveStyle({ color: '#fff' }) + expect(screen.getByText('15').style.textShadow).toBe('') }) it('FE-COMP-VACAYMONTHCARD-012: Four vacation entries render quadrant overlay', () => { diff --git a/client/src/mobile/screens/vacay/vacayDayModel.ts b/client/src/mobile/screens/vacay/vacayDayModel.ts index a0f209056..f6275658f 100644 --- a/client/src/mobile/screens/vacay/vacayDayModel.ts +++ b/client/src/mobile/screens/vacay/vacayDayModel.ts @@ -8,6 +8,9 @@ export const FALLBACK_PERSON_COLOR = '#6366f1' export interface DayVisual { background: string numColor: string + // Set on hatched (comp/flex) cells, where the screen shows through between the + // stripes and the digit needs its own contrast (#1074). + textShadow?: string boxShadow?: string // At least one person logged this day as a half day (#552) — the cell shows a ½ badge. half?: boolean @@ -81,15 +84,6 @@ export function hatchTint(color: string): string { return `repeating-linear-gradient(45deg, ${color} 0 2.5px, transparent 2.5px 5px)` } -/** Hard split background when several persons logged the same day. */ -export function splitBackground(colors: string[]): string { - const n = colors.length - const stops = colors - .map((c, i) => `${c} ${Math.round((i * 100) / n)}% ${Math.round(((i + 1) * 100) / n)}%`) - .join(',') - return `linear-gradient(105deg,${stops})` -} - /** * Day-cell color matrix, priority top-down: today ring > company holiday > * logged persons (pastel, split for several) > public holiday > weekend > @@ -120,7 +114,13 @@ function baseDayVisual(dateStr: string, dayOfWeek: number, ctx: DayVisualContext const segments = entries.map(e => ({ color: personTint(e.person_color || FALLBACK_PERSON_COLOR), comp: e.kind === 'comp' })) const single = segments.length === 1 const background = single ? (segments[0].comp ? hatchTint(segments[0].color) : segments[0].color) : 'transparent' + // The pastel fills are theme-independent surfaces, so every logged day keeps + // the same hard dark digit. An all-comp day is hatched, and the screen shows + // through between its stripes — a light shadow carries the contrast there so + // the digit survives dark mode without changing colour (#1074). + const allComp = segments.every(s => s.comp) const visual: DayVisual = { background, numColor: '#101013', segments } + if (allComp) visual.textShadow = '0 1px 2px rgba(255,255,255,0.9), 0 0 3px rgba(255,255,255,0.6)' if (entries.some(e => (e.fraction ?? 1) === 0.5)) visual.half = true return withSchool(visual) } diff --git a/client/tests/unit/mobile/vacay/vacayDayModel.test.ts b/client/tests/unit/mobile/vacay/vacayDayModel.test.ts index 6383f4fba..d87e39499 100644 --- a/client/tests/unit/mobile/vacay/vacayDayModel.test.ts +++ b/client/tests/unit/mobile/vacay/vacayDayModel.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { dayVisual, monthLead, personTint, splitBackground, type DayVisualContext } from '../../../../src/mobile/screens/vacay/vacayDayModel'; +import { dayVisual, hatchTint, monthLead, personTint, type DayVisualContext } from '../../../../src/mobile/screens/vacay/vacayDayModel'; // FE-MOB-VACAY-001 onwards @@ -44,11 +44,13 @@ describe('vacayDayModel', () => { // ...but not when company holidays are disabled. expect(dayVisual('2026-07-15', 3, { ...notToday, companyHolidaysEnabled: false }).numColor).toBe('#101013'); - // Logged person: pastel tint of the person color, hard dark digit. + // Logged person: pastel tint of the person color, hard dark digit. One person + // fills the cell directly and still reports the segment (#1074). const logged = ctx({ todayStr: '2026-01-01', entryMap: { '2026-07-15': [entry] } }); expect(dayVisual('2026-07-15', 3, logged)).toEqual({ background: personTint('#EC4899'), numColor: '#101013', + segments: [{ color: personTint('#EC4899'), comp: false }], }); // Public holiday: calendar color fill. @@ -66,7 +68,7 @@ describe('vacayDayModel', () => { }); }); - it('FE-MOB-VACAY-002: splits logged days evenly and offsets month starts by week start', () => { + it('FE-MOB-VACAY-002: splits logged days into segments and offsets month starts by week start', () => { const two = ctx({ todayStr: '2026-01-01', entryMap: { @@ -76,10 +78,42 @@ describe('vacayDayModel', () => { ], }, }); - expect(dayVisual('2026-07-15', 3, two).background).toBe( - splitBackground([personTint('#EC4899'), personTint('#2FA9A0')]), - ); - expect(splitBackground(['a', 'b'])).toBe('linear-gradient(105deg,a 0% 50%,b 50% 100%)'); + // Several persons render as segment overlays (#1074), so the cell itself stays + // transparent underneath them and the split lives in `segments`. + const shared = dayVisual('2026-07-15', 3, two); + expect(shared.background).toBe('transparent'); + expect(shared.segments).toEqual([ + { color: personTint('#EC4899'), comp: false }, + { color: personTint('#2FA9A0'), comp: false }, + ]); + + // A comp/flex day marks its own segment without touching the other one. + const mixed = ctx({ + todayStr: '2026-01-01', + entryMap: { + '2026-07-15': [ + { date: '2026-07-15', user_id: 1, person_color: '#EC4899', kind: 'comp' as const }, + { date: '2026-07-15', user_id: 2, person_color: '#2FA9A0' }, + ], + }, + }); + expect(dayVisual('2026-07-15', 3, mixed).segments).toEqual([ + { color: personTint('#EC4899'), comp: true }, + { color: personTint('#2FA9A0'), comp: false }, + ]); + + // A single comp day hatches the whole cell instead. + const soloComp = ctx({ + todayStr: '2026-01-01', + entryMap: { '2026-07-15': [{ date: '2026-07-15', user_id: 1, person_color: '#EC4899', kind: 'comp' as const }] }, + }); + expect(dayVisual('2026-07-15', 3, soloComp).background).toBe(hatchTint(personTint('#EC4899'))); + // The hatch lets the screen through, so the digit keeps its colour and gains a + // light shadow to stay readable; a day with a solid segment needs none. + expect(dayVisual('2026-07-15', 3, soloComp).numColor).toBe('#101013'); + expect(dayVisual('2026-07-15', 3, soloComp).textShadow).toBeTruthy(); + expect(dayVisual('2026-07-15', 3, mixed).numColor).toBe('#101013'); + expect(dayVisual('2026-07-15', 3, mixed).textShadow).toBeUndefined(); // July 2026 starts on a Wednesday: 2 leading cells Monday-first, 3 Sunday-first. expect(monthLead(2026, 6, 1)).toBe(2); diff --git a/server/tests/e2e/vacay.e2e.test.ts b/server/tests/e2e/vacay.e2e.test.ts index 064e7f2df..5cc81dcef 100644 --- a/server/tests/e2e/vacay.e2e.test.ts +++ b/server/tests/e2e/vacay.e2e.test.ts @@ -83,7 +83,7 @@ describe('Vacay e2e (real auth guard + temp SQLite)', () => { .set('Cookie', sessionCookie(1)).set('X-Socket-Id', 'sock-7').send({ date: '2026-07-01' }); expect(res.status).toBe(200); expect(res.body).toEqual({ action: 'added' }); - expect(svc.toggleEntry).toHaveBeenCalledWith(1, 10, '2026-07-01', undefined, 'sock-7'); + expect(svc.toggleEntry).toHaveBeenCalledWith(1, 10, '2026-07-01', undefined, undefined, 'sock-7'); }); it('400 on entries/toggle without a date', async () => { diff --git a/server/tests/unit/nest/vacay.controller.test.ts b/server/tests/unit/nest/vacay.controller.test.ts index 44855d120..7336923ac 100644 --- a/server/tests/unit/nest/vacay.controller.test.ts +++ b/server/tests/unit/nest/vacay.controller.test.ts @@ -137,13 +137,42 @@ describe('VacayController (parity with the legacy /api/addons/vacay route)', () it('toggles for the caller', () => { const toggleEntry = vi.fn().mockReturnValue({ action: 'added' }); expect(makeController({ ...planBase, toggleEntry }).toggleEntry(user, { date: '2026-07-01' }, 'sock')).toEqual({ action: 'added' }); - expect(toggleEntry).toHaveBeenCalledWith(1, 10, '2026-07-01', undefined, 'sock'); + expect(toggleEntry).toHaveBeenCalledWith(1, 10, '2026-07-01', undefined, undefined, 'sock'); }); it('forwards the half-day fraction (#552)', () => { const toggleEntry = vi.fn().mockReturnValue({ action: 'added', fraction: 0.5 }); makeController({ ...planBase, toggleEntry }).toggleEntry(user, { date: '2026-07-01', fraction: 0.5 }, 'sock'); - expect(toggleEntry).toHaveBeenCalledWith(1, 10, '2026-07-01', 0.5, 'sock'); + expect(toggleEntry).toHaveBeenCalledWith(1, 10, '2026-07-01', 0.5, undefined, 'sock'); + }); + + it('forwards the comp/flex leave type (#1074)', () => { + const toggleEntry = vi.fn().mockReturnValue({ action: 'added', kind: 'comp' }); + makeController({ ...planBase, toggleEntry }).toggleEntry(user, { date: '2026-07-01', kind: 'comp' }, 'sock'); + expect(toggleEntry).toHaveBeenCalledWith(1, 10, '2026-07-01', undefined, 'comp', 'sock'); + }); + + it('GET reads entries over the caller window (#737)', () => { + const getEntries = vi.fn().mockReturnValue({ entries: [], companyHolidays: [] }); + makeController({ ...planBase, getEntries }).entries(user, '2026'); + expect(getEntries).toHaveBeenCalledWith(10, '2026', 1); + }); + }); + + describe('year settings (#737)', () => { + it('GET wraps the caller settings', () => { + const getYearSettings = vi.fn().mockReturnValue({ year_type: 'calendar' }); + expect(makeController({ getYearSettings }).yearSettings(user)).toEqual({ settings: { year_type: 'calendar' } }); + expect(getYearSettings).toHaveBeenCalledWith(1); + }); + + it('PUT saves for the caller and returns the stored settings', () => { + const updateYearSettings = vi.fn().mockReturnValue({ year_type: 'fiscal', year_start_month: 7, year_start_day: 1 }); + const body = { year_type: 'fiscal' as const, year_start_month: 7, year_start_day: 1 }; + expect(makeController({ updateYearSettings }).updateYearSettings(user, body)).toEqual({ + settings: { year_type: 'fiscal', year_start_month: 7, year_start_day: 1 }, + }); + expect(updateYearSettings).toHaveBeenCalledWith(1, body); }); });