import { test, describe } from "node:test"; import assert from "node:assert/strict"; import { decideTaskDrop } from "../../src/lib/weekly/drag-drop.ts"; import type { Task, SomedayList } from "../../src/lib/weekly/types.ts"; /** * Charakterisierungstests fuer die Drop-Entscheidung im Tagesraster (WeeklyView.tsx' * handleDrop). Beobachtbares Verhalten aus dem bestehenden Code, nicht neu erfundenes * Wunschverhalten -- siehe die Analyse in TODO-CODESCENE-FORTSETZUNG-2026-09-12.md. * * decideTaskDrop kennt weder Maus noch Touch: sie bekommt Quelle (Task), Ziel (Tag/Slot) * und die bereits vorhandenen Kollisions-Pruefungen als Parameter. Das ist bewusst so, damit * dieselbe Entscheidung spaeter auch von einem Touch-basierten Drag-Mechanismus fuer * Mobilgeraete genutzt werden koennte -- Aufgaben lassen sich heute NUR am Desktop per * Drag-and-Drop verschieben (natives HTML5-Drag-Event, feuert auf Touch-Geraeten nicht). */ const baseTask: Task = { id: "t1", title: "Test task", completed: false, order: 0, updatedAt: new Date("2026-09-01T00:00:00Z"), userId: "u1", }; const dayOfWeek = 2; // Dienstag const scheduledDate = "2026-09-15"; const allSlots = ["08:00", "08:30", "09:00", "09:30", "10:00", "10:30", "11:00"]; const neverOccupied = () => false; const neverProtected = () => false; describe("decideTaskDrop", () => { test("kein gezogener Task -> noop", () => { const op = decideTaskDrop( null, dayOfWeek, scheduledDate, "09:00", allSlots, neverOccupied, neverProtected, [], ); assert.deepEqual(op, { kind: "noop" }); }); test("normaler Task auf freien Slot -> moveToSlot mit genau diesem Slot", () => { const op = decideTaskDrop( baseTask, dayOfWeek, scheduledDate, "09:00", allSlots, neverOccupied, neverProtected, [], ); assert.deepEqual(op, { kind: "moveToSlot", taskId: "t1", dayOfWeek, startTime: "09:00", scheduledDate, }); }); test("normaler Task auf belegten Slot -> erster freier, ungeschuetzter Folge-Slot", () => { const isOccupied = (slot: string) => slot === "09:00" || slot === "09:30"; const op = decideTaskDrop( baseTask, dayOfWeek, scheduledDate, "09:00", allSlots, isOccupied, neverProtected, [], ); assert.equal(op.kind, "moveToSlot"); assert.equal((op as { startTime: string }).startTime, "10:00"); }); test("alle Folge-Slots belegt/geschuetzt -> faellt auf den urspruenglich angeforderten Slot zurueck", () => { const isOccupied = () => true; const op = decideTaskDrop( baseTask, dayOfWeek, scheduledDate, "09:00", allSlots, isOccupied, neverProtected, [], ); assert.equal(op.kind, "moveToSlot"); assert.equal((op as { startTime: string }).startTime, "09:00"); }); test("geschuetzter Folge-Slot wird uebersprungen, naechster freier genommen", () => { const isOccupied = (slot: string) => slot === "09:00"; const isProtected = (slot: string) => slot === "09:30"; const op = decideTaskDrop( baseTask, dayOfWeek, scheduledDate, "09:00", allSlots, isOccupied, isProtected, [], ); assert.equal(op.kind, "moveToSlot"); assert.equal((op as { startTime: string }).startTime, "10:00"); }); test("Drop auf Tag ohne Slot, Task hat bereits eine Startzeit -> behaelt sie", () => { const task = { ...baseTask, startTime: "08:30" }; const op = decideTaskDrop( task, dayOfWeek, scheduledDate, undefined, allSlots, neverOccupied, neverProtected, [], ); assert.equal(op.kind, "moveToSlot"); assert.equal((op as { startTime: string }).startTime, "08:30"); }); test("Drop auf Tag ohne Slot, Task hat keine Startzeit -> leere Startzeit", () => { const op = decideTaskDrop( baseTask, dayOfWeek, scheduledDate, undefined, allSlots, neverOccupied, neverProtected, [], ); assert.equal(op.kind, "moveToSlot"); assert.equal((op as { startTime: string }).startTime, ""); }); test("Drop auf denselben Slot, den der Task schon hat -> heutiges Verhalten: erneutes moveToSlot, kein noop", () => { // Bewusst dokumentiertes Ist-Verhalten (kein Persistenz-Kurzschluss heute) -- // nicht Wunschverhalten. Eine Aenderung hier ist ein eigener, spaeterer Schritt. const task = { ...baseTask, startTime: "09:00", scheduledDate }; const op = decideTaskDrop( task, dayOfWeek, scheduledDate, "09:00", allSlots, neverOccupied, neverProtected, [], ); assert.equal(op.kind, "moveToSlot"); }); test("Subtask wird auf jedes Ziel gedroppt -> promoteSubtask, vom Elternteil geloest", () => { const subtask = { ...baseTask, id: "sub1", parentTaskId: "parent1" }; const op = decideTaskDrop( subtask, dayOfWeek, scheduledDate, "09:00", allSlots, neverOccupied, neverProtected, [], ); assert.deepEqual(op, { kind: "promoteSubtask", taskId: "sub1", scheduledDate, dayOfWeek, startTime: "09:00", }); }); test("Someday-Task ohne eigenen Provider erbt den Provider der Liste", () => { const somedayTask = { ...baseTask, id: "sd1", somedayListId: "list1" }; const lists: SomedayList[] = [ { id: "list1", title: "Someday", tasks: [], externalProvider: "google" }, ]; const op = decideTaskDrop( somedayTask, dayOfWeek, scheduledDate, "09:00", allSlots, neverOccupied, neverProtected, lists, ); assert.equal(op.kind, "moveFromSomeday"); assert.equal((op as { externalProvider: string | null }).externalProvider, "google"); }); test("Someday-Task mit eigenem Provider behaelt ihn, ignoriert den der Liste", () => { const somedayTask = { ...baseTask, id: "sd2", somedayListId: "list1", externalProvider: "outlook" }; const lists: SomedayList[] = [ { id: "list1", title: "Someday", tasks: [], externalProvider: "google" }, ]; const op = decideTaskDrop( somedayTask, dayOfWeek, scheduledDate, "09:00", allSlots, neverOccupied, neverProtected, lists, ); assert.equal(op.kind, "moveFromSomeday"); assert.equal((op as { externalProvider: string | null }).externalProvider, "outlook"); }); test("Someday-Task mit externalId+Provider -> syncExternal gesetzt", () => { const somedayTask = { ...baseTask, id: "sd3", somedayListId: "list1", externalId: "ext-1", externalProvider: "google", }; const op = decideTaskDrop( somedayTask, dayOfWeek, scheduledDate, "09:00", allSlots, neverOccupied, neverProtected, [], ); assert.equal(op.kind, "moveFromSomeday"); assert.deepEqual((op as { syncExternal: unknown }).syncExternal, { taskId: "sd3", scheduledDate, }); }); test("Someday-Task ohne externalId -> kein syncExternal, obwohl Provider vorhanden", () => { const somedayTask = { ...baseTask, id: "sd4", somedayListId: "list1", externalProvider: "google" }; const op = decideTaskDrop( somedayTask, dayOfWeek, scheduledDate, "09:00", allSlots, neverOccupied, neverProtected, [], ); assert.equal(op.kind, "moveFromSomeday"); assert.equal((op as { syncExternal: unknown }).syncExternal, null); }); test("virtuelle wiederkehrende Instanz behaelt ihre virtuelle Id unveraendert", () => { // Der Server (VIRTUAL_ID_REGEX in api/tasks/route.ts) materialisiert daraus bei // PATCH eine echte Instanz. Die Entscheidung darf die Id nicht selbst aufloesen. const virtualTask = { ...baseTask, id: "virtual-t1-2026-09-20" }; const op = decideTaskDrop( virtualTask, dayOfWeek, scheduledDate, "09:00", allSlots, neverOccupied, neverProtected, [], ); assert.equal(op.kind, "moveToSlot"); assert.equal((op as { taskId: string }).taskId, "virtual-t1-2026-09-20"); }); test("Subtask hat Vorrang vor Someday, falls (praktisch unmoeglich) beides gesetzt waere", () => { const both = { ...baseTask, parentTaskId: "parent1", somedayListId: "list1" }; const op = decideTaskDrop( both, dayOfWeek, scheduledDate, "09:00", allSlots, neverOccupied, neverProtected, [], ); assert.equal(op.kind, "promoteSubtask"); }); });