Erster Schnitt aus TODO-CODESCENE-FORTSETZUNG-2026-09-12.md, nach Owner-Freigabe. handleDrop (WeeklyView.tsx) vermischte fachliche Drop-Entscheidung (Zielslot bei Belegung finden, Subtask-Befoerderung vs. Someday-Verschieben vs. normales Verschieben) mit React-State-Updates und Netzwerkaufrufen. Die Entscheidung ist jetzt eine reine Funktion decideTaskDrop in src/lib/weekly/drag-drop.ts: - kennt weder DOM noch React noch Netzwerk, nur Task/Zieltag/Slot als Daten - kein Import von date-utils.ts: der Aufrufer liefert dayOfWeek/scheduledDate/ allSlots fertig, damit die Datei nur einen Typ-Import hat (wie date-utils.ts/ format.ts/event-layout.ts in diesem Ordner) und ohne Next.js-Bundler in node:test laeuft - 15 Charakterisierungstests: freier/belegter/geschuetzter Slot, Drop ohne Slot mit/ohne vorhandene Startzeit, Subtask-Befoerderung, Someday-Provider- Vererbung (eigener vs. geerbter Provider), externe Sync-Bedingung, virtuelle wiederkehrende Instanzen (Server materialisiert sie weiterhin unveraendert ueber VIRTUAL_ID_REGEX in api/tasks/route.ts) handleDrop selbst liest jetzt nur noch Eingaben, ruft decideTaskDrop auf und fuehrt in einem switch exakt die bisherigen Seiteneffekte aus -- keine geaenderten fetch-Aufrufe, kein geaendertes optimistisches UI-Update, keine geaenderte API-Payload. Wichtiger Nebenbefund, kein Teil dieses Refactorings: Task-Drag-and-Drop funktioniert nur am Desktop. WeeklyView.tsx nutzt ausschliesslich natives HTML5-Drag (draggable/onDragStart/onDrop), das auf Touch-Geraeten nicht feuert. Kein separater Touch-Mechanismus vorhanden; react-dnd/ react-dnd-html5-backend sind installiert, aber nirgends importiert. decideTaskDrop ist bewusst so geschnitten, dass ein kuenftiger Touch-Mechanismus sie unveraendert wiederverwenden koennte -- das Bauen dieses Mechanismus ist ein eigenes Feature-Vorhaben (siehe TODO.md Block M3). Verifiziert: Typecheck, Lint (0 Fehler), 100 Unit-Tests (85 -> 100, 15 neu), Produktions-Build -- alle gruen. Kein Playwright-E2E fuer Drag-and-Drop (existiert im Repo bislang nicht). v1.126.0 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
299 lines
9.7 KiB
TypeScript
299 lines
9.7 KiB
TypeScript
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");
|
|
});
|
|
});
|