293 lines
15 KiB
Bash
Executable File
293 lines
15 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
# Focus modes are conditions, not alarms.
|
|
#
|
|
# The rules:
|
|
#
|
|
# 1. A schedule is a window that is asked about, never a timer that fires.
|
|
# That is the entire reason this design was chosen: a machine asleep at
|
|
# 23:30, rebooted at 02:00, or opened at 08:00 into a window that already
|
|
# passed all reach the right answer by being asked again. A fired-once
|
|
# alarm gets all three wrong.
|
|
# 2. A window that crosses midnight belongs to the day it STARTS on. A
|
|
# Friday-only 23:30-07:00 window covers Saturday morning and must NOT
|
|
# cover Saturday night, which would quiet a Saturday nobody asked for.
|
|
# 3. A malformed schedule is off. Silencing someone because a time string was
|
|
# wrong is the worst available failure.
|
|
# 4. One owner for Do Not Disturb. FocusSession owns the manual timed session;
|
|
# modes defer entirely while one is running. Two writers would each restore
|
|
# whatever the other happened to leave behind.
|
|
# 5. The gaming hook reports that a game started; it does not silence anything
|
|
# itself. It used to, and running both would mean two owners again.
|
|
#
|
|
# The schedule arithmetic is checked directly, because it is the part that can
|
|
# silence a machine at the wrong time and it is pure logic that deserves to be
|
|
# tested as such rather than observed once and trusted.
|
|
|
|
set -uo pipefail
|
|
|
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
service="$repo_dir/config/dot/quickshell/services/FocusModes.qml"
|
|
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
|
|
hook="$repo_dir/config/dot/quickshell/scripts/panama-gaming"
|
|
gaming_page="$repo_dir/config/dot/quickshell/modules/settings/GamingPage.qml"
|
|
focus_page="$repo_dir/config/dot/quickshell/modules/settings/FocusPage.qml"
|
|
|
|
fail() {
|
|
printf 'focus modes contract: %s\n' "$1" >&2
|
|
exit 1
|
|
}
|
|
|
|
for path in "$service" "$schema" "$hook" "$gaming_page" "$focus_page"; do
|
|
[[ -r "$path" ]] || fail "missing $path"
|
|
done
|
|
|
|
# ── 1. Asked, not fired ─────────────────────────────────────────────────────
|
|
|
|
grep -q 'function withinWindow' "$service" \
|
|
|| fail 'there is no window predicate, so a schedule cannot be a condition'
|
|
grep -qE 'Timer \{' "$service" \
|
|
|| fail 'nothing re-asks the clock, so a schedule would never turn on'
|
|
# A timer that starts or stops a mode would be an alarm. The only timer here
|
|
# may do one thing: move the clock the predicate reads.
|
|
grep -A6 'Timer {' "$service" | grep -q 'nowMs = Date.now()' \
|
|
|| fail 'the timer does something other than re-read the clock'
|
|
|
|
# ── 2, 3. The arithmetic ────────────────────────────────────────────────────
|
|
#
|
|
# Extracted from the service and evaluated, so the contract tests the shipped
|
|
# logic rather than a copy of it that can drift.
|
|
|
|
python3 - "$service" <<'PY' || fail 'the schedule window arithmetic is wrong'
|
|
import re, sys
|
|
from datetime import datetime
|
|
|
|
source = open(sys.argv[1]).read()
|
|
|
|
# Re-implemented from the service's own rules, and cross-checked against its
|
|
# text below so the two cannot silently diverge.
|
|
def minutes_of(text):
|
|
m = re.match(r'^(\d{1,2}):(\d{2})$', str(text or ''))
|
|
if not m:
|
|
return -1
|
|
h, mi = int(m.group(1)), int(m.group(2))
|
|
return -1 if h > 23 or mi > 59 else h * 60 + mi
|
|
|
|
def within(trigger, when):
|
|
start, end = minutes_of(trigger.get('start')), minutes_of(trigger.get('end'))
|
|
if start < 0 or end < 0 or start == end:
|
|
return False
|
|
days = [int(d) for d in (trigger.get('days') or [])]
|
|
if not days:
|
|
return False
|
|
day = (when.weekday() + 1) % 7
|
|
mins = when.hour * 60 + when.minute
|
|
if start < end:
|
|
return day in days and start <= mins < end
|
|
return (day in days and mins >= start) or ((day + 6) % 7 in days and mins < end)
|
|
|
|
for needle, why in [
|
|
('const yesterday = (day + 6) % 7', 'the midnight-crossing rule'),
|
|
('return -1', 'the malformed-time guard'),
|
|
('start === end', 'the zero-length window guard'),
|
|
]:
|
|
if needle not in source:
|
|
raise SystemExit(f'{why} is missing from the service')
|
|
|
|
ALL, FRI, WEEK = [0,1,2,3,4,5,6], [5], [1,2,3,4,5]
|
|
def dt(s): return datetime.strptime(s, "%Y-%m-%d %H:%M")
|
|
|
|
cases = [
|
|
({'start':'23:30','end':'07:00','days':FRI}, "2026-08-21 23:45", True, "Friday night"),
|
|
({'start':'23:30','end':'07:00','days':FRI}, "2026-08-22 06:00", True, "Saturday morning belongs to Friday"),
|
|
({'start':'23:30','end':'07:00','days':FRI}, "2026-08-22 23:45", False, "Saturday night must NOT be quiet"),
|
|
({'start':'23:30','end':'07:00','days':FRI}, "2026-08-23 06:00", False, "Sunday morning must NOT be quiet"),
|
|
({'start':'23:30','end':'07:00','days':ALL}, "2026-08-22 08:00", False, "after the window"),
|
|
({'start':'09:00','end':'17:00','days':WEEK}, "2026-08-24 10:00", True, "a workday"),
|
|
({'start':'09:00','end':'17:00','days':WEEK}, "2026-08-23 10:00", False, "a Sunday is not"),
|
|
({'start':'09:00','end':'17:00','days':WEEK}, "2026-08-24 17:00", False, "the end is exclusive"),
|
|
({'start':'','end':'07:00','days':ALL}, "2026-08-21 23:45", False, "malformed start is off"),
|
|
({'start':'25:00','end':'07:00','days':ALL}, "2026-08-21 23:45", False, "an impossible hour is off"),
|
|
({'start':'09:00','end':'09:00','days':ALL}, "2026-08-24 09:00", False, "a zero-length window is off"),
|
|
({'start':'23:30','end':'07:00','days':[]}, "2026-08-21 23:45", False, "no days enabled is off"),
|
|
]
|
|
for trigger, when, expected, why in cases:
|
|
if within(trigger, dt(when)) != expected:
|
|
raise SystemExit(f'{why}: {when} should be {expected}')
|
|
PY
|
|
|
|
# ── 4. One owner for Do Not Disturb ─────────────────────────────────────────
|
|
|
|
grep -q 'if (FocusSession.active)' "$service" \
|
|
|| fail 'modes do not defer to a running manual session, so both would write Do Not Disturb'
|
|
grep -q 'root.previousDnd = Notifs.doNotDisturb' "$service" \
|
|
|| fail 'nothing records what Do Not Disturb was before a mode took over'
|
|
|
|
# ── 5. The gaming hook reports rather than acts ─────────────────────────────
|
|
|
|
grep -q 'gameStarted' "$hook" \
|
|
|| fail 'the gaming hook does not tell the shell a game started'
|
|
grep -q 'setDnd' "$hook" \
|
|
&& fail 'the gaming hook still sets Do Not Disturb itself, so there are two owners again'
|
|
# Matched as a declaration, not as prose: the schema comment explains what the
|
|
# Gaming mode replaced, and naming the retired key there must not trip this.
|
|
grep -q 'key: "gamingSilenceNotifications"' "$schema" \
|
|
&& fail 'the retired gaming setting is still in the schema, where nothing reads it'
|
|
grep -q 'FocusModes' "$gaming_page" \
|
|
|| fail 'the Gaming page does not point at the mode that replaced its switch'
|
|
|
|
# ── 6. An exception list that is actually consulted ─────────────────────────
|
|
#
|
|
# This rule exists because the field shipped before the enforcement did: the
|
|
# summary would say "2 apps may interrupt" while nothing anywhere read the list.
|
|
# A settings page that states something untrue is worse than one missing the
|
|
# feature, so the claim and the behaviour are asserted together.
|
|
|
|
notifs="$repo_dir/config/dot/quickshell/services/Notifs.qml"
|
|
page="$repo_dir/config/dot/quickshell/modules/settings/NotificationsPage.qml"
|
|
|
|
# Matched on the gate itself, not the name. The comment beside it explains why
|
|
# a manual Do Not Disturb has no exceptions, and naming the function there must
|
|
# not be able to satisfy this check -- which it did, on the first attempt.
|
|
grep -q 'doNotDisturb || FocusModes.allows(' "$notifs" \
|
|
|| fail 'the banner gate never consults the active mode, so an exception list would do nothing'
|
|
grep -q 'function allows' "$service" \
|
|
|| fail 'there is no way to ask whether an application is excepted'
|
|
|
|
# Exceptions belong to a mode. A Do Not Disturb switched on by hand must stay
|
|
# absolute, which holds only because allowedApps is empty with no mode active.
|
|
grep -q 'if (!mode || mode.silence !== true)' "$service" \
|
|
|| fail 'exceptions are not scoped to an active silencing mode, so a manual Do Not Disturb could leak'
|
|
|
|
# The editor moved from the Notifications page to the Focus tab when the
|
|
# category split in two, and the accordion row and its chips became their own
|
|
# components. What must hold is unchanged: somewhere a person can reach, the
|
|
# list the gate above consults can be edited. Asserted over the whole Focus
|
|
# surface rather than the page file, so pulling a row into a component does
|
|
# not read as the feature being deleted.
|
|
focus_surface=("$focus_page" "$repo_dir"/config/dot/quickshell/modules/settings/Focus*.qml)
|
|
|
|
focus_surface_has() {
|
|
grep -qF "$1" "${focus_surface[@]}"
|
|
}
|
|
|
|
focus_surface_has 'FocusAllowChips' \
|
|
|| fail 'nothing on the Focus page renders the exception chips, so the list is unreachable'
|
|
focus_surface_has 'FocusModes.update(' \
|
|
|| fail 'the Focus page never writes a mode back, so nothing it shows can be edited'
|
|
focus_surface_has 'allow:' \
|
|
|| fail 'the exception list cannot be edited, so the summary could claim something unreachable'
|
|
# The chips are only honest if removing one writes the shorter list back.
|
|
focus_surface_has 'toggled(' \
|
|
|| fail 'the chips report nothing, so removing an application from a mode would change only the chip'
|
|
|
|
# ── 7. A mode you can make, name, order and retrigger ───────────────────────
|
|
#
|
|
# The five modes used to be whatever the schema shipped: the page could toggle
|
|
# them and edit a schedule, and nothing else. Everything below is new surface
|
|
# on a service whose entire job is to silence a machine, so each entry point is
|
|
# pinned by name and the two that compute something are replayed.
|
|
|
|
for required in createMode removeMode renameMode moveMode setTriggerKind seedTrigger uniqueId hasMode; do
|
|
grep -q "function $required" "$service" \
|
|
|| fail "there is no $required(), so the editor would be calling something that does not exist"
|
|
done
|
|
|
|
# Manual modes did not gain an activation path when the editor did. A manual
|
|
# mode is never `activeMode`, because triggerActive() answers false for it and
|
|
# `automatic` filters it out -- FocusSession owns the by-hand session, which is
|
|
# rule 4 above. An editor that quietly made "manual" mean "on" would be a
|
|
# second owner for Do Not Disturb wearing a new name.
|
|
grep -q 'trigger.kind !== "manual"' "$service" \
|
|
|| fail 'manual modes are no longer excluded from the automatic list, so switching one on would silence the machine behind FocusSession'
|
|
grep -q '"manual" included: FocusSession owns those' "$service" \
|
|
|| fail 'the manual fall-through lost the note explaining why it is not an activation path'
|
|
|
|
# Order is priority -- activeMode takes the first matching mode -- so reordering
|
|
# is a behaviour change and the page has to say so rather than looking like a
|
|
# cosmetic sort.
|
|
grep -qi 'priority' "$focus_page" \
|
|
|| fail 'the Focus page never says the order is the priority, so dragging a row reads as decoration'
|
|
|
|
PANAMA_FOCUS_SERVICE="$service" bun -e '
|
|
const source = await Bun.file(process.env.PANAMA_FOCUS_SERVICE).text();
|
|
|
|
function fail(message) {
|
|
console.error(`focus modes contract: ${message}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Same extraction the schedule arithmetic uses above, so these replay the
|
|
// shipped function rather than a copy that can drift.
|
|
function functionBody(name) {
|
|
const start = source.indexOf(`function ${name}(`);
|
|
if (start === -1)
|
|
fail(`missing ${name}()`);
|
|
const open = source.indexOf("{", start);
|
|
let depth = 0;
|
|
for (let index = open; index < source.length; index++) {
|
|
if (source[index] === "{") depth++;
|
|
if (source[index] === "}" && --depth === 0)
|
|
return source.slice(open + 1, index);
|
|
}
|
|
fail(`${name}() is unterminated`);
|
|
}
|
|
|
|
const seedTrigger = Function("kind", "fields", functionBody("seedTrigger"));
|
|
const uniqueIdBody = Function("root", "name", functionBody("uniqueId"));
|
|
const stubRoot = modes => ({
|
|
modes: modes,
|
|
hasMode: id => modes.some(mode => mode.id === id)
|
|
});
|
|
|
|
function same(actual, expected, why) {
|
|
if (JSON.stringify(actual) !== JSON.stringify(expected))
|
|
fail(`${why}: got ${JSON.stringify(actual)}, expected ${JSON.stringify(expected)}`);
|
|
}
|
|
|
|
// A trigger is seeded so that a mode is never saved meaning nothing. A
|
|
// schedule with no days, or a workspace trigger with no workspace, is a mode
|
|
// that silently never turns on -- the failure that is hardest to notice,
|
|
// because nothing happens.
|
|
same(seedTrigger("schedule", undefined),
|
|
{ kind: "schedule", start: "22:00", end: "07:00", days: [0, 1, 2, 3, 4, 5, 6] },
|
|
"a fresh schedule trigger");
|
|
same(seedTrigger("schedule", { start: "23:15", days: [5] }),
|
|
{ kind: "schedule", start: "23:15", end: "07:00", days: [5] },
|
|
"a schedule keeps what it was handed and defaults the rest");
|
|
same(seedTrigger("workspace", undefined), { kind: "workspace", id: 1 },
|
|
"a fresh workspace trigger");
|
|
same(seedTrigger("workspace", { id: "3" }), { kind: "workspace", id: 3 },
|
|
"a workspace id arriving as text");
|
|
same(seedTrigger("fullscreen", undefined), { kind: "fullscreen" },
|
|
"a fullscreen trigger carries no fields");
|
|
same(seedTrigger("game", undefined), { kind: "game" }, "a game trigger carries no fields");
|
|
same(seedTrigger("manual", undefined), { kind: "manual" }, "a manual trigger carries no fields");
|
|
|
|
// An unknown kind is refused rather than stored. triggerActive() reads an
|
|
// unknown kind as off, so storing one would be a mode that can never turn on.
|
|
for (const kind of ["", "sometimes", undefined, null]) {
|
|
if (seedTrigger(kind, undefined) !== null)
|
|
fail(`an unknown trigger kind ${JSON.stringify(kind)} was seeded instead of refused`);
|
|
}
|
|
|
|
// Ids are derived from the name and made unique by suffix. A duplicate id
|
|
// would make removeMode and update() act on whichever came first.
|
|
const existing = [{ id: "deep-work" }, { id: "deep-work-2" }, { id: "sleep" }];
|
|
for (const [name, expected, why] of [
|
|
["Reading", "reading", "a plain name"],
|
|
[" Sleep ", "sleep-2", "a name whose id is taken"],
|
|
["Deep work", "deep-work-3", "a name whose first two ids are taken"],
|
|
["!!!", "mode", "a name with nothing usable in it"],
|
|
["", "mode", "an empty name"]
|
|
]) {
|
|
const actual = uniqueIdBody(stubRoot(existing), name);
|
|
if (actual !== expected)
|
|
fail(`${why}: uniqueId gave ${JSON.stringify(actual)}, expected ${JSON.stringify(expected)}`);
|
|
}
|
|
|
|
console.log("focus modes API: ok");
|
|
' >/dev/null || fail 'the mode editor API does not behave as the Focus page assumes'
|
|
|
|
printf 'focus modes contract: ok\n'
|