mirror of
https://github.com/MaSzyna-EU07/StarterNG.git
synced 2026-11-01 20:34:57 +01:00
Write settings, presets and favourites whole or not at all
A failed write leaves the old file in place. Settings that could not be read are not saved over. UI exceptions reach the log. Started from a shortcut set up elsewhere, the starter moves to its own folder when only that one holds a game.
This commit is contained in:
161
StarterNG.Tests/ConfigFileTests.cs
Normal file
161
StarterNG.Tests/ConfigFileTests.cs
Normal file
@@ -0,0 +1,161 @@
|
||||
using StarterNG.Classes;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public class ConfigFileTests
|
||||
{
|
||||
private static string[] Lines(ConfigFile config) =>
|
||||
config.ToText().Split(Environment.NewLine);
|
||||
|
||||
[Fact]
|
||||
public void A_file_is_written_with_the_hosts_line_endings_whichever_it_came_with()
|
||||
{
|
||||
// parser.cpp breaks words on \r as on \n, so the simulator takes either.
|
||||
string nl = Environment.NewLine;
|
||||
|
||||
Assert.Equal($"a 1{nl}b 2{nl}", ConfigFile.Parse("a 1\nb 2\n").ToText());
|
||||
Assert.Equal($"a 1{nl}b 2{nl}", ConfigFile.Parse("a 1\r\nb 2\r\n").ToText());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Removing_a_key_comments_every_line_that_sets_it()
|
||||
{
|
||||
var config = ConfigFile.Parse("width 800\nheight 600\nwidth 1024\nWIDTH 1920\n");
|
||||
|
||||
config.Remove("width");
|
||||
|
||||
Assert.False(config.Has("width"));
|
||||
Assert.Equal(new[] { "// width 800", "height 600", "// width 1024", "// WIDTH 1920", "" }, Lines(config));
|
||||
// Parsed again, nothing of it comes back.
|
||||
Assert.False(ConfigFile.Parse(config.ToText()).Has("width"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_removed_key_keeps_its_comment_and_indent()
|
||||
{
|
||||
var config = ConfigFile.Parse(" width 800\t// mine\n");
|
||||
|
||||
config.Remove("width");
|
||||
|
||||
Assert.Equal(" // width 800\t// mine", Lines(config)[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Removing_a_key_leaves_what_only_mentions_it()
|
||||
{
|
||||
var config = ConfigFile.Parse("// width 800\nwidth.max 900\nwidth 1024\n");
|
||||
|
||||
config.Remove("width");
|
||||
|
||||
Assert.Equal(new[] { "// width 800", "width.max 900", "// width 1024", "" }, Lines(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_new_key_goes_before_endconfig()
|
||||
{
|
||||
// Globals.cpp stops reading at endconfig.
|
||||
var config = ConfigFile.Parse("width 800\nendconfig\n// notes\n");
|
||||
|
||||
config.Set("height", "600");
|
||||
config.Set("fullscreen", "no");
|
||||
|
||||
Assert.Equal(new[] { "width 800", "height 600", "fullscreen no", "endconfig", "// notes", "" }, Lines(config));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("endconfig")]
|
||||
[InlineData("ENDCONFIG")]
|
||||
[InlineData(" EndConfig // the end")]
|
||||
public void Endconfig_is_known_as_the_simulator_knows_it(string line)
|
||||
{
|
||||
// Its parser lower-cases every word.
|
||||
var config = ConfigFile.Parse(line + "\n");
|
||||
|
||||
config.Set("width", "800");
|
||||
|
||||
Assert.Equal("width 800", Lines(config)[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Endconfig_in_a_comment_ends_nothing()
|
||||
{
|
||||
var config = ConfigFile.Parse("// endconfig\n");
|
||||
|
||||
config.Set("width", "800");
|
||||
|
||||
Assert.Equal(new[] { "// endconfig", "", "width 800" }, Lines(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Keys_found_after_an_insertion_are_still_the_right_lines()
|
||||
{
|
||||
var config = ConfigFile.Parse("width 800\nendconfig\nlate 1\n");
|
||||
|
||||
config.Set("height", "600");
|
||||
config.Set("width", "1024");
|
||||
config.Set("height", "720");
|
||||
|
||||
Assert.Equal(new[] { "width 1024", "height 720", "endconfig", "late 1", "" }, Lines(config));
|
||||
Assert.Equal("1024", config.GetRaw("width"));
|
||||
Assert.Equal("720", config.GetRaw("height"));
|
||||
Assert.Equal("1", config.GetRaw("late"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_key_past_endconfig_is_read_but_known_to_be_past_it()
|
||||
{
|
||||
var config = ConfigFile.Parse("width 800\nendconfig\nheight 600\n");
|
||||
|
||||
Assert.Equal("600", config.GetRaw("height"));
|
||||
Assert.True(config.IsPastEnd("height"));
|
||||
Assert.False(config.IsPastEnd("width"));
|
||||
Assert.False(config.IsPastEnd("endconfig"));
|
||||
Assert.False(config.IsPastEnd("absent"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_key_set_past_endconfig_moves_before_it_with_its_comment()
|
||||
{
|
||||
var config = ConfigFile.Parse("endconfig\n height 600\t// mine\nlate 1\n");
|
||||
|
||||
config.Set("height", "720");
|
||||
|
||||
Assert.Equal(new[] { " height 720\t// mine", "endconfig", "late 1", "" }, Lines(config));
|
||||
Assert.False(config.IsPastEnd("height"));
|
||||
Assert.True(config.IsPastEnd("late"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Of_a_key_on_both_sides_of_endconfig_the_line_the_simulator_reads_counts()
|
||||
{
|
||||
var config = ConfigFile.Parse("width 800\nendconfig\nwidth 1920\n");
|
||||
|
||||
Assert.Equal("800", config.GetRaw("width"));
|
||||
Assert.False(config.IsPastEnd("width"));
|
||||
|
||||
config.Set("width", "1024");
|
||||
|
||||
Assert.Equal(new[] { "width 1024", "endconfig", "width 1920", "" }, Lines(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_key_past_endconfig_is_removed_like_any_other()
|
||||
{
|
||||
var config = ConfigFile.Parse("width 800\nendconfig\nwidth 1920\nheight 600\n");
|
||||
|
||||
config.Remove("width");
|
||||
config.Remove("height");
|
||||
|
||||
Assert.Equal(new[] { "// width 800", "endconfig", "// width 1920", "// height 600", "" }, Lines(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_endconfig_a_new_key_goes_at_the_end_as_it_did()
|
||||
{
|
||||
var config = ConfigFile.Parse("width 800");
|
||||
|
||||
config.Set("height", "600");
|
||||
|
||||
Assert.Equal(new[] { "width 800", "height 600" }, Lines(config));
|
||||
}
|
||||
}
|
||||
@@ -9,16 +9,61 @@ namespace StarterNG.Tests.Fakes;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Paths are normalised to forward slashes and compared case-insensitively,
|
||||
/// matching how the parsers treat installation paths on both platforms.
|
||||
/// matching how the parsers treat installation paths on both platforms - or,
|
||||
/// asked for, case-sensitively, as a Linux disk holds them.
|
||||
/// </remarks>
|
||||
public sealed class InMemoryFileSystem : IFileSystem
|
||||
{
|
||||
private readonly Dictionary<string, byte[]> _files = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<string> _directories = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<string> _executable = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly StringComparison _comparison;
|
||||
private readonly Dictionary<string, byte[]> _files;
|
||||
private readonly HashSet<string> _directories;
|
||||
private readonly HashSet<string> _executable;
|
||||
private readonly Dictionary<string, string> _links;
|
||||
private readonly HashSet<string> _unreadable;
|
||||
private readonly HashSet<string> _unlistable;
|
||||
private readonly Dictionary<string, DateTime> _written;
|
||||
|
||||
public InMemoryFileSystem(bool caseSensitive = false)
|
||||
{
|
||||
_comparison = caseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase;
|
||||
var comparer = StringComparer.FromComparison(_comparison);
|
||||
_files = new Dictionary<string, byte[]>(comparer);
|
||||
_directories = new HashSet<string>(comparer);
|
||||
_executable = new HashSet<string>(comparer);
|
||||
_links = new Dictionary<string, string>(comparer);
|
||||
_unreadable = new HashSet<string>(comparer);
|
||||
_unlistable = new HashSet<string>(comparer);
|
||||
_written = new Dictionary<string, DateTime>(comparer);
|
||||
}
|
||||
|
||||
public DateTime LastWriteTimeUtc { get; set; } = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
/// <summary>Making a link throws, as on a disk that cannot hold one.</summary>
|
||||
public bool LinksFail { get; set; }
|
||||
|
||||
/// <summary>A folder moved to a destination this says yes to, its path with forward slashes, throws.</summary>
|
||||
public Func<string, bool>? MoveFails { get; set; }
|
||||
|
||||
/// <summary>The file stays, but reading or copying it throws, as a locked one does; writing it still goes through.</summary>
|
||||
public InMemoryFileSystem FailReading(string path)
|
||||
{
|
||||
_unreadable.Add(Normalize(path));
|
||||
return this;
|
||||
}
|
||||
|
||||
public InMemoryFileSystem AllowReading(string path)
|
||||
{
|
||||
_unreadable.Remove(Normalize(path));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>The folder is there, but listing it throws, as one without the permission does.</summary>
|
||||
public InMemoryFileSystem DenyListing(string path)
|
||||
{
|
||||
_unlistable.Add(Normalize(path));
|
||||
return this;
|
||||
}
|
||||
|
||||
static InMemoryFileSystem()
|
||||
{
|
||||
// The game's own files are code page 1250; a test that writes Polish text
|
||||
@@ -54,7 +99,10 @@ public sealed class InMemoryFileSystem : IFileSystem
|
||||
|
||||
public bool FileExists(string path) => _files.ContainsKey(Normalize(path));
|
||||
|
||||
public bool DirectoryExists(string path) => _directories.Contains(Normalize(path));
|
||||
/// <summary>A link counts as the directory it points at, as the disk has it.</summary>
|
||||
public bool DirectoryExists(string path) =>
|
||||
_directories.Contains(Normalize(path)) ||
|
||||
(_links.TryGetValue(Normalize(path), out string? target) && _directories.Contains(Normalize(target)));
|
||||
|
||||
public string ReadAllText(string path) => Encoding.UTF8.GetString(Bytes(path));
|
||||
|
||||
@@ -76,6 +124,10 @@ public sealed class InMemoryFileSystem : IFileSystem
|
||||
AddParents(key);
|
||||
}
|
||||
|
||||
public void WriteAllTextAtomic(string path, string contents) => WithFile(path, contents);
|
||||
|
||||
public void WriteAllBytesAtomic(string path, byte[] bytes) => WriteAllBytes(path, bytes);
|
||||
|
||||
public void WriteAllLines(string path, IEnumerable<string> lines) =>
|
||||
WithFile(path, string.Join("\n", lines));
|
||||
|
||||
@@ -103,7 +155,7 @@ public sealed class InMemoryFileSystem : IFileSystem
|
||||
string prefix = Normalize(path) + "/";
|
||||
var pattern = ToRegex(searchPattern);
|
||||
return _files.Keys
|
||||
.Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(key => key.StartsWith(prefix, _comparison))
|
||||
.Where(key => !key[prefix.Length..].Contains('/'))
|
||||
.Where(key => pattern.IsMatch(key[prefix.Length..]))
|
||||
.OrderBy(key => key, StringComparer.OrdinalIgnoreCase)
|
||||
@@ -115,7 +167,8 @@ public sealed class InMemoryFileSystem : IFileSystem
|
||||
string prefix = Normalize(path) + "/";
|
||||
var pattern = ToRegex(searchPattern);
|
||||
return _files.Keys
|
||||
.Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(key => key.StartsWith(prefix, _comparison))
|
||||
.Where(key => !_unlistable.Any(folder => key.StartsWith(folder + "/", _comparison)))
|
||||
.Where(key => pattern.IsMatch(key[(key.LastIndexOf('/') + 1)..]))
|
||||
.OrderBy(key => key, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
@@ -123,15 +176,26 @@ public sealed class InMemoryFileSystem : IFileSystem
|
||||
|
||||
public IReadOnlyList<string> GetDirectories(string path)
|
||||
{
|
||||
if (_unlistable.Contains(Normalize(path)))
|
||||
throw new UnauthorizedAccessException($"Access to the path '{path}' is denied.");
|
||||
|
||||
string prefix = Normalize(path) + "/";
|
||||
return _directories
|
||||
.Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(key => key.StartsWith(prefix, _comparison))
|
||||
.Where(key => !key[prefix.Length..].Contains('/'))
|
||||
.OrderBy(key => key, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public DateTime GetLastWriteTimeUtc(string path) => LastWriteTimeUtc;
|
||||
public DateTime GetLastWriteTimeUtc(string path) =>
|
||||
_written.TryGetValue(Normalize(path), out var written) ? written : LastWriteTimeUtc;
|
||||
|
||||
/// <summary>One file's own time, in place of <see cref="LastWriteTimeUtc"/>.</summary>
|
||||
public InMemoryFileSystem WrittenAt(string path, DateTime utc)
|
||||
{
|
||||
_written[Normalize(path)] = utc;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Files added through <see cref="WithExecutable"/> carry the execute bit.</summary>
|
||||
public long GetFileSize(string path) => Bytes(path).LongLength;
|
||||
@@ -145,10 +209,56 @@ public sealed class InMemoryFileSystem : IFileSystem
|
||||
return this;
|
||||
}
|
||||
|
||||
private byte[] Bytes(string path) =>
|
||||
_files.TryGetValue(Normalize(path), out var bytes)
|
||||
public string? LinkTarget(string path) => _links.GetValueOrDefault(Normalize(path));
|
||||
|
||||
public void CreateDirectoryLink(string path, string target)
|
||||
{
|
||||
if (LinksFail)
|
||||
throw new IOException($"cannot link {path}");
|
||||
|
||||
string key = Normalize(path);
|
||||
if (_links.ContainsKey(key) || _directories.Contains(key) || _files.ContainsKey(key))
|
||||
throw new IOException($"{path} already exists");
|
||||
_links[key] = target;
|
||||
AddParents(key);
|
||||
}
|
||||
|
||||
public void MoveDirectory(string sourcePath, string destinationPath)
|
||||
{
|
||||
if (MoveFails?.Invoke(Normalize(destinationPath)) == true)
|
||||
throw new IOException($"cannot move to {destinationPath}");
|
||||
|
||||
string from = Normalize(sourcePath), to = Normalize(destinationPath);
|
||||
if (!_directories.Contains(from) && !_links.ContainsKey(from))
|
||||
throw new DirectoryNotFoundException(sourcePath);
|
||||
if (_directories.Contains(to) || _links.ContainsKey(to) || _files.ContainsKey(to))
|
||||
throw new IOException($"{destinationPath} already exists");
|
||||
|
||||
string? Moved(string key) =>
|
||||
string.Equals(key, from, _comparison) ? to
|
||||
: key.StartsWith(from + "/", _comparison) ? to + key[from.Length..]
|
||||
: null;
|
||||
|
||||
foreach (var (key, bytes) in _files.ToList())
|
||||
if (Moved(key) is { } moved) { _files.Remove(key); _files[moved] = bytes; }
|
||||
foreach (string key in _executable.ToList())
|
||||
if (Moved(key) is { } moved) { _executable.Remove(key); _executable.Add(moved); }
|
||||
foreach (var (key, target) in _links.ToList())
|
||||
if (Moved(key) is { } moved) { _links.Remove(key); _links[moved] = target; }
|
||||
foreach (string key in _directories.ToList())
|
||||
if (Moved(key) is { } moved) { _directories.Remove(key); _directories.Add(moved); }
|
||||
AddParents(to);
|
||||
}
|
||||
|
||||
private byte[] Bytes(string path)
|
||||
{
|
||||
if (_unreadable.Contains(Normalize(path)))
|
||||
throw new IOException($"The process cannot access the file '{path}'.");
|
||||
|
||||
return _files.TryGetValue(Normalize(path), out var bytes)
|
||||
? bytes
|
||||
: throw new FileNotFoundException(path);
|
||||
}
|
||||
|
||||
private void AddParents(string key)
|
||||
{
|
||||
|
||||
12
StarterNG.Tests/Fakes/TestLog.cs
Normal file
12
StarterNG.Tests/Fakes/TestLog.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using StarterNG.Application.Abstractions;
|
||||
using StarterNG.Infrastructure.Adapters;
|
||||
|
||||
namespace StarterNG.Tests.Fakes;
|
||||
|
||||
/// <summary>The diagnostics log for a test that only wants one to write to or read back.</summary>
|
||||
public static class TestLog
|
||||
{
|
||||
/// <param name="files">Where the log file goes; a disk of its own when left out.</param>
|
||||
public static FileDiagnosticsLog Over(IFileSystem? files = null) =>
|
||||
new(files ?? new InMemoryFileSystem(), new FixedClock(new DateTime(2026, 10, 13)), new GamePaths("game"));
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
using StarterNG.Application;
|
||||
using StarterNG.Domain.Sceneries;
|
||||
using StarterNG.Tests.Fakes;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
@@ -35,4 +34,43 @@ public class FavoriteSceneriesTests
|
||||
|
||||
Assert.False(favorites.Contains(scenery));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_file_that_could_not_be_read_goes_aside_before_a_star_replaces_it()
|
||||
{
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithLegacyFile(TestInstallation.At("scenery", "td.scn"), "//$n Testowa")
|
||||
.WithFile(FavoritesPath, "one.scn\ntwo.scn")
|
||||
.FailReading(FavoritesPath));
|
||||
var scenery = Assert.Single(installation.Sceneries.LoadAll());
|
||||
var favorites = new FavoriteSceneries(installation.Files, installation.Log, FavoritesPath);
|
||||
|
||||
installation.Files.AllowReading(FavoritesPath);
|
||||
favorites.Toggle(scenery);
|
||||
|
||||
Assert.Equal("one.scn\ntwo.scn", installation.Files.ReadAllText(FavoritesPath + ".unreadable"));
|
||||
Assert.Equal("td.scn", installation.Files.ReadAllText(FavoritesPath));
|
||||
Assert.Contains(FavoritesPath + ".unreadable", installation.Log.Text);
|
||||
|
||||
// Set aside once: the file is the starter's own again.
|
||||
favorites.Toggle(scenery);
|
||||
Assert.False(installation.Files.FileExists(FavoritesPath + ".unreadable-2"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_locked_file_is_left_alone_when_no_copy_of_it_can_be_made()
|
||||
{
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithLegacyFile(TestInstallation.At("scenery", "td.scn"), "//$n Testowa")
|
||||
.WithFile(FavoritesPath, "one.scn\ntwo.scn")
|
||||
.FailReading(FavoritesPath));
|
||||
var scenery = Assert.Single(installation.Sceneries.LoadAll());
|
||||
var favorites = new FavoriteSceneries(installation.Files, installation.Log, FavoritesPath);
|
||||
|
||||
favorites.Toggle(scenery);
|
||||
|
||||
installation.Files.AllowReading(FavoritesPath);
|
||||
Assert.Equal("one.scn\ntwo.scn", installation.Files.ReadAllText(FavoritesPath));
|
||||
Assert.Contains("writing", installation.Log.Text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,4 +42,128 @@ public class FileDiagnosticsLogTests
|
||||
|
||||
Assert.Equal(string.Empty, log.Text);
|
||||
}
|
||||
|
||||
private static string Written(string path)
|
||||
{
|
||||
// The writer holds it open for as long as the session lasts.
|
||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
using var reader = new StreamReader(stream);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_first_entry_of_a_session_keeps_the_log_of_the_one_before()
|
||||
{
|
||||
string root = Directory.CreateTempSubdirectory("starterng-log-").FullName;
|
||||
try
|
||||
{
|
||||
var paths = new GamePaths(root);
|
||||
Directory.CreateDirectory(paths.StarterConfig);
|
||||
File.WriteAllText(paths.DiagnosticsLog, "the crash\n");
|
||||
var log = new FileDiagnosticsLog(new PhysicalFileSystem(), new FixedClock(new DateTime(2026, 3, 7)), paths);
|
||||
|
||||
log.Log("after the restart");
|
||||
log.Log("and on");
|
||||
|
||||
Assert.Equal("the crash\n", Written(paths.DiagnosticsLog + ".1"));
|
||||
string now = Written(paths.DiagnosticsLog);
|
||||
Assert.DoesNotContain("the crash", now);
|
||||
Assert.Contains("after the restart", now);
|
||||
Assert.Contains("and on", now);
|
||||
log.Clear();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Only_the_session_before_is_kept()
|
||||
{
|
||||
string root = Directory.CreateTempSubdirectory("starterng-log-").FullName;
|
||||
try
|
||||
{
|
||||
var paths = new GamePaths(root);
|
||||
Directory.CreateDirectory(paths.StarterConfig);
|
||||
File.WriteAllText(paths.DiagnosticsLog, "the last one\n");
|
||||
File.WriteAllText(paths.DiagnosticsLog + ".1", "the one before it\n");
|
||||
var log = new FileDiagnosticsLog(new PhysicalFileSystem(), new FixedClock(new DateTime(2026, 3, 7)), paths);
|
||||
|
||||
log.Log("now");
|
||||
|
||||
Assert.Equal("the last one\n", Written(paths.DiagnosticsLog + ".1"));
|
||||
log.Clear();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clearing_the_log_does_not_touch_the_session_kept()
|
||||
{
|
||||
var log = Build(out var files);
|
||||
string path = new GamePaths("game").DiagnosticsLog;
|
||||
files.WithFile(path, "the crash\n");
|
||||
|
||||
log.Log("after the restart");
|
||||
log.Clear();
|
||||
log.Log("cleared");
|
||||
|
||||
Assert.Equal("the crash\n", files.ReadAllText(path + ".1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_log_that_cannot_be_copied_is_added_to_and_not_wiped()
|
||||
{
|
||||
string root = Directory.CreateTempSubdirectory("starterng-log-").FullName;
|
||||
try
|
||||
{
|
||||
var paths = new GamePaths(root);
|
||||
Directory.CreateDirectory(paths.StarterConfig);
|
||||
File.WriteAllText(paths.DiagnosticsLog, "the crash\n");
|
||||
// A folder where the copy would go: nothing can be written in its place.
|
||||
Directory.CreateDirectory(paths.DiagnosticsLog + ".1");
|
||||
var log = new FileDiagnosticsLog(new PhysicalFileSystem(), new FixedClock(new DateTime(2026, 3, 7)), paths);
|
||||
|
||||
log.Log("after the restart");
|
||||
|
||||
string now = Written(paths.DiagnosticsLog);
|
||||
Assert.Contains("the crash", now);
|
||||
Assert.Contains("after the restart", now);
|
||||
log.Clear();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_folder_it_cannot_write_in_does_not_stop_the_program()
|
||||
{
|
||||
string root = Directory.CreateTempSubdirectory("starterng-log-").FullName;
|
||||
try
|
||||
{
|
||||
var paths = new GamePaths(root);
|
||||
// A file where the log's folder should be, as unwritable as read-only gets
|
||||
// on every platform and for every user.
|
||||
File.WriteAllText(paths.StarterConfig, "");
|
||||
var log = new FileDiagnosticsLog(new PhysicalFileSystem(), new FixedClock(new DateTime(2026, 3, 7)), paths);
|
||||
|
||||
log.Log("first");
|
||||
log.Log("second");
|
||||
log.Flush();
|
||||
log.Clear();
|
||||
log.Log("third");
|
||||
|
||||
Assert.Contains("third", log.Text);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
36
StarterNG.Tests/PhysicalFileSystemTests.cs
Normal file
36
StarterNG.Tests/PhysicalFileSystemTests.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using StarterNG.Infrastructure.Adapters;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public sealed class PhysicalFileSystemTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Directory.CreateTempSubdirectory("starterng-disk-").FullName;
|
||||
private readonly PhysicalFileSystem _files = new();
|
||||
|
||||
public void Dispose() => Directory.Delete(_root, recursive: true);
|
||||
|
||||
[Fact]
|
||||
public void An_atomic_write_makes_or_replaces_the_file_and_leaves_nothing_beside_it()
|
||||
{
|
||||
string path = Path.Combine(_root, "file.json");
|
||||
|
||||
_files.WriteAllTextAtomic(path, "first");
|
||||
_files.WriteAllTextAtomic(path, "zażółć");
|
||||
|
||||
Assert.Equal("zażółć", File.ReadAllText(path));
|
||||
Assert.Equal(new[] { path }, Directory.GetFiles(_root));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_atomic_write_that_cannot_finish_leaves_what_stood_there()
|
||||
{
|
||||
string path = Path.Combine(_root, "file.json");
|
||||
File.WriteAllText(path, "what stood there");
|
||||
// Nowhere to put the new text on its way in.
|
||||
Directory.CreateDirectory(path + PhysicalFileSystem.TempSuffix);
|
||||
|
||||
Assert.ThrowsAny<Exception>(() => _files.WriteAllTextAtomic(path, "half"));
|
||||
|
||||
Assert.Equal("what stood there", File.ReadAllText(path));
|
||||
}
|
||||
}
|
||||
160
StarterNG.Tests/PresetFileTests.cs
Normal file
160
StarterNG.Tests/PresetFileTests.cs
Normal file
@@ -0,0 +1,160 @@
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Infrastructure.Adapters;
|
||||
using StarterNG.Tests.Fakes;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
// Over the fake disk throughout: PresetStore itself is the player's real file.
|
||||
public class PresetFileTests
|
||||
{
|
||||
private const string PresetsPath = "/home/user/.config/MaSzyna/starter/userpresets.json";
|
||||
|
||||
private const string ThreePresets = """
|
||||
{ "Presets": [
|
||||
{ "Name": "a", "Entry": "trainset a" },
|
||||
{ "Name": "b", "Entry": "trainset b" },
|
||||
{ "Name": "c", "Entry": "trainset c" }
|
||||
] }
|
||||
""";
|
||||
|
||||
private static (PresetFile Presets, InMemoryFileSystem Files, FileDiagnosticsLog Log) Rig(string? held = null)
|
||||
{
|
||||
var files = new InMemoryFileSystem();
|
||||
if (held is not null)
|
||||
files.WithFile(PresetsPath, held);
|
||||
var log = TestLog.Over(files);
|
||||
return (new PresetFile(files, log, PresetsPath), files, log);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_saved_preset_joins_the_ones_in_the_file()
|
||||
{
|
||||
var (presets, files, _) = Rig(ThreePresets);
|
||||
|
||||
presets.Save("d", "trainset d");
|
||||
|
||||
Assert.Equal(new[] { "a", "b", "c", "d" }, presets.Presets().Select(p => p.Name));
|
||||
Assert.False(files.FileExists(PresetsPath + ".unreadable"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Saving_under_a_name_there_replaces_that_one()
|
||||
{
|
||||
var (presets, _, _) = Rig(ThreePresets);
|
||||
|
||||
presets.Save("B", "trainset other");
|
||||
|
||||
Assert.Equal(new[] { "a", "B", "c" }, presets.Presets().Select(p => p.Name));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_first_preset_needs_no_file_to_be_there()
|
||||
{
|
||||
var (presets, files, log) = Rig();
|
||||
|
||||
presets.Save("a", "trainset a");
|
||||
|
||||
Assert.Equal("a", Assert.Single(presets.Presets()).Name);
|
||||
Assert.False(files.FileExists(PresetsPath + ".unreadable"));
|
||||
Assert.Equal(string.Empty, log.Text);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{ "Presets": [ { "Name": "a", "Entry": "trainset a" }, { "Na""")]
|
||||
[InlineData("")]
|
||||
[InlineData("null")]
|
||||
[InlineData("""{ "Presets": null }""")]
|
||||
public void A_file_cut_short_goes_aside_before_a_save_replaces_it(string held)
|
||||
{
|
||||
var (presets, files, log) = Rig(held);
|
||||
|
||||
presets.Save("new", "trainset new");
|
||||
|
||||
Assert.Equal(held, files.ReadAllText(PresetsPath + ".unreadable"));
|
||||
Assert.Equal("new", Assert.Single(presets.Presets()).Name);
|
||||
Assert.Contains(PresetsPath + ".unreadable", log.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_earlier_copy_set_aside_is_not_overwritten()
|
||||
{
|
||||
var (presets, files, _) = Rig("{ cut");
|
||||
files.WithFile(PresetsPath + ".unreadable", "older");
|
||||
|
||||
presets.Save("new", "trainset new");
|
||||
|
||||
Assert.Equal("older", files.ReadAllText(PresetsPath + ".unreadable"));
|
||||
Assert.Equal("{ cut", files.ReadAllText(PresetsPath + ".unreadable-2"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_locked_file_is_left_alone_when_no_copy_of_it_can_be_made()
|
||||
{
|
||||
var (presets, files, log) = Rig(ThreePresets);
|
||||
files.FailReading(PresetsPath);
|
||||
|
||||
presets.Save("new", "trainset new");
|
||||
|
||||
files.AllowReading(PresetsPath);
|
||||
Assert.Equal(ThreePresets, files.ReadAllText(PresetsPath));
|
||||
Assert.False(files.FileExists(PresetsPath + ".unreadable"));
|
||||
Assert.Contains("saving", log.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_file_unlocked_between_the_read_and_the_write_is_still_kept()
|
||||
{
|
||||
// The list in hand is the empty one from the read that failed: the file has
|
||||
// to be kept whatever it looks like by the time it would be replaced.
|
||||
var (presets, files, _) = Rig(ThreePresets);
|
||||
var unlocking = new UnlockingLog(files, PresetsPath);
|
||||
var racing = new PresetFile(files, unlocking, PresetsPath);
|
||||
files.FailReading(PresetsPath);
|
||||
|
||||
racing.Save("new", "trainset new");
|
||||
|
||||
Assert.Equal(ThreePresets, files.ReadAllText(PresetsPath + ".unreadable"));
|
||||
Assert.Equal("new", Assert.Single(presets.Presets()).Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deleting_from_an_unreadable_file_leaves_it_as_it_is()
|
||||
{
|
||||
var (presets, files, _) = Rig("{ cut");
|
||||
|
||||
presets.Delete("a");
|
||||
|
||||
Assert.Equal("{ cut", files.ReadAllText(PresetsPath));
|
||||
Assert.False(files.FileExists(PresetsPath + ".unreadable"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unreadable_file_shows_as_no_presets_and_is_logged()
|
||||
{
|
||||
var (presets, _, log) = Rig("{ cut");
|
||||
|
||||
Assert.Empty(presets.Presets());
|
||||
Assert.Contains("reading", log.Text);
|
||||
}
|
||||
|
||||
/// <summary>Lets the file go the moment its read has failed and been logged.</summary>
|
||||
private sealed class UnlockingLog : StarterNG.Application.Abstractions.IDiagnosticsLog
|
||||
{
|
||||
private readonly InMemoryFileSystem _files;
|
||||
private readonly string _path;
|
||||
|
||||
public UnlockingLog(InMemoryFileSystem files, string path)
|
||||
{
|
||||
_files = files;
|
||||
_path = path;
|
||||
}
|
||||
|
||||
public string LogPath => "";
|
||||
public string Text => "";
|
||||
public void Log(string message) => _files.AllowReading(_path);
|
||||
public void Log(string context, Exception exception) => _files.AllowReading(_path);
|
||||
public void Log(IEnumerable<string> messages) => _files.AllowReading(_path);
|
||||
public void Clear() { }
|
||||
public void Flush() { }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user