harness.py

Part of You’re the OS.

  1"""Scripted-input harness: drives the port headlessly and captures seven stages.
  2
  3Stages:
  4    01_boot.png            the title screen, before the machine is booted
  5    02_first_process.png   one process visible
  6    03_queue_filling.png   ~20 processes spawned, filling slots
  7    04_cpu_loaded.png      click-toggled several processes onto CPUs
  8    05_pages_swapped.png   triggered page swaps via clicks
  9    06_io_event.png        I/O queue lit, event delivered
 10    07_late_game.png       the shutdown notice, after ten unattended processes starved
 11
 12Input goes through ``InputSimulator``, so every click and key travels the same
 13path a real mouse or keyboard would: polled state, scene-tree events, and UI
 14events all move in lockstep.
 15"""
 16
 17from __future__ import annotations
 18
 19import sys
 20from collections.abc import Callable
 21from pathlib import Path
 22
 23_PORT_DIR = Path(__file__).parent
 24if str(_PORT_DIR) not in sys.path:
 25    sys.path.insert(0, str(_PORT_DIR))
 26
 27from main import OsRoot  # noqa: E402
 28from nodes.stage import (  # noqa: E402
 29    DESIGN_HEIGHT,
 30    DESIGN_WIDTH,
 31    PAGE_GAP,
 32    PAGE_H,
 33    PAGE_W,
 34    PROCESS_AREA_X,
 35    PROCESS_AREA_Y,
 36    PROCESS_GAP,
 37    PROCESS_SIZE,
 38    RAM_AREA_X,
 39    RAM_Y,
 40)
 41
 42from simvx.core.input.controls import Key  # noqa: E402
 43from simvx.core.testing import InputSimulator  # noqa: E402
 44from simvx.graphics import App, save_png  # noqa: E402
 45
 46OUT = _PORT_DIR / "screenshots"
 47
 48
 49def _idle_slot_centre(idx: int, cols: int) -> tuple[float, float]:
 50    """Centre of idle process slot *idx*, from the stage's own layout constants."""
 51    col, row = idx % cols, idx // cols
 52    return (
 53        PROCESS_AREA_X + col * (PROCESS_SIZE + PROCESS_GAP) + PROCESS_SIZE * 0.5,
 54        PROCESS_AREA_Y + row * (PROCESS_SIZE + PROCESS_GAP) + PROCESS_SIZE * 0.5,
 55    )
 56
 57
 58def _ram_page_centre(idx: int, cols: int) -> tuple[float, float]:
 59    """Centre of RAM page slot *idx*, from the stage's own layout constants."""
 60    col, row = idx % cols, idx // cols
 61    return (
 62        RAM_AREA_X + col * (PAGE_W + PAGE_GAP) + PAGE_W * 0.5,
 63        RAM_Y + row * (PAGE_H + PAGE_GAP) + PAGE_H * 0.5,
 64    )
 65
 66
 67def run_harness() -> None:
 68    OUT.mkdir(exist_ok=True)
 69    app = App(width=DESIGN_WIDTH, height=DESIGN_HEIGHT, title="You're the OS (Harness)", visible=False)
 70    root = OsRoot()
 71    sim = InputSimulator(root)
 72
 73    def click_at(pos: tuple[float, float]) -> Callable[[], None]:
 74        return lambda: sim.click(pos)
 75
 76    from nodes import state as gs
 77
 78    captures: dict[int, str] = {}
 79    actions: dict[int, Callable[[], None]] = {}
 80
 81    # Stage 1: the title screen, before anything is clicked.
 82    captures[1] = "01_boot.png"
 83
 84    # Boot the machine, then let the startup burst spawn its first process.
 85    actions[4] = lambda: sim.click((DESIGN_WIDTH * 0.5, DESIGN_HEIGHT * 0.5))
 86    captures[60] = "02_first_process.png"
 87
 88    # Stage 3: most slots filled by frame ~600 (10 s of spawns).
 89    captures[600] = "03_queue_filling.png"
 90
 91    # Stage 4: click four idle processes onto the four CPUs.
 92    for i in range(4):
 93        actions[610 + i * 8] = click_at(_idle_slot_centre(i, gs.NUM_PROCESS_SLOT_COLS))
 94    captures[660] = "04_cpu_loaded.png"
 95
 96    # Stage 5: click the first few RAM pages to request swaps to disk.
 97    for i in range(3):
 98        actions[700 + i * 6] = click_at(_ram_page_centre(i, gs.PAGES_PER_ROW))
 99    captures[800] = "05_pages_swapped.png"
100
101    # Stage 6: by now an I/O event has queued; deliver it with Space.
102    actions[1200] = lambda: sim.press_key(Key.SPACE)
103    actions[1205] = lambda: sim.release_key(Key.SPACE)
104    captures[1300] = "06_io_event.png"
105
106    # Stage 7: only four processes were ever scheduled, so the rest starve and
107    # the machine reaches its shutdown notice.
108    captures[1800] = "07_late_game.png"
109
110    total_frames = 1850
111
112    def on_frame(idx: int, t: float) -> None:
113        action = actions.get(idx)
114        if action is not None:
115            action()
116
117    frames = app.run_headless(
118        root,
119        frames=total_frames,
120        on_frame=on_frame,
121        capture_fn=lambda idx: idx in captures,
122    )
123
124    for frame_index, image in zip(sorted(captures), frames, strict=False):
125        out = OUT / captures[frame_index]
126        save_png(image, out)
127        print(f"saved {out}")
128    print(f"{len(captures)} screenshots written")
129
130
131if __name__ == "__main__":
132    run_harness()