hypr/monitors.lua applies the stored per-output entries when the compositor reads its config, and never again. A monitor plugged in an hour later got the compositor's automatic placement instead of the position, scale and rotation this machine was told to use, and the only way back was to open Settings and apply it again. Docking should not cost you your desk. Deliberately not a confirmed transaction. applyLayout arms a fifteen second countdown because it is about to show you something you might not be able to undo; this restores a layout you already confirmed, on hardware you already had, and a countdown would be asking you to re-approve your own decision every time you sat down. It refuses rather than guesses when the stored mode is one the connected panel does not offer -- DP-1 on one dock is not DP-1 on another -- and when the surviving layout would name no primary. Both land on the compositor's automatic placement plus a toast that opens the Displays page, which is recoverable; silence would not be. That toast needed a new open-settings verb in StatusEvents, whose page name goes through ShellState's existing allow-list. The decision is split from the action as plannedRestore so it can be tested without driving a real compositor, and the harness sets topology and stored arrangement in one call because a real query landing between two would replace the fixture. Both fixtures travel base64: qs ipc call splits a JSON array of several objects into one argument per object, so a two-monitor fixture was arriving as an extra argument.
442 lines
20 KiB
Bash
Executable File
442 lines
20 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
# Display configuration.
|
|
#
|
|
# This is the only setting in Panama that can leave the user unable to SEE the
|
|
# screen well enough to undo it: a mode the panel cannot show, or a scale that
|
|
# makes everything unreadable, is not recoverable through the UI that caused it.
|
|
#
|
|
# So the property under test is not "can it change the resolution" but "does an
|
|
# unconfirmed change always come back". A regression here is not a broken
|
|
# feature, it is a user staring at a blank monitor.
|
|
#
|
|
# * an unconfirmed change reverts on its own, and stores nothing
|
|
# * a confirmed change is what writes to the settings store
|
|
# * a mode, scale, rotation, or output the compositor did not offer is refused
|
|
# before anything is applied
|
|
#
|
|
# The compositor is the live one -- there is no way to test this otherwise --
|
|
# but preferences are isolated, and every path restores the display it started
|
|
# from.
|
|
|
|
set -euo pipefail
|
|
|
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
harness="$repo_dir/config/dot/quickshell/displays-harness.qml"
|
|
service="$repo_dir/config/dot/quickshell/services/Displays.qml"
|
|
page="$repo_dir/config/dot/quickshell/modules/settings/DisplaysPage.qml"
|
|
settings_page="$repo_dir/config/dot/quickshell/modules/settings/SettingsPage.qml"
|
|
monitors_lua="$repo_dir/config/dot/hypr/monitors.lua"
|
|
|
|
fail() {
|
|
printf 'displays contract: %s\n' "$1" >&2
|
|
exit 1
|
|
}
|
|
|
|
# Keep is unavailable until compositor readback exactly matches the request.
|
|
for contract in \
|
|
'property var pendingRequestedLayout:' \
|
|
'property var revertExpectedLayout:' \
|
|
'property bool revertVerificationActive:' \
|
|
'property int revertGeneration:' \
|
|
'readonly property bool canConfirm:' \
|
|
'function matchesLayout(' \
|
|
'function scalesForMode(' \
|
|
'function isScaleClean(' \
|
|
'x: Number.isInteger(monitor.x)' \
|
|
'y: Number.isInteger(monitor.y)' \
|
|
'primary: monitor.name === primaryName'; do
|
|
rg -Fq "$contract" "$service" || fail "display service contract is missing: $contract"
|
|
done
|
|
rg -Fq 'enabled: Displays.canConfirm' "$page" \
|
|
|| fail 'Keep is enabled before the display change is verified'
|
|
rg -Fq 'options: Displays.scalesForMode(' "$page" \
|
|
|| fail 'scale choices are not filtered for the active resolution'
|
|
rg -Fq 'property string selectedOutput:' "$page" \
|
|
|| fail 'connected outputs cannot be selected'
|
|
# primaryFirstMonitors is monitors sorted with the primary first, so the
|
|
# selector is still populated from what is connected -- which is what this
|
|
# protects. Naming the sorted list rather than the raw one is the point: the
|
|
# picker should open on the display somebody is most likely to mean.
|
|
rg -Fq 'options: Displays.primaryFirstMonitors.map(' "$page" \
|
|
|| fail 'the output selector is not populated from connected displays'
|
|
rg -Fq 'id: revertVerifyTimer' "$service" \
|
|
|| fail 'automatic restoration has no bounded readback verification'
|
|
rg -Fq 'if (root.busy)' "$service" \
|
|
|| fail 'the display service accepts a new apply while another operation is busy'
|
|
|
|
# Stored JSON is untyped at field level, so the Lua startup consumer is the
|
|
# final validation boundary and must support every named output it accepts.
|
|
for contract in 'valid_mode' 'valid_scale' 'valid_transform' 'valid_position' 'valid_primary' 'pairs(displays)'; do
|
|
rg -Fq "$contract" "$monitors_lua" || fail "monitor startup validation is missing: $contract"
|
|
done
|
|
|
|
# SettingsPage headers are genuinely pinned outside its scrolling surface.
|
|
python3 - "$settings_page" <<'PY' || fail 'SettingsPage header is not pinned outside the Flickable'
|
|
import sys
|
|
text = open(sys.argv[1], encoding="utf-8").read()
|
|
loader = text.find("id: pinnedHeader")
|
|
flickable = text.find("id: pageScroll")
|
|
if loader < 0 or flickable < 0 or loader > flickable:
|
|
raise SystemExit(1)
|
|
PY
|
|
|
|
MONITORS_LUA="$monitors_lua" lua - <<'LUA' || fail 'monitor startup accepted invalid persisted geometry or ignored a named output'
|
|
package.preload["prefs"] = function()
|
|
return {
|
|
get = function()
|
|
return {
|
|
["DP-2"] = {
|
|
mode = "4500x3000@60", scale = 1.5, transform = 0,
|
|
x = 0, y = 0, primary = true,
|
|
},
|
|
["HDMI-A-1"] = {
|
|
mode = "2560x1440@60", scale = 1, transform = 1,
|
|
x = 3000, y = 0, primary = false,
|
|
},
|
|
["LEGACY-1"] = { mode = "1920x1080@60", scale = 1.5, transform = 0 },
|
|
["PARTIAL-1"] = {
|
|
mode = "1920x1080@60", scale = 1, transform = 0,
|
|
x = 4440,
|
|
},
|
|
["BAD-PRIMARY"] = {
|
|
mode = "1920x1080@60", scale = 1, transform = 0,
|
|
x = 4440, y = 0, primary = "yes",
|
|
},
|
|
["BAD OUTPUT"] = {
|
|
mode = "1920x1080@60", scale = 1, transform = 0,
|
|
x = 4440, y = 0, primary = false,
|
|
},
|
|
}
|
|
end,
|
|
}
|
|
end
|
|
|
|
local calls = {}
|
|
hl = { monitor = function(value) table.insert(calls, value) end }
|
|
assert(loadfile(os.getenv("MONITORS_LUA")))()
|
|
|
|
local by_output = {}
|
|
for _, value in ipairs(calls) do by_output[value.output] = value end
|
|
assert(by_output["DP-2"].mode == "4500x3000@60")
|
|
assert(by_output["DP-2"].scale == 1.5)
|
|
assert(by_output["DP-2"].transform == 0)
|
|
assert(by_output["DP-2"].position == "0x0")
|
|
-- The panel-specific color policy rides the description-matched rule, not the
|
|
-- connector: a stranger's monitor on DP-2 must not inherit the Kuycon's mode
|
|
-- or its 10-bit request.
|
|
local kuycon = by_output["desc:GVT Kuycon P20"]
|
|
assert(kuycon ~= nil)
|
|
assert(kuycon.mode == "4500x3000@60")
|
|
assert(kuycon.bitdepth == 10)
|
|
assert(kuycon.cm == "auto")
|
|
assert(by_output["HDMI-A-1"].mode == "2560x1440@60")
|
|
assert(by_output["HDMI-A-1"].scale == 1)
|
|
assert(by_output["HDMI-A-1"].transform == 1)
|
|
assert(by_output["HDMI-A-1"].position == "3000x0")
|
|
assert(by_output["LEGACY-1"].position == "auto")
|
|
assert(by_output["PARTIAL-1"] == nil)
|
|
assert(by_output["BAD-PRIMARY"] == nil)
|
|
assert(by_output["BAD OUTPUT"] == nil)
|
|
assert(by_output[""] ~= nil)
|
|
LUA
|
|
|
|
rg -Fq 'Resolution, scale, rotation, position, and primary display' \
|
|
"$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml" \
|
|
|| fail 'the display preference does not document complete layout persistence'
|
|
|
|
if [[ "${PANAMA_DISPLAYS_STATIC_ONLY:-0}" == "1" ]]; then
|
|
printf 'displays contract: PASS (static)\n'
|
|
exit 0
|
|
fi
|
|
|
|
config_home="$(mktemp -d /tmp/panama-displays-config.XXXXXX)"
|
|
|
|
run() { XDG_CONFIG_HOME="$config_home" qs -p "$harness" "$@"; }
|
|
status() { run ipc call displays-test status; }
|
|
|
|
# The shipped geometry, read from the Hyprland config rather than from the
|
|
# running compositor.
|
|
#
|
|
# Everything below captures "original" from what it observes at start, which is
|
|
# correct only if the display is already in a good state. A previous run that
|
|
# failed mid-revert leaves the display changed, and the next run then captures
|
|
# THAT as the original and faithfully restores the desktop to a broken value.
|
|
# One flake becomes permanent. So refuse to run from a state that does not match
|
|
# what the config says, rather than laundering it.
|
|
shipped_scale="$(sed -n 's/^local shipped_scale *= *\([0-9.]*\).*/\1/p' \
|
|
"$repo_dir/config/dot/hypr/monitors.lua" | head -1)"
|
|
[[ -n "$shipped_scale" ]] || fail 'could not read the shipped scale from monitors.lua -- the guard below depends on it, and skipping it silently is how a dirty baseline gets laundered'
|
|
if [[ -n "$shipped_scale" ]]; then
|
|
live_scale="$(hyprctl -j monitors | jq -r '.[0].scale')"
|
|
if ! awk -v a="$live_scale" -v b="$shipped_scale" 'BEGIN { exit !(a == b) }'; then
|
|
fail "the display is at scale $live_scale but the config ships $shipped_scale -- refusing to capture a dirty state as the baseline. Restore it first: hyprctl eval 'hl.monitor({ output = \"DP-2\", mode = \"4500x3000@60\", scale = $shipped_scale, transform = 0 })'"
|
|
fi
|
|
fi
|
|
|
|
original_mode=""
|
|
original_scale=""
|
|
original_transform=""
|
|
original_width=""
|
|
original_height=""
|
|
original_refresh=""
|
|
original_x=""
|
|
original_y=""
|
|
monitor_name=""
|
|
|
|
monitor_state() {
|
|
hyprctl -j monitors | jq -c --arg output "$monitor_name" '.[] | select(.name == $output)'
|
|
}
|
|
|
|
display_is_restored() {
|
|
local current
|
|
current="$(monitor_state)"
|
|
[[ -n "$current" ]] || return 1
|
|
jq -e \
|
|
--argjson width "$original_width" \
|
|
--argjson height "$original_height" \
|
|
--argjson refresh "$original_refresh" \
|
|
--argjson scale "$original_scale" \
|
|
--argjson transform "$original_transform" \
|
|
--argjson x "$original_x" \
|
|
--argjson y "$original_y" \
|
|
'.width == $width and .height == $height
|
|
and ((.refreshRate - $refresh) | fabs) < 0.01
|
|
and ((.scale - $scale) | fabs) < 0.001
|
|
and .transform == $transform
|
|
and .x == $x and .y == $y' <<<"$current" >/dev/null
|
|
}
|
|
|
|
restore_display() {
|
|
[[ -n "$original_mode" ]] || return 0
|
|
hyprctl eval "hl.monitor({ output = \"$monitor_name\", mode = \"$original_mode\", position = \"${original_x}x${original_y}\", scale = $original_scale, transform = $original_transform })" >/dev/null \
|
|
|| return 1
|
|
for _ in $(seq 1 50); do
|
|
display_is_restored && return 0
|
|
sleep 0.2
|
|
done
|
|
return 1
|
|
}
|
|
|
|
stop_harness() {
|
|
# Kill by PID, never `pkill -f displays-harness`: that pattern also matches
|
|
# any shell whose command line contains this script's text, which includes
|
|
# the invoking shell itself.
|
|
[[ -n "${harness_pid:-}" ]] && kill "$harness_pid" >/dev/null 2>&1 || true
|
|
rm -rf "$config_home"
|
|
}
|
|
|
|
cleanup() {
|
|
local status=$?
|
|
trap - EXIT
|
|
if ! restore_display; then
|
|
printf 'displays contract: FAILED to restore %s to %s at %sx%s scale %s transform %s\n' \
|
|
"$monitor_name" "$original_mode" "$original_x" "$original_y" \
|
|
"$original_scale" "$original_transform" >&2
|
|
status=1
|
|
fi
|
|
stop_harness
|
|
exit "$status"
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
XDG_CONFIG_HOME="$config_home" qs -p "$harness" --daemonize >/dev/null
|
|
harness_pid=""
|
|
for _ in $(seq 1 40); do
|
|
run ipc show 2>/dev/null | rg -q '^target displays-test$' && break
|
|
sleep 0.1
|
|
done
|
|
run ipc show 2>/dev/null | rg -q '^target displays-test$' || fail 'test IPC target did not start'
|
|
harness_pid="$(run list | awk '/Process ID:/ { print $3; exit }')"
|
|
|
|
refresh_fixture="$(run ipc call displays-test refreshIdentityFixture)"
|
|
jq -e '
|
|
.count == 2
|
|
and .modes == ["[email protected]", "[email protected]"]
|
|
and .selected == ["[email protected]"]
|
|
' <<<"$refresh_fixture" >/dev/null \
|
|
|| fail "59.94 Hz and 60.00 Hz lost their distinct selection identity: $refresh_fixture"
|
|
|
|
position_fixture="$(run ipc call displays-test positionFixture)"
|
|
jq -e '. == [
|
|
{"name":"DP-2","x":140,"y":80,"primary":true},
|
|
{"name":"HDMI-A-1","x":3140,"y":80,"primary":false}
|
|
]' <<<"$position_fixture" >/dev/null \
|
|
|| fail "monitor positions or primary selection were parsed incorrectly: $position_fixture"
|
|
|
|
for _ in $(seq 1 50); do
|
|
[[ "$(status | jq -r .count)" != "0" ]] && break
|
|
sleep 0.1
|
|
done
|
|
|
|
state="$(status)"
|
|
monitor_name="$(jq -r .name <<<"$state")"
|
|
[[ -n "$monitor_name" ]] || fail "no display was detected: $state"
|
|
original_mode="$(jq -r .mode <<<"$state")"
|
|
original_width="$(jq -r .width <<<"$state")"
|
|
original_height="$(jq -r .height <<<"$state")"
|
|
original_refresh="$(jq -r .refresh <<<"$state")"
|
|
original_scale="$(jq -r .scale <<<"$state")"
|
|
original_transform="$(jq -r .transform <<<"$state")"
|
|
original_x="$(jq -r .x <<<"$state")"
|
|
original_y="$(jq -r .y <<<"$state")"
|
|
|
|
[[ "$(jq -r .modes <<<"$state")" -gt 0 ]] || fail 'the display reported no usable modes'
|
|
|
|
# ── Anything the compositor did not offer is refused before applying ─────────
|
|
while IFS= read -r kind; do
|
|
[[ "$(run ipc call displays-test applyBad "$kind")" == "false" ]] \
|
|
|| fail "an invalid $kind was accepted"
|
|
[[ "$(status | jq -r .awaiting)" == "false" ]] \
|
|
|| fail "an invalid $kind left a change pending"
|
|
done <<'KINDS'
|
|
mode
|
|
scale
|
|
transform
|
|
output
|
|
dirtyScale
|
|
KINDS
|
|
|
|
# The display must not have moved for any of those.
|
|
now="$(status)"
|
|
[[ "$(jq -r .scale <<<"$now")" == "$original_scale" ]] || fail 'a refused change still altered the scale'
|
|
|
|
# An immediate Revert may race both the apply process and its first readback.
|
|
# It must queue until both are clear, then verify the original generation.
|
|
target_scale=$(awk -v s="$original_scale" 'BEGIN { print (s == 1.25) ? 1.5 : 1.25 }')
|
|
[[ "$(run ipc call displays-test applyScale "$target_scale")" == "true" ]] \
|
|
|| fail 'the immediate-revert fixture could not apply'
|
|
run ipc call displays-test revertChange >/dev/null
|
|
immediate_reverted=false
|
|
for _ in $(seq 1 60); do
|
|
if display_is_restored && [[ "$(status | jq -r .awaiting)" == "false" ]]; then
|
|
immediate_reverted=true
|
|
break
|
|
fi
|
|
sleep 0.2
|
|
done
|
|
[[ "$immediate_reverted" == true ]] \
|
|
|| fail 'an immediate Revert raced the apply/readback and did not restore the display'
|
|
|
|
# ── An unconfirmed change reverts on its own and stores nothing ──────────────
|
|
[[ "$(run ipc call displays-test applyScale "$target_scale")" == "true" ]] \
|
|
|| fail 'a valid scale change was refused'
|
|
|
|
applied=false
|
|
for _ in $(seq 1 30); do
|
|
[[ "$(monitor_state | jq -r '.scale')" == "$target_scale" ]] && { applied=true; break; }
|
|
sleep 0.2
|
|
done
|
|
[[ "$applied" == true ]] || fail 'the scale change never reached the compositor'
|
|
[[ "$(status | jq -r .awaiting)" == "true" ]] || fail 'an applied change is not awaiting confirmation'
|
|
[[ "$(status | jq -r .canConfirm)" == "true" ]] || fail 'an applied change was never verified by compositor readback'
|
|
|
|
# Wait out the countdown. This is the whole point of the contract.
|
|
reverted=false
|
|
for _ in $(seq 1 120); do
|
|
if [[ "$(monitor_state | jq -r '.scale')" == "$original_scale" ]]; then
|
|
reverted=true
|
|
break
|
|
fi
|
|
sleep 0.5
|
|
done
|
|
[[ "$reverted" == true ]] || fail 'an unconfirmed change did NOT revert -- this would strand a user on an unreadable display'
|
|
[[ "$(status | jq -r .awaiting)" == "false" ]] || fail 'the pending state survived the revert'
|
|
[[ "$(status | jq -r .overridden)" == "false" ]] || fail 'an unconfirmed change was written to the settings store'
|
|
|
|
# ── A confirmed change is what writes ────────────────────────────────────────
|
|
[[ "$(run ipc call displays-test applyScale "$target_scale")" == "true" ]] \
|
|
|| fail 'the confirmed-change fixture could not apply'
|
|
|
|
# Hyprland can apply and read back a change faster than two IPC round trips, so
|
|
# this live test cannot reliably observe the pre-readback state. The
|
|
# fake-compositor contract deterministically holds that boundary open and
|
|
# proves Keep refuses it; this path proves a real readback eventually enables
|
|
# and persists Keep on the physical display.
|
|
verified=false
|
|
for _ in $(seq 1 30); do
|
|
[[ "$(status | jq -r .canConfirm)" == "true" ]] && { verified=true; break; }
|
|
sleep 0.2
|
|
done
|
|
[[ "$verified" == true ]] || fail 'the confirmed change never became safe to keep'
|
|
[[ "$(run ipc call displays-test confirmChange)" == "true" ]] \
|
|
|| fail 'Keep refused a verified display change'
|
|
sleep 0.6
|
|
[[ "$(status | jq -r .awaiting)" == "false" ]] || fail 'confirming did not clear the pending state'
|
|
[[ "$(status | jq -r .overridden)" == "true" ]] || fail 'confirming did not store the change'
|
|
|
|
store="$config_home/panama/settings.json"
|
|
jq -e --arg m "$monitor_name" '.displays[$m].scale != null' "$store" >/dev/null \
|
|
|| fail 'the confirmed change is not in the settings store'
|
|
|
|
# ── Forgetting clears it ─────────────────────────────────────────────────────
|
|
run ipc call displays-test forget >/dev/null
|
|
sleep 0.6
|
|
[[ "$(status | jq -r .overridden)" == "false" ]] || fail 'forget did not clear the stored display setting'
|
|
|
|
# ── Restoring an arrangement when a display comes back ───────────────────────
|
|
#
|
|
# hypr/monitors.lua applies stored per-output entries when the compositor reads
|
|
# its config, and never again. A monitor plugged in an hour later used to get
|
|
# the compositor's automatic placement instead of the arrangement this machine
|
|
# was told to use, and the only way back was to open Settings and apply it
|
|
# again. Docking should not cost you your desk.
|
|
#
|
|
# Driven through plannedRestore, which is the decision with no side effects --
|
|
# applying a real layout here would drive the live compositor.
|
|
#
|
|
# Three cases, and the last two matter most: a wrong answer is a screen you
|
|
# cannot see well enough to fix, so it refuses rather than guesses.
|
|
|
|
# Single-line and space-free: the IPC call splits its arguments on whitespace,
|
|
# so a pretty-printed fixture arrives as several arguments instead of one.
|
|
two_screens='[{"name":"DP-2","width":1920,"height":1080,"refreshRate":60.0,"scale":1.0,"transform":0,"x":0,"y":0,"availableModes":["[email protected]","[email protected]"]},{"name":"HDMI-A-1","width":1920,"height":1080,"refreshRate":60.0,"scale":1.0,"transform":0,"x":1920,"y":0,"availableModes":["[email protected]"]}]'
|
|
|
|
# base64 because `qs ipc call` splits a JSON array of several objects into one
|
|
# argument per object, which makes a two-monitor fixture look like an extra
|
|
# argument and the call is refused for arity.
|
|
b64() { printf '%s' "$1" | base64 -w0; }
|
|
plan() { run ipc call displays-test restorePlan "$(b64 "$1")" "$(b64 "$2")"; }
|
|
|
|
# Nothing stored: the compositor's placement stands.
|
|
result="$(plan "$two_screens" '{}')"
|
|
jq -e '.action == "none"' <<<"$result" >/dev/null \
|
|
|| fail "with nothing stored, Panama wanted to change the arrangement: $result"
|
|
|
|
# Stored and already correct: still nothing to do, so a reconnect does not
|
|
# push a layout the compositor is already showing.
|
|
result="$(plan "$two_screens" '{"DP-2":{"mode":"[email protected]","scale":1,"transform":0,"x":0,"y":0,"primary":true}}')"
|
|
jq -e '.action == "none"' <<<"$result" >/dev/null \
|
|
|| fail "an arrangement that already matches was re-applied anyway: $result"
|
|
|
|
# Stored and different: restore it, with the stored scale and position.
|
|
result="$(plan "$two_screens" '{"DP-2":{"mode":"[email protected]","scale":1,"transform":0,"x":0,"y":0,"primary":true}}')"
|
|
jq -e '.action == "apply" and (.layout[] | select(.name == "DP-2") | .mode == "[email protected]")' \
|
|
<<<"$result" >/dev/null \
|
|
|| fail "a stored arrangement was not restored when the display reconnected: $result"
|
|
|
|
# A mode this panel does not offer. Same connector, different hardware -- DP-1
|
|
# on one dock is not DP-1 on another. It must refuse rather than ask the
|
|
# compositor for a mode that does not exist.
|
|
result="$(plan "$two_screens" '{"DP-2":{"mode":"[email protected]","scale":1,"transform":0,"x":0,"y":0,"primary":true}}')"
|
|
jq -e '.action == "refuse"' <<<"$result" >/dev/null \
|
|
|| fail "a stored mode the connected panel does not offer was requested anyway: $result"
|
|
|
|
# Undocked: the stored primary is gone. The layout that survives must still
|
|
# name exactly one primary, or DisplayLayout.validate refuses it outright and
|
|
# the machine keeps whatever it happens to have.
|
|
one_screen='[{"name":"DP-2","width":1920,"height":1080,"refreshRate":60.0,"scale":1.0,"transform":0,"x":0,"y":0,"availableModes":["[email protected]","[email protected]"]}]'
|
|
result="$(plan "$one_screen" '{"DP-2":{"mode":"[email protected]","scale":1,"transform":0,"x":0,"y":0,"primary":false},"HDMI-A-1":{"mode":"[email protected]","scale":1,"transform":0,"x":1920,"y":0,"primary":true}}')"
|
|
jq -e '.action == "apply" and ([.layout[] | select(.primary)] | length == 1)' <<<"$result" >/dev/null \
|
|
|| fail "after undocking, the restored layout did not name exactly one primary: $result"
|
|
jq -e '[.layout[] | .name] == ["DP-2"]' <<<"$result" >/dev/null \
|
|
|| fail "the restored layout mentions a display that is not connected: $result"
|
|
|
|
|
|
restore_display || fail 'the final cleanup could not restore and verify the original display'
|
|
|
|
original_mode=""
|
|
stop_harness
|
|
trap - EXIT
|
|
printf 'displays contract: PASS\n'
|