-
Notifications
You must be signed in to change notification settings - Fork 1
feat(#8): Project configuration load/save #49
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,136 @@ | ||
| using System; | ||
| using System.IO; | ||
| using System.Threading.Tasks; | ||
| using Xunit; | ||
| using BannerlordModEditor.Common.Services; | ||
|
|
||
| namespace BannerlordModEditor.Common.Tests.Services | ||
| { | ||
| public class ProjectSettingsServiceTests | ||
| { | ||
| private readonly ProjectSettingsService _service; | ||
|
|
||
| public ProjectSettingsServiceTests() | ||
| { | ||
| _service = new ProjectSettingsService(); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void GetDefault_CreatesValidDefault() | ||
| { | ||
| var result = _service.GetDefault("/test/path"); | ||
|
|
||
| Assert.Equal("/test/path", result.ProjectPath); | ||
| Assert.Equal("path", result.ProjectName); | ||
| Assert.Equal("Latest", result.GameVersion); | ||
| Assert.Empty(result.RecentFiles); | ||
| Assert.Empty(result.OpenTabs); | ||
| Assert.NotNull(result.Window); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void GetSettingsFilePath_ReturnsCorrectPath() | ||
| { | ||
| var result = _service.GetSettingsFilePath("/test/project"); | ||
|
|
||
| Assert.Equal("/test/project/.bannerlordmodeditor.json", result); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void Load_ReturnsDefaultForNonExistentFile() | ||
| { | ||
| var result = _service.Load("/nonexistent/path"); | ||
|
|
||
| Assert.NotNull(result); | ||
| Assert.Equal("/nonexistent/path", result.ProjectPath); | ||
| } | ||
|
|
||
| [Fact] | ||
| public async Task LoadAsync_ReturnsDefaultForNonExistentFile() | ||
| { | ||
| var result = await _service.LoadAsync("/nonexistent/path"); | ||
|
|
||
| Assert.NotNull(result); | ||
| Assert.Equal("/nonexistent/path", result.ProjectPath); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void Save_CreatesSettingsFile() | ||
| { | ||
| var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); | ||
| var settings = new ProjectSettings | ||
| { | ||
| ProjectName = "Test Project", | ||
| GameVersion = "v1.2.9" | ||
| }; | ||
|
|
||
| try | ||
| { | ||
| _service.Save(settings, tempDir); | ||
|
|
||
| var settingsPath = Path.Combine(tempDir, ".bannerlordmodeditor.json"); | ||
| Assert.True(File.Exists(settingsPath)); | ||
|
|
||
| var loaded = _service.Load(tempDir); | ||
| Assert.Equal("Test Project", loaded.ProjectName); | ||
| Assert.Equal("v1.2.9", loaded.GameVersion); | ||
| } | ||
| finally | ||
| { | ||
| if (Directory.Exists(tempDir)) | ||
| Directory.Delete(tempDir, true); | ||
| } | ||
| } | ||
|
|
||
| [Fact] | ||
| public async Task SaveAsync_CreatesSettingsFile() | ||
| { | ||
| var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); | ||
| var settings = _service.GetDefault(tempDir); | ||
| settings.ProjectName = "Async Test"; | ||
|
|
||
| try | ||
| { | ||
| await _service.SaveAsync(settings, tempDir); | ||
|
|
||
| var loaded = await _service.LoadAsync(tempDir); | ||
| Assert.Equal("Async Test", loaded.ProjectName); | ||
| } | ||
| finally | ||
| { | ||
| if (Directory.Exists(tempDir)) | ||
| Directory.Delete(tempDir, true); | ||
| } | ||
| } | ||
|
|
||
| [Fact] | ||
| public void Save_PreservesRecentFiles() | ||
| { | ||
| var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); | ||
| var settings = new ProjectSettings | ||
| { | ||
| ProjectName = "Test", | ||
| RecentFiles = new System.Collections.Generic.List<string> | ||
| { | ||
| "/path/to/file1.xml", | ||
| "/path/to/file2.xml" | ||
| } | ||
| }; | ||
|
|
||
| try | ||
| { | ||
| _service.Save(settings, tempDir); | ||
|
|
||
| var loaded = _service.Load(tempDir); | ||
| Assert.Equal(2, loaded.RecentFiles.Count); | ||
| Assert.Contains("/path/to/file1.xml", loaded.RecentFiles); | ||
| Assert.Contains("/path/to/file2.xml", loaded.RecentFiles); | ||
| } | ||
| finally | ||
| { | ||
| if (Directory.Exists(tempDir)) | ||
| Directory.Delete(tempDir, true); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,116 @@ | ||||||||||||||||||||||||||||||
| using System; | ||||||||||||||||||||||||||||||
| using System.Collections.Generic; | ||||||||||||||||||||||||||||||
| using System.IO; | ||||||||||||||||||||||||||||||
| using System.Text.Json; | ||||||||||||||||||||||||||||||
| using System.Threading.Tasks; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| namespace BannerlordModEditor.Common.Services | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| public class ProjectSettings | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| public string ProjectName { get; set; } = string.Empty; | ||||||||||||||||||||||||||||||
| public string ProjectPath { get; set; } = string.Empty; | ||||||||||||||||||||||||||||||
| public string GameVersion { get; set; } = "Latest"; | ||||||||||||||||||||||||||||||
| public string LastOpenedFile { get; set; } = string.Empty; | ||||||||||||||||||||||||||||||
| public DateTime LastOpened { get; set; } | ||||||||||||||||||||||||||||||
| public Dictionary<string, string> CustomSettings { get; set; } = new(); | ||||||||||||||||||||||||||||||
| public List<string> RecentFiles { get; set; } = new(); | ||||||||||||||||||||||||||||||
| public List<string> OpenTabs { get; set; } = new(); | ||||||||||||||||||||||||||||||
| public WindowSettings Window { get; set; } = new(); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| public class WindowSettings | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| public double Width { get; set; } = 1200; | ||||||||||||||||||||||||||||||
| public double Height { get; set; } = 800; | ||||||||||||||||||||||||||||||
| public double X { get; set; } | ||||||||||||||||||||||||||||||
| public double Y { get; set; } | ||||||||||||||||||||||||||||||
| public bool IsMaximized { get; set; } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
Comment on lines
+9
to
+29
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| public interface IProjectSettingsService | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| Task<ProjectSettings> LoadAsync(string projectPath); | ||||||||||||||||||||||||||||||
| ProjectSettings Load(string projectPath); | ||||||||||||||||||||||||||||||
| Task SaveAsync(ProjectSettings settings, string projectPath); | ||||||||||||||||||||||||||||||
| void Save(ProjectSettings settings, string projectPath); | ||||||||||||||||||||||||||||||
| ProjectSettings GetDefault(string projectPath); | ||||||||||||||||||||||||||||||
| string GetSettingsFilePath(string projectPath); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| public class ProjectSettingsService : IProjectSettingsService | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| private const string SettingsFileName = ".bannerlordmodeditor.json"; | ||||||||||||||||||||||||||||||
| private static readonly JsonSerializerOptions JsonOptions = new() | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| WriteIndented = true, | ||||||||||||||||||||||||||||||
| PropertyNamingPolicy = JsonNamingPolicy.CamelCase | ||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| public async Task<ProjectSettings> LoadAsync(string projectPath) | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| return await Task.Run(() => Load(projectPath)); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
Comment on lines
+50
to
+53
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| public ProjectSettings Load(string projectPath) | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| var settingsPath = GetSettingsFilePath(projectPath); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| if (!File.Exists(settingsPath)) | ||||||||||||||||||||||||||||||
| return GetDefault(projectPath); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| try | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| var json = File.ReadAllText(settingsPath); | ||||||||||||||||||||||||||||||
| var settings = JsonSerializer.Deserialize<ProjectSettings>(json, JsonOptions); | ||||||||||||||||||||||||||||||
| return settings ?? GetDefault(projectPath); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| catch | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| return GetDefault(projectPath); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
Comment on lines
+68
to
+71
|
||||||||||||||||||||||||||||||
| catch | |
| { | |
| return GetDefault(projectPath); | |
| } | |
| catch (JsonException ex) | |
| { | |
| Console.Error.WriteLine($"Failed to parse project settings file '{settingsPath}': {ex}"); | |
| return GetDefault(projectPath); | |
| } | |
| catch (IOException ex) | |
| { | |
| Console.Error.WriteLine($"I/O error while reading project settings file '{settingsPath}': {ex}"); | |
| return GetDefault(projectPath); | |
| } |
Copilot
AI
Mar 20, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LoadAsync/SaveAsync are implemented via Task.Run, which offloads synchronous disk I/O to the thread pool rather than using true async file I/O. This can reduce scalability and responsiveness under load. Prefer File.ReadAllTextAsync/File.WriteAllTextAsync (and async JSON serialize/deserialize where applicable) so the async API is genuinely non-blocking.
Copilot
AI
Mar 20, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
DateTime.Now is local-time dependent and makes values harder to compare across machines/timezones; it also tends to complicate deterministic tests. Prefer DateTime.UtcNow or DateTimeOffset.UtcNow for persisted timestamps. Also, mutating settings.ProjectPath and settings.LastOpened inside Save is a side effect that may surprise callers; if this is required behavior, consider documenting it on the interface/method.
Copilot
AI
Mar 20, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Writing directly to the final settings file risks leaving a partially written/corrupted JSON file if the process crashes mid-write or the disk fills up. Prefer an atomic write pattern (write to a temp file in the same directory, flush, then replace/move over the target) to improve reliability.
| File.WriteAllText(settingsPath, json); | |
| var tempSettingsPath = settingsPath + ".tmp"; | |
| File.WriteAllText(tempSettingsPath, json); | |
| File.Move(tempSettingsPath, settingsPath, true); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This assertion hard-codes
/path separators and will fail on Windows wherePath.Combineuses\. Build the expected value usingPath.Combine("/test/project", ".bannerlordmodeditor.json")(or construct the test input usingPath.Combinetoo) so the test is cross-platform.