Files
obsidian-penny/test/settings-validation.test.ts
james.maes d0593eeaf4 feat: comprehensive logging overhaul, error modals, documentation
Logging: Replace boolean verboseLogging with LogLevel enum (debug/info/
warn/error/off). Default: debug. pennyLog() helper for level-filtered
console output. All console.log calls updated.

Errors: 23 error Notices replaced with persistent showPennyError() modal
showing full details + stack trace. Settings test failures also use modal.

Documentation: JSDoc added across multiple source files.

Google/OpenAI: Implementation plan saved to docs/plans/.

Strict TS: tsconfig.json updated with strict:true, noUnusedLocals,
noUnusedParameters, noImplicitReturns.

335 tests passing.
2026-04-17 05:36:21 -05:00

410 lines
13 KiB
TypeScript

/**
* PENNY - Settings Validation Tests
*
* Tests for route config construction, pipeline settings consumption,
* and pure validation functions that underpin the settings UI.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { buildRouteConfig } from "../src/pipeline";
import type { PipelineInput } from "../src/pipeline";
import { runPipeline } from "../src/pipeline";
import { resolveModel } from "../src/providers/router";
import { assembleContext } from "../src/context";
import type { ContextFiles } from "../src/context";
import { countProseWords } from "../src/frontmatter";
import { DEFAULT_SETTINGS, DEFAULT_SYSTEM_PROMPT } from "../src/types";
import type { PennySettings } from "../src/types";
import type { CompletionRequest, CompletionResponse } from "../src/providers/service";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeSettings(overrides: Partial<PennySettings> = {}): PennySettings {
return { ...DEFAULT_SETTINGS, ...overrides };
}
function createMockProvider(responses: string[] = ["Revised text."]) {
let callIndex = 0;
const calls: CompletionRequest[] = [];
return {
provider: {
async complete(request: CompletionRequest): Promise<CompletionResponse> {
calls.push(request);
const text = responses[callIndex] ?? `[mock response ${callIndex}]`;
callIndex++;
return {
text,
model: request.model,
provider: "mock",
usage: { inputTokens: 100, outputTokens: 50 },
};
},
},
getCalls: () => calls,
};
}
function makeContextFiles(chapter: string): ContextFiles {
return {
chapter,
voiceTests: "",
styleGuide: "",
outline: "",
characters: [],
wiki: [],
seriesBible: "",
themes: "",
};
}
const SIMPLE_CHAPTER = `---
type: chapter
book: 1
chapter: 1
---
# Chapter 1
<!-- Prose begins below -->
She walked in.
%% REWRITE: Add sensory detail %%
`;
// ---------------------------------------------------------------------------
// Route config tests
// ---------------------------------------------------------------------------
describe("buildRouteConfig", () => {
it("with useSameModelForAll: true returns all three tiers as routeStandard", () => {
const settings = makeSettings({
useSameModelForAll: true,
routeLight: { provider: "ollama", model: "llama3.2" },
routeStandard: { provider: "anthropic", model: "claude-sonnet-4-6" },
routeHeavy: { provider: "anthropic", model: "claude-opus-4-6" },
});
const config = buildRouteConfig(settings);
expect(config.light).toEqual({ provider: "anthropic", model: "claude-sonnet-4-6" });
expect(config.standard).toEqual({ provider: "anthropic", model: "claude-sonnet-4-6" });
expect(config.heavy).toEqual({ provider: "anthropic", model: "claude-sonnet-4-6" });
});
it("with useSameModelForAll: false returns distinct routes per tier", () => {
const settings = makeSettings({
useSameModelForAll: false,
routeLight: { provider: "ollama", model: "llama3.2" },
routeStandard: { provider: "anthropic", model: "claude-sonnet-4-6" },
routeHeavy: { provider: "anthropic", model: "claude-opus-4-6" },
});
const config = buildRouteConfig(settings);
expect(config.light).toEqual({ provider: "ollama", model: "llama3.2" });
expect(config.standard).toEqual({ provider: "anthropic", model: "claude-sonnet-4-6" });
expect(config.heavy).toEqual({ provider: "anthropic", model: "claude-opus-4-6" });
});
it("with auto-latest resolves models correctly per tier", () => {
const settings = makeSettings({
useSameModelForAll: false,
routeLight: { provider: "anthropic", model: "auto-latest" },
routeStandard: { provider: "anthropic", model: "auto-latest" },
routeHeavy: { provider: "anthropic", model: "auto-latest" },
});
const config = buildRouteConfig(settings);
// buildRouteConfig returns the raw config; resolveModel is called
// downstream by getRoute. Verify the raw values pass through.
expect(config.light.model).toBe("auto-latest");
expect(config.standard.model).toBe("auto-latest");
expect(config.heavy.model).toBe("auto-latest");
// Verify that resolveModel correctly maps auto-latest per tier
expect(resolveModel(config.light.model, "light")).toBe("claude-haiku-4-5");
expect(resolveModel(config.standard.model, "standard")).toBe("claude-sonnet-4-6");
expect(resolveModel(config.heavy.model, "heavy")).toBe("claude-opus-4-6");
});
});
// ---------------------------------------------------------------------------
// Pipeline settings consumption tests
// ---------------------------------------------------------------------------
describe("pipeline settings consumption", () => {
it("maxTokens value from settings is passed to CompletionRequest", async () => {
const mock = createMockProvider(["Revised passage."]);
const settings = makeSettings({ maxTokens: 8000 });
const input: PipelineInput = {
content: SIMPLE_CHAPTER,
versionContent: "0",
stateContent: "",
contextFiles: makeContextFiles(SIMPLE_CHAPTER),
settings,
chapterId: "ch-01",
bookId: "book-1",
getProvider: () => mock.provider,
};
await runPipeline(input);
const calls = mock.getCalls();
expect(calls).toHaveLength(1);
expect(calls[0].maxTokens).toBe(8000);
});
it("contextBudget value limits context assembly", () => {
// Create a very small budget that can barely fit the chapter
const chapter = "A".repeat(500); // ~500 words worth
const files: ContextFiles = {
chapter,
voiceTests: "B".repeat(1000),
styleGuide: "C".repeat(1000),
outline: "",
characters: [],
wiki: [],
};
const annotation = {
tag: "REWRITE" as const,
instruction: "fix it",
originalText: "original",
lineStart: 1,
lineEnd: 2,
scope: "paragraph" as const,
actionable: true,
hash: "abc123",
};
// With a very tight budget, lower-priority items should be excluded
const smallBudgetSettings = makeSettings({ contextBudget: 100 });
const ctx = assembleContext(files, annotation, smallBudgetSettings);
// Chapter is always included (priority 1), but total should be constrained
expect(ctx.chapter).toBe(chapter);
expect(ctx.totalTokenEstimate).toBeGreaterThan(0);
// With a huge budget, everything should fit
const largeBudgetSettings = makeSettings({ contextBudget: 1000000 });
const ctxLarge = assembleContext(files, annotation, largeBudgetSettings);
expect(ctxLarge.voiceTests).toBe(files.voiceTests);
expect(ctxLarge.styleGuide).toBe(files.styleGuide);
});
it("customVoiceRules appears in assembled context", () => {
const files: ContextFiles = {
chapter: "Some chapter text.",
voiceTests: "",
styleGuide: "",
outline: "",
characters: [],
wiki: [],
};
const annotation = {
tag: "REWRITE" as const,
instruction: "fix it",
originalText: "original",
lineStart: 1,
lineEnd: 2,
scope: "paragraph" as const,
actionable: true,
hash: "abc123",
};
const settings = makeSettings({
customVoiceRules: "No internal monologue. Ever.",
});
const ctx = assembleContext(files, annotation, settings);
expect(ctx.voiceRules).toBe("No internal monologue. Ever.");
});
it("proseMarker affects word count calculation (with marker vs without)", () => {
const contentWithMarker = `---
type: chapter
---
# Chapter 1
## Outline notes that should not be counted
<!-- Prose begins below -->
She walked into the room and sat down.
`;
const contentWithoutMarker = `She walked into the room and sat down.`;
// With marker: only counts words after the marker
const countWithMarker = countProseWords(contentWithMarker, "<!-- Prose begins below -->");
// Without marker (empty string): counts all words
const countWithoutMarker = countProseWords(contentWithMarker, "");
expect(countWithMarker).toBeLessThan(countWithoutMarker);
// The prose section has 8 words
expect(countWithMarker).toBe(8);
});
it("logLevel controls console output verbosity", async () => {
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
// logLevel is a settings flag. The pipeline itself does not
// directly call console.log (that happens in the command layer), but
// the setting must be propagated. Verify the setting value is accessible.
const settings = makeSettings({ logLevel: "debug" });
expect(settings.logLevel).toBe("debug");
const settingsOff = makeSettings({ logLevel: "off" });
expect(settingsOff.logLevel).toBe("off");
const settingsInfo = makeSettings({ logLevel: "info" });
expect(settingsInfo.logLevel).toBe("info");
consoleSpy.mockRestore();
});
});
// ---------------------------------------------------------------------------
// Validation tests (pure functions)
// ---------------------------------------------------------------------------
describe("resolveModel", () => {
it('resolveModel("auto-latest", "light") returns "claude-haiku-4-5"', () => {
expect(resolveModel("auto-latest", "light")).toBe("claude-haiku-4-5");
});
it('resolveModel("auto-latest", "standard") returns "claude-sonnet-4-6"', () => {
expect(resolveModel("auto-latest", "standard")).toBe("claude-sonnet-4-6");
});
it('resolveModel("auto-latest", "heavy") returns "claude-opus-4-6"', () => {
expect(resolveModel("auto-latest", "heavy")).toBe("claude-opus-4-6");
});
it('resolveModel("specific-model", "light") returns "specific-model" unchanged', () => {
expect(resolveModel("specific-model", "light")).toBe("specific-model");
});
});
describe("commitMessageFormat template substitution", () => {
it("replaces {chapter}, {version}, {tags} placeholders", () => {
const fmt = "docs({chapter}): PENNY v{version} - {tags}";
const result = fmt
.replace(/\{chapter\}/g, "ch-05")
.replace(/\{version\}/g, "3")
.replace(/\{tags\}/g, "REWRITE, EXPAND");
expect(result).toBe("docs(ch-05): PENNY v3 - REWRITE, EXPAND");
});
it("empty format string falls through to default", () => {
const fmt = "";
const chapterChanges = ["ch-01", "ch-02"];
// Simulate the command layer fallback logic
let message: string;
if (fmt) {
message = fmt
.replace(/\{chapter\}/g, "ch-01")
.replace(/\{version\}/g, "1")
.replace(/\{tags\}/g, "REWRITE");
} else if (chapterChanges.length > 0) {
message = `docs: revise ${chapterChanges.join(", ")}`;
} else {
message = "docs: PENNY writing session";
}
expect(message).toBe("docs: revise ch-01, ch-02");
});
});
describe("countProseWords", () => {
it("with proseMarker counts only after marker", () => {
const content = `# Heading
Some outline text here that should not count.
<!-- Prose begins below -->
The actual prose starts here with seven words.
`;
const count = countProseWords(content, "<!-- Prose begins below -->");
// "The actual prose starts here with seven words." = 8 words
expect(count).toBe(8);
});
it("without proseMarker counts entire content", () => {
const content = `# Heading
Some text here.
More text here.
`;
const count = countProseWords(content, "");
// All words in the content are counted
expect(count).toBeGreaterThan(0);
// "Heading Some text here. More text here." = 7 words
expect(count).toBe(7);
});
});
// ---------------------------------------------------------------------------
// Integration-level tests for non-default settings
// ---------------------------------------------------------------------------
describe("integration: non-default maxTokens", () => {
it("non-default maxTokens (8000) reaches the provider", async () => {
const mock = createMockProvider(["Revised."]);
const input: PipelineInput = {
content: SIMPLE_CHAPTER,
versionContent: "0",
stateContent: "",
contextFiles: makeContextFiles(SIMPLE_CHAPTER),
settings: makeSettings({ maxTokens: 8000 }),
chapterId: "ch-01",
bookId: "book-1",
getProvider: () => mock.provider,
};
const result = await runPipeline(input);
expect(result).not.toBeNull();
const calls = mock.getCalls();
expect(calls).toHaveLength(1);
expect(calls[0].maxTokens).toBe(8000);
});
it("empty customVoiceRules still produces a working prompt", async () => {
const mock = createMockProvider(["Revised."]);
const input: PipelineInput = {
content: SIMPLE_CHAPTER,
versionContent: "0",
stateContent: "",
contextFiles: makeContextFiles(SIMPLE_CHAPTER),
settings: makeSettings({ customVoiceRules: "" }),
chapterId: "ch-01",
bookId: "book-1",
getProvider: () => mock.provider,
};
const result = await runPipeline(input);
expect(result).not.toBeNull();
const calls = mock.getCalls();
expect(calls).toHaveLength(1);
// The system prompt should still be valid (not crash or be empty)
expect(calls[0].systemPrompt.length).toBeGreaterThan(0);
// The voice_rules section should be absent or empty
expect(calls[0].systemPrompt).not.toContain("CRITICAL VOICE RULES");
});
});