import test from 'node:test'; import assert from 'node:assert/strict'; import { parseSusTimeline } from './sus-timeline.mjs'; import { susScoreWeight } from './sus-score-weight.mjs'; function grid(points) { const cells = Array(16).fill('00'); for (const [index, value] of points) cells[index] = value; return cells.join(''); } const fixture = `#MUSIC_ID m0001 #BASEBPM 60 #FULL_COMBO_NOTE_COUNT 7 #TOTAL_NOTE_COUNT 7 #NORMAL_NOTE_COUNT 1 #FLICK_NOTE_COUNT 1 #LONG_START_NOTE_COUNT 1 #LONG_END_NOTE_COUNT 0 #LONG_FLICK_END_NOTE_COUNT 1 #LONG_RELAY_NOTE_COUNT 1 #LONG_CONTINUE_NOTE_COUNT 2 #GHOST_NOTE_COUNT 0 #DAMAGE_NOTE_COUNT 0 #00002:4 #000320:${grid([[1,'13'],[3,'33'],[4,'53'],[5,'23']])} #00012:${grid([[5,'13'],[15,'13']])} #00052:${grid([[5,'13'],[8,'13']])} `; test('끝 플릭을 중복 집계하지 않고 중간 판정을 곡의 반박 격자에 배치한다', () => { const { notes } = parseSusTimeline(fixture); assert.deepEqual(notes, [ { timeSeconds:0.25,type:'regular',isFlick:false }, { timeSeconds:0.5,type:'mid',isFlick:false }, { timeSeconds:0.75,type:'mid',isFlick:false }, { timeSeconds:1,type:'mid',isFlick:false }, { timeSeconds:1.25,type:'regular',isFlick:false,isHoldEnd:true }, { timeSeconds:2,type:'regular',isFlick:true }, { timeSeconds:3.75,type:'regular',isFlick:false } ]); // Song Mechanics: hidden relay contributes only to the denominator. assert.equal(susScoreWeight(fixture).perfectNoteWeight, 4450); assert.equal(notes.reduce((sum,n)=>sum+(n.type==='mid'?100:n.isFlick?1050:1000),0),4350); }); test('헤더 불일치를 후반 삭제나 임의 중간 노트 추가로 숨기지 않는다', () => { assert.throws(()=>parseSusTimeline(fixture.replace('#NORMAL_NOTE_COUNT 1','#NORMAL_NOTE_COUNT 0')), /category mismatch/); assert.throws(()=>parseSusTimeline(fixture.replace('#LONG_CONTINUE_NOTE_COUNT 2','#LONG_CONTINUE_NOTE_COUNT 3')), /category mismatch/); assert.throws(()=>parseSusTimeline(fixture.replace('#FLICK_NOTE_COUNT 1','#FLICK_NOTE_COUNT 0')), /category mismatch/); }); test('채보에는 레인·너비·끝 플릭과 숨은 연결점을 보존하고 자동 판정은 그리지 않는다', () => { const { visual } = parseSusTimeline(fixture); assert.equal(visual.laneCount, 12); assert.equal(visual.notes.length, 5); assert.deepEqual(visual.notes[0], { timeSeconds: 0.25, lane: 0, width: 3, kind: 'hold' }); assert.deepEqual(visual.notes[2], { timeSeconds: 1.25, lane: 0, width: 3, kind: 'hold', direction: 'up' }); assert.equal(visual.holds[0].length, 4); const curved = parseSusTimeline(fixture.replace('53', '43')).visual; assert.equal(curved.holds[0][2].control, true); assert.equal(curved.notes.some(note => note.timeSeconds === 1), false); }); test('마디 길이 변경은 다음 변경까지 유지한다', () => { const text = `#MUSIC_ID m0002 #BASEBPM 60 #NORMAL_NOTE_COUNT 2 #FLICK_NOTE_COUNT 0 #LONG_START_NOTE_COUNT 0 #LONG_END_NOTE_COUNT 0 #LONG_FLICK_END_NOTE_COUNT 0 #LONG_RELAY_NOTE_COUNT 0 #LONG_CONTINUE_NOTE_COUNT 0 #00002:3 #00112:11 #00212:11 `; assert.deepEqual(parseSusTimeline(text).notes.map(n=>n.timeSeconds),[3,6]); assert.deepEqual(parseSusTimeline(text).visual.measures, [ { number: 0, timeSeconds: 0 }, { number: 1, timeSeconds: 3 }, { number: 2, timeSeconds: 6 }, { number: 3, timeSeconds: 9 } ]); }); test('원본 마디 번호는 빈 마디, 마디 중간 BPM 변경과 WAVEOFFSET을 반영한다', () => { const text = `#MUSIC_ID m0003 #MEASURE_COUNT 4 #BASEBPM 60 #WAVEOFFSET 0.5 #NORMAL_NOTE_COUNT 1 #FLICK_NOTE_COUNT 0 #LONG_START_NOTE_COUNT 0 #LONG_END_NOTE_COUNT 0 #LONG_FLICK_END_NOTE_COUNT 0 #LONG_RELAY_NOTE_COUNT 0 #LONG_CONTINUE_NOTE_COUNT 0 #BPM01:60 #BPM02:120 #00008:01 #00108:0002 #00202:3 #00312:11 `; assert.deepEqual(parseSusTimeline(text).visual.measures, [ { number: 0, timeSeconds: 0.5 }, { number: 1, timeSeconds: 4.5 }, { number: 2, timeSeconds: 7.5 }, { number: 3, timeSeconds: 9 }, { number: 4, timeSeconds: 10.5 } ]); assert.deepEqual(parseSusTimeline(text).notes.map(note => note.timeSeconds), [9]); // Before the first explicit BPM event, use BASEBPM rather than extrapolating it backwards. assert.deepEqual(parseSusTimeline(text.replace('#00008:01\n', '')).visual.measures, parseSusTimeline(text).visual.measures); assert.throws(() => parseSusTimeline(text.replace('#MEASURE_COUNT 4', '#MEASURE_COUNT NaN')), /마디 수/); });