[malody] test pass !
This commit is contained in:
parent
7b110e2462
commit
b613a2d960
@ -1,3 +1,7 @@
|
|||||||
|
# v1.2.0
|
||||||
|
## Added
|
||||||
|
- [malody] 🎉 initial malody support !
|
||||||
|
|
||||||
# v1.1.3
|
# v1.1.3
|
||||||
## Fixed
|
## Fixed
|
||||||
- [jubeat-analyser] All files are read and written in `surrogateescape` error
|
- [jubeat-analyser] All files are read and written in `surrogateescape` error
|
||||||
|
@ -4,6 +4,7 @@ from enum import Enum
|
|||||||
class Format(str, Enum):
|
class Format(str, Enum):
|
||||||
EVE = "eve"
|
EVE = "eve"
|
||||||
JBSQ = "jbsq"
|
JBSQ = "jbsq"
|
||||||
|
MALODY = "malody"
|
||||||
MEMON_LEGACY = "memon:legacy"
|
MEMON_LEGACY = "memon:legacy"
|
||||||
MEMON_0_1_0 = "memon:v0.1.0"
|
MEMON_0_1_0 = "memon:v0.1.0"
|
||||||
MEMON_0_2_0 = "memon:v0.2.0"
|
MEMON_0_2_0 = "memon:v0.2.0"
|
||||||
|
@ -12,6 +12,7 @@ from .jubeat_analyser import (
|
|||||||
load_mono_column,
|
load_mono_column,
|
||||||
)
|
)
|
||||||
from .konami import dump_eve, dump_jbsq, load_eve, load_jbsq
|
from .konami import dump_eve, dump_jbsq, load_eve, load_jbsq
|
||||||
|
from .malody import dump_malody, load_malody
|
||||||
from .memon import (
|
from .memon import (
|
||||||
dump_memon_0_1_0,
|
dump_memon_0_1_0,
|
||||||
dump_memon_0_2_0,
|
dump_memon_0_2_0,
|
||||||
@ -25,6 +26,7 @@ from .typing import Dumper, Loader
|
|||||||
LOADERS: Dict[Format, Loader] = {
|
LOADERS: Dict[Format, Loader] = {
|
||||||
Format.EVE: load_eve,
|
Format.EVE: load_eve,
|
||||||
Format.JBSQ: load_jbsq,
|
Format.JBSQ: load_jbsq,
|
||||||
|
Format.MALODY: load_malody,
|
||||||
Format.MEMON_LEGACY: load_memon_legacy,
|
Format.MEMON_LEGACY: load_memon_legacy,
|
||||||
Format.MEMON_0_1_0: load_memon_0_1_0,
|
Format.MEMON_0_1_0: load_memon_0_1_0,
|
||||||
Format.MEMON_0_2_0: load_memon_0_2_0,
|
Format.MEMON_0_2_0: load_memon_0_2_0,
|
||||||
@ -37,6 +39,7 @@ LOADERS: Dict[Format, Loader] = {
|
|||||||
DUMPERS: Dict[Format, Dumper] = {
|
DUMPERS: Dict[Format, Dumper] = {
|
||||||
Format.EVE: dump_eve,
|
Format.EVE: dump_eve,
|
||||||
Format.JBSQ: dump_jbsq,
|
Format.JBSQ: dump_jbsq,
|
||||||
|
Format.MALODY: dump_malody,
|
||||||
Format.MEMON_LEGACY: dump_memon_legacy,
|
Format.MEMON_LEGACY: dump_memon_legacy,
|
||||||
Format.MEMON_0_1_0: dump_memon_0_1_0,
|
Format.MEMON_0_1_0: dump_memon_0_1_0,
|
||||||
Format.MEMON_0_2_0: dump_memon_0_2_0,
|
Format.MEMON_0_2_0: dump_memon_0_2_0,
|
||||||
|
@ -10,7 +10,7 @@ def guess_format(path: Path) -> Format:
|
|||||||
raise ValueError("Can't guess chart format for a folder")
|
raise ValueError("Can't guess chart format for a folder")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return recognize_memon_version(path)
|
return recognize_json_formats(path)
|
||||||
except (json.JSONDecodeError, UnicodeDecodeError, ValueError):
|
except (json.JSONDecodeError, UnicodeDecodeError, ValueError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@ -28,19 +28,26 @@ def guess_format(path: Path) -> Format:
|
|||||||
raise ValueError("Unrecognized file format")
|
raise ValueError("Unrecognized file format")
|
||||||
|
|
||||||
|
|
||||||
def recognize_memon_version(path: Path) -> Format:
|
def recognize_json_formats(path: Path) -> Format:
|
||||||
with path.open() as f:
|
with path.open() as f:
|
||||||
obj = json.load(f)
|
obj = json.load(f)
|
||||||
|
|
||||||
|
if not isinstance(obj, dict):
|
||||||
|
raise ValueError("Top level value is not an object")
|
||||||
|
|
||||||
|
if obj.keys() >= {"metadata", "data"}:
|
||||||
|
return recognize_memon_version(obj)
|
||||||
|
elif obj.keys() >= {"meta", "time", "note"}:
|
||||||
|
return Format.MALODY
|
||||||
|
else:
|
||||||
|
raise ValueError("Unrecognized file format")
|
||||||
|
|
||||||
|
|
||||||
|
def recognize_memon_version(obj: dict) -> Format:
|
||||||
try:
|
try:
|
||||||
version = obj["version"]
|
version = obj["version"]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
return Format.MEMON_LEGACY
|
return Format.MEMON_LEGACY
|
||||||
except TypeError:
|
|
||||||
raise ValueError(
|
|
||||||
"This JSON file is not a correct memon file : the top-level "
|
|
||||||
"value is not an object"
|
|
||||||
)
|
|
||||||
|
|
||||||
if version == "0.1.0":
|
if version == "0.1.0":
|
||||||
return Format.MEMON_0_1_0
|
return Format.MEMON_0_1_0
|
||||||
|
@ -5,7 +5,7 @@ from jubeatools.formats import Format
|
|||||||
from jubeatools.formats.konami.testutils import eve_compatible_song, open_temp_dir
|
from jubeatools.formats.konami.testutils import eve_compatible_song, open_temp_dir
|
||||||
from jubeatools.testutils.test_patterns import dump_and_load_then_compare
|
from jubeatools.testutils.test_patterns import dump_and_load_then_compare
|
||||||
|
|
||||||
from ..construct import jbsq
|
from .construct import jbsq
|
||||||
|
|
||||||
|
|
||||||
@given(eve_compatible_song())
|
@given(eve_compatible_song())
|
@ -4,3 +4,6 @@ many different games or "Modes", including jubeat (known as "Pad" Mode)
|
|||||||
|
|
||||||
The file format it uses is not that well documented but is simple enough to
|
The file format it uses is not that well documented but is simple enough to
|
||||||
make sense of without docs. It's a json file with some defined schema"""
|
make sense of without docs. It's a json file with some defined schema"""
|
||||||
|
|
||||||
|
from .dump import dump_malody
|
||||||
|
from .load import load_malody
|
||||||
|
@ -1,11 +1,14 @@
|
|||||||
from pathlib import Path
|
|
||||||
from typing import List
|
|
||||||
import json
|
|
||||||
import time
|
import time
|
||||||
|
from functools import singledispatch
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Tuple, Union
|
||||||
|
|
||||||
|
import simplejson as json
|
||||||
|
|
||||||
from jubeatools import song
|
from jubeatools import song
|
||||||
from jubeatools.formats.dump_tools import make_dumper_from_chart_file_dumper
|
from jubeatools.formats.dump_tools import make_dumper_from_chart_file_dumper
|
||||||
from jubeatools.formats.filetypes import ChartFile
|
from jubeatools.formats.filetypes import ChartFile
|
||||||
|
from jubeatools.utils import none_or
|
||||||
|
|
||||||
from . import schema as malody
|
from . import schema as malody
|
||||||
|
|
||||||
@ -14,8 +17,8 @@ def dump_malody_song(song: song.Song, **kwargs: dict) -> List[ChartFile]:
|
|||||||
res = []
|
res = []
|
||||||
for dif, chart, timing in song.iter_charts_with_timing():
|
for dif, chart, timing in song.iter_charts_with_timing():
|
||||||
malody_chart = dump_malody_chart(song.metadata, dif, chart, timing)
|
malody_chart = dump_malody_chart(song.metadata, dif, chart, timing)
|
||||||
json_chart = malody.Chart.Schema().dump(malody_chart)
|
json_chart = malody.CHART_SCHEMA.dump(malody_chart)
|
||||||
chart_bytes = json.dumps(json_chart).encode("utf-8")
|
chart_bytes = json.dumps(json_chart, indent=4, use_decimal=True).encode("utf-8")
|
||||||
res.append(ChartFile(chart_bytes, song, dif, chart))
|
res.append(ChartFile(chart_bytes, song, dif, chart))
|
||||||
|
|
||||||
return res
|
return res
|
||||||
@ -26,14 +29,17 @@ dump_malody = make_dumper_from_chart_file_dumper(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def dump_malody_chart(metadata: song.Metadata, dif: str, chart: song.Chart, timing: song.Timing) -> malody.Chart:
|
def dump_malody_chart(
|
||||||
|
metadata: song.Metadata, dif: str, chart: song.Chart, timing: song.Timing
|
||||||
|
) -> malody.Chart:
|
||||||
meta = dump_metadata(metadata, dif)
|
meta = dump_metadata(metadata, dif)
|
||||||
time = dump_timing(timing)
|
time = dump_timing(timing)
|
||||||
notes = dump_notes(chart.notes)
|
notes = dump_notes(chart.notes)
|
||||||
if metadata.audio is not None:
|
if metadata.audio is not None:
|
||||||
notes += dump_bgm(metadata.audio, timing)
|
notes += [dump_bgm(metadata.audio, timing)]
|
||||||
return malody.Chart(meta=meta, time=time, note=notes)
|
return malody.Chart(meta=meta, time=time, note=notes)
|
||||||
|
|
||||||
|
|
||||||
def dump_metadata(metadata: song.Metadata, dif: str) -> malody.Metadata:
|
def dump_metadata(metadata: song.Metadata, dif: str) -> malody.Metadata:
|
||||||
return malody.Metadata(
|
return malody.Metadata(
|
||||||
cover="",
|
cover="",
|
||||||
@ -47,20 +53,23 @@ def dump_metadata(metadata: song.Metadata, dif: str) -> malody.Metadata:
|
|||||||
title=metadata.title,
|
title=metadata.title,
|
||||||
artist=metadata.artist,
|
artist=metadata.artist,
|
||||||
id=0,
|
id=0,
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def dump_timing(timing: song.Timing) -> List[malody.BPMEvent]:
|
def dump_timing(timing: song.Timing) -> List[malody.BPMEvent]:
|
||||||
sorted_events = sorted(timing.events, key=lambda e: e.time)
|
sorted_events = sorted(timing.events, key=lambda e: e.time)
|
||||||
return [dump_bpm_change(e) for e in sorted_events]
|
return [dump_bpm_change(e) for e in sorted_events]
|
||||||
|
|
||||||
|
|
||||||
def dump_bpm_change(b: song.BPMEvent) -> malody.BPMEvent:
|
def dump_bpm_change(b: song.BPMEvent) -> malody.BPMEvent:
|
||||||
return malody.BPMEvent(
|
return malody.BPMEvent(
|
||||||
beat=beats_to_tuple(b.time),
|
beat=beats_to_tuple(b.time),
|
||||||
bpm=b.BPM,
|
bpm=b.BPM,
|
||||||
)
|
)
|
||||||
|
|
||||||
def dump_notes(notes: List[Union[song.TapNote, song.LongNote]]) -> List[Union[malody.TapNote, malody.LongNote]]:
|
|
||||||
|
def dump_notes(notes: List[Union[song.TapNote, song.LongNote]]) -> List[malody.Event]:
|
||||||
return [dump_note(n) for n in notes]
|
return [dump_note(n) for n in notes]
|
||||||
|
|
||||||
|
|
||||||
@ -78,26 +87,29 @@ def dump_tap_note(n: song.TapNote) -> malody.TapNote:
|
|||||||
index=n.position.index,
|
index=n.position.index,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@dump_note.register
|
@dump_note.register
|
||||||
def dump_long_note(n: song.LongNote) -> malody.LongNote:
|
def dump_long_note(n: song.LongNote) -> malody.LongNote:
|
||||||
return malody.LongNote(
|
return malody.LongNote(
|
||||||
beat=beats_to_tuple(n.time)
|
beat=beats_to_tuple(n.time),
|
||||||
index=n.position.index,
|
index=n.position.index,
|
||||||
endbeat=beats_to_tuple(n.time + n.duration),
|
endbeat=beats_to_tuple(n.time + n.duration),
|
||||||
endindex=n.tail_tip.index,
|
endindex=n.tail_tip.index,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def dump_bgm(audio: Path, timing: song.Timing) -> malody.Sound:
|
def dump_bgm(audio: Path, timing: song.Timing) -> malody.Sound:
|
||||||
return malody.Sound(
|
return malody.Sound(
|
||||||
beat=beats_to_tuple(song.BeatsTime(0)),
|
beat=beats_to_tuple(song.BeatsTime(0)),
|
||||||
sound=str(audio),
|
sound=str(audio),
|
||||||
vol=100,
|
vol=100,
|
||||||
offset=-int(timing.beat_zero_offset*1000),
|
offset=-int(timing.beat_zero_offset * 1000),
|
||||||
type=malody.SoundType.BACKGROUND_MUSIC,
|
type=malody.SoundType.BACKGROUND_MUSIC,
|
||||||
isBgm=None,
|
isBgm=None,
|
||||||
x=None,
|
x=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def beats_to_tuple(b: song.BeatsTime) -> Tuple[int, int, int]:
|
def beats_to_tuple(b: song.BeatsTime) -> Tuple[int, int, int]:
|
||||||
integer_part = int(b)
|
integer_part = int(b)
|
||||||
remainder = b % 1
|
remainder = b % 1
|
||||||
@ -105,4 +117,4 @@ def beats_to_tuple(b: song.BeatsTime) -> Tuple[int, int, int]:
|
|||||||
integer_part,
|
integer_part,
|
||||||
remainder.numerator,
|
remainder.numerator,
|
||||||
remainder.denominator,
|
remainder.denominator,
|
||||||
)
|
)
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import json
|
|
||||||
import warnings
|
import warnings
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from fractions import Fraction
|
from fractions import Fraction
|
||||||
@ -6,6 +5,8 @@ from functools import reduce, singledispatch
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, List, Optional, Tuple, Union
|
from typing import Any, List, Optional, Tuple, Union
|
||||||
|
|
||||||
|
import simplejson as json
|
||||||
|
|
||||||
from jubeatools import song
|
from jubeatools import song
|
||||||
from jubeatools.formats import timemap
|
from jubeatools.formats import timemap
|
||||||
from jubeatools.formats.load_tools import make_folder_loader
|
from jubeatools.formats.load_tools import make_folder_loader
|
||||||
@ -22,14 +23,14 @@ def load_malody(path: Path, **kwargs: Any) -> song.Song:
|
|||||||
|
|
||||||
def load_file(path: Path) -> Any:
|
def load_file(path: Path) -> Any:
|
||||||
with path.open() as f:
|
with path.open() as f:
|
||||||
return json.load(f)
|
return json.load(f, use_decimal=True)
|
||||||
|
|
||||||
|
|
||||||
load_folder = make_folder_loader("*.mc", load_file)
|
load_folder = make_folder_loader("*.mc", load_file)
|
||||||
|
|
||||||
|
|
||||||
def load_malody_file(raw_dict: dict) -> song.Song:
|
def load_malody_file(raw_dict: dict) -> song.Song:
|
||||||
file: malody.Chart = malody.Chart.Schema().load(raw_dict)
|
file: malody.Chart = malody.CHART_SCHEMA.load(raw_dict)
|
||||||
if file.meta.mode != malody.Mode.PAD:
|
if file.meta.mode != malody.Mode.PAD:
|
||||||
raise ValueError("This file is not a Malody Pad Chart (Malody's jubeat mode)")
|
raise ValueError("This file is not a Malody Pad Chart (Malody's jubeat mode)")
|
||||||
|
|
||||||
@ -86,7 +87,7 @@ def load_timing_info(
|
|||||||
|
|
||||||
def load_notes(events: List[malody.Event]) -> List[Union[song.TapNote, song.LongNote]]:
|
def load_notes(events: List[malody.Event]) -> List[Union[song.TapNote, song.LongNote]]:
|
||||||
# filter out sound events
|
# filter out sound events
|
||||||
notes = filter(lambda e: isinstance(e, (malody.TapNote, malody.TapNote)), events)
|
notes = filter(lambda e: isinstance(e, (malody.TapNote, malody.LongNote)), events)
|
||||||
return [load_note(n) for n in notes]
|
return [load_note(n) for n in notes]
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,34 +1,22 @@
|
|||||||
from __future__ import annotations
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
from dataclasses import field
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import ClassVar, List, Optional, Tuple, Type, Union
|
from typing import List, Optional, Tuple, Union
|
||||||
|
|
||||||
from marshmallow import Schema as ms_Schema
|
|
||||||
from marshmallow.validate import Range
|
from marshmallow.validate import Range
|
||||||
from marshmallow_dataclass import NewType, dataclass
|
from marshmallow_dataclass import NewType, class_schema
|
||||||
|
|
||||||
|
|
||||||
|
class Ordered:
|
||||||
|
class Meta:
|
||||||
|
ordered = True
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Chart:
|
class SongInfo(Ordered):
|
||||||
meta: Metadata
|
title: Optional[str]
|
||||||
time: List[BPMEvent] = field(default_factory=list)
|
artist: Optional[str]
|
||||||
note: List[Event] = field(default_factory=list)
|
|
||||||
|
|
||||||
Schema: ClassVar[Type[ms_Schema]] = ms_Schema
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Metadata:
|
|
||||||
cover: Optional[str] # path to album art ?
|
|
||||||
creator: Optional[str] # Chart author
|
|
||||||
background: Optional[str] # path to background image
|
|
||||||
version: Optional[str] # freeform difficulty name
|
|
||||||
id: Optional[int]
|
id: Optional[int]
|
||||||
mode: int
|
|
||||||
time: Optional[int] # creation timestamp ?
|
|
||||||
song: SongInfo
|
|
||||||
|
|
||||||
|
|
||||||
class Mode(int, Enum):
|
class Mode(int, Enum):
|
||||||
@ -42,22 +30,26 @@ class Mode(int, Enum):
|
|||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SongInfo:
|
class Metadata(Ordered):
|
||||||
title: Optional[str]
|
cover: Optional[str] # path to album art ?
|
||||||
artist: Optional[str]
|
creator: Optional[str] # Chart author
|
||||||
|
background: Optional[str] # path to background image
|
||||||
|
version: Optional[str] # freeform difficulty name
|
||||||
id: Optional[int]
|
id: Optional[int]
|
||||||
|
mode: int
|
||||||
|
time: Optional[int] # creation timestamp ?
|
||||||
|
song: SongInfo
|
||||||
|
|
||||||
|
|
||||||
PositiveInt = NewType("PositiveInt", int, validate=Range(min=0))
|
PositiveInt = NewType("PositiveInt", int, validate=Range(min=0))
|
||||||
BeatTime = Tuple[PositiveInt, PositiveInt, PositiveInt]
|
BeatTime = Tuple[PositiveInt, PositiveInt, PositiveInt]
|
||||||
|
|
||||||
StrictlyPositiveDecimal = NewType(
|
StrictlyPositiveDecimal = NewType(
|
||||||
"StrictlyPositiveDecimal", Decimal, validate=Range(min=0, min_inclusive=False)
|
"StrictlyPositiveDecimal", Decimal, validate=Range(min=0, min_inclusive=False)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class BPMEvent:
|
class BPMEvent(Ordered):
|
||||||
beat: BeatTime
|
beat: BeatTime
|
||||||
bpm: StrictlyPositiveDecimal
|
bpm: StrictlyPositiveDecimal
|
||||||
|
|
||||||
@ -66,13 +58,13 @@ ButtonIndex = NewType("ButtonIndex", int, validate=Range(min=0, max=15))
|
|||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class TapNote:
|
class TapNote(Ordered):
|
||||||
beat: BeatTime
|
beat: BeatTime
|
||||||
index: ButtonIndex
|
index: ButtonIndex
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class LongNote:
|
class LongNote(Ordered):
|
||||||
beat: BeatTime
|
beat: BeatTime
|
||||||
index: ButtonIndex
|
index: ButtonIndex
|
||||||
endbeat: BeatTime
|
endbeat: BeatTime
|
||||||
@ -80,7 +72,7 @@ class LongNote:
|
|||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Sound:
|
class Sound(Ordered):
|
||||||
"""Used both for the background music and keysounds"""
|
"""Used both for the background music and keysounds"""
|
||||||
|
|
||||||
beat: BeatTime
|
beat: BeatTime
|
||||||
@ -98,3 +90,13 @@ class SoundType(int, Enum):
|
|||||||
|
|
||||||
|
|
||||||
Event = Union[Sound, LongNote, TapNote]
|
Event = Union[Sound, LongNote, TapNote]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Chart(Ordered):
|
||||||
|
meta: Metadata
|
||||||
|
time: List[BPMEvent] = field(default_factory=list)
|
||||||
|
note: List[Event] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
CHART_SCHEMA = class_schema(Chart)()
|
||||||
|
32
jubeatools/formats/malody/test_malody.py
Normal file
32
jubeatools/formats/malody/test_malody.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from hypothesis import given
|
||||||
|
from hypothesis import strategies as st
|
||||||
|
|
||||||
|
from jubeatools import song
|
||||||
|
from jubeatools.formats import Format
|
||||||
|
from jubeatools.formats.konami.testutils import open_temp_dir
|
||||||
|
from jubeatools.testutils import strategies as jbst
|
||||||
|
from jubeatools.testutils.test_patterns import dump_and_load_then_compare
|
||||||
|
from jubeatools.testutils.typing import DrawFunc
|
||||||
|
|
||||||
|
|
||||||
|
@st.composite
|
||||||
|
def malody_compatible_song(draw: DrawFunc) -> song.Song:
|
||||||
|
"""Malody files only hold one chart and have limited metadata"""
|
||||||
|
diff = draw(st.sampled_from(list(song.Difficulty))).value
|
||||||
|
chart = draw(jbst.chart(level_strat=st.just(Decimal(0))))
|
||||||
|
metadata = draw(jbst.metadata())
|
||||||
|
metadata.preview = None
|
||||||
|
metadata.preview_file = None
|
||||||
|
return song.Song(metadata=metadata, charts={diff: chart})
|
||||||
|
|
||||||
|
|
||||||
|
@given(malody_compatible_song())
|
||||||
|
def test_that_full_chart_roundtrips(song: song.Song) -> None:
|
||||||
|
dump_and_load_then_compare(
|
||||||
|
Format.MALODY,
|
||||||
|
song,
|
||||||
|
temp_path=open_temp_dir(),
|
||||||
|
bytes_decoder=lambda b: b.decode("utf-8"),
|
||||||
|
)
|
Loading…
Reference in New Issue
Block a user