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