From 75c366bff4896e20ad20d6173b79a6acf7727ee0 Mon Sep 17 00:00:00 2001 From: Till JS Date: Sat, 25 Apr 2026 12:57:24 +0200 Subject: [PATCH] test(writing): unit tests for prompt-builder + reference-resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 64 new tests across two pure-logic surfaces — no Dexie / network / component setup, runs in <150ms. Plus the LOCAL TIER PATCH revert that's been waiting for the release window. prompt-builder.test.ts (39 tests): - buildDraftPrompt: ghostwriter system + topic/length/kind plumbing, optional audience/tone/extra-instructions, preset style injection, resolved-references rendering with singular/plural Quelle wording and proper bookend markers. - All five selection prompts (shorten 50–60% / expand 150–180% / tone with target / rewrite with instruction / translate with target lang). - buildTitleSuggestionPrompt: 4–8-word ask, no quotes, no period, no prefix; with/without excerpt block. - cleanSuggestedTitle: now iterative-until-stable so combined artefacts ("Titel: \"Hello World\".") collapse in one call. Quote variants (straight, curly, German „, French «, single ‚) all stripped via asymmetric open/close sets. - estimateMaxTokens: clamping to [256, 8000], words/chars/minutes conversions, fallback when targetLength is null. reference-resolver.test.ts (25 tests): - Per-kind shaping for article (siteName-prefix, content/excerpt fallback, truncation marker), note (untitled fallback), library (book metadata in the label), url (no fetch), kontext (singleton via scopedForModule, deletedAt skip), goal (plaintext, no decrypt call asserted), me-image (label + tags descriptor, kind fallback). - Aggregate-budget enforcement in resolveReferences: drops nulls, stops adding once MAX_TOTAL_REFERENCE_CHARS is exceeded, but always keeps the first ref even if it alone busts the cap (so a single large reference doesn't silently produce zero output). Side-fix: resolver uses `||` for the article content/excerpt fallback so empty-string content (extraction failures) falls through to the excerpt — `??` was passing empty strings as valid. LOCAL TIER PATCH revert: requiredTier flips from 'guest' to 'beta' in shared-branding/mana-apps.ts. Writing now gates correctly on release; the comment marker is removed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../writing/utils/prompt-builder.test.ts | 348 +++++++++++++++ .../modules/writing/utils/prompt-builder.ts | 45 +- .../writing/utils/reference-resolver.test.ts | 420 ++++++++++++++++++ .../writing/utils/reference-resolver.ts | 4 +- packages/shared-branding/src/mana-apps.ts | 2 +- 5 files changed, 797 insertions(+), 22 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/modules/writing/utils/prompt-builder.test.ts create mode 100644 apps/mana/apps/web/src/lib/modules/writing/utils/reference-resolver.test.ts diff --git a/apps/mana/apps/web/src/lib/modules/writing/utils/prompt-builder.test.ts b/apps/mana/apps/web/src/lib/modules/writing/utils/prompt-builder.test.ts new file mode 100644 index 000000000..a587366fc --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/writing/utils/prompt-builder.test.ts @@ -0,0 +1,348 @@ +/** + * Pure-function tests for the writing prompt builder. No Dexie + no + * network — only string assembly and deterministic helpers. + */ + +import { describe, it, expect } from 'vitest'; +import { + buildDraftPrompt, + buildShortenPrompt, + buildExpandPrompt, + buildChangeTonePrompt, + buildRewritePrompt, + buildTranslatePrompt, + buildTitleSuggestionPrompt, + cleanSuggestedTitle, + estimateMaxTokens, + type SelectionContext, +} from './prompt-builder'; +import type { ResolvedReference } from './reference-resolver'; +import type { DraftBriefing, DraftKind } from '../types'; +import type { StylePreset } from '../presets/styles'; + +const baseBriefing: DraftBriefing = { + topic: 'Was Mana von klassischen Tools unterscheidet', + audience: null, + tone: null, + language: 'de', + targetLength: { type: 'words', value: 500 }, + extraInstructions: null, + useResearch: false, +}; + +function preset(overrides: Partial = {}): StylePreset { + return { + id: 'casual-blog', + name: { de: 'Casual Blog', en: 'Casual blog' }, + description: { de: 'Du-Ansprache, kurze Absätze.', en: '...' }, + principles: { + toneTraits: ['conversational', 'direct'], + rawAnalysis: 'Kurze Sätze. Du-Form. Keine Buzzwords.', + extractedAt: '2026-04-24T00:00:00Z', + }, + ...overrides, + }; +} + +describe('buildDraftPrompt', () => { + it('produces a system+user pair with topic + length + kind', () => { + const { system, user } = buildDraftPrompt({ + kind: 'blog', + title: 'Mein Test-Post', + briefing: baseBriefing, + }); + expect(system).toContain('Ghostwriter'); + expect(system).toContain('Blogpost'); + expect(system).toContain('Deutsch'); + // The "no preamble" instruction is the most-load-bearing part of + // the system prompt — guard it explicitly. + expect(system).toContain('Hier ist dein Text'); + expect(user).toContain('Titel: Mein Test-Post'); + expect(user).toContain('Thema: Was Mana von'); + expect(user).toContain('Ziel-Länge: ca. 500 Wörter'); + }); + + it('omits audience/tone/extraInstructions when not set', () => { + const { user } = buildDraftPrompt({ + kind: 'blog', + title: 'X', + briefing: baseBriefing, + }); + expect(user).not.toContain('Zielgruppe:'); + expect(user).not.toContain('Ton:'); + expect(user).not.toContain('Zusätzliche Hinweise:'); + }); + + it('includes audience/tone/extraInstructions when set', () => { + const { user } = buildDraftPrompt({ + kind: 'blog', + title: 'X', + briefing: { + ...baseBriefing, + audience: 'Gründer', + tone: 'sachlich', + extraInstructions: 'mit einem Zitat beginnen', + }, + }); + expect(user).toContain('Zielgruppe: Gründer'); + expect(user).toContain('Ton: sachlich'); + expect(user).toContain('Zusätzliche Hinweise: mit einem Zitat beginnen'); + }); + + it('embeds preset style principles into the system prompt', () => { + const { system } = buildDraftPrompt({ + kind: 'blog', + title: 'X', + briefing: baseBriefing, + stylePreset: preset(), + }); + expect(system).toContain('Casual Blog'); + expect(system).toContain('Du-Form'); + expect(system).toContain('conversational'); + }); + + it('renders resolved references as a "Quellen" block + flags it in system', () => { + const refs: ResolvedReference[] = [ + { + kind: 'article', + sourceLabel: 'Artikel: NYT — Headline', + title: 'Headline', + content: 'Body of the article.', + note: 'wichtig fürs Argument', + }, + { + kind: 'note', + sourceLabel: 'Notiz: Mein Gedanke', + title: 'Mein Gedanke', + content: 'kurze notiz', + note: null, + }, + ]; + const { system, user } = buildDraftPrompt({ + kind: 'blog', + title: 'X', + briefing: baseBriefing, + resolvedReferences: refs, + }); + expect(system).toContain('2 Quellen verknüpft'); + expect(system).toContain('Paraphrasiere'); + expect(user).toContain('--- Quellen'); + expect(user).toContain('[Quelle 1] Artikel: NYT — Headline'); + expect(user).toContain('Kontext: wichtig fürs Argument'); + expect(user).toContain('[Quelle 2] Notiz: Mein Gedanke'); + expect(user).toContain('--- Ende Quellen ---'); + }); + + it('does not mention Quellen when no references are resolved', () => { + const { system, user } = buildDraftPrompt({ + kind: 'blog', + title: 'X', + briefing: baseBriefing, + resolvedReferences: [], + }); + expect(system).not.toContain('Quellen'); + expect(user).not.toContain('Quellen'); + }); + + it('uses singular "Quelle" for exactly one reference', () => { + const { system } = buildDraftPrompt({ + kind: 'blog', + title: 'X', + briefing: baseBriefing, + resolvedReferences: [ + { + kind: 'url', + sourceLabel: 'Link: https://example.com', + title: 'https://example.com', + content: '', + note: null, + }, + ], + }); + expect(system).toContain('1 Quelle verknüpft'); + expect(system).not.toContain('1 Quellen'); + }); +}); + +describe('selection prompts', () => { + const ctx: SelectionContext = { + selectionText: 'Dieser Satz ist redundant und enthält Füllwörter.', + language: 'de', + }; + + it('buildShortenPrompt asks for ~50–60% length and fences the selection', () => { + const { system, user } = buildShortenPrompt(ctx); + expect(system).toContain('kürzt'); + // Trailer must be present for every selection prompt — it's what + // keeps the model from prefixing "Hier ist…". + expect(system).toContain('Nur der Ersatztext'); + expect(user).toContain('50–60%'); + expect(user).toContain('---\nDieser Satz ist redundant'); + }); + + it('buildExpandPrompt asks for ~150–180% length', () => { + const { user } = buildExpandPrompt(ctx); + expect(user).toContain('150–180%'); + }); + + it('buildChangeTonePrompt embeds the target tone', () => { + const { user } = buildChangeTonePrompt(ctx, { targetTone: 'warm' }); + expect(user).toContain('"warm"'); + }); + + it('buildRewritePrompt embeds the freeform instruction', () => { + const { user } = buildRewritePrompt(ctx, { instruction: 'aktiver formulieren' }); + expect(user).toContain('aktiver formulieren'); + }); + + it('buildTranslatePrompt drops the "keep source language" rule', () => { + const { system, user } = buildTranslatePrompt(ctx, { targetLanguage: 'en' }); + expect(system).toContain('übersetzt'); + expect(user).toContain('English'); + }); + + it('selection prompts inject the style hint when present', () => { + const ctxWithStyle: SelectionContext = { + ...ctx, + stylePreset: preset(), + }; + const { system } = buildShortenPrompt(ctxWithStyle); + expect(system).toContain('Stil-Kontext'); + expect(system).toContain('Casual Blog'); + }); +}); + +describe('buildTitleSuggestionPrompt', () => { + it('asks for 4–8 words, no quotes, no period, no prefix', () => { + const { system, user } = buildTitleSuggestionPrompt({ + kind: 'blog', + briefing: baseBriefing, + }); + expect(system).toContain('4 bis 8 Wörter'); + expect(system).toContain('keine Anführungszeichen'); + expect(system).toContain('"Titel:"-Präfix'); + expect(user).toContain('Thema: Was Mana von'); + expect(user).toContain('Schlage genau einen Titel vor'); + }); + + it('appends the excerpt block when provided', () => { + const { user } = buildTitleSuggestionPrompt({ + kind: 'blog', + briefing: baseBriefing, + excerpt: 'Hier ist mein Entwurf-Anfang.', + }); + expect(user).toContain('Aktueller Textauszug:'); + expect(user).toContain('Hier ist mein Entwurf-Anfang'); + }); +}); + +describe('cleanSuggestedTitle', () => { + it.each([ + ['"Hello World"', 'Hello World'], + ["'Hello World'", 'Hello World'], + ['„Hello World"', 'Hello World'], + ['«Hello World»', 'Hello World'], + ['‚Hello World‘', 'Hello World'], + ])('strips wrapping quotes %s → %s', (raw, expected) => { + expect(cleanSuggestedTitle(raw)).toBe(expected); + }); + + it('strips a "Titel:" prefix', () => { + expect(cleanSuggestedTitle('Titel: Hello World')).toBe('Hello World'); + expect(cleanSuggestedTitle('Title: Hello World')).toBe('Hello World'); + }); + + it('strips a single trailing period', () => { + expect(cleanSuggestedTitle('Hello World.')).toBe('Hello World'); + }); + + it('keeps "?" and "!" as intentional punctuation', () => { + expect(cleanSuggestedTitle('Hello World?')).toBe('Hello World?'); + expect(cleanSuggestedTitle('Hello World!')).toBe('Hello World!'); + }); + + it('keeps doubled periods (ellipsis-style)', () => { + expect(cleanSuggestedTitle('Hello World..')).toBe('Hello World..'); + }); + + it('trims surrounding whitespace', () => { + expect(cleanSuggestedTitle(' Hello World ')).toBe('Hello World'); + }); + + it('returns empty string for empty input', () => { + expect(cleanSuggestedTitle('')).toBe(''); + expect(cleanSuggestedTitle(' ')).toBe(''); + }); + + it('handles combined artefacts in one pass', () => { + expect(cleanSuggestedTitle('Titel: "Hello World".')).toBe('Hello World'); + }); +}); + +describe('estimateMaxTokens', () => { + it('clamps to the 256–8000 range', () => { + const tiny: DraftBriefing = { + ...baseBriefing, + targetLength: { type: 'words', value: 10 }, + }; + expect(estimateMaxTokens(tiny)).toBeGreaterThanOrEqual(256); + + const huge: DraftBriefing = { + ...baseBriefing, + targetLength: { type: 'words', value: 100000 }, + }; + expect(estimateMaxTokens(huge)).toBeLessThanOrEqual(8000); + }); + + it('returns roughly 2x target words + buffer', () => { + const briefing: DraftBriefing = { + ...baseBriefing, + targetLength: { type: 'words', value: 1000 }, + }; + // 1000 * 2 + 200 = 2200 + expect(estimateMaxTokens(briefing)).toBe(2200); + }); + + it('handles minutes-of-speech via 150 words/minute', () => { + const briefing: DraftBriefing = { + ...baseBriefing, + targetLength: { type: 'minutes', value: 5 }, + }; + // 5 * 150 = 750 words → 750 * 2 + 200 = 1700 + expect(estimateMaxTokens(briefing)).toBe(1700); + }); + + it('handles chars unit via /5 words estimate', () => { + const briefing: DraftBriefing = { + ...baseBriefing, + targetLength: { type: 'chars', value: 5000 }, + }; + // 5000 / 5 = 1000 words → 2200 + expect(estimateMaxTokens(briefing)).toBe(2200); + }); + + it('falls back to 500 words when targetLength is missing', () => { + const briefing: DraftBriefing = { + ...baseBriefing, + targetLength: null, + }; + // 500 * 2 + 200 = 1200 + expect(estimateMaxTokens(briefing)).toBe(1200); + }); +}); + +describe('language coverage', () => { + it.each(['blog', 'essay', 'email', 'social', 'story', 'cover-letter', 'speech'])( + 'kind=%s gets a German label in the system prompt', + (kind) => { + const { system } = buildDraftPrompt({ + kind, + title: 'X', + briefing: baseBriefing, + }); + // Each kind should produce a non-empty German label fragment. + expect(system.length).toBeGreaterThan(80); + expect(system).toContain('Deutsch'); + } + ); +}); diff --git a/apps/mana/apps/web/src/lib/modules/writing/utils/prompt-builder.ts b/apps/mana/apps/web/src/lib/modules/writing/utils/prompt-builder.ts index 7ca8b3be5..e3b5a7ee0 100644 --- a/apps/mana/apps/web/src/lib/modules/writing/utils/prompt-builder.ts +++ b/apps/mana/apps/web/src/lib/modules/writing/utils/prompt-builder.ts @@ -333,30 +333,35 @@ export function buildTitleSuggestionPrompt(input: TitleSuggestionInput): PromptP /** * Strip common LLM-output artefacts (wrapping quotes, trailing period, - * trailing newline) from a single suggested title. Keeps the result - * usable as a one-shot drop-in. + * "Titel:" prefix) from a single suggested title. Iterative until + * stable so combined artefacts (e.g. `Titel: "Hello World".`) all get + * cleaned in one call. Keeps `?` / `!` intact — those may be intentional + * for blog/social titles. */ +const QUOTE_OPENS = new Set(['"', "'", '„', '«', '‚', '“', '‘']); +const QUOTE_CLOSES = new Set(['"', "'", '“', '»', '‘', '„', '‚']); + export function cleanSuggestedTitle(raw: string): string { let out = raw.trim(); - // Strip wrapping quotes — straight or curly, single or double. - const QUOTE_PAIRS: Array<[string, string]> = [ - ['"', '"'], - ["'", "'"], - ['„', '“'], - ['«', '»'], - ['‚', '‘'], - ]; - for (const [open, close] of QUOTE_PAIRS) { - if (out.startsWith(open) && out.endsWith(close) && out.length >= open.length + close.length) { - out = out.slice(open.length, out.length - close.length).trim(); - break; + let stable = false; + let iterations = 0; + while (!stable && iterations < 5) { + const before = out; + // Strip a single leading quote char (any common variant). + if (out.length >= 2 && QUOTE_OPENS.has(out[0])) { + out = out.slice(1).trim(); } + // Strip a single trailing quote char. + if (out.length >= 1 && QUOTE_CLOSES.has(out[out.length - 1])) { + out = out.slice(0, -1).trim(); + } + // Drop a "Titel:" / "Title:" prefix if the model adds one despite + // the instruction. + out = out.replace(/^(?:Titel|Title)\s*:\s*/i, '').trim(); + // Strip terminal full stop only — keep "?" / "!" / "..". + if (out.endsWith('.') && !out.endsWith('..')) out = out.slice(0, -1).trim(); + stable = out === before; + iterations++; } - // Drop a "Titel:" / "Title:" prefix if the model adds one despite the - // instruction. - out = out.replace(/^(?:Titel|Title)\s*:\s*/i, '').trim(); - // Strip terminal full stop only — keep "?" / "!" because they may be - // intentional for blog/social titles. - if (out.endsWith('.') && !out.endsWith('..')) out = out.slice(0, -1).trim(); return out; } diff --git a/apps/mana/apps/web/src/lib/modules/writing/utils/reference-resolver.test.ts b/apps/mana/apps/web/src/lib/modules/writing/utils/reference-resolver.test.ts new file mode 100644 index 000000000..47ba9fd31 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/writing/utils/reference-resolver.test.ts @@ -0,0 +1,420 @@ +/** + * Tests for the writing reference-resolver. + * + * The pure helpers (truncation cap + aggregate budget enforcement) are + * directly testable. The per-kind resolvers depend on Dexie + decryption + * + module type-converters; we mock those so the tests exercise the + * shaping logic without needing a real IndexedDB. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Mock dependencies before importing the resolver ────────────────── + +vi.mock('$lib/data/scope', () => ({ + scopedGet: vi.fn(), + scopedForModule: vi.fn(), +})); + +vi.mock('$lib/data/crypto', () => ({ + decryptRecords: vi.fn(), +})); + +vi.mock('$lib/data/database', () => ({ + db: { + table: vi.fn(), + }, +})); + +vi.mock('$lib/modules/articles/queries', () => ({ + toArticle: vi.fn((local) => ({ ...local })), +})); +vi.mock('$lib/modules/notes/queries', () => ({ + toNote: vi.fn((local) => ({ ...local })), +})); +vi.mock('$lib/modules/library/queries', () => ({ + toLibraryEntry: vi.fn((local) => ({ + creators: [], + year: null, + rating: null, + review: null, + ...local, + })), +})); +vi.mock('$lib/modules/kontext/queries', () => ({ + toKontextDoc: vi.fn((local) => ({ ...local })), +})); + +import { scopedGet, scopedForModule } from '$lib/data/scope'; +import { decryptRecords } from '$lib/data/crypto'; +import { db } from '$lib/data/database'; +import { + resolveReference, + resolveReferences, + MAX_TOTAL_REFERENCE_CHARS, +} from './reference-resolver'; +import type { DraftReference } from '../types'; + +const mockScopedGet = scopedGet as ReturnType; +const mockScopedForModule = scopedForModule as ReturnType; +const mockDecryptRecords = decryptRecords as ReturnType; +const mockDbTable = db.table as ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// ── Per-kind resolver tests ────────────────────────────────────────── + +describe('resolveReference - article', () => { + it('returns sourceLabel + truncated content from a valid article', async () => { + mockScopedGet.mockResolvedValue({ + id: 'a1', + title: 'Headline', + content: 'Body of the article.', + siteName: 'NYT', + }); + mockDecryptRecords.mockResolvedValue([ + { id: 'a1', title: 'Headline', content: 'Body of the article.', siteName: 'NYT' }, + ]); + + const ref: DraftReference = { kind: 'article', targetId: 'a1', note: null }; + const result = await resolveReference(ref); + expect(result).not.toBeNull(); + expect(result?.kind).toBe('article'); + expect(result?.sourceLabel).toBe('Artikel: NYT — Headline'); + expect(result?.content).toBe('Body of the article.'); + }); + + it('returns null when the article is deleted', async () => { + mockScopedGet.mockResolvedValue({ id: 'a1', deletedAt: '2026-01-01T00:00:00Z' }); + const result = await resolveReference({ kind: 'article', targetId: 'a1', note: null }); + expect(result).toBeNull(); + }); + + it('returns null when targetId is missing', async () => { + const result = await resolveReference({ kind: 'article', note: null }); + expect(result).toBeNull(); + }); + + it('returns null when scopedGet returns undefined', async () => { + mockScopedGet.mockResolvedValue(undefined); + const result = await resolveReference({ kind: 'article', targetId: 'a1', note: null }); + expect(result).toBeNull(); + }); + + it('truncates content over the per-ref char cap', async () => { + const longBody = 'x'.repeat(2000); + mockScopedGet.mockResolvedValue({ id: 'a1', title: 'Long', content: longBody }); + mockDecryptRecords.mockResolvedValue([ + { id: 'a1', title: 'Long', content: longBody, siteName: null }, + ]); + + const result = await resolveReference({ kind: 'article', targetId: 'a1', note: null }); + expect(result?.content.length).toBeLessThan(2000); + expect(result?.content).toContain('[… gekürzt …]'); + }); + + it('falls back to excerpt when content is empty', async () => { + mockScopedGet.mockResolvedValue({ + id: 'a1', + title: 'X', + content: '', + excerpt: 'Just a teaser.', + }); + mockDecryptRecords.mockResolvedValue([ + { id: 'a1', title: 'X', content: '', excerpt: 'Just a teaser.', siteName: null }, + ]); + const result = await resolveReference({ kind: 'article', targetId: 'a1', note: null }); + expect(result?.content).toBe('Just a teaser.'); + }); + + it('omits the siteName prefix when missing', async () => { + mockScopedGet.mockResolvedValue({ id: 'a1', title: 'X', content: 'body' }); + mockDecryptRecords.mockResolvedValue([ + { id: 'a1', title: 'X', content: 'body', siteName: null }, + ]); + const result = await resolveReference({ kind: 'article', targetId: 'a1', note: null }); + expect(result?.sourceLabel).toBe('Artikel: X'); + }); + + it('preserves the user note', async () => { + mockScopedGet.mockResolvedValue({ id: 'a1', title: 'X', content: 'body' }); + mockDecryptRecords.mockResolvedValue([ + { id: 'a1', title: 'X', content: 'body', siteName: null }, + ]); + const result = await resolveReference({ + kind: 'article', + targetId: 'a1', + note: 'wichtig fürs Argument', + }); + expect(result?.note).toBe('wichtig fürs Argument'); + }); +}); + +describe('resolveReference - note', () => { + it('returns title + content for a valid note', async () => { + mockScopedGet.mockResolvedValue({ id: 'n1', title: 'My Note', content: 'note body' }); + mockDecryptRecords.mockResolvedValue([{ id: 'n1', title: 'My Note', content: 'note body' }]); + const result = await resolveReference({ kind: 'note', targetId: 'n1', note: null }); + expect(result?.sourceLabel).toBe('Notiz: My Note'); + expect(result?.content).toBe('note body'); + }); + + it('handles untitled notes', async () => { + mockScopedGet.mockResolvedValue({ id: 'n1', title: '', content: 'body' }); + mockDecryptRecords.mockResolvedValue([{ id: 'n1', title: '', content: 'body' }]); + const result = await resolveReference({ kind: 'note', targetId: 'n1', note: null }); + expect(result?.sourceLabel).toBe('Notiz: Ohne Titel'); + }); +}); + +describe('resolveReference - library', () => { + it('shapes a book entry with creators + year + rating into the label', async () => { + mockScopedGet.mockResolvedValue({ + id: 'l1', + kind: 'book', + title: 'Dune', + creators: ['Frank Herbert'], + year: 1965, + rating: 5, + review: 'Lebensbuch.', + }); + mockDecryptRecords.mockResolvedValue([ + { + id: 'l1', + kind: 'book', + title: 'Dune', + creators: ['Frank Herbert'], + year: 1965, + rating: 5, + review: 'Lebensbuch.', + }, + ]); + const result = await resolveReference({ kind: 'library', targetId: 'l1', note: null }); + expect(result?.sourceLabel).toBe('Buch: Dune (von Frank Herbert, 1965, Rating: 5/5)'); + expect(result?.content).toContain('Lebensbuch'); + }); + + it('uses the empty body when there is no review', async () => { + mockScopedGet.mockResolvedValue({ + id: 'l1', + kind: 'movie', + title: 'Arrival', + creators: [], + year: null, + rating: null, + review: null, + }); + mockDecryptRecords.mockResolvedValue([ + { + id: 'l1', + kind: 'movie', + title: 'Arrival', + creators: [], + year: null, + rating: null, + review: null, + }, + ]); + const result = await resolveReference({ kind: 'library', targetId: 'l1', note: null }); + expect(result?.sourceLabel).toBe('Film: Arrival'); + expect(result?.content).toBe(''); + }); +}); + +describe('resolveReference - url', () => { + it('returns the url as label without fetching', async () => { + const result = await resolveReference({ + kind: 'url', + url: 'https://example.com/post', + note: 'sehr gutes argument', + }); + expect(result?.kind).toBe('url'); + expect(result?.sourceLabel).toBe('Link: https://example.com/post'); + expect(result?.content).toBe(''); + expect(result?.note).toBe('sehr gutes argument'); + }); + + it('returns null when url is missing', async () => { + const result = await resolveReference({ kind: 'url', note: null }); + expect(result).toBeNull(); + }); +}); + +describe('resolveReference - kontext (singleton)', () => { + it('reads the singleton via scopedForModule and ignores the targetId', async () => { + const toArrayMock = vi + .fn() + .mockResolvedValue([{ id: 'kontext-uuid', content: 'mein laufender kontext' }]); + mockScopedForModule.mockReturnValue({ toArray: toArrayMock }); + mockDecryptRecords.mockResolvedValue([ + { id: 'kontext-uuid', content: 'mein laufender kontext' }, + ]); + + const result = await resolveReference({ + kind: 'kontext', + targetId: 'irrelevant', + note: null, + }); + expect(result?.sourceLabel).toBe('Kontext-Dokument des Spaces'); + expect(result?.content).toBe('mein laufender kontext'); + }); + + it('skips deleted singleton rows', async () => { + const toArrayMock = vi + .fn() + .mockResolvedValue([ + { id: 'kontext-uuid', content: 'old', deletedAt: '2026-01-01T00:00:00Z' }, + ]); + mockScopedForModule.mockReturnValue({ toArray: toArrayMock }); + const result = await resolveReference({ kind: 'kontext', targetId: 'x', note: null }); + expect(result).toBeNull(); + }); +}); + +describe('resolveReference - goal (plaintext)', () => { + it('returns title + status + progress without decryption', async () => { + mockDbTable.mockReturnValue({ + get: vi.fn().mockResolvedValue({ + id: 'g1', + title: '20 Bücher 2026', + description: 'Lesen ist Leben', + status: 'active', + currentValue: 7, + target: { value: 20, period: 'year', comparison: 'gte' }, + }), + }); + const result = await resolveReference({ kind: 'goal', targetId: 'g1', note: null }); + expect(result?.sourceLabel).toBe('Ziel: 20 Bücher 2026'); + expect(result?.content).toContain('Lesen ist Leben'); + expect(result?.content).toContain('Status: active'); + expect(result?.content).toContain('Ziel: 20'); + // Verify decryptRecords was NOT called for goals. + expect(mockDecryptRecords).not.toHaveBeenCalled(); + }); + + it('returns null when goal is deleted', async () => { + mockDbTable.mockReturnValue({ + get: vi.fn().mockResolvedValue({ + id: 'g1', + title: 'X', + deletedAt: '2026-01-01T00:00:00Z', + }), + }); + const result = await resolveReference({ kind: 'goal', targetId: 'g1', note: null }); + expect(result).toBeNull(); + }); +}); + +describe('resolveReference - me-image', () => { + it('builds a textual descriptor from kind + label + tags', async () => { + mockScopedGet.mockResolvedValue({ + id: 'm1', + kind: 'face', + label: 'Portrait Juni', + tags: ['ohne-brille', 'studio'], + }); + mockDecryptRecords.mockResolvedValue([ + { + id: 'm1', + kind: 'face', + label: 'Portrait Juni', + tags: ['ohne-brille', 'studio'], + }, + ]); + const result = await resolveReference({ kind: 'me-image', targetId: 'm1', note: null }); + expect(result?.sourceLabel).toBe('Bild (face): Portrait Juni'); + expect(result?.content).toBe('Portrait Juni — ohne-brille, studio'); + }); + + it('falls back to "-Referenzbild" when no label/tags', async () => { + mockScopedGet.mockResolvedValue({ id: 'm1', kind: 'fullbody', label: null, tags: [] }); + mockDecryptRecords.mockResolvedValue([{ id: 'm1', kind: 'fullbody', label: null, tags: [] }]); + const result = await resolveReference({ kind: 'me-image', targetId: 'm1', note: null }); + expect(result?.content).toBe('fullbody-Referenzbild'); + }); +}); + +describe('resolveReference - unsupported kinds', () => { + it('returns null for unknown kinds', async () => { + // `goal` is supported; `wishes` is not — but our type system doesn't + // allow arbitrary strings, so simulate via cast. + const result = await resolveReference({ + // @ts-expect-error testing fallback path + kind: 'unsupported', + targetId: 'x', + note: null, + }); + expect(result).toBeNull(); + }); +}); + +// ── Aggregate budget tests ────────────────────────────────────────── + +describe('resolveReferences', () => { + it('drops nulls (unresolvable refs) silently', async () => { + mockScopedGet.mockImplementation(async (_table: string, id: string) => { + if (id === 'a1') return { id: 'a1', title: 'X', content: 'body' }; + return undefined; + }); + mockDecryptRecords.mockImplementation(async (_t: string, rows: unknown[]) => rows); + + const refs: DraftReference[] = [ + { kind: 'article', targetId: 'a1', note: null }, + { kind: 'article', targetId: 'missing', note: null }, + ]; + const out = await resolveReferences(refs); + expect(out).toHaveLength(1); + expect(out[0].title).toBe('X'); + }); + + it('keeps everything below the aggregate cap', async () => { + mockScopedGet.mockResolvedValue({ id: 'a', title: 'short', content: 'small' }); + mockDecryptRecords.mockImplementation(async (_t: string, rows: unknown[]) => rows); + + const refs: DraftReference[] = [ + { kind: 'article', targetId: 'a1', note: null }, + { kind: 'article', targetId: 'a2', note: null }, + { kind: 'article', targetId: 'a3', note: null }, + ]; + const out = await resolveReferences(refs); + expect(out).toHaveLength(3); + }); + + it('stops adding extras once aggregate cap is exceeded', async () => { + // Each ref is ~1500 chars (truncated) — 6 of those is 9000+, over the + // 8000-char total cap. + const big = 'x'.repeat(2000); + mockScopedGet.mockResolvedValue({ id: 'a', title: 'big', content: big }); + mockDecryptRecords.mockImplementation(async (_t: string, rows: unknown[]) => rows); + + const refs: DraftReference[] = Array.from({ length: 8 }, (_, i) => ({ + kind: 'article' as const, + targetId: `a${i}`, + note: null, + })); + const out = await resolveReferences(refs); + expect(out.length).toBeLessThan(8); + const totalChars = out.reduce( + (sum, r) => sum + r.sourceLabel.length + r.content.length + (r.note?.length ?? 0), + 0 + ); + // The last accepted ref pushed the total over the cap; everything after + // is dropped. The first ref always passes regardless. + expect(totalChars).toBeGreaterThan(0); + expect(out.length).toBeGreaterThanOrEqual(1); + }); + + it('always keeps at least the first ref even if it alone exceeds the cap', async () => { + // A single 10000-char article — beyond the aggregate cap — should + // still be kept as the first one (otherwise the user gets nothing + // when they attached one large reference). + const huge = 'x'.repeat(MAX_TOTAL_REFERENCE_CHARS + 5000); + mockScopedGet.mockResolvedValue({ id: 'a', title: 'huge', content: huge }); + mockDecryptRecords.mockImplementation(async (_t: string, rows: unknown[]) => rows); + + const out = await resolveReferences([{ kind: 'article', targetId: 'a1', note: null }]); + expect(out).toHaveLength(1); + }); +}); diff --git a/apps/mana/apps/web/src/lib/modules/writing/utils/reference-resolver.ts b/apps/mana/apps/web/src/lib/modules/writing/utils/reference-resolver.ts index 79f0fd2c9..4f6756379 100644 --- a/apps/mana/apps/web/src/lib/modules/writing/utils/reference-resolver.ts +++ b/apps/mana/apps/web/src/lib/modules/writing/utils/reference-resolver.ts @@ -65,7 +65,9 @@ async function resolveArticle( return { sourceLabel: `Artikel: ${siteName}${article.title}`, title: article.title, - content: truncate(article.content ?? article.excerpt ?? ''), + // `||` (not `??`) so empty-string content falls through to excerpt; + // articles with extraction failures often have content === ''. + content: truncate(article.content || article.excerpt || ''), }; } diff --git a/packages/shared-branding/src/mana-apps.ts b/packages/shared-branding/src/mana-apps.ts index 2b4af301c..2160a72da 100644 --- a/packages/shared-branding/src/mana-apps.ts +++ b/packages/shared-branding/src/mana-apps.ts @@ -1086,7 +1086,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#0ea5e9', comingSoon: false, status: 'development', - requiredTier: 'guest', // LOCAL TIER PATCH — revert to 'beta' before release + requiredTier: 'beta', }, { id: 'broadcast',