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,15 @@
namespace StarterNG.Application.Abstractions;
using System;
/// <summary>
/// Port over the system clock. Scenery weather defaults, timetable rendering and
/// log timestamps all depend on "now"; routing them through a clock keeps those
/// rules deterministic under test.
/// </summary>
public interface IClock
{
DateTime Now { get; }
DateTime UtcNow { get; }
}

View File

@@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
namespace StarterNG.Application.Abstractions;
/// <summary>
/// Port over the starter's error log. Domain and application code depends on
/// this interface only; the file-backed implementation and the message box that
/// surfaces faults to the user live in Infrastructure and Presentation.
/// </summary>
public interface IDiagnosticsLog
{
void Log(string message);
void Log(string context, Exception exception);
void Log(IEnumerable<string> messages);
/// <summary>Everything logged so far, newline separated.</summary>
string Text { get; }
void Clear();
void Flush();
}

View File

@@ -0,0 +1,55 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
namespace StarterNG.Application.Abstractions;
/// <summary>
/// Port over the file system. Everything that reads or writes the MaSzyna
/// installation goes through this interface instead of <see cref="File"/> and
/// <see cref="Directory"/>, so parsers and use cases can be exercised against an
/// in-memory installation in tests.
/// </summary>
/// <remarks>
/// Kept deliberately narrow: only the operations the application actually
/// performs are exposed. Paths are passed through verbatim to the adapter, which
/// resolves them relative to the process working directory just like the
/// framework methods it replaces.
/// </remarks>
public interface IFileSystem
{
bool FileExists(string path);
bool DirectoryExists(string path);
string ReadAllText(string path);
string ReadAllText(string path, Encoding encoding);
string[] ReadAllLines(string path);
Stream OpenRead(string path);
void WriteAllText(string path, string contents);
void WriteAllText(string path, string contents, Encoding encoding);
void WriteAllLines(string path, IEnumerable<string> lines);
void WriteAllBytes(string path, byte[] bytes);
void CreateDirectory(string path);
void DeleteFile(string path);
void CopyFile(string sourcePath, string destinationPath, bool overwrite);
/// <summary>Top-level files, empty when the directory does not exist.</summary>
IReadOnlyList<string> GetFiles(string path, string searchPattern = "*");
/// <summary>Top-level directories, empty when the directory does not exist.</summary>
IReadOnlyList<string> GetDirectories(string path);
DateTime GetLastWriteTimeUtc(string path);
}

View File

@@ -0,0 +1,34 @@
namespace StarterNG.Application.Abstractions;
/// <summary>
/// The layout of a MaSzyna installation. Replaces the
/// <c>Directory.GetCurrentDirectory()</c> calls and hard-coded "scenery/",
/// "dynamic", "textures/mini/" literals that were previously spread across
/// parsers, views and the loader.
/// </summary>
public interface IGamePaths
{
/// <summary>Installation root: the folder holding eu07.exe.</summary>
string Root { get; }
/// <summary>Scenery definitions (*.scn).</summary>
string Scenery { get; }
/// <summary>Rolling stock: dynamic/&lt;maker&gt;/&lt;vehicle&gt;/textures.txt.</summary>
string Dynamic { get; }
/// <summary>Vehicle thumbnails used by the depot and the consist strip.</summary>
string MiniTextures { get; }
/// <summary>Simulator data files (load_weights.txt and friends).</summary>
string Data { get; }
/// <summary>Starter-owned configuration: presets, profiles, key bindings.</summary>
string StarterConfig { get; }
/// <summary>The starter's own error log (starter/bledy.txt).</summary>
string DiagnosticsLog { get; }
/// <summary>Resolves a path relative to <see cref="Root"/>.</summary>
string FromRoot(params string[] segments);
}

View File

@@ -0,0 +1,22 @@
namespace StarterNG.Application.Abstractions;
/// <summary>
/// Port over the translation table. Anything that needs a user-facing string
/// takes this, rather than reaching for the concrete localization service or a
/// static on <c>App</c>.
/// </summary>
public interface ILocalizedStrings
{
/// <summary>
/// Translation for a key. Missing keys return the key itself (and are logged
/// once), so an untranslated screen stays usable.
/// </summary>
string this[string key] { get; }
/// <summary>Lookup for a key that may legitimately be absent; "" when missing.</summary>
string Opt(string key);
string CurrentLanguage { get; }
string CurrentLangCode { get; }
}

View File

@@ -0,0 +1,42 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StarterNG.Application.Abstractions;
/// <summary>
/// A process the starter has launched or adopted. Exposes only what the starter
/// needs — whether the simulator is still up, and a way to await its exit — so
/// the presentation layer never handles <see cref="System.Diagnostics.Process"/>
/// directly.
/// </summary>
public interface IProcessHandle
{
bool HasExited { get; }
Task WaitForExitAsync(CancellationToken cancellationToken = default);
}
/// <summary>
/// Port over process creation: starting the simulator, opening a file, folder or
/// URL in the desktop shell, and adopting an already running simulator.
/// </summary>
public interface IProcessLauncher
{
/// <summary>
/// Starts an executable with the given arguments. Returns null when the
/// process could not be created; <paramref name="error"/> then carries the
/// reason for display.
/// </summary>
IProcessHandle? Start(string executablePath, IReadOnlyList<string> arguments, string? workingDirectory,
out string? error);
/// <summary>
/// Hands a path or URL to the desktop shell (explorer, xdg-open, ...).
/// Returns false and logs when the shell refused it.
/// </summary>
bool OpenInShell(string pathOrUrl);
/// <summary>The first running process with that name, or null.</summary>
IProcessHandle? FindRunning(string processName);
}