Преглед изворни кода

test(core): add logger, stream monitor, and fallback parser tests

- logger.test.ts: 8 tests covering stderr sink level filtering,
  color/no-color output, JSON line sink, null sink, child() context
  merge, stream monitor interval/counts/done, undefined onProgress
- writer-parser.test.ts: 5 new tests for fallback parsing —
  markdown heading, 正文 label, longest prose block, too-short input,
  normal === TAG === still works
Ma пре 7 месеци
родитељ
комит
e1b1466ccb
2 измењених фајлова са 236 додато и 1 уклоњено
  1. 175 0
      packages/core/src/__tests__/logger.test.ts
  2. 61 1
      packages/core/src/__tests__/writer-parser.test.ts

+ 175 - 0
packages/core/src/__tests__/logger.test.ts

@@ -0,0 +1,175 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import {
+  createLogger,
+  createStderrSink,
+  createJsonLineSink,
+  nullSink,
+  type LogEntry,
+} from "../utils/logger.js";
+import { createStreamMonitor } from "../llm/provider.js";
+
+describe("createStderrSink", () => {
+  const originalWrite = process.stderr.write;
+  const captured: string[] = [];
+
+  beforeEach(() => {
+    captured.length = 0;
+    process.stderr.write = ((chunk: unknown) => {
+      captured.push(String(chunk));
+      return true;
+    }) as typeof process.stderr.write;
+  });
+
+  afterEach(() => {
+    process.stderr.write = originalWrite;
+  });
+
+  it("filters entries below minLevel", () => {
+    const sink = createStderrSink({ minLevel: "warn", enableColors: false });
+    const logger = createLogger({ tag: "test", sinks: [sink] });
+
+    logger.info("should be filtered");
+    expect(captured).toHaveLength(0);
+
+    logger.warn("should pass");
+    expect(captured).toHaveLength(1);
+    expect(captured[0]).toContain("should pass");
+  });
+
+  it("includes ANSI escape codes when enableColors=true", () => {
+    const sink = createStderrSink({ minLevel: "info", enableColors: true });
+    const logger = createLogger({ tag: "color-test", sinks: [sink] });
+
+    logger.info("colored message");
+    expect(captured[0]).toContain("\x1b[36m"); // cyan for info
+    expect(captured[0]).toContain("\x1b[0m");  // reset
+  });
+
+  it("omits ANSI escape codes when enableColors=false", () => {
+    const sink = createStderrSink({ minLevel: "info", enableColors: false });
+    const logger = createLogger({ tag: "no-color", sinks: [sink] });
+
+    logger.info("plain message");
+    expect(captured[0]).not.toContain("\x1b[");
+    expect(captured[0]).toContain("plain message");
+    expect(captured[0]).toContain("[no-color]");
+  });
+});
+
+describe("createJsonLineSink", () => {
+  it("outputs parseable JSON, one entry per line", () => {
+    const chunks: string[] = [];
+    const writable = {
+      write(data: string) {
+        chunks.push(data);
+        return true;
+      },
+    } as unknown as NodeJS.WritableStream;
+
+    const sink = createJsonLineSink(writable);
+    const logger = createLogger({ tag: "json-test", sinks: [sink] });
+
+    logger.info("first message");
+    logger.warn("second message", { key: "value" });
+
+    expect(chunks).toHaveLength(2);
+
+    const entry1 = JSON.parse(chunks[0]!) as LogEntry;
+    expect(entry1.level).toBe("info");
+    expect(entry1.tag).toBe("json-test");
+    expect(entry1.message).toBe("first message");
+    expect(entry1.timestamp).toBeTruthy();
+
+    const entry2 = JSON.parse(chunks[1]!) as LogEntry;
+    expect(entry2.level).toBe("warn");
+    expect(entry2.ctx).toEqual({ key: "value" });
+  });
+});
+
+describe("nullSink", () => {
+  it("does not throw", () => {
+    const logger = createLogger({ tag: "null-test", sinks: [nullSink] });
+    expect(() => {
+      logger.debug("ignored");
+      logger.info("ignored");
+      logger.warn("ignored");
+      logger.error("ignored");
+    }).not.toThrow();
+  });
+});
+
+describe("Logger.child()", () => {
+  it("replaces tag and merges context", () => {
+    const entries: LogEntry[] = [];
+    const sink = {
+      write(entry: LogEntry) {
+        entries.push(entry);
+      },
+    };
+
+    const parent = createLogger({
+      tag: "parent",
+      sinks: [sink],
+      baseCtx: { parentKey: "pv" },
+    });
+
+    const child = parent.child("child", { childKey: "cv" });
+    child.info("from child");
+
+    expect(entries).toHaveLength(1);
+    expect(entries[0]!.tag).toBe("child");
+    expect(entries[0]!.ctx).toEqual({ parentKey: "pv", childKey: "cv" });
+  });
+});
+
+describe("createStreamMonitor", () => {
+  beforeEach(() => {
+    vi.useFakeTimers();
+  });
+
+  afterEach(() => {
+    vi.useRealTimers();
+  });
+
+  it("calls onProgress at interval with correct counts", () => {
+    const calls: Array<{ totalChars: number; chineseChars: number; status: string }> = [];
+    const monitor = createStreamMonitor((progress) => {
+      calls.push({
+        totalChars: progress.totalChars,
+        chineseChars: progress.chineseChars,
+        status: progress.status,
+      });
+    }, 1000);
+
+    monitor.onChunk("hello");
+    monitor.onChunk("世界你好");
+
+    // Advance past one interval
+    vi.advanceTimersByTime(1000);
+    expect(calls).toHaveLength(1);
+    expect(calls[0]!.totalChars).toBe(9); // 5 + 4
+    expect(calls[0]!.chineseChars).toBe(4);
+    expect(calls[0]!.status).toBe("streaming");
+
+    // More chunks
+    monitor.onChunk("abc");
+    vi.advanceTimersByTime(1000);
+    expect(calls).toHaveLength(2);
+    expect(calls[1]!.totalChars).toBe(12);
+
+    monitor.stop();
+    // stop() emits a final "done" event
+    expect(calls).toHaveLength(3);
+    expect(calls[2]!.status).toBe("done");
+    expect(calls[2]!.totalChars).toBe(12);
+  });
+
+  it("works without onProgress (undefined)", () => {
+    const monitor = createStreamMonitor(undefined);
+    expect(() => {
+      monitor.onChunk("test data");
+      monitor.onChunk("更多数据");
+      monitor.stop();
+    }).not.toThrow();
+  });
+});

+ 61 - 1
packages/core/src/__tests__/writer-parser.test.ts

@@ -1,5 +1,5 @@
 import { describe, it, expect } from "vitest";
 import { describe, it, expect } from "vitest";
-import { parseWriterOutput, type ParsedWriterOutput } from "../agents/writer-parser.js";
+import { parseWriterOutput, parseCreativeOutput, type ParsedWriterOutput } from "../agents/writer-parser.js";
 import type { GenreProfile } from "../models/genre-profile.js";
 import type { GenreProfile } from "../models/genre-profile.js";
 
 
 const defaultGenreProfile: GenreProfile = {
 const defaultGenreProfile: GenreProfile = {
@@ -219,3 +219,63 @@ describe("WriterAgent parseOutput", () => {
     expect(result.wordCount).toBe(chineseContent.length);
     expect(result.wordCount).toBe(chineseContent.length);
   });
   });
 });
 });
+
+// ---------------------------------------------------------------------------
+// Fallback parsing for local/small models (#13)
+// ---------------------------------------------------------------------------
+
+describe("parseCreativeOutput fallback", () => {
+  it("extracts content from markdown heading when tags are missing", () => {
+    const raw = `# 第1章 觉醒之日
+
+林风缓缓睁开了眼睛,映入眼帘的是一片陌生的天花板。他的脑海中充斥着混乱的记忆碎片,${"一段很长的正文内容".repeat(30)}完。`;
+
+    const result = parseCreativeOutput(1, raw);
+    expect(result.title).toBe("觉醒之日");
+    expect(result.content.length).toBeGreaterThan(100);
+    expect(result.content).toContain("林风");
+  });
+
+  it("extracts content from 正文 label when tags are missing", () => {
+    const raw = `章节标题:暗夜追踪
+
+正文:
+${"黑暗中一道身影掠过屋顶,无声无息。".repeat(20)}`;
+
+    const result = parseCreativeOutput(5, raw);
+    expect(result.title).toBe("暗夜追踪");
+    expect(result.content.length).toBeGreaterThan(100);
+  });
+
+  it("falls back to longest prose block when no structure is found", () => {
+    const prose = "这是一段完整的小说正文,描述了主角在黑暗中探索未知世界的经历。".repeat(10);
+    const raw = `PRE_WRITE_CHECK: 已完成自检
+CHAPTER_TITLE: 探索
+
+${prose}`;
+
+    const result = parseCreativeOutput(3, raw);
+    expect(result.content.length).toBeGreaterThan(100);
+  });
+
+  it("returns empty content when raw output is too short", () => {
+    const result = parseCreativeOutput(1, "太短了");
+    expect(result.content).toBe("");
+    expect(result.title).toBe("第1章");
+  });
+
+  it("still works with proper === TAG === format", () => {
+    const raw = `=== PRE_WRITE_CHECK ===
+自检完成
+
+=== CHAPTER_TITLE ===
+正常标题
+
+=== CHAPTER_CONTENT ===
+正常的章节内容,这里是完整的正文。`;
+
+    const result = parseCreativeOutput(1, raw);
+    expect(result.title).toBe("正常标题");
+    expect(result.content).toBe("正常的章节内容,这里是完整的正文。");
+  });
+});