MIL-013: Debian container and Windows Sandbox checks

A Debian image (python:3.13-slim-bookworm with Xvfb and GNOME's window
manager) runs the linters, the whole test suite, the screenshots and the
smoke test of a PyInstaller Linux binary and of the source program, so no
window opens on the development machine. The screenshot tool works on
Linux, a smoke driver presses the known button with the virtual mouse, and
Windows Sandbox scripts are written (not run, they open a window on the
host). README and the wiki Install pages describe it. Review records
RC-077 (Python) and RC-078 (shell).

Task: MIL-013#2
Task: MIL-013#3
Task: MIL-013#4
Task: MIL-013#5
Task: MIL-013#6
Task: MIL-013#7
Task: MIL-013#9
Task: MIL-013#10
This commit is contained in:
2026-10-15 10:09:48 +08:00
parent a72ddb9175
commit f8ccfca02e
24 changed files with 1205 additions and 10 deletions
+56 -9
View File
@@ -13,7 +13,7 @@ from __future__ import annotations
import argparse
import ctypes
import ctypes.wintypes
import os
import random
import shutil
import sys
@@ -23,7 +23,7 @@ import tkinter as tk
from collections.abc import Sequence
from pathlib import Path
from PIL import ImageGrab
from PIL import Image, ImageGrab
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
@@ -45,6 +45,46 @@ DEFAULT_OUTPUT_DIR = PROJECT_ROOT / "docs" / "images"
SCREENSHOT_SEED = 7
## @brief Extra time in seconds to let the window settle before a grab.
SETTLE_SECONDS = 0.5
## @brief Height in pixels of the menu bar above the client area on X11.
MENU_BAR_HEIGHT_X11 = 28
## @brief Platform kind of Windows.
KIND_WINDOWS = "windows"
## @brief Platform kind of every other platform (an X11 display).
KIND_X11 = "x11"
def platform_kind(platform: str) -> str:
"""Choose how to grab the screen.
@param platform A `sys.platform` value.
@return "windows" for win32, otherwise "x11".
"""
return KIND_WINDOWS if platform == "win32" else KIND_X11
def window_box(
kind: str, rootx: int, rooty: int, width: int, height: int, menu_height: int
) -> tuple[int, int, int, int]:
"""Return the screen box to grab for a window.
@param kind The platform kind from `platform_kind`.
@param rootx Left edge of the client area.
@param rooty Top edge of the client area.
@param width Width of the client area.
@param height Height of the client area.
@param menu_height Height of the menu bar above the client area; it is
added on X11 only, as on Windows the frame box is read from the system.
@return Left, top, right and bottom in screen pixels.
"""
top = rooty - menu_height if kind == KIND_X11 else rooty
return rootx, top, rootx + width, rooty + height
def _grab(box: tuple[int, int, int, int]) -> Image.Image:
"""Grab a screen box on the current platform."""
if platform_kind(sys.platform) == KIND_X11:
return ImageGrab.grab(bbox=box, xdisplay=os.environ.get("DISPLAY"))
return ImageGrab.grab(bbox=box)
def _make_dpi_aware() -> None:
@@ -59,6 +99,8 @@ def _frame_box(root: tk.Tk) -> tuple[int, int, int, int]:
@param root The Tk root window.
@return Left, top, right and bottom in screen pixels.
"""
import ctypes.wintypes # Windows only, so it is imported here
handle = ctypes.windll.user32.GetParent(root.winfo_id()) # type: ignore[attr-defined,unused-ignore]
rect = ctypes.wintypes.RECT()
ctypes.windll.user32.GetWindowRect(handle, ctypes.byref(rect)) # type: ignore[attr-defined,unused-ignore]
@@ -102,13 +144,14 @@ def capture(output_dir: Path) -> list[Path]:
root.lift()
root.attributes("-topmost", True)
_pump(root, SETTLE_SECONDS)
window_box = (
kind = platform_kind(sys.platform)
client = (
root.winfo_rootx(),
root.winfo_rooty(),
root.winfo_rootx() + root.winfo_width(),
root.winfo_rooty() + root.winfo_height(),
root.winfo_width(),
root.winfo_height(),
)
ImageGrab.grab(bbox=window_box).save(level)
_grab(window_box(kind, *client, 0)).save(level)
application.start(Level(None))
view = application.view
if not isinstance(view, FlashCardView):
@@ -123,10 +166,14 @@ def capture(output_dir: Path) -> list[Path]:
+ view.right_button.winfo_height()
+ 20,
)
ImageGrab.grab(bbox=box).save(front)
ImageGrab.grab(bbox=_frame_box(root)).save(menu)
_grab(box).save(front)
if kind == KIND_WINDOWS:
menu_box = _frame_box(root)
else:
menu_box = window_box(kind, *client, MENU_BAR_HEIGHT_X11)
_grab(menu_box).save(menu)
_pump(root, FLIP_DELAY_MS / 1000 + SETTLE_SECONDS)
ImageGrab.grab(bbox=box).save(back)
_grab(box).save(back)
root.destroy()
return [level, front, back, menu]