mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
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:
parent
d924895de0
commit
75c366bff4
5 changed files with 797 additions and 22 deletions
|
|
@ -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 ~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<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');
|
||||
}
|
||||
);
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 || ''),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue