diff --git a/pubspec.yaml b/pubspec.yaml index 25835bf0..15919808 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -18,7 +18,7 @@ repository: https://github.com/postmeridiem/clide # Pre-push line-coverage floor. Ratchets up only — see D-66. # Reading: `awk -F: '/^coverage_floor:/ {gsub(/ /,"",$2); print $2}' pubspec.yaml`. -coverage_floor: 80 +coverage_floor: 81 # Project metadata (was project.yaml, folded in per D-056). # version: above is the single source of truth. The Makefile reads diff --git a/test/kernel/src/services_stubs_test.dart b/test/kernel/src/services_stubs_test.dart new file mode 100644 index 00000000..6a929cd1 --- /dev/null +++ b/test/kernel/src/services_stubs_test.dart @@ -0,0 +1,222 @@ +/// Unit tests for the small Tier-0 service stubs in `lib/kernel/src/`: +/// ClideClipboard, FocusTracker, NetworkStatus, SecretsVault, +/// TrayRegistry, Notifications. +library; + +import 'package:clide/extension/src/contribution.dart'; +import 'package:clide/kernel/kernel.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('ClideClipboard', () { + test('typed write + readAs round-trip the latest value', () async { + final c = ClideClipboard(); + await c.write(42); + expect(c.readAs(), 42); + await c.write(43); + expect(c.readAs(), 43); + }); + + test('typed history is bounded by historyLimit (LIFO)', () async { + final c = ClideClipboard(historyLimit: 3); + for (var i = 0; i < 5; i++) { + await c.write(i); + } + final history = c.historyOf(); + expect(history, [4, 3, 2]); + }); + + test('readAs returns null when the type has never been written', () { + expect(ClideClipboard().readAs(), isNull); + expect(ClideClipboard().historyOf(), isEmpty); + }); + + testWidgets('writePlain + readPlain go through the platform clipboard channel', (tester) async { + String? last; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.setData') { + last = (call.arguments as Map)['text'] as String?; + } else if (call.method == 'Clipboard.getData') { + return {'text': last}; + } + return null; + }, + ); + final c = ClideClipboard(); + await c.writePlain('hello'); + expect(last, 'hello'); + expect(await c.readPlain(), 'hello'); + // writePlain also feeds the typed-String history. + expect(c.readAs(), 'hello'); + }); + + testWidgets('write with toPlain syncs to the OS clipboard', (tester) async { + String? last; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.setData') { + last = (call.arguments as Map)['text'] as String?; + } + return null; + }, + ); + final c = ClideClipboard(); + await c.write(7, toPlain: (n) => 'n=$n'); + expect(last, 'n=7'); + }); + + test('clear empties every typed history bucket', () async { + final c = ClideClipboard(); + await c.write(1); + await c.write('s'); + c.clear(); + expect(c.readAs(), isNull); + expect(c.readAs(), isNull); + }); + }); + + group('FocusTracker', () { + test('setActive flips slot + contribution and notifies', () { + final f = FocusTracker(); + var calls = 0; + f.addListener(() => calls++); + f.setActive(slot: Slots.sidebar, contributionId: 'files'); + expect(f.activeSlot, Slots.sidebar); + expect(f.activeContributionId, 'files'); + expect(calls, 1); + }); + + test('setActive with the same slot+id is a no-op (no notify)', () { + final f = FocusTracker(); + f.setActive(slot: Slots.sidebar, contributionId: 'files'); + var calls = 0; + f.addListener(() => calls++); + f.setActive(slot: Slots.sidebar, contributionId: 'files'); + expect(calls, 0); + }); + + test('clear resets both fields and notifies once', () { + final f = FocusTracker(); + f.setActive(slot: Slots.sidebar, contributionId: 'files'); + var calls = 0; + f.addListener(() => calls++); + f.clear(); + expect(f.activeSlot, isNull); + expect(f.activeContributionId, isNull); + expect(calls, 1); + // clear when already cleared is a no-op. + f.clear(); + expect(calls, 1); + }); + }); + + group('NetworkStatus', () { + test('default state is online', () { + final n = NetworkStatus(); + expect(n.state, Reachability.online); + expect(n.isOnline, isTrue); + }); + + test('setState notifies only when the value changes', () { + final n = NetworkStatus(); + var calls = 0; + n.addListener(() => calls++); + n.setState(Reachability.online); // same value → no notify + expect(calls, 0); + n.setState(Reachability.offline); + expect(n.isOnline, isFalse); + expect(calls, 1); + n.setState(Reachability.metered); + expect(n.isOnline, isTrue); // metered counts as online + expect(calls, 2); + }); + }); + + group('SecretsVault', () { + test('write + read round-trip a value by (extensionId, key)', () async { + final v = SecretsVault(); + await v.write(extensionId: 'e', key: 'token', value: 'sekrit'); + expect(await v.read(extensionId: 'e', key: 'token'), 'sekrit'); + }); + + test('isolates by extensionId', () async { + final v = SecretsVault(); + await v.write(extensionId: 'a', key: 'k', value: 'x'); + expect(await v.read(extensionId: 'b', key: 'k'), isNull); + }); + + test('delete removes one entry', () async { + final v = SecretsVault(); + await v.write(extensionId: 'e', key: 'k', value: 'v'); + await v.delete(extensionId: 'e', key: 'k'); + expect(await v.read(extensionId: 'e', key: 'k'), isNull); + }); + + test('deleteAll removes every entry for the extension', () async { + final v = SecretsVault(); + await v.write(extensionId: 'e', key: 'k1', value: 'v1'); + await v.write(extensionId: 'e', key: 'k2', value: 'v2'); + await v.write(extensionId: 'other', key: 'k', value: 'keep'); + await v.deleteAll(extensionId: 'e'); + expect(await v.read(extensionId: 'e', key: 'k1'), isNull); + expect(await v.read(extensionId: 'e', key: 'k2'), isNull); + expect(await v.read(extensionId: 'other', key: 'k'), 'keep'); + }); + }); + + group('TrayRegistry', () { + test('add registers items; remove drops them', () { + final t = TrayRegistry(); + t.add(TrayItemContribution(id: 'a', label: 'A', priority: 10, onSelected: () {})); + t.add(TrayItemContribution(id: 'b', label: 'B', priority: 5, onSelected: () {})); + expect(t.items.map((i) => i.id), ['b', 'a']); // sorted by priority asc + t.remove('a'); + expect(t.items.map((i) => i.id), ['b']); + }); + + test('remove of an unknown id is a silent no-op', () { + final t = TrayRegistry(); + var calls = 0; + t.addListener(() => calls++); + t.remove('nope'); + expect(calls, 0); + }); + }); + + group('Notifications', () { + test('info/warn/error/success push entries with the right level', () { + final n = Notifications(); + n.info('i', title: 'I'); + n.warn('w'); + n.error('e'); + n.success('s', duration: const Duration(seconds: 1)); + expect(n.active, hasLength(4)); + expect(n.active.map((x) => x.level), [ + NotificationLevel.info, + NotificationLevel.warning, + NotificationLevel.error, + NotificationLevel.success, + ]); + n.dispose(); + }); + + test('dismiss removes the matching entry and notifies', () { + final n = Notifications(); + n.info('m'); + final id = n.active.single.id; + var calls = 0; + n.addListener(() => calls++); + n.dismiss(id); + expect(n.active, isEmpty); + expect(calls, 1); + // Dismissing an unknown id is a silent no-op. + n.dismiss('no-such-id'); + expect(calls, 1); + n.dispose(); + }); + }); +}