/* * ★ 구조 리비전 — **실 DB로 경계를 넘어 본다.** * * 커널의 마디 계산(`replaySegments`)는 그쪽 테스트가 지킨다. 여기서 지키는 것은 **호스트 배선**이다: * 리비전을 언제 새로 만드는가 · 이벤트에 번호를 찍는가 · 재생이 그 번호로 마디를 나누는가 · * 컬럼이 생기기 전 행(번호 없음)을 어디에 붙이는가. * * 이 배선은 **경계를 넘어야만** 틀리는 것이 드러난다. 그런데 지금 앱의 트윈은 전부 리비전 1이라 * 경계를 한 번도 안 넘어 봤다 — 오늘 하루에만 "테스트는 통과하는데 배선이 틀린" 경우를 셋 봤으므로, * 그 상태로 두지 않는다. * * 실 엔티티·실 네이밍 전략으로 만든 sqlite in-memory 스키마에 **빌드 산출물**을 그대로 실행한다. * 소스를 고친 뒤에는 `npm run build:server` 를 먼저 돌려야 반영된다. */ import { test, before, after, beforeEach } from 'node:test' import assert from 'node:assert/strict' import { createRequire } from 'node:module' import type { DataSource } from 'typeorm' /* * **CJS 로 실어 온다.** `dist-server` 는 CJS 이고 그 안의 twin-engine 은 커널을 `require` 로 든다. * 그것을 ESM `import` 로 끌면 Node 의 ESM→CJS 번역기가 내부 단언에서 터진다(ERR_INTERNAL_ASSERTION). * 실 산출물을 그대로 시험하는 것이 이 스펙의 요점이므로, 부르는 쪽이 CJS 로 맞춘다. */ 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 { TwinStructure } = req('../dist-server/service/twin-structure/twin-structure.js') const { TwinInstance } = req('../dist-server/service/twin-instance/twin-instance.js') const { WmsKernel, EventJournal } = req('@operato/twin-kernel') const { DataSource: TypeOrmDataSource } = req('typeorm') let ds: DataSource let domainId: string const INSTANCE = 'struct-probe' const GTIN = 'urn:epc:idpat:sgtin:0614141.012345.*' /** 저장 자리 `bins` 개 + 지게차 `forklifts` 대 — 구조를 다이얼한다. */ const boardWith = (bins: number, forklifts: number): any => ({ spaceId: 'struct-space', /* 이 픽스처가 식별자를 만드는 이름공간 — **픽스처마다 다르게** 둔다(같은 접두 아래에서 서로 다른 시험의 번호가 부딪치면 표준의 유일성 요구를 깬다, CBV 2.0 §8.2.4). 커널은 선언 없이 팔레트·문서를 만들지 않는다(0.7.45 에서 날조 프리픽스를 지웠다). */ identity: { namespaces: ['https://twin.test/structure-revision'] }, utcOffsetMinutes: 0, locations: [ { id: 'dock-recv', type: 'dock', capacity: 100 }, { id: 'staging', type: 'staging', capacity: 100 }, ...Array.from({ length: bins }, (_, i) => ({ id: `bin-${i + 1}`, type: 'storage', capacity: 20 })) ], equipment: Array.from({ length: forklifts }, (_, i) => ({ id: `fk-${i + 1}`, kind: 'forklift', homeLocation: 'dock-recv' })) }) /** 커널을 실제로 굴려 봉투를 얻는다 — 손으로 지어낸 모양은 검증이 아니다. */ function realEvents(model: any, ticks: number): any[] { const journal = new EventJournal() const k = new WmsKernel('struct') k.onEvent((e: any) => journal.append(e)) k.loadTwinModel(model) k.scenario.load({ seed: 11, generators: [{ kind: 'inbound-arrival', rate: { distribution: 'constant', meanPerHour: 120 }, content: { skuMix: [{ gtin: GTIN, weight: 1 }], qtyPerLine: { min: 1, max: 1 } } }] }) k.scenario.start() for (let i = 0; i < ticks; i++) k.tick(1000) return journal.all() as any[] } 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: 'struct-test', subdomain: 'struct-test' } as any) domainId = d.id }) after(async () => { await ds?.destroy() }) beforeEach(async () => { for (const target of [TwinEvent, TwinStructure, TwinInstance]) await ds.query(`DELETE FROM "${ds.getMetadata(target).tableName}"`) /* 엔진의 리비전 캐시도 비운다 — 스펙 간에 남으면 다음 스펙이 옛 번호를 찍는다. */ ;(TwinEngine as any).structureRevCache = {} /* 키가 `(domainId, instanceId)` 로 바뀌었다 — id 만으로 지우면 아무것도 지워지지 않는다. */ delete (TwinEngine as any).recovered[`${domainId}:${INSTANCE}`] }) test('같은 구조로 다시 프로비저닝하면 리비전이 늘지 않는다 — 부팅마다 늘면 이력이 잘게 쪼개진다', async () => { const model = boardWith(2, 1) await TwinEngine.provision(domainId, INSTANCE, 'wms', model, 'reset') await TwinEngine.provision(domainId, INSTANCE, 'wms', model, 'reset') await TwinEngine.provision(domainId, INSTANCE, 'wms', JSON.parse(JSON.stringify(model)), 'reset') const revs = await ds.getRepository(TwinStructure).find({ where: { domain: { id: domainId }, instanceId: INSTANCE } }) assert.equal(revs.length, 1) assert.equal(revs[0].rev, 1) }) test('구조가 바뀌면 새 리비전이 생기고 **저널은 살아남는다**', async () => { await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1), 'reset') const first = realEvents(boardWith(2, 1), 300) assert.ok(first.length > 0, '이벤트가 있어야 "살아남는지" 를 물을 수 있다') await TwinEngine.persistBatch(domainId, INSTANCE, first, 0) await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(6, 3), 'reset') const kept = await ds.getRepository(TwinEvent).count({ where: { domain: { id: domainId }, instanceId: INSTANCE } }) assert.equal(kept, first.length, '구조가 바뀌었다고 이력을 지우면 안 된다 — 그것이 이 작업의 전부다') const revs = await ds.getRepository(TwinStructure).find({ where: { domain: { id: domainId }, instanceId: INSTANCE }, order: { rev: 'ASC' } }) assert.deepEqual(revs.map(r => r.rev), [1, 2]) assert.notEqual(revs[0].signature, revs[1].signature) }) test('이벤트가 자기 구조 번호를 달고 저장된다 — 안 달면 나중에 어느 공장인지 알 길이 없다', async () => { await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1), 'reset') await TwinEngine.persistBatch(domainId, INSTANCE, realEvents(boardWith(2, 1), 200), 0) await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(6, 3), 'reset') const before = await ds.getRepository(TwinEvent).count({ where: { domain: { id: domainId }, instanceId: INSTANCE } }) await TwinEngine.persistBatch(domainId, INSTANCE, realEvents(boardWith(6, 3), 200), before) const rows = await ds.getRepository(TwinEvent).find({ where: { domain: { id: domainId }, instanceId: INSTANCE } }) const byRev = new Map() for (const r of rows) byRev.set(r.structureRev, (byRev.get(r.structureRev) ?? 0) + 1) assert.deepEqual([...byRev.keys()].sort(), [1, 2], '두 구조에서 각각 쓰인 이벤트가 있어야 한다') assert.ok((byRev.get(1) ?? 0) > 0 && (byRev.get(2) ?? 0) > 0) }) test('★ 재생이 경계를 넘는다 — 앞선 사실은 남고, 새 구조의 설비가 들어온다', async () => { await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1), 'reset') const first = realEvents(boardWith(2, 1), 300) await TwinEngine.persistBatch(domainId, INSTANCE, first, 0) await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(6, 3), 'reset') await TwinEngine.persistBatch(domainId, INSTANCE, realEvents(boardWith(6, 3), 300), first.length) const state = await TwinEngine.recover(domainId, INSTANCE) assert.ok(state.items.length > 0, '앞 마디의 물품이 사라지면 이력을 지운 것과 다를 바 없다') assert.equal(state.equipment.length, 3, '새 구조의 지게차 셋이 상태에 들어와야 한다') assert.equal(state.locations.filter((n: any) => n.type === 'storage').length, 6, '늘린 자리가 안 보이면 늘린 의미가 없다') }) test('구조 번호가 없는 옛 행은 **가장 오래된 구조**로 처리한다 — 새 공장에 대고 계산하지 않는다', async () => { /* * 컬럼이 생기기 전에 쓰인 행은 `structureRev` 가 비어 있다. 그것을 현재 보드로 계산하면 그때 없던 * 설비에서 일이 있었던 것처럼 보인다 — 이 규칙이 그 행들을 제자리에 붙인다. */ await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1), 'reset') const legacy = realEvents(boardWith(2, 1), 300) await TwinEngine.persistBatch(domainId, INSTANCE, legacy, 0) /* 컬럼이 없던 시절을 흉내 낸다 — 번호를 지운다. */ await ds.query(`UPDATE "${ds.getMetadata(TwinEvent).tableName}" SET structure_rev = NULL`) await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(6, 3), 'reset') const state = await TwinEngine.recover(domainId, INSTANCE) assert.ok(state.items.length > 0, '번호 없는 옛 행도 읽혀야 한다 — 알리지 않고 버리지 않는다') assert.equal(state.equipment.length, 3, '마지막 구조가 자원의 진실이다') }) test('리비전이 하나뿐이면 예전과 똑같이 계산한다 — 구조 전환이 결과를 바꾸지 않는다', async () => { await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(3, 2), 'reset') const events = realEvents(boardWith(3, 2), 300) await TwinEngine.persistBatch(domainId, INSTANCE, events, 0) const state = await TwinEngine.recover(domainId, INSTANCE) assert.equal(state.equipment.length, 2) assert.equal(state.locations.filter((n: any) => n.type === 'storage').length, 3) assert.ok(state.items.length > 0) }) test('꺼진 트윈의 이력도 읽을 수 있다 — 저널은 트윈이 꺼져 있을 때 가장 필요하다', async () => { await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1), 'reset') assert.equal(TwinEngine.owns(domainId, INSTANCE), false, '떠 있지 않다') assert.equal(await TwinEngine.ownsRegistered(domainId, INSTANCE), true, '그래도 이 테넌트 것이다') assert.equal(await TwinEngine.ownsRegistered(domainId, 'someone-elses-twin'), false) }) test('시간여행은 **그때의 공장**을 보여준다 — 과거에 최신 구조를 얹으면 없던 설비가 선다', async () => { /* * "지금" 을 물으면 최신 구조를 얹는 것이 맞다(재프로비저닝 직후 늘린 설비가 보여야 하니까). * 그런데 그 규칙을 과거 조회에도 적용하면, 그 시점에 존재하지도 않던 설비가 화면에 선다 — * 이 작업이 막으려던 바로 그 거짓말이다. 규칙이 "지금" 에만 걸리는지 못박는다. */ await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1), 'reset') const first = realEvents(boardWith(2, 1), 300) await TwinEngine.persistBatch(domainId, INSTANCE, first, 0) await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(6, 3), 'reset') const now = await TwinEngine.recover(domainId, INSTANCE) assert.equal(now.equipment.length, 3, '"지금" 은 늘린 설비를 보여준다') const past = await TwinEngine.recover(domainId, INSTANCE, first.length) assert.equal(past.equipment.length, 1, '그 시점에는 지게차가 하나뿐이었다') assert.equal(past.locations.filter((n: any) => n.type === 'storage').length, 2, '그때는 자리도 둘이었다') }) test('구조 이력이 **무엇이 달라졌는지** 알린다 — 보드 두 개를 눈으로 비교할 수는 없다', async () => { await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1), 'reset') const first = realEvents(boardWith(2, 1), 200) await TwinEngine.persistBatch(domainId, INSTANCE, first, 0) await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(6, 3), 'reset', '도장 능력 보강') await TwinEngine.persistBatch(domainId, INSTANCE, realEvents(boardWith(6, 3), 100), first.length) const history = await TwinEngine.structureHistory(domainId, INSTANCE) assert.equal(history.length, 2) /* 최초 리비전은 비교 대상이 없다 — 전부 새로 생긴 것으로 센다. */ assert.equal(history[0].rev, 1) assert.equal(history[0].diff.locationsAdded, 4, '자리 넷(도크·스테이징·빈 둘)') assert.ok(history[0].events > 0, '그 구조 아래 일어난 일의 수를 함께 말한다') const second = history[1] assert.equal(second.rev, 2) assert.equal(second.comment, '도장 능력 보강') assert.equal(second.diff.locationsAdded, 4, '저장 자리 넷이 늘었다') assert.equal(second.diff.equipmentAdded, 2, '지게차 둘이 늘었다') assert.deepEqual( second.diff.locationTypes.find(c => c.key === 'storage'), { key: 'storage', before: 2, after: 6, delta: 4 }, '"저장 자리 2 → 6" 이라고 읽혀야 한다' ) assert.deepEqual(second.diff.equipmentKinds.find(c => c.key === 'forklift'), { key: 'forklift', before: 1, after: 3, delta: 2 }) }) test('번호 없는 옛 행도 가장 오래된 리비전의 몫으로 센다 — 재생과 같은 규칙', async () => { await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1), 'reset') const legacy = realEvents(boardWith(2, 1), 200) await TwinEngine.persistBatch(domainId, INSTANCE, legacy, 0) await ds.query(`UPDATE "${ds.getMetadata(TwinEvent).tableName}" SET structure_rev = NULL`) await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(6, 3), 'reset') const history = await TwinEngine.structureHistory(domainId, INSTANCE) assert.equal(history[0].events, legacy.length, '재생이 rev 1 로 계산하는 행들을 세는 쪽만 빠뜨리면 두 화면이 다른 말을 한다') assert.equal(history[1].events, 0) }) test('지문 표기가 낡았을 뿐이면 리비전을 만들지 않는다 — 계산 방식을 바꿔도 이력이 부풀지 않는다', async () => { /* * 실제로 당했다. 지문을 원문에서 해시로 바꾸자 저장된 문자열이 무엇과도 안 맞아, 아무것도 안 * 바뀐 트윈들이 재기동 한 번에 전부 "구조가 바뀌었다" 로 기록됐다. 저장된 **보드**와 비교하면 * 그런 일이 없다. */ const model = boardWith(2, 1) await TwinEngine.provision(domainId, INSTANCE, 'wms', model, 'reset') const table = ds.getMetadata(TwinStructure).tableName await ds.query(`UPDATE "${table}" SET signature = 'old-style-signature-from-a-previous-algorithm'`) await TwinEngine.provision(domainId, INSTANCE, 'wms', model, 'reset') const revs = await ds.getRepository(TwinStructure).find({ where: { domain: { id: domainId }, instanceId: INSTANCE } }) assert.equal(revs.length, 1, '공장은 그대로인데 리비전이 늘면 이력이 거짓으로 부푼다') assert.notEqual(revs[0].signature, 'old-style-signature-from-a-previous-algorithm', '표기는 새 방식으로 고쳐 둔다') }) /* * ── 이어지는 현실(sim-world) — 재기동해도 이력이 끊기지 않는다 ──────────────── * * 예전에는 sim-world 선언이 저널을 지우고 fresh 로 띄웠다. 실제로 없던 것은 **번호 이어 세기 * 하나**였다: 상태 복원은 이미 저널 재생으로 하고 있었고, 막힌 것은 커널이 0부터 다시 세어 기존 * 행과 겹치는 문제였다. 그 겹침은 오류를 내지 않고 알리지 않고 재생 순서를 뒤섞는다. */ test('sim-world 로 다시 띄워도 저널이 살아남는다 — 선언이 지키는 약속', async () => { await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1), 'reset') const first = realEvents(boardWith(2, 1), 300) await TwinEngine.persistBatch(domainId, INSTANCE, first, 0) const rt = await TwinEngine.bootDeclared(domainId, INSTANCE, 'resume') try { const kept = await ds.getRepository(TwinEvent).count({ where: { domain: { id: domainId }, instanceId: INSTANCE } }) assert.equal(kept, first.length, 'sim-world 는 이어지는 현실이다 — 지우면 선언이 거짓이 된다') } finally { await TwinEngine.stop(domainId, INSTANCE) } }) test('★ 재기동 후 새 이벤트는 마지막 번호 뒤에 온다 — 겹치면 재생 순서가 뒤섞인다', async () => { await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1), 'reset') const first = realEvents(boardWith(2, 1), 300) await TwinEngine.persistBatch(domainId, INSTANCE, first, 0) const rt = await TwinEngine.bootDeclared(domainId, INSTANCE, 'resume') try { const kernel: any = rt.kernel assert.ok(kernel, '커널이 떠야 이어 세기를 물을 수 있다') assert.ok( kernel.getSnapshot().revision >= first.length, `이어 세지 않았다 — 저널 마지막 ${first.length}, 커널 ${kernel.getSnapshot().revision}` ) } finally { await TwinEngine.stop(domainId, INSTANCE) } }) test('저널이 비어 있으면 이어 셀 것이 없다 — 새로 시작하는 트윈에 영향 없음', async () => { await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1), 'reset') const rt = await TwinEngine.bootDeclared(domainId, INSTANCE, 'resume') try { assert.equal((rt.kernel as any).getSnapshot().revision, 0) } finally { await TwinEngine.stop(domainId, INSTANCE) } })