mirror of
https://github.com/MaSzyna-EU07/StarterNG.git
synced 2026-11-04 13:15:48 +01:00
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:
22
StarterNG.Tests/Fakes/StubRandom.cs
Normal file
22
StarterNG.Tests/Fakes/StubRandom.cs
Normal 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;
|
||||
}
|
||||
146
StarterNG.Tests/SceneryExportTests.cs
Normal file
146
StarterNG.Tests/SceneryExportTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
154
StarterNG.Tests/SceneryParserTests.cs
Normal file
154
StarterNG.Tests/SceneryParserTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
101
StarterNG.Tests/SceneryRepositoryTests.cs
Normal file
101
StarterNG.Tests/SceneryRepositoryTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
87
StarterNG.Tests/SceneryWeatherTests.cs
Normal file
87
StarterNG.Tests/SceneryWeatherTests.cs
Normal 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()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user