pageta-native/Tests/UnitTests/DigestTests.swift
Till JS 7cb9399b4c feat: Lese-Rückblick (Tag/Woche/Gesamt) als nativer Tab
Pendant zur Web-Route /rueckblick. Synthetisiert, was man WIRKLICH
gelesen hat (status=finished oder readingProgress>=0.5 im Fenster).

- Core/Domain/Digest.swift: reine, getestete Logik — Swift-Spiegel von
  pageta-domain/digest.ts (Auswahl nach Zeitfenster + Lese-Signal,
  Theme-Gruppierung nach Tags, Prompt-Builder). 8 XCTest-Fälle, grün.
- Features/RueckblickTabView.swift: deterministisches Gerüst steht IMMER
  (gratis, offline) — Kennzahlen, Themencluster, eigene Markierungen
  wörtlich. Darüber optionale Synthese via ManaLLM.generate (lokal-first;
  liefert sie nichts, bleibt das Gerüst nutzbar). Tag/Woche/Gesamt-Picker,
  Markdown-ShareLink, Tap → ArticleDetailView.
- RootView: neuer Tab „Rückblick" (iOS TabView + macOS Sidebar), Icon
  clock.arrow.circlepath, nach Markierungen.
- Motivations-Leitplanke (Wert 8): ruhiger Spiegel, keine Streaks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 19:26:43 +02:00

123 lines
4.1 KiB
Swift

import XCTest
@testable import PagetaNative
final class DigestTests: XCTestCase {
// Fester Bezugspunkt, damit die rollenden Fenster deterministisch sind.
private let now = ISO8601DateFormatter().date(from: "2026-06-08T12:00:00Z")!
private func iso(_ offsetDays: Double) -> String {
let d = now.addingTimeInterval(-offsetDays * 86_400)
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f.string(from: d)
}
private func makeArticle(
id: String = "a1",
status: ArticleStatus = .finished,
readingProgress: Double = 1,
readAtDaysAgo: Double = 0,
tags: [String] = []
) -> Article {
Article(
id: id,
userId: "u1",
originalUrl: "https://example.com/\(id)",
title: "Titel \(id)",
excerpt: nil,
content: "",
htmlContent: nil,
author: nil,
siteName: "Example",
imageUrl: nil,
wordCount: nil,
readingTimeMinutes: nil,
publishedAt: nil,
status: status,
readingProgress: readingProgress,
isFavorite: false,
savedAt: iso(readAtDaysAgo),
readAt: iso(readAtDaysAgo),
userNote: nil,
extractedVersion: 1,
createdAt: iso(readAtDaysAgo),
updatedAt: iso(readAtDaysAgo),
warning: nil,
tags: tags
)
}
private func makeBrief(id: String = "a1", tags: [String] = []) -> DigestBrief {
DigestBrief(id: id, title: "Titel \(id)", source: "Example", url: id, brief: "Kurztext.", tags: tags)
}
func testWindowStart() {
XCTAssertEqual(Digest.windowStart(scope: .day, now: now), now.addingTimeInterval(-86_400))
XCTAssertEqual(Digest.windowStart(scope: .week, now: now), now.addingTimeInterval(-7 * 86_400))
XCTAssertNil(Digest.windowStart(scope: .all, now: now))
}
func testSelectFinishedInWindow() {
let recent = makeArticle(id: "recent", readAtDaysAgo: 2)
let old = makeArticle(id: "old", readAtDaysAgo: 30)
let picked = Digest.selectArticles([recent, old], scope: .week, now: now)
XCTAssertEqual(picked.map(\.id), ["recent"])
}
func testSelectSubstantiallyReadButNotBarely() {
let half = makeArticle(id: "half", status: .reading, readingProgress: 0.6)
let barely = makeArticle(id: "barely", status: .reading, readingProgress: 0.1)
let picked = Digest.selectArticles([half, barely], scope: .week, now: now)
XCTAssertEqual(picked.map(\.id), ["half"])
}
func testSelectExcludesArchivedAndSortsNewestFirst() {
let archived = makeArticle(id: "arch", status: .archived)
let older = makeArticle(id: "older", readAtDaysAgo: 3)
let newer = makeArticle(id: "newer", readAtDaysAgo: 1)
let picked = Digest.selectArticles([archived, older, newer], scope: .week, now: now)
XCTAssertEqual(picked.map(\.id), ["newer", "older"])
}
func testAllScopeIgnoresWindow() {
let ancient = makeArticle(id: "ancient", readAtDaysAgo: 400)
let picked = Digest.selectArticles([ancient], scope: .all, now: now)
XCTAssertEqual(picked.map(\.id), ["ancient"])
}
func testGroupByThemePrimaryTagUntaggedLast() {
let groups = Digest.groupByTheme([
makeBrief(id: "1", tags: ["klima"]),
makeBrief(id: "2", tags: ["klima", "politik"]),
makeBrief(id: "3", tags: []),
makeBrief(id: "4", tags: ["politik"]),
])
XCTAssertEqual(groups.first?.theme, "klima")
XCTAssertEqual(groups.first?.articles.map(\.id), ["1", "2"])
XCTAssertEqual(groups.last?.theme, Digest.untaggedTheme)
}
func testBuildSynthesisPromptContainsTitlesAndHighlights() {
let out = Digest.buildSynthesisPrompt(
scope: .week,
briefs: [makeBrief(id: "ki", tags: ["ki"])],
highlights: [
DigestHighlightLine(
articleId: "ki", source: "Example", title: "Titel ki",
text: "wichtiger Satz", note: "merken"
),
]
)
XCTAssertTrue(out.prompt.contains("Titel ki"))
XCTAssertTrue(out.prompt.contains("wichtiger Satz"))
XCTAssertTrue(out.prompt.contains("## Überblick"))
XCTAssertEqual(out.usedArticles, 1)
XCTAssertEqual(out.usedHighlights, 1)
}
func testBuildSynthesisPromptTruncates() {
let briefs = (0..<100).map { makeBrief(id: "b\($0)") }
let out = Digest.buildSynthesisPrompt(scope: .all, briefs: briefs, highlights: [], maxArticles: 40)
XCTAssertEqual(out.usedArticles, 40)
}
}