"""Slideshow tests: storage budget math, viewer assembly fit, and a full disk round-trip (viewer + N data files written and read back). Run with `pytest` or directly: `python tests/test_slideshow.py`. """ import os import subprocess import sys import tempfile import numpy as np from PIL import Image sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from lenser import slideshow as ss # noqa: E402 from lenser import imageprep # noqa: E402 from lenser.diskimage import DiskError, have_c1541 # noqa: E402 from lenser.viewer.assemble import (build_slideshow_prg, # noqa: E402 have_xa) def _gradient_png(path, w=160, h=200): yy, xx = np.mgrid[0:h, 0:w] rgb = np.stack([(xx * 255 // w), (yy * 255 // h), ((xx + yy) * 255 // (w + h))], axis=-1).astype(np.uint8) Image.fromarray(rgb, "RGB").save(path) def test_slideshow_budget(): # CBM block rounding: a PRG of L bytes -> ceil((L+2)/254) blocks. assert ss.item_blocks("c64", "d64", 10001) == 40 # multicolor assert ss.item_blocks("c64", "d64", 9000) == 36 # hires/mono assert ss.item_blocks("c64", "d64", 1) == 1 b = ss.budget("c64", "d64", [10001, 9000, 9000], viewer_len=283) assert b.fits and b.files == 4 assert b.used_blocks == ss._cbm_blocks(283) + 40 + 36 + 36 # too many multicolor images for a d64, but fine on a d81 many = [10001] * 20 assert not ss.budget("c64", "d64", many, 283).fits assert ss.budget("c64", "d81", many, 283).fits # directory-entry cap (d81 = 296) is enforced even when blocks would fit: # 300 one-block files use ~301 blocks (well under 3160) but 301 dir entries. tiny = [10] * 300 over = ss.budget("c64", "d81", tiny, 283) assert over.used_blocks < over.total_blocks # blocks would fit assert not over.fits and "directory" in over.reason def test_slideshow_stub_assembles_and_fits(): if not have_xa(): return for advance in ("key", "seconds", "both"): for loop in (True, False): prg = build_slideshow_prg([0, 1, 0], advance=advance, seconds=5, loop=loop) assert prg[:2] == bytes([0x01, 0x08]) # load address $0801 end = 0x0801 + (len(prg) - 2) assert end < 0x2000 # code clears picture RAM def test_slideshow_disk_roundtrip(): if not (have_xa() and have_c1541()): return with tempfile.TemporaryDirectory() as td: src = os.path.join(td, "g.png") _gradient_png(src) items = [ ss.SlideItem(src, mode="multicolor"), ss.SlideItem(src, mode="hires", prep=imageprep.PrepOptions(brightness=1.2)), ss.SlideItem(src, mode="mono", mono_base="green"), ] show = ss.Slideshow(platform="c64", disk_format="d64", advance="both", seconds=5, loop=True, items=items) convs = [ss.convert_item(show, it) for it in items] out = os.path.join(td, "show.d64") ss.build_disk(show, out, convs=convs) # directory lists the viewer first, then 00, 01, 02 in order dirout = subprocess.run(["c1541", "-attach", out, "-dir"], capture_output=True, text=True).stdout assert '"show"' in dirout for i in range(3): assert f'"{i:02d}"' in dirout # each data file reads back byte-identical to the conversion payload for i, c in enumerate(convs): host = os.path.join(td, f"r{i}.prg") subprocess.run(["c1541", "-attach", out, "-read", f"{i:02d}", host], capture_output=True) raw = open(host, "rb").read() assert raw[0] | (raw[1] << 8) == c.data_addr assert raw[2:] == c.data def test_slideshow_flavor_detection(): class C: def __init__(self, v): self.viewer = v assert ss.slideshow_flavor([C("hires"), C("multicolor"), C("hires")]) == "simple" assert ss.slideshow_flavor([C("fli"), C("fli")]) == "fli" assert ss.slideshow_flavor([C("interlace")]) == "interlace" for bad in ([C("hires"), C("fli")], [C("fli"), C("interlace")]): try: ss.slideshow_flavor(bad) assert False, "mixed flavors should be rejected" except ValueError: pass def test_fli_interlace_stubs_fit(): if not have_xa(): return # fli code must clear $4000, interlace must clear $2000 (their data regions) for flavor, limit in (("fli", 0x4000), ("interlace", 0x2000)): for video in ("pal", "ntsc"): prg = build_slideshow_prg([0, 0, 0], advance="both", seconds=5, loop=True, video=video, flavor=flavor) assert prg[:2] == bytes([0x01, 0x08]) assert 0x0801 + (len(prg) - 2) < limit def test_fli_interlace_disk_roundtrip(): if not (have_xa() and have_c1541()): return with tempfile.TemporaryDirectory() as td: src = os.path.join(td, "g.png") _gradient_png(src) for flavor, mode in (("fli", "fli"), ("interlace", "interlace")): items = [ss.SlideItem(src, mode=mode), ss.SlideItem(src, mode=mode)] show = ss.Slideshow(platform="c64", disk_format="d64", advance="both", seconds=5, loop=True, items=items) convs = [ss.convert_item(show, it) for it in items] assert ss.slideshow_flavor(convs) == flavor out = os.path.join(td, f"{flavor}.d64") ss.build_disk(show, out, convs=convs) for i, c in enumerate(convs): host = os.path.join(td, f"{flavor}{i}.prg") subprocess.run(["c1541", "-attach", out, "-read", f"{i:02d}", host], capture_output=True) raw = open(host, "rb").read() assert raw[0] | (raw[1] << 8) == c.data_addr # $4000 fli / $2000 ifli assert raw[2:] == c.data def test_c128_slideshow(): assert ss.supports_slideshow("c128") # CBM-DOS budget shared with the C64 assert ss.item_blocks("c128", "d64", 16384) == ss.item_blocks("c64", "d64", 16384) if not (have_xa() and have_c1541()): return with tempfile.TemporaryDirectory() as td: src = os.path.join(td, "g.png") _gradient_png(src) items = [ss.SlideItem(src, mode="hicolor"), ss.SlideItem(src, mode="mono")] show = ss.Slideshow(platform="c128", disk_format="d64", advance="both", seconds=5, loop=True, items=items) convs = [ss.convert_item(show, it) for it in items] out = os.path.join(td, "c128.d64") ss.build_disk(show, out, convs=convs) dirout = subprocess.run(["c1541", "-attach", out, "-dir"], capture_output=True, text=True).stdout assert '"pic"' in dirout # boots via RUN"PIC" for i in range(2): assert f'"{i:02d}"' in dirout host = os.path.join(td, f"r{i}.prg") subprocess.run(["c1541", "-attach", out, "-read", f"{i:02d}", host], capture_output=True) raw = open(host, "rb").read() assert raw[0] | (raw[1] << 8) == 0x4000 # VDC images load to $4000 assert raw[2:] == convs[i].data # the 80x100 'color' VDC mode is not yet supported in slideshows cshow = ss.Slideshow(platform="c128", items=[ss.SlideItem(src, mode="color")]) try: ss.check_modes("c128", [ss.convert_item(cshow, cshow.items[0])]) assert False, "color mode should be rejected" except ValueError: pass def test_atari_slideshow(): assert ss.supports_slideshow("atari") assert ss.disk_formats("atari") == ["atr"] # ATR sectors are 128 bytes, no 2-byte load-address overhead assert ss.item_blocks("atari", "atr", 8196) == 65 assert ss.item_blocks("atari", "atr", 256) == 2 if not have_xa(): return with tempfile.TemporaryDirectory() as td: src = os.path.join(td, "g.png") _gradient_png(src, 160, 192) items = [ss.SlideItem(src, mode="gr15") for _ in range(3)] show = ss.Slideshow(platform="atari", disk_format="atr", advance="both", seconds=5, loop=True, items=items) convs = [ss.convert_item(show, it) for it in items] out = os.path.join(td, "show.atr") ss.build_disk(show, out, convs=convs) raw = open(out, "rb").read() assert raw[:2] == bytes([0x96, 0x02]) # ATR magic assert raw[16 + 1] == 8 # boot loads 8 sectors spi = ss.item_blocks("atari", "atr", len(convs[0].data)) for i, c in enumerate(convs): sector = 9 + i * spi # boot_sectors(8)+1 + i*spi off = 16 + (sector - 1) * 128 assert raw[off:off + len(c.data)] == c.data # gr15 / gr9 / gr8 / gr15dli all build a disk for mode in ("gr9", "gr8", "gr15dli"): sh = ss.Slideshow(platform="atari", disk_format="atr", items=[ss.SlideItem(src, mode=mode)] * 2) ss.build_disk(sh, os.path.join(td, f"{mode}.atr")) # but modes may not be MIXED in one slideshow mix = [ss.convert_item(ss.Slideshow(platform="atari"), ss.SlideItem(src, mode=m)) for m in ("gr15", "gr9")] try: ss.check_modes("atari", mix) assert False, "mixed Atari modes should be rejected" except ValueError: pass def test_bbc_slideshow(): assert ss.supports_slideshow("bbc") assert ss.disk_formats("bbc") == ["ssd"] assert ss.item_blocks("bbc", "ssd", 20480) == 80 # 256-byte DFS sectors if not have_xa(): return with tempfile.TemporaryDirectory() as td: src = os.path.join(td, "g.png") _gradient_png(src, 320, 256) items = [ss.SlideItem(src, mode="mode1") for _ in range(3)] show = ss.Slideshow(platform="bbc", disk_format="ssd", advance="both", seconds=5, loop=True, items=items) convs = [ss.convert_item(show, it) for it in items] out = os.path.join(td, "show.ssd") ss.build_disk(show, out, convs=convs) d = open(out, "rb").read() nfiles = d[0x105] // 8 names = [d[8 + i * 8:8 + i * 8 + 7].decode("ascii").rstrip() for i in range(nfiles)] assert "!BOOT" in names and "PIC" in names for i in range(3): assert f"{i:02d}" in names j = names.index(f"{i:02d}") e = 0x100 + 8 + j * 8 start = ((d[e + 6] & 3) << 8) | d[e + 7] length = (((d[e + 6] >> 4) & 3) << 16) | (d[e + 5] << 8) | d[e + 4] off = start * 256 assert d[off:off + length] == convs[i].data # a single BBC screen mode per slideshow m2 = ss.convert_item(ss.Slideshow(platform="bbc"), ss.SlideItem(src, mode="mode2")) try: ss.check_modes("bbc", [convs[0], m2]) assert False, "mixed BBC modes should be rejected" except ValueError: pass def test_apple_slideshow(): assert ss.supports_slideshow("apple") assert ss.disk_formats("apple") == ["dsk"] assert ss.item_blocks("apple", "dsk", 8192) == 32 assert ss.budget("apple", "dsk", [8192] * 4, 0).fits # RAM holds 4 HGR assert not ss.budget("apple", "dsk", [8192] * 5, 0).fits if not have_xa(): return with tempfile.TemporaryDirectory() as td: src = os.path.join(td, "g.png") _gradient_png(src, 280, 192) items = [ss.SlideItem(src, mode="hgr_color") for _ in range(3)] show = ss.Slideshow(platform="apple", disk_format="dsk", advance="both", seconds=5, loop=True, items=items) convs = [ss.convert_item(show, it) for it in items] out = os.path.join(td, "show.dsk") ss.build_disk(show, out, convs=convs) d = open(out, "rb").read() assert len(d) == 143360 # DOS 3.3 .dsk assert d[0] == 0x01 # boot sector byte 0 # too many images for RAM try: ss.check_modes("apple", convs * 2) # 6 > 4 assert False, "over-RAM Apple slideshow should be rejected" except ValueError: pass # DHGR is not supported for slideshows yet dh = ss.convert_item(ss.Slideshow(platform="apple"), ss.SlideItem(src, mode="dhgr")) try: ss.check_modes("apple", [dh]) assert False, "DHGR should be rejected" except ValueError: pass def test_iigs_slideshow(): assert ss.supports_slideshow("iigs") assert ss.disk_formats("iigs") == ["dsk"] assert ss.item_blocks("iigs", "dsk", 32768) == 128 assert ss.budget("iigs", "dsk", [32768] * 4, 0).fits assert not ss.budget("iigs", "dsk", [32768] * 5, 0).fits if not have_xa(): return # the two-stage loader: boot must fit one 256-byte sector from lenser.iigs.viewer.assemble import build_slideshow boot, stage2, pages = build_slideshow(3, advance="both", seconds=5, loop=True) assert len(boot) <= 256 and len(stage2) > 0 and pages >= 1 with tempfile.TemporaryDirectory() as td: src = os.path.join(td, "g.png") _gradient_png(src, 320, 200) items = [ss.SlideItem(src, mode="shr") for _ in range(3)] show = ss.Slideshow(platform="iigs", disk_format="dsk", advance="both", seconds=5, loop=True, items=items) convs = [ss.convert_item(show, it) for it in items] out = os.path.join(td, "show.dsk") ss.build_disk(show, out, convs=convs) d = open(out, "rb").read() assert len(d) == 143360 and d[0] == 0x01 # bootable .dsk try: ss.check_modes("iigs", convs * 2) # 6 > 4 assert False, "over-capacity IIgs slideshow should be rejected" except ValueError: pass def test_amiga_slideshow(): assert ss.supports_slideshow("amiga") assert ss.disk_formats("amiga") == ["adf"] with tempfile.TemporaryDirectory() as td: src = os.path.join(td, "g.png") _gradient_png(src, 320, 256) items = [ss.SlideItem(src, mode="lowres") for _ in range(3)] show = ss.Slideshow(platform="amiga", disk_format="adf", advance="both", seconds=4, loop=True, items=items) convs = [ss.convert_item(show, it) for it in items] # Amiga conv.data is a dict; its on-disk size comes from the blob nb = ss.image_nbytes(convs[0]) assert nb > 8000 and ss.item_blocks("amiga", "adf", nb) == -(-nb // 512) out = os.path.join(td, "show.adf") ss.build_disk(show, out, convs=convs) d = open(out, "rb").read() assert len(d) == 901120 and d[:4] == b"DOS\x00" # bootable 880K .adf # chip-RAM bound rejects an oversized show assert not ss.budget("amiga", "adf", [40000] * 20, 0).fits def test_slideshow_overfill_raises(): if not have_xa(): return with tempfile.TemporaryDirectory() as td: src = os.path.join(td, "g.png") _gradient_png(src) # 25 multicolor images (~40 blocks each) cannot fit a 664-block d64 items = [ss.SlideItem(src, mode="multicolor") for _ in range(25)] show = ss.Slideshow(disk_format="d64", items=items) # reuse one conversion for all to keep the test fast c0 = ss.convert_item(show, items[0]) convs = [c0] * 25 out = os.path.join(td, "show.d64") try: ss.build_disk(show, out, convs=convs) assert False, "expected DiskError for an over-budget slideshow" except DiskError as e: assert "does not fit" in str(e) def _imgobj(w, h): arr = np.zeros((h, w, 3), np.uint8) return Image.fromarray(arr, "RGB") if __name__ == "__main__": fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")] for fn in fns: fn() print(f"PASS {fn.__name__}") print(f"\nAll {len(fns)} slideshow tests passed.")