From 1f563f246edd666b636b658b36ba204277fe150d Mon Sep 17 00:00:00 2001 From: StoneDC <19905433+StoneDC@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:50:09 +0800 Subject: [PATCH] feat(tools): preview controller timelines without queuing --- CMakeLists.txt | 3 + tools/preview_pad_timeline.py | 45 ++++++++++++++ tools/tests/test_preview_pad_timeline.py | 77 ++++++++++++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 tools/preview_pad_timeline.py create mode 100644 tools/tests/test_preview_pad_timeline.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 7db1bb743219..6f00ccfebf93 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -214,6 +214,9 @@ if(BUILD_TESTING) ${CMAKE_CURRENT_SOURCE_DIR}/tools/tests/test_check_conventions_wrapped_declarations.py) add_test(NAME conventions_ci_gate COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tools/tests/test_check_conventions_ci_gate.py) + add_test(NAME preview_pad_timeline COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tools/tests/test_preview_pad_timeline.py) + set_tests_properties(preview_pad_timeline PROPERTIES TIMEOUT 20) add_test(NAME conventions_git_errors COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tools/tests/test_check_conventions_git.py) add_test(NAME conventions_doc_links COMMAND ${Python3_EXECUTABLE} diff --git a/tools/preview_pad_timeline.py b/tools/preview_pad_timeline.py new file mode 100644 index 000000000000..7dfde85376c3 --- /dev/null +++ b/tools/preview_pad_timeline.py @@ -0,0 +1,45 @@ +import argparse +import json +import sys +from pathlib import Path + +from pad_timeline import compile_timeline + + +def preview(document, repeat=1, gap_ms=200): + compiled = compile_timeline(document, 1, repeat, gap_ms) + events = [] + for line in compiled.splitlines()[1:]: + at_ms, hold_ms, buttons = map(int, line.split()) + events.append({"at_ms": at_ms, "hold_ms": hold_ms, "buttons": buttons}) + return {"preview": True, "event_count": len(events), "duration_ms": events[-1]["at_ms"] + events[-1]["hold_ms"], + "events": events} + + +def main(argv=None): + for stream in (sys.stdout, sys.stderr): + if hasattr(stream, "reconfigure"): + stream.reconfigure(encoding="utf-8", errors="backslashreplace") + parser = argparse.ArgumentParser(description="Validate and preview a controller timeline without accessing a controller command file.") + parser.add_argument("timeline", type=Path) + parser.add_argument("--repeat", type=int, default=1) + parser.add_argument("--gap-ms", type=int, default=200) + parser.add_argument("--json", action="store_true") + args = parser.parse_args(argv) + try: + document = json.loads(args.timeline.read_text(encoding="utf-8")) + result = preview(document, args.repeat, args.gap_ms) + except (OSError, ValueError, RecursionError) as error: + parser.exit(2, f"{parser.prog}: {error}\n") + if args.json: + print(json.dumps(result, indent=2)) + else: + print(f"Preview only: {result['event_count']} events, ending at {result['duration_ms']} ms. No controller command file was written.") + print("at_ms hold_ms button_mask") + for event in result["events"]: + print(f"{event['at_ms']} {event['hold_ms']} 0x{event['buttons']:x}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/tests/test_preview_pad_timeline.py b/tools/tests/test_preview_pad_timeline.py new file mode 100644 index 000000000000..3c049e4fa91f --- /dev/null +++ b/tools/tests/test_preview_pad_timeline.py @@ -0,0 +1,77 @@ +import copy +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import pad_timeline +import preview_pad_timeline + + +DOCUMENT = {"version": 1, "events": [{"at_ms": 10, "hold_ms": 25, "buttons": ["cross"]}, + {"at_ms": 50, "hold_ms": 10, "buttons": ["release"]}]} + + +class TimelinePreviewTests(unittest.TestCase): + def test_repetition_and_gap_match_the_production_compiler(self): + result = preview_pad_timeline.preview(DOCUMENT, repeat=2, gap_ms=20) + self.assertEqual(result["event_count"], 4) + self.assertEqual(result["duration_ms"], 140) + self.assertEqual([event["at_ms"] for event in result["events"]], [10, 50, 90, 130]) + self.assertEqual([event["buttons"] for event in result["events"]], [pad_timeline.BUTTONS["cross"], 0] * 2) + compiled = pad_timeline.compile_timeline(DOCUMENT, 999, repeat=2, gap_ms=20) + self.assertEqual([list(map(int, line.split())) for line in compiled.splitlines()[1:]], + [[event["at_ms"], event["hold_ms"], event["buttons"]] for event in result["events"]]) + + def test_cli_previews_without_creating_or_changing_command_files(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + timeline = root / "timeline.json" + timeline.write_text(json.dumps(DOCUMENT), encoding="utf-8") + command = root / "commands.txt" + original = b"777 0 100\n" + command.write_bytes(original) + before = {path.name: (path.read_bytes(), path.stat().st_mtime_ns) for path in root.iterdir()} + for options in ([], ["--json"]): + result = subprocess.run([sys.executable, str(Path(preview_pad_timeline.__file__)), str(timeline), + "--repeat", "2", "--gap-ms", "20", *options], capture_output=True, + env={**os.environ, "PYTHONIOENCODING": "ascii:strict"}, timeout=5) + self.assertEqual(result.returncode, 0, result.stderr.decode("utf-8")) + if options: + value = json.loads(result.stdout) + self.assertTrue(value["preview"]) + self.assertEqual(value["event_count"], 4) + self.assertNotIn("sequence", value) + else: + self.assertIn(b"Preview only: 4 events", result.stdout) + self.assertNotIn(b"APS5_PAD_TIMELINE", result.stdout) + self.assertEqual({path.name: (path.read_bytes(), path.stat().st_mtime_ns) for path in root.iterdir()}, before) + + def test_existing_validation_errors_remain_errors(self): + for field, value in (("at_ms", 0), ("hold_ms", 0), ("buttons", ["unknown"])): + document = copy.deepcopy(DOCUMENT) + document["events"][1][field] = value + with self.subTest(field=field), self.assertRaises(ValueError): + preview_pad_timeline.preview(document) + for repeat, gap in ((0, 20), (1, -1), (4097, 0)): + with self.subTest(repeat=repeat, gap=gap), self.assertRaises(ValueError): + preview_pad_timeline.preview(DOCUMENT, repeat, gap) + + def test_bad_json_and_invalid_events_return_usage_errors_without_a_preview(self): + with tempfile.TemporaryDirectory() as directory: + timeline = Path(directory) / "timeline.json" + for text in ("{", json.dumps({"version": 1, "events": []})): + timeline.write_text(text, encoding="utf-8") + result = subprocess.run([sys.executable, str(Path(preview_pad_timeline.__file__)), str(timeline), "--json"], + capture_output=True, timeout=5) + self.assertEqual(result.returncode, 2) + self.assertEqual(result.stdout, b"") + self.assertNotIn(b"Traceback", result.stderr) + + +if __name__ == "__main__": + unittest.main()