/* * ★ 돌고 있는 미러에 새 구조를 넣는다 — **미러는 멈출 수 없다.** * * 미러가 비추는 현실은 24시간 돈다. 설비 한 대가 늘었다고 트윈을 멈췄다 세우면 그 사이의 * 사실을 잃고, 라이브에서 유실은 곧 거짓이다. 그래서 미러는 **돌면서** 갈아탄다. * * 갈아타기가 옳으려면 **세 층이 함께** 움직여야 한다. 하나라도 빠지면 알리지 않고 어긋난다: * ① 리비전 — 이후의 이벤트가 새 번호를 달고 다닌다(재생이 마디를 나눌 수 있게) * ② 저장 model·구조 행 — 조회가 새 구조를 본다 * ③ 커널 — 지금 계산에 들어가는 이벤트가 새 구조 위에서 계산된다 * * 커널 단위 거동(무엇을 지키고 무엇을 버리나)은 커널 쪽 `adopt-structure.test.ts` 가 지킨다. * 여기서 지키는 것은 **호스트 배선** — 세 층이 같은 인제스트에서 함께 움직이는가. * * 실 DataSource 로 돈다. 부분 적용은 정의상 "여러 표에 걸쳐 무엇이 남았나" 이므로. */ 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 { TwinInstance } = req('../dist-server/service/twin-instance/twin-instance.js') const { TwinStructure } = req('../dist-server/service/twin-structure/twin-structure.js') const { TwinLocation } = req('../dist-server/service/twin-model/twin-location.js') const { TwinEquipment } = req('../dist-server/service/twin-model/twin-equipment.js') const { DataSource: TypeOrmDataSource } = req('typeorm') let ds: DataSource let domainId: string const MIRROR = 'mirror-probe' const SIM = 'sim-probe' const master = (source: string, bins: number, forklifts: number): any => ({ source, system: 'wms', siteName: '미러 창고', space: { width: 60, depth: 30, unit: 'm' }, locations: [ { id: 'dock-recv', type: 'dock', role: 'location', name: '입고 도크', capacity: 100 }, ...Array.from({ length: bins }, (_, i) => ({ id: `bin-${i + 1}`, type: 'storage', role: 'location', name: `빈${i + 1}`, capacity: 20 })) ], equipment: Array.from({ length: forklifts }, (_, i) => ({ id: `fk-${i + 1}`, kind: 'forklift', homeLocationId: 'dock-recv' })) }) 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: 'adopt-test', subdomain: 'adopt-test' } as any) domainId = d.id await TwinEngine.ingestMaster(domainId, master(MIRROR, 3, 2), 'reset') await TwinEngine.ingestMaster(domainId, master(SIM, 3, 2), 'reset') }) after(async () => { for (const id of [MIRROR, SIM]) await TwinEngine.stop(domainId, id).catch(() => {}) await ds?.destroy() }) test('돌고 있는 미러는 멈추지 않고 갈아탄다 — 세 층이 함께 움직인다', async () => { /* 미러 기동은 선언 기반 부팅이 가른다 — `startFromRegistry` 는 시뮬 경로다(같은 함수가 아니다). */ const rt = await TwinEngine.bootDeclared(domainId, MIRROR, 'resync') assert.equal(rt.mode, 'live', '미러로 떠야 이 검사가 뜻이 있다') const res = await TwinEngine.ingestMaster(domainId, master(MIRROR, 7, 2), 'reset') /* ① 리비전 — 이후 이벤트가 달고 다닐 번호가 올라간다 */ const revs = await ds.getRepository(TwinStructure).find({ where: { instanceId: MIRROR }, order: { rev: 'ASC' } }) assert.deepEqual(revs.map((r: any) => r.rev), [1, 2], '구조가 달라졌으니 새 리비전이 남는다') /* ② 저장 model·구조 행 — 조회가 새 구조를 본다 */ const inst = await ds.getRepository(TwinInstance).findOne({ where: { instanceId: MIRROR } }) assert.equal(inst.model.locations.length, 8, '저장 model 가 새 구조다(도크 1 + 빈 7)') assert.equal(await ds.getRepository(TwinLocation).countBy({ instance: { id: inst.id } } as any), 8, '구조 행도 함께 다시 그려진다') /* ③ 커널 — 멈추지 않은 채로 새 구조를 들고 있다 */ assert.equal(TwinEngine.instances[`${domainId}:${MIRROR}`]?.mode, 'live', '갈아타기가 미러를 멈추지 않는다') const snap = TwinEngine.snapshot(domainId, MIRROR) assert.equal(snap.locations.filter((n: any) => n.type === 'storage').length, 7, '커널도 새 자리를 들고 있다') /* 무엇이 달라졌는지 **말한다** — 멈췄다 세우는 마디가 없으므로 말하지 않으면 알리지 않고 지나간다 */ const adopted = res.warnings.find((w: any) => w.code === 'structure-adopted') assert.ok(adopted, '갈아타기는 말없이 성공하지 않는다') assert.equal(adopted.params.locationsAdded, 4, '자리 4개가 늘었다(빈 3→7)') assert.equal(adopted.params.rev, 2) }) test('사라진 설비를 수로 알린다 — 자리가 수천인 현장에서 직접 세어 보게 하지 않는다', async () => { const res = await TwinEngine.ingestMaster(domainId, master(MIRROR, 7, 1), 'reset') // 지게차 2 → 1 const adopted = res.warnings.find((w: any) => w.code === 'structure-adopted') assert.equal(adopted.params.equipmentDropped, 1) const inst = await ds.getRepository(TwinInstance).findOne({ where: { instanceId: MIRROR } }) assert.equal(await ds.getRepository(TwinEquipment).countBy({ instance: { id: inst.id } } as any), 1, '표에서도 사라진다') assert.equal(TwinEngine.snapshot(domainId, MIRROR).equipment.length, 1, '커널에서도 사라진다') }) test('돌고 있는 시뮬레이션은 여전히 거절한다 — 조건이 바뀐 실험은 다른 실험이다', async () => { await TwinEngine.bootDeclared(domainId, SIM, 'reset') const before = (await ds.getRepository(TwinStructure).find({ where: { instanceId: SIM } })).length await assert.rejects(() => TwinEngine.ingestMaster(domainId, master(SIM, 9, 2), 'reset'), /is running/) assert.equal( (await ds.getRepository(TwinStructure).find({ where: { instanceId: SIM } })).length, before, '거절된 인제스트는 흔적을 남기지 않는다' ) }) test('멈춘 미러는 구조를 전환하지 않는다 — 정지한 대상을 전환하려 하지 않는다', async () => { await TwinEngine.stop(domainId, MIRROR) await assert.rejects( () => TwinEngine.adoptStructure(domainId, MIRROR, { locations: [], equipment: [] } as any), /is not running/, '멈춘 트윈은 프로비저닝으로 세우는 것이지 갈아타는 것이 아니다' ) })