/* * ★ 가동 중 인제스트 — **거절할 것이면 아무것도 쓰기 전에 거절한다.** * * 가동 중 재프로비저닝은 `provision` 이 막는다. 그런데 그 호출은 `ingestMaster` 의 **맨 끝**이었고, * 그때까지 공간·표현·구역(TwinArea)은 이미 저장된 뒤였다. 사용자 화면에는 "인제스트 실패" 가 * 뜨는데 실제로는 절반이 적용된 상태다 — **실패라고 말하면서 데이터를 바꾸는 것**이 가장 나쁘다. * * 이 검사는 그 경계 하나를 지킨다: 거절된 인제스트는 **흔적을 남기지 않는다.** * * 순수 함수로는 확인되지 않는다. 부분 쓰기는 정의상 "여러 표에 걸쳐 무엇이 남았나" 이므로 실 * DataSource 가 있어야 한다(멀티DB 규약상 sqlite 가 5개 드라이버 공통의 최소선이다). * * 이 제약 자체는 최종형이 아니다 — 커널에는 가동 중 구조 교체(`adoptStructure`)가 이미 있고 * 재생은 리비전 경계마다 그것을 쓴다. 라이브에 배선되면 이 거절은 사라지고 이 검사도 함께 간다. */ 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 { TwinArea } = req('../dist-server/service/twin-space/twin-area.js') const { TwinSpace } = req('../dist-server/service/twin-space/twin-space.js') const { TwinLocation } = req('../dist-server/service/twin-model/twin-location.js') const { DataSource: TypeOrmDataSource } = req('typeorm') let ds: DataSource let domainId: string const INSTANCE = 'guard-probe' /** WMS 마스터 — `bins` 로 규모를, `siteName`·구역 이름으로 "무엇이 바뀌려 했나" 를 다이얼한다. */ const master = (bins: number, siteName: string, areaName: string): any => ({ source: INSTANCE, system: 'wms', siteName, space: { width: 60, depth: 30, unit: 'm' }, locations: [ { id: 'a-dc', type: 'warehouse', role: 'area', name: areaName }, { 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}`, parentId: 'a-dc', capacity: 20 })) ], equipment: [{ id: 'fk-1', kind: 'forklift', homeLocationId: 'dock-recv' }] }) /** 지금 DB 에 남아 있는 모든 흔적 — 인제스트가 건드리는 표를 한 줌으로 뜬다. */ async function trace() { const inst = await ds.getRepository(TwinInstance).findOne({ where: { instanceId: INSTANCE } }) const space = await ds.getRepository(TwinSpace).findOne({ where: { spaceId: `${INSTANCE}-site` } }) const areas = await ds.getRepository(TwinArea).find({ order: { areaId: 'ASC' } }) const revs = await ds.getRepository(TwinStructure).find({ order: { rev: 'ASC' } }) return { boardLocations: (inst?.model?.locations ?? []).length, spaceName: space?.name, areaNames: areas.map((a: any) => a.name), revs: revs.map((r: any) => r.rev), rows: await ds.getRepository(TwinLocation).count() } } 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: 'guard-test', subdomain: 'guard-test' } as any) domainId = d.id await TwinEngine.ingestMaster(domainId, master(3, '서울 창고', '보관 구역'), 'reset') }) after(async () => { await TwinEngine.stop(domainId, INSTANCE).catch(() => {}) await ds?.destroy() }) test('멈춰 있으면 재인제스트가 통한다 — 막는 것은 가동 중일 때뿐이다', async () => { await TwinEngine.ingestMaster(domainId, master(5, '서울 창고', '보관 구역'), 'reset') const t = await trace() assert.equal(t.boardLocations, 6, '자리 6개(도크 1 + 빈 5)로 갱신된다') assert.deepEqual(t.revs, [1, 2], '구조가 달라졌으니 리비전이 하나 늘어난다') assert.equal(t.rows, 6, '표도 함께 다시 그려진다') }) test('가동 중이면 거절하고 — **아무것도 바꾸지 않는다**', async () => { await TwinEngine.startFromRegistry(domainId, INSTANCE, 'reset') const before = await trace() /* * 바뀔 것을 **일부러 전부** 흔든다. 구조(빈 5→9)뿐 아니라 공간 이름·구역 이름까지 — * 부분 쓰기는 앞쪽(공간·구역)에서 일어났으므로, 구조만 확인하면 예전 결함을 못 잡는다. */ await assert.rejects( () => TwinEngine.ingestMaster(domainId, master(9, '부산 창고', '다른 구역'), 'reset'), /is running/, '가동 중에는 거절해야 한다' ) assert.deepEqual(await trace(), before, '거절된 인제스트는 어느 표에도 흔적을 남기지 않는다') }) test('멈추면 다시 통한다 — 거절은 잠금이 아니라 순서다', async () => { await TwinEngine.stop(domainId, INSTANCE) await TwinEngine.ingestMaster(domainId, master(9, '부산 창고', '다른 구역'), 'reset') const t = await trace() assert.equal(t.boardLocations, 10, '아까 거절당한 그 구조가 이제 들어간다') assert.deepEqual(t.revs, [1, 2, 3]) assert.equal(t.rows, 10) /* * 공간 이름은 **안 바뀐다** — 이미 있는 공간의 이름은 인제스트가 덮지 않는다(공유 공간에서 * 먼저 쓴 값이 이긴다). 거절 검사가 "안 바뀌었다" 를 확인할 때 이 성질에 기대지 않도록, * 여기서 그것이 별개의 규칙임을 못 박아 둔다. */ assert.equal(t.spaceName, '서울 창고', '기존 공간 이름은 재인제스트가 덮지 않는다(별개 규칙)') })