make settings persistence safe for nested data and crashes (T-376)
Three failure modes in the YAML store: maps nested inside lists (the documented keymap-overlay shape) fell through _emitScalar to toString() and corrupted on the next read; writes went straight to the live file, so a crash mid-write truncated every setting; and a parse failure silently returned an empty map that the next set() wrote over the user's file. Maps in lists now emit as YAML flow mappings, writes are temp-file + rename, and an unparseable file is preserved as .broken with a warning through the kernel Logger (new onError hook, wired in the facade). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -170,6 +170,43 @@ void main() {
|
||||
expect(store.get<int>('app.anything'), isNull);
|
||||
});
|
||||
|
||||
// T-376: maps nested inside lists were emitted via toString() and
|
||||
// corrupted on the next read — breaking the documented keymap overlay.
|
||||
test('maps inside lists round-trip across save/load (keymap overlay shape)', () async {
|
||||
final overlay = [
|
||||
{'keys': 'ctrl+k ctrl+s', 'command': 'keybindings.open'},
|
||||
{'keys': 'shift shift', 'command': 'finder.open', 'when': 'editorFocus'},
|
||||
];
|
||||
await store.set<Object>('app.keymap.overlay', overlay);
|
||||
final loaded = SettingsStore(appDir: tmp);
|
||||
addTearDown(loaded.dispose);
|
||||
await loaded.load();
|
||||
final got = loaded.get<List>('app.keymap.overlay');
|
||||
expect(got, hasLength(2));
|
||||
expect((got![0] as Map)['keys'], 'ctrl+k ctrl+s');
|
||||
expect((got[0] as Map)['command'], 'keybindings.open');
|
||||
expect((got[1] as Map)['when'], 'editorFocus');
|
||||
});
|
||||
|
||||
test('a parse failure preserves the original file and reports it (T-376)', () async {
|
||||
final errors = <String>[];
|
||||
final f = File('${tmp.path}/settings.yaml');
|
||||
const garbage = 'app:\n broken: [unclosed\n'; // genuinely invalid YAML
|
||||
await f.writeAsString(garbage);
|
||||
final reporting = SettingsStore(appDir: tmp, onError: errors.add);
|
||||
addTearDown(reporting.dispose);
|
||||
await reporting.load();
|
||||
expect(errors, hasLength(1));
|
||||
expect(errors.single, contains('.broken'));
|
||||
expect(File('${f.path}.broken').readAsStringSync(), garbage, reason: 'the broken original is preserved for recovery');
|
||||
});
|
||||
|
||||
test('writes are atomic — no .tmp residue, content lands whole', () async {
|
||||
await store.set<String>('app.k', 'v');
|
||||
expect(File('${tmp.path}/settings.yaml.tmp').existsSync(), isFalse);
|
||||
expect(File('${tmp.path}/settings.yaml').readAsStringSync(), contains('k: v'));
|
||||
});
|
||||
|
||||
test('load returns empty when the settings file is blank or missing', () async {
|
||||
// File missing → empty.
|
||||
final f = File('${tmp.path}/settings.yaml');
|
||||
|
||||
Reference in New Issue
Block a user