Eine im Titel getippte URL war bisher reiner Text innerhalb des klickbaren Bereichs
der Aufgabe -- ein Klick darauf hat je nach Ansicht die Aufgabe durchgestrichen
(Tagesraster, GridTaskBlock) oder den Bearbeiten-Modus geoeffnet (Kanban/Someday/
Liste/Prioritaet, weekly/TaskItem.tsx und PriorityView.tsx), statt den Link zu oeffnen.
- src/lib/weekly/format.ts: neue reine Funktion splitTextAndUrls zerlegt einen Titel
in Text- und URL-Abschnitte (http(s)://, www.-Adressen). Abschliessende Satzzeichen
(".", ",", ")", ...) bleiben Text, nicht Teil des Links.
- src/components/weekly/LinkifiedText.tsx: rendert diese Abschnitte, URL-Abschnitte als
echtes <a target="_blank" rel="noopener noreferrer"> mit stopPropagation, damit ein
Klick ausschliesslich den Link oeffnet und nicht an den umgebenden Klick-Handler
durchgereicht wird. Kein Titel ohne URL bekommt ein zusaetzliches Wrapper-Element.
- Eingebaut ueberall dort, wo ein Aufgabentitel bislang als reiner Text stand:
GridTaskBlock.tsx, weekly/TaskItem.tsx, PriorityView.tsx.
Keine Aenderung an Daten, API oder dem separaten task.url-Feld (der eigene "Termin-
Link"-Button in TaskItem.tsx) -- reine Darstellungsaenderung.
8 neue Unit-Tests fuer splitTextAndUrls (Titel ohne URL, nur URL, URL mit Text davor/
danach, www.-Form, Satzzeichen am Ende, Klammern, zwei URLs im selben Titel).
Verifiziert: Typecheck, Lint (0 Fehler), 108 Unit-Tests (8 neu), Produktions-Build.
v1.128.0
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
205 lines
7.3 KiB
TypeScript
205 lines
7.3 KiB
TypeScript
import { test, describe } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import {
|
|
stripHtml, invertColor, getSomedaySlotCount, DEFAULT_SOMEDAY_SLOT_COUNT,
|
|
splitTextAndUrls,
|
|
} 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("<p>Hallo <b>Welt</b></p>"), "Hallo Welt");
|
|
});
|
|
|
|
test("schneidet umgebende Leerzeichen ab", () => {
|
|
assert.equal(stripHtml(" <p> Text </p> "), "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);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* URLs in Aufgabentiteln erkennen (damit sie als Link statt als reiner Text angezeigt
|
|
* werden können, siehe LinkifiedText.tsx). Erwartetes Verhalten: eine getippte Adresse
|
|
* im Titel wird als eigenes Segment erkannt, umgebender Text bleibt unverändert, und
|
|
* Satzzeichen, die grammatisch zum Satz gehören, werden nicht mit in den Link gezogen.
|
|
*/
|
|
describe("splitTextAndUrls", () => {
|
|
test("Titel ohne URL bleibt ein einzelnes Textsegment", () => {
|
|
assert.deepEqual(splitTextAndUrls("Einkaufen gehen"), [
|
|
{ type: "text", value: "Einkaufen gehen" },
|
|
]);
|
|
});
|
|
|
|
test("leerer Titel ergibt keine Segmente", () => {
|
|
assert.deepEqual(splitTextAndUrls(""), []);
|
|
});
|
|
|
|
test("Titel, der nur aus einer URL besteht", () => {
|
|
assert.deepEqual(splitTextAndUrls("https://example.com"), [
|
|
{ type: "url", value: "https://example.com" },
|
|
]);
|
|
});
|
|
|
|
test("URL mit Text davor und danach", () => {
|
|
assert.deepEqual(
|
|
splitTextAndUrls("Rechnung siehe https://example.com/invoice prüfen"),
|
|
[
|
|
{ type: "text", value: "Rechnung siehe " },
|
|
{ type: "url", value: "https://example.com/invoice" },
|
|
{ type: "text", value: " prüfen" },
|
|
],
|
|
);
|
|
});
|
|
|
|
test("www.-Adresse ohne Schema wird ebenfalls erkannt", () => {
|
|
assert.deepEqual(splitTextAndUrls("siehe www.example.com"), [
|
|
{ type: "text", value: "siehe " },
|
|
{ type: "url", value: "www.example.com" },
|
|
]);
|
|
});
|
|
|
|
test("abschließender Satzpunkt gehört zum Text, nicht zur URL", () => {
|
|
const segments = splitTextAndUrls("Link: https://example.com.");
|
|
assert.deepEqual(segments, [
|
|
{ type: "text", value: "Link: " },
|
|
{ type: "url", value: "https://example.com" },
|
|
{ type: "text", value: "." },
|
|
]);
|
|
});
|
|
|
|
test("URL in Klammern: die schließende Klammer bleibt Text", () => {
|
|
assert.deepEqual(splitTextAndUrls("Details (https://example.com/x) nachlesen"), [
|
|
{ type: "text", value: "Details (" },
|
|
{ type: "url", value: "https://example.com/x" },
|
|
{ type: "text", value: ") nachlesen" },
|
|
]);
|
|
});
|
|
|
|
test("zwei URLs im selben Titel", () => {
|
|
assert.deepEqual(
|
|
splitTextAndUrls("https://a.example.com und https://b.example.com"),
|
|
[
|
|
{ type: "url", value: "https://a.example.com" },
|
|
{ type: "text", value: " und " },
|
|
{ type: "url", value: "https://b.example.com" },
|
|
],
|
|
);
|
|
});
|
|
});
|