Files
clide/test/kernel/src/settings_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

198 lines
7.4 KiB
Dart

import 'dart:io';
import 'package:clide/kernel/kernel.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('SettingsStore', () {
late Directory tmp;
late SettingsStore store;
setUp(() async {
tmp = await Directory.systemTemp.createTemp('clide_settings_');
store = SettingsStore(appDir: tmp);
await store.load();
});
tearDown(() async {
store.dispose();
if (await tmp.exists()) {
try {
await tmp.delete(recursive: true);
} catch (_) {}
}
});
test('scope key validation — rejects non-standard prefixes', () async {
expect(
() => store.get<String>('nothing.here'),
throwsA(isA<ArgumentError>()),
);
expect(
() => store.set('notascope.key', 'v'),
throwsA(isA<ArgumentError>()),
);
});
test('app.* scope round-trips via YAML on disk', () async {
await store.set<String>('app.theme.current', 'summer-night');
expect(store.get<String>('app.theme.current'), 'summer-night');
final loaded = SettingsStore(appDir: tmp);
await loaded.load();
expect(loaded.get<String>('app.theme.current'), 'summer-night');
loaded.dispose();
});
test('app.* scope supports bool + int + list', () async {
await store.set<bool>('app.extensions.git.enabled', false);
await store.set<int>('app.layout.width', 240);
await store.set<List<String>>('app.recent', const ['/a', '/b']);
final loaded = SettingsStore(appDir: tmp);
await loaded.load();
expect(loaded.get<bool>('app.extensions.git.enabled'), false);
expect(loaded.get<int>('app.layout.width'), 240);
expect(loaded.get<List<dynamic>>('app.recent'), ['/a', '/b']);
loaded.dispose();
});
test('setting a project.* key without an open project throws', () async {
expect(
() => store.set('project.thing', 'x'),
throwsA(isA<StateError>()),
);
});
test('project scope is isolated from app scope', () async {
final projectDir = await Directory.systemTemp.createTemp('clide_proj_');
try {
await store.setProjectDir(projectDir);
await store.set<String>('app.global', 'A');
await store.set<String>('project.scoped', 'P');
expect(store.get<String>('app.global'), 'A');
expect(store.get<String>('project.scoped'), 'P');
// Reload project dir (simulate reopening) and confirm app values
// don't leak into project store.
await store.setProjectDir(null);
expect(store.get<String>('app.global'), 'A');
expect(store.get<String>('project.scoped'), isNull);
await store.setProjectDir(projectDir);
expect(store.get<String>('project.scoped'), 'P');
} finally {
try {
await projectDir.delete(recursive: true);
} catch (_) {}
}
});
test('notifyListeners fires on set and load', () async {
var count = 0;
store.addListener(() => count++);
await store.set<String>('app.k', 'v');
expect(count, greaterThanOrEqualTo(1));
});
test('project-scoped set + get round-trip when projectDir is configured', () async {
final project = await Directory.systemTemp.createTemp('clide_settings_project_');
addTearDown(() => project.deleteSync(recursive: true));
await store.setProjectDir(project);
await store.set<int>('project.foo.bar', 7);
expect(store.get<int>('project.foo.bar'), 7);
// Persisted on disk.
final file = File('${project.path}/.clide/settings.yaml');
expect(file.existsSync(), isTrue);
// Reload sees the value.
await store.load();
expect(store.get<int>('project.foo.bar'), 7);
});
test('setting a project-scoped key without a project throws StateError', () async {
expect(
() async => store.set<int>('project.unset', 1),
throwsA(isA<StateError>()),
);
});
test('ext.* keys default to app scope; project overrides app for the same key', () async {
await store.set<String>('ext.foo.bar', 'app-value');
expect(store.get<String>('ext.foo.bar'), 'app-value');
// With a project open, the project value wins.
final project = await Directory.systemTemp.createTemp('clide_settings_extp_');
addTearDown(() => project.deleteSync(recursive: true));
await store.setProjectDir(project);
// Inject a project-scoped ext value via the on-disk file (simulating
// a per-project override).
final pfile = File('${project.path}/.clide/settings.yaml');
await pfile.parent.create(recursive: true);
await pfile.writeAsString('ext:\n foo:\n bar: project-value\n');
await store.load();
expect(store.get<String>('ext.foo.bar'), 'project-value');
});
test('setProjectDir(null) clears the project values', () async {
final project = await Directory.systemTemp.createTemp('clide_settings_clear_');
addTearDown(() => project.deleteSync(recursive: true));
await store.setProjectDir(project);
await store.set<int>('project.x', 1);
await store.setProjectDir(null);
expect(store.get<int>('project.x'), isNull);
});
test('YAML emitter handles every scalar / collection branch', () async {
// Null, list of mixed types, bool, num, string with special chars,
// empty string, empty map → exercises _emitScalar + _emit.
await store.set<Object>('app.bool', true);
await store.set<Object>('app.num', 42);
await store.set<Object>('app.str.simple', 'hi');
await store.set<Object>('app.str.special', 'has:colon and # hash');
await store.set<Object>('app.str.empty', '');
await store.set<Object>('app.list', [1, 'two', null, false]);
// Round-trip through reload.
await store.load();
expect(store.get<bool>('app.bool'), isTrue);
expect(store.get<int>('app.num'), 42);
expect(store.get<String>('app.str.simple'), 'hi');
expect(store.get<String>('app.str.special'), 'has:colon and # hash');
expect(store.get<String>('app.str.empty'), '');
expect(store.get<List>('app.list'), [1, 'two', null, false]);
});
test('YAML emitter handles nested lists and empty maps', () async {
// Nested list — forces _emitScalar's `v is List` recursive branch.
await store.set<Object>('app.nested', [
[1, 2],
['a', 'b'],
]);
// Empty map under an app.* key — forces _emit's empty-map branch.
// Use a key whose value is itself a Map.
await store.set<Object>('app.empty', <String, Object?>{});
// Round-trip.
await store.load();
expect(store.get<List>('app.nested'), [
[1, 2],
['a', 'b'],
]);
});
test('load tolerates a malformed YAML file', () async {
// Write garbage to the on-disk app settings, then load.
final f = File('${tmp.path}/settings.yaml');
await f.writeAsString(': : : not yaml');
await store.load();
// No exception; in-memory store is empty.
expect(store.get<int>('app.anything'), isNull);
});
test('load returns empty when the settings file is blank or missing', () async {
// File missing → empty.
final f = File('${tmp.path}/settings.yaml');
if (f.existsSync()) await f.delete();
await store.load();
expect(store.get<int>('app.foo'), isNull);
// Blank file → empty.
await f.writeAsString('');
await store.load();
expect(store.get<int>('app.foo'), isNull);
});
});
}