company-hire-org-layer.test.js 11.9 KB
/**
 * Unit Tests — 招聘的「层」:work 层本体不得进 org graph
 * (27-P3B §四 W7 / 27-REQ R-18.2 末段)
 *
 * 锁的是 company-service.decideOrgNodeRecording 的三条判据 + 默认行为:
 *   ① config.type ∈ {meta-bootstrap, meta-planning} 的图基础设施本体永不入图;
 *   ② system/work-graph-templates/*.json 里点名当节点的本体永不入图;
 *   ③ 公司已有**成型**的 org graph(有 active 边)时,不在图上的角色不入图;
 *   ④ 都不命中 → 入图,即 P1/P2 既有语义(「雇一个角色 → org graph 出现它」)。
 *
 * 每条「不入图」的用例都配一条同角色(或同公司)只换一个条件的正例,
 * 免得测试退化成「这个角色被整体拉黑了」而看不出判据本身有没有生效。
 * 三条判据都做过变异验证(删判据①/②/③ 各会红掉对应用例)。
 *
 * 走的是**真的** hire()(不是 mock),落库后从 org-graph-service.getGraph() 读回
 * ——即 GET /api/company/:id/org-graph 的同一条读路径。
 *
 * 隔离手法与 graph-agent-p1-acceptance.test.js 同源:
 *   - GRAPH_DB_PATH 指向一次性 sqlite,真实 data/graph.sqlite 全程不被碰;
 *   - 公司建在 data/companies/tmp-w7-* 下,afterAll 删掉;
 *   - hire() 会 bump system/templates/_registry.json 的 usedCount,快照后恢复。
 */
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import path from 'path';

describe('hire() 的 org / work 分层(27-P3B W7)', () => {
  const REPO_ROOT = path.resolve(__dirname, '../../..');
  const TEST_DB_PATH = '/tmp/test-company-hire-org-layer.sqlite';
  const DATA_DIR = path.join(REPO_ROOT, 'data/companies');
  const TEMPLATES_DIR = path.join(REPO_ROOT, 'system/templates');
  const REGISTRY_PATH = path.join(TEMPLATES_DIR, '_registry.json');

  // 用到的模板(都是仓库里真实存在的),beforeAll 里逐个核实
  const TEMPLATES = ['qa-judge', 'hr', 'orggraph-planning', 'prd-planning', 'validator-triager', 'coder-validator'];

  const RUN_ID = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
  const CO_PLANNED = `tmp-w7-planned-${RUN_ID}`;     // orggraph-planning 已提交过成型的图
  const CO_ZEROEDGE = `tmp-w7-zeroedge-${RUN_ID}`;   // 真跑里 orggraph-planning 裁出过的**零条边单节点图**
  const CO_BLANK = `tmp-w7-blank-${RUN_ID}`;         // 从没有过图(P1/P2 的老场景)
  const CO_BOOTSTRAP = `tmp-w7-bootstrap-${RUN_ID}`; // 刚建的公司,也没有图

  // org graph(照抄 system/org-graph-templates/dev.json,能过 §3.3 四项校验)
  const ORG_NODES = ['org-planner', 'org-coder', 'org-validator'];
  const ORG_EDGES = [
    { from: 'org-planner', to: 'org-coder', condition: 'success' },
    { from: 'org-coder', to: 'org-validator', condition: 'success' },
    { from: 'org-validator', to: 'org-coder', condition: 'fail', edgeKind: 'backward' },
  ];

  let graphDb;
  let orgGraphService;
  let companyService;
  let registrySnapshot;

  /** 从 GET org-graph 的同一条读路径拿 active 角色名 */
  const roleNames = (companyId) =>
    orgGraphService.getGraph(companyId).nodes.filter((n) => n.status === 'active').map((n) => n.roleName);

  const systemAgentRow = (id) =>
    graphDb.getDb().prepare('SELECT * FROM system_agents WHERE id = ?').get(id);

  beforeAll(() => {
    // 模板必须真的在仓库里,否则 hire() 会走 base 继承分支,测试变成空跑
    for (const tpl of TEMPLATES) {
      expect(fs.existsSync(path.join(TEMPLATES_DIR, tpl, 'config.json'))).toBe(true);
    }
    // 判据②的数据源也要真的点名了 validator-triager,否则那条用例是空跑
    const workTpl = JSON.parse(
      fs.readFileSync(path.join(REPO_ROOT, 'system/work-graph-templates/validator.json'), 'utf-8')
    );
    expect(workTpl.nodes.map((n) => n.systemAgentId)).toContain('validator-triager');

    registrySnapshot = fs.readFileSync(REGISTRY_PATH, 'utf-8');

    if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH);
    process.env.GRAPH_DB_PATH = TEST_DB_PATH;
    for (const m of ['../../services/graph-db', '../../services/graph-service',
      '../../services/org-graph-service', '../../services/company-service']) {
      delete require.cache[require.resolve(m)];
    }
    graphDb = require('../../services/graph-db');
    orgGraphService = require('../../services/org-graph-service');
    const { CompanyService } = require('../../services/company-service');
    companyService = new CompanyService();

    // 镜像生产种子:这些模板在 system_agents 里是 source_kind='template'(引用模式)
    const db = graphDb.getDb();
    const now = new Date().toISOString();
    for (const id of TEMPLATES) {
      db.prepare(`
        INSERT INTO system_agents (id, name, scope, owner_company_id, source_kind, legacy_path, status, benchmark_score, created_at, updated_at)
        VALUES (?, ?, 'public', NULL, 'template', ?, 'active', NULL, ?, ?)
      `).run(id, id, `system/templates/${id}`, now, now);
    }

    for (const [id, name] of [[CO_PLANNED, 'W7 已有组织图'], [CO_BLANK, 'W7 空图公司'], [CO_BOOTSTRAP, 'W7 新建公司'], [CO_ZEROEDGE, 'W7 零边图公司']]) {
      companyService.create({ id, name, description: 'W7 分层测试' });
    }

    // CO_PLANNED 走 P2 §4.1 的正常顺序:orggraph-planning 先提交图,再招聘
    const written = orgGraphService.writeGraph(CO_PLANNED, { nodes: ORG_NODES, edges: ORG_EDGES });
    expect(written.ok).toBe(true);
    expect(written.nodeCount).toBe(3);
    expect(written.edgeCount).toBe(3);

    // 零边单节点图 —— 这个形状是真跑里 orggraph-planning 自己裁出来的,
    // 而上面那 9 例造的全是**有边**的成型图,正好绕开它(P3B 第三次真跑发现)。
    const zero = orgGraphService.writeGraph(CO_ZEROEDGE, { nodes: ['org-writer'], edges: [] });
    expect(zero.ok).toBe(true);
    expect(zero.nodeCount).toBe(1);
    expect(zero.edgeCount).toBe(0);
  });

  afterAll(() => {
    for (const id of [CO_PLANNED, CO_BLANK, CO_BOOTSTRAP, CO_ZEROEDGE]) {
      try { fs.rmSync(path.join(DATA_DIR, id), { recursive: true, force: true }); } catch {}
    }
    try { fs.unlinkSync(TEST_DB_PATH); } catch {}
    try { fs.unlinkSync(`${TEST_DB_PATH}-shm`); } catch {}
    try { fs.unlinkSync(`${TEST_DB_PATH}-wal`); } catch {}
    delete process.env.GRAPH_DB_PATH;
    if (registrySnapshot !== undefined) fs.writeFileSync(REGISTRY_PATH, registrySnapshot, 'utf-8');
  });

  // ── ④ 默认行为:P1/P2 语义原样保住 ──────────────────────────────────
  it('公司还没有 org graph 时雇一个角色 → org graph 里出现这个节点(P2 §4.2 语义)', () => {
    expect(roleNames(CO_BLANK)).toEqual([]);

    expect(companyService.hire(CO_BLANK, 'qa-judge', {}).status).toBe('hired_from_template');

    expect(roleNames(CO_BLANK)).toEqual(['qa-judge']);
  });

  it('同一家没有图的公司连雇两个人,第二个照样入图(判据③不能拿节点当信号的原因)', () => {
    expect(companyService.hire(CO_BLANK, 'hr', {}).status).toBe('hired_from_template');

    expect(roleNames(CO_BLANK).sort()).toEqual(['hr', 'qa-judge']);
  });

  // ── ② work graph 骨架点名的本体 ─────────────────────────────────────
  it('validator-triager(type=validator,骨架里点过名)不入图,哪怕公司连一条边都没有', () => {
    const before = roleNames(CO_BLANK).sort();

    expect(companyService.hire(CO_BLANK, 'validator-triager', {}).status).toBe('hired_from_template');

    expect(roleNames(CO_BLANK).sort()).toEqual(before);
    // 但 system_agents 那行必须在:work_graph_nodes.system_agent_id 是指向它的外键,
    // 缺行的话 workgraph-planning 雇完人再提交 work graph 会直接外键挂(W1 那条失效链)
    expect(systemAgentRow('validator-triager')).toBeTruthy();
  });

  // ── ① 图基础设施本体 ────────────────────────────────────────────────
  it('meta-bootstrap(orggraph-planning)不入图:建公司到规划跑完之间不会返回一张假组织图', () => {
    expect(companyService.hire(CO_BOOTSTRAP, 'orggraph-planning', {}).status).toBe('hired_from_template');

    expect(roleNames(CO_BOOTSTRAP)).toEqual([]);
    expect(systemAgentRow('orggraph-planning')).toBeTruthy();
  });

  it('meta-planning(prd-planning)不入图,哪怕公司还没有图(判据①单独生效)', () => {
    expect(roleNames(CO_BOOTSTRAP)).toEqual([]);

    expect(companyService.hire(CO_BOOTSTRAP, 'prd-planning', {}).status).toBe('hired_from_template');

    expect(roleNames(CO_BOOTSTRAP)).toEqual([]);
  });

  it('coder-validator 在没有成型图的公司里照样入图 —— 判据①②都不认识它', () => {
    expect(companyService.hire(CO_BOOTSTRAP, 'coder-validator', {}).status).toBe('hired_from_template');

    expect(roleNames(CO_BOOTSTRAP)).toEqual(['coder-validator']);
  });

  // ── ③ 成型的图 + 不在图上 = work 层本体 ─────────────────────────────
  it('同一个 coder-validator,在已有成型 org graph 的公司里被雇 → GET org-graph 里没有它', () => {
    expect(roleNames(CO_PLANNED)).toEqual(ORG_NODES);

    expect(companyService.hire(CO_PLANNED, 'coder-validator', {}).status).toBe('hired_from_template');

    // 雇到位了:目录在、公司名册里有
    expect(fs.existsSync(path.join(DATA_DIR, CO_PLANNED, 'agents/coder-validator'))).toBe(true);
    expect(companyService.get(CO_PLANNED).agents).toContain('coder-validator');
    // 但组织图一个节点都没多
    expect(roleNames(CO_PLANNED)).toEqual(ORG_NODES);
  });

  it('R-18.2 验收 7:workgraph-planning 雇一轮 work 层本体后,org graph 仍然只有 org 节点', () => {
    for (const body of ['prd-planning', 'validator-triager']) {
      expect(['hired_from_template', 'already_exists']).toContain(
        companyService.hire(CO_PLANNED, body, {}).status
      );
    }

    expect(roleNames(CO_PLANNED)).toEqual(ORG_NODES);
  });

  it('W7-fix:零条边的单节点图也算「成型」—— work 层本体不得入图', () => {
    // 判据原先看的是**边数**,零边图上三条判据全落空 → work 层本体被写进 org_graph_nodes,
    // 下一次下达任务时它会被 advanceOne 当成 org 节点执行。真跑里 readme-writer 就是这么混进去的。
    expect(roleNames(CO_ZEROEDGE)).toEqual(['org-writer']);

    expect(['hired_from_template', 'already_exists']).toContain(
      companyService.hire(CO_ZEROEDGE, 'coder-validator', {}).status
    );

    // 雇到位了,但组织图一个节点都没多
    expect(fs.existsSync(path.join(DATA_DIR, CO_ZEROEDGE, 'agents/coder-validator'))).toBe(true);
    expect(roleNames(CO_ZEROEDGE)).toEqual(['org-writer']);
  });

  it('W7-fix:零边图上,图里点名的那个 org 节点重复雇仍然入图(P2 语义不被误伤)', () => {
    expect(['hired_from_template', 'hired_from_base', 'already_exists']).toContain(
      companyService.hire(CO_ZEROEDGE, 'org-writer', {}).status
    );
    expect(roleNames(CO_ZEROEDGE)).toEqual(['org-writer']);
  });

  it('org 节点本身重复雇不产生新节点(P2 幂等语义不变)', () => {
    const res = companyService.hire(CO_PLANNED, 'org-coder', {});
    // 模板库里没有 org-coder → 走 base 元 agent 继承分支,同样要过 _recordHireInGraph
    expect(['hired_from_template', 'hired_from_base']).toContain(res.status);

    expect(roleNames(CO_PLANNED)).toEqual(ORG_NODES);
    const count = graphDb.getDb()
      .prepare(`SELECT COUNT(*) AS c FROM org_graph_nodes WHERE company_id = ? AND role_name = 'org-coder' AND status = 'active'`)
      .get(CO_PLANNED).c;
    expect(count).toBe(1);
  });
});