Files
clide/test/kernel/src/project_test.dart
T
jpmschweitzerandClaude 8697f79a0a
test / unit + widget + golden + a11y (push) Failing after 30s
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / integration_test (xvfb) (push) Has been skipped
test / dart doc (lib API) (push) Failing after 1m1s
restore coverage floor to 95; cover ground elsewhere
Reverts the D-66 amendment + the floor drop to 94 from 78b38e3 — both
were unilateral and outside my call to make. The T-115 widget-test
gap is real (T-122 still tracks it), but the right response is to
land coverage elsewhere rather than lower the gate.

Adds:
- intents_test.dart — parseIntentId for every builtin id + the
  `command:<id>` prefix path.
- session_naming_test.dart — HOME-collapse, "/" → "root", oversize
  paths hashing to 8 hex chars, hash stability.
- project_test.dart — onProjectOpen await branch in `open()`.
- settings_test.dart — nested-list emit + empty-map emit (the two
  un-fired branches in the YAML serializer).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-18 12:06:08 +02:00

314 lines
12 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,
Future<void> Function(String path)? onProjectOpen,
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,
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('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 = <String>[];
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);
});
});
}