1
0
mirror of synced 2024-12-19 14:15:55 +01:00
tja2fumen/testing/test_conversion.py
Viv 9ce1d76ca3
Fix branching behavior for #BRANCHSTART r songs (#46)
This PR updates the behavior for songs with drumroll branching
conditions (`#BRANCHSTART r`)

The changes include:

- Correctly setting the `branchInfo` bytes for branching conditions that
occur _after_ the first condition.
- Correctly setting the `branch_points` bytes in the header when there
are only drumroll conditions.
- Correctly setting the `branch_ratio` bytes in the header when total
notes differ between branches.
- Correctly handling #SECTION commands for a number of different corner
cases:
    1. #SECTION occurs on its own without a #BRANCHSTART
    2. #BRANCHSTART occurs with a #SECTION command
3. #BRANCHSTART occurs without a #SECTION command (and is first branch
condition)
4. #BRANCHSTART occurs without a #SECTION command (and is NOT first
branch condition)


Note: I've added `shoto9` to the test suite, but the TJA file is
structured in a way that the number of measures doesn't match the number
of fumen measures. The TJA needs to be reworked, but that's okay,
because its purpose was just to check that the branching bytes were
correct.

Fixes #40.
2023-07-19 16:47:08 -04:00

177 lines
9.7 KiB
Python

import os
import shutil
import zipfile
import re
import glob
import pytest
from tja2fumen import main as convert
from tja2fumen.parsers import readFumen
from tja2fumen.constants import COURSE_IDS, NORMALIZE_COURSE
@pytest.mark.parametrize('id_song', [
pytest.param('shoto9', marks=pytest.mark.skip("TJA structure does not match fumen yet.")),
pytest.param('genpe'),
pytest.param('gimcho'),
pytest.param('imcanz'),
pytest.param('clsca'),
pytest.param('linda'),
pytest.param('senpac'),
pytest.param('butou5'),
pytest.param('hol6po'),
pytest.param('mikdp'),
pytest.param('ia6cho'),
])
def test_converted_tja_vs_cached_fumen(id_song, tmp_path, entry_point):
# Define the testing directory
path_test = os.path.dirname(os.path.realpath(__file__))
# Define the working directory
path_temp = os.path.join(tmp_path, id_song)
os.mkdir(path_temp)
# Copy input TJA to working directory
path_tja = os.path.join(path_test, "data", f"{id_song}.tja")
path_tja_tmp = os.path.join(path_temp, f"{id_song}.tja")
shutil.copy(path_tja, path_tja_tmp)
# Convert TJA file to fumen files
if entry_point == "python-api":
convert(argv=[path_tja_tmp])
elif entry_point == "python-cli":
os.system(f"tja2fumen {path_tja_tmp}")
elif entry_point == "exe":
exe_path = glob.glob(os.path.join(os.path.split(path_test)[0], "dist", "*.exe"))[0]
os.system(f"{exe_path} {path_tja_tmp}")
# Fetch output fumen paths
paths_out = glob.glob(os.path.join(path_temp, "*.bin"))
assert paths_out, f"No bin files generated in {path_temp}"
order = "xmhne" # Ura Oni -> Oni -> Hard -> Normal -> Easy
paths_out = sorted(paths_out, key=lambda s: [order.index(c) if c in order else len(order) for c in s])
# Extract cached fumen files to working directory
path_binzip = os.path.join(path_test, "data", f"{id_song}.zip")
path_bin = os.path.join(path_temp, "ca_bins")
with zipfile.ZipFile(path_binzip, 'r') as zip_ref:
zip_ref.extractall(path_bin)
# Compare cached fumen with generated fumen
for path_out in paths_out:
# Difficulty introspection to help with debugging
i_difficult_id = os.path.basename(path_out).split(".")[0].split("_")[1]
i_difficulty = NORMALIZE_COURSE[{v: k for k, v in COURSE_IDS.items()}[i_difficult_id]] # noqa
# 0. Read fumen data (converted vs. cached)
co_song = readFumen(path_out, exclude_empty_measures=True)
ca_song = readFumen(os.path.join(path_bin, os.path.basename(path_out)), exclude_empty_measures=True)
# 1. Check song headers
checkValidHeader(co_song.header)
checkValidHeader(ca_song.header)
# 2. Check song metadata
assert_song_property(co_song.header, ca_song.header, 'order')
assert_song_property(co_song.header, ca_song.header, 'b432_b435_has_branches')
assert_song_property(co_song, ca_song, 'scoreInit')
assert_song_property(co_song, ca_song, 'scoreDiff')
# 3. Check measure data
for i_measure in range(max([len(co_song.measures), len(ca_song.measures)])):
# NB: We could assert that len(measures) is the same for both songs, then iterate through zipped measures.
# But, if there is a mismatched number of measures, we want to know _where_ it occurs. So, we let the
# comparison go on using the max length of both songs until something else fails.
co_measure = co_song.measures[i_measure]
ca_measure = ca_song.measures[i_measure]
# 3a. Check measure metadata
assert_song_property(co_measure, ca_measure, 'bpm', i_measure, abs=0.01)
assert_song_property(co_measure, ca_measure, 'fumenOffsetStart', i_measure, abs=0.15)
assert_song_property(co_measure, ca_measure, 'gogo', i_measure)
assert_song_property(co_measure, ca_measure, 'barline', i_measure)
# NB: KAGEKIYO's fumen has some strange details that can't be replicated using the TJA charting format.
# So, for now, we use a special case to skip checking A) notes for certain measures and B) branchInfo
if id_song == 'genpe':
# A) The 2/4 measures in the Ura of KAGEKIYO's official Ura fumen don't match the wikiwiki.jp/TJA
# charts. In the official fumen, the note ms offsets of branches 5/12/17/etc. go _past_ the duration of
# the measure. This behavior is impossible to represent using the TJA format, so we skip checking notes
# for these measures, since the rest of the measures have perfect note ms offsets anyway.
if i_difficult_id == "x" and i_measure in [5, 6, 12, 13, 17, 18, 26, 27, 46, 47, 51, 52, 56, 57]:
continue
# B) The branching condition for KAGEKIYO is very strange (accuracy for the 7 big notes in the song)
# So, we only test the branchInfo bytes for non-KAGEKIYO songs:
else:
assert_song_property(co_measure, ca_measure, 'branchInfo', i_measure)
# 3b. Check measure notes
for i_branch in ['normal', 'advanced', 'master']:
co_branch = co_measure.branches[i_branch]
ca_branch = ca_measure.branches[i_branch]
# NB: We only check speed for non-empty branches, as fumens store speed changes even for empty branches
if co_branch.length != 0:
assert_song_property(co_branch, ca_branch, 'speed', i_measure, i_branch)
# NB: We could assert that len(notes) is the same for both songs, then iterate through zipped notes.
# But, if there is a mismatched number of notes, we want to know _where_ it occurs. So, we let the
# comparison go on using the max length of both branches until something else fails.
for i_note in range(max([co_branch.length, ca_branch.length])):
co_note = co_branch.notes[i_note]
ca_note = ca_branch.notes[i_note]
assert_song_property(co_note, ca_note, 'note_type', i_measure, i_branch, i_note, func=normalize_type)
assert_song_property(co_note, ca_note, 'pos', i_measure, i_branch, i_note, abs=0.1)
# NB: Drumroll duration doesn't always end exactly on a beat. Plus, TJA charters often eyeball
# drumrolls, leading them to be often off by a 1/4th/8th/16th/32th/etc. These charting errors
# are fixable, but tedious to do when writing tests. So, I've added a try/except so that they
# can be checked locally with a breakpoint when adding new songs, but so that fixing every
# duration-related chart error isn't 100% mandatory.
try:
assert_song_property(co_note, ca_note, 'duration', i_measure, i_branch, i_note, abs=25.0)
except AssertionError:
pass
if ca_note.note_type not in ["Balloon", "Kusudama"]:
assert_song_property(co_note, ca_note, 'scoreInit', i_measure, i_branch, i_note)
assert_song_property(co_note, ca_note, 'scoreDiff', i_measure, i_branch, i_note)
# NB: 'item' still needs to be implemented: https://github.com/vivaria/tja2fumen/issues/17
# assert_song_property(co_note, ca_note, 'item', i_measure, i_branch, i_note)
def assert_song_property(converted_obj, cached_obj, prop, measure=None, branch=None, note=None, func=None, abs=None):
# NB: TJA parser/converter uses 0-based indexing, but TJA files use 1-based indexing.
# So, we increment 1 in the error message to more easily identify problematic lines in TJA files.
msg_failure = f"'{prop}' mismatch"
msg_failure += f": measure '{measure+1}'" if measure is not None else ""
msg_failure += f", branch '{branch}'" if branch is not None else ""
msg_failure += f", note '{note+1}'" if note is not None else ""
converted_val = converted_obj.__getattribute__(prop)
cached_val = cached_obj.__getattribute__(prop)
if func:
assert func(converted_val) == func(cached_val), msg_failure
elif abs:
assert converted_val == pytest.approx(cached_val, abs=abs), msg_failure
else:
assert converted_val == cached_val, msg_failure
def normalize_type(note_type):
return re.sub(r'[0-9]', '', note_type)
def checkValidHeader(header):
assert len(header.raw_bytes) == 520
assert header.b432_b435_has_branches in [0, 1]
assert header.b436_b439_hp_max == 10000
assert header.b440_b443_hp_clear in [6000, 7000, 8000]
assert 10 <= header.b444_b447_hp_gain_good <= 1020
assert 5 <= header.b448_b451_hp_gain_ok <= 1020
assert -765 <= header.b452_b455_hp_loss_bad <= -20
assert header.b456_b459_normal_normal_ratio <= 65536
assert header.b460_b463_normal_professional_ratio <= 65536
assert header.b464_b467_normal_master_ratio <= 65536
assert header.b468_b471_branch_points_good in [20, 0, 1, 2]
assert header.b472_b475_branch_points_ok in [10, 0, 1]
assert header.b476_b479_branch_points_bad == 0
assert header.b480_b483_branch_points_drumroll in [1, 0]
assert header.b484_b487_branch_points_good_BIG in [20, 0, 1, 2]
assert header.b488_b491_branch_points_ok_BIG in [10, 0, 1]
assert header.b492_b495_branch_points_drumroll_BIG in [1, 0]
assert header.b496_b499_branch_points_balloon in [30, 0, 1]
assert header.b500_b503_branch_points_kusudama in [30, 0]