/** * 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_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 新建公司']]) { 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); }); afterAll(() => { for (const id of [CO_PLANNED, CO_BLANK, CO_BOOTSTRAP]) { 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('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); }); });