From 5c5f2a284e086636089b6d2c75373e0282bb0bee Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Mon, 7 Sep 2026 09:19:41 +0200 Subject: [PATCH] feat: 63 Unit-Tests fuer die ausgelagerten Funktionen Nodes eingebauter Test-Runner mit --experimental-strip-types: keine neue Abhaengigkeit, keine Browser, keine Datenbank, Laufzeit rund 170 ms. npm test fuehrt sie aus, npm run test:e2e startet weiterhin Playwright. Die Tests haben sofort zwei echte Fehler gefunden, die ich beim Auslagern in v1.117.7 selbst eingebaut hatte: - isAllDayEvent rief parseCalendarDate ohne Import auf - resolveViewDays nutzte clampViewDays ohne Import Beides haette zur Laufzeit einen ReferenceError gegeben. Der Build meldete nichts, weil typescript.ignoreBuildErrors noch aktiv ist -- genau der Fall, gegen den C1 laeuft. isAllDayEvent liegt jetzt in date-utils.ts, wo es thematisch hingehoert; clampViewDays und MAX_VIEW_DAYS in device-settings.ts, DaysSelector re-exportiert sie. Geprueft werden ISO-Kalenderwochen gegen bekannte Kalenderdaten, die Wochenstart-Berechnung inklusive Sonntag-Sonderfall, das Zeitraster, die 12/24-Stunden-Anzeige und der Round-Trip formatDateToISO <-> parseCalendarDate -- die Bruecke zwischen Datenbank und Anzeige, aus der Ein-Tag-Verschiebungen entstehen. Mit npm run build verifiziert (Exit 0). v1.118.0 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 23 +++ TODO.md | 8 +- package-lock.json | 4 +- package.json | 8 +- playwright.config.ts | 2 +- src/components/DaysSelector.tsx | 8 +- src/lib/weekly/date-utils.ts | 23 ++- src/lib/weekly/device-settings.ts | 10 ++ src/lib/weekly/format.ts | 23 +-- tests/unit/date-utils.test.ts | 230 ++++++++++++++++++++++++++++++ tests/unit/format.test.ts | 133 +++++++++++++++++ 11 files changed, 436 insertions(+), 36 deletions(-) create mode 100644 tests/unit/date-utils.test.ts create mode 100644 tests/unit/format.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 92c901c..4cddd16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ All notable changes to this project are documented in this file. +## [1.118.0] - 2026-09-07 + +### Added +- **63 Unit-Tests** in `tests/unit/` für die ausgelagerten reinen Funktionen. Sie laufen in + rund 170 ms über Nodes eingebauten Test-Runner — ohne Browser, ohne Datenbank, ohne neue + Abhängigkeit. `npm test` führt sie aus, `npm run test:e2e` startet weiterhin Playwright. + Geprüft werden ISO-Kalenderwochen, Wochenstart-Berechnung, das Zeitraster, die + 12/24-Stunden-Anzeige, der Round-Trip zwischen `formatDateToISO` und `parseCalendarDate` + sowie Farbinvertierung und Spaltenbegrenzung. + +### Fixed +- `isAllDayEvent` rief `parseCalendarDate` auf, ohne es zu importieren — beim Auslagern aus + `WeeklyView.tsx` in v1.117.7 übersehen. Der Build meldete nichts, weil + `typescript.ignoreBuildErrors` noch aktiv ist; zur Laufzeit hätte der erste ganztägige + Termin mit Uhrzeit einen `ReferenceError` ausgelöst. Die Funktion liegt jetzt in + `date-utils.ts`, wo sie thematisch hingehört. +- `resolveViewDays` nutzte `clampViewDays` ohne Import — derselbe Fehler beim selben Schnitt. + `clampViewDays` und `MAX_VIEW_DAYS` sind nach `device-settings.ts` gewandert; `DaysSelector` + re-exportiert sie, bestehende Importe bleiben gültig. + +Beide Fehler sind genau die Sorte, die `typescript.ignoreBuildErrors` verdeckt — und beide +wären von den neuen Tests sofort gefunden worden. + ## [1.117.7] - 2026-09-07 ### Changed diff --git a/TODO.md b/TODO.md index 5579f9b..4911011 100644 --- a/TODO.md +++ b/TODO.md @@ -22,9 +22,13 @@ Kein Login-Bypass (der Vergleich schlägt immer fehl), aber unnötige Angriffsfl ### A2. `npm test` reparieren (Audit 6) - [x] Entscheiden: Jest raus oder Jest einrichten -- [x] `package.json`: `"test"` auf das zeigen lassen, was wirklich existiert (Playwright) +- [x] `package.json`: `"test"` auf das zeigen lassen, was wirklich existiert - [x] `"test:e2e"` für Playwright ergänzen -- [x] Debug-Skripte in `tests/` aussortieren (siehe unten) +- [x] Debug-Skripte in `tests/` aussortieren +- [x] **63 Unit-Tests** für die ausgelagerten reinen Funktionen (`tests/unit/`), laufen in + ~170 ms ohne Browser und ohne Datenbank über Nodes eingebauten Test-Runner +- [ ] E2E-Tests mit echtem Login schreiben (braucht laufende App + Datenbank) +- [ ] Testlauf in den nächtlichen Qualitätslauf aufnehmen (`scripts/quality-check.sh`) **Warum:** `"test": "jest"` — aber es gibt **keine Jest-Config und keinen einzigen Jest-Test**. Die 15 Dateien in `tests/` sind Playwright-Specs, und die Namen verraten den Charakter: diff --git a/package-lock.json b/package-lock.json index 516a187..dc91b5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "my-weekly-todo-list", - "version": "1.117.7", + "version": "1.118.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "my-weekly-todo-list", - "version": "1.117.7", + "version": "1.118.0", "license": "MIT", "dependencies": { "@auth/prisma-adapter": "^2.11.1", diff --git a/package.json b/package.json index 0714048..70311c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-weekly-todo-list", - "version": "1.117.7", + "version": "1.118.0", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view", "main": "index.js", "scripts": { @@ -9,8 +9,10 @@ "start": "echo \"\\n ✦ My Weekly ToDo List v$(node -p \"require('./package.json').version\") — production\\n\" && next start", "lint": "next lint", "type-check": "tsc --noEmit", - "test": "playwright test", - "test:ui": "playwright test --ui", + "test": "npm run test:unit", + "test:unit": "node --experimental-strip-types --test tests/unit/*.test.ts", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", "test:debug": "playwright test tests/debug", "sonar": "todo-sonar-scan" }, diff --git a/playwright.config.ts b/playwright.config.ts index 30f32b6..24ed78b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -14,7 +14,7 @@ import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './tests', /* tests/debug enthält Wegwerf-Skripte aus der Fehlersuche, keine Regressionstests */ - testIgnore: '**/debug/**', + testIgnore: ['**/debug/**', '**/unit/**'], /* Run tests in files in parallel */ fullyParallel: true, /* Fail the build on CI if you accidentally left test.only in the source code. */ diff --git a/src/components/DaysSelector.tsx b/src/components/DaysSelector.tsx index 678df21..374a2c5 100644 --- a/src/components/DaysSelector.tsx +++ b/src/components/DaysSelector.tsx @@ -1,14 +1,12 @@ "use client"; import React, { useEffect, useRef, useState } from "react"; +import { MAX_VIEW_DAYS, clampViewDays } from "@/lib/weekly/device-settings"; +// Re-Export: WeeklyView importiert clampViewDays historisch von hier +export { MAX_VIEW_DAYS, clampViewDays }; export const DAY_PRESETS = [1, 2, 3, 5, 7]; -export const MAX_VIEW_DAYS = 31; -export function clampViewDays(n: number): number { - if (!Number.isFinite(n)) return 7; - return Math.min(MAX_VIEW_DAYS, Math.max(1, Math.round(n))); -} type Variant = "panel" | "header" | "menu"; diff --git a/src/lib/weekly/date-utils.ts b/src/lib/weekly/date-utils.ts index f817538..7733d9c 100644 --- a/src/lib/weekly/date-utils.ts +++ b/src/lib/weekly/date-utils.ts @@ -1,4 +1,4 @@ -import type { CellDuration } from "@/lib/weekly/types"; +import type { CellDuration, CalendarEvent } from "@/lib/weekly/types"; /** * Datums-, Wochen- und Zeitraster-Helfer der Wochenansicht. @@ -199,3 +199,24 @@ export function getSelectedDay(days: Date[], explicitSelection?: Date | null): D d.getFullYear() === today.getFullYear() ) ? today : days[0]; } + +export const isAllDayEvent = (event: CalendarEvent): boolean => { + if (!event.startTime) return false; + + // Date-only format (YYYY-MM-DD) + if (!event.startTime.includes("T")) return true; + + const start = parseCalendarDate(event.startTime); + const end = parseCalendarDate(event.endTime); + const durationHours = (end.getTime() - start.getTime()) / (1000 * 60 * 60); + + // Check if strictly midnight to midnight in local time + const isLocalMidnight = start.getHours() === 0 && start.getMinutes() === 0; + + // Check if UTC midnight (common for API-converted date strings) + const isUTCMidnight = + start.getUTCHours() === 0 && start.getUTCMinutes() === 0; + + // If it's effectively 24h+ and starts at midnight (local or UTC), treat as all-day + return durationHours >= 24 && (isLocalMidnight || isUTCMidnight); +}; diff --git a/src/lib/weekly/device-settings.ts b/src/lib/weekly/device-settings.ts index ac792a0..47562f6 100644 --- a/src/lib/weekly/device-settings.ts +++ b/src/lib/weekly/device-settings.ts @@ -6,6 +6,16 @@ * Lagen bis v1.117.7 in WeeklyView.tsx. */ + +/** Obergrenze für die Spaltenzahl der Wochenansicht. */ +export const MAX_VIEW_DAYS = 31; + +/** Hält eine gewünschte Spaltenzahl im gültigen Bereich 1…31. */ +export function clampViewDays(n: number): number { + if (!Number.isFinite(n)) return 7; + return Math.min(MAX_VIEW_DAYS, Math.max(1, Math.round(n))); +} + // Cookie helpers for per-device settings // Keys here bypass the DB and save/load from cookie only — each device keeps its own value export const DEVICE_SETTINGS_KEYS = ["viewDays", "cellDuration", "startHour", "endHour", "fontSize", "showSubHourSlots"]; diff --git a/src/lib/weekly/format.ts b/src/lib/weekly/format.ts index 3239b1a..b6cc56f 100644 --- a/src/lib/weekly/format.ts +++ b/src/lib/weekly/format.ts @@ -1,4 +1,4 @@ -import type { Task, CalendarEvent } from "@/lib/weekly/types"; +import type { Task } from "@/lib/weekly/types"; /** * Darstellungs-Helfer der Wochenansicht: Text, Farben, Ganztags-Erkennung, Slot-Anzahl. @@ -22,27 +22,6 @@ export const getSomedaySlotCount = (tasks: Task[]) => { return Math.max(DEFAULT_SOMEDAY_SLOT_COUNT, maxIdx + 2); }; -export const isAllDayEvent = (event: CalendarEvent): boolean => { - if (!event.startTime) return false; - - // Date-only format (YYYY-MM-DD) - if (!event.startTime.includes("T")) return true; - - const start = parseCalendarDate(event.startTime); - const end = parseCalendarDate(event.endTime); - const durationHours = (end.getTime() - start.getTime()) / (1000 * 60 * 60); - - // Check if strictly midnight to midnight in local time - const isLocalMidnight = start.getHours() === 0 && start.getMinutes() === 0; - - // Check if UTC midnight (common for API-converted date strings) - const isUTCMidnight = - start.getUTCHours() === 0 && start.getUTCMinutes() === 0; - - // If it's effectively 24h+ and starts at midnight (local or UTC), treat as all-day - return durationHours >= 24 && (isLocalMidnight || isUTCMidnight); -}; - export function invertColor(hex: string): string { if (!hex) return hex; let color = hex.startsWith("#") ? hex.slice(1) : hex; diff --git a/tests/unit/date-utils.test.ts b/tests/unit/date-utils.test.ts new file mode 100644 index 0000000..aeb4df3 --- /dev/null +++ b/tests/unit/date-utils.test.ts @@ -0,0 +1,230 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { + getStartOfWeek, getDayName, isSameDay, formatDateToISO, parseCalendarDate, + formatHour, getTimeSlots, getHourFromSlot, getWeekNumber, getCWReferenceDate, + getSelectedDay, +} from "../../src/lib/weekly/date-utils.ts"; + +/** + * Unit-Tests der Datums- und Zeitraster-Helfer. + * + * Läuft ohne Browser, ohne Datenbank, ohne laufende App: `npm run test:unit`. + * + * Die Erwartungen sind bewusst gegen unabhängig überprüfbare Fakten formuliert + * (Kalenderdaten, ISO-8601-Regeln) und nicht gegen die Implementierung — ein Test, + * der nur nachspricht, was der Code tut, findet keine Fehler. + */ + +describe("getWeekNumber (ISO 8601)", () => { + // Die Wochenzahl wird prominent in der Kopfzeile angezeigt ("KW 34 | August 2026"), + // ein Fehler hier fällt sofort jedem Nutzer auf. + test("Donnerstag 01.01.2026 liegt in KW 1", () => { + assert.equal(getWeekNumber(new Date(2026, 0, 1)), 1); + }); + + test("Samstag 22.08.2026 liegt in KW 34", () => { + assert.equal(getWeekNumber(new Date(2026, 7, 22)), 34); + }); + + test("Mittwoch 02.09.2026 liegt in KW 36", () => { + assert.equal(getWeekNumber(new Date(2026, 8, 2)), 36); + }); + + test("Montag 01.01.2024 liegt in KW 1", () => { + assert.equal(getWeekNumber(new Date(2024, 0, 1)), 1); + }); + + test("Sonntag 01.01.2023 gehört noch zu KW 52 des Vorjahres", () => { + // ISO 8601: Die Woche gehört dem Jahr, in dem ihr Donnerstag liegt. + assert.equal(getWeekNumber(new Date(2023, 0, 1)), 52); + }); + + test("Freitag 01.01.2021 gehört zu KW 53 des Vorjahres", () => { + assert.equal(getWeekNumber(new Date(2021, 0, 1)), 53); + }); +}); + +describe("getStartOfWeek", () => { + test("Wochenstart Montag: Donnerstag 03.09.2026 → Montag 31.08.2026", () => { + const r = getStartOfWeek(new Date(2026, 8, 3), 1); + assert.equal(formatDateToISO(r), "2026-08-31"); + }); + + test("Wochenstart Montag: Sonntag gehört zur ablaufenden Woche", () => { + // Der klassische Stolperstein — Sonntag ist Tag 0, liegt aber am Wochenende. + const r = getStartOfWeek(new Date(2026, 8, 6), 1); + assert.equal(formatDateToISO(r), "2026-08-31"); + }); + + test("Wochenstart Sonntag: Sonntag ist bereits der Wochenstart", () => { + const r = getStartOfWeek(new Date(2026, 8, 6), 0); + assert.equal(formatDateToISO(r), "2026-09-06"); + }); + + test("Ein Tag, der bereits der Wochenstart ist, bleibt unverändert", () => { + const r = getStartOfWeek(new Date(2026, 7, 31), 1); + assert.equal(formatDateToISO(r), "2026-08-31"); + }); + + test("verändert das übergebene Datum nicht", () => { + const input = new Date(2026, 8, 3); + getStartOfWeek(input, 1); + assert.equal(formatDateToISO(input), "2026-09-03"); + }); +}); + +describe("formatDateToISO", () => { + test("formatiert mit führenden Nullen", () => { + assert.equal(formatDateToISO(new Date(2026, 0, 5)), "2026-01-05"); + }); + + test("nutzt die lokale Zeit, nicht UTC", () => { + // Kern der Sache: toISOString() würde bei einem späten Abendtermin in einer + // Zeitzone östlich von UTC auf den Folgetag rutschen. Genau daraus entstehen + // Termine, die im Kalender einen Tag daneben landen. + const spaeterAbend = new Date(2026, 8, 7, 23, 30, 0); + assert.equal(formatDateToISO(spaeterAbend), "2026-09-07"); + }); + + test("früher Morgen bleibt am selben Tag", () => { + assert.equal(formatDateToISO(new Date(2026, 8, 7, 0, 30, 0)), "2026-09-07"); + }); +}); + +describe("parseCalendarDate", () => { + test("reines Datum wird als lokale Mitternacht gelesen", () => { + const d = parseCalendarDate("2026-09-07"); + assert.equal(d.getFullYear(), 2026); + assert.equal(d.getMonth(), 8); + assert.equal(d.getDate(), 7); + assert.equal(d.getHours(), 0); + }); + + test("Round-Trip mit formatDateToISO ist verlustfrei", () => { + // Diese beiden Funktionen sind die Brücke zwischen Datenbank und Anzeige. + // Wenn sie auseinanderlaufen, verschieben sich Aufgaben um einen Tag. + for (const s of ["2026-01-01", "2026-06-15", "2026-12-31", "2026-02-28"]) { + assert.equal(formatDateToISO(parseCalendarDate(s)), s); + } + }); +}); + +describe("getTimeSlots", () => { + test("30-Minuten-Raster von 8 bis 10 Uhr", () => { + assert.deepEqual(getTimeSlots(30, 8, 10), ["08:00", "08:30", "09:00", "09:30"]); + }); + + test("15-Minuten-Raster ergibt vier Einträge pro Stunde", () => { + assert.deepEqual(getTimeSlots(15, 9, 10), ["09:00", "09:15", "09:30", "09:45"]); + }); + + test("volle Stunden über den ganzen Tag ergeben 24 Einträge", () => { + const slots = getTimeSlots(60, 0, 24); + assert.equal(slots.length, 24); + assert.equal(slots[0], "00:00"); + assert.equal(slots[23], "23:00"); + }); + + test("Endstunde ist exklusiv", () => { + assert.equal(getTimeSlots(60, 8, 9).length, 1); + }); + + test("leeres Ergebnis, wenn Ende nicht nach Start liegt", () => { + assert.deepEqual(getTimeSlots(30, 10, 10), []); + }); +}); + +describe("formatHour", () => { + test("24h-Format, kurz, ohne Minuten", () => { + assert.equal(formatHour(9, 0, "short", "24h"), "9"); + }); + + test("24h-Format mit Minuten wird zweistellig", () => { + assert.equal(formatHour(9, 30, "short", "24h"), "09:30"); + }); + + test("12h-Format: Nachmittag", () => { + assert.equal(formatHour(13, 0, "short", "12h"), "1 PM"); + }); + + test("12h-Format: Mitternacht ist 12 AM, nicht 0 AM", () => { + assert.equal(formatHour(0, 0, "short", "12h"), "12 AM"); + }); + + test("12h-Format: Mittag ist 12 PM", () => { + assert.equal(formatHour(12, 0, "short", "12h"), "12 PM"); + }); +}); + +describe("getHourFromSlot", () => { + test("liest die Stunde aus dem Slot", () => { + assert.equal(getHourFromSlot("08:30"), 8); + assert.equal(getHourFromSlot("23:45"), 23); + }); + + test("führende Null wird nicht als Oktalzahl gelesen", () => { + assert.equal(getHourFromSlot("09:00"), 9); + }); +}); + +describe("isSameDay", () => { + test("gleicher Tag, verschiedene Uhrzeit", () => { + assert.equal(isSameDay(new Date(2026, 8, 7, 8, 0), new Date(2026, 8, 7, 22, 0)), true); + }); + + test("aufeinanderfolgende Tage sind nicht gleich", () => { + assert.equal(isSameDay(new Date(2026, 8, 7), new Date(2026, 8, 8)), false); + }); + + test("gleicher Tag im anderen Jahr ist nicht gleich", () => { + assert.equal(isSameDay(new Date(2026, 8, 7), new Date(2025, 8, 7)), false); + }); +}); + +describe("getCWReferenceDate", () => { + test("liefert den Montag der Woche des ersten sichtbaren Tages", () => { + const days = [new Date(2026, 8, 3), new Date(2026, 8, 4)]; // Do, Fr + assert.equal(formatDateToISO(getCWReferenceDate(days)), "2026-08-31"); + }); + + test("Sonntag gehört zur ablaufenden Woche, nicht zur kommenden", () => { + const days = [new Date(2026, 8, 6)]; // Sonntag + assert.equal(formatDateToISO(getCWReferenceDate(days)), "2026-08-31"); + }); + + test("leere Liste liefert ein Datum statt zu werfen", () => { + assert.ok(getCWReferenceDate([]) instanceof Date); + }); +}); + +describe("getSelectedDay", () => { + test("ohne ausdrückliche Auswahl wird ein Tag aus der Liste geliefert", () => { + const days = [new Date(2026, 8, 7), new Date(2026, 8, 8)]; + assert.ok(days.some((d) => isSameDay(d, getSelectedDay(days)))); + }); + + test("eine ausdrückliche Auswahl aus der Liste wird übernommen", () => { + const days = [new Date(2026, 8, 7), new Date(2026, 8, 8)]; + assert.equal(formatDateToISO(getSelectedDay(days, days[1])), "2026-09-08"); + }); +}); + +describe("getDayName", () => { + test("deutscher Wochentag, lang", () => { + assert.equal(getDayName(new Date(2026, 8, 7), "de-DE"), "Montag"); + }); + + test("eigene Namen werden nach Wochentag zugeordnet", () => { + const namen = "So,Mo,Di,Mi,Do,Fr,Sa"; + assert.equal(getDayName(new Date(2026, 8, 7), "de-DE", "custom", namen, 0), "Mo"); + }); + + test("eigene Namen mit falscher Anzahl fallen auf das Gebietsschema zurück", () => { + assert.equal(getDayName(new Date(2026, 8, 7), "de-DE", "custom", "nur,drei,Namen"), "Montag"); + }); + + test("Großschreibung ist einstellbar", () => { + assert.equal(getDayName(new Date(2026, 8, 7), "de-DE", undefined, undefined, 0, "uppercase"), "MONTAG"); + }); +}); diff --git a/tests/unit/format.test.ts b/tests/unit/format.test.ts new file mode 100644 index 0000000..0ea61db --- /dev/null +++ b/tests/unit/format.test.ts @@ -0,0 +1,133 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { + stripHtml, invertColor, getSomedaySlotCount, DEFAULT_SOMEDAY_SLOT_COUNT, +} from "../../src/lib/weekly/format.ts"; +import { isAllDayEvent } from "../../src/lib/weekly/date-utils.ts"; +import { clampViewDays, MAX_VIEW_DAYS } from "../../src/lib/weekly/device-settings.ts"; +import type { Task, CalendarEvent } from "../../src/lib/weekly/types.ts"; + +/** + * Unit-Tests der Darstellungs-Helfer und der Spaltenbegrenzung. + * Läuft ohne Browser und ohne Datenbank: `npm run test:unit`. + */ + +// Minimale Aufgabe — nur die Felder, die die getestete Funktion liest. +const task = (somedaySlotIndex: number | null): Task => ({ + id: "t", title: "t", completed: false, updatedAt: new Date(), order: 0, + userId: "u", somedaySlotIndex, +}); + +describe("stripHtml", () => { + test("entfernt Tags, behält den Text", () => { + assert.equal(stripHtml("

Hallo Welt

"), "Hallo Welt"); + }); + + test("schneidet umgebende Leerzeichen ab", () => { + assert.equal(stripHtml("

Text

"), "Text"); + }); + + test("lässt reinen Text unverändert", () => { + assert.equal(stripHtml("Einkaufen gehen"), "Einkaufen gehen"); + }); + + test("kommt mit leerer Eingabe zurecht", () => { + assert.equal(stripHtml(""), ""); + }); +}); + +describe("invertColor", () => { + test("invertiert Weiß zu Schwarz", () => { + assert.equal(invertColor("#ffffff"), "#000000"); + }); + + test("invertiert Schwarz zu Weiß", () => { + assert.equal(invertColor("#000000"), "#ffffff"); + }); + + test("Kurzform wird ausgeschrieben", () => { + assert.equal(invertColor("#fff"), "#000000"); + }); + + test("Rautezeichen ist optional", () => { + assert.equal(invertColor("ffffff"), "#000000"); + }); + + test("zweimal invertieren ergibt die Ausgangsfarbe", () => { + assert.equal(invertColor(invertColor("#3b82f6")), "#3b82f6"); + }); + + test("ungültige Eingaben werden unverändert zurückgegeben", () => { + assert.equal(invertColor("keine-farbe"), "keine-farbe"); + assert.equal(invertColor(""), ""); + }); +}); + +describe("getSomedaySlotCount", () => { + test("leere Liste ergibt die Grundanzahl", () => { + assert.equal(getSomedaySlotCount([]), DEFAULT_SOMEDAY_SLOT_COUNT); + }); + + test("hält unter der Grundanzahl immer die Grundanzahl", () => { + assert.equal(getSomedaySlotCount([task(0), task(1)]), DEFAULT_SOMEDAY_SLOT_COUNT); + }); + + test("wächst mit, sobald der letzte Platz belegt ist", () => { + // Anspruch laut Kommentar im Code: unten bleibt immer ein freier Platz. + assert.equal(getSomedaySlotCount([task(5)]), 7); + }); + + test("Aufgaben ohne Platznummer verändern nichts", () => { + assert.equal(getSomedaySlotCount([task(null), task(null)]), DEFAULT_SOMEDAY_SLOT_COUNT); + }); +}); + +describe("isAllDayEvent", () => { + // Diese Funktion rief parseCalendarDate auf, ohne es zu importieren — beim Auslagern + // aus WeeklyView.tsx übersehen. Der Build meldete nichts, weil ignoreBuildErrors + // aktiv war; zur Laufzeit hätte es einen ReferenceError gegeben. Dieser Test hätte + // den Fehler sofort gefunden. + const ev = (startTime: string, endTime: string): CalendarEvent => + ({ id: "e", title: "e", startTime, endTime, source: "google" }); + + test("reines Datum ohne Uhrzeit gilt als ganztägig", () => { + assert.equal(isAllDayEvent(ev("2026-09-07", "2026-09-08")), true); + }); + + test("Termin mit Uhrzeit über eine Stunde ist nicht ganztägig", () => { + assert.equal(isAllDayEvent(ev("2026-09-07T10:00:00", "2026-09-07T11:00:00")), false); + }); + + test("Mitternacht bis Mitternacht gilt als ganztägig", () => { + assert.equal(isAllDayEvent(ev("2026-09-07T00:00:00", "2026-09-08T00:00:00")), true); + }); + + test("ohne Startzeit nicht ganztägig", () => { + assert.equal(isAllDayEvent(ev("", "")), false); + }); +}); + +describe("clampViewDays", () => { + test("gültige Werte bleiben unverändert", () => { + for (const n of [1, 7, 14, 21, 31]) assert.equal(clampViewDays(n), n); + }); + + test("begrenzt nach oben", () => { + assert.equal(clampViewDays(999), MAX_VIEW_DAYS); + }); + + test("begrenzt nach unten", () => { + assert.equal(clampViewDays(0), 1); + assert.equal(clampViewDays(-5), 1); + }); + + test("rundet Kommazahlen", () => { + assert.equal(clampViewDays(7.4), 7); + assert.equal(clampViewDays(7.6), 8); + }); + + test("fällt bei Unsinn auf 7 zurück", () => { + assert.equal(clampViewDays(NaN), 7); + assert.equal(clampViewDays(Infinity), 7); + }); +});