/* * ★ 동기화 판정이 **실제 트윈 위에서** 옳게 나온다 — 엔진 이음새. * * 순수 모듈 시험(`ingest-health.test.ts`)은 「이런 장부가 주어지면 이런 판정」을 지킨다. 그런데 화면이 * 실제로 받는 판정은 그 앞에 한 층이 더 있다: **피드 사실을 어디서 얻는가**(`liveFeedStateOf` — 이 트윈이 * 미러인가 시뮬인가, 지금 도는가) 그리고 **장부를 어느 트윈의 것으로 찾는가**(도메인+인스턴스 키). * * 그 층이 틀리면 순수 시험은 전부 초록인 채로 화면만 거짓말한다. 실제로 두 가지가 알리지 않고 틀릴 수 있다: * ① 시뮬 트윈이 「연결 안 됨」으로 보인다 — 거짓 경보이고, 거짓 경보는 참 경보를 죽인다 * ② 남의 도메인 장부를 집는다 — 키를 도메인 없이 만들면 그렇게 된다 * * 그래서 실 DataSource 로 트윈을 세우고, 미러 하나·시뮬 하나로 판정 여섯을 이 자리에서 확인한다. * 목 HTTP 게이트웨이는 쓰지 않는다(그것은 데모 환경변수에 딸린 것이고, 여기서 볼 것은 장부와 판정이다). */ 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 { INGEST_WINDOW_MS, STALE_AFTER_MS } = req('../dist-server/engine/ingest-health.js') const { registerLiveFeedProbe } = req('../dist-server/engine/live-feed-registry.js') const { DataSource: TypeOrmDataSource } = req('typeorm') /* * 피드 사실을 이 시험이 쥔다. * * 실제 커넥터 피드는 원천에 붙어야 서고, 붙지 못하면 비동기로 떨어진다 — 그 타이밍에 판정이 흔들리면 * 이 시험은 **판정을 재는 게 아니라 커넥터를 재는 것**이 된다(실제로 그렇게 깨졌다: 세 번째 검사까지는 * 붙어 있다가 네 번째에서 떨어져 「연결 안 됨」이 나왔다). 그래서 `registerLiveFeedProbe` — 계층이 자기 * 장부를 등록하도록 만들어 둔 그 확장점 — 으로 시험의 장부를 하나 더 얹는다. 커넥터 거동은 커넥터 * 시험이 지킨다. */ let feedUp = new Set() let ds: DataSource let domainId: string let otherDomainId: string /* * 시나리오마다 트윈을 따로 둔다. * * 장부는 트윈마다 하나이고 **창은 이어진다.** 트윈 하나로 여러 시나리오를 돌리면 앞 검사가 남긴 창에 * 뒤 검사의 건수가 섞여 「최근 창」이 엉뚱한 것을 가리킨다(실제로 그렇게 깨졌다: 200건을 기대한 자리에 * 50건이 나왔다). 트윈을 나누면 각 검사가 자기 창만 본다. */ const MIRROR = 'sync-mirror' /* 붙었는데 아직 안 받음 → 창이 닫히면 동기화 중 */ const BLOCKED = 'sync-blocked' /* 거부가 문턱을 넘음 */ const STALE = 'sync-stale' /* 오래 끊김 */ const SIM = 'sync-sim' const MIRRORS = [MIRROR, BLOCKED, STALE] /** * 이 검사가 쓰는 창의 시작점. * * 창 하나를 닫고도 **읽는 시점이 다음 창 안에** 있어야 한다. `1.4창` 전에서 시작해 `창+1` 에서 닫으면 * 읽을 때까지 `0.4창`밖에 지나지 않아 두 번째 창이 닫히지 않는다 — 정확히 `2창` 전으로 잡았을 때 * 밀리초 몇 개 때문에 한 창이 더 닫혀 깨졌다. */ const windowStart = () => Date.now() - Math.floor(INGEST_WINDOW_MS * 1.4) const master = (source: string): any => ({ source, system: 'wms', siteName: '동기화 검사 창고', space: { width: 40, depth: 20, unit: 'm' }, locations: [ { id: 'dock-recv', type: 'dock', role: 'location', name: '입고 도크', capacity: 100 }, { id: 'bin-1', type: 'storage', role: 'location', name: '빈1', capacity: 20 } ], equipment: [{ id: 'fk-1', kind: 'forklift', homeLocationId: 'dock-recv' }] }) /** 거부 하나 — 사유는 커널이 주는 모양을 그대로 흉내낸다(레코드 원문 포함). */ const reject = (why: string) => ({ errors: [why], record: { epc: 'urn:epc:id:sgtin:0614141.107346.2018', bizStep: '?' } }) 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) registerLiveFeedProbe(() => [...feedUp]) const d = await ds.getRepository(Domain).save({ name: 'sync-test', subdomain: 'sync-test' } as any) domainId = d.id const o = await ds.getRepository(Domain).save({ name: 'sync-other', subdomain: 'sync-other' } as any) otherDomainId = o.id for (const id of [...MIRRORS, SIM]) await TwinEngine.ingestMaster(domainId, master(id), 'reset') }) after(async () => { for (const id of [...MIRRORS, SIM]) await TwinEngine.stop(domainId, id).catch(() => {}) await ds?.destroy() }) /** 미러로 세우고 피드를 붙인 상태로 만든다 — 각 검사가 자기 트윈을 자기 손으로 세운다. */ async function bootMirror(instanceId: string): Promise { const rt = await TwinEngine.bootDeclared(domainId, instanceId, 'resync') assert.equal(rt.mode, 'live', `${instanceId} 는 미러로 떠야 이 검사가 뜻이 있다`) feedUp.add(instanceId) } test('시뮬 트윈은 「해당 없음」이다 — 물을 대상이 아닌 것에 경고색을 주지 않는다', async () => { const rt = await TwinEngine.bootDeclared(domainId, SIM, 'reset') assert.equal(rt.mode, 'sim', '시뮬로 떠야 이 검사가 뜻이 있다') assert.equal(TwinEngine.ingestHealthOf(domainId, SIM).verdict, 'not-applicable') }) test('미러가 붙었지만 아직 아무것도 안 받았으면 「모름」이다 — 정상도 비정상도 아니다', async () => { await bootMirror(MIRROR) const v = TwinEngine.ingestHealthOf(domainId, MIRROR) assert.equal(v.verdict, 'unknown') assert.equal(v.lastAt, null, '받은 적이 없다 — 0 이 아니다') assert.equal(v.total.acceptedRatio, null, '비율이 없다 — 1 이 아니다') }) test('창이 닫히면 「동기화 중」이 되고, 통과와 거부를 함께 낸다', () => { const t0 = windowStart() TwinEngine.recordIngestResult(domainId, MIRROR, 200, [reject('unknown bizStep "?"')], t0) /* 창을 닫는다 — 창은 **다음 유입이 있을 때** 닫힌다. */ TwinEngine.recordIngestResult(domainId, MIRROR, 50, [], t0 + INGEST_WINDOW_MS + 1) const v = TwinEngine.ingestHealthOf(domainId, MIRROR) assert.equal(v.verdict, 'syncing') assert.equal(v.recent.offered, 200) assert.equal(v.recent.rejected, 1) assert.equal(v.recent.accepted, 199) assert.equal(v.recent.acceptedRatio, 0.995) assert.equal(v.total.offered, 250, '누적은 두 창을 합한다') }) test('거부가 문턱을 넘으면 「매핑이 막고 있음」이고, 사유와 레코드 원문이 함께 나온다', async () => { await bootMirror(BLOCKED) const t0 = windowStart() TwinEngine.recordIngestResult( domainId, BLOCKED, 100, Array.from({ length: 40 }, () => reject('unknown location "A-99"')), t0 ) TwinEngine.recordIngestResult(domainId, BLOCKED, 1, [], t0 + INGEST_WINDOW_MS + 1) const v = TwinEngine.ingestHealthOf(domainId, BLOCKED) assert.equal(v.verdict, 'mapping-blocked') assert.equal(v.recent.acceptedRatio, 0.6, '100건 중 40건이 거부됐다') const reason = v.reasons.find((r: any) => r.reason === 'unknown location "A-99"') assert.ok(reason, '사유를 묶어서 낸다') assert.equal(reason.count, 40) /* 표본은 커널이 낸 사유를 그대로 들고 있다(`errors`) — 우리가 다시 쓰지 않는다. */ const sample = v.samples.find((s: any) => s.errors.includes('unknown location "A-99"')) assert.ok(sample, '사유만으로는 원천이 무엇을 보냈는지 알 수 없다 — 원문이 있어야 고친다') assert.match(sample.record, /urn:epc:id:sgtin/, '레코드 원문이 그대로 들어 있다') }) test('오래 끊긴 미러는 「유입 없음」이다 — 건강했던 옛 창으로 초록을 내지 않는다', async () => { /* * 이것이 이 기능이 있는 이유다. 창은 다음 유입이 있을 때 닫히므로, 유입이 멈추면 「최근 창」은 멈추기 * 직전의 건강했던 창으로 남는다. 그 창만 보면 어제 죽은 피드가 오늘도 「동기화 중」이다. */ await bootMirror(STALE) const long = Date.now() - STALE_AFTER_MS * 5 TwinEngine.recordIngestResult(domainId, STALE, 1000, [], long) TwinEngine.recordIngestResult(domainId, STALE, 1000, [], long + INGEST_WINDOW_MS + 1) // 건강한 창을 닫는다 const v = TwinEngine.ingestHealthOf(domainId, STALE) assert.equal(v.verdict, 'no-traffic', '마지막 유입이 한 창보다 오래 전이다') assert.equal(v.recent.acceptedRatio, 1, '(옛 창 자체는 건강했다 — 그래서 창만 보면 속는다)') }) test('피드가 떨어진 미러는 「연결 안 됨」이다 — 「유입 없음」과 처방이 다르다', () => { /* 「유입 없음」은 원천·망을 보라는 말이고, 「연결 안 됨」은 결선하라는 말이다. 섞으면 엉뚱한 곳을 본다. */ feedUp.delete(MIRROR) assert.equal(TwinEngine.ingestHealthOf(domainId, MIRROR).verdict, 'not-connected') feedUp.add(MIRROR) }) test('멈춘 미러는 「해당 없음」이다 — 고치려고 멈춰 둔 트윈에 「연결 안 됨」이라 하지 않는다', async () => { await TwinEngine.stop(domainId, MIRROR) const v = TwinEngine.ingestHealthOf(domainId, MIRROR) assert.equal(v.verdict, 'not-applicable') assert.ok(v.total.offered > 0, '멈춰도 장부는 남는다 — 마지막에 무엇이 거부됐는지 볼 수 있어야 한다') }) test('멈춘 트윈에 계속 들어오면 「버려지는 중」이라 말한다 — 실제로 겪은 거짓 안심', async () => { /* * 겪은 모습: 트윈이 멈췄는데 피드는 살아 레코드를 나르고, 커널은 알리지 않고 버렸다. 화면은 「해당 없음 · * 150 통과 · 100%」였다. 여기서는 그 상황을 그대로 만들어 판정과 수치를 확인한다. */ /* 사람이 트윈을 멈춘다 — 그런데 피드는 남는다. 그 조합이 사실을 잃게 만든다. */ await TwinEngine.stop(domainId, STALE) assert.equal(TwinEngine.instances[`${domainId}:${STALE}`], undefined, '멈췄다') /* 커널이 받지 않는다 — `ingestLive` 가 0 을 돌려준다. */ const applied = TwinEngine.ingestLive(domainId, STALE, [{ any: 'envelope' } as any]) assert.equal(applied, 0, '멈춘 트윈은 봉투를 받지 않는다') TwinEngine.recordIngestResult(domainId, STALE, 10, [], Date.now(), 10) const v = TwinEngine.ingestHealthOf(domainId, STALE) assert.equal(v.verdict, 'twin-not-running', '「해당 없음」으로 덮지 않는다') assert.equal(v.total.undelivered >= 10, true, '버려진 수를 센다') }) test('장부는 도메인으로 갈린다 — 같은 instanceId 라도 남의 것을 집지 않는다', () => { const v = TwinEngine.ingestHealthOf(otherDomainId, MIRROR) assert.equal(v.total.offered, 0, '다른 도메인에는 이 트윈의 장부가 없다') assert.equal(v.total.acceptedRatio, null) assert.equal(v.samples.length, 0, '남의 레코드 원문이 새지 않는다') }) test('목록 요약은 숫자만 낸다 — 원문을 스무 벌 실어 조회를 무겁게 하지 않는다', () => { const brief = TwinEngine.ingestHealthBrief(domainId, [MIRROR, SIM]) assert.equal(brief.length, 2) const m = brief.find((b: any) => b.instanceId === MIRROR) assert.ok(m, '멈춘 트윈도 목록에 남는다') assert.equal((m as any).samples, undefined, '표본은 목록에 없다') assert.equal((m as any).reasons, undefined, '사유도 목록에 없다') assert.ok(Array.isArray((m as any).trend), '스파크라인용 추이는 있다') const s = brief.find((b: any) => b.instanceId === SIM) assert.equal((s as any).verdict, 'not-applicable') assert.equal((s as any).acceptedRatio, null, '시뮬은 비율이 없다 — 0 이 아니다') })