/* * ★ 화면이 이미 부르는 문에서 파생 테이블을 쓴다 — 그리고 못 답할 때는 물러난다. * * ── 무엇을 놓쳤던가 (2026-08-28) ──────────────────────────────────────────── * 대상별 사건 목록을 만들고 8,382,017줄을 채웠는데, 360 화면은 예전부터 `twinEventList` 를 부르고 * 있었다. 그래서 조회는 그대로 64초였다. **자리는 있고 길이 없었다** — 가장 자주 나는 결함이다. * * 이 시험이 지키는 것은 그 길이다. * ① 어떤 물음이 파생 테이블로 가는가 — 좁게, 그리고 그 판단이 순수한가 * ② 못 답하는 물음에 억지로 답하지 않는가 * ③ 답할 수 없는 구간을 「사건이 없다」로 그리게 만들지 않는가 * ④ 화면이 부르는 자리에 실제로 붙어 있는가 */ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { join } from 'node:path' import { readModelShape, AXIS_KIND } from '../server/service/twin-subject/subject-query-shape.js' const dir = (...p: string[]) => join(import.meta.dirname, '..', 'server', ...p) const fastPath = readFileSync(dir('service', 'twin-subject', 'twin-subject-fast-path.ts'), 'utf8') const journal = readFileSync(dir('service', 'twin-journal', 'twin-journal-query.ts'), 'utf8') /** 360 화면이 실제로 보내는 모양(§`loadTwinEventPage`) — 축 하나를 부분일치로, 시각 내림차순. */ const panelParams = (axis: string, value: string, extra: any[] = []) => ({ filters: [{ name: axis, operator: 'search', value }, ...extra], sortings: [{ name: 'eventTime', desc: true }] }) const DESC = [{ name: 'eventTime', desc: true }] test('★ 360 화면이 보내는 네 축이 모두 파생 테이블로 간다', () => { /* 이것이 이 작업의 목적이다. 하나라도 빠지면 그 축의 이력은 여전히 64초다. */ const expected: Record = { epc: 'item', orderId: 'order', locationId: 'location', moverId: 'equipment' } for (const [axis, kind] of Object.entries(expected)) { const shape = readModelShape(panelParams(axis, 'V-1'), DESC) assert.ok(shape, `${axis} 축이 파생 테이블로 가야 한다`) assert.equal(shape!.kind, kind) assert.equal(shape!.value, 'V-1') } assert.deepEqual(Object.keys(AXIS_KIND).sort(), ['epc', 'locationId', 'moverId', 'orderId']) }) test('시각 상한(시간여행)이 함께 와도 간다 — 화면이 과거를 볼 때 보내는 모양', () => { const shape = readModelShape(panelParams('epc', 'ITEM-1', [{ name: 'eventTime', operator: 'lte', value: '2026-08-01T00:00:00.000Z' }]), DESC) assert.ok(shape) assert.equal(shape!.untilTime?.toISOString(), '2026-08-01T00:00:00.000Z') }) test('구간으로 물으면 두 끝을 모두 읽는다', () => { const shape = readModelShape( panelParams('locationId', 'BUFFER-CK1', [ { name: 'eventTime', operator: 'between', value: ['2026-08-01T00:00:00.000Z', '2026-08-02T00:00:00.000Z'] } ]), DESC ) assert.ok(shape) assert.equal(shape!.fromTime?.toISOString(), '2026-08-01T00:00:00.000Z') assert.equal(shape!.untilTime?.toISOString(), '2026-08-02T00:00:00.000Z') }) test('★ 맞지 않는 물음은 답하지 않고 저널로 보낸다 — 억지로 답하면 목록이 알리지 않고 달라진다', () => { const cases: [string, any, any[]][] = [ ['자유 검색어', { ...panelParams('epc', 'ITEM-1'), search: '냉동' }, DESC], ['종류 필터', panelParams('epc', 'ITEM-1', [{ name: 'eventType', operator: 'in', value: ['task.status'] }]), DESC], ['상관 식별자', panelParams('epc', 'ITEM-1', [{ name: 'correlationId', operator: 'eq', value: 'C1' }]), DESC], ['축 둘', { filters: [{ name: 'epc', operator: 'eq', value: 'A' }, { name: 'orderId', operator: 'eq', value: 'B' }], sortings: DESC }, DESC], ['축 없음', { filters: [], sortings: DESC }, DESC], ['여러 값(in)', panelParams('epc', 'A').filters && { filters: [{ name: 'epc', operator: 'in', value: ['A', 'B'] }], sortings: DESC }, DESC], ['리비전 정렬', panelParams('epc', 'ITEM-1'), [{ name: 'revision', desc: true }]], ['오름차순 정렬', panelParams('epc', 'ITEM-1'), [{ name: 'eventTime', desc: false }]], ['정렬 둘', panelParams('epc', 'ITEM-1'), [{ name: 'eventTime', desc: true }, { name: 'revision', desc: true }]], ['시각 하한만', panelParams('epc', 'ITEM-1', [{ name: 'eventTime', operator: 'gte', value: '2026-08-01T00:00:00.000Z' }]), DESC], ['못 읽는 시각', panelParams('epc', 'ITEM-1', [{ name: 'eventTime', operator: 'lte', value: '어제' }]), DESC], ['빈 축 값', panelParams('epc', ' '), DESC] ] for (const [why, params, sortings] of cases) { assert.equal(readModelShape(params, sortings), null, `${why} 는 저널로 가야 한다`) } }) test('★ 적지 못한 줄이 있으면 이 경로를 쓰지 않는다 — 그 창에는 구멍이 있다', () => { assert.match( fastPath, /subjectWriteFailures\(domainId, instanceId\) > 0\) return null/, '실패가 세어져 있으면 온전한 쪽(저널)으로 보내야 한다' ) }) test('★ 원시 값을 직접 날짜로 읽지 않는다 — 시간대가 어긋난다', () => { /* * sqlite 가 주는 값은 시간대가 없는 문자열이고, `new Date()` 로 읽으면 한국 시간으로 해석해 UTC 로 * 9시간을 뺀다. 실측: 커서의 시각이 9시간 이르게 나가 이어 읽기가 구간을 건너뛰고 본 줄이 또 나왔다 * (120건 중 15건). 그래서 경계는 엔티티로 받고, 커서의 시각은 저널 줄에서 가져온다. */ /* 경계는 엔티티로 받는다 — 숫자(리비전)는 원시 값이어도 뜻이 변하지 않으므로 날짜에만 걸린다. */ assert.doesNotMatch(fastPath, /getRawOne<\{ at:/, '날짜를 원시 값으로 받던 자리가 남아 있다') assert.match(fastPath, /sub\?\.eventTime instanceof Date/) assert.match(fastPath, /journal\?\.eventTime instanceof Date/) assert.match(fastPath, /row\?\.eventTime instanceof Date/, '커서의 시각은 저널 줄의 날짜여야 한다') }) test('★ 담지 못한 구간을 「사건이 없다」로 그리게 만들지 않는다', () => { /* 경계 아래를 물으면 물러난다. */ assert.match(fastPath, /cursor\.at\.getTime\(\) <= cov\.from\.getTime\(\)\) return null/) assert.match(fastPath, /shape\.untilTime\.getTime\(\) < cov\.from\.getTime\(\)\) return null/) /* * 이 대상의 줄을 다 냈을 때가 가장 위험한 자리다. 커서를 내지 않으면 화면은 「여기서 끝」으로 읽고, * 경계 아래에 저널만 아는 사건이 있으면 그것이 거짓이 된다. */ assert.match(fastPath, /boundaryCursor\(cov\.from\)/, '짧게 끝난 페이지는 경계 아래를 읽을 커서를 내야 한다') assert.match(fastPath, /function boundaryCursor/, '그 커서를 만드는 곳이 있어야 한다') /* 저널의 처음까지 덮을 때만 끝이다. */ assert.match(fastPath, /cov\.full\s*\n?\s*\?\s*undefined/, '덮고 있을 때만 커서를 내지 않는다') }) test('★ 전체 건수와 「더 있나」를 함께 정직하게 낸다 — 화면은 건수를 모를 때 capped 로 판단한다', () => { /* * 화면의 `hasMore` 는 전체 건수를 모르면 `capped` 를 본다. 그래서 「모른다」면서 `capped` 를 내리면 * 목록이 알리지 않고 끝난 것처럼 보인다. 두 칸이 한 판단에서 나와야 한다. */ assert.match(fastPath, /const capped = total === undefined/) assert.match(fastPath, /if \(page\.fullyCovered\)/, '덮고 있을 때만 정확한 건수를 낸다') }) test('★ 화면이 부르는 자리에 붙어 있다 — 새 문을 열어 두고 기다리지 않는다', () => { /* 이 한 줄이 없으면 테이블은 채워지고도 쓰이지 않는다(2026-08-27 에 실제로 그랬다). */ assert.match(journal, /const fast = await readModelPage\(domain\.id, instanceIds\[0\], effective, limit\)/) assert.match(journal, /if \(fast\) return fast/) /* 붙는 자리가 저널 질의를 만들기 **전**이어야 한다 — 뒤에 두면 느린 질의를 이미 치른 뒤다. */ assert.ok( journal.indexOf('readModelPage(domain.id') < journal.indexOf('const qb = getQueryBuilderFromListParams'), '저널 질의를 만들기 전에 물어야 한다' ) /* 트윈 하나일 때만 — 여러 트윈을 합친 목록은 이 테이블의 물음이 아니다. */ assert.match(journal, /if \(instanceIds\.length === 1\) \{\s*\n\s*const fast = await readModelPage/) }) test('품목만 식별자를 되돌린다 — 적는 쪽과 같은 함수로', () => { /* * 화면은 짧게 줄인 품목 식별자를 들고 있고(`net/class/product-M1`) 저장된 것은 전체 식별자다. * 되돌림을 두 벌로 적으면 어느 날 서로 다른 답을 내고, 그때 이력이 빈다. */ assert.match(fastPath, /if \(kind !== 'item'\) return value/) assert.match(fastPath, /itemResolverFor\(domainId, instanceId, det\.model\)/) assert.match(fastPath, /if \(!itemOf\) return null/, '선언을 모르면 짐작하지 않고 물러난다') })