feat: add random song tool with level filters and shareable history
Publish GitHub Pages output / publish (push) Successful in 53s
Publish GitHub Pages output / publish (push) Successful in 53s
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user