mirror of
https://github.com/MaSzyna-EU07/StarterNG.git
synced 2026-11-01 20:34:57 +01:00
Compare commits
14 Commits
feat/openg
...
feature/ha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81c7e8ba95 | ||
|
|
b56a2a0ae6 | ||
|
|
80115b7a7e | ||
|
|
ceab05462a | ||
|
|
2ebff91316 | ||
|
|
6e45f5de59 | ||
|
|
82429010d4 | ||
|
|
f152a020e3 | ||
|
|
3b21583b53 | ||
|
|
69aa075bfa | ||
|
|
b6735fe345 | ||
|
|
07987a60a9 | ||
|
|
6948b13503 | ||
|
|
22c51ddf3c |
149
StarterNG.Tests/CargoTests.cs
Normal file
149
StarterNG.Tests/CargoTests.cs
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
using StarterNG.Classes;
|
||||||
|
using StarterNG.Domain;
|
||||||
|
using StarterNG.Domain.Vehicles;
|
||||||
|
using StarterNG.Tests.Fakes;
|
||||||
|
|
||||||
|
namespace StarterNG.Tests;
|
||||||
|
|
||||||
|
public class CargoTests
|
||||||
|
{
|
||||||
|
private readonly Consist _consist;
|
||||||
|
private readonly VehicleCatalog _catalog;
|
||||||
|
private readonly VehicleInfo _info;
|
||||||
|
|
||||||
|
public CargoTests()
|
||||||
|
{
|
||||||
|
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||||
|
.WithFile(TestInstallation.At("dynamic", "pkp", "en57", "textures.txt"), "!=b\nen57-a.mat=en57,EN57")
|
||||||
|
.WithFile(TestInstallation.At("dynamic", "pkp", "en57", "en57.fiz"),
|
||||||
|
"Load: LoadAccepted=Passengers,Pantstate MaxLoad=50")
|
||||||
|
.WithFile(TestInstallation.At("dynamic", "pkp", "tram", "textures.txt"), "!=b\ntram-a.mat=tram,TRAM")
|
||||||
|
.WithFile(TestInstallation.At("dynamic", "pkp", "tram", "tram.fiz"),
|
||||||
|
"Load: LoadAccepted=pantstate,Passengers MaxLoad=52")
|
||||||
|
.WithFile(TestInstallation.At("dynamic", "pkp", "s1", "textures.txt"), "!=b\ns1-a.mat=s1,S1")
|
||||||
|
.WithFile(TestInstallation.At("dynamic", "pkp", "s1", "s1.fiz"), "Load: LoadAccepted=Coal,Sand"));
|
||||||
|
installation.Vehicles.Load(installation.Library.Vehicles);
|
||||||
|
|
||||||
|
_catalog = installation.Library.Vehicles;
|
||||||
|
_info = new VehicleInfo(_catalog, installation.Physics);
|
||||||
|
_consist = new Consist(_catalog, _info);
|
||||||
|
}
|
||||||
|
|
||||||
|
private (Dynamic Car, List<ConsistItem> Consist) Wagon(string skin, string? type = null, int count = 0)
|
||||||
|
{
|
||||||
|
var car = _consist.MakeDynamic(_catalog.TextureForSkin(skin)!, null);
|
||||||
|
car.LoadType = type;
|
||||||
|
car.LoadCount = count;
|
||||||
|
return (car, new List<ConsistItem> { new() { Cars = new List<Dynamic> { car } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
private Cargo Picking(int index) => new(_info, new Pick(index));
|
||||||
|
|
||||||
|
private sealed class Pick : Random
|
||||||
|
{
|
||||||
|
private readonly int _index;
|
||||||
|
|
||||||
|
public Pick(int index) => _index = index;
|
||||||
|
|
||||||
|
public override int Next(int maxValue) => _index;
|
||||||
|
|
||||||
|
public override int Next(int minValue, int maxValue) => maxValue - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_random_type_takes_no_more_than_it_holds()
|
||||||
|
{
|
||||||
|
var (car, consist) = Wagon("en57-a", "Passengers", 50);
|
||||||
|
|
||||||
|
Picking(1).RandomTypes(consist);
|
||||||
|
|
||||||
|
Assert.Equal("Pantstate", car.LoadType);
|
||||||
|
Assert.Equal(Dynamic.PantStateMax, car.LoadCount);
|
||||||
|
Assert.Contains(" 3 Pantstate ", car.ToTrainsetNode());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_random_type_leaves_a_count_it_can_hold()
|
||||||
|
{
|
||||||
|
var (car, consist) = Wagon("en57-a", "Pantstate", 2);
|
||||||
|
|
||||||
|
Picking(0).RandomTypes(consist);
|
||||||
|
|
||||||
|
Assert.Equal("Passengers", car.LoadType);
|
||||||
|
Assert.Equal(2, car.LoadCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_random_type_with_no_limit_declared_keeps_the_count()
|
||||||
|
{
|
||||||
|
// MaxLoad unset: nothing to hold it to.
|
||||||
|
var (car, consist) = Wagon("s1-a", "Coal", 40);
|
||||||
|
|
||||||
|
Picking(1).RandomTypes(consist);
|
||||||
|
|
||||||
|
Assert.Equal("Sand", car.LoadType);
|
||||||
|
Assert.Equal(40, car.LoadCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Filling_an_empty_car_goes_by_the_type_it_is_given()
|
||||||
|
{
|
||||||
|
// Its first accepted type is pantographs: the 52 of the .fiz is not theirs.
|
||||||
|
var (car, _) = Wagon("tram-a");
|
||||||
|
|
||||||
|
Picking(0).FillToMax(car);
|
||||||
|
|
||||||
|
Assert.Equal("pantstate", car.LoadType);
|
||||||
|
Assert.Equal(Dynamic.PantStateMax, car.LoadCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_random_amount_for_an_empty_car_goes_by_the_type_it_is_given()
|
||||||
|
{
|
||||||
|
var (car, consist) = Wagon("tram-a");
|
||||||
|
|
||||||
|
Picking(0).RandomAmounts(consist);
|
||||||
|
|
||||||
|
Assert.Equal("pantstate", car.LoadType);
|
||||||
|
Assert.InRange(car.LoadCount, 1, Dynamic.PantStateMax);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_count_passed_on_keeps_where_the_next_car_declares_no_limit()
|
||||||
|
{
|
||||||
|
// MaxLoad unset: nothing to hold it to, as when taken from the car before.
|
||||||
|
var (_, consist) = Wagon("s1-a", "Coal", 40);
|
||||||
|
var (next, following) = Wagon("s1-a");
|
||||||
|
consist.AddRange(following);
|
||||||
|
|
||||||
|
Picking(0).CopyToFollowing(consist, consist[0]);
|
||||||
|
|
||||||
|
Assert.Equal("Coal", next.LoadType);
|
||||||
|
Assert.Equal(40, next.LoadCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_count_passed_on_is_held_to_what_the_next_car_declares()
|
||||||
|
{
|
||||||
|
var (_, consist) = Wagon("tram-a", "Passengers", 52);
|
||||||
|
var (next, following) = Wagon("en57-a");
|
||||||
|
consist.AddRange(following);
|
||||||
|
|
||||||
|
Picking(0).CopyToFollowing(consist, consist[0]);
|
||||||
|
|
||||||
|
Assert.Equal("Passengers", next.LoadType);
|
||||||
|
Assert.Equal(50, next.LoadCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_car_with_nothing_declared_stays_untouched()
|
||||||
|
{
|
||||||
|
var (car, _) = Wagon("en57-a");
|
||||||
|
car.MaxLoad = 0;
|
||||||
|
|
||||||
|
Picking(0).FillToMax(car);
|
||||||
|
|
||||||
|
Assert.Null(car.LoadType);
|
||||||
|
Assert.Equal(0, car.LoadCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
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,6 +9,7 @@ public class ConsistCrewTests
|
|||||||
{
|
{
|
||||||
private readonly Consist _consist;
|
private readonly Consist _consist;
|
||||||
private readonly VehicleCatalog _catalog;
|
private readonly VehicleCatalog _catalog;
|
||||||
|
private readonly VehicleInfo _info;
|
||||||
|
|
||||||
public ConsistCrewTests()
|
public ConsistCrewTests()
|
||||||
{
|
{
|
||||||
@@ -20,7 +21,129 @@ public class ConsistCrewTests
|
|||||||
installation.Vehicles.Load(installation.Library.Vehicles);
|
installation.Vehicles.Load(installation.Library.Vehicles);
|
||||||
|
|
||||||
_catalog = installation.Library.Vehicles;
|
_catalog = installation.Library.Vehicles;
|
||||||
_consist = new Consist(_catalog, new VehicleInfo(_catalog, installation.Physics));
|
_info = new VehicleInfo(_catalog, installation.Physics);
|
||||||
|
_consist = new Consist(_catalog, _info);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Dynamic> Cars(params string[] skins) =>
|
||||||
|
skins.Select(skin => _consist.MakeDynamic(_catalog.TextureForSkin(skin)!, null)).ToList();
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(eDriverType.Reardriver)]
|
||||||
|
[InlineData(eDriverType.Passenger)]
|
||||||
|
public void A_loaded_consist_keeps_the_kind_of_crew(eDriverType crew)
|
||||||
|
{
|
||||||
|
var before = Cars("ep07-001", "b111-01");
|
||||||
|
before[0].DriverType = crew;
|
||||||
|
var loaded = Cars("ep07-002", "b111-01");
|
||||||
|
loaded[0].DriverType = eDriverType.Headdriver;
|
||||||
|
|
||||||
|
var cars = Consist.LoadedOver(before, loaded, _catalog, _info);
|
||||||
|
|
||||||
|
Assert.Equal(crew, cars[0].DriverType);
|
||||||
|
Assert.Equal(eDriverType.Nobody, cars[1].DriverType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_consist_with_nobody_in_it_stays_so_after_loading()
|
||||||
|
{
|
||||||
|
var loaded = Cars("ep07-002");
|
||||||
|
loaded[0].DriverType = eDriverType.Headdriver;
|
||||||
|
|
||||||
|
var cars = Consist.LoadedOver(Cars("ep07-001"), loaded, _catalog, _info);
|
||||||
|
|
||||||
|
Assert.Equal(eDriverType.Nobody, cars[0].DriverType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_driver_does_not_go_into_loaded_wagons()
|
||||||
|
{
|
||||||
|
var before = Cars("ep07-001");
|
||||||
|
before[0].DriverType = eDriverType.Headdriver;
|
||||||
|
|
||||||
|
var cars = Consist.LoadedOver(before, Cars("b111-01"), _catalog, _info);
|
||||||
|
|
||||||
|
Assert.Equal(eDriverType.Nobody, cars[0].DriverType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Loaded_over_an_empty_consist_the_vehicles_keep_their_own_crew()
|
||||||
|
{
|
||||||
|
var loaded = Cars("ep07-002");
|
||||||
|
loaded[0].DriverType = eDriverType.Reardriver;
|
||||||
|
|
||||||
|
Assert.Equal(eDriverType.Reardriver,
|
||||||
|
Consist.LoadedOver(new List<Dynamic>(), loaded, _catalog, _info)[0].DriverType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Vehicles_joining_a_consist_come_without_their_own_crew()
|
||||||
|
{
|
||||||
|
var before = Cars("ep07-001");
|
||||||
|
before[0].DriverType = eDriverType.Headdriver;
|
||||||
|
var added = Cars("ep07-002");
|
||||||
|
added[0].DriverType = eDriverType.Headdriver;
|
||||||
|
|
||||||
|
var cars = Consist.Joined(before, added);
|
||||||
|
|
||||||
|
Assert.Equal(eDriverType.Headdriver, cars[0].DriverType);
|
||||||
|
Assert.Equal(eDriverType.Nobody, cars[1].DriverType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_locomotive_moved_to_the_head_takes_the_driver_in_the_same_cab()
|
||||||
|
{
|
||||||
|
var first = Add("ep07-001", 0);
|
||||||
|
var second = Add("ep07-002", 1);
|
||||||
|
first.Driver = eDriverType.Reardriver;
|
||||||
|
first.Cars[0].DriverType = eDriverType.Reardriver;
|
||||||
|
|
||||||
|
_consist.Move(1, 0);
|
||||||
|
|
||||||
|
Assert.Equal(eDriverType.Reardriver, second.Cars[0].DriverType);
|
||||||
|
Assert.Equal(eDriverType.Nobody, first.Cars[0].DriverType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Vehicles_joining_an_empty_consist_keep_their_crew()
|
||||||
|
{
|
||||||
|
var added = Cars("ep07-002");
|
||||||
|
added[0].DriverType = eDriverType.Headdriver;
|
||||||
|
|
||||||
|
Assert.Equal(eDriverType.Headdriver, Consist.Joined(new List<Dynamic>(), added)[0].DriverType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Selecting_a_wagon_keeps_the_locomotive_as_the_starting_vehicle()
|
||||||
|
{
|
||||||
|
var loco = Add("ep07-001", 0);
|
||||||
|
var wagon = Add("b111-01", 1);
|
||||||
|
loco.Cars[0].Name = "loco";
|
||||||
|
wagon.Cars[0].Name = "wagon";
|
||||||
|
|
||||||
|
Assert.Equal("loco", Consist.StartingVehicle(_consist, wagon, "loco"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_wagon_selected_after_a_stale_pick_falls_back_to_the_driver()
|
||||||
|
{
|
||||||
|
var loco = Add("ep07-001", 0);
|
||||||
|
var wagon = Add("b111-01", 1);
|
||||||
|
loco.Cars[0].Name = "loco";
|
||||||
|
|
||||||
|
Assert.Equal("loco", Consist.StartingVehicle(_consist, wagon, "gone"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Selecting_a_crewed_unit_makes_it_the_starting_vehicle()
|
||||||
|
{
|
||||||
|
var first = Add("ep07-001", 0);
|
||||||
|
var wagon = Add("b111-01", 1);
|
||||||
|
first.Cars[0].Name = "loco";
|
||||||
|
wagon.Cars[0].Name = "wagon";
|
||||||
|
wagon.Cars[0].DriverType = eDriverType.Passenger;
|
||||||
|
|
||||||
|
Assert.Equal("wagon", Consist.StartingVehicle(_consist, wagon, "loco"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
98
StarterNG.Tests/ConsistHistoryTests.cs
Normal file
98
StarterNG.Tests/ConsistHistoryTests.cs
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
using StarterNG.Classes;
|
||||||
|
using StarterNG.Domain;
|
||||||
|
|
||||||
|
namespace StarterNG.Tests;
|
||||||
|
|
||||||
|
public class ConsistHistoryTests
|
||||||
|
{
|
||||||
|
private static Dynamic Car(string name) => new()
|
||||||
|
{
|
||||||
|
Name = name, DataFolder = "pkp", SkinFile = name, MmdFile = name, PathName = ""
|
||||||
|
};
|
||||||
|
|
||||||
|
private static Trainset With(params string[] names) =>
|
||||||
|
new("trainset none none 0 0\nendtrainset") { Vehicles = names.Select(Car).ToList() };
|
||||||
|
|
||||||
|
private static string[] Names(IEnumerable<Dynamic> vehicles) => vehicles.Select(v => v.Name).ToArray();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_redraw_without_a_change_leaves_nothing_to_undo()
|
||||||
|
{
|
||||||
|
var history = new ConsistHistory();
|
||||||
|
var trainset = With("a", "b");
|
||||||
|
|
||||||
|
history.Record(trainset, 0);
|
||||||
|
history.Record(trainset, 1);
|
||||||
|
|
||||||
|
Assert.False(history.CanUndo(trainset));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Undo_goes_back_one_change_and_redo_forward_again()
|
||||||
|
{
|
||||||
|
var history = new ConsistHistory();
|
||||||
|
var trainset = With("a");
|
||||||
|
history.Record(trainset, 0);
|
||||||
|
trainset.Vehicles.Add(Car("b"));
|
||||||
|
history.Record(trainset, 1);
|
||||||
|
|
||||||
|
var back = history.Undo(trainset, 1);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "a" }, Names(back!.Vehicles));
|
||||||
|
Assert.True(history.CanRedo(trainset));
|
||||||
|
|
||||||
|
trainset.Vehicles = back.Vehicles.ToList();
|
||||||
|
history.Record(trainset, 0, rebase: true);
|
||||||
|
var forward = history.Redo(trainset, 0);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "a", "b" }, Names(forward!.Vehicles));
|
||||||
|
Assert.Equal(1, forward.Selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void An_edit_made_in_place_is_still_undone()
|
||||||
|
{
|
||||||
|
// The vehicles are shared with the editor, so a change to one of them must
|
||||||
|
// not reach the state kept for undo.
|
||||||
|
var history = new ConsistHistory();
|
||||||
|
var trainset = With("a");
|
||||||
|
history.Record(trainset, 0);
|
||||||
|
|
||||||
|
trainset.Vehicles[0].DriverType = eDriverType.Passenger;
|
||||||
|
var back = history.Undo(trainset, 0);
|
||||||
|
|
||||||
|
Assert.NotEqual(eDriverType.Passenger, back!.Vehicles[0].DriverType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_new_change_drops_what_could_be_redone()
|
||||||
|
{
|
||||||
|
var history = new ConsistHistory();
|
||||||
|
var trainset = With("a");
|
||||||
|
history.Record(trainset, 0);
|
||||||
|
trainset.Vehicles.Add(Car("b"));
|
||||||
|
history.Record(trainset, 1);
|
||||||
|
trainset.Vehicles = history.Undo(trainset, 1)!.Vehicles.ToList();
|
||||||
|
history.Record(trainset, 0, rebase: true);
|
||||||
|
|
||||||
|
trainset.Vehicles.Add(Car("c"));
|
||||||
|
history.Record(trainset, 1);
|
||||||
|
|
||||||
|
Assert.False(history.CanRedo(trainset));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Each_trainset_has_its_own_history()
|
||||||
|
{
|
||||||
|
var history = new ConsistHistory();
|
||||||
|
var first = With("a");
|
||||||
|
var second = With("x");
|
||||||
|
history.Record(first, 0);
|
||||||
|
first.Vehicles.Add(Car("b"));
|
||||||
|
history.Record(first, 1);
|
||||||
|
history.Record(second, 0);
|
||||||
|
|
||||||
|
Assert.True(history.CanUndo(first));
|
||||||
|
Assert.False(history.CanUndo(second));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using StarterNG.Application;
|
||||||
using StarterNG.Classes;
|
using StarterNG.Classes;
|
||||||
using StarterNG.Domain;
|
using StarterNG.Domain;
|
||||||
using StarterNG.Domain.Vehicles;
|
using StarterNG.Domain.Vehicles;
|
||||||
@@ -87,6 +88,25 @@ public class ConsistOrientationTests
|
|||||||
Assert.All(consist, card => Assert.Equal(Consist.IsTurned(card.Cars[0]), card.Flipped));
|
Assert.All(consist, card => Assert.Equal(Consist.IsTurned(card.Cars[0]), card.Flipped));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_turned_unit_comes_back_as_it_was_from_every_load()
|
||||||
|
{
|
||||||
|
AppServices.Initialize(_installation.Paths, _installation.Files, _installation.Clock, _installation.Environment);
|
||||||
|
var consist = NewConsist(out var unit);
|
||||||
|
consist.Flip(unit);
|
||||||
|
string[] turned = Describe(consist);
|
||||||
|
var trainset = new Trainset("trainset a tor 0 0\nendtrainset");
|
||||||
|
|
||||||
|
for (int load = 0; load < 3; load++)
|
||||||
|
{
|
||||||
|
trainset.Vehicles = consist.Flatten();
|
||||||
|
consist.LoadFrom(trainset);
|
||||||
|
|
||||||
|
Assert.True(Assert.Single(consist).Flipped);
|
||||||
|
Assert.Equal(turned, Describe(consist));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private Consist NewConsist(out ConsistItem unit)
|
private Consist NewConsist(out ConsistItem unit)
|
||||||
{
|
{
|
||||||
var consist = new Consist(_catalog, new VehicleInfo(_catalog, _installation.Physics));
|
var consist = new Consist(_catalog, new VehicleInfo(_catalog, _installation.Physics));
|
||||||
|
|||||||
@@ -21,6 +21,21 @@ public class ConsistTailTests
|
|||||||
_consist = new Consist(_catalog, new VehicleInfo(_catalog, installation.Physics));
|
_consist = new Consist(_catalog, new VehicleInfo(_catalog, installation.Physics));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_unit_can_join_the_one_it_is_held_to_from_either_side()
|
||||||
|
{
|
||||||
|
var first = Append("b111-01");
|
||||||
|
var second = Append("b111-02");
|
||||||
|
|
||||||
|
Assert.False(_consist.CanJoin(first, 0));
|
||||||
|
Assert.False(_consist.CanJoin(second, 1));
|
||||||
|
|
||||||
|
first.Cars[0].Coupling.Set(Coupling.WorkshopLock, true);
|
||||||
|
|
||||||
|
Assert.True(_consist.CanJoin(first, 0));
|
||||||
|
Assert.True(_consist.CanJoin(second, 1));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void The_vehicle_count_counts_cars_not_units()
|
public void The_vehicle_count_counts_cars_not_units()
|
||||||
{
|
{
|
||||||
|
|||||||
100
StarterNG.Tests/DisplaysTests.cs
Normal file
100
StarterNG.Tests/DisplaysTests.cs
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
using StarterNG.Infrastructure;
|
||||||
|
using StarterNG.Tests.Fakes;
|
||||||
|
|
||||||
|
namespace StarterNG.Tests;
|
||||||
|
|
||||||
|
public class DisplaysTests
|
||||||
|
{
|
||||||
|
/// <summary>A 128-byte block with an 18-byte timing at 54, as a panel's base EDID has it.</summary>
|
||||||
|
private static byte[] BaseBlock(int pixelClock10KHz, int hActive, int hBlank, int vActive, int vBlank,
|
||||||
|
int extensions = 0)
|
||||||
|
{
|
||||||
|
var edid = new byte[128];
|
||||||
|
new byte[] { 0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0 }.CopyTo(edid, 0);
|
||||||
|
edid[126] = (byte)extensions;
|
||||||
|
int at = 54;
|
||||||
|
edid[at] = (byte)pixelClock10KHz;
|
||||||
|
edid[at + 1] = (byte)(pixelClock10KHz >> 8);
|
||||||
|
edid[at + 2] = (byte)hActive;
|
||||||
|
edid[at + 3] = (byte)hBlank;
|
||||||
|
edid[at + 4] = (byte)((hActive >> 8) << 4 | hBlank >> 8);
|
||||||
|
edid[at + 5] = (byte)vActive;
|
||||||
|
edid[at + 6] = (byte)vBlank;
|
||||||
|
edid[at + 7] = (byte)((vActive >> 8) << 4 | vBlank >> 8);
|
||||||
|
return edid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A DisplayID 2.0 block with one type VII timing (kHz, sizes less one).</summary>
|
||||||
|
private static byte[] DisplayIdBlock(int pixelClockKHz, int hActive, int hBlank, int vActive, int vBlank)
|
||||||
|
{
|
||||||
|
var block = new byte[128];
|
||||||
|
block[0] = 0x70;
|
||||||
|
block[1] = 0x20;
|
||||||
|
block[2] = 23; // one data block: 3 bytes of header, 20 of timing
|
||||||
|
block[3] = 2;
|
||||||
|
int at = 5;
|
||||||
|
block[at] = 0x22;
|
||||||
|
block[at + 2] = 20;
|
||||||
|
int t = at + 3, clock = pixelClockKHz - 1;
|
||||||
|
block[t] = (byte)clock;
|
||||||
|
block[t + 1] = (byte)(clock >> 8);
|
||||||
|
block[t + 2] = (byte)(clock >> 16);
|
||||||
|
void Put(int offset, int value)
|
||||||
|
{
|
||||||
|
block[t + offset] = (byte)(value - 1);
|
||||||
|
block[t + offset + 1] = (byte)((value - 1) >> 8);
|
||||||
|
}
|
||||||
|
Put(4, hActive);
|
||||||
|
Put(6, hBlank);
|
||||||
|
Put(12, vActive);
|
||||||
|
Put(14, vBlank);
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_plain_monitor_gives_its_preferred_timing()
|
||||||
|
{
|
||||||
|
// 1920x1080 at 148.5 MHz: the usual 60 Hz.
|
||||||
|
Assert.Equal(60, Edid.HighestRefreshRate(BaseBlock(14850, 1920, 280, 1080, 45)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_fast_panel_found_only_in_displayid_is_read_there()
|
||||||
|
{
|
||||||
|
// As the eDP panel of a gaming laptop: 60 Hz in the base block, 165 Hz in DisplayID.
|
||||||
|
var edid = BaseBlock(15394, 1920, 160, 1200, 35, extensions: 1)
|
||||||
|
.Concat(DisplayIdBlock(423250, 1920, 160, 1200, 33)).ToArray();
|
||||||
|
|
||||||
|
Assert.Equal(165, Edid.HighestRefreshRate(edid));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Something_too_short_for_an_edid_has_no_rate()
|
||||||
|
{
|
||||||
|
Assert.Equal(0, Edid.HighestRefreshRate(new byte[] { 0, 0xFF }));
|
||||||
|
Assert.Equal(0, Edid.HighestRefreshRate(new byte[128]));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Only_connected_screens_are_listed_by_their_connector()
|
||||||
|
{
|
||||||
|
var files = new InMemoryFileSystem(caseSensitive: true)
|
||||||
|
.WithFile("/sys/class/drm/card1-eDP-1/status", "connected\n")
|
||||||
|
.WithFile("/sys/class/drm/card1-HDMI-A-1/status", "disconnected\n")
|
||||||
|
.WithFile("/sys/class/drm/card1/device/uevent", "DRIVER=amdgpu\n");
|
||||||
|
files.WriteAllBytes("/sys/class/drm/card1-eDP-1/edid", BaseBlock(14850, 1920, 280, 1080, 45));
|
||||||
|
files.WriteAllBytes("/sys/class/drm/card1-HDMI-A-1/edid", BaseBlock(14850, 1920, 280, 1080, 45));
|
||||||
|
|
||||||
|
var displays = new Displays(files, new FakeEnvironment()).List();
|
||||||
|
|
||||||
|
Assert.Equal(new[] { new Display("eDP-1", 60) }, displays);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Off_linux_without_windows_nothing_is_read()
|
||||||
|
{
|
||||||
|
var environment = new FakeEnvironment { IsLinux = false, IsMacOS = true };
|
||||||
|
|
||||||
|
Assert.Empty(new Displays(new InMemoryFileSystem(), environment).List());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -171,6 +171,65 @@ public class ExecutableLocatorTests
|
|||||||
Assert.Equal(arguments, programArguments);
|
Assert.Equal(arguments, programArguments);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void What_is_inside_says_what_the_file_is_not_its_name()
|
||||||
|
{
|
||||||
|
string windowsBuild = TestInstallation.At("eu07-win");
|
||||||
|
string nativeBuild = TestInstallation.At("eu07.exe");
|
||||||
|
var installation = WithWine(new InMemoryFileSystem()
|
||||||
|
.WithFile(windowsBuild, PortableExecutable)
|
||||||
|
.WithExecutable(nativeBuild, Elf));
|
||||||
|
var executables = installation.Executables;
|
||||||
|
|
||||||
|
Assert.True(executables.IsWindowsBuild("eu07-win"));
|
||||||
|
Assert.Equal(Wine, executables.LaunchCommand(windowsBuild, Array.Empty<string>()).FileName);
|
||||||
|
Assert.False(executables.IsWindowsBuild("eu07.exe"));
|
||||||
|
Assert.Equal(nativeBuild, executables.LaunchCommand(nativeBuild, Array.Empty<string>()).FileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_script_on_linux_starts_itself()
|
||||||
|
{
|
||||||
|
string script = TestInstallation.At("eu07-start");
|
||||||
|
var installation = WithWine(new InMemoryFileSystem().WithExecutable(script, "#!/bin/sh\nwine eu07.exe\n"));
|
||||||
|
|
||||||
|
string resolved = installation.Executables.Resolve(new SimulatorSettings(), out var problem);
|
||||||
|
|
||||||
|
Assert.EndsWith("eu07-start", resolved);
|
||||||
|
Assert.Equal(ExeProblem.None, problem);
|
||||||
|
Assert.Equal(script, installation.Executables.LaunchCommand(script, Array.Empty<string>()).FileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("notatki do eu07\n")]
|
||||||
|
[InlineData("")]
|
||||||
|
public void On_linux_the_execute_bit_alone_does_not_make_a_program(string inside)
|
||||||
|
{
|
||||||
|
// On a Windows partition every file has the bit.
|
||||||
|
string note = TestInstallation.At("eu07_opis");
|
||||||
|
var installation = WithWine(new InMemoryFileSystem()
|
||||||
|
.WithExecutable(note, inside)
|
||||||
|
.WithFile(TestInstallation.At("eu07.exe"), PortableExecutable));
|
||||||
|
|
||||||
|
string resolved = installation.Executables.Resolve(new SimulatorSettings(), out var problem);
|
||||||
|
|
||||||
|
Assert.EndsWith("eu07.exe", resolved);
|
||||||
|
Assert.Equal(ExeProblem.WrongPlatform, problem);
|
||||||
|
Assert.Equal(new[] { "eu07.exe" }, installation.Executables.ListCandidates());
|
||||||
|
Assert.Equal(ExeProblem.NotAProgram, installation.Executables.Validate(note));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_mac_binary_is_not_taken_for_a_note()
|
||||||
|
{
|
||||||
|
string build = TestInstallation.At("eu07");
|
||||||
|
var installation = new TestInstallation(new InMemoryFileSystem().WithExecutable(build, "Ïúíþ"));
|
||||||
|
installation.Environment.IsLinux = false;
|
||||||
|
installation.Environment.IsMacOS = true;
|
||||||
|
|
||||||
|
Assert.Equal(ExeProblem.None, installation.Executables.Validate(build));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Wine_is_never_looked_for_on_windows()
|
public void Wine_is_never_looked_for_on_windows()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,16 +9,61 @@ namespace StarterNG.Tests.Fakes;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Paths are normalised to forward slashes and compared case-insensitively,
|
/// 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>
|
/// </remarks>
|
||||||
public sealed class InMemoryFileSystem : IFileSystem
|
public sealed class InMemoryFileSystem : IFileSystem
|
||||||
{
|
{
|
||||||
private readonly Dictionary<string, byte[]> _files = new(StringComparer.OrdinalIgnoreCase);
|
private readonly StringComparison _comparison;
|
||||||
private readonly HashSet<string> _directories = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, byte[]> _files;
|
||||||
private readonly HashSet<string> _executable = new(StringComparer.OrdinalIgnoreCase);
|
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);
|
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()
|
static InMemoryFileSystem()
|
||||||
{
|
{
|
||||||
// The game's own files are code page 1250; a test that writes Polish text
|
// 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 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));
|
public string ReadAllText(string path) => Encoding.UTF8.GetString(Bytes(path));
|
||||||
|
|
||||||
@@ -76,6 +124,10 @@ public sealed class InMemoryFileSystem : IFileSystem
|
|||||||
AddParents(key);
|
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) =>
|
public void WriteAllLines(string path, IEnumerable<string> lines) =>
|
||||||
WithFile(path, string.Join("\n", lines));
|
WithFile(path, string.Join("\n", lines));
|
||||||
|
|
||||||
@@ -103,7 +155,7 @@ public sealed class InMemoryFileSystem : IFileSystem
|
|||||||
string prefix = Normalize(path) + "/";
|
string prefix = Normalize(path) + "/";
|
||||||
var pattern = ToRegex(searchPattern);
|
var pattern = ToRegex(searchPattern);
|
||||||
return _files.Keys
|
return _files.Keys
|
||||||
.Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
.Where(key => key.StartsWith(prefix, _comparison))
|
||||||
.Where(key => !key[prefix.Length..].Contains('/'))
|
.Where(key => !key[prefix.Length..].Contains('/'))
|
||||||
.Where(key => pattern.IsMatch(key[prefix.Length..]))
|
.Where(key => pattern.IsMatch(key[prefix.Length..]))
|
||||||
.OrderBy(key => key, StringComparer.OrdinalIgnoreCase)
|
.OrderBy(key => key, StringComparer.OrdinalIgnoreCase)
|
||||||
@@ -115,7 +167,8 @@ public sealed class InMemoryFileSystem : IFileSystem
|
|||||||
string prefix = Normalize(path) + "/";
|
string prefix = Normalize(path) + "/";
|
||||||
var pattern = ToRegex(searchPattern);
|
var pattern = ToRegex(searchPattern);
|
||||||
return _files.Keys
|
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)..]))
|
.Where(key => pattern.IsMatch(key[(key.LastIndexOf('/') + 1)..]))
|
||||||
.OrderBy(key => key, StringComparer.OrdinalIgnoreCase)
|
.OrderBy(key => key, StringComparer.OrdinalIgnoreCase)
|
||||||
.ToList();
|
.ToList();
|
||||||
@@ -123,15 +176,26 @@ public sealed class InMemoryFileSystem : IFileSystem
|
|||||||
|
|
||||||
public IReadOnlyList<string> GetDirectories(string path)
|
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) + "/";
|
string prefix = Normalize(path) + "/";
|
||||||
return _directories
|
return _directories
|
||||||
.Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
.Where(key => key.StartsWith(prefix, _comparison))
|
||||||
.Where(key => !key[prefix.Length..].Contains('/'))
|
.Where(key => !key[prefix.Length..].Contains('/'))
|
||||||
.OrderBy(key => key, StringComparer.OrdinalIgnoreCase)
|
.OrderBy(key => key, StringComparer.OrdinalIgnoreCase)
|
||||||
.ToList();
|
.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>
|
/// <summary>Files added through <see cref="WithExecutable"/> carry the execute bit.</summary>
|
||||||
public long GetFileSize(string path) => Bytes(path).LongLength;
|
public long GetFileSize(string path) => Bytes(path).LongLength;
|
||||||
@@ -145,10 +209,56 @@ public sealed class InMemoryFileSystem : IFileSystem
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
private byte[] Bytes(string path) =>
|
public string? LinkTarget(string path) => _links.GetValueOrDefault(Normalize(path));
|
||||||
_files.TryGetValue(Normalize(path), out var bytes)
|
|
||||||
|
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
|
? bytes
|
||||||
: throw new FileNotFoundException(path);
|
: throw new FileNotFoundException(path);
|
||||||
|
}
|
||||||
|
|
||||||
private void AddParents(string key)
|
private void AddParents(string key)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ public sealed class TestInstallation
|
|||||||
|
|
||||||
Executables = new ExecutableLocator(Files, Paths, Environment, Log);
|
Executables = new ExecutableLocator(Files, Paths, Environment, Log);
|
||||||
SettingsStore = new SettingsStore(Files, Clock, Log, SettingsPaths, new SettingsSerializer(), Executables);
|
SettingsStore = new SettingsStore(Files, Clock, Log, SettingsPaths, new SettingsSerializer(), Executables);
|
||||||
|
Tapes = new HaslerTapes(Files, Paths, Log, Clock, new TimetableLocator(Files), () => SimulatorRunning);
|
||||||
}
|
}
|
||||||
|
|
||||||
public InMemoryFileSystem Files { get; }
|
public InMemoryFileSystem Files { get; }
|
||||||
@@ -57,6 +58,8 @@ public sealed class TestInstallation
|
|||||||
public SettingsPaths SettingsPaths { get; }
|
public SettingsPaths SettingsPaths { get; }
|
||||||
public ExecutableLocator Executables { get; }
|
public ExecutableLocator Executables { get; }
|
||||||
public SettingsStore SettingsStore { get; }
|
public SettingsStore SettingsStore { get; }
|
||||||
|
public HaslerTapes Tapes { get; }
|
||||||
|
public bool SimulatorRunning { get; set; }
|
||||||
|
|
||||||
/// <summary>Path of a file inside the fake installation.</summary>
|
/// <summary>Path of a file inside the fake installation.</summary>
|
||||||
public static string At(params string[] segments) => Path.Combine(new[] { Root }.Concat(segments).ToArray());
|
public static string At(params string[] segments) => Path.Combine(new[] { Root }.Concat(segments).ToArray());
|
||||||
|
|||||||
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"));
|
||||||
|
}
|
||||||
16
StarterNG.Tests/Fakes/TestScenery.cs
Normal file
16
StarterNG.Tests/Fakes/TestScenery.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
using StarterNG.Application.Abstractions;
|
||||||
|
using StarterNG.Infrastructure.Adapters;
|
||||||
|
using StarterNG.Infrastructure.Sceneries;
|
||||||
|
|
||||||
|
namespace StarterNG.Tests.Fakes;
|
||||||
|
|
||||||
|
/// <summary>The scenery reader over a fake clock and dice, for tests of what it makes of a text.</summary>
|
||||||
|
public static class TestScenery
|
||||||
|
{
|
||||||
|
/// <param name="now">The hour a scenery that names none starts at.</param>
|
||||||
|
public static SceneryParser Parser(DateTime? now = null) =>
|
||||||
|
new(new FixedClock(now ?? new DateTime(2026, 1, 1, 12, 0, 0)), new StubRandom());
|
||||||
|
|
||||||
|
public static SceneryRepository Repository(IFileSystem files) =>
|
||||||
|
new(files, new GamePaths("game"), TestLog.Over(files), Parser());
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
using StarterNG.Application;
|
using StarterNG.Application;
|
||||||
using StarterNG.Domain.Sceneries;
|
|
||||||
using StarterNG.Tests.Fakes;
|
using StarterNG.Tests.Fakes;
|
||||||
|
|
||||||
namespace StarterNG.Tests;
|
namespace StarterNG.Tests;
|
||||||
@@ -35,4 +34,43 @@ public class FavoriteSceneriesTests
|
|||||||
|
|
||||||
Assert.False(favorites.Contains(scenery));
|
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);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,4 +72,45 @@ public class GameLibraryTests
|
|||||||
|
|
||||||
Assert.Contains("textures.txt", installation.Log.Text);
|
Assert.Contains("textures.txt", installation.Log.Text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_folder_of_the_rolling_stock_that_cannot_be_listed_costs_only_itself()
|
||||||
|
{
|
||||||
|
var files = new InMemoryFileSystem()
|
||||||
|
.WithFile(TestInstallation.At("dynamic", "aaa", "ep09", "textures.txt"), "ep09-1.mat=ep09.e3d,ep09")
|
||||||
|
.WithFile(TestInstallation.At("dynamic", "aaa", "ep09", "ep09.fiz"), "Param: M=2")
|
||||||
|
.WithFile(TestInstallation.At("dynamic", "pkp", "ep07", "textures.txt"), "ep07-135.mat=ep07.e3d,ep07")
|
||||||
|
.WithFile(TestInstallation.At("dynamic", "pkp", "ep07", "ep07.fiz"), "Param: M=1")
|
||||||
|
.WithFile(TestInstallation.At("scenery", "kato.scn"), "//$n Katowice")
|
||||||
|
.DenyListing(TestInstallation.At("dynamic", "aaa"));
|
||||||
|
var installation = new TestInstallation(files);
|
||||||
|
|
||||||
|
installation.Library.Load();
|
||||||
|
|
||||||
|
Assert.True(installation.Library.Loaded);
|
||||||
|
Assert.Equal("ep07-135", Assert.Single(installation.Library.Vehicles.Textures).Skinfile);
|
||||||
|
Assert.Equal("Katowice", Assert.Single(installation.Library.Sceneries).Name);
|
||||||
|
Assert.Contains("aaa", installation.Log.Text);
|
||||||
|
Assert.Equal(1, installation.Physics.For("pkp/ep07/", "ep07")!.Mass);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_whole_rolling_stock_failing_still_leaves_the_sceneries()
|
||||||
|
{
|
||||||
|
var installation = Complete();
|
||||||
|
var library = new GameLibrary(new FailingVehicles(), installation.Sceneries, installation.MiniTextures,
|
||||||
|
installation.Physics, installation.Log);
|
||||||
|
|
||||||
|
library.Load();
|
||||||
|
|
||||||
|
Assert.True(library.Loaded);
|
||||||
|
Assert.Equal("Katowice", Assert.Single(library.Sceneries).Name);
|
||||||
|
Assert.Contains("no disk today", installation.Log.Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FailingVehicles : StarterNG.Application.Abstractions.IVehicleRepository
|
||||||
|
{
|
||||||
|
public int Load(StarterNG.Domain.Vehicles.VehicleCatalog catalog) =>
|
||||||
|
throw new IOException("no disk today");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
190
StarterNG.Tests/GpuOffloadTests.cs
Normal file
190
StarterNG.Tests/GpuOffloadTests.cs
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
using StarterNG.Domain.Settings;
|
||||||
|
using StarterNG.Infrastructure;
|
||||||
|
using StarterNG.Tests.Fakes;
|
||||||
|
|
||||||
|
namespace StarterNG.Tests;
|
||||||
|
|
||||||
|
public class GpuOffloadTests
|
||||||
|
{
|
||||||
|
// The laptop this was written on: an RTX 5060 beside a Radeon 780M that drives the screen.
|
||||||
|
private static readonly GpuDevice Nvidia = new("0000:01:00.0", "10DE:2D19", "nvidia", BootVga: false,
|
||||||
|
InternalPanel: true);
|
||||||
|
private static readonly GpuDevice Radeon780M = new("0000:65:00.0", "1002:1900", "amdgpu", BootVga: true,
|
||||||
|
InternalPanel: true);
|
||||||
|
private static readonly GpuDevice RadeonDiscrete = new("0000:03:00.0", "1002:73DF", "amdgpu", BootVga: false);
|
||||||
|
private static readonly GpuDevice IntelIntegrated = new("0000:00:02.0", "8086:A7A0", "i915", BootVga: true,
|
||||||
|
InternalPanel: true);
|
||||||
|
|
||||||
|
private static string? Unset(string name) => null;
|
||||||
|
|
||||||
|
private static IReadOnlyDictionary<string, string> For(GpuPreference preference, params GpuDevice[] gpus) =>
|
||||||
|
Inheriting(Unset, preference, gpus);
|
||||||
|
|
||||||
|
private static IReadOnlyDictionary<string, string> Inheriting(Func<string, string?> inherited,
|
||||||
|
GpuPreference preference,
|
||||||
|
params GpuDevice[] gpus) =>
|
||||||
|
GpuOffload.Target(gpus, preference, inherited) is { } target
|
||||||
|
? GpuOffload.EnvironmentFor(target)
|
||||||
|
: new Dictionary<string, string>();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_proprietary_nvidia_card_gets_prime_render_offload()
|
||||||
|
{
|
||||||
|
var environment = For(GpuPreference.Discrete, Nvidia, Radeon780M);
|
||||||
|
|
||||||
|
Assert.Equal("1", environment["__NV_PRIME_RENDER_OFFLOAD"]);
|
||||||
|
Assert.Equal("nvidia", environment["__GLX_VENDOR_LIBRARY_NAME"]);
|
||||||
|
Assert.Equal("NVIDIA_only", environment["__VK_LAYER_NV_optimus"]);
|
||||||
|
// DRI_PRIME on a card Mesa does not drive leaves Mesa on llvmpipe.
|
||||||
|
Assert.False(environment.ContainsKey("DRI_PRIME"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_card_mesa_drives_is_picked_by_its_pci_address()
|
||||||
|
{
|
||||||
|
var environment = For(GpuPreference.Discrete, IntelIntegrated, RadeonDiscrete);
|
||||||
|
|
||||||
|
Assert.Equal("pci-0000_03_00_0", Assert.Single(environment).Value);
|
||||||
|
Assert.Equal("DRI_PRIME", environment.Keys.Single());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_card_on_a_driver_of_unknown_ways_is_left_alone()
|
||||||
|
{
|
||||||
|
var odd = Nvidia with { Driver = "vfio-pci" };
|
||||||
|
|
||||||
|
Assert.Empty(For(GpuPreference.Discrete, odd, Radeon780M));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void One_card_leaves_nothing_to_choose()
|
||||||
|
{
|
||||||
|
Assert.Empty(For(GpuPreference.Discrete, Radeon780M));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_system_choice_changes_nothing()
|
||||||
|
{
|
||||||
|
Assert.Empty(For(GpuPreference.System, Nvidia, Radeon780M));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("DRI_PRIME")]
|
||||||
|
[InlineData("__NV_PRIME_RENDER_OFFLOAD")]
|
||||||
|
[InlineData("__GLX_VENDOR_LIBRARY_NAME")]
|
||||||
|
public void A_choice_the_player_made_stands(string variable)
|
||||||
|
{
|
||||||
|
// prime-run %command% in Steam's launch options, say.
|
||||||
|
var environment = Inheriting(name => name == variable ? "1" : null, GpuPreference.Discrete, Nvidia, Radeon780M);
|
||||||
|
|
||||||
|
Assert.Empty(environment);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_discrete_card_already_driving_the_screen_needs_no_offload()
|
||||||
|
{
|
||||||
|
// A laptop with its MUX switched to the NVIDIA card.
|
||||||
|
Assert.Empty(For(GpuPreference.Discrete, Nvidia with { BootVga = true },
|
||||||
|
Radeon780M with { BootVga = false }));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_desktop_stays_on_the_card_its_monitor_is_plugged_into()
|
||||||
|
{
|
||||||
|
// The monitor on a Radeon, the processor's own graphics left on: moving
|
||||||
|
// off the boot card would land on the weaker one.
|
||||||
|
var radeon = RadeonDiscrete with { BootVga = true };
|
||||||
|
var intel = IntelIntegrated with { BootVga = false, InternalPanel = false };
|
||||||
|
|
||||||
|
Assert.Empty(For(GpuPreference.Discrete, radeon, intel));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── What the settings say about it ───────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void No_card_read_is_said_whatever_was_chosen()
|
||||||
|
{
|
||||||
|
Assert.Equal(GpuNote.Unread, GpuOffload.NoteFor(Array.Empty<GpuDevice>(), GpuPreference.Discrete, Unset));
|
||||||
|
Assert.Equal(GpuNote.Unread, GpuOffload.NoteFor(Array.Empty<GpuDevice>(), GpuPreference.System, name => "1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void One_card_is_said_whatever_was_chosen()
|
||||||
|
{
|
||||||
|
Assert.Equal(GpuNote.OneCard, GpuOffload.NoteFor(new[] { Radeon780M }, GpuPreference.Discrete, Unset));
|
||||||
|
Assert.Equal(GpuNote.OneCard, GpuOffload.NoteFor(new[] { Radeon780M }, GpuPreference.System, name => "1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_system_choice_comes_before_what_the_player_set()
|
||||||
|
{
|
||||||
|
Assert.Equal(GpuNote.LeftToSystem,
|
||||||
|
GpuOffload.NoteFor(new[] { Nvidia, Radeon780M }, GpuPreference.System, name => "1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void What_the_player_set_comes_before_the_card_it_would_be_moved_to()
|
||||||
|
{
|
||||||
|
static string? Inherited(string name) => name is "DRI_PRIME" or "__NV_PRIME_RENDER_OFFLOAD" ? "1" : null;
|
||||||
|
|
||||||
|
Assert.Equal(GpuNote.PlayerChose,
|
||||||
|
GpuOffload.NoteFor(new[] { Nvidia, Radeon780M }, GpuPreference.Discrete, Inherited));
|
||||||
|
Assert.Equal(new[] { "__NV_PRIME_RENDER_OFFLOAD", "DRI_PRIME" }, GpuOffload.PlayerChoices(Inherited));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_variable_set_empty_is_no_choice_of_the_player()
|
||||||
|
{
|
||||||
|
Assert.Empty(GpuOffload.PlayerChoices(name => string.Empty));
|
||||||
|
Assert.Equal(GpuNote.Moved,
|
||||||
|
GpuOffload.NoteFor(new[] { Nvidia, Radeon780M }, GpuPreference.Discrete, name => string.Empty));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_note_says_a_move_only_with_a_card_to_move_to()
|
||||||
|
{
|
||||||
|
Assert.Equal(GpuNote.Moved, GpuOffload.NoteFor(new[] { Nvidia, Radeon780M }, GpuPreference.Discrete, Unset));
|
||||||
|
Assert.Equal(GpuNote.NoTarget,
|
||||||
|
GpuOffload.NoteFor(new[] { Nvidia with { Driver = "vfio-pci" }, Radeon780M },
|
||||||
|
GpuPreference.Discrete, Unset));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reading the cards ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static InMemoryFileSystem Card(InMemoryFileSystem files, string card, string driver, string id,
|
||||||
|
string slot, bool bootVga) =>
|
||||||
|
files.WithFile($"/sys/class/drm/{card}/device/uevent",
|
||||||
|
$"DRIVER={driver}\nPCI_CLASS=30000\nPCI_ID={id}\nPCI_SLOT_NAME={slot}\n")
|
||||||
|
.WithFile($"/sys/class/drm/{card}/device/boot_vga", bootVga ? "1\n" : "0\n");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_cards_are_read_from_sysfs()
|
||||||
|
{
|
||||||
|
var files = new InMemoryFileSystem(caseSensitive: true);
|
||||||
|
Card(files, "card0", "nvidia", "10DE:2D19", "0000:01:00.0", bootVga: false);
|
||||||
|
Card(files, "card1", "amdgpu", "1002:1900", "0000:65:00.0", bootVga: true);
|
||||||
|
// A connector, not a card.
|
||||||
|
files.WithFile("/sys/class/drm/card0-eDP-2/device/uevent", "DRIVER=nvidia\nPCI_ID=10DE:2D19\nPCI_SLOT_NAME=x\n");
|
||||||
|
|
||||||
|
files.WithFile("/sys/class/drm/card1-eDP-1/status", "connected\n");
|
||||||
|
|
||||||
|
var gpus = new LinuxGpus(files, new FakeEnvironment()).List();
|
||||||
|
|
||||||
|
Assert.Equal(new[] { Nvidia, Radeon780M }, gpus);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Off_linux_there_are_no_cards_to_move_between()
|
||||||
|
{
|
||||||
|
var files = new InMemoryFileSystem(caseSensitive: true);
|
||||||
|
Card(files, "card0", "nvidia", "10DE:2D19", "0000:01:00.0", bootVga: false);
|
||||||
|
|
||||||
|
Assert.Empty(new LinuxGpus(files, new FakeEnvironment().AsWindows()).List());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Without_sysfs_there_are_no_cards()
|
||||||
|
{
|
||||||
|
Assert.Empty(new LinuxGpus(new InMemoryFileSystem(), new FakeEnvironment()).List());
|
||||||
|
}
|
||||||
|
}
|
||||||
628
StarterNG.Tests/GraphicsPresetTests.cs
Normal file
628
StarterNG.Tests/GraphicsPresetTests.cs
Normal file
@@ -0,0 +1,628 @@
|
|||||||
|
using StarterNG.Application;
|
||||||
|
using StarterNG.Domain.Settings;
|
||||||
|
using StarterNG.Tests.Fakes;
|
||||||
|
|
||||||
|
namespace StarterNG.Tests;
|
||||||
|
|
||||||
|
public class GraphicsPresetTests
|
||||||
|
{
|
||||||
|
private static SimulatorSettings Defaults()
|
||||||
|
{
|
||||||
|
var installation = new TestInstallation();
|
||||||
|
installation.SettingsStore.Load();
|
||||||
|
return installation.SettingsStore.Settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SimulatorSettings AtHigh()
|
||||||
|
{
|
||||||
|
var settings = Defaults().Copy();
|
||||||
|
GraphicsPreset.High.ApplyTo(settings);
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_keys_named_are_the_keys_a_level_sets()
|
||||||
|
{
|
||||||
|
var store = new TestInstallation().SettingsStore;
|
||||||
|
var low = Defaults().Copy();
|
||||||
|
var top = Defaults().Copy();
|
||||||
|
(GraphicsPreset.Minimal with { Extras = null }).ApplyTo(low);
|
||||||
|
(GraphicsPreset.Ultra with { Extras = null, Top = null }).ApplyTo(top);
|
||||||
|
|
||||||
|
var lowKeys = store.Render(low);
|
||||||
|
var topKeys = store.Render(top);
|
||||||
|
var differing = lowKeys.Keys.Union(topKeys.Keys)
|
||||||
|
.Where(key => lowKeys.GetValueOrDefault(key) != topKeys.GetValueOrDefault(key))
|
||||||
|
.Where(key => !key.StartsWith("gfx.framebuffer", StringComparison.Ordinal));
|
||||||
|
|
||||||
|
Assert.Equal(GraphicsPreset.Keys.OrderBy(k => k), differing.OrderBy(k => k));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The scale ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static void Climbs(Func<GraphicsPreset, double> value, int repeatsAllowed)
|
||||||
|
{
|
||||||
|
var values = GraphicsPreset.All.Select(value).ToList();
|
||||||
|
|
||||||
|
Assert.Equal(values.OrderBy(v => v), values);
|
||||||
|
Assert.True(values.Count - values.Distinct().Count() <= repeatsAllowed, string.Join(", ", values));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Every_slider_moves_from_level_to_level_as_far_as_it_has_steps_for_six()
|
||||||
|
{
|
||||||
|
// Sizes and filtering run out of steps above High.
|
||||||
|
Climbs(p => p.TextureSize, 1);
|
||||||
|
Climbs(p => p.CabTextureSize, 1);
|
||||||
|
Climbs(p => p.Anisotropy, 1);
|
||||||
|
|
||||||
|
// Four steps or three for six levels.
|
||||||
|
Climbs(p => p.Multisampling, 2);
|
||||||
|
Climbs(p => p.SmokeFidelity, 2);
|
||||||
|
Climbs(p => p.SplineFidelity, 2);
|
||||||
|
Climbs(p => p.ReflectionsFidelity, 3);
|
||||||
|
Climbs(p => p.ShadowRank, 3);
|
||||||
|
|
||||||
|
// The simulator takes no more than its own 7 lights, and draws no less than 2 km.
|
||||||
|
Climbs(p => p.DynamicLights, 2);
|
||||||
|
Climbs(p => p.DrawRange, 1);
|
||||||
|
Climbs(p => p.DrawRangeFactor, 2);
|
||||||
|
Climbs(p => p.CabShadowsRange, 1);
|
||||||
|
|
||||||
|
Climbs(p => p.ShadowMap, 1);
|
||||||
|
Climbs(p => p.ShadowRange, 0);
|
||||||
|
Climbs(p => p.ReflectionsRate, 0);
|
||||||
|
Climbs(p => p.DistanceFactor, 0);
|
||||||
|
Climbs(p => p.BufferScale, 0);
|
||||||
|
Climbs(p => p.SleeperDistance, 0);
|
||||||
|
Climbs(p => p.ReflectedInstances, 0);
|
||||||
|
Climbs(p => p.ReflectedVehicles, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void No_step_up_a_scale_is_more_than_a_few_steps_of_its_slider()
|
||||||
|
{
|
||||||
|
// In steps of what the screen offers, so Ultra does not jump to the top from far below.
|
||||||
|
var rates = GraphicsPreset.All.Select(p => Steps.IndexOf(Steps.ReflectionUpdates, p.ReflectionsRate)).ToList();
|
||||||
|
Assert.All(rates.Zip(rates.Skip(1)), step => Assert.InRange(step.Second - step.First, 1, 4));
|
||||||
|
|
||||||
|
foreach (Func<GraphicsPreset, int> fidelity in new Func<GraphicsPreset, int>[] { p => p.SmokeFidelity, p => p.SplineFidelity })
|
||||||
|
{
|
||||||
|
var levels = GraphicsPreset.All.Select(fidelity).ToList();
|
||||||
|
Assert.All(levels.Zip(levels.Skip(1)), step => Assert.InRange(step.Second - step.First, 0, 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void High_is_the_simulators_own_but_where_that_is_the_bottom_of_a_scale()
|
||||||
|
{
|
||||||
|
var store = new TestInstallation().SettingsStore;
|
||||||
|
var own = store.Render(Defaults());
|
||||||
|
var high = store.Render(AtHigh());
|
||||||
|
|
||||||
|
var differing = own.Keys.Union(high.Keys)
|
||||||
|
.Where(key => own.GetValueOrDefault(key) != high.GetValueOrDefault(key))
|
||||||
|
.Where(key => !GraphicsPreset.RowKeys.Contains(key))
|
||||||
|
.Where(key => !key.StartsWith("gfx.framebuffer", StringComparison.Ordinal));
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "gfx.reflections.fidelity", "gfx.reflections.framerate", "gfx.shadows.cab.range",
|
||||||
|
"gfx.smoke.fidelity", "splinefidelity" }, differing.OrderBy(k => k));
|
||||||
|
Assert.Equal(1, Defaults().SmokeFidelity);
|
||||||
|
Assert.Equal(1, Defaults().SplineFidelity);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_player_with_no_settings_of_their_own_starts_at_high()
|
||||||
|
{
|
||||||
|
var installation = new TestInstallation();
|
||||||
|
var store = installation.SettingsStore;
|
||||||
|
store.Load();
|
||||||
|
|
||||||
|
store.StartAtHighWithoutOwnSettings();
|
||||||
|
|
||||||
|
Assert.Same(GraphicsPreset.High, GraphicsPreset.Matching(store.Settings));
|
||||||
|
Assert.False(store.LastSaveFailed);
|
||||||
|
|
||||||
|
var again = new TestInstallation(installation.Files).SettingsStore;
|
||||||
|
again.Load();
|
||||||
|
Assert.Same(GraphicsPreset.High, GraphicsPreset.Matching(again.Settings));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_player_with_settings_of_their_own_stays_as_they_are()
|
||||||
|
{
|
||||||
|
var installation = new TestInstallation();
|
||||||
|
var store = installation.SettingsStore;
|
||||||
|
store.Load();
|
||||||
|
GraphicsPreset.Low.ApplyTo(store.Settings);
|
||||||
|
store.Save();
|
||||||
|
|
||||||
|
var again = new TestInstallation(installation.Files).SettingsStore;
|
||||||
|
again.Load();
|
||||||
|
again.StartAtHighWithoutOwnSettings();
|
||||||
|
|
||||||
|
Assert.Same(GraphicsPreset.Low, GraphicsPreset.Matching(again.Settings));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Ultra_is_the_top_of_everything_the_screen_offers()
|
||||||
|
{
|
||||||
|
var ultra = GraphicsPreset.Ultra;
|
||||||
|
double Top(string key) => SimulatorKeys.Named(key).Maximum;
|
||||||
|
|
||||||
|
Assert.Equal(8192, ultra.TextureSize);
|
||||||
|
Assert.Equal(8192, ultra.CabTextureSize);
|
||||||
|
Assert.Equal(8192, ultra.ShadowMap);
|
||||||
|
Assert.Equal(600, ultra.ShadowRange);
|
||||||
|
Assert.Equal(Steps.Anisotropy.Max(), ultra.Anisotropy);
|
||||||
|
Assert.Equal(Steps.MultisamplingLevels.Max(), ultra.Multisampling);
|
||||||
|
Assert.Equal(Steps.ReflectionUpdates.Max(), ultra.ReflectionsRate);
|
||||||
|
Assert.Equal(4, ultra.SmokeFidelity);
|
||||||
|
Assert.Equal(4, ultra.SplineFidelity);
|
||||||
|
Assert.Equal(200, ultra.BufferScale);
|
||||||
|
Assert.Equal(Top(GraphicsPreset.DrawRangeKey), ultra.DrawRange);
|
||||||
|
Assert.Equal(Top(GraphicsPreset.DistanceFactorKey), ultra.DistanceFactor);
|
||||||
|
Assert.Equal(Top(GraphicsPreset.SleeperKey), ultra.SleeperDistance);
|
||||||
|
Assert.Equal(Steps.DrawRangeFactors.Max(), ultra.DrawRangeFactor);
|
||||||
|
Assert.Equal(Top(GraphicsPreset.ReflectedInstancesKey), ultra.ReflectedInstances);
|
||||||
|
Assert.Equal(Top(GraphicsPreset.ReflectedVehiclesKey), ultra.ReflectedVehicles);
|
||||||
|
Assert.Equal(100, ultra.CabShadowsRange);
|
||||||
|
|
||||||
|
var extras = ultra.Extras!;
|
||||||
|
Assert.Equal(200, extras.PythonUpdateMs);
|
||||||
|
Assert.True(extras.MotionBlur && extras.ChromaticAberration && extras.Smoke);
|
||||||
|
|
||||||
|
var top = ultra.Top!;
|
||||||
|
Assert.Equal(Top(GraphicsTop.ScreenWindowFpsKey), top.ScreenWindowFps);
|
||||||
|
Assert.True(top.Screens && top.ScreenMipmaps && top.Sky && top.Cab && top.AirCouplers && top.SmoothTraction);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void At_ultra_what_would_only_take_away_is_off()
|
||||||
|
{
|
||||||
|
var top = GraphicsPreset.Ultra.Top!;
|
||||||
|
|
||||||
|
// The longest shadows, the bottom of that scale.
|
||||||
|
Assert.Equal(0.2, top.ShadowAngleLimit);
|
||||||
|
Assert.Equal(0, top.TargetFps);
|
||||||
|
Assert.Equal(0, top.TextureRelease);
|
||||||
|
Assert.False(top.CompressTextures);
|
||||||
|
Assert.False(top.SkipDecor);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Ultra_undoes_what_was_moved_earlier()
|
||||||
|
{
|
||||||
|
var settings = Defaults().Copy();
|
||||||
|
settings.ShadowAngleLimit = 0.8;
|
||||||
|
settings.IgnoreIrrelevantTrains = true;
|
||||||
|
settings.Extra[GraphicsTop.TargetFpsKey] = "60";
|
||||||
|
settings.Extra[GraphicsTop.TextureReleaseKey] = "2";
|
||||||
|
|
||||||
|
GraphicsPreset.Ultra.ApplyTo(settings);
|
||||||
|
|
||||||
|
Assert.Same(GraphicsPreset.Ultra, GraphicsPreset.Matching(settings));
|
||||||
|
Assert.Equal(0.2, settings.ShadowAngleLimit);
|
||||||
|
Assert.False(settings.IgnoreIrrelevantTrains);
|
||||||
|
Assert.Equal("0", settings.Extra[GraphicsTop.TargetFpsKey]);
|
||||||
|
Assert.Equal("0", settings.Extra[GraphicsTop.TextureReleaseKey]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Ultra_leaves_the_screens_in_their_own_windows_to_the_player()
|
||||||
|
{
|
||||||
|
var settings = Defaults().Copy();
|
||||||
|
settings.Extra["python.displaywindows"] = "yes";
|
||||||
|
|
||||||
|
GraphicsPreset.Ultra.ApplyTo(settings);
|
||||||
|
|
||||||
|
Assert.Equal("yes", settings.Extra["python.displaywindows"]);
|
||||||
|
Assert.Same(GraphicsPreset.Ultra, GraphicsPreset.Matching(settings));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_other_levels_leave_what_only_ultra_sets()
|
||||||
|
{
|
||||||
|
foreach (var preset in GraphicsPreset.All.Where(preset => preset != GraphicsPreset.Ultra))
|
||||||
|
{
|
||||||
|
var settings = Defaults().Copy();
|
||||||
|
settings.IgnoreIrrelevantTrains = true;
|
||||||
|
settings.Extra[GraphicsTop.TargetFpsKey] = "60";
|
||||||
|
|
||||||
|
preset.ApplyTo(settings);
|
||||||
|
|
||||||
|
Assert.True(settings.IgnoreIrrelevantTrains, preset.NameKey);
|
||||||
|
Assert.Equal("60", settings.Extra[GraphicsTop.TargetFpsKey]);
|
||||||
|
Assert.Same(preset, GraphicsPreset.Matching(settings));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Ultra_is_not_recognised_with_one_of_its_values_moved()
|
||||||
|
{
|
||||||
|
var settings = Defaults().Copy();
|
||||||
|
GraphicsPreset.Ultra.ApplyTo(settings);
|
||||||
|
Assert.Same(GraphicsPreset.Ultra, GraphicsPreset.Matching(settings));
|
||||||
|
|
||||||
|
settings.Extra[GraphicsPreset.ReflectedVehiclesKey] = "250";
|
||||||
|
|
||||||
|
Assert.Null(GraphicsPreset.Matching(settings));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void No_level_asks_for_a_range_the_renderer_would_raise_anyway()
|
||||||
|
{
|
||||||
|
Assert.All(GraphicsPreset.All, preset => Assert.True(preset.DrawRange >= 2000, preset.NameKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_view_only_shortens_going_down_the_levels()
|
||||||
|
{
|
||||||
|
var factors = GraphicsPreset.All.Select(preset => preset.DistanceFactor).ToList();
|
||||||
|
|
||||||
|
Assert.Equal(factors.OrderBy(factor => factor), factors);
|
||||||
|
Assert.Equal(1, GraphicsPreset.High.DistanceFactor);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Shadows_reach_further_with_every_level_that_has_them()
|
||||||
|
{
|
||||||
|
static double Reach(GraphicsPreset preset) =>
|
||||||
|
SimulatorSettings.ShadowReach(preset.ShadowRange, preset.ShadowMap, legacyRenderer: false);
|
||||||
|
|
||||||
|
var reaches = GraphicsPreset.All.Where(preset => preset.Shadows).Select(Reach).ToList();
|
||||||
|
|
||||||
|
Assert.Equal(reaches.Distinct().OrderBy(reach => reach), reaches);
|
||||||
|
Assert.Equal(250, Reach(GraphicsPreset.High));
|
||||||
|
Assert.Equal(800, Reach(GraphicsPreset.VeryHigh));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Without_extras_the_settings_are_what_the_simulator_goes_by()
|
||||||
|
{
|
||||||
|
Assert.Equal(GraphicsExtras.None, GraphicsExtras.Of(Defaults()));
|
||||||
|
Assert.Equal(GraphicsTop.None, GraphicsTop.Of(Defaults()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_level_leaves_what_only_the_lowest_one_sets()
|
||||||
|
{
|
||||||
|
var settings = Defaults().Copy();
|
||||||
|
settings.MotionBlur = false;
|
||||||
|
|
||||||
|
GraphicsPreset.Low.ApplyTo(settings);
|
||||||
|
|
||||||
|
Assert.False(settings.MotionBlur);
|
||||||
|
Assert.Same(GraphicsPreset.Low, GraphicsPreset.Matching(settings));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Every_level_sets_the_buffer_and_is_not_recognised_with_another()
|
||||||
|
{
|
||||||
|
foreach (var preset in GraphicsPreset.All)
|
||||||
|
{
|
||||||
|
var settings = Defaults().Copy();
|
||||||
|
settings.BufferScalePercent = 120;
|
||||||
|
|
||||||
|
preset.ApplyTo(settings);
|
||||||
|
Assert.Equal(preset.BufferScale, settings.BufferScalePercent);
|
||||||
|
|
||||||
|
settings.BufferScalePercent = 120;
|
||||||
|
Assert.Null(GraphicsPreset.Matching(settings));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_buffer_is_the_screen_at_high_and_climbs_through_it()
|
||||||
|
{
|
||||||
|
Assert.Equal(100, GraphicsPreset.High.BufferScale);
|
||||||
|
Assert.Equal(200, GraphicsPreset.Ultra.BufferScale);
|
||||||
|
Assert.InRange(GraphicsPreset.Minimal.BufferScale, 50, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_lowest_level_is_not_recognised_with_one_of_its_extras_undone()
|
||||||
|
{
|
||||||
|
var settings = Defaults().Copy();
|
||||||
|
GraphicsPreset.Minimal.ApplyTo(settings);
|
||||||
|
|
||||||
|
settings.Smoke = true;
|
||||||
|
|
||||||
|
Assert.Null(GraphicsPreset.Matching(settings));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_extras_sit_on_the_steps_the_screen_offers()
|
||||||
|
{
|
||||||
|
foreach (var extras in new[] { GraphicsPreset.Minimal.Extras!, GraphicsPreset.Ultra.Extras! })
|
||||||
|
Assert.Contains(extras.PythonUpdateMs, new[] { 200, 400, 800, 1600 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Leaving an end of the scale ──────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_level_between_the_ends_chooses_only_its_own_keys()
|
||||||
|
{
|
||||||
|
var settings = Defaults().Copy();
|
||||||
|
settings.MotionBlur = false;
|
||||||
|
settings.Extra[GraphicsTop.TargetFpsKey] = "60";
|
||||||
|
|
||||||
|
var applied = new GraphicsPresetMemory().Apply(GraphicsPreset.Medium, settings);
|
||||||
|
|
||||||
|
Assert.Same(GraphicsPreset.Medium, GraphicsPreset.Matching(settings));
|
||||||
|
Assert.False(settings.MotionBlur);
|
||||||
|
Assert.Equal("60", settings.Extra[GraphicsTop.TargetFpsKey]);
|
||||||
|
Assert.Equal(GraphicsPreset.RowKeys, applied.RowKeys);
|
||||||
|
Assert.Equal(GraphicsPreset.Keys, applied.ChosenKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Leaving_the_lowest_level_brings_back_what_was_there_before_it()
|
||||||
|
{
|
||||||
|
var memory = new GraphicsPresetMemory();
|
||||||
|
var settings = Defaults().Copy();
|
||||||
|
settings.MotionBlur = false;
|
||||||
|
var before = GraphicsExtras.Of(settings);
|
||||||
|
|
||||||
|
var entering = memory.Apply(GraphicsPreset.Minimal, settings);
|
||||||
|
Assert.Equal(GraphicsPreset.Minimal.Extras, GraphicsExtras.Of(settings));
|
||||||
|
Assert.Equal(GraphicsPreset.RowKeys, entering.RowKeys);
|
||||||
|
Assert.Equal(GraphicsPreset.Keys.Concat(GraphicsExtras.Keys), entering.ChosenKeys);
|
||||||
|
|
||||||
|
var leaving = memory.Apply(GraphicsPreset.Medium, settings);
|
||||||
|
|
||||||
|
Assert.Equal(before, GraphicsExtras.Of(settings));
|
||||||
|
Assert.Same(GraphicsPreset.Medium, GraphicsPreset.Matching(settings));
|
||||||
|
Assert.Equal(GraphicsPreset.RowKeys, leaving.RowKeys);
|
||||||
|
Assert.Equal(GraphicsPreset.Keys.Concat(GraphicsExtras.Keys), leaving.ChosenKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Leaving_ultra_brings_back_what_was_there_before_it()
|
||||||
|
{
|
||||||
|
var memory = new GraphicsPresetMemory();
|
||||||
|
var settings = Defaults().Copy();
|
||||||
|
settings.MotionBlur = false;
|
||||||
|
settings.ShadowAngleLimit = 0.8;
|
||||||
|
settings.Extra[GraphicsTop.TargetFpsKey] = "60";
|
||||||
|
var extras = GraphicsExtras.Of(settings);
|
||||||
|
var top = GraphicsTop.Of(settings);
|
||||||
|
|
||||||
|
memory.Apply(GraphicsPreset.Ultra, settings);
|
||||||
|
var applied = memory.Apply(GraphicsPreset.VeryHigh, settings);
|
||||||
|
|
||||||
|
Assert.Equal(extras, GraphicsExtras.Of(settings));
|
||||||
|
Assert.Equal(top, GraphicsTop.Of(settings));
|
||||||
|
Assert.Same(GraphicsPreset.VeryHigh, GraphicsPreset.Matching(settings));
|
||||||
|
Assert.Equal(GraphicsPreset.RowKeys.Concat(GraphicsTop.RowKeys), applied.RowKeys);
|
||||||
|
Assert.Equal(GraphicsPreset.Keys.Concat(GraphicsExtras.Keys).Concat(GraphicsTop.Keys), applied.ChosenKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Leaving_an_end_chosen_before_this_run_gives_the_simulators_own_values()
|
||||||
|
{
|
||||||
|
var settings = Defaults().Copy();
|
||||||
|
GraphicsPreset.Ultra.ApplyTo(settings);
|
||||||
|
|
||||||
|
var applied = new GraphicsPresetMemory().Apply(GraphicsPreset.Low, settings);
|
||||||
|
|
||||||
|
Assert.Equal(GraphicsExtras.None, GraphicsExtras.Of(settings));
|
||||||
|
Assert.Equal(GraphicsTop.None, GraphicsTop.Of(settings));
|
||||||
|
Assert.Same(GraphicsPreset.Low, GraphicsPreset.Matching(settings));
|
||||||
|
Assert.Equal(GraphicsPreset.RowKeys.Concat(GraphicsTop.RowKeys), applied.RowKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void One_end_to_the_other_keeps_what_was_there_before_either()
|
||||||
|
{
|
||||||
|
var memory = new GraphicsPresetMemory();
|
||||||
|
var settings = Defaults().Copy();
|
||||||
|
settings.MotionBlur = false;
|
||||||
|
var extras = GraphicsExtras.Of(settings);
|
||||||
|
var top = GraphicsTop.Of(settings);
|
||||||
|
|
||||||
|
memory.Apply(GraphicsPreset.Minimal, settings);
|
||||||
|
var toUltra = memory.Apply(GraphicsPreset.Ultra, settings);
|
||||||
|
Assert.Same(GraphicsPreset.Ultra, GraphicsPreset.Matching(settings));
|
||||||
|
Assert.Equal(GraphicsPreset.RowKeys.Concat(GraphicsTop.RowKeys), toUltra.RowKeys);
|
||||||
|
|
||||||
|
var toMinimal = memory.Apply(GraphicsPreset.Minimal, settings);
|
||||||
|
Assert.Same(GraphicsPreset.Minimal, GraphicsPreset.Matching(settings));
|
||||||
|
// Ultra's own values go, Minimal has no say over them.
|
||||||
|
Assert.Equal(top, GraphicsTop.Of(settings));
|
||||||
|
Assert.Equal(GraphicsPreset.RowKeys.Concat(GraphicsTop.RowKeys), toMinimal.RowKeys);
|
||||||
|
|
||||||
|
memory.Apply(GraphicsPreset.Medium, settings);
|
||||||
|
Assert.Equal(extras, GraphicsExtras.Of(settings));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void What_was_remembered_comes_back_only_once()
|
||||||
|
{
|
||||||
|
var memory = new GraphicsPresetMemory();
|
||||||
|
var settings = Defaults().Copy();
|
||||||
|
settings.MotionBlur = false;
|
||||||
|
|
||||||
|
memory.Apply(GraphicsPreset.Minimal, settings);
|
||||||
|
memory.Apply(GraphicsPreset.Medium, settings);
|
||||||
|
Assert.False(settings.MotionBlur);
|
||||||
|
|
||||||
|
// Moved since, and no end left behind this time: it stays as moved.
|
||||||
|
settings.MotionBlur = true;
|
||||||
|
var applied = memory.Apply(GraphicsPreset.High, settings);
|
||||||
|
|
||||||
|
Assert.True(settings.MotionBlur);
|
||||||
|
Assert.Equal(GraphicsPreset.RowKeys, applied.RowKeys);
|
||||||
|
Assert.Equal(GraphicsPreset.Keys, applied.ChosenKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_level_applied_twice_at_an_end_remembers_what_came_before_the_first()
|
||||||
|
{
|
||||||
|
var memory = new GraphicsPresetMemory();
|
||||||
|
var settings = Defaults().Copy();
|
||||||
|
settings.MotionBlur = false;
|
||||||
|
var before = GraphicsExtras.Of(settings);
|
||||||
|
|
||||||
|
memory.Apply(GraphicsPreset.Minimal, settings);
|
||||||
|
memory.Apply(GraphicsPreset.Minimal, settings);
|
||||||
|
memory.Apply(GraphicsPreset.Low, settings);
|
||||||
|
|
||||||
|
Assert.Equal(before, GraphicsExtras.Of(settings));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_level_applied_is_recognised_until_one_value_moves()
|
||||||
|
{
|
||||||
|
foreach (var preset in GraphicsPreset.All)
|
||||||
|
{
|
||||||
|
var settings = Defaults().Copy();
|
||||||
|
preset.ApplyTo(settings);
|
||||||
|
Assert.Same(preset, GraphicsPreset.Matching(settings));
|
||||||
|
|
||||||
|
settings.DynamicLights = 6;
|
||||||
|
Assert.Null(GraphicsPreset.Matching(settings));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Every_level_sits_on_the_steps_the_screen_offers()
|
||||||
|
{
|
||||||
|
void OnSteps(double value, string name)
|
||||||
|
{
|
||||||
|
var key = SimulatorKeys.Named(name);
|
||||||
|
Assert.InRange(value, key.Minimum, key.Maximum);
|
||||||
|
double steps = value / key.Step;
|
||||||
|
Assert.Equal(Math.Round(steps), steps, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var preset in GraphicsPreset.All)
|
||||||
|
{
|
||||||
|
OnSteps(preset.DistanceFactor, GraphicsPreset.DistanceFactorKey);
|
||||||
|
OnSteps(preset.DrawRange, GraphicsPreset.DrawRangeKey);
|
||||||
|
OnSteps(preset.SleeperDistance, GraphicsPreset.SleeperKey);
|
||||||
|
OnSteps(preset.ReflectedInstances, GraphicsPreset.ReflectedInstancesKey);
|
||||||
|
OnSteps(preset.ReflectedVehicles, GraphicsPreset.ReflectedVehiclesKey);
|
||||||
|
Assert.True(Steps.IndexOf(Steps.Anisotropy, preset.Anisotropy) >= 0, preset.NameKey);
|
||||||
|
Assert.True(Steps.IndexOf(Steps.MultisamplingLevels, preset.Multisampling) >= 0, preset.NameKey);
|
||||||
|
Assert.True(Steps.IndexOf(Steps.ReflectionUpdates, preset.ReflectionsRate) >= 0, preset.NameKey);
|
||||||
|
Assert.True(Steps.IndexOf(Steps.DrawRangeFactors, preset.DrawRangeFactor) >= 0, preset.NameKey);
|
||||||
|
foreach (int size in new[] { preset.TextureSize, preset.CabTextureSize, preset.ShadowMap })
|
||||||
|
Assert.True(size is >= 512 and <= 8192 && (size & (size - 1)) == 0, preset.NameKey);
|
||||||
|
Assert.True(preset.ShadowRange >= SimulatorSettings.ShadowRangeFloor(preset.ShadowMap), preset.NameKey);
|
||||||
|
Assert.Equal(0, preset.CabShadowsRange % 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── A level for one part ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static GraphicsGroupLevels Groups() => new(new TestInstallation().SettingsStore);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Every_key_a_level_sets_but_the_buffer_belongs_to_one_part()
|
||||||
|
{
|
||||||
|
var inGroups = GraphicsGroup.All.SelectMany(group => group.Keys).ToList();
|
||||||
|
|
||||||
|
Assert.Equal(inGroups.Distinct(), inGroups);
|
||||||
|
Assert.Empty(inGroups.Except(GraphicsPreset.Keys).Except(GraphicsExtras.Keys).Except(GraphicsTop.Keys));
|
||||||
|
Assert.Equal(new[] { "starter.bufferscale" }, GraphicsPreset.Keys.Except(inGroups));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void One_part_moves_alone()
|
||||||
|
{
|
||||||
|
var groups = Groups();
|
||||||
|
var settings = AtHigh();
|
||||||
|
|
||||||
|
var moved = groups.Apply(GraphicsGroup.Shadows, GraphicsPreset.Low, settings);
|
||||||
|
|
||||||
|
Assert.Equal(GraphicsPreset.Low.ShadowMap, moved.ShadowMapResolution);
|
||||||
|
Assert.Equal(GraphicsPreset.Low.ShadowRange, moved.ShadowProjectionRange);
|
||||||
|
Assert.Equal(GraphicsPreset.Low.ShadowRank, moved.ShadowRankCutoff);
|
||||||
|
Assert.Equal(settings.MaxTextureSize, moved.MaxTextureSize);
|
||||||
|
Assert.Equal(settings.DynamicLights, moved.DynamicLights);
|
||||||
|
Assert.Equal(settings.BufferScalePercent, moved.BufferScalePercent);
|
||||||
|
|
||||||
|
var levels = groups.LevelsOf(moved);
|
||||||
|
Assert.Same(GraphicsPreset.Low, levels[GraphicsGroup.Shadows]);
|
||||||
|
Assert.Same(GraphicsPreset.High, levels[GraphicsGroup.Textures]);
|
||||||
|
Assert.Null(GraphicsPreset.Matching(moved));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Every_part_shows_ultra_when_the_whole_is_at_ultra()
|
||||||
|
{
|
||||||
|
var settings = Defaults().Copy();
|
||||||
|
GraphicsPreset.Ultra.ApplyTo(settings);
|
||||||
|
|
||||||
|
Assert.All(Groups().LevelsOf(settings).Values, level => Assert.Same(GraphicsPreset.Ultra, level));
|
||||||
|
|
||||||
|
// The row of a key at the simulator's own value leaves it out of the file.
|
||||||
|
settings.Extra.Remove(GraphicsTop.TextureReleaseKey);
|
||||||
|
|
||||||
|
Assert.Same(GraphicsPreset.Ultra, Groups().LevelsOf(settings)[GraphicsGroup.Textures]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_part_moved_by_hand_is_at_no_level_and_the_others_keep_theirs()
|
||||||
|
{
|
||||||
|
var settings = AtHigh();
|
||||||
|
settings.DynamicLights = 6;
|
||||||
|
|
||||||
|
var levels = Groups().LevelsOf(settings);
|
||||||
|
|
||||||
|
Assert.Null(levels[GraphicsGroup.Effects]);
|
||||||
|
Assert.Same(GraphicsPreset.High, levels[GraphicsGroup.Shadows]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Every_part_at_a_level_and_the_buffer_with_it_is_that_level()
|
||||||
|
{
|
||||||
|
var groups = Groups();
|
||||||
|
foreach (var level in GraphicsPreset.All.Where(level => level.Extras is null))
|
||||||
|
{
|
||||||
|
var settings = Defaults().Copy();
|
||||||
|
foreach (var group in GraphicsGroup.All)
|
||||||
|
settings = groups.Apply(group, level, settings);
|
||||||
|
settings.BufferScalePercent = level.BufferScale;
|
||||||
|
|
||||||
|
Assert.Same(level, GraphicsPreset.Matching(settings));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Every_part_tells_every_level_from_the_others()
|
||||||
|
{
|
||||||
|
var groups = Groups();
|
||||||
|
foreach (var group in GraphicsGroup.All)
|
||||||
|
foreach (var level in GraphicsPreset.All)
|
||||||
|
Assert.Same(level, groups.LevelsOf(groups.Apply(group, level, Defaults()))[group]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Leaving_an_end_in_a_part_gives_the_simulators_own_for_what_only_the_end_sets()
|
||||||
|
{
|
||||||
|
var groups = Groups();
|
||||||
|
var settings = AtHigh();
|
||||||
|
|
||||||
|
var ultra = groups.Apply(GraphicsGroup.Textures, GraphicsPreset.Ultra, settings);
|
||||||
|
Assert.False(ultra.CompressTextures);
|
||||||
|
Assert.Same(GraphicsPreset.Ultra, groups.LevelsOf(ultra)[GraphicsGroup.Textures]);
|
||||||
|
|
||||||
|
var back = groups.Apply(GraphicsGroup.Textures, GraphicsPreset.High, ultra);
|
||||||
|
Assert.Equal(GraphicsTop.None.CompressTextures, back.CompressTextures);
|
||||||
|
Assert.Same(GraphicsPreset.High, groups.LevelsOf(back)[GraphicsGroup.Textures]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_level_in_a_part_between_the_ends_leaves_what_only_an_end_sets()
|
||||||
|
{
|
||||||
|
var settings = AtHigh();
|
||||||
|
settings.MotionBlur = false;
|
||||||
|
|
||||||
|
var moved = Groups().Apply(GraphicsGroup.Effects, GraphicsPreset.VeryHigh, settings);
|
||||||
|
|
||||||
|
Assert.False(moved.MotionBlur);
|
||||||
|
Assert.Equal(GraphicsPreset.VeryHigh.SmokeFidelity, moved.SmokeFidelity);
|
||||||
|
}
|
||||||
|
}
|
||||||
168
StarterNG.Tests/HaslerTapeTests.cs
Normal file
168
StarterNG.Tests/HaslerTapeTests.cs
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using StarterNG.Domain.Tapes;
|
||||||
|
using StarterNG.Tests.Fakes;
|
||||||
|
|
||||||
|
namespace StarterNG.Tests;
|
||||||
|
|
||||||
|
public class HaslerTapeTests
|
||||||
|
{
|
||||||
|
private const string Header =
|
||||||
|
"Time[s] Velocity[m/s] Acceleration[m/ss] Coupler.Dist[m] Coupler.Force[N] TractionForce[kN] FrictionForce[kN] " +
|
||||||
|
"BrakeForce[kN] BrakePress[MPa] PipePress[MPa] MotorCurrent[A] MCP SCP BCP LBP Direction Command CVal1 CVal2 " +
|
||||||
|
"Security Wheelslip EngineTemp[Deg] OilTemp[Deg] WaterTemp[Deg] WaterAuxTemp[Deg]\r\n";
|
||||||
|
|
||||||
|
private static string Line(double t, double v, double brake, double current, string command = "none",
|
||||||
|
int security = 0) =>
|
||||||
|
string.Create(CultureInfo.InvariantCulture,
|
||||||
|
$"{t:0.0000} {v:0.0000} 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 {brake:0.0000} 0.5000 {current:0.0000} ") +
|
||||||
|
$"3 0 0.0000 0.0000 1 {command} 0.0000 0.0000 {security} 0 0.0000 0.0000 0.0000 0.0000\r\n";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_physics_log_reads_as_speed_distance_and_the_two_hasler_marks()
|
||||||
|
{
|
||||||
|
var tape = HaslerTape.Parse(Header
|
||||||
|
+ Line(10, 0, 0.45, 0)
|
||||||
|
+ Line(20, 36, 0, 400)
|
||||||
|
+ Line(30, 72, 0, 0, security: 1));
|
||||||
|
|
||||||
|
Assert.Equal(3, tape.Samples.Count);
|
||||||
|
Assert.Equal(72, tape.MaxSpeedKmh, 6);
|
||||||
|
Assert.Equal(20, tape.Duration, 6);
|
||||||
|
Assert.Equal(50 + 150, tape.DistanceM, 6); // trapezoids: 0->36 km/h and 36->72 km/h over 10 s each
|
||||||
|
Assert.True(tape.Samples[0].Braking);
|
||||||
|
Assert.True(tape.Samples[1].Current);
|
||||||
|
Assert.True(tape.Samples[2].SecurityAlarm);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Standing_and_running_time_split_the_recording_like_the_old_analyser()
|
||||||
|
{
|
||||||
|
var tape = HaslerTape.Parse(Header + Line(0, 0, 0.45, 0) + Line(60, 0, 0.45, 0) + Line(120, 36, 0, 400)
|
||||||
|
+ Line(180, 36, 0, 0));
|
||||||
|
|
||||||
|
Assert.Equal(60, tape.StandingTime, 6);
|
||||||
|
Assert.Equal(120, tape.RunningTime, 6);
|
||||||
|
Assert.Equal(900 / 180.0 * 3.6, tape.AverageWithStopsKmh, 6); // 300 m ramping up to 36 km/h, 600 m at it
|
||||||
|
Assert.Equal(900 / 120.0 * 3.6, tape.AverageSpeedKmh, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_line_from_the_simulator_reads_in_kmh_and_bar_whatever_its_header_says()
|
||||||
|
{
|
||||||
|
var tape = HaslerTape.Parse(Header +
|
||||||
|
"107.5512 38.6099 -0.0201 -0.0002 917.2355 -0.0000 436.4420 0.0000 0.0021 4.9981 0.0000 0 0 0.0000 " +
|
||||||
|
"0.0000 1 none 0.0000 0.0000 0 0 50.0000 45.0000 50.0000 40.0000\r\n");
|
||||||
|
|
||||||
|
var s = Assert.Single(tape.Samples);
|
||||||
|
Assert.Equal(38.6099, s.SpeedKmh, 6);
|
||||||
|
Assert.Equal(4.9981, s.PipePressBar, 6);
|
||||||
|
Assert.False(s.Braking);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_line_still_being_written_or_a_command_with_spaces_does_not_break_the_tape()
|
||||||
|
{
|
||||||
|
var tape = HaslerTape.Parse(Header + Line(0, 18, 0, 0, command: "Set Velocity") + Line(1, 18, 0, 0) + "2.0500 5.0");
|
||||||
|
|
||||||
|
Assert.Equal(2, tape.Samples.Count);
|
||||||
|
Assert.Equal(5, tape.DistanceM, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_sample_under_a_point_of_the_tape_is_found_by_time_and_by_distance()
|
||||||
|
{
|
||||||
|
var tape = HaslerTape.Parse(Header + Line(0, 36, 0, 0) + Line(1, 36, 0, 0) + Line(2, 36, 0, 0));
|
||||||
|
|
||||||
|
Assert.Equal(1, tape.IndexAt(1.5, byDistance: false));
|
||||||
|
Assert.Equal(2, tape.IndexAt(25, byDistance: true));
|
||||||
|
Assert.Equal(0, tape.IndexAt(-3, byDistance: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0, 90)]
|
||||||
|
[InlineData(90, 90)]
|
||||||
|
[InlineData(90.5, 100)]
|
||||||
|
[InlineData(120, 120)]
|
||||||
|
[InlineData(151, 180)]
|
||||||
|
[InlineData(180, 180)]
|
||||||
|
[InlineData(181, 200)]
|
||||||
|
[InlineData(205, 220)]
|
||||||
|
public void The_scale_tops_out_at_the_speedometers_range_then_at_the_next_twenty(double max, double top) =>
|
||||||
|
Assert.Equal(top, TapeFigures.TopSpeed(max));
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(1, 10)]
|
||||||
|
[InlineData(0.8, 10)]
|
||||||
|
[InlineData(0.79, 20)]
|
||||||
|
[InlineData(0.4, 20)]
|
||||||
|
[InlineData(0.16, 50)]
|
||||||
|
[InlineData(0.08, 100)]
|
||||||
|
[InlineData(0.04, 200)]
|
||||||
|
[InlineData(0.039, 500)]
|
||||||
|
public void Speed_lines_take_the_first_step_at_least_8_pixels_apart(double pxPerKmh, int step) =>
|
||||||
|
Assert.Equal(step, TapeFigures.SpeedStep(pxPerKmh));
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(60, 1)]
|
||||||
|
[InlineData(59, 2)]
|
||||||
|
[InlineData(30, 2)]
|
||||||
|
[InlineData(12, 5)]
|
||||||
|
[InlineData(6, 10)]
|
||||||
|
[InlineData(4, 15)]
|
||||||
|
[InlineData(2, 30)]
|
||||||
|
[InlineData(1, 60)]
|
||||||
|
[InlineData(0.99, 120)]
|
||||||
|
public void Clock_lines_take_the_first_step_at_least_60_pixels_apart(double pxPerMinute, double step) =>
|
||||||
|
Assert.Equal(step, TapeFigures.MinuteStep(pxPerMinute));
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(600, 0.1)]
|
||||||
|
[InlineData(500, 0.1)]
|
||||||
|
[InlineData(499, 0.2)]
|
||||||
|
[InlineData(100, 0.5)]
|
||||||
|
[InlineData(50, 1)]
|
||||||
|
[InlineData(25, 2)]
|
||||||
|
[InlineData(10, 5)]
|
||||||
|
[InlineData(5, 10)]
|
||||||
|
[InlineData(2.5, 20)]
|
||||||
|
[InlineData(2, 50)]
|
||||||
|
public void Distance_marks_take_the_first_step_at_least_50_pixels_apart(double pxPerKm, double step) =>
|
||||||
|
Assert.Equal(step, TapeFigures.KmStep(pxPerKm));
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(-5, "0:00")]
|
||||||
|
[InlineData(0, "0:00")]
|
||||||
|
[InlineData(65, "1:05")]
|
||||||
|
[InlineData(3599, "59:59")]
|
||||||
|
[InlineData(3600, "1:00:00")]
|
||||||
|
[InlineData(3723, "1:02:03")]
|
||||||
|
[InlineData(90000, "25:00:00")]
|
||||||
|
public void A_length_of_time_prints_hours_only_when_it_has_them(double seconds, string text) =>
|
||||||
|
Assert.Equal(text, TapeFigures.Clock(seconds));
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("7:52", 7, 52, 0)]
|
||||||
|
[InlineData("07:52", 7, 52, 0)]
|
||||||
|
[InlineData(" 23:05 ", 23, 5, 0)]
|
||||||
|
[InlineData("7:52:30", 7, 52, 30)]
|
||||||
|
[InlineData("24:00", 0, 0, 0)]
|
||||||
|
[InlineData("noon", 0, 0, 0)]
|
||||||
|
[InlineData("", 0, 0, 0)]
|
||||||
|
[InlineData(null, 0, 0, 0)]
|
||||||
|
public void A_clock_typed_in_is_a_time_of_the_day_or_nothing(string? text, int hours, int minutes, int seconds) =>
|
||||||
|
Assert.Equal(new TimeSpan(hours, minutes, seconds), TapeFigures.ParseClock(text));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Tapes_are_listed_from_physicslog_newest_first()
|
||||||
|
{
|
||||||
|
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||||
|
.WithFile(TestInstallation.At("physicslog", "EU07-424.dat"), Header + Line(0, 1, 0, 0)));
|
||||||
|
var tapes = installation.Tapes;
|
||||||
|
|
||||||
|
var list = tapes.List();
|
||||||
|
|
||||||
|
Assert.Single(list);
|
||||||
|
Assert.Equal("EU07-424", list[0].Vehicle);
|
||||||
|
Assert.Single(tapes.Load(list[0])!.Samples);
|
||||||
|
}
|
||||||
|
}
|
||||||
119
StarterNG.Tests/KeyMapTests.cs
Normal file
119
StarterNG.Tests/KeyMapTests.cs
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
using StarterNG.Classes;
|
||||||
|
|
||||||
|
namespace StarterNG.Tests;
|
||||||
|
|
||||||
|
public class KeyMapTests
|
||||||
|
{
|
||||||
|
/// <summary>The names of the simulator's key table, keytonamemap in input/keyboardinput.cpp.</summary>
|
||||||
|
private static readonly HashSet<string> SimulatorKeyNames = new(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
"0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "-", "=",
|
||||||
|
"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m",
|
||||||
|
"n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z",
|
||||||
|
"backspace", "[", "]", "\\", ";", "'", "enter", ",", ".", "/", "space", "pause",
|
||||||
|
"insert", "delete", "home", "end",
|
||||||
|
"num_/", "num_*", "num_-", "num_+", "num_enter", "num_.",
|
||||||
|
"num_0", "num_1", "num_2", "num_3", "num_4", "num_5", "num_6", "num_7", "num_8", "num_9",
|
||||||
|
"f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9", "f10", "f11", "f12",
|
||||||
|
"tab", "esc", "left", "right", "up", "down", "page_up", "page_down"
|
||||||
|
};
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("tab", "KbUseMap")]
|
||||||
|
[InlineData("f1", "KbUseConsist")]
|
||||||
|
[InlineData("f2", "KbUseTimetable")]
|
||||||
|
[InlineData("f3", "KbUseScenario")]
|
||||||
|
[InlineData("f9", "KbUseLog")]
|
||||||
|
[InlineData("f10", "KbUseQuit")]
|
||||||
|
public void What_the_simulator_takes_first_blocks_only_without_a_modifier(string token, string text)
|
||||||
|
{
|
||||||
|
Assert.Equal(new SimulatorKeyUse(text, true), KeyMap.SimulatorUse(token, false, false));
|
||||||
|
Assert.Null(KeyMap.SimulatorUse(token, true, false));
|
||||||
|
Assert.Null(KeyMap.SimulatorUse(token, false, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Shift_F12_blocks_and_F12_alone_is_free()
|
||||||
|
{
|
||||||
|
Assert.Equal(new SimulatorKeyUse("KbUseDebugPanel", true), KeyMap.SimulatorUse("f12", true, false));
|
||||||
|
Assert.Null(KeyMap.SimulatorUse("f12", false, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("f4", false, false, "KbUseCab")]
|
||||||
|
[InlineData("f4", true, false, "KbUseCab")]
|
||||||
|
[InlineData("f5", false, true, "KbUseVehicle")]
|
||||||
|
[InlineData("f6", false, false, "KbUseDebug")]
|
||||||
|
[InlineData("f7", false, true, "KbUseDebug")]
|
||||||
|
[InlineData("f11", false, false, "KbUseEditor")]
|
||||||
|
[InlineData("3", true, false, "KbUseSceneryEvent")]
|
||||||
|
[InlineData("3", false, true, "KbUseCamera")]
|
||||||
|
public void What_is_left_to_a_key_a_binding_may_replace(string token, bool shift, bool ctrl, string text)
|
||||||
|
{
|
||||||
|
Assert.Equal(new SimulatorKeyUse(text, false), KeyMap.SimulatorUse(token, shift, ctrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("3", false, false)]
|
||||||
|
[InlineData("a", false, false)]
|
||||||
|
[InlineData("a", true, true)]
|
||||||
|
[InlineData("f7", true, false)]
|
||||||
|
[InlineData("f8", false, false)]
|
||||||
|
[InlineData("f11", true, false)]
|
||||||
|
[InlineData("num_5", true, false)]
|
||||||
|
[InlineData("enter", false, false)]
|
||||||
|
public void The_rest_the_simulator_leaves_alone(string token, bool shift, bool ctrl)
|
||||||
|
{
|
||||||
|
Assert.Null(KeyMap.SimulatorUse(token, shift, ctrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_bundled_keys_use_nothing_the_simulator_takes_first()
|
||||||
|
{
|
||||||
|
var config = new KeyboardConfig();
|
||||||
|
string path = Path.Combine(AppContext.BaseDirectory, "startercfg", "eu07_input-keyboard.ini");
|
||||||
|
config.Parse(File.ReadAllText(path));
|
||||||
|
|
||||||
|
var taken = config.Bindings
|
||||||
|
.Where(b => b.IsAssigned && KeyMap.SimulatorUse(b.Key, b.Shift, b.Ctrl) is { Blocks: true })
|
||||||
|
.Select(b => b.Command)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
Assert.Empty(taken);
|
||||||
|
Assert.Contains(config.Bindings, b => b.IsAssigned);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Every_key_drawn_is_one_the_simulator_knows()
|
||||||
|
{
|
||||||
|
var drawn = KeyMap.MainBlock.Concat(KeyMap.NumpadBlock).Concat(KeyMap.NavBlock)
|
||||||
|
.SelectMany(row => row)
|
||||||
|
.Select(cap => cap.Token)
|
||||||
|
.OfType<string>();
|
||||||
|
|
||||||
|
Assert.Empty(drawn.Where(token => !SimulatorKeyNames.Contains(token)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("page_up", "Page Up")]
|
||||||
|
[InlineData("page_down", "Page Down")]
|
||||||
|
[InlineData("esc", "Esc")]
|
||||||
|
[InlineData("num_enter", "Num Enter")]
|
||||||
|
public void Key_names_read_as_on_a_keyboard(string token, string expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, KeyMap.DisplayName(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("a", false, false, "A")]
|
||||||
|
[InlineData("a", true, false, "Shift + A")]
|
||||||
|
[InlineData("a", false, true, "Ctrl + A")]
|
||||||
|
[InlineData("num_enter", true, true, "Ctrl + Shift + Num Enter")]
|
||||||
|
[InlineData("none", true, true, "—")]
|
||||||
|
[InlineData("NONE", false, true, "—")]
|
||||||
|
[InlineData("", true, false, "—")]
|
||||||
|
public void A_combination_reads_with_its_modifiers_unless_there_is_no_key(string token, bool shift, bool ctrl, string expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, KeyMap.ComboText(token, shift, ctrl));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,4 +7,151 @@ public class KeyboardConfigTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void A_description_without_polish_letters_matches_the_bundled_one() =>
|
public void A_description_without_polish_letters_matches_the_bundled_one() =>
|
||||||
Assert.Equal("zwiekszenie nastawnika glownego", KeyboardConfig.StripPolish("zwiększenie nastawnika głównego"));
|
Assert.Equal("zwiekszenie nastawnika glownego", KeyboardConfig.StripPolish("zwiększenie nastawnika głównego"));
|
||||||
|
|
||||||
|
private static KeyBinding Parse(string line)
|
||||||
|
{
|
||||||
|
var config = new KeyboardConfig();
|
||||||
|
config.Parse(line);
|
||||||
|
return Assert.Single(config.Bindings);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_binding_without_a_description_shows_the_translated_one()
|
||||||
|
{
|
||||||
|
var binding = Parse("hornhighactivate a");
|
||||||
|
|
||||||
|
string text = new KeyboardConfig().DescriptionOf(binding, _ => "high tone horn");
|
||||||
|
|
||||||
|
Assert.Equal("high tone horn", text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_description_someone_wrote_wins_over_the_translation()
|
||||||
|
{
|
||||||
|
var binding = Parse("hornhighactivate a // my own horn");
|
||||||
|
|
||||||
|
string text = new KeyboardConfig().DescriptionOf(binding, _ => "high tone horn");
|
||||||
|
|
||||||
|
Assert.Equal("my own horn", text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Without_a_translation_the_file_description_stays()
|
||||||
|
{
|
||||||
|
var binding = Parse("hornhighactivate a // syrena wysoka");
|
||||||
|
|
||||||
|
Assert.Equal("syrena wysoka", new KeyboardConfig().DescriptionOf(binding, _ => string.Empty));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Which binding sits where ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static KeyboardConfig Config(params string[] lines)
|
||||||
|
{
|
||||||
|
var config = new KeyboardConfig();
|
||||||
|
config.Parse(string.Join("\n", lines));
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_cleared_binding_is_on_no_key_and_no_modifier()
|
||||||
|
{
|
||||||
|
var binding = Parse("hornhighactivate shift ctrl a");
|
||||||
|
|
||||||
|
binding.Clear();
|
||||||
|
|
||||||
|
Assert.False(binding.IsAssigned);
|
||||||
|
Assert.False(binding.Shift);
|
||||||
|
Assert.False(binding.Ctrl);
|
||||||
|
Assert.Equal("none", binding.Key);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("a", true, false, true)]
|
||||||
|
[InlineData("A", true, false, true)]
|
||||||
|
[InlineData("a", false, false, false)]
|
||||||
|
[InlineData("a", true, true, false)]
|
||||||
|
[InlineData("b", true, false, false)]
|
||||||
|
public void A_binding_is_on_its_key_with_exactly_its_modifiers(string token, bool shift, bool ctrl, bool on) =>
|
||||||
|
Assert.Equal(on, Parse("hornhighactivate shift a").IsOn(token, shift, ctrl));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void An_unassigned_binding_is_on_nothing_not_even_the_word_none() =>
|
||||||
|
Assert.False(Parse("hornhighactivate none").IsOn("none", false, false));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_binding_at_a_combination_is_found_by_key_and_modifiers()
|
||||||
|
{
|
||||||
|
var config = Config("plain a", "shifted shift a");
|
||||||
|
|
||||||
|
Assert.Equal("plain", config.BindingAt("A", false, false)?.Command);
|
||||||
|
Assert.Equal("shifted", config.BindingAt("a", true, false)?.Command);
|
||||||
|
Assert.Null(config.BindingAt("a", false, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_combination_put_on_a_binding_moves_there_from_the_one_that_had_it()
|
||||||
|
{
|
||||||
|
var config = Config("first ctrl a", "second b", "third a");
|
||||||
|
|
||||||
|
config.Put(config.Bindings[1], "a", shift: false, ctrl: true);
|
||||||
|
|
||||||
|
Assert.False(config.Bindings[0].IsAssigned);
|
||||||
|
Assert.False(config.Bindings[0].Ctrl);
|
||||||
|
Assert.True(config.Bindings[1].IsOn("a", false, true));
|
||||||
|
// The same key with other modifiers is another combination.
|
||||||
|
Assert.True(config.Bindings[2].IsOn("a", false, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Every_binding_doubled_up_on_a_combination_leaves_it_but_the_one_put_there()
|
||||||
|
{
|
||||||
|
var config = Config("first a", "second a", "third a");
|
||||||
|
|
||||||
|
config.Put(config.Bindings[1], "a", shift: false, ctrl: false);
|
||||||
|
|
||||||
|
Assert.False(config.Bindings[0].IsAssigned);
|
||||||
|
Assert.True(config.Bindings[1].IsOn("a", false, false));
|
||||||
|
Assert.False(config.Bindings[2].IsAssigned);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Nothing_put_on_a_combination_frees_it()
|
||||||
|
{
|
||||||
|
var config = Config("first shift a", "second b");
|
||||||
|
|
||||||
|
config.Put(null, "a", shift: true, ctrl: false);
|
||||||
|
|
||||||
|
Assert.False(config.Bindings[0].IsAssigned);
|
||||||
|
Assert.True(config.Bindings[1].IsOn("b", false, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_combination_put_back_on_the_binding_that_has_it_stays()
|
||||||
|
{
|
||||||
|
var config = Config("first a");
|
||||||
|
|
||||||
|
config.Put(config.Bindings[0], "a", shift: false, ctrl: false);
|
||||||
|
|
||||||
|
Assert.True(config.Bindings[0].IsOn("a", false, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Two_bindings_on_a_combination_conflict_however_the_key_is_spelled()
|
||||||
|
{
|
||||||
|
var config = Config("first shift a", "second shift A", "third a", "fourth ctrl a", "fifth none", "sixth none");
|
||||||
|
|
||||||
|
var conflicts = config.ConflictingCombos();
|
||||||
|
|
||||||
|
Assert.Equal(config.Bindings[0].ComboKey, Assert.Single(conflicts));
|
||||||
|
Assert.Contains(config.Bindings[1].ComboKey, conflicts);
|
||||||
|
Assert.DoesNotContain(config.Bindings[2].ComboKey, conflicts);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Only_bindings_on_a_key_of_the_numpad_count_as_on_it()
|
||||||
|
{
|
||||||
|
var config = Config("first num_1", "second shift NUM_enter", "third a", "fourth none");
|
||||||
|
|
||||||
|
Assert.Equal(2, config.BindingsOnNumpad());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
237
StarterNG.Tests/LocalizationTests.cs
Normal file
237
StarterNG.Tests/LocalizationTests.cs
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
using System.Xml.Linq;
|
||||||
|
using StarterNG.Infrastructure.Adapters;
|
||||||
|
using StarterNG.Services;
|
||||||
|
using StarterNG.Tests.Fakes;
|
||||||
|
|
||||||
|
namespace StarterNG.Tests;
|
||||||
|
|
||||||
|
public class LocalizationTests
|
||||||
|
{
|
||||||
|
private static LocalizationService Build() =>
|
||||||
|
new(TestLog.Over());
|
||||||
|
|
||||||
|
private static Dictionary<string, string> Strings(string path) =>
|
||||||
|
XDocument.Load(path).Descendants("String")
|
||||||
|
.ToDictionary(s => (string)s.Attribute("key")!, s => s.Value);
|
||||||
|
|
||||||
|
public static TheoryData<string> Languages()
|
||||||
|
{
|
||||||
|
var data = new TheoryData<string>();
|
||||||
|
foreach (var lang in LocalizationService.AvailableLanguages())
|
||||||
|
if (lang.Code != "en")
|
||||||
|
data.Add(lang.Code);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(Languages))]
|
||||||
|
public void Every_language_has_every_key_but_the_optional_ones(string code)
|
||||||
|
{
|
||||||
|
var all = LocalizationService.AvailableLanguages();
|
||||||
|
var english = Strings(all.Single(l => l.Code == "en").Path);
|
||||||
|
var other = Strings(all.Single(l => l.Code == code).Path);
|
||||||
|
|
||||||
|
var missing = english.Keys
|
||||||
|
.Where(key => !key.EndsWith("Desc", StringComparison.Ordinal) &&
|
||||||
|
!key.StartsWith("Cmd.", StringComparison.Ordinal))
|
||||||
|
.Where(key => !other.ContainsKey(key));
|
||||||
|
|
||||||
|
Assert.Empty(missing);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_key_description_only_english_has_falls_back_to_english()
|
||||||
|
{
|
||||||
|
var loc = Build();
|
||||||
|
loc.Load("cz");
|
||||||
|
|
||||||
|
Assert.Equal(string.Empty, loc.Opt("Cmd.mastercontrollerincrease"));
|
||||||
|
Assert.Equal("master controller up", loc.OptOrEnglish("Cmd.mastercontrollerincrease"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_string_the_language_has_is_not_replaced_with_english()
|
||||||
|
{
|
||||||
|
var loc = Build();
|
||||||
|
loc.Load("pl");
|
||||||
|
|
||||||
|
Assert.Equal(loc["Language"], loc.OptOrEnglish("Language"));
|
||||||
|
Assert.NotEqual("Language", loc.OptOrEnglish("Language"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private const string English =
|
||||||
|
"<Language code=\"en\" name=\"English\"><String key=\"Hello\">Hello</String></Language>";
|
||||||
|
|
||||||
|
/// <summary>A file cut off right after its header: it still looks like a language.</summary>
|
||||||
|
private const string CutShort = "<Language code=\"pl\" name=\"Polski\">";
|
||||||
|
|
||||||
|
private static (LocalizationService Loc, FileDiagnosticsLog Log, string Directory) InFolderOf(
|
||||||
|
params (string File, string Text)[] languages)
|
||||||
|
{
|
||||||
|
string directory = Directory.CreateTempSubdirectory("starterng-lang-").FullName;
|
||||||
|
foreach (var (file, text) in languages)
|
||||||
|
File.WriteAllText(Path.Combine(directory, file), text);
|
||||||
|
var log = TestLog.Over();
|
||||||
|
return (new LocalizationService(log, directory), log, directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_translation_cut_short_counts_as_missing_and_is_logged()
|
||||||
|
{
|
||||||
|
var (loc, log, directory) = InFolderOf(("en.xml", English), ("pl.xml", CutShort));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.Equal("English", loc.Load("pl"));
|
||||||
|
|
||||||
|
Assert.Equal("en", loc.CurrentLangCode);
|
||||||
|
Assert.Equal("Hello", loc["Hello"]);
|
||||||
|
Assert.Contains("lang/pl.xml", log.Text);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Directory.Delete(directory, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void With_every_translation_cut_short_the_starter_still_starts()
|
||||||
|
{
|
||||||
|
var (loc, log, directory) = InFolderOf(("pl.xml", CutShort));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.Equal("English", loc.Load("pl"));
|
||||||
|
|
||||||
|
Assert.Equal("en", loc.CurrentLangCode);
|
||||||
|
Assert.Equal("Hello", loc["Hello"]);
|
||||||
|
Assert.Contains("lang/pl.xml", log.Text);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Directory.Delete(directory, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_language_cut_short_leaves_english_rather_than_the_one_in_use_before()
|
||||||
|
{
|
||||||
|
var (loc, _, directory) = InFolderOf(
|
||||||
|
("cz.xml", "<Language code=\"cz\" name=\"Cestina\"><String key=\"Hello\">Ahoj</String></Language>"),
|
||||||
|
("en.xml", English),
|
||||||
|
("pl.xml", CutShort));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
loc.Load("cz");
|
||||||
|
Assert.Equal("Ahoj", loc["Hello"]);
|
||||||
|
|
||||||
|
loc.Load("pl");
|
||||||
|
|
||||||
|
Assert.Equal("en", loc.CurrentLangCode);
|
||||||
|
Assert.Equal("Hello", loc["Hello"]);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Directory.Delete(directory, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A player's folder, the bundled one with English in it, the old starter's and the simulator's.</summary>
|
||||||
|
private sealed class Folders : IDisposable
|
||||||
|
{
|
||||||
|
public readonly string Root = Directory.CreateTempSubdirectory("starterng-langs-").FullName;
|
||||||
|
|
||||||
|
public string Own => Sub("own");
|
||||||
|
public string Bundled => Sub("bundled");
|
||||||
|
public string OldStarter => Sub("starter");
|
||||||
|
public string Simulator => Sub("lang");
|
||||||
|
|
||||||
|
private string Sub(string name) => Directory.CreateDirectory(Path.Combine(Root, name)).FullName;
|
||||||
|
|
||||||
|
public LocalizationService Service() =>
|
||||||
|
new(TestLog.Over(),
|
||||||
|
Bundled, new LanguageFolders(new[] { Own, Bundled }, OldStarter, Simulator));
|
||||||
|
|
||||||
|
public void Dispose() => Directory.Delete(Root, recursive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_players_own_translation_wins_over_the_bundled_one()
|
||||||
|
{
|
||||||
|
using var folders = new Folders();
|
||||||
|
File.WriteAllText(Path.Combine(folders.Bundled, "en.xml"), English);
|
||||||
|
File.WriteAllText(Path.Combine(folders.Bundled, "pl.xml"),
|
||||||
|
"<Language code=\"pl\" name=\"Polski\"><String key=\"Hello\">Czesc</String></Language>");
|
||||||
|
File.WriteAllText(Path.Combine(folders.Own, "moj.xml"),
|
||||||
|
"<Language code=\"pl\" name=\"Moj polski\"><String key=\"Hello\">Witaj</String></Language>");
|
||||||
|
var loc = folders.Service();
|
||||||
|
|
||||||
|
Assert.Equal("Moj polski", loc.Load("pl"));
|
||||||
|
Assert.Equal("Witaj", loc["Hello"]);
|
||||||
|
Assert.True(loc.HasOwnTexts);
|
||||||
|
Assert.Single(loc.Languages(), lang => lang.Code == "pl");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void An_old_starters_language_is_offered_in_english_with_its_key_descriptions()
|
||||||
|
{
|
||||||
|
using var folders = new Folders();
|
||||||
|
File.WriteAllText(Path.Combine(folders.Bundled, "en.xml"), English);
|
||||||
|
File.WriteAllText(Path.Combine(folders.OldStarter, "lang-ZH.txt"),
|
||||||
|
"[Main]\ncbLang.Items.Text=PL|EN\n[labels]\nx=y\n<eu07_input-keyboard.ini\nhornhighactivate=gwizd\n\n[frmOther]\nz=q\n");
|
||||||
|
var loc = folders.Service();
|
||||||
|
|
||||||
|
Assert.Contains(loc.Languages(), lang => lang.Code == "zh" && lang.Name == "ZH" && lang.Path.Length == 0);
|
||||||
|
|
||||||
|
loc.Load("zh");
|
||||||
|
|
||||||
|
Assert.Equal("zh", loc.CurrentLangCode);
|
||||||
|
Assert.False(loc.HasOwnTexts);
|
||||||
|
Assert.Equal("Hello", loc["Hello"]);
|
||||||
|
Assert.Equal("gwizd", loc.Opt("Cmd.hornhighactivate"));
|
||||||
|
Assert.Equal(string.Empty, loc.Opt("Cmd.z"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Of_the_simulators_files_only_a_language_code_counts()
|
||||||
|
{
|
||||||
|
using var folders = new Folders();
|
||||||
|
File.WriteAllText(Path.Combine(folders.Bundled, "en.xml"), English);
|
||||||
|
foreach (string file in new[] { "hu.po", "editor_pl.po", "template.po" })
|
||||||
|
File.WriteAllText(Path.Combine(folders.Simulator, file), string.Empty);
|
||||||
|
|
||||||
|
var codes = folders.Service().Languages().Select(lang => lang.Code).ToList();
|
||||||
|
|
||||||
|
Assert.Contains("hu", codes);
|
||||||
|
Assert.DoesNotContain("editor_pl", codes);
|
||||||
|
Assert.DoesNotContain("template", codes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_code_eu07_ini_names_stays_on_the_list_once()
|
||||||
|
{
|
||||||
|
using var folders = new Folders();
|
||||||
|
File.WriteAllText(Path.Combine(folders.Bundled, "en.xml"), English);
|
||||||
|
var loc = folders.Service();
|
||||||
|
|
||||||
|
loc.Keep("sk");
|
||||||
|
loc.Keep("SK");
|
||||||
|
loc.Keep("en");
|
||||||
|
|
||||||
|
Assert.Single(loc.Languages(), lang => lang.Code == "sk");
|
||||||
|
Assert.Equal(2, loc.Languages().Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_key_a_translation_lacks_comes_in_english()
|
||||||
|
{
|
||||||
|
using var folders = new Folders();
|
||||||
|
File.WriteAllText(Path.Combine(folders.Bundled, "en.xml"), English);
|
||||||
|
File.WriteAllText(Path.Combine(folders.Own, "sk.xml"),
|
||||||
|
"<Language code=\"sk\" name=\"Slovensky\"><String key=\"Other\">Ine</String></Language>");
|
||||||
|
var loc = folders.Service();
|
||||||
|
|
||||||
|
loc.Load("sk");
|
||||||
|
|
||||||
|
Assert.Equal("Hello", loc["Hello"]);
|
||||||
|
Assert.Equal("Ine", loc["Other"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,6 +74,70 @@ public class PhysicsAndAssetsTests
|
|||||||
Assert.NotNull(installation.Physics.For("pkp/ep07/", "ep07"));
|
Assert.NotNull(installation.Physics.For("pkp/ep07/", "ep07"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("pkp/21xm_v1/")]
|
||||||
|
[InlineData("pkp/21xm_v1")]
|
||||||
|
[InlineData("PKP\\21XM_V1")]
|
||||||
|
[InlineData("dynamic/pkp/21xm_v1/")]
|
||||||
|
public void Two_fiz_files_of_one_name_are_told_apart_by_their_folder(string folder)
|
||||||
|
{
|
||||||
|
// As in the game: dynamic/**/21xm_v1/214m.fiz and sa134_v1/214m.fiz.
|
||||||
|
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||||
|
.WithFile(Fiz("pkp", "21xm_v1", "214m"), "Param: M=51000\nDimensions: L=24.38")
|
||||||
|
.WithFile(Fiz("pkp", "sa134_v1", "214m"), "Param: M=48000\nDimensions: L=24.5"));
|
||||||
|
|
||||||
|
var physics = installation.Physics.For(folder, "214m.e3d")!;
|
||||||
|
|
||||||
|
Assert.Equal(51000, physics.Mass);
|
||||||
|
Assert.Equal(24.38, physics.Length);
|
||||||
|
Assert.Equal(48000, installation.Physics.For("pkp/sa134_v1/", "214m")!.Mass);
|
||||||
|
// Asked again, the first answer has not stuck to the name.
|
||||||
|
Assert.Equal(51000, installation.Physics.For(folder, "214m")!.Mass);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_folder_without_the_fiz_falls_back_to_the_name_alone()
|
||||||
|
{
|
||||||
|
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||||
|
.WithFile(Fiz("pkp", "ep07", "ep07"), "Param: M=80000"));
|
||||||
|
|
||||||
|
Assert.Equal(80000, installation.Physics.For("pkp/other/", "ep07")!.Mass);
|
||||||
|
Assert.Equal(80000, installation.Physics.For(null, "ep07")!.Mass);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_fallback_to_a_name_two_folders_share_is_the_same_on_every_machine()
|
||||||
|
{
|
||||||
|
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||||
|
.WithFile(Fiz("zz", "b", "214m"), "Param: M=2")
|
||||||
|
.WithFile(Fiz("aa", "a", "214m"), "Param: M=1"));
|
||||||
|
|
||||||
|
Assert.Equal(1, installation.Physics.For("elsewhere/", "214m")!.Mass);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_dumb_variant_in_the_vehicles_own_folder_goes_before_a_namesake_elsewhere()
|
||||||
|
{
|
||||||
|
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||||
|
.WithFile(Fiz("pkp", "ep07", "ep07dumb"), "Param: M=1000")
|
||||||
|
.WithFile(Fiz("pkp", "other", "ep07"), "Param: M=2000"));
|
||||||
|
|
||||||
|
Assert.Equal(1000, installation.Physics.For("pkp/ep07/", "ep07")!.Mass);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Two_folders_sharing_a_name_count_once_among_what_is_indexed()
|
||||||
|
{
|
||||||
|
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||||
|
.WithFile(Fiz("pkp", "a", "214m"), "Param: M=1")
|
||||||
|
.WithFile(Fiz("pkp", "b", "214m"), "Param: M=2")
|
||||||
|
.WithFile(Fiz("pkp", "c", "ep07"), "Param: M=3"));
|
||||||
|
|
||||||
|
installation.Physics.Preload();
|
||||||
|
|
||||||
|
Assert.Equal(2, installation.Physics.IndexedCount);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void An_unknown_model_has_no_physics_and_an_empty_installation_indexes_nothing()
|
public void An_unknown_model_has_no_physics_and_an_empty_installation_indexes_nothing()
|
||||||
{
|
{
|
||||||
|
|||||||
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() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
103
StarterNG.Tests/ResolutionChoicesTests.cs
Normal file
103
StarterNG.Tests/ResolutionChoicesTests.cs
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
using StarterNG.Domain.Settings;
|
||||||
|
|
||||||
|
namespace StarterNG.Tests;
|
||||||
|
|
||||||
|
public class ResolutionChoicesTests
|
||||||
|
{
|
||||||
|
private static List<string> Listed(ScreenSize[] monitors, ScreenSize current) =>
|
||||||
|
ResolutionChoices.For(monitors, current).Select(c => c.Size.ToString()).ToList();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_monitors_come_first_and_are_marked()
|
||||||
|
{
|
||||||
|
var choices = ResolutionChoices.For(new[] { new ScreenSize(1920, 1200), new ScreenSize(2560, 1440) },
|
||||||
|
new ScreenSize(1280, 720));
|
||||||
|
|
||||||
|
Assert.Equal(new ScreenSize(1920, 1200), choices[0].Size);
|
||||||
|
Assert.Equal(new ScreenSize(2560, 1440), choices[1].Size);
|
||||||
|
Assert.True(choices[0].IsMonitor);
|
||||||
|
Assert.True(choices[1].IsMonitor);
|
||||||
|
Assert.All(choices.Skip(2), c => Assert.False(c.IsMonitor));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_size_no_monitor_can_show_is_not_offered()
|
||||||
|
{
|
||||||
|
var listed = Listed(new[] { new ScreenSize(1920, 1080) }, new ScreenSize(1280, 720));
|
||||||
|
|
||||||
|
Assert.Contains("1920x1080", listed);
|
||||||
|
Assert.Contains("1600x900", listed);
|
||||||
|
Assert.DoesNotContain("3840x2160", listed);
|
||||||
|
Assert.DoesNotContain("2560x1440", listed);
|
||||||
|
// Taller than the screen, though no wider.
|
||||||
|
Assert.DoesNotContain("1920x1200", listed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_size_fitting_any_of_the_monitors_is_offered()
|
||||||
|
{
|
||||||
|
var listed = Listed(new[] { new ScreenSize(1920, 1080), new ScreenSize(3440, 1440) }, new ScreenSize(1280, 720));
|
||||||
|
|
||||||
|
Assert.Contains("2560x1080", listed);
|
||||||
|
Assert.DoesNotContain("3840x2160", listed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(1920, 1200)]
|
||||||
|
[InlineData(1280, 800)]
|
||||||
|
[InlineData(2560, 1080)]
|
||||||
|
[InlineData(3440, 1440)]
|
||||||
|
public void The_monitors_size_is_there_even_where_it_is_not_a_usual_one(int width, int height)
|
||||||
|
{
|
||||||
|
var listed = Listed(new[] { new ScreenSize(width, height) }, new ScreenSize(1024, 768));
|
||||||
|
|
||||||
|
Assert.Equal($"{width}x{height}", listed[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_size_asked_for_stays_on_the_list_whatever_it_is()
|
||||||
|
{
|
||||||
|
var listed = Listed(new[] { new ScreenSize(1920, 1080) }, new ScreenSize(5120, 1440));
|
||||||
|
|
||||||
|
Assert.Contains("5120x1440", listed);
|
||||||
|
Assert.Single(listed, "5120x1440");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_rest_goes_from_the_largest_down_without_repeats()
|
||||||
|
{
|
||||||
|
var listed = Listed(new[] { new ScreenSize(2560, 1440) }, new ScreenSize(1920, 1080));
|
||||||
|
|
||||||
|
Assert.Equal(listed.Count, listed.Distinct().Count());
|
||||||
|
var sizes = ResolutionChoices.For(new[] { new ScreenSize(2560, 1440) }, new ScreenSize(1920, 1080))
|
||||||
|
.Skip(1).Select(c => c.Size).ToList();
|
||||||
|
Assert.Equal(sizes.OrderByDescending(s => s.Width).ThenByDescending(s => s.Height), sizes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Two_monitors_of_a_size_are_one_entry()
|
||||||
|
{
|
||||||
|
var choices = ResolutionChoices.For(new[] { new ScreenSize(1920, 1080), new ScreenSize(1920, 1080) },
|
||||||
|
new ScreenSize(1920, 1080));
|
||||||
|
|
||||||
|
Assert.Single(choices, c => c.Size == new ScreenSize(1920, 1080));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void With_no_monitors_known_everything_usual_is_offered()
|
||||||
|
{
|
||||||
|
var listed = Listed(Array.Empty<ScreenSize>(), new ScreenSize(1280, 720));
|
||||||
|
|
||||||
|
Assert.Contains("3840x2160", listed);
|
||||||
|
Assert.Contains("1280x800", listed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_size_of_nothing_is_not_listed()
|
||||||
|
{
|
||||||
|
var listed = Listed(new[] { new ScreenSize(0, 0) }, new ScreenSize(0, 0));
|
||||||
|
|
||||||
|
Assert.DoesNotContain("0x0", listed);
|
||||||
|
Assert.NotEmpty(listed);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
StarterNG.Tests/RevisionSpanTests.cs
Normal file
47
StarterNG.Tests/RevisionSpanTests.cs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
using StarterNG.Domain.Vehicles;
|
||||||
|
|
||||||
|
namespace StarterNG.Tests;
|
||||||
|
|
||||||
|
public class RevisionSpanTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("14.01.2005", "2005-01-14", "2005-01-14")]
|
||||||
|
[InlineData("06.12.2016", "2016-12-06", "2016-12-06")]
|
||||||
|
[InlineData("xx.06.2008", "2008-06-01", "2008-06-30")]
|
||||||
|
[InlineData("?.06.2008", "2008-06-01", "2008-06-30")]
|
||||||
|
[InlineData("xx.xx.2011", "2011-01-01", "2011-12-31")]
|
||||||
|
[InlineData("?.?.2011", "2011-01-01", "2011-12-31")]
|
||||||
|
[InlineData("02.2016", "2016-02-01", "2016-02-29")]
|
||||||
|
public void A_credit_line_date_is_as_exact_as_it_says(string text, string from, string to)
|
||||||
|
{
|
||||||
|
var span = RevisionSpan.Parse(text);
|
||||||
|
|
||||||
|
Assert.Equal(DateTime.Parse(from), span?.From);
|
||||||
|
Assert.Equal(DateTime.Parse(to), span?.To);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("2016", "2016-01-01", "2016-12-31")]
|
||||||
|
[InlineData("2016-12", "2016-12-01", "2016-12-31")]
|
||||||
|
[InlineData("2016-12-06", "2016-12-06", "2016-12-06")]
|
||||||
|
[InlineData(" 2016-12- ", "2016-12-01", "2016-12-31")]
|
||||||
|
public void What_is_typed_to_filter_by_reads_the_same_way(string text, string from, string to)
|
||||||
|
{
|
||||||
|
var span = RevisionSpan.Parse(text);
|
||||||
|
|
||||||
|
Assert.Equal(DateTime.Parse(from), span?.From);
|
||||||
|
Assert.Equal(DateTime.Parse(to), span?.To);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData("?")]
|
||||||
|
[InlineData("xx.xx.xxxx")]
|
||||||
|
[InlineData("31.02.2015")]
|
||||||
|
[InlineData("01.13.2015")]
|
||||||
|
[InlineData("VRZ-99_Kolowag_Septemwri")]
|
||||||
|
[InlineData("201")]
|
||||||
|
public void Anything_else_is_no_date(string? text) =>
|
||||||
|
Assert.Null(RevisionSpan.Parse(text));
|
||||||
|
}
|
||||||
@@ -1,13 +1,9 @@
|
|||||||
using StarterNG.Infrastructure.Sceneries;
|
|
||||||
using StarterNG.Tests.Fakes;
|
using StarterNG.Tests.Fakes;
|
||||||
|
|
||||||
namespace StarterNG.Tests;
|
namespace StarterNG.Tests;
|
||||||
|
|
||||||
public class SceneryExportTests
|
public class SceneryExportTests
|
||||||
{
|
{
|
||||||
private static SceneryParser Parser() =>
|
|
||||||
new(new FixedClock(new DateTime(2026, 1, 1, 12, 0, 0)), new StubRandom());
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void An_untouched_scenery_exports_its_trainsets_back_verbatim()
|
public void An_untouched_scenery_exports_its_trainsets_back_verbatim()
|
||||||
{
|
{
|
||||||
@@ -18,7 +14,7 @@ public class SceneryExportTests
|
|||||||
endtrainset
|
endtrainset
|
||||||
FirstInit
|
FirstInit
|
||||||
""";
|
""";
|
||||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||||
|
|
||||||
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
|
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
|
||||||
|
|
||||||
@@ -27,6 +23,45 @@ public class SceneryExportTests
|
|||||||
Assert.Contains("endtrainset", exported);
|
Assert.Contains("endtrainset", exported);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_consist_emptied_stays_out_of_the_export()
|
||||||
|
{
|
||||||
|
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 = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||||
|
|
||||||
|
scenery.Trainsets[0].RemoveVehicles();
|
||||||
|
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
|
||||||
|
|
||||||
|
Assert.DoesNotContain("trainset pociag", exported);
|
||||||
|
Assert.DoesNotContain("ep07-001", exported);
|
||||||
|
Assert.Contains("FirstInit", exported);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void An_entry_that_could_not_be_read_goes_when_emptied_too()
|
||||||
|
{
|
||||||
|
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 = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||||
|
scenery.Trainsets[0].Parsed = false;
|
||||||
|
|
||||||
|
scenery.Trainsets[0].RemoveVehicles();
|
||||||
|
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
|
||||||
|
|
||||||
|
Assert.DoesNotContain("ep07-001", exported);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Selected_optional_includes_are_written_back_before_FirstInit()
|
public void Selected_optional_includes_are_written_back_before_FirstInit()
|
||||||
{
|
{
|
||||||
@@ -36,7 +71,7 @@ public class SceneryExportTests
|
|||||||
endoptional
|
endoptional
|
||||||
FirstInit
|
FirstInit
|
||||||
""";
|
""";
|
||||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||||
|
|
||||||
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
|
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
|
||||||
|
|
||||||
@@ -54,7 +89,7 @@ public class SceneryExportTests
|
|||||||
endoptional
|
endoptional
|
||||||
FirstInit
|
FirstInit
|
||||||
""";
|
""";
|
||||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||||
scenery.Includes[0].Selected = false;
|
scenery.Includes[0].Selected = false;
|
||||||
|
|
||||||
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
|
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
|
||||||
@@ -71,7 +106,7 @@ public class SceneryExportTests
|
|||||||
endoptional
|
endoptional
|
||||||
FirstInit
|
FirstInit
|
||||||
""";
|
""";
|
||||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||||
|
|
||||||
scenery.HasCompanionTimetable = false;
|
scenery.HasCompanionTimetable = false;
|
||||||
Assert.Contains("include rozklad.inc",
|
Assert.Contains("include rozklad.inc",
|
||||||
@@ -95,7 +130,7 @@ public class SceneryExportTests
|
|||||||
time 8:30 0 0 endtime
|
time 8:30 0 0 endtime
|
||||||
FirstInit
|
FirstInit
|
||||||
""";
|
""";
|
||||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||||
|
|
||||||
scenery.Weather.Day = 200;
|
scenery.Weather.Day = 200;
|
||||||
scenery.Weather.Temperature = 25;
|
scenery.Weather.Temperature = 25;
|
||||||
@@ -120,7 +155,7 @@ public class SceneryExportTests
|
|||||||
endtrainset
|
endtrainset
|
||||||
FirstInit
|
FirstInit
|
||||||
""";
|
""";
|
||||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||||
|
|
||||||
Assert.True(scenery.Trainsets[0].Decor);
|
Assert.True(scenery.Trainsets[0].Decor);
|
||||||
Assert.DoesNotContain("trainset ai_1",
|
Assert.DoesNotContain("trainset ai_1",
|
||||||
@@ -136,11 +171,28 @@ public class SceneryExportTests
|
|||||||
node -1 -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 enddynamic
|
node -1 -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 enddynamic
|
||||||
FirstInit
|
FirstInit
|
||||||
""";
|
""";
|
||||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||||
|
|
||||||
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
|
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
|
||||||
|
|
||||||
Assert.Contains("wagon_1", exported);
|
Assert.Contains("wagon_1", exported);
|
||||||
Assert.Contains("enddynamic", exported);
|
Assert.Contains("enddynamic", exported);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0, "3 0 enddynamic")]
|
||||||
|
[InlineData(3, "3 3 pantstate enddynamic")]
|
||||||
|
public void A_load_type_is_written_only_after_a_count_above_zero(int count, string tail)
|
||||||
|
{
|
||||||
|
// After a 0 the simulator would read the type as a destination and take
|
||||||
|
// the next vehicle with it.
|
||||||
|
var car = new StarterNG.Classes.Dynamic
|
||||||
|
{
|
||||||
|
Name = "en57", DataFolder = "pkp/en57", SkinFile = "s", MmdFile = "m",
|
||||||
|
LoadType = StarterNG.Classes.Dynamic.PantState, LoadCount = count
|
||||||
|
};
|
||||||
|
car.Coupling.Flags = 3;
|
||||||
|
|
||||||
|
Assert.EndsWith(tail + "\n", car.ToTrainsetNode());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
77
StarterNG.Tests/SceneryFaultTextTests.cs
Normal file
77
StarterNG.Tests/SceneryFaultTextTests.cs
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
using StarterNG.Domain.Sceneries;
|
||||||
|
|
||||||
|
namespace StarterNG.Tests;
|
||||||
|
|
||||||
|
public class SceneryFaultTextTests
|
||||||
|
{
|
||||||
|
private static string Wording(string key) => key switch
|
||||||
|
{
|
||||||
|
"SyntaxLost" => "lost: {0}",
|
||||||
|
"SyntaxUnmade" => "unmade: {0}",
|
||||||
|
"SyntaxMore" => "and {0} more",
|
||||||
|
_ => key + " {0} {1}"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string Format(int limit, params SceneryFault[] faults) =>
|
||||||
|
SceneryFaultText.Format(faults, limit, Wording);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void No_faults_read_as_nothing() =>
|
||||||
|
Assert.Equal("", Format(10));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_place_and_the_fault_stand_in_two_columns_as_wide_as_the_longest_place()
|
||||||
|
{
|
||||||
|
string text = Format(10,
|
||||||
|
new SceneryFault(SceneryFaultKind.VehicleIncomplete, "a.scn", 3, "v1", "x"),
|
||||||
|
new SceneryFault(SceneryFaultKind.NotANumber, "long.inc", 120, "v2", "y"));
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
"a.scn:3 SyntaxVehicleIncomplete v1 x\n" +
|
||||||
|
"long.inc:120 SyntaxNotANumber v2 y",
|
||||||
|
text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void What_a_fault_has_no_subject_or_token_for_is_filled_in()
|
||||||
|
{
|
||||||
|
string text = Format(10, new SceneryFault(SceneryFaultKind.StrayEndTrainset, "a.scn", 3));
|
||||||
|
|
||||||
|
Assert.Equal("a.scn:3 SyntaxStrayEndTrainset ?", text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void What_a_fault_costs_stands_under_it_in_the_second_column()
|
||||||
|
{
|
||||||
|
string text = Format(10,
|
||||||
|
new SceneryFault(SceneryFaultKind.VehicleNotClosed, "a.scn", 3, "v1", "x") { Lost = "v2, v3", Unmade = "b" });
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
"a.scn:3 SyntaxVehicleNotClosed v1 x\n" +
|
||||||
|
" lost: v2, v3\n" +
|
||||||
|
" unmade: b",
|
||||||
|
text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Past_the_limit_the_faults_are_only_counted_and_do_not_widen_the_column()
|
||||||
|
{
|
||||||
|
string text = Format(1,
|
||||||
|
new SceneryFault(SceneryFaultKind.NotANumber, "a.scn", 3, "v1", "x"),
|
||||||
|
new SceneryFault(SceneryFaultKind.NotANumber, "a-longer-name.scn", 4, "v2", "y"),
|
||||||
|
new SceneryFault(SceneryFaultKind.NotANumber, "a.scn", 5, "v3", "z"));
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
"a.scn:3 SyntaxNotANumber v1 x\n" +
|
||||||
|
"and 2 more",
|
||||||
|
text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Exactly_as_many_as_the_limit_are_not_counted_as_more()
|
||||||
|
{
|
||||||
|
string text = Format(1, new SceneryFault(SceneryFaultKind.NotANumber, "a.scn", 3, "v1", "x"));
|
||||||
|
|
||||||
|
Assert.DoesNotContain("more", text);
|
||||||
|
}
|
||||||
|
}
|
||||||
376
StarterNG.Tests/SceneryFileWatchTests.cs
Normal file
376
StarterNG.Tests/SceneryFileWatchTests.cs
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
using StarterNG.Application;
|
||||||
|
using StarterNG.Domain.Sceneries;
|
||||||
|
using StarterNG.Infrastructure.Adapters;
|
||||||
|
using StarterNG.Tests.Fakes;
|
||||||
|
|
||||||
|
namespace StarterNG.Tests;
|
||||||
|
|
||||||
|
public class SceneryFileWatchTests
|
||||||
|
{
|
||||||
|
private static readonly string Scn = TestInstallation.At("scenery", "kato.scn");
|
||||||
|
private static readonly string Other = TestInstallation.At("scenery", "lk4.scn");
|
||||||
|
private static readonly string Inc = TestInstallation.At("scenery", "auta.inc");
|
||||||
|
|
||||||
|
private const string Text =
|
||||||
|
"//$n Katowice\n" +
|
||||||
|
"FirstInit\n" +
|
||||||
|
"trainset a tor 0 0\n" +
|
||||||
|
"node -1 0 v1 dynamic d s m 0 headdriver 3 0 enddynamic\n" +
|
||||||
|
"endtrainset\n" +
|
||||||
|
"trainset b tor 0 0\n" +
|
||||||
|
"node -1 0 v2 dynamic d s m 0 headdriver 3 0 enddynamic\n" +
|
||||||
|
"endtrainset\n";
|
||||||
|
|
||||||
|
private static (TestInstallation Installation, SceneryFileWatch Watch, AppState State) Rig(InMemoryFileSystem files)
|
||||||
|
{
|
||||||
|
var installation = new TestInstallation(files);
|
||||||
|
installation.Library.ReloadSceneries();
|
||||||
|
var state = new AppState();
|
||||||
|
return (installation, new SceneryFileWatch(files, installation.Sceneries, installation.Library, state), state);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static InMemoryFileSystem Folder() => new InMemoryFileSystem().WithFile(Scn, Text);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Files_as_they_were_read_are_no_change()
|
||||||
|
{
|
||||||
|
var (installation, watch, _) = Rig(Folder());
|
||||||
|
|
||||||
|
Assert.Empty(watch.Look(installation.Library.Sceneries));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_file_changed_outside_is_read_anew()
|
||||||
|
{
|
||||||
|
var files = Folder();
|
||||||
|
var (installation, watch, _) = Rig(files);
|
||||||
|
var current = installation.Library.Sceneries.Single();
|
||||||
|
|
||||||
|
files.WriteAllText(Scn, Text.Replace("Katowice", "Katowice Główne"));
|
||||||
|
|
||||||
|
var change = Assert.Single(watch.Look(installation.Library.Sceneries));
|
||||||
|
Assert.Same(current, change.Current);
|
||||||
|
Assert.Equal("Katowice Główne", change.Fresh!.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void An_include_that_changed_or_came_makes_the_scenery_read_anew()
|
||||||
|
{
|
||||||
|
var files = new InMemoryFileSystem().WithFile(Scn, Text + "include auta.inc end\n");
|
||||||
|
var (installation, watch, _) = Rig(files);
|
||||||
|
Assert.Empty(watch.Look(installation.Library.Sceneries));
|
||||||
|
|
||||||
|
files.WriteAllText(Inc, "// auta\n");
|
||||||
|
Assert.Single(watch.Look(installation.Library.Sceneries));
|
||||||
|
|
||||||
|
installation.Library.ReloadSceneries();
|
||||||
|
Assert.Empty(watch.Look(installation.Library.Sceneries));
|
||||||
|
|
||||||
|
files.WriteAllText(Inc, "// inne auta\n");
|
||||||
|
Assert.Single(watch.Look(installation.Library.Sceneries));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_new_file_in_the_folder_comes_as_an_added_scenery()
|
||||||
|
{
|
||||||
|
var files = Folder();
|
||||||
|
var (installation, watch, _) = Rig(files);
|
||||||
|
|
||||||
|
files.WriteAllText(Other, "//$n Lublin\n");
|
||||||
|
|
||||||
|
var change = Assert.Single(watch.Look(installation.Library.Sceneries));
|
||||||
|
Assert.Null(change.Current);
|
||||||
|
Assert.Equal("Lublin", change.Fresh!.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_file_is_gone_only_if_still_away_on_the_next_look()
|
||||||
|
{
|
||||||
|
var files = Folder();
|
||||||
|
var (installation, watch, _) = Rig(files);
|
||||||
|
var current = installation.Library.Sceneries.Single();
|
||||||
|
|
||||||
|
files.DeleteFile(Scn);
|
||||||
|
Assert.Empty(watch.Look(installation.Library.Sceneries));
|
||||||
|
|
||||||
|
var change = Assert.Single(watch.Look(installation.Library.Sceneries));
|
||||||
|
Assert.Same(current, change.Current);
|
||||||
|
Assert.Null(change.Fresh);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_file_replaced_between_two_looks_is_not_gone()
|
||||||
|
{
|
||||||
|
var files = Folder();
|
||||||
|
var (installation, watch, _) = Rig(files);
|
||||||
|
|
||||||
|
files.DeleteFile(Scn);
|
||||||
|
Assert.Empty(watch.Look(installation.Library.Sceneries));
|
||||||
|
|
||||||
|
files.WriteAllText(Scn, Text);
|
||||||
|
Assert.Empty(watch.Look(installation.Library.Sceneries));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_file_that_will_not_load_is_tried_and_logged_once_until_it_can_be()
|
||||||
|
{
|
||||||
|
var files = Folder();
|
||||||
|
var (installation, watch, _) = Rig(files);
|
||||||
|
files.WriteAllText(Other, "//$n Lublin\n");
|
||||||
|
files.FailReading(Other);
|
||||||
|
|
||||||
|
Assert.Empty(watch.Look(installation.Library.Sceneries));
|
||||||
|
string once = installation.Log.Text;
|
||||||
|
Assert.Contains("lk4.scn", once);
|
||||||
|
|
||||||
|
Assert.Empty(watch.Look(installation.Library.Sceneries));
|
||||||
|
Assert.Equal(once, installation.Log.Text);
|
||||||
|
|
||||||
|
files.AllowReading(Other);
|
||||||
|
Assert.Equal("Lublin", Assert.Single(watch.Look(installation.Library.Sceneries)).Fresh!.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Its_own_save_is_no_change_from_outside()
|
||||||
|
{
|
||||||
|
var files = Folder();
|
||||||
|
var (installation, watch, _) = Rig(files);
|
||||||
|
var scenery = installation.Library.Sceneries.Single();
|
||||||
|
var trainset = scenery.Trainsets[0];
|
||||||
|
trainset.ReadyToGo = true;
|
||||||
|
|
||||||
|
var log = new FileDiagnosticsLog(files, installation.Clock, installation.Paths);
|
||||||
|
Assert.Equal(SceneryTrainsetSaveOutcome.Saved, new SaveSceneryTrainset(files, log).Execute(scenery, trainset).Outcome);
|
||||||
|
|
||||||
|
Assert.False(watch.IsStale(scenery));
|
||||||
|
Assert.Empty(watch.Look(installation.Library.Sceneries));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_consist_whose_text_stands_goes_over_with_its_edits()
|
||||||
|
{
|
||||||
|
var files = Folder();
|
||||||
|
var (installation, watch, _) = Rig(files);
|
||||||
|
var current = installation.Library.Sceneries.Single();
|
||||||
|
var edited = current.Trainsets[1];
|
||||||
|
edited.ReadyToGo = true;
|
||||||
|
|
||||||
|
files.WriteAllText(Scn, Text.Replace("Katowice", "Katowice Główne"));
|
||||||
|
var fresh = watch.Look(installation.Library.Sceneries).Single().Fresh!;
|
||||||
|
|
||||||
|
Assert.Empty(SceneryFileWatch.CarryOver(current, fresh));
|
||||||
|
Assert.Same(edited, fresh.Trainsets[1]);
|
||||||
|
Assert.True(SceneryTrainsetText.IsModified(fresh.Trainsets[1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void An_edited_consist_whose_text_changed_too_is_the_conflict()
|
||||||
|
{
|
||||||
|
var files = Folder();
|
||||||
|
var (installation, watch, _) = Rig(files);
|
||||||
|
var current = installation.Library.Sceneries.Single();
|
||||||
|
var edited = current.Trainsets[1];
|
||||||
|
edited.ReadyToGo = true;
|
||||||
|
|
||||||
|
files.WriteAllText(Scn, Text.Replace(" v2 ", " v22 "));
|
||||||
|
var fresh = watch.Look(installation.Library.Sceneries).Single().Fresh!;
|
||||||
|
|
||||||
|
Assert.Same(edited, Assert.Single(SceneryFileWatch.CarryOver(current, fresh)));
|
||||||
|
Assert.NotSame(edited, fresh.Trainsets[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_consist_changed_but_not_edited_just_gives_way()
|
||||||
|
{
|
||||||
|
var files = Folder();
|
||||||
|
var (installation, watch, _) = Rig(files);
|
||||||
|
var current = installation.Library.Sceneries.Single();
|
||||||
|
|
||||||
|
files.WriteAllText(Scn, Text.Replace(" v2 ", " v22 "));
|
||||||
|
var fresh = watch.Look(installation.Library.Sceneries).Single().Fresh!;
|
||||||
|
|
||||||
|
Assert.Empty(SceneryFileWatch.CarryOver(current, fresh));
|
||||||
|
Assert.Equal("v22", fresh.Trainsets[1].Vehicles.Single().Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_weather_chosen_by_hand_stays_and_the_authored_one_follows_the_file()
|
||||||
|
{
|
||||||
|
var files = Folder();
|
||||||
|
var (installation, watch, _) = Rig(files);
|
||||||
|
var current = installation.Library.Sceneries.Single();
|
||||||
|
current.Weather.Day = 123;
|
||||||
|
|
||||||
|
files.WriteAllText(Scn, Text.Replace("Katowice", "Katowice Główne"));
|
||||||
|
var fresh = watch.Look(installation.Library.Sceneries).Single().Fresh!;
|
||||||
|
SceneryFileWatch.CarryOver(current, fresh);
|
||||||
|
|
||||||
|
Assert.Equal(123, fresh.Weather.Day);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void What_is_picked_follows_into_the_scenery_read_anew()
|
||||||
|
{
|
||||||
|
var files = Folder();
|
||||||
|
var (installation, watch, state) = Rig(files);
|
||||||
|
var current = installation.Library.Sceneries.Single();
|
||||||
|
var picked = current.Trainsets[1];
|
||||||
|
state.CurrentScenery = current;
|
||||||
|
state.CurrentTrainset = picked;
|
||||||
|
state.StartingVehicleName = "v2";
|
||||||
|
|
||||||
|
files.WriteAllText(Scn, Text.Replace("Katowice", "Katowice Główne"));
|
||||||
|
var change = watch.Look(installation.Library.Sceneries).Single();
|
||||||
|
SceneryFileWatch.CarryOver(change.Current!, change.Fresh!);
|
||||||
|
watch.Apply(change);
|
||||||
|
|
||||||
|
Assert.Same(change.Fresh, installation.Library.Sceneries.Single());
|
||||||
|
Assert.Same(change.Fresh, state.CurrentScenery);
|
||||||
|
Assert.Same(picked, state.CurrentTrainset);
|
||||||
|
Assert.Equal("v2", state.StartingVehicleName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_picked_consist_changed_in_the_file_is_followed_by_its_name()
|
||||||
|
{
|
||||||
|
var files = Folder();
|
||||||
|
var (installation, watch, state) = Rig(files);
|
||||||
|
var current = installation.Library.Sceneries.Single();
|
||||||
|
var picked = current.Trainsets[1];
|
||||||
|
state.CurrentScenery = current;
|
||||||
|
state.CurrentTrainset = picked;
|
||||||
|
state.StartingVehicleName = "v2";
|
||||||
|
|
||||||
|
files.WriteAllText(Scn, Text.Replace(" v2 ", " v22 "));
|
||||||
|
var change = watch.Look(installation.Library.Sceneries).Single();
|
||||||
|
SceneryFileWatch.CarryOver(change.Current!, change.Fresh!);
|
||||||
|
watch.Apply(change);
|
||||||
|
|
||||||
|
Assert.Same(change.Fresh!.Trainsets[1], state.CurrentTrainset);
|
||||||
|
Assert.Null(state.StartingVehicleName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_change_to_a_scenery_reloaded_since_the_look_is_left()
|
||||||
|
{
|
||||||
|
var files = Folder();
|
||||||
|
var (installation, watch, _) = Rig(files);
|
||||||
|
|
||||||
|
files.WriteAllText(Scn, Text.Replace("Katowice", "Katowice Główne"));
|
||||||
|
var change = watch.Look(installation.Library.Sceneries).Single();
|
||||||
|
installation.Library.ReloadSceneries();
|
||||||
|
|
||||||
|
Assert.Equal(SceneryVerdict.Leave, watch.Judge(change).Verdict);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_change_to_a_scenery_saved_from_here_since_the_look_is_left_and_nothing_carried_over()
|
||||||
|
{
|
||||||
|
var files = Folder();
|
||||||
|
var (installation, watch, _) = Rig(files);
|
||||||
|
var current = installation.Library.Sceneries.Single();
|
||||||
|
|
||||||
|
files.WriteAllText(Scn, Text.Replace("Katowice", "Katowice Główne"));
|
||||||
|
var change = watch.Look(installation.Library.Sceneries).Single();
|
||||||
|
current.Sources = change.Fresh!.Sources;
|
||||||
|
|
||||||
|
Assert.Equal(SceneryVerdict.Leave, watch.Judge(change).Verdict);
|
||||||
|
Assert.NotSame(current.Trainsets[0], change.Fresh.Trainsets[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_scenery_new_in_the_folder_is_taken()
|
||||||
|
{
|
||||||
|
var files = Folder();
|
||||||
|
var (installation, watch, _) = Rig(files);
|
||||||
|
|
||||||
|
files.WriteAllText(Other, "//$n Lublin\n");
|
||||||
|
var change = watch.Look(installation.Library.Sceneries).Single();
|
||||||
|
|
||||||
|
Assert.Equal(SceneryVerdict.Take, watch.Judge(change).Verdict);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_scenery_new_in_the_folder_that_got_in_since_the_look_is_left()
|
||||||
|
{
|
||||||
|
var (installation, watch, _) = Rig(Folder());
|
||||||
|
|
||||||
|
var change = new SceneryChange(null, installation.Library.Sceneries.Single());
|
||||||
|
|
||||||
|
Assert.Equal(SceneryVerdict.Leave, watch.Judge(change).Verdict);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_change_that_loses_no_edits_is_taken_with_the_edits_carried_over()
|
||||||
|
{
|
||||||
|
var files = Folder();
|
||||||
|
var (installation, watch, _) = Rig(files);
|
||||||
|
var edited = installation.Library.Sceneries.Single().Trainsets[1];
|
||||||
|
edited.ReadyToGo = true;
|
||||||
|
|
||||||
|
files.WriteAllText(Scn, Text.Replace("Katowice", "Katowice Główne"));
|
||||||
|
var change = watch.Look(installation.Library.Sceneries).Single();
|
||||||
|
|
||||||
|
var judgement = watch.Judge(change);
|
||||||
|
|
||||||
|
Assert.Equal(SceneryVerdict.Take, judgement.Verdict);
|
||||||
|
Assert.Empty(judgement.Lost);
|
||||||
|
Assert.Same(edited, change.Fresh!.Trainsets[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_change_that_loses_edits_is_asked_about_naming_them()
|
||||||
|
{
|
||||||
|
var files = Folder();
|
||||||
|
var (installation, watch, _) = Rig(files);
|
||||||
|
var current = installation.Library.Sceneries.Single();
|
||||||
|
var edited = current.Trainsets[1];
|
||||||
|
edited.ReadyToGo = true;
|
||||||
|
|
||||||
|
files.WriteAllText(Scn, Text.Replace(" v2 ", " v22 "));
|
||||||
|
var change = watch.Look(installation.Library.Sceneries).Single();
|
||||||
|
|
||||||
|
var judgement = watch.Judge(change);
|
||||||
|
|
||||||
|
Assert.Equal(SceneryVerdict.AskFirst, judgement.Verdict);
|
||||||
|
Assert.Same(edited, Assert.Single(judgement.Lost));
|
||||||
|
Assert.Same(current.Trainsets[0], change.Fresh!.Trainsets[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_scenery_gone_with_edits_in_it_stays_for_them_to_be_saved_elsewhere()
|
||||||
|
{
|
||||||
|
var (installation, watch, _) = Rig(Folder());
|
||||||
|
var current = installation.Library.Sceneries.Single();
|
||||||
|
current.Trainsets[1].ReadyToGo = true;
|
||||||
|
|
||||||
|
Assert.Equal(SceneryVerdict.Leave, watch.Judge(new SceneryChange(current, null)).Verdict);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_scenery_gone_with_no_edits_in_it_is_taken_off()
|
||||||
|
{
|
||||||
|
var (installation, watch, _) = Rig(Folder());
|
||||||
|
var current = installation.Library.Sceneries.Single();
|
||||||
|
|
||||||
|
Assert.Equal(SceneryVerdict.Take, watch.Judge(new SceneryChange(current, null)).Verdict);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_scenery_gone_takes_what_was_picked_with_it()
|
||||||
|
{
|
||||||
|
var files = Folder();
|
||||||
|
var (installation, watch, state) = Rig(files);
|
||||||
|
var current = installation.Library.Sceneries.Single();
|
||||||
|
state.CurrentScenery = current;
|
||||||
|
state.CurrentTrainset = current.Trainsets[0];
|
||||||
|
state.StartingVehicleName = "v1";
|
||||||
|
|
||||||
|
watch.Apply(new SceneryChange(current, null));
|
||||||
|
|
||||||
|
Assert.Empty(installation.Library.Sceneries);
|
||||||
|
Assert.Null(state.CurrentScenery);
|
||||||
|
Assert.Null(state.CurrentTrainset);
|
||||||
|
Assert.Null(state.StartingVehicleName);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,10 @@
|
|||||||
using StarterNG.Infrastructure.Sceneries;
|
using StarterNG.Domain.Sceneries;
|
||||||
using StarterNG.Tests.Fakes;
|
using StarterNG.Tests.Fakes;
|
||||||
|
|
||||||
namespace StarterNG.Tests;
|
namespace StarterNG.Tests;
|
||||||
|
|
||||||
public class SceneryParserTests
|
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]
|
[Fact]
|
||||||
public void Directives_become_the_scenery_headline()
|
public void Directives_become_the_scenery_headline()
|
||||||
{
|
{
|
||||||
@@ -19,7 +16,7 @@ public class SceneryParserTests
|
|||||||
//$i images/kato.jpg
|
//$i images/kato.jpg
|
||||||
""";
|
""";
|
||||||
|
|
||||||
var scenery = Parser().Parse("scenery/kato.scn", scn);
|
var scenery = TestScenery.Parser().Parse("scenery/kato.scn", scn);
|
||||||
|
|
||||||
Assert.Equal("Śląsk", scenery.Group);
|
Assert.Equal("Śląsk", scenery.Group);
|
||||||
Assert.Equal("Osobowy do Katowic", scenery.Name);
|
Assert.Equal("Osobowy do Katowic", scenery.Name);
|
||||||
@@ -32,7 +29,7 @@ public class SceneryParserTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void The_a_directive_marks_a_scenery_archival()
|
public void The_a_directive_marks_a_scenery_archival()
|
||||||
{
|
{
|
||||||
var scenery = Parser().Parse("scenery/old.scn", "//$a\n//$n Stary\n");
|
var scenery = TestScenery.Parser().Parse("scenery/old.scn", "//$a\n//$n Stary\n");
|
||||||
|
|
||||||
Assert.True(scenery.Archival);
|
Assert.True(scenery.Archival);
|
||||||
}
|
}
|
||||||
@@ -46,7 +43,7 @@ public class SceneryParserTests
|
|||||||
//$f doc/notes.txt
|
//$f doc/notes.txt
|
||||||
""";
|
""";
|
||||||
|
|
||||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||||
|
|
||||||
Assert.Collection(scenery.Attachments,
|
Assert.Collection(scenery.Attachments,
|
||||||
first =>
|
first =>
|
||||||
@@ -76,7 +73,7 @@ public class SceneryParserTests
|
|||||||
FirstInit
|
FirstInit
|
||||||
""";
|
""";
|
||||||
|
|
||||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||||
|
|
||||||
var include = Assert.Single(scenery.Includes);
|
var include = Assert.Single(scenery.Includes);
|
||||||
Assert.Equal("zima.inc", include.FilePath);
|
Assert.Equal("zima.inc", include.FilePath);
|
||||||
@@ -95,7 +92,7 @@ public class SceneryParserTests
|
|||||||
endoptional
|
endoptional
|
||||||
""";
|
""";
|
||||||
|
|
||||||
var include = Assert.Single(Parser().Parse("scenery/x.scn", scn).Includes);
|
var include = Assert.Single(TestScenery.Parser().Parse("scenery/x.scn", scn).Includes);
|
||||||
|
|
||||||
Assert.Equal(2, include.Kind);
|
Assert.Equal(2, include.Kind);
|
||||||
Assert.False(include.Selected);
|
Assert.False(include.Selected);
|
||||||
@@ -112,7 +109,7 @@ public class SceneryParserTests
|
|||||||
FirstInit
|
FirstInit
|
||||||
""";
|
""";
|
||||||
|
|
||||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||||
|
|
||||||
var trainset = Assert.Single(scenery.Trainsets);
|
var trainset = Assert.Single(scenery.Trainsets);
|
||||||
Assert.True(trainset.Parsed);
|
Assert.True(trainset.Parsed);
|
||||||
@@ -126,11 +123,11 @@ public class SceneryParserTests
|
|||||||
public void Vehicles_outside_a_trainset_are_collected_as_loose()
|
public void Vehicles_outside_a_trainset_are_collected_as_loose()
|
||||||
{
|
{
|
||||||
const string scn = """
|
const string scn = """
|
||||||
node -1 -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 enddynamic
|
node -1 -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 0 enddynamic
|
||||||
FirstInit
|
FirstInit
|
||||||
""";
|
""";
|
||||||
|
|
||||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||||
|
|
||||||
var vehicle = Assert.Single(scenery.LooseVehicles);
|
var vehicle = Assert.Single(scenery.LooseVehicles);
|
||||||
Assert.Equal("wagon_1", vehicle.Name);
|
Assert.Equal("wagon_1", vehicle.Name);
|
||||||
@@ -142,13 +139,43 @@ public class SceneryParserTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void A_malformed_loose_vehicle_is_reported_and_left_in_place()
|
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";
|
const string scn = "node x -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 0 enddynamic\n";
|
||||||
|
|
||||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||||
|
|
||||||
Assert.Empty(scenery.LooseVehicles);
|
Assert.Empty(scenery.LooseVehicles);
|
||||||
Assert.Equal(new[] { "wagon_1" }, scenery.LooseVehicleNames);
|
Assert.Equal(new[] { "wagon_1" }, scenery.LooseVehicleNames);
|
||||||
Assert.Contains(scenery.Faults, fault => fault.Contains("wagon_1"));
|
var fault = Assert.Single(scenery.Faults);
|
||||||
|
Assert.Equal((SceneryFaultKind.NotANumber, "wagon_1", "x", 1), (fault.Kind, fault.Subject, fault.Token, fault.Line));
|
||||||
Assert.Contains("enddynamic", scenery.Template);
|
Assert.Contains("enddynamic", scenery.Template);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Directives_in_a_file_put_in_place_are_not_the_scenery_s_own()
|
||||||
|
{
|
||||||
|
const string included = """
|
||||||
|
//$n Auta
|
||||||
|
//$l Inne
|
||||||
|
//$d Opis aut
|
||||||
|
//$f doc/auta.pdf Auta
|
||||||
|
//$i auta.jpg
|
||||||
|
//$a
|
||||||
|
trainset auto road1 0 50
|
||||||
|
endtrainset
|
||||||
|
""";
|
||||||
|
string text = SceneryIncludes.Inline("//$d Opis scenerii\n//$f doc/mapa.png Mapa\nFirstInit\ninclude auta.scm end\n",
|
||||||
|
name => (name, included));
|
||||||
|
|
||||||
|
var scenery = TestScenery.Parser().Parse("scenery/x.scn", text);
|
||||||
|
|
||||||
|
Assert.Equal("Opis scenerii", scenery.Description);
|
||||||
|
Assert.Equal("doc/mapa.png", Assert.Single(scenery.Attachments).FilePath);
|
||||||
|
Assert.Null(scenery.Name);
|
||||||
|
Assert.Null(scenery.Group);
|
||||||
|
Assert.Null(scenery.ImageName);
|
||||||
|
Assert.False(scenery.Archival);
|
||||||
|
// The file's own trainset is still listed, and its text still written for a start.
|
||||||
|
Assert.Equal("auta.scm", Assert.Single(scenery.Trainsets).SourceFile);
|
||||||
|
Assert.Contains("//$d Opis aut", scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
382
StarterNG.Tests/ScenerySyntaxTests.cs
Normal file
382
StarterNG.Tests/ScenerySyntaxTests.cs
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
using StarterNG.Domain.Sceneries;
|
||||||
|
using StarterNG.Tests.Fakes;
|
||||||
|
|
||||||
|
namespace StarterNG.Tests;
|
||||||
|
|
||||||
|
public class ScenerySyntaxTests
|
||||||
|
{
|
||||||
|
private static (SceneryFaultKind, string, int, string?, string?) Seen(SceneryFault fault) =>
|
||||||
|
(fault.Kind, fault.File, fault.Line, fault.Subject, fault.Token);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_trainset_commented_out_is_no_trainset()
|
||||||
|
{
|
||||||
|
const string scn = """
|
||||||
|
/* trainset stary tor_1 0 0
|
||||||
|
node -1 0 st dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic
|
||||||
|
endtrainset */
|
||||||
|
// trainset dla gracza:
|
||||||
|
trainset pociag tor_1 10 0
|
||||||
|
node -1 0 ep07-001 dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic//koniec
|
||||||
|
endtrainset
|
||||||
|
""";
|
||||||
|
|
||||||
|
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||||
|
|
||||||
|
var trainset = Assert.Single(scenery.Trainsets);
|
||||||
|
Assert.Equal("pociag", trainset.Name);
|
||||||
|
Assert.Single(trainset.Vehicles);
|
||||||
|
Assert.Empty(scenery.Faults);
|
||||||
|
Assert.Contains("trainset stary", scenery.Template);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_trainset_without_its_end_is_reported_and_the_next_one_read_on_its_own()
|
||||||
|
{
|
||||||
|
const string scn = """
|
||||||
|
FirstInit
|
||||||
|
trainset pierwszy tor_1 10 0
|
||||||
|
node -1 0 a dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic
|
||||||
|
trainset drugi tor_2 10 0
|
||||||
|
node -1 0 b dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic
|
||||||
|
endtrainset
|
||||||
|
""";
|
||||||
|
|
||||||
|
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||||
|
|
||||||
|
Assert.Equal((SceneryFaultKind.TrainsetNotClosed, "x.scn", 2, "pierwszy", null),
|
||||||
|
Seen(Assert.Single(scenery.Faults)));
|
||||||
|
var trainset = Assert.Single(scenery.Trainsets);
|
||||||
|
Assert.Equal("drugi", trainset.Name);
|
||||||
|
Assert.Equal("b", Assert.Single(trainset.Vehicles).Name);
|
||||||
|
// The unended one is the simulator's to read as written.
|
||||||
|
Assert.Contains("trainset pierwszy", scenery.Template);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_vehicle_without_its_end_is_reported_yet_its_trainset_listed()
|
||||||
|
{
|
||||||
|
const string scn = """
|
||||||
|
trainset pociag tor_1 10 0
|
||||||
|
node -1 0 a dynamic PKP/EP07 ep07 ep07 0 headdriver 3
|
||||||
|
node -1 0 b dynamic PKP/111A 111a 111a 0 nobody 3 0 enddynamic
|
||||||
|
endtrainset
|
||||||
|
""";
|
||||||
|
|
||||||
|
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||||
|
|
||||||
|
var fault = Assert.Single(scenery.Faults);
|
||||||
|
Assert.Equal((SceneryFaultKind.VehicleNotClosed, "x.scn", 2, "a", null), Seen(fault));
|
||||||
|
// The simulator goes on to the next enddynamic, taking b for a's.
|
||||||
|
Assert.Equal("b", fault.Lost);
|
||||||
|
Assert.Equal(new[] { "a", "b" }, Assert.Single(scenery.Trainsets).Vehicles.Select(v => v.Name));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_vehicle_with_no_load_count_takes_the_next_one_with_it()
|
||||||
|
{
|
||||||
|
const string scn = """
|
||||||
|
trainset pociag tor_1 10 0
|
||||||
|
node -1 0 a dynamic PKP/EP07 ep07 ep07 0 headdriver 3 enddynamic
|
||||||
|
node -1 0 b dynamic PKP/111A 111a 111a 0 nobody 3 0 enddynamic
|
||||||
|
endtrainset
|
||||||
|
""";
|
||||||
|
|
||||||
|
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||||
|
|
||||||
|
// Its enddynamic read for the count, "node" for a destination, b's for its end.
|
||||||
|
var fault = Assert.Single(scenery.Faults);
|
||||||
|
Assert.Equal((SceneryFaultKind.LoadCountMissing, 2, "a", "b"), (fault.Kind, fault.Line, fault.Subject, fault.Lost));
|
||||||
|
Assert.Equal(new[] { "a", "b" }, Assert.Single(scenery.Trainsets).Vehicles.Select(v => v.Name));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_load_with_no_type_runs_on_into_the_next_trainset()
|
||||||
|
{
|
||||||
|
const string scn = """
|
||||||
|
trainset pierwszy tor_1 10 0
|
||||||
|
node -1 0 a dynamic PKP/412Z 412z 412z 0 nobody 3 40 enddynamic
|
||||||
|
endtrainset
|
||||||
|
trainset drugi tor_2 10 0
|
||||||
|
node -1 0 b dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic
|
||||||
|
node -1 0 c dynamic PKP/111A 111a 111a 0 nobody 3 0 enddynamic
|
||||||
|
endtrainset
|
||||||
|
""";
|
||||||
|
|
||||||
|
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||||
|
|
||||||
|
// The second endtrainset is what ends the first trainset then.
|
||||||
|
var fault = Assert.Single(scenery.Faults);
|
||||||
|
Assert.Equal((SceneryFaultKind.LoadTypeMissing, 2, "a", "40", "endtrainset, trainset drugi, b"),
|
||||||
|
(fault.Kind, fault.Line, fault.Subject, fault.Token, fault.Lost));
|
||||||
|
Assert.Equal(new[] { "pierwszy", "drugi" }, scenery.Trainsets.Select(t => t.Name));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("0 none", "none")]
|
||||||
|
[InlineData("0 pantstate", "pantstate")]
|
||||||
|
[InlineData("0 cel Katowice", null)]
|
||||||
|
[InlineData("3 pantstate", null)]
|
||||||
|
[InlineData("12 wegiel", null)]
|
||||||
|
public void One_word_after_the_load_is_taken_for_a_destination_named_enddynamic(string load, string? word)
|
||||||
|
{
|
||||||
|
string scn = $"""
|
||||||
|
trainset pociag tor_1 10 0
|
||||||
|
node -1 0 a dynamic PKP/EP07 ep07 ep07 0 headdriver 3 {load} enddynamic
|
||||||
|
node -1 0 b dynamic PKP/111A 111a 111a 0 nobody 3 0 enddynamic
|
||||||
|
endtrainset
|
||||||
|
""";
|
||||||
|
|
||||||
|
var faults = TestScenery.Parser().Parse("scenery/x.scn", scn).Faults;
|
||||||
|
|
||||||
|
if (word is null)
|
||||||
|
Assert.Empty(faults);
|
||||||
|
else
|
||||||
|
Assert.Equal((SceneryFaultKind.WordBeforeEnd, word, "b"), (faults.Single().Kind, faults.Single().Token, faults.Single().Lost));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_header_written_twice_is_told_as_such()
|
||||||
|
{
|
||||||
|
const string scn = """
|
||||||
|
trainset baltyk\mod\exe45151 chojnice301 40 0.1
|
||||||
|
//$o -Skład ozdobny
|
||||||
|
trainset baltyk\mod\exe45151 chojnice301 40 0.1
|
||||||
|
node -1 0 a dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic
|
||||||
|
endtrainset
|
||||||
|
""";
|
||||||
|
|
||||||
|
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||||
|
|
||||||
|
Assert.Equal((SceneryFaultKind.TrainsetHeaderRepeated, "x.scn", 1, "baltyk\\mod\\exe45151", null),
|
||||||
|
Seen(Assert.Single(scenery.Faults)));
|
||||||
|
Assert.Single(scenery.Trainsets);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_trainset_whose_header_is_swallowed_is_told_at_the_fault_that_began_it()
|
||||||
|
{
|
||||||
|
const string scn = """
|
||||||
|
trainset pierwszy tor_1 10 0
|
||||||
|
node -1 0 a dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic
|
||||||
|
endtrainset
|
||||||
|
//trainset bez_naglowka tor_2 0 0
|
||||||
|
node -1 0 b dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic
|
||||||
|
endtrainset
|
||||||
|
trainset trzeci tor_3 0 0
|
||||||
|
node -1 0 c1 dynamic PKP/EU07 eu07 eu07 0 headdriver 3 0 enddynamic
|
||||||
|
node -1 0 c2 dynamic PKP/111A 111a 111a 0 nobody 3 0 enddynamic
|
||||||
|
node -1 0 c3 dynamic PKP/111A 111a 111a 0 nobody 3 0 enddynamic
|
||||||
|
endtrainset
|
||||||
|
trainset czwarty tor_4 0 0
|
||||||
|
node -1 0 d dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic
|
||||||
|
endtrainset
|
||||||
|
""";
|
||||||
|
|
||||||
|
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||||
|
|
||||||
|
// c2 with no trainset takes c3, and the endtrainset is left alone - all of
|
||||||
|
// it from b, where the fault is.
|
||||||
|
var fault = Assert.Single(scenery.Faults);
|
||||||
|
Assert.Equal((SceneryFaultKind.VehicleOutsideTrainset, 5, "endtrainset, trainset trzeci, c1", "trzeci"),
|
||||||
|
(fault.Kind, fault.Line, fault.Lost, fault.Unmade));
|
||||||
|
Assert.Equal(new[] { "pierwszy", "trzeci", "czwarty" }, scenery.Trainsets.Select(t => t.Name));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Text_where_a_number_belongs_is_named()
|
||||||
|
{
|
||||||
|
const string scn = """
|
||||||
|
trainset pociag tor_1 1O 0
|
||||||
|
node -1 0 a dynamic PKP/EP07 ep07 ep07 0,5 headdriver 3 0 enddynamic
|
||||||
|
endtrainset
|
||||||
|
""";
|
||||||
|
|
||||||
|
var faults = TestScenery.Parser().Parse("scenery/x.scn", scn).Faults;
|
||||||
|
|
||||||
|
Assert.Equal(new[]
|
||||||
|
{
|
||||||
|
(SceneryFaultKind.NotANumber, "x.scn", 1, (string?)"pociag", (string?)"1O"),
|
||||||
|
(SceneryFaultKind.NotANumber, "x.scn", 2, "a", "0,5")
|
||||||
|
}, faults.Select(Seen));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_track_name_split_by_a_space_is_named_whole()
|
||||||
|
{
|
||||||
|
// "Pawonków#L" with its "ó" lost to a space.
|
||||||
|
const string scn = """
|
||||||
|
trainset none Pawonk w#L 200.0 0.0
|
||||||
|
node -1 0 a dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic
|
||||||
|
endtrainset
|
||||||
|
""";
|
||||||
|
|
||||||
|
var faults = TestScenery.Parser().Parse("scenery/x.scn", scn).Faults;
|
||||||
|
|
||||||
|
Assert.Equal((SceneryFaultKind.TrackNameSplit, "x.scn", 1, "none", "Pawonk w#L"), Seen(Assert.Single(faults)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Vehicles_left_without_their_trainset_header_are_told_so()
|
||||||
|
{
|
||||||
|
const string scn = """
|
||||||
|
//trainset 2003r\64040 Staniszcze_Male_po#2 -50 0.1
|
||||||
|
node -1 0 EN57-1542ra dynamic pkp\en57_v2 EN57-1542ra EN57RA 0 headdriver 255 0 enddynamic
|
||||||
|
endtrainset
|
||||||
|
node -1 0 auto dynamic road\ford transit transit droga-b 0 headdriver 25.0 0 enddynamic
|
||||||
|
""";
|
||||||
|
|
||||||
|
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||||
|
|
||||||
|
// On no track the simulator has, it drops the vehicle and goes on to the
|
||||||
|
// next enddynamic - past the endtrainset, to the car's.
|
||||||
|
var fault = Assert.Single(scenery.Faults);
|
||||||
|
Assert.Equal((SceneryFaultKind.VehicleOutsideTrainset, 2, "endtrainset, auto"), (fault.Kind, fault.Line, fault.Lost));
|
||||||
|
Assert.Equal("auto", Assert.Single(scenery.LooseVehicles).Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Structure_faults_are_found_where_the_simulator_reports_them()
|
||||||
|
{
|
||||||
|
const string scn = """
|
||||||
|
endtrainset
|
||||||
|
trainset pusty tor_1 10 0
|
||||||
|
endtrainset trainset pociag tor_1 10 0
|
||||||
|
node -1 0 a dynamic PKP/EP07 ep07 ep07 0 headdriver enddynamic
|
||||||
|
node -1 0 b dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic
|
||||||
|
reversed
|
||||||
|
endtrainset
|
||||||
|
include dekoracje.scm // bez end
|
||||||
|
include auta.scm end
|
||||||
|
/* bez końca
|
||||||
|
""";
|
||||||
|
|
||||||
|
var faults = TestScenery.Parser().Parse("scenery/x.scn", scn).Faults.Select(f => (f.Kind, f.Line)).ToList();
|
||||||
|
|
||||||
|
Assert.Equal(new[]
|
||||||
|
{
|
||||||
|
(SceneryFaultKind.StrayEndTrainset, 1),
|
||||||
|
(SceneryFaultKind.VehicleIncomplete, 4),
|
||||||
|
(SceneryFaultKind.ReversedMisplaced, 6),
|
||||||
|
(SceneryFaultKind.IncludeNotClosed, 8),
|
||||||
|
(SceneryFaultKind.UnclosedComment, 10)
|
||||||
|
}, faults);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_trainset_with_no_vehicles_is_listed_to_be_filled_in_the_depot()
|
||||||
|
{
|
||||||
|
const string scn = """
|
||||||
|
trainset td/rozklad tdo_n1734 0.0 0.1
|
||||||
|
//$o Skład gotowy na żeberku.
|
||||||
|
endtrainset
|
||||||
|
include koniec.scm
|
||||||
|
""";
|
||||||
|
|
||||||
|
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn, _ => new IncludeLookup(true));
|
||||||
|
|
||||||
|
Assert.Empty(scenery.Faults);
|
||||||
|
Assert.Equal("td/rozklad", Assert.Single(scenery.Trainsets).Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Reversed_may_follow_the_assignments_ahead_of_the_vehicles()
|
||||||
|
{
|
||||||
|
const string scn = """
|
||||||
|
Trainset pociag tor_1 10 0
|
||||||
|
assignment pl "Pociąg" endassignment
|
||||||
|
reversed
|
||||||
|
Node -1 0 a dynamic PKP/EP07 ep07 ep07 0 HeadDriver 3 0 EndDynamic
|
||||||
|
EndTrainset
|
||||||
|
""";
|
||||||
|
|
||||||
|
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||||
|
|
||||||
|
Assert.Empty(scenery.Faults);
|
||||||
|
var trainset = Assert.Single(scenery.Trainsets);
|
||||||
|
Assert.True(trainset.Reversed);
|
||||||
|
Assert.Equal(StarterNG.Classes.eDriverType.Headdriver, Assert.Single(trainset.Vehicles).DriverType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_fault_in_an_included_file_is_placed_in_that_file()
|
||||||
|
{
|
||||||
|
string text = SceneryIncludes.Inline(
|
||||||
|
"FirstInit\ninclude tomaszewo/auta.scm end\ntrainset zly tor 0\nendtrainset\n",
|
||||||
|
name => (name, "// auta\ntrainset auto road1 0 50\nnode -1 0 a1 dynamic car car car x headdriver 3 0 enddynamic\nendtrainset"));
|
||||||
|
|
||||||
|
var faults = TestScenery.Parser().Parse("scenery/x.scn", text).Faults.Select(Seen);
|
||||||
|
|
||||||
|
Assert.Equal(new[]
|
||||||
|
{
|
||||||
|
(SceneryFaultKind.NotANumber, "tomaszewo/auta.scm", 3, (string?)"a1", (string?)"x"),
|
||||||
|
(SceneryFaultKind.TrainsetIncomplete, "x.scn", 3, "zly", null)
|
||||||
|
}, faults);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Missing_includes_are_reported_but_optional_ones_and_templates_are_not()
|
||||||
|
{
|
||||||
|
const string scn = """
|
||||||
|
include tory.scm end
|
||||||
|
include brak.scm end // komentarz
|
||||||
|
include budynek.inc (p1) 0 0 end
|
||||||
|
include $(p1)_dom.inc end
|
||||||
|
include opcja.scm end //$optional 2,Opcja
|
||||||
|
endoptional
|
||||||
|
""";
|
||||||
|
|
||||||
|
var faults = TestScenery.Parser().Parse("scenery/x.scn", scn, name => new IncludeLookup(name is "tory.scm" or "budynek.inc")).Faults;
|
||||||
|
|
||||||
|
Assert.Equal((SceneryFaultKind.MissingInclude, "x.scn", 2, null, "brak.scm"), Seen(Assert.Single(faults)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void An_include_named_in_another_case_is_found_as_the_simulator_finds_it()
|
||||||
|
{
|
||||||
|
var files = new InMemoryFileSystem(caseSensitive: true)
|
||||||
|
.WithLegacyFile("game/scenery/x.scn", "FirstInit\ninclude Tomaszewo/Auta.SCM end\n")
|
||||||
|
.WithLegacyFile("game/scenery/tomaszewo/auta.scm",
|
||||||
|
"trainset auto road1 0 50\nnode -1 0 a1 dynamic car car car 0 headdriver 3 0 enddynamic\nendtrainset\n");
|
||||||
|
var repository = TestScenery.Repository(files);
|
||||||
|
|
||||||
|
var scenery = repository.Load("game/scenery/x.scn")!;
|
||||||
|
|
||||||
|
Assert.Empty(scenery.Faults);
|
||||||
|
Assert.Equal("tomaszewo/auta.scm", Assert.Single(scenery.Trainsets).SourceFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void An_include_on_disk_only_in_other_letter_case_is_what_linux_misses()
|
||||||
|
{
|
||||||
|
// The simulator folds the name to lower case and opens it so.
|
||||||
|
var files = new InMemoryFileSystem(caseSensitive: true)
|
||||||
|
.WithLegacyFile("game/scenery/braniewo_szeroki.scn",
|
||||||
|
"include braniewo/base.ctr end\ninclude Braniewo/Tory.SCM end\n")
|
||||||
|
.WithFile("game/scenery/Braniewo/Base.ctr", "")
|
||||||
|
.WithFile("game/scenery/braniewo/tory.scm", "");
|
||||||
|
var repository = TestScenery.Repository(files);
|
||||||
|
|
||||||
|
var faults = repository.Load("game/scenery/braniewo_szeroki.scn")!.Faults;
|
||||||
|
|
||||||
|
Assert.Equal((SceneryFaultKind.IncludeLetterCase, "braniewo_szeroki.scn", 1, "Braniewo/Base.ctr", "braniewo/base.ctr"),
|
||||||
|
Seen(Assert.Single(faults)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(true)]
|
||||||
|
[InlineData(false)]
|
||||||
|
public void A_missing_terrain_file_is_no_fault_while_the_scenery_has_its_own_sbt(bool sbt)
|
||||||
|
{
|
||||||
|
var files = new InMemoryFileSystem(caseSensitive: true)
|
||||||
|
.WithLegacyFile("game/scenery/Lk2_Sandbox.scn", "include nmt/nmt100_mazowieckie_ter.scm end\nFirstInit\n");
|
||||||
|
if (sbt)
|
||||||
|
files.WithFile("game/scenery/lk2_sandbox.sbt", "EU07");
|
||||||
|
var repository = TestScenery.Repository(files);
|
||||||
|
|
||||||
|
var scenery = repository.Load("game/scenery/Lk2_Sandbox.scn")!;
|
||||||
|
|
||||||
|
Assert.Equal(sbt, scenery.HasCompanionTimetable);
|
||||||
|
Assert.Equal(sbt ? 0 : 1, scenery.Faults.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
578
StarterNG.Tests/SceneryTrainsetSaveTests.cs
Normal file
578
StarterNG.Tests/SceneryTrainsetSaveTests.cs
Normal file
@@ -0,0 +1,578 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using System.Text;
|
||||||
|
using StarterNG.Application;
|
||||||
|
using StarterNG.Application.Abstractions;
|
||||||
|
using StarterNG.Classes;
|
||||||
|
using StarterNG.Domain.Sceneries;
|
||||||
|
using StarterNG.Infrastructure.Adapters;
|
||||||
|
using StarterNG.Tests.Fakes;
|
||||||
|
|
||||||
|
namespace StarterNG.Tests;
|
||||||
|
|
||||||
|
public class SceneryTrainsetSaveTests
|
||||||
|
{
|
||||||
|
private const string Path = "scenery/test.scn";
|
||||||
|
|
||||||
|
private const string Scn =
|
||||||
|
"//$n Bałtyk\r\n" +
|
||||||
|
"FirstInit\r\n" +
|
||||||
|
"trainset pociag tor_1 200.0 0.0\r\n" +
|
||||||
|
"//$o -Opis składu\r\n" +
|
||||||
|
"node -1 0 ep07-001 dynamic PKP\\EP07 ep07-001 ep07 0 headdriver 3 0 enddynamic\r\n" +
|
||||||
|
"node -1 0 wagon-1 dynamic PKP\\111A b10-1 111a 0 nobody 3 0 enddynamic\r\n" +
|
||||||
|
"endtrainset\r\n" +
|
||||||
|
"// koniec pliku z polską literą: żółw\r\n";
|
||||||
|
|
||||||
|
private static Encoding Cp1250 => Encoding.GetEncoding(1250);
|
||||||
|
|
||||||
|
private static (InMemoryFileSystem Files, Scenery Scenery, SaveSceneryTrainset Save) Setup(string scn) =>
|
||||||
|
Setup(Cp1250.GetBytes(scn));
|
||||||
|
|
||||||
|
private static (InMemoryFileSystem Files, Scenery Scenery, SaveSceneryTrainset Save) Setup(byte[] bytes)
|
||||||
|
{
|
||||||
|
var files = new InMemoryFileSystem();
|
||||||
|
files.WriteAllBytes(Path, bytes);
|
||||||
|
var scenery = TestScenery.Parser().Parse(Path, LegacyText.Decode(bytes));
|
||||||
|
return (files, scenery, new SaveSceneryTrainset(files, TestLog.Over(files)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Reversed_after_the_header_is_read_and_written_back()
|
||||||
|
{
|
||||||
|
var trainset = new Trainset(
|
||||||
|
"trainset a tor 0 0 reversed\n" +
|
||||||
|
"node -1 0 v1 dynamic d s m 0 nobody 3 0 enddynamic\n" +
|
||||||
|
"endtrainset");
|
||||||
|
|
||||||
|
Assert.True(trainset.Reversed);
|
||||||
|
Assert.Single(trainset.Vehicles);
|
||||||
|
Assert.StartsWith("trainset a tor 0 0 reversed\n", trainset.GetTrainsetEntry());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_trainset_without_reversed_is_not_turned()
|
||||||
|
{
|
||||||
|
var trainset = new Trainset("trainset a tor 0 0\nendtrainset");
|
||||||
|
|
||||||
|
Assert.False(trainset.Reversed);
|
||||||
|
Assert.DoesNotContain("reversed", trainset.GetTrainsetEntry());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void An_untouched_trainset_is_not_modified_however_its_numbers_are_spelled()
|
||||||
|
{
|
||||||
|
var (_, scenery, _) = Setup(Scn);
|
||||||
|
|
||||||
|
Assert.False(SceneryTrainsetText.IsModified(scenery.Trainsets[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_head_stays_as_written_and_the_vehicles_are_rewritten()
|
||||||
|
{
|
||||||
|
var (_, scenery, _) = Setup(Scn.Replace("200.0 0.0\r\n", "200.0 0.0\r\nreversed\r\n"));
|
||||||
|
var trainset = scenery.Trainsets[0];
|
||||||
|
trainset.Vehicles.RemoveAt(1);
|
||||||
|
|
||||||
|
string entry = SceneryTrainsetText.Compose(trainset, "\r\n");
|
||||||
|
|
||||||
|
Assert.StartsWith("trainset pociag tor_1 200.0 0.0\r\nreversed\r\n//$o -Opis składu\r\nnode", entry);
|
||||||
|
Assert.Contains("ep07-001", entry);
|
||||||
|
Assert.DoesNotContain("wagon-1", entry);
|
||||||
|
Assert.EndsWith("enddynamic\r\nendtrainset", entry);
|
||||||
|
Assert.DoesNotContain("\r\r", entry);
|
||||||
|
Assert.DoesNotMatch("[^\r]\n", entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Only_the_speed_of_the_header_follows_the_ready_switch()
|
||||||
|
{
|
||||||
|
var (_, scenery, _) = Setup(Scn);
|
||||||
|
var trainset = scenery.Trainsets[0];
|
||||||
|
trainset.ReadyToGo = true;
|
||||||
|
|
||||||
|
string entry = SceneryTrainsetText.Compose(trainset, "\n");
|
||||||
|
|
||||||
|
Assert.StartsWith("trainset pociag tor_1 200.0 0.1\n", entry);
|
||||||
|
Assert.True(SceneryTrainsetText.IsModified(trainset));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Saving_rewrites_the_trainset_alone_and_keeps_the_original_once()
|
||||||
|
{
|
||||||
|
var (files, scenery, save) = Setup(Scn);
|
||||||
|
var trainset = scenery.Trainsets[0];
|
||||||
|
trainset.Vehicles.RemoveAt(1);
|
||||||
|
|
||||||
|
var (outcome, _) = save.Execute(scenery, trainset);
|
||||||
|
|
||||||
|
Assert.Equal(SceneryTrainsetSaveOutcome.Saved, outcome);
|
||||||
|
string written = Cp1250.GetString(files.ReadAllBytes(Path));
|
||||||
|
Assert.StartsWith("//$n Bałtyk\r\nFirstInit\r\ntrainset pociag tor_1 200.0 0.0\r\n//$o -Opis składu\r\n", written);
|
||||||
|
Assert.DoesNotContain("wagon-1", written);
|
||||||
|
Assert.EndsWith("endtrainset\r\n// koniec pliku z polską literą: żółw\r\n", written);
|
||||||
|
Assert.Equal(Scn, Cp1250.GetString(files.ReadAllBytes(Path + SaveSceneryTrainset.BackupSuffix)));
|
||||||
|
Assert.False(SceneryTrainsetText.IsModified(trainset));
|
||||||
|
|
||||||
|
// A second save finds what the first one wrote; the backup stays the original.
|
||||||
|
trainset.Vehicles.Clear();
|
||||||
|
Assert.Equal(SceneryTrainsetSaveOutcome.Saved, save.Execute(scenery, trainset).Outcome);
|
||||||
|
Assert.DoesNotContain("ep07-001", Cp1250.GetString(files.ReadAllBytes(Path)));
|
||||||
|
Assert.Equal(Scn, Cp1250.GetString(files.ReadAllBytes(Path + SaveSceneryTrainset.BackupSuffix)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Of_two_trainsets_written_alike_the_right_one_is_saved()
|
||||||
|
{
|
||||||
|
const string block =
|
||||||
|
"trainset none tor_1 0 0\r\n" +
|
||||||
|
"node -1 0 v1 dynamic d s m 0 nobody 3 0 enddynamic\r\n" +
|
||||||
|
"endtrainset\r\n";
|
||||||
|
var (files, scenery, save) = Setup(block + "// x\r\n" + block);
|
||||||
|
var second = scenery.Trainsets[1];
|
||||||
|
second.Vehicles.Clear();
|
||||||
|
|
||||||
|
Assert.Equal(SceneryTrainsetSaveOutcome.Saved, save.Execute(scenery, second).Outcome);
|
||||||
|
|
||||||
|
string written = Cp1250.GetString(files.ReadAllBytes(Path));
|
||||||
|
Assert.StartsWith(block + "// x\r\ntrainset none tor_1 0 0\r\nendtrainset", written);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_file_changed_meanwhile_is_left_alone()
|
||||||
|
{
|
||||||
|
var (files, scenery, save) = Setup(Scn);
|
||||||
|
var trainset = scenery.Trainsets[0];
|
||||||
|
trainset.Vehicles.Clear();
|
||||||
|
files.WithLegacyFile(Path, Scn.Replace("wagon-1", "wagon-2"));
|
||||||
|
|
||||||
|
Assert.Equal(SceneryTrainsetSaveOutcome.NotFound, save.Execute(scenery, trainset).Outcome);
|
||||||
|
Assert.Contains("wagon-2", Cp1250.GetString(files.ReadAllBytes(Path)));
|
||||||
|
Assert.False(files.FileExists(Path + SaveSceneryTrainset.BackupSuffix));
|
||||||
|
}
|
||||||
|
|
||||||
|
private const string Cars =
|
||||||
|
"trainset none road1 -20 50\r\n" +
|
||||||
|
"node -1 0 auto_1 dynamic road\\man man nl293 0 headdriver 0 0 enddynamic\r\n" +
|
||||||
|
"endtrainset\r\n";
|
||||||
|
|
||||||
|
private const string Traffic =
|
||||||
|
"node -1 0 nl293_a dynamic road\\man man nl293 \t\tryp_rd110 \t0 headdriver 50 0 enddynamic\r\n";
|
||||||
|
|
||||||
|
private const string WithIncludes =
|
||||||
|
"include tory.scm end\r\n" +
|
||||||
|
"FirstInit\r\n" +
|
||||||
|
"trainset pociag tor_1 0 0\r\n" +
|
||||||
|
"node -1 0 ep07-001 dynamic PKP\\EP07 ep07-001 ep07 0 headdriver 3 0 enddynamic\r\n" +
|
||||||
|
"endtrainset\r\n" +
|
||||||
|
"include tomaszewo/auta.scm end //ruch uliczny\r\n" +
|
||||||
|
"include tomaszewo/eventy.ctr end\r\n" +
|
||||||
|
"/* include tomaszewo/auta.scm end */\r\n" +
|
||||||
|
"include tomaszewo/auta.scm parametr end\r\n";
|
||||||
|
|
||||||
|
private static (string File, string Text)? Files(string name) => name switch
|
||||||
|
{
|
||||||
|
"tory.scm" => (name, Cars),
|
||||||
|
"tomaszewo/auta.scm" => (name, Cars + Traffic),
|
||||||
|
"tomaszewo/eventy.ctr" => (name, "event e1 multiple 0 none endevent\r\n"),
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Includes_after_first_init_that_hold_trainsets_are_put_in_place()
|
||||||
|
{
|
||||||
|
string text = SceneryIncludes.Inline(WithIncludes, Files);
|
||||||
|
|
||||||
|
// Before FirstInit is the scenery itself; without trainsets there is nothing to list.
|
||||||
|
Assert.StartsWith("include tory.scm end\r\n", text);
|
||||||
|
Assert.Contains("include tomaszewo/eventy.ctr end", text);
|
||||||
|
Assert.Contains(SceneryIncludes.BeginMarker + "tomaszewo/auta.scm\r\ntrainset none road1", text);
|
||||||
|
// Commented out, or with parameters to put in: left to the simulator.
|
||||||
|
Assert.Contains("/* include tomaszewo/auta.scm end */", text);
|
||||||
|
Assert.Contains("include tomaszewo/auta.scm parametr end", text);
|
||||||
|
Assert.Single(SceneryIncludes.Ranges(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_trainset_from_an_include_is_listed_with_its_file_and_exported_in_place()
|
||||||
|
{
|
||||||
|
var scenery = TestScenery.Parser().Parse(Path, SceneryIncludes.Inline(WithIncludes, Files));
|
||||||
|
|
||||||
|
Assert.Equal(2, scenery.Trainsets.Count);
|
||||||
|
Assert.Null(scenery.Trainsets[0].SourceFile);
|
||||||
|
Assert.Equal("tomaszewo/auta.scm", scenery.Trainsets[1].SourceFile);
|
||||||
|
|
||||||
|
// Loose road traffic in the file stays put, as written; only its name is known.
|
||||||
|
Assert.Empty(scenery.LooseVehicles);
|
||||||
|
Assert.Contains("nl293_a", scenery.LooseVehicleNames);
|
||||||
|
|
||||||
|
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
|
||||||
|
Assert.Contains("auto_1", exported);
|
||||||
|
Assert.Contains(Traffic, exported);
|
||||||
|
Assert.True(exported.IndexOf(Traffic, StringComparison.Ordinal) > exported.IndexOf("FirstInit", StringComparison.Ordinal));
|
||||||
|
Assert.DoesNotContain("include tomaszewo/auta.scm end //", exported);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_trainset_from_an_include_is_saved_into_that_file()
|
||||||
|
{
|
||||||
|
var files = new InMemoryFileSystem()
|
||||||
|
.WithLegacyFile(Path, WithIncludes)
|
||||||
|
.WithLegacyFile("scenery/tomaszewo/auta.scm", Cars + "// koniec\r\n");
|
||||||
|
var scenery = TestScenery.Parser().Parse(Path, SceneryIncludes.Inline(WithIncludes, Files));
|
||||||
|
var log = TestLog.Over(files);
|
||||||
|
var car = scenery.Trainsets[1];
|
||||||
|
car.Vehicles.Clear();
|
||||||
|
|
||||||
|
Assert.Equal(SceneryTrainsetSaveOutcome.Saved, new SaveSceneryTrainset(files, log).Execute(scenery, car).Outcome);
|
||||||
|
|
||||||
|
Assert.Equal("trainset none road1 -20 50\r\nendtrainset\r\n// koniec\r\n",
|
||||||
|
Cp1250.GetString(files.ReadAllBytes("scenery/tomaszewo/auta.scm")));
|
||||||
|
Assert.Equal(WithIncludes, Cp1250.GetString(files.ReadAllBytes(Path)));
|
||||||
|
Assert.True(files.FileExists("scenery/tomaszewo/auta.scm" + SaveSceneryTrainset.BackupSuffix));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_utf8_file_stays_utf8_with_its_mark()
|
||||||
|
{
|
||||||
|
var (files, scenery, save) = Setup(new byte[] { 0xEF, 0xBB, 0xBF }.Concat(Encoding.UTF8.GetBytes(Scn)).ToArray());
|
||||||
|
scenery.Trainsets[0].Vehicles.RemoveAt(1);
|
||||||
|
|
||||||
|
save.Execute(scenery, scenery.Trainsets[0]);
|
||||||
|
|
||||||
|
byte[] bytes = files.ReadAllBytes(Path);
|
||||||
|
Assert.Equal(new byte[] { 0xEF, 0xBB, 0xBF }, bytes[..3]);
|
||||||
|
Assert.Contains("żółw", Encoding.UTF8.GetString(bytes[3..]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private const string V1 = "node -1 0 v1 dynamic d s m 0 nobody 3 0 enddynamic";
|
||||||
|
private const string V2 = "node -1 0 v2 dynamic d s m 0 nobody 3 0 enddynamic";
|
||||||
|
private const string V3 = "node -1 0 v3 dynamic d s m 0 nobody 3 0 enddynamic";
|
||||||
|
|
||||||
|
private static string SavedText(string scn, Action<Trainset>? change = null)
|
||||||
|
{
|
||||||
|
var (files, scenery, save) = Setup(scn);
|
||||||
|
change?.Invoke(scenery.Trainsets[0]);
|
||||||
|
|
||||||
|
Assert.Equal(SceneryTrainsetSaveOutcome.Saved, save.Execute(scenery, scenery.Trainsets[0]).Outcome);
|
||||||
|
|
||||||
|
return Cp1250.GetString(files.ReadAllBytes(Path));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> VehicleNames(string scn) =>
|
||||||
|
TestScenery.Parser().Parse(Path, scn).Trainsets.Single().Vehicles.Select(v => v.Name);
|
||||||
|
|
||||||
|
private static void Edit(Trainset trainset) => trainset.Vehicles[0].Coupling.Flags = 5;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_vehicle_commented_out_stays_in_its_comment()
|
||||||
|
{
|
||||||
|
string scn = $"FirstInit\ntrainset a t 0 0\n/*\n{V1}\n*/\n{V2}\nendtrainset\n" +
|
||||||
|
"node -1 0 poza dynamic d s m none 0 nobody 0 0 enddynamic\n";
|
||||||
|
|
||||||
|
Assert.Equal(scn, SavedText(scn));
|
||||||
|
|
||||||
|
string edited = SavedText(scn, Edit);
|
||||||
|
Assert.Contains($"/*\n{V1}\n*/\n", edited);
|
||||||
|
var scenery = TestScenery.Parser().Parse(Path, edited);
|
||||||
|
Assert.Equal(new[] { "v2" }, scenery.Trainsets.Single().Vehicles.Select(v => v.Name));
|
||||||
|
Assert.Equal(new[] { "poza" }, scenery.LooseVehicleNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("trainset a t 0 0\nNode -1 0 v1 dynamic d s m 0 nobody 3 0 enddynamic\nendtrainset\n")]
|
||||||
|
[InlineData("trainset a t 0 0\nNODE -1 0 v1 dynamic d s m 0 nobody 3 0 enddynamic\nendtrainset\n")]
|
||||||
|
[InlineData("trainset a t 0 0 node -1 0 v1 dynamic d s m 0 nobody 3 0 enddynamic\nendtrainset\n")]
|
||||||
|
[InlineData("trainset a t 0 0 /*x*/node -1 0 v1 dynamic d s m 0 nobody 3 0 enddynamic\nendtrainset\n")]
|
||||||
|
public void A_vehicle_is_found_however_it_is_written_and_not_written_twice(string scn)
|
||||||
|
{
|
||||||
|
Assert.Equal(scn, SavedText(scn));
|
||||||
|
|
||||||
|
string edited = SavedText(scn, Edit);
|
||||||
|
var vehicle = Assert.Single(TestScenery.Parser().Parse(Path, edited).Trainsets.Single().Vehicles);
|
||||||
|
Assert.Equal(5, vehicle.Coupling.Flags);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_vehicle_named_as_a_keyword_is_no_keyword()
|
||||||
|
{
|
||||||
|
string scn = "trainset node node 0 0\nnode -1 0 node dynamic d s m 0 nobody 3 0 enddynamic\nendtrainset\n";
|
||||||
|
|
||||||
|
Assert.Equal(scn, SavedText(scn));
|
||||||
|
Assert.Contains("trainset node node 0 0.1\n", SavedText(scn, t => t.ReadyToGo = true));
|
||||||
|
}
|
||||||
|
|
||||||
|
private const string Commented =
|
||||||
|
"trainset a t 0 0\n" +
|
||||||
|
V1 + "\n" +
|
||||||
|
"// o v2\n" +
|
||||||
|
V2 + " // przy v2\n" +
|
||||||
|
"/* o v3 */\n" +
|
||||||
|
V3 + "\n" +
|
||||||
|
"// koniec\n" +
|
||||||
|
"EndTrainset\n";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_save_with_no_change_leaves_the_vehicles_and_comments_as_they_were()
|
||||||
|
{
|
||||||
|
Assert.Equal(Commented, SavedText(Commented));
|
||||||
|
|
||||||
|
// Nor is anything but the speed touched when only the speed changes.
|
||||||
|
Assert.Equal(Commented.Replace("t 0 0\n", "t 0 0.1\n"), SavedText(Commented, t => t.ReadyToGo = true));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Comments_stay_where_they_are_when_a_vehicle_is_edited()
|
||||||
|
{
|
||||||
|
string edited = SavedText(Commented, t => t.Vehicles[1].Coupling.Flags = 5);
|
||||||
|
|
||||||
|
Assert.Equal(Commented.Replace(V2, V2.Replace("nobody 3", "nobody 5")), edited);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Comments_stay_when_a_vehicle_is_removed()
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
"trainset a t 0 0\n" + V1 + "\n// o v2\n// przy v2\n/* o v3 */\n" + V3 + "\n// koniec\nEndTrainset\n",
|
||||||
|
SavedText(Commented, t => t.Vehicles.RemoveAt(1)));
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
"trainset a t 0 0\n// o v2\n// przy v2\n/* o v3 */\n// koniec\nEndTrainset\n",
|
||||||
|
SavedText(Commented, t => t.Vehicles.Clear()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Comments_stay_when_a_vehicle_is_added()
|
||||||
|
{
|
||||||
|
var added = new Dynamic { Name = "nowy", DataFolder = "d", SkinFile = "s", MmdFile = "m" };
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
Commented.Replace(V3, added.ToTrainsetNode() + V3),
|
||||||
|
SavedText(Commented, t => t.Vehicles.Insert(2, added)));
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
Commented.Replace("EndTrainset", added.ToTrainsetNode() + "EndTrainset"),
|
||||||
|
SavedText(Commented, t => t.Vehicles.Add(added)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Comments_stay_when_vehicles_change_places_and_a_moved_one_stays_as_written()
|
||||||
|
{
|
||||||
|
string moved = "node 500 0 v3 dynamic d s m /*w srodku*/ 0 nobody 3 0 enddynamic";
|
||||||
|
string scn = Commented.Replace(V3, moved);
|
||||||
|
|
||||||
|
string saved = SavedText(scn, t => t.Vehicles.Reverse());
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "v3", "v2", "v1" }, VehicleNames(saved));
|
||||||
|
foreach (string comment in new[] { "// o v2\n", " // przy v2\n", "/* o v3 */\n", "// koniec\n" })
|
||||||
|
Assert.Contains(comment, saved);
|
||||||
|
Assert.Contains(moved, saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("trainset a /*tor*/ t 100 0\nendtrainset\n", "trainset a /*tor*/ t 100 0.1\nendtrainset\n")]
|
||||||
|
[InlineData("trainset a t /*x*/ 100/*y*/ 0.0 reversed\nendtrainset\n", "trainset a t /*x*/ 100/*y*/ 0.1 reversed\nendtrainset\n")]
|
||||||
|
[InlineData("Trainset a // tor\n t\n 100\n 0.0\nendtrainset\n", "Trainset a // tor\n t\n 100\n 0.1\nendtrainset\n")]
|
||||||
|
public void The_speed_is_found_past_a_comment_in_the_header(string scn, string expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, SavedText(scn, t => t.ReadyToGo = true));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void An_optional_include_after_first_init_stays_an_option()
|
||||||
|
{
|
||||||
|
const string scn = "FirstInit\ninclude zima.inc end //$optional 1, Zima\nx\nendoptional\n";
|
||||||
|
string content = $"trainset a t 0 0\n{V1}\nendtrainset\n";
|
||||||
|
|
||||||
|
string text = SceneryIncludes.Inline(scn, name => (name, content));
|
||||||
|
|
||||||
|
Assert.Equal(scn, text);
|
||||||
|
var scenery = TestScenery.Parser().Parse(Path, text);
|
||||||
|
Assert.Equal("Zima", Assert.Single(scenery.Includes).Desc);
|
||||||
|
Assert.Empty(scenery.Trainsets);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A disk that gives out on whatever is being written; what it does to the file is <see cref="PhysicalFileSystemTests"/>' business.</summary>
|
||||||
|
public class FailingDisk : DispatchProxy
|
||||||
|
{
|
||||||
|
public InMemoryFileSystem Files = null!;
|
||||||
|
|
||||||
|
protected override object? Invoke(MethodInfo? method, object?[]? args) =>
|
||||||
|
method!.Name == nameof(IFileSystem.WriteAllBytesAtomic)
|
||||||
|
? throw new IOException("the disk gave out")
|
||||||
|
: method.Invoke(Files, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(true)]
|
||||||
|
[InlineData(false)]
|
||||||
|
public void A_write_that_fails_leaves_the_file_and_its_backup_whole(bool backedUp)
|
||||||
|
{
|
||||||
|
var (files, scenery, _) = Setup(Scn);
|
||||||
|
string backup = Path + SaveSceneryTrainset.BackupSuffix;
|
||||||
|
if (backedUp)
|
||||||
|
files.WithLegacyFile(backup, Scn);
|
||||||
|
var disk = DispatchProxy.Create<IFileSystem, FailingDisk>();
|
||||||
|
((FailingDisk)(object)disk).Files = files;
|
||||||
|
var log = TestLog.Over();
|
||||||
|
var trainset = scenery.Trainsets[0];
|
||||||
|
string before = trainset.RawEntry;
|
||||||
|
trainset.Vehicles.RemoveAt(1);
|
||||||
|
|
||||||
|
var (outcome, detail) = new SaveSceneryTrainset(disk, log).Execute(scenery, trainset);
|
||||||
|
|
||||||
|
Assert.Equal((SceneryTrainsetSaveOutcome.Failed, "the disk gave out"), (outcome, detail));
|
||||||
|
Assert.Equal(Scn, Cp1250.GetString(files.ReadAllBytes(Path)));
|
||||||
|
Assert.Equal(backedUp, files.FileExists(backup));
|
||||||
|
Assert.Equal(new[] { Path }.Concat(backedUp ? new[] { backup } : Array.Empty<string>()),
|
||||||
|
files.GetFiles("scenery"));
|
||||||
|
Assert.Equal(before, trainset.RawEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (string Scn, SaveSceneryTrainset Save, Scenery Scenery, string Folder) OnDisk(string text)
|
||||||
|
{
|
||||||
|
string folder = Directory.CreateTempSubdirectory("starter-save-").FullName;
|
||||||
|
string scn = System.IO.Path.Combine(folder, "test.scn");
|
||||||
|
var files = new PhysicalFileSystem();
|
||||||
|
files.WriteAllText(scn, text, LegacyText.CodePage1250);
|
||||||
|
var log = TestLog.Over();
|
||||||
|
return (scn, new SaveSceneryTrainset(files, log), TestScenery.Parser().Parse(scn, text), folder);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void On_disk_the_new_text_is_written_beside_the_file_and_moved_over_it()
|
||||||
|
{
|
||||||
|
var (scn, save, scenery, folder) = OnDisk(Scn);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var trainset = scenery.Trainsets[0];
|
||||||
|
trainset.Vehicles.RemoveAt(1);
|
||||||
|
|
||||||
|
// Nothing can be written beside it: the file is not touched either.
|
||||||
|
string temp = scn + PhysicalFileSystem.TempSuffix;
|
||||||
|
Directory.CreateDirectory(temp);
|
||||||
|
Assert.Equal(SceneryTrainsetSaveOutcome.Failed, save.Execute(scenery, trainset).Outcome);
|
||||||
|
Assert.Equal(Scn, Cp1250.GetString(File.ReadAllBytes(scn)));
|
||||||
|
Assert.True(File.Exists(scn + SaveSceneryTrainset.BackupSuffix));
|
||||||
|
|
||||||
|
Directory.Delete(temp);
|
||||||
|
Assert.Equal((SceneryTrainsetSaveOutcome.Saved, (string?)null), save.Execute(scenery, trainset));
|
||||||
|
|
||||||
|
Assert.DoesNotContain("wagon-1", Cp1250.GetString(File.ReadAllBytes(scn)));
|
||||||
|
Assert.Equal(Scn, Cp1250.GetString(File.ReadAllBytes(scn + SaveSceneryTrainset.BackupSuffix)));
|
||||||
|
Assert.Equal(
|
||||||
|
new[] { "test.scn", "test.scn" + SaveSceneryTrainset.BackupSuffix },
|
||||||
|
Directory.GetFiles(folder, "test.scn*").Select(file => System.IO.Path.GetFileName(file)).Order());
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Directory.Delete(folder, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void On_disk_a_file_that_cannot_be_replaced_stays_and_nothing_is_left_beside_it()
|
||||||
|
{
|
||||||
|
// Only Windows keeps a file in use from being moved over.
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
return;
|
||||||
|
|
||||||
|
var (scn, save, scenery, folder) = OnDisk(Scn);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
scenery.Trainsets[0].Vehicles.RemoveAt(1);
|
||||||
|
|
||||||
|
using (new FileStream(scn, FileMode.Open, FileAccess.Read, FileShare.Read))
|
||||||
|
Assert.Equal(SceneryTrainsetSaveOutcome.Failed, save.Execute(scenery, scenery.Trainsets[0]).Outcome);
|
||||||
|
|
||||||
|
Assert.Equal(Scn, Cp1250.GetString(File.ReadAllBytes(scn)));
|
||||||
|
Assert.False(File.Exists(scn + PhysicalFileSystem.TempSuffix));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Directory.Delete(folder, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void On_disk_a_link_stays_a_link_and_its_file_gets_the_text()
|
||||||
|
{
|
||||||
|
var (scn, save, scenery, folder) = OnDisk(Scn);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string target = scn + ".wlasciwy";
|
||||||
|
File.Move(scn, target);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.CreateSymbolicLink(scn, target);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
// Windows lets only some users make links.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scenery.Trainsets[0].Vehicles.RemoveAt(1);
|
||||||
|
|
||||||
|
Assert.Equal(SceneryTrainsetSaveOutcome.Saved, save.Execute(scenery, scenery.Trainsets[0]).Outcome);
|
||||||
|
|
||||||
|
Assert.NotNull(new FileInfo(scn).LinkTarget);
|
||||||
|
Assert.DoesNotContain("wagon-1", Cp1250.GetString(File.ReadAllBytes(target)));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Directory.Delete(folder, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] Bytes(params object[] parts) =>
|
||||||
|
parts.SelectMany(part => part as byte[] ?? Encoding.ASCII.GetBytes((string)part)).ToArray();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Bytes_behind_a_mark_that_are_not_utf8_stay_as_they_were()
|
||||||
|
{
|
||||||
|
byte[] mark = { 0xEF, 0xBB, 0xBF };
|
||||||
|
byte[] polish = { 0xB3, 0xF3, 0xBF }; // "łóż" in code page 1250
|
||||||
|
string trainset = $"trainset a t 0 0\n{V1}\nendtrainset\n";
|
||||||
|
var (files, scenery, save) = Setup(Bytes(mark, "// ", polish, "\n", trainset, "// ", polish, "\n"));
|
||||||
|
scenery.Trainsets[0].Vehicles.Clear();
|
||||||
|
|
||||||
|
Assert.Equal(SceneryTrainsetSaveOutcome.Saved, save.Execute(scenery, scenery.Trainsets[0]).Outcome);
|
||||||
|
|
||||||
|
Assert.Equal(Bytes(mark, "// ", polish, "\ntrainset a t 0 0\nendtrainset\n// ", polish, "\n"), files.ReadAllBytes(Path));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false)]
|
||||||
|
[InlineData(true)]
|
||||||
|
public void Every_byte_outside_the_vehicles_stays_as_it_was(bool marked)
|
||||||
|
{
|
||||||
|
byte[] mark = marked ? new byte[] { 0xEF, 0xBB, 0xBF } : Array.Empty<byte>();
|
||||||
|
byte[] all = Enumerable.Range(0, 256).Select(b => (byte)b).ToArray();
|
||||||
|
string trainset = $"trainset a t 0 0\n{V1}\nendtrainset\n";
|
||||||
|
var (files, scenery, save) = Setup(Bytes(mark, trainset, all));
|
||||||
|
scenery.Trainsets[0].Vehicles.Clear();
|
||||||
|
|
||||||
|
Assert.Equal(SceneryTrainsetSaveOutcome.Saved, save.Execute(scenery, scenery.Trainsets[0]).Outcome);
|
||||||
|
|
||||||
|
Assert.Equal(Bytes(mark, "trainset a t 0 0\nendtrainset\n", all), files.ReadAllBytes(Path));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
// Its own line ends, in a file with the other kind elsewhere.
|
||||||
|
[InlineData("// x\r\ntrainset a t 0 0\n" + V1 + "\nendtrainset\n// y\r\n")]
|
||||||
|
// Both kinds in the trainset.
|
||||||
|
[InlineData("trainset a t 0 0\r\n// x\n" + V1 + "\r\nendtrainset\r\n")]
|
||||||
|
public void Mixed_line_ends_stay_as_they_were(string scn)
|
||||||
|
{
|
||||||
|
Assert.Equal(scn, SavedText(scn));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void What_the_starter_writes_takes_the_line_ends_of_the_trainset()
|
||||||
|
{
|
||||||
|
string scn = "// x\r\ntrainset a t 0 0\n" + V1 + "\nendtrainset\n// y\r\n";
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
"// x\r\ntrainset a t 0 0\n" + V2 + "\nendtrainset\n// y\r\n",
|
||||||
|
SavedText(scn, t => t.Vehicles[0].Name = "v2"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,10 @@
|
|||||||
using StarterNG.Domain.Sceneries;
|
using StarterNG.Domain.Sceneries;
|
||||||
using StarterNG.Infrastructure.Sceneries;
|
|
||||||
using StarterNG.Tests.Fakes;
|
using StarterNG.Tests.Fakes;
|
||||||
|
|
||||||
namespace StarterNG.Tests;
|
namespace StarterNG.Tests;
|
||||||
|
|
||||||
public class SceneryWeatherTests
|
public class SceneryWeatherTests
|
||||||
{
|
{
|
||||||
private static SceneryParser Parser(DateTime now) => new(new FixedClock(now), new StubRandom());
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Authored_weather_is_read_from_the_config_block()
|
public void Authored_weather_is_read_from_the_config_block()
|
||||||
{
|
{
|
||||||
@@ -21,7 +18,7 @@ public class SceneryWeatherTests
|
|||||||
time 8:30 0 0 endtime
|
time 8:30 0 0 endtime
|
||||||
""";
|
""";
|
||||||
|
|
||||||
var weather = Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", scn).Weather;
|
var weather = TestScenery.Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", scn).Weather;
|
||||||
|
|
||||||
Assert.True(weather.IsAuthored);
|
Assert.True(weather.IsAuthored);
|
||||||
Assert.Equal(172, weather.Day);
|
Assert.Equal(172, weather.Day);
|
||||||
@@ -35,7 +32,7 @@ public class SceneryWeatherTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void A_scenery_without_a_time_starts_at_the_players_clock()
|
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;
|
var weather = TestScenery.Parser(new DateTime(2026, 5, 4, 17, 42, 0)).Parse("scenery/x.scn", "//$n Test").Weather;
|
||||||
|
|
||||||
Assert.Equal("17:42", weather.ScenarioTimeOverride);
|
Assert.Equal("17:42", weather.ScenarioTimeOverride);
|
||||||
Assert.False(weather.IsAuthored);
|
Assert.False(weather.IsAuthored);
|
||||||
@@ -44,7 +41,7 @@ public class SceneryWeatherTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Freshly_parsed_weather_counts_as_unchanged()
|
public void Freshly_parsed_weather_counts_as_unchanged()
|
||||||
{
|
{
|
||||||
var weather = Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", "movelight 100\n").Weather;
|
var weather = TestScenery.Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", "movelight 100\n").Weather;
|
||||||
|
|
||||||
Assert.False(weather.Changed);
|
Assert.False(weather.Changed);
|
||||||
}
|
}
|
||||||
@@ -52,7 +49,7 @@ public class SceneryWeatherTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Editing_then_restoring_returns_to_the_authored_values()
|
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;
|
var weather = TestScenery.Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", "movelight 100\n").Weather;
|
||||||
|
|
||||||
weather.Day = 5;
|
weather.Day = 5;
|
||||||
weather.Temperature = -12;
|
weather.Temperature = -12;
|
||||||
@@ -84,4 +81,168 @@ public class SceneryWeatherTests
|
|||||||
Assert.Equal(-1.5, weather.EffectiveOvercast(new StubRandom()));
|
Assert.Equal(-1.5, weather.EffectiveOvercast(new StubRandom()));
|
||||||
Assert.Equal(0.42, new SceneryWeather { Overcast = 0.42 }.EffectiveOvercast(new StubRandom()));
|
Assert.Equal(0.42, new SceneryWeather { Overcast = 0.42 }.EffectiveOvercast(new StubRandom()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string Rewritten(string scn) =>
|
||||||
|
SceneryWeatherWriter.Rewrite(scn, new SceneryWeather(), new StubRandom());
|
||||||
|
|
||||||
|
private static string Code(string text) => SceneryCode.Mask(text, out _);
|
||||||
|
|
||||||
|
/// <summary>The block the writer puts at the top of the text, and the rest.</summary>
|
||||||
|
private static (string Written, string Others) Split(string rewritten)
|
||||||
|
{
|
||||||
|
const string end = "endtime\r\n";
|
||||||
|
int at = rewritten.IndexOf(end, StringComparison.Ordinal) + end.Length;
|
||||||
|
return (rewritten[..at], rewritten[at..]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_config_commented_out_stays_off_and_the_one_after_it_is_kept()
|
||||||
|
{
|
||||||
|
// As linia61_osobowy2.scn has them.
|
||||||
|
const string commented = "//config pause yes endconfig //yes: zatrzymanie symulacji zaraz po wczytaniu, jeśli start jako jadący";
|
||||||
|
string scn = $"""
|
||||||
|
sky skydome_clouds.t3d endsky
|
||||||
|
atmo 0 0 0 1000 2000 0.341 0.408 0.471 -1.2 endatmo
|
||||||
|
time 6:43 06:07 21:02 endtime //w Lublińcu 7:30
|
||||||
|
|
||||||
|
{commented}
|
||||||
|
config hiddenevents 1 endconfig
|
||||||
|
|
||||||
|
include linia61/czestochowa.scm end
|
||||||
|
|
||||||
|
""";
|
||||||
|
|
||||||
|
var (written, rest) = Split(Rewritten(scn));
|
||||||
|
|
||||||
|
Assert.Contains("hiddenevents 1\r\n", written);
|
||||||
|
Assert.DoesNotContain("pause", written);
|
||||||
|
Assert.Contains(commented, rest);
|
||||||
|
Assert.Contains("//w Lublińcu 7:30", rest);
|
||||||
|
Assert.Contains("sky skydome_clouds.t3d endsky", rest);
|
||||||
|
Assert.Contains("include linia61/czestochowa.scm end", rest);
|
||||||
|
Assert.DoesNotContain("hiddenevents", rest);
|
||||||
|
foreach (string word in new[] { "atmo", "time", "pause" })
|
||||||
|
Assert.DoesNotContain(word, Code(rest));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_keyword_in_a_comment_does_not_cut_up_to_the_next_block()
|
||||||
|
{
|
||||||
|
const string scn = """
|
||||||
|
// atmo: tu byla mgla
|
||||||
|
include a.scm end
|
||||||
|
{{0}}
|
||||||
|
/* config */
|
||||||
|
include b.scm end
|
||||||
|
{{1}}
|
||||||
|
// time 8 tu nie
|
||||||
|
include c.scm end
|
||||||
|
atmo 0 0 0 10 2000 0 0 0 0 endatmo
|
||||||
|
config hiddenevents 1 endconfig
|
||||||
|
time 8:30 0 0 endtime
|
||||||
|
""";
|
||||||
|
|
||||||
|
var (written, rest) = Split(Rewritten(scn));
|
||||||
|
|
||||||
|
Assert.Contains("hiddenevents 1", written);
|
||||||
|
foreach (string kept in new[]
|
||||||
|
{
|
||||||
|
"// atmo: tu byla mgla", "include a.scm end", "{{0}}", "/* config */",
|
||||||
|
"include b.scm end", "{{1}}", "// time 8 tu nie", "include c.scm end"
|
||||||
|
})
|
||||||
|
Assert.Contains(kept, rest);
|
||||||
|
foreach (string gone in new[] { "endatmo", "endconfig", "endtime" })
|
||||||
|
Assert.DoesNotContain(gone, rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("/* config a 1 endconfig */")]
|
||||||
|
[InlineData("// config a 1 endconfig")]
|
||||||
|
[InlineData("/* atmo 0 0 0 1 2 0 0 0 0 endatmo */")]
|
||||||
|
[InlineData("// atmo 0 0 0 1 2 0 0 0 0 endatmo")]
|
||||||
|
[InlineData("/* time 8:30 0 0 endtime */")]
|
||||||
|
[InlineData("// time 8:30 0 0 endtime")]
|
||||||
|
[InlineData("// movelight 100")]
|
||||||
|
[InlineData("/*\nmovelight 100\n*/")]
|
||||||
|
[InlineData("// scenario.weather.temperature 5")]
|
||||||
|
[InlineData("/* scenario.time.override 6:00 */")]
|
||||||
|
public void What_is_commented_out_stays_as_it_is(string comment)
|
||||||
|
{
|
||||||
|
var (written, rest) = Split(Rewritten($"x\n{comment}\ny\n"));
|
||||||
|
|
||||||
|
Assert.Equal($"x\n{comment}\ny\n", rest);
|
||||||
|
Assert.DoesNotContain("a 1", written);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_comment_in_a_block_stays_when_the_block_goes()
|
||||||
|
{
|
||||||
|
const string scn = "atmo 0 0 0 // mgla\n10 2000 0 0 0 /* zachmurzenie */ 0 endatmo\n" +
|
||||||
|
"config\n// uwaga\nmovelight 5 // lato\nhiddenevents 1\nendconfig\ninclude a.scm end\n";
|
||||||
|
|
||||||
|
var (written, rest) = Split(Rewritten(scn));
|
||||||
|
|
||||||
|
foreach (string comment in new[] { "// mgla\n", "/* zachmurzenie */", "// uwaga\n", "// lato\n" })
|
||||||
|
Assert.Contains(comment, rest);
|
||||||
|
foreach (string word in new[] { "atmo", "2000", "config", "movelight", "hiddenevents" })
|
||||||
|
Assert.DoesNotContain(word, Code(rest));
|
||||||
|
Assert.Contains("hiddenevents 1\r\n", written);
|
||||||
|
Assert.Contains("include a.scm end", rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Settings_of_every_config_are_carried_and_only_the_starters_own_are_replaced()
|
||||||
|
{
|
||||||
|
const string scn = "config movelight 5 hiddenevents 1 endconfig\nCONFIG\nscenario.weather.temperature 3 pause no\nENDCONFIG\n";
|
||||||
|
|
||||||
|
var (written, rest) = Split(Rewritten(scn));
|
||||||
|
|
||||||
|
Assert.Contains("hiddenevents 1\r\n", written);
|
||||||
|
Assert.Contains("pause no\r\n", written);
|
||||||
|
Assert.DoesNotContain("movelight 5", written);
|
||||||
|
Assert.DoesNotContain("temperature 3", written);
|
||||||
|
Assert.Equal("", rest.Trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_word_in_an_include_s_path_is_no_keyword()
|
||||||
|
{
|
||||||
|
const string scn = """
|
||||||
|
include atmo/lasy.scm end
|
||||||
|
include time/8.scm end
|
||||||
|
include config/a.scm end
|
||||||
|
atmo 0 0 0 10 2000 0 0 0 0 endatmo
|
||||||
|
config hiddenevents 1 endconfig
|
||||||
|
""";
|
||||||
|
|
||||||
|
var (_, rest) = Split(Rewritten(scn));
|
||||||
|
|
||||||
|
Assert.Contains("include atmo/lasy.scm end", rest);
|
||||||
|
Assert.Contains("include time/8.scm end", rest);
|
||||||
|
Assert.Contains("include config/a.scm end", rest);
|
||||||
|
Assert.DoesNotContain("endatmo", rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Weather_in_comments_is_not_read()
|
||||||
|
{
|
||||||
|
const string scn = """
|
||||||
|
// movelight 7
|
||||||
|
// scenario.weather.temperature 40
|
||||||
|
// scenario.time.override 3:33
|
||||||
|
/* time 4:44 0 0 endtime */
|
||||||
|
// atmo 5 mgla w l61
|
||||||
|
include l61/a.scm end
|
||||||
|
movelight 100
|
||||||
|
atmo 0 0 0 50 900 0 0 0 0.7 endatmo
|
||||||
|
""";
|
||||||
|
|
||||||
|
var weather = TestScenery.Parser(new DateTime(2026, 5, 4, 17, 42, 0)).Parse("scenery/x.scn", scn).Weather;
|
||||||
|
|
||||||
|
Assert.Equal(100, weather.Day);
|
||||||
|
Assert.NotEqual(40, weather.Temperature);
|
||||||
|
Assert.Equal("17:42", weather.ScenarioTimeOverride);
|
||||||
|
Assert.Equal(50, weather.FogStart);
|
||||||
|
Assert.Equal(0.7, weather.Overcast);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
128
StarterNG.Tests/SettingReachTests.cs
Normal file
128
StarterNG.Tests/SettingReachTests.cs
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
using StarterNG.Domain.Settings;
|
||||||
|
using StarterNG.Tests.Fakes;
|
||||||
|
|
||||||
|
namespace StarterNG.Tests;
|
||||||
|
|
||||||
|
public class SettingReachTests
|
||||||
|
{
|
||||||
|
private static SimulatorTarget Linux(string renderer = "full", bool server = false) =>
|
||||||
|
new(WindowsBuild: false, renderer, server);
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("feedbackmode")]
|
||||||
|
[InlineData("feedbackport")]
|
||||||
|
[InlineData("showsystemconsole")]
|
||||||
|
[InlineData("gfx.angleplatform")]
|
||||||
|
public void Windows_only_keys_idle_on_a_linux_build(string key)
|
||||||
|
{
|
||||||
|
Assert.Equal(IdleReason.WindowsBuildOnly, SettingReach.Why(key, Linux()));
|
||||||
|
Assert.Equal(IdleReason.None, SettingReach.Why(key, Linux() with { WindowsBuild = true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("full", IdleReason.LegacyRendererOnly)]
|
||||||
|
[InlineData("experimental", IdleReason.LegacyRendererOnly)]
|
||||||
|
[InlineData("legacy", IdleReason.None)]
|
||||||
|
[InlineData("simpleshader", IdleReason.None)]
|
||||||
|
[InlineData("simple", IdleReason.None)]
|
||||||
|
public void Legacy_renderer_keys_follow_the_renderer(string renderer, IdleReason expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, SettingReach.Why("smoothtraction", Linux(renderer)));
|
||||||
|
Assert.Equal(expected, SettingReach.Why("usevbo", Linux(renderer)));
|
||||||
|
Assert.Equal(expected, SettingReach.Why("gfx.shadows.cab.range", Linux(renderer)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Pausing_in_the_background_needs_a_network_server()
|
||||||
|
{
|
||||||
|
Assert.Equal(IdleReason.NeedsNetworkServer, SettingReach.Why("inactivepause", Linux()));
|
||||||
|
Assert.Equal(IdleReason.None, SettingReach.Why("inactivepause", Linux(server: true)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_starters_own_keys_never_idle()
|
||||||
|
{
|
||||||
|
Assert.Equal(IdleReason.None, SettingReach.Why("starter.autoclose", Linux()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("width, height")]
|
||||||
|
[InlineData("fullscreen")]
|
||||||
|
public void The_monitors_own_mode_overrides_size_and_full_screen(string key)
|
||||||
|
{
|
||||||
|
Assert.Equal(IdleReason.MonitorModeInstead,
|
||||||
|
SettingReach.Why(key, Linux() with { FullscreenWindowed = true }));
|
||||||
|
Assert.Equal(IdleReason.None, SettingReach.Why(key, Linux()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void An_ordinary_key_works_everywhere()
|
||||||
|
{
|
||||||
|
Assert.Equal(IdleReason.None, SettingReach.Why("fieldofview", Linux()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("full", IdleReason.None)]
|
||||||
|
[InlineData("legacy", IdleReason.ModernRendererOnly)]
|
||||||
|
[InlineData("experimental", IdleReason.ModernRendererOnly)]
|
||||||
|
public void Effects_of_the_full_renderer_follow_the_renderer(string renderer, IdleReason expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, SettingReach.Why("gfx.postfx.motionblur.enabled", Linux(renderer)));
|
||||||
|
Assert.Equal(expected, SettingReach.Why("gfx.envmap.enabled", Linux(renderer)));
|
||||||
|
Assert.Equal(expected, SettingReach.Why("sleeperdistance", Linux(renderer)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("full", IdleReason.None)]
|
||||||
|
[InlineData("legacy", IdleReason.None)]
|
||||||
|
[InlineData("experimental", IdleReason.OpenGlRenderersOnly)]
|
||||||
|
public void The_experimental_renderer_passes_over_what_only_opengl_reads(string renderer, IdleReason expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, SettingReach.Why("multisampling", Linux(renderer)));
|
||||||
|
Assert.Equal(expected, SettingReach.Why("anisotropicfiltering", Linux(renderer)));
|
||||||
|
Assert.Equal(expected, SettingReach.Why("gfx.smoke", Linux(renderer)));
|
||||||
|
// ...but reads the rest of the reflections and shadows.
|
||||||
|
Assert.Equal(IdleReason.None, SettingReach.Why("gfx.reflections.fidelity", Linux(renderer)));
|
||||||
|
Assert.Equal(IdleReason.None, SettingReach.Why("shadowtune", Linux(renderer)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null, 0, false)]
|
||||||
|
[InlineData(0.0, 0, false)]
|
||||||
|
[InlineData(60.0, 0, true)]
|
||||||
|
[InlineData(null, 50, true)] // fpsaverage, the older regulator
|
||||||
|
public void The_draw_range_factor_needs_a_regulator(double? targetFps, double fpsAverage, bool regulated)
|
||||||
|
{
|
||||||
|
var target = Linux() with { TargetFps = targetFps, FpsAverage = fpsAverage };
|
||||||
|
|
||||||
|
Assert.Equal(regulated ? IdleReason.None : IdleReason.NeedsFrameRateTarget,
|
||||||
|
SettingReach.Why("gfx.drawrange.factor.max", target));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The rest of the target ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_windows_build_is_told_from_its_header()
|
||||||
|
{
|
||||||
|
var files = new InMemoryFileSystem()
|
||||||
|
.WithExecutable(TestInstallation.At("eu07"), "\u007fELF binary")
|
||||||
|
.WithFile(TestInstallation.At("eu07.exe"), "MZ binary");
|
||||||
|
var installation = new TestInstallation(files);
|
||||||
|
|
||||||
|
Assert.False(installation.Executables.IsWindowsBuild("eu07"));
|
||||||
|
Assert.True(installation.Executables.IsWindowsBuild("eu07.exe"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_network_server_in_the_ini_is_noticed()
|
||||||
|
{
|
||||||
|
string ini = Path.Combine("/home/kolejarz", ".config", "MaSzyna", "eu07.ini");
|
||||||
|
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||||
|
.WithFile(ini, "network.server tcp 0.0.0.0:7424\n"));
|
||||||
|
installation.Environment.With("HOME", "/home/kolejarz");
|
||||||
|
|
||||||
|
installation.SettingsStore.Load();
|
||||||
|
|
||||||
|
Assert.True(installation.SettingsStore.Settings.NetworkServer);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -32,8 +32,39 @@ public class SimulatorAftermathTests
|
|||||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||||
.WithFile(TestInstallation.At("log.txt"), "first\r\nsecond\r\n\r\nthird\r\n"));
|
.WithFile(TestInstallation.At("log.txt"), "first\r\nsecond\r\n\r\nthird\r\n"));
|
||||||
|
|
||||||
var tail = new SimulatorAftermath(installation.Files, installation.Paths, installation.Log).SimulatorLogTail(2);
|
var started = installation.Files.LastWriteTimeUtc.AddMinutes(-1);
|
||||||
|
|
||||||
|
var tail = new SimulatorAftermath(installation.Files, installation.Paths, installation.Log)
|
||||||
|
.SimulatorLogTail(2, started);
|
||||||
|
|
||||||
Assert.Equal(new[] { "second", "third" }, tail);
|
Assert.Equal(new[] { "second", "third" }, tail);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_log_not_written_since_the_start_says_nothing_of_this_run()
|
||||||
|
{
|
||||||
|
// Dead before it opened its log: what is there is the session before.
|
||||||
|
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||||
|
.WithFile(TestInstallation.At("log.txt"), "yesterday's error\r\n"));
|
||||||
|
var started = installation.Files.LastWriteTimeUtc.AddMinutes(10);
|
||||||
|
|
||||||
|
var tail = new SimulatorAftermath(installation.Files, installation.Paths, installation.Log)
|
||||||
|
.SimulatorLogTail(2, started);
|
||||||
|
|
||||||
|
Assert.Empty(tail);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_log_written_a_moment_before_the_start_was_taken_counts()
|
||||||
|
{
|
||||||
|
// The start is taken just after the launch, as for the crash dumps.
|
||||||
|
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||||
|
.WithFile(TestInstallation.At("log.txt"), "opening\r\n"));
|
||||||
|
var started = installation.Files.LastWriteTimeUtc.AddSeconds(3);
|
||||||
|
|
||||||
|
var tail = new SimulatorAftermath(installation.Files, installation.Paths, installation.Log)
|
||||||
|
.SimulatorLogTail(2, started);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "opening" }, tail);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,11 +55,28 @@ public class SimulatorExitTests
|
|||||||
? ("cmd", new[] { "/c", "echo boom 1>&2 & exit 3" })
|
? ("cmd", new[] { "/c", "echo boom 1>&2 & exit 3" })
|
||||||
: ("/bin/sh", new[] { "-c", "echo boom >&2; exit 3" });
|
: ("/bin/sh", new[] { "-c", "echo boom >&2; exit 3" });
|
||||||
|
|
||||||
var process = launcher.Start(shell, script, null, captureErrors: true, out string? error);
|
var process = launcher.Start(shell, script, null, new Dictionary<string, string>(), captureErrors: true,
|
||||||
|
out string? error);
|
||||||
Assert.Null(error);
|
Assert.Null(error);
|
||||||
await process!.WaitForExitAsync();
|
await process!.WaitForExitAsync();
|
||||||
|
|
||||||
Assert.Equal(3, process.ExitCode);
|
Assert.Equal(3, process.ExitCode);
|
||||||
Assert.Equal("boom", Assert.Single(process.ErrorTail).Trim());
|
Assert.Equal("boom", Assert.Single(process.ErrorTail).Trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_real_process_gets_the_environment_it_was_given()
|
||||||
|
{
|
||||||
|
var launcher = new SystemProcessLauncher(new TestInstallation().Log);
|
||||||
|
var (shell, script) = OperatingSystem.IsWindows()
|
||||||
|
? ("cmd", new[] { "/c", "echo %__NV_PRIME_RENDER_OFFLOAD% 1>&2" })
|
||||||
|
: ("/bin/sh", new[] { "-c", "echo $__NV_PRIME_RENDER_OFFLOAD >&2" });
|
||||||
|
|
||||||
|
var process = launcher.Start(shell, script, null,
|
||||||
|
new Dictionary<string, string> { ["__NV_PRIME_RENDER_OFFLOAD"] = "1" },
|
||||||
|
captureErrors: true, out _);
|
||||||
|
await process!.WaitForExitAsync();
|
||||||
|
|
||||||
|
Assert.Equal("1", Assert.Single(process.ErrorTail).Trim());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using StarterNG.Application;
|
using StarterNG.Application;
|
||||||
using StarterNG.Application.Abstractions;
|
using StarterNG.Application.Abstractions;
|
||||||
|
using StarterNG.Classes;
|
||||||
|
using StarterNG.Infrastructure;
|
||||||
using StarterNG.Infrastructure.Sceneries;
|
using StarterNG.Infrastructure.Sceneries;
|
||||||
using StarterNG.Tests.Fakes;
|
using StarterNG.Tests.Fakes;
|
||||||
|
|
||||||
@@ -23,6 +25,58 @@ public class StartSimulationTests
|
|||||||
Assert.Equal(installation.Paths.Root, launcher.WorkingDirectory);
|
Assert.Equal(installation.Paths.Root, launcher.WorkingDirectory);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void With_the_physics_log_on_the_launch_is_noted_for_the_tapes()
|
||||||
|
{
|
||||||
|
var (start, launcher, installation) = Rig(files => files.WithExecutable(Build), sceneryClock: "07:52");
|
||||||
|
installation.SettingsStore.Settings.PhysicsLog = true;
|
||||||
|
|
||||||
|
start.Execute(freeFly: false, saveSettings: false);
|
||||||
|
|
||||||
|
// Under the name the simulator is given, which is the name it logs under.
|
||||||
|
string driven = launcher.Arguments![launcher.Arguments.ToList().IndexOf("-v") + 1];
|
||||||
|
string noted = installation.Files.ReadAllText(TestInstallation.At("physicslog", "starter-launch.txt"));
|
||||||
|
Assert.Contains("clock\t07:52", noted);
|
||||||
|
Assert.Contains($"vehicle\t{driven}\tPKP/EP07\tep07.mmd", noted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Without_the_physics_log_nothing_is_noted()
|
||||||
|
{
|
||||||
|
var (start, _, installation) = Rig(files => files.WithExecutable(Build));
|
||||||
|
|
||||||
|
start.Execute(freeFly: false, saveSettings: false);
|
||||||
|
|
||||||
|
Assert.False(installation.Files.FileExists(TestInstallation.At("physicslog", "starter-launch.txt")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("v2")]
|
||||||
|
[InlineData("V2")]
|
||||||
|
[InlineData("gone")]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
public void A_crew_is_there_to_start_with_when_the_pick_has_one_or_names_nothing(string? preferred) =>
|
||||||
|
Assert.True(StartSimulation.HasCrewToStart(WagonAndLocomotive, preferred));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_pick_with_nobody_in_it_says_no_crew() =>
|
||||||
|
Assert.False(StartSimulation.HasCrewToStart(WagonAndLocomotive, "v1"));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_consist_nobody_sits_in_has_no_crew_to_start()
|
||||||
|
{
|
||||||
|
var empty = new Trainset("trainset a tor 0 0\nnode -1 0 v1 dynamic d s m 0 nobody 3 0 enddynamic\nendtrainset");
|
||||||
|
|
||||||
|
Assert.False(StartSimulation.HasCrewToStart(empty, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Trainset WagonAndLocomotive => new(
|
||||||
|
"trainset a tor 0 0\n" +
|
||||||
|
"node -1 0 v1 dynamic d s m 0 nobody 3 0 enddynamic\n" +
|
||||||
|
"node -1 0 v2 dynamic d s m 0 headdriver 3 0 enddynamic\n" +
|
||||||
|
"endtrainset");
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void A_build_for_the_other_system_is_still_launched()
|
public void A_build_for_the_other_system_is_still_launched()
|
||||||
{
|
{
|
||||||
@@ -54,6 +108,49 @@ public class StartSimulationTests
|
|||||||
Assert.Equal(installation.Paths.Root, launcher.WorkingDirectory);
|
Assert.Equal(installation.Paths.Root, launcher.WorkingDirectory);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Wine_is_pointed_at_the_settings_the_starter_saves()
|
||||||
|
{
|
||||||
|
string wine = Path.Combine("/usr/bin", "wine");
|
||||||
|
string exe = Path.Combine(Path.GetTempPath(), "maszyna", "eu07.exe");
|
||||||
|
const string roaming = "/home/kolejarz/.wine/drive_c/users/kolejarz/AppData/Roaming";
|
||||||
|
var (start, _, installation) = Rig(files => files
|
||||||
|
.WithExecutable(wine)
|
||||||
|
.WithFile(exe, "MZ\u0090\u0000")
|
||||||
|
.WithDirectory(roaming));
|
||||||
|
installation.Environment.With("PATH", "/usr/bin").With("HOME", "/home/kolejarz").With("USER", "kolejarz");
|
||||||
|
installation.SettingsStore.Settings.ExecutablePath = exe;
|
||||||
|
|
||||||
|
start.Execute(freeFly: true, saveSettings: false);
|
||||||
|
|
||||||
|
Assert.Equal(Path.GetFullPath(Path.Combine("/home/kolejarz", ".config", "MaSzyna")),
|
||||||
|
installation.Files.LinkTarget(Path.Combine(roaming, "MaSzyna")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_native_build_leaves_the_wine_prefix_alone()
|
||||||
|
{
|
||||||
|
const string roaming = "/home/kolejarz/.wine/drive_c/users/kolejarz/AppData/Roaming";
|
||||||
|
var (start, _, installation) = Rig(files => files.WithExecutable(Build).WithDirectory(roaming));
|
||||||
|
installation.Environment.With("HOME", "/home/kolejarz").With("USER", "kolejarz");
|
||||||
|
|
||||||
|
start.Execute(freeFly: true, saveSettings: false);
|
||||||
|
|
||||||
|
Assert.Null(installation.Files.LinkTarget(roaming + "/MaSzyna"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0)]
|
||||||
|
[InlineData(3)]
|
||||||
|
public void Any_renderer_is_left_for_the_simulator_to_take(int renderer)
|
||||||
|
{
|
||||||
|
var (start, launcher, installation) = Rig(files => files.WithExecutable(Build, "\u007fELF binary"));
|
||||||
|
installation.SettingsStore.Settings.RenderEngine = renderer;
|
||||||
|
|
||||||
|
Assert.Equal(SimulationStartOutcome.Started, start.Execute(freeFly: true, saveSettings: false).Outcome);
|
||||||
|
Assert.NotNull(launcher.Executable);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void The_error_stream_is_kept_only_while_the_starter_stays_to_read_it()
|
public void The_error_stream_is_kept_only_while_the_starter_stays_to_read_it()
|
||||||
{
|
{
|
||||||
@@ -80,6 +177,28 @@ public class StartSimulationTests
|
|||||||
Assert.False(installation.Files.FileExists(TestInstallation.At("scenery", "$td.scn")));
|
Assert.False(installation.Files.FileExists(TestInstallation.At("scenery", "$td.scn")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_new_scenery_opens_at_the_wizard_even_with_a_scenery_selected()
|
||||||
|
{
|
||||||
|
var (start, launcher, _) = Rig(files => files.WithExecutable(Build));
|
||||||
|
|
||||||
|
var result = start.ExecuteNewSceneryEditor();
|
||||||
|
|
||||||
|
Assert.Equal(SimulationStartOutcome.Started, result.Outcome);
|
||||||
|
Assert.Equal(new[] { "-edit" }, launcher.Arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void With_no_scenery_selected_the_editor_opens_at_its_wizard()
|
||||||
|
{
|
||||||
|
var (start, launcher, _) = Rig(files => files.WithExecutable(Build), state => state.CurrentScenery = null);
|
||||||
|
|
||||||
|
var result = start.ExecuteEditor();
|
||||||
|
|
||||||
|
Assert.Equal(SimulationStartOutcome.Started, result.Outcome);
|
||||||
|
Assert.Equal(new[] { "-edit" }, launcher.Arguments);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void A_file_that_cannot_be_run_is_not()
|
public void A_file_that_cannot_be_run_is_not()
|
||||||
{
|
{
|
||||||
@@ -92,8 +211,134 @@ public class StartSimulationTests
|
|||||||
Assert.Null(launcher.Executable);
|
Assert.Null(launcher.Executable);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_scenery_with_no_consist_picked_starts_with_the_free_camera()
|
||||||
|
{
|
||||||
|
var (start, launcher, _) = Rig(files => files.WithExecutable(Build), state => state.CurrentTrainset = null);
|
||||||
|
|
||||||
|
var result = start.Execute(freeFly: false, saveSettings: false);
|
||||||
|
|
||||||
|
Assert.Equal(SimulationStartOutcome.Started, result.Outcome);
|
||||||
|
Assert.Equal(new[] { "-s", "$td.scn" }, launcher.Arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_copy_for_a_start_is_named_in_lower_case_as_the_simulator_looks_for_it()
|
||||||
|
{
|
||||||
|
var (start, launcher, _) = Rig(files => files.WithExecutable(Build), sceneryFile: "Glinojeck.scn");
|
||||||
|
|
||||||
|
start.Execute(freeFly: true, saveSettings: false);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "-s", "$glinojeck.scn" }, launcher.Arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_free_camera_start_without_a_consist_notes_no_driven_vehicle_for_the_tapes()
|
||||||
|
{
|
||||||
|
var (start, launcher, installation) = Rig(files => files.WithExecutable(Build),
|
||||||
|
state => state.CurrentTrainset = null);
|
||||||
|
installation.SettingsStore.Settings.PhysicsLog = true;
|
||||||
|
|
||||||
|
start.Execute(freeFly: true, saveSettings: false);
|
||||||
|
|
||||||
|
Assert.NotNull(launcher.Executable);
|
||||||
|
var note = StarterNG.Domain.Tapes.TapeLaunch.Parse(
|
||||||
|
installation.Files.ReadAllText(TestInstallation.At("physicslog", "starter-launch.txt")))!;
|
||||||
|
Assert.Null(note.Driven);
|
||||||
|
Assert.Empty(note.Consist!);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_start_in_a_vehicle_notes_it_and_its_consist_for_the_tapes()
|
||||||
|
{
|
||||||
|
var (start, launcher, installation) = Rig(files => files.WithExecutable(Build));
|
||||||
|
installation.SettingsStore.Settings.PhysicsLog = true;
|
||||||
|
|
||||||
|
start.Execute(freeFly: false, saveSettings: false);
|
||||||
|
|
||||||
|
// Under the name the simulator is given, which is the name it logs under.
|
||||||
|
string given = launcher.Arguments![launcher.Arguments.ToList().IndexOf("-v") + 1];
|
||||||
|
var note = StarterNG.Domain.Tapes.TapeLaunch.Parse(
|
||||||
|
installation.Files.ReadAllText(TestInstallation.At("physicslog", "starter-launch.txt")))!;
|
||||||
|
Assert.Equal(given, note.Driven);
|
||||||
|
Assert.Equal(new[] { given }, note.Consist);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_start_notes_the_scenario_and_the_train_of_the_timetable_for_the_tapes()
|
||||||
|
{
|
||||||
|
var (start, _, installation) = Rig(files =>
|
||||||
|
{
|
||||||
|
files.WithExecutable(Build);
|
||||||
|
files.WithFile(TestInstallation.At("scenery", "pociag.txt"),
|
||||||
|
"[ Rodzaj i numer pociagu | MPJ15120 ]\r\n");
|
||||||
|
});
|
||||||
|
installation.SettingsStore.Settings.PhysicsLog = true;
|
||||||
|
|
||||||
|
start.Execute(freeFly: false, saveSettings: false);
|
||||||
|
|
||||||
|
var note = StarterNG.Domain.Tapes.TapeLaunch.Parse(
|
||||||
|
installation.Files.ReadAllText(TestInstallation.At("physicslog", "starter-launch.txt")))!;
|
||||||
|
Assert.Equal("Test", note.SceneryName);
|
||||||
|
Assert.Equal("MPJ15120", note.Train);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_free_camera_start_notes_no_train_for_the_tapes()
|
||||||
|
{
|
||||||
|
var (start, _, installation) = Rig(files => files.WithExecutable(Build));
|
||||||
|
installation.SettingsStore.Settings.PhysicsLog = true;
|
||||||
|
|
||||||
|
start.Execute(freeFly: true, saveSettings: false);
|
||||||
|
|
||||||
|
var note = StarterNG.Domain.Tapes.TapeLaunch.Parse(
|
||||||
|
installation.Files.ReadAllText(TestInstallation.At("physicslog", "starter-launch.txt")))!;
|
||||||
|
Assert.Null(note.Train);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void HybridLaptop(InMemoryFileSystem files)
|
||||||
|
{
|
||||||
|
files.WithFile("/sys/class/drm/card0/device/uevent",
|
||||||
|
"DRIVER=nvidia\nPCI_ID=10DE:2D19\nPCI_SLOT_NAME=0000:01:00.0\n")
|
||||||
|
.WithFile("/sys/class/drm/card0/device/boot_vga", "0\n")
|
||||||
|
.WithFile("/sys/class/drm/card1/device/uevent",
|
||||||
|
"DRIVER=amdgpu\nPCI_ID=1002:1900\nPCI_SLOT_NAME=0000:65:00.0\n")
|
||||||
|
.WithFile("/sys/class/drm/card1/device/boot_vga", "1\n")
|
||||||
|
.WithFile("/sys/class/drm/card1-eDP-1/status", "connected\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void On_a_laptop_with_two_cards_the_simulator_goes_to_the_discrete_one()
|
||||||
|
{
|
||||||
|
var (start, launcher, _) = Rig(files =>
|
||||||
|
{
|
||||||
|
files.WithExecutable(Build);
|
||||||
|
HybridLaptop(files);
|
||||||
|
});
|
||||||
|
|
||||||
|
start.Execute(freeFly: true, saveSettings: false);
|
||||||
|
|
||||||
|
Assert.Equal("1", launcher.Environment!["__NV_PRIME_RENDER_OFFLOAD"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Left_to_the_system_the_simulator_gets_no_card_of_the_starters_choosing()
|
||||||
|
{
|
||||||
|
var (start, launcher, installation) = Rig(files =>
|
||||||
|
{
|
||||||
|
files.WithExecutable(Build);
|
||||||
|
HybridLaptop(files);
|
||||||
|
});
|
||||||
|
installation.SettingsStore.Settings.Gpu = StarterNG.Domain.Settings.GpuPreference.System;
|
||||||
|
|
||||||
|
start.Execute(freeFly: true, saveSettings: false);
|
||||||
|
|
||||||
|
Assert.Empty(launcher.Environment!);
|
||||||
|
}
|
||||||
|
|
||||||
private static (StartSimulation Start, RecordingLauncher Launcher, TestInstallation Installation) Rig(
|
private static (StartSimulation Start, RecordingLauncher Launcher, TestInstallation Installation) Rig(
|
||||||
Action<InMemoryFileSystem> addSimulator)
|
Action<InMemoryFileSystem> addSimulator, Action<AppState>? arrange = null, string sceneryFile = "td.scn",
|
||||||
|
string sceneryClock = "10:30")
|
||||||
{
|
{
|
||||||
var installation = new TestInstallation();
|
var installation = new TestInstallation();
|
||||||
addSimulator(installation.Files);
|
addSimulator(installation.Files);
|
||||||
@@ -103,7 +348,7 @@ public class StartSimulationTests
|
|||||||
settings.ExecutablePath = Build;
|
settings.ExecutablePath = Build;
|
||||||
|
|
||||||
var scenery = new SceneryParser(installation.Clock, installation.Random).Parse(
|
var scenery = new SceneryParser(installation.Clock, installation.Random).Parse(
|
||||||
TestInstallation.At("scenery", "td.scn"),
|
TestInstallation.At("scenery", sceneryFile),
|
||||||
"""
|
"""
|
||||||
//$n Test
|
//$n Test
|
||||||
trainset pociag tor_1 10 0
|
trainset pociag tor_1 10 0
|
||||||
@@ -111,12 +356,20 @@ public class StartSimulationTests
|
|||||||
endtrainset
|
endtrainset
|
||||||
FirstInit
|
FirstInit
|
||||||
""");
|
""");
|
||||||
|
scenery.Weather.ScenarioTimeOverride = sceneryClock;
|
||||||
var state = new AppState { CurrentScenery = scenery, CurrentTrainset = scenery.Trainsets[0] };
|
var state = new AppState { CurrentScenery = scenery, CurrentTrainset = scenery.Trainsets[0] };
|
||||||
|
arrange?.Invoke(state);
|
||||||
|
|
||||||
var launcher = new RecordingLauncher();
|
var launcher = new RecordingLauncher();
|
||||||
var start = new StartSimulation(state, installation.SettingsStore, installation.Library.Vehicles,
|
var start = new StartSimulation(state, installation.SettingsStore, installation.Library.Vehicles,
|
||||||
installation.Executables, installation.Files, installation.Paths, launcher,
|
installation.Executables,
|
||||||
installation.Random, installation.Log);
|
installation.Files, installation.Paths, launcher,
|
||||||
|
installation.Random, installation.Log,
|
||||||
|
new LinuxGpus(installation.Files, installation.Environment),
|
||||||
|
installation.Environment,
|
||||||
|
new WineConfigLink(installation.Files, installation.Environment,
|
||||||
|
installation.SettingsPaths, installation.Log),
|
||||||
|
installation.Tapes);
|
||||||
return (start, launcher, installation);
|
return (start, launcher, installation);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,9 +383,13 @@ public class StartSimulationTests
|
|||||||
|
|
||||||
public bool? CapturedErrors { get; private set; }
|
public bool? CapturedErrors { get; private set; }
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, string>? Environment { get; private set; }
|
||||||
|
|
||||||
public IProcessHandle? Start(string executablePath, IReadOnlyList<string> arguments, string? workingDirectory,
|
public IProcessHandle? Start(string executablePath, IReadOnlyList<string> arguments, string? workingDirectory,
|
||||||
bool captureErrors, out string? error)
|
IReadOnlyDictionary<string, string> environment, bool captureErrors,
|
||||||
|
out string? error)
|
||||||
{
|
{
|
||||||
|
Environment = environment;
|
||||||
Executable = executablePath;
|
Executable = executablePath;
|
||||||
CapturedErrors = captureErrors;
|
CapturedErrors = captureErrors;
|
||||||
Arguments = arguments;
|
Arguments = arguments;
|
||||||
@@ -144,6 +401,8 @@ public class StartSimulationTests
|
|||||||
public bool OpenInShell(string pathOrUrl) => false;
|
public bool OpenInShell(string pathOrUrl) => false;
|
||||||
|
|
||||||
public IProcessHandle? FindRunning(string processName) => null;
|
public IProcessHandle? FindRunning(string processName) => null;
|
||||||
|
|
||||||
|
public bool IsRunning(string processName) => false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class RunningProcess : IProcessHandle
|
private sealed class RunningProcess : IProcessHandle
|
||||||
|
|||||||
42
StarterNG.Tests/StepsTests.cs
Normal file
42
StarterNG.Tests/StepsTests.cs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
using StarterNG.Domain.Settings;
|
||||||
|
|
||||||
|
namespace StarterNG.Tests;
|
||||||
|
|
||||||
|
public class StepsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void A_value_among_the_steps_leaves_them_as_they_are()
|
||||||
|
{
|
||||||
|
Assert.Same(Steps.FrameRateLimits, Steps.With(Steps.FrameRateLimits, 144));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_value_between_the_steps_joins_them_in_order()
|
||||||
|
{
|
||||||
|
var steps = Steps.With(Steps.FrameRateLimits, 58);
|
||||||
|
|
||||||
|
Assert.Equal(Steps.FrameRateLimits.Length + 1, steps.Count);
|
||||||
|
Assert.Equal(new double[] { 50, 58, 60 }, steps.Skip(Steps.IndexOf(steps, 50)).Take(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Values_past_either_end_join_at_that_end()
|
||||||
|
{
|
||||||
|
Assert.Equal(0.5, Steps.With(Steps.Anisotropy, 0.5)[0]);
|
||||||
|
Assert.Equal(500, Steps.With(Steps.FrameRateLimits, 500)[^1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_frame_rate_limits_start_with_none()
|
||||||
|
{
|
||||||
|
Assert.Equal(new double[] { 0, 10, 20 }, Steps.With(Steps.FrameRateLimits, 10).Take(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_step_is_found_from_its_rounding_in_the_file()
|
||||||
|
{
|
||||||
|
// 1/300 is written as 0.003333.
|
||||||
|
Assert.Equal(0, Steps.IndexOf(Steps.ReflectionUpdates, 0.003333));
|
||||||
|
Assert.Equal(-1, Steps.IndexOf(Steps.ReflectionUpdates, 0.0033));
|
||||||
|
}
|
||||||
|
}
|
||||||
485
StarterNG.Tests/TapeLaunchTests.cs
Normal file
485
StarterNG.Tests/TapeLaunchTests.cs
Normal file
@@ -0,0 +1,485 @@
|
|||||||
|
using StarterNG.Application;
|
||||||
|
using StarterNG.Domain.Tapes;
|
||||||
|
using StarterNG.Tests.Fakes;
|
||||||
|
|
||||||
|
namespace StarterNG.Tests;
|
||||||
|
|
||||||
|
public class TapeLaunchTests
|
||||||
|
{
|
||||||
|
private static readonly DateTime Launched = new(2025, 12, 31, 23, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_launch_is_written_and_read_back_with_names_that_hold_spaces()
|
||||||
|
{
|
||||||
|
var launch = new TapeLaunch(Launched, "td.scn", new TimeSpan(7, 52, 0), new[]
|
||||||
|
{
|
||||||
|
new TapeVehicle("EP07-424-HIST", "pkp/ep07_v1", "ep07-424"),
|
||||||
|
new TapeVehicle("wagon 1", "pkp/111a_v2", "111a")
|
||||||
|
});
|
||||||
|
|
||||||
|
var back = TapeLaunch.Parse(launch.Format())!;
|
||||||
|
|
||||||
|
Assert.Equal(Launched, back.LaunchedUtc);
|
||||||
|
Assert.Equal("td.scn", back.Scenery);
|
||||||
|
Assert.Equal(new TimeSpan(7, 52, 0), back.StartClock);
|
||||||
|
Assert.Equal("pkp/ep07_v1", back.Find("ep07-424-hist")!.DataFolder);
|
||||||
|
Assert.Equal("111a", back.Find("wagon 1")!.TypeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_driven_vehicle_and_the_consist_are_written_and_read_back()
|
||||||
|
{
|
||||||
|
var launch = new TapeLaunch(Launched, "td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(),
|
||||||
|
"EP07 424", new[] { "EP07 424", "wagon 1" });
|
||||||
|
|
||||||
|
var back = TapeLaunch.Parse(launch.Format())!;
|
||||||
|
|
||||||
|
Assert.Equal(TapeRole.Driven, back.RoleOf("ep07 424"));
|
||||||
|
Assert.Equal(TapeRole.OwnConsist, back.RoleOf("WAGON 1"));
|
||||||
|
Assert.Equal(TapeRole.Other, back.RoleOf("et22-100"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_note_without_the_driven_vehicle_tells_nothing_of_it()
|
||||||
|
{
|
||||||
|
var back = TapeLaunch.Parse("launched\t2025-12-31T23:00:00.0000000Z\nscenery\ttd.scn\nclock\t10:30\n")!;
|
||||||
|
|
||||||
|
Assert.Null(back.Driven);
|
||||||
|
Assert.Equal(TapeRole.Other, back.RoleOf("ep07-424"));
|
||||||
|
Assert.Null(back.SceneryName);
|
||||||
|
Assert.Null(back.Train);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_scenario_and_the_train_are_written_and_read_back()
|
||||||
|
{
|
||||||
|
var launch = new TapeLaunch(Launched, "td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(),
|
||||||
|
SceneryName: "Tor doświadczalny", Train: "EIE 1531");
|
||||||
|
|
||||||
|
var back = TapeLaunch.Parse(launch.Format())!;
|
||||||
|
|
||||||
|
Assert.Equal("Tor doświadczalny", back.SceneryName);
|
||||||
|
Assert.Equal("EIE 1531", back.Train);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_tape_of_the_last_launch_gets_its_scenario_and_train()
|
||||||
|
{
|
||||||
|
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||||
|
.WithFile(TestInstallation.At("physicslog", "EP07-424.dat"), "t"));
|
||||||
|
installation.Clock.Now = Launched;
|
||||||
|
var tapes = installation.Tapes;
|
||||||
|
|
||||||
|
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "EP07-424", new[] { "EP07-424" },
|
||||||
|
"Tor doświadczalny", "EIE1531");
|
||||||
|
var context = tapes.ContextOf(tapes.List().Single());
|
||||||
|
|
||||||
|
Assert.Equal("Tor doświadczalny", context.Scenery);
|
||||||
|
Assert.Equal("EIE1531", context.Train);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_tape_of_someone_elses_vehicle_does_not_get_the_train_of_the_player()
|
||||||
|
{
|
||||||
|
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||||
|
.WithFile(TestInstallation.At("physicslog", "ET22-100.dat"), "t"));
|
||||||
|
installation.Clock.Now = Launched;
|
||||||
|
var tapes = installation.Tapes;
|
||||||
|
|
||||||
|
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "EP07-424", new[] { "EP07-424" },
|
||||||
|
"Tor doświadczalny", "EIE1531");
|
||||||
|
var context = tapes.ContextOf(tapes.List().Single());
|
||||||
|
|
||||||
|
Assert.Equal("Tor doświadczalny", context.Scenery);
|
||||||
|
Assert.Null(context.Train);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_scenario_without_a_name_goes_by_its_file()
|
||||||
|
{
|
||||||
|
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||||
|
.WithFile(TestInstallation.At("physicslog", "EP07-424.dat"), "t"));
|
||||||
|
installation.Clock.Now = Launched;
|
||||||
|
var tapes = installation.Tapes;
|
||||||
|
|
||||||
|
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>());
|
||||||
|
|
||||||
|
Assert.Equal("td.scn", tapes.ContextOf(tapes.List().Single()).Scenery);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("[ Rodzaj i numer pociagu | EIE1531 \t ] \r\n[ Kategoria | IC ]\r\n", "EIE1531")]
|
||||||
|
[InlineData(" ____\r\n[ Rodzaj i numer pociągu | LPE 782 ]\r\n", "LPE 782")]
|
||||||
|
[InlineData("[ Kategoria | IC ]\r\n", null)]
|
||||||
|
[InlineData("", null)]
|
||||||
|
public void The_train_number_comes_from_the_heading_of_its_timetable(string timetable, string? number)
|
||||||
|
{
|
||||||
|
var files = new InMemoryFileSystem().WithFile(TestInstallation.At("scenery", "baltyk", "eie1531.txt"), timetable);
|
||||||
|
|
||||||
|
Assert.Equal(number, new StarterNG.Infrastructure.Sceneries.TimetableLocator(files)
|
||||||
|
.TrainNumber(new StarterNG.Domain.Sceneries.Scenery(TestInstallation.At("scenery", "td.scn"), ""),
|
||||||
|
"baltyk/eie1531"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("none")]
|
||||||
|
[InlineData("NONE")]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(null)]
|
||||||
|
public void A_trainset_without_a_timetable_has_no_train_number(string? name) =>
|
||||||
|
Assert.Null(new StarterNG.Infrastructure.Sceneries.TimetableLocator(new InMemoryFileSystem())
|
||||||
|
.TrainNumber(new StarterNG.Domain.Sceneries.Scenery(TestInstallation.At("scenery", "td.scn"), ""), name));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_timetable_that_is_not_there_gives_no_train_number() =>
|
||||||
|
Assert.Null(new StarterNG.Infrastructure.Sceneries.TimetableLocator(new InMemoryFileSystem())
|
||||||
|
.TrainNumber(new StarterNG.Domain.Sceneries.Scenery(TestInstallation.At("scenery", "td.scn"), ""),
|
||||||
|
@"baltyk\mod\eie1531"));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_driven_tape_and_the_consist_are_listed_ahead_of_the_rest()
|
||||||
|
{
|
||||||
|
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||||
|
.WithFile(TestInstallation.At("physicslog", "a-other.dat"), "t")
|
||||||
|
.WithFile(TestInstallation.At("physicslog", "b-wagon.dat"), "t")
|
||||||
|
.WithFile(TestInstallation.At("physicslog", "c-lok.dat"), "t"));
|
||||||
|
installation.Clock.Now = Launched;
|
||||||
|
var tapes = installation.Tapes;
|
||||||
|
|
||||||
|
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "c-lok", new[] { "c-lok", "b-wagon" });
|
||||||
|
|
||||||
|
Assert.Equal("c-lok", tapes.DrivenVehicle);
|
||||||
|
Assert.Equal(new[] { "c-lok", "b-wagon", "a-other" }, tapes.List().Select(tape => tape.Vehicle));
|
||||||
|
Assert.Equal(new[] { TapeRole.StartedIn, TapeRole.OwnConsist, TapeRole.Other }, tapes.List().Select(tape => tape.Role));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void With_no_log_the_vehicle_of_the_launch_is_only_the_one_it_started_in()
|
||||||
|
{
|
||||||
|
var launch = new TapeLaunch(Launched, "td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "ra", new[] { "ra", "rb" });
|
||||||
|
|
||||||
|
Assert.Equal(TapeRole.StartedIn, launch.RoleOf("ra", PlayerVehicles.Unknown));
|
||||||
|
Assert.Equal(TapeRole.OwnConsist, launch.RoleOf("rb", PlayerVehicles.Unknown));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Where_the_player_has_been_is_written_and_read_back()
|
||||||
|
{
|
||||||
|
var launch = new TapeLaunch(Launched, "td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(),
|
||||||
|
Player: PlayerVehicles.None.After("a received command: [x]\nb received command: [x]\n"));
|
||||||
|
|
||||||
|
var back = TapeLaunch.Parse(launch.Format())!;
|
||||||
|
|
||||||
|
Assert.Equal("b", back.Player!.Current);
|
||||||
|
Assert.True(back.Player.Used("a"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_note_of_a_single_launch_still_reads()
|
||||||
|
{
|
||||||
|
var all = TapeLaunch.ParseAll("launched\t2025-12-31T23:00:00.0000000Z\nscenery\ttd.scn\nclock\t10:30\n");
|
||||||
|
|
||||||
|
Assert.Equal("td.scn", all.Single().Scenery);
|
||||||
|
Assert.Null(all.Single().Player);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Tapes_part_between_the_launches_they_were_written_after()
|
||||||
|
{
|
||||||
|
var files = new InMemoryFileSystem()
|
||||||
|
.WithFile(TestInstallation.At("physicslog", "ra.dat"), "t")
|
||||||
|
.WithFile(TestInstallation.At("physicslog", "rb.dat"), "t");
|
||||||
|
var installation = new TestInstallation(files);
|
||||||
|
var tapes = installation.Tapes;
|
||||||
|
installation.Clock.Now = Launched;
|
||||||
|
tapes.RecordLaunch("a.scn", new TimeSpan(7, 0, 0), Array.Empty<TapeVehicle>(), "ra", new[] { "ra" }, "A", "T1");
|
||||||
|
installation.Clock.Now = Launched.AddHours(1);
|
||||||
|
tapes.RecordLaunch("b.scn", new TimeSpan(9, 0, 0), Array.Empty<TapeVehicle>(), "rb", new[] { "rb" }, "B", "T2");
|
||||||
|
files.WrittenAt(TestInstallation.At("physicslog", "ra.dat"), Launched.AddMinutes(30))
|
||||||
|
.WrittenAt(TestInstallation.At("physicslog", "rb.dat"), Launched.AddMinutes(90));
|
||||||
|
|
||||||
|
var sessions = tapes.Sessions();
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "B", "A" }, sessions.Select(session => session.Launch!.SceneryName));
|
||||||
|
Assert.Equal("rb", sessions[0].Tapes.Single().Vehicle);
|
||||||
|
Assert.Equal("ra", sessions[1].Tapes.Single().Vehicle);
|
||||||
|
Assert.Equal(new TimeSpan(7, 0, 0), tapes.ContextOf(sessions[1].Tapes[0]).StartClock);
|
||||||
|
Assert.Equal("T1", tapes.ContextOf(sessions[1].Tapes[0]).Train);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Tapes_with_no_launch_noted_go_together_by_when_they_were_written()
|
||||||
|
{
|
||||||
|
var early = new DateTime(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||||
|
.WithFile(TestInstallation.At("physicslog", "a.dat"), "t")
|
||||||
|
.WithFile(TestInstallation.At("physicslog", "b.dat"), "t")
|
||||||
|
.WithFile(TestInstallation.At("physicslog", "c.dat"), "t")
|
||||||
|
.WrittenAt(TestInstallation.At("physicslog", "a.dat"), early)
|
||||||
|
.WrittenAt(TestInstallation.At("physicslog", "b.dat"), early.AddSeconds(30))
|
||||||
|
.WrittenAt(TestInstallation.At("physicslog", "c.dat"), early.AddHours(1)));
|
||||||
|
|
||||||
|
var sessions = installation.Tapes.Sessions();
|
||||||
|
|
||||||
|
Assert.Equal(new[] { 1, 2 }, sessions.Select(session => session.Tapes.Count));
|
||||||
|
Assert.All(sessions, session => Assert.Null(session.Launch));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_running_session_is_listed_with_no_tapes_yet_and_goes_with_the_simulator()
|
||||||
|
{
|
||||||
|
var installation = new TestInstallation(new InMemoryFileSystem());
|
||||||
|
installation.Clock.Now = Launched;
|
||||||
|
var tapes = installation.Tapes;
|
||||||
|
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "ra");
|
||||||
|
|
||||||
|
installation.SimulatorRunning = true;
|
||||||
|
var running = tapes.Sessions().Single();
|
||||||
|
Assert.True(running.Running);
|
||||||
|
Assert.Empty(running.Tapes);
|
||||||
|
|
||||||
|
installation.SimulatorRunning = false;
|
||||||
|
Assert.Empty(tapes.Sessions());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_log_of_its_own_tells_of_the_vehicles_when_the_common_one_is_stale()
|
||||||
|
{
|
||||||
|
string kept = TestInstallation.At("logs", "log (td.scn) 2026.txt");
|
||||||
|
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||||
|
.WithFile(TestInstallation.At("log.txt"), "ra received command: [x]\n")
|
||||||
|
.WithFile(kept, "rb received command: [x]\n")
|
||||||
|
.WrittenAt(TestInstallation.At("log.txt"), Launched.AddHours(-2))
|
||||||
|
.WrittenAt(kept, Launched.AddMinutes(10)));
|
||||||
|
installation.Clock.Now = Launched;
|
||||||
|
var tapes = installation.Tapes;
|
||||||
|
|
||||||
|
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "ra", new[] { "ra", "rb" });
|
||||||
|
|
||||||
|
Assert.Equal("rb", tapes.DrivenVehicle);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_vehicles_of_a_run_stay_with_it_when_the_next_begins()
|
||||||
|
{
|
||||||
|
var (tapes, installation) = TapesOfTrain("ra received command: [x]\nrb received command: [x]\n");
|
||||||
|
var written = Launched.AddMinutes(30);
|
||||||
|
installation.Files.WrittenAt(TestInstallation.At("log.txt"), written)
|
||||||
|
.WrittenAt(TestInstallation.At("physicslog", "ra.dat"), written)
|
||||||
|
.WrittenAt(TestInstallation.At("physicslog", "rb.dat"), written)
|
||||||
|
.WrittenAt(TestInstallation.At("physicslog", "obcy.dat"), written);
|
||||||
|
|
||||||
|
installation.Clock.Now = Launched.AddHours(1);
|
||||||
|
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "inny", new[] { "inny" });
|
||||||
|
|
||||||
|
var run = tapes.Sessions().Single();
|
||||||
|
Assert.Equal("Test", run.Launch!.SceneryName);
|
||||||
|
Assert.Equal(TapeRole.Driven, run.Tapes.Single(tape => tape.Vehicle == "rb").Role);
|
||||||
|
Assert.Equal(TapeRole.DrivenEarlier, run.Tapes.Single(tape => tape.Vehicle == "ra").Role);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Only_the_last_launches_are_remembered()
|
||||||
|
{
|
||||||
|
var installation = new TestInstallation(new InMemoryFileSystem());
|
||||||
|
var tapes = installation.Tapes;
|
||||||
|
for (int i = 0; i < 35; i++)
|
||||||
|
{
|
||||||
|
installation.Clock.Now = Launched.AddMinutes(i);
|
||||||
|
tapes.RecordLaunch($"s{i}.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>());
|
||||||
|
}
|
||||||
|
|
||||||
|
var noted = TapeLaunch.ParseAll(installation.Files.ReadAllText(
|
||||||
|
TestInstallation.At("physicslog", "starter-launch.txt"), System.Text.Encoding.UTF8));
|
||||||
|
|
||||||
|
Assert.Equal(30, noted.Count);
|
||||||
|
Assert.Equal("s5.scn", noted[0].Scenery);
|
||||||
|
Assert.Equal("s34.scn", noted[^1].Scenery);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_vehicle_the_commands_go_to_is_the_one_the_player_is_in()
|
||||||
|
{
|
||||||
|
var player = PlayerVehicles.None.After(
|
||||||
|
"[ 21.764 ]\t\tTrying to enter player train, \"lok-a\"\r\n" +
|
||||||
|
"[ 25.609 ]\t\tlok-a received command: [aidriverdisable]\r\n" +
|
||||||
|
"[ 255.539 ]\t\tKey pressed: [F5]\r\n" +
|
||||||
|
"[ 255.569 ]\t\tlok b received command: [aidriverdisable]\r\n" +
|
||||||
|
"lok-c received command: [cabchangebackward]\n");
|
||||||
|
|
||||||
|
Assert.Equal("lok-c", player.Current);
|
||||||
|
Assert.True(player.Used("LOK-A"));
|
||||||
|
Assert.True(player.Used("lok b"));
|
||||||
|
Assert.False(player.Used("lok-d"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Going_back_to_a_vehicle_makes_it_the_current_one_again()
|
||||||
|
{
|
||||||
|
var player = PlayerVehicles.None
|
||||||
|
.After("a received command: [x]\nb received command: [x]\n")
|
||||||
|
.After("A received command: [x]\n");
|
||||||
|
|
||||||
|
Assert.Equal("A", player.Current);
|
||||||
|
Assert.True(player.Used("b"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void After_a_change_of_vehicle_the_new_one_is_driven_and_the_first_was_driven_earlier()
|
||||||
|
{
|
||||||
|
var launch = new TapeLaunch(Launched, "td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(),
|
||||||
|
"ra", new[] { "ra", "s", "rb" });
|
||||||
|
var player = PlayerVehicles.None.After("ra received command: [x]\nrb received command: [x]\n");
|
||||||
|
|
||||||
|
Assert.Equal(TapeRole.Driven, launch.RoleOf("RB", player));
|
||||||
|
Assert.Equal(TapeRole.DrivenEarlier, launch.RoleOf("ra", player));
|
||||||
|
Assert.Equal(TapeRole.OwnConsist, launch.RoleOf("s", player));
|
||||||
|
Assert.Equal(TapeRole.Other, launch.RoleOf("obcy", player));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Before_the_first_command_the_vehicle_of_the_launch_is_driven()
|
||||||
|
{
|
||||||
|
var launch = new TapeLaunch(Launched, "td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "ra", new[] { "ra", "rb" });
|
||||||
|
|
||||||
|
Assert.Equal(TapeRole.Driven, launch.RoleOf("ra", PlayerVehicles.None));
|
||||||
|
Assert.Equal(TapeRole.OwnConsist, launch.RoleOf("rb", PlayerVehicles.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (HaslerTapes Tapes, TestInstallation Installation) TapesOfTrain(string log)
|
||||||
|
{
|
||||||
|
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||||
|
.WithFile(TestInstallation.At("physicslog", "ra.dat"), "t")
|
||||||
|
.WithFile(TestInstallation.At("physicslog", "rb.dat"), "t")
|
||||||
|
.WithFile(TestInstallation.At("physicslog", "obcy.dat"), "t")
|
||||||
|
.WithFile(TestInstallation.At("log.txt"), log));
|
||||||
|
installation.Clock.Now = Launched;
|
||||||
|
var tapes = installation.Tapes;
|
||||||
|
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "ra", new[] { "ra", "rb" }, "Test", "MPJ1");
|
||||||
|
return (tapes, installation);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_tape_of_the_vehicle_changed_to_comes_first_and_keeps_the_train()
|
||||||
|
{
|
||||||
|
var (tapes, _) = TapesOfTrain("ra received command: [x]\nrb received command: [x]\n");
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "rb", "ra", "obcy" }, tapes.List().Select(tape => tape.Vehicle));
|
||||||
|
Assert.Equal(new[] { TapeRole.Driven, TapeRole.DrivenEarlier, TapeRole.Other }, tapes.List().Select(tape => tape.Role));
|
||||||
|
Assert.Equal("rb", tapes.DrivenVehicle);
|
||||||
|
Assert.Equal("MPJ1", tapes.ContextOf(tapes.List().First()).Train);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_vehicle_of_another_train_changed_to_is_driven_without_the_number_of_the_first()
|
||||||
|
{
|
||||||
|
var (tapes, _) = TapesOfTrain("obcy received command: [x]\n");
|
||||||
|
|
||||||
|
var driven = tapes.List().First();
|
||||||
|
|
||||||
|
Assert.Equal("obcy", driven.Vehicle);
|
||||||
|
Assert.Null(tapes.ContextOf(driven).Train);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_change_of_vehicle_during_the_run_is_seen_at_the_next_look()
|
||||||
|
{
|
||||||
|
const string start = "ra received command: [x]\n";
|
||||||
|
var (tapes, installation) = TapesOfTrain(start);
|
||||||
|
Assert.Equal("ra", tapes.DrivenVehicle);
|
||||||
|
|
||||||
|
installation.Files.WithFile(TestInstallation.At("log.txt"), start + "rb received com");
|
||||||
|
Assert.Equal("ra", tapes.DrivenVehicle);
|
||||||
|
|
||||||
|
installation.Files.WithFile(TestInstallation.At("log.txt"), start + "rb received command: [x]\n");
|
||||||
|
Assert.Equal("rb", tapes.DrivenVehicle);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_log_begun_anew_forgets_the_vehicles_of_the_old_one()
|
||||||
|
{
|
||||||
|
var (tapes, installation) = TapesOfTrain("ra received command: [x]\nrb received command: [x]\n");
|
||||||
|
Assert.Equal("rb", tapes.DrivenVehicle);
|
||||||
|
|
||||||
|
installation.Files.WithFile(TestInstallation.At("log.txt"), "obcy received command: [x]\n");
|
||||||
|
|
||||||
|
Assert.Equal("obcy", tapes.DrivenVehicle);
|
||||||
|
Assert.Equal(TapeRole.OwnConsist, tapes.List().Single(tape => tape.Vehicle == "rb").Role);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_log_older_than_the_launch_says_nothing_of_it()
|
||||||
|
{
|
||||||
|
var (tapes, installation) = TapesOfTrain("rb received command: [x]\n");
|
||||||
|
installation.Clock.Now = installation.Files.LastWriteTimeUtc.AddMinutes(10);
|
||||||
|
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "ra", new[] { "ra", "rb" });
|
||||||
|
|
||||||
|
Assert.Equal("ra", tapes.DrivenVehicle);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_tape_older_than_the_last_launch_has_no_part_in_it()
|
||||||
|
{
|
||||||
|
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||||
|
.WithFile(TestInstallation.At("physicslog", "c-lok.dat"), "t"));
|
||||||
|
installation.Clock.Now = installation.Files.LastWriteTimeUtc.AddDays(1);
|
||||||
|
var tapes = installation.Tapes;
|
||||||
|
|
||||||
|
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "c-lok");
|
||||||
|
|
||||||
|
Assert.Equal(TapeRole.Other, tapes.List().Single().Role);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_log_written_before_the_launch_is_not_taken_for_one_of_it()
|
||||||
|
{
|
||||||
|
var launch = new TapeLaunch(Launched, "td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>());
|
||||||
|
|
||||||
|
Assert.True(launch.Covers(Launched.AddMinutes(3)));
|
||||||
|
Assert.False(launch.Covers(Launched.AddHours(-1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("pkp/ep07_v1", "ep07-424", RecorderKind.Hasler)]
|
||||||
|
[InlineData("pkp/en57al_v1", "en57al-1234", RecorderKind.Electronic)]
|
||||||
|
[InlineData("pkp/x4e_v1", "x4e-659", RecorderKind.Electronic)]
|
||||||
|
[InlineData("pkp/et22_v3", "et22-1100", RecorderKind.Hasler)]
|
||||||
|
public void The_recorder_follows_the_vehicle_behind_the_name(string folder, string type, RecorderKind kind) =>
|
||||||
|
Assert.Equal(kind, Recorders.Guess(new TapeVehicle("poc1_lok", folder, type), "poc1_lok"));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Without_a_launch_the_tape_name_is_all_there_is_to_go_by()
|
||||||
|
{
|
||||||
|
Assert.Equal(RecorderKind.Electronic, Recorders.Guess(null, "ED250-001"));
|
||||||
|
Assert.Equal(RecorderKind.Hasler, Recorders.Guess(null, "eu07-193"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_tape_of_the_last_launch_gets_its_clock_and_recorder()
|
||||||
|
{
|
||||||
|
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||||
|
.WithFile(TestInstallation.At("physicslog", "ED250-001.dat"), "t"));
|
||||||
|
installation.Clock.Now = Launched;
|
||||||
|
var tapes = installation.Tapes;
|
||||||
|
|
||||||
|
tapes.RecordLaunch("lk4.scn", new TimeSpan(14, 5, 0),
|
||||||
|
new[] { new TapeVehicle("ED250-001", "pkp/ed250_v1", "ed250-001") });
|
||||||
|
var context = tapes.ContextOf(tapes.List().Single());
|
||||||
|
|
||||||
|
Assert.Equal(new TimeSpan(14, 5, 0), context.StartClock);
|
||||||
|
Assert.Equal(RecorderKind.Electronic, context.Recorder);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_tape_older_than_the_last_launch_keeps_its_hour_unknown()
|
||||||
|
{
|
||||||
|
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||||
|
.WithFile(TestInstallation.At("physicslog", "EU07-424.dat"), "t"));
|
||||||
|
installation.Clock.Now = installation.Files.LastWriteTimeUtc.AddDays(1);
|
||||||
|
var tapes = installation.Tapes;
|
||||||
|
|
||||||
|
tapes.RecordLaunch("td.scn", new TimeSpan(8, 0, 0), Array.Empty<TapeVehicle>());
|
||||||
|
|
||||||
|
Assert.Null(tapes.ContextOf(tapes.List().Single()).StartClock);
|
||||||
|
}
|
||||||
|
}
|
||||||
197
StarterNG.Tests/TexRandomizerTests.cs
Normal file
197
StarterNG.Tests/TexRandomizerTests.cs
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
using StarterNG.Classes;
|
||||||
|
using StarterNG.Domain;
|
||||||
|
using StarterNG.Domain.Vehicles;
|
||||||
|
using StarterNG.Tests.Fakes;
|
||||||
|
|
||||||
|
namespace StarterNG.Tests;
|
||||||
|
|
||||||
|
public class TexRandomizerTests
|
||||||
|
{
|
||||||
|
private readonly Consist _consist;
|
||||||
|
private readonly VehicleCatalog _catalog;
|
||||||
|
|
||||||
|
public TexRandomizerTests()
|
||||||
|
{
|
||||||
|
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||||
|
.WithFile(TestInstallation.At("dynamic", "pkp", "ep07", "textures.txt"),
|
||||||
|
"!=e\nep07-001.mat=ep07,EP07\nep07-002.mat=ep07,EP07")
|
||||||
|
.WithFile(TestInstallation.At("dynamic", "pkp", "en57", "textures.txt"),
|
||||||
|
"!=z\n^2\nen57-a.mat=en57a,EN57\nen57-b.mat=en57b,EN57\nen57-c.mat=en57a,EN57\nen57-d.mat=en57b,EN57")
|
||||||
|
.WithFile(TestInstallation.At("dynamic", "pkp", "b111", "textures.txt"),
|
||||||
|
"!=b\nb111-01.mat=b111,B111\nb111-02.mat=b111,B111"));
|
||||||
|
installation.Vehicles.Load(installation.Library.Vehicles);
|
||||||
|
|
||||||
|
_catalog = installation.Library.Vehicles;
|
||||||
|
_consist = new Consist(_catalog, new VehicleInfo(_catalog, installation.Physics));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Dynamic Car(string skin) => _consist.MakeDynamic(_catalog.TextureForSkin(skin)!, null);
|
||||||
|
|
||||||
|
private ConsistItem Put(params Dynamic[] cars)
|
||||||
|
{
|
||||||
|
var item = new ConsistItem
|
||||||
|
{
|
||||||
|
Cars = cars.ToList(),
|
||||||
|
Grouped = cars.Length > 1,
|
||||||
|
Driver = Consist.UnitDriver(cars)
|
||||||
|
};
|
||||||
|
_consist.Insert(_consist.Count, item);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The randomizer with the next pick in line always the other livery of two.</summary>
|
||||||
|
private int Randomize() => new TexRandomizer(_catalog, new SecondPick()).Apply(_consist, t => _consist.MakeDynamic(t, null));
|
||||||
|
|
||||||
|
private sealed class SecondPick : Random
|
||||||
|
{
|
||||||
|
public override int Next(int maxValue) => 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(eDriverType.Headdriver)]
|
||||||
|
[InlineData(eDriverType.Reardriver)]
|
||||||
|
[InlineData(eDriverType.Passenger)]
|
||||||
|
public void The_crew_stays_on_the_vehicle_that_takes_over(eDriverType crew)
|
||||||
|
{
|
||||||
|
var loco = Car("ep07-001");
|
||||||
|
loco.DriverType = crew;
|
||||||
|
Put(loco);
|
||||||
|
|
||||||
|
Assert.Equal(1, Randomize());
|
||||||
|
|
||||||
|
var car = Assert.Single(_consist[0].Cars);
|
||||||
|
Assert.Equal("ep07-002", car.SkinFile);
|
||||||
|
Assert.Equal(crew, car.DriverType);
|
||||||
|
Assert.Equal(crew, _consist[0].Driver);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_driver_kept_is_what_the_export_writes()
|
||||||
|
{
|
||||||
|
var loco = Car("ep07-001");
|
||||||
|
loco.DriverType = eDriverType.Headdriver;
|
||||||
|
Put(loco);
|
||||||
|
|
||||||
|
Randomize();
|
||||||
|
|
||||||
|
Assert.Contains(" headdriver ", _consist[0].Cars[0].ToTrainsetNode());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void An_empty_locomotive_stays_empty()
|
||||||
|
{
|
||||||
|
// Consist.Replace would staff it; a driver of its own is not this one's to give.
|
||||||
|
Put(Car("ep07-001"));
|
||||||
|
|
||||||
|
Randomize();
|
||||||
|
|
||||||
|
Assert.Equal(eDriverType.Nobody, _consist[0].Cars[0].DriverType);
|
||||||
|
Assert.Equal(eDriverType.Nobody, _consist[0].Driver);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_vehicle_keeps_facing_the_way_it_did()
|
||||||
|
{
|
||||||
|
var loco = Car("ep07-001");
|
||||||
|
loco.Offset = -1f;
|
||||||
|
var item = Put(loco);
|
||||||
|
item.Flipped = true;
|
||||||
|
|
||||||
|
Randomize();
|
||||||
|
|
||||||
|
Assert.True(_consist[0].Flipped);
|
||||||
|
Assert.True(Consist.IsTurned(_consist[0].Cars[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_vehicle_keeps_its_load()
|
||||||
|
{
|
||||||
|
var wagon = Car("b111-01");
|
||||||
|
wagon.LoadType = "Passengers";
|
||||||
|
wagon.LoadCount = 40;
|
||||||
|
wagon.MaxLoad = 60;
|
||||||
|
Put(wagon);
|
||||||
|
|
||||||
|
Assert.Equal(1, Randomize());
|
||||||
|
|
||||||
|
var car = _consist[0].Cars[0];
|
||||||
|
Assert.Equal("b111-02", car.SkinFile);
|
||||||
|
Assert.Equal("Passengers", car.LoadType);
|
||||||
|
Assert.Equal(40, car.LoadCount);
|
||||||
|
Assert.Equal(60, car.MaxLoad);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_unit_of_the_same_length_keeps_all_of_it_car_by_car()
|
||||||
|
{
|
||||||
|
var first = Car("en57-a");
|
||||||
|
var second = Car("en57-b");
|
||||||
|
second.DriverType = eDriverType.Reardriver;
|
||||||
|
first.LoadType = "Passengers";
|
||||||
|
first.LoadCount = 10;
|
||||||
|
second.LoadType = "Passengers";
|
||||||
|
second.LoadCount = 20;
|
||||||
|
first.Offset = -1f;
|
||||||
|
second.Offset = -1f;
|
||||||
|
var item = Put(first, second);
|
||||||
|
item.Flipped = true;
|
||||||
|
|
||||||
|
Assert.Equal(1, Randomize());
|
||||||
|
|
||||||
|
var unit = _consist[0];
|
||||||
|
Assert.Equal(new[] { "en57-c", "en57-d" }, unit.Cars.Select(c => c.SkinFile));
|
||||||
|
Assert.True(unit.Grouped);
|
||||||
|
Assert.True(unit.Flipped);
|
||||||
|
Assert.Equal(new[] { eDriverType.Nobody, eDriverType.Reardriver }, unit.Cars.Select(c => c.DriverType));
|
||||||
|
Assert.Equal(eDriverType.Reardriver, unit.Driver);
|
||||||
|
Assert.Equal(new[] { 10, 20 }, unit.Cars.Select(c => c.LoadCount));
|
||||||
|
Assert.All(unit.Cars, c => Assert.True(Consist.IsTurned(c)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(eDriverType.Headdriver, 0)]
|
||||||
|
[InlineData(eDriverType.Reardriver, 1)]
|
||||||
|
public void A_crew_moving_into_a_longer_unit_sits_in_the_car_with_its_cab(eDriverType crew, int car)
|
||||||
|
{
|
||||||
|
var loco = Car("en57-c");
|
||||||
|
loco.DriverType = crew;
|
||||||
|
Put(loco);
|
||||||
|
|
||||||
|
Assert.Equal(1, new TexRandomizer(_catalog, new FirstPick()).Apply(_consist, t => _consist.MakeDynamic(t, null)));
|
||||||
|
|
||||||
|
var unit = _consist[0];
|
||||||
|
Assert.Equal(2, unit.Cars.Count);
|
||||||
|
Assert.Equal(crew, unit.Cars[car].DriverType);
|
||||||
|
Assert.Equal(eDriverType.Nobody, unit.Cars[1 - car].DriverType);
|
||||||
|
Assert.Equal(crew, unit.Driver);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FirstPick : Random
|
||||||
|
{
|
||||||
|
public override int Next(int maxValue) => 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_selected_vehicle_stays_selected_as_the_one_that_took_over()
|
||||||
|
{
|
||||||
|
var item = Put(Car("ep07-001"));
|
||||||
|
_consist.Selected = item;
|
||||||
|
|
||||||
|
Randomize();
|
||||||
|
|
||||||
|
Assert.Same(_consist[0], _consist.Selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_vehicle_not_selected_does_not_take_the_selection()
|
||||||
|
{
|
||||||
|
// ep07-001's turn picks ep07-002; ep07-002's turn picks itself and passes.
|
||||||
|
Put(Car("ep07-001"));
|
||||||
|
var selected = Put(Car("ep07-002"));
|
||||||
|
_consist.Selected = selected;
|
||||||
|
|
||||||
|
Assert.Equal(1, Randomize());
|
||||||
|
|
||||||
|
Assert.Same(selected, _consist.Selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
77
StarterNG.Tests/TrainsetDisplayTests.cs
Normal file
77
StarterNG.Tests/TrainsetDisplayTests.cs
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
using StarterNG.Classes;
|
||||||
|
using StarterNG.Domain;
|
||||||
|
using StarterNG.Views;
|
||||||
|
|
||||||
|
namespace StarterNG.Tests;
|
||||||
|
|
||||||
|
public class TrainsetDisplayTests
|
||||||
|
{
|
||||||
|
private static Trainset Consist(string driver = "headdriver", string? description = null) =>
|
||||||
|
new(string.Concat(
|
||||||
|
description is null ? "" : $"//$o {description}\n",
|
||||||
|
$"trainset a tor 0 0\nnode -1 0 v1 dynamic d s m 0 {driver} 3 0 enddynamic\nendtrainset"));
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("-ai", true)]
|
||||||
|
[InlineData(" - ai", true)]
|
||||||
|
[InlineData("a - b", false)]
|
||||||
|
[InlineData("rozkladowy", false)]
|
||||||
|
public void A_dash_opening_the_description_says_the_consist_is_ai(string description, bool ai) =>
|
||||||
|
Assert.Equal(ai, TrainsetDisplay.IsAi(Consist(description: description)));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void No_description_is_no_ai() =>
|
||||||
|
Assert.False(TrainsetDisplay.IsAi(Consist()));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void An_ai_consist_is_listed_only_when_they_are_shown()
|
||||||
|
{
|
||||||
|
var ai = Consist(description: "-ai");
|
||||||
|
|
||||||
|
Assert.False(TrainsetDisplay.ListedIn(ai, showAi: false, drivableOnly: false, current: null));
|
||||||
|
Assert.True(TrainsetDisplay.ListedIn(ai, showAi: true, drivableOnly: false, current: null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Drivable_only_leaves_out_a_consist_nobody_sits_in()
|
||||||
|
{
|
||||||
|
var empty = Consist(driver: "nobody");
|
||||||
|
|
||||||
|
Assert.False(TrainsetDisplay.ListedIn(empty, showAi: true, drivableOnly: true, current: null));
|
||||||
|
Assert.True(TrainsetDisplay.ListedIn(empty, showAi: true, drivableOnly: false, current: null));
|
||||||
|
Assert.True(TrainsetDisplay.ListedIn(Consist(), showAi: true, drivableOnly: true, current: null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Drivable_only_keeps_the_one_picked_whatever_its_crew()
|
||||||
|
{
|
||||||
|
var picked = Consist(driver: "nobody");
|
||||||
|
|
||||||
|
Assert.True(TrainsetDisplay.ListedIn(picked, showAi: true, drivableOnly: true, current: picked));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Being_picked_does_not_show_an_ai_consist_the_filter_hides()
|
||||||
|
{
|
||||||
|
var picked = Consist(description: "-ai");
|
||||||
|
|
||||||
|
Assert.False(TrainsetDisplay.ListedIn(picked, showAi: false, drivableOnly: true, current: picked));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(eDriverType.Headdriver, 0)]
|
||||||
|
[InlineData(eDriverType.Reardriver, 1)]
|
||||||
|
[InlineData(eDriverType.Passenger, 2)]
|
||||||
|
[InlineData(eDriverType.Nobody, 3)]
|
||||||
|
public void A_crew_keeps_its_place_in_the_list(eDriverType crew, int place)
|
||||||
|
{
|
||||||
|
Assert.Equal(place, CrewChoices.IndexOf(crew));
|
||||||
|
Assert.Equal(crew, CrewChoices.At(place));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(-1)]
|
||||||
|
[InlineData(4)]
|
||||||
|
public void A_list_with_nothing_selected_means_nobody(int place) =>
|
||||||
|
Assert.Equal(eDriverType.Nobody, CrewChoices.At(place));
|
||||||
|
}
|
||||||
162
StarterNG.Tests/VehicleBaseQueryTests.cs
Normal file
162
StarterNG.Tests/VehicleBaseQueryTests.cs
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
using StarterNG.Domain.Vehicles;
|
||||||
|
using StarterNG.Tests.Fakes;
|
||||||
|
using StarterNG.Views;
|
||||||
|
|
||||||
|
namespace StarterNG.Tests;
|
||||||
|
|
||||||
|
public class VehicleBaseQueryTests
|
||||||
|
{
|
||||||
|
private static VehicleBaseRow Row(string skin, string? operatorName = null, string? revision = null,
|
||||||
|
string? author = null, string? depot = null, string? photo = null,
|
||||||
|
string directory = "dynamic/pkp/", string? model = "model")
|
||||||
|
{
|
||||||
|
return new VehicleBaseRow(new VehicleTexture
|
||||||
|
{
|
||||||
|
Directory = directory,
|
||||||
|
Skinfile = skin,
|
||||||
|
Model = model,
|
||||||
|
Meta = new VehicleMeta
|
||||||
|
{
|
||||||
|
Operator = operatorName,
|
||||||
|
RevisionDate = revision,
|
||||||
|
TextureAuthor = author,
|
||||||
|
Depot = depot,
|
||||||
|
PhotoAuthor = photo
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<VehicleTexture> Catalogued(params string[] textures)
|
||||||
|
{
|
||||||
|
var files = new InMemoryFileSystem();
|
||||||
|
foreach (string text in textures)
|
||||||
|
files.WithFile(TestInstallation.At("dynamic", "pkp", text[..text.IndexOf('=')], "textures.txt"),
|
||||||
|
text[(text.IndexOf('=') + 1)..]);
|
||||||
|
var installation = new TestInstallation(files);
|
||||||
|
installation.Vehicles.Load(installation.Library.Vehicles);
|
||||||
|
return installation.Library.Vehicles.Textures;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string[] Skins(IEnumerable<VehicleBaseRow> rows) => rows.Select(r => r.Skin).ToArray();
|
||||||
|
|
||||||
|
private static string[] Selected(VehicleBaseFilters filters, params VehicleBaseRow[] rows) =>
|
||||||
|
Skins(VehicleBaseQuery.Select(rows, filters, r => r.Skin, descending: false));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_question_mark_in_the_credit_line_is_what_its_author_did_not_know()
|
||||||
|
{
|
||||||
|
var row = Row("a", operatorName: "?", revision: "?", author: "Jan");
|
||||||
|
|
||||||
|
Assert.Equal("", row.Operator);
|
||||||
|
Assert.Equal("", row.Revision);
|
||||||
|
Assert.Null(row.Date);
|
||||||
|
Assert.Equal("", row.DateKey);
|
||||||
|
Assert.Equal("Jan", row.Author);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_date_key_sorts_as_the_dates_do() =>
|
||||||
|
Assert.Equal("20081201", Row("a", revision: "xx.12.2008").DateKey);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Each_filter_looks_in_its_own_field_ignoring_case_and_spaces_around()
|
||||||
|
{
|
||||||
|
var a = Row("alfa", operatorName: "PKP", author: "Jan", depot: "Lublin", photo: "Ewa", directory: "dynamic/one/");
|
||||||
|
var b = Row("beta", operatorName: "CD", author: "Olaf", depot: "Praha", photo: "Karel", directory: "dynamic/two/");
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "alfa" }, Selected(new VehicleBaseFilters { Name = " ALF " }, a, b));
|
||||||
|
Assert.Equal(new[] { "beta" }, Selected(new VehicleBaseFilters { Operator = "cd" }, a, b));
|
||||||
|
Assert.Equal(new[] { "alfa" }, Selected(new VehicleBaseFilters { Author = "jan" }, a, b));
|
||||||
|
Assert.Equal(new[] { "beta" }, Selected(new VehicleBaseFilters { Station = "prah" }, a, b));
|
||||||
|
Assert.Equal(new[] { "alfa" }, Selected(new VehicleBaseFilters { Photo = "EW" }, a, b));
|
||||||
|
Assert.Equal(new[] { "beta" }, Selected(new VehicleBaseFilters { Model = "TWO/" }, a, b));
|
||||||
|
Assert.Empty(Selected(new VehicleBaseFilters { Name = "alf", Operator = "cd" }, a, b));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
public void A_filter_with_nothing_typed_lets_everything_through(string? typed) =>
|
||||||
|
Assert.Equal(new[] { "a", "b" }, Selected(new VehicleBaseFilters { Name = typed }, Row("a"), Row("b")));
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("2016", null, true)]
|
||||||
|
[InlineData("2016-12", null, true)]
|
||||||
|
[InlineData("2017", null, false)]
|
||||||
|
[InlineData(null, "2016", true)]
|
||||||
|
[InlineData(null, "2015", false)]
|
||||||
|
[InlineData("2010", "2020", true)]
|
||||||
|
public void A_revision_counts_while_its_span_overlaps_what_is_asked(string? from, string? to, bool shown)
|
||||||
|
{
|
||||||
|
var row = Row("a", revision: "xx.xx.2016");
|
||||||
|
|
||||||
|
var found = Selected(new VehicleBaseFilters { RevisionFrom = from, RevisionTo = to }, row);
|
||||||
|
|
||||||
|
Assert.Equal(shown, found.Length == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_month_only_date_overlaps_at_both_of_its_ends()
|
||||||
|
{
|
||||||
|
var row = Row("a", revision: "xx.06.2008");
|
||||||
|
|
||||||
|
Assert.Single(Selected(new VehicleBaseFilters { RevisionFrom = "30.06.2008" }, row));
|
||||||
|
Assert.Single(Selected(new VehicleBaseFilters { RevisionTo = "01.06.2008" }, row));
|
||||||
|
Assert.Empty(Selected(new VehicleBaseFilters { RevisionFrom = "01.07.2008" }, row));
|
||||||
|
Assert.Empty(Selected(new VehicleBaseFilters { RevisionTo = "31.05.2008" }, row));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_revision_asked_for_leaves_out_what_has_no_date_and_what_cannot_be_read_asks_nothing()
|
||||||
|
{
|
||||||
|
var dated = Row("dated", revision: "2016");
|
||||||
|
var undated = Row("undated");
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "dated" }, Selected(new VehicleBaseFilters { RevisionFrom = "2000" }, dated, undated));
|
||||||
|
Assert.Equal(new[] { "dated", "undated" },
|
||||||
|
Selected(new VehicleBaseFilters { RevisionFrom = "soon" }, dated, undated));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false, new[] { "b", "c", "a", "d" })]
|
||||||
|
[InlineData(true, new[] { "c", "b", "a", "d" })]
|
||||||
|
public void Blanks_stay_at_the_bottom_whichever_way_a_column_is_sorted_and_the_name_settles_a_tie(
|
||||||
|
bool descending, string[] order)
|
||||||
|
{
|
||||||
|
var rows = new[] { Row("d"), Row("c", operatorName: "Z"), Row("a"), Row("b", operatorName: "M") };
|
||||||
|
|
||||||
|
Assert.Equal(order, Skins(VehicleBaseQuery.Select(rows, new VehicleBaseFilters(), r => r.Operator, descending)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_class_list_counts_a_class_under_one_name_whatever_its_case_in_order_of_the_names()
|
||||||
|
{
|
||||||
|
var textures = Catalogued("b111=!=b\nb111-01.mat=b111,B111\nb111-02.mat=b222,b111",
|
||||||
|
"ep07=!=e\nep07-01.mat=ep07,AP07");
|
||||||
|
|
||||||
|
var counts = VehicleBaseQuery.ClassCounts(textures.Select(t => new VehicleBaseRow(t)));
|
||||||
|
|
||||||
|
Assert.Equal(new[] { ("AP07", 1), ("B111", 2) },
|
||||||
|
counts.Select(c => (c.Name.ToUpperInvariant(), c.Count)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_row_with_no_class_is_not_counted() =>
|
||||||
|
Assert.Empty(VehicleBaseQuery.ClassCounts(new[] { Row("a") }));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_class_picked_has_to_match_whole_while_the_typed_one_only_has_to_appear()
|
||||||
|
{
|
||||||
|
var textures = Catalogued("b111=!=b\nb111-01.mat=b111,B111");
|
||||||
|
var row = new VehicleBaseRow(textures[0]);
|
||||||
|
Assert.NotEqual("", row.Class);
|
||||||
|
|
||||||
|
Assert.Single(VehicleBaseQuery.Select(new[] { row }, new VehicleBaseFilters { PickedClass = row.Class.ToUpperInvariant() },
|
||||||
|
r => r.Skin, descending: false));
|
||||||
|
Assert.Empty(VehicleBaseQuery.Select(new[] { row }, new VehicleBaseFilters { PickedClass = row.Class[..1] + "?" },
|
||||||
|
r => r.Skin, descending: false));
|
||||||
|
Assert.Single(VehicleBaseQuery.Select(new[] { row }, new VehicleBaseFilters { Class = row.Class[..1] },
|
||||||
|
r => r.Skin, descending: false));
|
||||||
|
}
|
||||||
|
}
|
||||||
139
StarterNG.Tests/WarehouseEditTests.cs
Normal file
139
StarterNG.Tests/WarehouseEditTests.cs
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
using StarterNG.Application;
|
||||||
|
using StarterNG.Classes;
|
||||||
|
using StarterNG.Tests.Fakes;
|
||||||
|
|
||||||
|
namespace StarterNG.Tests;
|
||||||
|
|
||||||
|
// PresetStore keeps the disk of the first test that reaches it, a fake one as every
|
||||||
|
// composition root in the tests is built on fakes. So each test has a name to itself.
|
||||||
|
public class WarehouseEditTests
|
||||||
|
{
|
||||||
|
private const string Text =
|
||||||
|
"trainset a tor 0 0\nnode -1 0 v1 dynamic d s m 0 headdriver 3 0 enddynamic\nendtrainset\n";
|
||||||
|
|
||||||
|
private static readonly string Entry = ConsistText.Serialize(new Trainset(Text));
|
||||||
|
|
||||||
|
private readonly WarehouseEdit _edit = new();
|
||||||
|
|
||||||
|
public WarehouseEditTests()
|
||||||
|
{
|
||||||
|
var installation = new TestInstallation(new InMemoryFileSystem());
|
||||||
|
AppServices.Initialize(installation.Paths, installation.Files, installation.Clock, installation.Environment);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TrainsetPreset Preset(string name, string? entry = null) => new() { Name = name, Entry = entry ?? Entry };
|
||||||
|
|
||||||
|
private static TrainsetPreset? Stored(string name) => PresetStore.All().FirstOrDefault(p => p.Name == name);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Opening_says_what_is_edited_and_closing_that_nothing_is()
|
||||||
|
{
|
||||||
|
var trainset = _edit.Open(Preset("edit-open"));
|
||||||
|
|
||||||
|
Assert.Same(trainset, _edit.Trainset);
|
||||||
|
Assert.Equal("edit-open", _edit.Name);
|
||||||
|
|
||||||
|
_edit.Close();
|
||||||
|
|
||||||
|
Assert.Null(_edit.Trainset);
|
||||||
|
Assert.Null(_edit.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_consist_opened_again_is_the_same_trainset_so_its_undo_history_outlives_a_switch()
|
||||||
|
{
|
||||||
|
var first = _edit.Open(Preset("edit-switch"));
|
||||||
|
_edit.Open(Preset("edit-switch-other"));
|
||||||
|
|
||||||
|
Assert.Same(first, _edit.Open(Preset("edit-switch")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void An_entry_written_over_from_elsewhere_is_read_afresh()
|
||||||
|
{
|
||||||
|
var first = _edit.Open(Preset("edit-over"));
|
||||||
|
|
||||||
|
var second = _edit.Open(Preset("edit-over", ConsistText.Serialize(new Trainset(Text.Replace(" v1 ", " v9 ")))));
|
||||||
|
|
||||||
|
Assert.NotSame(first, second);
|
||||||
|
Assert.Equal("v9", second.Vehicles.Single().Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_name_of_a_consist_goes_with_any_letter_case()
|
||||||
|
{
|
||||||
|
var first = _edit.Open(Preset("edit-case"));
|
||||||
|
|
||||||
|
Assert.Same(first, _edit.Open(Preset("EDIT-CASE")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Nothing_is_written_while_the_consist_stands_as_it_was_read()
|
||||||
|
{
|
||||||
|
PresetStore.Save("edit-unchanged", Entry);
|
||||||
|
_edit.Open(Stored("edit-unchanged")!);
|
||||||
|
PresetStore.Delete("edit-unchanged");
|
||||||
|
|
||||||
|
_edit.Save();
|
||||||
|
|
||||||
|
Assert.Null(Stored("edit-unchanged"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_change_is_written_once()
|
||||||
|
{
|
||||||
|
PresetStore.Save("edit-changed", Entry);
|
||||||
|
var trainset = _edit.Open(Stored("edit-changed")!);
|
||||||
|
trainset.Vehicles.Add(trainset.Vehicles[0].Clone());
|
||||||
|
|
||||||
|
_edit.Save();
|
||||||
|
|
||||||
|
Assert.Equal(ConsistText.Serialize(trainset), Stored("edit-changed")!.Entry);
|
||||||
|
|
||||||
|
PresetStore.Delete("edit-changed");
|
||||||
|
_edit.Save();
|
||||||
|
|
||||||
|
Assert.Null(Stored("edit-changed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Nothing_is_written_with_nothing_open()
|
||||||
|
{
|
||||||
|
var trainset = _edit.Open(Preset("edit-closed"));
|
||||||
|
trainset.Vehicles.Add(trainset.Vehicles[0].Clone());
|
||||||
|
_edit.Close();
|
||||||
|
|
||||||
|
_edit.Save();
|
||||||
|
|
||||||
|
Assert.Null(Stored("edit-closed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_rename_keeps_the_trainset_and_saves_under_the_new_name()
|
||||||
|
{
|
||||||
|
var trainset = _edit.Open(Preset("edit-old"));
|
||||||
|
|
||||||
|
_edit.Renamed("edit-old", "edit-new");
|
||||||
|
|
||||||
|
Assert.Equal("edit-new", _edit.Name);
|
||||||
|
trainset.Vehicles.Add(trainset.Vehicles[0].Clone());
|
||||||
|
_edit.Save();
|
||||||
|
Assert.Null(Stored("edit-old"));
|
||||||
|
Assert.Equal(ConsistText.Serialize(trainset), Stored("edit-new")!.Entry);
|
||||||
|
|
||||||
|
Assert.Same(trainset, _edit.Open(Preset("edit-new", Stored("edit-new")!.Entry)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Renaming_another_consist_leaves_the_one_open_alone()
|
||||||
|
{
|
||||||
|
var other = _edit.Open(Preset("edit-other"));
|
||||||
|
var open = _edit.Open(Preset("edit-mine"));
|
||||||
|
|
||||||
|
_edit.Renamed("edit-other", "edit-other-new");
|
||||||
|
|
||||||
|
Assert.Same(open, _edit.Trainset);
|
||||||
|
Assert.Equal("edit-mine", _edit.Name);
|
||||||
|
Assert.Same(other, _edit.Open(Preset("edit-other-new")));
|
||||||
|
}
|
||||||
|
}
|
||||||
209
StarterNG.Tests/WineConfigLinkTests.cs
Normal file
209
StarterNG.Tests/WineConfigLinkTests.cs
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
using StarterNG.Infrastructure;
|
||||||
|
using StarterNG.Tests.Fakes;
|
||||||
|
|
||||||
|
namespace StarterNG.Tests;
|
||||||
|
|
||||||
|
public class WineConfigLinkTests
|
||||||
|
{
|
||||||
|
// Rooted the way the host roots a path: the link is made to the folder's full
|
||||||
|
// path, and on a Windows host "/home" alone is not one. Wine itself only runs off
|
||||||
|
// Windows, but what is decided here is the same wherever these tests run.
|
||||||
|
private static readonly string Home = Path.GetFullPath("/home/kolejarz");
|
||||||
|
private static readonly string Config = Path.GetFullPath(Home + "/.config/MaSzyna");
|
||||||
|
private static readonly string Roaming = Home + "/.wine/drive_c/users/kolejarz/AppData/Roaming";
|
||||||
|
private static readonly string PrefixFolder = Roaming + "/MaSzyna";
|
||||||
|
|
||||||
|
private static (WineConfigLink Link, TestInstallation Installation) Rig(Action<InMemoryFileSystem>? arrange = null)
|
||||||
|
{
|
||||||
|
var files = new InMemoryFileSystem(caseSensitive: true)
|
||||||
|
.WithFile(Config + "/eu07.ini", "fpslimit 30\n");
|
||||||
|
arrange?.Invoke(files);
|
||||||
|
var installation = new TestInstallation(files);
|
||||||
|
installation.Environment.With("HOME", Home).With("USER", "kolejarz");
|
||||||
|
return (new WineConfigLink(files, installation.Environment, installation.SettingsPaths, installation.Log),
|
||||||
|
installation);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_prefixs_own_folder_is_kept_aside_and_linked_to_the_starters()
|
||||||
|
{
|
||||||
|
var (link, installation) = Rig(files => files
|
||||||
|
.WithFile(PrefixFolder + "/eu07.ini", "fpslimit 0\n")
|
||||||
|
.WithFile(PrefixFolder + "/eu07_input-uart.ini", "port COM3\n"));
|
||||||
|
|
||||||
|
link.Ensure();
|
||||||
|
|
||||||
|
var files = installation.Files;
|
||||||
|
Assert.Equal(Config, files.LinkTarget(PrefixFolder));
|
||||||
|
Assert.Equal("fpslimit 0\n", files.ReadAllText(PrefixFolder + ".before-starter/eu07.ini"));
|
||||||
|
// What only the prefix had comes along; the starter's own eu07.ini stays.
|
||||||
|
Assert.Equal("port COM3\n", files.ReadAllText(Config + "/eu07_input-uart.ini"));
|
||||||
|
Assert.Equal("fpslimit 30\n", files.ReadAllText(Config + "/eu07.ini"));
|
||||||
|
Assert.Contains("now links to", installation.Log.Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_prefix_already_linked_is_left_alone()
|
||||||
|
{
|
||||||
|
var (link, installation) = Rig(files => files.WithDirectory(PrefixFolder));
|
||||||
|
link.Ensure();
|
||||||
|
|
||||||
|
link.Ensure();
|
||||||
|
|
||||||
|
Assert.Equal(Config, installation.Files.LinkTarget(PrefixFolder));
|
||||||
|
Assert.False(installation.Files.DirectoryExists(PrefixFolder + ".before-starter-2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_link_of_the_players_own_is_not_touched()
|
||||||
|
{
|
||||||
|
var (link, installation) = Rig(files =>
|
||||||
|
{
|
||||||
|
files.WithDirectory(Roaming);
|
||||||
|
files.CreateDirectoryLink(PrefixFolder, "/mnt/shared/MaSzyna");
|
||||||
|
});
|
||||||
|
|
||||||
|
link.Ensure();
|
||||||
|
|
||||||
|
Assert.Equal("/mnt/shared/MaSzyna", installation.Files.LinkTarget(PrefixFolder));
|
||||||
|
Assert.Contains("left as it is", installation.Log.Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Without_a_prefix_nothing_is_made()
|
||||||
|
{
|
||||||
|
var (link, installation) = Rig();
|
||||||
|
|
||||||
|
link.Ensure();
|
||||||
|
|
||||||
|
Assert.False(installation.Files.DirectoryExists(Home + "/.wine"));
|
||||||
|
Assert.Contains("no prefix", installation.Log.Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_prefix_named_in_wineprefix_is_the_one_linked()
|
||||||
|
{
|
||||||
|
string prefix = Path.GetFullPath("/games/wine-maszyna");
|
||||||
|
var (link, installation) = Rig(files => files
|
||||||
|
.WithDirectory(prefix + "/drive_c/users/kolejarz/AppData/Roaming")
|
||||||
|
.WithDirectory(Roaming));
|
||||||
|
installation.Environment.With("WINEPREFIX", prefix);
|
||||||
|
|
||||||
|
link.Ensure();
|
||||||
|
|
||||||
|
Assert.Equal(Config, installation.Files.LinkTarget(prefix + "/drive_c/users/kolejarz/AppData/Roaming/MaSzyna"));
|
||||||
|
Assert.Null(installation.Files.LinkTarget(PrefixFolder));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void An_older_backup_is_not_overwritten()
|
||||||
|
{
|
||||||
|
var (link, installation) = Rig(files => files
|
||||||
|
.WithFile(PrefixFolder + ".before-starter/eu07.ini", "first\n")
|
||||||
|
.WithFile(PrefixFolder + "/eu07.ini", "second\n"));
|
||||||
|
|
||||||
|
link.Ensure();
|
||||||
|
|
||||||
|
Assert.Equal("first\n", installation.Files.ReadAllText(PrefixFolder + ".before-starter/eu07.ini"));
|
||||||
|
Assert.Equal("second\n", installation.Files.ReadAllText(PrefixFolder + ".before-starter-2/eu07.ini"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void An_older_wine_keeps_application_data_where_xp_did()
|
||||||
|
{
|
||||||
|
string applicationData = Home + "/.wine/drive_c/users/kolejarz/Application Data";
|
||||||
|
var (link, installation) = Rig(files => files.WithDirectory(applicationData));
|
||||||
|
|
||||||
|
link.Ensure();
|
||||||
|
|
||||||
|
Assert.Equal(Config, installation.Files.LinkTarget(applicationData + "/MaSzyna"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_only_profile_is_used_when_the_user_name_differs()
|
||||||
|
{
|
||||||
|
string profile = Home + "/.wine/drive_c/users/steamuser/AppData/Roaming";
|
||||||
|
var (link, installation) = Rig(files => files
|
||||||
|
.WithDirectory(Home + "/.wine/drive_c/users/Public")
|
||||||
|
.WithDirectory(profile));
|
||||||
|
|
||||||
|
link.Ensure();
|
||||||
|
|
||||||
|
Assert.Equal(Config, installation.Files.LinkTarget(profile + "/MaSzyna"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_link_that_cannot_be_made_leaves_the_prefix_as_it_was()
|
||||||
|
{
|
||||||
|
var (link, installation) = Rig(files => files
|
||||||
|
.WithFile(PrefixFolder + "/eu07.ini", "fpslimit 0\n")
|
||||||
|
.WithFile(PrefixFolder + "/eu07_input-uart.ini", "port COM3\n"));
|
||||||
|
var files = installation.Files;
|
||||||
|
files.LinksFail = true;
|
||||||
|
|
||||||
|
link.Ensure();
|
||||||
|
|
||||||
|
Assert.Null(files.LinkTarget(PrefixFolder));
|
||||||
|
Assert.Equal("fpslimit 0\n", files.ReadAllText(PrefixFolder + "/eu07.ini"));
|
||||||
|
Assert.False(files.DirectoryExists(PrefixFolder + ".before-starter"));
|
||||||
|
// Nothing carried over stays behind for a move that did not happen.
|
||||||
|
Assert.False(files.FileExists(Config + "/eu07_input-uart.ini"));
|
||||||
|
Assert.Equal("fpslimit 30\n", files.ReadAllText(Config + "/eu07.ini"));
|
||||||
|
Assert.Contains("linking the settings", installation.Log.Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Attempts_that_fail_do_not_pile_up_backups()
|
||||||
|
{
|
||||||
|
var (link, installation) = Rig(files => files.WithFile(PrefixFolder + "/eu07.ini", "fpslimit 0\n"));
|
||||||
|
var files = installation.Files;
|
||||||
|
files.LinksFail = true;
|
||||||
|
|
||||||
|
link.Ensure();
|
||||||
|
link.Ensure();
|
||||||
|
link.Ensure();
|
||||||
|
|
||||||
|
Assert.False(files.DirectoryExists(PrefixFolder + ".before-starter"));
|
||||||
|
Assert.False(files.DirectoryExists(PrefixFolder + ".before-starter-2"));
|
||||||
|
|
||||||
|
files.LinksFail = false;
|
||||||
|
link.Ensure();
|
||||||
|
|
||||||
|
Assert.Equal(Config, files.LinkTarget(PrefixFolder));
|
||||||
|
Assert.Equal("fpslimit 0\n", files.ReadAllText(PrefixFolder + ".before-starter/eu07.ini"));
|
||||||
|
Assert.False(files.DirectoryExists(PrefixFolder + ".before-starter-2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_copy_that_fails_half_way_takes_back_what_it_had_copied()
|
||||||
|
{
|
||||||
|
var (link, installation) = Rig(files => files
|
||||||
|
.WithFile(PrefixFolder + "/a.ini", "first\n")
|
||||||
|
.WithFile(PrefixFolder + "/b.ini", "second\n")
|
||||||
|
// It is read from where the folder has been moved to by then.
|
||||||
|
.FailReading(PrefixFolder + ".before-starter/b.ini"));
|
||||||
|
var files = installation.Files;
|
||||||
|
|
||||||
|
link.Ensure();
|
||||||
|
|
||||||
|
Assert.Null(files.LinkTarget(PrefixFolder));
|
||||||
|
Assert.Equal("first\n", files.ReadAllText(PrefixFolder + "/a.ini"));
|
||||||
|
Assert.False(files.FileExists(Config + "/a.ini"));
|
||||||
|
Assert.False(files.DirectoryExists(PrefixFolder + ".before-starter"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_folder_that_cannot_be_put_back_is_said_to_be_where_it_is()
|
||||||
|
{
|
||||||
|
var (link, installation) = Rig(files => files.WithFile(PrefixFolder + "/eu07.ini", "fpslimit 0\n"));
|
||||||
|
var files = installation.Files;
|
||||||
|
files.LinksFail = true;
|
||||||
|
// In the way of the move back: made by whatever broke the link, say.
|
||||||
|
files.MoveFails = path => path.EndsWith("/Roaming/MaSzyna");
|
||||||
|
|
||||||
|
link.Ensure();
|
||||||
|
|
||||||
|
Assert.Equal("fpslimit 0\n", files.ReadAllText(PrefixFolder + ".before-starter/eu07.ini"));
|
||||||
|
Assert.Contains("stays at", installation.Log.Text);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,17 @@
|
|||||||
<StyleInclude Source="avares://Starter/Styles/Carbon.axaml" />
|
<StyleInclude Source="avares://Starter/Styles/Carbon.axaml" />
|
||||||
<StyleInclude Source="avares://Starter/Styles/Settings.axaml" />
|
<StyleInclude Source="avares://Starter/Styles/Settings.axaml" />
|
||||||
|
|
||||||
|
<!-- A dropdown whose options are bound to the translation table. The closed
|
||||||
|
box keeps a copy of the text picked, so it would stay in the old
|
||||||
|
language; this shows the option itself, which follows the change. -->
|
||||||
|
<Style Selector="ComboBox.Localized">
|
||||||
|
<Setter Property="SelectionBoxItemTemplate">
|
||||||
|
<DataTemplate>
|
||||||
|
<TextBlock Text="{Binding $parent[ComboBox].((ComboBoxItem)SelectedItem).Content}" />
|
||||||
|
</DataTemplate>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
<Style Selector="Button[(chrome|WindowDecorationProperties.ElementRole)=FullScreenButton]">
|
<Style Selector="Button[(chrome|WindowDecorationProperties.ElementRole)=FullScreenButton]">
|
||||||
<Setter Property="IsVisible" Value="False" />
|
<Setter Property="IsVisible" Value="False" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Controls.ApplicationLifetimes;
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
@@ -30,6 +27,7 @@ public partial class App : Avalonia.Application
|
|||||||
AvaloniaXamlLoader.Load(this);
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
|
||||||
AppServices.Current.SettingsStore.Load();
|
AppServices.Current.SettingsStore.Load();
|
||||||
|
AppServices.Current.SettingsStore.StartAtHighWithoutOwnSettings();
|
||||||
|
|
||||||
KeyboardConfig.Instance.Load();
|
KeyboardConfig.Instance.Load();
|
||||||
|
|
||||||
@@ -48,6 +46,10 @@ public partial class App : Avalonia.Application
|
|||||||
// actually display.
|
// actually display.
|
||||||
string requestedCode = AppServices.Current.Settings.LanguageCode;
|
string requestedCode = AppServices.Current.Settings.LanguageCode;
|
||||||
|
|
||||||
|
// Subscribed before any view, so the views rebuild in the new culture.
|
||||||
|
Loc.LanguageChanged += () => ApplyCulture(Loc.CurrentLangCode);
|
||||||
|
|
||||||
|
Loc.Keep(requestedCode);
|
||||||
ApplyLanguage(langName);
|
ApplyLanguage(langName);
|
||||||
|
|
||||||
// The starter ships no translation for this code, so its own interface
|
// The starter ships no translation for this code, so its own interface
|
||||||
@@ -60,15 +62,26 @@ public partial class App : Avalonia.Application
|
|||||||
|
|
||||||
public static void ApplyLanguage(string langNameOrCode)
|
public static void ApplyLanguage(string langNameOrCode)
|
||||||
{
|
{
|
||||||
|
|
||||||
var resolvedName = Loc.Load(langNameOrCode);
|
var resolvedName = Loc.Load(langNameOrCode);
|
||||||
|
|
||||||
// The service is the authority on which language actually loaded: the code
|
// The service is the authority on which language actually loaded: the code
|
||||||
// goes to eu07.ini for the simulator, the name to the picker.
|
// goes to eu07.ini for the simulator, the name to the picker.
|
||||||
AppServices.Current.Settings.Language = resolvedName;
|
AppServices.Current.Settings.Language = resolvedName;
|
||||||
AppServices.Current.Settings.LanguageCode = Loc.CurrentLangCode;
|
AppServices.Current.Settings.LanguageCode = Loc.CurrentLangCode;
|
||||||
|
}
|
||||||
|
|
||||||
ApplyCulture(Loc.CurrentLangCode);
|
/// <summary>
|
||||||
|
/// A language picked by the user, written to eu07.ini straight away as the old
|
||||||
|
/// starter did - not left waiting for the Save button of a different tab.
|
||||||
|
/// Only what is already in the settings goes with it, not other unsaved edits.
|
||||||
|
/// </summary>
|
||||||
|
public static void ChangeLanguage(string code)
|
||||||
|
{
|
||||||
|
if (string.Equals(code, Loc.CurrentLangCode, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return;
|
||||||
|
|
||||||
|
ApplyLanguage(code);
|
||||||
|
AppServices.Current.SettingsStore.Save();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ApplyCulture(string langCode)
|
private static void ApplyCulture(string langCode)
|
||||||
@@ -88,10 +101,11 @@ public partial class App : Avalonia.Application
|
|||||||
culture = CultureInfo.InvariantCulture;
|
culture = CultureInfo.InvariantCulture;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only the defaults: a culture set on the thread is an async local, so one set
|
||||||
|
// from the picker's handler was undone as soon as the handler returned, and
|
||||||
|
// the UI kept the first language's decimal comma and month names.
|
||||||
CultureInfo.DefaultThreadCurrentCulture = culture;
|
CultureInfo.DefaultThreadCurrentCulture = culture;
|
||||||
CultureInfo.DefaultThreadCurrentUICulture = culture;
|
CultureInfo.DefaultThreadCurrentUICulture = culture;
|
||||||
CultureInfo.CurrentCulture = culture;
|
|
||||||
CultureInfo.CurrentUICulture = culture;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void OnFrameworkInitializationCompleted()
|
public override void OnFrameworkInitializationCompleted()
|
||||||
@@ -187,6 +201,9 @@ public partial class App : Avalonia.Application
|
|||||||
|
|
||||||
if (check.ExecutableMissing())
|
if (check.ExecutableMissing())
|
||||||
await main.OfferExecutablePickAsync();
|
await main.OfferExecutablePickAsync();
|
||||||
|
|
||||||
|
if (CpuFeatures.LacksAvx2 && AppServices.Current.Settings.CpuNotice)
|
||||||
|
await main.ShowCpuNoticeAsync();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,16 @@ public interface IFileSystem
|
|||||||
|
|
||||||
void WriteAllText(string path, string contents, Encoding encoding);
|
void WriteAllText(string path, string contents, Encoding encoding);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes the file whole or not at all: a crash or a full disk half way leaves
|
||||||
|
/// what stood there, not the start of what was on its way. A link stays a link,
|
||||||
|
/// its file gets the contents; a file keeps its mode.
|
||||||
|
/// </summary>
|
||||||
|
void WriteAllBytesAtomic(string path, byte[] bytes);
|
||||||
|
|
||||||
|
/// <summary>As UTF-8 without a mark, by <see cref="WriteAllBytesAtomic"/>.</summary>
|
||||||
|
void WriteAllTextAtomic(string path, string contents);
|
||||||
|
|
||||||
void WriteAllLines(string path, IEnumerable<string> lines);
|
void WriteAllLines(string path, IEnumerable<string> lines);
|
||||||
|
|
||||||
void WriteAllBytes(string path, byte[] bytes);
|
void WriteAllBytes(string path, byte[] bytes);
|
||||||
@@ -46,4 +56,32 @@ public interface IFileSystem
|
|||||||
long GetFileSize(string path);
|
long GetFileSize(string path);
|
||||||
|
|
||||||
bool IsExecutable(string path);
|
bool IsExecutable(string path);
|
||||||
|
|
||||||
|
/// <summary>Where the symbolic link at <paramref name="path"/> points, or null for anything else.</summary>
|
||||||
|
string? LinkTarget(string path);
|
||||||
|
|
||||||
|
/// <summary>Makes <paramref name="path"/> a symbolic link to the directory <paramref name="target"/>.</summary>
|
||||||
|
void CreateDirectoryLink(string path, string target);
|
||||||
|
|
||||||
|
void MoveDirectory(string sourcePath, string destinationPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class FileSystemExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Copies a file that could not be read beside itself, before something replaces
|
||||||
|
/// it: it may hold what was not read. Throws when it cannot; then it must stay.
|
||||||
|
/// </summary>
|
||||||
|
public static void KeepAside(this IFileSystem files, string path, IDiagnosticsLog log)
|
||||||
|
{
|
||||||
|
if (!files.FileExists(path))
|
||||||
|
return;
|
||||||
|
|
||||||
|
string aside = path + ".unreadable";
|
||||||
|
for (int n = 2; files.FileExists(aside); n++)
|
||||||
|
aside = $"{path}.unreadable-{n}";
|
||||||
|
|
||||||
|
files.CopyFile(path, aside, overwrite: false);
|
||||||
|
log.Log($"{path} could not be read; a copy is kept as {aside}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,14 +20,17 @@ public interface IProcessHandle
|
|||||||
public interface IProcessLauncher
|
public interface IProcessLauncher
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Starts a process. With captureErrors its error stream is kept for the
|
/// Starts a process, with <paramref name="environment"/> added to the starter's
|
||||||
/// handle - only for a process the starter outlives: a write to a pipe nobody
|
/// own. With captureErrors its error stream is kept for the handle - only for a
|
||||||
/// reads any more can kill the writer.
|
/// process the starter outlives: a write to a pipe nobody reads any more can
|
||||||
|
/// kill the writer.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
IProcessHandle? Start(string executablePath, IReadOnlyList<string> arguments, string? workingDirectory,
|
IProcessHandle? Start(string executablePath, IReadOnlyList<string> arguments, string? workingDirectory,
|
||||||
bool captureErrors, out string? error);
|
IReadOnlyDictionary<string, string> environment, bool captureErrors, out string? error);
|
||||||
|
|
||||||
bool OpenInShell(string pathOrUrl);
|
bool OpenInShell(string pathOrUrl);
|
||||||
|
|
||||||
IProcessHandle? FindRunning(string processName);
|
IProcessHandle? FindRunning(string processName);
|
||||||
|
|
||||||
|
bool IsRunning(string processName);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,4 +11,7 @@ public interface ISceneryRepository
|
|||||||
IReadOnlyList<Scenery> LoadAll(IProgress<SceneryLoadProgress>? progress = null);
|
IReadOnlyList<Scenery> LoadAll(IProgress<SceneryLoadProgress>? progress = null);
|
||||||
|
|
||||||
Scenery? Load(string path);
|
Scenery? Load(string path);
|
||||||
|
|
||||||
|
/// <summary>The scenery files there are now; null while their folder is away.</summary>
|
||||||
|
IReadOnlyList<string>? ListFiles();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.IO;
|
||||||
using StarterNG.Application.Abstractions;
|
using StarterNG.Application.Abstractions;
|
||||||
using StarterNG.Domain.Settings;
|
using StarterNG.Domain.Settings;
|
||||||
|
using StarterNG.Infrastructure;
|
||||||
using StarterNG.Infrastructure.Adapters;
|
using StarterNG.Infrastructure.Adapters;
|
||||||
using StarterNG.Infrastructure.Sceneries;
|
using StarterNG.Infrastructure.Sceneries;
|
||||||
using StarterNG.Infrastructure.Settings;
|
using StarterNG.Infrastructure.Settings;
|
||||||
@@ -25,37 +27,53 @@ public sealed class AppServices
|
|||||||
|
|
||||||
var log = new FileDiagnosticsLog(files, clock, paths);
|
var log = new FileDiagnosticsLog(files, clock, paths);
|
||||||
Log = log;
|
Log = log;
|
||||||
Random = new SystemRandomSource();
|
var random = new SystemRandomSource();
|
||||||
Processes = new SystemProcessLauncher(log);
|
Processes = new SystemProcessLauncher(log);
|
||||||
Localization = new LocalizationService(log);
|
|
||||||
Sceneries = new SceneryRepository(files, paths, log, new SceneryParser(clock, Random));
|
|
||||||
SceneryImages = new SceneryImageLocator(files);
|
|
||||||
MiniTextures = new MiniTextureIndex(files, paths);
|
|
||||||
var settingsPaths = new SettingsPaths(environment, paths);
|
var settingsPaths = new SettingsPaths(environment, paths);
|
||||||
SettingsPaths = settingsPaths;
|
SettingsPaths = settingsPaths;
|
||||||
|
// The player's own first: the game folder may need rights to write to, the user folder never.
|
||||||
|
Localization = new LocalizationService(log, folders: new LanguageFolders(
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
Path.Combine(settingsPaths.UserConfigDirectory(), "starter", "lang"),
|
||||||
|
paths.FromRoot("starter", "lang"),
|
||||||
|
LocalizationService.LangDirectory,
|
||||||
|
paths.FromRoot("startercfg", "lang")
|
||||||
|
},
|
||||||
|
paths.FromRoot("starter"),
|
||||||
|
paths.FromRoot("lang")));
|
||||||
|
Sceneries = new SceneryRepository(files, paths, log, new SceneryParser(clock, random));
|
||||||
|
SceneryImages = new SceneryImageLocator(files);
|
||||||
|
MiniTextures = new MiniTextureIndex(files, paths);
|
||||||
|
|
||||||
// textures.txt stays the truth; the starter reads its own JSON copy of it
|
// textures.txt stays the truth; the starter reads its own JSON copy of it
|
||||||
// and rebuilds that copy whenever the installation has moved on.
|
// and rebuilds that copy whenever the installation has moved on.
|
||||||
TexturesTxt = new TexturesTxtVehicleRepository(files, paths, log, new TexturesTxtParser());
|
var texturesTxt = new TexturesTxtVehicleRepository(files, paths, log, new TexturesTxtParser());
|
||||||
VehicleJson = new VehicleJsonSerializer();
|
var vehicleJson = new VehicleJsonSerializer();
|
||||||
VehicleCache = new VehicleCacheRepository(files, paths, log, TexturesTxt, VehicleJson,
|
Vehicles = new VehicleCacheRepository(files, paths, log, texturesTxt, vehicleJson,
|
||||||
settingsPaths.VehicleCacheDirectory());
|
settingsPaths.VehicleCacheDirectory());
|
||||||
Vehicles = VehicleCache;
|
|
||||||
Physics = new FizPhysicsRepository(files, paths, log);
|
Physics = new FizPhysicsRepository(files, paths, log);
|
||||||
MissingAssets = new MissingAssetScanner(files, paths);
|
var missingAssets = new MissingAssetScanner(files, paths);
|
||||||
SceneryTexts = new SceneryTranslations(files, log);
|
SceneryTexts = new SceneryTranslations(files, log);
|
||||||
Timetables = new TimetableLocator(files);
|
Timetables = new TimetableLocator(files);
|
||||||
LoadWeights = new LoadWeightsRepository(files, paths, log);
|
LoadWeights = new LoadWeightsRepository(files, paths, log);
|
||||||
Library = new GameLibrary(Vehicles, Sceneries, MiniTextures, Physics, log);
|
Library = new GameLibrary(Vehicles, Sceneries, MiniTextures, Physics, log);
|
||||||
State = new AppState();
|
State = new AppState();
|
||||||
|
SceneryFiles = new SceneryFileWatch(files, Sceneries, Library, State);
|
||||||
|
|
||||||
Executables = new ExecutableLocator(files, paths, environment, log);
|
Executables = new ExecutableLocator(files, paths, environment, log);
|
||||||
SettingsStore = new SettingsStore(files, clock, log, settingsPaths, new SettingsSerializer(), Executables);
|
SettingsStore = new SettingsStore(files, clock, log, settingsPaths, new SettingsSerializer(), Executables);
|
||||||
MissingVehicleLog = new MissingVehicleLog(SettingsStore.Settings, MissingAssets, Library, log);
|
MissingVehicleLog = new MissingVehicleLog(SettingsStore.Settings, missingAssets, Library, log);
|
||||||
Favorites = new FavoriteSceneries(files, log, settingsPaths.FavoritesPath());
|
Favorites = new FavoriteSceneries(files, log, settingsPaths.FavoritesPath());
|
||||||
StartSimulation = new StartSimulation(State, SettingsStore, Library.Vehicles, Executables, files, paths,
|
Tapes = new HaslerTapes(files, paths, log, clock, Timetables,
|
||||||
Processes, Random, log);
|
() => Processes.IsRunning(Path.GetFileNameWithoutExtension(SettingsStore.ResolveExecutable())));
|
||||||
|
Gpus = new LinuxGpus(files, environment);
|
||||||
|
Displays = new Displays(files, environment);
|
||||||
|
StartSimulation = new StartSimulation(State, SettingsStore, Library.Vehicles, Executables,
|
||||||
|
files, paths, Processes, random, log, Gpus, environment,
|
||||||
|
new WineConfigLink(files, environment, settingsPaths, log), Tapes);
|
||||||
Aftermath = new SimulatorAftermath(files, paths, log);
|
Aftermath = new SimulatorAftermath(files, paths, log);
|
||||||
|
SaveTrainset = new SaveSceneryTrainset(files, log);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static AppServices Current =>
|
public static AppServices Current =>
|
||||||
@@ -85,28 +103,18 @@ public sealed class AppServices
|
|||||||
|
|
||||||
public IProcessLauncher Processes { get; }
|
public IProcessLauncher Processes { get; }
|
||||||
|
|
||||||
public IRandomSource Random { get; }
|
|
||||||
|
|
||||||
public ISceneryRepository Sceneries { get; }
|
public ISceneryRepository Sceneries { get; }
|
||||||
|
|
||||||
|
public SceneryFileWatch SceneryFiles { get; }
|
||||||
|
|
||||||
public SceneryImageLocator SceneryImages { get; }
|
public SceneryImageLocator SceneryImages { get; }
|
||||||
|
|
||||||
public IMiniTextureIndex MiniTextures { get; }
|
public IMiniTextureIndex MiniTextures { get; }
|
||||||
|
|
||||||
public IVehicleRepository Vehicles { get; }
|
public IVehicleRepository Vehicles { get; }
|
||||||
|
|
||||||
/// <summary>The same object as <see cref="Vehicles"/>, typed for the developer tab.</summary>
|
|
||||||
public VehicleCacheRepository VehicleCache { get; }
|
|
||||||
|
|
||||||
/// <summary>The legacy reader behind the cache, for tools that go to the source.</summary>
|
|
||||||
public TexturesTxtVehicleRepository TexturesTxt { get; }
|
|
||||||
|
|
||||||
public VehicleJsonSerializer VehicleJson { get; }
|
|
||||||
|
|
||||||
public IPhysicsRepository Physics { get; }
|
public IPhysicsRepository Physics { get; }
|
||||||
|
|
||||||
public MissingAssetScanner MissingAssets { get; }
|
|
||||||
|
|
||||||
public ISceneryTranslations SceneryTexts { get; }
|
public ISceneryTranslations SceneryTexts { get; }
|
||||||
|
|
||||||
public TimetableLocator Timetables { get; }
|
public TimetableLocator Timetables { get; }
|
||||||
@@ -123,6 +131,10 @@ public sealed class AppServices
|
|||||||
|
|
||||||
public ExecutableLocator Executables { get; }
|
public ExecutableLocator Executables { get; }
|
||||||
|
|
||||||
|
public LinuxGpus Gpus { get; }
|
||||||
|
|
||||||
|
public Displays Displays { get; }
|
||||||
|
|
||||||
public SettingsPaths SettingsPaths { get; }
|
public SettingsPaths SettingsPaths { get; }
|
||||||
|
|
||||||
public MissingVehicleLog MissingVehicleLog { get; }
|
public MissingVehicleLog MissingVehicleLog { get; }
|
||||||
@@ -133,5 +145,9 @@ public sealed class AppServices
|
|||||||
|
|
||||||
public SimulatorAftermath Aftermath { get; }
|
public SimulatorAftermath Aftermath { get; }
|
||||||
|
|
||||||
|
public HaslerTapes Tapes { get; }
|
||||||
|
|
||||||
|
public SaveSceneryTrainset SaveTrainset { get; }
|
||||||
|
|
||||||
public LocalizationService Localization { get; }
|
public LocalizationService Localization { get; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ public sealed class FavoriteSceneries
|
|||||||
private readonly string _path;
|
private readonly string _path;
|
||||||
private readonly HashSet<string> _names = new(StringComparer.OrdinalIgnoreCase);
|
private readonly HashSet<string> _names = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// The file holds more than _names does: it goes aside before anything replaces it.
|
||||||
|
private bool _unread;
|
||||||
|
|
||||||
public FavoriteSceneries(IFileSystem files, IDiagnosticsLog log, string path)
|
public FavoriteSceneries(IFileSystem files, IDiagnosticsLog log, string path)
|
||||||
{
|
{
|
||||||
_files = files;
|
_files = files;
|
||||||
@@ -52,6 +55,7 @@ public sealed class FavoriteSceneries
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_log.Log($"reading {_path}", ex);
|
_log.Log($"reading {_path}", ex);
|
||||||
|
_unread = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +66,12 @@ public sealed class FavoriteSceneries
|
|||||||
if (Path.GetDirectoryName(_path) is { Length: > 0 } directory)
|
if (Path.GetDirectoryName(_path) is { Length: > 0 } directory)
|
||||||
_files.CreateDirectory(directory);
|
_files.CreateDirectory(directory);
|
||||||
|
|
||||||
_files.WriteAllText(_path, string.Join('\n', _names.Order(StringComparer.OrdinalIgnoreCase)));
|
if (_unread)
|
||||||
|
{
|
||||||
|
_files.KeepAside(_path, _log);
|
||||||
|
_unread = false;
|
||||||
|
}
|
||||||
|
_files.WriteAllTextAtomic(_path, string.Join('\n', _names.Order(StringComparer.OrdinalIgnoreCase)));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -47,10 +47,19 @@ public sealed class GameLibrary
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
progress?.Report(new LoadStatus(0, LoadPhase.Vehicles, "textures.txt"));
|
progress?.Report(new LoadStatus(0, LoadPhase.Vehicles, "textures.txt"));
|
||||||
if (_vehicles.Load(Vehicles) == 0)
|
|
||||||
_log.Log("dynamic: brak plikow textures.txt");
|
|
||||||
|
|
||||||
Parallel.Invoke(_minis.Preload, _physics.Preload);
|
// Whatever goes wrong with the rolling stock, the sceneries still get their turn.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_vehicles.Load(Vehicles) == 0)
|
||||||
|
_log.Log("dynamic: brak plikow textures.txt");
|
||||||
|
|
||||||
|
Parallel.Invoke(_minis.Preload, _physics.Preload);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.Log("dynamic: loading the rolling stock", ex);
|
||||||
|
}
|
||||||
|
|
||||||
ReloadSceneries(progress is null
|
ReloadSceneries(progress is null
|
||||||
? null
|
? null
|
||||||
|
|||||||
56
StarterNG/Application/GraphicsGroupLevels.cs
Normal file
56
StarterNG/Application/GraphicsGroupLevels.cs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using StarterNG.Domain.Settings;
|
||||||
|
|
||||||
|
namespace StarterNG.Application;
|
||||||
|
|
||||||
|
/// <summary>Levels of detail part by part: what a level holds for one <see cref="GraphicsGroup"/>, the rest as it is.</summary>
|
||||||
|
public sealed class GraphicsGroupLevels
|
||||||
|
{
|
||||||
|
private readonly SettingsStore _store;
|
||||||
|
|
||||||
|
public GraphicsGroupLevels(SettingsStore store) => _store = store;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The level each group is at in <paramref name="settings"/>, null where it is at none. At
|
||||||
|
/// Ultra the level under it fits too, as it leaves alone what only Ultra sets: the highest counts.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyDictionary<GraphicsGroup, GraphicsPreset?> LevelsOf(SimulatorSettings settings)
|
||||||
|
{
|
||||||
|
var now = _store.Render(settings);
|
||||||
|
var rendered = GraphicsPreset.All.Select(level => (Level: level, Keys: RenderAt(level, settings))).ToList();
|
||||||
|
return GraphicsGroup.All.ToDictionary(group => group, group =>
|
||||||
|
(GraphicsPreset?)rendered.LastOrDefault(level => Same(group, level.Keys, now)).Level);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A copy of <paramref name="settings"/> with the group at <paramref name="level"/>. What only
|
||||||
|
/// an end of the scale sets goes back to the simulator's own on leaving that end.
|
||||||
|
/// </summary>
|
||||||
|
public SimulatorSettings Apply(GraphicsGroup group, GraphicsPreset level, SimulatorSettings settings)
|
||||||
|
{
|
||||||
|
var current = LevelsOf(settings)[group];
|
||||||
|
var source = settings.Copy();
|
||||||
|
if (level.Extras is null && current?.Extras is not null)
|
||||||
|
GraphicsExtras.None.ApplyTo(source);
|
||||||
|
if (level.Top is null && current?.Top is not null)
|
||||||
|
GraphicsTop.None.ApplyTo(source);
|
||||||
|
level.ApplyTo(source);
|
||||||
|
return _store.WithKeysOf(settings, source, group.Keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IReadOnlyDictionary<string, string> RenderAt(GraphicsPreset level, SimulatorSettings settings)
|
||||||
|
{
|
||||||
|
var copy = settings.Copy();
|
||||||
|
level.ApplyTo(copy);
|
||||||
|
return _store.Render(copy);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Same(GraphicsGroup group, IReadOnlyDictionary<string, string> one, IReadOnlyDictionary<string, string> other) =>
|
||||||
|
group.Keys.All(key => string.Equals(ValueIn(one, key), ValueIn(other, key), StringComparison.Ordinal));
|
||||||
|
|
||||||
|
// A key the file does not set is at the simulator's own value.
|
||||||
|
private static string? ValueIn(IReadOnlyDictionary<string, string> keys, string key) =>
|
||||||
|
keys.TryGetValue(key, out string? value) ? value : SimulatorKeys.All.FirstOrDefault(known => known.Key == key)?.Default;
|
||||||
|
}
|
||||||
308
StarterNG/Application/HaslerTapes.cs
Normal file
308
StarterNG/Application/HaslerTapes.cs
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using StarterNG.Application.Abstractions;
|
||||||
|
using StarterNG.Classes;
|
||||||
|
using StarterNG.Domain.Sceneries;
|
||||||
|
using StarterNG.Domain.Tapes;
|
||||||
|
using StarterNG.Infrastructure.Adapters;
|
||||||
|
using StarterNG.Infrastructure.Sceneries;
|
||||||
|
|
||||||
|
namespace StarterNG.Application;
|
||||||
|
|
||||||
|
public sealed record TapeFile(string Path, string Vehicle, DateTime LastWriteUtc, long Size,
|
||||||
|
TapeRole Role = TapeRole.Other);
|
||||||
|
|
||||||
|
/// <summary>One run of the simulator and what is left of its tapes: a later run overwrites those of the vehicles it has too.</summary>
|
||||||
|
public sealed record TapeSession(TapeLaunch? Launch, DateTime StartedUtc, DateTime LastWrittenUtc, bool Running,
|
||||||
|
IReadOnlyList<TapeFile> Tapes);
|
||||||
|
|
||||||
|
/// <summary>What is known about a tape beyond its samples: the clock it started at, the recorder it is read as, the scenario and the train.</summary>
|
||||||
|
public sealed record TapeContext(TimeSpan? StartClock, RecorderKind Recorder,
|
||||||
|
string? Scenery = null, string? Train = null);
|
||||||
|
|
||||||
|
/// <summary>The tapes the simulator left in physicslog/ - one per driven vehicle, overwritten every run.</summary>
|
||||||
|
public sealed class HaslerTapes
|
||||||
|
{
|
||||||
|
private const int RememberedLaunches = 30;
|
||||||
|
private static readonly TimeSpan QuietBetweenRuns = TimeSpan.FromMinutes(2);
|
||||||
|
|
||||||
|
private readonly IFileSystem _files;
|
||||||
|
private readonly IGamePaths _paths;
|
||||||
|
private readonly IDiagnosticsLog _log;
|
||||||
|
private readonly IClock _clock;
|
||||||
|
private readonly TimetableLocator _timetables;
|
||||||
|
private readonly Func<bool> _simulatorRunning;
|
||||||
|
private readonly object _playerLock = new();
|
||||||
|
private IReadOnlyList<TapeLaunch>? _launches;
|
||||||
|
private (DateTime Written, long Size) _launchesAt;
|
||||||
|
private PlayerVehicles _player = PlayerVehicles.None;
|
||||||
|
private DateTime _playerLaunch;
|
||||||
|
private string? _playerLog;
|
||||||
|
private long _playerLogRead;
|
||||||
|
|
||||||
|
public HaslerTapes(IFileSystem files, IGamePaths paths, IDiagnosticsLog log, IClock clock,
|
||||||
|
TimetableLocator timetables, Func<bool> simulatorRunning)
|
||||||
|
{
|
||||||
|
_files = files;
|
||||||
|
_paths = paths;
|
||||||
|
_log = log;
|
||||||
|
_clock = clock;
|
||||||
|
_timetables = timetables;
|
||||||
|
_simulatorRunning = simulatorRunning;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Folder => _paths.FromRoot("physicslog");
|
||||||
|
|
||||||
|
/// <summary>Not a .dat, so the simulator's own logs are never mistaken for it, nor it for one.</summary>
|
||||||
|
private string LaunchFile => Path.Combine(Folder, "starter-launch.txt");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The simulator logs every driven vehicle under its scenery name, with the
|
||||||
|
/// time counted from the start; the clock it starts at and what each name is
|
||||||
|
/// are only known here. So is whose tape is the player's: a scenery with no
|
||||||
|
/// consist picked starts with the free camera, in no vehicle at all.
|
||||||
|
/// </summary>
|
||||||
|
public void RecordLaunch(Scenery scenery, Trainset? trainset, string? driven)
|
||||||
|
{
|
||||||
|
var clock = TimeSpan.TryParseExact(scenery.Weather.ScenarioTimeOverride, @"h\:mm",
|
||||||
|
CultureInfo.InvariantCulture, out var over)
|
||||||
|
? over
|
||||||
|
: TimeSpan.TryParseExact(scenery.Weather.Time, @"h\:mm", CultureInfo.InvariantCulture, out var time)
|
||||||
|
? time
|
||||||
|
: TimeSpan.Zero;
|
||||||
|
|
||||||
|
var own = trainset?.Vehicles ?? new List<Dynamic>();
|
||||||
|
var vehicles = scenery.Trainsets.SelectMany(t => t.Vehicles).Concat(own)
|
||||||
|
.Select(v => new TapeVehicle(v.Name, v.DataFolder ?? "", v.MmdFile ?? ""));
|
||||||
|
RecordLaunch(Path.GetFileName(scenery.Path), clock, vehicles, driven,
|
||||||
|
driven is null ? null : own.Select(v => v.Name), scenery.Name,
|
||||||
|
driven is null ? null : _timetables.TrainNumber(scenery, trainset?.Name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Notes down, next to the logs the simulator is about to write, what only the starter knows.</summary>
|
||||||
|
public void RecordLaunch(string scenery, TimeSpan startClock, IEnumerable<TapeVehicle> vehicles,
|
||||||
|
string? driven = null, IEnumerable<string>? consist = null,
|
||||||
|
string? sceneryName = null, string? train = null)
|
||||||
|
{
|
||||||
|
var launch = new TapeLaunch(_clock.UtcNow, scenery, startClock,
|
||||||
|
vehicles.Where(v => !string.IsNullOrEmpty(v.Name))
|
||||||
|
.DistinctBy(v => v.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList(),
|
||||||
|
driven, consist?.ToList(), sceneryName, train);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var noted = Launches().ToList();
|
||||||
|
// The log is about to begin anew: what it says of the run before stays with that run.
|
||||||
|
if (noted.Count > 0)
|
||||||
|
noted[^1] = noted[^1] with { Player = PlayerIn(noted[^1]) };
|
||||||
|
noted.Add(launch);
|
||||||
|
|
||||||
|
_files.CreateDirectory(Folder);
|
||||||
|
_files.WriteAllText(LaunchFile, string.Concat(noted.TakeLast(RememberedLaunches).Select(l => l.Format())),
|
||||||
|
Encoding.UTF8);
|
||||||
|
lock (_playerLock)
|
||||||
|
_launches = null;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.Log($"writing {LaunchFile}", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The launches noted so far, oldest first. Read again only when the file has
|
||||||
|
/// changed: this runs every few seconds next to a running simulator.
|
||||||
|
/// </summary>
|
||||||
|
private IReadOnlyList<TapeLaunch> Launches()
|
||||||
|
{
|
||||||
|
lock (_playerLock)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!_files.FileExists(LaunchFile))
|
||||||
|
return Array.Empty<TapeLaunch>();
|
||||||
|
|
||||||
|
var at = (_files.GetLastWriteTimeUtc(LaunchFile), _files.GetFileSize(LaunchFile));
|
||||||
|
if (_launches is null || at != _launchesAt)
|
||||||
|
{
|
||||||
|
_launches = TapeLaunch.ParseAll(_files.ReadAllText(LaunchFile, Encoding.UTF8));
|
||||||
|
_launchesAt = at;
|
||||||
|
}
|
||||||
|
return _launches;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.Log($"reading {LaunchFile}", ex);
|
||||||
|
return Array.Empty<TapeLaunch>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The simulator's log of this launch: log.txt or, with logs kept, one of its own in logs/.</summary>
|
||||||
|
private string? LogOf(TapeLaunch launch)
|
||||||
|
{
|
||||||
|
var logs = new List<string> { _paths.FromRoot("log.txt") };
|
||||||
|
string kept = _paths.FromRoot("logs");
|
||||||
|
if (_files.DirectoryExists(kept))
|
||||||
|
logs.AddRange(_files.GetFiles(kept, "log (*.txt"));
|
||||||
|
|
||||||
|
return logs.Where(_files.FileExists)
|
||||||
|
.Where(log => launch.Covers(_files.GetLastWriteTimeUtc(log)))
|
||||||
|
.OrderByDescending(_files.GetLastWriteTimeUtc)
|
||||||
|
.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where the player has been in this launch. Only what the log has gained since
|
||||||
|
/// the last look is read: this runs every few seconds next to a running simulator.
|
||||||
|
/// </summary>
|
||||||
|
private PlayerVehicles PlayerIn(TapeLaunch launch)
|
||||||
|
{
|
||||||
|
lock (_playerLock)
|
||||||
|
{
|
||||||
|
string? path = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
path = LogOf(launch);
|
||||||
|
if (path is null)
|
||||||
|
return PlayerVehicles.Unknown;
|
||||||
|
|
||||||
|
long size = _files.GetFileSize(path);
|
||||||
|
if (launch.LaunchedUtc != _playerLaunch || path != _playerLog || size < _playerLogRead)
|
||||||
|
{
|
||||||
|
_player = PlayerVehicles.None;
|
||||||
|
_playerLaunch = launch.LaunchedUtc;
|
||||||
|
_playerLog = path;
|
||||||
|
_playerLogRead = 0;
|
||||||
|
}
|
||||||
|
if (size == _playerLogRead)
|
||||||
|
return _player;
|
||||||
|
|
||||||
|
using var stream = _files.OpenRead(path);
|
||||||
|
stream.Seek(_playerLogRead, SeekOrigin.Begin);
|
||||||
|
using var added = new MemoryStream();
|
||||||
|
stream.CopyTo(added);
|
||||||
|
byte[] bytes = added.ToArray();
|
||||||
|
|
||||||
|
// A line still being written waits for the next look.
|
||||||
|
int end = Array.LastIndexOf(bytes, (byte)'\n') + 1;
|
||||||
|
if (end > 0)
|
||||||
|
{
|
||||||
|
_player = _player.After(LegacyText.Decode(bytes[..end]));
|
||||||
|
_playerLogRead += end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.Log($"reading {path}", ex);
|
||||||
|
}
|
||||||
|
return _player;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The vehicle the player is in, or went off in while the log has not said otherwise, if the starter launched one.</summary>
|
||||||
|
public string? DrivenVehicle =>
|
||||||
|
Launches().LastOrDefault() is { } launch ? PlayerIn(launch).Current ?? launch.Driven : null;
|
||||||
|
|
||||||
|
public TapeContext ContextOf(TapeFile file)
|
||||||
|
{
|
||||||
|
var launch = Launches().LastOrDefault(l => l.Covers(file.LastWriteUtc));
|
||||||
|
if (launch is null)
|
||||||
|
return new TapeContext(null, Recorders.Guess(null, file.Vehicle));
|
||||||
|
|
||||||
|
var vehicle = launch.Find(file.Vehicle);
|
||||||
|
string scenery = string.IsNullOrWhiteSpace(launch.SceneryName) ? launch.Scenery : launch.SceneryName;
|
||||||
|
return new TapeContext(launch.StartClock, Recorders.Guess(vehicle, file.Vehicle),
|
||||||
|
string.IsNullOrWhiteSpace(scenery) ? null : scenery,
|
||||||
|
launch.RoleOf(file.Vehicle) == TapeRole.Other ? null : launch.Train);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Every tape, session by session.</summary>
|
||||||
|
public IReadOnlyList<TapeFile> List() => Sessions().SelectMany(session => session.Tapes).ToList();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The runs the tapes came from, the running or latest one first. A tape belongs
|
||||||
|
/// to the last launch before it was written; one written before any known launch
|
||||||
|
/// goes with those written about the same time, as a run writes them all at its end.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<TapeSession> Sessions()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var launches = Launches();
|
||||||
|
var tapes = _files.DirectoryExists(Folder)
|
||||||
|
? _files.GetFiles(Folder, "*.dat").Select(ReadTape).ToList()
|
||||||
|
: new List<TapeFile>();
|
||||||
|
var behind = tapes.GroupBy(tape => launches.LastOrDefault(l => l.Covers(tape.LastWriteUtc)))
|
||||||
|
.Where(group => group.Key is not null)
|
||||||
|
.ToDictionary(group => group.Key!, group => group.ToList());
|
||||||
|
|
||||||
|
var sessions = new List<TapeSession>();
|
||||||
|
foreach (var launch in launches)
|
||||||
|
{
|
||||||
|
bool last = ReferenceEquals(launch, launches[^1]);
|
||||||
|
bool running = last && _simulatorRunning();
|
||||||
|
if (!running && !behind.ContainsKey(launch))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var player = last ? PlayerIn(launch) : launch.Player ?? PlayerVehicles.Unknown;
|
||||||
|
sessions.Add(Session(launch, running, behind.GetValueOrDefault(launch, new List<TapeFile>())
|
||||||
|
.Select(t => t with { Role = launch.RoleOf(t.Vehicle, player) })));
|
||||||
|
}
|
||||||
|
|
||||||
|
var run = new List<TapeFile>();
|
||||||
|
foreach (var tape in tapes.Where(t => !launches.Any(l => l.Covers(t.LastWriteUtc))).OrderBy(t => t.LastWriteUtc))
|
||||||
|
{
|
||||||
|
if (run.Count > 0 && tape.LastWriteUtc - run[^1].LastWriteUtc > QuietBetweenRuns)
|
||||||
|
{
|
||||||
|
sessions.Add(Session(null, running: false, run));
|
||||||
|
run = new List<TapeFile>();
|
||||||
|
}
|
||||||
|
run.Add(tape);
|
||||||
|
}
|
||||||
|
if (run.Count > 0)
|
||||||
|
sessions.Add(Session(null, running: false, run));
|
||||||
|
|
||||||
|
return sessions.OrderByDescending(session => session.Running)
|
||||||
|
.ThenByDescending(session => session.LastWrittenUtc)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.Log($"listing {Folder}", ex);
|
||||||
|
return Array.Empty<TapeSession>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private TapeFile ReadTape(string path) =>
|
||||||
|
new(path, Path.GetFileNameWithoutExtension(path), _files.GetLastWriteTimeUtc(path), _files.GetFileSize(path));
|
||||||
|
|
||||||
|
/// <summary>The player's own tapes first: every manned vehicle leaves one.</summary>
|
||||||
|
private static TapeSession Session(TapeLaunch? launch, bool running, IEnumerable<TapeFile> tapes)
|
||||||
|
{
|
||||||
|
var listed = tapes.OrderByDescending(tape => tape.Role).ThenByDescending(tape => tape.LastWriteUtc).ToList();
|
||||||
|
var started = launch?.LaunchedUtc ?? listed.Min(tape => tape.LastWriteUtc);
|
||||||
|
return new TapeSession(launch, started, listed.Count > 0 ? listed.Max(tape => tape.LastWriteUtc) : started,
|
||||||
|
running, listed);
|
||||||
|
}
|
||||||
|
|
||||||
|
public HaslerTape? Load(TapeFile file)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Through OpenRead, which does not mind the simulator still writing the file.
|
||||||
|
using var stream = _files.OpenRead(file.Path);
|
||||||
|
using var reader = new StreamReader(stream, Encoding.ASCII);
|
||||||
|
return HaslerTape.Parse(reader.ReadToEnd());
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.Log($"reading {file.Path}", ex);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
87
StarterNG/Application/SaveSceneryTrainset.cs
Normal file
87
StarterNG/Application/SaveSceneryTrainset.cs
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using StarterNG.Application.Abstractions;
|
||||||
|
using StarterNG.Classes;
|
||||||
|
using StarterNG.Domain.Sceneries;
|
||||||
|
using StarterNG.Infrastructure.Adapters;
|
||||||
|
|
||||||
|
namespace StarterNG.Application;
|
||||||
|
|
||||||
|
public enum SceneryTrainsetSaveOutcome
|
||||||
|
{
|
||||||
|
Saved,
|
||||||
|
|
||||||
|
/// <summary>The file no longer holds the trainset as it was read - edited outside meanwhile.</summary>
|
||||||
|
NotFound,
|
||||||
|
|
||||||
|
Failed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes a consist edited in the depot into the scenery file itself, so it is
|
||||||
|
/// there the next time too - not only in the copy written for one start. The
|
||||||
|
/// file's first version is kept beside it, once - under a name of the starter's
|
||||||
|
/// own, since the simulator's editor keeps its <c>.scn.bak</c> there and rewrites
|
||||||
|
/// it on every save.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SaveSceneryTrainset
|
||||||
|
{
|
||||||
|
public const string BackupSuffix = ".starter.bak";
|
||||||
|
|
||||||
|
private readonly IFileSystem _files;
|
||||||
|
private readonly IDiagnosticsLog _log;
|
||||||
|
|
||||||
|
public SaveSceneryTrainset(IFileSystem files, IDiagnosticsLog log)
|
||||||
|
{
|
||||||
|
_files = files;
|
||||||
|
_log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
public (SceneryTrainsetSaveOutcome Outcome, string? Detail) Execute(Scenery scenery, Trainset trainset)
|
||||||
|
{
|
||||||
|
string path = FileOf(scenery, trainset);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
byte[] bytes = _files.ReadAllBytes(path);
|
||||||
|
var (encoding, bom) = LegacyText.Detect(bytes);
|
||||||
|
string text = encoding.GetString(bytes, bom.Length, bytes.Length - bom.Length);
|
||||||
|
|
||||||
|
// The trainset's own line ends, if it has any: a file can hold both kinds.
|
||||||
|
string lines = trainset.RawEntry.Contains('\n') ? trainset.RawEntry : text;
|
||||||
|
string newline = lines.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n";
|
||||||
|
string entry = SceneryTrainsetText.Compose(trainset, newline);
|
||||||
|
var sameFile = scenery.Trainsets.Where(t => t.SourceFile == trainset.SourceFile).ToList();
|
||||||
|
if (SceneryTrainsetText.Replace(text, trainset, sameFile, entry) is not { } updated)
|
||||||
|
return (SceneryTrainsetSaveOutcome.NotFound, null);
|
||||||
|
|
||||||
|
string backup = path + BackupSuffix;
|
||||||
|
if (!_files.FileExists(backup))
|
||||||
|
_files.WriteAllBytesAtomic(backup, bytes);
|
||||||
|
|
||||||
|
_files.WriteAllBytesAtomic(path, bom.Concat(encoding.GetBytes(updated)).ToArray());
|
||||||
|
|
||||||
|
// Written here, so no change from outside for the scenery to follow.
|
||||||
|
scenery.Sources = new Dictionary<string, FileStamp?>(scenery.Sources) { [path] = FileStamp.Of(_files, path) };
|
||||||
|
|
||||||
|
// What the file holds now is what the next save looks for.
|
||||||
|
trainset.RawEntry = entry;
|
||||||
|
trainset.OriginalVelocity = trainset.Velocity;
|
||||||
|
return (SceneryTrainsetSaveOutcome.Saved, null);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.Log($"saving trainset {trainset.Name} to {path}", ex);
|
||||||
|
return (SceneryTrainsetSaveOutcome.Failed, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The scenery file, or the included one the trainset came from - named, as
|
||||||
|
/// includes are, from the scenery folder the scenery itself is in.
|
||||||
|
/// </summary>
|
||||||
|
public static string FileOf(Scenery scenery, Trainset trainset) =>
|
||||||
|
trainset.SourceFile is { } file
|
||||||
|
? System.IO.Path.Combine(System.IO.Path.GetDirectoryName(scenery.Path) ?? "", file.Replace('\\', '/'))
|
||||||
|
: scenery.Path;
|
||||||
|
}
|
||||||
204
StarterNG/Application/SceneryFileWatch.cs
Normal file
204
StarterNG/Application/SceneryFileWatch.cs
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using StarterNG.Application.Abstractions;
|
||||||
|
using StarterNG.Classes;
|
||||||
|
using StarterNG.Domain.Sceneries;
|
||||||
|
|
||||||
|
namespace StarterNG.Application;
|
||||||
|
|
||||||
|
/// <summary>What became of a scenery's files since they were read.</summary>
|
||||||
|
/// <param name="Current">The scenery as it is held; null for a file new in the folder.</param>
|
||||||
|
/// <param name="Fresh">As its files have it now; null for one gone from the folder.</param>
|
||||||
|
public sealed record SceneryChange(Scenery? Current, Scenery? Fresh);
|
||||||
|
|
||||||
|
public enum SceneryVerdict
|
||||||
|
{
|
||||||
|
/// <summary>Nothing to do about it, for now or for good.</summary>
|
||||||
|
Leave,
|
||||||
|
|
||||||
|
/// <summary>The library can take it in.</summary>
|
||||||
|
Take,
|
||||||
|
|
||||||
|
/// <summary>Taking it in loses edits, so that is for someone to say.</summary>
|
||||||
|
AskFirst
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What is to become of a change.</summary>
|
||||||
|
/// <param name="Lost">The consists with unsaved edits that <see cref="SceneryVerdict.AskFirst"/> is about.</param>
|
||||||
|
public sealed record SceneryJudgement(SceneryVerdict Verdict, IReadOnlyList<Trainset> Lost)
|
||||||
|
{
|
||||||
|
public static readonly SceneryJudgement Leave = new(SceneryVerdict.Leave, Array.Empty<Trainset>());
|
||||||
|
public static readonly SceneryJudgement Take = new(SceneryVerdict.Take, Array.Empty<Trainset>());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Keeps the sceneries in step with their files when those change outside the
|
||||||
|
/// starter - in a text editor, in the simulator's editor - without anyone having
|
||||||
|
/// to ask for a reload.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SceneryFileWatch
|
||||||
|
{
|
||||||
|
private readonly IFileSystem _files;
|
||||||
|
private readonly ISceneryRepository _sceneries;
|
||||||
|
private readonly GameLibrary _library;
|
||||||
|
private readonly AppState _state;
|
||||||
|
|
||||||
|
// Files that would not load, and how they stood then: not tried, and not logged,
|
||||||
|
// again before something about them changes.
|
||||||
|
private readonly Dictionary<string, string> _unreadable = new();
|
||||||
|
|
||||||
|
private HashSet<string> _away = new();
|
||||||
|
|
||||||
|
public SceneryFileWatch(IFileSystem files, ISceneryRepository sceneries, GameLibrary library, AppState state)
|
||||||
|
{
|
||||||
|
_files = files;
|
||||||
|
_sceneries = sceneries;
|
||||||
|
_library = library;
|
||||||
|
_state = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What has changed among the files of <paramref name="held"/>. Off the UI thread, one look at a time.</summary>
|
||||||
|
public IReadOnlyList<SceneryChange> Look(IReadOnlyList<Scenery> held)
|
||||||
|
{
|
||||||
|
var changes = new List<SceneryChange>();
|
||||||
|
if (_sceneries.ListFiles() is not { } files)
|
||||||
|
return changes;
|
||||||
|
|
||||||
|
var there = files.ToHashSet();
|
||||||
|
var away = new HashSet<string>();
|
||||||
|
foreach (var scenery in held)
|
||||||
|
{
|
||||||
|
if (!there.Contains(scenery.Path))
|
||||||
|
{
|
||||||
|
// A file being replaced is away for a moment: gone only if still so next time.
|
||||||
|
away.Add(scenery.Path);
|
||||||
|
if (_away.Contains(scenery.Path))
|
||||||
|
changes.Add(new SceneryChange(scenery, null));
|
||||||
|
}
|
||||||
|
else if (IsStale(scenery) && LoadOnce(scenery.Path, scenery.Sources.Keys) is { } fresh)
|
||||||
|
{
|
||||||
|
changes.Add(new SceneryChange(scenery, fresh));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_away = away;
|
||||||
|
|
||||||
|
var known = held.Select(scenery => scenery.Path).ToHashSet();
|
||||||
|
foreach (string file in files.Where(file => !known.Contains(file)))
|
||||||
|
if (LoadOnce(file, new[] { file }) is { } added)
|
||||||
|
changes.Add(new SceneryChange(null, added));
|
||||||
|
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether any of the files it was read from is no longer as it was.</summary>
|
||||||
|
public bool IsStale(Scenery scenery) =>
|
||||||
|
scenery.Sources.Any(source => FileStamp.Of(_files, source.Key) != source.Value);
|
||||||
|
|
||||||
|
private Scenery? LoadOnce(string path, IEnumerable<string> sources)
|
||||||
|
{
|
||||||
|
string stood = string.Join('|', sources.Select(source => FileStamp.Of(_files, source)));
|
||||||
|
if (_unreadable.TryGetValue(path, out string? before) && before == stood)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var loaded = _sceneries.Load(path);
|
||||||
|
if (loaded is null)
|
||||||
|
_unreadable[path] = stood;
|
||||||
|
else
|
||||||
|
_unreadable.Remove(path);
|
||||||
|
return loaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Puts what was held into the scenery read anew. A consist whose text is still
|
||||||
|
/// what it was goes over as the object it is, with its edits and its undo steps;
|
||||||
|
/// the choices made by hand go with them.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The consists with unsaved edits that have no place there: their text changed too.</returns>
|
||||||
|
public static IReadOnlyList<Trainset> CarryOver(Scenery current, Scenery fresh)
|
||||||
|
{
|
||||||
|
var waiting = current.Trainsets.ToList();
|
||||||
|
for (int i = 0; i < fresh.Trainsets.Count; i++)
|
||||||
|
{
|
||||||
|
var read = fresh.Trainsets[i];
|
||||||
|
int at = waiting.FindIndex(t => t.SourceFile == read.SourceFile && t.RawEntry == read.RawEntry);
|
||||||
|
if (at < 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
fresh.Trainsets[i] = waiting[at];
|
||||||
|
waiting.RemoveAt(at);
|
||||||
|
}
|
||||||
|
|
||||||
|
fresh.Weather.KeepChoicesOf(current.Weather);
|
||||||
|
foreach (var include in fresh.Includes)
|
||||||
|
if (current.Includes.FirstOrDefault(i => i.FilePath == include.FilePath) is { } earlier)
|
||||||
|
include.Selected = earlier.Selected;
|
||||||
|
|
||||||
|
return waiting.Where(SceneryTrainsetText.IsModified).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// On the UI thread, once for each change and just before acting on it: what a
|
||||||
|
/// look found may be out of date by then. A scenery read anew has what was held
|
||||||
|
/// put into it here.
|
||||||
|
/// </summary>
|
||||||
|
public SceneryJudgement Judge(SceneryChange change)
|
||||||
|
{
|
||||||
|
var sceneries = _library.Sceneries;
|
||||||
|
if (change.Current is not { } current)
|
||||||
|
return sceneries.Any(s => s.Path == change.Fresh!.Path) ? SceneryJudgement.Leave : SceneryJudgement.Take;
|
||||||
|
|
||||||
|
// Saved from here since that look, or reloaded by hand.
|
||||||
|
if (!sceneries.Contains(current) || (change.Fresh is not null && !IsStale(current)))
|
||||||
|
return SceneryJudgement.Leave;
|
||||||
|
|
||||||
|
// One whose file went stays with what was edited in it, for that to be saved elsewhere.
|
||||||
|
if (change.Fresh is not { } fresh)
|
||||||
|
return current.Trainsets.Any(SceneryTrainsetText.IsModified) ? SceneryJudgement.Leave : SceneryJudgement.Take;
|
||||||
|
|
||||||
|
var lost = CarryOver(current, fresh);
|
||||||
|
return lost.Count > 0 ? new SceneryJudgement(SceneryVerdict.AskFirst, lost) : SceneryJudgement.Take;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>On the UI thread: the library, and what is picked in it, take the change in.</summary>
|
||||||
|
public void Apply(SceneryChange change)
|
||||||
|
{
|
||||||
|
var sceneries = _library.Sceneries;
|
||||||
|
if (change.Current is not { } current)
|
||||||
|
{
|
||||||
|
sceneries.Add(change.Fresh!);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (change.Fresh is not { } fresh)
|
||||||
|
{
|
||||||
|
sceneries.Remove(current);
|
||||||
|
if (ReferenceEquals(_state.CurrentScenery, current))
|
||||||
|
{
|
||||||
|
_state.CurrentScenery = null;
|
||||||
|
_state.CurrentTrainset = null;
|
||||||
|
_state.StartingVehicleName = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sceneries[sceneries.IndexOf(current)] = fresh;
|
||||||
|
if (ReferenceEquals(_state.CurrentScenery, current))
|
||||||
|
Follow(current, fresh);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Follow(Scenery current, Scenery fresh)
|
||||||
|
{
|
||||||
|
// Its text changed, the one of its name stands for it, or the one in its place.
|
||||||
|
var picked = _state.CurrentTrainset;
|
||||||
|
var kept = picked is null || fresh.Trainsets.Contains(picked)
|
||||||
|
? picked
|
||||||
|
: fresh.Trainsets.FirstOrDefault(t => t.Name == picked.Name)
|
||||||
|
?? fresh.Trainsets.ElementAtOrDefault(current.Trainsets.IndexOf(picked));
|
||||||
|
|
||||||
|
_state.CurrentScenery = fresh;
|
||||||
|
_state.CurrentTrainset = kept;
|
||||||
|
if (kept is null || kept.Vehicles.All(v => v.Name != _state.StartingVehicleName))
|
||||||
|
_state.StartingVehicleName = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using StarterNG.Application.Abstractions;
|
using StarterNG.Application.Abstractions;
|
||||||
using StarterNG.Classes;
|
using StarterNG.Classes;
|
||||||
@@ -12,8 +14,20 @@ namespace StarterNG.Application;
|
|||||||
public interface ISettingsCapture
|
public interface ISettingsCapture
|
||||||
{
|
{
|
||||||
void CaptureInto(SimulatorSettings settings);
|
void CaptureInto(SimulatorSettings settings);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Keys the screen shows otherwise than the settings hold them - a value its
|
||||||
|
/// control cannot show - by key, in the file's terms.
|
||||||
|
/// </summary>
|
||||||
|
IReadOnlyDictionary<string, ShownValue> ShownUnlikeSettings => new Dictionary<string, ShownValue>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>A key as the screen shows it, and as the settings held it when it was put there; null for absent.</summary>
|
||||||
|
public sealed record ShownValue(string? Shown, string? Held);
|
||||||
|
|
||||||
|
/// <summary>A key a save changes: its value in the file before and after; null where it has none.</summary>
|
||||||
|
public sealed record IniChange(string Key, string? Before, string? After);
|
||||||
|
|
||||||
public sealed class SettingsStore
|
public sealed class SettingsStore
|
||||||
{
|
{
|
||||||
private readonly IFileSystem _files;
|
private readonly IFileSystem _files;
|
||||||
@@ -29,6 +43,11 @@ public sealed class SettingsStore
|
|||||||
private DateTime _configAgeUtc = DateTime.MinValue;
|
private DateTime _configAgeUtc = DateTime.MinValue;
|
||||||
private ISettingsCapture? _capture;
|
private ISettingsCapture? _capture;
|
||||||
|
|
||||||
|
// The settings in the file's terms when it was last read or written. A key
|
||||||
|
// they still put the same way was not changed here, so the file keeps it as
|
||||||
|
// it has it: a value outside what the starter offers, its spacing, its comment.
|
||||||
|
private IReadOnlyDictionary<string, string> _synced = new Dictionary<string, string>();
|
||||||
|
|
||||||
public SettingsStore(IFileSystem files, IClock clock, IDiagnosticsLog log, SettingsPaths paths,
|
public SettingsStore(IFileSystem files, IClock clock, IDiagnosticsLog log, SettingsPaths paths,
|
||||||
SettingsSerializer serializer, ExecutableLocator executables)
|
SettingsSerializer serializer, ExecutableLocator executables)
|
||||||
{
|
{
|
||||||
@@ -45,6 +64,15 @@ public sealed class SettingsStore
|
|||||||
|
|
||||||
public string LoadedFrom { get; private set; } = string.Empty;
|
public string LoadedFrom { get; private set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The last read failed, so <see cref="Settings"/> holds defaults, not the file's.
|
||||||
|
/// Nothing is saved until a read works: that would put them in its place.
|
||||||
|
/// </summary>
|
||||||
|
public bool LoadFailed { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>The last save did not reach the file; the log says why.</summary>
|
||||||
|
public bool LastSaveFailed { get; private set; }
|
||||||
|
|
||||||
public string SavePath => _savePath;
|
public string SavePath => _savePath;
|
||||||
|
|
||||||
public void RegisterCapture(ISettingsCapture capture) => _capture = capture;
|
public void RegisterCapture(ISettingsCapture capture) => _capture = capture;
|
||||||
@@ -57,6 +85,16 @@ public sealed class SettingsStore
|
|||||||
LoadFrom(source);
|
LoadFrom(source);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>A player with no settings of their own yet starts at High, in the file at once so the simulator goes by it too.</summary>
|
||||||
|
public void StartAtHighWithoutOwnSettings()
|
||||||
|
{
|
||||||
|
if (LoadFailed || _files.FileExists(_savePath))
|
||||||
|
return;
|
||||||
|
|
||||||
|
GraphicsPreset.High.ApplyTo(Settings);
|
||||||
|
Save();
|
||||||
|
}
|
||||||
|
|
||||||
public void LoadFrom(string path)
|
public void LoadFrom(string path)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -64,17 +102,38 @@ public sealed class SettingsStore
|
|||||||
_config = _files.FileExists(path)
|
_config = _files.FileExists(path)
|
||||||
? ConfigFile.Parse(_files.ReadAllText(path, _encoding))
|
? ConfigFile.Parse(_files.ReadAllText(path, _encoding))
|
||||||
: new ConfigFile();
|
: new ConfigFile();
|
||||||
|
LoadFailed = false;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_log.Log($"reading {path}", ex);
|
_log.Log($"reading {path}", ex);
|
||||||
_config = new ConfigFile();
|
_config = new ConfigFile();
|
||||||
|
LoadFailed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
_serializer.Read(Settings, _config);
|
_serializer.Read(Settings, _config);
|
||||||
|
_synced = Render(Settings);
|
||||||
TouchConfigAge();
|
TouchConfigAge();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads a profile into the settings; true when it read cleanly. A read that failed
|
||||||
|
/// would leave defaults to be saved over the real settings, so those are read again.
|
||||||
|
/// </summary>
|
||||||
|
public bool TryLoadProfile(string path)
|
||||||
|
{
|
||||||
|
if (_files.FileExists(path))
|
||||||
|
{
|
||||||
|
LoadFrom(path);
|
||||||
|
if (!LoadFailed)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (LoadFailed)
|
||||||
|
Load();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
public void CaptureAndSave()
|
public void CaptureAndSave()
|
||||||
{
|
{
|
||||||
_capture?.CaptureInto(Settings);
|
_capture?.CaptureInto(Settings);
|
||||||
@@ -91,7 +150,21 @@ public sealed class SettingsStore
|
|||||||
|
|
||||||
public void SaveTo(string path)
|
public void SaveTo(string path)
|
||||||
{
|
{
|
||||||
_serializer.Write(Settings, _config);
|
LastSaveFailed = true;
|
||||||
|
if (LoadFailed)
|
||||||
|
{
|
||||||
|
_log.Log($"{path} not saved: the settings could not be read, what is held is defaults");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var change in Changes(Settings))
|
||||||
|
{
|
||||||
|
if (change.After is null)
|
||||||
|
_config.Remove(change.Key);
|
||||||
|
else
|
||||||
|
_config.Set(change.Key, change.After);
|
||||||
|
}
|
||||||
|
_synced = Render(Settings);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -99,8 +172,9 @@ public sealed class SettingsStore
|
|||||||
if (!string.IsNullOrEmpty(directory))
|
if (!string.IsNullOrEmpty(directory))
|
||||||
_files.CreateDirectory(directory);
|
_files.CreateDirectory(directory);
|
||||||
|
|
||||||
_files.WriteAllText(path, _config.ToText(), _encoding);
|
_files.WriteAllBytesAtomic(path, _encoding.GetBytes(_config.ToText()));
|
||||||
TouchConfigAge();
|
TouchConfigAge();
|
||||||
|
LastSaveFailed = false;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -108,6 +182,116 @@ public sealed class SettingsStore
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Every key as <paramref name="settings"/> would put it, in the order they are written.</summary>
|
||||||
|
public IReadOnlyDictionary<string, string> Render(SimulatorSettings settings)
|
||||||
|
{
|
||||||
|
var config = new ConfigFile();
|
||||||
|
_serializer.Write(settings, config);
|
||||||
|
var keys = new OrderedDictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var (key, value) in config.Entries())
|
||||||
|
keys[key] = value;
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What saving <paramref name="settings"/> would change in the file: the keys
|
||||||
|
/// changed since it was read, those it lacks, and the ones no simulator reads.
|
||||||
|
/// A key the screen shows otherwise than the file has it is left alone until
|
||||||
|
/// moved. One the file leaves bare or past endconfig counts as lacking.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<IniChange> Changes(SimulatorSettings settings)
|
||||||
|
{
|
||||||
|
var now = Render(settings);
|
||||||
|
var shown = _capture?.ShownUnlikeSettings ?? new Dictionary<string, ShownValue>();
|
||||||
|
var changes = new List<IniChange>();
|
||||||
|
|
||||||
|
foreach (string key in SettingsSerializer.ObsoleteKeys)
|
||||||
|
Add(key, null);
|
||||||
|
foreach (string key in SettingsSerializer.FixedLengthKeys.Where(key => !now.ContainsKey(key)))
|
||||||
|
Add(key, null);
|
||||||
|
foreach (string key in SettingsSerializer.OptionalKeys.Concat(SimulatorKeys.All.Select(key => key.Key)))
|
||||||
|
if (!now.ContainsKey(key) && _config.GetRaw(key) is { Length: 0 })
|
||||||
|
Add(key, null);
|
||||||
|
foreach (var (key, value) in now)
|
||||||
|
if (!Kept(key, value))
|
||||||
|
Add(key, value);
|
||||||
|
foreach (string key in _synced.Keys.Where(key => !now.ContainsKey(key)))
|
||||||
|
if (!Kept(key, null))
|
||||||
|
Add(key, null);
|
||||||
|
return changes;
|
||||||
|
|
||||||
|
bool Kept(string key, string? value)
|
||||||
|
{
|
||||||
|
// Past endconfig the simulator never gets to it; bare, it takes the next
|
||||||
|
// word of the file for its value.
|
||||||
|
if (_config.IsPastEnd(key) || _config.GetRaw(key) is { Length: 0 })
|
||||||
|
return false;
|
||||||
|
if (string.Equals(key, "uartfeature", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Uart.FeaturesStand(_config.GetRaw(key), value);
|
||||||
|
if (SettingsSerializer.FixedLengthKeys.Contains(key, StringComparer.OrdinalIgnoreCase))
|
||||||
|
return _config.GetRaw(key) == value;
|
||||||
|
string? synced = _synced.GetValueOrDefault(key);
|
||||||
|
if (shown.TryGetValue(key, out var onScreen) && onScreen.Shown == value && onScreen.Held == synced)
|
||||||
|
return true;
|
||||||
|
return _config.Has(key) && synced == value;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Add(string key, string? value)
|
||||||
|
{
|
||||||
|
// To the simulator a key past endconfig is not set yet, whatever it says.
|
||||||
|
string? before = value is not null && _config.IsPastEnd(key) ? null : _config.GetRaw(key);
|
||||||
|
if (before != value)
|
||||||
|
changes.Add(new IniChange(key, before, value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The keys <paramref name="shown"/> puts otherwise than the settings hold them,
|
||||||
|
/// with both values, in the file's terms.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyDictionary<string, ShownValue> ShownUnlike(SimulatorSettings shown)
|
||||||
|
{
|
||||||
|
var held = Render(Settings);
|
||||||
|
var now = Render(shown);
|
||||||
|
var unlike = new Dictionary<string, ShownValue>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (string key in held.Keys.Union(now.Keys, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
string? before = held.GetValueOrDefault(key), after = now.GetValueOrDefault(key);
|
||||||
|
if (before != after)
|
||||||
|
unlike[key] = new ShownValue(after, before);
|
||||||
|
}
|
||||||
|
return unlike;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A copy of <paramref name="settings"/> with <paramref name="keys"/> back at
|
||||||
|
/// what the simulator goes by when eu07.ini does not set them.
|
||||||
|
/// </summary>
|
||||||
|
public SimulatorSettings WithDefaults(SimulatorSettings settings, IReadOnlyCollection<string> keys)
|
||||||
|
{
|
||||||
|
var defaults = new SimulatorSettings();
|
||||||
|
_serializer.Read(defaults, new ConfigFile());
|
||||||
|
return WithKeysOf(settings, defaults, keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A copy of <paramref name="settings"/> with <paramref name="keys"/> as <paramref name="source"/> has them.</summary>
|
||||||
|
public SimulatorSettings WithKeysOf(SimulatorSettings settings, SimulatorSettings source, IReadOnlyCollection<string> keys)
|
||||||
|
{
|
||||||
|
var taken = Render(source);
|
||||||
|
|
||||||
|
var config = new ConfigFile();
|
||||||
|
foreach (var (key, value) in Render(settings))
|
||||||
|
if (!keys.Contains(key, StringComparer.OrdinalIgnoreCase))
|
||||||
|
config.Set(key, value);
|
||||||
|
foreach (string key in keys)
|
||||||
|
if (taken.TryGetValue(key, out string? value))
|
||||||
|
config.Set(key, value);
|
||||||
|
|
||||||
|
var copy = settings.Copy();
|
||||||
|
_serializer.Read(copy, config);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
public void TouchConfigAge()
|
public void TouchConfigAge()
|
||||||
{
|
{
|
||||||
string path = string.IsNullOrEmpty(_savePath) ? _paths.UserConfigPath() : _savePath;
|
string path = string.IsNullOrEmpty(_savePath) ? _paths.UserConfigPath() : _savePath;
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ namespace StarterNG.Application;
|
|||||||
/// <summary>What a simulator that went down left behind: its log and its crash dumps.</summary>
|
/// <summary>What a simulator that went down left behind: its log and its crash dumps.</summary>
|
||||||
public sealed class SimulatorAftermath
|
public sealed class SimulatorAftermath
|
||||||
{
|
{
|
||||||
|
// A little slack: the start is taken just after the launch, not before it.
|
||||||
|
private static readonly TimeSpan StartSlack = TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
private readonly IFileSystem _files;
|
private readonly IFileSystem _files;
|
||||||
private readonly IGamePaths _paths;
|
private readonly IGamePaths _paths;
|
||||||
private readonly IDiagnosticsLog _log;
|
private readonly IDiagnosticsLog _log;
|
||||||
@@ -42,8 +45,7 @@ public sealed class SimulatorAftermath
|
|||||||
if (_files.DirectoryExists(crashpad))
|
if (_files.DirectoryExists(crashpad))
|
||||||
dumps.AddRange(_files.GetFilesRecursive(crashpad, "*.dmp"));
|
dumps.AddRange(_files.GetFilesRecursive(crashpad, "*.dmp"));
|
||||||
|
|
||||||
// A little slack: the start is taken just after the launch, not before it.
|
var since = startedUtc - StartSlack;
|
||||||
var since = startedUtc - TimeSpan.FromSeconds(5);
|
|
||||||
return dumps.Distinct(StringComparer.OrdinalIgnoreCase)
|
return dumps.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
.Where(dump => _files.GetLastWriteTimeUtc(dump) >= since)
|
.Where(dump => _files.GetLastWriteTimeUtc(dump) >= since)
|
||||||
.OrderByDescending(_files.GetLastWriteTimeUtc)
|
.OrderByDescending(_files.GetLastWriteTimeUtc)
|
||||||
@@ -56,13 +58,17 @@ public sealed class SimulatorAftermath
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The end of the simulator's own log, for when it died without a word on its error stream.</summary>
|
/// <summary>
|
||||||
public IReadOnlyList<string> SimulatorLogTail(int count)
|
/// The end of the simulator's own log, for when it died without a word on its
|
||||||
|
/// error stream. A log not written since the start is the session before's, the
|
||||||
|
/// simulator having died before opening its own: it says nothing about this one.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<string> SimulatorLogTail(int count, DateTime startedUtc)
|
||||||
{
|
{
|
||||||
string path = _paths.FromRoot("log.txt");
|
string path = _paths.FromRoot("log.txt");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!_files.FileExists(path))
|
if (!_files.FileExists(path) || _files.GetLastWriteTimeUtc(path) < startedUtc - StartSlack)
|
||||||
return Array.Empty<string>();
|
return Array.Empty<string>();
|
||||||
|
|
||||||
var lines = LegacyText.Decode(_files.ReadAllBytes(path))
|
var lines = LegacyText.Decode(_files.ReadAllBytes(path))
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
|
||||||
using StarterNG.Application.Abstractions;
|
using StarterNG.Application.Abstractions;
|
||||||
using StarterNG.Classes;
|
using StarterNG.Classes;
|
||||||
using StarterNG.Domain;
|
using StarterNG.Domain;
|
||||||
using StarterNG.Domain.Settings;
|
using StarterNG.Domain.Settings;
|
||||||
using StarterNG.Domain.Vehicles;
|
using StarterNG.Domain.Vehicles;
|
||||||
|
using StarterNG.Infrastructure;
|
||||||
using StarterNG.Infrastructure.Adapters;
|
using StarterNG.Infrastructure.Adapters;
|
||||||
using StarterNG.Infrastructure.Settings;
|
using StarterNG.Infrastructure.Settings;
|
||||||
|
|
||||||
@@ -47,11 +47,21 @@ public sealed class StartSimulation
|
|||||||
private readonly IProcessLauncher _processes;
|
private readonly IProcessLauncher _processes;
|
||||||
private readonly IRandomSource _random;
|
private readonly IRandomSource _random;
|
||||||
private readonly IDiagnosticsLog _log;
|
private readonly IDiagnosticsLog _log;
|
||||||
|
private readonly LinuxGpus _gpus;
|
||||||
|
private readonly IEnvironment _environment;
|
||||||
|
private readonly WineConfigLink _wineConfig;
|
||||||
|
private readonly HaslerTapes _tapes;
|
||||||
|
|
||||||
public StartSimulation(AppState state, SettingsStore settings, VehicleCatalog vehicles,
|
public StartSimulation(AppState state, SettingsStore settings, VehicleCatalog vehicles,
|
||||||
ExecutableLocator executables, IFileSystem files, IGamePaths paths,
|
ExecutableLocator executables, IFileSystem files, IGamePaths paths,
|
||||||
IProcessLauncher processes, IRandomSource random, IDiagnosticsLog log)
|
IProcessLauncher processes, IRandomSource random, IDiagnosticsLog log,
|
||||||
|
LinuxGpus gpus, IEnvironment environment, WineConfigLink wineConfig,
|
||||||
|
HaslerTapes tapes)
|
||||||
{
|
{
|
||||||
|
_gpus = gpus;
|
||||||
|
_wineConfig = wineConfig;
|
||||||
|
_environment = environment;
|
||||||
|
_tapes = tapes;
|
||||||
_state = state;
|
_state = state;
|
||||||
_settings = settings;
|
_settings = settings;
|
||||||
_vehicles = vehicles;
|
_vehicles = vehicles;
|
||||||
@@ -63,11 +73,12 @@ public sealed class StartSimulation
|
|||||||
_log = log;
|
_log = log;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string? StartableVehicle(Trainset? trainset, string? preferred)
|
/// <summary>
|
||||||
|
/// What the start button says of the consist. The vehicle picked answers for it
|
||||||
|
/// even with nobody in it, where the launch itself goes on to the first crew.
|
||||||
|
/// </summary>
|
||||||
|
public static bool HasCrewToStart(Trainset trainset, string? preferred)
|
||||||
{
|
{
|
||||||
if (trainset is null || trainset.Vehicles.Count == 0)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
static bool CanStart(Dynamic vehicle) =>
|
static bool CanStart(Dynamic vehicle) =>
|
||||||
vehicle.DriverType is eDriverType.Headdriver or eDriverType.Reardriver or eDriverType.Passenger;
|
vehicle.DriverType is eDriverType.Headdriver or eDriverType.Reardriver or eDriverType.Passenger;
|
||||||
|
|
||||||
@@ -75,39 +86,49 @@ public sealed class StartSimulation
|
|||||||
{
|
{
|
||||||
var picked = trainset.Vehicles.FirstOrDefault(vehicle =>
|
var picked = trainset.Vehicles.FirstOrDefault(vehicle =>
|
||||||
string.Equals(vehicle.Name, preferred, StringComparison.OrdinalIgnoreCase));
|
string.Equals(vehicle.Name, preferred, StringComparison.OrdinalIgnoreCase));
|
||||||
if (picked is not null && CanStart(picked))
|
if (picked is not null)
|
||||||
return picked.Name;
|
return CanStart(picked);
|
||||||
}
|
}
|
||||||
|
|
||||||
return trainset.Vehicles.FirstOrDefault(CanStart)?.Name;
|
return trainset.Vehicles.Any(CanStart);
|
||||||
}
|
}
|
||||||
|
|
||||||
public SimulationStartResult Execute(bool freeFly, bool saveSettings)
|
public SimulationStartResult Execute(bool freeFly, bool saveSettings)
|
||||||
{
|
{
|
||||||
var scenery = _state.CurrentScenery;
|
var scenery = _state.CurrentScenery;
|
||||||
var trainset = _state.CurrentTrainset;
|
var trainset = _state.CurrentTrainset;
|
||||||
if (scenery is null || trainset is null)
|
if (scenery is null)
|
||||||
return new SimulationStartResult(SimulationStartOutcome.NothingSelected);
|
return new SimulationStartResult(SimulationStartOutcome.NothingSelected);
|
||||||
|
|
||||||
// The depot checkbox is the consist's own state; this setting is a blanket
|
// A scenery with no consist picked starts with the free camera.
|
||||||
// override for people who always want the same thing, and it wins.
|
string? vehicle = null;
|
||||||
switch (_settings.Settings.ReadyOverride)
|
if (trainset is null)
|
||||||
{
|
{
|
||||||
case ConsistReadyOverride.AlwaysCold:
|
freeFly = true;
|
||||||
trainset.ReadyToGo = false;
|
|
||||||
break;
|
|
||||||
case ConsistReadyOverride.AlwaysReady:
|
|
||||||
trainset.ReadyToGo = true;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// The depot checkbox is the consist's own state; this setting is a blanket
|
||||||
|
// override for people who always want the same thing, and it wins.
|
||||||
|
switch (_settings.Settings.ReadyOverride)
|
||||||
|
{
|
||||||
|
case ConsistReadyOverride.AlwaysCold:
|
||||||
|
trainset.ReadyToGo = false;
|
||||||
|
break;
|
||||||
|
case ConsistReadyOverride.AlwaysReady:
|
||||||
|
trainset.ReadyToGo = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
string? vehicle = TrainsetDisplay.UniquifyForLaunch(trainset, scenery, _state.StartingVehicleName, _vehicles)
|
vehicle = TrainsetDisplay.UniquifyForLaunch(trainset, scenery, _state.StartingVehicleName, _vehicles);
|
||||||
?? StartableVehicle(trainset, _state.StartingVehicleName);
|
_state.StartingVehicleName = vehicle;
|
||||||
_state.StartingVehicleName = vehicle;
|
}
|
||||||
|
|
||||||
scenery.Weather.Dirty = true;
|
scenery.Weather.Dirty = true;
|
||||||
|
|
||||||
string exportName = ExportPrefix + Path.GetFileName(scenery.Path);
|
// In lower case: that is the name the simulator looks for, and on Linux
|
||||||
|
// "$Glinojeck.scn" is not "$glinojeck.scn".
|
||||||
|
string exportName = ExportPrefix + SimulatorNames.Lower(Path.GetFileName(scenery.Path));
|
||||||
string exportPath = Path.Combine(Path.GetDirectoryName(scenery.Path) ?? "scenery", exportName);
|
string exportPath = Path.Combine(Path.GetDirectoryName(scenery.Path) ?? "scenery", exportName);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -125,6 +146,9 @@ public sealed class StartSimulation
|
|||||||
if (saveSettings)
|
if (saveSettings)
|
||||||
_settings.CaptureAndSave();
|
_settings.CaptureAndSave();
|
||||||
|
|
||||||
|
if (_settings.Settings.PhysicsLog)
|
||||||
|
_tapes.RecordLaunch(scenery, trainset, freeFly ? null : vehicle);
|
||||||
|
|
||||||
string[] arguments = freeFly || string.IsNullOrEmpty(vehicle)
|
string[] arguments = freeFly || string.IsNullOrEmpty(vehicle)
|
||||||
? new[] { "-s", exportName }
|
? new[] { "-s", exportName }
|
||||||
: new[] { "-s", exportName, "-v", vehicle };
|
: new[] { "-s", exportName, "-v", vehicle };
|
||||||
@@ -134,15 +158,16 @@ public sealed class StartSimulation
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Opens the selected scenery in the simulator's editor. The scenery file itself,
|
/// Opens the selected scenery in the simulator's editor. The scenery file itself,
|
||||||
/// not an export: what the editor saves has to land in the scenery.
|
/// not an export: what the editor saves has to land in the scenery. With none
|
||||||
|
/// selected the simulator opens its wizard of a new scenery.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public SimulationStartResult ExecuteEditor()
|
public SimulationStartResult ExecuteEditor() =>
|
||||||
{
|
Launch(_state.CurrentScenery is { } scenery
|
||||||
if (_state.CurrentScenery is not { } scenery)
|
? new[] { "-edit", Path.GetFileName(scenery.Path) }
|
||||||
return new SimulationStartResult(SimulationStartOutcome.NothingSelected);
|
: new[] { "-edit" });
|
||||||
|
|
||||||
return Launch(new[] { "-edit", Path.GetFileName(scenery.Path) });
|
/// <summary>The simulator's wizard of a new scenery, whichever one is selected.</summary>
|
||||||
}
|
public SimulationStartResult ExecuteNewSceneryEditor() => Launch(new[] { "-edit" });
|
||||||
|
|
||||||
private SimulationStartResult Launch(IReadOnlyList<string> arguments)
|
private SimulationStartResult Launch(IReadOnlyList<string> arguments)
|
||||||
{
|
{
|
||||||
@@ -157,10 +182,14 @@ public sealed class StartSimulation
|
|||||||
// The installation, not the folder of the binary: a simulator picked from a
|
// The installation, not the folder of the binary: a simulator picked from a
|
||||||
// build tree lives beside the data, and the data is what it opens.
|
// build tree lives beside the data, and the data is what it opens.
|
||||||
var (program, programArguments) = _executables.LaunchCommand(executable, arguments);
|
var (program, programArguments) = _executables.LaunchCommand(executable, arguments);
|
||||||
|
// Under Wine eu07.exe would read the prefix's own eu07.ini, not the one just saved.
|
||||||
|
if (_executables.RunsThroughWine(executable))
|
||||||
|
_wineConfig.Ensure();
|
||||||
// Its error stream is kept for a report on a crash - unless the starter closes
|
// Its error stream is kept for a report on a crash - unless the starter closes
|
||||||
// now, leaving nobody to read it or to report.
|
// now, leaving nobody to read it or to report.
|
||||||
bool watched = !_settings.Settings.AutoCloseStarter;
|
bool watched = !_settings.Settings.AutoCloseStarter;
|
||||||
var process = _processes.Start(program, programArguments, _paths.Root, watched, out string? error);
|
var process = _processes.Start(program, programArguments, _paths.Root, GraphicsCard(), watched,
|
||||||
|
out string? error);
|
||||||
if (process is null)
|
if (process is null)
|
||||||
return new SimulationStartResult(SimulationStartOutcome.LaunchFailed, ExecutablePath: executable,
|
return new SimulationStartResult(SimulationStartOutcome.LaunchFailed, ExecutablePath: executable,
|
||||||
Detail: error);
|
Detail: error);
|
||||||
@@ -168,4 +197,30 @@ public sealed class StartSimulation
|
|||||||
return new SimulationStartResult(SimulationStartOutcome.Started, process, executable);
|
return new SimulationStartResult(SimulationStartOutcome.Started, process, executable);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What puts the simulator on the card the settings ask for, said in the log
|
||||||
|
/// either way: a slow simulator on a laptop is the first thing to ask about.
|
||||||
|
/// </summary>
|
||||||
|
private IReadOnlyDictionary<string, string> GraphicsCard()
|
||||||
|
{
|
||||||
|
var gpus = _gpus.List();
|
||||||
|
if (gpus.Count < 2)
|
||||||
|
return new Dictionary<string, string>();
|
||||||
|
|
||||||
|
var preference = _settings.Settings.Gpu;
|
||||||
|
string cards = string.Join(", ", gpus);
|
||||||
|
if (GpuOffload.Target(gpus, preference, _environment.GetVariable) is not { } target)
|
||||||
|
{
|
||||||
|
_log.Log($"GPU: {cards}; left to the system ({(preference == GpuPreference.System
|
||||||
|
? "chosen in the settings"
|
||||||
|
: "set by the player, or no card to move to")})");
|
||||||
|
return new Dictionary<string, string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var environment = GpuOffload.EnvironmentFor(target);
|
||||||
|
_log.Log($"GPU: {cards}; simulator on {target} with " +
|
||||||
|
string.Join(' ', environment.Select(pair => $"{pair.Key}={pair.Value}")));
|
||||||
|
return environment;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
62
StarterNG/Application/WarehouseEdit.cs
Normal file
62
StarterNG/Application/WarehouseEdit.cs
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using StarterNG.Classes;
|
||||||
|
|
||||||
|
namespace StarterNG.Application;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The warehouse consist the strip edits while the warehouse tab is open, under
|
||||||
|
/// its name there, and the entry as last written - every change saves itself.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WarehouseEdit
|
||||||
|
{
|
||||||
|
/// <summary>One trainset per warehouse consist, so its undo history outlives a switch.</summary>
|
||||||
|
private readonly Dictionary<string, Trainset> _trainsets = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private string? _saved;
|
||||||
|
|
||||||
|
public Trainset? Trainset { get; private set; }
|
||||||
|
|
||||||
|
public string? Name { get; private set; }
|
||||||
|
|
||||||
|
public Trainset Open(TrainsetPreset preset)
|
||||||
|
{
|
||||||
|
// Written over from elsewhere - "add to warehouse as" the same name - the
|
||||||
|
// entry is read afresh; the history of what it was no longer applies.
|
||||||
|
if (!_trainsets.TryGetValue(preset.Name, out var trainset) ||
|
||||||
|
ConsistText.Serialize(trainset) != preset.Entry)
|
||||||
|
_trainsets[preset.Name] = trainset = new Trainset(preset.Entry);
|
||||||
|
|
||||||
|
Trainset = trainset;
|
||||||
|
Name = preset.Name;
|
||||||
|
_saved = preset.Entry;
|
||||||
|
return trainset;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Close()
|
||||||
|
{
|
||||||
|
Trainset = null;
|
||||||
|
Name = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Renamed(string old, string fresh)
|
||||||
|
{
|
||||||
|
if (_trainsets.Remove(old, out var trainset))
|
||||||
|
_trainsets[fresh] = trainset;
|
||||||
|
if (string.Equals(Name, old, StringComparison.OrdinalIgnoreCase))
|
||||||
|
Name = fresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Save()
|
||||||
|
{
|
||||||
|
if (Trainset is null || Name is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
string entry = ConsistText.Serialize(Trainset);
|
||||||
|
if (entry == _saved)
|
||||||
|
return;
|
||||||
|
|
||||||
|
PresetStore.Save(Name, entry);
|
||||||
|
_saved = entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
namespace StarterNG.Classes;
|
namespace StarterNG.Classes;
|
||||||
@@ -12,6 +13,12 @@ public sealed class ConfigFile
|
|||||||
private readonly Dictionary<string, int> _index =
|
private readonly Dictionary<string, int> _index =
|
||||||
new(StringComparer.OrdinalIgnoreCase);
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// The simulator stops reading at this word (Globals.cpp), whatever its case.
|
||||||
|
private const string EndKey = "endconfig";
|
||||||
|
|
||||||
|
// Its first line, or -1 when the file runs to its end.
|
||||||
|
private int _end = -1;
|
||||||
|
|
||||||
public static ConfigFile Parse(string text)
|
public static ConfigFile Parse(string text)
|
||||||
{
|
{
|
||||||
var cfg = new ConfigFile();
|
var cfg = new ConfigFile();
|
||||||
@@ -24,14 +31,44 @@ public sealed class ConfigFile
|
|||||||
|
|
||||||
private void AddLine(string raw)
|
private void AddLine(string raw)
|
||||||
{
|
{
|
||||||
int idx = _lines.Count;
|
|
||||||
_lines.Add(raw);
|
_lines.Add(raw);
|
||||||
if (TryParseKey(raw, out string key))
|
IndexLine(_lines.Count - 1);
|
||||||
_index[key] = idx;
|
}
|
||||||
|
|
||||||
|
private void IndexLine(int line)
|
||||||
|
{
|
||||||
|
if (!TryParseKey(_lines[line], out string key))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (_end < 0 && key.Equals(EndKey, StringComparison.OrdinalIgnoreCase))
|
||||||
|
_end = line;
|
||||||
|
|
||||||
|
// Of a key on both sides of the end, the line the simulator reads counts.
|
||||||
|
if (_end < 0 || line == _end || !_index.TryGetValue(key, out int seen) || seen > _end)
|
||||||
|
_index[key] = line;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Reindex()
|
||||||
|
{
|
||||||
|
_index.Clear();
|
||||||
|
_end = -1;
|
||||||
|
for (int line = 0; line < _lines.Count; line++)
|
||||||
|
IndexLine(line);
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool Has(string key) => _index.ContainsKey(key);
|
public bool Has(string key) => _index.ContainsKey(key);
|
||||||
|
|
||||||
|
/// <summary>Whether the line setting the key is one the simulator never gets to.</summary>
|
||||||
|
public bool IsPastEnd(string key) =>
|
||||||
|
_end >= 0 && _index.TryGetValue(key, out int line) && line > _end;
|
||||||
|
|
||||||
|
/// <summary>Every key the file sets, in the order of its lines, with its value.</summary>
|
||||||
|
public IEnumerable<KeyValuePair<string, string>> Entries()
|
||||||
|
{
|
||||||
|
foreach (var (key, _) in _index.OrderBy(pair => pair.Value))
|
||||||
|
yield return new KeyValuePair<string, string>(key, GetRaw(key) ?? string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
public string? GetRaw(string key)
|
public string? GetRaw(string key)
|
||||||
{
|
{
|
||||||
if (!_index.TryGetValue(key, out int i))
|
if (!_index.TryGetValue(key, out int i))
|
||||||
@@ -83,35 +120,61 @@ public sealed class ConfigFile
|
|||||||
if (_index.TryGetValue(key, out int i))
|
if (_index.TryGetValue(key, out int i))
|
||||||
{
|
{
|
||||||
SplitLine(_lines[i], out string indent, out _, out _, out string comment);
|
SplitLine(_lines[i], out string indent, out _, out _, out string comment);
|
||||||
var sb = new StringBuilder();
|
string line = Line(indent, key, value, comment);
|
||||||
sb.Append(indent).Append(key);
|
|
||||||
if (value.Length > 0)
|
if (i > _end && _end >= 0)
|
||||||
sb.Append(' ').Append(value);
|
{
|
||||||
if (comment.Length > 0)
|
_lines.RemoveAt(i);
|
||||||
sb.Append('\t').Append(comment);
|
InsertBeforeEnd(line);
|
||||||
_lines[i] = sb.ToString();
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_lines[i] = line;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (_end >= 0)
|
||||||
|
{
|
||||||
|
InsertBeforeEnd(Line("", key, value, ""));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
int idx = _lines.Count;
|
int idx = _lines.Count;
|
||||||
_lines.Add(value.Length > 0 ? $"{key} {value}" : key);
|
_lines.Add(Line("", key, value, ""));
|
||||||
_index[key] = idx;
|
_index[key] = idx;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Remove(string key)
|
private static string Line(string indent, string key, string value, string comment)
|
||||||
{
|
{
|
||||||
if (!_index.TryGetValue(key, out int i))
|
|
||||||
return;
|
|
||||||
|
|
||||||
SplitLine(_lines[i], out string indent, out _, out string value, out string comment);
|
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
sb.Append(indent).Append("// ").Append(key);
|
sb.Append(indent).Append(key);
|
||||||
if (value.Length > 0)
|
if (value.Length > 0)
|
||||||
sb.Append(' ').Append(value);
|
sb.Append(' ').Append(value);
|
||||||
if (comment.Length > 0)
|
if (comment.Length > 0)
|
||||||
sb.Append('\t').Append(comment);
|
sb.Append('\t').Append(comment);
|
||||||
_lines[i] = sb.ToString();
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InsertBeforeEnd(string line)
|
||||||
|
{
|
||||||
|
_lines.Insert(_end, line);
|
||||||
|
Reindex();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Comments out every line setting the key: the last one alone would bring the one before it back.</summary>
|
||||||
|
public void Remove(string key)
|
||||||
|
{
|
||||||
|
if (!_index.ContainsKey(key))
|
||||||
|
return;
|
||||||
|
|
||||||
|
for (int i = 0; i < _lines.Count; i++)
|
||||||
|
{
|
||||||
|
SplitLine(_lines[i], out string indent, out string found, out string value, out string comment);
|
||||||
|
if (!found.Equals(key, StringComparison.OrdinalIgnoreCase))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
_lines[i] = Line(indent + "// ", found, value, comment);
|
||||||
|
}
|
||||||
|
|
||||||
_index.Remove(key);
|
_index.Remove(key);
|
||||||
}
|
}
|
||||||
@@ -133,7 +196,7 @@ public sealed class ConfigFile
|
|||||||
private static void SplitLine(string raw, out string indent, out string key,
|
private static void SplitLine(string raw, out string indent, out string key,
|
||||||
out string value, out string comment)
|
out string value, out string comment)
|
||||||
{
|
{
|
||||||
indent = key = value = comment = string.Empty;
|
key = value = comment = string.Empty;
|
||||||
|
|
||||||
int start = 0;
|
int start = 0;
|
||||||
while (start < raw.Length && char.IsWhiteSpace(raw[start]))
|
while (start < raw.Length && char.IsWhiteSpace(raw[start]))
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
|
|
||||||
namespace StarterNG.Classes;
|
namespace StarterNG.Classes;
|
||||||
@@ -18,8 +19,46 @@ public sealed class KeyCap
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>What the simulator does with a key combination on its own.</summary>
|
||||||
|
/// <param name="TextKey">What that is, as a translation key.</param>
|
||||||
|
/// <param name="Blocks">
|
||||||
|
/// It gets there ahead of the key file, so a binding here would never fire.
|
||||||
|
/// Otherwise it is what is left to the key when no binding takes it.
|
||||||
|
/// </param>
|
||||||
|
public readonly record struct SimulatorKeyUse(string TextKey, bool Blocks);
|
||||||
|
|
||||||
public static class KeyMap
|
public static class KeyMap
|
||||||
{
|
{
|
||||||
|
public const string NumpadPrefix = "num_";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read from the simulator's code: ui_layer::on_key and driver_ui::on_key are asked first and only
|
||||||
|
/// without a modifier, driver_mode::on_key takes Shift+F12 itself, then comes the key
|
||||||
|
/// file, and driver_mode::OnKeyDown gets what that leaves.
|
||||||
|
/// </summary>
|
||||||
|
public static SimulatorKeyUse? SimulatorUse(string token, bool shift, bool ctrl)
|
||||||
|
{
|
||||||
|
bool plain = !shift && !ctrl;
|
||||||
|
switch (token.ToLowerInvariant())
|
||||||
|
{
|
||||||
|
case "tab" when plain: return new SimulatorKeyUse("KbUseMap", true);
|
||||||
|
case "f1" when plain: return new SimulatorKeyUse("KbUseConsist", true);
|
||||||
|
case "f2" when plain: return new SimulatorKeyUse("KbUseTimetable", true);
|
||||||
|
case "f3" when plain: return new SimulatorKeyUse("KbUseScenario", true);
|
||||||
|
case "f9" when plain: return new SimulatorKeyUse("KbUseLog", true);
|
||||||
|
case "f10" when plain: return new SimulatorKeyUse("KbUseQuit", true);
|
||||||
|
case "f12" when shift: return new SimulatorKeyUse("KbUseDebugPanel", true);
|
||||||
|
|
||||||
|
case "f4": return new SimulatorKeyUse("KbUseCab", false);
|
||||||
|
case "f5": return new SimulatorKeyUse("KbUseVehicle", false);
|
||||||
|
case "f6": return new SimulatorKeyUse("KbUseDebug", false);
|
||||||
|
case "f7" when plain || ctrl: return new SimulatorKeyUse("KbUseDebug", false);
|
||||||
|
case "f11" when plain: return new SimulatorKeyUse("KbUseEditor", false);
|
||||||
|
case [>= '0' and <= '9'] when shift: return new SimulatorKeyUse("KbUseSceneryEvent", false);
|
||||||
|
case [>= '0' and <= '9'] when ctrl: return new SimulatorKeyUse("KbUseCamera", false);
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static string? FromInput(Key key, PhysicalKey physical)
|
public static string? FromInput(Key key, PhysicalKey physical)
|
||||||
{
|
{
|
||||||
@@ -75,6 +114,16 @@ public static class KeyMap
|
|||||||
Key.OemSemicolon => ";",
|
Key.OemSemicolon => ";",
|
||||||
Key.OemQuotes => "'",
|
Key.OemQuotes => "'",
|
||||||
Key.OemBackslash or Key.OemPipe => "\\",
|
Key.OemBackslash or Key.OemPipe => "\\",
|
||||||
|
Key.OemOpenBrackets => "[",
|
||||||
|
Key.OemCloseBrackets => "]",
|
||||||
|
Key.Enter => "enter",
|
||||||
|
Key.Tab => "tab",
|
||||||
|
Key.PageUp => "page_up",
|
||||||
|
Key.PageDown => "page_down",
|
||||||
|
Key.Left => "left",
|
||||||
|
Key.Right => "right",
|
||||||
|
Key.Up => "up",
|
||||||
|
Key.Down => "down",
|
||||||
Key.F1 => "f1", Key.F2 => "f2", Key.F3 => "f3", Key.F4 => "f4",
|
Key.F1 => "f1", Key.F2 => "f2", Key.F3 => "f3", Key.F4 => "f4",
|
||||||
Key.F5 => "f5", Key.F6 => "f6", Key.F7 => "f7", Key.F8 => "f8",
|
Key.F5 => "f5", Key.F6 => "f6", Key.F7 => "f7", Key.F8 => "f8",
|
||||||
Key.F9 => "f9", Key.F10 => "f10", Key.F11 => "f11", Key.F12 => "f12",
|
Key.F9 => "f9", Key.F10 => "f10", Key.F11 => "f11", Key.F12 => "f12",
|
||||||
@@ -88,15 +137,29 @@ public static class KeyMap
|
|||||||
Key.LeftAlt or Key.RightAlt or
|
Key.LeftAlt or Key.RightAlt or
|
||||||
Key.LWin or Key.RWin;
|
Key.LWin or Key.RWin;
|
||||||
|
|
||||||
|
/// <summary>A key combination as it reads on screen; a dash for no key, whatever the modifiers.</summary>
|
||||||
|
public static string ComboText(string? token, bool shift, bool ctrl)
|
||||||
|
{
|
||||||
|
if (IsNoKey(token))
|
||||||
|
return "—";
|
||||||
|
var parts = new List<string>();
|
||||||
|
if (ctrl) parts.Add("Ctrl");
|
||||||
|
if (shift) parts.Add("Shift");
|
||||||
|
parts.Add(DisplayName(token));
|
||||||
|
return string.Join(" + ", parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsNoKey([NotNullWhen(false)] string? token) =>
|
||||||
|
string.IsNullOrEmpty(token) || token.Equals("none", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
public static string DisplayName(string? token)
|
public static string DisplayName(string? token)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(token) ||
|
if (IsNoKey(token))
|
||||||
token.Equals("none", StringComparison.OrdinalIgnoreCase))
|
|
||||||
return "—";
|
return "—";
|
||||||
|
|
||||||
if (token.StartsWith("num_", StringComparison.OrdinalIgnoreCase))
|
if (token.StartsWith(NumpadPrefix, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
string rest = token.Substring(4);
|
string rest = token.Substring(NumpadPrefix.Length);
|
||||||
return rest switch
|
return rest switch
|
||||||
{
|
{
|
||||||
"enter" => "Num Enter",
|
"enter" => "Num Enter",
|
||||||
@@ -116,8 +179,9 @@ public static class KeyMap
|
|||||||
"end" => "End",
|
"end" => "End",
|
||||||
"insert" => "Insert",
|
"insert" => "Insert",
|
||||||
"delete" => "Delete",
|
"delete" => "Delete",
|
||||||
"pageup" => "Page Up",
|
"page_up" => "Page Up",
|
||||||
"pagedown" => "Page Down",
|
"page_down" => "Page Down",
|
||||||
|
"esc" => "Esc",
|
||||||
"backspace" => "Backspace",
|
"backspace" => "Backspace",
|
||||||
"pause" => "Pause",
|
"pause" => "Pause",
|
||||||
"\\" => "\\",
|
"\\" => "\\",
|
||||||
@@ -131,10 +195,10 @@ public static class KeyMap
|
|||||||
|
|
||||||
new[]
|
new[]
|
||||||
{
|
{
|
||||||
new KeyCap("Esc", null, 1.4),
|
new KeyCap("Esc", "esc", 1.4),
|
||||||
new KeyCap("F1"), new KeyCap("F2"), new KeyCap("F3"), new KeyCap("F4"),
|
new KeyCap("F1", "f1"), new KeyCap("F2", "f2"), new KeyCap("F3", "f3"), new KeyCap("F4", "f4"),
|
||||||
new KeyCap("F5"), new KeyCap("F6"), new KeyCap("F7"), new KeyCap("F8"),
|
new KeyCap("F5", "f5"), new KeyCap("F6", "f6"), new KeyCap("F7", "f7"), new KeyCap("F8", "f8"),
|
||||||
new KeyCap("F9"), new KeyCap("F10"), new KeyCap("F11"), new KeyCap("F12"),
|
new KeyCap("F9", "f9"), new KeyCap("F10", "f10"), new KeyCap("F11", "f11"), new KeyCap("F12", "f12"),
|
||||||
},
|
},
|
||||||
new[]
|
new[]
|
||||||
{
|
{
|
||||||
@@ -147,11 +211,11 @@ public static class KeyMap
|
|||||||
},
|
},
|
||||||
new[]
|
new[]
|
||||||
{
|
{
|
||||||
new KeyCap("Tab", null, 1.5),
|
new KeyCap("Tab", "tab", 1.5),
|
||||||
new KeyCap("Q", "q"), new KeyCap("W", "w"), new KeyCap("E", "e"), new KeyCap("R", "r"),
|
new KeyCap("Q", "q"), new KeyCap("W", "w"), new KeyCap("E", "e"), new KeyCap("R", "r"),
|
||||||
new KeyCap("T", "t"), new KeyCap("Y", "y"), new KeyCap("U", "u"), new KeyCap("I", "i"),
|
new KeyCap("T", "t"), new KeyCap("Y", "y"), new KeyCap("U", "u"), new KeyCap("I", "i"),
|
||||||
new KeyCap("O", "o"), new KeyCap("P", "p"),
|
new KeyCap("O", "o"), new KeyCap("P", "p"),
|
||||||
new KeyCap("[", null), new KeyCap("]", null),
|
new KeyCap("[", "["), new KeyCap("]", "]"),
|
||||||
new KeyCap("\\", "\\", 1.5),
|
new KeyCap("\\", "\\", 1.5),
|
||||||
},
|
},
|
||||||
new[]
|
new[]
|
||||||
@@ -161,7 +225,7 @@ public static class KeyMap
|
|||||||
new KeyCap("G", "g"), new KeyCap("H", "h"), new KeyCap("J", "j"), new KeyCap("K", "k"),
|
new KeyCap("G", "g"), new KeyCap("H", "h"), new KeyCap("J", "j"), new KeyCap("K", "k"),
|
||||||
new KeyCap("L", "l"),
|
new KeyCap("L", "l"),
|
||||||
new KeyCap(";", ";"), new KeyCap("'", "'"),
|
new KeyCap(";", ";"), new KeyCap("'", "'"),
|
||||||
new KeyCap("Enter", null, 2.2),
|
new KeyCap("Enter", "enter", 2.2),
|
||||||
},
|
},
|
||||||
new[]
|
new[]
|
||||||
{
|
{
|
||||||
@@ -215,11 +279,19 @@ public static class KeyMap
|
|||||||
|
|
||||||
new[]
|
new[]
|
||||||
{
|
{
|
||||||
new KeyCap("Ins", "insert"), new KeyCap("Home", "home"), new KeyCap("PgUp"),
|
new KeyCap("Ins", "insert"), new KeyCap("Home", "home"), new KeyCap("PgUp", "page_up"),
|
||||||
},
|
},
|
||||||
new[]
|
new[]
|
||||||
{
|
{
|
||||||
new KeyCap("Del", "delete"), new KeyCap("End", "end"), new KeyCap("PgDn"),
|
new KeyCap("Del", "delete"), new KeyCap("End", "end"), new KeyCap("PgDn", "page_down"),
|
||||||
|
},
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new KeyCap("", null), new KeyCap("↑", "up"), new KeyCap("", null),
|
||||||
|
},
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new KeyCap("←", "left"), new KeyCap("↓", "down"), new KeyCap("→", "right"),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using StarterNG.Infrastructure.Adapters;
|
using StarterNG.Infrastructure.Adapters;
|
||||||
|
|
||||||
@@ -23,6 +24,20 @@ public sealed class KeyBinding
|
|||||||
public bool IsAssigned =>
|
public bool IsAssigned =>
|
||||||
!string.IsNullOrEmpty(Key) &&
|
!string.IsNullOrEmpty(Key) &&
|
||||||
!Key.Equals("none", StringComparison.OrdinalIgnoreCase);
|
!Key.Equals("none", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>The combination in a form to compare, however the key is spelled.</summary>
|
||||||
|
public string ComboKey => $"{(Ctrl ? 1 : 0)}|{(Shift ? 1 : 0)}|{Key.ToLowerInvariant()}";
|
||||||
|
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
Shift = Ctrl = false;
|
||||||
|
Key = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsOn(string token, bool shift, bool ctrl) =>
|
||||||
|
IsAssigned &&
|
||||||
|
string.Equals(Key, token, StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
Shift == shift && Ctrl == ctrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class KeyboardConfig
|
public sealed class KeyboardConfig
|
||||||
@@ -119,19 +134,66 @@ public sealed class KeyboardConfig
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The description to show: the bundled one with Polish letters when the file's
|
/// The description to show: the file's own when someone wrote their own, else
|
||||||
/// is the same text without them, the file's own when someone wrote their own.
|
/// the <paramref name="translated"/> one for the starter's language, else the
|
||||||
|
/// bundled one with Polish letters.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string DescriptionOf(KeyBinding binding)
|
public string DescriptionOf(KeyBinding binding, Func<string, string>? translated = null)
|
||||||
{
|
{
|
||||||
if (_polishDescriptions.TryGetValue(binding.Command, out string? polish) &&
|
bool stock = string.IsNullOrEmpty(binding.Description);
|
||||||
(string.IsNullOrEmpty(binding.Description) ||
|
if (_polishDescriptions.TryGetValue(binding.Command, out string? polish))
|
||||||
string.Equals(StripPolish(polish), binding.Description.Trim(), StringComparison.OrdinalIgnoreCase)))
|
stock |= string.Equals(StripPolish(polish), binding.Description.Trim(), StringComparison.OrdinalIgnoreCase);
|
||||||
return polish;
|
if (!stock)
|
||||||
|
return binding.Description;
|
||||||
|
|
||||||
return binding.Description;
|
if (translated?.Invoke(binding.Command) is { Length: > 0 } text)
|
||||||
|
return text;
|
||||||
|
|
||||||
|
return polish ?? binding.Description;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public KeyBinding? BindingAt(string token, bool shift, bool ctrl) =>
|
||||||
|
Bindings.FirstOrDefault(b => b.IsOn(token, shift, ctrl));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Puts <paramref name="binding"/> on a key combination, or nothing for null, and
|
||||||
|
/// frees it from the rest: the simulator maps a combination to one command - in
|
||||||
|
/// driving and outside the cab alike, the editor has its own fixed keys - so of
|
||||||
|
/// two, one would silently never fire.
|
||||||
|
/// </summary>
|
||||||
|
public void Put(KeyBinding? binding, string token, bool shift, bool ctrl)
|
||||||
|
{
|
||||||
|
foreach (var b in Bindings)
|
||||||
|
if (!ReferenceEquals(b, binding) && b.IsOn(token, shift, ctrl))
|
||||||
|
b.Clear();
|
||||||
|
|
||||||
|
if (binding is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
binding.Key = token;
|
||||||
|
binding.Shift = shift;
|
||||||
|
binding.Ctrl = ctrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The <see cref="KeyBinding.ComboKey"/> of each combination more than one binding is on.</summary>
|
||||||
|
public HashSet<string> ConflictingCombos()
|
||||||
|
{
|
||||||
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var dup = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var b in Bindings)
|
||||||
|
{
|
||||||
|
if (!b.IsAssigned)
|
||||||
|
continue;
|
||||||
|
string k = b.ComboKey;
|
||||||
|
if (!seen.Add(k))
|
||||||
|
dup.Add(k);
|
||||||
|
}
|
||||||
|
return dup;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int BindingsOnNumpad() =>
|
||||||
|
Bindings.Count(b => b.IsAssigned && b.Key.StartsWith(KeyMap.NumpadPrefix, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
public static string StripPolish(string text)
|
public static string StripPolish(string text)
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder(text.Length);
|
var sb = new StringBuilder(text.Length);
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using System.Text.Json.Serialization.Metadata;
|
using System.Text.Json.Serialization.Metadata;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
using StarterNG.Application;
|
||||||
|
using StarterNG.Application.Abstractions;
|
||||||
using StarterNG.Infrastructure.Adapters;
|
using StarterNG.Infrastructure.Adapters;
|
||||||
|
|
||||||
namespace StarterNG.Classes;
|
namespace StarterNG.Classes;
|
||||||
@@ -54,18 +54,14 @@ public static class PresetStore
|
|||||||
|
|
||||||
public static PresetSort SortMode { get; set; } = PresetSort.Name;
|
public static PresetSort SortMode { get; set; } = PresetSort.Name;
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions Options = new()
|
private static PresetFile? _disk;
|
||||||
{
|
|
||||||
WriteIndented = true,
|
|
||||||
TypeInfoResolver = PresetJsonContext.Default,
|
|
||||||
};
|
|
||||||
|
|
||||||
private static JsonTypeInfo<PresetCollection> TypeInfo =>
|
private static PresetFile Disk =>
|
||||||
(JsonTypeInfo<PresetCollection>)Options.GetTypeInfo(typeof(PresetCollection));
|
_disk ??= new PresetFile(AppServices.Current.Files, AppServices.Current.Log, FilePath);
|
||||||
|
|
||||||
public static IReadOnlyList<TrainsetPreset> All()
|
public static IReadOnlyList<TrainsetPreset> All()
|
||||||
{
|
{
|
||||||
var list = Load().Presets.ToList();
|
var list = Disk.Presets();
|
||||||
if (SortMode == PresetSort.Track)
|
if (SortMode == PresetSort.Track)
|
||||||
list.Sort((a, b) => string.Compare(TrackOf(a), TrackOf(b), StringComparison.OrdinalIgnoreCase));
|
list.Sort((a, b) => string.Compare(TrackOf(a), TrackOf(b), StringComparison.OrdinalIgnoreCase));
|
||||||
else
|
else
|
||||||
@@ -73,25 +69,9 @@ public static class PresetStore
|
|||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void Save(string? name, string? entry)
|
public static void Save(string? name, string? entry) => Disk.Save(name, entry);
|
||||||
{
|
|
||||||
name = name?.Trim() ?? "";
|
|
||||||
if (name.Length == 0 || string.IsNullOrWhiteSpace(entry))
|
|
||||||
return;
|
|
||||||
|
|
||||||
var col = Load();
|
public static void Delete(string name) => Disk.Delete(name);
|
||||||
col.Presets.RemoveAll(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase));
|
|
||||||
col.Presets.Add(new TrainsetPreset { Name = name, Entry = entry! });
|
|
||||||
col.Presets.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase));
|
|
||||||
Persist(col);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void Delete(string name)
|
|
||||||
{
|
|
||||||
var col = Load();
|
|
||||||
if (col.Presets.RemoveAll(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)) > 0)
|
|
||||||
Persist(col);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static int ImportMagazyn(string? path = null)
|
public static int ImportMagazyn(string? path = null)
|
||||||
{
|
{
|
||||||
@@ -162,35 +142,95 @@ public static class PresetStore
|
|||||||
|
|
||||||
private static string Sanitize(string name) =>
|
private static string Sanitize(string name) =>
|
||||||
string.IsNullOrWhiteSpace(name) ? "preset" : name.Replace(' ', '_');
|
string.IsNullOrWhiteSpace(name) ? "preset" : name.Replace(' ', '_');
|
||||||
|
}
|
||||||
|
|
||||||
private static PresetCollection Load()
|
/// <summary>The presets as one file holds them; <see cref="PresetStore"/> is the player's own.</summary>
|
||||||
|
public sealed class PresetFile
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions Options = new()
|
||||||
{
|
{
|
||||||
try
|
WriteIndented = true,
|
||||||
{
|
TypeInfoResolver = PresetJsonContext.Default,
|
||||||
if (File.Exists(FilePath))
|
};
|
||||||
{
|
|
||||||
var col = JsonSerializer.Deserialize(File.ReadAllText(FilePath), TypeInfo);
|
private static JsonTypeInfo<PresetCollection> TypeInfo =>
|
||||||
if (col?.Presets != null)
|
(JsonTypeInfo<PresetCollection>)Options.GetTypeInfo(typeof(PresetCollection));
|
||||||
return col;
|
|
||||||
}
|
private readonly IFileSystem _files;
|
||||||
}
|
private readonly IDiagnosticsLog _log;
|
||||||
catch (Exception ex)
|
private readonly string _path;
|
||||||
{
|
|
||||||
StarterNG.Infrastructure.Diagnostics.Log($"reading {FilePath}", ex);
|
public PresetFile(IFileSystem files, IDiagnosticsLog log, string path)
|
||||||
}
|
{
|
||||||
return new PresetCollection();
|
_files = files;
|
||||||
|
_log = log;
|
||||||
|
_path = path;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void Persist(PresetCollection col)
|
public List<TrainsetPreset> Presets()
|
||||||
{
|
{
|
||||||
|
TryRead(out var col);
|
||||||
|
return col.Presets;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Save(string? name, string? entry)
|
||||||
|
{
|
||||||
|
name = name?.Trim() ?? "";
|
||||||
|
if (name.Length == 0 || string.IsNullOrWhiteSpace(entry))
|
||||||
|
return;
|
||||||
|
|
||||||
|
bool read = TryRead(out var col);
|
||||||
|
col.Presets.RemoveAll(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||||
|
col.Presets.Add(new TrainsetPreset { Name = name, Entry = entry! });
|
||||||
|
col.Presets.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase));
|
||||||
|
Persist(col, read);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Delete(string name)
|
||||||
|
{
|
||||||
|
bool read = TryRead(out var col);
|
||||||
|
if (col.Presets.RemoveAll(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)) > 0)
|
||||||
|
Persist(col, read);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The file's presets; false, with none, when there is a file but they cannot be had from it.</summary>
|
||||||
|
private bool TryRead(out PresetCollection col)
|
||||||
|
{
|
||||||
|
col = new PresetCollection();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!);
|
if (!_files.FileExists(_path))
|
||||||
File.WriteAllText(FilePath, JsonSerializer.Serialize(col, TypeInfo));
|
return true;
|
||||||
|
|
||||||
|
var read = JsonSerializer.Deserialize(_files.ReadAllText(_path), TypeInfo);
|
||||||
|
if (read?.Presets != null)
|
||||||
|
{
|
||||||
|
col = read;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
_log.Log($"reading {_path}: no presets in it");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
StarterNG.Infrastructure.Diagnostics.Log($"saving {FilePath}", ex);
|
_log.Log($"reading {_path}", ex);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// What was not read is not in the list: written over, those presets are gone, so
|
||||||
|
// a copy goes first and without one the file stays - the new preset waits.
|
||||||
|
private void Persist(PresetCollection col, bool read)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_files.CreateDirectory(Path.GetDirectoryName(_path)!);
|
||||||
|
if (!read)
|
||||||
|
_files.KeepAside(_path, _log);
|
||||||
|
_files.WriteAllTextAtomic(_path, JsonSerializer.Serialize(col, TypeInfo));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.Log($"saving {_path}", ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using System.Globalization;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
using StarterNG.Domain.Sceneries;
|
||||||
|
|
||||||
namespace StarterNG.Classes;
|
namespace StarterNG.Classes;
|
||||||
|
|
||||||
@@ -58,6 +59,28 @@ public class Trainset
|
|||||||
|
|
||||||
public string? Mini;
|
public string? Mini;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// "reversed" ahead of the vehicles: the simulator turns the whole trainset round -
|
||||||
|
/// the first vehicle stays at the offset and leads towards the track's point 1,
|
||||||
|
/// every vehicle turned. The author's to set; the starter keeps it as found.
|
||||||
|
/// </summary>
|
||||||
|
public bool Reversed { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The file the trainset is written in, as the scenery's include names it, for
|
||||||
|
/// one from a file put in place by <see cref="Domain.Sceneries.SceneryIncludes"/>;
|
||||||
|
/// null for the scenery's own.
|
||||||
|
/// </summary>
|
||||||
|
public string? SourceFile { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where the speed, each vehicle and the <c>endtrainset</c> stand in the text, read
|
||||||
|
/// word by word as the fields above are - comments left out, a word taken for a
|
||||||
|
/// field whatever it says. With no <c>endtrainset</c> the end is the end of the text.
|
||||||
|
/// Known only of a trainset that was <see cref="Parsed"/>.
|
||||||
|
/// </summary>
|
||||||
|
internal (SceneryCode.Token? Speed, List<(int Start, int End)> Vehicles, int End) Layout { get; private set; }
|
||||||
|
|
||||||
public Trainset(string trainsetEntry)
|
public Trainset(string trainsetEntry)
|
||||||
{
|
{
|
||||||
RawEntry = trainsetEntry;
|
RawEntry = trainsetEntry;
|
||||||
@@ -77,11 +100,7 @@ public class Trainset
|
|||||||
|
|
||||||
private void ParseEntry(string trainsetEntry)
|
private void ParseEntry(string trainsetEntry)
|
||||||
{
|
{
|
||||||
List<string> tokens = Regex
|
List<SceneryCode.Token> tokens = SceneryCode.Tokens(SceneryCode.Mask(trainsetEntry, out _));
|
||||||
.Matches(trainsetEntry, @"/\*[\s\S]*?\*/|//[^\r\n]*|[^\s\r\n]+")
|
|
||||||
.Select(m => m.Value)
|
|
||||||
.Where(t => !t.StartsWith("//") && !t.StartsWith("/*"))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var match = Regex.Match(
|
var match = Regex.Match(
|
||||||
trainsetEntry,
|
trainsetEntry,
|
||||||
@@ -99,66 +118,92 @@ public class Trainset
|
|||||||
if (mini.Success)
|
if (mini.Success)
|
||||||
Mini = mini.Groups[1].Value.Trim();
|
Mini = mini.Groups[1].Value.Trim();
|
||||||
|
|
||||||
|
// The simulator reads its words in lower case: "Trainset" is a trainset too.
|
||||||
|
bool Is(int at, string word) => tokens[at].Is(word);
|
||||||
|
|
||||||
|
SceneryCode.Token? speed = null;
|
||||||
|
var spans = new List<(int Start, int End)>();
|
||||||
|
int end = trainsetEntry.Length;
|
||||||
|
|
||||||
for (int i = 0; i < tokens.Count; i++)
|
for (int i = 0; i < tokens.Count; i++)
|
||||||
{
|
{
|
||||||
if (tokens[i] == "endtrainset")
|
if (Is(i, "endtrainset"))
|
||||||
break;
|
|
||||||
|
|
||||||
if (tokens[i] == "trainset")
|
|
||||||
{
|
{
|
||||||
this.Name = tokens[++i];
|
end = tokens[i].Index;
|
||||||
this.Track = tokens[++i];
|
break;
|
||||||
this.Offset = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
|
}
|
||||||
this.Velocity = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
|
|
||||||
|
if (Is(i, "trainset"))
|
||||||
|
{
|
||||||
|
this.Name = tokens[++i].Text;
|
||||||
|
this.Track = tokens[++i].Text;
|
||||||
|
this.Offset = float.Parse(tokens[++i].Text, CultureInfo.InvariantCulture);
|
||||||
|
speed = tokens[++i];
|
||||||
|
this.Velocity = float.Parse(tokens[i].Text, CultureInfo.InvariantCulture);
|
||||||
this.OriginalVelocity = this.Velocity;
|
this.OriginalVelocity = this.Velocity;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tokens[i] == "assignments")
|
// Ahead of the vehicles; the simulator refuses it after the first one.
|
||||||
|
if (Is(i, "reversed"))
|
||||||
{
|
{
|
||||||
while (i < tokens.Count && tokens[i] != "endassignment")
|
Reversed |= Vehicles.Count == 0;
|
||||||
i++;
|
|
||||||
i++;
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tokens[i] == "node")
|
if (Is(i, "assignment") || Is(i, "assignments"))
|
||||||
{
|
{
|
||||||
|
while (i < tokens.Count && !Is(i, "endassignment"))
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Is(i, "node"))
|
||||||
|
{
|
||||||
|
int start = tokens[i].Index;
|
||||||
Dynamic nodeDynamic = new Dynamic();
|
Dynamic nodeDynamic = new Dynamic();
|
||||||
nodeDynamic.RangeMax = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
|
nodeDynamic.RangeMax = float.Parse(tokens[++i].Text, CultureInfo.InvariantCulture);
|
||||||
nodeDynamic.RangeMin = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
|
nodeDynamic.RangeMin = float.Parse(tokens[++i].Text, CultureInfo.InvariantCulture);
|
||||||
nodeDynamic.Name = tokens[++i];
|
nodeDynamic.Name = tokens[++i].Text;
|
||||||
i++;
|
i++;
|
||||||
nodeDynamic.DataFolder = tokens[++i];
|
nodeDynamic.DataFolder = tokens[++i].Text;
|
||||||
nodeDynamic.SkinFile = Dynamic.StripSkinExtension(tokens[++i]);
|
nodeDynamic.SkinFile = Dynamic.StripSkinExtension(tokens[++i].Text);
|
||||||
nodeDynamic.MmdFile = tokens[++i];
|
nodeDynamic.MmdFile = tokens[++i].Text;
|
||||||
nodeDynamic.Offset = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
|
nodeDynamic.Offset = float.Parse(tokens[++i].Text, CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
nodeDynamic.DriverType = tokens[++i] switch
|
nodeDynamic.DriverType = Dynamic.ParseDriver(tokens[++i].Text);
|
||||||
{
|
|
||||||
"headdriver" => eDriverType.Headdriver,
|
|
||||||
"reardriver" => eDriverType.Reardriver,
|
|
||||||
"passenger" => eDriverType.Passenger,
|
|
||||||
_ => eDriverType.Nobody
|
|
||||||
};
|
|
||||||
|
|
||||||
nodeDynamic.Coupling = Coupling.Parse(tokens[++i]);
|
nodeDynamic.Coupling = Coupling.Parse(tokens[++i].Text);
|
||||||
|
|
||||||
|
// A missing enddynamic does not take the next vehicle with it.
|
||||||
var trailing = new List<string>();
|
var trailing = new List<string>();
|
||||||
while (i + 1 < tokens.Count && tokens[i + 1] != "enddynamic")
|
while (i + 1 < tokens.Count && !Is(i + 1, "enddynamic") && !Is(i + 1, "node") &&
|
||||||
trailing.Add(tokens[++i]);
|
!Is(i + 1, "endtrainset"))
|
||||||
|
trailing.Add(tokens[++i].Text);
|
||||||
nodeDynamic.ReadTrailing(trailing);
|
nodeDynamic.ReadTrailing(trailing);
|
||||||
|
|
||||||
i++;
|
if (i + 1 < tokens.Count && Is(i + 1, "enddynamic"))
|
||||||
|
i++;
|
||||||
|
|
||||||
Vehicles.Add(nodeDynamic);
|
Vehicles.Add(nodeDynamic);
|
||||||
|
spans.Add((start, tokens[i].End));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Layout = (speed, spans, end);
|
||||||
}
|
}
|
||||||
|
|
||||||
public string ToSceneryEntry() =>
|
public string ToSceneryEntry() =>
|
||||||
Parsed ? GetTrainsetEntry() + "endtrainset\n" : RawEntry;
|
Parsed ? GetTrainsetEntry() + "endtrainset\n" : RawEntry;
|
||||||
|
|
||||||
|
/// <summary>No vehicles, in the export too: an entry that could not be read goes out as its raw text.</summary>
|
||||||
|
public void RemoveVehicles()
|
||||||
|
{
|
||||||
|
Vehicles = new List<Dynamic>();
|
||||||
|
if (!Parsed)
|
||||||
|
RawEntry = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
public string GetTrainsetEntry()
|
public string GetTrainsetEntry()
|
||||||
{
|
{
|
||||||
|
|
||||||
@@ -177,13 +222,11 @@ public class Trainset
|
|||||||
sb.Append(Track).Append(' ');
|
sb.Append(Track).Append(' ');
|
||||||
sb.Append(Offset.ToString(CultureInfo.InvariantCulture)).Append(' ');
|
sb.Append(Offset.ToString(CultureInfo.InvariantCulture)).Append(' ');
|
||||||
sb.Append(Velocity.ToString(CultureInfo.InvariantCulture));
|
sb.Append(Velocity.ToString(CultureInfo.InvariantCulture));
|
||||||
|
if (Reversed)
|
||||||
|
sb.Append(" reversed");
|
||||||
sb.Append('\n');
|
sb.Append('\n');
|
||||||
|
|
||||||
string entry = sb.ToString();
|
return sb.Append(Dynamic.ToTrainsetNodes(Vehicles)).ToString();
|
||||||
foreach (Dynamic vehicle in Vehicles)
|
|
||||||
entry += vehicle.ToTrainsetNode();
|
|
||||||
|
|
||||||
return entry;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,12 +244,6 @@ public class Dynamic
|
|||||||
|
|
||||||
public Coupling Coupling = new();
|
public Coupling Coupling = new();
|
||||||
|
|
||||||
public byte couplingData
|
|
||||||
{
|
|
||||||
get => (byte)(Coupling.AbsFlags & 0xFF);
|
|
||||||
set => Coupling.Flags = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public const string PantState = "pantstate";
|
public const string PantState = "pantstate";
|
||||||
|
|
||||||
public const int PantStateMax = 3;
|
public const int PantStateMax = 3;
|
||||||
@@ -244,8 +281,6 @@ public class Dynamic
|
|||||||
public static bool IsPantStateType(string? type) =>
|
public static bool IsPantStateType(string? type) =>
|
||||||
string.Equals(type, PantState, StringComparison.OrdinalIgnoreCase);
|
string.Equals(type, PantState, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
public bool IsPantState => IsPantStateType(LoadType);
|
|
||||||
|
|
||||||
internal void ReadTrailing(List<string> t)
|
internal void ReadTrailing(List<string> t)
|
||||||
{
|
{
|
||||||
int p = 0;
|
int p = 0;
|
||||||
@@ -278,14 +313,33 @@ public class Dynamic
|
|||||||
return dot > 0 ? token[..dot] : token;
|
return dot > 0 ? token[..dot] : token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static eDriverType ParseDriver(string word) => word.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"headdriver" => eDriverType.Headdriver,
|
||||||
|
"reardriver" => eDriverType.Reardriver,
|
||||||
|
"passenger" => eDriverType.Passenger,
|
||||||
|
_ => eDriverType.Nobody
|
||||||
|
};
|
||||||
|
|
||||||
|
private string DriverWord => DriverType switch
|
||||||
|
{
|
||||||
|
eDriverType.Headdriver => "headdriver",
|
||||||
|
eDriverType.Reardriver => "reardriver",
|
||||||
|
eDriverType.Passenger => "passenger",
|
||||||
|
_ => "nobody"
|
||||||
|
};
|
||||||
|
|
||||||
internal string WriteTrailing()
|
internal string WriteTrailing()
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
|
|
||||||
bool loaded = !string.IsNullOrEmpty(LoadType);
|
bool loaded = !string.IsNullOrEmpty(LoadType) && LoadCount > 0;
|
||||||
sb.Append(' ').Append((loaded ? LoadCount : 0).ToString(CultureInfo.InvariantCulture));
|
sb.Append(' ').Append((loaded ? LoadCount : 0).ToString(CultureInfo.InvariantCulture));
|
||||||
|
|
||||||
if (loaded && (LoadCount > 0 || IsPantState))
|
// The simulator reads a type only after a count above 0. One after a 0 it
|
||||||
|
// takes for a destination, and with it the next vehicle, up to its enddynamic.
|
||||||
|
// Pantographs set to none are the simulator's default anyway.
|
||||||
|
if (loaded)
|
||||||
sb.Append(' ').Append(LoadType);
|
sb.Append(' ').Append(LoadType);
|
||||||
|
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
@@ -314,37 +368,26 @@ public class Dynamic
|
|||||||
|
|
||||||
public string ToTrainsetNode()
|
public string ToTrainsetNode()
|
||||||
{
|
{
|
||||||
string driver = DriverType switch
|
|
||||||
{
|
|
||||||
eDriverType.Headdriver => "headdriver",
|
|
||||||
eDriverType.Reardriver => "reardriver",
|
|
||||||
eDriverType.Passenger => "passenger",
|
|
||||||
_ => "nobody"
|
|
||||||
};
|
|
||||||
|
|
||||||
return
|
return
|
||||||
$"node {RangeMax} {RangeMin} {Name} dynamic " +
|
$"node {RangeMax.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"{RangeMin.ToString(CultureInfo.InvariantCulture)} {Name} dynamic " +
|
||||||
$"{DataFolder} {SkinFile} {MmdFile} " +
|
$"{DataFolder} {SkinFile} {MmdFile} " +
|
||||||
$"{Offset.ToString(CultureInfo.InvariantCulture)} " +
|
$"{Offset.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
$"{driver} {Coupling}{WriteTrailing()} enddynamic\n";
|
$"{DriverWord} {Coupling}{WriteTrailing()} enddynamic\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static string ToTrainsetNodes(IEnumerable<Dynamic> vehicles) =>
|
||||||
|
string.Concat(vehicles.Select(v => v.ToTrainsetNode()));
|
||||||
|
|
||||||
public string ToLooseNode()
|
public string ToLooseNode()
|
||||||
{
|
{
|
||||||
string driver = DriverType switch
|
|
||||||
{
|
|
||||||
eDriverType.Headdriver => "headdriver",
|
|
||||||
eDriverType.Reardriver => "reardriver",
|
|
||||||
eDriverType.Passenger => "passenger",
|
|
||||||
_ => "nobody"
|
|
||||||
};
|
|
||||||
string path = string.IsNullOrEmpty(PathName) ? "none" : PathName;
|
string path = string.IsNullOrEmpty(PathName) ? "none" : PathName;
|
||||||
string vel = (HasVelocity ? Velocity : 0f).ToString(CultureInfo.InvariantCulture);
|
string vel = (HasVelocity ? Velocity : 0f).ToString(CultureInfo.InvariantCulture);
|
||||||
return
|
return
|
||||||
$"node {RangeMax.ToString(CultureInfo.InvariantCulture)} " +
|
$"node {RangeMax.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
$"{RangeMin.ToString(CultureInfo.InvariantCulture)} {Name} dynamic " +
|
$"{RangeMin.ToString(CultureInfo.InvariantCulture)} {Name} dynamic " +
|
||||||
$"{DataFolder} {SkinFile} {MmdFile} {path} " +
|
$"{DataFolder} {SkinFile} {MmdFile} {path} " +
|
||||||
$"{Offset.ToString(CultureInfo.InvariantCulture)} {driver} {vel}" +
|
$"{Offset.ToString(CultureInfo.InvariantCulture)} {DriverWord} {vel}" +
|
||||||
$"{WriteTrailing()} enddynamic\n";
|
$"{WriteTrailing()} enddynamic\n";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
21
StarterNG/Controls/KeySlot.cs
Normal file
21
StarterNG/Controls/KeySlot.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace StarterNG.Controls;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where the settings layout wants the row of one of the SimulatorKeys, so it
|
||||||
|
/// sits among the rows it belongs with; the row is put in when the page is built.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class KeySlot : Decorator
|
||||||
|
{
|
||||||
|
public static readonly StyledProperty<string?> KeyProperty =
|
||||||
|
AvaloniaProperty.Register<KeySlot, string?>(nameof(Key));
|
||||||
|
|
||||||
|
/// <summary>The eu07.ini key whose row goes here.</summary>
|
||||||
|
public string? Key
|
||||||
|
{
|
||||||
|
get => GetValue(KeyProperty);
|
||||||
|
set => SetValue(KeyProperty, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,13 @@ public class SettingRow : ContentControl
|
|||||||
public static readonly StyledProperty<string?> ValueTextProperty =
|
public static readonly StyledProperty<string?> ValueTextProperty =
|
||||||
AvaloniaProperty.Register<SettingRow, string?>(nameof(ValueText));
|
AvaloniaProperty.Register<SettingRow, string?>(nameof(ValueText));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Why the simulator to be started would pass this setting over; the row dims
|
||||||
|
/// and says so. The input stays usable - the file may serve another build.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly StyledProperty<string?> IdleNoteProperty =
|
||||||
|
AvaloniaProperty.Register<SettingRow, string?>(nameof(IdleNote));
|
||||||
|
|
||||||
public string? Label
|
public string? Label
|
||||||
{
|
{
|
||||||
get => GetValue(LabelProperty);
|
get => GetValue(LabelProperty);
|
||||||
@@ -41,6 +48,12 @@ public class SettingRow : ContentControl
|
|||||||
set => SetValue(ValueTextProperty, value);
|
set => SetValue(ValueTextProperty, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public string? IdleNote
|
||||||
|
{
|
||||||
|
get => GetValue(IdleNoteProperty);
|
||||||
|
set => SetValue(IdleNoteProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
public SettingRow()
|
public SettingRow()
|
||||||
{
|
{
|
||||||
UpdatePseudoClasses();
|
UpdatePseudoClasses();
|
||||||
@@ -54,8 +67,12 @@ public class SettingRow : ContentControl
|
|||||||
change.Property == DescriptionProperty ||
|
change.Property == DescriptionProperty ||
|
||||||
change.Property == ConfigKeyProperty ||
|
change.Property == ConfigKeyProperty ||
|
||||||
change.Property == ValueTextProperty ||
|
change.Property == ValueTextProperty ||
|
||||||
|
change.Property == IdleNoteProperty ||
|
||||||
change.Property == ContentProperty)
|
change.Property == ContentProperty)
|
||||||
UpdatePseudoClasses();
|
UpdatePseudoClasses();
|
||||||
|
|
||||||
|
if (change.Property == IdleNoteProperty)
|
||||||
|
ToolTip.SetTip(this, string.IsNullOrEmpty(IdleNote) ? null : IdleNote);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdatePseudoClasses()
|
private void UpdatePseudoClasses()
|
||||||
@@ -65,5 +82,6 @@ public class SettingRow : ContentControl
|
|||||||
PseudoClasses.Set(":no-key", string.IsNullOrEmpty(ConfigKey));
|
PseudoClasses.Set(":no-key", string.IsNullOrEmpty(ConfigKey));
|
||||||
PseudoClasses.Set(":no-value", string.IsNullOrEmpty(ValueText));
|
PseudoClasses.Set(":no-value", string.IsNullOrEmpty(ValueText));
|
||||||
PseudoClasses.Set(":no-content", Content is null);
|
PseudoClasses.Set(":no-content", Content is null);
|
||||||
|
PseudoClasses.Set(":idle", !string.IsNullOrEmpty(IdleNote));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
using System;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
using Avalonia.Controls.Primitives;
|
using Avalonia.Controls.Primitives;
|
||||||
|
|
||||||
namespace StarterNG.Controls;
|
namespace StarterNG.Controls;
|
||||||
@@ -8,9 +10,56 @@ public class SettingsSection : HeaderedContentControl
|
|||||||
public static readonly StyledProperty<object?> ToggleProperty =
|
public static readonly StyledProperty<object?> ToggleProperty =
|
||||||
AvaloniaProperty.Register<SettingsSection, object?>(nameof(Toggle));
|
AvaloniaProperty.Register<SettingsSection, object?>(nameof(Toggle));
|
||||||
|
|
||||||
|
public static readonly StyledProperty<string?> ToggleKeyProperty =
|
||||||
|
AvaloniaProperty.Register<SettingsSection, string?>(nameof(ToggleKey));
|
||||||
|
|
||||||
|
public static readonly StyledProperty<string?> ResetLabelProperty =
|
||||||
|
AvaloniaProperty.Register<SettingsSection, string?>(nameof(ResetLabel));
|
||||||
|
|
||||||
|
public static readonly StyledProperty<object?> LevelProperty =
|
||||||
|
AvaloniaProperty.Register<SettingsSection, object?>(nameof(Level));
|
||||||
|
|
||||||
|
private Button? _reset;
|
||||||
|
|
||||||
|
/// <summary>The header's choice of a level of detail for this section alone; none where it has no such thing.</summary>
|
||||||
|
public object? Level
|
||||||
|
{
|
||||||
|
get => GetValue(LevelProperty);
|
||||||
|
set => SetValue(LevelProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
public object? Toggle
|
public object? Toggle
|
||||||
{
|
{
|
||||||
get => GetValue(ToggleProperty);
|
get => GetValue(ToggleProperty);
|
||||||
set => SetValue(ToggleProperty, value);
|
set => SetValue(ToggleProperty, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The eu07.ini key the header's switch sets.</summary>
|
||||||
|
public string? ToggleKey
|
||||||
|
{
|
||||||
|
get => GetValue(ToggleKeyProperty);
|
||||||
|
set => SetValue(ToggleKeyProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The words on the header's button that puts the section back as the simulator has it; none, no button.</summary>
|
||||||
|
public string? ResetLabel
|
||||||
|
{
|
||||||
|
get => GetValue(ResetLabelProperty);
|
||||||
|
set => SetValue(ResetLabelProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Raised when the header's reset button is pressed.</summary>
|
||||||
|
public event Action<SettingsSection>? ResetRequested;
|
||||||
|
|
||||||
|
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnApplyTemplate(e);
|
||||||
|
if (_reset is not null)
|
||||||
|
_reset.Click -= OnResetClick;
|
||||||
|
_reset = e.NameScope.Find<Button>("PART_Reset");
|
||||||
|
if (_reset is not null)
|
||||||
|
_reset.Click += OnResetClick;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnResetClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => ResetRequested?.Invoke(this);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,14 +61,18 @@ public sealed class Cargo
|
|||||||
|
|
||||||
public void FillToMax(Dynamic car)
|
public void FillToMax(Dynamic car)
|
||||||
{
|
{
|
||||||
int max = MaxFor(car);
|
string? type = TypeToLoad(car);
|
||||||
|
if (string.IsNullOrEmpty(type)) return;
|
||||||
|
int max = MaxFor(car, type);
|
||||||
if (max <= 0) return;
|
if (max <= 0) return;
|
||||||
if (string.IsNullOrEmpty(car.LoadType))
|
car.LoadType = type;
|
||||||
car.LoadType = AcceptedTypes(car).FirstOrDefault();
|
|
||||||
if (string.IsNullOrEmpty(car.LoadType)) return;
|
|
||||||
car.LoadCount = max;
|
car.LoadCount = max;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The type first: what it may hold depends on it, pantographs above all.
|
||||||
|
private string? TypeToLoad(Dynamic car) =>
|
||||||
|
string.IsNullOrEmpty(car.LoadType) ? AcceptedTypes(car).FirstOrDefault() : car.LoadType;
|
||||||
|
|
||||||
public void CopyFromPrevious(IReadOnlyList<ConsistItem> consist, ConsistItem item)
|
public void CopyFromPrevious(IReadOnlyList<ConsistItem> consist, ConsistItem item)
|
||||||
{
|
{
|
||||||
int idx = IndexOf(consist, item);
|
int idx = IndexOf(consist, item);
|
||||||
@@ -103,7 +107,7 @@ public sealed class Cargo
|
|||||||
if (!AcceptedTypes(c).Contains(type!, StringComparer.OrdinalIgnoreCase))
|
if (!AcceptedTypes(c).Contains(type!, StringComparer.OrdinalIgnoreCase))
|
||||||
continue;
|
continue;
|
||||||
c.LoadType = type;
|
c.LoadType = type;
|
||||||
c.LoadCount = Math.Min(lead.LoadCount, MaxFor(c));
|
c.LoadCount = Math.Min(lead.LoadCount, LimitFor(c));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +117,9 @@ public sealed class Cargo
|
|||||||
{
|
{
|
||||||
var types = AcceptedTypes(c);
|
var types = AcceptedTypes(c);
|
||||||
if (types.Count == 0) continue;
|
if (types.Count == 0) continue;
|
||||||
c.LoadType = types[_rng.Next(types.Count)];
|
string type = types[_rng.Next(types.Count)];
|
||||||
|
c.LoadType = type;
|
||||||
|
c.LoadCount = Math.Min(c.LoadCount, LimitFor(c, type));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,11 +133,11 @@ public sealed class Cargo
|
|||||||
{
|
{
|
||||||
foreach (var c in consist.SelectMany(u => u.Cars))
|
foreach (var c in consist.SelectMany(u => u.Cars))
|
||||||
{
|
{
|
||||||
int max = MaxFor(c);
|
string? type = TypeToLoad(c);
|
||||||
|
if (string.IsNullOrEmpty(type)) continue;
|
||||||
|
int max = MaxFor(c, type);
|
||||||
if (max <= 0) continue;
|
if (max <= 0) continue;
|
||||||
if (string.IsNullOrEmpty(c.LoadType))
|
c.LoadType = type;
|
||||||
c.LoadType = AcceptedTypes(c).FirstOrDefault();
|
|
||||||
if (string.IsNullOrEmpty(c.LoadType)) continue;
|
|
||||||
int min = Math.Max(1, max / 10);
|
int min = Math.Max(1, max / 10);
|
||||||
c.LoadCount = _rng.Next(min, max + 1);
|
c.LoadCount = _rng.Next(min, max + 1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,12 +52,21 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
|
|||||||
|
|
||||||
public void Raise() => Changed?.Invoke();
|
public void Raise() => Changed?.Invoke();
|
||||||
|
|
||||||
public void LoadFrom(Trainset trainset)
|
/// <param name="select">The unit to select, by position; the first one otherwise.</param>
|
||||||
|
public void LoadFrom(Trainset trainset, int select = 0)
|
||||||
{
|
{
|
||||||
EditingTrainset = trainset;
|
EditingTrainset = trainset;
|
||||||
|
GroupIntoUnits(trainset.Vehicles);
|
||||||
|
Selected = _items.Count == 0 ? null : _items[Math.Clamp(select, 0, _items.Count - 1)];
|
||||||
|
SyncStartingVehicle();
|
||||||
|
Raise();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void GroupIntoUnits(IEnumerable<Dynamic> vehicles)
|
||||||
|
{
|
||||||
_items.Clear();
|
_items.Clear();
|
||||||
|
|
||||||
var cars = trainset.Vehicles.Select(v =>
|
var cars = vehicles.Select(v =>
|
||||||
{
|
{
|
||||||
var c = v.Clone();
|
var c = v.Clone();
|
||||||
c.MiniName = _db.MiniFor(c.DataFolder, c.SkinFile, c.MmdFile) ?? c.MiniName;
|
c.MiniName = _db.MiniFor(c.DataFolder, c.SkinFile, c.MmdFile) ?? c.MiniName;
|
||||||
@@ -74,13 +83,7 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
|
|||||||
while (j < cars.Count - 1 && HoldsNext(cars[j]))
|
while (j < cars.Count - 1 && HoldsNext(cars[j]))
|
||||||
locked.Add(cars[++j]);
|
locked.Add(cars[++j]);
|
||||||
|
|
||||||
_items.Add(new ConsistItem
|
_items.Add(Unit(locked));
|
||||||
{
|
|
||||||
Cars = locked,
|
|
||||||
Grouped = true,
|
|
||||||
Flipped = locked[0].Offset < 0,
|
|
||||||
Driver = UnitDriver(locked)
|
|
||||||
});
|
|
||||||
i = j + 1;
|
i = j + 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -106,13 +109,7 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
|
|||||||
|
|
||||||
if (group.Count >= 2)
|
if (group.Count >= 2)
|
||||||
{
|
{
|
||||||
_items.Add(new ConsistItem
|
_items.Add(Unit(group));
|
||||||
{
|
|
||||||
Cars = group,
|
|
||||||
Grouped = true,
|
|
||||||
Flipped = group[0].Offset < 0,
|
|
||||||
Driver = UnitDriver(group)
|
|
||||||
});
|
|
||||||
i = j;
|
i = j;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -128,13 +125,7 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
|
|||||||
|
|
||||||
if (group.Count >= 2)
|
if (group.Count >= 2)
|
||||||
{
|
{
|
||||||
_items.Add(new ConsistItem
|
_items.Add(Unit(group));
|
||||||
{
|
|
||||||
Cars = group,
|
|
||||||
Grouped = true,
|
|
||||||
Flipped = group[0].Offset < 0,
|
|
||||||
Driver = UnitDriver(group)
|
|
||||||
});
|
|
||||||
i = j;
|
i = j;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -149,9 +140,34 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
|
|||||||
});
|
});
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Selected = _items.FirstOrDefault();
|
/// <summary>
|
||||||
SyncStartingVehicle();
|
/// A unit from cars in the order they run. A turned unit keeps its own order
|
||||||
|
/// reversed, which <see cref="RunningOrder"/> undoes - kept as it runs, its cars
|
||||||
|
/// would swap ends with every load.
|
||||||
|
/// </summary>
|
||||||
|
private static ConsistItem Unit(List<Dynamic> running)
|
||||||
|
{
|
||||||
|
bool flipped = running[0].Offset < 0;
|
||||||
|
if (flipped)
|
||||||
|
running.Reverse();
|
||||||
|
return new ConsistItem
|
||||||
|
{
|
||||||
|
Cars = running,
|
||||||
|
Grouped = true,
|
||||||
|
Flipped = flipped,
|
||||||
|
Driver = UnitDriver(running)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Nothing to edit: the strip goes empty until a consist is loaded again.</summary>
|
||||||
|
public void Unload()
|
||||||
|
{
|
||||||
|
EditingTrainset = null;
|
||||||
|
_items.Clear();
|
||||||
|
Selected = null;
|
||||||
|
SelectedCar = null;
|
||||||
Raise();
|
Raise();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,16 +177,49 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
|
|||||||
public static IEnumerable<Dynamic> RunningOrder(ConsistItem item) =>
|
public static IEnumerable<Dynamic> RunningOrder(ConsistItem item) =>
|
||||||
item.Flipped ? Enumerable.Reverse(item.Cars) : item.Cars;
|
item.Flipped ? Enumerable.Reverse(item.Cars) : item.Cars;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The vehicle the simulator puts the player into follows the selection - but
|
||||||
|
/// only onto a unit someone sits in. Selecting a wagon to edit it, or adding
|
||||||
|
/// one, which selects it, keeps the pick made before; otherwise Start would
|
||||||
|
/// read "no crew" and the simulator would open in an empty wagon.
|
||||||
|
/// </summary>
|
||||||
public void SyncStartingVehicle()
|
public void SyncStartingVehicle()
|
||||||
{
|
{
|
||||||
if (Selected is not { Cars.Count: > 0 } item)
|
// A consist from the warehouse, say, is edited here but not the one started.
|
||||||
|
var state = AppServices.Current.State;
|
||||||
|
if (EditingTrainset is null || !ReferenceEquals(EditingTrainset, state.CurrentTrainset))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var driven = item.Cars.FirstOrDefault(c =>
|
if (StartingVehicle(_items, Selected, state.StartingVehicleName) is { } pick)
|
||||||
c.DriverType is eDriverType.Headdriver or eDriverType.Reardriver);
|
state.StartingVehicleName = pick;
|
||||||
AppServices.Current.State.StartingVehicleName = (driven ?? item.Cars[0]).Name;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The selected unit's driver, or its passenger; with no one in it, the pick
|
||||||
|
/// made before while it still can be started from, else the first crew there is.
|
||||||
|
/// </summary>
|
||||||
|
public static string? StartingVehicle(IEnumerable<ConsistItem> items, ConsistItem? selected, string? current)
|
||||||
|
{
|
||||||
|
var cars = items.SelectMany(i => i.Cars).ToList();
|
||||||
|
string? pick =
|
||||||
|
selected?.Cars.FirstOrDefault(c => c.DriverType is eDriverType.Headdriver or eDriverType.Reardriver)?.Name
|
||||||
|
?? selected?.Cars.FirstOrDefault(c => c.DriverType is eDriverType.Passenger)?.Name;
|
||||||
|
if (pick != null)
|
||||||
|
return pick;
|
||||||
|
|
||||||
|
if (cars.Any(c => IsNamed(c, current) && Crewed(c)))
|
||||||
|
return current;
|
||||||
|
|
||||||
|
return cars.FirstOrDefault(c => c.DriverType is eDriverType.Headdriver or eDriverType.Reardriver)?.Name
|
||||||
|
?? cars.FirstOrDefault(Crewed)?.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Crewed(Dynamic car) =>
|
||||||
|
car.DriverType is eDriverType.Headdriver or eDriverType.Reardriver or eDriverType.Passenger;
|
||||||
|
|
||||||
|
private static bool IsNamed(Dynamic car, string? name) =>
|
||||||
|
!string.IsNullOrEmpty(name) && string.Equals(car.Name, name, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
public Dynamic MakeDynamic(VehicleTexture texture, Scenery? scenery)
|
public Dynamic MakeDynamic(VehicleTexture texture, Scenery? scenery)
|
||||||
{
|
{
|
||||||
string skin = Base(texture.Skinfile);
|
string skin = Base(texture.Skinfile);
|
||||||
@@ -340,8 +389,7 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A locomotive that comes to the head of the train takes over the driver of
|
/// A locomotive that comes to the head of the train takes over the driver of
|
||||||
/// the one it put behind, which is left unstaffed: the train is driven from its
|
/// the one it put behind, in the same cab, and that one is left unstaffed.
|
||||||
/// front, from cab A.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void HandOverLead(ConsistItem previousHead)
|
private void HandOverLead(ConsistItem previousHead)
|
||||||
{
|
{
|
||||||
@@ -350,11 +398,55 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
|
|||||||
previousHead.Driver is not (eDriverType.Headdriver or eDriverType.Reardriver))
|
previousHead.Driver is not (eDriverType.Headdriver or eDriverType.Reardriver))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
var crew = previousHead.Driver;
|
||||||
previousHead.Driver = eDriverType.Nobody;
|
previousHead.Driver = eDriverType.Nobody;
|
||||||
foreach (var car in previousHead.Cars)
|
foreach (var car in previousHead.Cars)
|
||||||
car.DriverType = eDriverType.Nobody;
|
car.DriverType = eDriverType.Nobody;
|
||||||
|
|
||||||
Crew(head, eDriverType.Headdriver);
|
Crew(head, crew);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Vehicles loaded in place of a consist keep its kind of crew: in the unit the
|
||||||
|
/// loaded ones have crew in, else in the first powered one. A driver goes only
|
||||||
|
/// into something powered, and with nobody in the old consist nobody is in the new.
|
||||||
|
/// </summary>
|
||||||
|
public static List<Dynamic> LoadedOver(IReadOnlyList<Dynamic> before, List<Dynamic> loaded,
|
||||||
|
VehicleCatalog db, VehicleInfo info)
|
||||||
|
{
|
||||||
|
if (before.Count == 0 || loaded.Count == 0)
|
||||||
|
return loaded;
|
||||||
|
|
||||||
|
var consist = new Consist(db, info);
|
||||||
|
consist.GroupIntoUnits(loaded);
|
||||||
|
|
||||||
|
var crew = UnitDriver(before);
|
||||||
|
if (crew == eDriverType.Nobody)
|
||||||
|
{
|
||||||
|
foreach (var item in consist._items)
|
||||||
|
Crew(item, eDriverType.Nobody);
|
||||||
|
return consist.Flatten();
|
||||||
|
}
|
||||||
|
|
||||||
|
var seat = consist._items.FirstOrDefault(item => item.Driver != eDriverType.Nobody)
|
||||||
|
?? consist._items.FirstOrDefault(consist.IsPowered);
|
||||||
|
if (seat is not null && (crew == eDriverType.Passenger || consist.IsPowered(seat)))
|
||||||
|
Crew(seat, crew);
|
||||||
|
return consist.Flatten();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Vehicles joining a consist that has its crew come without their own.</summary>
|
||||||
|
public static List<Dynamic> Joined(IEnumerable<Dynamic> before, IEnumerable<Dynamic> added)
|
||||||
|
{
|
||||||
|
var joined = before.ToList();
|
||||||
|
bool crewed = joined.Count > 0;
|
||||||
|
foreach (var car in added)
|
||||||
|
{
|
||||||
|
if (crewed)
|
||||||
|
car.DriverType = eDriverType.Nobody;
|
||||||
|
joined.Add(car);
|
||||||
|
}
|
||||||
|
return joined;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Remove(ConsistItem item)
|
public void Remove(ConsistItem item)
|
||||||
@@ -673,6 +765,11 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
|
|||||||
public static bool HoldsTail(ConsistItem item) =>
|
public static bool HoldsTail(ConsistItem item) =>
|
||||||
item.Cars.Count > 0 && HoldsNext(TailCar(item));
|
item.Cars.Count > 0 && HoldsNext(TailCar(item));
|
||||||
|
|
||||||
|
/// <summary>Whether the unit at that place can join a neighbour: one it holds or may form a unit with, or one holding it.</summary>
|
||||||
|
public bool CanJoin(ConsistItem item, int index) =>
|
||||||
|
(index + 1 < Count && (HoldsTail(item) || CanFormUnit(item, this[index + 1]))) ||
|
||||||
|
(index > 0 && HoldsTail(this[index - 1]));
|
||||||
|
|
||||||
public static bool HoldsNext(Dynamic car) =>
|
public static bool HoldsNext(Dynamic car) =>
|
||||||
car.Coupling.Has(Coupling.WorkshopLock) || car.Coupling.Locked;
|
car.Coupling.Has(Coupling.WorkshopLock) || car.Coupling.Locked;
|
||||||
|
|
||||||
|
|||||||
92
StarterNG/Domain/ConsistHistory.cs
Normal file
92
StarterNG/Domain/ConsistHistory.cs
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using StarterNG.Classes;
|
||||||
|
|
||||||
|
namespace StarterNG.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Undo and redo for the consist editor, one history per trainset. It is fed the
|
||||||
|
/// vehicles as written back after every redraw; a state equal to the last one -
|
||||||
|
/// a redraw for a new selection - changes nothing, so feeding it is cheap to do
|
||||||
|
/// everywhere instead of remembering which of the many edits went through it.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ConsistHistory
|
||||||
|
{
|
||||||
|
private const int Depth = 100;
|
||||||
|
|
||||||
|
public sealed record State(IReadOnlyList<Dynamic> Vehicles, string Signature, int Selected);
|
||||||
|
|
||||||
|
private sealed class Track
|
||||||
|
{
|
||||||
|
public State? Last;
|
||||||
|
public readonly List<State> Undo = new();
|
||||||
|
public readonly Stack<State> Redo = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly ConditionalWeakTable<Trainset, Track> _tracks = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Notes what the trainset holds now. With <paramref name="rebase"/> it only
|
||||||
|
/// becomes the state undo returns to - for the redraw after an undo itself.
|
||||||
|
/// </summary>
|
||||||
|
public void Record(Trainset trainset, int selected, bool rebase = false)
|
||||||
|
{
|
||||||
|
var track = _tracks.GetOrCreateValue(trainset);
|
||||||
|
string signature = Dynamic.ToTrainsetNodes(trainset.Vehicles);
|
||||||
|
|
||||||
|
if (track.Last is { } last && last.Signature == signature)
|
||||||
|
{
|
||||||
|
track.Last = last with { Selected = selected };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = new State(trainset.Vehicles.Select(v => v.Clone()).ToList(), signature, selected);
|
||||||
|
if (track.Last is { } previous && !rebase)
|
||||||
|
{
|
||||||
|
// Undone, the selection goes back to where the change was made.
|
||||||
|
track.Undo.Add(previous with { Selected = selected });
|
||||||
|
if (track.Undo.Count > Depth)
|
||||||
|
track.Undo.RemoveAt(0);
|
||||||
|
track.Redo.Clear();
|
||||||
|
}
|
||||||
|
track.Last = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool CanUndo(Trainset trainset) =>
|
||||||
|
_tracks.TryGetValue(trainset, out var track) && track.Undo.Count > 0;
|
||||||
|
|
||||||
|
public bool CanRedo(Trainset trainset) =>
|
||||||
|
_tracks.TryGetValue(trainset, out var track) && track.Redo.Count > 0;
|
||||||
|
|
||||||
|
/// <summary>The state before the last change, as fresh copies to put into the trainset.</summary>
|
||||||
|
public State? Undo(Trainset trainset, int selected)
|
||||||
|
{
|
||||||
|
// An edit that never asked for a redraw is still in the trainset; noting it
|
||||||
|
// first makes it the change that gets undone instead of being lost.
|
||||||
|
Record(trainset, selected);
|
||||||
|
var track = _tracks.GetOrCreateValue(trainset);
|
||||||
|
if (track.Undo.Count == 0 || track.Last is not { } last)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
track.Redo.Push(last);
|
||||||
|
track.Last = track.Undo[^1];
|
||||||
|
track.Undo.RemoveAt(track.Undo.Count - 1);
|
||||||
|
return Copy(track.Last);
|
||||||
|
}
|
||||||
|
|
||||||
|
public State? Redo(Trainset trainset, int selected)
|
||||||
|
{
|
||||||
|
Record(trainset, selected);
|
||||||
|
var track = _tracks.GetOrCreateValue(trainset);
|
||||||
|
if (track.Redo.Count == 0 || track.Last is not { } last)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
track.Undo.Add(last);
|
||||||
|
track.Last = track.Redo.Pop();
|
||||||
|
return Copy(track.Last);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static State Copy(State state) =>
|
||||||
|
state with { Vehicles = state.Vehicles.Select(v => v.Clone()).ToList() };
|
||||||
|
}
|
||||||
23
StarterNG/Domain/Sceneries/FileStamp.cs
Normal file
23
StarterNG/Domain/Sceneries/FileStamp.cs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
using System;
|
||||||
|
using StarterNG.Application.Abstractions;
|
||||||
|
|
||||||
|
namespace StarterNG.Domain.Sceneries;
|
||||||
|
|
||||||
|
/// <summary>A file as it stood, to tell whether it has been changed since.</summary>
|
||||||
|
public readonly record struct FileStamp(DateTime LastWriteUtc, long Size)
|
||||||
|
{
|
||||||
|
/// <summary>Null for one that is not there, or cannot be looked at just now.</summary>
|
||||||
|
public static FileStamp? Of(IFileSystem files, string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return files.FileExists(path)
|
||||||
|
? new FileStamp(files.GetLastWriteTimeUtc(path), files.GetFileSize(path))
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using StarterNG.Application.Abstractions;
|
using StarterNG.Application.Abstractions;
|
||||||
using StarterNG.Classes;
|
using StarterNG.Classes;
|
||||||
using StarterNG.Domain;
|
|
||||||
|
|
||||||
namespace StarterNG.Domain.Sceneries;
|
namespace StarterNG.Domain.Sceneries;
|
||||||
|
|
||||||
@@ -59,10 +57,16 @@ public sealed class Scenery
|
|||||||
|
|
||||||
public List<string> LooseVehicleNames { get; } = new();
|
public List<string> LooseVehicleNames { get; } = new();
|
||||||
|
|
||||||
public List<string> Faults { get; } = new();
|
public List<SceneryFault> Faults { get; } = new();
|
||||||
|
|
||||||
public SceneryWeather Weather { get; } = new();
|
public SceneryWeather Weather { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The files it was read from, as each stood then: its own and those put in place
|
||||||
|
/// of their includes. Replaced whole, as it is looked at off the UI thread too.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyDictionary<string, FileStamp?> Sources { get; set; } = new Dictionary<string, FileStamp?>();
|
||||||
|
|
||||||
public string DisplayName => System.IO.Path.GetFileNameWithoutExtension(Path);
|
public string DisplayName => System.IO.Path.GetFileNameWithoutExtension(Path);
|
||||||
|
|
||||||
public string LocalizedDescription(ISceneryTranslations translations) =>
|
public string LocalizedDescription(ISceneryTranslations translations) =>
|
||||||
@@ -81,7 +85,9 @@ public sealed class Scenery
|
|||||||
|
|
||||||
for (int i = 0; i < Trainsets.Count; i++)
|
for (int i = 0; i < Trainsets.Count; i++)
|
||||||
{
|
{
|
||||||
string entry = skipDecorTrainsets && Trainsets[i].Decor
|
// The simulator logs an empty trainset as a bad one and does nothing else with it.
|
||||||
|
bool empty = Trainsets[i].Parsed && Trainsets[i].Vehicles.Count == 0;
|
||||||
|
string entry = empty || (skipDecorTrainsets && Trainsets[i].Decor)
|
||||||
? string.Empty
|
? string.Empty
|
||||||
: Trainsets[i].ToSceneryEntry();
|
: Trainsets[i].ToSceneryEntry();
|
||||||
result = result.Replace("{{" + i + "}}", entry);
|
result = result.Replace("{{" + i + "}}", entry);
|
||||||
|
|||||||
85
StarterNG/Domain/Sceneries/SceneryCode.cs
Normal file
85
StarterNG/Domain/Sceneries/SceneryCode.cs
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace StarterNG.Domain.Sceneries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A scenery's text read the way the simulator's parser reads it: comments -
|
||||||
|
/// <c>//</c> to the end of the line, <c>/* */</c> anywhere, even glued to a word -
|
||||||
|
/// left out, and the rest split into words at spaces, tabs, line ends and
|
||||||
|
/// semicolons. A trainset commented out is no trainset.
|
||||||
|
/// </summary>
|
||||||
|
public static class SceneryCode
|
||||||
|
{
|
||||||
|
public readonly record struct Token(string Text, int Index)
|
||||||
|
{
|
||||||
|
public int End => Index + Text.Length;
|
||||||
|
|
||||||
|
public bool Is(string word) => string.Equals(Text, word, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly Regex Word = new(@"[^\s;]+");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The text with every comment blanked out, line ends kept, so a place in one
|
||||||
|
/// is the same place in the other.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="unclosed">Where a <c>/*</c> that never ends starts, or -1.</param>
|
||||||
|
public static string Mask(string text, out int unclosed)
|
||||||
|
{
|
||||||
|
unclosed = -1;
|
||||||
|
char[]? masked = null;
|
||||||
|
|
||||||
|
for (int i = 0; i < text.Length; i++)
|
||||||
|
{
|
||||||
|
char c = text[i];
|
||||||
|
if (c == '"')
|
||||||
|
{
|
||||||
|
// Quotes glue what they enclose into one word, comment marks and all.
|
||||||
|
int close = text.IndexOf('"', i + 1);
|
||||||
|
i = close < 0 ? text.Length : close;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c != '/' || i + 1 >= text.Length)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
int end;
|
||||||
|
if (text[i + 1] == '/')
|
||||||
|
{
|
||||||
|
end = text.IndexOf('\n', i + 2);
|
||||||
|
if (end < 0)
|
||||||
|
end = text.Length;
|
||||||
|
}
|
||||||
|
else if (text[i + 1] == '*')
|
||||||
|
{
|
||||||
|
int close = text.IndexOf("*/", i + 2, StringComparison.Ordinal);
|
||||||
|
if (close < 0)
|
||||||
|
unclosed = i;
|
||||||
|
end = close < 0 ? text.Length : close + 2;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
masked ??= text.ToCharArray();
|
||||||
|
for (int j = i; j < end; j++)
|
||||||
|
if (masked[j] != '\n' && masked[j] != '\r')
|
||||||
|
masked[j] = ' ';
|
||||||
|
i = end - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return masked is null ? text : new string(masked);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <param name="code">Text with its comments blanked out by <see cref="Mask"/>.</param>
|
||||||
|
public static List<Token> Tokens(string code)
|
||||||
|
{
|
||||||
|
var tokens = new List<Token>();
|
||||||
|
for (var m = Word.Match(code); m.Success; m = m.NextMatch())
|
||||||
|
tokens.Add(new Token(m.Value, m.Index));
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
}
|
||||||
104
StarterNG/Domain/Sceneries/SceneryFault.cs
Normal file
104
StarterNG/Domain/Sceneries/SceneryFault.cs
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
namespace StarterNG.Domain.Sceneries;
|
||||||
|
|
||||||
|
public enum SceneryFaultKind
|
||||||
|
{
|
||||||
|
/// <summary><c>/*</c> never closed: the simulator skips the rest of the file.</summary>
|
||||||
|
UnclosedComment,
|
||||||
|
|
||||||
|
/// <summary>A trainset with no <c>endtrainset</c>; the simulator ends it at the next one.</summary>
|
||||||
|
TrainsetNotClosed,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A trainset's header written twice: the simulator ends the first as an empty
|
||||||
|
/// trainset, noting it in its log, and reads the trainset under the second.
|
||||||
|
/// </summary>
|
||||||
|
TrainsetHeaderRepeated,
|
||||||
|
|
||||||
|
/// <summary>The header lacks its name, track, offset or speed.</summary>
|
||||||
|
TrainsetIncomplete,
|
||||||
|
|
||||||
|
StrayEndTrainset,
|
||||||
|
|
||||||
|
/// <summary><c>reversed</c> outside a trainset, or after its first vehicle.</summary>
|
||||||
|
ReversedMisplaced,
|
||||||
|
|
||||||
|
/// <summary>A vehicle with no <c>enddynamic</c>, which swallows what follows.</summary>
|
||||||
|
VehicleNotClosed,
|
||||||
|
|
||||||
|
VehicleIncomplete,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A vehicle with no load count: the simulator takes its <c>enddynamic</c> for
|
||||||
|
/// one and goes on to the next.
|
||||||
|
/// </summary>
|
||||||
|
LoadCountMissing,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A load count other than 0 with no type after it - the simulator takes the
|
||||||
|
/// <c>enddynamic</c> for the type; <see cref="SceneryFault.Token"/> holds the count.
|
||||||
|
/// </summary>
|
||||||
|
LoadTypeMissing,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One word between the load and <c>enddynamic</c>: the simulator takes it for
|
||||||
|
/// a destination, the <c>enddynamic</c> for its name, and goes on to the next.
|
||||||
|
/// <see cref="SceneryFault.Token"/> holds the word.
|
||||||
|
/// </summary>
|
||||||
|
WordBeforeEnd,
|
||||||
|
|
||||||
|
/// <summary>A vehicle written as a trainset's is, with no trainset around it.</summary>
|
||||||
|
VehicleOutsideTrainset,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A trainset's track named with a space, which splits it into words; the
|
||||||
|
/// <see cref="SceneryFault.Token"/> holds the name as written.
|
||||||
|
/// </summary>
|
||||||
|
TrackNameSplit,
|
||||||
|
|
||||||
|
/// <summary>Text where a number belongs; <see cref="SceneryFault.Token"/> holds it.</summary>
|
||||||
|
NotANumber,
|
||||||
|
|
||||||
|
/// <summary>An include whose file is not there; <see cref="SceneryFault.Token"/> names it.</summary>
|
||||||
|
MissingInclude,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An include whose file is there only under a name in other letter case: the
|
||||||
|
/// simulator looks for it in lower case, which on Linux misses it. The
|
||||||
|
/// <see cref="SceneryFault.Subject"/> holds the name on disk.
|
||||||
|
/// </summary>
|
||||||
|
IncludeLetterCase,
|
||||||
|
|
||||||
|
/// <summary>An include with no <c>end</c>, which swallows what follows it.</summary>
|
||||||
|
IncludeNotClosed,
|
||||||
|
|
||||||
|
/// <summary>A trainset the starter could not read, for a reason none of the above names.</summary>
|
||||||
|
TrainsetUnreadable,
|
||||||
|
|
||||||
|
/// <summary>A vehicle outside a trainset the starter could not read.</summary>
|
||||||
|
VehicleUnreadable
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A mistake in a scenery's text: where it is - the scenery's file or one it
|
||||||
|
/// includes, and the line, as an editor counts them - and what it concerns.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Subject">The trainset or vehicle it concerns, when it has a name.</param>
|
||||||
|
public sealed record SceneryFault(SceneryFaultKind Kind, string File, int Line, string? Subject = null,
|
||||||
|
string? Token = null)
|
||||||
|
{
|
||||||
|
/// <summary>Where in the scenery's text it was found, to tell which trainset it falls in.</summary>
|
||||||
|
public int Offset { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What the simulator does not read because of it - the vehicles, trainsets and
|
||||||
|
/// includes it takes as part of this entry - or null when it loses none.
|
||||||
|
/// </summary>
|
||||||
|
public string? Lost { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The trainsets the simulator does not make because of it, their headers
|
||||||
|
/// swallowed by it or by what it set going - named, or placed when unnamed -
|
||||||
|
/// or null when there are none.
|
||||||
|
/// </summary>
|
||||||
|
public string? Unmade { get; init; }
|
||||||
|
}
|
||||||
37
StarterNG/Domain/Sceneries/SceneryFaultText.cs
Normal file
37
StarterNG/Domain/Sceneries/SceneryFaultText.cs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace StarterNG.Domain.Sceneries;
|
||||||
|
|
||||||
|
/// <summary>A scenery's faults as they are read: where each one is, in a column, and what it is.</summary>
|
||||||
|
public static class SceneryFaultText
|
||||||
|
{
|
||||||
|
/// <param name="limit">Most it lists; the rest are only counted.</param>
|
||||||
|
/// <param name="text">The wording for a key, in the language shown.</param>
|
||||||
|
public static string Format(IReadOnlyList<SceneryFault> faults, int limit, Func<string, string> text)
|
||||||
|
{
|
||||||
|
if (faults.Count == 0)
|
||||||
|
return "";
|
||||||
|
|
||||||
|
var shown = faults.Take(limit).Select(f => (Where: $"{f.File}:{f.Line}", Fault: f)).ToList();
|
||||||
|
int width = shown.Max(f => f.Where.Length) + 2;
|
||||||
|
var lines = new StringBuilder();
|
||||||
|
foreach (var (where, fault) in shown)
|
||||||
|
{
|
||||||
|
lines.Append(where.PadRight(width)).Append(Describe(fault, text)).Append('\n');
|
||||||
|
// What it costs, under it: too long to follow on the same line.
|
||||||
|
if (fault.Lost is { } lost)
|
||||||
|
lines.Append(' ', width).Append(string.Format(text("SyntaxLost"), lost)).Append('\n');
|
||||||
|
if (fault.Unmade is { } unmade)
|
||||||
|
lines.Append(' ', width).Append(string.Format(text("SyntaxUnmade"), unmade)).Append('\n');
|
||||||
|
}
|
||||||
|
if (faults.Count > limit)
|
||||||
|
lines.Append(string.Format(text("SyntaxMore"), faults.Count - limit));
|
||||||
|
return lines.ToString().TrimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Describe(SceneryFault fault, Func<string, string> text) =>
|
||||||
|
string.Format(text("Syntax" + fault.Kind), fault.Subject ?? "?", fault.Token ?? "");
|
||||||
|
}
|
||||||
78
StarterNG/Domain/Sceneries/SceneryIncludes.cs
Normal file
78
StarterNG/Domain/Sceneries/SceneryIncludes.cs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace StarterNG.Domain.Sceneries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The scenario's own files - included after FirstInit - put in place of their
|
||||||
|
/// include line when they hold trainsets, as the old starter did. Decorations
|
||||||
|
/// and road traffic live there; inlined, their consists are listed, can be
|
||||||
|
/// started from, and the copy written for a start carries them instead of the
|
||||||
|
/// include, so they are not loaded twice.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Only the plain <c>include path end</c> form: an include with parameters needs
|
||||||
|
/// them put in, and one whose <c>end</c> is not on its line is read by the
|
||||||
|
/// simulator in ways not worth guessing. One marked <c>$optional</c> stays too:
|
||||||
|
/// whether it is loaded is the player's to choose. Each inlined file is wrapped in
|
||||||
|
/// marker comments, which is how a trainset is told to belong to it.
|
||||||
|
/// </remarks>
|
||||||
|
public static class SceneryIncludes
|
||||||
|
{
|
||||||
|
public const string BeginMarker = "//$starter-include ";
|
||||||
|
public const string EndMarker = "//$starter-endinclude";
|
||||||
|
|
||||||
|
private static readonly Regex FirstInit = new(@"\bFirstInit\b", RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
private static readonly Regex Include = new(
|
||||||
|
@"^[ \t]*include[ \t]+(\S+)[ \t]+end\b[^\r\n]*", RegexOptions.Multiline | RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
private static readonly Regex HasTrainset = new(@"^[ \t]*trainset\b", RegexOptions.Multiline | RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
private static readonly Regex BlockComment = new(@"/\*[\s\S]*?\*/");
|
||||||
|
|
||||||
|
private static readonly Regex Marked = new(
|
||||||
|
Regex.Escape(BeginMarker) + @"(\S+)[\s\S]*?" + Regex.Escape(EndMarker));
|
||||||
|
|
||||||
|
/// <param name="read">
|
||||||
|
/// A file named as an include names it: its name as it is on disk, which the
|
||||||
|
/// marker carries, and its text - or null when there is none.
|
||||||
|
/// </param>
|
||||||
|
public static string Inline(string text, Func<string, (string File, string Text)?> read)
|
||||||
|
{
|
||||||
|
var firstInit = FirstInit.Match(text);
|
||||||
|
if (!firstInit.Success)
|
||||||
|
return text;
|
||||||
|
|
||||||
|
string head = text[..firstInit.Index];
|
||||||
|
string tail = text[firstInit.Index..];
|
||||||
|
string newline = text.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n";
|
||||||
|
var comments = BlockComment.Matches(tail).Select(m => (m.Index, End: m.Index + m.Length)).ToList();
|
||||||
|
|
||||||
|
return head + Include.Replace(tail, m =>
|
||||||
|
{
|
||||||
|
if (comments.Any(c => m.Index >= c.Index && m.Index < c.End))
|
||||||
|
return m.Value;
|
||||||
|
|
||||||
|
if (m.Value.Contains("$optional", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return m.Value;
|
||||||
|
|
||||||
|
if (read(m.Groups[1].Value) is not var (file, content) || !HasTrainset.IsMatch(content))
|
||||||
|
return m.Value;
|
||||||
|
|
||||||
|
return BeginMarker + file + newline + content.TrimEnd() + newline + EndMarker;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The text without the inlined files: what the scenery itself says.</summary>
|
||||||
|
public static string WithoutInlined(string text) => Marked.Replace(text, "");
|
||||||
|
|
||||||
|
/// <summary>Where each inlined file sits in the text, by the name its include gave.</summary>
|
||||||
|
public static IReadOnlyList<(int Start, int End, string File)> Ranges(string text) =>
|
||||||
|
Marked.Matches(text).Select(m => (m.Index, m.Index + m.Length, m.Groups[1].Value)).ToList();
|
||||||
|
|
||||||
|
public static string? FileAt(IReadOnlyList<(int Start, int End, string File)> ranges, int index) =>
|
||||||
|
ranges.FirstOrDefault(r => index >= r.Start && index < r.End).File;
|
||||||
|
}
|
||||||
66
StarterNG/Domain/Sceneries/SceneryLines.cs
Normal file
66
StarterNG/Domain/Sceneries/SceneryLines.cs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace StarterNG.Domain.Sceneries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Which file and line a place in a scenery's text is - its own file, or one put
|
||||||
|
/// in place of its include by <see cref="SceneryIncludes"/>, counted from that
|
||||||
|
/// file's own first line.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SceneryLines
|
||||||
|
{
|
||||||
|
private readonly List<int> _starts = new();
|
||||||
|
private readonly List<(string File, int Line)> _lines = new();
|
||||||
|
|
||||||
|
public SceneryLines(string text, string file)
|
||||||
|
{
|
||||||
|
var outer = new Stack<(string File, int Line)>();
|
||||||
|
string current = file;
|
||||||
|
int number = 1;
|
||||||
|
|
||||||
|
for (int start = 0; ;)
|
||||||
|
{
|
||||||
|
int end = text.IndexOf('\n', start);
|
||||||
|
if (end < 0)
|
||||||
|
end = text.Length;
|
||||||
|
var line = text.AsSpan(start, end - start);
|
||||||
|
|
||||||
|
_starts.Add(start);
|
||||||
|
if (line.StartsWith(SceneryIncludes.BeginMarker, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
// The marker stands where the include was; the file's lines follow.
|
||||||
|
_lines.Add((current, number));
|
||||||
|
outer.Push((current, number));
|
||||||
|
current = line[SceneryIncludes.BeginMarker.Length..].Trim().ToString();
|
||||||
|
number = 1;
|
||||||
|
}
|
||||||
|
else if (line.StartsWith(SceneryIncludes.EndMarker, StringComparison.Ordinal) && outer.Count > 0)
|
||||||
|
{
|
||||||
|
(current, number) = outer.Pop();
|
||||||
|
_lines.Add((current, number++));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_lines.Add((current, number++));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (end == text.Length)
|
||||||
|
break;
|
||||||
|
start = end + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public (string File, int Line) At(int index)
|
||||||
|
{
|
||||||
|
int found = _starts.BinarySearch(index);
|
||||||
|
return _lines[Math.Max(0, found >= 0 ? found : ~found - 1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A mistake at a place in the text, told by its file and line.</summary>
|
||||||
|
public SceneryFault Fault(SceneryFaultKind kind, int at, string? subject = null, string? token = null)
|
||||||
|
{
|
||||||
|
var (file, line) = At(at);
|
||||||
|
return new SceneryFault(kind, file, line, subject, token) { Offset = at };
|
||||||
|
}
|
||||||
|
}
|
||||||
528
StarterNG/Domain/Sceneries/ScenerySyntax.cs
Normal file
528
StarterNG/Domain/Sceneries/ScenerySyntax.cs
Normal file
@@ -0,0 +1,528 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace StarterNG.Domain.Sceneries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What the simulator makes of an include: whether it opens the file - and when
|
||||||
|
/// it does not, the file that is there under a name differing only in letter case.
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct IncludeLookup(bool Opens, string? OnDisk = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Goes through a scenery's text for the parts the starter reads - trainsets,
|
||||||
|
/// vehicles, includes - finding where each trainset and loose vehicle is, and
|
||||||
|
/// what in them is written wrong. Whatever else the text holds is passed over.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The text is read twice. Once word by word as the simulator reads it, which
|
||||||
|
/// takes every field it expects whatever stands there, and after a vehicle
|
||||||
|
/// written wrong goes on to the next <c>enddynamic</c> - that finds the faults,
|
||||||
|
/// and what each makes the simulator lose. Once as the author meant it, each
|
||||||
|
/// entry ending where it was meant to - that finds the trainsets and vehicles,
|
||||||
|
/// so one the simulator would lose is still listed, and written right when
|
||||||
|
/// the starter writes it.
|
||||||
|
/// </remarks>
|
||||||
|
public static class ScenerySyntax
|
||||||
|
{
|
||||||
|
public sealed record Result(
|
||||||
|
IReadOnlyList<(int Start, int End)> Trainsets,
|
||||||
|
IReadOnlyList<(int Start, int End)> LooseVehicles,
|
||||||
|
IReadOnlyList<SceneryFault> Faults,
|
||||||
|
SceneryLines Lines);
|
||||||
|
|
||||||
|
/// <param name="text">The scenery's text, its includes put in place.</param>
|
||||||
|
/// <param name="file">The scenery's file name, for faults in its own text.</param>
|
||||||
|
/// <param name="findInclude">
|
||||||
|
/// What the simulator makes of an include, named as the include names it; null
|
||||||
|
/// leaves includes unchecked.
|
||||||
|
/// </param>
|
||||||
|
public static Result Scan(string text, string file, Func<string, IncludeLookup>? findInclude = null)
|
||||||
|
{
|
||||||
|
string code = SceneryCode.Mask(text, out int unclosed);
|
||||||
|
var tokens = SceneryCode.Tokens(code);
|
||||||
|
var lines = new SceneryLines(text, file);
|
||||||
|
|
||||||
|
var simulator = new Reading(tokens, lines, findInclude, asSimulator: true);
|
||||||
|
simulator.Run();
|
||||||
|
var author = new Reading(tokens, lines, null, asSimulator: false);
|
||||||
|
author.Run();
|
||||||
|
|
||||||
|
var faults = Unmade(simulator, author, lines);
|
||||||
|
if (unclosed >= 0)
|
||||||
|
faults.Insert(0, lines.Fault(SceneryFaultKind.UnclosedComment, unclosed));
|
||||||
|
|
||||||
|
return new Result(author.Trainsets.Select(t => (t.Start, t.End)).ToList(), author.Loose, faults, lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The simulator's faults, with those in a trainset whose header it swallowed
|
||||||
|
/// left out: they come of reading its vehicles with no trainset, and the
|
||||||
|
/// trainset is told of instead, with the fault it came of - the first of a
|
||||||
|
/// run of them, where the run started.
|
||||||
|
/// </summary>
|
||||||
|
private static List<SceneryFault> Unmade(Reading simulator, Reading author, SceneryLines lines)
|
||||||
|
{
|
||||||
|
var unmade = new List<(int Start, int End, int Cause)>();
|
||||||
|
var lost = new Dictionary<int, List<string>>();
|
||||||
|
foreach (var (start, end, name) in author.Trainsets)
|
||||||
|
{
|
||||||
|
int cause = -1;
|
||||||
|
foreach (var (from, to, at) in simulator.Swallowed)
|
||||||
|
if (start >= from && start <= to)
|
||||||
|
{
|
||||||
|
cause = at;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (cause < 0)
|
||||||
|
continue;
|
||||||
|
foreach (var earlier in unmade)
|
||||||
|
if (cause >= earlier.Start && cause < earlier.End)
|
||||||
|
{
|
||||||
|
cause = earlier.Cause;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
unmade.Add((start, end, cause));
|
||||||
|
|
||||||
|
var (file, line) = lines.At(start);
|
||||||
|
bool named = name.Length > 0 && !name.Equals("none", StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (!lost.TryGetValue(cause, out var names))
|
||||||
|
lost[cause] = names = new List<string>();
|
||||||
|
names.Add(named ? name : $"{file}:{line}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unmade.Count == 0)
|
||||||
|
return simulator.Faults;
|
||||||
|
|
||||||
|
var faults = new List<SceneryFault>();
|
||||||
|
foreach (var fault in simulator.Faults)
|
||||||
|
{
|
||||||
|
if (unmade.Any(u => fault.Offset >= u.Start && fault.Offset < u.End))
|
||||||
|
continue;
|
||||||
|
faults.Add(fault.Lost is not null && lost.Remove(fault.Offset, out var names)
|
||||||
|
? fault with { Unmade = Listed(names) }
|
||||||
|
: fault);
|
||||||
|
}
|
||||||
|
return faults;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Listed(List<string> entries)
|
||||||
|
{
|
||||||
|
const int Named = 6;
|
||||||
|
string named = string.Join(", ", entries.Take(Named));
|
||||||
|
return entries.Count > Named ? $"{named} … +{entries.Count - Named}" : named;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Reading(
|
||||||
|
List<SceneryCode.Token> tokens, SceneryLines lines, Func<string, IncludeLookup>? findInclude, bool asSimulator)
|
||||||
|
{
|
||||||
|
public readonly List<(int Start, int End, string Name)> Trainsets = new();
|
||||||
|
public readonly List<(int, int)> Loose = new();
|
||||||
|
public readonly List<SceneryFault> Faults = new();
|
||||||
|
|
||||||
|
/// <summary>The text each fault makes the simulator pass over - from, to - and where the fault is.</summary>
|
||||||
|
public readonly List<(int From, int To, int At)> Swallowed = new();
|
||||||
|
|
||||||
|
// The trainset being read: where it starts, its name, how many vehicles so
|
||||||
|
// far, and the word its header starts at.
|
||||||
|
private (int Index, string Name, int Vehicles, int At)? _open;
|
||||||
|
|
||||||
|
public void Run()
|
||||||
|
{
|
||||||
|
for (int i = 0; i < tokens.Count; i++)
|
||||||
|
{
|
||||||
|
var token = tokens[i];
|
||||||
|
|
||||||
|
if (token.Is("trainset"))
|
||||||
|
i = Header(i);
|
||||||
|
else if (token.Is("endtrainset"))
|
||||||
|
{
|
||||||
|
// One with no vehicles is no mistake: a place for the player's own
|
||||||
|
// consist, filled in the depot.
|
||||||
|
if (_open is { } ended)
|
||||||
|
Trainsets.Add((ended.Index, token.End, ended.Name));
|
||||||
|
else
|
||||||
|
Fault(SceneryFaultKind.StrayEndTrainset, token.Index);
|
||||||
|
_open = null;
|
||||||
|
}
|
||||||
|
else if (token.Is("reversed"))
|
||||||
|
{
|
||||||
|
// Turns the trainset round only while none of its vehicles is placed yet.
|
||||||
|
if (_open is not { Vehicles: 0 })
|
||||||
|
Fault(SceneryFaultKind.ReversedMisplaced, token.Index);
|
||||||
|
}
|
||||||
|
else if (token.Is("assignment") || token.Is("assignments"))
|
||||||
|
{
|
||||||
|
while (i + 1 < tokens.Count && !tokens[i].Is("endassignment"))
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
else if (token.Is("node"))
|
||||||
|
{
|
||||||
|
// Not a vehicle: only its kind is stepped over, what follows is its own.
|
||||||
|
if (i + 4 >= tokens.Count || !tokens[i + 4].Is("dynamic"))
|
||||||
|
i += 4;
|
||||||
|
else
|
||||||
|
i = Vehicle(i);
|
||||||
|
}
|
||||||
|
else if (token.Is("include"))
|
||||||
|
i = Include(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_open is { } last)
|
||||||
|
NotClosed(last);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unless its header is told incomplete already: the endtrainset taken for a field.
|
||||||
|
private void NotClosed((int Index, string Name, int Vehicles, int At) trainset)
|
||||||
|
{
|
||||||
|
if (!Faults.Any(f => f.Offset == trainset.Index))
|
||||||
|
Fault(SceneryFaultKind.TrainsetNotClosed, trainset.Index, trainset.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each reader below takes the entry starting at the given word and gives
|
||||||
|
// back the last word it took.
|
||||||
|
|
||||||
|
private int Header(int at)
|
||||||
|
{
|
||||||
|
var token = tokens[at];
|
||||||
|
if (_open is { } unended)
|
||||||
|
{
|
||||||
|
// The same header twice over, the first with nothing under it: the
|
||||||
|
// simulator ends that one as empty - noting it in its log - and reads on.
|
||||||
|
if (unended.Vehicles == 0 && Same(unended.At, at, 4))
|
||||||
|
Fault(SceneryFaultKind.TrainsetHeaderRepeated, unended.Index, unended.Name);
|
||||||
|
else
|
||||||
|
NotClosed(unended);
|
||||||
|
}
|
||||||
|
_open = null;
|
||||||
|
|
||||||
|
if (!Fields(at, 4))
|
||||||
|
{
|
||||||
|
// Still opened, so its endtrainset ends it rather than standing alone.
|
||||||
|
int given = 0;
|
||||||
|
while (given < 4 && at + given + 1 < tokens.Count && !Keyword(tokens[at + given + 1]))
|
||||||
|
given++;
|
||||||
|
string? partial = given > 0 ? tokens[at + 1].Text : null;
|
||||||
|
_open = (token.Index, partial ?? "", 0, at);
|
||||||
|
if (!asSimulator)
|
||||||
|
return at + given;
|
||||||
|
|
||||||
|
// The simulator takes four words, whatever they are.
|
||||||
|
int taken = Math.Min(at + 4, tokens.Count - 1);
|
||||||
|
Fault(SceneryFaultKind.TrainsetIncomplete, token.Index, partial, swallowed: (at + given, taken));
|
||||||
|
return taken;
|
||||||
|
}
|
||||||
|
|
||||||
|
string name = tokens[at + 1].Text;
|
||||||
|
if (SplitTrack(tokens, at) is { } words)
|
||||||
|
{
|
||||||
|
// Its offset is the rest of the track's name - a lost letter, often.
|
||||||
|
string track = string.Join(' ', tokens.Skip(at + 2).Take(words).Select(t => t.Text));
|
||||||
|
Fault(SceneryFaultKind.TrackNameSplit, tokens[at + 2].Index, name, track);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Number(tokens[at + 3], name);
|
||||||
|
Number(tokens[at + 4], name);
|
||||||
|
}
|
||||||
|
_open = (token.Index, name, 0, at);
|
||||||
|
return at + 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int Vehicle(int at)
|
||||||
|
{
|
||||||
|
bool inTrainset = _open is not null;
|
||||||
|
// node range range name dynamic, then folder skin mmd [track] offset driver [coupling | speed]
|
||||||
|
int fields = inTrainset ? 10 : 11;
|
||||||
|
string name = tokens[at + 3].Text;
|
||||||
|
bool complete = Fields(at, fields);
|
||||||
|
|
||||||
|
// Where the author meant it to end: its enddynamic, or with that missing
|
||||||
|
// the word before whatever comes next.
|
||||||
|
int meant = at + 5;
|
||||||
|
for (; meant < tokens.Count && !Keyword(tokens[meant]); meant++)
|
||||||
|
if (tokens[meant].Is("enddynamic"))
|
||||||
|
break;
|
||||||
|
bool closed = meant < tokens.Count && tokens[meant].Is("enddynamic");
|
||||||
|
if (!closed)
|
||||||
|
meant--;
|
||||||
|
|
||||||
|
if (!asSimulator)
|
||||||
|
return ListVehicle(at, complete, closed, meant);
|
||||||
|
|
||||||
|
if (at + fields + 1 >= tokens.Count)
|
||||||
|
{
|
||||||
|
Fault(complete ? SceneryFaultKind.VehicleNotClosed : SceneryFaultKind.VehicleIncomplete,
|
||||||
|
tokens[at].Index, name);
|
||||||
|
return tokens.Count - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (fault, trackless) = CheckFields(at, complete, inTrainset, name);
|
||||||
|
if (fault is null && !closed)
|
||||||
|
fault = SceneryFaultKind.VehicleNotClosed;
|
||||||
|
|
||||||
|
int end = LoadEnd(at + fields + 1, trackless, meant, ref fault, out string? faulty);
|
||||||
|
|
||||||
|
if (fault is { } kind)
|
||||||
|
Fault(kind, tokens[at].Index, name, faulty, end > meant ? (meant, end) : null);
|
||||||
|
|
||||||
|
if (_open is { } open)
|
||||||
|
_open = open with { Vehicles = open.Vehicles + 1 };
|
||||||
|
return end;
|
||||||
|
}
|
||||||
|
|
||||||
|
// As the author meant it: there to be listed, whatever is wrong with it.
|
||||||
|
private int ListVehicle(int at, bool complete, bool closed, int meant)
|
||||||
|
{
|
||||||
|
// Without even the fields every vehicle has it is no vehicle to list.
|
||||||
|
if (!complete)
|
||||||
|
return at;
|
||||||
|
// One with its end missing still counts in its trainset: the starter
|
||||||
|
// reads it all the same, and writes it whole for a start. One written
|
||||||
|
// for a trainset with none around it is no loose vehicle either.
|
||||||
|
if (_open is { } current)
|
||||||
|
_open = current with { Vehicles = current.Vehicles + 1 };
|
||||||
|
else if (closed && !TrainsetStyle(at))
|
||||||
|
Loose.Add((tokens[at].Index, tokens[meant].End));
|
||||||
|
return meant;
|
||||||
|
}
|
||||||
|
|
||||||
|
// What is written wrong in a vehicle's fields, the numbers told of right away.
|
||||||
|
private (SceneryFaultKind? Fault, bool Trackless) CheckFields(int at, bool complete, bool inTrainset, string name)
|
||||||
|
{
|
||||||
|
SceneryFaultKind? fault = null;
|
||||||
|
// A track named by a number is none the simulator has: it drops the vehicle
|
||||||
|
// right after its load, going to the next enddynamic.
|
||||||
|
bool trackless = false;
|
||||||
|
if (!complete)
|
||||||
|
fault = SceneryFaultKind.VehicleIncomplete;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Number(tokens[at + 1], name);
|
||||||
|
Number(tokens[at + 2], name);
|
||||||
|
if (inTrainset)
|
||||||
|
{
|
||||||
|
Number(tokens[at + 8], name);
|
||||||
|
string coupling = tokens[at + 10].Text;
|
||||||
|
int dot = coupling.IndexOf('.');
|
||||||
|
string flags = dot < 0 ? coupling : coupling[..dot];
|
||||||
|
if (flags.Length > 0 && !int.TryParse(flags, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
|
||||||
|
Fault(SceneryFaultKind.NotANumber, tokens[at + 10].Index, name, coupling);
|
||||||
|
}
|
||||||
|
else if (TrainsetStyle(at))
|
||||||
|
{
|
||||||
|
// Its header missing or commented out.
|
||||||
|
fault = SceneryFaultKind.VehicleOutsideTrainset;
|
||||||
|
trackless = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Number(tokens[at + 9], name);
|
||||||
|
Number(tokens[at + 11], name);
|
||||||
|
trackless = IsNumber(tokens[at + 8]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (fault, trackless);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The last word of a vehicle, from its load count on, with what is written wrong
|
||||||
|
// there unless something before it already was.
|
||||||
|
private int LoadEnd(int next, bool trackless, int meant, ref SceneryFaultKind? fault, out string? faulty)
|
||||||
|
{
|
||||||
|
faulty = null;
|
||||||
|
|
||||||
|
// Then the load: its count, and only after a count other than 0 its type.
|
||||||
|
var count = tokens[next++];
|
||||||
|
if (Counted(count.Text))
|
||||||
|
{
|
||||||
|
if (next < tokens.Count && tokens[next].Is("enddynamic") && fault is null)
|
||||||
|
(fault, faulty) = (SceneryFaultKind.LoadTypeMissing, count.Text);
|
||||||
|
next++;
|
||||||
|
}
|
||||||
|
else if (count.Is("enddynamic"))
|
||||||
|
fault ??= SceneryFaultKind.LoadCountMissing;
|
||||||
|
|
||||||
|
// Then enddynamic - or a destination, one word more, and on to the next enddynamic.
|
||||||
|
int end;
|
||||||
|
if (trackless)
|
||||||
|
end = EndDynamic(next);
|
||||||
|
else if (next < tokens.Count && tokens[next].Is("enddynamic"))
|
||||||
|
end = next;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
end = EndDynamic(next + 2);
|
||||||
|
if (fault is null && end > meant)
|
||||||
|
(fault, faulty) = (SceneryFaultKind.WordBeforeEnd, tokens[next].Text);
|
||||||
|
}
|
||||||
|
return end;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int Include(int at)
|
||||||
|
{
|
||||||
|
var token = tokens[at];
|
||||||
|
string name = at + 1 < tokens.Count ? tokens[at + 1].Text : "";
|
||||||
|
|
||||||
|
if (!asSimulator)
|
||||||
|
{
|
||||||
|
// Its parameters run to "end". Another include, a trainset or a node on
|
||||||
|
// the way means the end is missing - read on as what they are.
|
||||||
|
int stop = at + 2;
|
||||||
|
while (stop < tokens.Count && !tokens[stop].Is("end") && !Keyword(tokens[stop]) &&
|
||||||
|
!tokens[stop].Is("include") && !tokens[stop].Is("firstinit"))
|
||||||
|
stop++;
|
||||||
|
return stop < tokens.Count && tokens[stop].Is("end") ? stop : stop - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (findInclude is not null && Plain(name) && findInclude(name) is { Opens: false } lookup)
|
||||||
|
{
|
||||||
|
if (lookup.OnDisk is { } onDisk)
|
||||||
|
Fault(SceneryFaultKind.IncludeLetterCase, token.Index, onDisk, name);
|
||||||
|
else
|
||||||
|
Fault(SceneryFaultKind.MissingInclude, token.Index, token: name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The simulator takes its parameters up to the word "end", written just so,
|
||||||
|
// whatever comes before it.
|
||||||
|
int end = at + 2;
|
||||||
|
while (end < tokens.Count && tokens[end].Text != "end")
|
||||||
|
end++;
|
||||||
|
int last = Math.Min(end, tokens.Count - 1);
|
||||||
|
if (Lost(at + 1, last) is not null)
|
||||||
|
Fault(SceneryFaultKind.IncludeNotClosed, token.Index, token: name, swallowed: (at + 1, last));
|
||||||
|
return last;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A vehicle with no trainset around it, written as within one - offset, crew,
|
||||||
|
// coupling - where its track, offset and crew belong.
|
||||||
|
private bool TrainsetStyle(int at) =>
|
||||||
|
at + 9 < tokens.Count && IsNumber(tokens[at + 8]) && Driver(tokens[at + 9]);
|
||||||
|
|
||||||
|
// The first enddynamic from the given word on, or the last word there is.
|
||||||
|
private int EndDynamic(int from)
|
||||||
|
{
|
||||||
|
int at = from;
|
||||||
|
while (at < tokens.Count && !tokens[at].Is("enddynamic"))
|
||||||
|
at++;
|
||||||
|
return Math.Min(at, tokens.Count - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The entries starting past <paramref name="after"/> up to <paramref name="last"/>,
|
||||||
|
/// as they would be named in the text - vehicles by their names - or null
|
||||||
|
/// when none start there.
|
||||||
|
/// </summary>
|
||||||
|
private string? Lost(int after, int last)
|
||||||
|
{
|
||||||
|
var entries = new List<string>();
|
||||||
|
for (int at = after + 1; at <= last && at < tokens.Count; at++)
|
||||||
|
{
|
||||||
|
var token = tokens[at];
|
||||||
|
if (token.Is("node") && at + 4 < tokens.Count)
|
||||||
|
entries.Add(tokens[at + 4].Is("dynamic")
|
||||||
|
? tokens[at + 3].Text
|
||||||
|
: $"{tokens[at + 4].Text} {tokens[at + 3].Text}");
|
||||||
|
else if ((token.Is("trainset") || token.Is("include")) && at + 1 < tokens.Count)
|
||||||
|
entries.Add($"{token.Text} {tokens[at + 1].Text}");
|
||||||
|
else if (token.Is("endtrainset") || token.Is("firstinit"))
|
||||||
|
entries.Add(token.Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries.Count == 0 ? null : Listed(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether the words after the two given are the same, so many of them.
|
||||||
|
private bool Same(int first, int second, int count)
|
||||||
|
{
|
||||||
|
for (int i = 1; i <= count; i++)
|
||||||
|
if (second + i >= tokens.Count || !tokens[first + i].Text.Equals(tokens[second + i].Text, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether the <paramref name="count"/> words after the one at <paramref name="at"/> are all there and none starts something else.</summary>
|
||||||
|
private bool Fields(int at, int count)
|
||||||
|
{
|
||||||
|
if (at + count >= tokens.Count)
|
||||||
|
return false;
|
||||||
|
for (int i = at + 1; i <= at + count; i++)
|
||||||
|
if (Keyword(tokens[i]) || tokens[i].Is("enddynamic"))
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Number(SceneryCode.Token field, string subject)
|
||||||
|
{
|
||||||
|
if (!IsNumber(field))
|
||||||
|
Fault(SceneryFaultKind.NotANumber, field.Index, subject, field.Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <param name="swallowed">The words the simulator takes along with it, past the one given up to the last.</param>
|
||||||
|
private void Fault(SceneryFaultKind kind, int index, string? subject = null, string? token = null,
|
||||||
|
(int After, int Last)? swallowed = null)
|
||||||
|
{
|
||||||
|
// Faults are the simulator's reading's to tell.
|
||||||
|
if (!asSimulator)
|
||||||
|
return;
|
||||||
|
string? lost = null;
|
||||||
|
if (swallowed is var (after, last) && Lost(after, last) is { } entries)
|
||||||
|
{
|
||||||
|
lost = entries;
|
||||||
|
Swallowed.Add((tokens[after + 1].Index, tokens[last].Index, index));
|
||||||
|
}
|
||||||
|
Faults.Add(lines.Fault(kind, index, subject, token) with { Lost = lost });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How many words a trainset's track name was split into by spaces, judged by
|
||||||
|
/// the two numbers - offset and speed - that follow them; null when it was not.
|
||||||
|
/// </summary>
|
||||||
|
private static int? SplitTrack(List<SceneryCode.Token> tokens, int header)
|
||||||
|
{
|
||||||
|
if (IsNumber(tokens[header + 3]))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
for (int words = 2; words <= 4; words++)
|
||||||
|
{
|
||||||
|
int offset = header + 2 + words;
|
||||||
|
if (offset + 1 >= tokens.Count || Keyword(tokens[offset - 1]) || IsNumber(tokens[offset - 1]))
|
||||||
|
return null;
|
||||||
|
if (IsNumber(tokens[offset]) && IsNumber(tokens[offset + 1]))
|
||||||
|
return words;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether a load count reads as other than 0, the way the simulator reads a
|
||||||
|
/// whole number: the digits it starts with, and 0 for one that does not.
|
||||||
|
/// </summary>
|
||||||
|
private static bool Counted(string text)
|
||||||
|
{
|
||||||
|
int at = text.Length > 0 && text[0] is '+' or '-' ? 1 : 0;
|
||||||
|
bool any = false;
|
||||||
|
for (; at < text.Length && char.IsAsciiDigit(text[at]); at++)
|
||||||
|
any |= text[at] != '0';
|
||||||
|
return any;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Driver(SceneryCode.Token token) =>
|
||||||
|
token.Is("headdriver") || token.Is("reardriver") || token.Is("passenger") || token.Is("nobody");
|
||||||
|
|
||||||
|
private static bool IsNumber(SceneryCode.Token token) =>
|
||||||
|
double.TryParse(token.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out _);
|
||||||
|
|
||||||
|
private static bool Keyword(SceneryCode.Token token) =>
|
||||||
|
token.Is("node") || token.Is("trainset") || token.Is("endtrainset");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An include named outright - not one built from a template's parameters or
|
||||||
|
/// picked at random, which only the simulator can tell.
|
||||||
|
/// </summary>
|
||||||
|
private static bool Plain(string name) =>
|
||||||
|
name.Length > 0 && !name.Contains("(p", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
name.IndexOfAny(new[] { '*', '?' }) < 0;
|
||||||
|
}
|
||||||
183
StarterNG/Domain/Sceneries/SceneryTrainsetText.cs
Normal file
183
StarterNG/Domain/Sceneries/SceneryTrainsetText.cs
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using StarterNG.Classes;
|
||||||
|
|
||||||
|
namespace StarterNG.Domain.Sceneries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A trainset written back into its own scenery file. Only the vehicles that
|
||||||
|
/// changed are the starter's to rewrite: everything else the author put there -
|
||||||
|
/// the header as typed, the description, comments, an assignments block, the other
|
||||||
|
/// vehicles as they are spelled - stays as it was, and the rest of the file is not
|
||||||
|
/// touched at all.
|
||||||
|
/// </summary>
|
||||||
|
public static class SceneryTrainsetText
|
||||||
|
{
|
||||||
|
private static readonly Regex LoneLineFeed = new(@"(?<!\r)\n");
|
||||||
|
|
||||||
|
private static readonly Regex RestOfLine = new(@"^[ \t]*\r?\n?");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The trainset as the file should now hold it, in the file's own line ends.
|
||||||
|
/// Ends at <c>endtrainset</c>, as the parser's match of the old one did.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// What stands between the vehicles stays in its place among those that stay.
|
||||||
|
/// A comment inside a vehicle's own node goes when that node is rewritten or
|
||||||
|
/// the vehicle removed: there is no longer a place for it.
|
||||||
|
/// </remarks>
|
||||||
|
public static string Compose(Trainset trainset, string newline)
|
||||||
|
{
|
||||||
|
string raw = trainset.RawEntry;
|
||||||
|
var saved = new Trainset(raw);
|
||||||
|
|
||||||
|
// Not read to its end: what stands there is not known, so it all stays.
|
||||||
|
if (!saved.Parsed)
|
||||||
|
return raw;
|
||||||
|
|
||||||
|
var (speed, vehicles, end) = saved.Layout;
|
||||||
|
|
||||||
|
// Line ends of both kinds are left as they are: there is no one kind to give them.
|
||||||
|
bool mixed = raw.Contains("\r\n", StringComparison.Ordinal) && LoneLineFeed.IsMatch(raw);
|
||||||
|
string Own(string text) => mixed ? text : Normalize(text).Replace("\n", newline);
|
||||||
|
|
||||||
|
int first = vehicles.Count > 0 ? vehicles[0].Start : end;
|
||||||
|
string head = raw[..first];
|
||||||
|
|
||||||
|
// The speed is the one header field the depot changes - the ready switch.
|
||||||
|
// Rewritten only when it differs, so "0.0" stays "0.0".
|
||||||
|
if (saved.Velocity != trainset.Velocity && speed is { } field)
|
||||||
|
head = raw[..field.Index] + trainset.Velocity.ToString(CultureInfo.InvariantCulture) + raw[field.End..first];
|
||||||
|
|
||||||
|
string[] was = saved.Vehicles.Select(v => v.ToTrainsetNode()).ToArray();
|
||||||
|
string[] now = trainset.Vehicles.Select(v => v.ToTrainsetNode()).ToArray();
|
||||||
|
string ending = end < raw.Length ? raw[end..] : "endtrainset";
|
||||||
|
if (was.SequenceEqual(now))
|
||||||
|
return Own(head + raw[first..end] + ending);
|
||||||
|
|
||||||
|
string Written(int i) => raw[vehicles[i].Start..vehicles[i].End];
|
||||||
|
string After(int i) => raw[vehicles[i].End..(i + 1 < vehicles.Count ? vehicles[i + 1].Start : end)];
|
||||||
|
|
||||||
|
var sb = new StringBuilder(Own(head));
|
||||||
|
|
||||||
|
void AtLineStart()
|
||||||
|
{
|
||||||
|
if (sb.Length > 0 && sb[^1] != '\n')
|
||||||
|
sb.Append(newline);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One moved here is still written as it was.
|
||||||
|
void Rewrite(int j)
|
||||||
|
{
|
||||||
|
AtLineStart();
|
||||||
|
int written = Array.IndexOf(was, now[j]);
|
||||||
|
sb.Append(written >= 0 ? Own(Written(written)) : now[j].TrimEnd('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
int[,] staying = Staying(was, now);
|
||||||
|
for (int i = 0, j = 0; i < was.Length || j < now.Length;)
|
||||||
|
{
|
||||||
|
bool both = i < was.Length && j < now.Length;
|
||||||
|
if (both && was[i] == now[j])
|
||||||
|
{
|
||||||
|
sb.Append(Own(Written(i) + After(i)));
|
||||||
|
i++;
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
else if (both && staying[i + 1, j + 1] == staying[i, j])
|
||||||
|
{
|
||||||
|
// In the place of the one there before, so what followed that one stays.
|
||||||
|
Rewrite(j++);
|
||||||
|
sb.Append(Own(After(i++)));
|
||||||
|
}
|
||||||
|
else if (j < now.Length && (i == was.Length || staying[i, j + 1] == staying[i, j]))
|
||||||
|
{
|
||||||
|
Rewrite(j++);
|
||||||
|
sb.Append(newline);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Gone, the end of its line with it; what followed it stays.
|
||||||
|
string after = After(i++);
|
||||||
|
if (!string.IsNullOrWhiteSpace(after))
|
||||||
|
{
|
||||||
|
AtLineStart();
|
||||||
|
sb.Append(Own(RestOfLine.Replace(after, "", 1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AtLineStart();
|
||||||
|
return sb.Append(Own(ending)).ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How many vehicles can stay in the order they are in, of those from each place on
|
||||||
|
/// in the old list and in the new one: the longest common run of the two.
|
||||||
|
/// </summary>
|
||||||
|
private static int[,] Staying(string[] was, string[] now)
|
||||||
|
{
|
||||||
|
var staying = new int[was.Length + 1, now.Length + 1];
|
||||||
|
for (int i = was.Length - 1; i >= 0; i--)
|
||||||
|
for (int j = now.Length - 1; j >= 0; j--)
|
||||||
|
staying[i, j] = was[i] == now[j]
|
||||||
|
? staying[i + 1, j + 1] + 1
|
||||||
|
: Math.Max(staying[i + 1, j], staying[i, j + 1]);
|
||||||
|
return staying;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The file with the trainset's old text swapped for <paramref name="entry"/>,
|
||||||
|
/// or null when that text cannot be told apart in the file: missing, or found
|
||||||
|
/// more often than the scenery has trainsets written that way.
|
||||||
|
/// </summary>
|
||||||
|
public static string? Replace(string fileText, Trainset trainset, IReadOnlyList<Trainset> sceneryTrainsets,
|
||||||
|
string entry)
|
||||||
|
{
|
||||||
|
var twins = sceneryTrainsets.Where(t => t.RawEntry == trainset.RawEntry).ToList();
|
||||||
|
int ordinal = twins.FindIndex(t => ReferenceEquals(t, trainset));
|
||||||
|
if (ordinal < 0 || trainset.RawEntry.Length == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var found = new List<int>();
|
||||||
|
for (int at = fileText.IndexOf(trainset.RawEntry, StringComparison.Ordinal);
|
||||||
|
at >= 0;
|
||||||
|
at = fileText.IndexOf(trainset.RawEntry, at + trainset.RawEntry.Length, StringComparison.Ordinal))
|
||||||
|
found.Add(at);
|
||||||
|
|
||||||
|
if (found.Count != twins.Count)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
int start = found[ordinal];
|
||||||
|
return fileText[..start] + entry + fileText[(start + trainset.RawEntry.Length)..];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the trainset now differs from what its file holds, compared the way
|
||||||
|
/// both would be written so a different spelling of the same number is no change.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsModified(Trainset trainset) =>
|
||||||
|
trainset.Parsed && Signature(trainset) != SavedSignature(trainset.RawEntry);
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, string> SavedSignatures = new();
|
||||||
|
|
||||||
|
private static string SavedSignature(string raw)
|
||||||
|
{
|
||||||
|
lock (SavedSignatures)
|
||||||
|
{
|
||||||
|
if (!SavedSignatures.TryGetValue(raw, out string? signature))
|
||||||
|
SavedSignatures[raw] = signature = Signature(new Trainset(raw));
|
||||||
|
return signature;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Signature(Trainset trainset) =>
|
||||||
|
trainset.Velocity.ToString(CultureInfo.InvariantCulture) + "\n" +
|
||||||
|
Dynamic.ToTrainsetNodes(trainset.Vehicles);
|
||||||
|
|
||||||
|
private static string Normalize(string text) => text.Replace("\r\n", "\n");
|
||||||
|
}
|
||||||
@@ -69,6 +69,23 @@ public sealed class SceneryWeather
|
|||||||
Dirty = true;
|
Dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>What was set by hand stays over a file read anew; untouched, the file's own stands.</summary>
|
||||||
|
public void KeepChoicesOf(SceneryWeather earlier)
|
||||||
|
{
|
||||||
|
if (!earlier.Changed)
|
||||||
|
return;
|
||||||
|
|
||||||
|
Time = earlier.Time;
|
||||||
|
ScenarioTimeOverride = earlier.ScenarioTimeOverride;
|
||||||
|
Day = earlier.Day;
|
||||||
|
Temperature = earlier.Temperature;
|
||||||
|
FogStart = earlier.FogStart;
|
||||||
|
FogEnd = earlier.FogEnd;
|
||||||
|
Overcast = earlier.Overcast;
|
||||||
|
OvercastRandom = earlier.OvercastRandom;
|
||||||
|
Dirty = earlier.Dirty;
|
||||||
|
}
|
||||||
|
|
||||||
public void SetFogRange(int start, int end, IRandomSource random)
|
public void SetFogRange(int start, int end, IRandomSource random)
|
||||||
{
|
{
|
||||||
FogStart = Math.Clamp(start, FogMin, FogMax);
|
FogStart = Math.Clamp(start, FogMin, FogMax);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
using System;
|
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using StarterNG.Application.Abstractions;
|
using StarterNG.Application.Abstractions;
|
||||||
@@ -8,9 +8,26 @@ namespace StarterNG.Domain.Sceneries;
|
|||||||
|
|
||||||
public static class SceneryWeatherWriter
|
public static class SceneryWeatherWriter
|
||||||
{
|
{
|
||||||
private static readonly Regex ManagedConfigLine = new(
|
// Whole words, as the simulator splits them: "atmo/lasy.scm" is a path, no atmo.
|
||||||
@"^\s*(movelight|scenario\.weather\.temperature|scenario\.time\.override)\b",
|
private const string WordStart = @"(?<![^\s;])";
|
||||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
private const string WordEnd = @"(?![^\s;])";
|
||||||
|
|
||||||
|
private static readonly Regex ConfigBlock = new(
|
||||||
|
$@"(?is){WordStart}config{WordEnd}(.*?){WordStart}endconfig{WordEnd}");
|
||||||
|
|
||||||
|
private static readonly Regex ManagedSetting = new(
|
||||||
|
$@"(?i){WordStart}(movelight|scenario\.weather\.temperature|scenario\.time\.override)\s+\S+");
|
||||||
|
|
||||||
|
/// <summary>What the written block takes the place of - looked for in the code alone, never in a comment.</summary>
|
||||||
|
private static readonly Regex[] Replaced =
|
||||||
|
{
|
||||||
|
ConfigBlock,
|
||||||
|
new($@"(?is){WordStart}time\s+\d[^\r\n]*?{WordStart}endtime{WordEnd}"),
|
||||||
|
new($@"(?is){WordStart}atmo{WordEnd}.*?{WordStart}endatmo{WordEnd}"),
|
||||||
|
new(@"(?im)^[ \t]*movelight\s+\S+"),
|
||||||
|
new($@"(?i){WordStart}scenario\.weather\.temperature\s+\S+"),
|
||||||
|
new($@"(?i){WordStart}scenario\.time\.override\s+\S+")
|
||||||
|
};
|
||||||
|
|
||||||
public static string Rewrite(string text, SceneryWeather weather, IRandomSource random)
|
public static string Rewrite(string text, SceneryWeather weather, IRandomSource random)
|
||||||
{
|
{
|
||||||
@@ -19,12 +36,8 @@ public static class SceneryWeatherWriter
|
|||||||
string carried = CarriedConfigLines(text);
|
string carried = CarriedConfigLines(text);
|
||||||
|
|
||||||
string stripped = text;
|
string stripped = text;
|
||||||
stripped = Regex.Replace(stripped, @"(?is)\bconfig\b.*?\bendconfig\b", " ");
|
foreach (var pattern in Replaced)
|
||||||
stripped = Regex.Replace(stripped, @"(?is)\btime\b\s+\d[^\r\n]*?\bendtime\b", " ");
|
stripped = Cut(stripped, pattern);
|
||||||
stripped = Regex.Replace(stripped, @"(?is)\batmo\b.*?\bendatmo\b", " ");
|
|
||||||
stripped = Regex.Replace(stripped, @"(?im)^[ \t]*movelight\s+\S+", " ");
|
|
||||||
stripped = Regex.Replace(stripped, @"(?i)scenario\.weather\.temperature\s+\S+", " ");
|
|
||||||
stripped = Regex.Replace(stripped, @"(?i)scenario\.time\.override\s+\S+", " ");
|
|
||||||
|
|
||||||
var inv = CultureInfo.InvariantCulture;
|
var inv = CultureInfo.InvariantCulture;
|
||||||
string config =
|
string config =
|
||||||
@@ -46,6 +59,38 @@ public static class SceneryWeatherWriter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The text without what the pattern finds in its code. A comment inside what
|
||||||
|
/// is taken out stays where it is, line ends and all, for a <c>//</c> to end
|
||||||
|
/// where it did.
|
||||||
|
/// </summary>
|
||||||
|
private static string Cut(string text, Regex pattern)
|
||||||
|
{
|
||||||
|
string code = SceneryCode.Mask(text, out _);
|
||||||
|
var sb = new StringBuilder(text.Length);
|
||||||
|
int at = 0;
|
||||||
|
|
||||||
|
foreach (Match found in pattern.Matches(code))
|
||||||
|
{
|
||||||
|
sb.Append(text, at, found.Index - at);
|
||||||
|
string written = text.Substring(found.Index, found.Length);
|
||||||
|
if (written == found.Value)
|
||||||
|
{
|
||||||
|
sb.Append(' ');
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var comments = new StringBuilder(written);
|
||||||
|
foreach (var word in Enumerable.Reverse(SceneryCode.Tokens(found.Value)))
|
||||||
|
comments.Remove(word.Index, word.Text.Length);
|
||||||
|
sb.Append(comments);
|
||||||
|
}
|
||||||
|
at = found.Index + found.Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.Append(text, at, text.Length - at).ToString();
|
||||||
|
}
|
||||||
|
|
||||||
private static string DelphiHourMinute(string hhmm)
|
private static string DelphiHourMinute(string hhmm)
|
||||||
{
|
{
|
||||||
int colon = hhmm.IndexOf(':');
|
int colon = hhmm.IndexOf(':');
|
||||||
@@ -57,20 +102,17 @@ public static class SceneryWeatherWriter
|
|||||||
: "0" + hhmm[colon..];
|
: "0" + hhmm[colon..];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The settings of every config block but those the starter writes itself.</summary>
|
||||||
private static string CarriedConfigLines(string text)
|
private static string CarriedConfigLines(string text)
|
||||||
{
|
{
|
||||||
var block = Regex.Match(text, @"(?is)\bconfig\b(.*?)\bendconfig\b");
|
|
||||||
if (!block.Success)
|
|
||||||
return "";
|
|
||||||
|
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
foreach (string raw in block.Groups[1].Value.Split('\n'))
|
foreach (Match block in ConfigBlock.Matches(SceneryCode.Mask(text, out _)))
|
||||||
{
|
foreach (string raw in ManagedSetting.Replace(block.Groups[1].Value, " ").Split('\n'))
|
||||||
string line = raw.TrimEnd('\r').Trim();
|
{
|
||||||
if (line.Length == 0 || ManagedConfigLine.IsMatch(line))
|
string line = raw.Trim();
|
||||||
continue;
|
if (line.Length > 0)
|
||||||
sb.Append(line).Append("\r\n");
|
sb.Append(line).Append("\r\n");
|
||||||
}
|
}
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
125
StarterNG/Domain/Settings/GpuOffload.cs
Normal file
125
StarterNG/Domain/Settings/GpuOffload.cs
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace StarterNG.Domain.Settings;
|
||||||
|
|
||||||
|
/// <summary>Which graphics card the simulator is started on, where there are two.</summary>
|
||||||
|
public enum GpuPreference
|
||||||
|
{
|
||||||
|
/// <summary>The card that is not driving the boot display - the discrete one on a laptop.</summary>
|
||||||
|
Discrete,
|
||||||
|
/// <summary>Whatever the system gives a program, as a rule the integrated card.</summary>
|
||||||
|
System
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What there is to say about the cards found and the choice made.</summary>
|
||||||
|
public enum GpuNote
|
||||||
|
{
|
||||||
|
/// <summary>No card could be read.</summary>
|
||||||
|
Unread,
|
||||||
|
/// <summary>One card leaves nothing to choose.</summary>
|
||||||
|
OneCard,
|
||||||
|
/// <summary>The system decides, by choice.</summary>
|
||||||
|
LeftToSystem,
|
||||||
|
/// <summary>The player chose, by what the starter was started with.</summary>
|
||||||
|
PlayerChose,
|
||||||
|
/// <summary>The simulator would be moved to <see cref="GpuOffload.Target"/>.</summary>
|
||||||
|
Moved,
|
||||||
|
/// <summary>There is no card to move the simulator to.</summary>
|
||||||
|
NoTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A graphics card as Linux lists it under /sys/class/drm.</summary>
|
||||||
|
/// <param name="Slot">PCI address, as 0000:01:00.0.</param>
|
||||||
|
/// <param name="PciId">Vendor and device, as 10DE:2D19.</param>
|
||||||
|
/// <param name="Driver">The kernel driver: nvidia, amdgpu, i915...</param>
|
||||||
|
/// <param name="BootVga">The card the firmware started the display on.</param>
|
||||||
|
/// <param name="InternalPanel">The card has a laptop's built-in screen among its outputs (eDP, LVDS, DSI).</param>
|
||||||
|
public sealed record GpuDevice(string Slot, string PciId, string Driver, bool BootVga, bool InternalPanel = false)
|
||||||
|
{
|
||||||
|
public string Vendor => PciId.Split(':')[0].ToUpperInvariant() switch
|
||||||
|
{
|
||||||
|
"10DE" => "NVIDIA",
|
||||||
|
"1002" => "AMD",
|
||||||
|
"8086" => "Intel",
|
||||||
|
_ => PciId
|
||||||
|
};
|
||||||
|
|
||||||
|
public override string ToString() => $"{Vendor} {PciId} ({Driver})";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The environment that moves a native Linux simulator onto the discrete card,
|
||||||
|
/// as switcheroo-control would set it. A Windows build asks for it itself
|
||||||
|
/// (NvOptimusEnablement in windows.cpp); on Linux the choice is made by
|
||||||
|
/// variables read when libGL loads, so the one starting the program sets them.
|
||||||
|
/// </summary>
|
||||||
|
public static class GpuOffload
|
||||||
|
{
|
||||||
|
// Any of these already set means the player chose - prime-run %command% in
|
||||||
|
// Steam's launch options, say - and the choice is theirs.
|
||||||
|
private static readonly string[] SelectionVariables =
|
||||||
|
{
|
||||||
|
"__NV_PRIME_RENDER_OFFLOAD", "__GLX_VENDOR_LIBRARY_NAME", "__VK_LAYER_NV_optimus",
|
||||||
|
"DRI_PRIME", "MESA_VK_DEVICE_SELECT", "VK_LOADER_DRIVERS_SELECT"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cards Mesa drives, which DRI_PRIME can pick. On one the proprietary NVIDIA
|
||||||
|
// driver owns, DRI_PRIME leaves Mesa without a card and on llvmpipe.
|
||||||
|
private static readonly HashSet<string> MesaDrivers = new(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
"amdgpu", "radeon", "i915", "xe", "nouveau"
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>The card the simulator would be moved to, or null when it is left alone.</summary>
|
||||||
|
public static GpuDevice? Target(IReadOnlyList<GpuDevice> gpus, GpuPreference preference,
|
||||||
|
Func<string, string?> inherited)
|
||||||
|
{
|
||||||
|
if (preference == GpuPreference.System || gpus.Count < 2)
|
||||||
|
return null;
|
||||||
|
if (PlayerChoices(inherited).Count > 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// Only a laptop's integrated card hands its work to the other one. A desktop
|
||||||
|
// runs on the card its monitor is plugged into - moving off it could as well
|
||||||
|
// land on the integrated one - and an NVIDIA card driving the screen, through
|
||||||
|
// a MUX, is already the discrete one.
|
||||||
|
var boot = gpus.FirstOrDefault(g => g.BootVga);
|
||||||
|
if (boot is null || !boot.InternalPanel || boot.Vendor == "NVIDIA")
|
||||||
|
return null;
|
||||||
|
return gpus.FirstOrDefault(g => !g.BootVga && Supported(g));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The variables that say the player chose a card, of those set.</summary>
|
||||||
|
public static IReadOnlyList<string> PlayerChoices(Func<string, string?> inherited) =>
|
||||||
|
SelectionVariables.Where(name => !string.IsNullOrEmpty(inherited(name))).ToArray();
|
||||||
|
|
||||||
|
public static GpuNote NoteFor(IReadOnlyList<GpuDevice> gpus, GpuPreference preference,
|
||||||
|
Func<string, string?> inherited) =>
|
||||||
|
gpus.Count switch
|
||||||
|
{
|
||||||
|
0 => GpuNote.Unread,
|
||||||
|
1 => GpuNote.OneCard,
|
||||||
|
_ when preference == GpuPreference.System => GpuNote.LeftToSystem,
|
||||||
|
_ when PlayerChoices(inherited).Count > 0 => GpuNote.PlayerChose,
|
||||||
|
_ => Target(gpus, preference, inherited) is not null ? GpuNote.Moved : GpuNote.NoTarget
|
||||||
|
};
|
||||||
|
|
||||||
|
private static bool Supported(GpuDevice gpu) => gpu.Driver == "nvidia" || MesaDrivers.Contains(gpu.Driver);
|
||||||
|
|
||||||
|
/// <summary>The variables that put a program on <paramref name="target"/>.</summary>
|
||||||
|
public static IReadOnlyDictionary<string, string> EnvironmentFor(GpuDevice target) =>
|
||||||
|
target.Driver == "nvidia"
|
||||||
|
? new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["__NV_PRIME_RENDER_OFFLOAD"] = "1",
|
||||||
|
["__GLX_VENDOR_LIBRARY_NAME"] = "nvidia",
|
||||||
|
["__VK_LAYER_NV_optimus"] = "NVIDIA_only",
|
||||||
|
["VK_LOADER_DRIVERS_SELECT"] = "*nvidia*"
|
||||||
|
}
|
||||||
|
: new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["DRI_PRIME"] = "pci-" + target.Slot.Replace(':', '_').Replace('.', '_')
|
||||||
|
};
|
||||||
|
}
|
||||||
396
StarterNG/Domain/Settings/GraphicsPreset.cs
Normal file
396
StarterNG/Domain/Settings/GraphicsPreset.cs
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace StarterNG.Domain.Settings;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What only the two ends of the scale touch: the effects left to taste at every other
|
||||||
|
/// level and how often the screens refresh. The lowest level gives them up, the highest
|
||||||
|
/// takes the most there is.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="PythonUpdateMs">python.updatetime, one of the steps of its list.</param>
|
||||||
|
public sealed record GraphicsExtras(
|
||||||
|
bool MotionBlur,
|
||||||
|
bool ChromaticAberration,
|
||||||
|
bool Smoke,
|
||||||
|
int PythonUpdateMs)
|
||||||
|
{
|
||||||
|
/// <summary>The eu07.ini keys these values live in.</summary>
|
||||||
|
public static readonly IReadOnlyList<string> Keys = new[]
|
||||||
|
{
|
||||||
|
"gfx.postfx.motionblur.enabled", "gfx.postfx.chromaticaberration.enabled",
|
||||||
|
"gfx.smoke", "python.updatetime"
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>The simulator's own, for leaving a level chosen before this run: what was there earlier is gone.</summary>
|
||||||
|
public static readonly GraphicsExtras None = new(true, true, true, 200);
|
||||||
|
|
||||||
|
public static GraphicsExtras Of(SimulatorSettings s) =>
|
||||||
|
new(s.MotionBlur, s.ChromaticAberration, s.Smoke, s.PythonScreenUpdateRate);
|
||||||
|
|
||||||
|
public void ApplyTo(SimulatorSettings s)
|
||||||
|
{
|
||||||
|
s.MotionBlur = MotionBlur;
|
||||||
|
s.ChromaticAberration = ChromaticAberration;
|
||||||
|
s.Smoke = Smoke;
|
||||||
|
s.PythonScreenUpdateRate = PythonUpdateMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What only Ultra sets, switches and limits with no scale to climb. Not every best is the
|
||||||
|
/// top of a scale: shadows are longest with the limit at its bottom, the regulator and the
|
||||||
|
/// release of textures would only take away, off.
|
||||||
|
/// Left to the player, picture unchanged: separate windows for the screens
|
||||||
|
/// (python.displaywindows, python.vsync), texture streaming, the frame pacing.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ShadowAngleLimit">gfx.shadow.angle.min: how low the sun counts for shadows, so how long they
|
||||||
|
/// get: up to 5× the height of what casts them at 0.2, no longer than it at 1 (opengl33renderer.cpp).</param>
|
||||||
|
/// <param name="TargetFps">targetfps: above 0 the simulator moves the distance factor by itself.</param>
|
||||||
|
/// <param name="ScreenWindowFps">python.fpslimit: read only by the screens' separate windows
|
||||||
|
/// (pythonscreenviewer.cpp), it does nothing without them.</param>
|
||||||
|
/// <param name="TextureRelease">gfx.textures.releasedistance: above 0 textures of far vehicles leave the card.</param>
|
||||||
|
/// <param name="CompressTextures">compresstex: on saves card memory at a cost to the picture.</param>
|
||||||
|
/// <param name="SkipDecor">starter.ignoreirrelevant: on leaves decorative consists out of the scenario.</param>
|
||||||
|
public sealed record GraphicsTop(
|
||||||
|
double ShadowAngleLimit,
|
||||||
|
double TargetFps,
|
||||||
|
double ScreenWindowFps,
|
||||||
|
double TextureRelease,
|
||||||
|
bool Screens,
|
||||||
|
bool ScreenMipmaps,
|
||||||
|
bool CompressTextures,
|
||||||
|
bool SkipDecor,
|
||||||
|
bool Sky,
|
||||||
|
bool Cab,
|
||||||
|
bool AirCouplers,
|
||||||
|
bool SmoothTraction)
|
||||||
|
{
|
||||||
|
public const string TargetFpsKey = "targetfps";
|
||||||
|
public const string ScreenWindowFpsKey = "python.fpslimit";
|
||||||
|
public const string TextureReleaseKey = "gfx.textures.releasedistance";
|
||||||
|
public const string SkyKey = "skyenabled";
|
||||||
|
public const string CabKey = "rendercab";
|
||||||
|
public const string AirCouplersKey = "newaircouplers";
|
||||||
|
public const string SmoothTractionKey = "smoothtraction";
|
||||||
|
|
||||||
|
/// <summary>Those of its keys shown as rows of <see cref="SimulatorKeys"/>.</summary>
|
||||||
|
public static readonly IReadOnlyList<string> RowKeys = new[]
|
||||||
|
{
|
||||||
|
TargetFpsKey, ScreenWindowFpsKey, TextureReleaseKey, SkyKey, CabKey, AirCouplersKey, SmoothTractionKey
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>The eu07.ini keys these values live in.</summary>
|
||||||
|
public static readonly IReadOnlyList<string> Keys = RowKeys.Concat(new[]
|
||||||
|
{
|
||||||
|
"gfx.shadow.angle.min", "python.enabled", "python.mipmaps", "compresstex", "starter.ignoreirrelevant"
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
/// <summary>What they are with nothing set, as <see cref="GraphicsExtras.None"/>.</summary>
|
||||||
|
public static readonly GraphicsTop None =
|
||||||
|
new(0.2, 0, 100, 0, true, true, true, false, true, true, true, true);
|
||||||
|
|
||||||
|
public static GraphicsTop Of(SimulatorSettings s) =>
|
||||||
|
new(s.ShadowAngleLimit, GraphicsPreset.NumberOf(s, TargetFpsKey),
|
||||||
|
GraphicsPreset.NumberOf(s, ScreenWindowFpsKey), GraphicsPreset.NumberOf(s, TextureReleaseKey),
|
||||||
|
s.PythonScreens, s.ScreenMipmaps, s.CompressTextures, s.IgnoreIrrelevantTrains,
|
||||||
|
GraphicsPreset.ToggleOf(s, SkyKey), GraphicsPreset.ToggleOf(s, CabKey),
|
||||||
|
GraphicsPreset.ToggleOf(s, AirCouplersKey), GraphicsPreset.ToggleOf(s, SmoothTractionKey));
|
||||||
|
|
||||||
|
public void ApplyTo(SimulatorSettings s)
|
||||||
|
{
|
||||||
|
void Number(string key, double value) => s.Extra[key] = value.ToString(CultureInfo.InvariantCulture);
|
||||||
|
void Toggle(string key, bool value) => s.Extra[key] = SimulatorKey.FormatToggle(value);
|
||||||
|
|
||||||
|
s.ShadowAngleLimit = ShadowAngleLimit;
|
||||||
|
Number(TargetFpsKey, TargetFps);
|
||||||
|
Number(ScreenWindowFpsKey, ScreenWindowFps);
|
||||||
|
Number(TextureReleaseKey, TextureRelease);
|
||||||
|
s.PythonScreens = Screens;
|
||||||
|
s.ScreenMipmaps = ScreenMipmaps;
|
||||||
|
s.CompressTextures = CompressTextures;
|
||||||
|
s.IgnoreIrrelevantTrains = SkipDecor;
|
||||||
|
Toggle(SkyKey, Sky);
|
||||||
|
Toggle(CabKey, Cab);
|
||||||
|
Toggle(AirCouplersKey, AirCouplers);
|
||||||
|
Toggle(SmoothTractionKey, SmoothTraction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A level of detail set in one go: what weighs on the graphics card. Every scale climbs in
|
||||||
|
/// even steps from <see cref="Minimal"/> to <see cref="Ultra"/>, as far as it has steps for six
|
||||||
|
/// levels. The screen (resolution, field of view), the frame pacing and the effects that are a
|
||||||
|
/// matter of taste - motion blur, chromatic aberration - stay as they are, except at the
|
||||||
|
/// two ends of the scale, which have <see cref="Extras"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="NameKey">Its name's translation key.</param>
|
||||||
|
/// <param name="ReflectionsRate">gfx.reflections.framerate, in updates a second.</param>
|
||||||
|
/// <param name="DrawRange">basedrawrange, in metres. Below 2000 it changes nothing on screen: the renderer
|
||||||
|
/// keeps at least that for signals and lights (opengl33renderer.cpp).</param>
|
||||||
|
/// <param name="DistanceFactor">heightbase: the only thing that draws less than those 2 km, and draws
|
||||||
|
/// shapes, wires and sections nearer. A regulating targetfps moves it from there.</param>
|
||||||
|
/// <param name="BufferScale">starter.bufferscale, in percent. Above the screen over High: on a 4K one
|
||||||
|
/// Ultra is a picture of 8K, multisampled on top.</param>
|
||||||
|
/// <param name="DrawRangeFactor">gfx.drawrange.factor.max: how far the regulator may stretch the draw range.</param>
|
||||||
|
/// <param name="CabShadowsRange">gfx.shadows.cab.range, in metres, read only by the legacy renderer.</param>
|
||||||
|
public sealed record GraphicsPreset(
|
||||||
|
string NameKey,
|
||||||
|
int TextureSize,
|
||||||
|
int CabTextureSize,
|
||||||
|
double Anisotropy,
|
||||||
|
int Multisampling,
|
||||||
|
int DynamicLights,
|
||||||
|
int SmokeFidelity,
|
||||||
|
bool Shadows,
|
||||||
|
int ShadowMap,
|
||||||
|
int ShadowRange,
|
||||||
|
int ShadowRank,
|
||||||
|
bool EnvMap,
|
||||||
|
int ReflectionsFidelity,
|
||||||
|
double ReflectionsRate,
|
||||||
|
bool ExtraEffects,
|
||||||
|
int SplineFidelity,
|
||||||
|
double DrawRange,
|
||||||
|
double DistanceFactor,
|
||||||
|
int BufferScale,
|
||||||
|
double SleeperDistance,
|
||||||
|
double DrawRangeFactor,
|
||||||
|
double ReflectedInstances,
|
||||||
|
double ReflectedVehicles,
|
||||||
|
int CabShadowsRange,
|
||||||
|
GraphicsExtras? Extras = null,
|
||||||
|
GraphicsTop? Top = null)
|
||||||
|
{
|
||||||
|
public const string DrawRangeKey = "basedrawrange";
|
||||||
|
public const string DistanceFactorKey = "heightbase";
|
||||||
|
public const string SleeperKey = "sleeperdistance";
|
||||||
|
public const string ReflectedInstancesKey = "gfx.reflections.range_instances";
|
||||||
|
public const string ReflectedVehiclesKey = "gfx.reflections.range_vehicles";
|
||||||
|
|
||||||
|
/// <summary>Those of its keys shown as rows of <see cref="SimulatorKeys"/>.</summary>
|
||||||
|
public static readonly IReadOnlyList<string> RowKeys = new[]
|
||||||
|
{
|
||||||
|
DrawRangeKey, DistanceFactorKey, SleeperKey, ReflectedInstancesKey, ReflectedVehiclesKey
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>The eu07.ini keys a level sets.</summary>
|
||||||
|
public static readonly IReadOnlyList<string> Keys = new[]
|
||||||
|
{
|
||||||
|
"maxtexturesize", "maxcabtexturesize", "anisotropicfiltering", "multisampling", "dynamiclights",
|
||||||
|
"gfx.smoke.fidelity", "shadows", "gfx.shadowmap.enabled", "shadowtune", "gfx.shadow.rank.cutoff",
|
||||||
|
"gfx.envmap.enabled", "gfx.reflections.fidelity", "gfx.reflections.framerate", "gfx.extraeffects",
|
||||||
|
"splinefidelity", DrawRangeKey, DistanceFactorKey, "starter.bufferscale", SleeperKey,
|
||||||
|
"gfx.drawrange.factor.max", ReflectedInstancesKey, ReflectedVehiclesKey, "gfx.shadows.cab.range"
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>The last resort: no shadows, no reflections, no effects, no smoke, a softer picture.</summary>
|
||||||
|
public static readonly GraphicsPreset Minimal = new(
|
||||||
|
"PresetMinimal", TextureSize: 512, CabTextureSize: 512, Anisotropy: 1, Multisampling: 0,
|
||||||
|
DynamicLights: 1, SmokeFidelity: 1, Shadows: false, ShadowMap: 512, ShadowRange: 75, ShadowRank: 1,
|
||||||
|
EnvMap: false, ReflectionsFidelity: 0, ReflectionsRate: 1 / 300.0, ExtraEffects: false,
|
||||||
|
SplineFidelity: 1, DrawRange: 2000, DistanceFactor: 0.6, BufferScale: 70,
|
||||||
|
SleeperDistance: 100, DrawRangeFactor: 1, ReflectedInstances: 250, ReflectedVehicles: 50, CabShadowsRange: 0,
|
||||||
|
Extras: new GraphicsExtras(MotionBlur: false, ChromaticAberration: false, Smoke: false, PythonUpdateMs: 800));
|
||||||
|
|
||||||
|
public static readonly GraphicsPreset Low = new(
|
||||||
|
"PresetLow", TextureSize: 1024, CabTextureSize: 1024, Anisotropy: 2, Multisampling: 0,
|
||||||
|
DynamicLights: 3, SmokeFidelity: 1, Shadows: true, ShadowMap: 1024, ShadowRange: 125, ShadowRank: 2,
|
||||||
|
EnvMap: true, ReflectionsFidelity: 0, ReflectionsRate: 1 / 60.0, ExtraEffects: true,
|
||||||
|
SplineFidelity: 1, DrawRange: 2000, DistanceFactor: 0.7, BufferScale: 80,
|
||||||
|
SleeperDistance: 150, DrawRangeFactor: 1.5, ReflectedInstances: 400, ReflectedVehicles: 100, CabShadowsRange: 0);
|
||||||
|
|
||||||
|
public static readonly GraphicsPreset Medium = new(
|
||||||
|
"PresetMedium", TextureSize: 2048, CabTextureSize: 2048, Anisotropy: 4, Multisampling: 1,
|
||||||
|
DynamicLights: 5, SmokeFidelity: 2, Shadows: true, ShadowMap: 1024, ShadowRange: 175, ShadowRank: 2,
|
||||||
|
EnvMap: true, ReflectionsFidelity: 1, ReflectionsRate: 1 / 30.0, ExtraEffects: true,
|
||||||
|
SplineFidelity: 2, DrawRange: 2250, DistanceFactor: 0.85, BufferScale: 90,
|
||||||
|
SleeperDistance: 200, DrawRangeFactor: 2, ReflectedInstances: 550, ReflectedVehicles: 150, CabShadowsRange: 20);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where a player with no settings of their own starts. The simulator's own values (Globals.h),
|
||||||
|
/// but for smoke, track curves, reflections and cab shadows: theirs are the bottom of a scale
|
||||||
|
/// with levels to go under this one.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly GraphicsPreset High = new(
|
||||||
|
"PresetHigh", TextureSize: 4096, CabTextureSize: 4096, Anisotropy: 8, Multisampling: 2,
|
||||||
|
DynamicLights: 7, SmokeFidelity: 2, Shadows: true, ShadowMap: 2048, ShadowRange: 250, ShadowRank: 3,
|
||||||
|
EnvMap: true, ReflectionsFidelity: 1, ReflectionsRate: 0.1, ExtraEffects: true,
|
||||||
|
SplineFidelity: 2, DrawRange: 2500, DistanceFactor: 1, BufferScale: 100,
|
||||||
|
SleeperDistance: 250, DrawRangeFactor: 3, ReflectedInstances: 750, ReflectedVehicles: 250, CabShadowsRange: 40);
|
||||||
|
|
||||||
|
/// <summary>For a strong card. A map of 4096 doubles the cascades, so the range here reaches 800 m.</summary>
|
||||||
|
public static readonly GraphicsPreset VeryHigh = new(
|
||||||
|
"PresetVeryHigh", TextureSize: 8192, CabTextureSize: 8192, Anisotropy: 16, Multisampling: 3,
|
||||||
|
DynamicLights: 7, SmokeFidelity: 3, Shadows: true, ShadowMap: 4096, ShadowRange: 400, ShadowRank: 3,
|
||||||
|
EnvMap: true, ReflectionsFidelity: 2, ReflectionsRate: 2, ExtraEffects: true,
|
||||||
|
SplineFidelity: 3, DrawRange: 5000, DistanceFactor: 2, BufferScale: 150,
|
||||||
|
SleeperDistance: 2000, DrawRangeFactor: 3, ReflectedInstances: 1500, ReflectedVehicles: 1000, CabShadowsRange: 70);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Every value at the top of what the screen offers. Not the best picture throughout: a map
|
||||||
|
/// of 8192 stretches the cascades fourfold, so shadows reach 2400 m but are coarser up close
|
||||||
|
/// than at <see cref="VeryHigh"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly GraphicsPreset Ultra = new(
|
||||||
|
"PresetUltra", TextureSize: 8192, CabTextureSize: 8192, Anisotropy: 16, Multisampling: 3,
|
||||||
|
DynamicLights: 7, SmokeFidelity: 4, Shadows: true, ShadowMap: 8192, ShadowRange: 600, ShadowRank: 3,
|
||||||
|
EnvMap: true, ReflectionsFidelity: 2, ReflectionsRate: 60, ExtraEffects: true,
|
||||||
|
SplineFidelity: 4, DrawRange: 10000, DistanceFactor: 3, BufferScale: 200,
|
||||||
|
SleeperDistance: 5000, DrawRangeFactor: 3, ReflectedInstances: 3000, ReflectedVehicles: 2000, CabShadowsRange: 100,
|
||||||
|
Extras: new GraphicsExtras(MotionBlur: true, ChromaticAberration: true, Smoke: true, PythonUpdateMs: 200),
|
||||||
|
Top: new GraphicsTop(ShadowAngleLimit: 0.2, TargetFps: 0, ScreenWindowFps: 200, TextureRelease: 0,
|
||||||
|
Screens: true, ScreenMipmaps: true, CompressTextures: false, SkipDecor: false,
|
||||||
|
Sky: true, Cab: true, AirCouplers: true, SmoothTraction: true));
|
||||||
|
|
||||||
|
public static readonly IReadOnlyList<GraphicsPreset> All = new[] { Minimal, Low, Medium, High, VeryHigh, Ultra };
|
||||||
|
|
||||||
|
public void ApplyTo(SimulatorSettings s)
|
||||||
|
{
|
||||||
|
s.MaxTextureSize = TextureSize;
|
||||||
|
s.MaxCabTextureSize = CabTextureSize;
|
||||||
|
s.TextureFiltering = Anisotropy;
|
||||||
|
s.Multisampling = Multisampling;
|
||||||
|
s.DynamicLights = DynamicLights;
|
||||||
|
s.SmokeFidelity = SmokeFidelity;
|
||||||
|
s.RenderShadows = Shadows;
|
||||||
|
s.ShadowMapResolution = ShadowMap;
|
||||||
|
s.ShadowProjectionRange = ShadowRange;
|
||||||
|
s.ShadowRankCutoff = ShadowRank;
|
||||||
|
s.EnvMap = EnvMap;
|
||||||
|
s.ReflectionsFidelity = ReflectionsFidelity;
|
||||||
|
s.ReflectionsFramerate = ReflectionsRate;
|
||||||
|
s.ExtraEffects = ExtraEffects;
|
||||||
|
s.SplineFidelity = SplineFidelity;
|
||||||
|
s.Extra[DrawRangeKey] = DrawRange.ToString(CultureInfo.InvariantCulture);
|
||||||
|
s.Extra[DistanceFactorKey] = DistanceFactor.ToString(CultureInfo.InvariantCulture);
|
||||||
|
s.BufferScalePercent = BufferScale;
|
||||||
|
s.Extra[SleeperKey] = SleeperDistance.ToString(CultureInfo.InvariantCulture);
|
||||||
|
s.DrawRangeFactor = DrawRangeFactor;
|
||||||
|
s.Extra[ReflectedInstancesKey] = ReflectedInstances.ToString(CultureInfo.InvariantCulture);
|
||||||
|
s.Extra[ReflectedVehiclesKey] = ReflectedVehicles.ToString(CultureInfo.InvariantCulture);
|
||||||
|
s.CabShadowsRange = CabShadowsRange;
|
||||||
|
Extras?.ApplyTo(s);
|
||||||
|
Top?.ApplyTo(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether <paramref name="s"/> is at this level throughout.</summary>
|
||||||
|
public bool Matches(SimulatorSettings s) =>
|
||||||
|
s.MaxTextureSize == TextureSize && s.MaxCabTextureSize == CabTextureSize &&
|
||||||
|
Near(s.TextureFiltering, Anisotropy) && s.Multisampling == Multisampling &&
|
||||||
|
s.DynamicLights == DynamicLights && s.SmokeFidelity == SmokeFidelity &&
|
||||||
|
s.RenderShadows == Shadows && s.ShadowMapResolution == ShadowMap &&
|
||||||
|
s.ShadowProjectionRange == ShadowRange && s.ShadowRankCutoff == ShadowRank &&
|
||||||
|
s.EnvMap == EnvMap && s.ReflectionsFidelity == ReflectionsFidelity &&
|
||||||
|
Near(s.ReflectionsFramerate, ReflectionsRate) && s.ExtraEffects == ExtraEffects &&
|
||||||
|
s.SplineFidelity == SplineFidelity && Near(NumberOf(s, DrawRangeKey), DrawRange) &&
|
||||||
|
Near(NumberOf(s, DistanceFactorKey), DistanceFactor) &&
|
||||||
|
s.BufferScalePercent == BufferScale &&
|
||||||
|
Near(NumberOf(s, SleeperKey), SleeperDistance) && Near(s.DrawRangeFactor, DrawRangeFactor) &&
|
||||||
|
Near(NumberOf(s, ReflectedInstancesKey), ReflectedInstances) &&
|
||||||
|
Near(NumberOf(s, ReflectedVehiclesKey), ReflectedVehicles) &&
|
||||||
|
s.CabShadowsRange == CabShadowsRange &&
|
||||||
|
(Extras is null || GraphicsExtras.Of(s) == Extras) &&
|
||||||
|
(Top is null || GraphicsTop.Of(s) == Top);
|
||||||
|
|
||||||
|
/// <summary>The level <paramref name="s"/> is at throughout, if any.</summary>
|
||||||
|
public static GraphicsPreset? Matching(SimulatorSettings s) => All.FirstOrDefault(preset => preset.Matches(s));
|
||||||
|
|
||||||
|
internal static double NumberOf(SimulatorSettings s, string name)
|
||||||
|
{
|
||||||
|
var key = SimulatorKeys.Named(name);
|
||||||
|
return key.ParseNumber(key.RawIn(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool ToggleOf(SimulatorSettings s, string name) =>
|
||||||
|
SimulatorKey.ParseToggle(SimulatorKeys.Named(name).RawIn(s));
|
||||||
|
|
||||||
|
// As the file rounds it: 1/300 comes back as 0.003333.
|
||||||
|
private static bool Near(double value, double target) =>
|
||||||
|
Math.Abs(value - target) <= Math.Abs(target) * 2e-4 + 1e-9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A part of the picture with a level of its own, as its section on the screen has it. The rest
|
||||||
|
/// of a level - the buffer, the frame pacing, the sky and the cab - goes only with the whole.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Keys">The eu07.ini keys it sets.</param>
|
||||||
|
public sealed record GraphicsGroup(IReadOnlyList<string> Keys)
|
||||||
|
{
|
||||||
|
public static readonly GraphicsGroup DrawRange = new(new[]
|
||||||
|
{
|
||||||
|
GraphicsPreset.DrawRangeKey, GraphicsPreset.DistanceFactorKey, "splinefidelity",
|
||||||
|
GraphicsPreset.SleeperKey, "gfx.drawrange.factor.max"
|
||||||
|
});
|
||||||
|
|
||||||
|
public static readonly GraphicsGroup Textures = new(new[]
|
||||||
|
{
|
||||||
|
"maxtexturesize", "maxcabtexturesize", "anisotropicfiltering", "multisampling",
|
||||||
|
"compresstex", GraphicsTop.TextureReleaseKey
|
||||||
|
});
|
||||||
|
|
||||||
|
public static readonly GraphicsGroup Effects = new(new[]
|
||||||
|
{
|
||||||
|
"dynamiclights", "gfx.smoke.fidelity", "gfx.extraeffects", "gfx.postfx.motionblur.enabled",
|
||||||
|
"gfx.postfx.chromaticaberration.enabled", "gfx.smoke"
|
||||||
|
});
|
||||||
|
|
||||||
|
public static readonly GraphicsGroup Shadows = new(new[]
|
||||||
|
{
|
||||||
|
"shadows", "gfx.shadowmap.enabled", "shadowtune", "gfx.shadow.rank.cutoff",
|
||||||
|
"gfx.shadow.angle.min", "gfx.shadows.cab.range"
|
||||||
|
});
|
||||||
|
|
||||||
|
public static readonly GraphicsGroup Reflections = new(new[]
|
||||||
|
{
|
||||||
|
"gfx.envmap.enabled", "gfx.reflections.fidelity", "gfx.reflections.framerate",
|
||||||
|
GraphicsPreset.ReflectedInstancesKey, GraphicsPreset.ReflectedVehiclesKey
|
||||||
|
});
|
||||||
|
|
||||||
|
public static readonly IReadOnlyList<GraphicsGroup> All = new[] { DrawRange, Textures, Effects, Shadows, Reflections };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What applying a level touched.</summary>
|
||||||
|
/// <param name="RowKeys">The keys of <see cref="SimulatorKeys"/> rows to show its values in, in that order.</param>
|
||||||
|
/// <param name="ChosenKeys">The eu07.ini keys it chose as a whole.</param>
|
||||||
|
public sealed record PresetApplied(IReadOnlyList<string> RowKeys, IReadOnlyList<string> ChosenKeys);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Remembers what was there before an end of the scale, which comes back with any level
|
||||||
|
/// between. Leaving one chosen before this run, what was there earlier is gone, so the
|
||||||
|
/// simulator's own values take its place.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GraphicsPresetMemory
|
||||||
|
{
|
||||||
|
private GraphicsExtras? _beforeExtras;
|
||||||
|
|
||||||
|
private GraphicsTop? _beforeTop;
|
||||||
|
|
||||||
|
/// <summary>Puts <paramref name="preset"/> in <paramref name="settings"/>, with what an end of the scale left behind.</summary>
|
||||||
|
public PresetApplied Apply(GraphicsPreset preset, SimulatorSettings settings)
|
||||||
|
{
|
||||||
|
var current = GraphicsPreset.Matching(settings);
|
||||||
|
var coming = preset.Extras is not null ? null
|
||||||
|
: _beforeExtras ?? (current?.Extras is not null ? GraphicsExtras.None : null);
|
||||||
|
_beforeExtras = preset.Extras is not null ? _beforeExtras ?? GraphicsExtras.Of(settings) : null;
|
||||||
|
var comingTop = preset.Top is not null ? null
|
||||||
|
: _beforeTop ?? (current?.Top is not null ? GraphicsTop.None : null);
|
||||||
|
_beforeTop = preset.Top is not null ? _beforeTop ?? GraphicsTop.Of(settings) : null;
|
||||||
|
coming?.ApplyTo(settings);
|
||||||
|
comingTop?.ApplyTo(settings);
|
||||||
|
preset.ApplyTo(settings);
|
||||||
|
|
||||||
|
var rows = new List<string>(GraphicsPreset.RowKeys);
|
||||||
|
var chosen = new List<string>(GraphicsPreset.Keys);
|
||||||
|
if (preset.Extras is not null || coming is not null)
|
||||||
|
chosen.AddRange(GraphicsExtras.Keys);
|
||||||
|
if (preset.Top is not null || comingTop is not null)
|
||||||
|
{
|
||||||
|
chosen.AddRange(GraphicsTop.Keys);
|
||||||
|
rows.AddRange(GraphicsTop.RowKeys);
|
||||||
|
}
|
||||||
|
return new PresetApplied(rows, chosen);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
StarterNG/Domain/Settings/ResolutionChoices.cs
Normal file
56
StarterNG/Domain/Settings/ResolutionChoices.cs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace StarterNG.Domain.Settings;
|
||||||
|
|
||||||
|
/// <summary>A size in pixels, as width and height in eu07.ini take it.</summary>
|
||||||
|
public readonly record struct ScreenSize(int Width, int Height)
|
||||||
|
{
|
||||||
|
public override string ToString() => $"{Width}x{Height}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A size to offer for the simulator's window.</summary>
|
||||||
|
/// <param name="IsMonitor">A monitor in the computer has exactly this size.</param>
|
||||||
|
public readonly record struct ResolutionChoice(ScreenSize Size, bool IsMonitor);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What the resolution list offers: the monitors in the computer first, then the
|
||||||
|
/// usual sizes that fit on one of them, largest first. A full-screen simulator gets
|
||||||
|
/// the closest mode its monitor has, so a size no monitor can show is of no use.
|
||||||
|
/// </summary>
|
||||||
|
public static class ResolutionChoices
|
||||||
|
{
|
||||||
|
private static readonly ScreenSize[] Usual =
|
||||||
|
{
|
||||||
|
// 16:9
|
||||||
|
new(3840, 2160), new(2560, 1440), new(1920, 1080), new(1600, 900), new(1366, 768), new(1280, 720),
|
||||||
|
// 16:10
|
||||||
|
new(2560, 1600), new(1920, 1200), new(1680, 1050), new(1440, 900), new(1280, 800),
|
||||||
|
// Ultrawide
|
||||||
|
new(3440, 1440), new(2560, 1080),
|
||||||
|
// 5:4 and 4:3
|
||||||
|
new(1280, 1024), new(1024, 768)
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <param name="monitors">The monitors, the primary one first; empty where they cannot be told.</param>
|
||||||
|
/// <param name="current">The size asked for now, kept on the list whatever it is.</param>
|
||||||
|
public static IReadOnlyList<ResolutionChoice> For(IReadOnlyCollection<ScreenSize> monitors, ScreenSize current)
|
||||||
|
{
|
||||||
|
var known = monitors.Where(IsReal).Distinct().ToList();
|
||||||
|
var rest = Usual.Where(size => known.Count == 0 || known.Any(m => Fits(size, m)));
|
||||||
|
if (IsReal(current))
|
||||||
|
rest = rest.Append(current);
|
||||||
|
|
||||||
|
return known.Select(size => new ResolutionChoice(size, true))
|
||||||
|
.Concat(rest.Except(known).Distinct()
|
||||||
|
.OrderByDescending(size => size.Width)
|
||||||
|
.ThenByDescending(size => size.Height)
|
||||||
|
.Select(size => new ResolutionChoice(size, false)))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsReal(ScreenSize size) => size.Width > 0 && size.Height > 0;
|
||||||
|
|
||||||
|
private static bool Fits(ScreenSize size, ScreenSize monitor) =>
|
||||||
|
size.Width <= monitor.Width && size.Height <= monitor.Height;
|
||||||
|
}
|
||||||
122
StarterNG/Domain/Settings/SettingReach.cs
Normal file
122
StarterNG/Domain/Settings/SettingReach.cs
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace StarterNG.Domain.Settings;
|
||||||
|
|
||||||
|
/// <summary>Why the simulator about to be started would pass a key over.</summary>
|
||||||
|
public enum IdleReason
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
WindowsBuildOnly,
|
||||||
|
LegacyRendererOnly,
|
||||||
|
/// <summary>Read by the full OpenGL renderer alone.</summary>
|
||||||
|
ModernRendererOnly,
|
||||||
|
/// <summary>Read by the OpenGL renderers, which the experimental one is not.</summary>
|
||||||
|
OpenGlRenderersOnly,
|
||||||
|
NeedsNetworkServer,
|
||||||
|
/// <summary>The draw range is only stretched by the frame rate regulator, which is off.</summary>
|
||||||
|
NeedsFrameRateTarget,
|
||||||
|
/// <summary>fullscreenwindowed takes the monitor's own mode instead.</summary>
|
||||||
|
MonitorModeInstead
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What about the simulator to be started decides which keys it heeds.</summary>
|
||||||
|
/// <param name="WindowsBuild">The executable is a Windows build, under Wine or not.</param>
|
||||||
|
/// <param name="Renderer">The gfxrenderer word, as <see cref="SimulatorSettings.RenderEngines"/> has it.</param>
|
||||||
|
/// <param name="NetworkServer">eu07.ini starts a network.server.</param>
|
||||||
|
/// <param name="TargetFps">targetfps as eu07.ini sets it, or null where it does not.</param>
|
||||||
|
/// <param name="FpsAverage">fpsaverage, the older regulator's target; 0 for none.</param>
|
||||||
|
/// <param name="FullscreenWindowed">fullscreenwindowed is on.</param>
|
||||||
|
public sealed record SimulatorTarget(bool WindowsBuild, string Renderer, bool NetworkServer,
|
||||||
|
double? TargetFps = null, double FpsAverage = 0,
|
||||||
|
bool FullscreenWindowed = false);
|
||||||
|
|
||||||
|
/// <summary>The renderers a gfxrenderer word picks, as application.cpp tells them apart.</summary>
|
||||||
|
public enum RendererKind { Modern, Legacy, Experimental }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where an eu07.ini key takes effect, from the simulator's sources: a key it
|
||||||
|
/// reads but leaves alone on the platform, render path or setup at hand.
|
||||||
|
/// </summary>
|
||||||
|
public static class SettingReach
|
||||||
|
{
|
||||||
|
// The keyboard lights, LPT and PoKeys behind feedback live in Console.cpp,
|
||||||
|
// which only the Windows build compiles; the system console and ANGLE are
|
||||||
|
// Windows-only in application.cpp.
|
||||||
|
private static readonly HashSet<string> WindowsBuildOnly = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"feedbackmode", "feedbackport", "showsystemconsole", "gfx.angleplatform"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Read by openglrenderer.cpp alone; the modern path forces VBOs on.
|
||||||
|
private static readonly HashSet<string> LegacyRendererOnly = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"smoothtraction", "gfx.shadows.cab.range", "usevbo"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Read by opengl33renderer.cpp and the shaders it builds (glsl_common.cpp) alone.
|
||||||
|
private static readonly HashSet<string> ModernRendererOnly = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"gfx.postfx.chromaticaberration.enabled", "gfx.postfx.motionblur.enabled",
|
||||||
|
"gfx.postfx.motionblur.shutter", "gfx.extraeffects", "gfx.envmap.enabled", "gfx.skippipeline",
|
||||||
|
"rendercab", "sleeperdistance"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Read by both OpenGL renderers but not by the experimental one, which sets its
|
||||||
|
// own anisotropy, draws no smoke and has no frame rate regulator.
|
||||||
|
private static readonly HashSet<string> OpenGlRenderersOnly = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"anisotropicfiltering", "multisampling", "dynamiclights", "gfx.smoke", "gfx.smoke.fidelity",
|
||||||
|
"gfx.reflections.framerate", "gfx.reflections.range_vehicles", "gfx.shadow.angle.min",
|
||||||
|
"scalespeculars", "gfx.drawrange.factor.max"
|
||||||
|
};
|
||||||
|
|
||||||
|
// application.cpp: fullscreenwindowed takes its size from the monitor and always
|
||||||
|
// goes full screen. "width, height" is how the settings screen names that row.
|
||||||
|
private static readonly HashSet<string> OverriddenByMonitorMode = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"width, height", "fullscreen"
|
||||||
|
};
|
||||||
|
|
||||||
|
public static IdleReason Why(string key, SimulatorTarget target)
|
||||||
|
{
|
||||||
|
// The starter's own keys mean nothing to any simulator, and need not.
|
||||||
|
if (key.StartsWith("starter.", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return IdleReason.None;
|
||||||
|
if (WindowsBuildOnly.Contains(key) && !target.WindowsBuild)
|
||||||
|
return IdleReason.WindowsBuildOnly;
|
||||||
|
if (OverriddenByMonitorMode.Contains(key) && target.FullscreenWindowed)
|
||||||
|
return IdleReason.MonitorModeInstead;
|
||||||
|
|
||||||
|
var renderer = KindOf(target.Renderer);
|
||||||
|
if (LegacyRendererOnly.Contains(key) && renderer != RendererKind.Legacy)
|
||||||
|
return IdleReason.LegacyRendererOnly;
|
||||||
|
if (ModernRendererOnly.Contains(key) && renderer != RendererKind.Modern)
|
||||||
|
return IdleReason.ModernRendererOnly;
|
||||||
|
if (OpenGlRenderersOnly.Contains(key) && renderer == RendererKind.Experimental)
|
||||||
|
return IdleReason.OpenGlRenderersOnly;
|
||||||
|
|
||||||
|
// on_focus_change pauses only a simulator that serves the network.
|
||||||
|
if (string.Equals(key, "inactivepause", StringComparison.OrdinalIgnoreCase) && !target.NetworkServer)
|
||||||
|
return IdleReason.NeedsNetworkServer;
|
||||||
|
if (string.Equals(key, "gfx.drawrange.factor.max", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
!DrawRangeRegulated(target))
|
||||||
|
return IdleReason.NeedsFrameRateTarget;
|
||||||
|
return IdleReason.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the renderer moves the draw range with the frame rate - the only use
|
||||||
|
/// of gfx.drawrange.factor.max: targetfps does it, as the older fpsaverage does.
|
||||||
|
/// </summary>
|
||||||
|
private static bool DrawRangeRegulated(SimulatorTarget target) =>
|
||||||
|
target.FpsAverage != 0 || (target.TargetFps ?? 0) != 0;
|
||||||
|
|
||||||
|
/// <summary>"full" and "default" are the modern renderer, "experimental" its own; every other word the legacy one.</summary>
|
||||||
|
public static RendererKind KindOf(string renderer) => renderer.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"full" or "default" => RendererKind.Modern,
|
||||||
|
"experimental" => RendererKind.Experimental,
|
||||||
|
_ => RendererKind.Legacy
|
||||||
|
};
|
||||||
|
}
|
||||||
128
StarterNG/Domain/Settings/SimulatorKeys.cs
Normal file
128
StarterNG/Domain/Settings/SimulatorKeys.cs
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace StarterNG.Domain.Settings;
|
||||||
|
|
||||||
|
public enum SimulatorKeyKind { Toggle, Number, Text }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An eu07.ini key the simulator reads that needs no logic of the starter's own:
|
||||||
|
/// a switch, a number or a word, shown as one row of the settings. The default
|
||||||
|
/// is the simulator's own, from Globals.h, used while the file does not carry
|
||||||
|
/// the key.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SimulatorKey(
|
||||||
|
string Key,
|
||||||
|
SimulatorKeyKind Kind,
|
||||||
|
string Default,
|
||||||
|
string Category,
|
||||||
|
string Section,
|
||||||
|
double Minimum = 0,
|
||||||
|
double Maximum = 0,
|
||||||
|
double Step = 1,
|
||||||
|
string Unit = "",
|
||||||
|
bool ZeroIsOff = false,
|
||||||
|
int Repeat = 1)
|
||||||
|
{
|
||||||
|
/// <summary>The label's translation key; the explanation's adds "Desc".</summary>
|
||||||
|
public string LabelKey => "Ini." + Key;
|
||||||
|
|
||||||
|
public string DescriptionKey => LabelKey + ".Desc";
|
||||||
|
|
||||||
|
public static bool ParseToggle(string raw) =>
|
||||||
|
FirstToken(raw).ToLowerInvariant() is "yes" or "true" or "1" or "tak";
|
||||||
|
|
||||||
|
public static string FormatToggle(bool value) => value ? "yes" : "no";
|
||||||
|
|
||||||
|
/// <summary>The key's value in <paramref name="s"/>, in the file's words: what is set, or else the default.</summary>
|
||||||
|
public string RawIn(SimulatorSettings s) => s.Extra.TryGetValue(Key, out string? raw) ? raw : Default;
|
||||||
|
|
||||||
|
public double ParseNumber(string raw) =>
|
||||||
|
double.TryParse(FirstToken(raw), NumberStyles.Float, CultureInfo.InvariantCulture, out double value)
|
||||||
|
? value
|
||||||
|
: double.Parse(Default, CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shake has a factor per axis; the row moves them together, so a number is
|
||||||
|
/// written as many times as the key takes it.
|
||||||
|
/// </summary>
|
||||||
|
public string FormatNumber(double value)
|
||||||
|
{
|
||||||
|
string one = value.ToString("0.####", CultureInfo.InvariantCulture);
|
||||||
|
return Repeat <= 1 ? one : string.Join(' ', Enumerable.Repeat(one, Repeat));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FirstToken(string raw)
|
||||||
|
{
|
||||||
|
var tokens = raw.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
return tokens.Length > 0 ? tokens[0] : string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class SimulatorKeys
|
||||||
|
{
|
||||||
|
public const string General = "general";
|
||||||
|
public const string Graphics = "graphics";
|
||||||
|
public const string Sound = "sound";
|
||||||
|
public const string Physics = "physics";
|
||||||
|
public const string Advanced = "advanced";
|
||||||
|
|
||||||
|
private static SimulatorKey Toggle(string key, bool value, string category, string section) =>
|
||||||
|
new(key, SimulatorKeyKind.Toggle, SimulatorKey.FormatToggle(value), category, section);
|
||||||
|
|
||||||
|
private static SimulatorKey Number(string key, double value, string category, string section,
|
||||||
|
double min, double max, double step, string unit = "",
|
||||||
|
bool zeroIsOff = false, int repeat = 1) =>
|
||||||
|
new(key, SimulatorKeyKind.Number, value.ToString(CultureInfo.InvariantCulture), category, section,
|
||||||
|
min, max, step, unit, zeroIsOff, repeat);
|
||||||
|
|
||||||
|
private static SimulatorKey Text(string key, string category, string section) =>
|
||||||
|
new(key, SimulatorKeyKind.Text, string.Empty, category, section);
|
||||||
|
|
||||||
|
public static SimulatorKey Named(string key) => All.First(k => k.Key == key);
|
||||||
|
|
||||||
|
public static IReadOnlyList<SimulatorKey> All { get; } = new[]
|
||||||
|
{
|
||||||
|
Number("ui.scale", 1, General, "IniSectionInterface", 0.5, 3, 0.05, "×"),
|
||||||
|
Number("ui.fontsize", 13, General, "IniSectionInterface", 8, 32, 1, " px"),
|
||||||
|
Number("ui.bg.opacity", 0.94, General, "IniSectionInterface", 0.1, 1, 0.05),
|
||||||
|
Toggle("gui.defaultwindows", true, General, "IniSectionInterface"),
|
||||||
|
Toggle("gui.trainingdefault", false, General, "IniSectionInterface"),
|
||||||
|
Toggle("gui.showtranscripts", true, General, "IniSectionInterface"),
|
||||||
|
Toggle("loadinglog", false, General, "IniSectionInterface"),
|
||||||
|
Toggle("captureonstart", true, General, "IniSectionInterface"),
|
||||||
|
Toggle("mousecontrol", true, General, "IniSectionInterface"),
|
||||||
|
Text("fullscreenmonitor", General, "IniSectionInterface"),
|
||||||
|
Text("screenshotsdir", General, "IniSectionInterface"),
|
||||||
|
|
||||||
|
Number("basedrawrange", 2500, Graphics, "IniSectionRanges", 500, 10000, 250, " m"),
|
||||||
|
Number("heightbase", 1, Graphics, "IniSectionRanges", 0.5, 3, 0.05, "×"),
|
||||||
|
Number("targetfps", 0, Graphics, "IniSectionRanges", 0, 240, 5, " FPS", zeroIsOff: true),
|
||||||
|
Number("sleeperdistance", 250, Graphics, "IniSectionRanges", 0, 5000, 50, " m", zeroIsOff: true),
|
||||||
|
Number("gfx.reflections.range_instances", 750, Graphics, "IniSectionRanges", 0, 3000, 50, " m"),
|
||||||
|
Number("gfx.reflections.range_vehicles", 250, Graphics, "IniSectionRanges", 0, 2000, 50, " m"),
|
||||||
|
Toggle("gfx.textures.streaming", false, Graphics, "IniSectionRanges"),
|
||||||
|
Number("gfx.textures.releasedistance", 0, Graphics, "IniSectionRanges", 0, 10, 0.5, "×", zeroIsOff: true),
|
||||||
|
Toggle("skyenabled", true, Graphics, "IniSectionRanges"),
|
||||||
|
Toggle("rendercab", true, Graphics, "IniSectionRanges"),
|
||||||
|
Number("gfx.postfx.motionblur.shutter", 0.01, Graphics, "IniSectionRanges", 0.001, 0.1, 0.001, " s"),
|
||||||
|
Number("python.fpslimit", 100, Graphics, "IniSectionRanges", 5, 200, 5, " FPS"),
|
||||||
|
Toggle("python.vsync", true, Graphics, "IniSectionRanges"),
|
||||||
|
Toggle("python.displaywindows", false, Graphics, "IniSectionRanges"),
|
||||||
|
Toggle("newaircouplers", true, Graphics, "IniSectionRanges"),
|
||||||
|
Toggle("smoothtraction", true, Graphics, "IniSectionRanges"),
|
||||||
|
|
||||||
|
Number("sound.maxsources", 30, Sound, "IniSectionAudio", 8, 256, 8),
|
||||||
|
Toggle("sound.asyncload", false, Sound, "IniSectionAudio"),
|
||||||
|
|
||||||
|
Toggle("loadtraction", true, Physics, "IniSectionPhysics"),
|
||||||
|
Toggle("rollfix", true, Physics, "IniSectionPhysics"),
|
||||||
|
Number("shakefactor", 1, Physics, "IniSectionPhysics", 0, 3, 0.1, "×", repeat: 3),
|
||||||
|
Toggle("map.manualswitchcontrol", false, Physics, "IniSectionPhysics"),
|
||||||
|
Number("map.highlightdistance", 3000, Physics, "IniSectionPhysics", 0, 10000, 250, " m", zeroIsOff: true),
|
||||||
|
|
||||||
|
Toggle("showsystemconsole", false, Advanced, "IniSectionConsole"),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,16 +1,23 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace StarterNG.Domain.Settings;
|
namespace StarterNG.Domain.Settings;
|
||||||
|
|
||||||
// Values match the key eu07.ini has carried since the old starter.
|
// Values match the key eu07.ini has carried since the old starter.
|
||||||
public enum ConsistReadyOverride { Scenery = 0, AlwaysCold = 1, AlwaysReady = 2 }
|
public enum ConsistReadyOverride { Scenery = 0, AlwaysCold = 1, AlwaysReady = 2 }
|
||||||
|
|
||||||
public enum ExeProblem { None, NotFound, NotExecutable, WrongPlatform }
|
public enum ExeProblem { None, NotFound, NotExecutable, WrongPlatform, NotAProgram }
|
||||||
|
|
||||||
public sealed class SimulatorSettings
|
public sealed class SimulatorSettings
|
||||||
{
|
{
|
||||||
public bool LanguageWasSet { get; internal set; }
|
public bool LanguageWasSet { get; internal set; }
|
||||||
|
|
||||||
|
/// <summary>eu07.ini starts a network.server, which the starter leaves to the file.</summary>
|
||||||
|
public bool NetworkServer { get; internal set; }
|
||||||
|
|
||||||
|
/// <summary>fpsaverage: the older regulator's frame rate target, which the starter leaves as it is.</summary>
|
||||||
|
public double FpsAverage { get; internal set; }
|
||||||
|
|
||||||
public string Language = "English";
|
public string Language = "English";
|
||||||
|
|
||||||
public string LanguageCode = "";
|
public string LanguageCode = "";
|
||||||
@@ -26,16 +33,20 @@ public sealed class SimulatorSettings
|
|||||||
public int FeedbackPort = 888;
|
public int FeedbackPort = 888;
|
||||||
public bool UartEnabled;
|
public bool UartEnabled;
|
||||||
public string UartPort = "";
|
public string UartPort = "";
|
||||||
|
public int UartBaud = Uart.DefaultBaud;
|
||||||
|
public double UartUpdateTime = Uart.DefaultUpdateTime;
|
||||||
public string UartTune = "";
|
public string UartTune = "";
|
||||||
public bool UartDebug;
|
public bool UartDebug;
|
||||||
public bool UartMain, UartScnd, UartTrain, UartLocal, UartRadioVolume, UartRadioChannel;
|
public bool UartMain, UartScnd, UartTrain, UartLocal, UartRadioVolume, UartRadioChannel;
|
||||||
|
|
||||||
|
/// <summary>The simulator's dynamicbrake feature, which has no box in the starter: kept as the file has it.</summary>
|
||||||
|
public bool UartDynamicBrake;
|
||||||
|
|
||||||
public bool SelectExeAutomatically = true;
|
public bool SelectExeAutomatically = true;
|
||||||
public string ExecutablePath = "eu07.exe";
|
public string ExecutablePath = "eu07.exe";
|
||||||
public bool DebugMode;
|
public bool DebugMode;
|
||||||
public bool VirtualShunting = true;
|
public bool VirtualShunting = true;
|
||||||
public bool LogMissingVehicleFiles;
|
public bool LogMissingVehicleFiles;
|
||||||
public bool DeveloperTools;
|
|
||||||
|
|
||||||
public int RenderEngine;
|
public int RenderEngine;
|
||||||
public int Width = 1280;
|
public int Width = 1280;
|
||||||
@@ -43,7 +54,7 @@ public sealed class SimulatorSettings
|
|||||||
public int BufferScalePercent = 100;
|
public int BufferScalePercent = 100;
|
||||||
public int MaxTextureSize = 4096;
|
public int MaxTextureSize = 4096;
|
||||||
public int MaxCabTextureSize = 4096;
|
public int MaxCabTextureSize = 4096;
|
||||||
public int TextureFiltering = 8;
|
public double TextureFiltering = 8;
|
||||||
public int Multisampling = 2;
|
public int Multisampling = 2;
|
||||||
public int DynamicLights = 7;
|
public int DynamicLights = 7;
|
||||||
public double DrawRangeFactor = 3.0;
|
public double DrawRangeFactor = 3.0;
|
||||||
@@ -56,8 +67,10 @@ public sealed class SimulatorSettings
|
|||||||
public bool EnvMap = true;
|
public bool EnvMap = true;
|
||||||
public bool UseVbo = true;
|
public bool UseVbo = true;
|
||||||
public bool RenderShadows = true;
|
public bool RenderShadows = true;
|
||||||
public int ReflectionsFramerate = 60;
|
/// <summary>Updates of the reflections a second; the simulator's own is one in five minutes.</summary>
|
||||||
|
public double ReflectionsFramerate = 1 / 300.0;
|
||||||
public int ShadowMapResolution = 2048;
|
public int ShadowMapResolution = 2048;
|
||||||
|
/// <summary>The third number of shadowtune; the second and fourth go unread.</summary>
|
||||||
public int ShadowProjectionRange = 250;
|
public int ShadowProjectionRange = 250;
|
||||||
public int CabShadowsRange;
|
public int CabShadowsRange;
|
||||||
public int ShadowRankCutoff = 3;
|
public int ShadowRankCutoff = 3;
|
||||||
@@ -66,7 +79,7 @@ public sealed class SimulatorSettings
|
|||||||
public bool PythonScreens = true;
|
public bool PythonScreens = true;
|
||||||
public bool PythonThreadedUpload = true;
|
public bool PythonThreadedUpload = true;
|
||||||
public bool FpsLimitEnabled;
|
public bool FpsLimitEnabled;
|
||||||
public int FpsLimit = 30;
|
public double FpsLimit = 30;
|
||||||
public double ShadowAngleLimit = 0.2;
|
public double ShadowAngleLimit = 0.2;
|
||||||
public bool FullscreenWindowed;
|
public bool FullscreenWindowed;
|
||||||
public bool RenderAngleVulkan;
|
public bool RenderAngleVulkan;
|
||||||
@@ -110,6 +123,12 @@ public sealed class SimulatorSettings
|
|||||||
public bool LargeThumbnails;
|
public bool LargeThumbnails;
|
||||||
public bool AutoExpandSceneryTree;
|
public bool AutoExpandSceneryTree;
|
||||||
|
|
||||||
|
/// <summary>Warn at start when the processor cannot run the official simulator builds.</summary>
|
||||||
|
public bool CpuNotice = true;
|
||||||
|
|
||||||
|
/// <summary>The card a native Linux simulator is started on, on a machine with two.</summary>
|
||||||
|
public GpuPreference Gpu = GpuPreference.Discrete;
|
||||||
|
|
||||||
public ConsistReadyOverride ReadyOverride = ConsistReadyOverride.Scenery;
|
public ConsistReadyOverride ReadyOverride = ConsistReadyOverride.Scenery;
|
||||||
|
|
||||||
public bool ShowAiVehicles = true;
|
public bool ShowAiVehicles = true;
|
||||||
@@ -120,12 +139,62 @@ public sealed class SimulatorSettings
|
|||||||
|
|
||||||
public bool WindowMaximized;
|
public bool WindowMaximized;
|
||||||
|
|
||||||
public static readonly string[] RenderEngines =
|
/// <summary>
|
||||||
{ "full", "legacy", "simpleshader", "simple", "off", "experimental" };
|
/// The <see cref="SimulatorKeys"/> as eu07.ini carries them, by key. One the
|
||||||
|
/// file does not set is absent, and stays out of the file until changed.
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, string> Extra { get; private set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
public static readonly int[] AnisotropySteps = { 1, 2, 4, 8, 16 };
|
/// <summary>A copy to try changes on, apart from these settings.</summary>
|
||||||
|
public SimulatorSettings Copy()
|
||||||
|
{
|
||||||
|
var copy = (SimulatorSettings)MemberwiseClone();
|
||||||
|
copy.Extra = new Dictionary<string, string>(Extra, StringComparer.OrdinalIgnoreCase);
|
||||||
|
copy.ShadowTuneUnused = (string[])ShadowTuneUnused.Clone();
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The gfxrenderer words the simulator tells apart (application.cpp): every
|
||||||
|
/// other word - the "simpleshader" and "off" older starters offered among them -
|
||||||
|
/// gets the legacy renderer.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly string[] RenderEngines = { "full", "legacy", "simple", "experimental" };
|
||||||
|
|
||||||
|
/// <summary>Where <paramref name="word"/> stands among <see cref="RenderEngines"/>, as the simulator reads it.</summary>
|
||||||
|
public static int RenderEngineOf(string word) => word.Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"full" or "default" => 0,
|
||||||
|
"simple" => 2,
|
||||||
|
"experimental" => 3,
|
||||||
|
_ => 1
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The least shadow range the simulator keeps for a map size: a map above
|
||||||
|
/// 2048 px stretches over proportionally more ground.
|
||||||
|
/// </summary>
|
||||||
|
public static int ShadowRangeFloor(int mapSize) => mapSize <= 2048 ? 75 : 75 * mapSize / 2048;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How far shadows actually reach. The full renderer's cascades stretch with a
|
||||||
|
/// map above 2048 px (opengl33renderer.cpp), on top of the floor; the legacy
|
||||||
|
/// one draws a single map out to the range itself.
|
||||||
|
/// </summary>
|
||||||
|
public static int ShadowReach(int range, int mapSize, bool legacyRenderer)
|
||||||
|
{
|
||||||
|
int reach = Math.Max(range, ShadowRangeFloor(mapSize));
|
||||||
|
return legacyRenderer || mapSize <= 2048 ? reach : reach * (mapSize / 2048);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The file's own first mousescale number. The slider takes whole steps: this
|
||||||
|
/// stands for as long as <see cref="CursorSensitivity"/> is the step nearest it.
|
||||||
|
/// </summary>
|
||||||
|
internal double MouseScaleXMagnitude { get; set; } = 2;
|
||||||
|
|
||||||
internal double MouseScaleYMagnitude { get; set; } = 0.2;
|
internal double MouseScaleYMagnitude { get; set; } = 0.2;
|
||||||
|
|
||||||
internal string[] ShadowTuneExtra { get; set; } = { "400", "300" };
|
/// <summary>The numbers of shadowtune the simulator skips, kept as the file has them.</summary>
|
||||||
|
internal string[] ShadowTuneUnused { get; set; } = { "250", "300" };
|
||||||
}
|
}
|
||||||
|
|||||||
59
StarterNG/Domain/Settings/Steps.cs
Normal file
59
StarterNG/Domain/Settings/Steps.cs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace StarterNG.Domain.Settings;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Values a setting is chosen from, for a number the simulator takes any value
|
||||||
|
/// of but only some of which are worth offering.
|
||||||
|
/// </summary>
|
||||||
|
public static class Steps
|
||||||
|
{
|
||||||
|
/// <summary>fpslimit: none, then from a laptop on battery up to the fastest monitors.</summary>
|
||||||
|
public static readonly double[] FrameRateLimits =
|
||||||
|
{ 0, 20, 25, 30, 40, 45, 50, 60, 72, 75, 90, 100, 120, 144, 165, 180, 200, 240, 360 };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// gfx.reflections.framerate, in updates a second: from the simulator's own
|
||||||
|
/// once in five minutes up to every frame.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly double[] ReflectionUpdates =
|
||||||
|
{ 1 / 300.0, 1 / 60.0, 1 / 30.0, 0.1, 0.2, 0.5, 1, 2, 5, 10, 30, 60 };
|
||||||
|
|
||||||
|
/// <summary>gfx.drawrange.factor.max: how far the regulator may stretch the draw range.</summary>
|
||||||
|
public static readonly double[] DrawRangeFactors = { 1, 1.5, 2, 2.5, 3 };
|
||||||
|
|
||||||
|
/// <summary>anisotropicfiltering: 16 is the most any card offers.</summary>
|
||||||
|
public static readonly double[] Anisotropy = { 1, 2, 4, 8, 16 };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// multisampling, as the power of two of the samples: none to x8. The simulator
|
||||||
|
/// takes 4 (x16) too, which only some cards can do - on one that cannot, the
|
||||||
|
/// frame buffer fails to build - so it stays in the file but off the slider.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly double[] MultisamplingLevels = { 0, 1, 2, 3 };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <paramref name="steps"/> with <paramref name="value"/> among them: one
|
||||||
|
/// eu07.ini has that is none of the steps stays what it is.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<double> With(IReadOnlyList<double> steps, double value)
|
||||||
|
{
|
||||||
|
if (IndexOf(steps, value) >= 0 || !double.IsFinite(value))
|
||||||
|
return steps;
|
||||||
|
|
||||||
|
var list = new List<double>(steps);
|
||||||
|
int at = list.FindIndex(step => step > value);
|
||||||
|
list.Insert(at < 0 ? list.Count : at, value);
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The step that is <paramref name="value"/>, give or take its rounding in the file.</summary>
|
||||||
|
public static int IndexOf(IReadOnlyList<double> steps, double value)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < steps.Count; i++)
|
||||||
|
if (Math.Abs(steps[i] - value) <= Math.Abs(steps[i]) * 2e-4)
|
||||||
|
return i;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
115
StarterNG/Domain/Settings/Uart.cs
Normal file
115
StarterNG/Domain/Settings/Uart.cs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace StarterNG.Domain.Settings;
|
||||||
|
|
||||||
|
/// <summary>What the simulator takes after its uart keys.</summary>
|
||||||
|
public static class Uart
|
||||||
|
{
|
||||||
|
/// <summary>The speeds the simulator offers in its own UART panel.</summary>
|
||||||
|
public static readonly int[] BaudRates =
|
||||||
|
{ 300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 74880, 115200, 230400, 250000, 500000, 1000000, 2000000 };
|
||||||
|
|
||||||
|
// The simulator has none of its own: "uart" must name all three.
|
||||||
|
public const int DefaultBaud = 57600;
|
||||||
|
public const double DefaultUpdateTime = 0.1;
|
||||||
|
|
||||||
|
public const int TuneValues = 18;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The calibration as the simulator reads it - eighteen numbers - or null.
|
||||||
|
/// With fewer, it takes the next keys of the file in their place.
|
||||||
|
/// </summary>
|
||||||
|
public static string? NormalizeTune(string? text)
|
||||||
|
{
|
||||||
|
var values = (text ?? "").Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
return values.Length == TuneValues && values.All(IsNumber)
|
||||||
|
? string.Join(' ', values)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The file's uarttune line as a calibration to show. With a word in it, it holds
|
||||||
|
/// no calibration at all but the next keys of the file, where the line was left short.
|
||||||
|
/// </summary>
|
||||||
|
public static string TuneInFile(string? text) =>
|
||||||
|
(text ?? "").Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).All(IsNumber) ? text ?? "" : "";
|
||||||
|
|
||||||
|
private static bool IsNumber(string word) =>
|
||||||
|
double.TryParse(word, NumberStyles.Float, CultureInfo.InvariantCulture, out _);
|
||||||
|
|
||||||
|
/// <summary>The simulator reads the port as one word.</summary>
|
||||||
|
public static bool IsPortName(string? port) =>
|
||||||
|
!string.IsNullOrWhiteSpace(port) && !port.Trim().Any(char.IsWhiteSpace);
|
||||||
|
|
||||||
|
/// <summary>The interval as its box can hold it, whatever the file says.</summary>
|
||||||
|
public static decimal ShownUpdateTime(double seconds) =>
|
||||||
|
double.IsNaN(seconds) ? (decimal)DefaultUpdateTime : (decimal)Math.Clamp(seconds, 0, MaxShownUpdateTime);
|
||||||
|
|
||||||
|
public const double MaxShownUpdateTime = 10;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// uartfeature with nothing selected. Every build of the simulator reads the four
|
||||||
|
/// words, only newer ones "none"; the features they do not name are off by default.
|
||||||
|
/// </summary>
|
||||||
|
public const string NoFeatures = "no no no no";
|
||||||
|
|
||||||
|
// In the order the older form's four words go by.
|
||||||
|
private static readonly string[] FeatureNames =
|
||||||
|
{ "main", "scnd", "train", "local", "radiovolume", "radiochannel", "dynamicbrake" };
|
||||||
|
|
||||||
|
private const int OldFormWords = 4;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The features a uartfeature value has on, as Globals.cpp reads it: a first word
|
||||||
|
/// with a pipe, "none" or a feature's name is a list; anything else is the older
|
||||||
|
/// form, four yes-or-no words for the first four features. Bare, nothing is on.
|
||||||
|
/// </summary>
|
||||||
|
public static ISet<string> FeaturesOn(string? value)
|
||||||
|
{
|
||||||
|
var words = (value ?? "").Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
var on = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
if (words.Length == 0)
|
||||||
|
return on;
|
||||||
|
|
||||||
|
if (IsFeatureList(words[0]))
|
||||||
|
{
|
||||||
|
foreach (string name in words[0].Split('|', StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
if (FeatureNames.Contains(name, StringComparer.OrdinalIgnoreCase))
|
||||||
|
on.Add(name);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
for (int i = 0; i < Math.Min(OldFormWords, words.Length); i++)
|
||||||
|
if (words[i].ToLowerInvariant() is "yes" or "true" or "1")
|
||||||
|
on.Add(FeatureNames[i]);
|
||||||
|
}
|
||||||
|
return on;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the file's own uartfeature line can stay in place of the one that would
|
||||||
|
/// be written: it means the same, and every simulator reads it whole. The older
|
||||||
|
/// form short of its words takes the next keys in; an empty list is what
|
||||||
|
/// <see cref="NoFeatures"/> is for.
|
||||||
|
/// </summary>
|
||||||
|
public static bool FeaturesStand(string? inFile, string? written)
|
||||||
|
{
|
||||||
|
if (written is null || string.IsNullOrWhiteSpace(inFile))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var on = FeaturesOn(inFile);
|
||||||
|
if (!on.SetEquals(FeaturesOn(written)))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var words = inFile.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
return IsFeatureList(words[0]) ? on.Count > 0 : words.Length >= OldFormWords;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsFeatureList(string word) =>
|
||||||
|
word.Contains('|') ||
|
||||||
|
word.Equals("none", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
FeatureNames.Contains(word, StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
18
StarterNG/Domain/SimulatorNames.cs
Normal file
18
StarterNG/Domain/SimulatorNames.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
namespace StarterNG.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// File names as the simulator makes them: its parser folds every word it reads
|
||||||
|
/// to lower case - ASCII letters only - the files a scenery includes and the
|
||||||
|
/// scenario given with <c>-s</c> among them, and opens the file by that name.
|
||||||
|
/// Where letter case counts, as on Linux, a file is found only when it is named
|
||||||
|
/// so on disk.
|
||||||
|
/// </summary>
|
||||||
|
public static class SimulatorNames
|
||||||
|
{
|
||||||
|
public static string Lower(string name) =>
|
||||||
|
string.Create(name.Length, name, static (folded, source) =>
|
||||||
|
{
|
||||||
|
for (int i = 0; i < source.Length; i++)
|
||||||
|
folded[i] = source[i] is >= 'A' and <= 'Z' ? (char)(source[i] + ('a' - 'A')) : source[i];
|
||||||
|
});
|
||||||
|
}
|
||||||
129
StarterNG/Domain/Tapes/HaslerTape.cs
Normal file
129
StarterNG/Domain/Tapes/HaslerTape.cs
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace StarterNG.Domain.Tapes;
|
||||||
|
|
||||||
|
/// <summary>One line of the simulator's physics log, in the units a speed recorder shows.</summary>
|
||||||
|
public readonly record struct TapeSample(
|
||||||
|
double Time,
|
||||||
|
double SpeedKmh,
|
||||||
|
double DistanceM,
|
||||||
|
double BrakePressBar,
|
||||||
|
double PipePressBar,
|
||||||
|
double MotorCurrentA,
|
||||||
|
bool SecurityAlarm)
|
||||||
|
{
|
||||||
|
/// <summary>Above this cylinder pressure the sample counts as braking.</summary>
|
||||||
|
public const double BrakeMarkPressBar = 0.4;
|
||||||
|
|
||||||
|
public bool Braking => BrakePressBar > BrakeMarkPressBar;
|
||||||
|
|
||||||
|
public bool Current => MotorCurrentA != 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A trip recorded by the simulator with "physicslog yes": physicslog/<vehicle>.dat,
|
||||||
|
/// a line every 0.05 s of simulated time, read as a Hasler speed recorder tape.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class HaslerTape
|
||||||
|
{
|
||||||
|
// Columns counted from the start; the security flag is counted from the end, past
|
||||||
|
// the command, which is free text. The header says m/s and MPa, the values are km/h and bar.
|
||||||
|
private const int TimeColumn = 0;
|
||||||
|
private const int VelocityColumn = 1;
|
||||||
|
private const int BrakePressColumn = 8;
|
||||||
|
private const int PipePressColumn = 9;
|
||||||
|
private const int MotorCurrentColumn = 10;
|
||||||
|
private const int SecurityFromEnd = 6;
|
||||||
|
private const int MinimumColumns = 17;
|
||||||
|
|
||||||
|
public HaslerTape(IReadOnlyList<TapeSample> samples)
|
||||||
|
{
|
||||||
|
Samples = samples;
|
||||||
|
foreach (var s in samples)
|
||||||
|
MaxSpeedKmh = Math.Max(MaxSpeedKmh, s.SpeedKmh);
|
||||||
|
for (int i = 1; i < samples.Count; i++)
|
||||||
|
if (samples[i].SpeedKmh > 0.5)
|
||||||
|
RunningTime += samples[i].Time - samples[i - 1].Time;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<TapeSample> Samples { get; }
|
||||||
|
|
||||||
|
public bool IsEmpty => Samples.Count == 0;
|
||||||
|
|
||||||
|
public double StartTime => IsEmpty ? 0 : Samples[0].Time;
|
||||||
|
|
||||||
|
public double Duration => IsEmpty ? 0 : Samples[^1].Time - Samples[0].Time;
|
||||||
|
|
||||||
|
public double DistanceM => IsEmpty ? 0 : Samples[^1].DistanceM;
|
||||||
|
|
||||||
|
public double MaxSpeedKmh { get; }
|
||||||
|
|
||||||
|
/// <summary>Seconds on the move; the rest of <see cref="Duration"/> was spent standing.</summary>
|
||||||
|
public double RunningTime { get; }
|
||||||
|
|
||||||
|
public double StandingTime => Duration - RunningTime;
|
||||||
|
|
||||||
|
/// <summary>Mean speed while moving.</summary>
|
||||||
|
public double AverageSpeedKmh => RunningTime > 0 ? DistanceM / RunningTime * 3.6 : 0;
|
||||||
|
|
||||||
|
/// <summary>Mean speed over the whole recording, stops included - the commercial speed.</summary>
|
||||||
|
public double AverageWithStopsKmh => Duration > 0 ? DistanceM / Duration * 3.6 : 0;
|
||||||
|
|
||||||
|
public static HaslerTape Parse(string text)
|
||||||
|
{
|
||||||
|
var samples = new List<TapeSample>();
|
||||||
|
double distance = 0;
|
||||||
|
TapeSample? previous = null;
|
||||||
|
|
||||||
|
foreach (var raw in text.Split('\n'))
|
||||||
|
{
|
||||||
|
var columns = raw.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (columns.Length < MinimumColumns
|
||||||
|
|| !Number(columns[TimeColumn], out double time)
|
||||||
|
|| !Number(columns[VelocityColumn], out double velocity))
|
||||||
|
continue; // the header, or a line cut short by a simulator still writing
|
||||||
|
|
||||||
|
Number(columns[BrakePressColumn], out double brake);
|
||||||
|
Number(columns[PipePressColumn], out double pipe);
|
||||||
|
Number(columns[MotorCurrentColumn], out double current);
|
||||||
|
bool security = columns[^SecurityFromEnd] == "1";
|
||||||
|
|
||||||
|
double speed = Math.Abs(velocity);
|
||||||
|
if (previous is { } p)
|
||||||
|
{
|
||||||
|
if (time < p.Time)
|
||||||
|
continue;
|
||||||
|
distance += (p.SpeedKmh + speed) / 2 / 3.6 * (time - p.Time);
|
||||||
|
}
|
||||||
|
|
||||||
|
var sample = new TapeSample(time, speed, distance, brake, pipe, current, security);
|
||||||
|
samples.Add(sample);
|
||||||
|
previous = sample;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HaslerTape(samples);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Index of the last sample at or before the given time or distance.</summary>
|
||||||
|
public int IndexAt(double position, bool byDistance)
|
||||||
|
{
|
||||||
|
int lo = 0, hi = Samples.Count - 1;
|
||||||
|
if (hi < 0)
|
||||||
|
return -1;
|
||||||
|
while (lo < hi)
|
||||||
|
{
|
||||||
|
int mid = (lo + hi + 1) / 2;
|
||||||
|
double value = byDistance ? Samples[mid].DistanceM : Samples[mid].Time;
|
||||||
|
if (value <= position)
|
||||||
|
lo = mid;
|
||||||
|
else
|
||||||
|
hi = mid - 1;
|
||||||
|
}
|
||||||
|
return lo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Number(string text, out double value) =>
|
||||||
|
double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out value);
|
||||||
|
}
|
||||||
56
StarterNG/Domain/Tapes/PlayerVehicles.cs
Normal file
56
StarterNG/Domain/Tapes/PlayerVehicles.cs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace StarterNG.Domain.Tapes;
|
||||||
|
|
||||||
|
/// <summary>The vehicles the player's commands went to during a run, the one used last at the end.</summary>
|
||||||
|
public sealed class PlayerVehicles
|
||||||
|
{
|
||||||
|
private const string CommandMark = " received command: [";
|
||||||
|
|
||||||
|
/// <summary>No log of the run to go by, so a change of vehicle cannot be seen.</summary>
|
||||||
|
public static readonly PlayerVehicles Unknown = new(Array.Empty<string>(), known: false);
|
||||||
|
|
||||||
|
/// <summary>A log with no command in it yet.</summary>
|
||||||
|
public static readonly PlayerVehicles None = new(Array.Empty<string>(), known: true);
|
||||||
|
|
||||||
|
private readonly IReadOnlyList<string> _vehicles;
|
||||||
|
|
||||||
|
private PlayerVehicles(IReadOnlyList<string> vehicles, bool known)
|
||||||
|
{
|
||||||
|
_vehicles = vehicles;
|
||||||
|
Known = known;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Known { get; }
|
||||||
|
|
||||||
|
public IReadOnlyList<string> Names => _vehicles;
|
||||||
|
|
||||||
|
public string? Current => _vehicles.Count > 0 ? _vehicles[^1] : null;
|
||||||
|
|
||||||
|
public static PlayerVehicles Of(IEnumerable<string> names) => new(names.ToList(), known: true);
|
||||||
|
|
||||||
|
public bool Used(string vehicle) => _vehicles.Contains(vehicle, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>What a further part of the simulator's log, in whole lines, adds to this.</summary>
|
||||||
|
public PlayerVehicles After(string log)
|
||||||
|
{
|
||||||
|
var vehicles = _vehicles.ToList();
|
||||||
|
foreach (string line in log.Split('\n'))
|
||||||
|
{
|
||||||
|
int mark = line.IndexOf(CommandMark, StringComparison.Ordinal);
|
||||||
|
if (mark < 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
int stamp = line.IndexOf(']');
|
||||||
|
string vehicle = line[(stamp < mark ? stamp + 1 : 0)..mark].Trim();
|
||||||
|
if (vehicle.Length == 0 || string.Equals(vehicles.LastOrDefault(), vehicle, StringComparison.OrdinalIgnoreCase))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
vehicles.RemoveAll(v => string.Equals(v, vehicle, StringComparison.OrdinalIgnoreCase));
|
||||||
|
vehicles.Add(vehicle);
|
||||||
|
}
|
||||||
|
return new PlayerVehicles(vehicles, known: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
StarterNG/Domain/Tapes/TapeFigures.cs
Normal file
53
StarterNG/Domain/Tapes/TapeFigures.cs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace StarterNG.Domain.Tapes;
|
||||||
|
|
||||||
|
/// <summary>The figures a tape is printed and read with, which no drawing is needed to work out.</summary>
|
||||||
|
public static class TapeFigures
|
||||||
|
{
|
||||||
|
private static readonly double[] SpeedRanges = { 90, 100, 120, 150, 180 };
|
||||||
|
private static readonly double[] SpeedSteps = { 10, 20, 50, 100, 200 };
|
||||||
|
private static readonly double[] MinuteSteps = { 1, 2, 5, 10, 15, 30, 60 };
|
||||||
|
private static readonly double[] KmSteps = { 0.1, 0.2, 0.5, 1, 2, 5, 10, 20 };
|
||||||
|
|
||||||
|
/// <summary>The scale is printed for the speedometer's range: 90, 100, 120, 150 or 180 km/h.</summary>
|
||||||
|
public static double TopSpeed(double maxKmh)
|
||||||
|
{
|
||||||
|
foreach (double range in SpeedRanges)
|
||||||
|
if (maxKmh <= range)
|
||||||
|
return range;
|
||||||
|
return Math.Ceiling(maxKmh / 20) * 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A round number of km/h between value lines, far enough apart for their figures to stay readable.</summary>
|
||||||
|
public static int SpeedStep(double pxPerKmh) => (int)FirstThatFits(SpeedSteps, pxPerKmh, 8, 500);
|
||||||
|
|
||||||
|
/// <summary>A round number of minutes between clock lines, about every 60 pixels or more.</summary>
|
||||||
|
public static double MinuteStep(double pxPerMinute) => FirstThatFits(MinuteSteps, pxPerMinute, 60, 120);
|
||||||
|
|
||||||
|
/// <summary>A round number of kilometres between distance marks, 50 pixels apart or more.</summary>
|
||||||
|
public static double KmStep(double pxPerKm) => FirstThatFits(KmSteps, pxPerKm, 50, 50);
|
||||||
|
|
||||||
|
private static double FirstThatFits(double[] steps, double pxPerUnit, double minPx, double otherwise)
|
||||||
|
{
|
||||||
|
foreach (double step in steps)
|
||||||
|
if (step * pxPerUnit >= minPx)
|
||||||
|
return step;
|
||||||
|
return otherwise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Hours only when there are any: 7:05, 1:02:03. Below zero counts as zero.</summary>
|
||||||
|
public static string Clock(double seconds)
|
||||||
|
{
|
||||||
|
var t = TimeSpan.FromSeconds(Math.Max(0, seconds));
|
||||||
|
return t.TotalHours >= 1 ? $"{(int)t.TotalHours}:{t.Minutes:00}:{t.Seconds:00}" : $"{t.Minutes}:{t.Seconds:00}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A time of day as typed; anything that is not one is midnight.</summary>
|
||||||
|
public static TimeSpan ParseClock(string? text) =>
|
||||||
|
TimeSpan.TryParseExact(text?.Trim(), new[] { @"h\:mm", @"hh\:mm", @"h\:mm\:ss" }, CultureInfo.InvariantCulture,
|
||||||
|
out var clock) && clock < TimeSpan.FromDays(1)
|
||||||
|
? clock
|
||||||
|
: TimeSpan.Zero;
|
||||||
|
}
|
||||||
178
StarterNG/Domain/Tapes/TapeLaunch.cs
Normal file
178
StarterNG/Domain/Tapes/TapeLaunch.cs
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace StarterNG.Domain.Tapes;
|
||||||
|
|
||||||
|
/// <summary>How a vehicle records its runs: a Hasler-type paper tape, or an electronic recorder read out on a screen.</summary>
|
||||||
|
public enum RecorderKind
|
||||||
|
{
|
||||||
|
Hasler,
|
||||||
|
Electronic
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A vehicle of a launched scenery, as the simulator names it in physicslog/.</summary>
|
||||||
|
public sealed record TapeVehicle(string Name, string DataFolder, string TypeName);
|
||||||
|
|
||||||
|
/// <summary>What a tape is to the one who started the simulator; in the order they are listed by.</summary>
|
||||||
|
public enum TapeRole
|
||||||
|
{
|
||||||
|
Other,
|
||||||
|
OwnConsist,
|
||||||
|
DrivenEarlier,
|
||||||
|
StartedIn,
|
||||||
|
Driven
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What the starter knew when it launched the simulator and the log does not
|
||||||
|
/// say: the time of day the scenery starts at - the log only counts seconds from
|
||||||
|
/// there - which vehicle stands behind each name the logs are written under,
|
||||||
|
/// which of them the player went off in: every manned vehicle leaves a log - the
|
||||||
|
/// scenario by its name and the number of the train the timetable gives. Once
|
||||||
|
/// the next launch is noted, also where the player has been.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record TapeLaunch(DateTime LaunchedUtc, string Scenery, TimeSpan StartClock,
|
||||||
|
IReadOnlyList<TapeVehicle> Vehicles, string? Driven = null,
|
||||||
|
IReadOnlyList<string>? Consist = null, string? SceneryName = null,
|
||||||
|
string? Train = null, PlayerVehicles? Player = null)
|
||||||
|
{
|
||||||
|
private static readonly Regex NextLaunch = new(@"(?m)^(?=launched\t)", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
public TapeVehicle? Find(string vehicleName) =>
|
||||||
|
Vehicles.FirstOrDefault(v => string.Equals(v.Name, vehicleName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
public TapeRole RoleOf(string vehicleName) =>
|
||||||
|
string.Equals(Driven, vehicleName, StringComparison.OrdinalIgnoreCase)
|
||||||
|
? TapeRole.Driven
|
||||||
|
: Consist?.Contains(vehicleName, StringComparer.OrdinalIgnoreCase) == true
|
||||||
|
? TapeRole.OwnConsist
|
||||||
|
: TapeRole.Other;
|
||||||
|
|
||||||
|
/// <summary>The role as the run went: the vehicle the player used last is the driven one, whichever they set off in.</summary>
|
||||||
|
public TapeRole RoleOf(string vehicleName, PlayerVehicles player)
|
||||||
|
{
|
||||||
|
var role = RoleOf(vehicleName);
|
||||||
|
if (!player.Known)
|
||||||
|
return role == TapeRole.Driven ? TapeRole.StartedIn : role;
|
||||||
|
if (player.Current is not { } current)
|
||||||
|
return role;
|
||||||
|
if (string.Equals(current, vehicleName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return TapeRole.Driven;
|
||||||
|
return role == TapeRole.Driven || player.Used(vehicleName) ? TapeRole.DrivenEarlier : role;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A log belongs to this launch if the simulator wrote it after the starter
|
||||||
|
/// started it; the simulator truncates the log of a vehicle on every run.
|
||||||
|
/// </summary>
|
||||||
|
public bool Covers(DateTime logWrittenUtc) => logWrittenUtc >= LaunchedUtc - TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
|
// One fact a line, tab separated: names and folders may hold spaces.
|
||||||
|
public string Format()
|
||||||
|
{
|
||||||
|
var text = new StringBuilder();
|
||||||
|
text.Append("launched\t").Append(LaunchedUtc.ToString("O", CultureInfo.InvariantCulture)).Append('\n');
|
||||||
|
text.Append("scenery\t").Append(Scenery).Append('\n');
|
||||||
|
text.Append("clock\t").Append(StartClock.ToString(@"hh\:mm", CultureInfo.InvariantCulture)).Append('\n');
|
||||||
|
if (!string.IsNullOrWhiteSpace(SceneryName))
|
||||||
|
text.Append("name\t").Append(SceneryName).Append('\n');
|
||||||
|
if (!string.IsNullOrWhiteSpace(Train))
|
||||||
|
text.Append("train\t").Append(Train).Append('\n');
|
||||||
|
foreach (var v in Vehicles)
|
||||||
|
text.Append("vehicle\t").Append(v.Name).Append('\t').Append(v.DataFolder).Append('\t').Append(v.TypeName)
|
||||||
|
.Append('\n');
|
||||||
|
if (Driven is not null)
|
||||||
|
text.Append("driven\t").Append(Driven).Append('\n');
|
||||||
|
foreach (string name in Consist ?? Array.Empty<string>())
|
||||||
|
text.Append("consist\t").Append(name).Append('\n');
|
||||||
|
if (Player is { Known: true } player)
|
||||||
|
text.AppendJoin('\t', player.Names.Prepend("played")).Append('\n');
|
||||||
|
return text.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Every launch a file holds, oldest first.</summary>
|
||||||
|
public static IReadOnlyList<TapeLaunch> ParseAll(string text) =>
|
||||||
|
NextLaunch.Split(text).Select(Parse).OfType<TapeLaunch>().OrderBy(launch => launch.LaunchedUtc).ToList();
|
||||||
|
|
||||||
|
public static TapeLaunch? Parse(string text)
|
||||||
|
{
|
||||||
|
DateTime? launched = null;
|
||||||
|
string scenery = "";
|
||||||
|
var clock = TimeSpan.Zero;
|
||||||
|
var vehicles = new List<TapeVehicle>();
|
||||||
|
string? driven = null;
|
||||||
|
var consist = new List<string>();
|
||||||
|
string? name = null;
|
||||||
|
string? train = null;
|
||||||
|
PlayerVehicles? player = null;
|
||||||
|
foreach (var line in text.Split('\n'))
|
||||||
|
{
|
||||||
|
var parts = line.TrimEnd('\r').Split('\t');
|
||||||
|
switch (parts[0])
|
||||||
|
{
|
||||||
|
case "launched" when parts.Length > 1 &&
|
||||||
|
DateTime.TryParse(parts[1], CultureInfo.InvariantCulture,
|
||||||
|
DateTimeStyles.RoundtripKind, out var at):
|
||||||
|
launched = at.ToUniversalTime();
|
||||||
|
break;
|
||||||
|
case "scenery" when parts.Length > 1:
|
||||||
|
scenery = parts[1];
|
||||||
|
break;
|
||||||
|
case "clock" when parts.Length > 1:
|
||||||
|
TimeSpan.TryParseExact(parts[1], @"h\:mm", CultureInfo.InvariantCulture, out clock);
|
||||||
|
break;
|
||||||
|
case "vehicle" when parts.Length > 3:
|
||||||
|
vehicles.Add(new TapeVehicle(parts[1], parts[2], parts[3]));
|
||||||
|
break;
|
||||||
|
case "driven" when parts.Length > 1:
|
||||||
|
driven = parts[1];
|
||||||
|
break;
|
||||||
|
case "consist" when parts.Length > 1:
|
||||||
|
consist.Add(parts[1]);
|
||||||
|
break;
|
||||||
|
case "name" when parts.Length > 1:
|
||||||
|
name = parts[1];
|
||||||
|
break;
|
||||||
|
case "train" when parts.Length > 1:
|
||||||
|
train = parts[1];
|
||||||
|
break;
|
||||||
|
case "played":
|
||||||
|
player = PlayerVehicles.Of(parts.Skip(1));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return launched is { } when
|
||||||
|
? new TapeLaunch(when, scenery, clock, vehicles, driven, consist, name, train, player)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Recorders
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Vehicles with an electronic recorder in place of a Hasler, matched on the vehicle
|
||||||
|
/// folder and type, or failing that on the name the log is written under.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly Regex Electronic = new(
|
||||||
|
@"(?ix) (^|[^a-z0-9])
|
||||||
|
( en57a[lk][a-z]* | en71ak[a-z]* | en76 | en96 | en97 | ed74 | ed160 | ed161 | ed250 | ed78 | ed72a
|
||||||
|
| ep07p | eu07a | e6act | e6acti | e4dcu | e4mcu | e4msu | e6mcu | 111e
|
||||||
|
| x4e | traxx | vectron | griffin | dragon | husarz | elf | impuls | flirt
|
||||||
|
| pendolino | sa1[0-9][0-9] | 22we | 27we | 31we | 34we | 35we | 36we | 45we )
|
||||||
|
($|[^a-z0-9])",
|
||||||
|
RegexOptions.Compiled);
|
||||||
|
|
||||||
|
public static RecorderKind Guess(TapeVehicle? vehicle, string tapeName)
|
||||||
|
{
|
||||||
|
if (vehicle is not null)
|
||||||
|
return Electronic.IsMatch(vehicle.DataFolder.Replace('\\', '/').Split('/').Last())
|
||||||
|
|| Electronic.IsMatch(vehicle.TypeName)
|
||||||
|
? RecorderKind.Electronic
|
||||||
|
: RecorderKind.Hasler;
|
||||||
|
return Electronic.IsMatch(tapeName) ? RecorderKind.Electronic : RecorderKind.Hasler;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -99,18 +99,49 @@ public sealed class TexRandomizer
|
|||||||
|
|
||||||
var set = _db.ResolveSet(pick);
|
var set = _db.ResolveSet(pick);
|
||||||
var unit = set ?? new List<VehicleTexture> { pick };
|
var unit = set ?? new List<VehicleTexture> { pick };
|
||||||
consist[i] = new ConsistItem
|
var replacement = new ConsistItem
|
||||||
{
|
{
|
||||||
Cars = unit.Select(makeCar).ToList(),
|
Cars = unit.Select(makeCar).ToList(),
|
||||||
Grouped = unit.Count > 1,
|
Grouped = unit.Count > 1,
|
||||||
Driver = item.Driver,
|
|
||||||
Flipped = item.Flipped
|
Flipped = item.Flipped
|
||||||
};
|
};
|
||||||
|
CarryOver(item, replacement);
|
||||||
|
consist[i] = replacement;
|
||||||
|
if (ReferenceEquals(consist.Selected, item))
|
||||||
|
consist.Selected = replacement;
|
||||||
changed++;
|
changed++;
|
||||||
}
|
}
|
||||||
return changed;
|
return changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only the livery changes, but a new car has no crew, faces forward and is empty.
|
||||||
|
// The cars are what the export writes, so they are what is taken from; not through
|
||||||
|
// Consist.Replace, which would hand a driver to a locomotive left without one.
|
||||||
|
private static void CarryOver(ConsistItem from, ConsistItem to)
|
||||||
|
{
|
||||||
|
for (int k = 0; k < to.Cars.Count; k++)
|
||||||
|
{
|
||||||
|
var before = from.Cars[Math.Min(k, from.Cars.Count - 1)];
|
||||||
|
var car = to.Cars[k];
|
||||||
|
car.Offset = before.Offset;
|
||||||
|
car.LoadType = before.LoadType;
|
||||||
|
car.LoadCount = before.LoadCount;
|
||||||
|
car.MaxLoad = before.MaxLoad;
|
||||||
|
}
|
||||||
|
|
||||||
|
var crew = Consist.UnitDriver(from.Cars);
|
||||||
|
if (to.Cars.Count == from.Cars.Count)
|
||||||
|
{
|
||||||
|
for (int k = 0; k < to.Cars.Count; k++)
|
||||||
|
to.Cars[k].DriverType = from.Cars[k].DriverType;
|
||||||
|
}
|
||||||
|
else if (crew != eDriverType.Nobody)
|
||||||
|
{
|
||||||
|
Consist.CrewCar(to, crew).DriverType = crew;
|
||||||
|
}
|
||||||
|
to.Driver = crew;
|
||||||
|
}
|
||||||
|
|
||||||
private string? ResolveFilterMini(ConsistItem item, int index, Consist consist, VehicleTexture? current)
|
private string? ResolveFilterMini(ConsistItem item, int index, Consist consist, VehicleTexture? current)
|
||||||
{
|
{
|
||||||
if (current != null)
|
if (current != null)
|
||||||
|
|||||||
@@ -17,6 +17,27 @@ public static class TrainsetDisplay
|
|||||||
trainset.Vehicles.Count == 0 ||
|
trainset.Vehicles.Count == 0 ||
|
||||||
trainset.Vehicles.All(v => string.IsNullOrWhiteSpace(v.Name) && string.IsNullOrWhiteSpace(v.SkinFile));
|
trainset.Vehicles.All(v => string.IsNullOrWhiteSpace(v.Name) && string.IsNullOrWhiteSpace(v.SkinFile));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether "drivable only" lists the consist. The one picked and one with changes
|
||||||
|
/// not saved stay whatever their crew: emptying a consist must not take away the
|
||||||
|
/// row it is filled again or saved from.
|
||||||
|
/// </summary>
|
||||||
|
public static bool ListedAsDrivable(Trainset trainset, Trainset? current) =>
|
||||||
|
UData.StaffedTrain(trainset) || ReferenceEquals(trainset, current) || SceneryTrainsetText.IsModified(trainset);
|
||||||
|
|
||||||
|
/// <summary>A consist the simulator drives itself: its description starts with a dash.</summary>
|
||||||
|
public static bool IsAi(Trainset trainset) =>
|
||||||
|
!string.IsNullOrEmpty(trainset.Description) &&
|
||||||
|
trainset.Description.TrimStart().StartsWith("-", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>Whether a list of the scenery's consists shows it, under the two filters those lists have.</summary>
|
||||||
|
public static bool ListedIn(Trainset trainset, bool showAi, bool drivableOnly, Trainset? current) =>
|
||||||
|
(showAi || !IsAi(trainset)) && (!drivableOnly || ListedAsDrivable(trainset, current));
|
||||||
|
|
||||||
|
/// <summary>The consist's row in those lists: what it is made of, or the track of one with nothing on it.</summary>
|
||||||
|
public static string ListLabel(Trainset trainset) =>
|
||||||
|
CompositionText(trainset) ?? string.Format(App.Loc["EmptyTrainset"], trainset.Track ?? "");
|
||||||
|
|
||||||
public static string? CompositionText(Trainset trainset)
|
public static string? CompositionText(Trainset trainset)
|
||||||
{
|
{
|
||||||
if (IsEmpty(trainset))
|
if (IsEmpty(trainset))
|
||||||
|
|||||||
@@ -106,13 +106,7 @@ public static class UDepot
|
|||||||
d.SkinFile = Dynamic.StripSkinExtension(t[p++]);
|
d.SkinFile = Dynamic.StripSkinExtension(t[p++]);
|
||||||
d.MmdFile = t[p++];
|
d.MmdFile = t[p++];
|
||||||
d.Offset = float.Parse(t[p++], inv);
|
d.Offset = float.Parse(t[p++], inv);
|
||||||
d.DriverType = t[p++].ToLowerInvariant() switch
|
d.DriverType = Dynamic.ParseDriver(t[p++]);
|
||||||
{
|
|
||||||
"headdriver" => eDriverType.Headdriver,
|
|
||||||
"reardriver" => eDriverType.Reardriver,
|
|
||||||
"passenger" => eDriverType.Passenger,
|
|
||||||
_ => eDriverType.Nobody
|
|
||||||
};
|
|
||||||
d.Coupling = Coupling.Parse(t[p++]);
|
d.Coupling = Coupling.Parse(t[p++]);
|
||||||
|
|
||||||
var trailing = new List<string>();
|
var trailing = new List<string>();
|
||||||
|
|||||||
50
StarterNG/Domain/Vehicles/RevisionSpan.cs
Normal file
50
StarterNG/Domain/Vehicles/RevisionSpan.cs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace StarterNG.Domain.Vehicles;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A revision date from a livery's credit line, as far as it is known: 14.01.2005,
|
||||||
|
/// xx.06.2008, xx.xx.2011. A span, so a month or a year is not taken for its first day.
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct RevisionSpan(DateTime From, DateTime To)
|
||||||
|
{
|
||||||
|
/// <summary>Also reads what is typed to filter by: 2016, 2016-12, 2016-12-06, 12.2016.</summary>
|
||||||
|
public static RevisionSpan? Parse(string? text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
string trimmed = text.Trim();
|
||||||
|
string[] parts = trimmed.Split('.', '-');
|
||||||
|
bool yearFirst = trimmed.Contains('-');
|
||||||
|
return parts.Length switch
|
||||||
|
{
|
||||||
|
1 => Of(parts[0], null, null),
|
||||||
|
2 => yearFirst ? Of(parts[0], parts[1], null) : Of(parts[1], parts[0], null),
|
||||||
|
3 => yearFirst ? Of(parts[0], parts[1], parts[2]) : Of(parts[2], parts[1], parts[0]),
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RevisionSpan? Of(string year, string? month, string? day)
|
||||||
|
{
|
||||||
|
if (year.Length != 4 || !Number(year, out int y) || y is < 1800 or > 2200)
|
||||||
|
return null;
|
||||||
|
if (!Number(month, out int m))
|
||||||
|
return new RevisionSpan(new DateTime(y, 1, 1), new DateTime(y, 12, 31));
|
||||||
|
if (m is < 1 or > 12)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
int days = DateTime.DaysInMonth(y, m);
|
||||||
|
if (!Number(day, out int d))
|
||||||
|
return new RevisionSpan(new DateTime(y, m, 1), new DateTime(y, m, days));
|
||||||
|
if (d < 1 || d > days)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return new RevisionSpan(new DateTime(y, m, d), new DateTime(y, m, d));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Number(string? text, out int value) =>
|
||||||
|
int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out value);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user