refactor: 收口 BangDream 谱面预览策略

This commit is contained in:
sunlei 2026-06-06 23:09:01 +08:00
parent 46f6a2465c
commit c726cad320
4 changed files with 596 additions and 424 deletions

View File

@ -14,7 +14,7 @@
## 当前事实
- Tsugu 源码目录:`src/qqbot/plugins/bangDream/tsugu`
- TS 文件92
- TS 文件:初始基线 92;当前 `tsugu` 源码 124
- 函数节点481其中稳定函数 410匿名/内联回调 71
- 源码 JSDoc稳定函数 410/410 已覆盖
- 变量声明1896
@ -350,6 +350,8 @@ export interface TsuguHook {
- 卡牌详情页已接入 `DetailBlockBuilder`,卡牌标题、插画、基础字段、缩略图和演出缩略图不再手写数组与分割线;`/查卡 472` 本地 smoke 输出非空长图。
- `scripts/bangdream-render-smoke.ps1` 已固化图片 smoke 完成判定:先删除旧目标图,轮询 stdout 成功 JSON 和新图片文件;如果 Tsugu 后台 handle 导致 Node 未自然退出,则在图片落盘后清理本次进程并返回成功,避免已成功出图仍卡到超时。
- 线上 `/qqbot/command/test` smoke 已固化为先按 `operationKey` 查询启用命令、传 `commandId`,并保留完整命令文本;避免默认 `preview` selfId 未绑定命令时误报“未匹配到命令”。
- 已新增 `render-blocks/song-chart-preview-spec.ts`,把谱面 BPM 时间计算、双押识别、滑条拆分、音符排序、展示/计数音符分类、难度颜色和布局规格从 `song-chart-preview.ts` 拆成可测纯策略;绘制文件改为消费 `createSongChartPreviewModel`,只保留资源加载和 canvas 绘制。
- 已新增 `song-chart-preview-spec.spec.ts`,覆盖 BPM 变速时间、十六分单点、双押、滑条 bar/tick/end、布局列数和音符分类本地生成 `song-chart-preview-spec-136-expert.jpg`,验证谱面预览重构后图片输出非空且视觉结构正常。
### Phase 6策略 policy 和时间/档线规则

View File

@ -0,0 +1,471 @@
export interface BestdoriConnection {
beat: number;
lane: number;
time?: number;
skill?: boolean;
flick?: boolean;
hidden?: boolean;
}
export interface BestdoriNote {
type: string;
beat: number;
lane?: number;
time?: number | number[];
bpm?: number;
connections?: BestdoriConnection[];
skill?: boolean;
flick?: boolean;
direction?: 'Left' | 'Right';
width?: number;
hidden?: boolean;
}
export type PreviewNote = Omit<BestdoriNote, 'time' | 'lane'> & {
type: string;
time: number | number[];
lane: number | number[];
};
export interface PreviewLayout {
infoAreaWidth: number;
laneWidth: number;
splitLineWidth: number;
blockDistance: number;
heightPerSecond: number;
originalWidth: number;
chartLength: number;
secondsPerCol: number;
width: number;
height: number;
colCount: number;
}
export const BANGDREAM_SONG_CHART_PREVIEW_SPEC = {
aspectRatioLimit: 16 / 9,
blockDistance: 72,
heightPerSecond: 216,
infoAreaWidth: 240,
laneCount: 7,
laneWidth: 32,
minHeight: 500,
noteEndPaddingSeconds: 0.25,
splitLineWidth: 2,
} as const;
export const BANGDREAM_SONG_CHART_DISPLAY_NOTE_TYPES = [
'Single',
'SingleOff',
'Skill',
'Flick',
'Directional',
'Long',
] as const;
export const BANGDREAM_SONG_CHART_COUNT_LINE_NOTE_TYPES = [
'Single',
'SingleOff',
'Flick',
'Long',
'Skill',
'Tick',
'Directional',
] as const;
export const BANGDREAM_SONG_CHART_DIFFICULTY_COLORS: Record<string, string> = {
easy: 'rgb(87, 192, 201)',
expert: 'rgb(199, 96, 96)',
hard: 'rgb(239, 161, 25)',
normal: 'rgb(138, 201, 87)',
special: 'rgb(195, 96, 199)',
};
const PREVIEW_NOTE_TYPE_SORT: Record<string, number> = {
Bar: -2,
Sim: -1,
};
/**
*
*
* @param type -
*/
export function isSongChartDisplayNoteType(type: string): boolean {
return BANGDREAM_SONG_CHART_DISPLAY_NOTE_TYPES.includes(type as never);
}
/**
* 线
*
* @param type -
*/
export function isSongChartCountLineNoteType(type: string): boolean {
return BANGDREAM_SONG_CHART_COUNT_LINE_NOTE_TYPES.includes(type as never);
}
/**
* BPM
*
* @param timepoints - BPM
* @returns
*/
function sortTimepoints(timepoints: BestdoriNote[]): BestdoriNote[] {
timepoints.sort((a, b) => a.beat - b.beat);
for (let i = 0; i < timepoints.length; i++) {
const current = timepoints[i];
if (i === 0) {
current.time = 0;
continue;
}
const previous = timepoints[i - 1];
current.time =
(previous.time as number) +
(current.beat - previous.beat) * (60 / previous.bpm);
}
return timepoints;
}
/**
* BPM
*
* @param timepoints - BPM
* @param beat -
* @returns
*/
function findTimepointAtBeat(
timepoints: BestdoriNote[],
beat: number,
): BestdoriNote {
let left = 0;
let right = timepoints.length - 1;
let result = timepoints[0];
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (timepoints[mid].beat > beat) {
right = mid - 1;
continue;
}
result = timepoints[mid];
left = mid + 1;
}
return result;
}
/**
* BPM
*
* @param timepoints - BPM
* @param beat -
* @returns
*/
function getNoteTime(timepoints: BestdoriNote[], beat: number): number {
const timepoint = findTimepointAtBeat(timepoints, beat);
return (
(timepoint.time as number) + (60 / timepoint.bpm) * (beat - timepoint.beat)
);
}
/**
*
*
* @param chart -
* @returns BPM
*/
export function assignSongChartTimes(chart: BestdoriNote[]): BestdoriNote[] {
const timepoints = sortTimepoints(
chart.filter((note) => note.type === 'BPM'),
);
for (const note of chart) {
if (note.type === 'Long' || note.type === 'Slide') {
for (const connection of note.connections ?? []) {
connection.time = getNoteTime(timepoints, connection.beat);
}
continue;
}
if (note.type !== 'BPM') {
note.time = getNoteTime(timepoints, note.beat);
}
}
return timepoints;
}
/**
*
*
* @param notes -
* @param beat -
* @param time -
* @param lane -
*/
function addSimNote(
notes: PreviewNote[],
beat: number,
time: number,
lane: number,
): void {
for (const note of notes) {
if (note.beat === beat && note.lane === lane) {
continue;
}
if (
['Single', 'Flick', 'Skill', 'Long', 'Directional'].includes(note.type) &&
note.beat === beat
) {
notes.push({
beat,
lane: [note.lane as number, lane].sort((a, b) => a - b),
time,
type: 'Sim',
});
}
}
}
/**
*
*
* @param note -
* @returns
*/
function getSingleNoteType(note: BestdoriNote): string {
if (note.flick) {
return 'Flick';
}
if (note.skill) {
return 'Skill';
}
if (note.beat % 0.5 !== 0) {
return 'SingleOff';
}
return 'Single';
}
/**
*
*
* @param notes -
* @param note -
*/
function pushSlideNotes(notes: PreviewNote[], note: BestdoriNote): void {
const barTime: number[] = [];
const lane: number[] = [];
const connections = note.connections ?? [];
for (let i = 0; i < connections.length; i++) {
const tick = connections[i];
const time = tick.time;
const firstTick = i === 0;
const lastTick = i === connections.length - 1;
barTime.push(time);
lane.push(tick.lane);
if (!firstTick) {
notes.push({
beat: tick.beat,
lane: [lane[0], lane[1]],
time: [barTime[0], barTime[1]],
type: 'Bar',
});
}
if (firstTick || lastTick) {
notes.push({
...tick,
lane: tick.lane,
time,
type: firstTick
? tick.skill
? 'Skill'
: 'Long'
: tick.flick
? 'Flick'
: tick.skill
? 'Skill'
: 'Long',
});
addSimNote(notes, tick.beat, time, tick.lane);
continue;
}
lane.shift();
barTime.shift();
if (!tick.hidden) {
notes.push({ ...tick, lane: tick.lane, time, type: 'Tick' });
}
}
}
/**
*
*
* @param notes -
* @param note -
*/
function pushPlayableNote(notes: PreviewNote[], note: BestdoriNote): void {
if (note.type === 'Single') {
const typedNote = {
...note,
lane: note.lane,
time: note.time as number,
type: getSingleNoteType(note),
} as PreviewNote;
notes.push(typedNote);
addSimNote(notes, note.beat, typedNote.time as number, note.lane);
return;
}
if (note.type === 'Directional') {
notes.push({
...note,
lane: note.lane,
time: note.time as number,
} as PreviewNote);
addSimNote(notes, note.beat, note.time as number, note.lane);
}
}
/**
*
*
* @param note -
* @returns
*/
function getSortLane(note: PreviewNote): number {
return Array.isArray(note.lane) ? note.lane[0] : note.lane;
}
/**
*
*
* @param notes -
* @returns
*/
function sortPreviewNotes(notes: PreviewNote[]): PreviewNote[] {
notes.sort((a, b) => {
const typeSortResult =
(PREVIEW_NOTE_TYPE_SORT[a.type] || 0) -
(PREVIEW_NOTE_TYPE_SORT[b.type] || 0);
if (typeSortResult !== 0) {
return typeSortResult;
}
if (a.time !== b.time) {
return (a.time as number) - (b.time as number);
}
return getSortLane(a) - getSortLane(b);
});
return notes;
}
/**
* Bestdori
*
* @param chart -
* @returns
*/
export function createSongChartPreviewNotes(
chart: BestdoriNote[],
): PreviewNote[] {
const notes: PreviewNote[] = [];
for (const note of chart) {
if (note.type === 'Slide' || note.type === 'Long') {
pushSlideNotes(notes, note);
continue;
}
if (note.type === 'BPM') {
notes.push(note as PreviewNote);
continue;
}
pushPlayableNote(notes, note);
}
return sortPreviewNotes(notes);
}
/**
*
*
* @param notes -
* @returns
*/
export function createSongChartPreviewLayout(
notes: PreviewNote[],
): PreviewLayout {
const {
aspectRatioLimit,
blockDistance,
heightPerSecond,
infoAreaWidth,
laneCount,
laneWidth,
minHeight,
noteEndPaddingSeconds,
splitLineWidth,
} = BANGDREAM_SONG_CHART_PREVIEW_SPEC;
const displayNotes = notes.filter((note) =>
isSongChartDisplayNoteType(note.type),
);
const chartLength = Math.ceil(
(displayNotes[displayNotes.length - 1].time as number) +
noteEndPaddingSeconds,
);
const originalWidth = blockDistance * 2 + laneWidth * laneCount;
const originalHeight = heightPerSecond * chartLength;
let width = infoAreaWidth + originalWidth;
let height = originalHeight;
let colCount = 1;
while (width / height < aspectRatioLimit) {
if (width / height > 4 / 3) {
break;
}
if (Math.ceil(originalHeight / (colCount + 1)) < minHeight) {
break;
}
colCount++;
const newWidth = infoAreaWidth + originalWidth * colCount;
const newHeight = originalHeight / colCount;
if (newHeight < minHeight) {
break;
}
width = newWidth;
height = newHeight;
}
return {
blockDistance,
chartLength,
colCount,
height,
heightPerSecond,
infoAreaWidth,
laneWidth,
originalWidth,
secondsPerCol: chartLength / colCount,
splitLineWidth,
width,
};
}
/**
*
*
* @param chart - Bestdori
* @returns
*/
export function createSongChartPreviewModel(chart: BestdoriNote[]) {
assignSongChartTimes(chart);
const notes = createSongChartPreviewNotes(chart);
return {
layout: createSongChartPreviewLayout(notes),
notes,
};
}

View File

@ -5,6 +5,15 @@ import {
getBangDreamAssetPath,
} from '@/qqbot/plugins/bangDream/tsugu/runtime/asset-manifest';
import { BANGDREAM_RENDER_THEME } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/theme';
import {
BANGDREAM_SONG_CHART_DIFFICULTY_COLORS,
BANGDREAM_SONG_CHART_PREVIEW_SPEC,
BestdoriNote,
createSongChartPreviewModel,
isSongChartCountLineNoteType,
PreviewLayout,
PreviewNote,
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/song-chart-preview-spec';
interface BestdoriPreviewPayload {
id: number;
@ -16,49 +25,6 @@ interface BestdoriPreviewPayload {
cover: string | Buffer;
}
interface BestdoriConnection {
beat: number;
lane: number;
time?: number;
skill?: boolean;
flick?: boolean;
hidden?: boolean;
}
interface BestdoriNote {
type: string;
beat: number;
lane?: number;
time?: number | number[];
bpm?: number;
connections?: BestdoriConnection[];
skill?: boolean;
flick?: boolean;
direction?: 'Left' | 'Right';
width?: number;
hidden?: boolean;
}
type PreviewNote = Omit<BestdoriNote, 'time' | 'lane'> & {
type: string;
time: number | number[];
lane: number | number[];
};
interface PreviewLayout {
infoAreaWidth: number;
laneWidth: number;
splitLineWidth: number;
blockDistance: number;
heightPerSecond: number;
originalWidth: number;
chartLength: number;
secondsPerCol: number;
width: number;
height: number;
colCount: number;
}
const OFFSET = 8;
const NOTE_IMAGE_KEYS = [
'Single',
@ -91,378 +57,6 @@ const NOTE_IMAGE_ASSET_KEYS: Record<
Skill: 'songChartNoteSkill',
Tick: 'songChartNoteTick',
};
const DISPLAY_NOTE_TYPES = [
'Single',
'SingleOff',
'Skill',
'Flick',
'Directional',
'Long',
];
const COUNT_LINE_NOTE_TYPES = [
'Single',
'SingleOff',
'Flick',
'Long',
'Skill',
'Tick',
'Directional',
];
const DIFFICULTY_COLOR_LIST: Record<string, string> = {
easy: 'rgb(87, 192, 201)',
normal: 'rgb(138, 201, 87)',
hard: 'rgb(239, 161, 25)',
expert: 'rgb(199, 96, 96)',
special: 'rgb(195, 96, 199)',
};
/**
* BPM
*
* @param timepoints - BPM
* @returns
*/
function sortTimepoints(timepoints: BestdoriNote[]): BestdoriNote[] {
timepoints.sort((a, b) => a.beat - b.beat);
for (let i = 0; i < timepoints.length; i++) {
const current = timepoints[i];
if (i === 0) {
current.time = 0;
continue;
}
const previous = timepoints[i - 1];
current.time =
(previous.time as number) +
(current.beat - previous.beat) * (60 / previous.bpm);
}
return timepoints;
}
/**
* BPM
*
* @param timepoints - BPM
* @param beat -
* @returns
*/
function findTimepointAtBeat(
timepoints: BestdoriNote[],
beat: number,
): BestdoriNote {
let left = 0;
let right = timepoints.length - 1;
let result = timepoints[0];
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (timepoints[mid].beat > beat) {
right = mid - 1;
continue;
}
result = timepoints[mid];
left = mid + 1;
}
return result;
}
/**
* BPM
*
* @param timepoints - BPM
* @param beat -
* @returns
*/
function getNoteTime(timepoints: BestdoriNote[], beat: number): number {
const timepoint = findTimepointAtBeat(timepoints, beat);
return (
(timepoint.time as number) + (60 / timepoint.bpm) * (beat - timepoint.beat)
);
}
/**
*
*
* @param chart -
* @returns
*/
function assignChartTimes(chart: BestdoriNote[]): BestdoriNote[] {
const timepoints = sortTimepoints(
chart.filter((note) => note.type === 'BPM'),
);
for (const note of chart) {
if (note.type === 'Long' || note.type === 'Slide') {
for (const connection of note.connections ?? []) {
connection.time = getNoteTime(timepoints, connection.beat);
}
continue;
}
if (note.type !== 'BPM') {
note.time = getNoteTime(timepoints, note.beat);
}
}
return timepoints;
}
/**
*
*
* @param notes -
* @param beat -
* @param time -
* @param lane -
*/
function addSimNote(
notes: PreviewNote[],
beat: number,
time: number,
lane: number,
): void {
for (const note of notes) {
if (note.beat === beat && note.lane === lane) {
continue;
}
if (
['Single', 'Flick', 'Skill', 'Long', 'Directional'].includes(note.type) &&
note.beat === beat
) {
notes.push({
type: 'Sim',
beat,
time,
lane: [note.lane as number, lane].sort((a, b) => a - b),
});
}
}
}
/**
*
*
* @param note -
* @returns
*/
function getSingleNoteType(note: BestdoriNote): string {
if (note.flick) {
return 'Flick';
}
if (note.skill) {
return 'Skill';
}
if (note.beat % 0.5 !== 0) {
return 'SingleOff';
}
return 'Single';
}
/**
*
*
* @param notes -
* @param note -
*/
function pushSlideNotes(notes: PreviewNote[], note: BestdoriNote): void {
const barTime: number[] = [];
const lane: number[] = [];
const connections = note.connections ?? [];
for (let i = 0; i < connections.length; i++) {
const tick = connections[i];
const time = tick.time;
const firstTick = i === 0;
const lastTick = i === connections.length - 1;
barTime.push(time);
lane.push(tick.lane);
if (!firstTick) {
notes.push({
type: 'Bar',
beat: tick.beat,
time: [barTime[0], barTime[1]],
lane: [lane[0], lane[1]],
});
}
if (firstTick || lastTick) {
notes.push({
...tick,
type: firstTick
? tick.skill
? 'Skill'
: 'Long'
: tick.flick
? 'Flick'
: tick.skill
? 'Skill'
: 'Long',
time,
lane: tick.lane,
});
addSimNote(notes, tick.beat, time, tick.lane);
continue;
}
lane.shift();
barTime.shift();
if (!tick.hidden) {
notes.push({ ...tick, type: 'Tick', time, lane: tick.lane });
}
}
}
/**
*
*
* @param notes -
* @param note -
*/
function pushPlayableNote(notes: PreviewNote[], note: BestdoriNote): void {
if (note.type === 'Single') {
const typedNote = {
...note,
type: getSingleNoteType(note),
time: note.time as number,
lane: note.lane,
} as PreviewNote;
notes.push(typedNote);
addSimNote(notes, note.beat, typedNote.time as number, note.lane);
return;
}
if (note.type === 'Directional') {
notes.push({
...note,
time: note.time as number,
lane: note.lane,
} as PreviewNote);
addSimNote(notes, note.beat, note.time as number, note.lane);
}
}
/**
* Sort轨道
*
* @param note -
* @returns
*/
function getSortLane(note: PreviewNote): number {
return Array.isArray(note.lane) ? note.lane[0] : note.lane;
}
/**
*
*
* @param notes -
* @returns
*/
function sortPreviewNotes(notes: PreviewNote[]): PreviewNote[] {
/**
* Sort
*
* @param type -
* @returns
*/
const typeSort = (type: string): number => ({ Bar: -2, Sim: -1 })[type] || 0;
notes.sort((a, b) => {
const typeSortResult = typeSort(a.type) - typeSort(b.type);
if (typeSortResult !== 0) {
return typeSortResult;
}
if (a.time !== b.time) {
return (a.time as number) - (b.time as number);
}
return getSortLane(a) - getSortLane(b);
});
return notes;
}
/**
* Bestdori
*
* @param chart -
* @returns
*/
function createPreviewNotes(chart: BestdoriNote[]): PreviewNote[] {
const notes: PreviewNote[] = [];
for (const note of chart) {
if (note.type === 'Slide' || note.type === 'Long') {
pushSlideNotes(notes, note);
continue;
}
if (note.type === 'BPM') {
notes.push(note as PreviewNote);
continue;
}
pushPlayableNote(notes, note);
}
return sortPreviewNotes(notes);
}
/**
*
*
* @param notes -
* @returns
*/
function createPreviewLayout(notes: PreviewNote[]): PreviewLayout {
const infoAreaWidth = 240;
const laneWidth = 32;
const splitLineWidth = 2;
const blockDistance = 72;
const heightPerSecond = 216;
const displayNotes = notes.filter((note) =>
DISPLAY_NOTE_TYPES.includes(note.type),
);
const chartLength = Math.ceil(
(displayNotes[displayNotes.length - 1].time as number) + 0.25,
);
const minHeight = 500;
const originalWidth = blockDistance * 2 + laneWidth * 7;
const originalHeight = heightPerSecond * chartLength;
let width = infoAreaWidth + originalWidth;
let height = originalHeight;
let colCount = 1;
while (width / height < 16 / 9) {
if (width / height > 4 / 3) {
break;
}
if (Math.ceil(originalHeight / (colCount + 1)) < minHeight) {
break;
}
colCount++;
const newWidth = infoAreaWidth + originalWidth * colCount;
const newHeight = originalHeight / colCount;
if (newHeight < minHeight) {
break;
}
width = newWidth;
height = newHeight;
}
return {
infoAreaWidth,
laneWidth,
splitLineWidth,
blockDistance,
heightPerSecond,
originalWidth,
chartLength,
secondsPerCol: chartLength / colCount,
width,
height,
colCount,
};
}
/**
*
*
@ -574,7 +168,7 @@ function drawBaseInfo(
const coverWidth = layout.infoAreaWidth - 16;
ctx.save();
ctx.fillStyle =
DIFFICULTY_COLOR_LIST[diff] ??
BANGDREAM_SONG_CHART_DIFFICULTY_COLORS[diff] ??
BANGDREAM_RENDER_THEME.color.chartDifficultyFallback;
ctx.fillRect(8 + coverWidth - 116, 8 + coverWidth - 12, 128, 24);
ctx.fillStyle = BANGDREAM_RENDER_THEME.color.chartText;
@ -596,7 +190,8 @@ function drawTracks(ctx: any, layout: PreviewLayout): void {
ctx.save();
const x =
layout.infoAreaWidth + i * layout.originalWidth + layout.blockDistance;
const w = layout.laneWidth * 7;
const w =
layout.laneWidth * BANGDREAM_SONG_CHART_PREVIEW_SPEC.laneCount;
const grd = ctx.createLinearGradient(
x,
0,
@ -658,7 +253,8 @@ function drawBeatLines(
}
const currentTime = (bpmNote.time as number) + beat * (60 / bpmNote.bpm);
const { x, y } = getTimePosition(layout, currentTime);
const w = 7 * layout.laneWidth;
const w =
BANGDREAM_SONG_CHART_PREVIEW_SPEC.laneCount * layout.laneWidth;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x, y);
@ -706,13 +302,13 @@ function drawCountAndBpmLines(
notes: PreviewNote[],
): void {
let count = 0;
const w = 7 * layout.laneWidth;
const w = BANGDREAM_SONG_CHART_PREVIEW_SPEC.laneCount * layout.laneWidth;
for (const note of notes) {
const time = note.time as number;
const { x, y } = getTimePosition(layout, time);
if (COUNT_LINE_NOTE_TYPES.includes(note.type)) {
if (isSongChartCountLineNoteType(note.type)) {
count++;
if (count % 50 !== 0) {
continue;
@ -955,9 +551,7 @@ export async function drawBestdoriPreview(
payload: BestdoriPreviewPayload,
chart: BestdoriNote[],
): Promise<Canvas> {
assignChartTimes(chart);
const notes = createPreviewNotes(chart);
const layout = createPreviewLayout(notes);
const { layout, notes } = createSongChartPreviewModel(chart);
const canvas = new Canvas(layout.width, layout.height);
const ctx = canvas.getContext('2d');
const [noteImages, coverImg] = await Promise.all([

View File

@ -0,0 +1,105 @@
import {
assignSongChartTimes,
BestdoriNote,
createSongChartPreviewLayout,
createSongChartPreviewModel,
createSongChartPreviewNotes,
isSongChartCountLineNoteType,
isSongChartDisplayNoteType,
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/song-chart-preview-spec';
describe('BangDream song chart preview spec', () => {
it('assigns note times from BPM changes', () => {
const chart: BestdoriNote[] = [
{ beat: 0, bpm: 120, type: 'BPM' },
{ beat: 4, bpm: 240, type: 'BPM' },
{ beat: 5, lane: 1, type: 'Single' },
];
assignSongChartTimes(chart);
expect(chart[0].time).toBe(0);
expect(chart[1].time).toBe(2);
expect(chart[2].time).toBe(2.25);
});
it('converts playable notes into preview notes', () => {
const chart: BestdoriNote[] = [
{ beat: 0, bpm: 120, type: 'BPM' },
{ beat: 1.25, lane: 1, type: 'Single' },
{ beat: 2, flick: true, lane: 2, type: 'Single' },
{ beat: 2, lane: 5, skill: true, type: 'Single' },
];
assignSongChartTimes(chart);
const notes = createSongChartPreviewNotes(chart);
expect(notes.map((note) => note.type)).toEqual([
'Sim',
'BPM',
'SingleOff',
'Flick',
'Skill',
]);
expect(notes.find((note) => note.type === 'Sim')?.lane).toEqual([2, 5]);
});
it('converts slide connections into bars, ticks and endpoints', () => {
const chart: BestdoriNote[] = [
{ beat: 0, bpm: 120, type: 'BPM' },
{
beat: 1,
connections: [
{ beat: 1, lane: 1 },
{ beat: 2, lane: 3 },
{ beat: 3, flick: true, lane: 4 },
],
type: 'Slide',
},
];
assignSongChartTimes(chart);
const notes = createSongChartPreviewNotes(chart);
expect(notes.map((note) => note.type)).toEqual([
'Bar',
'Bar',
'BPM',
'Long',
'Tick',
'Flick',
]);
expect(notes.filter((note) => note.type === 'Bar')).toHaveLength(2);
});
it('creates layout from display note duration', () => {
const layout = createSongChartPreviewLayout([
{ beat: 0, lane: 0, time: 0, type: 'BPM' },
{ beat: 1, lane: 1, time: 1, type: 'Single' },
{ beat: 120, lane: 2, time: 60, type: 'Single' },
]);
expect(layout.chartLength).toBe(61);
expect(layout.colCount).toBeGreaterThan(1);
expect(layout.secondsPerCol).toBe(layout.chartLength / layout.colCount);
});
it('classifies display and count-line note types', () => {
expect(isSongChartDisplayNoteType('Long')).toBe(true);
expect(isSongChartDisplayNoteType('BPM')).toBe(false);
expect(isSongChartCountLineNoteType('Tick')).toBe(true);
expect(isSongChartCountLineNoteType('Sim')).toBe(false);
});
it('creates preview model in one call', () => {
const chart: BestdoriNote[] = [
{ beat: 0, bpm: 120, type: 'BPM' },
{ beat: 1, lane: 1, type: 'Single' },
];
const model = createSongChartPreviewModel(chart);
expect(model.notes.some((note) => note.type === 'Single')).toBe(true);
expect(model.layout.width).toBeGreaterThan(0);
});
});