/* * ★ 여러 구간 동시 비교 — **실 DB로 배관을 넘어 본다.** * * 분포 산수는 `kpi-baseline.test.ts`(순수)가 지킨다. 여기서 지키는 것은 **호스트 배관**이다: * 창을 하루씩 뒤로 옮겨 **각 창을 따로 읽는가** · 그 창을 같은 규칙으로 계산하는가 · 지표 값을 정본 표 * (`TWIN_METRIC`)로 꺼내는가 · 기록 없는 날을 결측으로 남기는가. * * 이 배관은 **경계를 넘어야만** 틀리는 것이 드러난다. 실제로 앱의 시뮬 트윈은 저널이 두 시간 남짓이라 * "지난 7일" 을 물어도 과거 창이 모두 비어 있어(정직하게 자리를 거부한다) 분포 경로를 한 번도 밟지 * 못했다. 그래서 여러 날에 걸친 저널을 손으로 심어 그 경로를 통과시킨다. * * 실 엔티티·실 네이밍 전략의 sqlite in-memory 스키마에 **빌드 산출물**을 실행한다. * **소스를 고친 뒤에는 `npm run build:server` 를 먼저 돌려야 반영된다.** */ 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 { computeTwinKpi } = req('../dist-server/engine/index.js') const { TwinEvent } = req('../dist-server/service/twin-event/twin-event.js') const { TwinInstance } = req('../dist-server/service/twin-instance/twin-instance.js') const { DataSource: TypeOrmDataSource } = req('typeorm') let ds: DataSource let domainId: string const INSTANCE = 'baseline-probe' const MS_DAY = 24 * 60 * 60_000 /** 창의 끝 — 고정 시각을 쓴다(테스트가 벽시계에 흔들리지 않게). */ const TO = Date.parse('2026-03-10T10:00:00.000Z') const FROM = TO - 60 * 60_000 /** * 완료 한 건 = 시작 + 완료 두 줄. 소요시간이 짝맞춤에서 나오므로 둘 다 필요하다. * 완료 시각이 창 안에 들면 그 창의 성과로 센다. */ function taskRows(dayBack: number, count: number, workMs: number, revBase: number) { const rows: any[] = [] const endBase = TO - dayBack * MS_DAY - 60_000 // 창 끝 1분 전에 완료 for (let i = 0; i < count; i++) { const taskId = `t-${dayBack}-${i}` const done = endBase - i * 1000 const started = done - workMs rows.push( { domain: { id: domainId }, instanceId: INSTANCE, revision: revBase + i * 2, eventType: 'task.status', eventTime: new Date(started).toISOString(), payload: { data: { taskId, status: 'in-progress', resourceRef: 'fk-1' } } }, { domain: { id: domainId }, instanceId: INSTANCE, revision: revBase + i * 2 + 1, eventType: 'task.status', eventTime: new Date(done).toISOString(), /* 완료 어휘는 **커널이 소유한다**(`foldTaskRecords`) — 'done' 이 아니라 'completed' 다. 처음에 'done' 으로 심었더니 창은 잡히는데 건수가 0 이었다(이 DB 스펙이 그것을 잡았다). */ payload: { data: { taskId, status: 'completed', resourceRef: 'fk-1' } } } ) } return rows } 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) const d = await ds.getRepository(Domain).save({ name: 'baseline-test', subdomain: 'baseline-test' } as any) domainId = d.id await ds.getRepository(TwinInstance).save({ domain: { id: domainId }, instanceId: INSTANCE, kind: 'wms', restartPolicy: 'reset', /* 선언은 이제 필수 — 비워 두면 읽는 자리마다 기본값을 고른다 */ purpose: 'operational', status: 'stopped', model: { locations: [{ id: 'dock', type: 'dock', capacity: 10 }], equipment: [{ id: 'fk-1', kind: 'forklift', homeLocation: 'dock' }] } } as any) /* * 지금 창 20건 · 어제 40건 · 그제 60건 · **3일 전은 기록 없음** · 4일 전 80건. * 3일 전을 비워 두는 것이 이 스펙의 요점이다 — 그 날이 결측으로 남고 분포에서 빠지는지 본다 * (알리지 않고 빼면 "지난 4일" 이 실제로는 3일이었다는 것을 아무도 모른다). */ const rows = [ ...taskRows(0, 20, 30_000, 1000), ...taskRows(1, 40, 40_000, 3000), ...taskRows(2, 60, 50_000, 6000), ...taskRows(4, 80, 60_000, 9000) ] await ds.getRepository(TwinEvent).save(rows) }) after(async () => { await ds?.destroy() }) const kpi = (periods: number, stride?: 'day' | 'week') => computeTwinKpi({ domainId, instanceId: INSTANCE, fromTime: new Date(FROM).toISOString(), toTime: new Date(TO).toISOString(), baseline: { periods, ...(stride ? { stride } : {}) } }) test('과거 창을 하루씩 따로 읽어 분포를 만든다', async () => { const out: any = await kpi(4) const tasks = out.baseline.metrics.find((m: any) => m.metric === 'throughput.tasks') assert.equal(out.baseline.stride, 'day') assert.equal(out.baseline.periods, 4) assert.equal(tasks.current, 20, '지금 창') /* 어제 40 · 그제 60 · 3일 전 없음 · 4일 전 80. */ assert.deepEqual([tasks.min, tasks.median, tasks.max], [40, 60, 80]) }) test('기록 없는 날을 **결측으로 남긴다** — 요청한 창 수와 측정된 수를 함께 답한다', async () => { const out: any = await kpi(4) const tasks = out.baseline.metrics.find((m: any) => m.metric === 'throughput.tasks') assert.equal(tasks.requested, 4) assert.equal(tasks.measuredCount, 3, '3일 전은 기록이 없다') assert.equal(tasks.samples.length, 4, '창 자체는 네 개 모두 돌려준다') assert.deepEqual(tasks.samples.map((s: any) => s.measured), [true, true, false, true]) assert.deepEqual(tasks.samples.map((s: any) => s.value), [40, 60, undefined, 80]) }) test('처리량이 지난 며칠 중 가장 낮으면 worst — 이 축이 답하려던 질문', async () => { /* 예전에는 "어제보다 나아졌나" 까지만 답할 수 있었다. 지금 창(20)은 어제(40)보다도 낮고 최근 어느 날보다도 낮다 — 그것을 한 번에 말할 수 있어야 한다. */ const out: any = await kpi(4) const tasks = out.baseline.metrics.find((m: any) => m.metric === 'throughput.tasks') assert.equal(tasks.standing, 'worst') assert.equal(tasks.rank, 1, '오름차순 첫째') assert.equal(tasks.rankOf, 4, '표본 3 + 현재 1') }) test('방향은 지표가 안다 — 작업시간이 가장 짧으면 best', async () => { const out: any = await kpi(4) const work = out.baseline.metrics.find((m: any) => m.metric === 'workTime.p50Ms') assert.equal(work.current, 30_000) assert.deepEqual([work.min, work.max], [40_000, 60_000]) assert.equal(work.standing, 'best', '짧을수록 좋다') }) test('구간 수를 줄이면 그만큼만 본다 — 창을 지어내지 않는다', async () => { const out: any = await kpi(1) const tasks = out.baseline.metrics.find((m: any) => m.metric === 'throughput.tasks') assert.equal(tasks.requested, 1) assert.equal(tasks.measuredCount, 1) /* 표본 하나는 분포가 아니다. */ assert.equal(tasks.noPosition, 'too-few-samples') assert.equal(tasks.standing, undefined) }) test('주 단위는 같은 요일을 본다 — 하루 전 기록에 걸리지 않는다', async () => { const out: any = await kpi(2, 'week') const tasks = out.baseline.metrics.find((m: any) => m.metric === 'throughput.tasks') assert.equal(out.baseline.stride, 'week') /* 심어 둔 기록은 최근 나흘뿐이라 지난주 같은 요일에는 아무것도 없다 — 지어내지 않는다. */ assert.equal(tasks.measuredCount, 0) assert.equal(tasks.noPosition, 'too-few-samples') }) test('요청하지 않으면 분포를 만들지 않는다 — 조회가 공짜가 아니다', async () => { const out: any = await computeTwinKpi({ domainId, instanceId: INSTANCE, fromTime: new Date(FROM).toISOString(), toTime: new Date(TO).toISOString() }) assert.equal(out.baseline, undefined) }) test('지금 창에 기록이 없어도 분포는 낸다 — 그때가 가장 알고 싶은 순간이다', async () => { /* 심어 둔 기록이 없는 시각을 창으로 잡는다(3일 전). 평소엔 일이 있었는데 지금 없다 = 멈춘 신호. */ const quietTo = TO - 3 * MS_DAY const out: any = await computeTwinKpi({ domainId, instanceId: INSTANCE, fromTime: new Date(quietTo - 60 * 60_000).toISOString(), toTime: new Date(quietTo).toISOString(), baseline: { periods: 2 } }) assert.equal(out.measured, false, '지금 창에는 측정할 기록이 없다') assert.ok(out.baseline, '그래도 분포는 낸다') const tasks = out.baseline.metrics.find((m: any) => m.metric === 'throughput.tasks') assert.equal(tasks.measuredCount, 1, '4일 전 하루만 걸린다(5일 전은 기록 없음)') assert.equal(tasks.samples.find((s: any) => s.measured).value, 80) })