chore(web): add Vitest; add dayCapabilities helper with full test coverage

- Install vitest + jsdom
- Add test/test:watch scripts to package.json
- Add test:web and test:all tasks to mise.toml
- Add dayCapabilities() to utils.ts — single source of truth for
  what actions are permitted per day (future/today/past, open/closed)
- Add DayCapabilities interface to utils.ts
- 11 unit tests: dayCapabilities (5 cases), weekDayKeys (3 cases),
  isWorkday (3 cases)
This commit is contained in:
2026-04-30 19:03:04 +02:00
parent 73e5b01577
commit 6c4f78d101
6 changed files with 1055 additions and 3 deletions

View File

@@ -44,6 +44,15 @@ run = "npm run dev"
description = "Run all Go tests"
run = "go test ./..."
[tasks."test:web"]
description = "Run frontend Vitest unit tests"
dir = "web"
run = "npm test"
[tasks."test:all"]
description = "Run Go tests and frontend Vitest tests"
depends = ["test", "test:web"]
[tasks.install]
description = "Install frontend npm dependencies"
dir = "web"

894
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,8 @@
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
@@ -15,10 +17,12 @@
"@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/kit": "^2.57.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"jsdom": "^29.1.1",
"svelte": "^5.55.2",
"svelte-check": "^4.4.6",
"typescript": "^6.0.2",
"vite": "^8.0.7"
"vite": "^8.0.7",
"vitest": "^4.1.5"
},
"dependencies": {
"@sveltejs/adapter-static": "^3.0.10",

84
web/src/lib/utils.test.ts Normal file
View File

@@ -0,0 +1,84 @@
import { describe, it, expect } from 'vitest';
import { dayCapabilities, weekDayKeys, isWorkday } from './utils';
describe('dayCapabilities', () => {
const today = '2026-04-30';
it('future day: only canMarkKind', () => {
const cap = dayCapabilities('2026-05-01', today, false);
expect(cap.canStartStop).toBe(false);
expect(cap.canAddInterval).toBe(false);
expect(cap.canEditEntries).toBe(false);
expect(cap.canMarkKind).toBe(true);
expect(cap.canCloseDay).toBe(false);
expect(cap.canReopenDay).toBe(false);
});
it('today, open: full access except reopen', () => {
const cap = dayCapabilities(today, today, false);
expect(cap.canStartStop).toBe(true);
expect(cap.canAddInterval).toBe(true);
expect(cap.canEditEntries).toBe(true);
expect(cap.canMarkKind).toBe(true);
expect(cap.canCloseDay).toBe(true);
expect(cap.canReopenDay).toBe(false);
});
it('today, closed: only markKind and reopen', () => {
const cap = dayCapabilities(today, today, true);
expect(cap.canStartStop).toBe(false);
expect(cap.canAddInterval).toBe(false);
expect(cap.canEditEntries).toBe(false);
expect(cap.canMarkKind).toBe(true);
expect(cap.canCloseDay).toBe(false);
expect(cap.canReopenDay).toBe(true);
});
it('past, open: no start/stop; can add/edit/mark/close', () => {
const cap = dayCapabilities('2026-04-29', today, false);
expect(cap.canStartStop).toBe(false);
expect(cap.canAddInterval).toBe(true);
expect(cap.canEditEntries).toBe(true);
expect(cap.canMarkKind).toBe(true);
expect(cap.canCloseDay).toBe(true);
expect(cap.canReopenDay).toBe(false);
});
it('past, closed: only markKind and reopen', () => {
const cap = dayCapabilities('2026-04-28', today, true);
expect(cap.canStartStop).toBe(false);
expect(cap.canAddInterval).toBe(false);
expect(cap.canEditEntries).toBe(false);
expect(cap.canMarkKind).toBe(true);
expect(cap.canCloseDay).toBe(false);
expect(cap.canReopenDay).toBe(true);
});
});
describe('weekDayKeys', () => {
it('2026-W18: Mon Apr 27 Sun May 3', () => {
const keys = weekDayKeys('2026-W18');
expect(keys[0]).toBe('2026-04-27');
expect(keys[6]).toBe('2026-05-03');
expect(keys).toHaveLength(7);
});
it('2024-W03: Mon Jan 15 Sun Jan 21', () => {
const keys = weekDayKeys('2024-W03');
expect(keys[0]).toBe('2024-01-15');
expect(keys[6]).toBe('2024-01-21');
});
it('2023-W01: Mon Jan 2 Sun Jan 8', () => {
const keys = weekDayKeys('2023-W01');
expect(keys[0]).toBe('2023-01-02');
expect(keys[6]).toBe('2023-01-08');
});
});
describe('isWorkday', () => {
const monToFri = 31; // bits 1+2+4+8+16
it('Monday is a workday', () => expect(isWorkday('2026-04-27', monToFri)).toBe(true));
it('Saturday is not a workday', () => expect(isWorkday('2026-05-02', monToFri)).toBe(false));
it('Sunday is not a workday', () => expect(isWorkday('2026-05-03', monToFri)).toBe(false));
});

View File

@@ -100,3 +100,61 @@ export function isWorkday(dayKey: string, mask: number): boolean {
const d = new Date(dayKey + 'T12:00:00Z');
return (weekdayBit(d.getUTCDay()) & mask) !== 0;
}
/** Capabilities for a given day in the day detail panel. */
export interface DayCapabilities {
canStartStop: boolean;
canAddInterval: boolean;
canEditEntries: boolean;
canMarkKind: boolean;
canCloseDay: boolean;
canReopenDay: boolean;
}
/**
* Compute what actions are permitted for a given day.
*
* @param dayKey The day in question (YYYY-MM-DD).
* @param today Today's day key (YYYY-MM-DD).
* @param isClosed Whether the day has a closed_days row.
*/
export function dayCapabilities(
dayKey: string,
today: string,
isClosed: boolean
): DayCapabilities {
const isFuture = dayKey > today;
const isToday = dayKey === today;
if (isFuture) {
return {
canStartStop: false,
canAddInterval: false,
canEditEntries: false,
canMarkKind: true,
canCloseDay: false,
canReopenDay: false
};
}
if (isClosed) {
return {
canStartStop: false,
canAddInterval: false,
canEditEntries: false,
canMarkKind: true,
canCloseDay: false,
canReopenDay: true
};
}
// Past or today, open
return {
canStartStop: isToday,
canAddInterval: true,
canEditEntries: true,
canMarkKind: true,
canCloseDay: true,
canReopenDay: false
};
}

View File

@@ -1,5 +1,5 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import { defineConfig } from 'vitest/config';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
@@ -45,5 +45,10 @@ export default defineConfig({
'/api': 'http://localhost:8080',
'/healthz': 'http://localhost:8080'
}
},
test: {
environment: 'jsdom',
globals: true,
include: ['src/**/*.test.ts']
}
});