/* * 소요시간 추정기 — **상수를 데이터로 바꾸는 두 층(실측 · 거리×속도).** * * 커널 `durationOf` 의 우선순위는 추정기 > 명세 > 상수다. 그 추정기 자리에 호스트가 넣는 두 가지를 * 여기서 고정한다. 핵심 규율은 하나다 — **모르면 답하지 않는다**(undefined → 커널이 명세·상수로). */ import { test } from 'node:test' import assert from 'node:assert/strict' import { EQUIPMENT_PROPERTY, buildTravelEstimator, chainEstimators } from '../server/engine/travel-estimator.ts' import { DEFAULT_MIN_SAMPLES, buildMeasuredEstimator } from '../server/engine/measured-estimator.ts' const layout = { dock: { x: 0, y: 0 }, rack: { x: 30, y: 40 } } // 거리 50m const equipment = (uom?: string, value = '2') => [ { id: 'fk-1', kind: 'forklift', properties: [{ id: EQUIPMENT_PROPERTY.speed, value, ...(uom ? { uom } : {}) }] } ] /* ── 거리 × 속도 ────────────────────────────────────────────────────────── */ test('거리와 속도가 있으면 이동시간을 계산한다 — 상수 30초가 아니라 50m/2(m/s)=25초', () => { const { estimator, reasons } = buildTravelEstimator({ layout, equipment: equipment('MTS'), unit: 'm' }) assert.deepEqual(reasons, []) assert.equal(estimator!.estimate({ kind: 'putaway', fromNode: 'dock', toNode: 'rack', resourceKind: 'forklift' }), 25_000) }) test('단위 코드를 표준대로 환산한다 — KMH 는 m/s 가 아니다', () => { const { estimator } = buildTravelEstimator({ layout, equipment: equipment('KMH', '7.2'), unit: 'm' }) // 7.2 km/h = 2 m/s → 50m = 25초 assert.equal(estimator!.estimate({ kind: 'putaway', fromNode: 'dock', toNode: 'rack', resourceKind: 'forklift' }), 25_000) }) test('단위 없는 속도는 m/s 라고 가정하지 않는다 — 3 이 초속 3m 인지 시속 3km 인지 뜻이 다르다', () => { const { estimator, reasons } = buildTravelEstimator({ layout, equipment: equipment(undefined), unit: 'm' }) assert.equal(estimator, undefined) assert.ok(reasons.some(r => r.includes('unit of measure'))) }) test('모르는 단위 코드는 받지 않는다', () => { const { estimator, reasons } = buildTravelEstimator({ layout, equipment: equipment('WAT'), unit: 'm' }) assert.equal(estimator, undefined) assert.ok(reasons.some(r => r.includes('unknown speed unit'))) }) test('좌표가 없으면 만들지 않고 이유를 남긴다', () => { const { estimator, reasons } = buildTravelEstimator({ layout: {}, equipment: equipment('MTS'), unit: 'm' }) assert.equal(estimator, undefined) assert.ok(reasons.some(r => r.includes('no location coordinates'))) }) test('속도를 아무도 선언하지 않으면 거리만으로 시간을 만들지 않는다', () => { const { estimator, reasons } = buildTravelEstimator({ layout, equipment: [{ id: 'fk-1', kind: 'forklift' }], unit: 'm' }) assert.equal(estimator, undefined) assert.ok(reasons.some(r => r.includes('speed'))) }) test('좌표 단위가 미터가 아니면 환산을 추측하지 않는다', () => { const { estimator, reasons } = buildTravelEstimator({ layout, equipment: equipment('MTS'), unit: 'ft' }) assert.equal(estimator, undefined) assert.ok(reasons.some(r => r.includes('not metres'))) }) test('같은 자리의 작업(처리·체류)은 다루지 않는다 — 거리 0 을 0 초로 만들면 공정 시간이 사라진다', () => { const { estimator } = buildTravelEstimator({ layout, equipment: equipment('MTS'), unit: 'm' }) assert.equal(estimator!.estimate({ kind: 'cut', fromNode: 'rack', toNode: 'rack', resourceKind: 'forklift' }), undefined) }) test('좌표 모르는 노드·속도 모르는 자원종류는 넘긴다(부분 적용)', () => { const { estimator } = buildTravelEstimator({ layout, equipment: equipment('MTS'), unit: 'm' }) assert.equal(estimator!.estimate({ kind: 'putaway', fromNode: 'dock', toNode: 'rack-99', resourceKind: 'forklift' }), undefined) assert.equal(estimator!.estimate({ kind: 'spot', fromNode: 'dock', toNode: 'rack', resourceKind: 'hostler' }), undefined) assert.equal(estimator!.estimate({ kind: 'spot', fromNode: 'dock', toNode: 'rack' }), undefined, '자원 종류 미지') }) /* ── 실측(이력) ─────────────────────────────────────────────────────────── */ test('작업 종류별 작업시간 p50 을 소요로 배운다 — 리드타임(대기 포함)이 아니라 작업시간', () => { const r = buildMeasuredEstimator([ { key: 'putaway', workTime: { count: 40, p50Ms: 47_000 } }, { key: 'pick', workTime: { count: 12, p50Ms: 22_500 } } ]) assert.deepEqual(r.learned, { putaway: 47_000, pick: 22_500 }) assert.deepEqual(r.estimator!.estimate({ kind: 'putaway', fromNode: 'a', toNode: 'b' }), { meanMs: 47_000 }) assert.equal(r.estimator!.estimate({ kind: 'unseen', fromNode: 'a', toNode: 'b' }), undefined, '못 배운 종류는 넘긴다') }) /* ── 관측된 퍼짐 ────────────────────────────────────────────────────────── */ test('두 꼬리가 있으면 퍼짐까지 배운다 — 평균만 배우면 줄이 생기지 않는다', () => { const r = buildMeasuredEstimator([{ key: 'putaway', workTime: { count: 40, p10Ms: 30_000, p50Ms: 47_000, p90Ms: 80_000 } }]) assert.deepEqual(r.spreads, { putaway: { minMs: 30_000, maxMs: 80_000 } }) assert.deepEqual(r.estimator!.estimate({ kind: 'putaway', fromNode: 'a', toNode: 'b' }), { meanMs: 47_000, spread: { distribution: 'triangular', minMs: 30_000, maxMs: 80_000, modeMs: 47_000 } }) }) test('한쪽 꼬리만 있으면 퍼짐을 만들지 않는다 — 아래쪽을 위쪽에서 베끼는 것은 발명이다', () => { const r = buildMeasuredEstimator([{ key: 'pick', workTime: { count: 40, p50Ms: 20_000, p90Ms: 40_000 } }]) assert.deepEqual(r.spreads, {}) assert.deepEqual(r.estimator!.estimate({ kind: 'pick', fromNode: 'a', toNode: 'b' }), { meanMs: 20_000 }) }) test('구간이 성립하지 않으면 퍼짐 없이 평균만 — 없는 흔들림을 만들지 않는다', () => { const r = buildMeasuredEstimator([{ key: 'pick', workTime: { count: 40, p10Ms: 50_000, p50Ms: 20_000, p90Ms: 20_000 } }]) assert.deepEqual(r.spreads, {}, 'p10 >= p90 이면 구간이 아니다') }) test('표본이 적으면 배우지 않고, 무엇을 버렸는지 밝힌다 — 두 건의 p50 은 현장 값이 아니다', () => { const r = buildMeasuredEstimator([{ key: 'pick', workTime: { count: 2, p50Ms: 9_000 } }]) assert.equal(r.estimator, undefined) assert.deepEqual(r.skipped, [{ kind: 'pick', count: 2 }]) assert.equal(r.minSamples, DEFAULT_MIN_SAMPLES) }) test("'unknown' 축은 배우지 않는다 — 종류 모르는 기록을 특정 종류의 근거로 쓰지 않는다", () => { const r = buildMeasuredEstimator([{ key: 'unknown', workTime: { count: 99, p50Ms: 30_000 } }]) assert.deepEqual(r.learned, {}) assert.equal(r.estimator, undefined) }) test('p50 이 0 이면 쓰지 않는다 — 0 초 작업은 측정 실패다', () => { const r = buildMeasuredEstimator([{ key: 'pick', workTime: { count: 50, p50Ms: 0 } }]) assert.deepEqual(r.learned, {}) }) test('그룹이 없으면 빈 결과 — 오류를 내지 않는다', () => { assert.equal(buildMeasuredEstimator(undefined).estimator, undefined) assert.equal(buildMeasuredEstimator([]).estimator, undefined) }) /* ── 사슬 — 실측이 계산을 이긴다 ────────────────────────────────────────── */ test('실측이 먼저, 없으면 거리 계산 — 강한 근거가 앞이다', () => { const measured = buildMeasuredEstimator([{ key: 'pick', workTime: { count: 30, p50Ms: 11_000 } }]) const travel = buildTravelEstimator({ layout, equipment: equipment('MTS'), unit: 'm' }) const chained = chainEstimators([measured.estimator, travel.estimator])! assert.deepEqual(chained.estimate({ kind: 'pick', fromNode: 'dock', toNode: 'rack', resourceKind: 'forklift' }), { meanMs: 11_000 }, '실측 우선') assert.equal(chained.estimate({ kind: 'putaway', fromNode: 'dock', toNode: 'rack', resourceKind: 'forklift' }), 25_000, '실측 없으면 거리') assert.equal(chained.estimate({ kind: 'putaway', fromNode: 'dock', toNode: 'dock', resourceKind: 'forklift' }), undefined, '둘 다 모르면 넘긴다') }) test('빈 사슬은 undefined — 주입하지 않는다(상수로 굴러가는 것이 정직하다)', () => { assert.equal(chainEstimators([undefined, undefined]), undefined) })