Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
45 changes: 45 additions & 0 deletions tools/preview_pad_timeline.py
Original file line number Diff line number Diff line change
@@ -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())
77 changes: 77 additions & 0 deletions tools/tests/test_preview_pad_timeline.py
Original file line number Diff line number Diff line change
@@ -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()
Loading