orggraph-planning-fixture.test.js 4.2 KB
/**
 * Unit Tests — fixtures/devloop-mock/orggraph-planning.json(P3B / W8)
 *
 * 这份 fixture 的价值全在「能不能被读回并落库」,所以本文件按四环各钉一颗钉子:
 *   产生 → chunks 里确实有一段 ```json 图(不是只写在 result 里,那样 agent-chat 的
 *          fullOutput 收不到,见 agent-chat.js:2315 只累积 agent_message_chunk)
 *   传递 → mock 协议按 agent id(含 `{cid}:` 前缀的作用域名)能命中这份 fixture 并把
 *          图原样吐进流式 chunks
 *   落库 → 这张图过得了 org-graph-service 的五项校验(真正的写入方 POST /org-graph 用的就是它)
 *   读回 → 形状与 system/org-graph-templates/dev.json 对齐:三节点 + 两条 fail 回退边
 *
 * 反例保护:把图从 chunks 挪到 result、或让回退边漏标 backward、或砍掉那条兜底 fail 边,
 * 本文件都会红。
 */
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import path from 'path';

const REPO = path.resolve(__dirname, '../../..');
const FIXTURE = path.join(REPO, 'fixtures/devloop-mock/orggraph-planning.json');

/** ```json 围栏块(与 graph-engine.extractPlannedGraph 同一取法) */
function fencedGraphs(text) {
  const out = [];
  const fence = /```(?:json|jsonc)?\s*([\s\S]*?)```/g;
  let m;
  while ((m = fence.exec(text))) {
    try {
      const doc = JSON.parse(m[1]);
      if (Array.isArray(doc.nodes)) out.push(doc);
    } catch { /* 非图的围栏块,跳过 */ }
  }
  return out;
}

describe('orggraph-planning mock fixture', () => {
  const fixture = JSON.parse(fs.readFileSync(FIXTURE, 'utf-8'));
  const chunkText = (fixture.chunks || []).join('');

  it('图放在 chunks 里(而不是只放 result)', () => {
    expect(fencedGraphs(chunkText)).toHaveLength(1);
    // result 只是收尾摘要,不该承载图 —— 它进不了 agent-chat 的 fullOutput
    expect(fencedGraphs(String(fixture.result || ''))).toHaveLength(0);
  });

  it('形状对齐 system/org-graph-templates/dev.json:三节点 + 两条 fail 回退边', () => {
    const g = fencedGraphs(chunkText)[0];
    const dev = JSON.parse(fs.readFileSync(path.join(REPO, 'system/org-graph-templates/dev.json'), 'utf-8'));

    expect(g.nodes.map((n) => n.roleName)).toEqual(dev.nodes);
    expect(g.edges.filter((e) => e.condition === 'success')).toHaveLength(2);

    const fails = g.edges.filter((e) => e.condition === 'fail');
    expect(fails).toHaveLength(2);
    expect(fails.every((e) => e.edgeKind === 'backward')).toBe(true);
    // 兜底边:有 fail 出边的节点必须留一条不带 failReason 的(org-graph-service 的 fail-fallback-edge 规则)
    expect(fails.filter((e) => !e.failReason)).toHaveLength(1);
  });

  /**
   * validateGraph 是纯函数(不碰 DB,graph-db 的连接只在 getDb() 里惰性打开),
   * 所以这里既不设 GRAPH_DB_PATH 也不清 require.cache —— 那套隔离手法会污染同一
   * worker 里后跑的 graph-seed-service.test.js(实测:加进去后它必红)。
   */
  it('这张图过得了 org-graph-service 的五项校验(真正的写入方用的就是它)', () => {
    const svc = require('../../services/org-graph-service');
    const g = fencedGraphs(chunkText)[0];
    expect(svc.validateGraph(g.nodes, g.edges).errors).toEqual([]);
  });

  /**
   * fixture 的查找键是 `agentInfo.name` 冒号后那段(ClaudeCliProtocol._sendMockPrompt)。
   * graph-engine 传的是 `{cid}:{agentId}`,命得中;agent-chat 传的是 identity.md 的显示名,
   * 命不中(W8 备注里记了这个断点)。这条用例钉住的是「按 agent id 传就必须命中」这半边契约。
   */
  it('mock 协议按 agent id / 公司作用域名都能回放出这张图', () => {
    const { ClaudeCliProtocol } = require('../../../packages/acp-core/protocol/ClaudeCliProtocol');
    for (const name of ['orggraph-planning', 'some-co:orggraph-planning']) {
      const p = new ClaudeCliProtocol({ name }, { mock: true });
      let text = '';
      p.on('session_update', (e) => { text += e.update.content.text; });
      p._sendMockPrompt('规划这家公司的 org graph');
      expect(fencedGraphs(text)).toHaveLength(1);
    }
  });
});