refactored broad range of the application, split files, modularized calendar and file views, centralized bottom sheets and clipboard handling, and implemented unit test coverage
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
import 'package:fake_async/fake_async.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/utils/debouncer.dart';
|
||||
|
||||
void main() {
|
||||
// Each test is wrapped in fakeAsync so timers fire deterministically.
|
||||
group('Debouncer.debounce', () {
|
||||
test('runs the action once after the delay elapses without further calls', () {
|
||||
fakeAsync((async) {
|
||||
var calls = 0;
|
||||
Debouncer.debounce('tag', const Duration(milliseconds: 100), () => calls++);
|
||||
|
||||
async.elapse(const Duration(milliseconds: 99));
|
||||
expect(calls, 0);
|
||||
|
||||
async.elapse(const Duration(milliseconds: 1));
|
||||
expect(calls, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('subsequent calls within the delay reset the timer (coalesce)', () {
|
||||
fakeAsync((async) {
|
||||
var calls = 0;
|
||||
void schedule() => Debouncer.debounce(
|
||||
'tag', const Duration(milliseconds: 100), () => calls++);
|
||||
|
||||
schedule();
|
||||
async.elapse(const Duration(milliseconds: 80));
|
||||
schedule(); // resets
|
||||
async.elapse(const Duration(milliseconds: 80));
|
||||
schedule(); // resets
|
||||
async.elapse(const Duration(milliseconds: 80));
|
||||
expect(calls, 0, reason: 'each schedule() resets the timer');
|
||||
|
||||
async.elapse(const Duration(milliseconds: 100));
|
||||
expect(calls, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('different tags are independent', () {
|
||||
fakeAsync((async) {
|
||||
var aCalls = 0;
|
||||
var bCalls = 0;
|
||||
Debouncer.debounce('a', const Duration(milliseconds: 100), () => aCalls++);
|
||||
Debouncer.debounce('b', const Duration(milliseconds: 100), () => bCalls++);
|
||||
|
||||
async.elapse(const Duration(milliseconds: 100));
|
||||
expect(aCalls, 1);
|
||||
expect(bCalls, 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('Debouncer.throttle', () {
|
||||
test('first call runs immediately, subsequent calls within window are dropped', () {
|
||||
fakeAsync((async) {
|
||||
var calls = 0;
|
||||
Debouncer.throttle('tag', const Duration(milliseconds: 100), () => calls++);
|
||||
expect(calls, 1, reason: 'throttle fires the first call synchronously');
|
||||
|
||||
Debouncer.throttle('tag', const Duration(milliseconds: 100), () => calls++);
|
||||
Debouncer.throttle('tag', const Duration(milliseconds: 100), () => calls++);
|
||||
expect(calls, 1, reason: 'subsequent calls within the gate are ignored');
|
||||
|
||||
async.elapse(const Duration(milliseconds: 100));
|
||||
Debouncer.throttle('tag', const Duration(milliseconds: 100), () => calls++);
|
||||
expect(calls, 2, reason: 'after the window elapses, throttle fires again');
|
||||
});
|
||||
});
|
||||
|
||||
test('different tags throttle independently', () {
|
||||
fakeAsync((async) {
|
||||
var aCalls = 0;
|
||||
var bCalls = 0;
|
||||
Debouncer.throttle('a', const Duration(milliseconds: 100), () => aCalls++);
|
||||
Debouncer.throttle('b', const Duration(milliseconds: 100), () => bCalls++);
|
||||
expect(aCalls, 1);
|
||||
expect(bCalls, 1);
|
||||
|
||||
async.elapse(const Duration(milliseconds: 100));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('Debouncer.cancel', () {
|
||||
test('cancels a pending debounce so the action never runs', () {
|
||||
fakeAsync((async) {
|
||||
var calls = 0;
|
||||
Debouncer.debounce('tag', const Duration(milliseconds: 100), () => calls++);
|
||||
Debouncer.cancel('tag');
|
||||
|
||||
async.elapse(const Duration(milliseconds: 200));
|
||||
expect(calls, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('cancels an active throttle gate so the next call fires immediately', () {
|
||||
fakeAsync((async) {
|
||||
var calls = 0;
|
||||
Debouncer.throttle('tag', const Duration(milliseconds: 100), () => calls++);
|
||||
expect(calls, 1);
|
||||
|
||||
Debouncer.cancel('tag');
|
||||
Debouncer.throttle('tag', const Duration(milliseconds: 100), () => calls++);
|
||||
expect(calls, 2,
|
||||
reason: 'cancel removed the gate so the next throttle fires again');
|
||||
|
||||
async.elapse(const Duration(milliseconds: 100));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
|
||||
import 'package:marianum_mobile/utils/file_clipboard.dart';
|
||||
|
||||
CacheableFile _file(String name) =>
|
||||
CacheableFile(path: '/$name', isDirectory: false, name: name);
|
||||
|
||||
void main() {
|
||||
// FileClipboard is a singleton — clear between tests so state doesn't leak.
|
||||
setUp(FileClipboard.instance.clear);
|
||||
|
||||
group('FileClipboard.cut', () {
|
||||
test('switches to cut state and notifies listeners', () {
|
||||
final cb = FileClipboard.instance;
|
||||
var notifyCount = 0;
|
||||
void listener() => notifyCount++;
|
||||
cb.addListener(listener);
|
||||
addTearDown(() => cb.removeListener(listener));
|
||||
|
||||
cb.cut([_file('a.txt')]);
|
||||
|
||||
expect(cb.operation, FileClipboardOperation.cut);
|
||||
expect(cb.files.map((f) => f.name), ['a.txt']);
|
||||
expect(cb.isEmpty, isFalse);
|
||||
expect(notifyCount, 1);
|
||||
});
|
||||
|
||||
test('empty input is a no-op', () {
|
||||
final cb = FileClipboard.instance;
|
||||
var notifyCount = 0;
|
||||
void listener() => notifyCount++;
|
||||
cb.addListener(listener);
|
||||
addTearDown(() => cb.removeListener(listener));
|
||||
|
||||
cb.cut(const []);
|
||||
|
||||
expect(cb.operation, isNull);
|
||||
expect(cb.isEmpty, isTrue);
|
||||
expect(notifyCount, 0, reason: 'no state change → no notifyListeners');
|
||||
});
|
||||
|
||||
test('files getter returns an unmodifiable view', () {
|
||||
final cb = FileClipboard.instance;
|
||||
cb.cut([_file('a.txt')]);
|
||||
expect(() => cb.files.add(_file('b.txt')), throwsUnsupportedError);
|
||||
});
|
||||
});
|
||||
|
||||
group('FileClipboard.copy', () {
|
||||
test('switches to copy state and notifies listeners', () {
|
||||
final cb = FileClipboard.instance;
|
||||
cb.copy([_file('a.txt'), _file('b.txt')]);
|
||||
|
||||
expect(cb.operation, FileClipboardOperation.copy);
|
||||
expect(cb.files, hasLength(2));
|
||||
});
|
||||
|
||||
test('overwrites a previous cut state', () {
|
||||
final cb = FileClipboard.instance;
|
||||
cb.cut([_file('cut.txt')]);
|
||||
cb.copy([_file('copy.txt')]);
|
||||
|
||||
expect(cb.operation, FileClipboardOperation.copy);
|
||||
expect(cb.files.single.name, 'copy.txt');
|
||||
});
|
||||
});
|
||||
|
||||
group('FileClipboard.clear', () {
|
||||
test('resets state and notifies', () {
|
||||
final cb = FileClipboard.instance;
|
||||
cb.copy([_file('a.txt')]);
|
||||
var notifyCount = 0;
|
||||
void listener() => notifyCount++;
|
||||
cb.addListener(listener);
|
||||
addTearDown(() => cb.removeListener(listener));
|
||||
|
||||
cb.clear();
|
||||
|
||||
expect(cb.operation, isNull);
|
||||
expect(cb.isEmpty, isTrue);
|
||||
expect(notifyCount, 1);
|
||||
});
|
||||
|
||||
test('clearing an already-empty clipboard is a no-op', () {
|
||||
final cb = FileClipboard.instance;
|
||||
var notifyCount = 0;
|
||||
void listener() => notifyCount++;
|
||||
cb.addListener(listener);
|
||||
addTearDown(() => cb.removeListener(listener));
|
||||
|
||||
cb.clear();
|
||||
cb.clear();
|
||||
|
||||
expect(notifyCount, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user