From fd066dc8dc5ce712c087b74e09152028b9bcc781 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Wed, 12 Aug 2026 22:30:26 +0800 Subject: [PATCH] =?UTF-8?q?emrg:=20fix=20v0.2.29=20Build=20Release=20gates?= =?UTF-8?q?=20=E2=80=94=20Windows=20git.EXE=20fake=20+=20filter-aware=20ic?= =?UTF-8?q?on=20decode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Agent.md | 2 +- packaging/gen-assets.sh | 220 ++++++++-------------------------------- packaging/pngutil.py | 151 +++++++++++++++++++++++++++ tests/test_pngutil.py | 164 ++++++++++++++++++++++++++++++ tests/test_scheduler.py | 12 ++- 5 files changed, 363 insertions(+), 186 deletions(-) create mode 100644 packaging/pngutil.py create mode 100644 tests/test_pngutil.py diff --git a/Agent.md b/Agent.md index 724ad035..15f72420 100644 --- a/Agent.md +++ b/Agent.md @@ -118,7 +118,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg ``` -Python: `uv run pytest tests/ -v` (748) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (758) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (229: 44 daemon_client + 19 conn-manager + 22 app-commands + 107 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) diff --git a/packaging/gen-assets.sh b/packaging/gen-assets.sh index 83be1729..a50e5843 100644 --- a/packaging/gen-assets.sh +++ b/packaging/gen-assets.sh @@ -20,6 +20,9 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" OUT="$ROOT/packaging/assets" SVG="$OUT/icon.svg" +# pngutil.py (filter-aware PNG decode) lives next to this script — the inline +# python heredocs below import it via PNGUTIL_DIR. +export PNGUTIL_DIR="$ROOT/packaging" mkdir -p "$OUT" if [ ! -f "$SVG" ]; then @@ -113,35 +116,20 @@ PYEOF # produce a fully-transparent PNG (SVG not painted). Fail loudly instead of # shipping a blank icon. Sample the alpha channel; if >90% of pixels are # transparent, the renderer failed → exit 1. +# ⚡ Filter-aware decode (v0.2.29 Build Release lesson): rsvg-convert / +# Chrome emit adaptively-filtered PNG rows (Sub/Up/Average/Paeth). A naive +# "strip filter byte" read returns deltas, not pixels — a fully opaque icon +# falsely read as "99.2% transparent". Use pngutil.read_png (reverses +# filters) so the check sees real alpha. python3 - "$OUT/icon.png" <<'PYEOF' -import sys, struct, zlib - -data = open(sys.argv[1], "rb").read() -pos, w, h, idat, colortype = 8, 0, 0, b"", 0 -while pos < len(data): - ln = struct.unpack(">I", data[pos:pos+4])[0] - tag = data[pos+4:pos+8] - chunk = data[pos+8:pos+8+ln] - if tag == b"IHDR": - w, h = struct.unpack(">II", chunk[:8]) - colortype = chunk[9] - elif tag == b"IDAT": - idat += chunk - pos += 12 + ln - -if colortype == 6: # RGBA — only then is transparency meaningful - raw = zlib.decompress(idat) - stride0 = w * 4 + 1 - raw = b"".join(raw[y*stride0+1:(y+1)*stride0] for y in range(h)) - total = opaque = 0 - for y in range(0, h, 8): - for x in range(0, w, 8): - a = raw[y*w*4 + x*4 + 3] - total += 1 - if a > 0: - opaque += 1 - ratio = opaque / total - print(f" icon.png alpha: {ratio:.1%} opaque ({opaque}/{total} sampled)") +import os, sys +sys.path.insert(0, os.environ.get("PNGUTIL_DIR", os.path.join(os.path.dirname(sys.argv[1]), ".."))) +import pngutil + +w, h, bpp, px = pngutil.read_png(sys.argv[1]) +if bpp == 4: + ratio = pngutil.opaque_ratio(w, h, bpp, px) + print(f" icon.png alpha: {ratio:.1%} opaque") if ratio < 0.10: sys.stderr.write( f"ERROR: icon.png is {1-ratio:.1%} transparent — renderer failed to paint " @@ -149,83 +137,22 @@ if colortype == 6: # RGBA — only then is transparency meaningful ) sys.exit(1) else: - print(f" icon.png alpha: colortype={colortype} (no alpha channel) — assumed opaque") + print(f" icon.png alpha: colortype={'RGB' if bpp == 3 else bpp} (no alpha channel) — assumed opaque") PYEOF echo "==> resizing to 512/256 (stdlib area-average box filter)" python3 - "$OUT/icon.png" "$OUT" <<'PYEOF' -import sys, zlib, struct +import os, sys +sys.path.insert(0, os.environ.get("PNGUTIL_DIR", os.path.join(os.path.dirname(sys.argv[1]), ".."))) +import pngutil src, outdir = sys.argv[1], sys.argv[2] - -def read_png(path): - data = open(path, "rb").read() - assert data[:8] == b"\x89PNG\r\n\x1a\n" - pos, w, h, idat, ct = 8, 0, 0, b"", 0 - while pos < len(data): - ln = struct.unpack(">I", data[pos:pos+4])[0] - tag = data[pos+4:pos+8] - chunk = data[pos+8:pos+8+ln] - if tag == b"IHDR": - w, h = struct.unpack(">II", chunk[:8]) - ct = chunk[9] - elif tag == b"IDAT": - idat += chunk - pos += 12 + ln - raw = zlib.decompress(idat) - # PNG scanlines each have a leading filter byte (0 for None); strip them. - # Chrome headless emits RGB (ct=2) when the page bg is opaque; rsvg-convert - # emits RGBA (ct=6). Normalize to RGBA so downstream code can assume 4 bpp. - if ct == 6: - stride0 = w * 4 + 1 - raw = b"".join(raw[y*stride0+1:(y+1)*stride0] for y in range(h)) - elif ct == 2: - stride0 = w * 3 + 1 - rows = [raw[y*stride0+1:(y+1)*stride0] for y in range(h)] - out = bytearray() - for row in rows: - for i in range(0, len(row), 3): - out += row[i:i+3] + b"\xff" - raw = bytes(out) - else: - raise SystemExit(f"unsupported PNG colortype {ct}") - return w, h, raw - -w, h, raw = read_png(src) -stride = w * 4 - -def resize_area(nw, nh): - """Area-average (box filter) downscale — better antialiasing than nearest.""" - out = bytearray([0, 0, 0, 0]) * (nw * nh) - for yy in range(nh): - y0 = yy * h // nh - y1 = max(y0 + 1, (yy + 1) * h // nh) - for xx in range(nw): - x0 = xx * w // nw - x1 = max(x0 + 1, (xx + 1) * w // nw) - r = g = b = a = 0 - n = 0 - for sy in range(y0, y1): - for sx in range(x0, x1): - i = sy * stride + sx * 4 - r += raw[i]; g += raw[i+1]; b += raw[i+2]; a += raw[i+3] - n += 1 - o = (yy * nw + xx) * 4 - out[o] = r // n; out[o+1] = g // n; out[o+2] = b // n; out[o+3] = a // n - return bytes(out) - -def write_png(path, nw, nh, rgba): - def chunk(tag, data): - c = struct.pack(">I", len(data)) + tag + data - c += struct.pack(">I", zlib.crc32(tag + data) & 0xffffffff) - return c - raw = b"".join(b"\x00" + rgba[y*nw*4:(y+1)*nw*4] for y in range(nh)) - png = b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", nw, nh, 8, 6, 0, 0, 0)) + chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b"") - open(path, "wb").write(png) +w, h, bpp, px = pngutil.read_png(src) +rgba = pngutil.to_rgba(w, h, bpp, px) for size in (512, 256): p = f"{outdir}/icon-{size}.png" - write_png(p, size, size, resize_area(size, size)) + pngutil.write_png(p, size, size, pngutil.resize_area(rgba, w, h, size, size)) print(" wrote", p) PYEOF @@ -236,47 +163,14 @@ if command -v iconutil >/dev/null 2>&1; then # iconutil wants specific sizes for s in 16 32 128 256 512; do python3 - "$OUT/icon.png" "$ICONSET/icon_${s}x${s}.png" "$s" <<'PYEOF' -import sys, zlib, struct +import os, sys +sys.path.insert(0, os.environ.get("PNGUTIL_DIR", os.path.join(os.path.dirname(sys.argv[1]), ".."))) +import pngutil + src, dst, size = sys.argv[1], sys.argv[2], int(sys.argv[3]) -data = open(src, "rb").read(); pos, w, h, idat, ct = 8, 0, 0, b"", 0 -while pos < len(data): - ln = struct.unpack(">I", data[pos:pos+4])[0]; tag = data[pos+4:pos+8]; chunk = data[pos+8:pos+8+ln] - if tag == b"IHDR": w, h = struct.unpack(">II", chunk[:8]); ct = chunk[9] - elif tag == b"IDAT": idat += chunk - pos += 12 + ln -raw = zlib.decompress(idat) -if ct == 6: - stride0 = w*4 + 1 - raw = b"".join(raw[y*stride0+1:(y+1)*stride0] for y in range(h)) -else: # ct==2 RGB → expand to RGBA - stride0 = w*3 + 1 - rows = [raw[y*stride0+1:(y+1)*stride0] for y in range(h)] - out = bytearray() - for row in rows: - for i in range(0, len(row), 3): - out += row[i:i+3] + b"\xff" - raw = bytes(out) -stride = w*4 -def resize_area(nw, nh): - out = bytearray([0,0,0,0])*(nw*nh) - for yy in range(nh): - y0 = yy*h//nh; y1 = max(y0+1, (yy+1)*h//nh) - for xx in range(nw): - x0 = xx*w//nw; x1 = max(x0+1, (xx+1)*w//nw) - r = g = b = a = n = 0 - for sy in range(y0, y1): - for sx in range(x0, x1): - i = sy*stride + sx*4 - r += raw[i]; g += raw[i+1]; b += raw[i+2]; a += raw[i+3]; n += 1 - o = (yy*nw+xx)*4 - out[o] = r//n; out[o+1] = g//n; out[o+2] = b//n; out[o+3] = a//n - return bytes(out) -def chunk(tag, data): - c = struct.pack(">I", len(data))+tag+data; c += struct.pack(">I", zlib.crc32(tag+data)&0xffffffff); return c -rgba = resize_area(size, size) -raw2 = b"".join(b"\x00"+rgba[y*size*4:(y+1)*size*4] for y in range(size)) -png = b"\x89PNG\r\n\x1a\n"+chunk(b"IHDR", struct.pack(">IIBBBBB", size, size, 8, 6, 0, 0, 0))+chunk(b"IDAT", zlib.compress(raw2,9))+chunk(b"IEND", b"") -open(dst, "wb").write(png) +w, h, bpp, px = pngutil.read_png(src) +rgba = pngutil.to_rgba(w, h, bpp, px) +pngutil.write_png(dst, size, size, pngutil.resize_area(rgba, w, h, size, size)) PYEOF cp "$ICONSET/icon_${s}x${s}.png" "$ICONSET/icon_${s}x${s}@2x.png" 2>/dev/null || true done @@ -290,57 +184,23 @@ fi echo "==> icon.ico (multi-size, win)" python3 - "$OUT/icon.png" "$OUT/icon.ico" <<'PYEOF' -import sys, zlib, struct -src, dst = sys.argv[1], sys.argv[2] -data = open(src, "rb").read(); pos, w, h, idat, ct = 8, 0, 0, b"", 0 -while pos < len(data): - ln = struct.unpack(">I", data[pos:pos+4])[0]; tag = data[pos+4:pos+8]; chunk = data[pos+8:pos+8+ln] - if tag == b"IHDR": w, h = struct.unpack(">II", chunk[:8]); ct = chunk[9] - elif tag == b"IDAT": idat += chunk - pos += 12 + ln -raw = zlib.decompress(idat) -if ct == 6: - stride0 = w*4 + 1 - raw = b"".join(raw[y*stride0+1:(y+1)*stride0] for y in range(h)) -else: # ct==2 RGB → expand to RGBA - stride0 = w*3 + 1 - rows = [raw[y*stride0+1:(y+1)*stride0] for y in range(h)] - out = bytearray() - for row in rows: - for i in range(0, len(row), 3): - out += row[i:i+3] + b"\xff" - raw = bytes(out) -stride = w*4 +import os, sys +sys.path.insert(0, os.environ.get("PNGUTIL_DIR", os.path.join(os.path.dirname(sys.argv[1]), ".."))) +import struct +import pngutil -def resize_area(nw, nh): - out = bytearray([0,0,0,0])*(nw*nh) - for yy in range(nh): - y0 = yy*h//nh; y1 = max(y0+1, (yy+1)*h//nh) - for xx in range(nw): - x0 = xx*w//nw; x1 = max(x0+1, (xx+1)*w//nw) - r = g = b = a = n = 0 - for sy in range(y0, y1): - for sx in range(x0, x1): - i = sy*stride + sx*4 - r += raw[i]; g += raw[i+1]; b += raw[i+2]; a += raw[i+3]; n += 1 - o = (yy*nw+xx)*4 - out[o] = r//n; out[o+1] = g//n; out[o+2] = b//n; out[o+3] = a//n - return bytes(out) - -def png_bytes(nw, nh, rgba): - def chunk(tag, data): - c = struct.pack(">I", len(data))+tag+data; c += struct.pack(">I", zlib.crc32(tag+data)&0xffffffff); return c - raw2 = b"".join(b"\x00"+rgba[y*nw*4:(y+1)*nw*4] for y in range(nh)) - return b"\x89PNG\r\n\x1a\n"+chunk(b"IHDR", struct.pack(">IIBBBBB", nw, nh, 8, 6, 0, 0, 0))+chunk(b"IDAT", zlib.compress(raw2,9))+chunk(b"IEND", b"") +src, dst = sys.argv[1], sys.argv[2] +w, h, bpp, px = pngutil.read_png(src) +rgba = pngutil.to_rgba(w, h, bpp, px) # ICO: header + directory + PNG-embedded entries sizes = [16, 24, 32, 48, 64, 128, 256] -imgs = [(s, png_bytes(s, s, resize_area(s, s))) for s in sizes] +imgs = [(s, pngutil.write_png_bytes(s, s, pngutil.resize_area(rgba, w, h, s, s))) for s in sizes] header = struct.pack("I", data[pos:pos + 4])[0] + tag = data[pos + 4:pos + 8] + chunk = data[pos + 8:pos + 8 + ln] + if tag == b"IHDR": + w, h = struct.unpack(">II", chunk[:8]) + ct = chunk[9] + elif tag == b"IDAT": + idat += chunk + pos += 12 + ln + bpp = {2: 3, 6: 4}.get(ct) + if bpp is None: + raise ValueError(f"{path}: unsupported PNG colortype {ct}") + raw = zlib.decompress(idat) + stride = w * bpp + out = bytearray() + prev = bytearray(stride) + p = 0 + for _ in range(h): + ft = raw[p] + p += 1 + line = bytearray(raw[p:p + stride]) + p += stride + if ft == 1: # Sub + for i in range(bpp, stride): + line[i] = (line[i] + line[i - bpp]) & 0xFF + elif ft == 2: # Up + for i in range(stride): + line[i] = (line[i] + prev[i]) & 0xFF + elif ft == 3: # Average + for i in range(stride): + a = line[i - bpp] if i >= bpp else 0 + line[i] = (line[i] + ((a + prev[i]) >> 1)) & 0xFF + elif ft == 4: # Paeth + for i in range(stride): + a = line[i - bpp] if i >= bpp else 0 + b = prev[i] + c = prev[i - bpp] if i >= bpp else 0 + pa, pb, pc = abs(b - c), abs(a - c), abs(a + b - 2 * c) + pr = a if (pa <= pb and pa <= pc) else (b if pb <= pc else c) + line[i] = (line[i] + pr) & 0xFF + # ft == 0 (None): unchanged + out += line + prev = line + return w, h, bpp, bytes(out) + + +def to_rgba(w, h, bpp, pixels): + """Expand RGB (3bpp) rows to RGBA (4bpp, alpha=255); RGBA passes through.""" + if bpp == 4: + return pixels + out = bytearray() + for i in range(0, len(pixels), 3): + out += pixels[i:i + 3] + b"\xFF" + return bytes(out) + + +def opaque_ratio(w, h, bpp, pixels, step=8): + """Fraction of sampled pixels with alpha > 0 (0.0–1.0). RGBA only.""" + if bpp != 4: + return 1.0 # RGB PNG has no alpha channel — nothing to check + total = opaque = 0 + for y in range(0, h, step): + for x in range(0, w, step): + a = pixels[y * w * 4 + x * 4 + 3] + total += 1 + if a > 0: + opaque += 1 + return opaque / total + + +def resize_area(rgba, w, h, nw, nh): + """Area-average (box filter) downscale of RGBA pixels.""" + stride = w * 4 + out = bytearray([0, 0, 0, 0]) * (nw * nh) + for yy in range(nh): + y0 = yy * h // nh + y1 = max(y0 + 1, (yy + 1) * h // nh) + for xx in range(nw): + x0 = xx * w // nw + x1 = max(x0 + 1, (xx + 1) * w // nw) + r = g = b = a = n = 0 + for sy in range(y0, y1): + for sx in range(x0, x1): + i = sy * stride + sx * 4 + r += rgba[i] + g += rgba[i + 1] + b += rgba[i + 2] + a += rgba[i + 3] + n += 1 + o = (yy * nw + xx) * 4 + out[o] = r // n + out[o + 1] = g // n + out[o + 2] = b // n + out[o + 3] = a // n + return bytes(out) + + +def write_png(path, nw, nh, rgba): + """Write RGBA pixels as a PNG (filter 0 rows, colortype 6).""" + with open(path, "wb") as f: + f.write(write_png_bytes(nw, nh, rgba)) + + +def write_png_bytes(nw, nh, rgba): + """Return RGBA pixels as PNG bytes (filter 0 rows, colortype 6).""" + + def chunk(tag, data): + c = struct.pack(">I", len(data)) + tag + data + c += struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF) + return c + + raw = b"".join(b"\x00" + rgba[y * nw * 4:(y + 1) * nw * 4] for y in range(nh)) + return ( + b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", struct.pack(">IIBBBBB", nw, nh, 8, 6, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(raw, 9)) + + chunk(b"IEND", b"") + ) diff --git a/tests/test_pngutil.py b/tests/test_pngutil.py new file mode 100644 index 00000000..61043a59 --- /dev/null +++ b/tests/test_pngutil.py @@ -0,0 +1,164 @@ +"""Filter-aware PNG decode regression tests (packaging/pngutil.py). + +Lesson 2026-08-12 v0.2.29 Build Release: gen-assets.sh derived icons from +icon.png rendered by rsvg-convert / Chrome headless. Both emit PNG scanlines +with adaptive filters (Sub/Up/Average/Paeth — libpng default). The old inline +decoders stripped the leading filter byte WITHOUT reversing the filter, so: + - the transparency check read deltas instead of alpha → a fully opaque icon + falsely reported "99.2% transparent" (Linux/macOS CI failure), and + - the area-average resize produced garbage color artifacts from filtered + input (silently wrong icon-512/256/icns/ico in shipped builds). + +These tests pin the filter reversal: decode must match the source pixels for +every PNG filter type, and the opacity check must keep discriminating real +transparent renders (positive/negative states). +""" + +import struct +import sys +import zlib +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "packaging")) +import pngutil # noqa: E402 + + +def _make_png(w, h, rgba_rows, filter_type): + """Encode RGBA rows (each a bytes of w*4 pixels) with a fixed filter type.""" + bpp = 4 + stride = w * bpp + + def chunk(tag, data): + c = struct.pack(">I", len(data)) + tag + data + c += struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF) + return c + + out = bytearray() + prev = bytearray(stride) + for row in rgba_rows: + line = bytearray(row) + if filter_type == 0: + out += b"\x00" + line + else: + filt = bytearray([filter_type]) + for i in range(stride): + left = line[i - bpp] if i >= bpp else 0 + if filter_type == 1: # Sub + pr = left + elif filter_type == 2: # Up + pr = prev[i] + elif filter_type == 3: # Average + pr = (left + prev[i]) >> 1 + else: # Paeth + b = prev[i] + c = prev[i - bpp] if i >= bpp else 0 + pa, pb, pc = abs(b - c), abs(left - c), abs(left + b - 2 * c) + pr = left if (pa <= pb and pa <= pc) else (b if pb <= pc else c) + filt.append((line[i] - pr) & 0xFF) + out += filt + prev = line + + raw = bytes(out) + png = ( + b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 6, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(raw, 9)) + + chunk(b"IEND", b"") + ) + return png + + +def _solid_rgba(w, h, color): + """h rows of w RGBA pixels, all `color`.""" + row = color * w + return [row] * h + + +@pytest.mark.parametrize("filter_type", [0, 1, 2, 3, 4]) +def test_decode_reverses_each_filter_type(tmp_path, filter_type): + """Every PNG filter type decodes back to the original pixels.""" + w = h = 16 + color = bytes([17, 28, 22, 255]) + p = tmp_path / f"f{filter_type}.png" + p.write_bytes(_make_png(w, h, _solid_rgba(w, h, color), filter_type)) + + dw, dh, bpp, px = pngutil.read_png(str(p)) + assert (dw, dh, bpp) == (w, h, 4) + assert px == color * (w * h), f"filter {filter_type} decode mismatch" + + +def test_rgb_decode_expands_alpha(tmp_path): + """Chrome emits RGB (colortype 2, 3bpp) → read_png returns 3bpp pixels.""" + w = h = 8 + # Minimal RGB PNG (colortype 2) — build manually via write path then re-read + # (pngutil writes RGBA; synthesize RGB with a tiny inline encoder). + def chunk(tag, data): + c = struct.pack(">I", len(data)) + tag + data + c += struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF) + return c + + row = b"\x00" + bytes([10, 20, 30]) * w # filter 0 + RGB pixels + png = ( + b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(row * h, 9)) + + chunk(b"IEND", b"") + ) + p = tmp_path / "rgb.png" + p.write_bytes(png) + + dw, dh, bpp, px = pngutil.read_png(str(p)) + assert (dw, dh, bpp) == (w, h, 3) + rgba = pngutil.to_rgba(dw, dh, bpp, px) + assert rgba == bytes([10, 20, 30, 255]) * (w * h) + + +def test_opaque_ratio_true_positive(tmp_path): + """Fully opaque filtered RGBA must read as 100% opaque (regression: the old + naive decoder reported 0.8% on this input).""" + w = h = 64 + color = bytes([17, 28, 22, 255]) + p = tmp_path / "opaque.png" + p.write_bytes(_make_png(w, h, _solid_rgba(w, h, color), 3)) # Average filter + + dw, dh, bpp, px = pngutil.read_png(str(p)) + assert pngutil.opaque_ratio(dw, dh, bpp, px) == 1.0 + + +def test_opaque_ratio_true_negative(tmp_path): + """A genuinely transparent render must still be caught (<10% opaque).""" + w = h = 64 + color = bytes([0, 0, 0, 0]) + p = tmp_path / "transparent.png" + p.write_bytes(_make_png(w, h, _solid_rgba(w, h, color), 1)) # Sub filter + + dw, dh, bpp, px = pngutil.read_png(str(p)) + assert pngutil.opaque_ratio(dw, dh, bpp, px) == 0.0 + assert pngutil.opaque_ratio(dw, dh, bpp, px) < 0.10 + + +def test_resize_area_preserves_colors(tmp_path): + """Area-average of a solid color stays that color (old bug: garbage hues).""" + w = h = 64 + color = bytes([17, 28, 22, 255]) + p = tmp_path / "src.png" + p.write_bytes(_make_png(w, h, _solid_rgba(w, h, color), 4)) # Paeth filter + + dw, dh, bpp, px = pngutil.read_png(str(p)) + rgba = pngutil.to_rgba(dw, dh, bpp, px) + small = pngutil.resize_area(rgba, dw, dh, 16, 16) + assert small == color * (16 * 16) + + +def test_write_png_roundtrip_filter0(tmp_path): + """write_png emits filter-0 rows that read_png decodes losslessly.""" + w = h = 8 + rgba = bytes([5, 6, 7, 255]) * (w * h) + p = tmp_path / "out.png" + pngutil.write_png(str(p), w, h, rgba) + + dw, dh, bpp, px = pngutil.read_png(str(p)) + assert (dw, dh, bpp) == (w, h, 4) + assert px == rgba diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 18fe52d0..c8d93f8e 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -636,7 +636,9 @@ def __call__(self, cmd, *args, **kwargs): # cmd[0] 可能是字面 "git"(dev 环境)或 resolve_git_gh() 解析出的 # 绝对路径(bundled git,2026-08-12 workspace-not-ready 事故修复后)—— # 统一按 basename 判断,避免测试在两种环境下行为不一致。 - cmd_head = Path(cmd[0]).name + # Windows 上 resolve_git_gh() 返回 git.EXE(大写后缀,2026-08-12 v0.2.29 + # Build Release Windows gate 实测)→ 比较必须大小写不敏感。 + cmd_head = Path(cmd[0]).name.lower() if cmd_head in ("git", "git.exe"): sub = self._norm(cmd) if sub and sub[0] == "rev-parse": @@ -1019,7 +1021,7 @@ def test_ensure_origin_reachable_switches_to_ssh_when_https_blocked(tmp_path): set_url_calls = [ c for c in fake.calls - if Path(c[0][0]).name in ("git", "git.exe") and c[0][1] == "remote" and c[0][2] == "set-url" + if Path(c[0][0]).name.lower() in ("git", "git.exe") and c[0][1] == "remote" and c[0][2] == "set-url" ] assert len(set_url_calls) == 1, f"expected one set-url, got {fake.calls}" assert set_url_calls[0][0][4] == "git@github.com:argszero/emrg.git" @@ -1048,7 +1050,7 @@ def test_ensure_origin_reachable_probes_only_once(tmp_path): set_url_calls = [ c for c in fake.calls - if Path(c[0][0]).name in ("git", "git.exe") and c[0][1] == "remote" and c[0][2] == "set-url" + if Path(c[0][0]).name.lower() in ("git", "git.exe") and c[0][1] == "remote" and c[0][2] == "set-url" ] assert len(set_url_calls) == 1 @@ -1072,7 +1074,7 @@ def test_ensure_origin_reachable_keeps_https_when_reachable(tmp_path): set_url_calls = [ c for c in fake.calls - if Path(c[0][0]).name in ("git", "git.exe") and c[0][1] == "remote" and c[0][2] == "set-url" + if Path(c[0][0]).name.lower() in ("git", "git.exe") and c[0][1] == "remote" and c[0][2] == "set-url" ] assert set_url_calls == [] @@ -1099,7 +1101,7 @@ def test_ensure_origin_reachable_ignores_non_connection_errors(tmp_path): set_url_calls = [ c for c in fake.calls - if Path(c[0][0]).name in ("git", "git.exe") and c[0][1] == "remote" and c[0][2] == "set-url" + if Path(c[0][0]).name.lower() in ("git", "git.exe") and c[0][1] == "remote" and c[0][2] == "set-url" ] assert set_url_calls == []