test(writing): unit tests for prompt-builder + reference-resolver

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-25 12:57:24 +02:00
parent d924895de0
commit 75c366bff4
5 changed files with 797 additions and 22 deletions

View file

@ -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> = {}): 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 ~5060% 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('5060%');
expect(user).toContain('---\nDieser Satz ist redundant');
});
it('buildExpandPrompt asks for ~150180% length', () => {
const { user } = buildExpandPrompt(ctx);
expect(user).toContain('150180%');
});
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 48 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 2568000 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<DraftKind>(['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');
}
);
});

View file

@ -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;
}

View file

@ -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<typeof vi.fn>;
const mockScopedForModule = scopedForModule as ReturnType<typeof vi.fn>;
const mockDecryptRecords = decryptRecords as ReturnType<typeof vi.fn>;
const mockDbTable = db.table as ReturnType<typeof vi.fn>;
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 "<kind>-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);
});
});

View file

@ -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 || ''),
};
}

View file

@ -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',