gnome-shell/tests/unit/breakManager.js
Philip Withnall df3b4d302d breakManager: Add new state machine for screen time/health breaks
This implements health break reminder support in gnome-shell. It depends
on a
few bits and bobs from other modules:
 - New settings schemas in gsettings-desktop-schemas (released in
   47.beta, which Mutter already depends on)
 - A settings UI in gnome-control-center
 - User documentation in gnome-user-docs

It implements the design from
https://gitlab.gnome.org/Teams/Design/settings-mockups/-/blob/master/wellbeing/wellbeing.png.

The core of the implementation is `BreakManager`, which is a state
machine which uses the Mutter `IdleMonitor` to track whether the user
is, or should be, in a screen time break.

The `BreakDispatcher` is based on top of this, and controls showing
notifications, countdown timers, screen fades, the lock shield, etc. to
make the user aware of upcoming or due breaks, as per their notification
preferences.

Unit tests are included to check that `BreakManager` works. These
provide mock implementations of basic GLib clock functions, the
`IdleMonitor` and `Gio.Settings` in order to test the state machine in
faster-than-real-time.

Signed-off-by: Philip Withnall <pwithnall@gnome.org>

See: https://gitlab.gnome.org/Teams/Design/initiatives/-/issues/130
Part-of: <https://gitlab.gnome.org/GNOME/gnome-shell/-/merge_requests/3251>
2024-11-28 10:44:10 +00:00

514 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Copyright 2024 GNOME Foundation, Inc.
//
// This is a GNOME Shell component to support break reminders and screen time
// statistics.
//
// Licensed under the GNU General Public License Version 2
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// SPDX-License-Identifier: GPL-2.0-or-later
import 'resource:///org/gnome/shell/ui/environment.js';
import GLib from 'gi://GLib';
import * as BreakManager from 'resource:///org/gnome/shell/misc/breakManager.js';
// Convenience alias
const {BreakState} = BreakManager;
// A harness for testing the BreakManager class. It simulates the passage of
// time, maintaining an internal ordered queue of events, and providing three
// groups of mock functions which the BreakManager uses to interact with it:
// a mock version of the IdleMonitor, mock versions of GLibs clock and timeout
// functions, and a mock version of Gio.Settings.
//
// The internal ordered queue of events is sorted by time (in seconds since an
// arbitrary epoch; the tests arbitrarily start from 100s to avoid potential
// issues around time zero). On each _tick(), the next event is shifted off the
// head of the queue and processed. An event might be a simulated idle watch
// (mocking the user being idle), a simulated active watch, a scheduled timeout,
// or an assertion function for actually running test assertions.
//
// The simulated clock jumps from the scheduled time of one event to the
// scheduled time of the next. This way, we can simulate half an hour of active
// time (waiting for the next rest break to be due) on the computer instantly.
class TestHarness {
constructor(settings) {
this._currentTimeSecs = 100;
this._nextSourceId = 1;
this._events = [];
this._idleWatch = null;
this._activeWatch = null;
this._settings = settings;
}
_allocateSourceId() {
const sourceId = this._nextSourceId;
this._nextSourceId++;
return sourceId;
}
_removeEventBySourceId(sourceId) {
const idx = this._events.findIndex(a => {
return a.sourceId === sourceId;
});
console.assert(idx !== -1);
this._events.splice(idx, 1);
}
_insertEvent(event) {
this._events.push(event);
this._events.sort((a, b) => {
return a.time - b.time;
});
return event;
}
// Add a timeout event to the event queue. It will be scheduled at the
// current simulated time plus `intervalSecs`. `callback` will be invoked
// when the event is processed.
addTimeoutEvent(intervalSecs, callback) {
return this._insertEvent({
type: 'timeout',
time: this._currentTimeSecs + intervalSecs,
callback,
sourceId: this._allocateSourceId(),
intervalSecs,
});
}
// Add an idle watch event to the event queue. This simulates the user
// becoming idle (no keyboard or mouse input) at time `timeSecs`.
addIdleEvent(timeSecs) {
return this._insertEvent({
type: 'idle',
time: timeSecs,
});
}
// Add an active watch event to the event queue. This simulates the user
// becoming active (using the keyboard or mouse after a period of
// inactivity) at time `timeSecs`.
addActiveEvent(timeSecs) {
return this._insertEvent({
type: 'active',
time: timeSecs,
});
}
// Add a delay action invocation to the event queue. This simulates the user
// invoking the delay action (typically via a notification) at time
// `timeSecs`.
addDelayAction(timeSecs, breakManager) {
return this._insertEvent({
type: 'action',
time: timeSecs,
callback: () => {
breakManager.delayBreak();
},
});
}
// Add a skip action invocation to the event queue. This simulates the user
// invoking the skip action (typically via a notification) at time
// `timeSecs`.
addSkipAction(timeSecs, breakManager) {
return this._insertEvent({
type: 'action',
time: timeSecs,
callback: () => {
breakManager.skipBreak();
},
});
}
// Add a take action invocation to the event queue. This simulates the user
// invoking the take action (typically via a notification) at time
// `timeSecs`.
addTakeAction(timeSecs, breakManager) {
return this._insertEvent({
type: 'action',
time: timeSecs,
callback: () => {
breakManager.takeBreak();
},
});
}
// Add an assertion event to the event queue. This is a callback which is
// invoked when the simulated clock reaches `timeSecs`. The callback can
// contain whatever test assertions you like.
addAssertionEvent(timeSecs, callback) {
return this._insertEvent({
type: 'assertion',
time: timeSecs,
callback,
});
}
// Add a state assertion event to the event queue. This is a specialised
// form of `addAssertionEvent()` which asserts that the `BreakManager.state`
// equals `state` at time `timeSecs`.
expectState(timeSecs, breakManager, expectedState) {
return this.addAssertionEvent(timeSecs, () => {
expect(BreakManager.breakStateToString(breakManager.state))
.withContext(`${timeSecs}s state`)
.toEqual(BreakManager.breakStateToString(expectedState));
});
}
// Add a state assertion event to the event queue. This is a specialised
// form of `addAssertionEvent()` which asserts that the given `BreakManager`
// properties equal the expected values at time `timeSecs`.
expectProperties(timeSecs, breakManager, expectedProperties) {
return this.addAssertionEvent(timeSecs, () => {
for (const [name, expectedValue] of Object.entries(expectedProperties)) {
expect(breakManager[name])
.withContext(`${timeSecs}s ${name}`)
.toEqual(expectedValue);
}
});
}
_popEvent() {
return this._events.shift();
}
// Get a mock clock object for use in the `BreakManager` under test.
// This provides a basic implementation of GLibs clock and timeout
// functions which use the simulated clock and event queue.
get mockClock() {
return {
getRealTimeSecs: () => {
return this._currentTimeSecs;
},
timeoutAddSeconds: (priority, intervalSecs, callback) => {
return this.addTimeoutEvent(intervalSecs, callback).sourceId;
},
sourceRemove: sourceId => {
this._removeEventBySourceId(sourceId);
},
};
}
// Get a mock idle monitor object for use in the `BreakManager` under test.
// This provides a basic implementation of the `IdleMonitor` which uses the
// simulated clock and event queue.
get mockIdleMonitor() {
return {
add_idle_watch: (waitMsec, callback) => {
console.assert(this._idleWatch === null);
this._idleWatch = {
waitMsec,
callback,
};
return 1;
},
add_user_active_watch: callback => {
console.assert(this._activeWatch === null);
this._activeWatch = callback;
return 2;
},
remove_watch: id => {
console.assert(id === 1 || id === 2);
if (id === 1)
this._idleWatch = null;
else if (id === 2)
this._activeWatch = null;
},
};
}
// Get a mock settings factory for use in the `BreakManager` under test.
// This is an object providing a couple of constructors for `Gio.Settings`
// objects. Each constructor returns a basic implementation of
// `Gio.Settings` which uses the settings dictionary passed to `TestHarness`
// in its constructor.
// This necessarily has an extra layer of indirection because there are
// multiple ways to construct a `Gio.Settings`.
get mockSettingsFactory() {
return {
new: schemaId => {
return {
connect: (unusedSignalName, unusedCallback) => {
/* no-op for mock purposes */
return 1;
},
get_boolean: key => {
return this._settings[schemaId][key];
},
get_strv: key => {
return this._settings[schemaId][key];
},
get_uint: key => {
return this._settings[schemaId][key];
},
};
},
newWithPath: (schemaId, unusedPath) => {
return {
connect: (unusedSignalName, unusedCallback) => {
/* no-op for mock purposes */
return 1;
},
get_boolean: key => {
return this._settings[schemaId][key];
},
get_strv: key => {
return this._settings[schemaId][key];
},
get_uint: key => {
return this._settings[schemaId][key];
},
};
},
};
}
_tick() {
const event = this._popEvent();
if (!event)
return false;
this._currentTimeSecs = event.time;
switch (event.type) {
case 'timeout':
if (event.callback()) {
event.time += event.intervalSecs;
this._insertEvent(event);
}
break;
case 'idle':
if (this._idleWatch)
this._idleWatch.callback();
break;
case 'active':
if (this._activeWatch) {
this._activeWatch();
this._activeWatch = null; // one-shot
}
break;
case 'action':
event.callback();
break;
case 'assertion':
event.callback();
break;
default:
console.assert(false, 'not reached');
}
return true;
}
// Run the test in a loop, blocking until all events are processed or an
// exception is raised.
run() {
const loop = new GLib.MainLoop(null, false);
let innerException = null;
GLib.idle_add(GLib.PRIORITY_DEFAULT, () => {
try {
if (this._tick())
return GLib.SOURCE_CONTINUE;
loop.quit();
return GLib.SOURCE_REMOVE;
} catch (e) {
// Quit the main loop then re-raise the exception
loop.quit();
innerException = e;
return GLib.SOURCE_REMOVE;
}
});
loop.run();
// Did we exit with an exception?
if (innerException)
throw innerException;
}
}
describe('Break manager', () => {
it('can be disabled via GSettings', () => {
const harness = new TestHarness({
'org.gnome.desktop.break-reminders': {
'selected-breaks': [],
},
});
const breakManager = new BreakManager.BreakManager(harness.mockClock, harness.mockIdleMonitor, harness.mockSettingsFactory);
harness.addActiveEvent(101);
harness.expectState(102, breakManager, BreakState.DISABLED);
harness.addIdleEvent(130);
harness.expectState(135, breakManager, BreakState.DISABLED);
harness.run();
});
// A simple test which simulates the user being active briefly, taking a short
// break before one is due, and then being active again until their next break
// is overdue.
it('tracks a single break type', () => {
const harness = new TestHarness({
'org.gnome.desktop.break-reminders': {
'selected-breaks': ['movement'],
},
'org.gnome.desktop.break-reminders.movement': {
'duration-seconds': 300, /* 5 minutes */
'interval-seconds': 1800, /* 30 minutes */
'delay-seconds': 300, /* 5 minutes */
'notify': true,
'play-sound': false,
'fade-screen': false,
},
});
const breakManager = new BreakManager.BreakManager(harness.mockClock, harness.mockIdleMonitor, harness.mockSettingsFactory);
harness.addActiveEvent(101);
harness.expectState(102, breakManager, BreakState.ACTIVE);
harness.addIdleEvent(130);
harness.expectState(135, breakManager, BreakState.IDLE);
harness.addActiveEvent(200); // cut the break short before its duration
harness.expectState(201, breakManager, BreakState.ACTIVE);
harness.expectProperties(2001, breakManager, { // break is due after 30 mins
'state': BreakState.BREAK_DUE,
'currentBreakType': 'movement',
'currentBreakStartTime': 0,
'lastBreakEndTime': 100,
});
harness.addIdleEvent(2005);
harness.expectProperties(2006, breakManager, {
'state': BreakState.IN_BREAK,
'currentBreakType': 'movement',
'currentBreakStartTime': 1900,
'lastBreakEndTime': 0,
});
harness.expectState(2195, breakManager, BreakState.IN_BREAK); // near the end of the break
harness.expectState(2210, breakManager, BreakState.IDLE); // just after the end of the break
harness.addActiveEvent(2320);
harness.expectProperties(2321, breakManager, {
'state': BreakState.ACTIVE,
'currentBreakType': null,
'currentBreakStartTime': 0,
'lastBreakEndTime': 2320,
});
harness.addIdleEvent(4100); // start the next break a little early
harness.expectState(4101, breakManager, BreakState.IDLE);
harness.expectState(4121, breakManager, BreakState.IN_BREAK);
harness.addActiveEvent(4420);
harness.expectProperties(4421, breakManager, {
'state': BreakState.ACTIVE,
'currentBreakType': null,
'currentBreakStartTime': 0,
'lastBreakEndTime': 4420,
});
harness.run();
});
// Test requesting to delay a break.
it('supports delaying a break', () => {
const harness = new TestHarness({
'org.gnome.desktop.break-reminders': {
'selected-breaks': ['movement'],
},
'org.gnome.desktop.break-reminders.movement': {
'duration-seconds': 300, /* 5 minutes */
'interval-seconds': 1800, /* 30 minutes */
'delay-seconds': 300, /* 5 minutes */
'notify': true,
'play-sound': false,
'fade-screen': false,
},
});
const breakManager = new BreakManager.BreakManager(harness.mockClock, harness.mockIdleMonitor, harness.mockSettingsFactory);
harness.addActiveEvent(101);
harness.expectState(102, breakManager, BreakState.ACTIVE);
harness.expectProperties(1901, breakManager, { // break is due after 30 mins
'state': BreakState.BREAK_DUE,
'currentBreakType': 'movement',
'currentBreakStartTime': 0,
'lastBreakEndTime': 100,
});
harness.addDelayAction(1902, breakManager);
harness.expectProperties(1903, breakManager, { // break is delayed by 5 mins
'state': BreakState.ACTIVE,
'currentBreakType': null,
'currentBreakStartTime': 0,
'lastBreakEndTime': 400,
});
harness.expectProperties(2201, breakManager, { // break is due after another 5 mins
'state': BreakState.BREAK_DUE,
'currentBreakType': 'movement',
'currentBreakStartTime': 0,
'lastBreakEndTime': 400,
});
harness.addIdleEvent(2202);
harness.expectState(2203, breakManager, BreakState.IN_BREAK);
harness.run();
});
// Test requesting to skip a break.
it('supports skipping a break', () => {
const harness = new TestHarness({
'org.gnome.desktop.break-reminders': {
'selected-breaks': ['movement'],
},
'org.gnome.desktop.break-reminders.movement': {
'duration-seconds': 300, /* 5 minutes */
'interval-seconds': 1800, /* 30 minutes */
'delay-seconds': 300, /* 5 minutes */
'notify': true,
'play-sound': false,
'fade-screen': false,
},
});
const breakManager = new BreakManager.BreakManager(harness.mockClock, harness.mockIdleMonitor, harness.mockSettingsFactory);
harness.addActiveEvent(101);
harness.expectState(102, breakManager, BreakState.ACTIVE);
harness.expectProperties(1901, breakManager, { // break is due after 30 mins
'state': BreakState.BREAK_DUE,
'currentBreakType': 'movement',
'currentBreakStartTime': 0,
'lastBreakEndTime': 100,
});
harness.addSkipAction(1902, breakManager);
harness.expectProperties(1903, breakManager, { // break is skipped for 30 mins
'state': BreakState.ACTIVE,
'currentBreakType': null,
'currentBreakStartTime': 0,
'lastBreakEndTime': 1902,
});
harness.expectProperties(3703, breakManager, { // break is due after another 30 mins
'state': BreakState.BREAK_DUE,
'currentBreakType': 'movement',
'currentBreakStartTime': 0,
'lastBreakEndTime': 1902,
});
harness.addIdleEvent(3704);
harness.expectState(3704, breakManager, BreakState.IN_BREAK);
harness.run();
});
});