Make Scenery a domain aggregate with no I/O

Parsing and loading move to Infrastructure/Sceneries. Weather becomes a value
object owning the "does this differ from the file" rule.
This commit is contained in:
maj00r
2026-09-04 20:53:50 +02:00
parent a3137a27c4
commit 789f0dd1c9
25 changed files with 1465 additions and 624 deletions

View File

@@ -0,0 +1,22 @@
using StarterNG.Application.Abstractions;
namespace StarterNG.Tests.Fakes;
/// <summary>
/// Randomness pinned to the low end of every range, so weather that varies in
/// production is deterministic in tests.
/// </summary>
public sealed class StubRandom : IRandomSource
{
private readonly int _fixedValue;
public StubRandom(int fixedValue = 0)
{
_fixedValue = fixedValue;
}
public int Next(int minInclusive, int maxExclusive) =>
Math.Clamp(minInclusive + _fixedValue, minInclusive, Math.Max(minInclusive, maxExclusive - 1));
public double NextDouble() => 0.0;
}

View File

@@ -0,0 +1,146 @@
using StarterNG.Infrastructure.Sceneries;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests;
public class SceneryExportTests
{
private static SceneryParser Parser() =>
new(new FixedClock(new DateTime(2026, 1, 1, 12, 0, 0)), new StubRandom());
[Fact]
public void An_untouched_scenery_exports_its_trainsets_back_verbatim()
{
const string scn = """
//$n Test
trainset pociag tor_1 10 0
node -1 -1 ep07-001 dynamic PKP/EP07 ep07 ep07.mmd 0 headdriver 0
endtrainset
FirstInit
""";
var scenery = Parser().Parse("scenery/x.scn", scn);
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
Assert.DoesNotContain("{{0}}", exported);
Assert.Contains("trainset pociag tor_1", exported);
Assert.Contains("endtrainset", exported);
}
[Fact]
public void Selected_optional_includes_are_written_back_before_FirstInit()
{
const string scn = """
include zima.inc end //$optional 1, Zima
x
endoptional
FirstInit
""";
var scenery = Parser().Parse("scenery/x.scn", scn);
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
Assert.Contains("include zima.inc end", exported);
Assert.True(exported.IndexOf("include zima.inc", StringComparison.Ordinal) <
exported.IndexOf("FirstInit", StringComparison.Ordinal));
}
[Fact]
public void A_deselected_optional_include_stays_out()
{
const string scn = """
include zima.inc end //$optional 1, Zima
x
endoptional
FirstInit
""";
var scenery = Parser().Parse("scenery/x.scn", scn);
scenery.Includes[0].Selected = false;
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
Assert.DoesNotContain("include zima.inc", exported);
}
[Fact]
public void A_kind_two_include_is_written_only_without_a_companion_timetable()
{
const string scn = """
include rozklad.inc end //$optional 2, Rozkład
x
endoptional
FirstInit
""";
var scenery = Parser().Parse("scenery/x.scn", scn);
scenery.HasCompanionTimetable = false;
Assert.Contains("include rozklad.inc",
scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom()));
scenery.HasCompanionTimetable = true;
Assert.DoesNotContain("include rozklad.inc",
scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom()));
}
[Fact]
public void Edited_weather_replaces_the_authored_config_block()
{
const string scn = """
config
movelight 100
scenario.weather.temperature 5
eventlauncher.range 200
endconfig
atmo 0 0 0 10 2000 0 0 0 0 endatmo
time 8:30 0 0 endtime
FirstInit
""";
var scenery = Parser().Parse("scenery/x.scn", scn);
scenery.Weather.Day = 200;
scenery.Weather.Temperature = 25;
scenery.Weather.Dirty = true;
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
Assert.Contains("movelight 200", exported);
Assert.Contains("scenario.weather.temperature 25", exported);
Assert.DoesNotContain("movelight 100", exported);
// Config the scenery author wrote and the starter does not manage survives.
Assert.Contains("eventlauncher.range 200", exported);
}
[Fact]
public void Decor_trainsets_can_be_dropped_from_the_export()
{
const string scn = """
//$n Test
trainset ai_1 tor_1 10 0 //$decor
node -1 -1 ai-1 dynamic PKP/EP07 ep07 ep07.mmd 0 nobody 0
endtrainset
FirstInit
""";
var scenery = Parser().Parse("scenery/x.scn", scn);
Assert.True(scenery.Trainsets[0].Decor);
Assert.DoesNotContain("trainset ai_1",
scenery.BuildExportContent(skipDecorTrainsets: true, new StubRandom()));
Assert.Contains("trainset ai_1",
scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom()));
}
[Fact]
public void Loose_vehicles_are_appended_as_nodes_again()
{
const string scn = """
node -1 -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 enddynamic
FirstInit
""";
var scenery = Parser().Parse("scenery/x.scn", scn);
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
Assert.Contains("wagon_1", exported);
Assert.Contains("enddynamic", exported);
}
}

View File

@@ -0,0 +1,154 @@
using StarterNG.Infrastructure.Sceneries;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests;
public class SceneryParserTests
{
private static SceneryParser Parser(DateTime? now = null) =>
new(new FixedClock(now ?? new DateTime(2026, 5, 4, 9, 15, 0)), new StubRandom());
[Fact]
public void Directives_become_the_scenery_headline()
{
const string scn = """
//$l Śląsk
//$n Osobowy do Katowic
//$d Pierwszy opis
//$d drugi wiersz
//$i images/kato.jpg
""";
var scenery = Parser().Parse("scenery/kato.scn", scn);
Assert.Equal("Śląsk", scenery.Group);
Assert.Equal("Osobowy do Katowic", scenery.Name);
Assert.Equal("Pierwszy opis\ndrugi wiersz", scenery.Description);
Assert.Equal("images/kato.jpg", scenery.ImageName);
Assert.False(scenery.Archival);
Assert.Equal("kato", scenery.DisplayName);
}
[Fact]
public void The_a_directive_marks_a_scenery_archival()
{
var scenery = Parser().Parse("scenery/old.scn", "//$a\n//$n Stary\n");
Assert.True(scenery.Archival);
}
[Fact]
public void Attachments_take_a_label_from_the_rest_of_the_line()
{
const string scn = """
//$f 01 doc/rozklad.pdf Rozkład jazdy
//$f doc/mapa.png Mapa
//$f doc/notes.txt
""";
var scenery = Parser().Parse("scenery/x.scn", scn);
Assert.Collection(scenery.Attachments,
first =>
{
Assert.Equal("doc/rozklad.pdf", first.FilePath);
Assert.Equal("Rozkład jazdy", first.Label);
},
second =>
{
Assert.Equal("doc/mapa.png", second.FilePath);
Assert.Equal("Mapa", second.Label);
},
third =>
{
Assert.Equal("doc/notes.txt", third.FilePath);
Assert.Equal("notes.txt", third.Label);
});
}
[Fact]
public void Optional_includes_are_lifted_out_of_the_template()
{
const string scn = """
include zima.inc end //$optional 1, Sceneria zimowa
some content
endoptional
FirstInit
""";
var scenery = Parser().Parse("scenery/x.scn", scn);
var include = Assert.Single(scenery.Includes);
Assert.Equal("zima.inc", include.FilePath);
Assert.Equal("Sceneria zimowa", include.Desc);
Assert.Equal(1, include.Kind);
Assert.True(include.Selected);
Assert.DoesNotContain("zima.inc", scenery.Template);
}
[Fact]
public void Includes_of_kind_two_are_off_until_export_decides()
{
const string scn = """
include rozklad.inc end //$optional 2, Rozkład wbudowany
x
endoptional
""";
var include = Assert.Single(Parser().Parse("scenery/x.scn", scn).Includes);
Assert.Equal(2, include.Kind);
Assert.False(include.Selected);
}
[Fact]
public void Trainsets_are_parsed_and_replaced_by_placeholders()
{
const string scn = """
//$n Test
trainset pociag tor_1 10 0
node -1 -1 ep07-001 dynamic PKP/EP07 ep07 ep07.mmd 0 headdriver 0
endtrainset
FirstInit
""";
var scenery = Parser().Parse("scenery/x.scn", scn);
var trainset = Assert.Single(scenery.Trainsets);
Assert.True(trainset.Parsed);
Assert.Equal("pociag", trainset.Name);
Assert.Equal("tor_1", trainset.Track);
Assert.Contains("{{0}}", scenery.Template);
Assert.DoesNotContain("endtrainset", scenery.Template);
}
[Fact]
public void Vehicles_outside_a_trainset_are_collected_as_loose()
{
const string scn = """
node -1 -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 enddynamic
FirstInit
""";
var scenery = Parser().Parse("scenery/x.scn", scn);
var vehicle = Assert.Single(scenery.LooseVehicles);
Assert.Equal("wagon_1", vehicle.Name);
Assert.Equal("PKP/111A", vehicle.DataFolder);
Assert.Equal(new[] { "wagon_1" }, scenery.LooseVehicleNames);
Assert.DoesNotContain("enddynamic", scenery.Template);
}
[Fact]
public void A_malformed_loose_vehicle_is_reported_and_left_in_place()
{
const string scn = "node x -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 enddynamic\n";
var scenery = Parser().Parse("scenery/x.scn", scn);
Assert.Empty(scenery.LooseVehicles);
Assert.Equal(new[] { "wagon_1" }, scenery.LooseVehicleNames);
Assert.Contains(scenery.Faults, fault => fault.Contains("wagon_1"));
Assert.Contains("enddynamic", scenery.Template);
}
}

View File

@@ -0,0 +1,101 @@
using System.Text;
using StarterNG.Infrastructure.Adapters;
using StarterNG.Infrastructure.Sceneries;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests;
public class SceneryRepositoryTests
{
private static SceneryRepository Build(InMemoryFileSystem files, out FileDiagnosticsLog log)
{
var paths = new GamePaths("game");
var clock = new FixedClock(new DateTime(2026, 1, 1));
log = new FileDiagnosticsLog(files, clock, paths);
return new SceneryRepository(files, paths, log, new SceneryParser(clock, new StubRandom()));
}
[Fact]
public void Every_scn_in_the_scenery_folder_is_loaded()
{
var files = new InMemoryFileSystem()
.WithFile(Path.Combine("game", "scenery", "kato.scn"), "//$n Katowice")
.WithFile(Path.Combine("game", "scenery", "gdansk.scn"), "//$n Gdańsk");
var loaded = Build(files, out _).LoadAll();
Assert.Equal(2, loaded.Count);
Assert.Contains(loaded, scenery => scenery.Name == "Katowice");
}
[Fact]
public void Authoring_scratch_files_and_other_extensions_are_skipped()
{
var files = new InMemoryFileSystem()
.WithFile(Path.Combine("game", "scenery", "$wip.scn"), "//$n Robocza")
.WithFile(Path.Combine("game", "scenery", "kato.inc"), "//$n Include")
.WithFile(Path.Combine("game", "scenery", "kato.scn"), "//$n Katowice");
var loaded = Build(files, out _).LoadAll();
Assert.Equal("Katowice", Assert.Single(loaded).Name);
}
[Fact]
public void A_companion_sbt_is_noticed_when_the_scenery_is_loaded()
{
var files = new InMemoryFileSystem()
.WithFile(Path.Combine("game", "scenery", "kato.scn"), "//$n Katowice")
.WithFile(Path.Combine("game", "scenery", "kato.sbt"), "timetable");
var scenery = Assert.Single(Build(files, out _).LoadAll());
Assert.True(scenery.HasCompanionTimetable);
}
[Fact]
public void A_missing_scenery_loads_as_null_rather_than_throwing()
{
Assert.Null(Build(new InMemoryFileSystem(), out _).Load(Path.Combine("game", "scenery", "gone.scn")));
}
[Fact]
public void Progress_is_reported_once_per_file()
{
var files = new InMemoryFileSystem()
.WithFile(Path.Combine("game", "scenery", "a.scn"), "//$n A")
.WithFile(Path.Combine("game", "scenery", "b.scn"), "//$n B");
var steps = new List<int>();
Build(files, out _).LoadAll(new Progress<StarterNG.Application.Abstractions.SceneryLoadProgress>(
step => { lock (steps) steps.Add(step.Loaded); }));
// Progress<T> posts asynchronously; give it a moment to drain.
SpinWait.SpinUntil(() => { lock (steps) return steps.Count == 2; }, TimeSpan.FromSeconds(2));
lock (steps)
Assert.Equal(new[] { 1, 2 }, steps.OrderBy(value => value));
}
[Fact]
public void The_preview_image_is_found_next_to_the_scenery()
{
var files = new InMemoryFileSystem()
.WithFile(Path.Combine("game", "scenery", "kato.scn"), "//$i kato.jpg")
.WithFile(Path.Combine("game", "scenery", "images", "kato.jpg"), "binary");
var scenery = Assert.Single(Build(files, out _).LoadAll());
string? image = new SceneryImageLocator(files).Resolve(scenery);
Assert.Equal(Path.Combine("game", "scenery", "images", "kato.jpg"), image);
}
[Fact]
public void A_scenery_without_an_image_directive_resolves_to_nothing()
{
var files = new InMemoryFileSystem()
.WithFile(Path.Combine("game", "scenery", "kato.scn"), "//$n Katowice");
var scenery = Assert.Single(Build(files, out _).LoadAll());
Assert.Null(new SceneryImageLocator(files).Resolve(scenery));
}
}

View File

@@ -0,0 +1,87 @@
using StarterNG.Domain.Sceneries;
using StarterNG.Infrastructure.Sceneries;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests;
public class SceneryWeatherTests
{
private static SceneryParser Parser(DateTime now) => new(new FixedClock(now), new StubRandom());
[Fact]
public void Authored_weather_is_read_from_the_config_block()
{
const string scn = """
config
movelight 172
scenario.weather.temperature 21.5
scenario.time.override 6:05
endconfig
atmo 0 0 0 50 900 0 0 0 0.7 endatmo
time 8:30 0 0 endtime
""";
var weather = Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", scn).Weather;
Assert.True(weather.IsAuthored);
Assert.Equal(172, weather.Day);
Assert.Equal(21.5, weather.Temperature);
Assert.Equal("06:05", weather.ScenarioTimeOverride);
Assert.Equal("08:30", weather.Time);
Assert.Equal(50, weather.FogStart);
Assert.Equal(0.7, weather.Overcast);
}
[Fact]
public void A_scenery_without_a_time_starts_at_the_players_clock()
{
var weather = Parser(new DateTime(2026, 5, 4, 17, 42, 0)).Parse("scenery/x.scn", "//$n Test").Weather;
Assert.Equal("17:42", weather.ScenarioTimeOverride);
Assert.False(weather.IsAuthored);
}
[Fact]
public void Freshly_parsed_weather_counts_as_unchanged()
{
var weather = Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", "movelight 100\n").Weather;
Assert.False(weather.Changed);
}
[Fact]
public void Editing_then_restoring_returns_to_the_authored_values()
{
var weather = Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", "movelight 100\n").Weather;
weather.Day = 5;
weather.Temperature = -12;
Assert.True(weather.Changed);
weather.Restore();
Assert.False(weather.Changed);
Assert.Equal(100, weather.Day);
Assert.True(weather.Dirty);
}
[Fact]
public void A_fog_range_is_clamped_into_the_supported_span()
{
var weather = new SceneryWeather();
weather.SetFogRange(start: 1, end: 999999, random: new StubRandom());
Assert.Equal(SceneryWeather.FogMin, weather.FogStart);
Assert.InRange(weather.FogEnd, SceneryWeather.FogMin, SceneryWeather.FogMax);
}
[Fact]
public void Random_overcast_draws_from_the_presets_rather_than_the_stored_value()
{
var weather = new SceneryWeather { Overcast = 0.42, OvercastRandom = true };
Assert.Equal(-1.5, weather.EffectiveOvercast(new StubRandom()));
Assert.Equal(0.42, new SceneryWeather { Overcast = 0.42 }.EffectiveOvercast(new StubRandom()));
}
}