test: the lab prints from a signed-in lesson, and judges black and white on the job

A lesson site for the lab (session cookie, one-time addresses, a second
origin, a request log) and real right-clicks on its words and on a JPEG, a
PNG, a transparent PNG, a one-time picture, another site's, a blob:, a
WebP, a canvas, a linked picture, a broken one, and Cancel. Each is judged
on the job CUPS kept — where and how large, no other ink — and on the site
log: nothing was fetched again. The clipboard is the teacher's afterwards.

Black and white: a colourful lesson turned back and forth five times on
page 2, a worksheet, a picture at 50% top left, a page full of pictures,
back to Color, and Cancel. Every grey preview is grey pixel for pixel and
the job is byte-for-byte the file shown, through pxlmono. The three 0.2.6.2
black-and-white scenarios now wait for the grey file before Print, as a
teacher has to. 343 checks, none failing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-09-26 16:34:38 -05:00
co-authored by Claude Opus 5.5
parent 594fbd810e
commit cb12e59dea
4 changed files with 535 additions and 35 deletions
+128
View File
@@ -0,0 +1,128 @@
"""A signed-in lesson with pictures in it, for right-click printing.
wonderink-lesson-site.py PICTURES PORT OTHER_PORT LOG
Run inside the lab by tools/lab/wonderink-print-check. Stands in for
Wonder Ink's own site, which the lab has no account for, with every kind
of picture a lesson might put under a teacher's pointer:
/login sets a session cookie (HttpOnly, SameSite=Lax), as a
sign-in does, and goes on to /lesson
/lesson the page; its pictures are all behind that cookie
/private/NAME a picture, 403 without the cookie
/once/N/NAME a picture that can be fetched exactly once — so a
print that fetches it again gets a 404, not a picture
OTHER_PORT the same pictures from another origin, with no CORS
Every request is logged, one JSON line each, so a check can say what was
fetched while it printed. What was measured with this, and why Print
image fetches nothing, is in classroom-wonderink's _take_picture.
"""
import http.server
import json
import sys
import threading
import time
import uuid
from pathlib import Path
from urllib.parse import urlparse
PICTURES = Path(sys.argv[1])
PORT, OTHER = int(sys.argv[2]), int(sys.argv[3])
LOG = Path(sys.argv[4])
TYPES = {".png": "image/png", ".jpg": "image/jpeg", ".webp": "image/webp"}
fetched_once = set()
lock = threading.Lock()
LESSON = """<html><head><title>Picture lesson</title><style>
body { margin: 8px; font: 15px sans-serif; }
img, canvas { width: 180px; height: 120px; object-fit: contain; border: 1px solid #ccc; margin: 4px; }
</style></head><body>
<h1 style="color:#c00">Noah's Ark</h1>
<p id="words">Noah built an ark, and the animals came two by two. Colour the pictures below.</p>
<img id="private-jpg" src="/private/ark-wide.jpg">
<img id="private-png" src="/private/ark-tall.png">
<img id="clear" src="/private/ark-clear.png">
<img id="webp" src="/private/ark-wide.webp">
<img id="once" src="/once/NONCE/ark-wide.png">
<img id="cross" src="http://localhost:OTHER/img/ark-tall.png">
<img id="blob">
<canvas id="canvas" width="1600" height="1000"></canvas>
<img id="broken" src="/private/not-there.png">
<a href="/lesson#more"><img id="linked" src="/private/ark-tall.png"></a>
<script>
var waiting = 2;
function one() { if (--waiting === 0) window.__ready = true; }
fetch('/private/ark-tall.png', {credentials: 'include'})
.then(function (r) { return r.blob(); })
.then(function (b) { var i = document.getElementById('blob'); i.onload = one; i.src = URL.createObjectURL(b); });
var art = new Image();
art.onload = function () { document.getElementById('canvas').getContext('2d').drawImage(art, 0, 0); one(); };
art.src = '/private/ark-wide.png';
</script></body></html>"""
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, *_):
pass
def answer(self, code, body=b"", kind="text/plain", headers=()):
self.send_response(code)
self.send_header("Content-Type", kind)
self.send_header("Content-Length", str(len(body)))
for name, value in headers:
self.send_header(name, value)
self.end_headers()
self.wfile.write(body)
def picture(self, name):
path = PICTURES / Path(name).name
if not path.is_file() or path.suffix not in TYPES:
return self.answer(404, b"no such picture")
return self.answer(200, path.read_bytes(), TYPES[path.suffix],
[("Cache-Control", "private, max-age=600")])
def do_GET(self):
path = urlparse(self.path).path
signed_in = "session=ok" in self.headers.get("Cookie", "")
with lock, open(LOG, "a") as log:
log.write(json.dumps({"t": time.time(), "port": self.server.server_port,
"path": path, "signed_in": signed_in}) + "\n")
if self.server.server_port == OTHER:
if path.startswith("/img/"):
return self.picture(path[5:])
return self.answer(404)
if path == "/login":
return self.answer(302, headers=[
("Set-Cookie", "session=ok; Path=/; HttpOnly; SameSite=Lax"),
("Location", "/lesson")])
if not signed_in:
return self.answer(403, b"sign in first")
if path == "/lesson":
page = LESSON.replace("NONCE", uuid.uuid4().hex).replace("OTHER", str(OTHER))
return self.answer(200, page.encode(), "text/html")
if path.startswith("/private/"):
return self.picture(path[9:])
if path.startswith("/once/"):
with lock:
if path in fetched_once:
return self.answer(404, b"this address has been used")
fetched_once.add(path)
return self.picture(path.rsplit("/", 1)[1])
return self.answer(404)
for port in (OTHER, PORT):
server = http.server.ThreadingHTTPServer(("127.0.0.1", port), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
threading.Event().wait()
+65 -2
View File
@@ -3,6 +3,7 @@
wonderink-picture-measure.py make DIRECTORY wonderink-picture-measure.py make DIRECTORY
wonderink-picture-measure.py measure PDF WIDTHxHEIGHT SIZE POSITION PAPER FILL wonderink-picture-measure.py measure PDF WIDTHxHEIGHT SIZE POSITION PAPER FILL
wonderink-picture-measure.py darkness PDF wonderink-picture-measure.py darkness PDF
wonderink-picture-measure.py colour PDF
Run inside the lab by tools/lab/wonderink-print-check. The pictures are Run inside the lab by tools/lab/wonderink-print-check. The pictures are
white with a black border drawn on their own edges and a pink mark near white with a black border drawn on their own edges and a pink mark near
@@ -55,10 +56,42 @@ def make(directory):
# Transparent around the drawing: on paper that has to be white, not # Transparent around the drawing: on paper that has to be white, not
# black, which is what a careless composite would make of it. # black, which is what a careless composite would make of it.
art(1200, 1200, "ark-clear.png", "Clear", transparent=True) art(1200, 1200, "ark-clear.png", "Clear", transparent=True)
webp(directory)
def page_pixels(pdf): def webp(directory):
ppm = subprocess.run(["pdftoppm", "-r", "72", "-f", "1", "-l", "1", "-singlefile", pdf], """ark-wide.png as WebP. Qt cannot write WebP, or read it; Chromium does both."""
import base64
from PyQt6.QtCore import QTimer, QUrl
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtWidgets import QApplication
app = QApplication.instance() or QApplication(sys.argv[:1])
source = base64.b64encode(open(f"{directory}/ark-wide.png", "rb").read()).decode()
view = QWebEngineView()
def made(answer):
if isinstance(answer, str) and answer.startswith("data:image/webp;base64,"):
with open(f"{directory}/ark-wide.webp", "wb") as handle:
handle.write(base64.b64decode(answer.split(",", 1)[1]))
app.quit()
def loaded(_ok):
view.page().runJavaScript("""var i = new Image(); i.onload = function(){
var c = document.createElement('canvas'); c.width = i.width; c.height = i.height;
c.getContext('2d').drawImage(i, 0, 0); window.__webp = c.toDataURL('image/webp', 0.95); };
i.src = 'data:image/png;base64,%s'; 0""" % source)
QTimer.singleShot(1500, lambda: view.page().runJavaScript("window.__webp", made))
view.loadFinished.connect(loaded)
view.setHtml("<html></html>")
QTimer.singleShot(20000, app.quit)
app.exec()
def page_pixels(pdf, page=1):
ppm = subprocess.run(["pdftoppm", "-r", "72", "-f", str(page), "-l", str(page), "-singlefile", pdf],
capture_output=True, check=True).stdout capture_output=True, check=True).stdout
# P6 header: magic, width, height, maxval, then raw RGB. # P6 header: magic, width, height, maxval, then raw RGB.
fields, index = [], 0 fields, index = [], 0
@@ -106,6 +139,19 @@ def measure(pdf, picture, size, position, paper, fill):
if magenta: if magenta:
pink += 1 pink += 1
# The corner mark, wherever it landed and whatever colour it came out:
# pink on a colour page, grey on a black-and-white one. Inked if most
# of the middle of where it should be is not paper.
mark_left = max(x + w * 0.07, MARGIN_POINTS)
mark_right = min(x + w * 0.13, width - MARGIN_POINTS)
mark_top = max(y + h * 0.82, MARGIN_POINTS)
mark_bottom = min(y + h * 0.88, height - MARGIN_POINTS)
mark = [
sum(rgb[(row * width + column) * 3:(row * width + column) * 3 + 3]) < 3 * 200
for row in range(int(mark_top), int(mark_bottom))
for column in range(int(mark_left), int(mark_right))
]
found = (min(xs), min(ys), max(xs) + 1, max(ys) + 1) if xs else None found = (min(xs), min(ys), max(xs) + 1, max(ys) + 1) if xs else None
expected = (round(left), round(top), round(right), round(bottom)) expected = (round(left), round(top), round(right), round(bottom))
off = max(abs(a - b) for a, b in zip(found, expected)) if found else None off = max(abs(a - b) for a, b in zip(found, expected)) if found else None
@@ -119,9 +165,24 @@ def measure(pdf, picture, size, position, paper, fill):
"off_by_points": off, "off_by_points": off,
"ink_outside_picture": outside, "ink_outside_picture": outside,
"pink_pixels": pink, "pink_pixels": pink,
"mark_inked": bool(mark) and sum(mark) > 0.8 * len(mark),
"colour_spread": spread(rgb),
})) }))
def spread(rgb):
"""The most any pixel's red, green and blue differ: 0 on a grey page."""
return max((max(rgb[i:i + 3]) - min(rgb[i:i + 3]) for i in range(0, len(rgb) - 2, 3)), default=0)
def colour(pdf):
"""Is every page grey? The colour spread of each page, and how many."""
pages = int(subprocess.run(["pdfinfo", pdf], capture_output=True, text=True).stdout
.split("Pages:")[1].split()[0])
spreads = [spread(page_pixels(pdf, n)[2]) for n in range(1, pages + 1)]
print(json.dumps({"pages": pages, "spreads": spreads, "gray": max(spreads) == 0}))
def darkness(pdf): def darkness(pdf):
"""How much of the first page is dark, 0 to 1.""" """How much of the first page is dark, 0 to 1."""
width, height, rgb = page_pixels(pdf) width, height, rgb = page_pixels(pdf)
@@ -140,5 +201,7 @@ if __name__ == "__main__":
make(sys.argv[2]) make(sys.argv[2])
elif sys.argv[1] == "darkness": elif sys.argv[1] == "darkness":
darkness(sys.argv[2]) darkness(sys.argv[2])
elif sys.argv[1] == "colour":
colour(sys.argv[2])
else: else:
measure(*sys.argv[2:]) measure(*sys.argv[2:])
+173 -27
View File
@@ -27,6 +27,9 @@ lab 'mkdir -p /opt/wonderink-check'
docker cp "$ROOT/tools/lab/wonderink-print-rig.py" "$NAME:$RIG" >/dev/null docker cp "$ROOT/tools/lab/wonderink-print-rig.py" "$NAME:$RIG" >/dev/null
MEASURE=/opt/wonderink-check/wonderink-picture-measure.py MEASURE=/opt/wonderink-check/wonderink-picture-measure.py
docker cp "$ROOT/tools/lab/wonderink-picture-measure.py" "$NAME:$MEASURE" >/dev/null docker cp "$ROOT/tools/lab/wonderink-picture-measure.py" "$NAME:$MEASURE" >/dev/null
SITE=/opt/wonderink-check/wonderink-lesson-site.py
SITE_LOG=/opt/wonderink-check/site.log
docker cp "$ROOT/tools/lab/wonderink-lesson-site.py" "$NAME:$SITE" >/dev/null
lab "chmod -R a+rX /opt/wonderink-check && test -r $RIG" || { lab "chmod -R a+rX /opt/wonderink-check && test -r $RIG" || {
echo "could not put the rig in the lab" >&2 echo "could not put the rig in the lab" >&2
exit 1 exit 1
@@ -70,9 +73,10 @@ mkdir -p /opt/wonderink-check/www
cupsctl PreserveJobFiles=Yes cupsctl PreserveJobFiles=Yes
' || { echo "could not prepare the lab's CUPS" >&2; exit 1; } ' || { echo "could not prepare the lab's CUPS" >&2; exit 1; }
lab "test -f /opt/wonderink-check/www/ark-clear.png || \ lab "test -f /opt/wonderink-check/www/ark-wide.webp || \
QT_QPA_PLATFORM=offscreen python3 $MEASURE make /opt/wonderink-check/www; \ QT_QPA_PLATFORM=offscreen QTWEBENGINE_CHROMIUM_FLAGS='--no-sandbox --disable-gpu' \
chmod -R a+r /opt/wonderink-check/www; test -f /opt/wonderink-check/www/ark-clear.png" || { python3 $MEASURE make /opt/wonderink-check/www; \
chmod -R a+r /opt/wonderink-check/www; test -f /opt/wonderink-check/www/ark-wide.webp" || {
echo "could not make the test pictures" >&2 echo "could not make the test pictures" >&2
exit 1 exit 1
} }
@@ -82,6 +86,15 @@ lab "test -f /opt/wonderink-check/www/ark-clear.png || \
lab 'pgrep -f "[h]ttp.server 8089" >/dev/null' || \ lab 'pgrep -f "[h]ttp.server 8089" >/dev/null' || \
docker exec -d -w /opt/wonderink-check/www "$NAME" python3 -m http.server 8089 docker exec -d -w /opt/wonderink-check/www "$NAME" python3 -m http.server 8089
# The signed-in lesson with pictures in it, restarted each run so that its
# one-time addresses start unused.
lab "pkill -f '[w]onderink-lesson-site.py' ; : > $SITE_LOG; chmod a+rw $SITE_LOG"
docker exec -d "$NAME" python3 "$SITE" /opt/wonderink-check/www 8094 8095 "$SITE_LOG"
lab 'for _ in $(seq 20); do curl -s -o /dev/null http://127.0.0.1:8094/login && exit 0; sleep 0.3; done; exit 1' || {
echo "the lesson site did not start" >&2
exit 1
}
passed=0 passed=0
failed=0 failed=0
@@ -112,6 +125,48 @@ count() { wc -w <<< "$jobs"; }
# The file CUPS was handed for a job, kept by PreserveJobFiles. # The file CUPS was handed for a job, kept by PreserveJobFiles.
spool_file() { local id="${1%% *}"; printf '/var/spool/cups/d%05d-001' "${id##*-}"; } spool_file() { local id="${1%% *}"; printf '/var/spool/cups/d%05d-001' "${id##*-}"; }
said() { grep -q -- "$1" <<< "$out"; } said() { grep -q -- "$1" <<< "$out"; }
# Every line of the rig's output that starts with WORD.
lines() { grep -o -- "$1.*" <<< "$out"; }
# Is every page of a PDF in the lab grey? {"pages", "spreads", "gray"}
colour_of() { lab "python3 $MEASURE colour $1"; }
is_gray() { python3 -c 'import json,sys; sys.exit(0 if json.loads(sys.argv[1])["gray"] else 1)' "$1"; }
# What the lesson site was asked for after the right-click.
fetched_since() {
local since
since=$(grep -o 'since=[0-9.]*' <<< "$out" | head -1 | cut -d= -f2)
lab "python3 -c 'import json,sys; print(sum(1 for l in open(\"$SITE_LOG\") if json.loads(l)[\"t\"] > float(sys.argv[1])))' ${since:-0}"
}
teacher_text_kept() { said "CLIPBOARD \"TEACHER'S TEXT, John 3:16\""; }
# picture layout, measured on the page CUPS kept: judges its own lines.
judge_layout() { # FILE_DIMS SIZE POSITION PAPER FILL COLOUR
local dims=$1 size=$2 position=$3 paper=$4 fill=$5 color=$6 spool result
spool=$(spool_file "$jobs")
judge "CUPS was given exactly the page the preview showed" \
"[[ -n '$spool' ]] && lab 'cmp -s /tmp/rig-out/preview.pdf $spool'"
result=$(lab "python3 $MEASURE measure $spool $dims $size $position $paper $fill")
field() { python3 -c "import json,sys; print(json.loads(sys.argv[1])[sys.argv[2]])" "$result" "$1"; }
judge "the paper is turned the way asked ($paper): $(field page)" \
'[[ "$(field page_landscape)" == "$(field wanted_landscape)" ]]'
judge "the picture is where and as large as asked, within 3 points (off by $(field off_by_points))" \
'[[ "$(field off_by_points)" != None ]] && (( $(field off_by_points) <= 3 ))'
judge "no ink anywhere else on the page — no bars ($(field ink_outside_picture) pixels)" \
'[[ "$(field ink_outside_picture)" == 0 ]]'
if [[ "$fill" == fill ]]; then
# The mark is in the picture's left fifth, which filling a
# portrait page with a wide picture cuts off. That is what the
# control says it does; the fit scenarios prove it happens only
# when asked.
judge "Fill cut the edges off, as it says it will" '(( $(field pink_pixels) == 0 ))'
elif [[ "$color" == gray ]]; then
judge "the corner mark is on the paper, in grey — nothing cut off" '[[ "$(field mark_inked)" == True ]]'
judge "and the page is grey, as the preview showed (colour spread $(field colour_spread))" \
'(( $(field colour_spread) == 0 ))'
judge "and it went in black and white, by the driver's own option" \
'[[ "$(job_options $jobs)" == *ColorModel=Grayscale* && "$(job_device $jobs)" == *pxlmono* ]]'
else
judge "the pink corner mark is on the paper — nothing cut off" '(( $(field pink_pixels) > 0 ))'
fi
}
exited_cleanly() { said 'exit=0' && ! said 'Traceback' && ! said 'Segmentation' && ! said 'Aborted'; } exited_cleanly() { said 'exit=0' && ! said 'Traceback' && ! said 'Segmentation' && ! said 'Aborted'; }
# Where a picture goes, measured on the page CUPS was given: # Where a picture goes, measured on the page CUPS was given:
@@ -125,12 +180,36 @@ PICTURES=(
picture:ark-wide.png:100:centre:portrait:fill picture:ark-wide.png:100:centre:portrait:fill
picture:ark-wide.jpg:75:right:auto:fit:gray picture:ark-wide.jpg:75:right:auto:fit:gray
picture:ark-clear.png:50:top:auto:fit picture:ark-clear.png:50:top:auto:fit
picture:ark-wide.png:50:top-left:auto:fit:gray
)
# Black and white shown as it prints, and Color again (0.2.6.3).
COLOURS=(bw-web bw-pdf bw-colour-again bw-cancel)
# Right-click in a signed-in lesson (0.2.6.3):
# rclick-image:ELEMENT:SIZE:POSITION[:gray], the elements in
# wonderink-lesson-site.py.
RIGHT_CLICKS=(
rclick-page
rclick-page-gray
rclick-image:private-jpg:50:top-left
rclick-image:private-png:100:centre
rclick-image:clear:50:top-left
rclick-image:once:100:centre
rclick-image:cross:75:right
rclick-image:blob:100:centre
rclick-image:webp:100:centre
rclick-image:canvas:100:centre
rclick-image:linked:33:bottom-right
rclick-image:private-jpg:50:top-left:gray
rclick-broken
rclick-cancel
) )
scenarios=("$@") scenarios=("$@")
[[ ${#scenarios[@]} -gt 0 ]] || scenarios=(web pdf pdf-options pdf-viewer-button inline [[ ${#scenarios[@]} -gt 0 ]] || scenarios=(web pdf pdf-options pdf-viewer-button inline
options bad-range broken cancel page-closes-itself close-window page-prints-again repeat options bad-range broken cancel page-closes-itself close-window page-prints-again repeat
dark-page "${PICTURES[@]}") dark-page "${PICTURES[@]}" "${COLOURS[@]}" "${RIGHT_CLICKS[@]}")
# The inline scenario needs a PDF to serve; the worksheet scenario writes one. # The inline scenario needs a PDF to serve; the worksheet scenario writes one.
lab 'test -f /opt/wonderink-check/www/ark.pdf' || { run pdf >/dev/null; lab 'cp "/home/teacher/Downloads/Wonder Ink/Colour the Ark.pdf" /opt/wonderink-check/www/ark.pdf; chmod a+r /opt/wonderink-check/www/ark.pdf'; } lab 'test -f /opt/wonderink-check/www/ark.pdf' || { run pdf >/dev/null; lab 'cp "/home/teacher/Downloads/Wonder Ink/Colour the Ark.pdf" /opt/wonderink-check/www/ark.pdf; chmod a+r /opt/wonderink-check/www/ark.pdf'; }
@@ -204,31 +283,98 @@ for scenario in "${scenarios[@]}"; do
IFS=: read -r _ file size position paper fill color <<< "$scenario" IFS=: read -r _ file size position paper fill color <<< "$scenario"
judge "the picture controls appeared, and none for a page" "said 'PICTURE picture_box=True'" judge "the picture controls appeared, and none for a page" "said 'PICTURE picture_box=True'"
judge "the print interface says Color" "said \"labels='Color','Color'\"" judge "the print interface says Color" "said \"labels='Color','Color'\""
judge "one job reached CUPS" '[[ $(count) -eq 1 ]]'
spool=$(spool_file "$jobs")
judge "CUPS was given exactly the page the preview showed" \
"[[ -n '$spool' ]] && lab 'cmp -s /tmp/rig-out/preview.pdf $spool'"
dims=$(lab "python3 -c 'from PyQt6.QtGui import QImage; i=QImage(\"/opt/wonderink-check/www/$file\"); print(f\"{i.width()}x{i.height()}\")'" 2>/dev/null)
result=$(lab "python3 $MEASURE measure $spool $dims $size $position $paper $fill")
field() { python3 -c "import json,sys; print(json.loads(sys.argv[1])[sys.argv[2]])" "$result" "$1"; }
judge "the paper is turned the way asked ($paper): $(field page)" \
'[[ "$(field page_landscape)" == "$(field wanted_landscape)" ]]'
judge "the picture is where and as large as asked, within 3 points (off by $(field off_by_points))" \
'[[ "$(field off_by_points)" != None ]] && (( $(field off_by_points) <= 3 ))'
judge "no ink anywhere else on the page — no bars ($(field ink_outside_picture) pixels)" \
'[[ "$(field ink_outside_picture)" == 0 ]]'
if [[ "$fill" == fill ]]; then
# The mark is in the picture's left fifth, which filling a
# portrait page with a wide picture cuts off. That is what the
# control says it does; the fit scenarios prove it happens only
# when asked.
judge "Fill cut the edges off, as it says it will" '(( $(field pink_pixels) == 0 ))'
else
judge "the pink corner mark is on the paper — nothing cut off" '(( $(field pink_pixels) > 0 ))'
fi
if [[ "$color" == gray ]]; then if [[ "$color" == gray ]]; then
judge "and it went in black and white" '[[ "$(job_device $jobs)" == *pxlmono* ]]' judge "the preview shows it in black and white" "said 'shown_gray=True'"
fi
judge "one job reached CUPS" '[[ $(count) -eq 1 ]]'
dims=$(lab "python3 -c 'from PyQt6.QtGui import QImage; i=QImage(\"/opt/wonderink-check/www/$file\"); print(f\"{i.width()}x{i.height()}\")'" 2>/dev/null)
judge_layout "$dims" "$size" "$position" "$paper" "$fill" "$color" ;;
bw-*)
judge "the preview starts in colour" "said 'COLOUR FIRST shown_gray=False'"
if [[ "$scenario" != bw-colour-again ]]; then
judge "Black and white shows a grey page, every time it is chosen" \
'! lines "want=gray" | grep -qv "shown_gray=True" && lines "want=gray" | grep -q .'
fi
if lines "want=colour" | grep -q .; then
judge "Color shows the colour page again, every time" '! lines "want=colour" | grep -qv "shown_gray=False"'
fi
case "$scenario" in
bw-web)
judge "turned over and over, it stays on page 2 of 3" '! lines TOGGLE | grep -qv "page=2/3"'
judge "black and white is made once, and the same file shown each time" \
'[[ $(lines "want=gray" | grep -o "pdf=[^ ]*" | sort -u | wc -l) -eq 1 ]]'
judge "and shown again at once (after the first: $(lines 'want=gray' | tail -n +2 | grep -o 'took=[0-9.]*' | tr '\n' ' '))" \
'! lines "want=gray" | tail -n +2 | grep -o "took=[0-9.]*" | cut -d= -f2 | grep -qv "^0\.[0-4]"'
judge "one job" '[[ $(count) -eq 1 ]]'
judge "page 2 only, two copies" \
'[[ "$(job_options $jobs)" == *page-ranges=2* && "$(job_options $jobs)" == *"argv[4]=\"2\""* ]]'
judge "in black and white, by the driver's own option" \
'[[ "$(job_options $jobs)" == *ColorModel=Grayscale* && "$(job_device $jobs)" == *pxlmono* ]]' ;;
bw-pdf)
judge "one job" '[[ $(count) -eq 1 ]]'
judge "in black and white, by the driver's own option" \
'[[ "$(job_options $jobs)" == *ColorModel=Grayscale* && "$(job_device $jobs)" == *pxlmono* ]]' ;;
bw-colour-again)
judge "one job" '[[ $(count) -eq 1 ]]'
judge "in colour, with no grey option" \
'[[ "$(job_options $jobs)" != *ColorModel=Grayscale* && "$(job_device $jobs)" == *pxlcolor* ]]' ;;
bw-cancel)
judge "Cancel sends nothing" '[[ $(count) -eq 0 ]]'
judge "and nothing was left behind" "said 'scratch_left=\[\]'" ;;
esac
if [[ $(count) -eq 1 ]]; then
spool=$(spool_file "$jobs")
judge "CUPS was given exactly the file the preview showed" "lab 'cmp -s /tmp/rig-out/preview.pdf $spool'"
answer=$(colour_of "$spool")
if [[ "$scenario" == bw-colour-again ]]; then
judge "and it is in colour ($answer)" '! is_gray "$answer"'
else
judge "and every page of it is grey ($answer)" 'is_gray "$answer"'
fi
fi ;; fi ;;
rclick-page)
judge "right-click on the words offers Print page, and not Print image" \
"said \"MENU .*'Print page…'\" && ! said \"'Print image…'\""
judge "which prints the lesson page, not a picture" "said 'PREVIEW picture=False'"
judge "one job" '[[ $(count) -eq 1 ]]'
judge "CUPS was given exactly the page the preview showed" \
"lab 'cmp -s /tmp/rig-out/preview.pdf $(spool_file "$jobs")'"
judge "the teacher's clipboard is untouched" teacher_text_kept ;;
rclick-page-gray)
judge "a lesson page full of pictures turns grey in the preview ($(lines 'GRAY PAGE' | grep -o 'took=[0-9.]*'))" \
"said 'GRAY PAGE .*shown_gray=True'"
judge "one job" '[[ $(count) -eq 1 ]]'
spool=$(spool_file "$jobs")
judge "CUPS was given exactly the file the preview showed" "lab 'cmp -s /tmp/rig-out/preview.pdf $spool'"
answer=$(colour_of "$spool")
judge "and every page of it is grey ($answer)" 'is_gray "$answer"'
judge "in black and white, by the driver's own option" \
'[[ "$(job_options $jobs)" == *ColorModel=Grayscale* && "$(job_device $jobs)" == *pxlmono* ]]' ;;
rclick-image:*)
IFS=: read -r _ element size position color <<< "$scenario"
judge "right-click on the picture offers Print image and Print page" \
"said \"MENU .*'Print image…', 'Print page…'\""
judge "Print image opens the picture layout" "said 'PICTURE picture_box=True'"
judge "named for the lesson it came from" "said \"title='Picture from Picture lesson'\""
judge "nothing was fetched again to get it ($(fetched_since) requests)" '[[ $(fetched_since) -eq 0 ]]'
judge "the teacher's clipboard is theirs again" teacher_text_kept
if [[ "$color" == gray ]]; then
judge "the preview shows it in black and white" "said 'shown_gray=True'"
fi
judge "one job reached CUPS" '[[ $(count) -eq 1 ]]'
dims=$(grep -o 'image=[0-9]*x[0-9]*' <<< "$out" | head -1 | cut -d= -f2)
judge_layout "$dims" "$size" "$position" auto fit "$color" ;;
rclick-broken)
judge "a picture that never loaded is still offered as a picture" "said \"MENU .*'Print image…'\""
judge "and the teacher is told it could not be taken, with Print off" \
"said \"BROKEN status='This picture could not be taken from the page\" && said 'print_enabled=False pages=0 picture=False'"
judge "the whole page is not printed instead" '[[ $(count) -eq 0 ]]'
judge "the teacher's clipboard is untouched" teacher_text_kept ;;
rclick-cancel)
judge "the picture preview came up" "said 'PREVIEW picture=True'"
judge "Cancel sends nothing" '[[ $(count) -eq 0 ]]'
judge "the teacher's clipboard is theirs again" teacher_text_kept
judge "and nothing was left behind" "said 'scratch_left=\[\]'" ;;
esac esac
[[ "$failed" -eq 0 ]] || { echo "$out" | tail -15 | sed 's/^/ /'; } [[ "$failed" -eq 0 ]] || { echo "$out" | tail -15 | sed 's/^/ /'; }
+169 -6
View File
@@ -59,7 +59,53 @@ def when(cond, then, label, limit=30):
def preview(): return window._preview def preview(): return window._preview
def ready(): return preview() is not None and preview()._pdf is not None def ready(): return preview() is not None and preview()._pdf is not None
def describe(d): def describe(d):
return f"picture={d._picture_box.isVisible()} shown={d._page in d._images} pages={d._pages} printers={[d._printer.itemText(i) for i in range(d._printer.count())]} paper={d.paper()} colour_offered={d._colour.isVisible()} status={d._status.text()!r}" return f"picture={d._picture_box.isVisible()} shown={(d._pdf, d._page) in d._images} pages={d._pages} printers={[d._printer.itemText(i) for i in range(d._printer.count())]} paper={d.paper()} colour_offered={d._colour.isVisible()} status={d._status.text()!r}"
def shown_gray():
"""Is the page the preview is drawing grey, pixel for pixel?"""
pixmap = preview()._view._pixmap
return None if pixmap is None else pixmap.toImage().allGray()
def keep_preview():
import shutil
out = Path("/tmp/rig-out"); out.mkdir(exist_ok=True)
shutil.copy(preview()._pdf, out / "preview.pdf")
def toggles(sequence, then):
"""Color (0) and Black and white (1) in turn, as a teacher would, each shown before the next."""
def step(i):
if i == len(sequence): return then()
start = time.monotonic(); preview()._colour.setCurrentIndex(sequence[i])
def shown():
d = preview()
stamp(f"TOGGLE {i} want={'gray' if sequence[i] else 'colour'} took={time.monotonic() - start:.2f} "
f"pdf={d._pdf.name} source={d._source.name} page={d._page}/{d._pages} shown_gray={shown_gray()}")
step(i + 1)
when(ready, shown, f"toggle {i}")
step(0)
def colour_lesson(view, title="Rainbow lesson"):
# Backgrounds do not print, so the colour is in the words and the drawings.
shapes = ("<svg width='600' height='120'><circle cx='60' cy='60' r='50' fill='#e00000'/>"
"<rect x='130' y='10' width='200' height='100' fill='#0050e0'/>"
"<circle cx='450' cy='60' r='50' fill='#00a000'/></svg>")
view.setHtml(f"<html><head><title>{title}</title></head><body>" + "".join(
f"<h1 style='color:#c00'>{title}, part {i}</h1>{shapes}"
f"<p style='color:#060'>{'God put a rainbow in the sky. ' * 60}</p>" for i in range(4)
) + "</body></html>", QUrl("https://app.wonderink.org/rainbow"))
def colour_worksheet(name="Colour the Rainbow"):
from PyQt6.QtGui import QColor
d = wi.DOWNLOADS; d.mkdir(parents=True, exist_ok=True)
p = d / f"{name}.pdf"
w = QPdfWriter(str(p)); w.setPageSize(QPageSize(QPageSize.PageSizeId.Letter)); w.setResolution(300)
pa = QPainter(w); pa.setFont(QFont("DejaVu Sans", 28))
for page, colours in enumerate((("#e00000", "#ff9900"), ("#00a000", "#0050e0"))):
if page: w.newPage()
pa.setPen(QColor(colours[0])); pa.drawText(200, 400, f"WORKSHEET {name} page {page + 1}")
pa.fillRect(200, 600, 1000, 800, QColor(colours[1]))
pa.end()
return p
def finish(code=0): def finish(code=0):
stamp(f"RESULT notes={notes} preview_open={preview() is not None} tabs={window._tabs.count()}") stamp(f"RESULT notes={notes} preview_open={preview() is not None} tabs={window._tabs.count()}")
@@ -76,7 +122,8 @@ actions = {
"cancel": lambda view: when(ready, lambda: (stamp("PREVIEW " + describe(preview())), preview()._cancel.click(), finish()), "preview"), "cancel": lambda view: when(ready, lambda: (stamp("PREVIEW " + describe(preview())), preview()._cancel.click(), finish()), "preview"),
"options": lambda view: when(ready, lambda: ( "options": lambda view: when(ready, lambda: (
preview()._which.setCurrentIndex(1), preview()._range.setText("2-3"), preview()._which.setCurrentIndex(1), preview()._range.setText("2-3"),
preview()._copies.setValue(2), preview()._colour.setCurrentIndex(1), press_print()), "preview"), preview()._copies.setValue(2), preview()._colour.setCurrentIndex(1),
when(ready, press_print, "black and white")), "preview"),
"bad-range": lambda view: when(ready, lambda: ( "bad-range": lambda view: when(ready, lambda: (
preview()._which.setCurrentIndex(1), preview()._range.setText("7"), preview()._print.click(), preview()._which.setCurrentIndex(1), preview()._range.setText("7"), preview()._print.click(),
stamp(f"bad range: open={preview() is not None} status={preview()._status.text()!r}"), stamp(f"bad range: open={preview() is not None} status={preview()._status.text()!r}"),
@@ -100,7 +147,120 @@ actions = {
QTimer.singleShot(800, lambda: (stamp(f"second request while open: still one preview={preview() is not None}"), press_print()))), "preview"), QTimer.singleShot(800, lambda: (stamp(f"second request while open: still one preview={preview() is not None}"), press_print()))), "preview"),
} }
if scenario.startswith("picture:"): SITE = "http://127.0.0.1:8094"
if scenario.startswith("bw-"):
# bw-web: a colourful lesson, turned to page 2, Color and Black and white
# back and forth, then page 2 twice in black and white.
# bw-pdf: a colourful worksheet, all of it, in black and white.
# bw-colour-again: black and white, then back to Color, then Print.
# bw-cancel: black and white, then Cancel.
if scenario == "bw-pdf":
window.open_pdf(colour_worksheet()); view = window.current()
else:
view = window.open_tab(); colour_lesson(view)
def colour_first():
stamp(f"COLOUR FIRST shown_gray={shown_gray()} pdf={preview()._pdf.name}")
if scenario == "bw-web":
preview()._turn(1)
toggles([1, 0, 1, 0, 1], lambda: (
preview()._which.setCurrentIndex(1), preview()._range.setText("2"),
preview()._copies.setValue(2), keep_preview(), press_print()))
elif scenario == "bw-pdf":
toggles([1], lambda: (keep_preview(), press_print()))
elif scenario == "bw-colour-again":
toggles([1, 0], lambda: (keep_preview(), press_print()))
else:
toggles([1], lambda: (stamp("PREVIEW " + describe(preview())), preview()._cancel.click(), finish()))
begin = lambda *_: (QTimer.singleShot(0, lambda: window.print_view(view)), when(ready, colour_first, "preview"))
if scenario == "bw-pdf":
QTimer.singleShot(2500, begin)
else:
view.loadFinished.connect(begin)
elif scenario.startswith("rclick"):
# A lesson behind a sign-in, with pictures in it; a real right-click.
# rclick-page on the words: the page prints
# rclick-image:ID:SIZE:POSITION[:gray] on a picture: that picture prints
# rclick-broken a picture that never loaded
# rclick-cancel a picture, then Cancel
from PyQt6.QtCore import QEvent, QPointF, Qt
from PyQt6.QtGui import QMouseEvent
from PyQt6.QtWidgets import QApplication as Application
parts = scenario.split(":")
target = {"rclick-page": "words", "rclick-page-gray": "words", "rclick-broken": "broken",
"rclick-cancel": "private-jpg"}.get(parts[0], parts[1] if len(parts) > 1 else "")
clipboard = Application.clipboard()
view = window.open_tab(); view.load(QUrl(f"{SITE}/login"))
page_ready = []
def poll_ready():
if view.url().path() == "/lesson":
view.page().runJavaScript("window.__ready === true", lambda ok: page_ready.append(1) if ok else None)
if not page_ready: QTimer.singleShot(200, poll_ready)
QTimer.singleShot(500, poll_ready)
def right_click(then):
js = ("(function(){var e=document.getElementById(%r);e.scrollIntoView({block:'center'});"
"var r=e.getBoundingClientRect();return [r.x+r.width/2,r.y+r.height/2]})()" % target)
def at(xy):
zoom = view.zoomFactor(); x, y = xy[0] * zoom, xy[1] * zoom
clipboard.setText("TEACHER'S TEXT, John 3:16")
stamp(f"RCLICK {target} at {x:.0f},{y:.0f} since={time.time():.3f}")
for kind, buttons in ((QEvent.Type.MouseButtonPress, Qt.MouseButton.RightButton),
(QEvent.Type.MouseButtonRelease, Qt.MouseButton.NoButton)):
Application.sendEvent(view.focusProxy(), QMouseEvent(
kind, QPointF(x, y), QPointF(view.mapToGlobal(QPointF(x, y))),
Qt.MouseButton.RightButton, buttons, Qt.KeyboardModifier.NoModifier))
when(lambda: Application.activePopupWidget() is not None,
lambda: then(Application.activePopupWidget()), "menu")
view.page().runJavaScript(js, at)
def choose(menu, text, after):
items = [a.text() for a in menu.actions() if a.text()]
stamp(f"MENU {items}")
action = next((a for a in menu.actions() if a.text() == text), None)
menu.close()
if action is None:
stamp(f"NO {text!r} IN THE MENU"); return finish(5)
after()
QTimer.singleShot(0, action.trigger)
def picture_ready():
d = preview(); return d is not None and d._picture is not None and d._pdf is not None
def clipboard_line():
stamp(f"CLIPBOARD {clipboard.text()!r}")
if parts[0] == "rclick-page":
go = lambda: right_click(lambda m: choose(m, "Print page…", lambda: when(ready, lambda: (
clipboard_line(), keep_preview(), press_print()), "page preview")))
elif parts[0] == "rclick-page-gray":
# The page with all its pictures, in black and white: the slow case.
def gray_page():
start = time.monotonic(); preview()._colour.setCurrentIndex(1)
when(ready, lambda: (
stamp(f"GRAY PAGE took={time.monotonic() - start:.1f} shown_gray={shown_gray()} pages={preview()._pages}"),
keep_preview(), press_print()), "black and white", limit=70)
go = lambda: right_click(lambda m: choose(m, "Print page…", lambda: when(ready, gray_page, "page preview")))
elif parts[0] == "rclick-broken":
go = lambda: right_click(lambda m: choose(m, "Print image…", lambda: when(
lambda: preview() is not None and "could not be taken" in preview()._status.text(), lambda: (
stamp(f"BROKEN status={preview()._status.text()!r} print_enabled={preview()._print.isEnabled()} "
f"pages={preview()._pages} picture={preview()._picture is not None}"),
clipboard_line(), preview()._cancel.click(), finish()), "the picture failing", limit=15)))
elif parts[0] == "rclick-cancel":
go = lambda: right_click(lambda m: choose(m, "Print image…", lambda: when(picture_ready, lambda: (
stamp("PREVIEW " + describe(preview())), clipboard_line(), preview()._cancel.click(), finish()),
"picture preview")))
else:
_, _, size, position, *colour = parts
def lay_out():
d = preview()
d._size.setCurrentIndex(d._size.findData(int(size)))
d._place.choose(position.replace("-", " "))
if colour == ["gray"]:
d._colour.setCurrentIndex(1)
QTimer.singleShot(900, lambda: when(ready, lambda: (
stamp(f"PICTURE picture_box={d._picture_box.isVisible()} image={d._picture.width()}x{d._picture.height()} "
f"title={d._title!r} shown_gray={shown_gray()}"),
clipboard_line(), keep_preview(), press_print()), "layout"))
go = lambda: right_click(lambda m: choose(m, "Print image…", lambda: when(picture_ready, lay_out, "picture preview")))
when(lambda: bool(page_ready), go, "the lesson and its pictures", limit=30)
elif scenario.startswith("picture:"):
# picture:FILE:SIZE:POSITION:PAPER:FILL[:color] # picture:FILE:SIZE:POSITION:PAPER:FILL[:color]
# Opens a picture on its own, chooses the layout with the preview's own # Opens a picture on its own, chooses the layout with the preview's own
# controls, keeps the exact PDF the preview showed, then prints it. # controls, keeps the exact PDF the preview showed, then prints it.
@@ -117,14 +277,16 @@ if scenario.startswith("picture:"):
d._fill.setChecked(fill == "fill") d._fill.setChecked(fill == "fill")
if rest and rest[0] == "gray": if rest and rest[0] == "gray":
d._colour.setCurrentIndex(1) d._colour.setCurrentIndex(1)
QTimer.singleShot(900, keep_and_print) # The layout settles (120 ms), then black and white if asked for.
QTimer.singleShot(900, lambda: when(ready, keep_and_print, "layout"))
def keep_and_print(): def keep_and_print():
import shutil import shutil
d = preview() d = preview()
out = Path("/tmp/rig-out"); out.mkdir(exist_ok=True) out = Path("/tmp/rig-out"); out.mkdir(exist_ok=True)
shutil.copy(d._pdf, out / "preview.pdf") shutil.copy(d._pdf, out / "preview.pdf")
stamp(f"PICTURE picture_box={d._picture_box.isVisible()} size_enabled={d._size.isEnabled()} " stamp(f"PICTURE picture_box={d._picture_box.isVisible()} size_enabled={d._size.isEnabled()} "
f"image={d._picture.width()}x{d._picture.height()} labels={d._colour_label.text()!r},{d._colour.itemText(0)!r}") f"image={d._picture.width()}x{d._picture.height()} labels={d._colour_label.text()!r},{d._colour.itemText(0)!r} "
f"shown_gray={shown_gray()}")
press_print() press_print()
QTimer.singleShot(2500, lambda: (QTimer.singleShot(0, lambda: window.print_view(view)), QTimer.singleShot(2500, lambda: (QTimer.singleShot(0, lambda: window.print_view(view)),
when(ready_picture, choose, "picture preview"))) when(ready_picture, choose, "picture preview")))
@@ -154,7 +316,8 @@ elif scenario in ("pdf", "pdf-options"):
if scenario == "pdf-options": if scenario == "pdf-options":
actions["pdf-options"] = lambda view: when(ready, lambda: ( actions["pdf-options"] = lambda view: when(ready, lambda: (
preview()._which.setCurrentIndex(1), preview()._range.setText("2"), preview()._which.setCurrentIndex(1), preview()._range.setText("2"),
preview()._copies.setValue(3), preview()._colour.setCurrentIndex(1), press_print()), "preview") preview()._copies.setValue(3), preview()._colour.setCurrentIndex(1),
when(ready, press_print, "black and white")), "preview")
QTimer.singleShot(2500, lambda: (QTimer.singleShot(0, lambda: window.print_view(view)), QTimer.singleShot(2500, lambda: (QTimer.singleShot(0, lambda: window.print_view(view)),
actions[scenario if scenario == "pdf-options" else "web"](view))) actions[scenario if scenario == "pdf-options" else "web"](view)))
elif scenario == "pdf-viewer-button": elif scenario == "pdf-viewer-button":