Files
AnyPS5/tools/tests/test_pad_timeline.py
T

181 lines
8.7 KiB
Python

import contextlib
import copy
import io
import json
import os
import stat
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import pad_input
import pad_timeline
class TimelineTests(unittest.TestCase):
def setUp(self):
self.document = {"version": 1, "events": [
{"at_ms": 200, "hold_ms": 100, "buttons": ["cross", "r1"]},
{"at_ms": 500, "hold_ms": 200, "buttons": ["down"]},
]}
def test_compilation_and_repetition(self):
text = pad_timeline.compile_timeline(self.document, 42, 2, 300)
self.assertEqual(text, "APS5_PAD_TIMELINE 1 42 4\n200 100 18432\n500 200 64\n1200 100 18432\n1500 200 64\n")
def test_touchpad_half_names_keep_their_command_bits(self):
self.document["events"][0]["buttons"] = ["cross", "touch-left", "touch-right"]
self.assertIn("200 100 6307840", pad_timeline.compile_timeline(self.document, 1))
def test_empty_buttons_release(self):
self.document["events"][0]["buttons"] = []
self.assertIn("200 100 0", pad_timeline.compile_timeline(self.document, 1))
def test_release_cannot_be_combined(self):
self.document["events"][0]["buttons"] = ["release", "cross"]
with self.assertRaises(ValueError):
pad_timeline.compile_timeline(self.document, 1)
def test_unknown_buttons(self):
for buttons in [["typo"], "cross", [True]]:
with self.subTest(buttons=buttons):
self.document["events"][0]["buttons"] = buttons
with self.assertRaises(ValueError):
pad_timeline.compile_timeline(self.document, 1)
def test_overlaps(self):
self.document["events"][1]["at_ms"] = 250
with self.assertRaises(ValueError):
pad_timeline.compile_timeline(self.document, 1)
def test_integer_bounds(self):
for field, values in {"at_ms": [-1, True, 1.5, 3600001], "hold_ms": [0, True, 5001]}.items():
for value in values:
with self.subTest(field=field, value=value):
document = copy.deepcopy(self.document)
document["events"][0][field] = value
with self.assertRaises(ValueError):
pad_timeline.compile_timeline(document, 1)
def test_schema_validation(self):
for document in [None, [], {}, {"version": True, "events": []}, {"version": 2, "events": []},
{"version": 1, "events": []}, {"version": 1, "events": [{}]},
dict(self.document, unknown=1)]:
with self.subTest(document=document):
with self.assertRaises(ValueError):
pad_timeline.compile_timeline(document, 1)
def test_repetition_bounds(self):
for repeat, gap in [(0, 0), (4097, 0), (True, 0), (1, -1), (4096, 3600000)]:
with self.assertRaises(ValueError):
pad_timeline.compile_timeline(self.document, 1, repeat, gap)
def test_repeated_event_count_at_capacity(self):
rows = pad_timeline.compile_timeline(self.document, 42, 2048, 0).splitlines()
self.assertEqual(rows[0], "APS5_PAD_TIMELINE 1 42 4096")
self.assertEqual(len(rows) - 1, 4096)
self.assertEqual(rows[1:3], ["200 100 18432", "500 200 64"])
self.assertEqual(rows[2047:2051], [
"716300 100 18432", "716600 200 64", "717000 100 18432", "717300 200 64",
])
self.assertEqual(rows[-2:], ["1433100 100 18432", "1433400 200 64"])
with self.assertRaisesRegex(ValueError, "1 to 4096 events after repetition"):
pad_timeline.compile_timeline(self.document, 42, 2049, 0)
def test_repeated_duration_includes_inter_iteration_gaps_only(self):
document = {"version": 1, "events": [{"at_ms": 0, "hold_ms": 5000, "buttons": ["cross"]}]}
self.assertEqual(
pad_timeline.compile_timeline(document, 42, 2, 3590000),
"APS5_PAD_TIMELINE 1 42 2\n0 5000 16384\n3595000 5000 16384\n")
with self.assertRaisesRegex(ValueError, "repeated timeline exceeds one hour"):
pad_timeline.compile_timeline(document, 42, 2, 3590001)
def test_sequence_bounds(self):
for sequence in [0, -1, True, 2**64]:
with self.assertRaises(ValueError):
pad_timeline.compile_timeline(self.document, sequence)
def test_maximum_duration(self):
document = {"version": 1, "events": [{"at_ms": 3599999, "hold_ms": 1, "buttons": ["cross"]}]}
self.assertIn("3599999 1", pad_timeline.compile_timeline(document, 2**64 - 1))
with self.assertRaises(ValueError):
pad_timeline.compile_timeline(document, 1, 2)
@unittest.skipUnless(os.name == "posix", "POSIX file permissions")
def test_replacement_preserves_shared_command_permissions(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "command"
path.write_text("previous\n", encoding="utf-8")
path.chmod(0o640)
metadata = path.stat()
pad_timeline.queue_timeline(path, "new\n")
self.assertEqual(path.read_bytes(), b"new\n")
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o640)
self.assertEqual((path.stat().st_uid, path.stat().st_gid), (metadata.st_uid, metadata.st_gid))
def test_atomic_replace_and_cleanup(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "command"
pad_timeline.queue_timeline(path, "new\n")
self.assertEqual(path.read_bytes(), b"new\n")
with mock.patch("pad_timeline.os.replace", side_effect=OSError("denied")):
with self.assertRaises(OSError):
pad_timeline.queue_timeline(path, "wrong\n")
self.assertEqual(path.read_bytes(), b"new\n")
self.assertEqual(list(Path(directory).iterdir()), [path])
def run_cli(self, timeline, output):
return subprocess.run([sys.executable, str(Path(pad_timeline.__file__)), str(timeline), str(output)],
capture_output=True, text=True, timeout=5)
def test_cli_roundtrip_and_invalid_input_preservation(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
timeline, output = root / "sequence.json", root / "command"
timeline.write_text(json.dumps(self.document), encoding="utf-8")
self.assertEqual(self.run_cli(timeline, output).returncode, 0)
original = output.read_bytes()
timeline.write_text('{"version":2}', encoding="utf-8")
self.assertEqual(self.run_cli(timeline, output).returncode, 2)
self.assertEqual(output.read_bytes(), original)
def test_input_alias_protection(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
timeline = root / "sequence.json"
original = json.dumps(self.document)
timeline.write_text(original, encoding="utf-8")
self.assertEqual(self.run_cli(timeline, timeline).returncode, 2)
linked = root / "hardlink"
os.link(timeline, linked)
self.assertEqual(self.run_cli(timeline, linked).returncode, 2)
self.assertEqual(timeline.read_text(encoding="utf-8"), original)
def test_sequence_advances_past_existing_command_after_clock_reset(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
timeline, output = root / "sequence.json", root / "command"
timeline.write_text(json.dumps(self.document), encoding="utf-8")
with mock.patch.object(sys, "argv", ["pad_timeline.py", str(timeline), str(output)]), \
mock.patch.object(pad_input.time, "monotonic_ns", return_value=1000), \
contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
output.write_text("2000 16384 300\n", encoding="utf-8")
pad_timeline.main()
self.assertTrue(output.read_text(encoding="utf-8").startswith("APS5_PAD_TIMELINE 1 2001 2\n"))
pad_timeline.main()
self.assertTrue(output.read_text(encoding="utf-8").startswith("APS5_PAD_TIMELINE 1 2002 2\n"))
exhausted = f"{2**64 - 1} 16384 300\n"
output.write_text(exhausted, encoding="utf-8")
with self.assertRaises(SystemExit) as error:
pad_timeline.main()
self.assertEqual(error.exception.code, 2)
self.assertEqual(output.read_text(encoding="utf-8"), exhausted)
if __name__ == "__main__":
unittest.main()