From a0c13c1dc6a49b16fa1e9f2a104d587129554ace Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Mon, 19 Oct 2026 09:34:29 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=A0=E5=BF=AB=E9=97=A8=E7=A6=81=EF=BC=9A?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=B5=8B=E8=AF=95=E7=AA=97=E5=8F=A3=E3=80=81?= =?UTF-8?q?=E9=87=8A=E6=94=BE=E6=A1=A9=E7=BA=BF=E7=A8=8B=E5=B9=B6=E5=B9=B6?= =?UTF-8?q?=E8=A1=8C=E8=BF=90=E8=A1=8C=E4=B8=89=E4=B8=AA=E9=98=B6=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 关闭后未删除的测试窗口让每次主题切换重排全部残留控件,ui_check 每次关窗还要等桩线程 5 秒。Linux 上结论不变,test_ui_smoke 75→64 s、ui_check 52→16 s;Windows 门禁结果见 .scratch/gate-speed/。 --- README.md | 5 +- apps/studio/tests/conftest.py | 10 +++ apps/studio/tests/test_ui_smoke.py | 4 +- apps/studio/tools/ui_check.py | 60 ++++++++------ docs/test-execution-plan.zh.md | 2 +- tools/gate.ps1 | 123 ++++++++++++++++++++++++----- 6 files changed, 160 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 4c6a978..dc7a0fd 100644 --- a/README.md +++ b/README.md @@ -118,8 +118,9 @@ is meant to be unskippable: powershell -NoProfile -ExecutionPolicy Bypass -File tools\gate.ps1 ``` -It runs `tools/lint_gate.py` over the changed files, the full studio suite, the full -shared suite, and `ui_check.py`; writes each one's output to +It runs `tools/lint_gate.py` over the changed files, then the full studio suite, the full +shared suite, and `ui_check.py` at the same time (`-Serial` runs those three one after +another with live output, for reading a failing stage); writes each one's output to `.scratch/gate/gate-/`; and holds the run to three contracts — an **empty** `tools/known_failures.txt`, the skip ceilings and collection floors in `tools/skip_budget.txt`, and zero findings anywhere. It exits 0 only when all of diff --git a/apps/studio/tests/conftest.py b/apps/studio/tests/conftest.py index b9e248d..94347f6 100644 --- a/apps/studio/tests/conftest.py +++ b/apps/studio/tests/conftest.py @@ -97,6 +97,8 @@ def make_window(qt_app, tmp_path, monkeypatch): yield _make + from PySide6.QtCore import QCoreApplication, QEvent + for window in created: try: window.close() @@ -104,6 +106,14 @@ def make_window(qt_app, tmp_path, monkeypatch): # Reported rather than swallowed: a window that cannot close is a Qt lifetime # problem worth seeing, but it must not turn a green test red. logging.getLogger(__name__).debug("could not close a test window", exc_info=True) + # ``close()`` only hides a window: something in the window keeps it referenced, + # so it stayed alive for the rest of the session. Every leaked window's ~600 + # widgets are then re-polished by each later ``app.setStyleSheet`` — measured on + # Linux, one theme switch takes 0.07 s with one window and 0.85 s after ten closed + # ones, and three theme tests took 76 s of a 231 s Windows suite. Delete it. + window.deleteLater() + created.clear() + QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete) @pytest.fixture diff --git a/apps/studio/tests/test_ui_smoke.py b/apps/studio/tests/test_ui_smoke.py index 6016f23..06f4991 100644 --- a/apps/studio/tests/test_ui_smoke.py +++ b/apps/studio/tests/test_ui_smoke.py @@ -1048,7 +1048,9 @@ def test_the_domain_boundary_is_latched_for_the_session(make_window): "the previous session's island survived into a new run" ) window._run_timer.stop() - window._stop_run(reason="user_stop") + # Release the stand-in worker as well: the second run re-armed its hang, and a + # window closed on a hanging worker waits out its 5 s join in teardown. + _finish(window, stuck) # ------------------------------------------------------------- experiment record diff --git a/apps/studio/tools/ui_check.py b/apps/studio/tools/ui_check.py index 44b6621..25366a4 100644 --- a/apps/studio/tools/ui_check.py +++ b/apps/studio/tools/ui_check.py @@ -414,6 +414,22 @@ DECORATIVE_PAIRS: tuple[tuple[str, tuple[str, ...]], ...] = ( ) +def _close_window(window) -> None: + """Close a check window, releasing its stand-in worker first. + + A ``_StuckWorker`` hangs on purpose, and the window's shutdown path stops its + worker with ``wait=True``, which joins that thread for up to 5 s. Every scene that + closed a window on a still-hanging stub paid those 5 s (measured: 7 joins, 35 s of + a 52 s run). What those scenes check happens before the close, so the stub is let + go here; the shutdown itself is checked separately in a subprocess. + """ + for name in ("_box_worker", "_finger_worker"): + worker = getattr(window, name, None) + if isinstance(worker, _StuckWorker): + worker.release() + window.close() + + def _srgb_channel(value: int) -> float: channel = value / 255.0 if channel <= 0.03928: @@ -836,7 +852,7 @@ def main() -> int: len(window.prefs.get("splitter_sizes", [])) == 4, "splitter sizes not persisted", ) - window.close() + _close_window(window) app.processEvents() section("small window") @@ -885,7 +901,7 @@ def main() -> int: f" in {type(parent).__name__} {parent.width()}x{parent.height()}" ) check(not oversize, f"widgets overflow their parent: {oversize}") - window.close() + _close_window(window) app.processEvents() section("top area") @@ -1072,7 +1088,7 @@ def main() -> int: not window.toolbar.settings_expanded(), f"{theme}: the settings area could not be collapsed again", ) - window.close() + _close_window(window) app.processEvents() # English labels are the widest case ("Start recording", "Sensor config"), @@ -1096,7 +1112,7 @@ def main() -> int: f"the sensor summary is not stated in full: " f"{english_window.toolbar.btn_sensor.text()!r}", ) - english_window.close() + _close_window(english_window) app.processEvents() set_locale("zh_CN") @@ -1153,7 +1169,7 @@ def main() -> int: f" reset label at 1280x720, {language}: live {live_width}px, " f"replay {replay_width}px (button width {button.width()}px)" ) - measured.close() + _close_window(measured) app.processEvents() set_locale("zh_CN") @@ -1320,7 +1336,7 @@ def main() -> int: window.toolbar.set_record_checked(False) window._stop_run(reason="user_stop") stuck.release() - window.close() + _close_window(window) app.processEvents() section("stopping a hung connect") @@ -1388,7 +1404,7 @@ def main() -> int: "Esc did not cancel the hung connect immediately", ) check(not window._running, "Esc left the session running") - window.close() + _close_window(window) app.processEvents() # Let the deliberately-stuck thread finish before the interpreter tears @@ -1491,7 +1507,7 @@ def main() -> int: "the depth colour scale re-fitted per frame instead of staying fixed", ) - window.close() + _close_window(window) app.processEvents() stuck.release() @@ -1797,14 +1813,14 @@ def main() -> int: "the 2D image area does not follow the output image's aspect ratio: " f"{image.width()}x{image.height()}", ) - finger_window.close() + _close_window(finger_window) app.processEvents() check( window.toolbar.record_chip.text() == t("record.state_idle"), f"the record strip did not fall back to idle: " f"{window.toolbar.record_chip.text()!r}", ) - window.close() + _close_window(window) app.processEvents() section("chart header and unit chip") @@ -1892,7 +1908,7 @@ def main() -> int: # The locale is process-wide: leave it where the other sections expect it. header_window._set_language("zh_CN") app.processEvents() - header_window.close() + _close_window(header_window) app.processEvents() section("acquisition correctness") @@ -2156,7 +2172,7 @@ def main() -> int: pane_window.replay.close() pane_window._stop_run(reason="user_stop") stuck.release() - pane_window.close() + _close_window(pane_window) app.processEvents() _shutil.rmtree(replay_root, ignore_errors=True) @@ -2323,7 +2339,7 @@ def main() -> int: ) check(meta["stats"]["frames"] == 31, f"unexpected frame count: {meta['stats']['frames']}") - window.close() + _close_window(window) app.processEvents() stuck.release() _shutil.rmtree(scratch, ignore_errors=True) @@ -2430,7 +2446,7 @@ def main() -> int: f"the session phase is {fail_window._session_phase!r} after a failed recording", ) - fail_window.close() + _close_window(fail_window) app.processEvents() stuck_fail.release() _app_module.recordings_dir = lambda: scratch @@ -2605,7 +2621,7 @@ def main() -> int: f"a matching calibration was not requested: {stuck_identity.calibration_requests}", ) - identity_window.close() + _close_window(identity_window) app.processEvents() _shutil.rmtree(record_dir, ignore_errors=True) @@ -2657,7 +2673,7 @@ def main() -> int: units_plots.history.sample_count > 0, "the rebuilt history lost every sample", ) - units_window.close() + _close_window(units_window) units_window._box_worker = None stuck_units.release() app.processEvents() @@ -2716,7 +2732,7 @@ def main() -> int: and not any(_math.isfinite(v) for v in one_state.values[:, 1]), "FX carried a value without a calibration curve", ) - one_window.close() + _close_window(one_window) one_window._box_worker = None stuck_one.release() app.processEvents() @@ -2903,7 +2919,7 @@ def main() -> int: check(plots._source_ready, "switching units detached the curve source") check(plots._unit == "N", f"the unit switch did not reach the charts: {plots._unit}") - window.close() + _close_window(window) app.processEvents() stuck.release() @@ -2996,7 +3012,7 @@ def main() -> int: "the way back to the camera did not take effect", ) - window.close() + _close_window(window) app.processEvents() stuck.release() @@ -3141,7 +3157,7 @@ def main() -> int: pane.close_view() check(True, "closing the 3D pane twice is safe") finally: - pane_window.close() + _close_window(pane_window) app.processEvents() section("replay") @@ -3325,7 +3341,7 @@ def main() -> int: ) other_window.replay_ctl.stop_activity() app.processEvents() - other_window.close() + _close_window(other_window) app.processEvents() replay_window.activateWindow() app.processEvents() @@ -3706,7 +3722,7 @@ def main() -> int: ) replay_window.replay.close() - replay_window.close() + _close_window(replay_window) app.processEvents() _shutil.rmtree(replay_root, ignore_errors=True) diff --git a/docs/test-execution-plan.zh.md b/docs/test-execution-plan.zh.md index ef53e4a..df34ab0 100644 --- a/docs/test-execution-plan.zh.md +++ b/docs/test-execution-plan.zh.md @@ -24,7 +24,7 @@ 代理开始验证前说明“改动 → 所选测试 → 扩大理由”。定向测试通过后结束验证;没有新增改动、失败或未解决风险时,不追加全量。全量只在最终候选状态运行一次;之后仅复验新改动涉及的测试,除非新改动再次触及公共基础。 -**提交前跑一次 `tools/gate.ps1`。** 定向测试用于开发中,门禁用于“这一版可以交给别人”的那一刻:它依次跑改动文件的 lint、studio 全量、shared 全量、`ui_check.py`,并要求 `tools/known_failures.txt` 为空、跳过数与收集数在 `tools/skip_budget.txt` 之内。可选 `pre-push` 钩子会调用它(安装方式见 [README](../README.md),不自动安装)。门禁平台是 **Windows**:测试渲染真实 Qt 控件,而 [测试修复与门禁计划](test-repair-plan.zh.md) 第 3 节的 11、13 号只在 Linux 上失败,Linux 的通过不等于同一结论。 +**提交前跑一次 `tools/gate.ps1`。** 定向测试用于开发中,门禁用于“这一版可以交给别人”的那一刻:它先跑改动文件的 lint,再同时跑 studio 全量、shared 全量与 `ui_check.py`(`-Serial` 改为逐个运行并实时显示输出),并要求 `tools/known_failures.txt` 为空、跳过数与收集数在 `tools/skip_budget.txt` 之内。可选 `pre-push` 钩子会调用它(安装方式见 [README](../README.md),不自动安装)。门禁平台是 **Windows**:测试渲染真实 Qt 控件,而 [测试修复与门禁计划](test-repair-plan.zh.md) 第 3 节的 11、13 号只在 Linux 上失败,Linux 的通过不等于同一结论。 不能通过跳过失败、删除断言或把必需测试标成可选来提速。测试缺依赖、收集为空或意外跳过必须报告。间歇性错误先保留证据,查明影响后只重跑受影响范围;不循环重跑全量碰运气。 diff --git a/tools/gate.ps1 b/tools/gate.ps1 index 3ec2f08..43598c9 100644 --- a/tools/gate.ps1 +++ b/tools/gate.ps1 @@ -3,13 +3,17 @@ Merge gate for orisys-app: lint the change, run both test suites, run the UI check. .DESCRIPTION - One command that a change cannot skip. It runs, in order: + One command that a change cannot skip. It runs: 1. tools/lint_gate.py the narrow lint rule set over the changed files 2. pytest apps/studio/tests full, with the ``timing`` tests deselected 3. pytest shared/tests full, with the ``timing`` tests deselected 4. apps/studio/tools/ui_check.py the layout / theme / record-channel checks + Lint runs first; 2-4 then run at the same time (they write to separate places), so + the gate takes as long as the slowest of them. -Serial runs 2-4 one after another + with live output; the verdict is the same either way. + and then holds the run to three contracts: * tools/known_failures.txt must be empty. Any content fails the gate: a known @@ -44,6 +48,11 @@ .PARAMETER SkipLint Run only the suites and the UI check (used when diagnosing a red gate). +.PARAMETER Serial + Run the studio suite, the shared suite and ui_check one after another with live + output, instead of at the same time. The verdict is the same; use it to read a + failing stage as it runs, or when a parallel run is suspected of interfering. + .EXAMPLE powershell -NoProfile -ExecutionPolicy Bypass -File tools/gate.ps1 @@ -58,7 +67,8 @@ param( [string]$Task = 'gate', [string]$Python = '', [string]$LintBase = 'origin/main', - [switch]$SkipLint + [switch]$SkipLint, + [switch]$Serial ) $ErrorActionPreference = 'Stop' @@ -218,22 +228,21 @@ try { } # -------------------------------------------------------------- test suites - function Invoke-Suite([string]$Name, [string]$Path) { + function Get-SuiteArguments([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 @( + return @( '-m', 'pytest', $Path, '-m', 'not timing', '-p', 'no:cacheprovider', - "--basetemp=$base", - "--junitxml=$xml" - ) $log + "--basetemp=$(Join-Path $Scratch "pytest-$safe")", + "--junitxml=$(Join-Path $Scratch "$safe.xml")" + ) + } + function Read-Suite([string]$Name, [int]$Exit) { + $xml = Join-Path $Scratch "$($Name.Replace(' ', '-')).xml" $counts = [pscustomobject]@{ - Tests = 0; Failures = 0; Errors = 0; Skipped = 0; Exit = $exit; Xml = (Test-Path -LiteralPath $xml) + Tests = 0; Failures = 0; Errors = 0; Skipped = 0; Exit = $Exit; Xml = (Test-Path -LiteralPath $xml) } if ($counts.Xml) { try { @@ -252,6 +261,51 @@ try { return $counts } + # A stage started in the background: stdout and stderr go to two files, joined into + # .log when it ends, so every stage leaves the same one log as a serial run. + function Start-Stage([string]$Name, [string[]]$Arguments) { + $safe = $Name.Replace(' ', '-') + $out = Join-Path $Scratch "$safe.out.tmp" + $err = Join-Path $Scratch "$safe.err.tmp" + # Start-Process joins -ArgumentList with spaces and quotes nothing, so each + # argument is quoted here ('not timing' is one argument). + $quoted = @($Arguments | ForEach-Object { '"' + ($_ -replace '"', '\"') + '"' }) + $options = @{ + FilePath = $Python + ArgumentList = $quoted + WorkingDirectory = $Repo + RedirectStandardOutput = $out + RedirectStandardError = $err + NoNewWindow = $true + PassThru = $true + } + $began = Get-Date + $process = Start-Process @options + # Windows PowerShell 5.1 reports no ExitCode for a -PassThru process unless its + # handle was read while the process ran. + $null = $process.Handle + return [pscustomobject]@{ + Name = $Name; Process = $process; Out = $out; Err = $err; Began = $began + Log = (Join-Path $Scratch "$safe.log") + } + } + + function Complete-Stage($Stage) { + $Stage.Process.WaitForExit() + $exit = $Stage.Process.ExitCode + # No exit code is not a pass: treat it like a crash that left no answer. + if ($null -eq $exit) { $exit = -1 } + $text = New-Object System.Collections.ArrayList + foreach ($part in @($Stage.Out, $Stage.Err)) { + if (Test-Path -LiteralPath $part) { + foreach ($line in (Get-Content -LiteralPath $part -Encoding UTF8)) { [void]$text.Add($line) } + Remove-Item -LiteralPath $part -Force -ErrorAction SilentlyContinue + } + } + Set-Content -LiteralPath $Stage.Log -Value $text -Encoding UTF8 + return $exit + } + #: 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. @@ -311,18 +365,48 @@ try { exit 2 } - Write-Step 'studio suite' - $studio = Invoke-Suite 'studio suite' 'apps/studio/tests' + # -------------------------------------------------------------- suites and ui_check + # The three stages are independent processes and write to separate places (studio + # tests: apps/studio/outputs/_test_record*, shared tests: shared/tests/_scratch, + # ui_check: apps/studio/outputs/_*_check, each pytest run its own --basetemp), so by + # default they run at the same time and the gate takes as long as the slowest one. + # Child output is UTF-8: a finding with Chinese text must not crash a cp1252 console. + $env:PYTHONIOENCODING = 'utf-8' + $stages = @( + [pscustomobject]@{ Name = 'studio suite'; Args = (Get-SuiteArguments 'studio suite' 'apps/studio/tests') }, + [pscustomobject]@{ Name = 'shared suite'; Args = (Get-SuiteArguments 'shared suite' 'shared/tests') }, + [pscustomobject]@{ Name = 'ui_check'; Args = @((Join-Path $Repo 'apps\studio\tools\ui_check.py')) } + ) + $exits = @{} + $durations = @{} + if ($Serial) { + foreach ($stage in $stages) { + Write-Step $stage.Name + $log = Join-Path $Scratch "$($stage.Name.Replace(' ', '-')).log" + $began = Get-Date + $exits[$stage.Name] = Invoke-Native $Python $stage.Args $log + $durations[$stage.Name] = ((Get-Date) - $began).TotalSeconds + } + } else { + Write-Step 'studio suite, shared suite and ui_check, in parallel (-Serial for live output)' + $running = @(foreach ($stage in $stages) { Start-Stage $stage.Name $stage.Args }) + foreach ($stage in $running) { + $exits[$stage.Name] = Complete-Stage $stage + # StartTime cannot be read once a process has exited; the launch time is ours. + $durations[$stage.Name] = ($stage.Process.ExitTime - $stage.Began).TotalSeconds + Write-Host (" {0,-13} exit {1} {2:n0} s ({3})" -f $stage.Name, $exits[$stage.Name], $durations[$stage.Name], $stage.Log) + } + } + + $studio = Read-Suite 'studio suite' $exits['studio suite'] Test-Suite 'studio suite' $studio $budget 'studio_min_tests' 'studio_max_skips' - Write-Step 'shared suite' - $shared = Invoke-Suite 'shared suite' 'shared/tests' + $shared = Read-Suite 'shared suite' $exits['shared suite'] 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 + $uiExit = $exits['ui_check'] # 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. @@ -358,6 +442,9 @@ try { [void]$lines.Add("Platform : Windows") [void]$lines.Add("Python : $Python") [void]$lines.Add("LintBase : $LintBase") + $mode = if ($Serial) { 'serial' } else { 'parallel' } + $timing = ($stages | ForEach-Object { "{0} {1:n0} s" -f $_.Name, $durations[$_.Name] }) -join ', ' + [void]$lines.Add("Stages : $mode; $timing") [void]$lines.Add("Scratch : $Scratch") [void]$lines.Add('') [void]$lines.Add('| Check | Result | Detail |')