diff --git a/data/dbus-interfaces/org.freedesktop.MalcontentTimer1.Child.xml b/data/dbus-interfaces/org.freedesktop.MalcontentTimer1.Child.xml new file mode 100644 index 0000000000000000000000000000000000000000..ee5e714ba617f86e27d2522a5c85c340a79b99b4 --- /dev/null +++ b/data/dbus-interfaces/org.freedesktop.MalcontentTimer1.Child.xml @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/data/gnome-shell-dbus-interfaces.gresource.xml b/data/gnome-shell-dbus-interfaces.gresource.xml index 714e65a0c9c858670595c9c2db77f1668482bace..01c11f2e652151a8b72b73465b48422b12b4e5f4 100644 --- a/data/gnome-shell-dbus-interfaces.gresource.xml +++ b/data/gnome-shell-dbus-interfaces.gresource.xml @@ -15,6 +15,7 @@ org.freedesktop.login1.Manager.xml org.freedesktop.login1.Session.xml org.freedesktop.login1.User.xml + org.freedesktop.MalcontentTimer1.Child.xml org.freedesktop.ModemManager1.Modem.Modem3gpp.xml org.freedesktop.ModemManager1.Modem.ModemCdma.xml org.freedesktop.ModemManager1.Modem.xml diff --git a/js/misc/parentalControlsManager.js b/js/misc/parentalControlsManager.js index 3dfe13d5a8f933482ed7b9cb0322517140ad0446..f4b284120bb5bd7048022a4e13e242945433d1ee 100644 --- a/js/misc/parentalControlsManager.js +++ b/js/misc/parentalControlsManager.js @@ -32,8 +32,13 @@ let Malcontent = null; if (HAVE_MALCONTENT) { ({default: Malcontent} = await import('gi://Malcontent?version=0')); Gio._promisify(Malcontent.Manager.prototype, 'get_app_filter_async'); + Gio._promisify(Malcontent.Manager.prototype, 'get_session_limits_async'); } +// We require libmalcontent ≥ 0.14.0 for session limits +const HAVE_MALCONTENT_0_14 = Malcontent && + GObject.signal_lookup('session-limits-changed', Malcontent.Manager) > 0; + let _singleton = null; /** @@ -53,6 +58,7 @@ export function getDefault() { const ParentalControlsManager = GObject.registerClass({ Signals: { 'app-filter-changed': {}, + 'session-limits-changed': {}, }, }, class ParentalControlsManager extends GObject.Object { _init() { @@ -61,6 +67,7 @@ const ParentalControlsManager = GObject.registerClass({ this._initialized = false; this._disabled = false; this._appFilter = null; + this._sessionLimits = null; this._initializeManager(); } @@ -70,6 +77,7 @@ const ParentalControlsManager = GObject.registerClass({ console.debug('Skipping parental controls support, malcontent not found'); this._initialized = true; this.emit('app-filter-changed'); + this.emit('session-limits-changed'); return; } @@ -77,16 +85,22 @@ const ParentalControlsManager = GObject.registerClass({ const connection = await Gio.DBus.get(Gio.BusType.SYSTEM, null); this._manager = new Malcontent.Manager({connection}); this._appFilter = await this._getAppFilter(); + + if (HAVE_MALCONTENT_0_14) + this._sessionLimits = await this._getSessionLimits(); } catch (e) { logError(e, 'Failed to get parental controls settings'); return; } this._manager.connect('app-filter-changed', this._onAppFilterChanged.bind(this)); + if (HAVE_MALCONTENT_0_14) + this._manager.connect('session-limits-changed', this._onSessionLimitsChanged.bind(this)); // Signal initialisation is complete. this._initialized = true; this.emit('app-filter-changed'); + this.emit('session-limits-changed'); } async _getAppFilter() { @@ -123,6 +137,40 @@ const ParentalControlsManager = GObject.registerClass({ } } + async _getSessionLimits() { + let sessionLimits = null; + + try { + sessionLimits = await this._manager.get_session_limits_async( + Shell.util_get_uid(), + Malcontent.ManagerGetValueFlags.NONE, + null); + } catch (e) { + if (!e.matches(Malcontent.ManagerError, Malcontent.ManagerError.DISABLED)) + throw e; + + console.debug('Parental controls globally disabled'); + this._disabled = true; + } + + return sessionLimits; + } + + async _onSessionLimitsChanged(manager, uid) { + // Emit 'changed' signal only if session-limits are changed for currently logged-in user + const currentUid = Shell.util_get_uid(); + if (currentUid !== uid) + return; + + try { + this._sessionLimits = await this._getSessionLimits(); + this.emit('session-limits-changed'); + } catch (e) { + // Keep the old session limits + logError(e, `Failed to get new MctSessionLimits for uid ${Shell.util_get_uid()} on session-limits-changed`); + } + } + get initialized() { return this._initialized; } @@ -152,4 +200,24 @@ const ParentalControlsManager = GObject.registerClass({ return this._appFilter.is_appinfo_allowed(appInfo); } + + /** + * Check whether parental controls session limits are enabled for the account, + * and if the library supports the features required for the functionality. + * + * @returns {bool} whether session limits are supported and enabled. + */ + sessionLimitsEnabled() { + // Are latest parental controls enabled (at configure or runtime)? + if (!HAVE_MALCONTENT_0_14 || this._disabled) + return false; + + // Have we finished initialising yet? + if (!this.initialized) { + console.debug('Ignoring session limits because parental controls not yet initialised'); + return false; + } + + return this._sessionLimits.is_enabled(); + } }); diff --git a/js/misc/timeLimitsManager.js b/js/misc/timeLimitsManager.js index 79642b9debbe3f4432ce5cf881f00b88c198c068..c0dd97ce9d66732cb076077353c54befc48f01f7 100644 --- a/js/misc/timeLimitsManager.js +++ b/js/misc/timeLimitsManager.js @@ -32,6 +32,13 @@ import * as Gettext from 'gettext'; import * as LoginManager from './loginManager.js'; import * as Main from '../ui/main.js'; import * as MessageTray from '../ui/messageTray.js'; +import * as ParentalControlsManager from './parentalControlsManager.js'; +import * as SystemActions from './systemActions.js'; + +import {loadInterfaceXML} from './fileUtils.js'; + +const TimerChildIface = loadInterfaceXML('org.freedesktop.MalcontentTimer1.Child'); +const TimerChildProxy = Gio.DBusProxy.makeProxyWrapper(TimerChildIface); export const HISTORY_THRESHOLD_SECONDS = 14 * 7 * 24 * 60 * 60; // maximum time history entries are kept const LIMIT_UPCOMING_NOTIFICATION_TIME_SECONDS = 10 * 60; // notify the user 10min before their limit is reached @@ -79,10 +86,17 @@ function userStateToString(userState) { /** * A manager class which tracks total active/inactive time for a user, and + * signals when the user has reached their session time limit for actively + * using the device if the user has parental controls session limits set. When + * they are disabled, the wellbeing daily time limit is considered, and manager * signals when the user has reached their daily time limit for actively using * the device, if limits are enabled. * - * Active/Inactive time is based off the total time the user account has spent + * For parental controls session time limit, the manager tracks the active/inactive + * time via malcontent-timerd. + * + * For wellbeing daily time limit, the way in which the manager tracks the + * active/inactive time is based off the total time the user account has spent * logged in to at least one active session, not idle (and not locked, but * that’s a subset of idle time), and not suspended. * This corresponds to the `active` state from sd_uid_get_state() @@ -123,7 +137,7 @@ export const TimeLimitsManager = GObject.registerClass({ 'daily-limit-reached': {}, }, }, class TimeLimitsManager extends GObject.Object { - constructor(historyFile, clock, loginManagerFactory, loginUserFactory, settingsFactory) { + constructor(historyFile, clock, loginManagerFactory, loginUserFactory, settingsFactory, parentalControlsManagerFactory, timerChildProxyFactory) { super(); // Allow these few bits of global state to be overridden for unit testing @@ -154,12 +168,43 @@ export const TimeLimitsManager = GObject.registerClass({ return loginManager.getCurrentUserProxy(); }, }; + + this._parentalControlsManagerFactory = parentalControlsManagerFactory ?? { + new: ParentalControlsManager.getDefault, + }; + this._parentalControlsManager = this._parentalControlsManagerFactory.new(); + + this._parentalControlsManager.connectObject( + 'session-limits-changed', () => this._onSessionLimitsChanged().catch(logError), + this); + + this._estimatedTimes = []; + this._timerChildProxyFactory = timerChildProxyFactory ?? { + new: () => { + return TimerChildProxy(Gio.DBus.system, + 'org.freedesktop.MalcontentTimer1', + '/org/freedesktop/MalcontentTimer1', + (proxy, error) => { + if (error) + console.debug(`Failed to get TimerChild proxy: ${error}`); + }, + null, /* cancellable */ + Gio.DBusProxyFlags.DO_NOT_AUTO_START_AT_CONSTRUCTION + ); + }, + }; + + this._timerChildProxy = this._timerChildProxyFactory.new(); + + this._timerChildProxy.connectSignal('EstimatedTimesChanged', + () => this._updateEstimatedTimes().catch(logError)); + this._settingsFactory = settingsFactory ?? { new: Gio.Settings.new, }; this._screenTimeLimitSettings = this._settingsFactory.new('org.gnome.desktop.screen-time-limits'); this._screenTimeLimitSettings.connectObject( - 'changed', () => this._updateSettings(), + 'changed', () => this._updateStateMachine(), 'changed::daily-limit-seconds', () => this.notify('daily-limit-time'), 'changed::daily-limit-enabled', () => this.notify('daily-limit-enabled'), 'changed::grayscale', () => this.notify('grayscale-enabled'), @@ -176,13 +221,68 @@ export const TimeLimitsManager = GObject.registerClass({ this._timeChangeId = 0; this._clockOffsetSecs = 0; this._ignoreClockOffsetChanges = false; + this._latestUsageEndSecs = 0; // Start tracking timings - this._updateSettings(); + this._updateStateMachine(); + } + + async _recordUsage(startSecs, endSecs) { + console.debug('Recording usage to the parental controls daemon: ' + + `${this._unixToString(startSecs)} ${this._unixToString(endSecs)}`); + try { + await this._timerChildProxy.RecordUsageAsync( + [[startSecs, endSecs, 'login-session', '']]); + this._latestUsageEndSecs = endSecs; + } catch (e) { + if (e.matches(Gio.DBusError, Gio.DBusError.SERVICE_UNKNOWN)) { + // Limits should only be enabled when the daemon is available. + if (this._parentalControlsManager.sessionLimitsEnabled()) + throw e; + else + console.debug('Parental controls timer daemon not available'); + } else { + console.warn(`Failed to record usage: ${e.message}`); + } + } } - _updateSettings() { - if (!this._screenTimeLimitSettings.get_boolean('history-enabled')) { + async _onSessionLimitsChanged() { + // New session limits may mean that the state machine will need to start/stop. + this._updateStateMachine(); + + // Checkpoint current active usage to the parental controls timer daemon, + // so that it can make a correct estimate of the remaining screen time. + // Wellbeing monitoring uses the stored edge events (state transitions), + // whereas the parental controls timer daemon stores time periods only after + // one is finished, which is why it cannot know the start of the usage period, + // without recording it first. Recording will trigger EstimatedTimesChanged + // signal, which handler updates the estimated times and state. + if (this._stateTransitions.length > 0 && + this._stateTransitions.at(-1).newState === UserState.ACTIVE) { + const startSecs = this._stateTransitions.at(-1).wallTimeSecs; + const endSecs = this.getCurrentTime(); + await this._recordUsage(startSecs, endSecs); + } + } + + async _updateEstimatedTimes() { + const [, timesSecs] = await this._timerChildProxy.GetEstimatedTimesAsync('login-session'); + if (timesSecs['']) + this._estimatedTimes = timesSecs['']; + else + this._estimatedTimes = []; + + this._updateState(); + } + + _storingTransitionsEnabled() { + return this._parentalControlsManager.sessionLimitsEnabled() || + this._screenTimeLimitSettings.get_boolean('history-enabled'); + } + + _updateStateMachine() { + if (!this._storingTransitionsEnabled()) { if (this._state !== TimeLimitsState.DISABLED) { this._stopStateMachine().catch( e => console.warn(`Failed to stop state machine: ${e.message}`)); @@ -190,7 +290,7 @@ export const TimeLimitsManager = GObject.registerClass({ return false; } - // If this is the first time _updateSettings() has been called, start + // If this is the first time _updateStateMachine() has been called, start // the state machine. if (this._state === TimeLimitsState.DISABLED) { this._startStateMachine().catch( @@ -308,7 +408,7 @@ export const TimeLimitsManager = GObject.registerClass({ this._lastStateChangeTimeSecs = 0; this.notify('state'); - if (this._screenTimeLimitSettings.get_boolean('history-enabled')) { + if (this._storingTransitionsEnabled()) { // Add a fake transition to show the shutdown. if (this._userState !== UserState.INACTIVE) { const nowSecs = this.getCurrentTime(); @@ -467,7 +567,7 @@ export const TimeLimitsManager = GObject.registerClass({ if (debugLog) { console.debug('TimeLimitsManager: User state changed from ' + - `${userStateToString(oldState)} to ${userStateToString(newState)} at ${wallTimeSecs}s`); + `${userStateToString(oldState)} to ${userStateToString(newState)} at ${this._unixToString(wallTimeSecs)}`); } // This potentially changed the limit time and timeout calculations. @@ -497,6 +597,13 @@ export const TimeLimitsManager = GObject.registerClass({ * if the system real time clock changes (relative to the monotonic clock). */ async _loadTransitions() { + // When parental controls session limits are enabled, additionally update + // the cached estimated times from the parental controls timer daemon. + if (this._parentalControlsManager.sessionLimitsEnabled()) { + await this._updateEstimatedTimes(); + console.debug('TimeLimitsManager: Loaded time estimates from the timer daemon'); + } + const file = this._historyFile; let contents; @@ -605,6 +712,30 @@ export const TimeLimitsManager = GObject.registerClass({ this._stateTransitions = newTransitions; + // Record all of the usage records to the parental controls timer daemon + // if the parental controls session limits are in place. + if (this._parentalControlsManager.sessionLimitsEnabled()) { + for (var j = 0; j < this._stateTransitions.length - 1; j++) { + const start = this._stateTransitions[j]; + const end = this._stateTransitions[j + 1]; + + // Make sure the transition is correct + if (start['newState'] !== UserState.ACTIVE || + end['newState'] !== UserState.INACTIVE) + continue; + + const startSecs = start['wallTimeSecs']; + const endSecs = end['wallTimeSecs']; + + // Avoid re-sending the old state transitions + if (endSecs <= this._latestUsageEndSecs) + continue; + + /* eslint-disable-next-line no-await-in-loop */ + await this._recordUsage(startSecs, endSecs); + } + } + if (this._stateTransitions.length === 0) { try { await file.delete(this._cancellable); @@ -751,6 +882,17 @@ export const TimeLimitsManager = GObject.registerClass({ return 0; } + /** + * Convert unix epoch to ISO 8601 formatted string for logging purposes. + * + * @param {number} secs Unix time to represent in human readable form + * @returns {string} + */ + _unixToString(secs) { + const dateTime = GLib.DateTime.new_from_unix_local(secs); + return dateTime.format_iso8601(); + } + _updateState() { console.assert(this._state !== TimeLimitsState.DISABLED, 'Time limits should not be disabled when updating timer'); @@ -764,24 +906,63 @@ export const TimeLimitsManager = GObject.registerClass({ if (startOfTodaySecs > this._lastStateChangeTimeSecs) newState = TimeLimitsState.ACTIVE; + const [, currentSessionStart, currentSessionEnd, nextSessionStart] = this._estimatedTimes; + const sessionLimitsEnabled = this._parentalControlsManager.sessionLimitsEnabled(); + + if (sessionLimitsEnabled) { + // Parental controls session limits have either just been enabled + // and the estimated times have not been cached yet, or disabled + // but the manager has not detected that yet, so skip updating state. + if (nextSessionStart === undefined || currentSessionEnd === undefined) + return; + } + + const sessionLimitsDebug = sessionLimitsEnabled ? 'current session start: ' + + `${this._unixToString(currentSessionStart)}, current session end: ` + + `${this._unixToString(currentSessionEnd)}` : 'disabled'; + console.debug('TimeLimitsManager: Parental controls session limits: ' + + `${sessionLimitsDebug}`); + // Work out how much time the user has spent at the screen today. const activeTimeTodaySecs = this._calculateActiveTimeTodaySecs(nowSecs, startOfTodaySecs); const dailyLimitSecs = this._screenTimeLimitSettings.get_uint('daily-limit-seconds'); const dailyLimitEnabled = this._screenTimeLimitSettings.get_boolean('daily-limit-enabled'); const dailyLimitDebug = dailyLimitEnabled ? `${dailyLimitSecs}s` : 'disabled'; - console.debug('TimeLimitsManager: Active time today: ' + + console.debug('TimeLimitsManager: Wellbeing active time today: ' + `${activeTimeTodaySecs}s, daily limit ${dailyLimitDebug}`); - if (dailyLimitEnabled && activeTimeTodaySecs >= dailyLimitSecs) { + + // Update TimeLimitsState. When the user is inactive, there's no point + // scheduling anything until they become active again. + if (sessionLimitsEnabled) { + if (nextSessionStart <= nowSecs) { + // Just entered daily schedule, so update cached estimated times + // which will perform state update afterwards. + this._updateEstimatedTimes(); + return; + } + + if (nowSecs >= currentSessionEnd) { + newState = TimeLimitsState.LIMIT_REACHED; + + // Schedule an update for when the session limit will be reset again. + this._scheduleUpdateState(nextSessionStart - nowSecs); + } else if (this._userState === UserState.ACTIVE) { + newState = TimeLimitsState.ACTIVE; + + // Schedule an update for when we expect the session limit will be reached. + this._scheduleUpdateState(currentSessionEnd - nowSecs); + } + } else if (dailyLimitEnabled && activeTimeTodaySecs >= dailyLimitSecs) { newState = TimeLimitsState.LIMIT_REACHED; - // Schedule an update for when the limit will be reset again. + // Schedule an update for when the daily limit will be reset again. this._scheduleUpdateState(startOfTomorrowSecs - nowSecs); } else if (this._userState === UserState.ACTIVE) { newState = TimeLimitsState.ACTIVE; - // Schedule an update for when we expect the limit will be reached. + // Schedule an update for when we expect the daily limit will be reached. if (dailyLimitEnabled) this._scheduleUpdateState(dailyLimitSecs - activeTimeTodaySecs); } else { @@ -791,6 +972,9 @@ export const TimeLimitsManager = GObject.registerClass({ // Update the saved state. if (newState !== this._state) { + console.debug('TimeLimitsManager: State changed from ' + + `${timeLimitsStateToString(this._state)} ` + + `to ${timeLimitsStateToString(newState)}`); this._state = newState; this._lastStateChangeTimeSecs = nowSecs; this.notify('state'); @@ -827,6 +1011,15 @@ export const TimeLimitsManager = GObject.registerClass({ return this._state; } + /** + * Whether the parental controls session limits are enabled. + * + * @type {boolean} + */ + get sessionLimitsEnabled() { + return this._parentalControlsManager.sessionLimitsEnabled(); + } + /** * The time when the daily limit will be reached. If the user is currently * active, and has not reached the limit, this is a non-zero value in the @@ -877,9 +1070,6 @@ export const TimeLimitsManager = GObject.registerClass({ /** * Whether the daily limit is enabled. * - * If false, screen usage information is recorded, but no limit is enforced. - * reached. - * * @type {boolean} */ get dailyLimitEnabled() { @@ -901,13 +1091,15 @@ export const TimeLimitsManager = GObject.registerClass({ * Glue class which takes the state-based output from TimeLimitsManager and * converts it to event-based notifications for the user to tell them * when their time limit has been reached. It factors the user’s UI preferences - * into account. + * into account, and whether parental controls session limits are enabled. */ export const TimeLimitsDispatcher = GObject.registerClass( class TimeLimitsDispatcher extends GObject.Object { constructor(manager) { super(); + this._systemActions = new SystemActions.getDefault(); + this._manager = manager; this._manager.connectObject( 'notify::state', this._onStateChanged.bind(this), @@ -971,6 +1163,16 @@ class TimeLimitsDispatcher extends GObject.Object { } case TimeLimitsState.LIMIT_REACHED: { + if (this._manager.sessionLimitsEnabled) { + // Trying to lock the screen will fail if the action is not + // available. This happens when admin disables lock screen, + // or when not running under GDM/logind. Those are corner cases + // which don’t need to be handled. + if (this._systemActions.canLockScreen) + this._systemActions.activateLockScreen(); + break; + } + this._ensureEnabled(); if (this._manager.grayscaleEnabled) { @@ -1139,8 +1341,10 @@ class TimeLimitsNotificationSource extends GObject.Object { case TimeLimitsState.LIMIT_REACHED: { // Notify the user that they’ve reached their limit, when we - // transition from any state to LIMIT_REACHED. - if (this._previousState !== TimeLimitsState.LIMIT_REACHED) { + // transition from any state to LIMIT_REACHED. Noop if parental + // controls session limits are enabled, since we’re locking then. + if (!this._manager.sessionLimitsEnabled && + this._previousState !== TimeLimitsState.LIMIT_REACHED) { this._ensureNotification({ title: _('Screen Time Limit Reached'), body: _('It’s time to stop using the device'), diff --git a/tests/unit/timeLimitsManager.js b/tests/unit/timeLimitsManager.js index bcbd77e885011dfbcdbefac6411ac1591d9fb542..d7c5a1724abb12893be23eda26eef4c38dccf15d 100644 --- a/tests/unit/timeLimitsManager.js +++ b/tests/unit/timeLimitsManager.js @@ -36,7 +36,9 @@ const {TimeLimitsState, UserState} = TimeLimitsManager; * of time, maintaining an internal ordered queue of events, and providing three * groups of mock functions which the `TimeLimitsManager` uses to interact with * it: mock versions of GLib’s clock and timeout functions, a mock proxy of the - * logind `User` D-Bus object, and a mock version of `Gio.Settings`. + * logind `User` D-Bus object, a mock proxy around `Malcontent.Manager` object, + * a mock proxy of the malcontent-timerd `Child` D-Bus object, and a mock + * version of `Gio.Settings`. * * The internal ordered queue of events is sorted by time (in real/wall clock * seconds, i.e. UNIX timestamps). On each _tick(), the next event is shifted @@ -53,7 +55,7 @@ const {TimeLimitsState, UserState} = TimeLimitsManager; * daily time limit is reset at a specific time each morning. */ class TestHarness { - constructor(settings, historyFileContents = null) { + constructor(settings, historyFileContents = null, sessionLimitsEnabled = false) { this._currentTimeSecs = 0; this._clockOffset = 100; // make the monotonic clock lag by 100s, arbitrarily this._nextSourceId = 1; @@ -65,6 +67,8 @@ class TestHarness { this._settingsChangedDailyLimitSecondsCallback = null; this._settingsChangedDailyLimitEnabledCallback = null; this._settingsChangedGrayscaleCallback = null; + this._parentalControlsManagerSessionLimitsChangedCallback = null; + this._timerChildProxyEstimatedTimesChangedCallback = null; // These two emulate relevant bits of the o.fdo.login1.User API // See https://www.freedesktop.org/software/systemd/man/latest/org.freedesktop.login1.html#User%20Objects @@ -88,6 +92,13 @@ class TestHarness { this._historyFile = file; + this._sessionLimitsEnabled = sessionLimitsEnabled; + this._limitReached = false; + this._currentSessionStartTimeSecs = 0; + this._currentSessionEstimatedEndTimeSecs = GLib.MAX_UINT64; + this._nextSessionStartTimeSecs = GLib.MAX_UINT64; + this._nextSessionEstimatedEndTimeSecs = GLib.MAX_UINT64; + // And a mock D-Bus proxy for logind. const harness = this; @@ -316,6 +327,55 @@ class TestHarness { }); } + /** + * Add a parental controls manager session limits changed event to the event + * queue. This simulates the `session-limits-changed` signal of the + * `Malcontent.Manager` object, notifying gnome-shell that the user’s + * session limits were changed, for example from malcontent-control. + * + * @param {string} timeStr Date/Time the event happens, in ISO 8601 format. + * @param {boolean} newSessionLimitsEnabled New session limits enabled setting. + */ + addParentalControlsManagerSessionLimitsChangedEvent(timeStr, newSessionLimitsEnabled) { + return this._insertEvent({ + type: 'parental-controls-manager-session-limits-changed', + time: TestHarness.timeStrToSecs(timeStr), + newSessionLimitsEnabled, + }); + } + + /** + * Add a timer child proxy estimated times changed event to the event + * queue. This simulates the `EstimatedTimesChanged` signal of the + * `org.freedesktop.MalcontentTimer1.Child` object, notifying gnome-shell + * that the user’s estimated session times were changed, for example as a + * result of gnome-shell recording usage. + * + * @param {string} timeStr Date/Time the event happens, in ISO 8601 format. + * @param {boolean} newLimitReached New limit reached setting. + * @param {number} newCurrentSessionStartTimeSecs New current session start + * time in seconds. + * @param {number} newCurrentSessionEstimatedEndTimeSecs New current session + * estimated end time in seconds. + * @param {number} newNextSessionStartTimeSecs New next session start time + * in seconds. + * @param {number} newNextSessionEstimatedEndTimeSecs New next session + * estimated end time in seconds. + */ + addTimerChildProxyEstimatedTimesChangedEvent(timeStr, newLimitReached, + newCurrentSessionStartTimeSecs, newCurrentSessionEstimatedEndTimeSecs, + newNextSessionStartTimeSecs, newNextSessionEstimatedEndTimeSecs) { + return this._insertEvent({ + type: 'timer-child-proxy-estimated-times-changed', + time: TestHarness.timeStrToSecs(timeStr), + newLimitReached, + newCurrentSessionStartTimeSecs, + newCurrentSessionEstimatedEndTimeSecs, + newNextSessionStartTimeSecs, + newNextSessionEstimatedEndTimeSecs, + }); + } + /** * Add a settings change event to the event queue. This simulates dconf * notifying gnome-shell that the user has changed a setting, for example @@ -410,6 +470,29 @@ class TestHarness { }); } + /** + * Add a state assertion event to the event queue. This is a specialised + * form of `addAssertionEvent()` which asserts that the given + * start and end date/times of the most recent usage recording equal the + * expected values at date/time `timeStr`. + * + * @param {string} timeStr Date/Time to check the times at, in ISO 8601 + * format. + * @param {string} startStr Start date/time in ISO 8601 format. + * @param {string} endStr End date/time in ISO 8601 format. + */ + expectUsage(timeStr, startStr, endStr) { + return this.addAssertionEvent(timeStr, () => { + const [start, end] = this._timeStore; + expect(start) + .withContext('start') + .toEqual(TestHarness.timeStrToSecs(startStr)); + expect(end) + .withContext('end') + .toEqual(TestHarness.timeStrToSecs(endStr)); + }); + } + _popEvent() { return this._events.shift(); } @@ -480,6 +563,21 @@ class TestHarness { this._currentTimeSecs = TestHarness.timeStrToSecs(timeStr); } + /** + * Create `TimeLimitManager`. + * + * @returns {TimeLimitsManager} the new timeLimitsManager. + */ + createTimeLimitsManager() { + return new TimeLimitsManager.TimeLimitsManager(this.mockHistoryFile, + this.mockClock, + this.mockLoginManagerFactory, + this.mockLoginUserFactory, + this.mockSettingsFactory, + this.mockParentalControlsManagerFactory, + this.mockTimerChildProxyFactory); + } + /** * Get a mock login manager factory for use in the `TimeLimitsManager` under * test. This is an object providing a constructor for the objects returned @@ -516,6 +614,92 @@ class TestHarness { }; } + /** + * Get a mock parental controls manager factory for use in the + * `TimeLimitsManager` under test. This is an object providing constructors + * for `ParentalControlsManager` objects, which are proxies around the + * `Malcontent.Manager` objects. Each constructor returns a basic + * implementation of `ParentalControlsManager` which uses the daily limit + * and daily schedule variables passed to `TestHarness` in its constructor + * in the settings dictionary. + * + * This has an extra layer of indirection to match `mockSettingsFactory`. + */ + get mockParentalControlsManagerFactory() { + return { + new: () => { + return { + connectObject: (...args) => { + const [ + sessionLimitsChangedStr, sessionLimitsChangedCallback, + obj, + ] = args; + + if (sessionLimitsChangedStr !== 'session-limits-changed' || + typeof obj !== 'object') + fail('ParentalControlsManager.connectObject() not called in expected way'); + if (this._parentalControlsManagerSessionLimitsChangedCallback !== null) + fail('ParentalControlsManager signals already connected'); + + this._parentalControlsManagerSessionLimitsChangedCallback = sessionLimitsChangedCallback; + }, + sessionLimitsEnabled: () => { + return this._sessionLimitsEnabled; + }, + }; + }, + }; + } + + /** + * Get a mock timer child proxy factory for use in the `TimeLimitsManager` + * under test. This is an object providing constructors for `TimerChildProxy` + * objects, which are proxies around the `org.freedesktop.MalcontentTimer1.Child` + * D-Bus API. Each constructor returns a basic implementation of `TimerChildProxy`. + * + * This has an extra layer of indirection to match `mockSettingsFactory`. + */ + get mockTimerChildProxyFactory() { + return { + new: () => { + return { + connectSignal: (...args) => { + const [ + estimatedTimesChangedStr, estimatedTimesChangedCallback, + ] = args; + + if (estimatedTimesChangedStr !== 'EstimatedTimesChanged') + fail('TimerChildProxy.connectSignal() not called in expected way'); + if (this._timerChildProxyEstimatedTimesChangedCallback !== null) + fail('TimerChildProxy signals already connected'); + + this._timerChildProxyEstimatedTimesChangedCallback = estimatedTimesChangedCallback; + }, + RecordUsageAsync: args => { + return new Promise(resolve => { + const [[startSecs, endSecs]] = args; + this._timeStore = [startSecs, endSecs]; + resolve(); + }); + }, + GetEstimatedTimesAsync: _ => { + return new Promise(resolve => { + resolve([0, { + '': [ + this._limitReached, + this._currentSessionStartTimeSecs, + this._currentSessionEstimatedEndTimeSecs, + this._nextSessionStartTimeSecs, + this._nextSessionEstimatedEndTimeSecs, + ], + }]); + }); + }, + }; + }, + }; + } + /** * Get a mock settings factory for use in the `TimeLimitsManager` under test. * This is an object providing constructors for `Gio.Settings` objects. Each @@ -608,6 +792,22 @@ class TestHarness { if (this._loginUserPropertiesChangedCallback) this._loginUserPropertiesChangedCallback(); break; + case 'parental-controls-manager-session-limits-changed': + this._sessionLimitsEnabled = event.newSessionLimitsEnabled; + + if (this._parentalControlsManagerSessionLimitsChangedCallback) + this._parentalControlsManagerSessionLimitsChangedCallback(); + break; + case 'timer-child-proxy-estimated-times-changed': + this._limitReached = event.newLimitReached; + this._currentSessionStartTimeSecs = event.newCurrentSessionStartTimeSecs; + this._currentSessionEstimatedEndTimeSecs = event.newCurrentSessionEstimatedEndTimeSecs; + this._nextSessionStartTimeSecs = event.newNextSessionStartTimeSecs; + this._nextSessionEstimatedEndTimeSecs = event.newNextSessionEstimatedEndTimeSecs; + + if (this._timerChildProxyEstimatedTimesChangedCallback) + this._timerChildProxyEstimatedTimesChangedCallback(); + break; case 'settings-change': this._settings[event.schemaId][event.key] = event.newValue; @@ -696,7 +896,7 @@ describe('Time limits manager', () => { }, }); harness.initializeMockClock('2024-06-01T10:00:00Z'); - const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory); + const timeLimitsManager = harness.createTimeLimitsManager(); harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.DISABLED); harness.expectState('2024-06-01T15:00:00Z', timeLimitsManager, TimeLimitsState.DISABLED); @@ -711,6 +911,35 @@ describe('Time limits manager', () => { harness.run(); }); + it('cannot be disabled via GSettings if parental controls enabled', () => { + const harness = new TestHarness( + { + 'org.gnome.desktop.screen-time-limits': { + 'history-enabled': false, + 'daily-limit-enabled': false, + 'daily-limit-seconds': 4 * 60 * 60, + }, + }, + null, + { + 'parental-enabled': true, + } + ); + harness.initializeMockClock('2024-06-01T10:00:00Z'); + const timeLimitsManager = harness.createTimeLimitsManager(); + + harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.ACTIVE); + harness.expectState('2024-06-01T15:00:00Z', timeLimitsManager, TimeLimitsState.ACTIVE); + harness.addLoginUserStateChangeEvent('2024-06-01T15:00:10Z', 'active', false); + harness.addLoginUserStateChangeEvent('2024-06-01T15:00:20Z', 'lingering', true); + harness.expectProperties('2024-06-01T15:00:30Z', timeLimitsManager, { + 'state': TimeLimitsState.ACTIVE, + }); + harness.shutdownManager('2024-06-01T15:10:00Z', timeLimitsManager); + + harness.run(); + }); + it('can be toggled on and off via GSettings', () => { const harness = new TestHarness({ 'org.gnome.desktop.screen-time-limits': { @@ -720,7 +949,7 @@ describe('Time limits manager', () => { }, }); harness.initializeMockClock('2024-06-01T10:00:00Z'); - const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory); + const timeLimitsManager = harness.createTimeLimitsManager(); harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.ACTIVE); harness.addSettingsChangeEvent('2024-06-01T11:00:00Z', @@ -755,7 +984,7 @@ describe('Time limits manager', () => { }, }); harness.initializeMockClock('2024-06-01T10:00:00Z'); - const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory); + const timeLimitsManager = harness.createTimeLimitsManager(); harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.ACTIVE); harness.expectProperties('2024-06-01T13:59:59Z', timeLimitsManager, { @@ -768,6 +997,37 @@ describe('Time limits manager', () => { harness.run(); }); + it('tracks a single day’s usage with parental controls limit enabled', () => { + const harness = new TestHarness({ + 'org.gnome.desktop.screen-time-limits': { + 'history-enabled': true, + 'daily-limit-enabled': true, + 'daily-limit-seconds': 4 * 60 * 60, + }, + }, null, true); + harness.initializeMockClock('2024-06-01T10:00:00Z'); + harness.addTimerChildProxyEstimatedTimesChangedEvent( + '2024-06-01T10:00:01Z', + false, + TestHarness.timeStrToSecs('2024-06-01T10:00:00Z'), + TestHarness.timeStrToSecs('2024-06-01T13:00:00Z'), + TestHarness.timeStrToSecs('2024-06-02T00:00:00Z'), + TestHarness.timeStrToSecs('2024-06-02T03:00:00Z') + ); + harness.addParentalControlsManagerSessionLimitsChangedEvent( + '2024-06-01T10:00:02Z', true + ); + + const timeLimitsManager = harness.createTimeLimitsManager(); + + harness.expectState('2024-06-01T10:00:03Z', timeLimitsManager, TimeLimitsState.ACTIVE); + harness.expectState('2024-06-01T12:59:59Z', timeLimitsManager, TimeLimitsState.ACTIVE); + harness.expectState('2024-06-01T13:00:01Z', timeLimitsManager, TimeLimitsState.LIMIT_REACHED); + harness.shutdownManager('2024-06-01T13:00:02Z', timeLimitsManager); + + harness.run(); + }); + it('tracks a single day’s usage early in the morning', () => { const harness = new TestHarness({ 'org.gnome.desktop.screen-time-limits': { @@ -777,7 +1037,7 @@ describe('Time limits manager', () => { }, }); harness.initializeMockClock('2024-06-01T00:30:00Z'); - const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory); + const timeLimitsManager = harness.createTimeLimitsManager(); harness.expectState('2024-06-01T00:30:01Z', timeLimitsManager, TimeLimitsState.ACTIVE); harness.expectProperties('2024-06-01T01:29:59Z', timeLimitsManager, { @@ -799,7 +1059,7 @@ describe('Time limits manager', () => { }, }); harness.initializeMockClock('2024-06-01T10:00:00Z'); - const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory); + const timeLimitsManager = harness.createTimeLimitsManager(); harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.ACTIVE); harness.expectProperties('2024-06-01T13:59:59Z', timeLimitsManager, { @@ -832,7 +1092,7 @@ describe('Time limits manager', () => { }, }); harness.initializeMockClock('2024-06-01T10:00:00Z'); - const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory); + const timeLimitsManager = harness.createTimeLimitsManager(); harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.ACTIVE); harness.expectProperties('2024-06-01T15:00:00Z', timeLimitsManager, { @@ -872,7 +1132,7 @@ describe('Time limits manager', () => { }, }); harness.initializeMockClock('2024-06-01T10:00:00Z'); - const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory); + const timeLimitsManager = harness.createTimeLimitsManager(); // Run until the limit is reached. harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.ACTIVE); @@ -900,6 +1160,189 @@ describe('Time limits manager', () => { harness.run(); }); + it('resets usage if the parental controls session limit is changed', () => { + const harness = new TestHarness({ + 'org.gnome.desktop.screen-time-limits': { + 'history-enabled': true, + 'daily-limit-enabled': true, + 'daily-limit-seconds': 4 * 60 * 60, + }, + }, null, true); + harness.initializeMockClock('2024-06-01T10:00:00Z'); + harness.addTimerChildProxyEstimatedTimesChangedEvent( + '2024-06-01T10:00:01Z', + false, + TestHarness.timeStrToSecs('2024-06-01T10:00:00Z'), + TestHarness.timeStrToSecs('2024-06-01T13:00:00Z'), + TestHarness.timeStrToSecs('2024-06-02T00:00:00Z'), + TestHarness.timeStrToSecs('2024-06-02T03:00:00Z') + ); + harness.addParentalControlsManagerSessionLimitsChangedEvent( + '2024-06-01T10:00:02Z', true + ); + const timeLimitsManager = harness.createTimeLimitsManager(); + + // Run before the limit is reached + harness.expectState('2024-06-01T10:00:03Z', timeLimitsManager, TimeLimitsState.ACTIVE); + harness.expectState('2024-06-01T12:00:00Z', timeLimitsManager, TimeLimitsState.ACTIVE); + + // Change session limits part-way through the day in a way which means + // the child still has time left that day + harness.addTimerChildProxyEstimatedTimesChangedEvent( + '2024-06-01T12:30:00Z', + false, + TestHarness.timeStrToSecs('2024-06-01T10:00:00Z'), + TestHarness.timeStrToSecs('2024-06-01T14:00:00Z'), + TestHarness.timeStrToSecs('2024-06-02T00:00:00Z'), + TestHarness.timeStrToSecs('2024-06-02T04:00:00Z') + ); + harness.addParentalControlsManagerSessionLimitsChangedEvent( + '2024-06-01T12:30:01Z', true + ); + + // Run before the limit is reached + harness.expectState('2024-06-01T12:30:02Z', timeLimitsManager, TimeLimitsState.ACTIVE); + harness.expectState('2024-06-01T13:30:00Z', timeLimitsManager, TimeLimitsState.ACTIVE); + + // Change session limits part-way through the day in a way which means + // the child no longer has time left that day + harness.addTimerChildProxyEstimatedTimesChangedEvent( + '2024-06-01T13:45:00Z', + true, + TestHarness.timeStrToSecs('2024-06-01T10:00:00Z'), + TestHarness.timeStrToSecs('2024-06-01T13:00:00Z'), + TestHarness.timeStrToSecs('2024-06-02T00:00:00Z'), + TestHarness.timeStrToSecs('2024-06-02T03:00:00Z') + ); + harness.addParentalControlsManagerSessionLimitsChangedEvent( + '2024-06-01T13:45:01Z', true + ); + + // Run before the limit is over + harness.expectState('2024-06-01T13:45:02Z', timeLimitsManager, TimeLimitsState.LIMIT_REACHED); + harness.expectState('2024-06-01T14:00:00Z', timeLimitsManager, TimeLimitsState.LIMIT_REACHED); + + // Change session limits after the child has run out of time in a way + // which means the child has still run out of time + harness.addTimerChildProxyEstimatedTimesChangedEvent( + '2024-06-01T14:30:00Z', + true, + TestHarness.timeStrToSecs('2024-06-01T10:00:00Z'), + TestHarness.timeStrToSecs('2024-06-01T14:00:00Z'), + TestHarness.timeStrToSecs('2024-06-02T00:00:00Z'), + TestHarness.timeStrToSecs('2024-06-02T04:00:00Z') + ); + harness.addParentalControlsManagerSessionLimitsChangedEvent( + '2024-06-01T14:30:01Z', true + ); + + // Run before the limit is over + harness.expectState('2024-06-01T14:30:02Z', timeLimitsManager, TimeLimitsState.LIMIT_REACHED); + harness.expectState('2024-06-01T15:00:00Z', timeLimitsManager, TimeLimitsState.LIMIT_REACHED); + + // Change session limits after the child has run out of time in a way + // which means the child has more time remaining + harness.addTimerChildProxyEstimatedTimesChangedEvent( + '2024-06-01T15:30:00Z', + true, + TestHarness.timeStrToSecs('2024-06-01T10:00:00Z'), + TestHarness.timeStrToSecs('2024-06-01T16:00:00Z'), + TestHarness.timeStrToSecs('2024-06-02T00:00:00Z'), + TestHarness.timeStrToSecs('2024-06-02T06:00:00Z') + ); + harness.addParentalControlsManagerSessionLimitsChangedEvent( + '2024-06-01T15:30:01Z', true + ); + + // Run until the limit is reached + harness.expectState('2024-06-01T15:30:02Z', timeLimitsManager, TimeLimitsState.ACTIVE); + harness.expectState('2024-06-01T16:00:05Z', timeLimitsManager, TimeLimitsState.LIMIT_REACHED); + + // Run before the limit is over + harness.expectState('2024-06-01T16:00:06Z', timeLimitsManager, TimeLimitsState.LIMIT_REACHED); + harness.expectState('2024-06-01T23:59:59Z', timeLimitsManager, TimeLimitsState.LIMIT_REACHED); + + // After the limit is over, GetEstimatedTimes will return new values of + // the current and next sessions, so change the values accordingly + harness.addTimerChildProxyEstimatedTimesChangedEvent( + '2024-06-02T00:00:00Z', + false, + TestHarness.timeStrToSecs('2024-06-02T00:00:00Z'), + TestHarness.timeStrToSecs('2024-06-02T06:00:00Z'), + TestHarness.timeStrToSecs('2024-06-03T00:00:00Z'), + TestHarness.timeStrToSecs('2024-06-03T06:00:00Z') + ); + harness.addParentalControlsManagerSessionLimitsChangedEvent( + '2024-06-02T00:00:01Z', true + ); + + // Run after the limit is over + harness.expectState('2024-06-02T00:00:02Z', timeLimitsManager, TimeLimitsState.ACTIVE); + + harness.shutdownManager('2024-06-02T01:00:00Z', timeLimitsManager); + + harness.run(); + }); + + it('records usage correctly to the malcontent timer daemon', () => { + const harness = new TestHarness({ + 'org.gnome.desktop.screen-time-limits': { + 'history-enabled': false, + 'daily-limit-enabled': true, + 'daily-limit-seconds': 4 * 60 * 60, + }, + }, null, true); + harness.initializeMockClock('2024-06-01T10:00:00Z'); + harness.addTimerChildProxyEstimatedTimesChangedEvent( + '2024-06-01T10:00:01Z', + false, + TestHarness.timeStrToSecs('2024-06-01T10:00:00Z'), + TestHarness.timeStrToSecs('2024-06-01T20:00:00Z'), + TestHarness.timeStrToSecs('2024-06-02T00:00:00Z'), + TestHarness.timeStrToSecs('2024-06-02T10:00:00Z') + ); + harness.addParentalControlsManagerSessionLimitsChangedEvent( + '2024-06-01T10:00:02Z', true + ); + const timeLimitsManager = harness.createTimeLimitsManager(); + + // Session limits changed + harness.addParentalControlsManagerSessionLimitsChangedEvent( + '2024-06-01T11:00:00Z', true + ); + harness.expectUsage('2024-06-01T11:00:01Z', '2024-06-01T10:00:00Z', '2024-06-01T11:00:00Z'); + + // User state changed + harness.addLoginUserStateChangeEvent('2024-06-01T12:00:00Z', 'lingering', true); + harness.expectUsage('2024-06-01T12:00:01Z', '2024-06-01T10:00:00Z', '2024-06-01T12:00:00Z'); + + harness.addLoginUserStateChangeEvent('2024-06-01T13:00:00Z', 'active', false); + harness.expectUsage('2024-06-01T13:00:01Z', '2024-06-01T10:00:00Z', '2024-06-01T12:00:00Z'); + + harness.addLoginUserStateChangeEvent('2024-06-01T14:00:00Z', 'offline', true); + harness.expectUsage('2024-06-01T14:00:01Z', '2024-06-01T13:00:00Z', '2024-06-01T14:00:00Z'); + + harness.addLoginUserStateChangeEvent('2024-06-01T15:00:00Z', 'active', false); + harness.expectUsage('2024-06-01T15:00:01Z', '2024-06-01T13:00:00Z', '2024-06-01T14:00:00Z'); + + // Disable wellbeing & parental + harness.addParentalControlsManagerSessionLimitsChangedEvent( + '2024-06-01T16:00:00Z', false + ); + harness.expectUsage('2024-06-01T16:00:01Z', '2024-06-01T15:00:00Z', '2024-06-01T16:00:00Z'); + + harness.addParentalControlsManagerSessionLimitsChangedEvent( + '2024-06-01T17:00:00Z', true + ); + harness.expectUsage('2024-06-01T17:00:01Z', '2024-06-01T15:00:00Z', '2024-06-01T16:00:00Z'); + + // Shutdown + harness.shutdownManager('2024-06-01T18:00:00Z', timeLimitsManager); + harness.expectUsage('2024-06-01T18:00:00Z', '2024-06-01T17:00:00Z', '2024-06-01T18:00:00Z'); + + harness.run(); + }); + it('tracks usage correctly from an existing history file', () => { const harness = new TestHarness({ 'org.gnome.desktop.screen-time-limits': { @@ -930,7 +1373,7 @@ describe('Time limits manager', () => { }, ])); harness.initializeMockClock('2024-06-01T10:00:00Z'); - const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory); + const timeLimitsManager = harness.createTimeLimitsManager(); // The existing history file (above) lists two active periods, // 07:30–08:00 and 08:30–09:30 that morning. So the user should have @@ -966,7 +1409,7 @@ describe('Time limits manager', () => { }, ])); harness.initializeMockClock('2024-06-01T10:00:00Z'); - const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory); + const timeLimitsManager = harness.createTimeLimitsManager(); // The existing history file (above) lists one active period, // 04:30–08:50 that morning. So the user should have no time left today. @@ -1005,7 +1448,7 @@ describe('Time limits manager', () => { }, }, invalidHistoryFileContents); harness.initializeMockClock('2024-06-01T10:00:00Z'); - const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory); + const timeLimitsManager = harness.createTimeLimitsManager(); // The existing history file (above) is invalid or a no-op and // should be ignored. @@ -1051,7 +1494,7 @@ describe('Time limits manager', () => { }, ])); harness.initializeMockClock('2024-06-01T10:00:00Z'); - const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory); + const timeLimitsManager = harness.createTimeLimitsManager(); // The existing history file (above) lists two active periods, // one of which is a long time ago and the other is ‘this’ morning in @@ -1116,7 +1559,7 @@ describe('Time limits manager', () => { }, ])); harness.initializeMockClock('2024-06-01T10:00:00Z'); - const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory); + const timeLimitsManager = harness.createTimeLimitsManager(); // The existing history file (above) lists one active period, // 04:30–08:50 that morning IN THE YEAR 3000. This could have resulted @@ -1151,7 +1594,7 @@ describe('Time limits manager', () => { }, ])); harness.initializeMockClock('2024-06-01T10:00:00Z'); - const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory); + const timeLimitsManager = harness.createTimeLimitsManager(); harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.ACTIVE); harness.addSettingsChangeEvent('2024-06-01T10:00:02Z', @@ -1187,7 +1630,7 @@ describe('Time limits manager', () => { }, ])); harness.initializeMockClock('2024-06-01T10:00:00Z'); - const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory); + const timeLimitsManager = harness.createTimeLimitsManager(); harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.ACTIVE); harness.expectState('2024-06-01T14:00:01Z', timeLimitsManager, TimeLimitsState.LIMIT_REACHED); @@ -1225,7 +1668,7 @@ describe('Time limits manager', () => { }, }); harness.initializeMockClock('2024-06-01T10:00:00Z'); - const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory); + const timeLimitsManager = harness.createTimeLimitsManager(); // Use up 2h of the daily limit. harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.ACTIVE); @@ -1262,7 +1705,7 @@ describe('Time limits manager', () => { }, }); harness.initializeMockClock('2024-06-01T10:00:00Z'); - const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory); + const timeLimitsManager = harness.createTimeLimitsManager(); // Use up 2h of the daily limit. harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.ACTIVE); @@ -1299,7 +1742,7 @@ describe('Time limits manager', () => { }, }); harness.initializeMockClock('2024-06-01T10:00:00Z'); - const timeLimitsManager = new TimeLimitsManager.TimeLimitsManager(harness.mockHistoryFile, harness.mockClock, harness.mockLoginManagerFactory, harness.mockLoginUserFactory, harness.mockSettingsFactory); + const timeLimitsManager = harness.createTimeLimitsManager(); // Use up 2h of the daily limit. harness.expectState('2024-06-01T10:00:01Z', timeLimitsManager, TimeLimitsState.ACTIVE);