diff --git a/.github/workflows/qml-tests.yml b/.github/workflows/qml-tests.yml new file mode 100644 index 000000000..dca756e68 --- /dev/null +++ b/.github/workflows/qml-tests.yml @@ -0,0 +1,31 @@ +name: QML tests + +on: + pull_request: + branches: [master, main, "stable-*"] + paths: + - "quickshell/**" + - "dank-qml-common" + - "Makefile" + - "flake.*" + - "distro/nix/**" + - ".github/workflows/qml-tests.yml" + +permissions: + contents: read + +jobs: + test-qml: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Install Nix + uses: cachix/install-nix-action@v31 + + - name: Run QML tests + run: nix develop --command make test-qml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 74c499351..8b3ea96e5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -378,6 +378,7 @@ jobs: # Tar the CONTENTS of quickshell/, not the directory itself (cd quickshell && tar --exclude='.git' \ --exclude='.github' \ + --exclude='./tests' \ --exclude='*.tar.gz' \ -czf ../_release_assets/dms-qml.tar.gz .) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 884bb45d7..fd775ef87 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -143,7 +143,9 @@ touch .qmlls.ini 5. Run `make lint-qml` from the repo root to lint QML entrypoints (requires the `.qmlls.ini` generated above). The script needs the **Qt 6** `qmllint`; it checks `qmllint6`, Fedora's `qmllint-qt6`, `/usr/lib/qt6/bin/qmllint`, then `qmllint` in `PATH`. If your Qt 6 binary lives elsewhere, set `QMLLINT=/path/to/qmllint`. -6. Make your changes, test, and open a pull request. +6. Run `make test-qml` for QML unit tests (`nix develop --command make test-qml` with Nix). These use Qt 6 `qmltestrunner` offscreen and do not require a running shell. Set `QMLTESTRUNNER=/path/to/qmltestrunner` if needed. + +7. Make your changes, test, and open a pull request. ### I18n/Localization diff --git a/Makefile b/Makefile index e9c72740c..5c93e49e0 100644 --- a/Makefile +++ b/Makefile @@ -14,6 +14,7 @@ USER_HOME := $(if $(SUDO_USER),$(shell getent passwd $(SUDO_USER) | cut -d: -f6) SYSTEMD_USER_DIR=$(USER_HOME)/.config/systemd/user SHELL_DIR=quickshell +QMLTESTRUNNER ?= qmltestrunner SHELL_INSTALL_DIR=$(DATA_DIR)/quickshell/dms ASSETS_DIR=assets APPLICATIONS_DIR=$(DATA_DIR)/applications @@ -41,6 +42,10 @@ clean: lint-qml: @./quickshell/scripts/qmllint-entrypoints.sh +.PHONY: test-qml +test-qml: + QT_QPA_PLATFORM=offscreen $(QMLTESTRUNNER) -input quickshell/tests -o -,txt + # Pull the latest dank-qml-common and pin it everywhere it is consumed # (submodule pointer + nix flake input). Commit both in one change. update-common: @@ -147,6 +152,7 @@ help: @echo " build - Same as 'all'" @echo " clean - Clean build artifacts" @echo " lint-qml - Run qmllint on shell entrypoints using the Quickshell tooling VFS" + @echo " test-qml - Run QML unit tests with Qt 6 qmltestrunner" @echo "" @echo "Install:" @echo " install - Build and install everything (requires sudo)" diff --git a/core/Makefile b/core/Makefile index 1ddc9d754..f29795687 100644 --- a/core/Makefile +++ b/core/Makefile @@ -51,6 +51,7 @@ sync-shell: @mkdir -p $(EMBED_DIR) @tar -C $(SHELL_SRC) --exclude=.qmlls.ini -chf - . | tar -C $(EMBED_DIR) -xf - @rm -rf $(EMBED_DIR)/.git* $(EMBED_DIR)/.github + @rm -rf $(EMBED_DIR)/tests @find $(EMBED_DIR) -type d \( -name .claude -o -name .vscode \) -prune -exec rm -rf {} + @rm -f $(EMBED_DIR)/AGENTS.md $(EMBED_DIR)/qmlformat-all.sh @rm -f $(EMBED_DIR)/scripts/i18nsync.py $(EMBED_DIR)/scripts/build-vscode-vsix.sh $(EMBED_DIR)/scripts/qmllint-entrypoints.sh diff --git a/distro/debian/dms/debian/rules b/distro/debian/dms/debian/rules index fd2048dc1..419373103 100755 --- a/distro/debian/dms/debian/rules +++ b/distro/debian/dms/debian/rules @@ -76,7 +76,8 @@ override_dh_auto_install: fi rm -rf debian/dms/usr/share/quickshell/dms/core \ - debian/dms/usr/share/quickshell/dms/distro + debian/dms/usr/share/quickshell/dms/distro \ + debian/dms/usr/share/quickshell/dms/tests override_dh_auto_clean: rm -f dms diff --git a/distro/opensuse/dms.spec b/distro/opensuse/dms.spec index 5be054cb1..3b747f2f3 100644 --- a/distro/opensuse/dms.spec +++ b/distro/opensuse/dms.spec @@ -71,6 +71,7 @@ install -Dm644 assets/danklogo.svg %{buildroot}%{_datadir}/icons/hicolor/scalabl install -dm755 %{buildroot}%{_datadir}/quickshell/dms cp -r quickshell/* %{buildroot}%{_datadir}/quickshell/dms/ +rm -rf %{buildroot}%{_datadir}/quickshell/dms/tests rm -rf %{buildroot}%{_datadir}/quickshell/dms/.git* rm -f %{buildroot}%{_datadir}/quickshell/dms/.gitignore diff --git a/distro/ubuntu/dms/debian/rules b/distro/ubuntu/dms/debian/rules index f6bc3e9f0..08e3c724b 100755 --- a/distro/ubuntu/dms/debian/rules +++ b/distro/ubuntu/dms/debian/rules @@ -69,6 +69,7 @@ override_dh_auto_install: rm -rf debian/dms/usr/share/quickshell/dms/core rm -rf debian/dms/usr/share/quickshell/dms/distro rm -rf debian/dms/usr/share/quickshell/dms/assets + rm -rf debian/dms/usr/share/quickshell/dms/tests # Create VERSION file for Quickshell (stable release format) echo "$(BASE_VERSION)" > debian/dms/usr/share/quickshell/dms/VERSION diff --git a/docs/IPC.md b/docs/IPC.md index 6960e573d..de03b2dc3 100644 --- a/docs/IPC.md +++ b/docs/IPC.md @@ -103,6 +103,35 @@ dms ipc call brightness increment 10 "" dms ipc call brightness decrement 5 "intel_backlight" ``` +## Target: `outputs` + +### `cycle` + +Cycles through connected outputs on Niri in stable name order, leaving one enabled after a successful switch. +If the next output is disabled, DMS enables it and waits for confirmation before disabling the others. +If it is already enabled, DMS disables the others immediately. + +The command returns immediately with one of these statuses: + +- `OUTPUT_CYCLE_ACCEPTED` — DMS started switching outputs; this does not confirm completion. +- `OUTPUT_CYCLE_BUSY` — DMS is still waiting for the previously selected output to become enabled. +- `OUTPUT_CYCLE_NOOP` — fewer than two outputs are connected, or DMS could not start switching outputs. +- `OUTPUT_CYCLE_UNSUPPORTED` — the active compositor is not Niri, its IPC socket is unavailable, or DMS output-state notifications are unavailable. + +If the selected output disconnects and no connected output is enabled, DMS enables the previous connected output in the cycle. +This fallback also works before the first cycle command and leaves any already-enabled outputs unchanged. +DMS keeps this cycle order in memory until it restarts. + +### Niri keybinding example + +Add this binding to your Niri configuration: + +```kdl +binds { + Super+P { spawn "dms" "ipc" "call" "outputs" "cycle"; } +} +``` + ## Target: `night` Night mode (gamma/color temperature) control. diff --git a/flake.nix b/flake.nix index 45d363f83..2d57fdb50 100644 --- a/flake.nix +++ b/flake.nix @@ -129,6 +129,8 @@ postInstall = '' mkdir -p $out/share/quickshell/dms cp -r ${rootSrc}/quickshell/. $out/share/quickshell/dms/ + chmod -R u+w $out/share/quickshell/dms/tests + rm -rf $out/share/quickshell/dms/tests rm -f $out/share/quickshell/dms/DankCommon cp -r ${dank-qml-common}/DankCommon $out/share/quickshell/dms/DankCommon diff --git a/quickshell/DMSShellIPC.qml b/quickshell/DMSShellIPC.qml index 924f339bb..01da4a30c 100644 --- a/quickshell/DMSShellIPC.qml +++ b/quickshell/DMSShellIPC.qml @@ -1948,6 +1948,10 @@ Item { } IpcHandler { + function cycle(): string { + return NiriService.cycleSingleOutput(); + } + function listProfiles(): string { const profiles = DisplayConfigState.validatedProfiles; const activeId = SessionData.getActiveDisplayProfile(CompositorService.compositor); diff --git a/quickshell/Services/NiriOutputCycle.qml b/quickshell/Services/NiriOutputCycle.qml new file mode 100644 index 000000000..bc152c36b --- /dev/null +++ b/quickshell/Services/NiriOutputCycle.qml @@ -0,0 +1,91 @@ +import QtQuick +import "OutputCycleState.js" as OutputCycleState + +QtObject { + id: root + + required property QtObject wlrOutputService + required property QtObject socket + required property bool isNiri + required property string currentOutput + + property var _outputCycleState: OutputCycleState.emptyState() + + Component.onCompleted: resolveOutputCycle() + + readonly property Connections outputConnection: Connections { + target: root.wlrOutputService + + function onStateChanged() { + root.resolveOutputCycle(); + } + } + + readonly property Connections socketConnection: Connections { + target: root.socket + + function onConnectionStateChanged() { + if (root.socket.linkUp) + root.resolveOutputCycle(); + } + } + + function outputDescriptors() { + return wlrOutputService.outputs + .filter(output => output.name) + .map(output => ({ id: output.name, enabled: output.enabled })); + } + + function sendOutputAction(outputName, action) { + if (!isNiri || !socket.connected || !socket.linkUp || !wlrOutputService.wlrOutputAvailable || !wlrOutputService.getOutput(outputName)) + return false; + socket.send({ + "Output": { + "output": outputName, + "action": action + } + }); + return true; + } + + function sendOutputIntent(intent) { + return sendOutputAction(intent.id, intent.enabled ? "On" : "Off"); + } + + function applyOutputCycleTransition(transition) { + const enableIntent = transition.intents.find(intent => intent.enabled); + if (enableIntent && !sendOutputIntent(enableIntent)) { + _outputCycleState = OutputCycleState.clearPending(transition.state); + return false; + } + + _outputCycleState = transition.state; + for (const intent of transition.intents) { + if (intent !== enableIntent) + sendOutputIntent(intent); + } + return true; + } + + function cycleSingleOutput() { + if (!isNiri || !socket.linkUp || !wlrOutputService.wlrOutputAvailable) + return "OUTPUT_CYCLE_UNSUPPORTED"; + if (_outputCycleState.pending) + return "OUTPUT_CYCLE_BUSY"; + + const transition = OutputCycleState.requestCycle(_outputCycleState, outputDescriptors(), currentOutput); + if (transition.status === "busy") + return "OUTPUT_CYCLE_BUSY"; + if (transition.status === "no-op") + return "OUTPUT_CYCLE_NOOP"; + + const accepted = applyOutputCycleTransition(transition); + return accepted ? "OUTPUT_CYCLE_ACCEPTED" : "OUTPUT_CYCLE_NOOP"; + } + + function resolveOutputCycle() { + if (!isNiri || !socket.linkUp || !wlrOutputService.wlrOutputAvailable) + return; + applyOutputCycleTransition(OutputCycleState.handleOutputsChanged(_outputCycleState, outputDescriptors())); + } +} diff --git a/quickshell/Services/NiriService.qml b/quickshell/Services/NiriService.qml index 855c5fbe0..6f263c782 100644 --- a/quickshell/Services/NiriService.qml +++ b/quickshell/Services/NiriService.qml @@ -265,6 +265,14 @@ Singleton { connected: CompositorService.isNiri } + NiriOutputCycle { + id: outputCycle + wlrOutputService: WlrOutputService + socket: requestSocket + isNiri: CompositorService.isNiri + currentOutput: root.currentOutput + } + function fetchOutputs() { if (!CompositorService.isNiri) return; @@ -287,6 +295,10 @@ Singleton { }); } + function cycleSingleOutput() { + return outputCycle.cycleSingleOutput(); + } + function updateDisplayScales() { if (!outputs || Object.keys(outputs).length === 0) return; diff --git a/quickshell/Services/OutputCycleState.js b/quickshell/Services/OutputCycleState.js new file mode 100644 index 000000000..938c1feb7 --- /dev/null +++ b/quickshell/Services/OutputCycleState.js @@ -0,0 +1,115 @@ +.pragma library + +function emptyState() { + return { + ring: [], + selected: "", + pending: "" + }; +} + +function requestCycle(state, outputs, currentOutput) { + if (state.pending) + return result(state, "busy", []); + + const ring = outputNames(outputs); + if (ring.length < 2) + return result(state, "no-op", []); + + const current = findOutput(outputs, currentOutput) ? currentOutput : (firstEnabled(outputs) || ring[0]); + const target = ring[(ring.indexOf(current) + 1) % ring.length]; + const targetOutput = findOutput(outputs, target); + if (targetOutput.enabled) + return result(withState(state, { selected: target }), "accepted", disableOthers(outputs, target)); + + return result(withState(state, { pending: target }), "accepted", [{ id: target, enabled: true }]); +} + +function handleOutputsChanged(state, outputs) { + const ring = outputNames(outputs); + if (ring.length === 0) + return result(state, "", []); + + let nextState = state; + let intents = []; + if (state.pending) { + const pendingOutput = findOutput(outputs, state.pending); + if (!pendingOutput) { + nextState = withState(nextState, { pending: "" }); + } else if (pendingOutput.enabled) { + nextState = withState(nextState, { + pending: "", + selected: pendingOutput.id + }); + intents = disableOthers(outputs, pendingOutput.id); + } + } + + if (!nextState.pending && nextState.selected && !findOutput(outputs, nextState.selected) && !firstEnabled(outputs)) { + const fallback = previousConnectedOutput(state.ring, nextState.selected, outputs); + if (fallback) { + nextState = withState(nextState, { selected: fallback.id }); + intents = intents.concat([{ id: fallback.id, enabled: true }]); + } + } + + nextState = withState(nextState, { ring: ring }); + const enabled = firstEnabled(outputs); + if (enabled && enabledOutputCount(outputs) === 1) + nextState = withState(nextState, { selected: enabled }); + return result(nextState, "", intents); +} + +function clearPending(state) { + return withState(state, { pending: "" }); +} + +function outputNames(outputs) { + return outputs.map(output => output.id).sort((a, b) => a.localeCompare(b)); +} + +function findOutput(outputs, id) { + return outputs.find(output => output.id === id); +} + +function firstEnabled(outputs) { + return outputNames(outputs).find(id => findOutput(outputs, id).enabled) || ""; +} + +function enabledOutputCount(outputs) { + return outputs.filter(output => output.enabled).length; +} + +function disableOthers(outputs, selected) { + return outputs + .filter(output => output.enabled && output.id !== selected) + .map(output => ({ id: output.id, enabled: false })); +} + +function previousConnectedOutput(ring, selected, outputs) { + const selectedIndex = ring.indexOf(selected); + if (selectedIndex < 0) + return null; + for (let offset = 1; offset < ring.length; offset++) { + const candidate = findOutput(outputs, ring[(selectedIndex - offset + ring.length) % ring.length]); + if (candidate) + return candidate; + } + return null; +} + +function withState(state, changes) { + return { + ring: changes.ring !== undefined ? changes.ring : state.ring, + selected: changes.selected !== undefined ? changes.selected : state.selected, + pending: changes.pending !== undefined ? changes.pending : state.pending + }; +} + +function result(state, status, intents) { + return { + state: state, + status: status, + intents: intents + }; +} diff --git a/quickshell/tests/tst_NiriOutputCycle.qml b/quickshell/tests/tst_NiriOutputCycle.qml new file mode 100644 index 000000000..ffe5630b1 --- /dev/null +++ b/quickshell/tests/tst_NiriOutputCycle.qml @@ -0,0 +1,184 @@ +import QtQuick +import QtTest +import "../Services" + +TestCase { + id: testCase + name: "NiriOutputCycle" + + property var wlr + property var socket + property var adapter + + Component { + id: wlrFactory + QtObject { + property bool wlrOutputAvailable: true + property var outputs: [] + signal stateChanged + + function getOutput(name) { + return outputs.find(output => output.name === name) || null; + } + + function publish(heads) { + outputs = heads; + stateChanged(); + } + } + } + + Component { + id: socketFactory + QtObject { + property bool connected: true + property bool linkUp: true + property var requests: [] + property var afterSend: null + signal connectionStateChanged + + function send(request) { + requests = requests.concat([request]); + if (afterSend) + afterSend(request); + } + + function reconnect() { + linkUp = true; + connectionStateChanged(); + } + } + } + + Component { + id: adapterFactory + NiriOutputCycle {} + } + + function head(name, enabled) { + return { name: name, enabled: enabled }; + } + + function start(heads, currentOutput, linkUp = true) { + wlr = createTemporaryObject(wlrFactory, testCase, { outputs: heads }); + socket = createTemporaryObject(socketFactory, testCase, { linkUp: linkUp }); + adapter = createTemporaryObject(adapterFactory, testCase, { + wlrOutputService: wlr, + socket: socket, + isNiri: true, + currentOutput: currentOutput + }); + verify(adapter !== null); + } + + function compareRequests(expected) { + compare(socket.requests, expected.map(item => ({ Output: { output: item[0], action: item[1] } }))); + } + + function test_wlrSignalConfirmsDisabledTargetBeforePeersTurnOff() { + start([head("eDP-1", true), head("", false), head("DP-1", false)], "eDP-1"); + + compare(adapter.cycleSingleOutput(), "OUTPUT_CYCLE_ACCEPTED"); + compareRequests([["DP-1", "On"]]); + compare(adapter.cycleSingleOutput(), "OUTPUT_CYCLE_BUSY"); + + wlr.publish([head("eDP-1", true), head("DP-1", false)]); + compareRequests([["DP-1", "On"]]); + compare(adapter.cycleSingleOutput(), "OUTPUT_CYCLE_BUSY"); + + wlr.publish([head("eDP-1", true), head("DP-1", true)]); + compareRequests([["DP-1", "On"], ["eDP-1", "Off"]]); + } + + function test_connectionDesiredWithoutActualLinkRejectsCycle() { + start([head("DP-1", false), head("eDP-1", true)], "eDP-1", false); + + verify(socket.connected); + compare(adapter.cycleSingleOutput(), "OUTPUT_CYCLE_UNSUPPORTED"); + compareRequests([]); + + socket.reconnect(); + compare(adapter.cycleSingleOutput(), "OUTPUT_CYCLE_ACCEPTED"); + compareRequests([["DP-1", "On"]]); + } + + function test_reconnectConsumesCurrentWlrStateWithoutAnotherOutputEvent() { + start([head("DP-1", false), head("eDP-1", true)], "eDP-1"); + compare(adapter.cycleSingleOutput(), "OUTPUT_CYCLE_ACCEPTED"); + + socket.linkUp = false; + wlr.publish([head("DP-1", true), head("eDP-1", true)]); + compare(adapter.cycleSingleOutput(), "OUTPUT_CYCLE_UNSUPPORTED"); + compareRequests([["DP-1", "On"]]); + + socket.reconnect(); + compareRequests([["DP-1", "On"], ["eDP-1", "Off"]]); + } + + function test_unavailableWlrPreservesPendingCycleUntilStateRecovery() { + start([head("DP-1", false), head("eDP-1", true)], "eDP-1"); + compare(adapter.cycleSingleOutput(), "OUTPUT_CYCLE_ACCEPTED"); + compareRequests([["DP-1", "On"]]); + + wlr.wlrOutputAvailable = false; + wlr.publish([head("DP-1", true), head("eDP-1", true)]); + compare(adapter.cycleSingleOutput(), "OUTPUT_CYCLE_UNSUPPORTED"); + compareRequests([["DP-1", "On"]]); + + wlr.wlrOutputAvailable = true; + compare(adapter.cycleSingleOutput(), "OUTPUT_CYCLE_BUSY"); + compareRequests([["DP-1", "On"]]); + + wlr.publish([head("DP-1", true), head("eDP-1", true)]); + compareRequests([["DP-1", "On"], ["eDP-1", "Off"]]); + } + + function test_initialSelectionRecoversDisabledHeadAfterUnplug() { + start([head("DP-1", true), head("eDP-1", false)], "DP-1"); + compareRequests([]); + + wlr.publish([head("eDP-1", false)]); + compareRequests([["eDP-1", "On"]]); + + wlr.publish([head("eDP-1", true)]); + compareRequests([["eDP-1", "On"]]); + compare(adapter.cycleSingleOutput(), "OUTPUT_CYCLE_NOOP"); + } + + function test_unpluggedPendingTargetCancelsAndNewHeadJoinsCycle() { + start([head("DP-1", false), head("eDP-1", true)], "eDP-1"); + compare(adapter.cycleSingleOutput(), "OUTPUT_CYCLE_ACCEPTED"); + + wlr.publish([head("eDP-1", true)]); + compareRequests([["DP-1", "On"]]); + compare(adapter.cycleSingleOutput(), "OUTPUT_CYCLE_NOOP"); + + wlr.publish([head("HDMI-A-1", false), head("eDP-1", true)]); + compare(adapter.cycleSingleOutput(), "OUTPUT_CYCLE_ACCEPTED"); + compareRequests([["DP-1", "On"], ["HDMI-A-1", "On"]]); + } + + function test_outputMembershipIsCheckedForEachSend() { + start([head("DP-1", false), head("HDMI-A-1", true), head("eDP-1", true)], "eDP-1"); + compare(adapter.cycleSingleOutput(), "OUTPUT_CYCLE_ACCEPTED"); + socket.afterSend = function(request) { + if (request.Output.action === "Off") + wlr.outputs = [head("DP-1", true)]; + }; + + wlr.publish([head("DP-1", true), head("HDMI-A-1", true), head("eDP-1", true)]); + compareRequests([["DP-1", "On"], ["HDMI-A-1", "Off"]]); + } + + function test_actualLinkIsCheckedForEachSend() { + start([head("DP-1", false), head("HDMI-A-1", true), head("eDP-1", true)], "eDP-1"); + compare(adapter.cycleSingleOutput(), "OUTPUT_CYCLE_ACCEPTED"); + socket.afterSend = function(request) { + if (request.Output.action === "Off") + socket.linkUp = false; + }; + + wlr.publish([head("DP-1", true), head("HDMI-A-1", true), head("eDP-1", true)]); + compareRequests([["DP-1", "On"], ["HDMI-A-1", "Off"]]); + } +} diff --git a/quickshell/tests/tst_OutputCycleState.qml b/quickshell/tests/tst_OutputCycleState.qml new file mode 100644 index 000000000..c8a53d008 --- /dev/null +++ b/quickshell/tests/tst_OutputCycleState.qml @@ -0,0 +1,167 @@ +import QtQuick +import QtTest +import "../Services/OutputCycleState.js" as OutputCycleState + +TestCase { + name: "OutputCycleState" + + function output(id, enabled) { + return { id: id, enabled: enabled }; + } + + function test_disabledTargetStartsPendingCycle() { + const result = OutputCycleState.requestCycle(OutputCycleState.emptyState(), [ + output("HDMI-A-1", false), + output("eDP-1", true) + ], "eDP-1"); + + compare(result.status, "accepted"); + compare(result.state.pending, "HDMI-A-1"); + compare(result.intents.length, 1); + compare(result.intents[0].id, "HDMI-A-1"); + verify(result.intents[0].enabled); + } + + function test_confirmedTargetDisablesEveryOtherEnabledOutput() { + const state = { ring: ["HDMI-A-1", "eDP-1", "DP-1"], selected: "eDP-1", pending: "HDMI-A-1" }; + const result = OutputCycleState.handleOutputsChanged(state, [ + output("DP-1", true), + output("HDMI-A-1", true), + output("eDP-1", true) + ]); + + compare(result.state.pending, ""); + compare(result.state.selected, "HDMI-A-1"); + compare(result.intents.length, 2); + compare(result.intents[0].id, "DP-1"); + verify(!result.intents[0].enabled); + compare(result.intents[1].id, "eDP-1"); + verify(!result.intents[1].enabled); + } + + function test_alreadyEnabledTargetDisablesPeersImmediately() { + const result = OutputCycleState.requestCycle(OutputCycleState.emptyState(), [ + output("DP-1", true), + output("eDP-1", true) + ], "DP-1"); + + compare(result.status, "accepted"); + compare(result.state.selected, "eDP-1"); + compare(result.intents.length, 1); + compare(result.intents[0].id, "DP-1"); + verify(!result.intents[0].enabled); + } + + function test_emptyMapRetainsState() { + const state = { ring: ["HDMI-A-1", "eDP-1"], selected: "HDMI-A-1", pending: "" }; + const result = OutputCycleState.handleOutputsChanged(state, []); + + compare(result.state.ring, state.ring); + compare(result.state.selected, state.selected); + compare(result.state.pending, state.pending); + compare(result.intents.length, 0); + } + + function test_emptyMapRetainsPendingCycleUntilTargetConfirms() { + const state = { ring: ["HDMI-A-1", "eDP-1"], selected: "eDP-1", pending: "HDMI-A-1" }; + const empty = OutputCycleState.handleOutputsChanged(state, []); + + compare(empty.state.ring, state.ring); + compare(empty.state.selected, "eDP-1"); + compare(empty.state.pending, "HDMI-A-1"); + compare(empty.intents.length, 0); + + const confirmed = OutputCycleState.handleOutputsChanged(empty.state, [ + output("HDMI-A-1", true), + output("eDP-1", true) + ]); + + compare(confirmed.state.pending, ""); + compare(confirmed.state.selected, "HDMI-A-1"); + compare(confirmed.intents.length, 1); + compare(confirmed.intents[0].id, "eDP-1"); + verify(!confirmed.intents[0].enabled); + } + + function test_unplugPreservesAllEnabledOutputs() { + const state = { ring: ["DP-1", "HDMI-A-1", "eDP-1"], selected: "HDMI-A-1", pending: "" }; + const result = OutputCycleState.handleOutputsChanged(state, [ + output("DP-1", true), + output("eDP-1", true) + ]); + + compare(result.state.pending, ""); + compare(result.intents, []); + } + + function test_disabledFallbackOnlyEnablesPreviousOutput() { + const state = { ring: ["DP-1", "HDMI-A-1", "eDP-1"], selected: "HDMI-A-1", pending: "" }; + const result = OutputCycleState.handleOutputsChanged(state, [ + output("DP-1", false), + output("eDP-1", false) + ]); + + compare(result.state.pending, ""); + compare(result.state.selected, "DP-1"); + compare(result.intents.length, 1); + compare(result.intents[0].id, "DP-1"); + verify(result.intents[0].enabled); + } + + function test_unplugWithoutCyclePreservesWorkingDisplay() { + let state = OutputCycleState.handleOutputsChanged(OutputCycleState.emptyState(), [ + output("DP-1", true), output("DP-2", false), output("eDP-1", false) + ]).state; + state = OutputCycleState.handleOutputsChanged(state, [ + output("DP-1", true), output("DP-2", true), output("eDP-1", false) + ]).state; + const unplugged = OutputCycleState.handleOutputsChanged(state, [ + output("DP-2", true), output("eDP-1", false) + ]); + compare(unplugged.intents, []); + compare(unplugged.state.pending, ""); + const repeated = OutputCycleState.handleOutputsChanged(unplugged.state, [ + output("DP-2", true), output("eDP-1", false) + ]); + compare(repeated.intents, []); + } + + function test_fallbackConfirmationDoesNotDisableNewlyEnabledPeer() { + const state = OutputCycleState.handleOutputsChanged(OutputCycleState.emptyState(), [ + output("DP-1", true), output("DP-2", false), output("eDP-1", false) + ]).state; + const unplugged = OutputCycleState.handleOutputsChanged(state, [ + output("DP-2", false), output("eDP-1", false) + ]); + compare(unplugged.intents, [{ id: "eDP-1", enabled: true }]); + const confirmed = OutputCycleState.handleOutputsChanged(unplugged.state, [ + output("DP-2", true), output("eDP-1", true) + ]); + compare(confirmed.intents, []); + compare(confirmed.state.pending, ""); + } + + function test_missingPendingTargetCancelsWithoutIntent() { + const state = { ring: ["HDMI-A-1", "eDP-1"], selected: "eDP-1", pending: "HDMI-A-1" }; + const result = OutputCycleState.handleOutputsChanged(state, [output("eDP-1", true)]); + + compare(result.state.pending, ""); + compare(result.intents.length, 0); + } + + function test_pendingCycleIsBusy() { + const state = { ring: ["HDMI-A-1", "eDP-1"], selected: "eDP-1", pending: "HDMI-A-1" }; + const result = OutputCycleState.requestCycle(state, [output("HDMI-A-1", false), output("eDP-1", true)], "eDP-1"); + + compare(result.status, "busy"); + compare(result.intents.length, 0); + } + + function test_singleOutputIsNoop() { + const state = OutputCycleState.emptyState(); + const result = OutputCycleState.requestCycle(state, [output("eDP-1", true)], "eDP-1"); + + compare(result.status, "no-op"); + compare(result.intents.length, 0); + } +}