Files
CDJ900NXS-Patcher/test_patcher.py
2026-10-20 08:07:27 +02:00

142 lines
5.5 KiB
Python

# SPDX-License-Identifier: GPL-2.0-or-later
"""Tests for the patcher: python -m pytest test_patcher.py
Offline: S-records, the boot loader's LZSS against our packer, the version
label guard. With the official update (CDJ900NXS_UPD, or C900NXS.UPD in this
folder): the stock file rebuilt byte for byte, the relabelled stock differing
in three bytes only, and every tampering with the boot area caught."""
import os
import random
import struct
import sys
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import upd # noqa: E402
import cdj900nxs_patcher as cli # noqa: E402
STOCK = os.environ.get('CDJ900NXS_UPD') or os.path.join(HERE, 'C900NXS.UPD')
need_stock = pytest.mark.skipif(not os.path.isfile(STOCK), reason='needs the official C900NXS.UPD')
def test_record_round_trip():
rec = upd.Record(2, 0x040020, bytes(range(32)))
line = rec.encode()
assert line.startswith(b'S2') and line.endswith(b'\r\n')
raw = bytes.fromhex(line[2:-2].decode())
assert raw[0] == len(raw) - 1 and sum(raw) & 0xFF == 0xFF
def test_lzss_matches_the_boot_loader():
_, lzss = cli.load_repo(cli.DEFAULT_REPO)
rnd = random.Random(7)
data = bytes(rnd.choice(b' \0ABAB\xff') for _ in range(20000)) + b' ' * 5000 + bytes(4000)
assert upd.unpack_lzss(lzss.pack(data)) == data
def test_region_checksum_and_padding():
reg = upd.pack_region(b'abc')
assert len(reg) % 32 == 0
size = struct.unpack_from('<I', reg)[0]
assert struct.unpack_from('<H', reg, 4 + size)[0] == sum(reg[:4 + size]) & 0xFFFF
def test_relabel_touches_the_label_only():
h = b'CDJ-900NXS MAINVer1.31\x00 0'
assert upd.relabel(h, '1.32') == b'CDJ-900NXS MAINVer1.32\x00 0'
with pytest.raises(upd.UpdError):
upd.relabel(h, '1.3')
@need_stock
def test_stock_rebuilt_byte_for_byte():
raw = open(STOCK, 'rb').read()
stock = upd.Stock(raw)
size = struct.unpack_from('<I', stock.flash, upd.APP_START)[0]
stream = stock.flash[upd.APP_START + 4:upd.APP_START + 4 + size]
assert upd.build(stock, stock.application, stream, None) == raw
@need_stock
def test_relabelled_stock_differs_in_three_bytes():
raw = open(STOCK, 'rb').read()
stock = upd.Stock(raw)
patcher, lzss = cli.load_repo(cli.DEFAULT_REPO)
out, app, checks = cli.build(stock, [], patcher, lzss)
assert len(out) == len(raw) and sum(a != b for a, b in zip(out, raw)) == 3
assert app == stock.application and any('boot loader' in c for c in checks)
@need_stock
def test_other_files_refused():
raw = bytearray(open(STOCK, 'rb').read())
raw[-3] ^= 1
with pytest.raises(upd.UpdError):
upd.Stock(bytes(raw))
@need_stock
def test_boot_area_tampering_caught():
raw = open(STOCK, 'rb').read()
stock = upd.Stock(raw)
size = struct.unpack_from('<I', stock.flash, upd.APP_START)[0]
stream = stock.flash[upd.APP_START + 4:upd.APP_START + 4 + size]
good = upd.build(stock, stock.application, stream)
# rebuild MAIN with one boot loader byte changed, CRCs made right again
sections = upd.parse(good)
main = sections[1]
recs = list(main.records)
i = next(n for n, r in enumerate(recs) if r.kind == 2 and r.address < upd.APP_START)
recs[i] = upd.Record(2, recs[i].address, bytes([recs[i].data[0] ^ 1]) + recs[i].data[1:])
bad = upd.join([sections[0].blob, upd.encode_section(main.header, recs), sections[2].blob])
with pytest.raises(upd.UpdError, match='boot loader'):
upd.verify(bad, stock, stock.application)
MODS = [('extended_bpm', '', True, ''), ('phasemeter', '', True, ''), ('advanced_waveforms', '', True, ''),
('beatjump', '', True, ''), ('cuecolours', '', True, ''), ('hotcuekeys', '', True, '')]
def test_every_mod_can_be_unticked():
chosen = {m[0] for m in MODS}
cli._toggle(chosen, 'extended_bpm', MODS)
assert 'extended_bpm' not in chosen and 'phasemeter' in chosen # each on its own
cli._toggle(chosen, 'cuecolours', MODS)
assert 'cuecolours' not in chosen and 'hotcuekeys' not in chosen
cli._toggle(chosen, 'hotcuekeys', MODS)
assert {'hotcuekeys', 'cuecolours'} <= chosen # and ticking brings what it needs
cli._toggle(chosen, 'advanced_waveforms', MODS)
assert 'advanced_waveforms' not in chosen
cli._toggle(chosen, 'cuecolours', MODS)
assert not {'cuecolours', 'hotcuekeys', 'beatjump'} & chosen # and what needs that
cli._toggle(chosen, 'beatjump', MODS)
assert {'beatjump', 'hotcuekeys', 'cuecolours'} <= chosen
def test_disclaimer_must_be_accepted(monkeypatch):
monkeypatch.setattr(cli.sys.stdin, 'isatty', lambda: True, raising=False)
assert 'Claude Code' in cli.DISCLAIMER
cli.confirm(False, ask=lambda _: 'yes')
cli.confirm(True, ask=lambda _: pytest.fail('asked although accepted'))
for answer in ('', 'no', 'q'):
with pytest.raises(SystemExit, match='not accepted'):
cli.confirm(False, ask=lambda _: answer)
def test_disclaimer_without_a_terminal(monkeypatch):
monkeypatch.setattr(cli.sys.stdin, 'isatty', lambda: False, raising=False)
with pytest.raises(SystemExit, match='--accept'):
cli.confirm(False)
def test_credit_can_be_unticked():
import types
mods = cli.available(types.SimpleNamespace(MODS={"extended_bpm": "c", "credit": "x"}), cli.DEFAULT_REPO)
assert [m[0] for m in mods] == ['extended_bpm', 'credit']
chosen = {m[0] for m in mods if m[0] not in cli.KNOWN_FAULTS}
assert 'credit' in chosen # ticked at the start
cli._toggle(chosen, 'credit', mods)
assert chosen == {'extended_bpm'}