Merge branch 'break-reminders' into 'main'

breakManager: Add new state machine for screen time/health breaks

See merge request GNOME/gnome-shell!3251
This commit is contained in:
Philip Withnall 2024-06-20 00:16:50 +00:00
commit 147179e697
14 changed files with 1933 additions and 2 deletions

View file

@ -4,6 +4,7 @@ dbus_interfaces = [
'org.gnome.Shell.PadOsd.xml',
'org.gnome.Shell.Screencast.xml',
'org.gnome.Shell.Screenshot.xml',
'org.gnome.Shell.ScreenTime.xml',
'org.gnome.ShellSearchProvider.xml',
'org.gnome.ShellSearchProvider2.xml'
]

View file

@ -0,0 +1,38 @@
<!DOCTYPE node PUBLIC
'-//freedesktop//DTD D-BUS Object Introspection 1.0//EN'
'http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd'>
<!--
SPDX-License-Identifier: GPL-3.0-or-later
SPDX-FileCopyrightText: 2024 GNOME Foundation, Inc.
-->
<node>
<!--
org.gnome.Shell.ScreenTime:
@short_description: Screen Time interface
The interface used to access Screen Time and Break Reminders usage data.
-->
<interface name="org.gnome.Shell.ScreenTime">
<!--
State:
The state of the break manager.
- 0: Break reminders are disabled.
- 1: Break reminders are enabled, user is active, no break is needed yet.
- 2: A break is needed and the user is taking it.
- 3: A break is needed but the user is still active.
-->
<property name="State" type="u" access="read"/>
<!--
LastBreakEndTime:
The time (in wall clock seconds since the Unix epoch) when the most
recent break ended. If there have been no breaks so far, zero is
returned.
-->
<property name="LastBreakEndTime" type="t" access="read"/>
</interface>
</node>

View file

@ -51,6 +51,7 @@
<file preprocess="xml-stripblanks">org.gnome.Shell.PortalHelper.xml</file>
<file preprocess="xml-stripblanks">org.gnome.Shell.Screencast.xml</file>
<file preprocess="xml-stripblanks">org.gnome.Shell.Screenshot.xml</file>
<file preprocess="xml-stripblanks">org.gnome.Shell.ScreenTime.xml</file>
<file preprocess="xml-stripblanks">org.gnome.Shell.Wacom.PadOsd.xml</file>
<file preprocess="xml-stripblanks">org.gnome.Shell.WeatherIntegration.xml</file>
<file preprocess="xml-stripblanks">org.gnome.Shell.xml</file>

View file

@ -51,6 +51,7 @@
<file preprocess="xml-stripblanks">scalable/status/screen-privacy-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/status/switch-on-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/status/switch-off-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/status/wellbeing-symbolic.svg</file>
</gresource>
</gresources>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
SPDX-License-Identifier: CC0-1.0
SPDX-FileCopyrightText: 2024 Jakub Steiner
-->
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<g fill="#222222">
<path d="m 3.3125 13.683594 c 0.011719 0.007812 0.023438 0.015625 0.039062 0.019531 l 4.195313 2.179687 c 0.289063 0.152344 0.636719 0.152344 0.925781 0 l 4.152344 -2.179687 c 0.019531 -0.007813 0.039062 -0.019531 0.058594 -0.03125 c 3.988281 -2.449219 2.25 -7.671875 -1.703125 -7.671875 c -1.011719 0 -1.75 0.441406 -2.238281 1.128906 c -0.894532 1.257813 -0.691407 1.277344 -1.621094 0.042969 c -0.554688 -0.730469 -1.320313 -1.203125 -2.128906 -1.171875 c -3.996094 0 -5.652344 5.390625 -1.679688 7.683594 z m 7.667969 -5.6875 c 1.726562 0 3.132812 2.449218 0.65625 3.96875 l 0.058593 -0.035156 l -4.152343 2.179687 h 0.925781 l -4.195312 -2.179687 l 0.039062 0.023437 c -2.386719 -1.378906 -1.097656 -3.957031 0.679688 -3.957031 c 1.378906 0 2.039062 0.945312 2.320312 1.535156 c 0.117188 0.25 0.398438 0.46875 0.675781 0.46875 h 0.011719 c 0.277344 0 0.558594 -0.21875 0.675781 -0.46875 c 0.28125 -0.589844 0.945313 -1.535156 2.304688 -1.535156 z m 0 0"/>
<path d="m 8 4 c -0.546875 0 -1 -0.453125 -1 -1 s 0.453125 -1 1 -1 s 1 0.453125 1 1 s -0.453125 1 -1 1 z m 0 -4 c -1.660156 0 -3 1.339844 -3 3 s 1.339844 3 3 3 s 3 -1.339844 3 -3 s -1.339844 -3 -3 -3 z m 0 0"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -35,7 +35,7 @@ $osd_levelbar_height:6px;
}
// Monitor number label
.osd-monitor-label {
.osd-monitor-label, .osd-break-countdown-label {
background-color: $selected_bg_color;
color: $selected_fg_color;
border-radius: $modal_radius;

View file

@ -15,6 +15,7 @@
<file>extensions/sharedInternals.js</file>
<file>misc/animationUtils.js</file>
<file>misc/breakManager.js</file>
<file>misc/config.js</file>
<file>misc/dateUtils.js</file>
<file>misc/dbusErrors.js</file>

1312
js/misc/breakManager.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -10,6 +10,7 @@ import St from 'gi://St';
import * as AccessDialog from './accessDialog.js';
import * as AudioDeviceSelection from './audioDeviceSelection.js';
import * as BreakManager from '../misc/breakManager.js';
import * as Config from '../misc/config.js';
import * as Components from './components.js';
import * as CtrlAltTab from './ctrlAltTab.js';
@ -91,6 +92,9 @@ export let inputMethod = null;
export let introspectService = null;
export let locatePointer = null;
export let endSessionDialog = null;
export let breakManager = null;
export let screenTimeDBus = null;
export let breakManagerDispatcher = null;
let _startDate;
let _defaultCssStylesheet = null;
@ -246,6 +250,11 @@ async function _initializeUI() {
introspectService = new Introspect.IntrospectService();
// Set up the global default break reminder manager and its D-Bus interface
breakManager = BreakManager.getDefault();
screenTimeDBus = new ShellDBus.ScreenTimeDBus(breakManager);
breakManagerDispatcher = new BreakManager.BreakDispatcher(breakManager);
layoutManager.init();
overview.init();

View file

@ -441,6 +441,16 @@ export class Notification extends GObject.Object {
});
this._actions.push(action);
this.emit('action-added', action);
return action;
}
removeAction(action) {
const index = this._actions.indexOf(action);
if (index < 0)
throw new Error('Action was already removed previously');
this._actions.splice(index, 1);
this.emit('action-removed', action);
}
clearActions() {
@ -494,6 +504,10 @@ export const Source = GObject.registerClass({
'policy', 'policy', 'policy',
GObject.ParamFlags.READWRITE,
NotificationPolicy.$gtype),
'allow-empty': GObject.ParamSpec.boolean(
'allow-empty', 'allow-empty', 'allow-empty',
GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
false),
},
Signals: {
'destroy': {param_types: [GObject.TYPE_UINT]},
@ -552,7 +566,7 @@ export const Source = GObject.registerClass({
this.emit('notification-removed', notification);
this.countUpdated();
if (!this._inDestruction && this.notifications.length === 0)
if (!this._inDestruction && this.notifications.length === 0 && !this.allowEmpty)
this.destroy();
}

View file

@ -17,6 +17,7 @@ import {ControlsState} from './overviewControls.js';
const GnomeShellIface = loadInterfaceXML('org.gnome.Shell');
const ScreenSaverIface = loadInterfaceXML('org.gnome.ScreenSaver');
const ScreenTimeIface = loadInterfaceXML('org.gnome.Shell.ScreenTime');
export class GnomeShell {
constructor() {
@ -544,3 +545,30 @@ export class ScreenSaverDBus {
return 0;
}
}
export class ScreenTimeDBus {
constructor(breakManager) {
this._manager = breakManager;
this._manager.connect('notify::state', this._onNotify.bind(this));
this._manager.connect('notify::last-break-end-time', this._onNotify.bind(this));
this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(ScreenTimeIface, this);
this._dbusImpl.export(Gio.DBus.session, '/org/gnome/Shell/ScreenTime');
}
_onNotify() {
// We always want to notify the two properties together, as clients need
// both values to be useful. GJS will combine the two emissions for us.
this._dbusImpl.emit_property_changed('State', new GLib.Variant('u', this.State));
this._dbusImpl.emit_property_changed('LastBreakEndTime', new GLib.Variant('t', this.LastBreakEndTime));
}
get State() {
return this._manager.state;
}
get LastBreakEndTime() {
return this._manager.lastBreakEndTime;
}
}

View file

@ -11,6 +11,7 @@ js/dbusServices/extensions/ui/extension-error-page.ui
js/gdm/authPrompt.js
js/gdm/loginDialog.js
js/gdm/util.js
js/misc/breakManager.js
js/misc/systemActions.js
js/misc/util.js
js/misc/dateUtils.js

View file

@ -20,6 +20,7 @@ unit_testenv.append('GI_TYPELIB_PATH', shell_typelib_path, separator: ':')
unit_testenv.append('GI_TYPELIB_PATH', st_typelib_path, separator: ':')
unit_tests = [
'breakManager',
'highlighter',
'injectionManager',
'insertSorted',

513
tests/unit/breakManager.js Normal file
View file

@ -0,0 +1,513 @@
// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
//
// 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.BreakState;
// 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 {
get_real_time_secs: () => {
return this._currentTimeSecs;
},
timeout_add_seconds: (priority, intervalSecs, callback) => {
return this.addTimeoutEvent(intervalSecs, callback).sourceId;
},
source_remove: 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];
},
};
},
new_with_path: (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);
});
// 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();
});
});