Skip to content

Commit b8dabeb

Browse files
committed
feat: add registry support — persistent hierarchical key-value store
1 parent 6573d21 commit b8dabeb

13 files changed

Lines changed: 1086 additions & 7 deletions
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
// SharpConsoleUI.Tests/Registry/AppRegistryTests.cs
2+
using System.Threading;
3+
using SharpConsoleUI.Configuration;
4+
using SharpConsoleUI.Core;
5+
using SharpConsoleUI.Registry;
6+
using Xunit;
7+
8+
namespace SharpConsoleUI.Tests.Registry;
9+
10+
public class AppRegistryManualFlushTests
11+
{
12+
private static (AppRegistry registry, MemoryStorage storage) MakeRegistry(bool eagerFlush = false)
13+
{
14+
var storage = new MemoryStorage();
15+
var config = new RegistryConfiguration(EagerFlush: eagerFlush, Storage: storage);
16+
var registry = new AppRegistry(config);
17+
return (registry, storage);
18+
}
19+
20+
[Fact]
21+
public void Save_PersistsToStorage()
22+
{
23+
var (reg, storage) = MakeRegistry();
24+
reg.OpenSection("App").SetString("name", "test");
25+
reg.Save();
26+
Assert.NotNull(storage.Load());
27+
}
28+
29+
[Fact]
30+
public void LoadAfterSave_RestoresValues()
31+
{
32+
var (reg, storage) = MakeRegistry();
33+
reg.OpenSection("App").SetInt("X", 42);
34+
reg.Save();
35+
36+
var reg2 = new AppRegistry(new RegistryConfiguration(Storage: storage));
37+
reg2.Load();
38+
Assert.Equal(42, reg2.OpenSection("App").GetInt("X"));
39+
}
40+
41+
[Fact]
42+
public void Load_EmptyStorage_StartsEmpty()
43+
{
44+
var (reg, _) = MakeRegistry();
45+
reg.Load(); // storage has nothing yet — should not throw
46+
Assert.Equal(0, reg.OpenSection("App").GetInt("X"));
47+
}
48+
49+
[Fact]
50+
public void Load_IsDestructive_DiscardsPendingWrites()
51+
{
52+
var (reg, storage) = MakeRegistry();
53+
reg.OpenSection("App").SetInt("X", 1);
54+
reg.Save();
55+
56+
reg.OpenSection("App").SetInt("X", 999); // unsaved write
57+
reg.Load(); // reload from storage — should discard 999
58+
Assert.Equal(1, reg.OpenSection("App").GetInt("X"));
59+
}
60+
}
61+
62+
public class AppRegistryEagerFlushTests
63+
{
64+
[Fact]
65+
public void EagerFlush_EachSet_TriggersImmediateSave()
66+
{
67+
var saveCount = 0;
68+
var storage = new CountingStorage(() => saveCount++);
69+
var config = new RegistryConfiguration(EagerFlush: true, Storage: storage);
70+
var reg = new AppRegistry(config);
71+
72+
reg.OpenSection("A").SetInt("x", 1);
73+
Assert.Equal(1, saveCount);
74+
75+
reg.OpenSection("A").SetInt("y", 2);
76+
Assert.Equal(2, saveCount);
77+
}
78+
79+
private class CountingStorage(Action onSave) : IRegistryStorage
80+
{
81+
public void Save(System.Text.Json.Nodes.JsonNode root) => onSave();
82+
public System.Text.Json.Nodes.JsonNode? Load() => null;
83+
}
84+
}
85+
86+
public class AppRegistryLazyFlushTests
87+
{
88+
[Fact]
89+
public async Task LazyFlush_TimerFires_PersistsData()
90+
{
91+
var storage = new MemoryStorage();
92+
var config = new RegistryConfiguration(
93+
FlushInterval: TimeSpan.FromMilliseconds(50),
94+
Storage: storage);
95+
96+
using var reg = new AppRegistry(config);
97+
reg.OpenSection("App").SetInt("X", 77);
98+
99+
// Wait 10x the interval — generous margin for CI environments under load
100+
await Task.Delay(500);
101+
102+
var loaded = storage.Load();
103+
Assert.NotNull(loaded);
104+
}
105+
106+
[Fact]
107+
public void Dispose_StopsTimer_NoFurtherSaves()
108+
{
109+
var saveCount = 0;
110+
var storage = new CountingStorage(() => saveCount++);
111+
var config = new RegistryConfiguration(
112+
FlushInterval: TimeSpan.FromMilliseconds(50),
113+
Storage: storage);
114+
115+
var reg = new AppRegistry(config);
116+
reg.Dispose();
117+
var countAfterDispose = saveCount;
118+
119+
// After dispose, waiting should not cause more saves
120+
Thread.Sleep(500);
121+
Assert.Equal(countAfterDispose, saveCount);
122+
}
123+
124+
private class CountingStorage(Action onSave) : IRegistryStorage
125+
{
126+
public void Save(System.Text.Json.Nodes.JsonNode root) => onSave();
127+
public System.Text.Json.Nodes.JsonNode? Load() => null;
128+
}
129+
}
130+
131+
public class RegistryStateServiceTests
132+
{
133+
[Fact]
134+
public void Dispose_CallsSave()
135+
{
136+
var storage = new MemoryStorage();
137+
var config = new RegistryConfiguration(Storage: storage);
138+
var registry = new AppRegistry(config);
139+
var service = new RegistryStateService(registry);
140+
141+
service.OpenSection("App").SetInt("X", 55);
142+
service.Dispose(); // should call Save()
143+
144+
var loaded = storage.Load();
145+
Assert.NotNull(loaded);
146+
}
147+
148+
[Fact]
149+
public void OpenSection_DelegatesTo_AppRegistry()
150+
{
151+
var storage = new MemoryStorage();
152+
var registry = new AppRegistry(new RegistryConfiguration(Storage: storage));
153+
var service = new RegistryStateService(registry);
154+
155+
service.OpenSection("A").SetString("k", "v");
156+
Assert.Equal("v", registry.OpenSection("A").GetString("k"));
157+
}
158+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
using System.Text.Json.Nodes;
2+
using SharpConsoleUI.Registry;
3+
using Xunit;
4+
5+
namespace SharpConsoleUI.Tests.Registry;
6+
7+
public class JsonFileStorageTests : IDisposable
8+
{
9+
private readonly string _tempFile = Path.Combine(Path.GetTempPath(), $"registry_test_{Guid.NewGuid()}.json");
10+
11+
[Fact]
12+
public void Load_MissingFile_ReturnsNull()
13+
{
14+
var storage = new JsonFileStorage(_tempFile);
15+
Assert.Null(storage.Load());
16+
}
17+
18+
[Fact]
19+
public void SaveThenLoad_RoundTrips()
20+
{
21+
var storage = new JsonFileStorage(_tempFile);
22+
var root = new JsonObject { ["key"] = "value" };
23+
storage.Save(root);
24+
25+
var loaded = storage.Load();
26+
Assert.NotNull(loaded);
27+
Assert.Equal("value", loaded!["key"]!.GetValue<string>());
28+
}
29+
30+
[Fact]
31+
public void Save_WritesValidJsonFile()
32+
{
33+
var storage = new JsonFileStorage(_tempFile);
34+
storage.Save(new JsonObject { ["x"] = 42 });
35+
36+
var json = File.ReadAllText(_tempFile);
37+
Assert.Contains("\"x\"", json);
38+
Assert.Contains("42", json);
39+
}
40+
41+
public void Dispose()
42+
{
43+
if (File.Exists(_tempFile))
44+
File.Delete(_tempFile);
45+
}
46+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
using System.Text.Json.Nodes;
2+
using SharpConsoleUI.Registry;
3+
using Xunit;
4+
5+
namespace SharpConsoleUI.Tests.Registry;
6+
7+
public class MemoryStorageTests
8+
{
9+
[Fact]
10+
public void Load_BeforeSave_ReturnsNull()
11+
{
12+
var storage = new MemoryStorage();
13+
Assert.Null(storage.Load());
14+
}
15+
16+
[Fact]
17+
public void SaveThenLoad_ReturnsCloneOfSavedData()
18+
{
19+
var storage = new MemoryStorage();
20+
var root = new JsonObject { ["key"] = "value" };
21+
storage.Save(root);
22+
23+
var loaded = storage.Load();
24+
Assert.NotNull(loaded);
25+
Assert.Equal("value", loaded!["key"]!.GetValue<string>());
26+
}
27+
28+
[Fact]
29+
public void Save_StoresClone_MutatingOriginalDoesNotAffectLoad()
30+
{
31+
var storage = new MemoryStorage();
32+
var root = new JsonObject { ["key"] = "original" };
33+
storage.Save(root);
34+
35+
// Mutate original — loaded copy should be unaffected
36+
root["key"] = "mutated";
37+
var loaded = storage.Load();
38+
Assert.Equal("original", loaded!["key"]!.GetValue<string>());
39+
}
40+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// SharpConsoleUI.Tests/Registry/RegistryIntegrationTests.cs
2+
using SharpConsoleUI;
3+
using SharpConsoleUI.Configuration;
4+
using SharpConsoleUI.Drivers;
5+
using SharpConsoleUI.Tests.Infrastructure;
6+
using Xunit;
7+
8+
namespace SharpConsoleUI.Tests.Registry;
9+
10+
public class RegistryIntegrationTests : IDisposable
11+
{
12+
private readonly string _tempFile = Path.Combine(
13+
Path.GetTempPath(), $"registry_integration_{Guid.NewGuid()}.json");
14+
15+
[Fact]
16+
public void WindowSystem_RegistryStateService_Null_WhenNoConfigProvided()
17+
{
18+
var ws = TestWindowSystemBuilder.CreateTestSystem();
19+
Assert.Null(ws.RegistryStateService);
20+
}
21+
22+
[Fact]
23+
public void WindowSystem_RegistryStateService_NotNull_WhenConfigProvided()
24+
{
25+
var mockDriver = new MockConsoleDriver();
26+
var config = RegistryConfiguration.ForFile(_tempFile);
27+
var ws = new ConsoleWindowSystem(mockDriver, registryConfiguration: config);
28+
Assert.NotNull(ws.RegistryStateService);
29+
}
30+
31+
[Fact]
32+
public void WriteDispose_ThenRestore_ValuesAreRecovered()
33+
{
34+
var config = RegistryConfiguration.ForFile(_tempFile);
35+
36+
// Session 1: write and dispose (triggers save)
37+
{
38+
var mockDriver = new MockConsoleDriver();
39+
var ws = new ConsoleWindowSystem(mockDriver, registryConfiguration: config);
40+
ws.RegistryStateService!.OpenSection("App/Windows/Main").SetInt("X", 42);
41+
ws.RegistryStateService.Dispose();
42+
}
43+
44+
// Session 2: create new instance and restore
45+
{
46+
var mockDriver = new MockConsoleDriver();
47+
var ws = new ConsoleWindowSystem(mockDriver, registryConfiguration: config);
48+
var x = ws.RegistryStateService!.OpenSection("App/Windows/Main").GetInt("X");
49+
Assert.Equal(42, x);
50+
ws.RegistryStateService.Dispose();
51+
}
52+
}
53+
54+
public void Dispose()
55+
{
56+
if (File.Exists(_tempFile))
57+
File.Delete(_tempFile);
58+
}
59+
}

0 commit comments

Comments
 (0)