/* * ★ 재기동을 건너도 **계보에 입력이 남는다** — 호스트의 웜스타트 경로로 확인한다. * * ── 무엇이 잘못돼 있었나 ────────────────────────────────────────────────────── * 오더의 진행 중 할당(`allocated`)과 거래번호(`bizTransaction`)가 상태 계약에 없어서, 웜스타트가 * 오더를 되살릴 때 둘 다 비운 채로 세웠다. 그 오더의 진행 중 작업이 완료되면 * `TransformationEvent` 가 **입력 없이** 나간다 — "무엇이 무엇으로 바뀌었나" 에서 "무엇이" 가 * 통째로 빠지고, 계보를 거슬러 올라갈 수 없다. * * 실 앱에서 변환 36건 중 35건이 입력 0 이었고 모든 이벤트의 거래번호가 빈값이었다. * * ── 왜 커널 검사만으로 충분하지 않나 ────────────────────────────────────────── * 커널 검사는 `hydrateObserved` 로 직접 심어 확인한다. 호스트는 **저널을 재생해서** 그 씨앗을 * 만든다(`recover` → `replay`). 그 경로에서 한 번 더 떨어질 수 있으므로 여기서 실제로 걷는다. */ 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 { TwinEvent } = req('../dist-server/service/twin-event/twin-event.js') const { TwinLineageQuery } = req('../dist-server/service/twin-model/twin-lineage-query.js') const { DataSource: TypeOrmDataSource } = req('typeorm') let ds: DataSource let domainId: string const INSTANCE = 'lineage-probe' const CP = '0614141' const pat = (ref: string) => `urn:epc:idpat:sgtin:${CP}.${ref}.*` /** 원자재 하나로 완제품 하나를 만드는 2단 라인 — 실제 트레일러 템플릿의 축소판. */ const master = (): any => ({ source: INSTANCE, system: 'mes', siteName: '계보 공장', space: { width: 60, depth: 30, unit: 'm' }, locations: [ { id: 'raw-store', type: 'raw-store', role: 'location', capacity: 500 }, { id: 'cut-station', type: 'cut-station', role: 'location', capacity: 50 }, { id: 'weld-station', type: 'weld-station', role: 'location', capacity: 50 }, { id: 'fg-store', type: 'fg-store', role: 'location', capacity: 500 } ], equipment: [ { id: 'cutter-1', kind: 'cutter', homeLocationId: 'cut-station' }, { id: 'welder-1', kind: 'welder', homeLocationId: 'weld-station' } ], productionSpec: { companyPrefix: CP, binding: { steel: '077101', frame: '078101' }, /* * 작업지시의 문서 식별자를 **이 현장이 선언한다** — 커널은 문서 타입을 정하지 않습니다(0.7.43). * GDTI 는 「회사 프리픽스 + 문서 타입 + 일련번호」이고, 그 타입은 프리픽스를 배정받은 회사가 * 자기 번호 용량에서 배정합니다. 그러니 픽스처도 그 선언을 해야 합니다. */ identity: { documentTypes: { workorder: '403' } }, definition: { /* 자재는 **자기 보관 자리를 선언한다** — 커널이 자재가 놓이는 자리를 지어내지 않습니다(0.7.43). */ materials: [ { key: 'steel', label: '강재', locationType: 'raw-store' }, { key: 'frame', label: '프레임', locationType: 'fg-store' } ], locationTypes: [ { key: 'raw-store', label: '자재 보관' }, { key: 'cut-station', label: '절단' }, { key: 'weld-station', label: '용접' }, { key: 'fg-store', label: '완제품 보관' } ], routes: [{ key: 'frame-route', steps: ['cut', 'weld'] }], recipes: [{ key: 'frame-recipe', label: 'f', route: 'frame-route', inputs: [{ material: 'steel', qty: 2 }], outputs: [{ material: 'frame', qty: 1 }] }], operations: [ { key: 'cut', label: '절단', intent: 'process', locationType: 'cut-station', resourceType: 'cutter', duration: 'PT1M' }, { key: 'weld', label: '용접', intent: 'process', locationType: 'weld-station', resourceType: 'welder', duration: 'PT1M' } ] } } }) const SCENARIO = { seed: 9, generators: [ { kind: 'inbound-arrival', stimulus: 'arrival', rate: { distribution: 'constant', meanPerHour: 3600 }, content: { skuMix: [{ gtin: pat('077101'), weight: 1 }], qtyPerLine: { min: 1, max: 1 } } }, { kind: 'production-order', stimulus: 'order', rate: { distribution: 'constant', meanPerHour: 120 }, content: { skuMix: [{ gtin: pat('078101'), weight: 1 }], qtyPerLine: { min: 1, max: 1 } } } ] } /** 저널에 남은 변환 이벤트들 — 계보가 실제로 무엇을 기록했나. */ async function transforms(): Promise<{ inputs: number; outputs: number; tx: string }[]> { const rows: any[] = await ds.getRepository(TwinEvent).find({ where: { domain: { id: domainId } as any, instanceId: INSTANCE } as any }) return rows .filter(r => r.eventType === 'epcis.TransformationEvent') .map(r => ({ inputs: (r.payload?.data?.inputEPCList ?? []).length, outputs: (r.payload?.data?.outputEPCList ?? []).length, tx: r.payload?.data?.bizTransactionList?.[0]?.bizTransaction ?? '' })) } /** * 틱과 자극은 **런타임**에 있다 — `startFromRegistry` 가 돌려주는 것은 호스트의 인스턴스 기록이고 * 커널 런타임은 그 안에 있다(둘을 같은 것으로 보면 `scenario` 가 없다고 나온다). */ const runtime = () => TwinEngine.runtime(domainId, INSTANCE) function tick(n: number): void { const rt = runtime() for (let i = 0; i < n; i++) rt.tick(1000) } /** * 상태는 런타임의 `resync()` 로 얻는다 — `{ revision, state }` 를 돌려주므로 축은 그 **안에** 있다. * (`TwinEngine.snapshot` 도 이것을 그대로 넘겨 주므로 축이 한 겹 안에 들어 있다.) */ const orders = () => ((runtime().resync().state?.orders ?? []) as any[]) function drive(): void { const rt = runtime() rt.scenario.load(SCENARIO) rt.scenario.start() } 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: 'lineage-test', subdomain: 'lineage-test' } as any) domainId = d.id await TwinEngine.ingestMaster(domainId, master(), 'reset') }) after(async () => { await TwinEngine.stop(domainId, INSTANCE).catch(() => {}) await ds?.destroy() }) test('재기동을 건너도 계보에 입력이 실린다 — 웜스타트가 확보분을 잃지 않는다', async () => { /* ① 한 번 굴려 진행 중 할당을 만든다. */ await TwinEngine.startFromRegistry(domainId, INSTANCE, 'resume') drive() tick(200) const workingBefore = orders().filter(o => (o.allocated ?? []).length) assert.ok(workingBefore.length, '진행 중 확보분이 있어야 이 검사가 뜻이 있다') /* ② 멈추고 **저널을 재생해** 다시 세운다 — 호스트의 웜스타트 경로 그대로. */ await TwinEngine.stop(domainId, INSTANCE) await TwinEngine.startFromRegistry(domainId, INSTANCE, 'resume') const revived = orders().filter(o => (o.allocated ?? []).length) assert.ok(revived.length, '되살아난 오더가 확보분을 들고 있어야 한다 — 잃으면 계보의 입력이 빈다') /* ③ 되살아난 작업이 끝날 만큼 더 굴린다. */ drive() tick(300) const all = await transforms() assert.ok(all.length, '변환이 저널에 남아야 이 검사가 뜻이 있다') const withInputs = all.filter(t => t.inputs > 0) assert.ok(withInputs.length, '입력이 실린 변환이 하나도 없다 — 계보의 "어디서 왔나" 가 통째로 빈다') /* 거래번호도 함께 — 계보가 **무슨 거래로** 일어났는지 잇는 축이다. */ assert.ok(all.some(t => t.tx), '거래번호가 전부 빈값이다 — 계보를 거래로 이을 수 없다') }) test('계보를 거슬러 올라가 선언과 나란히 놓는다 — 그 차이가 진단이다', async () => { const q = new TwinLineageQuery() const ctx = { state: { domain: { id: domainId } } } as any /* 완제품 하나를 고른다 — 저널이 그것을 산출로 실은 변환이 있어야 계보가 있다. */ const rows: any[] = await ds.getRepository(TwinEvent).find({ where: { domain: { id: domainId } as any, instanceId: INSTANCE } as any }) /* **완제품**을 고른다. 변환에는 중간 산물(WIP)을 내는 것도 있어서 아무거나 집으면 WIP 이 잡히고, WIP 은 선언된 자재가 아니라 라우트의 내부 산물이므로 견줄 대상이 아니다. */ const made = rows .filter(r => r.eventType === 'epcis.TransformationEvent' && (r.payload?.data?.inputEPCList ?? []).length) .flatMap(r => r.payload.data.outputEPCList ?? []) .filter((e: string) => e.includes(`.078101.`)) assert.ok(made.length, '완제품을 낸 변환이 있어야 이 검사가 뜻이 있다') const lin = await q.twinItemLineage(INSTANCE, made[0], ctx) assert.ok(lin, '계보가 나와야 한다') assert.equal(lin.epc, made[0]) assert.ok(lin.items.length > 1, '거슬러 올라간 것이 있어야 한다 — 하나면 아무것도 안 찾은 것이다') assert.ok(lin.edges.some((e: any) => e.from === made[0]), '이 개체를 만든 직접 투입이 보여야 한다') /* 근거를 밝힌다 — 창을 넘겨 끊겼으면 계보가 실제보다 얕게 보인다. */ assert.equal(lin.basis.source, 'journal') assert.equal(typeof lin.basis.transformationsRead, 'number') /* **겹쳐 보기** — 선언(레시피)과 관측(실제 투입)을 자재별로 나란히. */ assert.equal(lin.conformance.comparable, true, `견줄 수 없으면 이유를 말한다: ${lin.conformance?.reason}`) const steel = lin.conformance.lines.find((l: any) => l.material === 'steel') assert.ok(steel, '레시피가 선언한 자재가 줄로 나와야 한다') assert.equal(steel.declared, 2, '레시피는 강재 2개를 요구한다') assert.equal(steel.observed, 2, '실제로도 2개를 먹었다 — 설계대로다') assert.equal(steel.matches, true) }) test('★ 만든 것을 관측하지 못했으면 「다르다」고 하지 않는다 — 안 본 것과 어긋난 것은 다른 사실이다', async () => { const q = new TwinLineageQuery() const ctx = { state: { domain: { id: domainId } } } as any /* * 완제품 클래스의 개체이지만 저널에 그것을 낸 변환이 없다 — 자재로도 이어지고 레시피도 하나다. * 예전에는 여기서 셈이 그대로 돌아 관측을 전부 0 으로 적고 「레시피와 다릅니다」를 냈다 * (2026-09-18 :3000 에서 카메라 로트 23 줄이 그렇게 보였다). */ const lin = await q.twinItemLineage(INSTANCE, `urn:epc:id:sgtin:${CP}.078101.999999`, ctx) assert.equal(lin.conformance.comparable, false, `관측이 없는데 견주었다: ${JSON.stringify(lin.conformance?.lines?.slice(0, 2))}`) assert.equal(lin.conformance.reason, 'made-not-observed') assert.ok(lin.conformance.recipe, '어느 레시피와 견주려 했는지는 말한다 — 사람이 다음 걸음을 안다') assert.equal(lin.basis.transformationsRead >= 0, true) }) test('바인딩이 없으면 비교하지 않고 이유를 알린다 — 짐작으로 이으면 거짓 진단이 된다', async () => { const q = new TwinLineageQuery() const ctx = { state: { domain: { id: domainId } } } as any /* 개체이긴 하지만 바인딩에 없는 품목 — 짐작으로 자재에 붙이면 "설계와 다르다" 는 거짓이 나온다. */ const lin = await q.twinItemLineage(INSTANCE, `urn:epc:id:sgtin:${CP}.099999.1`, ctx) assert.equal(lin.conformance.comparable, false) assert.equal(lin.conformance.reason, 'item-not-bound') })