From 9e0c4f5c2b92f813382ad9500c8bd3bf76d40371 Mon Sep 17 00:00:00 2001 From: Stepland <16676308+Stepland@users.noreply.github.com> Date: Mon, 3 May 2021 23:10:48 +0200 Subject: [PATCH] Fix a lot of jubeat analyser bugs --- CHANGELOG.md | 10 ++- jubeatools/formats/jubeat_analyser/command.py | 36 +++++++- .../formats/jubeat_analyser/dump_tools.py | 12 +-- .../formats/jubeat_analyser/load_tools.py | 25 +++--- .../formats/jubeat_analyser/memo/dump.py | 13 ++- .../formats/jubeat_analyser/memo/load.py | 86 +++++++++++++------ .../formats/jubeat_analyser/memo1/dump.py | 17 ++-- .../formats/jubeat_analyser/memo1/load.py | 71 ++++++++++----- .../formats/jubeat_analyser/memo2/dump.py | 14 +-- .../formats/jubeat_analyser/memo2/load.py | 35 ++++++-- .../jubeat_analyser/mono_column/dump.py | 6 +- .../jubeat_analyser/mono_column/load.py | 36 ++++---- .../jubeat_analyser/tests/memo/example2.py | 29 +++++++ .../jubeat_analyser/tests/memo/example3.py | 32 +++++++ .../jubeat_analyser/tests/memo/test_memo.py | 48 ++++++----- .../jubeat_analyser/tests/memo1/__init__.py | 0 .../jubeat_analyser/tests/memo1/example1.py | 36 ++++++++ .../jubeat_analyser/tests/memo1/test_memo1.py | 44 ++++++++++ .../jubeat_analyser/tests/memo2/__init__.py | 0 .../jubeat_analyser/tests/memo2/example1.py | 32 +++++++ .../jubeat_analyser/tests/memo2/example2.py | 32 +++++++ .../jubeat_analyser/tests/memo2/example3.py | 32 +++++++ .../tests/{ => memo2}/test_memo2.py | 21 ++++- .../tests/mono_column/__init__.py | 0 .../{ => mono_column}/test_mono_column.py | 2 +- .../test_mono_column_hypothesis.py | 20 +++-- .../jubeat_analyser/tests/test_command.py | 11 +++ .../jubeat_analyser/tests/test_memo1.py | 43 ---------- .../jubeat_analyser/tests/test_utils.py | 55 ++++++++++++ jubeatools/testutils/strategies.py | 25 ++++-- 30 files changed, 627 insertions(+), 196 deletions(-) create mode 100644 jubeatools/formats/jubeat_analyser/tests/memo/example2.py create mode 100644 jubeatools/formats/jubeat_analyser/tests/memo/example3.py create mode 100644 jubeatools/formats/jubeat_analyser/tests/memo1/__init__.py create mode 100644 jubeatools/formats/jubeat_analyser/tests/memo1/example1.py create mode 100644 jubeatools/formats/jubeat_analyser/tests/memo1/test_memo1.py create mode 100644 jubeatools/formats/jubeat_analyser/tests/memo2/__init__.py create mode 100644 jubeatools/formats/jubeat_analyser/tests/memo2/example1.py create mode 100644 jubeatools/formats/jubeat_analyser/tests/memo2/example2.py create mode 100644 jubeatools/formats/jubeat_analyser/tests/memo2/example3.py rename jubeatools/formats/jubeat_analyser/tests/{ => memo2}/test_memo2.py (58%) create mode 100644 jubeatools/formats/jubeat_analyser/tests/mono_column/__init__.py rename jubeatools/formats/jubeat_analyser/tests/{ => mono_column}/test_mono_column.py (98%) rename jubeatools/formats/jubeat_analyser/tests/{ => mono_column}/test_mono_column_hypothesis.py (76%) create mode 100644 jubeatools/formats/jubeat_analyser/tests/test_command.py delete mode 100644 jubeatools/formats/jubeat_analyser/tests/test_memo1.py create mode 100644 jubeatools/formats/jubeat_analyser/tests/test_utils.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a33e07..bae8928 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,14 @@ # Unreleased ## Fixed -- [jubeat-analyser] Raise exception earlier when a mono-column file is detected by the other #memo parsers (based on "--" separator lines) +- [jubeat-analyser] + - Raise exception earlier when a mono-column file is detected by the other #memo parsers (based on "--" separator lines) + - [#memo] [#memo1] + - Fix incorrect handling of mid-chart `t=` and `b=` commands + - Prettify rendering by adding more blank lines between sections + - [#memo1] Fix dumping of chart with bpm changes happening on beat times that aren't multiples of 1/4 + - [#memo2] + - Fix parsing of BPM changes + - Fix dumping of BPM changes # v0.1.3 ## Changed diff --git a/jubeatools/formats/jubeat_analyser/command.py b/jubeatools/formats/jubeat_analyser/command.py index 4da5c20..3f99e4e 100644 --- a/jubeatools/formats/jubeat_analyser/command.py +++ b/jubeatools/formats/jubeat_analyser/command.py @@ -42,7 +42,7 @@ command_grammar = Grammar( equals_value = ws "=" ws value value = value_in_quotes / number value_in_quotes = '"' quoted_value '"' - quoted_value = ~r"[^\"]*" + quoted_value = ~r"([^\"\\]|\\\"|\\\\)*" number = ~r"-?\d+(\.\d+)?" ws = ~r"[\t ]*" comment = ~r"//.*" @@ -76,7 +76,7 @@ class CommandVisitor(NodeVisitor): self.key = letter.text def visit_quoted_value(self, node: Node, visited_children: List[Node]) -> None: - self.value = node.text + self.value = parse_value(node.text) def visit_number(self, node: Node, visited_children: List[Node]) -> None: self.value = node.text @@ -115,8 +115,38 @@ def dump_command(key: str, value: Any = None) -> str: if isinstance(value, Number): value_part = f"={value}" elif value is not None: - value_part = f'="{value}"' + escaped = dump_value(str(value)) + value_part = f'="{escaped}"' else: value_part = "" return key_part + value_part + + +BACKSLASH = "\\" + + +def parse_value(escaped: str) -> str: + """Unescapes a backslash-escaped string""" + res = [] + i = 0 + while i < len(escaped): + char = escaped[i] + if char == BACKSLASH: + if i + 1 == len(escaped): + raise ValueError("backslash at end of string") + else: + i += 1 + + res.append(escaped[i]) + i += 1 + + return "".join(res) + + +ESCAPE_TABLE = str.maketrans({'"': BACKSLASH + '"', BACKSLASH: BACKSLASH + BACKSLASH}) + + +def dump_value(value: str) -> str: + """backslash-escapes \ and " from a string""" + return value.translate(ESCAPE_TABLE) diff --git a/jubeatools/formats/jubeat_analyser/dump_tools.py b/jubeatools/formats/jubeat_analyser/dump_tools.py index e178fc7..7a5a00c 100644 --- a/jubeatools/formats/jubeat_analyser/dump_tools.py +++ b/jubeatools/formats/jubeat_analyser/dump_tools.py @@ -132,7 +132,7 @@ class SortedDefaultDict(SortedDict, Generic[K, V]): @dataclass class _JubeatAnalyerDumpedSection: current_beat: BeatsTime - length: Decimal = Decimal(4) + length: BeatsTime = BeatsTime(4) commands: Dict[str, Optional[str]] = field(default_factory=dict) symbol_definitions: Dict[BeatsTime, str] = field(default_factory=dict) symbols: Dict[BeatsTime, str] = field(default_factory=dict) @@ -188,15 +188,15 @@ def create_sections_from_chart( header = sections[BeatsTime(0)].commands header["o"] = int(timing.beat_zero_offset * 1000) - header["lev"] = int(chart.level) + header["lev"] = Decimal(chart.level) header["dif"] = DIFFICULTIES.get(difficulty, 3) - if metadata.audio: + if metadata.audio is not None: header["m"] = metadata.audio - if metadata.title: + if metadata.title is not None: header["title"] = metadata.title - if metadata.artist: + if metadata.artist is not None: header["artist"] = metadata.artist - if metadata.cover: + if metadata.cover is not None: header["jacket"] = metadata.cover if metadata.preview is not None: header["prevpos"] = int(metadata.preview.start * 1000) diff --git a/jubeatools/formats/jubeat_analyser/load_tools.py b/jubeatools/formats/jubeat_analyser/load_tools.py index 0dd32a7..ba1acae 100644 --- a/jubeatools/formats/jubeat_analyser/load_tools.py +++ b/jubeatools/formats/jubeat_analyser/load_tools.py @@ -35,10 +35,10 @@ from .symbols import ( DIFFICULTIES = {1: "BSC", 2: "ADV", 3: "EXT"} -SYMBOL_TO_DECIMAL_TIME = {c: Decimal("0.25") * i for i, c in enumerate(NOTE_SYMBOLS)} +SYMBOL_TO_BEATS_TIME = {c: BeatsTime("1/4") * i for i, c in enumerate(NOTE_SYMBOLS)} -CIRCLE_FREE_TO_DECIMAL_TIME = { - c: Decimal("0.25") * i for i, c in enumerate(CIRCLE_FREE_SYMBOLS) +CIRCLE_FREE_TO_BEATS_TIME = { + c: BeatsTime("1/4") * i for i, c in enumerate(CIRCLE_FREE_SYMBOLS) } CIRCLE_FREE_TO_NOTE_SYMBOL = dict(zip(CIRCLE_FREE_SYMBOLS, NOTE_SYMBOLS)) @@ -301,14 +301,14 @@ def is_simple_solution(solution: Solution, domains: Candidates) -> bool: class JubeatAnalyserParser: def __init__(self) -> None: self.music: Optional[str] = None - self.symbols = deepcopy(SYMBOL_TO_DECIMAL_TIME) - self.section_starting_beat = Decimal("0") + self.symbols = deepcopy(SYMBOL_TO_BEATS_TIME) + self.section_starting_beat = BeatsTime(0) self.current_tempo = Decimal(120) self.timing_events: List[BPMEvent] = [] self.offset = 0 - self.beats_per_section = Decimal(4) + self.beats_per_section = BeatsTime(4) self.bytes_per_panel = 2 - self.level = 1 + self.level = Decimal(1) self.difficulty: Optional[str] = None self.title: Optional[str] = None self.artist: Optional[str] = None @@ -329,7 +329,7 @@ class JubeatAnalyserParser: method() def do_b(self, value: str) -> None: - self.beats_per_section = Decimal(value) + self.beats_per_section = decimal_to_beats(Decimal(value)) def do_m(self, value: str) -> None: self.music = value @@ -344,7 +344,7 @@ class JubeatAnalyserParser: self.current_tempo = Decimal(value) self.timing_events.append( BPMEvent( - BeatsTime(self.section_starting_beat), + time=self._current_beat(), BPM=self.current_tempo, ) ) @@ -356,7 +356,7 @@ class JubeatAnalyserParser: do_ph = do_pw def do_lev(self, value: str) -> None: - self.level = int(value) + self.level = Decimal(value) def do_dif(self, value: str) -> None: dif = int(value) @@ -430,7 +430,7 @@ class JubeatAnalyserParser: f"{self.beats_per_section} beats, a symbol cannot happen " f"afterwards at {timing}" ) - self.symbols[symbol] = timing + self.symbols[symbol] = decimal_to_beats(timing) def is_short_line(self, line: str) -> bool: return len(line.encode("shift-jis-2004")) < self.bytes_per_panel * 4 @@ -449,6 +449,9 @@ class JubeatAnalyserParser: f"in mono-column format (1列形式) there should be no {format_} line" ) + def _current_beat(self) -> BeatsTime: + raise NotImplementedError + @dataclass class DoubleColumnFrame: diff --git a/jubeatools/formats/jubeat_analyser/memo/dump.py b/jubeatools/formats/jubeat_analyser/memo/dump.py index 4a6100e..ae5167a 100644 --- a/jubeatools/formats/jubeat_analyser/memo/dump.py +++ b/jubeatools/formats/jubeat_analyser/memo/dump.py @@ -54,7 +54,7 @@ class Frame: positions: Dict[NotePosition, str] = field(default_factory=dict) bars: Dict[int, Dict[int, str]] = field(default_factory=dict) - def dump(self, length: Decimal) -> Iterator[str]: + def dump(self, length: BeatsTime) -> Iterator[str]: self.raise_if_unfit() for pos, bar in zip_longest(self.dump_positions(), self.dump_bars(length)): if bar is None: @@ -84,7 +84,7 @@ class Frame: for x in range(4) ) - def dump_bars(self, length: Decimal) -> Iterator[str]: + def dump_bars(self, length: BeatsTime) -> Iterator[str]: all_bars = [] for i in range(ceil(length * 4)): bar_index = i // 4 @@ -219,6 +219,10 @@ def _raise_if_unfit_for_memo(chart: Chart, timing: Timing, circle_free: bool) -> ) +def _section_factory(b: BeatsTime) -> MemoDumpedSection: + return MemoDumpedSection(current_beat=b) + + def _dump_memo_chart( difficulty: str, chart: Chart, @@ -257,8 +261,9 @@ def _dump_memo_chart( file = StringIO() file.write(f"// Converted using jubeatools {__version__}\n") file.write(f"// https://github.com/Stepland/jubeatools\n\n") - for _, section in sections.items(): - file.write(section.render(circle_free) + "\n\n") + file.write( + "\n\n".join(section.render(circle_free) for _, section in sections.items()) + ) return file diff --git a/jubeatools/formats/jubeat_analyser/memo/load.py b/jubeatools/formats/jubeat_analyser/memo/load.py index 6ecdcd6..b49b6a8 100644 --- a/jubeatools/formats/jubeat_analyser/memo/load.py +++ b/jubeatools/formats/jubeat_analyser/memo/load.py @@ -13,6 +13,7 @@ from more_itertools import collapse, mark_ends from parsimonious import Grammar, NodeVisitor, ParseError from jubeatools.song import ( + BeatsTime, Chart, LongNote, Metadata, @@ -28,7 +29,6 @@ from jubeatools.utils import none_or from ..command import is_command, parse_command from ..files import load_files from ..load_tools import ( - CIRCLE_FREE_TO_DECIMAL_TIME, CIRCLE_FREE_TO_NOTE_SYMBOL, EMPTY_BEAT_SYMBOLS, LONG_ARROWS, @@ -53,18 +53,18 @@ from ..symbols import CIRCLE_FREE_SYMBOLS, NOTE_SYMBOLS class MemoFrame(DoubleColumnFrame): @property - def duration(self) -> Decimal: - res = 0 - for t in self.timing_part: - res += len(t) - return Decimal("0.25") * res + def duration(self) -> BeatsTime: + # This is wrong for the last frame in a section if the section has a + # decimal beat length that's not a multiple of 1/4 + number_of_symbols = sum(len(t) for t in self.timing_part) + return BeatsTime("1/4") * number_of_symbols @dataclass class MemoLoadedSection: frames: List[MemoFrame] - symbols: Dict[str, Decimal] - length: Decimal + symbols: Dict[str, BeatsTime] + length: BeatsTime tempo: Decimal def __str__(self) -> str: @@ -87,15 +87,26 @@ class MemoParser(JubeatAnalyserParser): def __init__(self) -> None: super().__init__() self.current_chart_lines: List[DoubleColumnChartLine] = [] - self.symbols: Dict[str, Decimal] = {} - self.frames: List[MemoFrame] = [] + self.current_frames: List[MemoFrame] = [] self.sections: List[MemoLoadedSection] = [] def do_memo(self) -> None: ... + def do_b(self, value: str) -> None: + """Because of the way the parser works, + b= commands must mark the end of a section to be properly taken into + account""" + if self.current_chart_lines: + raise SyntaxError("Found a b= command before the end of a frame") + if self.current_frames and self._frames_duration() < self.beats_per_section: + raise SyntaxError("Found a b= command before the end of a section") + + self._push_section() + super().do_b(value) + def do_bpp(self, value: str) -> None: - if self.sections or self.frames: + if self.sections or self.current_frames: raise ValueError("jubeatools does not handle changes of #bpp halfway") else: self._do_bpp(value) @@ -106,10 +117,23 @@ class MemoParser(JubeatAnalyserParser): if len(self.current_chart_lines) == 4: self._push_frame() - def _frames_duration(self) -> Decimal: - return sum((frame.duration for frame in self.frames), start=Decimal(0)) + def _frames_duration(self) -> BeatsTime: + return sum( + (frame.duration for frame in self.current_frames), start=BeatsTime(0) + ) + + def _current_beat(self) -> BeatsTime: + # If we've already seen enough beats, we need to circumvent the wrong + # duration computation + if self._frames_duration() >= self.beats_per_section: + frames_duration = self.beats_per_section + else: + frames_duration = self._frames_duration() + + return self.section_starting_beat + frames_duration def _push_frame(self) -> None: + """Take all chart lines and push them to a new frame""" position_part = [ self._split_chart_line(memo_line.position) for memo_line in self.current_chart_lines @@ -127,19 +151,25 @@ class MemoParser(JubeatAnalyserParser): # then the current frame starts a new section self._push_section() - self.frames.append(frame) + self.current_frames.append(frame) self.current_chart_lines = [] def _push_section(self) -> None: + """Take all currently stacked frames and push them to a new section, + Move time forward by the number of beats per section""" + if not self.current_frames: + raise RuntimeError( + "Tried pushing a new section but no frames are currently stacked" + ) self.sections.append( MemoLoadedSection( - frames=self.frames, + frames=self.current_frames, symbols=deepcopy(self.symbols), length=self.beats_per_section, tempo=self.current_tempo, ) ) - self.frames = [] + self.current_frames = [] self.section_starting_beat += self.beats_per_section def finish_last_few_notes(self) -> None: @@ -179,20 +209,22 @@ class MemoParser(JubeatAnalyserParser): def _iter_frames( self, - ) -> Iterator[Tuple[Mapping[str, Decimal], MemoFrame, Decimal, MemoLoadedSection]]: + ) -> Iterator[ + Tuple[Mapping[str, BeatsTime], MemoFrame, BeatsTime, MemoLoadedSection] + ]: """iterate over tuples of currently_defined_symbols, frame_starting_beat, frame, section_starting_beat, section""" - local_symbols: Dict[str, Decimal] = {} - section_starting_beat = Decimal(0) + local_symbols: Dict[str, BeatsTime] = {} + section_starting_beat = BeatsTime(0) for section in self.sections: - frame_starting_beat = Decimal(0) + frame_starting_beat = BeatsTime(0) for i, frame in enumerate(section.frames): if frame.timing_part: frame_starting_beat = sum( - (f.duration for f in section.frames[:i]), start=Decimal(0) + (f.duration for f in section.frames[:i]), start=BeatsTime(0) ) local_symbols = { - symbol: Decimal("0.25") * i + frame_starting_beat + symbol: BeatsTime("1/4") * i + frame_starting_beat for i, symbol in enumerate(collapse(frame.timing_part)) if symbol not in EMPTY_BEAT_SYMBOLS } @@ -232,7 +264,7 @@ class MemoParser(JubeatAnalyserParser): continue should_skip.add(pos) - note_time = decimal_to_beats(section_starting_beat + symbol_time) + note_time = section_starting_beat + symbol_time yield unfinished_long.ends_at(note_time) unfinished_longs = { @@ -253,7 +285,7 @@ class MemoParser(JubeatAnalyserParser): should_skip.add(note_pos) symbol = frame.position_part[note_pos.y][note_pos.x] symbol_time = currently_defined_symbols[symbol] - note_time = decimal_to_beats(section_starting_beat + symbol_time) + note_time = section_starting_beat + symbol_time unfinished_longs[note_pos] = UnfinishedLongNote( time=note_time, position=note_pos, tail_tip=arrow_pos ) @@ -268,7 +300,7 @@ class MemoParser(JubeatAnalyserParser): symbol_time = currently_defined_symbols[symbol] except KeyError: continue - note_time = decimal_to_beats(section_starting_beat + symbol_time) + note_time = section_starting_beat + symbol_time yield TapNote(note_time, position) def _iter_notes_without_longs(self) -> Iterator[TapNote]: @@ -285,7 +317,7 @@ class MemoParser(JubeatAnalyserParser): symbol_time = currently_defined_symbols[symbol] except KeyError: continue - note_time = decimal_to_beats(section_starting_beat + symbol_time) + note_time = section_starting_beat + symbol_time position = NotePosition(x, y) yield TapNote(note_time, position) @@ -296,7 +328,7 @@ def _load_memo_file(lines: List[str]) -> Song: try: parser.load_line(raw_line) except Exception as e: - raise SyntaxError(f"On line {i}\n{e}") from None + raise SyntaxError(f"On line {i+1}\n{e}") parser.finish_last_few_notes() metadata = Metadata( diff --git a/jubeatools/formats/jubeat_analyser/memo1/dump.py b/jubeatools/formats/jubeat_analyser/memo1/dump.py index 948b44e..cbee23f 100644 --- a/jubeatools/formats/jubeat_analyser/memo1/dump.py +++ b/jubeatools/formats/jubeat_analyser/memo1/dump.py @@ -55,7 +55,7 @@ class Frame: positions: Dict[NotePosition, str] = field(default_factory=dict) bars: Dict[int, List[str]] = field(default_factory=dict) - def dump(self, length: Decimal) -> Iterator[str]: + def dump(self, length: BeatsTime) -> Iterator[str]: # Check that bars are contiguous for a, b in windowed(sorted(self.bars), 2): if a is not None and b is not None: @@ -77,7 +77,7 @@ class Frame: for x in range(4) ) - def dump_bars(self, length: Decimal) -> Iterator[str]: + def dump_bars(self, length: BeatsTime) -> Iterator[str]: for i in range(ceil(length)): if i in self.bars: yield f"|{''.join(self.bars[i])}|" @@ -110,7 +110,9 @@ class Memo1DumpedSection(JubeatAnalyserDumpedSection): symbols_iterator = iter(NOTE_SYMBOLS) for bar_index in range(ceil(self.length)): notes = notes_by_bar.get(bar_index, []) - bar_length = lcm(*(note.time.denominator for note in notes)) + bar_length = lcm( + *((note.time - self.current_beat).denominator for note in notes) + ) if bar_length < 3: bar_length = 4 bar_dict: Dict[int, str] = {} @@ -215,6 +217,10 @@ def _raise_if_unfit_for_memo1( ) +def _section_factory(b: BeatsTime) -> Memo1DumpedSection: + return Memo1DumpedSection(current_beat=b) + + def _dump_memo1_chart( difficulty: str, chart: Chart, @@ -236,8 +242,9 @@ def _dump_memo1_chart( file = StringIO() file.write(f"// Converted using jubeatools {__version__}\n") file.write(f"// https://github.com/Stepland/jubeatools\n\n") - for _, section in sections.items(): - file.write(section.render(circle_free) + "\n") + file.write( + "\n\n".join(section.render(circle_free) for _, section in sections.items()) + ) return file diff --git a/jubeatools/formats/jubeat_analyser/memo1/load.py b/jubeatools/formats/jubeat_analyser/memo1/load.py index 59d258a..9364523 100644 --- a/jubeatools/formats/jubeat_analyser/memo1/load.py +++ b/jubeatools/formats/jubeat_analyser/memo1/load.py @@ -29,7 +29,6 @@ from jubeatools.utils import none_or from ..command import is_command, parse_command from ..files import load_files from ..load_tools import ( - CIRCLE_FREE_TO_DECIMAL_TIME, CIRCLE_FREE_TO_NOTE_SYMBOL, EMPTY_BEAT_SYMBOLS, LONG_ARROWS, @@ -54,14 +53,16 @@ from ..symbols import CIRCLE_FREE_SYMBOLS, NOTE_SYMBOLS class Memo1Frame(DoubleColumnFrame): @property - def duration(self) -> Decimal: - return Decimal(len(self.timing_part)) + def duration(self) -> BeatsTime: + # This is wrong for the last frame in a section if the section has a + # length in beats that's not an integer + return BeatsTime(len(self.timing_part)) @dataclass class Memo1LoadedSection: frames: List[Memo1Frame] - length: Decimal + length: BeatsTime tempo: Decimal def __str__(self) -> str: @@ -82,14 +83,26 @@ class Memo1Parser(JubeatAnalyserParser): def __init__(self) -> None: super().__init__() self.current_chart_lines: List[DoubleColumnChartLine] = [] - self.frames: List[Memo1Frame] = [] + self.current_frames: List[Memo1Frame] = [] self.sections: List[Memo1LoadedSection] = [] def do_memo1(self) -> None: ... + def do_b(self, value: str) -> None: + """Because of the way the parser works, + b= commands must mark the end of a section to be properly taken into + account""" + if self.current_chart_lines: + raise SyntaxError("Found a b= command before the end of a frame") + if self.current_frames and self._frames_duration() < self.beats_per_section: + raise SyntaxError("Found a b= command before the end of a section") + + self._push_section() + super().do_b(value) + def do_bpp(self, value: str) -> None: - if self.sections or self.frames: + if self.sections or self.current_frames: raise ValueError("jubeatools does not handle changes of #bpp halfway") else: self._do_bpp(value) @@ -100,8 +113,20 @@ class Memo1Parser(JubeatAnalyserParser): if len(self.current_chart_lines) == 4: self._push_frame() - def _frames_duration(self) -> Decimal: - return sum((frame.duration for frame in self.frames), start=Decimal(0)) + def _frames_duration(self) -> BeatsTime: + return sum( + (frame.duration for frame in self.current_frames), start=BeatsTime(0) + ) + + def _current_beat(self) -> BeatsTime: + # If we've already seen enough beats, we need to circumvent the wrong + # duration computation + if self._frames_duration() >= self.beats_per_section: + frames_duration = self.beats_per_section + else: + frames_duration = self._frames_duration() + + return self.section_starting_beat + frames_duration def _push_frame(self) -> None: position_part = [ @@ -121,18 +146,24 @@ class Memo1Parser(JubeatAnalyserParser): # then the current frame starts a new section self._push_section() - self.frames.append(frame) + self.current_frames.append(frame) self.current_chart_lines = [] def _push_section(self) -> None: + """Take all currently stacked frames and push them to a new section, + Move time forward by the number of beats per section""" + if not self.current_frames: + raise RuntimeError( + "Tried pushing a new section but no frames are currently stacked" + ) self.sections.append( Memo1LoadedSection( - frames=deepcopy(self.frames), + frames=deepcopy(self.current_frames), length=self.beats_per_section, tempo=self.current_tempo, ) ) - self.frames = [] + self.current_frames = [] self.section_starting_beat += self.beats_per_section def finish_last_few_notes(self) -> None: @@ -170,23 +201,23 @@ class Memo1Parser(JubeatAnalyserParser): def _iter_frames( self, ) -> Iterator[ - Tuple[Mapping[str, BeatsTime], Memo1Frame, Decimal, Memo1LoadedSection] + Tuple[Mapping[str, BeatsTime], Memo1Frame, BeatsTime, Memo1LoadedSection] ]: """iterate over tuples of currently_defined_symbols, frame, section_starting_beat, section""" local_symbols = {} - section_starting_beat = Decimal(0) + section_starting_beat = BeatsTime(0) for section in self.sections: - frame_starting_beat = Decimal(0) + frame_starting_beat = BeatsTime(0) for i, frame in enumerate(section.frames): if frame.timing_part: frame_starting_beat = sum( - (f.duration for f in section.frames[:i]), start=Decimal(0) + (f.duration for f in section.frames[:i]), start=BeatsTime(0) ) local_symbols = { symbol: BeatsTime(symbol_index, len(bar)) + bar_index - + decimal_to_beats(frame_starting_beat) + + frame_starting_beat for bar_index, bar in enumerate(frame.timing_part) for symbol_index, symbol in enumerate(bar) if symbol not in EMPTY_BEAT_SYMBOLS @@ -226,7 +257,7 @@ class Memo1Parser(JubeatAnalyserParser): continue should_skip.add(pos) - note_time = decimal_to_beats(section_starting_beat) + symbol_time + note_time = section_starting_beat + symbol_time yield unfinished_long.ends_at(note_time) unfinished_longs = { @@ -247,7 +278,7 @@ class Memo1Parser(JubeatAnalyserParser): should_skip.add(note_pos) symbol = frame.position_part[note_pos.y][note_pos.x] symbol_time = currently_defined_symbols[symbol] - note_time = decimal_to_beats(section_starting_beat) + symbol_time + note_time = section_starting_beat + symbol_time unfinished_longs[note_pos] = UnfinishedLongNote( time=note_time, position=note_pos, tail_tip=arrow_pos ) @@ -262,7 +293,7 @@ class Memo1Parser(JubeatAnalyserParser): symbol_time = currently_defined_symbols[symbol] except KeyError: continue - note_time = decimal_to_beats(section_starting_beat) + symbol_time + note_time = section_starting_beat + symbol_time yield TapNote(note_time, position) def _iter_notes_without_longs(self) -> Iterator[TapNote]: @@ -279,7 +310,7 @@ class Memo1Parser(JubeatAnalyserParser): symbol_time = currently_defined_symbols[symbol] except KeyError: continue - note_time = decimal_to_beats(section_starting_beat) + symbol_time + note_time = section_starting_beat + symbol_time position = NotePosition(x, y) yield TapNote(note_time, position) diff --git a/jubeatools/formats/jubeat_analyser/memo2/dump.py b/jubeatools/formats/jubeat_analyser/memo2/dump.py index bf56f3c..50c0113 100644 --- a/jubeatools/formats/jubeat_analyser/memo2/dump.py +++ b/jubeatools/formats/jubeat_analyser/memo2/dump.py @@ -292,8 +292,8 @@ def _dump_memo2_chart( # Timing events sections[0].events.append(StopEvent(BeatsTime(0), timing.beat_zero_offset)) for event in timing_events: - section_index = event.time // 4 - sections[section_index].events.append(event) + section_beat = event.time - (event.time % 4) + sections[section_beat].events.append(event) # Fill sections with notes for key, next_key in windowed(chain(sections.keys(), [None]), 2): @@ -307,15 +307,15 @@ def _dump_memo2_chart( file.write(f"// https://github.com/Stepland/jubeatools\n\n") # Header - file.write(dump_command("lev", int(chart.level)) + "\n") + file.write(dump_command("lev", Decimal(chart.level)) + "\n") file.write(dump_command("dif", DIFFICULTIES.get(difficulty, 1)) + "\n") - if metadata.audio: + if metadata.audio is not None: file.write(dump_command("m", metadata.audio) + "\n") - if metadata.title: + if metadata.title is not None: file.write(dump_command("title", metadata.title) + "\n") - if metadata.artist: + if metadata.artist is not None: file.write(dump_command("artist", metadata.artist) + "\n") - if metadata.cover: + if metadata.cover is not None: file.write(dump_command("jacket", metadata.cover) + "\n") if metadata.preview is not None: file.write(dump_command("prevpos", int(metadata.preview.start * 1000)) + "\n") diff --git a/jubeatools/formats/jubeat_analyser/memo2/load.py b/jubeatools/formats/jubeat_analyser/memo2/load.py index 723114c..96252b8 100644 --- a/jubeatools/formats/jubeat_analyser/memo2/load.py +++ b/jubeatools/formats/jubeat_analyser/memo2/load.py @@ -31,7 +31,6 @@ from jubeatools.utils import none_or from ..command import is_command, parse_command from ..files import load_files from ..load_tools import ( - CIRCLE_FREE_TO_DECIMAL_TIME, CIRCLE_FREE_TO_NOTE_SYMBOL, EMPTY_BEAT_SYMBOLS, LONG_ARROWS, @@ -98,6 +97,13 @@ class Memo2ChartLine: position: str timing: Optional[List[str]] + @property + def duration(self) -> BeatsTime: + if self.timing: + return BeatsTime(1) + else: + return BeatsTime(0) + memo2_chart_line_grammar = Grammar( r""" @@ -185,26 +191,27 @@ class Memo2Parser(JubeatAnalyserParser): def __init__(self) -> None: super().__init__() self.current_chart_lines: List[Memo2ChartLine] = [] - self.current_beat = BeatsTime(0) self.frames: List[Memo2Frame] = [] def do_b(self, value: str) -> None: - raise ValueError( + raise RuntimeError( "beat command (b=...) found, this commands cannot be used in #memo2 files" ) def do_t(self, value: str) -> None: if self.frames: - raise ValueError( + raise RuntimeError( "tempo command (t=...) found outside of the file header, " "this should not happen in #memo2 files" ) else: - self.timing_events.append(BPMEvent(self.current_beat, BPM=Decimal(value))) + self.timing_events.append( + BPMEvent(self._current_beat(), BPM=Decimal(value)) + ) def do_r(self, value: str) -> None: if self.frames: - raise ValueError( + raise RuntimeError( "offset increase command (r=...) found outside of the file " "header, this is not supported by jubeatools" ) @@ -219,7 +226,7 @@ class Memo2Parser(JubeatAnalyserParser): def do_bpp(self, value: Union[int, str]) -> None: if self.frames: - raise ValueError("jubeatools does not handle changes of #bpp halfway") + raise RuntimeError("jubeatools does not handle changes of #bpp halfway") else: self._do_bpp(value) @@ -256,10 +263,12 @@ class Memo2Parser(JubeatAnalyserParser): in_bar_beat += symbol_duration elif isinstance(event, BPM): self.timing_events.append( - BPMEvent(time=self.current_beat + in_bar_beat, BPM=event.value) + BPMEvent( + time=self._current_beat() + in_bar_beat, BPM=event.value + ) ) elif isinstance(event, Stop): - time = self.current_beat + in_bar_beat + time = self._current_beat() + in_bar_beat if time != 0: raise ValueError( "Chart contains a pause that's not happening at the " @@ -274,9 +283,17 @@ class Memo2Parser(JubeatAnalyserParser): if len(self.current_chart_lines) == 4: self._push_frame() + def _current_beat(self) -> BeatsTime: + return self._frames_duration() + self._lines_duration() + def _frames_duration(self) -> BeatsTime: return sum((frame.duration for frame in self.frames), start=BeatsTime(0)) + def _lines_duration(self) -> BeatsTime: + return sum( + (line.duration for line in self.current_chart_lines), start=BeatsTime(0) + ) + def _push_frame(self) -> None: position_part = [ self._split_chart_line(memo_line.position) diff --git a/jubeatools/formats/jubeat_analyser/mono_column/dump.py b/jubeatools/formats/jubeat_analyser/mono_column/dump.py index ead891e..773c991 100644 --- a/jubeatools/formats/jubeat_analyser/mono_column/dump.py +++ b/jubeatools/formats/jubeat_analyser/mono_column/dump.py @@ -139,6 +139,10 @@ def _raise_if_unfit_for_mono_column( ) +def _section_factory(b: BeatsTime) -> MonoColumnDumpedSection: + return MonoColumnDumpedSection(current_beat=b) + + def _dump_mono_column_chart( difficulty: str, chart: Chart, @@ -150,7 +154,7 @@ def _dump_mono_column_chart( _raise_if_unfit_for_mono_column(chart, timing, circle_free) sections = create_sections_from_chart( - MonoColumnDumpedSection, chart, difficulty, timing, metadata, circle_free + _section_factory, chart, difficulty, timing, metadata, circle_free ) # Define extra symbols diff --git a/jubeatools/formats/jubeat_analyser/mono_column/load.py b/jubeatools/formats/jubeat_analyser/mono_column/load.py index 9c589c4..8ea61fc 100644 --- a/jubeatools/formats/jubeat_analyser/mono_column/load.py +++ b/jubeatools/formats/jubeat_analyser/mono_column/load.py @@ -32,7 +32,7 @@ from jubeatools.utils import none_or from ..command import is_command, parse_command from ..files import load_files from ..load_tools import ( - CIRCLE_FREE_TO_DECIMAL_TIME, + CIRCLE_FREE_TO_BEATS_TIME, LONG_ARROWS, LONG_DIRECTION, JubeatAnalyserParser, @@ -81,11 +81,6 @@ def parse_mono_column_chart_line(line: str) -> str: return MonoColumnChartLineVisitor().visit(mono_column_chart_line_grammar.parse(line)) # type: ignore -SYMBOL_TO_DECIMAL_TIME = { - symbol: Decimal("0.25") * index for index, symbol in enumerate(NOTE_SYMBOLS) -} - - @dataclass class MonoColumnLoadedSection: """ @@ -98,8 +93,8 @@ class MonoColumnLoadedSection: """ chart_lines: List[str] - symbols: Dict[str, Decimal] - length: Decimal + symbols: Dict[str, BeatsTime] + length: BeatsTime tempo: Decimal def blocs(self, bpp: int = 2) -> Iterator[List[List[str]]]: @@ -120,6 +115,9 @@ class MonoColumnParser(JubeatAnalyserParser): self.current_chart_lines: List[str] = [] self.sections: List[MonoColumnLoadedSection] = [] + def _current_beat(self) -> BeatsTime: + return self.section_starting_beat + def do_bpp(self, value: Union[int, str]) -> None: if self.sections: raise ValueError( @@ -179,8 +177,8 @@ class MonoColumnParser(JubeatAnalyserParser): def _iter_blocs( self, - ) -> Iterator[Tuple[Decimal, MonoColumnLoadedSection, List[List[str]]]]: - section_starting_beat = Decimal(0) + ) -> Iterator[Tuple[BeatsTime, MonoColumnLoadedSection, List[List[str]]]]: + section_starting_beat = BeatsTime(0) for section in self.sections: for bloc in section.blocs(): yield section_starting_beat, section, bloc @@ -198,10 +196,8 @@ class MonoColumnParser(JubeatAnalyserParser): if self.circle_free: if symbol in CIRCLE_FREE_SYMBOLS: should_skip.add(pos) - symbol_time = CIRCLE_FREE_TO_DECIMAL_TIME[symbol] - note_time = decimal_to_beats( - section_starting_beat + symbol_time - ) + symbol_time = CIRCLE_FREE_TO_BEATS_TIME[symbol] + note_time = section_starting_beat + symbol_time yield unfinished_long.ends_at(note_time) elif symbol in section.symbols: raise SyntaxError( @@ -212,9 +208,7 @@ class MonoColumnParser(JubeatAnalyserParser): if symbol in section.symbols: should_skip.add(pos) symbol_time = section.symbols[symbol] - note_time = decimal_to_beats( - section_starting_beat + symbol_time - ) + note_time = section_starting_beat + symbol_time yield unfinished_long.ends_at(note_time) unfinished_longs = { @@ -235,7 +229,7 @@ class MonoColumnParser(JubeatAnalyserParser): should_skip.add(note_pos) symbol = bloc[note_pos.y][note_pos.x] symbol_time = section.symbols[symbol] - note_time = decimal_to_beats(section_starting_beat + symbol_time) + note_time = section_starting_beat + symbol_time unfinished_longs[note_pos] = UnfinishedLongNote( time=note_time, position=note_pos, tail_tip=arrow_pos ) @@ -248,17 +242,17 @@ class MonoColumnParser(JubeatAnalyserParser): symbol = bloc[y][x] if symbol in section.symbols: symbol_time = section.symbols[symbol] - note_time = decimal_to_beats(section_starting_beat + symbol_time) + note_time = section_starting_beat + symbol_time yield TapNote(note_time, position) def _iter_notes_without_longs(self) -> Iterator[TapNote]: - section_starting_beat = Decimal(0) + section_starting_beat = BeatsTime(0) for section in self.sections: for bloc, y, x in product(section.blocs(), range(4), range(4)): symbol = bloc[y][x] if symbol in section.symbols: symbol_time = section.symbols[symbol] - note_time = decimal_to_beats(section_starting_beat + symbol_time) + note_time = section_starting_beat + symbol_time position = NotePosition(x, y) yield TapNote(note_time, position) section_starting_beat += section.length diff --git a/jubeatools/formats/jubeat_analyser/tests/memo/example2.py b/jubeatools/formats/jubeat_analyser/tests/memo/example2.py new file mode 100644 index 0000000..d0dfc30 --- /dev/null +++ b/jubeatools/formats/jubeat_analyser/tests/memo/example2.py @@ -0,0 +1,29 @@ +from decimal import Decimal +from pathlib import Path + +from jubeatools.song import * + +data = ( + Song( + metadata=Metadata( + title="", + artist="", + audio=Path('000"'), + cover=Path(""), + preview=None, + preview_file=None, + ), + charts={ + "BSC": Chart( + level=Decimal(0), + timing=Timing( + events=[BPMEvent(time=Fraction(0, 1), BPM=Decimal("1.000"))], + beat_zero_offset=Decimal("0.000"), + ), + notes=[], + ) + }, + global_timing=None, + ), + False, +) diff --git a/jubeatools/formats/jubeat_analyser/tests/memo/example3.py b/jubeatools/formats/jubeat_analyser/tests/memo/example3.py new file mode 100644 index 0000000..bee1e3b --- /dev/null +++ b/jubeatools/formats/jubeat_analyser/tests/memo/example3.py @@ -0,0 +1,32 @@ +from decimal import Decimal +from pathlib import Path + +from jubeatools.song import * + +data = ( + Song( + metadata=Metadata( + title="", + artist="", + audio=Path(""), + cover=Path(""), + preview=None, + preview_file=None, + ), + charts={ + "BSC": Chart( + level=Decimal("0.0"), + timing=Timing( + events=[ + BPMEvent(time=Fraction(0, 1), BPM=Decimal("1.000")), + BPMEvent(time=Fraction(17, 4), BPM=Decimal("1.000")), + ], + beat_zero_offset=Decimal("0.000"), + ), + notes=[], + ) + }, + global_timing=None, + ), + False, +) diff --git a/jubeatools/formats/jubeat_analyser/tests/memo/test_memo.py b/jubeatools/formats/jubeat_analyser/tests/memo/test_memo.py index 1f7cd73..11b117a 100644 --- a/jubeatools/formats/jubeat_analyser/tests/memo/test_memo.py +++ b/jubeatools/formats/jubeat_analyser/tests/memo/test_memo.py @@ -1,41 +1,38 @@ +import tempfile from decimal import Decimal from fractions import Fraction from pathlib import Path from typing import Set, Union from hypothesis import example, given +from hypothesis import note as hypothesis_note +from hypothesis import strategies as st -from jubeatools.formats.jubeat_analyser.memo.dump import _dump_memo_chart -from jubeatools.formats.jubeat_analyser.memo.load import MemoParser -from jubeatools.song import ( - BeatsTime, - BPMEvent, - Chart, - LongNote, - Metadata, - NotePosition, - SecondsTime, - TapNote, - Timing, -) -from jubeatools.testutils.strategies import NoteOption -from jubeatools.testutils.strategies import notes as notes_strat +from jubeatools import song +from jubeatools.formats.enum import Format +from jubeatools.formats.guess import guess_format +from jubeatools.formats.jubeat_analyser.memo.dump import _dump_memo_chart, dump_memo +from jubeatools.formats.jubeat_analyser.memo.load import MemoParser, load_memo +from jubeatools.testutils import strategies as jbst +from jubeatools.testutils.typing import DrawFunc -from . import example1 +from ..test_utils import load_and_dump_then_check, memo_compatible_song +from . import example1, example2, example3 -@given(notes_strat(NoteOption.LONGS)) +@given(jbst.notes(jbst.NoteOption.LONGS)) @example(example1.notes) -def test_many_notes(notes: Set[Union[TapNote, LongNote]]) -> None: - timing = Timing( - events=[BPMEvent(BeatsTime(0), Decimal(120))], beat_zero_offset=SecondsTime(0) +def test_that_notes_roundtrip(notes: Set[Union[song.TapNote, song.LongNote]]) -> None: + timing = song.Timing( + events=[song.BPMEvent(song.BeatsTime(0), Decimal(120))], + beat_zero_offset=song.SecondsTime(0), ) - chart = Chart( + chart = song.Chart( level=Decimal(0), timing=timing, notes=sorted(notes, key=lambda n: (n.time, n.position)), ) - metadata = Metadata("", "", Path(""), Path("")) + metadata = song.Metadata("", "", Path(""), Path("")) string_io = _dump_memo_chart("", chart, metadata, timing, False) chart_text = string_io.getvalue() parser = MemoParser() @@ -44,3 +41,10 @@ def test_many_notes(notes: Set[Union[TapNote, LongNote]]) -> None: parser.finish_last_few_notes() actual = set(parser.notes()) assert notes == actual + + +@given(memo_compatible_song(), st.booleans()) +@example(*example2.data) +@example(*example3.data) +def test_that_full_chart_roundtrips(song: song.Song, circle_free: bool) -> None: + load_and_dump_then_check(Format.MEMO, song, circle_free) diff --git a/jubeatools/formats/jubeat_analyser/tests/memo1/__init__.py b/jubeatools/formats/jubeat_analyser/tests/memo1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/jubeatools/formats/jubeat_analyser/tests/memo1/example1.py b/jubeatools/formats/jubeat_analyser/tests/memo1/example1.py new file mode 100644 index 0000000..901e672 --- /dev/null +++ b/jubeatools/formats/jubeat_analyser/tests/memo1/example1.py @@ -0,0 +1,36 @@ +from decimal import Decimal +from pathlib import Path + +from jubeatools.song import * + +data = ( + Song( + metadata=Metadata( + title="", + artist="", + audio=Path(""), + cover=Path(""), + preview=None, + preview_file=None, + ), + charts={ + "BSC": Chart( + level=Decimal("0.0"), + timing=Timing( + events=[ + BPMEvent(time=Fraction(0, 1), BPM=Decimal("1.000")), + BPMEvent(time=Fraction(13, 3), BPM=Decimal("1.000")), + ], + beat_zero_offset=Decimal("0.000"), + ), + notes=[ + TapNote(time=Fraction(0, 1), position=NotePosition(x=0, y=0)), + TapNote(time=Fraction(0, 1), position=NotePosition(x=0, y=1)), + TapNote(time=Fraction(9, 2), position=NotePosition(x=0, y=0)), + ], + ) + }, + global_timing=None, + ), + False, +) diff --git a/jubeatools/formats/jubeat_analyser/tests/memo1/test_memo1.py b/jubeatools/formats/jubeat_analyser/tests/memo1/test_memo1.py new file mode 100644 index 0000000..f6177f6 --- /dev/null +++ b/jubeatools/formats/jubeat_analyser/tests/memo1/test_memo1.py @@ -0,0 +1,44 @@ +from decimal import Decimal +from pathlib import Path +from typing import List, Union + +from hypothesis import example, given +from hypothesis import strategies as st + +from jubeatools import song +from jubeatools.formats import Format +from jubeatools.formats.jubeat_analyser.memo1.dump import _dump_memo1_chart, dump_memo1 +from jubeatools.formats.jubeat_analyser.memo1.load import Memo1Parser +from jubeatools.testutils.strategies import NoteOption +from jubeatools.testutils.strategies import notes as notes_strat + +from ..test_utils import load_and_dump_then_check, memo_compatible_song +from . import example1 + + +@given(notes_strat(NoteOption.LONGS)) +def test_that_notes_roundtrip(notes: List[Union[song.TapNote, song.LongNote]]) -> None: + timing = song.Timing( + events=[song.BPMEvent(song.BeatsTime(0), Decimal(120))], + beat_zero_offset=song.SecondsTime(0), + ) + chart = song.Chart( + level=Decimal(0), + timing=timing, + notes=sorted(notes, key=lambda n: (n.time, n.position)), + ) + metadata = song.Metadata("", "", Path(""), Path("")) + string_io = _dump_memo1_chart("", chart, metadata, timing) + chart_text = string_io.getvalue() + parser = Memo1Parser() + for line in chart_text.split("\n"): + parser.load_line(line) + parser.finish_last_few_notes() + actual = set(parser.notes()) + assert notes == actual + + +@given(memo_compatible_song(), st.booleans()) +@example(*example1.data) +def test_that_full_chart_roundtrips(song: song.Song, circle_free: bool) -> None: + load_and_dump_then_check(Format.MEMO_1, song, circle_free) diff --git a/jubeatools/formats/jubeat_analyser/tests/memo2/__init__.py b/jubeatools/formats/jubeat_analyser/tests/memo2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/jubeatools/formats/jubeat_analyser/tests/memo2/example1.py b/jubeatools/formats/jubeat_analyser/tests/memo2/example1.py new file mode 100644 index 0000000..b748787 --- /dev/null +++ b/jubeatools/formats/jubeat_analyser/tests/memo2/example1.py @@ -0,0 +1,32 @@ +from decimal import Decimal +from pathlib import Path + +from jubeatools.song import * + +data = ( + Song( + metadata=Metadata( + title="", + artist="", + audio=Path(""), + cover=Path(""), + preview=None, + preview_file=None, + ), + charts={ + "BSC": Chart( + level=Decimal("0.0"), + timing=Timing( + events=[ + BPMEvent(time=Fraction(0, 1), BPM=Decimal("1.000")), + BPMEvent(time=Fraction(4, 1), BPM=Decimal("1.000")), + ], + beat_zero_offset=Decimal("0.000"), + ), + notes=[], + ) + }, + global_timing=None, + ), + False, +) diff --git a/jubeatools/formats/jubeat_analyser/tests/memo2/example2.py b/jubeatools/formats/jubeat_analyser/tests/memo2/example2.py new file mode 100644 index 0000000..b256ff5 --- /dev/null +++ b/jubeatools/formats/jubeat_analyser/tests/memo2/example2.py @@ -0,0 +1,32 @@ +from decimal import Decimal +from pathlib import Path + +from jubeatools.song import * + +data = ( + Song( + metadata=Metadata( + title="", + artist="", + audio=Path(""), + cover=Path(""), + preview=None, + preview_file=None, + ), + charts={ + "BSC": Chart( + level=Decimal("0.0"), + timing=Timing( + events=[ + BPMEvent(time=Fraction(0, 1), BPM=Decimal("1.000")), + BPMEvent(time=Fraction(5, 1), BPM=Decimal("1.000")), + ], + beat_zero_offset=Decimal("0.000"), + ), + notes=[], + ) + }, + global_timing=None, + ), + False, +) diff --git a/jubeatools/formats/jubeat_analyser/tests/memo2/example3.py b/jubeatools/formats/jubeat_analyser/tests/memo2/example3.py new file mode 100644 index 0000000..34dcc7d --- /dev/null +++ b/jubeatools/formats/jubeat_analyser/tests/memo2/example3.py @@ -0,0 +1,32 @@ +from decimal import Decimal +from pathlib import Path + +from jubeatools.song import * + +data = ( + Song( + metadata=Metadata( + title="", + artist="", + audio=Path(""), + cover=Path(""), + preview=None, + preview_file=None, + ), + charts={ + "BSC": Chart( + level=Decimal("0.0"), + timing=Timing( + events=[ + BPMEvent(time=Fraction(0, 1), BPM=Decimal("1.000")), + BPMEvent(time=Fraction(8, 1), BPM=Decimal("1.000")), + ], + beat_zero_offset=Decimal("0.000"), + ), + notes=[], + ) + }, + global_timing=None, + ), + False, +) diff --git a/jubeatools/formats/jubeat_analyser/tests/test_memo2.py b/jubeatools/formats/jubeat_analyser/tests/memo2/test_memo2.py similarity index 58% rename from jubeatools/formats/jubeat_analyser/tests/test_memo2.py rename to jubeatools/formats/jubeat_analyser/tests/memo2/test_memo2.py index 6c7fe12..d7ac0ef 100644 --- a/jubeatools/formats/jubeat_analyser/tests/test_memo2.py +++ b/jubeatools/formats/jubeat_analyser/tests/memo2/test_memo2.py @@ -2,8 +2,12 @@ from decimal import Decimal from pathlib import Path from typing import List, Union -from hypothesis import given +from hypothesis import example, given +from hypothesis import strategies as st +from jubeatools.formats import Format +from jubeatools.formats.jubeat_analyser.memo2.dump import _dump_memo2_chart +from jubeatools.formats.jubeat_analyser.memo2.load import Memo2Parser from jubeatools.song import ( BeatsTime, BPMEvent, @@ -12,18 +16,19 @@ from jubeatools.song import ( Metadata, NotePosition, SecondsTime, + Song, TapNote, Timing, ) from jubeatools.testutils.strategies import NoteOption from jubeatools.testutils.strategies import notes as notes_strat -from ..memo2.dump import _dump_memo2_chart -from ..memo2.load import Memo2Parser +from ..test_utils import load_and_dump_then_check, memo_compatible_song +from . import example1, example2, example3 @given(notes_strat(NoteOption.LONGS)) -def test_many_notes(notes: List[Union[TapNote, LongNote]]) -> None: +def test_that_notes_roundtrip(notes: List[Union[TapNote, LongNote]]) -> None: timing = Timing( events=[BPMEvent(BeatsTime(0), Decimal(120))], beat_zero_offset=SecondsTime(0) ) @@ -41,3 +46,11 @@ def test_many_notes(notes: List[Union[TapNote, LongNote]]) -> None: parser.finish_last_few_notes() actual = set(parser.notes()) assert notes == actual + + +@given(memo_compatible_song(), st.booleans()) +@example(*example1.data) +@example(*example2.data) +@example(*example3.data) +def test_that_full_chart_roundtrips(song: Song, circle_free: bool) -> None: + load_and_dump_then_check(Format.MEMO_2, song, circle_free) diff --git a/jubeatools/formats/jubeat_analyser/tests/mono_column/__init__.py b/jubeatools/formats/jubeat_analyser/tests/mono_column/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/jubeatools/formats/jubeat_analyser/tests/test_mono_column.py b/jubeatools/formats/jubeat_analyser/tests/mono_column/test_mono_column.py similarity index 98% rename from jubeatools/formats/jubeat_analyser/tests/test_mono_column.py rename to jubeatools/formats/jubeat_analyser/tests/mono_column/test_mono_column.py index cea918b..86aaf76 100644 --- a/jubeatools/formats/jubeat_analyser/tests/test_mono_column.py +++ b/jubeatools/formats/jubeat_analyser/tests/mono_column/test_mono_column.py @@ -4,7 +4,7 @@ import pytest from jubeatools.song import BeatsTime, LongNote, NotePosition, TapNote -from ..mono_column.load import MonoColumnParser +from jubeatools.formats.jubeat_analyser.mono_column.load import MonoColumnParser def compare_chart_notes( diff --git a/jubeatools/formats/jubeat_analyser/tests/test_mono_column_hypothesis.py b/jubeatools/formats/jubeat_analyser/tests/mono_column/test_mono_column_hypothesis.py similarity index 76% rename from jubeatools/formats/jubeat_analyser/tests/test_mono_column_hypothesis.py rename to jubeatools/formats/jubeat_analyser/tests/mono_column/test_mono_column_hypothesis.py index af647f5..2af61ef 100644 --- a/jubeatools/formats/jubeat_analyser/tests/test_mono_column_hypothesis.py +++ b/jubeatools/formats/jubeat_analyser/tests/mono_column/test_mono_column_hypothesis.py @@ -14,17 +14,22 @@ from jubeatools.song import ( SecondsTime, TapNote, Timing, + Song ) from jubeatools.testutils.strategies import NoteOption, long_note from jubeatools.testutils.strategies import notes as notes_strat from jubeatools.testutils.strategies import tap_note -from ..mono_column.dump import _dump_mono_column_chart -from ..mono_column.load import MonoColumnParser +from jubeatools.formats import Format +from jubeatools.formats.jubeat_analyser.mono_column.dump import _dump_mono_column_chart +from jubeatools.formats.jubeat_analyser.mono_column.load import MonoColumnParser + + +from ..test_utils import load_and_dump_then_check, memo_compatible_song @given(st.sets(tap_note(), min_size=1, max_size=100)) -def test_tap_notes(notes: Set[TapNote]) -> None: +def test_that_a_set_of_tap_notes_roundtrip(notes: Set[TapNote]) -> None: timing = Timing( events=[BPMEvent(BeatsTime(0), Decimal(120))], beat_zero_offset=SecondsTime(0) ) @@ -44,7 +49,7 @@ def test_tap_notes(notes: Set[TapNote]) -> None: @given(long_note()) -def test_single_long_note(note: LongNote) -> None: +def test_that_a_single_long_note_roundtrips(note: LongNote) -> None: timing = Timing( events=[BPMEvent(BeatsTime(0), Decimal(120))], beat_zero_offset=SecondsTime(0) ) @@ -60,7 +65,7 @@ def test_single_long_note(note: LongNote) -> None: @given(notes_strat(NoteOption.LONGS)) -def test_many_notes(notes: List[Union[TapNote, LongNote]]) -> None: +def test_that_many_notes_roundtrip(notes: List[Union[TapNote, LongNote]]) -> None: timing = Timing( events=[BPMEvent(BeatsTime(0), Decimal(120))], beat_zero_offset=SecondsTime(0) ) @@ -77,3 +82,8 @@ def test_many_notes(notes: List[Union[TapNote, LongNote]]) -> None: parser.load_line(line) actual = set(parser.notes()) assert notes == actual + + +@given(memo_compatible_song(), st.booleans()) +def test_that_full_chart_roundtrips(song: Song, circle_free: bool) -> None: + load_and_dump_then_check(Format.MONO_COLUMN, song, circle_free) \ No newline at end of file diff --git a/jubeatools/formats/jubeat_analyser/tests/test_command.py b/jubeatools/formats/jubeat_analyser/tests/test_command.py new file mode 100644 index 0000000..5c831b0 --- /dev/null +++ b/jubeatools/formats/jubeat_analyser/tests/test_command.py @@ -0,0 +1,11 @@ +from hypothesis import given +from hypothesis import strategies as st + +from ..command import dump_value, parse_value + + +@given(st.text()) +def test_that_strings_roundtrip(expected: str) -> None: + dumped = dump_value(expected) + actual = parse_value(dumped) + assert expected == actual diff --git a/jubeatools/formats/jubeat_analyser/tests/test_memo1.py b/jubeatools/formats/jubeat_analyser/tests/test_memo1.py deleted file mode 100644 index efbb0af..0000000 --- a/jubeatools/formats/jubeat_analyser/tests/test_memo1.py +++ /dev/null @@ -1,43 +0,0 @@ -from decimal import Decimal -from pathlib import Path -from typing import List, Union - -from hypothesis import given - -from jubeatools.song import ( - BeatsTime, - BPMEvent, - Chart, - LongNote, - Metadata, - NotePosition, - SecondsTime, - TapNote, - Timing, -) -from jubeatools.testutils.strategies import NoteOption -from jubeatools.testutils.strategies import notes as notes_strat - -from ..memo1.dump import _dump_memo1_chart -from ..memo1.load import Memo1Parser - - -@given(notes_strat(NoteOption.LONGS)) -def test_many_notes(notes: List[Union[TapNote, LongNote]]) -> None: - timing = Timing( - events=[BPMEvent(BeatsTime(0), Decimal(120))], beat_zero_offset=SecondsTime(0) - ) - chart = Chart( - level=Decimal(0), - timing=timing, - notes=sorted(notes, key=lambda n: (n.time, n.position)), - ) - metadata = Metadata("", "", Path(""), Path("")) - string_io = _dump_memo1_chart("", chart, metadata, timing) - chart_text = string_io.getvalue() - parser = Memo1Parser() - for line in chart_text.split("\n"): - parser.load_line(line) - parser.finish_last_few_notes() - actual = set(parser.notes()) - assert notes == actual diff --git a/jubeatools/formats/jubeat_analyser/tests/test_utils.py b/jubeatools/formats/jubeat_analyser/tests/test_utils.py new file mode 100644 index 0000000..860f37a --- /dev/null +++ b/jubeatools/formats/jubeat_analyser/tests/test_utils.py @@ -0,0 +1,55 @@ +import tempfile +from pathlib import Path + +from hypothesis import note as hypothesis_note +from hypothesis import strategies as st + +from jubeatools import song +from jubeatools.formats import DUMPERS, LOADERS, Format +from jubeatools.formats.guess import guess_format +from jubeatools.testutils import strategies as jbst +from jubeatools.testutils.typing import DrawFunc + + +@st.composite +def memo_compatible_metadata(draw: DrawFunc) -> song.Metadata: + text_strat = st.text(alphabet=st.characters(min_codepoint=0x20, max_codepoint=0x7E)) + metadata: song.Metadata = draw( + jbst.metadata(text_strat=text_strat, path_start=text_strat) + ) + metadata.preview = None + metadata.preview_file = None + return metadata + + +@st.composite +def memo_compatible_song(draw: DrawFunc) -> song.Song: + """Memo only supports one difficulty per file""" + diff = draw(st.sampled_from(["BSC", "ADV", "EXT"])) + chart = draw( + jbst.chart( + timing_strat=jbst.timing_info(bpm_changes=True), + notes_strat=jbst.notes(jbst.NoteOption.LONGS), + ) + ) + metadata: song.Metadata = draw(memo_compatible_metadata()) + return song.Song( + metadata=metadata, + charts={diff: chart}, + ) + + +def load_and_dump_then_check(f: Format, song: song.Song, circle_free: bool) -> None: + loader = LOADERS[f] + dumper = DUMPERS[f] + with tempfile.NamedTemporaryFile(suffix=".txt") as dst: + path = Path(dst.name) + files = dumper(song, path, circle_free=circle_free) + assert len(files) == 1 + bytes_ = files.popitem()[1] + hypothesis_note(f"Chart file :\n{bytes_.decode('shift-jis-2004')}") + dst.write(bytes_) + dst.flush() + assert guess_format(path) == f + recovered_song = loader(path) + assert recovered_song == song diff --git a/jubeatools/testutils/strategies.py b/jubeatools/testutils/strategies.py index fdc4379..6e20290 100644 --- a/jubeatools/testutils/strategies.py +++ b/jubeatools/testutils/strategies.py @@ -4,6 +4,7 @@ Hypothesis strategies to generate notes and charts from decimal import Decimal from enum import Enum, Flag, auto from itertools import product +from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Set, TypeVar, Union import hypothesis.strategies as st @@ -184,7 +185,14 @@ def get_bpm_change_time(b: BPMEvent) -> BeatsTime: @st.composite def chart(draw: DrawFunc, timing_strat: Any, notes_strat: Any) -> Chart: - level = draw(st.integers(min_value=0)) + level = Decimal( + draw( + st.one_of( + st.integers(min_value=0), + st.decimals(min_value=0, max_value=10.9, places=1), + ) + ) + ) timing = draw(timing_strat) notes = draw(notes_strat) return Chart( @@ -206,13 +214,18 @@ def preview(draw: DrawFunc) -> Preview: @st.composite -def metadata(draw: DrawFunc) -> Metadata: +def metadata( + draw: DrawFunc, + text_strat: st.SearchStrategy[str] = st.text(), + path_start: st.SearchStrategy[str] = st.text(), +) -> Metadata: return Metadata( - title=draw(st.text()), - artist=draw(st.text()), - audio=draw(st.text()), - cover=draw(st.text()), + title=draw(text_strat), + artist=draw(text_strat), + audio=Path(draw(path_start)), + cover=Path(draw(path_start)), preview=draw(st.one_of(st.none(), preview())), + preview_file=draw(path_start), )