2026-02-14 18:42:20 +01:00
|
|
|
import 'dart:async';
|
2026-04-12 20:45:24 +02:00
|
|
|
import 'dart:io';
|
2026-02-14 18:42:20 +01:00
|
|
|
|
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
|
import 'package:pomodoro_app/models/pomodoro_state.dart';
|
2026-04-12 20:45:24 +02:00
|
|
|
import 'package:pomodoro_app/services/notification_service.dart';
|
2026-02-14 18:42:20 +01:00
|
|
|
import 'package:pomodoro_app/services/pomodoro_timer.dart';
|
2026-04-12 20:45:24 +02:00
|
|
|
import 'package:pomodoro_app/services/sound_service.dart';
|
2026-02-14 18:42:20 +01:00
|
|
|
|
|
|
|
|
/// A controllable fake timer for testing.
|
|
|
|
|
class FakeTimerController {
|
|
|
|
|
void Function(Timer)? _callback;
|
|
|
|
|
bool _isActive = true;
|
|
|
|
|
|
|
|
|
|
void tick() {
|
|
|
|
|
if (_isActive) {
|
|
|
|
|
_callback?.call(_FakeTimer(this));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void cancel() {
|
|
|
|
|
_isActive = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool get isActive => _isActive;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _FakeTimer implements Timer {
|
|
|
|
|
_FakeTimer(this._controller);
|
|
|
|
|
final FakeTimerController _controller;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void cancel() => _controller.cancel();
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
bool get isActive => _controller.isActive;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
int get tick => 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void main() {
|
|
|
|
|
late PomodoroTimer timer;
|
|
|
|
|
late FakeTimerController fakeController;
|
|
|
|
|
|
|
|
|
|
Timer fakeTimerFactory(Duration duration, void Function(Timer) callback) {
|
2026-04-12 20:45:24 +02:00
|
|
|
fakeController = FakeTimerController()
|
|
|
|
|
.._callback = callback;
|
2026-02-14 18:42:20 +01:00
|
|
|
return _FakeTimer(fakeController);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setUp(() {
|
|
|
|
|
timer = PomodoroTimer(
|
|
|
|
|
workMinutes: 1, // 60 seconds for faster testing
|
|
|
|
|
shortBreakMinutes: 1,
|
|
|
|
|
longBreakMinutes: 2,
|
|
|
|
|
pomodorosPerCycle: 2,
|
|
|
|
|
timerFactory: fakeTimerFactory,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tearDown(() {
|
|
|
|
|
timer.dispose();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
group('Initial state', () {
|
|
|
|
|
test('starts in work mode', () {
|
|
|
|
|
expect(timer.state.mode, PomodoroMode.work);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('is not running', () {
|
|
|
|
|
expect(timer.state.isRunning, false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('has correct initial time', () {
|
|
|
|
|
expect(timer.state.remainingSeconds, 60);
|
|
|
|
|
expect(timer.state.totalSeconds, 60);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('has zero completed pomodoros', () {
|
|
|
|
|
expect(timer.state.completedPomodoros, 0);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
group('start()', () {
|
|
|
|
|
test('sets isRunning to true', () {
|
|
|
|
|
timer.start();
|
|
|
|
|
expect(timer.state.isRunning, true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('does nothing if already running', () {
|
2026-04-12 20:45:24 +02:00
|
|
|
final stateAfterFirstStart = (timer..start()).state;
|
2026-02-14 18:42:20 +01:00
|
|
|
timer.start(); // second call
|
|
|
|
|
expect(timer.state, stateAfterFirstStart);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('notifies listeners', () {
|
|
|
|
|
var notified = false;
|
2026-04-12 20:45:24 +02:00
|
|
|
timer
|
|
|
|
|
..addListener(() => notified = true)
|
|
|
|
|
..start();
|
2026-02-14 18:42:20 +01:00
|
|
|
expect(notified, true);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
group('pause()', () {
|
|
|
|
|
test('sets isRunning to false', () {
|
2026-04-12 20:45:24 +02:00
|
|
|
timer
|
|
|
|
|
..start()
|
|
|
|
|
..pause();
|
2026-02-14 18:42:20 +01:00
|
|
|
expect(timer.state.isRunning, false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('does nothing if already paused', () {
|
|
|
|
|
final state = timer.state;
|
|
|
|
|
timer.pause();
|
|
|
|
|
expect(timer.state, state);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('preserves remaining time', () {
|
|
|
|
|
timer.start();
|
2026-04-12 20:45:24 +02:00
|
|
|
fakeController
|
|
|
|
|
..tick() // -1s
|
|
|
|
|
..tick(); // -1s
|
2026-02-14 18:42:20 +01:00
|
|
|
timer.pause();
|
|
|
|
|
expect(timer.state.remainingSeconds, 58);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
group('Ticking', () {
|
|
|
|
|
test('decrements remaining seconds', () {
|
|
|
|
|
timer.start();
|
|
|
|
|
fakeController.tick();
|
|
|
|
|
expect(timer.state.remainingSeconds, 59);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('notifies on each tick', () {
|
|
|
|
|
timer.start();
|
|
|
|
|
var count = 0;
|
|
|
|
|
timer.addListener(() => count++);
|
2026-04-12 20:45:24 +02:00
|
|
|
fakeController
|
|
|
|
|
..tick()
|
|
|
|
|
..tick();
|
2026-02-14 18:42:20 +01:00
|
|
|
expect(count, 2);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
group('Session completion', () {
|
|
|
|
|
test('transitions from work to short break', () {
|
|
|
|
|
timer.start();
|
|
|
|
|
// Tick down to 1 second, then one more tick completes.
|
|
|
|
|
for (var i = 0; i < 60; i++) {
|
|
|
|
|
fakeController.tick();
|
|
|
|
|
}
|
|
|
|
|
expect(timer.state.mode, PomodoroMode.shortBreak);
|
|
|
|
|
expect(timer.state.isRunning, false);
|
|
|
|
|
expect(timer.state.completedPomodoros, 1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('transitions to long break after cycle', () {
|
|
|
|
|
// Complete 2 pomodoros (pomodorosPerCycle = 2).
|
|
|
|
|
// First pomodoro.
|
|
|
|
|
timer.start();
|
|
|
|
|
for (var i = 0; i < 60; i++) {
|
|
|
|
|
fakeController.tick();
|
|
|
|
|
}
|
|
|
|
|
expect(timer.state.mode, PomodoroMode.shortBreak);
|
|
|
|
|
|
|
|
|
|
// Skip break.
|
|
|
|
|
timer.skip();
|
|
|
|
|
expect(timer.state.mode, PomodoroMode.work);
|
|
|
|
|
|
|
|
|
|
// Second pomodoro.
|
|
|
|
|
timer.start();
|
|
|
|
|
for (var i = 0; i < 60; i++) {
|
|
|
|
|
fakeController.tick();
|
|
|
|
|
}
|
|
|
|
|
expect(timer.state.mode, PomodoroMode.longBreak);
|
|
|
|
|
expect(timer.state.completedPomodoros, 2);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('transitions from break to work', () {
|
|
|
|
|
// Complete a work session.
|
|
|
|
|
timer.start();
|
|
|
|
|
for (var i = 0; i < 60; i++) {
|
|
|
|
|
fakeController.tick();
|
|
|
|
|
}
|
|
|
|
|
expect(timer.state.mode, PomodoroMode.shortBreak);
|
|
|
|
|
|
|
|
|
|
// Complete the break.
|
|
|
|
|
timer.start();
|
|
|
|
|
for (var i = 0; i < 60; i++) {
|
|
|
|
|
fakeController.tick();
|
|
|
|
|
}
|
|
|
|
|
expect(timer.state.mode, PomodoroMode.work);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
group('reset()', () {
|
|
|
|
|
test('resets to full duration', () {
|
|
|
|
|
timer.start();
|
2026-04-12 20:45:24 +02:00
|
|
|
fakeController
|
|
|
|
|
..tick()
|
|
|
|
|
..tick();
|
2026-02-14 18:42:20 +01:00
|
|
|
timer.reset();
|
|
|
|
|
expect(timer.state.remainingSeconds, 60);
|
|
|
|
|
expect(timer.state.isRunning, false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('keeps the current mode', () {
|
|
|
|
|
timer.start();
|
|
|
|
|
for (var i = 0; i < 60; i++) {
|
|
|
|
|
fakeController.tick();
|
|
|
|
|
}
|
|
|
|
|
// Now in short break mode.
|
|
|
|
|
timer.reset();
|
|
|
|
|
expect(timer.state.mode, PomodoroMode.shortBreak);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
group('skip()', () {
|
|
|
|
|
test('skips from work to short break', () {
|
|
|
|
|
timer.skip();
|
|
|
|
|
expect(timer.state.mode, PomodoroMode.shortBreak);
|
|
|
|
|
expect(timer.state.isRunning, false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('skips from break to work', () {
|
2026-04-12 20:45:24 +02:00
|
|
|
timer
|
|
|
|
|
..skip() // work -> short break
|
|
|
|
|
..skip(); // short break -> work
|
2026-02-14 18:42:20 +01:00
|
|
|
expect(timer.state.mode, PomodoroMode.work);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('stops the timer when skipping', () {
|
2026-04-12 20:45:24 +02:00
|
|
|
timer
|
|
|
|
|
..start()
|
|
|
|
|
..skip();
|
2026-02-14 18:42:20 +01:00
|
|
|
expect(timer.state.isRunning, false);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
group('dispose()', () {
|
|
|
|
|
test('cancels internal timer', () {
|
|
|
|
|
// Create a separate timer so tearDown does not double-dispose.
|
2026-04-12 20:45:24 +02:00
|
|
|
PomodoroTimer(
|
2026-02-14 18:42:20 +01:00
|
|
|
workMinutes: 1,
|
|
|
|
|
shortBreakMinutes: 1,
|
|
|
|
|
longBreakMinutes: 2,
|
|
|
|
|
pomodorosPerCycle: 2,
|
|
|
|
|
timerFactory: fakeTimerFactory,
|
2026-04-12 20:45:24 +02:00
|
|
|
)
|
|
|
|
|
..start()
|
|
|
|
|
..dispose();
|
2026-02-14 18:42:20 +01:00
|
|
|
expect(fakeController.isActive, false);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-21 20:40:33 +01:00
|
|
|
group('switchStyle()', () {
|
|
|
|
|
test('switches to ultraradian with correct durations', () {
|
|
|
|
|
timer.switchStyle(TimerStyle.ultraradian);
|
|
|
|
|
expect(timer.timerStyle, TimerStyle.ultraradian);
|
|
|
|
|
expect(timer.state.remainingSeconds, 90 * 60);
|
|
|
|
|
expect(timer.state.totalSeconds, 90 * 60);
|
|
|
|
|
expect(timer.state.pomodorosPerCycle, 1);
|
|
|
|
|
expect(timer.state.mode, PomodoroMode.work);
|
|
|
|
|
expect(timer.state.isRunning, false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('switches back to pomodoro', () {
|
2026-04-12 20:45:24 +02:00
|
|
|
timer
|
|
|
|
|
..switchStyle(TimerStyle.ultraradian)
|
|
|
|
|
..switchStyle(TimerStyle.pomodoro);
|
2026-02-21 20:40:33 +01:00
|
|
|
expect(timer.timerStyle, TimerStyle.pomodoro);
|
|
|
|
|
expect(timer.state.remainingSeconds, 25 * 60);
|
|
|
|
|
expect(timer.state.totalSeconds, 25 * 60);
|
|
|
|
|
expect(timer.state.pomodorosPerCycle, 4);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('resets running timer when switching', () {
|
|
|
|
|
timer.start();
|
|
|
|
|
fakeController.tick();
|
|
|
|
|
expect(timer.state.isRunning, true);
|
|
|
|
|
|
|
|
|
|
timer.switchStyle(TimerStyle.ultraradian);
|
|
|
|
|
expect(timer.state.isRunning, false);
|
|
|
|
|
expect(timer.state.remainingSeconds, 90 * 60);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('does nothing when switching to same style', () {
|
|
|
|
|
timer.start();
|
|
|
|
|
fakeController.tick();
|
|
|
|
|
final stateBefore = timer.state;
|
|
|
|
|
|
|
|
|
|
timer.switchStyle(TimerStyle.pomodoro);
|
|
|
|
|
expect(timer.state, stateBefore);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('notifies listeners', () {
|
|
|
|
|
var notified = false;
|
2026-04-12 20:45:24 +02:00
|
|
|
timer
|
|
|
|
|
..addListener(() => notified = true)
|
|
|
|
|
..switchStyle(TimerStyle.ultraradian);
|
2026-02-21 20:40:33 +01:00
|
|
|
expect(notified, true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('resets completed pomodoros', () {
|
|
|
|
|
timer.start();
|
|
|
|
|
for (var i = 0; i < 60; i++) {
|
|
|
|
|
fakeController.tick();
|
|
|
|
|
}
|
|
|
|
|
expect(timer.state.completedPomodoros, 1);
|
|
|
|
|
|
|
|
|
|
timer.switchStyle(TimerStyle.ultraradian);
|
|
|
|
|
expect(timer.state.completedPomodoros, 0);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
group('timerStyle getter', () {
|
|
|
|
|
test('defaults to pomodoro', () {
|
|
|
|
|
expect(timer.timerStyle, TimerStyle.pomodoro);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-14 18:42:20 +01:00
|
|
|
group('applyRemoteState()', () {
|
|
|
|
|
test('applies remote state and notifies listeners', () {
|
|
|
|
|
var notified = false;
|
|
|
|
|
timer.addListener(() => notified = true);
|
|
|
|
|
|
2026-04-12 20:45:24 +02:00
|
|
|
const remoteState = PomodoroState(
|
2026-02-14 18:42:20 +01:00
|
|
|
mode: PomodoroMode.shortBreak,
|
|
|
|
|
remainingSeconds: 200,
|
|
|
|
|
totalSeconds: 300,
|
|
|
|
|
isRunning: false,
|
|
|
|
|
completedPomodoros: 2,
|
|
|
|
|
pomodorosPerCycle: 4,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
timer.applyRemoteState(remoteState, 'pause');
|
|
|
|
|
expect(timer.state.mode, PomodoroMode.shortBreak);
|
|
|
|
|
expect(timer.state.remainingSeconds, 200);
|
|
|
|
|
expect(timer.state.completedPomodoros, 2);
|
|
|
|
|
expect(timer.state.isRunning, false);
|
|
|
|
|
expect(notified, true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('starts local ticking when remote state is running', () {
|
2026-04-12 20:45:24 +02:00
|
|
|
const remoteState = PomodoroState(
|
2026-02-14 18:42:20 +01:00
|
|
|
mode: PomodoroMode.work,
|
|
|
|
|
remainingSeconds: 500,
|
|
|
|
|
totalSeconds: 600,
|
|
|
|
|
isRunning: true,
|
|
|
|
|
completedPomodoros: 0,
|
|
|
|
|
pomodorosPerCycle: 4,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
timer.applyRemoteState(remoteState, 'start');
|
|
|
|
|
expect(timer.state.isRunning, true);
|
|
|
|
|
|
|
|
|
|
// The fake timer should have been created; ticking should work.
|
|
|
|
|
fakeController.tick();
|
|
|
|
|
expect(timer.state.remainingSeconds, 499);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('stops local ticking when remote state is paused', () {
|
|
|
|
|
// First start the timer locally.
|
|
|
|
|
timer.start();
|
|
|
|
|
fakeController.tick();
|
|
|
|
|
expect(timer.state.remainingSeconds, 59);
|
|
|
|
|
|
|
|
|
|
// Apply remote pause.
|
|
|
|
|
final remoteState = timer.state.copyWith(isRunning: false);
|
|
|
|
|
timer.applyRemoteState(remoteState, 'pause');
|
|
|
|
|
expect(timer.state.isRunning, false);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-04-12 20:45:24 +02:00
|
|
|
|
|
|
|
|
group('Session complete with services', () {
|
|
|
|
|
late List<String> playedSounds;
|
|
|
|
|
late List<_Call> notifyCalls;
|
|
|
|
|
late SoundService soundService;
|
|
|
|
|
late NotificationService notificationService;
|
|
|
|
|
late PomodoroTimer timerWithServices;
|
|
|
|
|
|
|
|
|
|
setUp(() {
|
|
|
|
|
playedSounds = [];
|
|
|
|
|
notifyCalls = [];
|
|
|
|
|
|
|
|
|
|
soundService = SoundService(
|
|
|
|
|
playCallback: (assetPath) async => playedSounds.add(assetPath),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
notificationService = NotificationService(
|
|
|
|
|
runProcess: (exec, args) async {
|
|
|
|
|
notifyCalls.add(_Call(exec, args));
|
|
|
|
|
return ProcessResult(0, 0, '(uint32 1,)', '');
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
timerWithServices = PomodoroTimer(
|
|
|
|
|
workMinutes: 1,
|
|
|
|
|
shortBreakMinutes: 1,
|
|
|
|
|
longBreakMinutes: 1,
|
|
|
|
|
pomodorosPerCycle: 2,
|
|
|
|
|
timerFactory: fakeTimerFactory,
|
|
|
|
|
soundService: soundService,
|
|
|
|
|
notificationService: notificationService,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tearDown(() {
|
|
|
|
|
timerWithServices.dispose();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('calls sound and notification services on session complete', () {
|
|
|
|
|
timerWithServices.start();
|
|
|
|
|
for (var i = 0; i < 60; i++) {
|
|
|
|
|
fakeController.tick();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
expect(playedSounds, isNotEmpty);
|
|
|
|
|
// showSessionComplete was called (the Notify call after the initial
|
|
|
|
|
// showTimer from start).
|
|
|
|
|
final sessionCompleteCall = notifyCalls.where(
|
|
|
|
|
(c) => c.args.any((a) => a.contains('complete!')),
|
|
|
|
|
);
|
|
|
|
|
expect(sessionCompleteCall, isNotEmpty);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('long break completes and transitions to work', () {
|
|
|
|
|
// Complete 2 work sessions to trigger long break.
|
|
|
|
|
timerWithServices.start();
|
|
|
|
|
for (var i = 0; i < 60; i++) {
|
|
|
|
|
fakeController.tick();
|
|
|
|
|
}
|
|
|
|
|
expect(timerWithServices.state.mode, PomodoroMode.shortBreak);
|
|
|
|
|
|
|
|
|
|
timerWithServices.skip();
|
|
|
|
|
expect(timerWithServices.state.mode, PomodoroMode.work);
|
|
|
|
|
|
|
|
|
|
timerWithServices.start();
|
|
|
|
|
for (var i = 0; i < 60; i++) {
|
|
|
|
|
fakeController.tick();
|
|
|
|
|
}
|
|
|
|
|
expect(timerWithServices.state.mode, PomodoroMode.longBreak);
|
|
|
|
|
|
|
|
|
|
// Now complete the long break.
|
|
|
|
|
timerWithServices.start();
|
|
|
|
|
for (var i = 0; i < 60; i++) {
|
|
|
|
|
fakeController.tick();
|
|
|
|
|
}
|
|
|
|
|
expect(timerWithServices.state.mode, PomodoroMode.work);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('notification updates at 30-second intervals', () {
|
|
|
|
|
timerWithServices.start();
|
|
|
|
|
notifyCalls.clear();
|
|
|
|
|
|
|
|
|
|
// Tick 30 times so remainingSeconds goes from 60 to 30.
|
|
|
|
|
for (var i = 0; i < 30; i++) {
|
|
|
|
|
fakeController.tick();
|
|
|
|
|
}
|
|
|
|
|
expect(timerWithServices.state.remainingSeconds, 30);
|
|
|
|
|
|
|
|
|
|
// At 30 seconds remaining (divisible by 30), a notification update
|
|
|
|
|
// should have been sent.
|
|
|
|
|
final timerUpdates = notifyCalls.where(
|
|
|
|
|
(c) => c.args.any(
|
|
|
|
|
(a) => a.contains('org.freedesktop.Notifications.Notify'),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
expect(timerUpdates, isNotEmpty);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Captured call for the mock process runner.
|
|
|
|
|
class _Call {
|
|
|
|
|
_Call(this.executable, this.args);
|
|
|
|
|
final String executable;
|
|
|
|
|
final List<String> args;
|
2026-02-14 18:42:20 +01:00
|
|
|
}
|