/* * ★ 저널 검색 키 승격 검증 — payload(JSON) 안의 값을 인덱스 가능한 실컬럼으로 꺼내는 규칙. * * 이 규칙이 틀리면 "저널에는 있는데 검색으로는 안 나오는 이벤트" 가 생긴다. 화면에서는 그냥 * **없는 것으로 보인다** — 저널에서 가장 나쁜 결함이라 규칙을 테스트로 고정한다. */ import { test } from 'node:test' import assert from 'node:assert/strict' /* * **빌드 산출물을 부른다** — 이 모듈은 로그 문(`engine/log.js`)을 값으로 참조하고, 타입만 지우고 소스를 * 실행하는 방식은 그 `.js` 경로를 `.ts` 로 바꿔 주지 않는다. 소스를 부르면 「모듈을 찾을 수 없다」로 선다. * **소스를 고친 뒤에는 `npm run build:server` 를 먼저 돌린다.** */ import { createRequire } from 'node:module' const req = createRequire(import.meta.url) const { bizStepOf, epcOf, orderOf, bizTransactionOf, locationOf, equipmentIdOf, twinEventKeys } = req('../dist-server/service/twin-event/twin-event-keys.js') /** EPCIS ObjectEvent 한 건(입고) — 실제 인제스트가 싣는 모양. */ const EPCIS_OBJECT = { eventType: 'epcis.ObjectEvent', eventTime: '2026-07-31T02:10:00.000Z', data: { bizStep: 'urn:epcglobal:cbv:bizstep:receiving', disposition: 'urn:epcglobal:cbv:disp:in_progress', epcList: ['urn:epc:id:sgtin:0614141.107346.2018'], readPoint: { id: 'urn:epc:id:sgln:0614141.00777.DOCK-3' }, bizLocation: { id: 'urn:epc:id:sgln:0614141.00888.0' }, bizTransactionList: [{ type: 'urn:epcglobal:cbv:btt:po', bizTransaction: 'urn:epc:id:gdti:0614141.402.2' }] } } test('EPCIS 이벤트에서 축이 모두 뽑히고, 거래는 거래 칸에 든다', () => { const k = twinEventKeys(EPCIS_OBJECT) assert.equal(k.bizStep, 'receiving') assert.equal(k.epc, 'urn:epc:id:sgtin:0614141.107346.2018') assert.equal(k.locationId, 'urn:epc:id:sgln:0614141.00777.DOCK-3') /* * ── 이 기대가 옮겨졌다 (2026-09-05 사용자 결정) ──────────────────────────── * 전에는 이 GDTI 가 `orderId` 로 뽑혔다. 그래서 한 오더의 이력이 색인 안에서 두 쪽으로 * 갈라졌다 — 운영 델타는 `order-1`, EPCIS 는 GDTI. 이제 칸이 둘이다. */ assert.equal(k.bizTransaction, 'urn:epc:id:gdti:0614141.402.2') assert.equal(k.orderId, undefined, '거래 식별자가 오더 칸에 앉았다') }) test('식별자는 끝마디가 아니라 전체를 저장한다 — 부분일치 검색이 양방향으로 성립해야 한다', () => { const k = twinEventKeys(EPCIS_OBJECT) /* 화면은 '2018' 로 축약해 보여주지만 색인은 원본을 갖는다. contains 검색이라 * 끝마디로도 URN 전체로도 찾힌다 — 끝마디만 저장하면 URN 경로가 사라진다. */ assert.ok(k.epc!.includes('2018'), '끝마디로 찾을 수 있어야 한다') assert.ok(k.epc!.startsWith('urn:epc:id:sgtin'), 'URN 전체로도 찾을 수 있어야 한다') }) test('readPoint 가 없으면 bizLocation 으로 내려간다', () => { const ev = { ...EPCIS_OBJECT, data: { ...EPCIS_OBJECT.data, readPoint: undefined } } assert.equal(locationOf(ev), 'urn:epc:id:sgln:0614141.00888.0') }) test('bizStep 이 없으면 이벤트 타입에서 유추한다 — 운영 델타도 단계를 말할 수 있어야 한다', () => { assert.equal(bizStepOf({ eventType: 'task.status', data: { task: 't1' } }), 'task.status') assert.equal(bizStepOf({ eventType: 'epcis.AggregationEvent', data: {} }), 'AggregationEvent') }) test('a private business step is indexed by its short name, like a CBV one', () => { /* `split(':')` on `https://hatiolab.com/voc/bizstep/consuming` kept `//hatiolab.com/voc/bizstep/consuming` — a journal search for `consuming` found nothing. The rule is the contract's bizStepName. */ assert.equal(bizStepOf({ data: { bizStep: 'https://hatiolab.com/voc/bizstep/consuming' } }), 'consuming') assert.equal(bizStepOf({ data: { bizStep: 'urn:epcglobal:cbv:bizstep:shipping' } }), 'shipping') }) test('집합 이벤트는 parentID 를, 수량 이벤트는 epcClass 를 품목으로 쓴다', () => { assert.equal(epcOf({ data: { parentID: 'urn:epc:id:sscc:0614141.1234567890' } }), 'urn:epc:id:sscc:0614141.1234567890') assert.equal(epcOf({ data: { quantityList: [{ epcClass: 'urn:epc:idpat:sgtin:0614141.107346.*', quantity: 40 }] } }), 'urn:epc:idpat:sgtin:0614141.107346.*') }) test('★ 운영 델타의 오더는 `orderId` 다 — 이 시험이 결함을 고정하고 있었다', () => { /* * ── 무엇이 잘못돼 있었나 (2026-08-24 실측) ──────────────────────────────── * 이 시험은 `data: { order: 'SO-10021' }` 를 단정했다. **커널은 그런 모양을 낸 적이 없다** — * 오더는 `orderId` 다(`OrderStatusDelta.orderId` · `TaskStatusDelta.orderId`). 픽스처가 실제 델타 * 대신 **스스로 지어낸 모양**을 썼고, 추출기도 같은 이름을 찾고 있었으니 둘이 나란히 초록이었다. * * 그동안 실 저널에서: * order.status 29,403,565 행 — order_id 100% 비어 있음 * task.status 191,175 행 — 100% 비어 있음 * * 색인 `ix_twin_event_4`(「이 오더가 어디까지 갔나」)가 자기 용도로 못 쓰였고, 화면은 오더를 눌러도 * 「연결된 이벤트가 없습니다」를 냈다. * * **교훈**: 승격 키의 픽스처는 **커널이 실제로 내는 델타**여야 한다. 지어낸 모양으로 검사하면 * 추출기와 시험이 함께 틀린 채로 영원히 초록이다. */ assert.equal(orderOf({ eventType: 'order.status', data: { orderId: 'WO-260410-0133', status: 'in-progress' } }), 'WO-260410-0133') assert.equal(orderOf({ eventType: 'task.status', data: { taskId: 'TK-1', orderId: 'WO-1' } }), 'WO-1') /* 오더를 스스로 말하면 그 말이 이긴다 — 거래(PO/SO)와 오더는 같은 것이 아닐 수 있다. */ assert.equal( orderOf({ data: { orderId: 'WO-1', bizTransactionList: [{ bizTransaction: 'urn:epc:id:gdti:0614141.402.2' }] } }), 'WO-1' ) /* * ── 이 줄이 뒤집혔다 (2026-09-05 사용자 결정) ────────────────────────────── * 전에는 오더를 말하지 않는 EPCIS 가 **거래로 내려갔다.** 그래서 한 칸에 두 낱말이 들었고, * 한 오더의 이력이 색인 안에서 두 쪽으로 갈라졌다(실측: 어휘가 섞인 행 10,004개). * * 바로 위 줄이 그 위험을 이미 알고 있었다 — 「거래와 오더는 같은 것이 아닐 수 있다」. 알면서 * 같은 칸에 넣고 있었다. 이제 갈라 든다. */ assert.equal(orderOf({ data: { bizTransactionList: [{ bizTransaction: 'PO-9' }] } }), undefined) assert.equal(bizTransactionOf({ data: { bizTransactionList: [{ bizTransaction: 'PO-9' }] } }), 'PO-9') }) test('★ 승격 키는 **커널이 실제로 내는 델타 모양**으로 검사한다', () => { /* * 위 결함의 부류는 「추출기가 찾는 이름을 발신자가 쓰지 않는다」이고, 지어낸 픽스처로는 절대 잡히지 * 않는다. 그래서 실 저널에서 뜬 payload 모양을 그대로 놓고 단정한다 — 세 축이 실제로 채워지는지. */ const orderDelta = { eventType: 'order.status', data: { orderId: 'order-1', kind: 'workorder', status: 'in-progress' } } const taskDelta = { eventType: 'task.status', data: { taskId: 'task-1', orderId: 'order-1', kind: 'cut', status: 'in-progress', resourceRef: 'cutter-1', fromNode: 'st-a', toNode: 'st-b' } } const energyEq = { eventType: 'energy.equipment', data: { equipmentId: 'pv-roof', at: '2026-08-17T10:45:20.664Z', generatedKW: 426 } } assert.equal(twinEventKeys(orderDelta).orderId, 'order-1', '오더 사건이 오더 축을 채운다') assert.equal(twinEventKeys(taskDelta).orderId, 'order-1', '작업도 자기 오더를 채운다 — 오더의 이력에 작업이 실린다') assert.equal(twinEventKeys(taskDelta).moverId, 'cutter-1', '작업이 쓴 자원이 설비 축에 실린다(`resourceRef`)') assert.equal(twinEventKeys(energyEq).moverId, 'pv-roof', '에너지 사건의 설비가 설비 축에 실린다(`equipmentId`)') }) test('없는 값은 빈 문자열이 아니라 undefined — 결측과 빈값을 섞지 않는다', () => { const k = twinEventKeys({ eventType: 'equipment.status', data: { equipment: 'fork-1', status: 'idle' } }) assert.equal(k.epc, undefined) assert.equal(k.orderId, undefined) assert.equal(k.locationId, undefined) assert.equal(k.moverId, undefined) /* bizStep 은 이벤트 타입에서 유추되므로 남는다 — 이건 결측이 아니다. */ assert.equal(k.bizStep, 'equipment.status') }) test('설비·무버 축 — 운영 델타의 moverId 를 뽑는다', () => { const ev = { eventType: 'equipment.status', data: { moverId: 'fork-3', status: 'down', location: 'rack-1' } } assert.equal(equipmentIdOf(ev), 'fork-3') const k = twinEventKeys(ev) assert.equal(k.moverId, 'fork-3') /* 개명 세대가 섞인다 — 읽는 쪽이 흡수한다(`moverId` 옛 채널 · `resourceRef` 작업 · `equipmentId` 에너지). */ assert.equal(equipmentIdOf({ data: { resourceRef: 'cutter-1' } }), 'cutter-1') assert.equal(equipmentIdOf({ data: { equipmentId: 'pv-roof' } }), 'pv-roof') assert.equal(equipmentIdOf({ data: { moverId: 'fork-1', resourceRef: 'x' } }), 'fork-1', '앞의 이름이 이긴다') /* 운영 델타는 readPoint/bizLocation 이 없다 — 평범한 location 을 위치로 받는다. * 빠뜨리면 "이 설비가 어디서 무엇을 했나" 가 위치 축에서 통째로 사라진다. */ assert.equal(k.locationId, 'rack-1') }) test('EPCIS 의 readPoint 는 운영 델타의 location 보다 우선한다', () => { assert.equal(locationOf({ data: { readPoint: { id: 'DOCK-1' }, location: 'rack-9' } }), 'DOCK-1') }) test('payload 가 data 로 감싸이지 않은 모양도 받는다', () => { assert.equal(epcOf({ epcList: ['urn:epc:id:sgtin:1.2.3'] }), 'urn:epc:id:sgtin:1.2.3') }) test('상한을 넘는 값은 오류 없이 사라지지 않는다 — 잘리되 경고가 남는다', () => { const long = 'urn:epc:id:sgtin:' + 'x'.repeat(400) const warned: string[] = [] const orig = console.warn console.warn = (...a: unknown[]) => void warned.push(String(a[0])) try { const k = twinEventKeys({ data: { epcList: [long] } }) assert.equal(k.epc!.length, 255) assert.equal(warned.length, 1, '잘렸다는 사실이 어딘가에는 남아야 한다') assert.ok(warned[0].includes('epc')) } finally { console.warn = orig } }) test('빈 봉투에도 터지지 않는다', () => { /* `provenance` 도 이 목록에 있어야 한다 — 봉투가 비어도 「선언된 것인지」는 낼 칸이 있다. */ const empty = { action: undefined, bizStep: undefined, epc: undefined, orderId: undefined, bizTransaction: undefined, locationId: undefined, moverId: undefined, provenance: undefined } assert.deepEqual(twinEventKeys(undefined), empty) assert.deepEqual(twinEventKeys({}), empty) })