513 lines
19 KiB
JavaScript
513 lines
19 KiB
JavaScript
// 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 GLib’s 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 GLib’s 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();
|
||
});
|
||
});
|