diff --git a/config/dot/quickshell/displays-harness.qml b/config/dot/quickshell/displays-harness.qml index b1dd0c1..5f1dbf5 100644 --- a/config/dot/quickshell/displays-harness.qml +++ b/config/dot/quickshell/displays-harness.qml @@ -39,6 +39,49 @@ ShellRoot { return Displays.apply(monitor.name, mode, scale, monitor.transform); } + function transactionStatus(): string { + return JSON.stringify({ + layout: Displays.currentLayout(), + pending: Displays.pendingRequestedLayout, + previous: Displays.pendingPreviousLayout, + reverting: Displays.revertExpectedLayout, + awaiting: Displays.awaitingConfirmation, + canConfirm: Displays.canConfirm, + busy: Displays.busy, + generation: Displays.operationGeneration, + revertGeneration: Displays.revertGeneration, + lastError: Displays.lastError + }); + } + + function applyLayoutFixture(secondX: int, secondY: int): bool { + const layout = Displays.currentLayout(); + if (layout.length !== 2) return false; + layout[0].x = 0; + layout[0].y = 0; + layout[0].primary = true; + layout[1].x = secondX; + layout[1].y = secondY; + layout[1].primary = false; + return Displays.applyLayout(layout); + } + + function makePrimaryFixture(output: string): bool { + return Displays.makePrimary(output); + } + + function injectReadback(text: string, generation: int): void { + Displays.parse(text, generation); + } + + function expireApplyVerification(): void { + Displays.verificationTimedOut(); + } + + function expireRevertVerification(): void { + Displays.revertVerificationTimedOut(); + } + function refreshIdentityFixture(): string { const modes = Displays.normaliseModes([ "1920x1080@59.94Hz", diff --git a/config/dot/quickshell/services/Displays.qml b/config/dot/quickshell/services/Displays.qml index dc7b9f4..d5f2f51 100644 --- a/config/dot/quickshell/services/Displays.qml +++ b/config/dot/quickshell/services/Displays.qml @@ -21,6 +21,7 @@ import Quickshell import Quickshell.Io import QtQuick import qs.config +import "DisplayLayout.js" as DisplayLayout Singleton { id: root @@ -31,12 +32,11 @@ Singleton { property string lastError: "" // Set while a change is applied but not yet confirmed. - property string pendingOutput: "" - property var pendingPrevious: null - property var pendingRequested: null + property var pendingPreviousLayout: null + property var pendingRequestedLayout: null property bool pendingVerified: false property bool revertQueued: false - property var revertExpected: null + property var revertExpectedLayout: null property string revertReason: "" property bool revertVerificationActive: false property int operationGeneration: 0 @@ -44,12 +44,12 @@ Singleton { property bool externalChangeBlocked: false property int secondsLeft: 0 - readonly property bool awaitingConfirmation: root.pendingOutput !== "" + readonly property bool awaitingConfirmation: root.pendingRequestedLayout !== null readonly property bool canConfirm: root.awaitingConfirmation && root.pendingVerified && !root.busy readonly property bool busy: query.running || applyRun.running || revertRun.running - || root.revertExpected !== null + || root.revertExpectedLayout !== null readonly property int confirmSeconds: 15 @@ -163,25 +163,26 @@ Singleton { modes: modes }; }); - if (root.awaitingConfirmation && root.pendingRequested - && root.matchesRequest(root.monitorNamed(root.pendingOutput), root.pendingRequested)) { + if (root.awaitingConfirmation && root.pendingRequestedLayout + && generation === root.operationGeneration + && root.matchesLayout(root.monitors, root.pendingRequestedLayout)) { root.pendingVerified = true; verifyTimer.stop(); root.lastError = ""; } else if (root.revertVerificationActive && generation === root.revertGeneration - && root.revertExpected - && root.matchesRequest(root.monitorNamed(root.revertExpected.output), root.revertExpected)) { + && root.revertExpectedLayout + && root.matchesLayout(root.monitors, root.revertExpectedLayout)) { revertVerifyTimer.stop(); root.revertVerificationActive = false; root.revertGeneration = -1; - root.revertExpected = null; + root.revertExpectedLayout = null; if (root.revertReason === "") root.lastError = ""; else root.lastError = root.revertReason; root.revertReason = ""; - } else if (!root.awaitingConfirmation && !root.revertExpected && ( + } else if (!root.awaitingConfirmation && !root.revertExpectedLayout && ( root.lastError === "Could not read the connected displays." || root.lastError === "The display list could not be read.")) { root.lastError = ""; @@ -274,16 +275,41 @@ Singleton { choices[0]); } - function matchesRequest(monitor: var, requested: var): bool { - if (!monitor || !requested || monitor.name !== requested.output) + function currentLayout(): var { + return root.monitors.map(monitor => ({ + name: monitor.name, + width: monitor.width, + height: monitor.height, + refreshRate: monitor.refreshRate, + mode: monitor.mode, + scale: monitor.scale, + transform: monitor.transform, + x: monitor.x, + y: monitor.y, + primary: monitor.primary === true + })); + } + + function matchesLayout(monitors: var, layout: var): bool { + if (!Array.isArray(monitors) || !Array.isArray(layout) + || monitors.length !== layout.length) return false; - const parts = root.modeParts(requested.mode); - return !!parts - && monitor.width === parts.width - && monitor.height === parts.height - && Math.abs(monitor.refreshRate - parts.refresh) < 0.01 - && Math.abs(monitor.scale - requested.scale) < 0.001 - && monitor.transform === requested.transform; + const expected = Array.from(layout).sort((a, b) => a.name.localeCompare(b.name)); + const actual = Array.from(monitors).sort((a, b) => a.name.localeCompare(b.name)); + for (let index = 0; index < expected.length; index++) { + const requested = expected[index]; + const monitor = actual[index]; + const parts = root.modeParts(requested.mode); + if (!parts || monitor.name !== requested.name + || monitor.width !== parts.width + || monitor.height !== parts.height + || Math.abs(monitor.refreshRate - parts.refresh) >= 0.01 + || Math.abs(monitor.scale - requested.scale) >= 0.001 + || monitor.transform !== requested.transform + || monitor.x !== requested.x || monitor.y !== requested.y) + return false; + } + return true; } function modeIsCurrent(monitor: var, candidate: var): bool { @@ -293,9 +319,47 @@ Singleton { && Math.abs(monitor.refreshRate - candidate.refresh) < 0.01; } - // Applies immediately and starts the countdown. Nothing is stored yet: the - // settings file is only written by confirm(). + function validRequestedLayout(layout: var): bool { + if (!DisplayLayout.validate(layout) || layout.length !== root.monitors.length) + return false; + const currentNames = root.monitors.map(monitor => monitor.name).sort(); + const requestedNames = layout.map(record => record.name).sort(); + if (JSON.stringify(currentNames) !== JSON.stringify(requestedNames)) + return false; + return layout.every(record => { + const monitor = root.monitorNamed(record.name); + const parts = root.modeParts(record.mode); + return !!monitor && !!parts + && record.width === parts.width && record.height === parts.height + && monitor.modes.some(candidate => candidate.mode === record.mode) + && root.isScaleClean(record.mode, record.scale) + && root.transforms.some(candidate => candidate.value === record.transform); + }); + } + + // One-field controls remain callers of the complete-layout transaction. + // Their edit is cloned into the current layout so every output's position + // participates in apply, verification, and rollback. function apply(output: string, mode: string, scale: real, transform: int): bool { + const layout = root.currentLayout(); + const record = layout.find(candidate => candidate.name === output); + const parts = root.modeParts(mode); + if (!record || !parts) { + root.lastError = record ? "That display does not offer that mode." : "That display is not connected."; + return false; + } + record.mode = mode; + record.width = parts.width; + record.height = parts.height; + record.refreshRate = parts.refresh; + record.scale = scale; + record.transform = transform; + return root.applyLayout(layout); + } + + // Applies immediately and starts the countdown. Nothing is stored yet: the + // complete connected layout is only written by confirm(). + function applyLayout(layout: var): bool { if (root.externalChangeBlocked) { root.lastError = "Wait for Settings to finish restoring before changing a display."; return false; @@ -308,58 +372,46 @@ Singleton { root.lastError = "Finish the current display change first."; return false; } - const monitor = root.monitorNamed(output); - if (!monitor) { - root.lastError = "That display is not connected."; - return false; - } - if (!monitor.modes.some(candidate => candidate.mode === mode)) { - root.lastError = "That display does not offer that mode."; - return false; - } - if (!root.isScaleClean(mode, scale)) { - root.lastError = "That scale does not divide this resolution cleanly."; - return false; - } - if (!root.transforms.some(candidate => candidate.value === transform)) { - root.lastError = "That rotation is not one Panama offers."; + const normalized = DisplayLayout.normalize(layout); + if (!root.validRequestedLayout(normalized)) { + root.lastError = "That complete display layout is not valid for the connected displays."; return false; } - root.pendingPrevious = { - output: output, - mode: monitor.mode, - scale: monitor.scale, - transform: monitor.transform - }; + root.pendingPreviousLayout = root.currentLayout(); root.operationGeneration++; - root.pendingRequested = { - output: output, - mode: mode, - scale: scale, - transform: transform - }; - root.pendingOutput = output; + root.pendingRequestedLayout = normalized; root.pendingVerified = false; root.revertQueued = false; root.secondsLeft = root.confirmSeconds; root.lastError = ""; countdown.restart(); - root.push(output, mode, scale, transform); + root.pushLayout(normalized, applyRun); return true; } - function push(output: string, mode: string, scale: real, transform: int): void { - // Values are validated above and the output name comes from the - // compositor's own list, so nothing user-authored reaches the payload. - applyRun.exec(["hyprctl", "eval", - `hl.monitor({ output = "${output}", mode = "${mode}", scale = ${scale}, transform = ${transform} })`]); + function makePrimary(output: string): bool { + const layout = root.currentLayout(); + if (!layout.some(record => record.name === output)) { + root.lastError = "That display is not connected."; + return false; + } + for (const record of layout) + record.primary = record.name === output; + return root.applyLayout(DisplayLayout.normalize(layout)); + } + + function pushLayout(layout: var, runner: var): void { + const payload = layout.map(record => + `hl.monitor({ output = "${record.name}", mode = "${record.mode}", position = "${record.x}x${record.y}", scale = ${record.scale}, transform = ${record.transform} })` + ).join("; "); + runner.exec(["hyprctl", "eval", payload]); } function confirm(): bool { - if (!root.canConfirm || !root.matchesRequest( - root.monitorNamed(root.pendingOutput), root.pendingRequested)) { + if (!root.canConfirm + || !root.matchesLayout(root.monitors, root.pendingRequestedLayout)) { if (root.awaitingConfirmation) root.lastError = "Wait for the display to finish applying before keeping it."; return false; @@ -367,11 +419,16 @@ Singleton { const stored = DesktopPreferences.get("displays"); const next = Object.assign({}, (stored && typeof stored === "object") ? stored : {}); - next[root.pendingOutput] = { - mode: root.pendingRequested.mode, - scale: root.pendingRequested.scale, - transform: root.pendingRequested.transform - }; + for (const record of root.pendingRequestedLayout) { + next[record.name] = { + mode: record.mode, + scale: record.scale, + transform: record.transform, + x: record.x, + y: record.y, + primary: record.primary + }; + } if (!DesktopPreferences.set("displays", next)) { root.lastError = "That display setting could not be saved. Revert it and try again."; return false; @@ -385,9 +442,8 @@ Singleton { function clearPending(): void { countdown.stop(); verifyTimer.stop(); - root.pendingOutput = ""; - root.pendingPrevious = null; - root.pendingRequested = null; + root.pendingPreviousLayout = null; + root.pendingRequestedLayout = null; root.pendingVerified = false; root.revertQueued = false; root.secondsLeft = 0; @@ -414,16 +470,25 @@ Singleton { } function performRevert(): void { - const previous = root.pendingPrevious; + const connected = {}; + for (const monitor of root.monitors) + connected[monitor.name] = true; + const previous = (root.pendingPreviousLayout || []) + .filter(record => connected[record.name]) + .map(record => Object.assign({}, record)); + if (previous.length > 0 && !previous.some(record => record.primary)) { + const origin = previous.find(record => record.x === 0 && record.y === 0); + (origin || previous[0]).primary = true; + } root.operationGeneration++; root.revertGeneration = root.operationGeneration; - root.revertExpected = previous; + root.revertExpectedLayout = previous.length > 0 ? previous : null; root.revertVerificationActive = false; root.clearPending(); - if (previous) { - revertRun.exec(["hyprctl", "eval", - `hl.monitor({ output = "${previous.output}", mode = "${previous.mode}", scale = ${previous.scale}, transform = ${previous.transform} })`]); - } + if (previous.length > 0) + root.pushLayout(previous, revertRun); + else + root.lastError = root.revertReason; } // Clears any stored override for an output so it returns to the value @@ -442,6 +507,19 @@ Singleton { return !!(stored && typeof stored === "object" && stored[output] !== undefined); } + function verificationTimedOut(): void { + root.revertWithMessage("The display did not apply that setting, so Panama restored the previous one."); + } + + function revertVerificationTimedOut(): void { + revertVerifyTimer.stop(); + root.revertVerificationActive = false; + root.revertGeneration = -1; + root.revertExpectedLayout = null; + root.revertReason = ""; + root.lastError = "The previous display setting could not be verified. Open Displays and restore it manually."; + } + Timer { id: verifyTimer property int attempts: 0 @@ -451,7 +529,7 @@ Singleton { onTriggered: { ticks++; if (ticks > 50) { - root.revertWithMessage("The display did not apply that setting, so Panama restored the previous one."); + root.verificationTimedOut(); return; } if (root.refresh()) @@ -468,12 +546,7 @@ Singleton { onTriggered: { ticks++; if (ticks > 50) { - stop(); - root.revertVerificationActive = false; - root.revertGeneration = -1; - root.revertExpected = null; - root.revertReason = ""; - root.lastError = "The previous display setting could not be verified. Open Displays and restore it manually."; + root.revertVerificationTimedOut(); return; } if (root.refresh()) diff --git a/tests/quickshell/display-transaction-contract.sh b/tests/quickshell/display-transaction-contract.sh new file mode 100755 index 0000000..5b181da --- /dev/null +++ b/tests/quickshell/display-transaction-contract.sh @@ -0,0 +1,264 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +service="$repo_dir/config/dot/quickshell/services/Displays.qml" +harness="$repo_dir/config/dot/quickshell/displays-harness.qml" + +fail() { + printf 'display transaction contract: %s\n' "$1" >&2 + exit 1 +} + +for contract in \ + 'property var pendingPreviousLayout: null' \ + 'property var pendingRequestedLayout: null' \ + 'property var revertExpectedLayout: null' \ + 'function currentLayout()' \ + 'function applyLayout(layout:' \ + 'function makePrimary(output:' \ + 'function matchesLayout(monitors:' \ + 'function pushLayout(layout:'; do + rg -Fq "$contract" "$service" \ + || fail "complete-layout service boundary is missing: $contract" +done + +rg -Fq 'position = "${record.x}x${record.y}"' "$service" \ + || fail 'the compositor payload does not include explicit positions' +rg -Fq 'generation === root.operationGeneration' "$service" \ + || fail 'stale monitor readback can settle a newer transaction' +rg -Fq 'function applyLayoutFixture(' "$harness" \ + || fail 'the fixture cannot exercise complete layout transactions' +rg -Fq 'function injectReadback(' "$harness" \ + || fail 'the fixture cannot prove stale readback isolation' + +fixture="$(mktemp -d /tmp/panama-display-transaction.XXXXXX)" +state_home="$fixture/state-home" +config_home="$fixture/config-home" +fake_bin="$fixture/bin" +monitor_state="$fixture/monitors.json" +eval_log="$fixture/eval.log" +harness_pid="" +mkdir -p "$state_home" "$config_home" "$fake_bin" + +cat >"$monitor_state" <<'JSON' +[ + { + "name":"DP-2","description":"Primary fixture","width":4500,"height":3000, + "refreshRate":60,"scale":1.5,"transform":0,"x":0,"y":0, + "availableModes":["4500x3000@60.00Hz"] + }, + { + "name":"HDMI-A-1","description":"Second fixture","width":2560,"height":1440, + "refreshRate":60,"scale":1,"transform":0,"x":3000,"y":0, + "availableModes":["2560x1440@60.00Hz"] + } +] +JSON + +cat >"$fake_bin/hyprctl" <<'SH' +#!/usr/bin/env bash +set -euo pipefail + +fixture="${PANAMA_DISPLAY_FIXTURE:?}" +if [[ "${1:-}" == "-j" && "${2:-}" == "monitors" ]]; then + cat "$fixture/monitors.json" + exit 0 +fi +if [[ "${1:-}" != "eval" ]]; then + exit 2 +fi + +payload="${2:-}" +printf '%s\n' "$payload" >>"$fixture/eval.log" +if [[ -f "$fixture/fail-once" ]]; then + rm -f "$fixture/fail-once" + exit 1 +fi +[[ -f "$fixture/no-apply" ]] && exit 0 + +python3 - "$fixture/monitors.json" "$payload" "$fixture/wrong-y" <<'PY' +import json +import pathlib +import re +import sys + +state_path = pathlib.Path(sys.argv[1]) +payload = sys.argv[2] +wrong_y = pathlib.Path(sys.argv[3]).exists() +monitors = json.loads(state_path.read_text(encoding="utf-8")) +by_name = {monitor["name"]: monitor for monitor in monitors} + +for block in re.findall(r"hl\.monitor\(\{([^}]*)\}\)", payload): + def field(pattern: str) -> str: + match = re.search(pattern, block) + if not match: + raise SystemExit(f"missing field {pattern}: {block}") + return match.group(1) + + name = field(r'output\s*=\s*"([A-Za-z0-9_.-]+)"') + mode = field(r'mode\s*=\s*"(\d+x\d+@\d+(?:\.\d+)?)"') + position = re.search(r'position\s*=\s*"(-?\d+)x(-?\d+)"', block) + if not position or name not in by_name: + raise SystemExit(f"bad output or position: {block}") + width, height, refresh = re.match(r"(\d+)x(\d+)@(\d+(?:\.\d+)?)", mode).groups() + monitor = by_name[name] + monitor.update({ + "width": int(width), + "height": int(height), + "refreshRate": float(refresh), + "scale": float(field(r"scale\s*=\s*([0-9.]+)")), + "transform": int(field(r"transform\s*=\s*(\d+)")), + "x": int(position.group(1)), + "y": int(position.group(2)), + }) + if wrong_y and name == "HDMI-A-1": + monitor["y"] += 1 + +state_path.write_text(json.dumps(monitors), encoding="utf-8") +PY +SH +chmod +x "$fake_bin/hyprctl" + +export PANAMA_DISPLAY_FIXTURE="$fixture" + +run() { + PATH="$fake_bin:$PATH" XDG_STATE_HOME="$state_home" XDG_CONFIG_HOME="$config_home" \ + qs -p "$harness" "$@" +} + +transaction_status() { + run ipc --pid "$harness_pid" call displays-test transactionStatus +} + +cleanup() { + [[ "$harness_pid" =~ ^[0-9]+$ ]] && kill "$harness_pid" 2>/dev/null || true + rm -rf "$fixture" +} +trap cleanup EXIT + +PATH="$fake_bin:$PATH" XDG_STATE_HOME="$state_home" XDG_CONFIG_HOME="$config_home" \ + qs -p "$harness" --daemonize >/dev/null +for _ in $(seq 1 60); do + harness_pid="$(qs list --all 2>/dev/null | awk -v expected="$harness" ' + /^Instance / {pid=""} /^[[:space:]]*Process ID:/ {pid=$3} + /^[[:space:]]*Config path:/ {path=$0; sub(/^[[:space:]]*Config path: /,"",path); if(path==expected) print pid}' | head -1)" + if [[ "$harness_pid" =~ ^[0-9]+$ ]]; then + ready="$(transaction_status 2>/dev/null || true)" + jq -e '.layout | length == 2' <<<"$ready" >/dev/null 2>&1 && break + fi + sleep 0.1 +done +[[ "$harness_pid" =~ ^[0-9]+$ ]] || fail 'fixture shell did not start' + +wait_for() { + local expression="$1" + local value="" + for _ in $(seq 1 80); do + value="$(transaction_status)" + jq -e "$expression" <<<"$value" >/dev/null && { printf '%s' "$value"; return 0; } + sleep 0.1 + done + fail "timed out waiting for $expression: $value" +} + +# A complete request is one evaluator call carrying every connected output. +[[ "$(run ipc --pid "$harness_pid" call displays-test applyLayoutFixture 3000 100)" == "true" ]] \ + || fail 'valid complete layout was refused' +wait_for '.canConfirm == true' >/dev/null +first_payload="$(sed -n '1p' "$eval_log")" +[[ "$(rg -o 'hl\.monitor' <<<"$first_payload" | wc -l)" == "2" ]] \ + || fail "layout was not sent as one complete payload: $first_payload" +rg -Fq 'output = "DP-2"' <<<"$first_payload" \ + && rg -Fq 'position = "0x0"' <<<"$first_payload" \ + && rg -Fq 'output = "HDMI-A-1"' <<<"$first_payload" \ + && rg -Fq 'position = "3000x100"' <<<"$first_payload" \ + || fail "layout payload omitted a literal output position: $first_payload" + +[[ "$(run ipc --pid "$harness_pid" call displays-test confirmChange)" == "true" ]] \ + || fail 'verified complete layout could not be kept' +store="$config_home/panama/settings.json" +jq -e '.displays | length == 2 + and .["DP-2"].x == 0 and .["DP-2"].y == 0 and .["DP-2"].primary == true + and .["HDMI-A-1"].x == 3000 and .["HDMI-A-1"].y == 100 + and .["HDMI-A-1"].primary == false' "$store" >/dev/null \ + || fail 'confirmation did not persist one complete layout with one primary' + +# Wrong readback never enables Keep; explicit revert restores both outputs. +touch "$fixture/wrong-y" +[[ "$(run ipc --pid "$harness_pid" call displays-test applyLayoutFixture 3000 200)" == "true" ]] \ + || fail 'wrong-readback fixture could not start' +wait_for '.busy == false and .awaiting == true' >/dev/null +jq -e '.canConfirm == false' <<<"$(transaction_status)" >/dev/null \ + || fail 'Keep enabled while one output had the wrong y coordinate' +rm -f "$fixture/wrong-y" +run ipc --pid "$harness_pid" call displays-test revertChange >/dev/null +wait_for '.busy == false and .awaiting == false' >/dev/null +jq -e '.[0].x == 0 and .[0].y == 0 and .[1].x == 3000 and .[1].y == 100' \ + "$monitor_state" >/dev/null || fail 'explicit revert did not restore the complete previous layout' + +# A stale pre-operation query may update visible data, but cannot settle the +# current generation or make Keep available. +touch "$fixture/no-apply" +[[ "$(run ipc --pid "$harness_pid" call displays-test applyLayoutFixture 3000 250)" == "true" ]] \ + || fail 'stale-generation fixture could not start' +wait_for '.busy == false and .awaiting == true' >/dev/null +generation="$(transaction_status | jq -r .generation)" +stale_json="$(jq '.[1].y = 250' "$monitor_state")" +run ipc --pid "$harness_pid" call displays-test injectReadback "$stale_json" "$((generation - 1))" >/dev/null +jq -e '.canConfirm == false' <<<"$(transaction_status)" >/dev/null \ + || fail 'stale readback confirmed a newer operation' +rm -f "$fixture/no-apply" +run ipc --pid "$harness_pid" call displays-test expireApplyVerification >/dev/null +wait_for '.busy == false and .awaiting == false' >/dev/null + +# A non-zero evaluator exit follows the same whole-layout recovery path. +touch "$fixture/fail-once" +[[ "$(run ipc --pid "$harness_pid" call displays-test applyLayoutFixture 3000 300)" == "true" ]] \ + || fail 'failed-evaluator fixture could not start' +failed_state="$(wait_for '.busy == false and .awaiting == false')" +jq -e '.lastError | contains("rejected")' <<<"$failed_state" >/dev/null \ + || fail "failed apply did not retain a useful recovery message: $failed_state" + +# If an output disconnects while a change is pending, rollback sends one +# transaction containing every output that is still connected. +[[ "$(run ipc --pid "$harness_pid" call displays-test applyLayoutFixture 3000 320)" == "true" ]] \ + || fail 'disconnect fixture could not start' +wait_for '.canConfirm == true' >/dev/null +jq '.[0:1]' "$monitor_state" >"$fixture/connected.json" +mv "$fixture/connected.json" "$monitor_state" +run ipc --pid "$harness_pid" call displays-test refresh >/dev/null +wait_for '.layout | length == 1' >/dev/null +run ipc --pid "$harness_pid" call displays-test revertChange >/dev/null +wait_for '.busy == false and .awaiting == false' >/dev/null +disconnect_payload="$(tail -1 "$eval_log")" +[[ "$(rg -o 'hl\.monitor' <<<"$disconnect_payload" | wc -l)" == "1" ]] \ + && ! rg -Fq 'HDMI-A-1' <<<"$disconnect_payload" \ + || fail "disconnect rollback targeted an absent output: $disconnect_payload" + +# Restore the second fixture output without touching the real compositor. +jq '. + [{ + "name":"HDMI-A-1","description":"Second fixture","width":2560,"height":1440, + "refreshRate":60,"scale":1,"transform":0,"x":3000,"y":100, + "availableModes":["2560x1440@60.00Hz"] +}]' "$monitor_state" >"$fixture/reconnected.json" +mv "$fixture/reconnected.json" "$monitor_state" +run ipc --pid "$harness_pid" call displays-test refresh >/dev/null +wait_for '.layout | length == 2' >/dev/null + +# A revert that exits zero but reads back wrong remains an explicit manual +# recovery error rather than pretending the desktop was restored. +[[ "$(run ipc --pid "$harness_pid" call displays-test applyLayoutFixture 3000 400)" == "true" ]] \ + || fail 'bad-revert fixture could not start' +wait_for '.canConfirm == true' >/dev/null +touch "$fixture/wrong-y" +run ipc --pid "$harness_pid" call displays-test revertChange >/dev/null +wait_for '.reverting != null' >/dev/null +run ipc --pid "$harness_pid" call displays-test expireRevertVerification >/dev/null +manual_state="$(transaction_status)" +jq -e '.busy == false and (.lastError | contains("restore it manually"))' \ + <<<"$manual_state" >/dev/null \ + || fail "wrong revert readback was reported as restored: $manual_state" + +printf 'display transaction contract: PASS\n' diff --git a/tests/quickshell/displays-contract.sh b/tests/quickshell/displays-contract.sh index ce120a4..46be04c 100755 --- a/tests/quickshell/displays-contract.sh +++ b/tests/quickshell/displays-contract.sh @@ -35,12 +35,12 @@ fail() { # Keep is unavailable until compositor readback exactly matches the request. for contract in \ - 'property var pendingRequested:' \ - 'property var revertExpected:' \ + 'property var pendingRequestedLayout:' \ + 'property var revertExpectedLayout:' \ 'property bool revertVerificationActive:' \ 'property int revertGeneration:' \ 'readonly property bool canConfirm:' \ - 'function matchesRequest(' \ + 'function matchesLayout(' \ 'function scalesForMode(' \ 'function isScaleClean(' \ 'x: Number.isInteger(monitor.x)' \