ソースを参照

test: add CLI test coverage (1 → 30 tests)

- Unit tests for computeAnalytics: empty input, pass rate, issue
  category extraction, ranking, limits
- Integration tests: init, config set/show, book list, status,
  doctor, analytics error cases
- Exclude dist/__tests__ from npm tarballs in both packages
majx_mac 8 ヶ月 前
親
コミット
26682ba541

+ 2 - 1
packages/cli/package.json

@@ -7,7 +7,8 @@
     "inkos": "dist/index.js"
   },
   "files": [
-    "dist"
+    "dist",
+    "!dist/__tests__"
   ],
   "license": "MIT",
   "repository": {

+ 139 - 0
packages/cli/src/__tests__/analytics.test.ts

@@ -0,0 +1,139 @@
+import { describe, it, expect } from "vitest";
+import { computeAnalytics } from "../commands/analytics.js";
+
+describe("computeAnalytics", () => {
+  it("returns zeros for empty chapters", () => {
+    const result = computeAnalytics("test-book", []);
+    expect(result.bookId).toBe("test-book");
+    expect(result.totalChapters).toBe(0);
+    expect(result.totalWords).toBe(0);
+    expect(result.avgWordsPerChapter).toBe(0);
+    expect(result.auditPassRate).toBe(100); // no audited chapters = 100%
+    expect(result.topIssueCategories).toEqual([]);
+    expect(result.chaptersWithMostIssues).toEqual([]);
+    expect(result.statusDistribution).toEqual({});
+  });
+
+  it("computes basic stats correctly", () => {
+    const chapters = [
+      { number: 1, status: "approved", wordCount: 3000, auditIssues: [] },
+      { number: 2, status: "approved", wordCount: 3200, auditIssues: [] },
+      { number: 3, status: "ready-for-review", wordCount: 2800, auditIssues: [] },
+    ];
+    const result = computeAnalytics("book-a", chapters);
+    expect(result.totalChapters).toBe(3);
+    expect(result.totalWords).toBe(9000);
+    expect(result.avgWordsPerChapter).toBe(3000);
+  });
+
+  it("calculates audit pass rate excluding un-audited statuses", () => {
+    const chapters = [
+      { number: 1, status: "approved", wordCount: 3000, auditIssues: [] },
+      { number: 2, status: "audit-failed", wordCount: 3000, auditIssues: ["[critical] 连续性:角色位置矛盾"] },
+      { number: 3, status: "drafted", wordCount: 3000, auditIssues: [] }, // not audited
+      { number: 4, status: "ready-for-review", wordCount: 3000, auditIssues: [] },
+    ];
+    const result = computeAnalytics("book-b", chapters);
+    // Audited: approved(1), audit-failed(2), ready-for-review(4) = 3
+    // Passed (approved + ready-for-review + published): 1 + 4 = 2
+    // Pass rate: 2/3 = 67%
+    expect(result.auditPassRate).toBe(67);
+  });
+
+  it("extracts issue categories from formatted strings", () => {
+    const chapters = [
+      {
+        number: 1,
+        status: "audit-failed",
+        wordCount: 3000,
+        auditIssues: [
+          "[critical] 连续性:角色位置矛盾",
+          "[warning] 数值错误:灵石数量不一致",
+          "[critical] 连续性:时间线冲突",
+        ],
+      },
+      {
+        number: 2,
+        status: "audit-failed",
+        wordCount: 2900,
+        auditIssues: [
+          "[warning] 数值错误:修炼速度超标",
+        ],
+      },
+    ];
+    const result = computeAnalytics("book-c", chapters);
+    expect(result.topIssueCategories).toEqual([
+      { category: "连续性", count: 2 },
+      { category: "数值错误", count: 2 },
+    ]);
+  });
+
+  it("falls back to 未分类 for unstructured issues", () => {
+    const chapters = [
+      {
+        number: 1,
+        status: "audit-failed",
+        wordCount: 3000,
+        auditIssues: ["some random issue without format"],
+      },
+    ];
+    const result = computeAnalytics("book-d", chapters);
+    expect(result.topIssueCategories).toEqual([
+      { category: "未分类", count: 1 },
+    ]);
+  });
+
+  it("ranks chapters by issue count", () => {
+    const chapters = [
+      { number: 1, status: "audit-failed", wordCount: 3000, auditIssues: ["a"] },
+      { number: 2, status: "audit-failed", wordCount: 3000, auditIssues: ["a", "b", "c"] },
+      { number: 3, status: "approved", wordCount: 3000, auditIssues: [] },
+      { number: 4, status: "audit-failed", wordCount: 3000, auditIssues: ["a", "b"] },
+    ];
+    const result = computeAnalytics("book-e", chapters);
+    expect(result.chaptersWithMostIssues).toEqual([
+      { chapter: 2, issueCount: 3 },
+      { chapter: 4, issueCount: 2 },
+      { chapter: 1, issueCount: 1 },
+    ]);
+  });
+
+  it("computes status distribution", () => {
+    const chapters = [
+      { number: 1, status: "approved", wordCount: 3000, auditIssues: [] },
+      { number: 2, status: "approved", wordCount: 3000, auditIssues: [] },
+      { number: 3, status: "audit-failed", wordCount: 3000, auditIssues: ["x"] },
+      { number: 4, status: "drafted", wordCount: 3000, auditIssues: [] },
+    ];
+    const result = computeAnalytics("book-f", chapters);
+    expect(result.statusDistribution).toEqual({
+      approved: 2,
+      "audit-failed": 1,
+      drafted: 1,
+    });
+  });
+
+  it("limits topIssueCategories to 10", () => {
+    const issues = Array.from({ length: 15 }, (_, i) =>
+      `[warning] cat${i}:something`,
+    );
+    const chapters = [
+      { number: 1, status: "audit-failed", wordCount: 3000, auditIssues: issues },
+    ];
+    const result = computeAnalytics("book-g", chapters);
+    expect(result.topIssueCategories.length).toBe(10);
+  });
+
+  it("limits chaptersWithMostIssues to 5", () => {
+    const chapters = Array.from({ length: 8 }, (_, i) => ({
+      number: i + 1,
+      status: "audit-failed",
+      wordCount: 3000,
+      auditIssues: Array.from({ length: i + 1 }, (_, j) => `issue-${j}`),
+    }));
+    const result = computeAnalytics("book-h", chapters);
+    expect(result.chaptersWithMostIssues.length).toBe(5);
+    // Sorted descending: ch8(8), ch7(7), ch6(6), ch5(5), ch4(4)
+    expect(result.chaptersWithMostIssues[0]!.chapter).toBe(8);
+  });
+});

+ 193 - 0
packages/cli/src/__tests__/cli-integration.test.ts

@@ -0,0 +1,193 @@
+import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { dirname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { execFileSync } from "node:child_process";
+import { describe, it, expect, beforeAll, afterAll } from "vitest";
+
+const testDir = dirname(fileURLToPath(import.meta.url));
+const cliDir = resolve(testDir, "..", "..");
+const cliEntry = resolve(cliDir, "dist", "index.js");
+
+let projectDir: string;
+
+function run(args: string[], options?: { env?: Record<string, string> }): string {
+  return execFileSync("node", [cliEntry, ...args], {
+    cwd: projectDir,
+    encoding: "utf-8",
+    env: {
+      ...process.env,
+      // Prevent global config from leaking into tests
+      HOME: projectDir,
+      ...options?.env,
+    },
+    timeout: 10_000,
+  });
+}
+
+function runStderr(args: string[]): { stdout: string; stderr: string; exitCode: number } {
+  try {
+    const stdout = execFileSync("node", [cliEntry, ...args], {
+      cwd: projectDir,
+      encoding: "utf-8",
+      env: { ...process.env, HOME: projectDir },
+      timeout: 10_000,
+    });
+    return { stdout, stderr: "", exitCode: 0 };
+  } catch (e: unknown) {
+    const err = e as { stdout: string; stderr: string; status: number };
+    return { stdout: err.stdout ?? "", stderr: err.stderr ?? "", exitCode: err.status ?? 1 };
+  }
+}
+
+describe("CLI integration", () => {
+  beforeAll(async () => {
+    projectDir = await mkdtemp(join(tmpdir(), "inkos-cli-test-"));
+  });
+
+  afterAll(async () => {
+    await rm(projectDir, { recursive: true, force: true });
+  });
+
+  describe("inkos --version", () => {
+    it("prints version number", () => {
+      const output = run(["--version"]);
+      expect(output.trim()).toMatch(/^\d+\.\d+\.\d+$/);
+    });
+  });
+
+  describe("inkos --help", () => {
+    it("prints help with command list", () => {
+      const output = run(["--help"]);
+      expect(output).toContain("inkos");
+      expect(output).toContain("init");
+      expect(output).toContain("book");
+      expect(output).toContain("write");
+    });
+  });
+
+  describe("inkos init", () => {
+    it("initializes project in current directory", () => {
+      const output = run(["init"]);
+      expect(output).toContain("Project initialized");
+    });
+
+    it("creates inkos.json with correct structure", async () => {
+      const raw = await readFile(join(projectDir, "inkos.json"), "utf-8");
+      const config = JSON.parse(raw);
+      expect(config.llm).toBeDefined();
+      expect(config.llm.provider).toBe("openai");
+      expect(config.llm.model).toBe("gpt-4o");
+      expect(config.daemon).toBeDefined();
+      expect(config.notify).toEqual([]);
+    });
+
+    it("creates .env file", async () => {
+      const envContent = await readFile(join(projectDir, ".env"), "utf-8");
+      expect(envContent).toContain("INKOS_LLM_API_KEY");
+    });
+
+    it("creates .gitignore", async () => {
+      const gitignore = await readFile(join(projectDir, ".gitignore"), "utf-8");
+      expect(gitignore).toContain(".env");
+    });
+
+    it("creates books/ and radar/ directories", async () => {
+      const booksStat = await stat(join(projectDir, "books"));
+      expect(booksStat.isDirectory()).toBe(true);
+      const radarStat = await stat(join(projectDir, "radar"));
+      expect(radarStat.isDirectory()).toBe(true);
+    });
+  });
+
+  describe("inkos init <name>", () => {
+    it("creates project in subdirectory", () => {
+      const output = run(["init", "subproject"]);
+      expect(output).toContain("Project initialized");
+    });
+
+    it("creates inkos.json in subdirectory", async () => {
+      const raw = await readFile(join(projectDir, "subproject", "inkos.json"), "utf-8");
+      const config = JSON.parse(raw);
+      expect(config.name).toBe("subproject");
+    });
+  });
+
+  describe("inkos config set", () => {
+    it("sets a top-level config value", () => {
+      const output = run(["config", "set", "name", "test-project"]);
+      expect(output).toContain("Set name = test-project");
+    });
+
+    it("sets a nested config value", async () => {
+      run(["config", "set", "llm.model", "gpt-5"]);
+      const raw = await readFile(join(projectDir, "inkos.json"), "utf-8");
+      const config = JSON.parse(raw);
+      expect(config.llm.model).toBe("gpt-5");
+    });
+
+    it("creates intermediate keys for deep paths", async () => {
+      run(["config", "set", "custom.nested.key", "value"]);
+      const raw = await readFile(join(projectDir, "inkos.json"), "utf-8");
+      const config = JSON.parse(raw);
+      expect(config.custom.nested.key).toBe("value");
+    });
+  });
+
+  describe("inkos config show", () => {
+    it("shows current config as JSON", () => {
+      const output = run(["config", "show"]);
+      const config = JSON.parse(output);
+      expect(config.name).toBe("test-project");
+      expect(config.llm.model).toBe("gpt-5");
+    });
+  });
+
+  describe("inkos book list", () => {
+    it("shows no books in empty project", () => {
+      const output = run(["book", "list"]);
+      expect(output).toContain("No books found");
+    });
+
+    it("returns empty array in JSON mode", () => {
+      const output = run(["book", "list", "--json"]);
+      const data = JSON.parse(output);
+      expect(data.books).toEqual([]);
+    });
+  });
+
+  describe("inkos status", () => {
+    it("shows project status with zero books", () => {
+      const output = run(["status"]);
+      expect(output).toContain("Books: 0");
+    });
+
+    it("returns JSON with --json flag", () => {
+      const output = run(["status", "--json"]);
+      const data = JSON.parse(output);
+      expect(data.project).toBeDefined();
+      expect(data.books).toEqual([]);
+    });
+
+    it("errors for nonexistent book", () => {
+      const { exitCode, stderr } = runStderr(["status", "nonexistent"]);
+      expect(exitCode).not.toBe(0);
+    });
+  });
+
+  describe("inkos doctor", () => {
+    it("checks environment health", () => {
+      const { stdout } = runStderr(["doctor"]);
+      expect(stdout).toContain("InkOS Doctor");
+      expect(stdout).toContain("Node.js >= 20");
+      expect(stdout).toContain("inkos.json");
+    });
+  });
+
+  describe("inkos analytics", () => {
+    it("errors when no book exists", () => {
+      const { exitCode } = runStderr(["analytics"]);
+      expect(exitCode).not.toBe(0);
+    });
+  });
+});

+ 1 - 1
packages/cli/src/commands/analytics.ts

@@ -13,7 +13,7 @@ interface AnalyticsData {
   readonly statusDistribution: Record<string, number>;
 }
 
-function computeAnalytics(
+export function computeAnalytics(
   bookId: string,
   chapters: ReadonlyArray<{
     readonly number: number;

+ 1 - 0
packages/core/package.json

@@ -13,6 +13,7 @@
   },
   "files": [
     "dist",
+    "!dist/__tests__",
     "genres"
   ],
   "license": "MIT",