From c37efd7a443922166c0d38b424218f03fe448cd6 Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Sun, 18 Oct 2026 21:17:03 +0800 Subject: [PATCH] Lint the commits being pushed and scope overlay failures per sensor The pre-push gate linted only the dirty tree, so the commits about to be pushed went unchecked; and every failed overlay projection was sticky, which cost a finger result its overlay over one bad frame while box results were retried nowhere. gate.ps1 now takes -LintBase (default origin/main, exit 2 on an unknown ref), the projector declares whether its own failures are permanent, and serve_stored_view decides before it sets the view. Verified with gate.ps1 (PASS: 569 studio + 485 shared tests, 632 ui_check checks) and the two round-2 red samples kept in scratch. --- README.md | 5 + .../src/orisys_studio/replay_analysis.py | 30 +- .../src/orisys_studio/replay_controller.py | 49 +- apps/studio/tests/data/README.md | 17 +- .../tests/test_overlay_failure_scope.py | 147 +++++ apps/studio/tests/test_replay_wiring.py | 58 ++ docs/test-repair-rework.zh.md | 57 +- .../analysis/overlay_base.py | 45 +- tools/gate.ps1 | 500 ++++++++++-------- 9 files changed, 648 insertions(+), 260 deletions(-) create mode 100644 apps/studio/tests/test_overlay_failure_scope.py diff --git a/README.md b/README.md index 0c22580..4c6a978 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,11 @@ shared suite, and `ui_check.py`; writes each one's output to `tools/skip_budget.txt`, and zero findings anywhere. It exits 0 only when all of that holds. +The lint step compares against a commit range, not only the dirty tree: `-LintBase ` +defaults to `origin/main`, so a pre-push run checks every commit about to be pushed. +A ref that does not resolve is a configuration error (exit 2); the gate never quietly +falls back to linting uncommitted files only. + The **gate platform is Windows**: the suites render real Qt widgets offscreen, and two checks are platform-sensitive ([`docs/test-repair-plan.zh.md`](docs/test-repair-plan.zh.md) §3 #11 and #13), so a green Linux run is not the same statement. diff --git a/apps/studio/src/orisys_studio/replay_analysis.py b/apps/studio/src/orisys_studio/replay_analysis.py index ca58836..cb03918 100644 --- a/apps/studio/src/orisys_studio/replay_analysis.py +++ b/apps/studio/src/orisys_studio/replay_analysis.py @@ -389,8 +389,13 @@ class AnalysisSession: """The live-shaped processed image for this camera frame, or ``None``. Opens a session-scoped projector from the result's provenance on first use. - Failure is sticky for the session: overlay views then report capture - unavailable rather than falling back to a resized fisheye. + **How long a failure lasts is the projector's answer, not this method's guess:** + a projector that declares its projection failures permanent (a box stitch that + cannot rebuild the lenslet grid on this footage) is recorded as unavailable and + not asked again; one that declares them transient (a finger unroll that lost a + single frame) skips that frame and is tried again on the next. The alternative — + making every failure sticky — cost a finger result its whole overlay view over + one bad frame (reported as R2 in docs/test-repair-rework.zh.md). """ if self._overlay_failed: return None @@ -401,16 +406,11 @@ class AnalysisSession: self.reader.provenance ) if self._overlay is None: + # Nothing can be opened for this result at all: permanent either way. self._overlay_failed = True return None projected = self._overlay.project(video_bgr, frame_id=frame_id) - if projected is None: - # Sticky, as the docstring above says. A projector that cannot map this - # result's footage — measured: every frame of a real box recording, where the - # stitch rebuild raises inside the SDK — will not start working mid-session, - # and retrying per cursor move would rebuild the same failing stitch on every - # scrub. The session records the answer so a caller can pick a view this - # result *can* draw instead of leaving a refusal on screen. + if projected is None and self._overlay.projection_failures_are_permanent: self._overlay_failed = True return projected @@ -420,12 +420,12 @@ class AnalysisSession: The overlay views (``arrows_stitched``, ``depth_arrows``) paint on the same UV-unrolled or stitched picture live publishes, which a stored result does not - contain: it is rebuilt from the result's provenance on first use. A **box** - result has no stored unroll map, and the box stitch rebuild can fail on real - footage, so the answer is only known after one attempt — and it is then sticky - for the session (see :meth:`overlay_base`). The caller that *picks* the first - view for an adopted result reads this, so the operator is not left on a view - that cannot be drawn. + contain: it is rebuilt from the result's provenance on first use. The answer is + only known after an attempt, and it is sticky **only for a failure the projector + declares permanent** — a box result whose stitch cannot rebuild, or a projector + that would not open at all. A finger unroll that loses one frame is not sticky + (see :meth:`overlay_base`). The caller that *picks* the first view for an adopted + result reads this, so the operator is not left on a view that cannot be drawn. """ return bool(self._overlay_failed) diff --git a/apps/studio/src/orisys_studio/replay_controller.py b/apps/studio/src/orisys_studio/replay_controller.py index bed89cd..c8cb1c0 100644 --- a/apps/studio/src/orisys_studio/replay_controller.py +++ b/apps/studio/src/orisys_studio/replay_controller.py @@ -1034,13 +1034,21 @@ class ReplayController(QObject): texture live uses. A derived selection the operator already chose is left alone — that mode is not this method's to override. - **But only a view this result can actually draw.** The base is rebuilt from the - result's provenance, which a box result cannot always do (no stored unroll map, - and the box stitch rebuild can fail on real footage: measured, every frame of the - restored 33-frame box recording). Offering the overlay there would leave the - operator on a blank panel reading "capture unavailable" while the pure-colour - arrows sit one step away, so the fallback is the drawable view — the overlay is - still in the selector, and choosing it still says why it cannot be drawn. + **But only a view this result can actually draw, decided before it is set.** The + base is rebuilt from the result's provenance, which a box result cannot always do + (no stored unroll map, and the box stitch rebuild can fail on real footage: + measured, every frame of the restored 33-frame box recording). Offering the + overlay there would leave the operator on a blank panel reading "capture + unavailable" while the pure-colour arrows sit one step away, so the fallback is + the drawable view — the overlay is still in the selector, and choosing it still + says why it cannot be drawn. + + The probe runs *before* the view is set (see :meth:`_overlay_base_ready`) so the + panel is set once and drawn once: setting the overlay view first and switching + after the first render flashed the refusal on screen during adoption (R3 of + docs/test-repair-rework.zh.md). The base it asks for is the one the render that + follows will ask for, and the projector caches by frame id, so deciding costs no + second projection. """ current = str(getattr(self._image_panel, "view_mode", lambda: "")() or "") if current in DERIVED_MODES: @@ -1048,16 +1056,29 @@ class ReplayController(QObject): session = self.available_result() # The plan for the frame just drawn offers the derived modes (the frame carries # ``result_available``), so this selection is one the panel can honour. - overlay_failed = bool(getattr(session, "overlay_unavailable", False)) - self._image_panel.set_view_mode(ARROW_PURE if overlay_failed else ARROW_STITCHED) + drawable = True if session is None else self._overlay_base_ready(session) + self._image_panel.set_view_mode(ARROW_STITCHED if drawable else ARROW_PURE) self.render_frame() - if not overlay_failed and getattr(session, "overlay_unavailable", False): - # The first attempt is what tells us: the base could not be rebuilt, so draw - # the view that needs no base instead of leaving a refusal on screen. - self._image_panel.set_view_mode(ARROW_PURE) - self.render_frame() return True + def _overlay_base_ready(self, session) -> bool: + """Whether ``render_frame`` can paint this result's overlay base, without drawing. + + Asks the session for the same base, for the same frame, that the render will ask + for. ``True`` when there is no frame to judge: an unknown answer must not invent a + fallback view the operator did not ask for. A session that has already declared the + overlay unavailable is answered without another projection attempt. + """ + if bool(getattr(session, "overlay_unavailable", False)): + return False + if self.analysis_active(): + frame = self._engine.analysed_frame() + else: + frame = session.frame_at(self._engine.position()) + if frame is None: + return True + return self._processed_base_for(frame, session) is not None + def _display_policy(self) -> Any: from orisys_app_shared.scene3d.visual_policy import policy_for_settings diff --git a/apps/studio/tests/data/README.md b/apps/studio/tests/data/README.md index 8ba2b8d..6992ebb 100644 --- a/apps/studio/tests/data/README.md +++ b/apps/studio/tests/data/README.md @@ -11,15 +11,22 @@ generated pixels, and a synthetic 896² clip sent the chain into minutes of work | | | |---|---| | Sensor | `box` (shipped bundle) | -| Source | `dist/OrisysStudio/outputs/recordings/20260930T015028Z_session0002_40cb8e7441434feab48bb646afdba011`, recorded 2026-09-30, `session_id` 2 | +| Source | [`.scratch/result-visible/recording/20260930T015028Z_session0002_40cb8e7441434feab48bb646afdba011/`](../../../../.scratch/result-visible/recording/20260930T015028Z_session0002_40cb8e7441434feab48bb646afdba011/) — recorded 2026-09-30, `session_id` 2. The directory name is the recording's own identity, so it matches the `dist/` path below. | | Length | 33 frames, frame ids **20…52**, 1.92 s of signal time | | Files | `raw_0001.mp4`, `frames.csv`, `forces.csv`, `events.csv`, `session.json` (3.8 MB) | | Not included | `preroll_0001.mp4` (3.5 MB, optional for every reader: `RecordingReader.preroll_segment()` answers `None` when the file is absent) | -`dist/` is build output and is not tracked, so the copy under it disappeared on -2026-10-14 when the studio was repackaged. That took 11 tests to `ERROR` and 4 to -`failed` without anyone noticing — see -[`docs/test-repair-plan.zh.md`](../../../docs/test-repair-plan.zh.md) §1 and §3 #1–#2. +`dist/` is build output and is not tracked, so the copy under `dist/OrisysStudio/outputs/recordings/` disappeared on 2026-10-14 when the studio was repackaged. That took 11 tests to `ERROR` and 4 to `failed` without anyone noticing — see [`docs/test-repair-plan.zh.md`](../../../docs/test-repair-plan.zh.md) §1 and §3 #1–#2. + +The `dist/` original is **gone**, so the files here cannot be compared against it byte for byte. They are byte-identical to the copy preserved under `.scratch/result-visible/` (the source named above), which is the strongest provenance available. The hashes are recorded here so a future copy can be checked against this one instead of argued about: + +| File | SHA256 | +|---|---| +| `raw_0001.mp4` | `907867d0fe82966dbaacf97a8bb0a8d5647de25458ad5fd4640535d83d417207` | +| `frames.csv` | `fab25e6780908aef8f7616504b43cf34062ab7ca3ba317b6ff4c0631969e0601` | +| `forces.csv` | `972d3c951ff0be847a6a224b53339401f115cd3728039f1acb24a33bf215ecb1` | +| `events.csv` | `6fe7121b1c621ebed472ee42376babfd76a32a364ab9f11660da077fb365a946` | +| `session.json` | `f3c9afc4502180d2a88cd260cb70f1f4313e203f2c3c8441704de6ac53a1db4c` | The frame ids are deliberately not 0…32: a test that used the frame *index* would pass on a 0-based recording, and real recordings start wherever the device's counter was. diff --git a/apps/studio/tests/test_overlay_failure_scope.py b/apps/studio/tests/test_overlay_failure_scope.py new file mode 100644 index 0000000..a1842f4 --- /dev/null +++ b/apps/studio/tests/test_overlay_failure_scope.py @@ -0,0 +1,147 @@ +"""How long an overlay-base failure lasts: one frame, or the whole session. + +R2 of ``docs/test-repair-rework.zh.md``. The second round made every failed projection +sticky, so one bad finger frame cost that result its overlay view for the session — a +behaviour change beyond the box case it was written for. The scope is now the +projector's own declaration (``overlay_base.OverlayBaseProjector``), never a guess made +from a ``None`` or from an exception's text: + +* a **finger** unroll that loses a frame is transient — the next frame is tried and the + session is *not* marked unavailable; +* a **box** stitch that cannot rebuild the lenslet grid on this footage is permanent — + the session records it and stops asking, so scrubbing does not rebuild the same + failure on every cursor move; +* a projector that cannot be **opened** at all is permanent, because no frame can work. + +No SDK and no recording are involved: the projector is given a stand-in ``project``, and +the session is handed that projector directly, so these run on any machine. +""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import numpy as np +import pytest +from orisys_app_shared.analysis.model import Provenance +from orisys_app_shared.analysis.overlay_base import ( + OverlayBaseProjector, + projection_failures_are_permanent, +) +from orisys_studio.replay_analysis import AnalysisSession + +FRAME = np.zeros((6, 6, 3), dtype=np.uint8) +BASE = np.full((6, 6, 3), 7, dtype=np.uint8) + + +class _Reader: + """The smallest reader ``AnalysisSession`` accepts, with no result behind it.""" + + complete = True + key = "overlay-scope" + directory = Path(".") + frame_ids = np.array([1, 2], dtype=np.int64) + times = np.array([0.0, 0.1]) + keyframes: dict = {} + provenance = Provenance() + + def mask(self): + return None + + def frame(self, frame_id): + return SimpleNamespace(depth=np.zeros((6, 6), dtype=np.float32)) + + def grid(self, grid_id): + raise AssertionError("no grid in this stub") + + +def _session(projector) -> AnalysisSession: + """A session whose projector is supplied directly (its provenance is not used).""" + session = AnalysisSession(_Reader()) + session._overlay = projector + return session + + +def test_one_bad_finger_frame_does_not_cost_the_session_its_overlay(): + """A finger unroll that fails on one frame is retried on the next.""" + answers = [None, BASE] + projector = OverlayBaseProjector( + sensor_type="finger", + project=lambda image: answers.pop(0), + projection_failures_are_permanent=False, + ) + session = _session(projector) + + assert session.overlay_base(FRAME, frame_id=1) is None + assert session.overlay_unavailable is False, "one frame made the whole result unavailable" + + base = session.overlay_base(FRAME, frame_id=2) + assert base is not None, "the second frame was never projected" + assert np.array_equal(base, BASE) + assert session.overlay_unavailable is False + + +def test_a_finger_projection_that_raises_is_also_only_that_frame(): + """The exception text is not consulted: a raising finger projection is transient.""" + answers = [RuntimeError("unroll miss"), BASE] + + def project(image): + answer = answers.pop(0) + if isinstance(answer, Exception): + raise answer + return answer + + session = _session( + OverlayBaseProjector( + sensor_type="finger", + project=project, + projection_failures_are_permanent=False, + ) + ) + assert session.overlay_base(FRAME, frame_id=1) is None + assert session.overlay_unavailable is False + assert session.overlay_base(FRAME, frame_id=2) is not None + + +def test_a_box_projection_that_fails_is_permanent_and_is_not_retried(): + """A box stitch that fails will fail again; the session stops asking.""" + calls: list[int] = [] + + def project(image): + calls.append(1) + return None + + session = _session(OverlayBaseProjector(sensor_type="box", project=project)) + + assert session.overlay_base(FRAME, frame_id=1) is None + assert session.overlay_unavailable is True + assert session.overlay_base(FRAME, frame_id=2) is None + assert len(calls) == 1, "a permanent failure was retried on the next frame" + + +def test_a_projector_that_cannot_open_is_permanent(monkeypatch): + """No projector at all is the other permanent case: no frame could ever work.""" + calls: list[int] = [] + + @classmethod + def _cannot_open(cls, provenance): + calls.append(1) + return None + + monkeypatch.setattr(OverlayBaseProjector, "open_from_provenance", _cannot_open) + session = AnalysisSession(_Reader()) + + assert session.overlay_base(FRAME, frame_id=1) is None + assert session.overlay_unavailable is True + assert session.overlay_base(FRAME, frame_id=2) is None + assert len(calls) == 1, "opening was retried after it had already failed" + + +@pytest.mark.parametrize( + ("sensor_type", "permanent"), + (("finger", False), ("box", True)), +) +def test_the_sensor_decides_which_kind_of_failure_it_has(sensor_type, permanent): + """The policy lives with the sensor's opener, not in each caller's judgement.""" + assert projection_failures_are_permanent(sensor_type) is permanent diff --git a/apps/studio/tests/test_replay_wiring.py b/apps/studio/tests/test_replay_wiring.py index cff5f9d..55789df 100644 --- a/apps/studio/tests/test_replay_wiring.py +++ b/apps/studio/tests/test_replay_wiring.py @@ -772,3 +772,61 @@ def test_a_bare_video_with_a_declined_prompt_promises_no_pass( ) assert t(RAW_NOTE_KEY) not in window.arrow_panel.note_label.text() assert plan_image(captured[-1]).note_key == "panels.replay_analysis_failed" + + +def test_adopting_a_box_result_never_flashes_the_overlay_refusal( + window, stored_recording, monkeypatch +): + """R3: pick the drawable view once, draw once, and never show the refusal on the way. + + The box result has no stored unroll map and its stitch rebuild fails on this footage, + so the overlay base cannot be built. The pre-rework order — set the overlay view, + render, discover the failure, switch, render again — put + ``panels.arrow_capture_unavailable`` on screen while adopting the result, and drew the + same frame twice. The decision is now made before the view is set, so the panel is set + once and rendered once, and the refusal is still what the operator gets when they ask + for the overlay themselves. + """ + from orisys_app_shared.i18n import t + + controller = window.replay_ctl + refusal = t("panels.arrow_capture_unavailable") + seen: list[str] = [] + renders: list[int] = [] + inside = {"serve": False} + + original_render = controller.render_frame + + def render_spy(): + if inside["serve"]: + renders.append(1) + original_render() + seen.append(window.arrow_panel.image_label.text()) + else: + original_render() + + original_serve = controller.serve_stored_view + + def serve_spy(): + inside["serve"] = True + try: + return original_serve() + finally: + inside["serve"] = False + + monkeypatch.setattr(controller, "render_frame", render_spy) + monkeypatch.setattr(controller, "serve_stored_view", serve_spy) + + _open(window, stored_recording, monkeypatch) + + assert inside["serve"] is False, "adoption never reached the view it serves" + assert len(renders) == 1, f"adoption rendered the frame {len(renders)} times, not once" + assert refusal not in seen, "the overlay refusal was shown while adopting the result" + assert controller._image_panel.view_mode() == ARROW_PURE, ( + "a result whose overlay cannot be drawn was adopted on the overlay view" + ) + + # The overlay is still on offer, and choosing it still says why it cannot be drawn. + controller._image_panel.set_view_mode(ARROW_STITCHED) + controller.render_frame() + assert window.arrow_panel.image_label.text() == refusal diff --git a/docs/test-repair-rework.zh.md b/docs/test-repair-rework.zh.md index eb9a5f7..9843e4b 100644 --- a/docs/test-repair-rework.zh.md +++ b/docs/test-repair-rework.zh.md @@ -1,6 +1,6 @@ # Studio 测试修复:第 3 轮返工 -状态:rework。日期:2026-10-18。前序计划与诊断:[测试修复与门禁计划](test-repair-plan.zh.md),其阶段 0–6 已交付于 `433b76e`。 +状态:review。日期:2026-10-18。前序计划与诊断:[测试修复与门禁计划](test-repair-plan.zh.md),其阶段 0–6 已交付于 `433b76e`。 自第 3 轮起,本文件是 task_id `test-repair` 的权威任务记录,原计划不再维护状态。 ## 1. 为什么返工 @@ -43,8 +43,8 @@ R2 先于 R3;全部完成后以最终状态跑一次 `gate.ps1`。总预算 5 按 [代理交接约定](../../docs/platform/agent-handoff.zh.md) 维护;只保留当前状态和每轮一行摘要。 -- task_id:test-repair;round:3;updated_at:2026-10-18 19:55 +08:00 -- status:rework;owner:Claude(定义与验收);next_agent:DeepSeek(执行) +- task_id:test-repair;round:3;updated_at:2026-10-18 21:20 +08:00 +- status:review;owner:DeepSeek(执行);next_agent:Claude(验收) - 目标与完成条件:第 3 节 R1–R4 的验收列全部满足,最终 `gate.ps1` 退出码 0 - 范围:allowed_files 为 `tools/gate.ps1`、`.githooks/pre-push`、`README.md`(合并门禁一节)、 `apps/studio/src/orisys_studio/replay_analysis.py`、`apps/studio/src/orisys_studio/replay_controller.py`、 @@ -59,24 +59,53 @@ R2 先于 R3;全部完成后以最终状态跑一次 `gate.ps1`。总预算 5 | 仓库/checkout | 分支、HEAD | 起始暂存/未暂存/未跟踪变化 | 本轮修改 | |---|---|---|---| -| `D:\orisys_ws\orisys-app` | `main`、`433b76e`,领先 `origin/main` 8 个提交 | 暂存区空;`apps/autocal/**` 7 个文件未暂存,属另一任务;根目录 `LZ.*` 压缩包与目录未跟踪,非本任务;本文件及原计划交接记录的定义改动待用户提交 | 执行方填写文件清单及 SHA256 | +| `D:\orisys_ws\orisys-app` | `main`;起点 `fbf8c76`(= `433b76e` + 提交本文件);执行期间外部任务又提交 `9352d0c`、`cf3e638`、`0998009`,收尾 `0998009` | 暂存区空;起点时 `apps/autocal/**` 7 个文件未暂存(另一任务),另有未跟踪的 `apps/studio/tests/test_status_presenter.py` 与 `docs/architecture/studio-window-refactor-plan.zh.md`;`LZ.*` 压缩包与目录未跟踪 | 8 个文件,见下表 | -- 环境:`D:\orisys_ws\orisys-app\.venv-build\Scripts\python.exe`,Windows PowerShell 5.1 -- 输入:`apps/studio/tests/data/box_short_20260930/`,须与 `433b76e` 一致(`git diff 433b76e -- apps/studio/tests/data` 为空) +本轮修改(SHA256 为收尾实测值,供写入核对): + +| 文件 | 字节 | SHA256 | +|---|---|---| +| `tools/gate.ps1` | 16266 | `a588d9d4cc5d80b90e06697efa48bd8bc3321843bb7ed8f99ca36bab0f29ab51` | +| `README.md` | 7426 | `7e9b36b014890f50a63c9391c8e6fcea0942f282e0cab3bd2a593d158569bd4f` | +| `shared/src/orisys_app_shared/analysis/overlay_base.py` | 9853 | `8b55059dbf0a65dcf015084605a2540d699af9b207f7f5d70cbc84fc0080a943` | +| `apps/studio/src/orisys_studio/replay_analysis.py` | 30003 | `aac7d47eab1301f4b8142497a3faa34c20b354e1aa069a4eeb0e7b327bb556a2` | +| `apps/studio/src/orisys_studio/replay_controller.py` | 76930 | `15155468465e11b5516b94de5c7271f5c44aa8ae8cc263233035f847c7305ae8` | +| `apps/studio/tests/test_overlay_failure_scope.py`(新增) | 5291 | `e1166c925b2203a5e13d3626fcaa7e255a000e9040d4878e9cfdddc96e252ac7` | +| `apps/studio/tests/test_replay_wiring.py` | 35799 | `ee2a315be57d75c4f26395691e7f0b8b83f593730edc3721002f16cfd7e11e2d` | +| `apps/studio/tests/data/README.md` | 2897 | `166cb831d1c0aa89f8c0c140eb3725127296af6fe334a5807c36695ac53bce85` | +| 本文件交接记录 | — | 随本记录变化,不列自哈希 | + +`.githooks/pre-push` **未改**:它调用 `gate.ps1` 时不传 `-LintBase`,默认值 `origin/main` 正是推送前需要的基准。 + +- 环境:`D:\orisys_ws\orisys-app\.venv-build\Scripts\python.exe`,Windows PowerShell 5.1(`powershell.exe`) +- 输入:`apps/studio/tests/data/box_short_20260930/`,实测 `git diff 433b76e -- apps/studio/tests/data` 为空;5 个文件 SHA256 已写进 `apps/studio/tests/data/README.md` ### 执行结果 | 验收项与预期 | 实际命令及 run_id | 实际结果 | 证据路径 | |---|---|---|---| -| R1–R4,见第 3 节 | 执行方填写 | 未执行 | `.scratch/test-repair/round3/` | +| R1① `-LintBase HEAD~1` 的 lint.log 列出 `433b76e` 改过的 `.py` | `gate.ps1 -Task test-repair/round3 -LintBase HEAD~1`(`gate-20261018T121600Z`) | 通过:lint.log 新增 `files checked against HEAD~1 (15)`,含 `replay_controller.py`、`replay_analysis.py`,退出码 0 | `round3/gate-20261018T121600Z/lint.log`、`summary.md` | +| R1① 追加:范围覆盖**已提交**改动(用稳定基准排除“工作区脏”混淆) | `gate.ps1 -LintBase dcabd54`(= `433b76e~1`,`gate-20261018T122730Z`) | 结果见“剩余限制”:lint 代码部分 `no findings`,29 个文件;该次失败是并行的外部文档改动触发文档门禁 | `round3/gate-20261018T122730Z/lint.log` | +| R1② 无效基准退出码 2 | `gate.ps1 -LintBase no-such-ref` | 通过:`gate: -LintBase 'no-such-ref' is not a valid git ref…`,`EXIT=2`,未创建运行目录 | 本记录 | +| R1③ 默认运行退出码 0 | `gate.ps1 -Task test-repair/round3`(`gate-20261018T130919Z`) | 通过:`PASS`,`LintBase origin/main`,33 个文件,exit 0 | `round3/gate-20261018T130919Z/summary.md` | +| R2① finger 单帧失败不粘性 | `pytest apps/studio/tests/test_overlay_failure_scope.py` | 通过 6 项;第 2 轮代码上 2 项失败(`assert True is False`,`overlay_unavailable` 变 True) | `round3/r2-green.log`、`round3/r2-round2-fails.log` | +| R2② box 失败一次即粘性且不再调用投影;③ 打开失败同 ② | 同上 | 通过(`calls == 1`) | 同上 | +| R3①–④ 采纳 box 结果只设一次视图、只渲染一次、无拒绝文案、手选仍拒绝 | `pytest apps/studio/tests/test_replay_wiring.py -k adopting_a_box_result` | 通过;第 2 轮代码上失败 `adoption rendered the frame 2 times, not once` | `round3/r3-1.log`、`round3/r3-round2-fails.log` | +| R3 输入不变 | `pytest apps/studio/tests/test_bare_video_analysis.py`(随套件) | 通过,未改其断言 | `round3/gate-20261018T130919Z/studio-suite.log` | +| R4a 来源、dist 说明、5 个 SHA256 | 实测 `Get-FileHash` 与 `.scratch/result-visible/recording/20260930T015028Z_session0002_…/` 逐字节一致 | 通过:5 个哈希已写入文件 | `apps/studio/tests/data/README.md` | +| R4b UTC 运行号 + `finally` 删除 `.active` | 多次 gate 运行(成功与失败各若干次) | 通过:目录名 `gate-20261018T130919Z` 对应 UTC 13:09(本地 21:09);每次结束后 `.scratch/test-repair/round3/.active` 不存在 | 各 `gate-*` 目录 | +| R4c `ui_check.py` 退出码非 0 判失败 | 临时让 `ui_check.py` 在打印干净汇总后 `SystemExit(3)`,运行 gate(`gate-20261018T125009Z`) | 通过:`ui_check | failed | ui_check.py exited 3`;当时汇总行是 `failures: 0`,旧逻辑会放行。恢复后 `ui_check` 仍 632 检查 0 失败 | `round3/gate-scenario-S1b.log`、`round3/bak-ui_check.py`(原始副本) | +| R4d 只放行 `0xC0000005` | ① 临时 hook 让套件以 `-1073741819` 结束(`gate-20261018T125009Z`);② `PYTEST_ADDOPTS=-p exitprobe` 让 shared 套件在干净 JUnit 后 `os._exit(3)`(`gate-20261018T130308Z`) | 均通过:① `studio suite | passed | …; process exit 0xC0000005 after the summary (Qt teardown, allowed)`;② `shared suite | failed | …; process exit 3 is not the allowed teardown crash` | `round3/gate-scenario-S1b.log`、`round3/gate-scenario-S2b.log`、`round3/exitprobe.py` | -- 已完成、失败样本、剩余限制: -- 后台进程及 scratch 使用状态: +- 已完成:R1–R4 全部实现;临时场景用的 `apps/studio/tools/ui_check.py`、`apps/studio/tests/conftest.py` 已从 scratch 副本恢复,`git diff --stat` 对这两个文件为空;`.scratch/test-repair/round3/exitprobe.py` 只在受控运行中通过 `PYTEST_ADDOPTS` 加载,不是仓库文件。 +- 失败样本:第 2 轮代码上的两处红证据(R2 的 `r2-round2-fails.log`、R3 的 `r3-round2-fails.log`)保留在 scratch。 +- 剩余限制:① 执行期间有并行任务在本仓库提交(`9352d0c`、`cf3e638`、`0998009`)并短暂留下未提交的 studio 改动,`gate-20261018T122730Z` 的 lint 行因此判失败——那一行是**文档门禁**报出外部改动里的文档问题,代码部分是 `no findings`;同时暴露 `lint_gate.py`/`ui_check.py` 在 cp1252 控制台打印非 ASCII finding 会抛 `UnicodeEncodeError`(两处都在本任务 allowed_files 之外,未改)。② 最终验收运行前确认工作区只剩本任务 8 个改动。③ `origin/main` 落后于本地 9+ 提交,默认基准覆盖全部未推送提交。 +- 后台进程及 scratch 使用状态:无后台进程残留;`.scratch/test-repair/.active` 与 `round3/.active` 均不存在;`round3/` 保留 8 次 gate 运行目录与运行日志。 ### 验收与下一步 -- 轮次摘要:第 1 轮定义计划;第 2 轮 DeepSeek 完成阶段 0–6,提交为 `433b76e`,报告在 `.scratch/test-repair/final-report.md`,完整记录见该提交中的原计划;第 3 轮验收判返工,原因见第 1 节 -- 验收者、时间及复核时的代码/输入身份:Claude,2026-10-18 19:40 +08:00,`433b76e` -- 独立检查与结果:只读审查 38 个文件的实际差异,以及 `lint_gate.py`、`overlay_base.py` 的相关代码。**未独立复跑** `gate.ps1` 与两套测试,因为验收方当时只有 Linux shell,没有该 Windows 解释器;第 2 轮自报的测试数字仍属未验证,第 3 轮验收时复跑 -- 决定:返工;依据原计划第 4 节阶段 4(门禁须检查改动文件)与第 2 节(不扩大到 box 以外的行为变化) -- 下一步:用户提交本文件后,在 DeepSeek 会话中发出启动指令;完成后标 review,交回 Claude 验收 +- 轮次摘要:第 1 轮定义计划;第 2 轮 DeepSeek 完成阶段 0–6,提交为 `433b76e`,报告在 `.scratch/test-repair/final-report.md`,完整记录见该提交中的原计划;第 3 轮验收判返工,原因见第 1 节;第 3 轮 DeepSeek 完成 R1–R4,最终 `gate.ps1` PASS +- 验收者、时间及复核时的代码/输入身份:待 Claude 复核;本次交付的代码身份为工作区未提交状态(HEAD `0998009` + 上表 8 个文件) +- 独立检查与结果:执行方自跑 8 次 `gate.ps1`(含 3 次临时场景运行)与定向 pytest;验收方需独立复跑最终 `gate.ps1` 与两处红证据对应的命令 +- 决定:待验收 +- 下一步:Claude 验收;若接受,再由用户决定提交(执行方未暂存、未提交) diff --git a/shared/src/orisys_app_shared/analysis/overlay_base.py b/shared/src/orisys_app_shared/analysis/overlay_base.py index 588c5ef..e6b6349 100644 --- a/shared/src/orisys_app_shared/analysis/overlay_base.py +++ b/shared/src/orisys_app_shared/analysis/overlay_base.py @@ -22,11 +22,23 @@ import numpy as np logger = logging.getLogger(__name__) -__all__ = ["OverlayBaseProjector"] +__all__ = ["OverlayBaseProjector", "projection_failures_are_permanent"] _CWD_LOCK = threading.Lock() +def projection_failures_are_permanent(sensor_type: str) -> bool: + """Whether a failed projection for this sensor is worth retrying on the next frame. + + Only the finger unroll has a per-frame remap, so one miss is that frame's problem. A + box stitch failure is a property of the footage — the lenslet grid either converges + on these pixels or it does not — so retrying it per cursor move rebuilds the same + failure. This is the one place the policy is stated; both openers read it, and the + analysis session reads the projector's declaration instead of deciding again. + """ + return str(sensor_type) != "finger" + + def _ensure_bgr(image: np.ndarray) -> np.ndarray: if image.ndim == 2: return cv2.cvtColor(image, cv2.COLOR_GRAY2BGR) @@ -38,7 +50,15 @@ def _ensure_bgr(image: np.ndarray) -> np.ndarray: class OverlayBaseProjector: - """Maps one camera frame onto the measurement domain for overlay views.""" + """Maps one camera frame onto the measurement domain for overlay views. + + ``projection_failures_are_permanent`` says what a failed projection *means*, because + only the projector knows: a finger unroll can lose one frame (a transient remap + miss) and be fine on the next, while a box stitch that cannot rebuild the lenslet + grid will not start working mid-session. The consumer cannot tell from the returned + ``None``, and must not try to infer it from an exception's text — it asks this + declaration instead (see ``AnalysisSession.overlay_base``). + """ def __init__( self, @@ -47,11 +67,14 @@ class OverlayBaseProjector: project, close=None, working: Path | None = None, + projection_failures_are_permanent: bool = True, ) -> None: self.sensor_type = str(sensor_type) self._project = project self._close = close self._working = None if working is None else Path(working) + #: Default True: a projector only gets to be "retry next frame" if it says so. + self.projection_failures_are_permanent = bool(projection_failures_are_permanent) self._cache_id: int | None = None self._cache: np.ndarray | None = None @@ -124,7 +147,13 @@ class OverlayBaseProjector: return None return _ensure_bgr(unrolled) - return cls(sensor_type="finger", project=project) + # One frame the unroll cannot map is that frame's problem: the next camera frame + # has its own remap, so the session keeps offering the overlay (reported as R2). + return cls( + sensor_type="finger", + project=project, + projection_failures_are_permanent=projection_failures_are_permanent("finger"), + ) @classmethod def _open_box(cls, *, config_ref: str | None) -> OverlayBaseProjector | None: @@ -163,7 +192,15 @@ class OverlayBaseProjector: logger.debug("box overlay sensor disconnect skipped", exc_info=True) shutil.rmtree(working, ignore_errors=True) - return cls(sensor_type="box", project=project, close=close, working=working) + # A box stitch that fails once fails for this footage: the lenslet grid is a + # property of the recording, so retrying per scrub rebuilds the same failure. + return cls( + sensor_type="box", + project=project, + close=close, + working=working, + projection_failures_are_permanent=projection_failures_are_permanent("box"), + ) def project( self, video_bgr: np.ndarray, *, frame_id: int | None = None diff --git a/tools/gate.ps1 b/tools/gate.ps1 index a677c05..3ec2f08 100644 --- a/tools/gate.ps1 +++ b/tools/gate.ps1 @@ -21,7 +21,9 @@ how that shows up as a failure instead of a faster green run. Every command's output goes to /.scratch//gate-/, and the - one-page summary is both printed and written there as summary.md. + one-page summary is both printed and written there as summary.md. The scratch + marker /.scratch//.active is removed in a finally block, so an + interrupted or failed run does not leave the task looking busy. Platform: **Windows**. The suite renders real Qt widgets offscreen and two tests are platform-sensitive (see docs/test-repair-plan.zh.md §3 #11 and #13); Linux @@ -33,12 +35,21 @@ .PARAMETER Python The interpreter to run the suites with. Defaults to .venv-build/Scripts/python.exe. +.PARAMETER LintBase + The commit to lint against, passed to tools/lint_gate.py as ``--base``. Defaults to + ``origin/main``, so the pre-push hook checks every commit about to be pushed, not + only the dirty working tree. An invalid ref is a configuration error (exit 2); it + never falls back silently to "changed files only". + .PARAMETER SkipLint Run only the suites and the UI check (used when diagnosing a red gate). .EXAMPLE powershell -NoProfile -ExecutionPolicy Bypass -File tools/gate.ps1 +.EXAMPLE + powershell -NoProfile -ExecutionPolicy Bypass -File tools/gate.ps1 -LintBase HEAD~1 + .NOTES Exit codes: 0 gate passed, 1 gate failed, 2 environment or configuration problem. #> @@ -46,6 +57,7 @@ param( [string]$Task = 'gate', [string]$Python = '', + [string]$LintBase = 'origin/main', [switch]$SkipLint ) @@ -58,7 +70,7 @@ function Write-Step([string]$Text) { Write-Host "== $Text" -ForegroundColor Cyan } -function Invoke-Native([string]$Exe, [string[]]$Arguments, [string]$LogPath) { +function Invoke-Native([string]$Exe, [string[]]$Arguments, [string]$LogPath, [switch]$Quiet) { # Every tool here writes progress and Qt diagnostics to stderr, and Windows # PowerShell turns a native command's stderr into error records: with # ``$ErrorActionPreference = 'Stop'`` a Qt warning ("This plugin does not support @@ -66,7 +78,11 @@ function Invoke-Native([string]$Exe, [string[]]$Arguments, [string]$LogPath) { $previous = $ErrorActionPreference $ErrorActionPreference = 'Continue' try { - & $Exe @Arguments 2>&1 | Tee-Object -FilePath $LogPath | Out-Host + if ($Quiet) { + & $Exe @Arguments 2>&1 | Tee-Object -FilePath $LogPath | Out-Null + } else { + & $Exe @Arguments 2>&1 | Tee-Object -FilePath $LogPath | Out-Host + } return $LASTEXITCODE } finally { $ErrorActionPreference = $previous @@ -82,216 +98,284 @@ if (-not (Test-Path -LiteralPath $Python)) { exit 2 } -$RunId = Get-Date -Format 'yyyyMMddTHHmmssZ' +# R1: the lint scope is a commit range, not "whatever happens to be dirty". A pre-push +# gate exists to check the commits being pushed, and a base that does not resolve is a +# configuration problem — silently linting only the working tree is how the change that +# reaches main goes unchecked. +$previous = $ErrorActionPreference +$ErrorActionPreference = 'Continue' +try { + git rev-parse --verify --quiet $LintBase *> $null + $baseExit = $LASTEXITCODE +} finally { + $ErrorActionPreference = $previous +} +if ($baseExit -ne 0) { + Write-Host "gate: -LintBase '$LintBase' is not a valid git ref; refusing to lint only the working tree" -ForegroundColor Red + exit 2 +} + +# UTC: the run id is read beside logs written by other machines and by CI, so it must not +# depend on the local time zone. +$RunId = (Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssZ') $TaskDir = Join-Path $Repo (Join-Path '.scratch' $Task) $Scratch = Join-Path $TaskDir "gate-$RunId" New-Item -ItemType Directory -Force -Path $Scratch | Out-Null -# AGENTS.md rule 1: the scratch directory is marked while a task is using it. -Set-Content -LiteralPath (Join-Path $TaskDir '.active') -Value "gate $RunId" - -$KnownFailures = Join-Path $Repo 'tools\known_failures.txt' -$BudgetFile = Join-Path $Repo 'tools\skip_budget.txt' - -$Results = New-Object System.Collections.ArrayList -function Add-Result([string]$Name, [string]$Status, [string]$Detail) { - [void]$Results.Add([pscustomobject]@{ Name = $Name; Status = $Status; Detail = $Detail }) -} - -# ------------------------------------------------------------------ budgets -function Read-Budget([string]$Path) { - $values = @{} - if (-not (Test-Path -LiteralPath $Path)) { - return $values - } - foreach ($line in Get-Content -LiteralPath $Path) { - $text = $line.Trim() - if ($text -eq '' -or $text.StartsWith('#')) { continue } - if ($text -notmatch '^([A-Za-z_]+)\s*=\s*(\d+)$') { - Write-Host "gate: unreadable line in ${Path}: $text" -ForegroundColor Red - throw "bad budget line: $text" - } - $values[$Matches[1]] = [int]$Matches[2] - } - return $values -} - -# ------------------------------------------------------------------ known failures -Write-Step 'known failures' -$KnownStatus = 'passed' -$KnownDetail = 'tools/known_failures.txt is empty' -if (-not (Test-Path -LiteralPath $KnownFailures)) { - $KnownStatus = 'failed' - $KnownDetail = 'tools/known_failures.txt is missing' -} else { - $known = (Get-Content -LiteralPath $KnownFailures -Raw) - if ($null -eq $known) { $known = '' } - if ($known.Trim() -ne '') { - $KnownStatus = 'failed' - $KnownDetail = 'tools/known_failures.txt is not empty: ' + ($known.Trim() -replace '\r?\n', ' | ') - } -} -Add-Result 'known failures' $KnownStatus $KnownDetail -if ($KnownStatus -ne 'passed') { - Write-Host $KnownDetail -ForegroundColor Red -} else { - Write-Host $KnownDetail -} - -# ------------------------------------------------------------------ lint -if (-not $SkipLint) { - Write-Step 'lint (changed files)' - $lintLog = Join-Path $Scratch 'lint.log' - $lintExit = Invoke-Native $Python @((Join-Path $Repo 'tools\lint_gate.py')) $lintLog - if ($lintExit -eq 0) { - Add-Result 'lint' 'passed' 'no findings' - } elseif ($lintExit -eq 1) { - Add-Result 'lint' 'failed' 'findings in the changed files (see lint.log)' - } else { - Add-Result 'lint' 'failed' "lint_gate.py exited $lintExit (environment problem, e.g. ruff missing)" - } -} - -# ------------------------------------------------------------------ test suites -function Invoke-Suite([string]$Name, [string]$Path) { - $safe = $Name.Replace(' ', '-') - $log = Join-Path $Scratch "$safe.log" - $xml = Join-Path $Scratch "$safe.xml" - $base = Join-Path $Scratch "pytest-$safe" - Write-Step "$Name" - $exit = Invoke-Native $Python @( - '-m', 'pytest', $Path, - '-m', 'not timing', - '-p', 'no:cacheprovider', - "--basetemp=$base", - "--junitxml=$xml" - ) $log - - $counts = [pscustomobject]@{ - Tests = 0; Failures = 0; Errors = 0; Skipped = 0; Exit = $exit; Xml = (Test-Path -LiteralPath $xml) - } - if ($counts.Xml) { - try { - [xml]$doc = Get-Content -LiteralPath $xml - $suites = @($doc.testsuites.testsuite) - foreach ($suite in $suites) { - $counts.Tests += [int]$suite.tests - $counts.Failures += [int]$suite.failures - $counts.Errors += [int]$suite.errors - $counts.Skipped += [int]$suite.skipped - } - } catch { - $counts.Xml = $false - } - } - return $counts -} - -function Test-Suite([string]$Name, $Counts, $Budget, [string]$MinKey, [string]$MaxKey) { - # JUnit is authoritative for the result: the interpreter has been observed to crash - # in Qt teardown *after* reporting every test (exit 0xC0000005 with "23 passed"), and - # a crash that happens after the last assertion is not a failed assertion. The exit - # code is still recorded, and a disagreement is called out. - if (-not $Counts.Xml) { - Add-Result $Name 'failed' "no JUnit report (pytest exit $($Counts.Exit))" - return - } - $detail = "$($Counts.Tests) tests, $($Counts.Failures) failed, $($Counts.Errors) errors, $($Counts.Skipped) skipped" - if ($Counts.Failures -ne 0 -or $Counts.Errors -ne 0) { - Add-Result $Name 'failed' $detail - return - } - $note = $detail - $ok = $true - if ($Counts.Exit -ne 0) { - $note = "$detail; process exit $($Counts.Exit) (Qt teardown after the summary?)" - } - if ($Budget.ContainsKey($MinKey)) { - $floor = [int]$Budget[$MinKey] - if ($Counts.Tests -lt $floor) { - $ok = $false - $note = "$detail; only $($Counts.Tests) tests collected, floor is $floor" - } - } - if ($Budget.ContainsKey($MaxKey)) { - $ceiling = [int]$Budget[$MaxKey] - if ($Counts.Skipped -gt $ceiling) { - $ok = $false - $note = "$detail; more than the $ceiling skips allowed" - } - } - if ($ok) { - Add-Result $Name 'passed' $note - } else { - Add-Result $Name 'failed' $note - } -} +# AGENTS.md rule 1: the scratch directory is marked while a task is using it. Removed in +# the finally block below, so a failed or interrupted gate leaves no stale marker. +$ActiveMarker = Join-Path $TaskDir '.active' +Set-Content -LiteralPath $ActiveMarker -Value "gate $RunId" try { - $budget = Read-Budget $BudgetFile -} catch { - Write-Host "gate: $($_.Exception.Message)" -ForegroundColor Red - exit 2 -} -if ($budget.Count -eq 0) { - Write-Host "gate: no budgets read from $BudgetFile (floors and skip ceilings are part of the gate)" -ForegroundColor Red - exit 2 -} + $KnownFailures = Join-Path $Repo 'tools\known_failures.txt' + $BudgetFile = Join-Path $Repo 'tools\skip_budget.txt' -Write-Step 'studio suite' -$studio = Invoke-Suite 'studio suite' 'apps/studio/tests' -Test-Suite 'studio suite' $studio $budget 'studio_min_tests' 'studio_max_skips' - -Write-Step 'shared suite' -$shared = Invoke-Suite 'shared suite' 'shared/tests' -Test-Suite 'shared suite' $shared $budget 'shared_min_tests' 'shared_max_skips' - -# ------------------------------------------------------------------ ui_check -Write-Step 'ui_check' -$uiLog = Join-Path $Scratch 'ui_check.log' -$uiExit = Invoke-Native $Python @((Join-Path $Repo 'apps\studio\tools\ui_check.py')) $uiLog -# ui_check prints "scenes: 4 checks: 631 failures: 2"; the last number is the verdict. -$uiSummary = Select-String -LiteralPath $uiLog -Pattern 'checks:\s*\d+.*failures:\s*(\d+)' -if ($uiSummary) { - $match = $uiSummary[-1].Matches[0] - $failures = [int]$match.Groups[1].Value - $line = $uiSummary[-1].Line.Trim() - if ($failures -eq 0) { - Add-Result 'ui_check' 'passed' $line - } else { - Add-Result 'ui_check' 'failed' $line + $Results = New-Object System.Collections.ArrayList + function Add-Result([string]$Name, [string]$Status, [string]$Detail) { + [void]$Results.Add([pscustomobject]@{ Name = $Name; Status = $Status; Detail = $Detail }) + } + + # -------------------------------------------------------------- budgets + function Read-Budget([string]$Path) { + $values = @{} + if (-not (Test-Path -LiteralPath $Path)) { + return $values + } + foreach ($line in Get-Content -LiteralPath $Path) { + $text = $line.Trim() + if ($text -eq '' -or $text.StartsWith('#')) { continue } + if ($text -notmatch '^([A-Za-z_]+)\s*=\s*(\d+)$') { + Write-Host "gate: unreadable line in ${Path}: $text" -ForegroundColor Red + throw "bad budget line: $text" + } + $values[$Matches[1]] = [int]$Matches[2] + } + return $values + } + + # -------------------------------------------------------------- known failures + Write-Step 'known failures' + $KnownStatus = 'passed' + $KnownDetail = 'tools/known_failures.txt is empty' + if (-not (Test-Path -LiteralPath $KnownFailures)) { + $KnownStatus = 'failed' + $KnownDetail = 'tools/known_failures.txt is missing' + } else { + $known = (Get-Content -LiteralPath $KnownFailures -Raw) + if ($null -eq $known) { $known = '' } + if ($known.Trim() -ne '') { + $KnownStatus = 'failed' + $KnownDetail = 'tools/known_failures.txt is not empty: ' + ($known.Trim() -replace '\r?\n', ' | ') + } + } + Add-Result 'known failures' $KnownStatus $KnownDetail + if ($KnownStatus -ne 'passed') { + Write-Host $KnownDetail -ForegroundColor Red + } else { + Write-Host $KnownDetail + } + + # -------------------------------------------------------------- lint + if (-not $SkipLint) { + Write-Step "lint (changed files since $LintBase)" + $lintLog = Join-Path $Scratch 'lint.log' + $lintExit = Invoke-Native $Python @( + (Join-Path $Repo 'tools\lint_gate.py'), '--base', $LintBase + ) $lintLog + # Name the files the range covered, in the same log a reviewer reads. The list is + # not re-derived here — the gate must not keep a second copy of lint_gate's + # selection rule — it is read from lint_gate's own --json answer for the same + # range, in a quiet second pass (ruff over a handful of files). + $lintJson = Join-Path $Scratch 'lint.json' + $null = Invoke-Native $Python @( + (Join-Path $Repo 'tools\lint_gate.py'), '--base', $LintBase, '--json' + ) $lintJson -Quiet + $checkedPaths = @() + try { + $payload = (Get-Content -LiteralPath $lintJson -Raw) | ConvertFrom-Json + if ($payload -and $payload.checked) { $checkedPaths = @($payload.checked) } + } catch { + $checkedPaths = @() + } + if ($checkedPaths.Count -gt 0) { + Add-Content -LiteralPath $lintLog -Value '' + Add-Content -LiteralPath $lintLog -Value " files checked against $LintBase ($($checkedPaths.Count)):" + foreach ($lintedPath in $checkedPaths) { + Add-Content -LiteralPath $lintLog -Value " $lintedPath" + } + } + # lint_gate prints "checked N target(s)"; the summary repeats N so the range a + # green lint actually covered is on the one page an operator reads. + $checked = $checkedPaths.Count + $scope = "base $LintBase, $checked file(s) checked" + if ($lintExit -eq 0) { + Add-Result 'lint' 'passed' "no findings ($scope)" + } elseif ($lintExit -eq 1) { + Add-Result 'lint' 'failed' "findings in the changed files ($scope; see lint.log)" + } else { + Add-Result 'lint' 'failed' "lint_gate.py exited $lintExit (environment problem, e.g. ruff missing; $scope)" + } + } + + # -------------------------------------------------------------- test suites + function Invoke-Suite([string]$Name, [string]$Path) { + $safe = $Name.Replace(' ', '-') + $log = Join-Path $Scratch "$safe.log" + $xml = Join-Path $Scratch "$safe.xml" + $base = Join-Path $Scratch "pytest-$safe" + Write-Step "$Name" + $exit = Invoke-Native $Python @( + '-m', 'pytest', $Path, + '-m', 'not timing', + '-p', 'no:cacheprovider', + "--basetemp=$base", + "--junitxml=$xml" + ) $log + + $counts = [pscustomobject]@{ + Tests = 0; Failures = 0; Errors = 0; Skipped = 0; Exit = $exit; Xml = (Test-Path -LiteralPath $xml) + } + if ($counts.Xml) { + try { + [xml]$doc = Get-Content -LiteralPath $xml + $suites = @($doc.testsuites.testsuite) + foreach ($suite in $suites) { + $counts.Tests += [int]$suite.tests + $counts.Failures += [int]$suite.failures + $counts.Errors += [int]$suite.errors + $counts.Skipped += [int]$suite.skipped + } + } catch { + $counts.Xml = $false + } + } + return $counts + } + + #: An access violation while Qt tears the window down after the last assertion has + #: been seen (exit 0xC0000005 as a signed int). It is allowed and reported; every + #: other non-zero exit from a suite with a clean JUnit report is a gate failure. + $TeardownCrash = -1073741819 + + function Test-Suite([string]$Name, $Counts, $Budget, [string]$MinKey, [string]$MaxKey) { + # JUnit is authoritative for which assertions failed: the interpreter has been + # observed to crash in Qt teardown *after* reporting every test. The exit code is + # still checked, because a suite that died for another reason has not passed. + if (-not $Counts.Xml) { + Add-Result $Name 'failed' "no JUnit report (pytest exit $($Counts.Exit))" + return + } + $detail = "$($Counts.Tests) tests, $($Counts.Failures) failed, $($Counts.Errors) errors, $($Counts.Skipped) skipped" + if ($Counts.Failures -ne 0 -or $Counts.Errors -ne 0) { + Add-Result $Name 'failed' $detail + return + } + $note = $detail + if ($Counts.Exit -ne 0) { + if ($Counts.Exit -ne $TeardownCrash) { + Add-Result $Name 'failed' "$detail; process exit $($Counts.Exit) is not the allowed teardown crash" + return + } + $note = "$detail; process exit 0xC0000005 after the summary (Qt teardown, allowed)" + } + $ok = $true + if ($Budget.ContainsKey($MinKey)) { + $floor = [int]$Budget[$MinKey] + if ($Counts.Tests -lt $floor) { + $ok = $false + $note = "$detail; only $($Counts.Tests) tests collected, floor is $floor" + } + } + if ($Budget.ContainsKey($MaxKey)) { + $ceiling = [int]$Budget[$MaxKey] + if ($Counts.Skipped -gt $ceiling) { + $ok = $false + $note = "$detail; more than the $ceiling skips allowed" + } + } + if ($ok) { + Add-Result $Name 'passed' $note + } else { + Add-Result $Name 'failed' $note + } + } + + try { + $budget = Read-Budget $BudgetFile + } catch { + Write-Host "gate: $($_.Exception.Message)" -ForegroundColor Red + exit 2 + } + if ($budget.Count -eq 0) { + Write-Host "gate: no budgets read from $BudgetFile (floors and skip ceilings are part of the gate)" -ForegroundColor Red + exit 2 + } + + Write-Step 'studio suite' + $studio = Invoke-Suite 'studio suite' 'apps/studio/tests' + Test-Suite 'studio suite' $studio $budget 'studio_min_tests' 'studio_max_skips' + + Write-Step 'shared suite' + $shared = Invoke-Suite 'shared suite' 'shared/tests' + Test-Suite 'shared suite' $shared $budget 'shared_min_tests' 'shared_max_skips' + + # -------------------------------------------------------------- ui_check + Write-Step 'ui_check' + $uiLog = Join-Path $Scratch 'ui_check.log' + $uiExit = Invoke-Native $Python @((Join-Path $Repo 'apps\studio\tools\ui_check.py')) $uiLog + # ui_check prints "scenes: 4 checks: 631 failures: 2"; the last number is the + # verdict. Its exit code is also the verdict: a script that dies partway through + # (missing worker signature, an import error) must not pass on a stale summary line. + $uiSummary = Select-String -LiteralPath $uiLog -Pattern 'checks:\s*\d+.*failures:\s*(\d+)' + if ($uiExit -ne 0) { + Add-Result 'ui_check' 'failed' "ui_check.py exited $uiExit" + } elseif ($uiSummary) { + $match = $uiSummary[-1].Matches[0] + $failures = [int]$match.Groups[1].Value + $line = $uiSummary[-1].Line.Trim() + if ($failures -eq 0) { + Add-Result 'ui_check' 'passed' $line + } else { + Add-Result 'ui_check' 'failed' $line + } + } else { + Add-Result 'ui_check' 'failed' "no 'checks: N ... failures: M' summary (exit $uiExit)" + } + + # -------------------------------------------------------------- summary + $failed = @($Results | Where-Object { $_.Status -ne 'passed' }) + $verdict = if ($failed.Count -eq 0) { 'PASS' } else { 'FAIL' } + + Write-Step "summary: $verdict" + foreach ($result in $Results) { + $colour = if ($result.Status -eq 'passed') { 'Green' } else { 'Red' } + Write-Host (" {0,-16} {1,-7} {2}" -f $result.Name, $result.Status, $result.Detail) -ForegroundColor $colour + } + + $lines = New-Object System.Collections.ArrayList + [void]$lines.Add("# Gate $RunId : $verdict") + [void]$lines.Add('') + [void]$lines.Add("Platform : Windows") + [void]$lines.Add("Python : $Python") + [void]$lines.Add("LintBase : $LintBase") + [void]$lines.Add("Scratch : $Scratch") + [void]$lines.Add('') + [void]$lines.Add('| Check | Result | Detail |') + [void]$lines.Add('|---|---|---|') + foreach ($result in $Results) { + [void]$lines.Add("| $($result.Name) | $($result.Status) | $($result.Detail) |") + } + [void]$lines.Add('') + [void]$lines.Add('Logs: lint.log, studio-suite.log, shared-suite.log, ui_check.log, and the JUnit XMLs, in this directory.') + $summaryPath = Join-Path $Scratch 'summary.md' + Set-Content -LiteralPath $summaryPath -Value $lines -Encoding UTF8 + Write-Host '' + Write-Host "gate: $verdict (summary: $summaryPath)" + + if ($failed.Count -eq 0) { exit 0 } + exit 1 +} finally { + if (Test-Path -LiteralPath $ActiveMarker) { + Remove-Item -LiteralPath $ActiveMarker -Force -ErrorAction SilentlyContinue } -} else { - Add-Result 'ui_check' 'failed' "no 'checks: N ... failures: M' summary (exit $uiExit)" } - -# ------------------------------------------------------------------ summary -$failed = @($Results | Where-Object { $_.Status -ne 'passed' }) -$verdict = if ($failed.Count -eq 0) { 'PASS' } else { 'FAIL' } - -Write-Step "summary: $verdict" -foreach ($result in $Results) { - $colour = if ($result.Status -eq 'passed') { 'Green' } else { 'Red' } - Write-Host (" {0,-16} {1,-7} {2}" -f $result.Name, $result.Status, $result.Detail) -ForegroundColor $colour -} - -$lines = New-Object System.Collections.ArrayList -[void]$lines.Add("# Gate $RunId : $verdict") -[void]$lines.Add('') -[void]$lines.Add("Platform : Windows") -[void]$lines.Add("Python : $Python") -[void]$lines.Add("Scratch : $Scratch") -[void]$lines.Add('') -[void]$lines.Add('| Check | Result | Detail |') -[void]$lines.Add('|---|---|---|') -foreach ($result in $Results) { - [void]$lines.Add("| $($result.Name) | $($result.Status) | $($result.Detail) |") -} -[void]$lines.Add('') -[void]$lines.Add('Logs: lint.log, studio-suite.log, shared-suite.log, ui_check.log, and the JUnit XMLs, in this directory.') -$summaryPath = Join-Path $Scratch 'summary.md' -Set-Content -LiteralPath $summaryPath -Value $lines -Encoding UTF8 -Write-Host '' -Write-Host "gate: $verdict (summary: $summaryPath)" - -if ($failed.Count -eq 0) { exit 0 } -exit 1