// Fixture-fed harness for the three sound services that shell out: // SoundCards (pactl list cards / set-card-profile), SoundRouting (pactl // move-sink-input) and SoundDefaults (pw-metadata -n default). // // Nothing here touches the real audio graph. The contracts that drive this file // replace pactl and pw-metadata with recording stubs, so what is under test is // the parsing and the argv -- not PipeWire, which has its own opinions and a // different set on every machine. // // Application groups arrive as plain objects with the same shape AudioStreams.js // produces, because that is what the services are handed at runtime and it is // the part a refactor is most likely to break silently. import Quickshell import Quickshell.Io import QtQuick import qs.services ShellRoot { id: root // Two streams of one application, the way PipeWire numbers them: an // `object.serial` that pactl accepts, distinct from the node id. readonly property var twoStreamGroup: ({ key: "org.chromium.Chromium", label: "Chromium", icon: "chromium", nodes: [ { id: 61, properties: { "object.serial": "412" } }, { id: 62, properties: { "object.serial": "418" } } ] }) // A stream PipeWire never gave a serial. The node id is the fallback, and // it has to be one: an application that cannot be moved is worse than one // moved by a less stable handle. readonly property var seriallessGroup: ({ key: "mpv", label: "mpv", icon: "mpv", nodes: [{ id: 77, properties: {} }] }) readonly property var emptyGroup: ({ key: "gone", label: "Gone", icon: "", nodes: [] }) function groupNamed(name: string): var { if (name === "twoStream") return root.twoStreamGroup; if (name === "serialless") return root.seriallessGroup; return root.emptyGroup; } IpcHandler { target: "sound-services-test" // ── SoundCards ────────────────────────────────────────────────────── function cards(): string { return JSON.stringify({ busy: SoundCards.busy, lastError: SoundCards.lastError, cards: SoundCards.cards }); } function refreshCards(): bool { SoundCards.refresh(); return true; } function setProfile(card: string, profile: string): bool { return SoundCards.setProfile(card, profile) !== false; } // ── SoundRouting ──────────────────────────────────────────────────── function routing(): string { return JSON.stringify({ busy: SoundRouting.busy, lastError: SoundRouting.lastError }); } function moveApplication(group: string, sink: string): bool { return SoundRouting.moveApplication(root.groupNamed(group), sink) !== false; } function routeToDefault(group: string): bool { return SoundRouting.routeToDefault(root.groupNamed(group)) !== false; } function currentSinkFor(group: string): string { return String(SoundRouting.currentSinkFor(root.groupNamed(group)) ?? ""); } // ── SoundDefaults ─────────────────────────────────────────────────── function defaults(): string { return JSON.stringify({ sink: SoundDefaults.configuredSinkName, source: SoundDefaults.configuredSourceName }); } function refreshDefaults(): bool { SoundDefaults.refresh(); return true; } // What the ghost row would render, and what it would call the device. // The name is all there is to go on: the node is gone, so there is no // description to borrow. function labelFor(name: string): string { return SoundDefaults.label(name); } function absent(output: string): string { const record = SoundDefaults.absent(output === "output"); return JSON.stringify(record === null ? { present: true } : { present: false, name: record.name, label: record.label }); } } }