My-Weekly-ToDo-List/tests/unit/drag-drop.test.ts
mARTin-B78 fba9a5d533
Some checks are pending
Playwright Tests / test (push) Waiting to run
Test and SonarQube / quality (push) Waiting to run
refactor: Drop-Entscheidung aus handleDrop in reine, getestete Funktion ausgelagert
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>
2026-09-13 13:54:28 +02:00

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");
});
});