Files
clide/test/kernel/src/project_test.dart
T
jpmschweitzerandClaude Opus 4.7 e430a87569 test sweep: kernel commands + widgets coverage, ratchet floor to 93
Add tests for `keybindings.dart` (KeyEvent → Keybinding mapping,
parse-error edges, resolver entries view), `toolchain_paths.dart`
(the Flutter-free `ToolchainView.resolved` static view), and several
`widgets/src/` primitives: tooltip hover/overlay, palette filter +
submit, multitab controller `copyWith` + size getters, and additional
markdown branches (h3–h6 headings, tables, strikethrough, default
block fallback, record-link tap).

Unfreezes the pre-push coverage floor that was held at 90 on
2026-05-14 by mistake and ratchets to 93. Tidies eight test files
that had accumulated unused imports flagged by `unnecessary_import`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:20:37 +02:00

207 lines
7.9 KiB
Dart

/// 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<ProjectManager> _build({
Future<String?> Function(String path)? onValidateProject,
Map<String, Object?> 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<Object>(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,
);
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('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);
});
});
}