/// Unit tests for `ProjectManager` and `RecentProject` in /// `lib/kernel/src/project.dart`. Uses an injected `onValidateProject` /// hook so the manager doesn't shell out to git in tests. library; import 'dart:convert'; import 'dart:io'; import 'package:clide/kernel/kernel.dart'; import 'package:test/test.dart'; Future _build({ Future Function(String path)? onValidateProject, Future Function(String path)? onProjectOpen, Map initialSettings = const {}, }) async { final tmp = await Directory.systemTemp.createTemp('clide-project-test-'); final settings = SettingsStore(appDir: tmp); await settings.load(); for (final entry in initialSettings.entries) { await settings.set(entry.key, entry.value!); } final toolchain = Toolchain(); toolchain.applyResolved(const ResolvedPaths(git: '/usr/bin/git')); final pm = ProjectManager( log: Logger(minLevel: LogLevel.error), events: DaemonBus(), settings: settings, toolchain: toolchain, onValidateProject: onValidateProject, onProjectOpen: onProjectOpen, ); addTearDown(() async { if (tmp.existsSync()) tmp.deleteSync(recursive: true); }); return pm; } void main() { group('RecentProject', () { test(r'relativePath collapses $HOME prefix to ~', () { final home = Platform.environment['HOME'] ?? ''; if (home.isEmpty) return; final p = RecentProject(path: '$home/projects/clide', name: 'clide', lastOpened: DateTime.now()); expect(p.relativePath, startsWith('~/')); }); test(r'relativePath returns the absolute path when not under $HOME', () { final p = RecentProject(path: '/elsewhere/repo', name: 'repo', lastOpened: DateTime.now()); // If $HOME is /elsewhere... we'd collapse. Otherwise absolute is preserved. if (!'/elsewhere/repo'.startsWith(Platform.environment['HOME'] ?? '')) { expect(p.relativePath, '/elsewhere/repo'); } }); test('timeAgo buckets walk minutes → hours → days → weeks → months', () { final now = DateTime.now(); RecentProject pq(Duration ago) => RecentProject(path: '/p', name: 'p', lastOpened: now.subtract(ago)); expect(pq(const Duration(seconds: 10)).timeAgo, 'just now'); expect(pq(const Duration(minutes: 5)).timeAgo, contains('min')); expect(pq(const Duration(hours: 3)).timeAgo, contains('hours')); expect(pq(const Duration(days: 1, hours: 1)).timeAgo, 'yesterday'); expect(pq(const Duration(days: 3)).timeAgo, contains('days')); expect(pq(const Duration(days: 14)).timeAgo, contains('weeks')); expect(pq(const Duration(days: 90)).timeAgo, contains('months')); }); test('toJson / fromJson round-trip', () { final p = RecentProject(path: '/p', name: 'p', branch: 'main', lastOpened: DateTime.utc(2026, 5, 1, 12)); final back = RecentProject.fromJson(p.toJson()); expect(back.path, '/p'); expect(back.name, 'p'); expect(back.branch, 'main'); expect(back.lastOpened, p.lastOpened); }); test('remote recent round-trips host/port/user and stays remote (T-332)', () { final p = RecentProject(path: '/srv/repo', name: 'repo', lastOpened: DateTime.utc(2026, 6, 1), host: 'buildbox', port: 2222, user: 'jeroen'); expect(p.isRemote, isTrue); final json = p.toJson(); expect(json['host'], 'buildbox'); final back = RecentProject.fromJson(json); expect(back.host, 'buildbox'); expect(back.port, 2222); expect(back.user, 'jeroen'); expect(back.isRemote, isTrue); expect(back.ref, WorkspaceRef.remote(host: 'buildbox', path: '/srv/repo', port: 2222, user: 'jeroen')); // sticky toggles must not drop the host identity expect(back.copyWith(startupSticky: true).host, 'buildbox'); }); test('local recent serializes without host keys and deserializes local (back-compat)', () { final p = RecentProject(path: '/p', name: 'p', lastOpened: DateTime.utc(2026, 6, 1)); expect(p.isRemote, isFalse); expect(p.toJson().containsKey('host'), isFalse); // A pre-T-332 persisted recent has no host/port/user keys at all. final old = RecentProject.fromJson(const {'path': '/p', 'name': 'p'}); expect(old.isRemote, isFalse); expect(old.ref, const WorkspaceRef.local('/p')); }); test('relativePath shows host:path for remote recents, never ~-collapses', () { final home = Platform.environment['HOME'] ?? '/home/x'; final p = RecentProject(path: '$home/repo', name: 'repo', lastOpened: DateTime.now(), host: 'buildbox'); expect(p.relativePath, 'buildbox:$home/repo'); }); test('fromJson tolerates missing/invalid fields', () { final r = RecentProject.fromJson(const {}); expect(r.path, ''); expect(r.name, ''); expect(r.branch, isNull); }); }); group('ProjectManager', () { test('open() with a non-git path returns false and leaves _current null', () async { final pm = await _build(onValidateProject: (_) async => null); expect(await pm.open('/tmp/not-a-git-repo'), isFalse); expect(pm.isOpen, isFalse); }); test('open() with a valid path sets current + emits ProjectOpened', () async { final sandbox = await Directory.systemTemp.createTemp('clide-pm-'); addTearDown(() => sandbox.deleteSync(recursive: true)); final pm = await _build(onValidateProject: (_) async => sandbox.path); expect(await pm.open(sandbox.path), isTrue); expect(pm.isOpen, isTrue); expect(pm.current?.path, sandbox.path); }); test('open() inserts the project into recents (newest first)', () async { final sandbox = await Directory.systemTemp.createTemp('clide-pm-'); addTearDown(() => sandbox.deleteSync(recursive: true)); final pm = await _build(onValidateProject: (_) async => sandbox.path); await pm.open(sandbox.path); expect(pm.recents, hasLength(1)); expect(pm.recents.first.path, sandbox.path); }); test('opening the same path twice deduplicates and bumps it to the top', () async { final a = await Directory.systemTemp.createTemp('clide-pm-a-'); final b = await Directory.systemTemp.createTemp('clide-pm-b-'); addTearDown(() { a.deleteSync(recursive: true); b.deleteSync(recursive: true); }); final pm = await _build(onValidateProject: (p) async => p); await pm.open(a.path); await pm.open(b.path); await pm.open(a.path); // re-open a → moves to front expect(pm.recents.map((r) => r.path), [a.path, b.path]); }); test('recents is capped at 10', () async { final dirs = [for (var i = 0; i < 12; i++) await Directory.systemTemp.createTemp('clide-pm-$i-')]; addTearDown(() { for (final d in dirs) { if (d.existsSync()) d.deleteSync(recursive: true); } }); final pm = await _build(onValidateProject: (p) async => p); for (final d in dirs) { await pm.open(d.path); } expect(pm.recents, hasLength(10)); }); test('close() resets _current and emits ProjectClosed', () async { final sandbox = await Directory.systemTemp.createTemp('clide-pm-'); addTearDown(() => sandbox.deleteSync(recursive: true)); final pm = await _build(onValidateProject: (_) async => sandbox.path); await pm.open(sandbox.path); await pm.close(); expect(pm.isOpen, isFalse); // close on an already-closed manager is a no-op. await pm.close(); }); test('loadRecents reads the serialised list from settings', () async { final earlier = RecentProject(path: '/p', name: 'p', lastOpened: DateTime.utc(2026, 1, 1)); final pm = await _build( initialSettings: { 'app.recentProjects': jsonEncode([earlier.toJson()]), }, ); await pm.loadRecents(); expect(pm.recents, hasLength(1)); expect(pm.recents.first.path, '/p'); }); test('loadRecents tolerates a malformed value', () async { final pm = await _build(initialSettings: {'app.recentProjects': '{not json'}); await pm.loadRecents(); expect(pm.recents, isEmpty); }); test('loadRecents with no setting clears to empty', () async { final pm = await _build(); await pm.loadRecents(); expect(pm.recents, isEmpty); }); test('openLast returns false when no last-project is stored', () async { final pm = await _build(); expect(await pm.openLast(), isFalse); }); test('openLast returns false when the stored path no longer exists', () async { final pm = await _build(initialSettings: {'app.lastProject': '/tmp/clide-no-such-${DateTime.now().microsecondsSinceEpoch}'}); expect(await pm.openLast(), isFalse); }); test('openLast re-opens the stored project when it still exists', () async { final sandbox = await Directory.systemTemp.createTemp('clide-pm-last-'); addTearDown(() => sandbox.deleteSync(recursive: true)); final pm = await _build(initialSettings: {'app.lastProject': sandbox.path}, onValidateProject: (_) async => sandbox.path); expect(await pm.openLast(), isTrue); expect(pm.current?.path, sandbox.path); }); test('resolveProject without an injected validator falls back to Process.run', () async { // No onValidateProject → uses Toolchain.git. With a real git in a tempdir // that's not a repo, returns null. final pm = await _build(); final notRepo = await Directory.systemTemp.createTemp('clide-pm-not-'); addTearDown(() => notRepo.deleteSync(recursive: true)); expect(await pm.resolveProject(notRepo.path), isNull); }); test('stickyProjectPath is null with zero sticky recents', () async { final pm = await _build(); expect(pm.stickyProjectPath, isNull); }); test('setStickyStartup is a no-op for an unknown path', () async { final pm = await _build(); await pm.setStickyStartup('/nope', true); expect(pm.stickyProjectPath, isNull); }); test('setStickyStartup flips, persists, and notifies listeners', () async { final sandbox = await Directory.systemTemp.createTemp('clide-pm-sticky-'); addTearDown(() => sandbox.deleteSync(recursive: true)); final pm = await _build(onValidateProject: (_) async => sandbox.path); expect(await pm.open(sandbox.path), isTrue); var calls = 0; pm.addListener(() => calls++); await pm.setStickyStartup(sandbox.path, true); expect(pm.isStickyStartup(sandbox.path), isTrue); expect(pm.stickyProjectPath, sandbox.path); expect(calls, 1); // Idempotent — second call with same value does nothing. await pm.setStickyStartup(sandbox.path, true); expect(calls, 1); // Flip back. await pm.setStickyStartup(sandbox.path, false); expect(pm.isStickyStartup(sandbox.path), isFalse); expect(pm.stickyProjectPath, isNull); }); test('sticky flag survives a reopen of the same project', () async { final sandbox = await Directory.systemTemp.createTemp('clide-pm-sticky-reopen-'); addTearDown(() => sandbox.deleteSync(recursive: true)); final pm = await _build(onValidateProject: (_) async => sandbox.path); await pm.open(sandbox.path); await pm.setStickyStartup(sandbox.path, true); // Reopen — sticky must persist. await pm.open(sandbox.path); expect(pm.isStickyStartup(sandbox.path), isTrue); }); test('stickyProjectPath is null when two recents are sticky', () async { final a = await Directory.systemTemp.createTemp('clide-pm-sticky-a-'); final b = await Directory.systemTemp.createTemp('clide-pm-sticky-b-'); addTearDown(() => a.deleteSync(recursive: true)); addTearDown(() => b.deleteSync(recursive: true)); final pm = await _build(onValidateProject: (p) async => p); await pm.open(a.path); await pm.open(b.path); await pm.setStickyStartup(a.path, true); await pm.setStickyStartup(b.path, true); // Ambiguous — picker should still show. expect(pm.stickyProjectPath, isNull); }); test('openStickyOrNothing returns false when none sticky', () async { final pm = await _build(); expect(await pm.openStickyOrNothing(), isFalse); }); test('openStickyOrNothing opens the lone sticky project', () async { final sandbox = await Directory.systemTemp.createTemp('clide-pm-open-sticky-'); addTearDown(() => sandbox.deleteSync(recursive: true)); final pm = await _build(onValidateProject: (_) async => sandbox.path); await pm.open(sandbox.path); await pm.setStickyStartup(sandbox.path, true); // Close, then reload from settings to simulate boot. await pm.close(); final pm2 = await _build( onValidateProject: (_) async => sandbox.path, initialSettings: { 'app.recentProjects': jsonEncode([ {'path': sandbox.path, 'name': 'pm-open-sticky', 'lastOpened': DateTime.now().toIso8601String(), 'startupSticky': true}, ]), }, ); await pm2.loadRecents(); expect(await pm2.openStickyOrNothing(), isTrue); expect(pm2.current?.path, sandbox.path); }); test('open() awaits the onProjectOpen hook with the resolved root', () async { final sandbox = await Directory.systemTemp.createTemp('clide-pm-onopen-'); addTearDown(() => sandbox.deleteSync(recursive: true)); final seen = []; final pm = await _build(onValidateProject: (_) async => sandbox.path, onProjectOpen: (path) async => seen.add(path)); expect(await pm.open(sandbox.path), isTrue); expect(seen, [sandbox.path]); }); test('startupSticky round-trips through toJson/fromJson', () { final p = RecentProject(path: '/p', name: 'p', lastOpened: DateTime.now(), startupSticky: true); final j = p.toJson(); expect(j['startupSticky'], isTrue); final p2 = RecentProject.fromJson(j); expect(p2.startupSticky, isTrue); // Default (not sticky) omits the key for cleaner JSON. final q = RecentProject(path: '/q', name: 'q', lastOpened: DateTime.now()); expect(q.toJson().containsKey('startupSticky'), isFalse); }); }); }