/* * ★ 사건 하나가 무엇을 가리키는지 뽑는다 — 360 이력이 64초인 원인을 없애는 첫 조각. * * ── 왜 이 모양인가 (2026-08-27 실측) ──────────────────────────────────────── * 「이 품목의 이력」 조회가 64초다. 저장된 식별자에 품목과 로트가 한 문자열로 붙어 있어서, 품목만으로는 * 찾을 방법이 없다. * * https://seunghwa.net/class/product-M0000618-lot-L2604100071 * * 문자열을 자르는 규칙을 여기에 넣지 않는다 — 어느 부호가 로트를 가리키는지 연결된 시스템마다 다르고, * chef 의 `-lot-` 을 굳히면 이 파일이 그 시스템 위에 서게 된다. 대신 대상마다 한 줄을 적고, 품목이 * 무엇인지는 그 트윈의 선언과 견주어 부르는 쪽이 답한다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { subjectsOf, SUBJECT_KINDS } from '../server/service/twin-subject/event-subjects.js' const objectEvent = (over: any = {}) => ({ eventType: 'epcis.ObjectEvent', eventTime: '2026-08-27T05:00:00.000Z', data: { epcList: ['https://seunghwa.net/class/product-M0000438-lot-L2403090386'], bizLocation: { id: 'M2R0001' }, action: 'OBSERVE', ...over } }) const find = (subjects: any[], kind: string) => subjects.filter(s => s.kind === kind).map(s => s.id) test('★ 품목과 로트를 두 줄로 적는다 — 문자열을 자르지 않고도 품목으로 찾을 수 있다', () => { const subjects = subjectsOf(objectEvent(), { /* 선언과 견주어 품목을 찾는 일은 부르는 쪽이 한다 — 그 트윈의 선언을 아는 것이 그쪽이다. */ itemOf: epc => (epc.includes('product-M0000438') ? 'M0000438' : null) }) assert.deepEqual(find(subjects, 'lot'), ['https://seunghwa.net/class/product-M0000438-lot-L2403090386']) assert.deepEqual(find(subjects, 'item'), ['M0000438']) }) test('★ 품목을 찾지 못하면 품목 줄을 만들지 않는다 — 짐작으로 이으면 남의 이력에 섞인다', () => { const subjects = subjectsOf(objectEvent(), { itemOf: () => null }) assert.equal(find(subjects, 'lot').length, 1, '개체는 그대로 적는다') assert.deepEqual(find(subjects, 'item'), [], '품목은 비운다') }) test('★ 찾은 값이 개체 식별자와 같으면 품목이 아니다 — itemRefOf 는 URI 를 그대로 돌려준다', () => { /* * `itemRefOf` 는 SGTIN 이 아닌 모양에서 문자열을 그대로 낸다(자르는 판단을 하지 않으므로). 그 값을 * 품목으로 적으면 로트와 같은 줄이 두 종류로 생기고, 품목 이력이 로트 이력과 같아진다. */ const epc = 'https://seunghwa.net/class/product-M0000438-lot-L2403090386' const subjects = subjectsOf(objectEvent(), { itemOf: () => epc }) assert.deepEqual(find(subjects, 'item'), []) }) test('★ 한 사건이 여러 대상을 가리킨다 — 자리·설비·오더·작업', () => { const subjects = subjectsOf({ eventType: 'task.status', eventTime: '2026-08-27T05:00:00.000Z', data: { taskId: 'tk-1', moverId: 'mixer-1', toNode: 'M2R0001', orderId: 'ORD-9' } }) assert.deepEqual(find(subjects, 'task'), ['tk-1']) assert.deepEqual(find(subjects, 'equipment'), ['mixer-1']) assert.deepEqual(find(subjects, 'order'), ['ORD-9']) assert.ok(find(subjects, 'location').length <= 1, '자리는 한 줄이다') }) test('★ 가리키는 것이 없으면 빈 배열이다 — 없는 대상을 만들지 않는다', () => { const subjects = subjectsOf({ eventType: 'energy.demand.window', data: { maxKW: 3 } }) assert.deepEqual(subjects, []) }) test('★ 작업이 오간 두 자리를 다 적는다 — 승격 컬럼은 둘 중 하나만 담을 수 있어 비워 둔다', () => { /* * `locationOf` 는 EPCIS 자리만 읽는다. 작업이 A→B 로 갈 때 컬럼 하나에 둘을 넣을 수 없으므로 * 아무것도 넣지 않는다. 이 표는 자리마다 줄이 하나라서 둘을 함께 담는다. */ const subjects = subjectsOf({ eventType: 'task.status', data: { taskId: 'tk-1', fromNode: 'M2R0001', toNode: 'M2R0002' } }) assert.deepEqual(find(subjects, 'location'), ['M2R0001', 'M2R0002']) }) test('★ 같은 대상을 두 번 적지 않는다 — 출발지와 도착지가 같을 수 있다', () => { const subjects = subjectsOf({ eventType: 'task.status', data: { taskId: 'tk-1', fromNode: 'M2R0001', toNode: 'M2R0001' } }) assert.deepEqual(find(subjects, 'location'), ['M2R0001']) }) test('종류 목록이 닫혀 있다 — 열어 두면 넣는 쪽마다 다른 이름을 쓴다', () => { /* `transaction` 은 2026-09-05 에 늘었다 — 거래가 오더 칸에 섞여 들어오던 것을 갈랐다. */ assert.deepEqual([...SUBJECT_KINDS], ['item', 'lot', 'order', 'location', 'equipment', 'task', 'transaction']) }) test('너무 긴 식별자는 적지 않는다 — 자르면 그 줄이 다른 대상을 가리킨다', () => { const long = 'x'.repeat(300) const subjects = subjectsOf({ eventType: 'task.status', data: { taskId: long } }) assert.deepEqual(find(subjects, 'task'), [], '알리지 않고 자르지 않고 아예 만들지 않는다') }) test('빈 값과 공백은 대상이 아니다 — 결측을 대상으로 만들지 않는다', () => { const subjects = subjectsOf({ eventType: 'task.status', data: { taskId: ' ', moverId: '' } }) assert.deepEqual(subjects, []) }) test('★ 클래스로 온 사건은 품목 줄만 만든다 — 개체가 없는 사건이다', () => { /* * 실측한 chef 의 사건이다. 무게로 담는 현장이라 개체 목록이 비어 있고 수량 목록에 클래스가 있다. * 그것을 로트로 적으면 뜻이 틀린다 — 개체가 아니라 품목이다. */ const subjects = subjectsOf({ eventType: 'epcis.ObjectEvent', data: { epcList: [], quantityList: [{ epcClass: 'https://seunghwa.net/class/product-M0000879', quantity: 11797.6, uom: 'KGM' }], bizLocation: { id: 'P2F0001' } } }) assert.deepEqual(find(subjects, 'lot'), [], '개체 줄을 만들지 않는다') assert.deepEqual(find(subjects, 'item'), ['https://seunghwa.net/class/product-M0000879']) assert.deepEqual(find(subjects, 'location'), ['P2F0001']) }) test('★ 클래스도 선언과 견주어 품목 코드로 줄인다 — 선언이 답하면 그것을 쓴다', () => { const subjects = subjectsOf( { eventType: 'epcis.ObjectEvent', data: { epcList: [], quantityList: [{ epcClass: 'https://seunghwa.net/class/product-M0000879' }] } }, { itemOf: epc => (epc.includes('M0000879') ? 'M0000879' : null) } ) assert.deepEqual(find(subjects, 'item'), ['M0000879']) }) test('개체와 클래스가 함께 오면 둘 다 적는다 — 한 사건이 둘을 가리킬 수 있다', () => { const subjects = subjectsOf({ eventType: 'epcis.ObjectEvent', data: { epcList: ['https://seunghwa.net/class/product-M0000438-lot-L1'], quantityList: [{ epcClass: 'https://seunghwa.net/class/product-M0000879' }] } }) assert.deepEqual(find(subjects, 'lot'), ['https://seunghwa.net/class/product-M0000438-lot-L1']) assert.deepEqual(find(subjects, 'item'), ['https://seunghwa.net/class/product-M0000879']) })