feat: add random song tool with level filters and shareable history
Publish GitHub Pages output / publish (push) Successful in 53s

This commit is contained in:
2026-09-10 21:33:43 +09:00
parent 1fa15cbe08
commit 50421280fa
10 changed files with 331 additions and 3 deletions
+56
View File
@@ -0,0 +1,56 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { formatRandomSongResults, pickRandomSong, randomSongCandidates } from './random-song.ts';
const charts = [
{ songId: 'a', title: 'A', difficulty: 'easy', difficultyLevel: 5 },
{ songId: 'a', title: 'A', difficulty: 'normal', difficultyLevel: 10 },
{ songId: 'a', title: 'A', difficulty: 'hard', difficultyLevel: 15 },
{ songId: 'a', title: 'A', difficulty: 'expert', difficultyLevel: 25 },
{ songId: 'b', title: 'B', difficulty: 'hard', difficultyLevel: 15 },
{ songId: 'b', title: 'B', difficulty: 'expert', difficultyLevel: 30 },
{ songId: 'c', title: 'C', difficulty: 'easy', difficultyLevel: null }
];
test('복사할 결과는 추첨 순서와 중복을 유지하며 한 줄씩 표시한다', () => {
assert.equal(formatRandomSongResults([charts[3], charts[0], charts[3]]),
'1. A / Expert / Lv. 25\n2. A / Easy / Lv. 5\n3. A / Expert / Lv. 25');
assert.equal(formatRandomSongResults([]), '');
});
test('복사할 제목의 줄바꿈을 정리하고 레벨이 없는 결과도 표시한다', () => {
assert.equal(formatRandomSongResults([{ ...charts[6], title: ' Song\nTitle\t ' }]),
'1. Song Title / Easy / 레벨 정보 없음');
});
test('범위를 지정하지 않으면 모든 악곡과 난이도 조합을 포함한다', () => {
assert.deepEqual(randomSongCandidates(charts), charts);
});
test('범위의 양 끝 레벨을 포함하며 같은 레벨만 지정할 수 있다', () => {
assert.deepEqual(randomSongCandidates(charts, { min: 10, max: 25 }), charts.slice(1, 5));
assert.deepEqual(randomSongCandidates(charts, { min: 15, max: 15 }), [charts[2], charts[4]]);
});
test('해당 레벨이 없거나 범위가 잘못되면 후보를 반환하지 않는다', () => {
for (const range of [{ min: 16, max: 24 }, { min: 30, max: 5 }, { min: NaN, max: 30 }, { min: 5, max: Infinity }]) {
assert.deepEqual(randomSongCandidates(charts, range), []);
}
});
test('모든 조합이 동일한 크기의 난수 구간으로 선택되고 첫 후보와 마지막 후보도 선택된다', () => {
const candidates = randomSongCandidates(charts);
for (let index = 0; index < candidates.length; index++) {
for (const offset of [0, 0.5, 0.999]) {
assert.equal(pickRandomSong(candidates, () => (index + offset) / candidates.length), candidates[index]);
}
}
assert.equal(pickRandomSong(candidates, () => 1 - Number.EPSILON), candidates.at(-1));
});
test('추첨은 레벨 조건을 지키고 후보가 하나거나 없어도 처리한다', () => {
const candidates = randomSongCandidates(charts, { min: 30, max: 30 });
assert.equal(pickRandomSong(candidates, () => 0.9), charts[5]);
assert.equal(pickRandomSong(candidates, () => 0), charts[5]);
assert.equal(pickRandomSong([], () => { throw new Error('빈 후보는 난수를 사용하지 않아야 한다'); }), null);
});
+28
View File
@@ -0,0 +1,28 @@
import type { SongChartSummary } from '../data/song-catalog.ts';
import { difficultyLabel } from '../data/difficulty.ts';
export function formatRandomSongResults(results: readonly SongChartSummary[]): string {
return results.map((result, index) => {
const title = result.title.replace(/\s+/g, ' ').trim();
const level = result.difficultyLevel == null ? '레벨 정보 없음' : `Lv. ${result.difficultyLevel}`;
return `${index + 1}. ${title} / ${difficultyLabel(result.difficulty)} / ${level}`;
}).join('\n');
}
export type SongLevelRange = { min: number; max: number };
export function randomSongCandidates<T extends SongChartSummary>(
charts: readonly T[],
range?: SongLevelRange
): T[] {
if (!range) return [...charts];
if (!Number.isFinite(range.min) || !Number.isFinite(range.max) || range.min > range.max) return [];
return charts.filter(({ difficultyLevel: level }) =>
typeof level === 'number' && Number.isFinite(level) && level >= range.min && level <= range.max);
}
/** Each chart (song/difficulty pair) has the same probability, including on repeat draws. */
export function pickRandomSong<T>(candidates: readonly T[], random = Math.random): T | null {
if (candidates.length === 0) return null;
return candidates[Math.floor(random() * candidates.length)] ?? null;
}