Introduce layers, ports and a composition root

Ports in Application/Abstractions, adapters in Infrastructure, hand-wired
AppServices (no reflection, as PublishAot requires). Adds a test project.
This commit is contained in:
maj00r
2026-09-04 20:46:51 +02:00
parent aef086e9f0
commit a3137a27c4
26 changed files with 1065 additions and 143 deletions

View File

@@ -0,0 +1,26 @@
using StarterNG.Application.Abstractions;
namespace StarterNG.Tests.Fakes;
/// <summary>
/// Translation table that echoes keys back, so assertions can name the key that
/// a screen or check would have shown.
/// </summary>
public sealed class FakeStrings : ILocalizedStrings
{
private readonly Dictionary<string, string> _overrides = new(StringComparer.Ordinal);
public string this[string key] => _overrides.TryGetValue(key, out var value) ? value : key;
public string Opt(string key) => _overrides.TryGetValue(key, out var value) ? value : string.Empty;
public string CurrentLanguage => "English";
public string CurrentLangCode => "en";
public FakeStrings With(string key, string value)
{
_overrides[key] = value;
return this;
}
}

View File

@@ -0,0 +1,16 @@
using StarterNG.Application.Abstractions;
namespace StarterNG.Tests.Fakes;
/// <summary>A clock frozen at a chosen instant.</summary>
public sealed class FixedClock : IClock
{
public FixedClock(DateTime now)
{
Now = now;
}
public DateTime Now { get; set; }
public DateTime UtcNow => Now.ToUniversalTime();
}

View File

@@ -0,0 +1,131 @@
using System.Text;
using StarterNG.Application.Abstractions;
namespace StarterNG.Tests.Fakes;
/// <summary>
/// An <see cref="IFileSystem"/> over a dictionary, so a whole MaSzyna
/// installation can be described inline in a test.
/// </summary>
/// <remarks>
/// Paths are normalised to forward slashes and compared case-insensitively,
/// matching how the parsers treat installation paths on both platforms.
/// </remarks>
public sealed class InMemoryFileSystem : IFileSystem
{
private readonly Dictionary<string, byte[]> _files = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _directories = new(StringComparer.OrdinalIgnoreCase);
public DateTime LastWriteTimeUtc { get; set; } = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
/// <summary>Adds a text file, creating its parent directories.</summary>
public InMemoryFileSystem WithFile(string path, string contents)
{
string key = Normalize(path);
_files[key] = Encoding.UTF8.GetBytes(contents);
AddParents(key);
return this;
}
/// <summary>Adds an empty directory.</summary>
public InMemoryFileSystem WithDirectory(string path)
{
string key = Normalize(path);
_directories.Add(key);
AddParents(key);
return this;
}
public bool FileExists(string path) => _files.ContainsKey(Normalize(path));
public bool DirectoryExists(string path) => _directories.Contains(Normalize(path));
public string ReadAllText(string path) => Encoding.UTF8.GetString(Bytes(path));
public string ReadAllText(string path, Encoding encoding) => encoding.GetString(Bytes(path));
public string[] ReadAllLines(string path) =>
ReadAllText(path).Split('\n').Select(line => line.TrimEnd('\r')).ToArray();
public Stream OpenRead(string path) => new MemoryStream(Bytes(path), writable: false);
public void WriteAllText(string path, string contents) => WithFile(path, contents);
public void WriteAllText(string path, string contents, Encoding encoding)
{
string key = Normalize(path);
_files[key] = encoding.GetBytes(contents);
AddParents(key);
}
public void WriteAllLines(string path, IEnumerable<string> lines) =>
WithFile(path, string.Join("\n", lines));
public void WriteAllBytes(string path, byte[] bytes)
{
string key = Normalize(path);
_files[key] = bytes;
AddParents(key);
}
public void CreateDirectory(string path) => WithDirectory(path);
public void DeleteFile(string path) => _files.Remove(Normalize(path));
public void CopyFile(string sourcePath, string destinationPath, bool overwrite)
{
string destination = Normalize(destinationPath);
if (!overwrite && _files.ContainsKey(destination))
throw new IOException($"{destinationPath} already exists");
WriteAllBytes(destinationPath, Bytes(sourcePath));
}
public IReadOnlyList<string> GetFiles(string path, string searchPattern = "*")
{
string prefix = Normalize(path) + "/";
var pattern = ToRegex(searchPattern);
return _files.Keys
.Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
.Where(key => !key[prefix.Length..].Contains('/'))
.Where(key => pattern.IsMatch(key[prefix.Length..]))
.OrderBy(key => key, StringComparer.OrdinalIgnoreCase)
.ToList();
}
public IReadOnlyList<string> GetDirectories(string path)
{
string prefix = Normalize(path) + "/";
return _directories
.Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
.Where(key => !key[prefix.Length..].Contains('/'))
.OrderBy(key => key, StringComparer.OrdinalIgnoreCase)
.ToList();
}
public DateTime GetLastWriteTimeUtc(string path) => LastWriteTimeUtc;
private byte[] Bytes(string path) =>
_files.TryGetValue(Normalize(path), out var bytes)
? bytes
: throw new FileNotFoundException(path);
private void AddParents(string key)
{
int slash = key.LastIndexOf('/');
while (slash > 0)
{
key = key[..slash];
if (!_directories.Add(key))
return;
slash = key.LastIndexOf('/');
}
}
private static string Normalize(string path) =>
path.Replace('\\', '/').TrimEnd('/');
private static System.Text.RegularExpressions.Regex ToRegex(string searchPattern) =>
new("^" + System.Text.RegularExpressions.Regex.Escape(searchPattern)
.Replace("\\*", ".*").Replace("\\?", ".") + "$",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
}

View File

@@ -0,0 +1,45 @@
using StarterNG.Infrastructure.Adapters;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests;
public class FileDiagnosticsLogTests
{
private static FileDiagnosticsLog Build(out InMemoryFileSystem files)
{
files = new InMemoryFileSystem();
return new FileDiagnosticsLog(files, new FixedClock(new DateTime(2026, 3, 7, 14, 5, 0)),
new GamePaths("game"));
}
[Fact]
public void Log_timestamps_each_line_from_the_clock()
{
var log = Build(out _);
log.Log("scenery loaded");
Assert.Equal("2026-03-07 14:05:00 scenery loaded", log.Text);
}
[Fact]
public void Exceptions_are_logged_with_their_context_and_type()
{
var log = Build(out _);
log.Log("scenery/foo.scn", new InvalidOperationException("bad node"));
Assert.Contains("scenery/foo.scn: InvalidOperationException: bad node", log.Text);
}
[Fact]
public void Clear_drops_everything_logged_so_far()
{
var log = Build(out _);
log.Log("first");
log.Clear();
Assert.Equal(string.Empty, log.Text);
}
}

View File

@@ -0,0 +1,26 @@
using StarterNG.Infrastructure.Adapters;
namespace StarterNG.Tests;
public class GamePathsTests
{
[Fact]
public void Wellknown_folders_hang_off_the_installation_root()
{
var paths = new GamePaths(Path.Combine("opt", "maszyna"));
Assert.Equal(Path.Combine("opt", "maszyna", "scenery"), paths.Scenery);
Assert.Equal(Path.Combine("opt", "maszyna", "dynamic"), paths.Dynamic);
Assert.Equal(Path.Combine("opt", "maszyna", "textures", "mini"), paths.MiniTextures);
Assert.Equal(Path.Combine("opt", "maszyna", "starter", "bledy.txt"), paths.DiagnosticsLog);
}
[Fact]
public void FromRoot_joins_segments_below_the_root()
{
var paths = new GamePaths("game");
Assert.Equal(Path.Combine("game", "data", "load_weights.txt"),
paths.FromRoot("data", "load_weights.txt"));
}
}

View File

@@ -0,0 +1,39 @@
using StarterNG.Infrastructure;
using StarterNG.Infrastructure.Adapters;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests;
public class InstallationCheckTests
{
[Fact]
public void An_empty_folder_reports_every_missing_game_directory()
{
var strings = new FakeStrings().With("FaultNoDir", "missing {0}");
var check = new InstallationCheck(new GamePaths("game"), new InMemoryFileSystem(), strings);
var faults = check.Run();
Assert.Contains("missing /dynamic", faults);
Assert.Contains("missing /scenery", faults);
Assert.Contains("FaultNoWeights", faults);
}
[Fact]
public void A_complete_installation_reports_no_missing_directories()
{
var files = new InMemoryFileSystem()
.WithDirectory(Path.Combine("game", "dynamic"))
.WithDirectory(Path.Combine("game", "sounds"))
.WithDirectory(Path.Combine("game", "models"))
.WithDirectory(Path.Combine("game", "scenery"))
.WithDirectory(Path.Combine("game", "textures"))
.WithFile(Path.Combine("game", "data", "load_weights.txt"), "");
var strings = new FakeStrings().With("FaultNoDir", "missing {0}");
var faults = new InstallationCheck(new GamePaths("game"), files, strings).Run();
Assert.DoesNotContain(faults, fault => fault.StartsWith("missing "));
Assert.DoesNotContain("FaultNoWeights", faults);
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StarterNG\StarterNG.csproj" />
</ItemGroup>
</Project>