OXYGENHUB
# 346ca3ee83f4 retro-remote-debug-controller / conformance / test_conformance.py
6.8 KiB · Python · last changed 0.4.0: pointer injection (POST/GET /pointer) 3 months ago source blame history raw
674b8447 Andy McCall ↶ 1
"""Contract conformance tests (SPEC.md 0.3.x). Platform-agnostic: framebuffer
8ac19619 Andy McCall ↶ 2
dimensions come from the PPM header, addresses use $0000, and `bank` is
674b8447 Andy McCall ↶ 3
exercised but banking is not required. Tests gated on the server's advertised
4
minor (input injection 0.2; memory write + audio 0.3) skip on older servers."""
8ac19619 Andy McCall ↶ 5
import urllib.error
6
import urllib.request
7
d44319b1 Andy McCall ↶ 8
import pytest
9
8ac19619 Andy McCall ↶ 10
from emu_control import parse_ppm_header
11
12
13
def _raw(emu, method, path):
14
    """Raw request returning (status_code, body) without the client's wrapping,
15
    so we can assert on 4xx status codes."""
16
    req = urllib.request.Request(emu.base + path, method=method)
17
    try:
18
        with urllib.request.urlopen(req, timeout=emu.timeout) as r:
19
            return r.status, r.read()
20
    except urllib.error.HTTPError as e:
21
        return e.code, e.read()
22
23
24
def test_status_contract(emu):
25
    st = emu.status()
26
    for k in ("contract", "emulator", "platform", "frame", "paused", "running"):
27
        assert k in st, f"missing /status.{k}"
28
    assert str(st["contract"]).split(".")[0] == "0", "unexpected contract MAJOR"
29
    assert isinstance(st["platform"], str) and st["platform"]
30
    assert isinstance(st["frame"], int)
31
    assert isinstance(st["paused"], bool)
32
33
34
def test_regs_object(emu):
35
    r = emu.regs()
36
    assert isinstance(r, dict) and r, "regs must be a non-empty JSON object"
37
    assert all(isinstance(v, int) for v in r.values()), "register values must be ints"
38
39
40
def test_mem_exact_length(emu):
41
    assert len(emu.mem(0x0000, 32)) == 32
42
    assert len(emu.mem(0x0000, 256)) == 256
43
    assert len(emu.mem(0x0000, 0)) == 0
44
45
46
def test_mem_bank_param_returns_length(emu):
47
    # bank is optional (ignored on flat platforms) but must still honour len.
48
    assert len(emu.mem(0x0000, 8, bank=0)) == 8
49
50
51
def test_screenshot_ppm(emu):
52
    data = emu.screenshot_ppm()
53
    w, h, maxval, off = parse_ppm_header(data)
54
    assert w > 0 and h > 0, f"bad dimensions {w}x{h}"
55
    assert maxval == 255
56
    assert len(data) == off + 3 * w * h, "PPM body size mismatches dimensions"
57
58
59
def test_step_is_deterministic(emu):
60
    emu.pause()
61
    try:
62
        a = emu.frame()
63
        b = emu.step(2); c = emu.frame()
64
        assert b == c, "/step must return the halted frame counter"
65
        d = emu.step(2); e = emu.frame()
66
        assert d == e
67
        assert c - a > 0, "/step must advance the frame counter"
68
        assert (c - a) == (e - c), "equal steps must advance by equal amounts"
69
    finally:
70
        emu.resume()
71
72
73
def test_pause_resume_toggle(emu):
74
    emu.pause()
75
    assert emu.status()["paused"] is True
76
    emu.resume()
77
    assert emu.status()["paused"] is False
78
79
80
def test_unknown_path_404(emu):
81
    code, _ = _raw(emu, "GET", "/no-such-endpoint")
82
    assert code == 404
83
84
85
def test_step_requires_post(emu):
86
    code, _ = _raw(emu, "GET", "/step?frames=1")
d44319b1 Andy McCall ↶ 87
    assert code == 405
88
89
90
def _contract_minor(emu) -> int:
91
    return int(str(emu.status()["contract"]).split(".")[1])
92
93
94
# -- contract 0.2: input injection (skipped on 0.1 servers) ------------------
95
96
def test_key_injection(emu):
97
    """/key accepts text= and code= with an optional down=, and rejects a
98
    request with neither. The key's *effect* is platform/program-specific and
99
    tested per-port; here we assert only the contract surface."""
100
    if _contract_minor(emu) < 2:
101
        pytest.skip("contract < 0.2 (no input injection)")
102
    emu.key(text="a", down=True)
103
    emu.key(text="a", down=False)
104
    emu.key(text="a")                    # tap (press then release)
105
    emu.key(code=32, down=True)
106
    emu.key(code=32, down=False)
107
    code, _ = _raw(emu, "POST", "/key")  # neither text nor code
108
    assert code == 400
109
110
111
def test_key_requires_post(emu):
112
    if _contract_minor(emu) < 2:
113
        pytest.skip("contract < 0.2")
114
    code, _ = _raw(emu, "GET", "/key?text=a")
115
    assert code == 405
116
117
674b8447 Andy McCall ↶ 118
# -- contract 0.3: memory write + audio (skipped on < 0.3 servers) -----------
119
120
def test_mem_write_roundtrip(emu):
121
    """POST /mem pokes bytes; GET /mem reads them back (contract 0.3)."""
122
    if _contract_minor(emu) < 3:
123
        pytest.skip("contract < 0.3 (no memory write)")
124
    emu.pause()               # freeze so the machine can't overwrite between write+read
125
    try:
126
        addr, payload = 0x0400, bytes([0xA5, 0x5A, 0x00, 0xFF])
127
        assert emu.mem_write(addr, payload) == len(payload)
128
        assert emu.mem(addr, len(payload)) == payload
129
    finally:
130
        emu.resume()
131
132
133
def test_mem_write_requires_post(emu):
134
    if _contract_minor(emu) < 3:
135
        pytest.skip("contract < 0.3")
136
    # GET /mem is the read path (200), never a write — writing needs POST.
137
    code, _ = _raw(emu, "GET", "/mem?addr=0&len=1")
138
    assert code == 200
139
140
141
def test_audio_is_valid_wav(emu):
142
    """GET /audio returns a parseable PCM WAV (contract 0.3)."""
143
    if _contract_minor(emu) < 3:
144
        pytest.skip("contract < 0.3 (no audio)")
145
    import io
146
    import wave
147
    emu.pause(); emu.step(4)          # produce a little audio deterministically
148
    w = wave.open(io.BytesIO(emu.audio()), "rb")
149
    assert w.getsampwidth() == 2      # signed 16-bit
150
    assert w.getnchannels() in (1, 2)
151
    assert w.getframerate() > 0
152
    emu.resume()
153
154
346ca3ee Andy McCall ↶ 155
# -- contract 0.4: pointer injection (skipped on < 0.4 servers) --------------
156
157
def test_pointer_injection(emu):
158
    """/pointer accepts absolute (x,y) and relative (dx,dy) with an optional
159
    buttons= bitmask, and rejects a request with neither. The pointer's *effect*
160
    is platform-specific and tested per-port; here we assert the contract
161
    surface only."""
162
    if _contract_minor(emu) < 4:
163
        pytest.skip("contract < 0.4 (no pointer injection)")
164
    emu.pointer(x=10, y=20)                 # absolute move, buttons unchanged
165
    emu.pointer(x=10, y=20, buttons=1)      # primary button down
166
    emu.pointer(x=10, y=20, buttons=0)      # buttons released
167
    emu.pointer(dx=4, dy=-2)                # relative move
168
    code, _ = _raw(emu, "POST", "/pointer")  # neither x/y nor dx/dy
169
    assert code == 400
170
171
172
def test_pointer_readback(emu):
173
    """GET /pointer reports {x, y, buttons} as ints (contract 0.4). Values are
174
    platform-specific; we assert only shape."""
175
    if _contract_minor(emu) < 4:
176
        pytest.skip("contract < 0.4")
177
    emu.pause()
178
    try:
179
        emu.pointer(x=42, y=24, buttons=0)
180
        emu.step(1)
181
        p = emu.pointer_get()
182
        assert {"x", "y", "buttons"} <= p.keys()
183
        assert all(isinstance(p[k], int) for k in ("x", "y", "buttons"))
184
    finally:
185
        emu.resume()
186
187
188
def test_pointer_get_is_get(emu):
189
    if _contract_minor(emu) < 4:
190
        pytest.skip("contract < 0.4")
191
    # GET /pointer is the readback path (200); POST is the inject path.
192
    code, _ = _raw(emu, "GET", "/pointer")
193
    assert code == 200
194
195
d44319b1 Andy McCall ↶ 196
def test_reset(emu):
197
    """/reset responds and is POST-only. Runs last (it restarts the machine)."""
198
    if _contract_minor(emu) < 2:
199
        pytest.skip("contract < 0.2")
200
    code, _ = _raw(emu, "GET", "/reset")
8ac19619 Andy McCall ↶ 201
    assert code == 405
d44319b1 Andy McCall ↶ 202
    emu.reset()