/* * ★ 다단 전개(BOM) — **자재가 자재를 품는 트리가 아니다.** * * 표준(ISA-95)이 택한 모양은 자재와 레시피가 번갈아 나오는 그래프다: * 완제품 ←산출— 레시피 —투입→ 부품, 그리고 그 부품이 또 어느 레시피의 산출이면 거기서 다시 내려간다. * 우리 선언(`ops.rel.input`·`ops.rel.output`)이 이미 그 두 홉이므로 **새 선언 없이** 재귀가 된다. * * 여기서 지키는 것: * · 패턴을 코드가 알지 않는다 — 선언 그래프에서 자기 축으로 돌아오는 순환을 찾는다 * · **알리지 않고 고르지 않는다** — 순환이 여럿이면 후보를 내고 멈춘다(방향이 뒤집힌 전개는 * 틀린 답이 아니라 **반대의 답**이라 화면만 보고는 구별되지 않는다) * · 수량은 곱해 내려가되, **모르면 곱하지 않는다**(1 로 메우면 윗단 합계가 통째로 거짓이 된다) * · 사이클을 만나면 다시 파지 않고 말한다 * · 상한에서 끊기면 끊겼다고 말한다 */ import { test, before, after } from 'node:test' import assert from 'node:assert/strict' import { createRequire } from 'node:module' import type { DataSource } from 'typeorm' const req = createRequire(import.meta.url) const { NamingStrategy, addDataSource, entities: shellEntities, Domain } = req('@things-factory/shell') const { entities: authEntities } = req('@things-factory/auth-base') const { entities: twinEntities } = req('../dist-server/service/index.js') const { TwinEngine } = req('../dist-server/engine/index.js') const { TwinModelTreeQuery } = req('../dist-server/service/twin-model/twin-model-tree-query.js') const { DataSource: TypeOrmDataSource } = req('typeorm') let ds: DataSource let domainId: string let q: any let ctx: any const INSTANCE = 'bom-probe' /** * 3단 BOM — 완제품 → 하위조립 → 원자재. * trailer ← trailer-line ← { frame ×1, axle ×2 } * frame ← frame-line ← { steel ×4 } */ const master = (extra: any = {}): any => ({ source: INSTANCE, system: 'mes', siteName: 'BOM 공장', space: { width: 60, depth: 30, unit: 'm' }, locations: [{ id: 'line', type: 'weld-station', role: 'location', capacity: 10 }], equipment: [{ id: 'welder-1', kind: 'welder', homeLocationId: 'line' }], productionSpec: { definition: { materials: [{ id: 'trailer' }, { id: 'frame' }, { id: 'axle' }, { id: 'steel' }], recipes: [ { key: 'trailer-line', label: '트레일러', inputs: [{ material: 'frame', qty: 1 }, { material: 'axle', qty: 2 }], outputs: [{ material: 'trailer', qty: 1 }] }, { key: 'frame-line', label: '프레임', inputs: [{ material: 'steel', qty: 4 }], outputs: [{ material: 'frame', qty: 1 }] } ], ...extra } } }) const explode = (itemId: string, opts: any = {}) => q.twinModelTree(INSTANCE, 'materials', itemId, ctx, 'ops.rel.output', 'ops.rel.input', opts.maxDepth, opts.maxItems) before(async () => { ds = new TypeOrmDataSource({ type: 'sqlite', database: ':memory:', synchronize: true, dropSchema: true, namingStrategy: new NamingStrategy(), entities: [...shellEntities, ...authEntities, ...twinEntities], logging: false }) await ds.initialize() addDataSource('default', ds) const d = await ds.getRepository(Domain).save({ name: 'bom-test', subdomain: 'bom-test' } as any) domainId = d.id q = new TwinModelTreeQuery() ctx = { state: { domain: { id: domainId } } } await TwinEngine.ingestMaster(domainId, master(), 'reset') }) after(async () => { await TwinEngine.stop(domainId, INSTANCE).catch(() => {}) await ds?.destroy() }) test('다단으로 내려간다 — 자재가 자재를 품지 않아도 전개된다', async () => { const t = await explode('trailer') const byDepth = (n: number) => t.items.filter((x: any) => x.depth === n).map((x: any) => x.id).sort() assert.deepEqual(byDepth(0), ['trailer']) assert.deepEqual(byDepth(1), ['axle', 'frame'], '레시피를 거쳐 1단이 나온다') assert.deepEqual(byDepth(2), ['steel'], '프레임이 또 어느 레시피의 산출이므로 거기서 더 내려간다') }) test('수량을 곱해 내려간다 — 그리고 모르면 곱하지 않는다', async () => { const t = await explode('trailer') const qty = (id: string) => t.items.find((n: any) => n.id === id)?.qty assert.equal(qty('frame'), 1) assert.equal(qty('axle'), 2) assert.equal(qty('steel'), 4, '프레임 1개당 강재 4 → 트레일러 1대당 4') assert.equal(t.qtyIncomplete, false) /* 수량이 빠진 선언이 섞이면 그 아래 합계는 성립하지 않는다 — 1 로 메우지 않고 그렇게 말한다. */ const noQty = master({ recipes: [ { key: 'trailer-line', label: 't', inputs: [{ material: 'frame' }], outputs: [{ material: 'trailer', qty: 1 }] }, { key: 'frame-line', label: 'f', inputs: [{ material: 'steel', qty: 4 }], outputs: [{ material: 'frame', qty: 1 }] } ] }) await TwinEngine.ingestMaster(domainId, noQty, 'reset') const t2 = await explode('trailer') assert.equal(t2.items.find((n: any) => n.id === 'frame').qty, null, '모르는 수량을 1 로 메우지 않는다') assert.equal(t2.items.find((n: any) => n.id === 'steel').qty, null, '모르는 값 아래는 전부 모르는 값이다') assert.equal(t2.qtyIncomplete, true, '합계가 성립하지 않는다는 사실을 낸다') await TwinEngine.ingestMaster(domainId, master(), 'reset') }) test('역전개도 같은 선언으로 — 방향만 뒤집는다', async () => { /* "이 강재를 쓰는 것은 무엇인가" — 투입으로 들어와 산출로 나간다. */ const up = await q.twinModelTree(INSTANCE, 'materials', 'steel', ctx, 'ops.rel.input', 'ops.rel.output') assert.deepEqual(up.items.filter((n: any) => n.depth === 1).map((n: any) => n.id), ['frame']) assert.deepEqual(up.items.filter((n: any) => n.depth === 2).map((n: any) => n.id), ['trailer']) }) test('방향을 안 고르면 아무것도 펼치지 않는다 — 뒤집힌 전개는 틀린 답이 아니라 반대의 답이다', async () => { const t = await q.twinModelTree(INSTANCE, 'materials', 'trailer', ctx) assert.equal(t.reason, 'ambiguous-pattern') assert.equal(t.items.length, 0, '한쪽을 집으면 아무도 모른 채 반대 답을 본다') assert.ok(t.patterns.length >= 2, '고를 수 있게 후보를 낸다') }) test('어떤 순환을 따라 펼쳤는지 밝힌다 — 화면만 보고는 방향을 구별할 수 없다', async () => { const t = await explode('trailer') assert.equal(t.pattern.inVia, 'ops.rel.output') assert.equal(t.pattern.outVia, 'ops.rel.input') assert.equal(t.pattern.through, 'recipes') }) test('사이클은 다시 탐색하지 않고 알린다', async () => { /* 강재가 트레일러에서 나온다고 선언하면(재활용) 그래프가 순환한다. 파고 들면 돌아 나오지 못한다. */ const loop = master({ recipes: [ { key: 'trailer-line', label: 't', inputs: [{ material: 'frame', qty: 1 }], outputs: [{ material: 'trailer', qty: 1 }] }, { key: 'frame-line', label: 'f', inputs: [{ material: 'trailer', qty: 1 }], outputs: [{ material: 'frame', qty: 1 }] } ] }) await TwinEngine.ingestMaster(domainId, loop, 'reset') const t = await explode('trailer') assert.ok(t.items.length < 10, '무한히 자라지 않는다') assert.ok(t.cycles.length > 0, '사이클을 만났다는 사실을 말한다') await TwinEngine.ingestMaster(domainId, master(), 'reset') }) test('상한에서 끊기면 끊겼다고 알린다 — 알리지 않고 자르지 않는다', async () => { const shallow = await explode('trailer', { maxDepth: 1 }) assert.equal(shallow.truncated.depth, true) assert.deepEqual(shallow.items.filter((n: any) => n.depth === 2), [], '깊이 상한 아래는 안 판다') /* 너비 우선이라 **얕은 층은 온전하다** — 깊이 우선이면 한 가지만 깊게 파고 끊긴다. */ assert.deepEqual(shallow.items.filter((n: any) => n.depth === 1).map((n: any) => n.id).sort(), ['axle', 'frame']) }) test('선언에 없는 자재를 참조하면 그 사실이 드러난다', async () => { const ghost = master({ recipes: [ { key: 'trailer-line', label: 't', inputs: [{ material: 'no-such-part', qty: 1 }], outputs: [{ material: 'trailer', qty: 1 }] } ] }) await TwinEngine.ingestMaster(domainId, ghost, 'reset') const t = await explode('trailer') const child = t.items.find((n: any) => n.id === 'no-such-part') assert.ok(child, '가리켜진 것은 그리되') assert.equal(child.known, false, '선언에 없다는 사실을 함께 낸다(있는 척하지 않는다)') await TwinEngine.ingestMaster(domainId, master(), 'reset') }) test('★ 선언에 없는 축을 거치면 터지지 않고 **왜 못 하는지 말한다**', async () => { /* * ── 실측으로 터진 것 (2026-08-24) ──────────────────────────────────────── * 이 전개는 **트윈 모델의 선언**을 읽는다(`axisInfo(...).path` 로 모델 안을 찾아 들어간다). 그런데 * 커널 카탈로그의 축 중 셋은 **선언되는 것이 아니라 일어나는 것**이다 — `orders`·`tasks`·`items` * 는 사건이 만들고 끝나면 사라진다. 모델이 그 목록을 가질 수 없으므로 `path` 가 비어 있다. * * Cannot read properties of undefined (reading 'split') * * ★ **터진 것은 요청한 축이 아니라 「거쳐 가는 축」이다.** `locations` 를 물으면 그 순환이 `tasks` 를 * 거치는데(작업이 `from`·`at` 으로 자리를 두 번 가리킨다) 그 `tasks` 에 경로가 없다. 그래서 요청한 * 축만 지켜서는 막히지 않는다 — 처음에 그렇게 고쳤다가 이 시험이 잡았다. * * 그리고 빈 트리로 답하면 화면이 「이어진 것이 없다」로 읽는데 그것은 거짓이다 — 작업은 자리를 * 가리키고 있고, 다만 이 질의가 그것을 아는 곳이 아니다. */ const q = new TwinModelTreeQuery() const ctx = { state: { domain: { id: domainId } } } as any /* 두 홉을 지정해야 순환이 하나로 정해진다 — 안 지정하면 앞 갈래(모호함)에서 답한다. */ const loc = await q.twinModelTree(INSTANCE, 'locations', 'st-a', ctx, 'ops.rel.from', 'ops.rel.at') assert.ok(loc, 'locations: 터지지 않는다') assert.deepEqual(loc.items, [], 'locations: 펼친 것이 없다') assert.equal(loc.reason, 'not-declared-in-model', 'locations: 왜 못 하는지 말한다(거쳐 갈 축이 선언에 없다)') /* 자기참조 순환이 아예 없는 축은 그 사실을 말한다 — 이유가 다르면 낱말도 달라야 한다. */ for (const axis of ['orders', 'tasks', 'items']) { const r = await q.twinModelTree(INSTANCE, axis, 'anything', ctx) assert.ok(r, `${axis}: 터지지 않는다`) assert.equal(r.reason, 'no-recursive-relation', `${axis}: 순환이 없다는 사실을 말한다`) } })