mirror of
https://github.com/boykopovar/AnyPS5.git
synced 2026-10-29 12:15:39 +00:00
181 lines
8.7 KiB
Python
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()
|