/* * ★ 여러 종류를 시각순으로 — 거르는 축과 정렬 축이 어긋나면 색인이 버려진다. * * ── 실측 (2026-08-28 · 승화푸드 저널 3,374,894행) ─────────────────────────── * 종류 8개 IN + 시각순 50건 SEARCH USING INDEX ix_twin_event_1 (domain, instance) 37.181초 · 0건 * 같은 조건 · 정렬 없음 SEARCH USING INDEX ix_twin_event_7 (…, event_type=?) 0.003초 * 종류 하나 + 시각순 SEARCH USING INDEX ix_twin_event_7 (…, event_type=?) 0.003초 * * 정렬을 붙이는 순간 여러 종류의 순서를 합칠 방법이 없어 시각 색인으로 337만 행을 걸으며 행마다 종류를 * 확인한다. 그 트윈에 에너지 사건은 **0건**이다 — 없는 것을 찾느라 37초를 쓴다. * * 360 이력이 64초였던 것과 같은 부류다. 고침도 같은 성질이다: **있는 색인을 쓰게 한다.** */ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { join } from 'node:path' import { splitShape } from '../server/service/twin-journal/event-type-split-shape.js' const dir = (...p: string[]) => join(import.meta.dirname, '..', 'server', ...p) const split = readFileSync(dir('service', 'twin-journal', 'event-type-split.ts'), 'utf8') const shape = readFileSync(dir('service', 'twin-journal', 'event-type-split-shape.ts'), 'utf8') const journal = readFileSync(dir('service', 'twin-journal', 'twin-journal-query.ts'), 'utf8') const DESC = [{ name: 'eventTime', desc: true }] const ENERGY = ['energy.measured', 'energy.equipment', 'energy.demand.window', 'energy.peak'] test('★ 에너지 원장이 보내는 모양이 이 갈래로 간다', () => { const shape = splitShape({ filters: [{ name: 'eventType', operator: 'in', value: ENERGY }] }, DESC) assert.ok(shape) assert.deepEqual(shape!.eventTypes, ENERGY) }) test('시간창이 함께 와도 간다 — 화면이 구간을 지정한다', () => { const shape = splitShape( { filters: [ { name: 'eventType', operator: 'in', value: ENERGY }, { 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[]][] = [ ['종류가 하나', { filters: [{ name: 'eventType', operator: 'in', value: ['a'] }] }, DESC], ['종류가 너무 많다', { filters: [{ name: 'eventType', operator: 'in', value: Array.from({ length: 13 }, (_, i) => `t${i}`) }] }, DESC], ['종류 필터가 없다', { filters: [] }, DESC], ['정확일치', { filters: [{ name: 'eventType', operator: 'eq', value: 'a' }] }, DESC], ['제외 목록', { filters: [{ name: 'eventType', operator: 'notin_with_null', value: ENERGY }] }, DESC], ['자유 검색어', { search: '전력', filters: [{ name: 'eventType', operator: 'in', value: ENERGY }] }, DESC], ['다른 필터가 섞였다', { filters: [{ name: 'eventType', operator: 'in', value: ENERGY }, { name: 'epc', operator: 'search', value: 'x' }] }, DESC], ['리비전 정렬', { filters: [{ name: 'eventType', operator: 'in', value: ENERGY }] }, [{ name: 'revision', desc: true }]], ['오름차순', { filters: [{ name: 'eventType', operator: 'in', value: ENERGY }] }, [{ name: 'eventTime', desc: false }]], ['못 읽는 시각', { filters: [{ name: 'eventType', operator: 'in', value: ENERGY }, { name: 'eventTime', operator: 'lte', value: '어제' }] }, DESC] ] for (const [why, params, sortings] of cases) { assert.equal(splitShape(params, sortings), null, `${why} 는 일반 경로로 가야 한다`) } }) test('★ 종류를 정확일치로 묻는다 — 이 한 줄이 색인을 쓰게 한다', () => { assert.match(split, /andWhere\('e\.eventType = :eventType', \{ eventType \}\)/) assert.doesNotMatch(split, /eventType IN/, '나눈 뒤에 다시 여러 값으로 묻지 않는다') /* 정렬 축이 색인의 마지막 칸과 같아야 한다. */ assert.match(split, /orderBy\('e\.eventTime', 'DESC'\)/) }) test('★ 한 갈래라도 못 읽으면 반쪽을 전부인 것처럼 내지 않는다', () => { assert.match(split, /if \(!page\) return null/) }) test('★ 이어 읽기 지점을 모든 갈래에 같이 건다', () => { /* 갈래마다 다르게 걸면 어떤 종류는 이미 지나간 구간을 다시 낸다. */ assert.match(split, /e\.eventTime < :curAt OR \(e\.eventTime = :curAt AND e\.\$\{tiebreak\} < :curTie\)/) assert.match(split, /if \(!cursor\) return null/, '못 읽는 커서로는 답하지 않는다') }) test('★ 나눌 수를 제한한다 — 나누는 것이 손해가 되는 지점이 있다', () => { assert.match(shape, /export const MAX_SPLIT = 24/) assert.match(split, /if \(!pairs \|\| pairs > MAX_SPLIT\) return null/) }) test('★ 화면이 부르는 자리에 붙어 있다', () => { assert.match(journal, /const split = splitShape\(effective, effective\.sortings \?\? \[\]\)/) assert.match(journal, /await eventTypeSplitPage\(domain\.id, instanceIds, split, limit, tiebreak, effective\.pagination\?\.after\)/) /* 파생 테이블 갈래보다 뒤, 일반 저널 질의보다 앞이어야 한다. */ assert.ok(journal.indexOf('readModelPage(domain.id') < journal.indexOf('splitShape(effective')) assert.ok(journal.indexOf('splitShape(effective') < journal.indexOf('const qb = getQueryBuilderFromListParams')) }) test('★ 걷을 조건이 적혀 있다 — 부르는 데가 없어지면 지운다', () => { /* * 앱에서 종류를 여러 값으로 보내는 곳은 에너지 원장 한 곳뿐이다. 그 화면이 종류를 하나씩 고르는 * 모양으로 바뀌면 이 갈래는 아무도 부르지 않는다 — 그때 지워야 한다. 그 조건을 코드에 적어 두지 * 않으면 아무것도 하지 않는 갈래가 무언가를 받쳐 주는 것처럼 남는다. */ assert.match(split, /이 갈래를 걷을 조건/) assert.match(split, /그때 \*\*지운다\.\*\*/) }) /* ── 공정 단계 칩 (2026-08-29) ───────────────────────────────────────────── * * ★ 칩을 고른 순간 이 갈래가 꺼져서, **고르지 않은 쪽이 더 빨랐다.** * * 원장은 종류 넷을 담아 묻고 그 위에 단계 칩을 겹친다. 앞 버전은 `bizStep` 필터를 만나면 모양을 * 알아보지 못해 예전 경로로 떨어졌다 — 좁혀 물으면 느려지는 화면이 된다. */ test('공정 단계 하나로 좁혀도 갈래 경로가 답한다', () => { const shape = splitShape( { filters: [ { name: 'eventType', operator: 'in', value: ['epcis.ObjectEvent', 'epcis.AggregationEvent'] }, { name: 'bizStep', operator: 'eq', value: 'shipping' } ] } as any, [{ name: 'eventTime', desc: true }] as any ) assert.ok(shape, '이 모양을 알아봐야 한다') assert.equal(shape?.bizStep, 'shipping') assert.deepEqual(shape?.eventTypes, ['epcis.ObjectEvent', 'epcis.AggregationEvent']) }) test('단계를 여러 값으로 주면 답하지 않는다 — 갈래가 곱해진다', () => { const shape = splitShape( { filters: [ { name: 'eventType', operator: 'in', value: ['epcis.ObjectEvent', 'epcis.AggregationEvent'] }, { name: 'bizStep', operator: 'in', value: ['shipping', 'receiving'] } ] } as any, [{ name: 'eventTime', desc: true }] as any ) assert.equal(shape, null) }) test('빈 단계 값은 받지 않는다 — 모든 행을 뜻하는지 없는 단계를 뜻하는지 알 수 없다', () => { const shape = splitShape( { filters: [ { name: 'eventType', operator: 'in', value: ['epcis.ObjectEvent', 'epcis.AggregationEvent'] }, { name: 'bizStep', operator: 'eq', value: ' ' } ] } as any, [{ name: 'eventTime', desc: true }] as any ) assert.equal(shape, null) }) /* * ── 목록과 건수가 같은 축을 본다 (2026-09-19) ─────────────────────────────── * * 빠른 갈래는 건수를 **다른 빌더**로 센다. 그 빌더가 검색 축을 빈 배열로 선언하고 있었다 — 목록은 * 걸러지고 건수는 안 걸러지는 모양이다(UX 레인이 검색 길을 세다 짚었다). * * **지금 그 일은 일어날 수 없다**: 검색어가 있으면 빠른 갈래를 아예 안 탄다(위 「자유 검색어」가 그 * 축을 붙든다). 그래서 이것은 산 결함이 아니라 **잠긴 함정**이고, 두 자리가 같은 상수를 보게 해서 * 닫는다 — 저 규칙이 언젠가 느슨해지면 그때 조용히 갈릴 자리다. */ test('★ 건수 probe 와 목록이 같은 검색 축을 쓴다 — 한쪽만 걸러지면 화면이 「한 건 / 전체 수」를 말한다', () => { const uses = [...journal.matchAll(/searchables:\s*([^,\n]+)/g)].map(m => m[1].trim()) assert.ok(uses.length >= 2, '검색 축을 선언하는 자리가 둘이어야 한다(목록 · 건수 probe)') assert.deepEqual( [...new Set(uses)], ['[...JOURNAL_SEARCHABLES]'], '두 자리가 같은 상수를 봐야 한다 — 배열을 따로 적으면 한쪽만 늘어난다' ) assert.doesNotMatch(journal, /searchables:\s*\[\]/, '빈 목록은 「검색어를 조용히 버린다」는 뜻이다') })