mirror of
https://github.com/MaSzyna-EU07/StarterNG.git
synced 2026-11-01 20:34:57 +01:00
Compare commits
14 Commits
dependabot
...
feature/ha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81c7e8ba95 | ||
|
|
b56a2a0ae6 | ||
|
|
80115b7a7e | ||
|
|
ceab05462a | ||
|
|
2ebff91316 | ||
|
|
6e45f5de59 | ||
|
|
82429010d4 | ||
|
|
f152a020e3 | ||
|
|
3b21583b53 | ||
|
|
69aa075bfa | ||
|
|
b6735fe345 | ||
|
|
07987a60a9 | ||
|
|
6948b13503 | ||
|
|
22c51ddf3c |
2
.github/workflows/dotnet.yml
vendored
2
.github/workflows/dotnet.yml
vendored
@@ -236,7 +236,7 @@ jobs:
|
||||
# Windows .zip a file - unpacked, its contents would end up in the release
|
||||
# instead of the package.
|
||||
- name: Collect the packages
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
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 VehicleCatalog _catalog;
|
||||
private readonly VehicleInfo _info;
|
||||
|
||||
public ConsistCrewTests()
|
||||
{
|
||||
@@ -20,7 +21,129 @@ public class ConsistCrewTests
|
||||
installation.Vehicles.Load(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]
|
||||
|
||||
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.Domain;
|
||||
using StarterNG.Domain.Vehicles;
|
||||
@@ -87,6 +88,25 @@ public class ConsistOrientationTests
|
||||
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)
|
||||
{
|
||||
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));
|
||||
}
|
||||
|
||||
[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]
|
||||
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);
|
||||
}
|
||||
|
||||
[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]
|
||||
public void Wine_is_never_looked_for_on_windows()
|
||||
{
|
||||
|
||||
@@ -9,16 +9,61 @@ namespace StarterNG.Tests.Fakes;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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>
|
||||
public sealed class InMemoryFileSystem : IFileSystem
|
||||
{
|
||||
private readonly Dictionary<string, byte[]> _files = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<string> _directories = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<string> _executable = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly StringComparison _comparison;
|
||||
private readonly Dictionary<string, byte[]> _files;
|
||||
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);
|
||||
|
||||
/// <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()
|
||||
{
|
||||
// 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 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));
|
||||
|
||||
@@ -76,6 +124,10 @@ public sealed class InMemoryFileSystem : IFileSystem
|
||||
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) =>
|
||||
WithFile(path, string.Join("\n", lines));
|
||||
|
||||
@@ -103,7 +155,7 @@ public sealed class InMemoryFileSystem : IFileSystem
|
||||
string prefix = Normalize(path) + "/";
|
||||
var pattern = ToRegex(searchPattern);
|
||||
return _files.Keys
|
||||
.Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(key => key.StartsWith(prefix, _comparison))
|
||||
.Where(key => !key[prefix.Length..].Contains('/'))
|
||||
.Where(key => pattern.IsMatch(key[prefix.Length..]))
|
||||
.OrderBy(key => key, StringComparer.OrdinalIgnoreCase)
|
||||
@@ -115,7 +167,8 @@ public sealed class InMemoryFileSystem : IFileSystem
|
||||
string prefix = Normalize(path) + "/";
|
||||
var pattern = ToRegex(searchPattern);
|
||||
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)..]))
|
||||
.OrderBy(key => key, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
@@ -123,15 +176,26 @@ public sealed class InMemoryFileSystem : IFileSystem
|
||||
|
||||
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) + "/";
|
||||
return _directories
|
||||
.Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(key => key.StartsWith(prefix, _comparison))
|
||||
.Where(key => !key[prefix.Length..].Contains('/'))
|
||||
.OrderBy(key => key, StringComparer.OrdinalIgnoreCase)
|
||||
.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>
|
||||
public long GetFileSize(string path) => Bytes(path).LongLength;
|
||||
@@ -145,10 +209,56 @@ public sealed class InMemoryFileSystem : IFileSystem
|
||||
return this;
|
||||
}
|
||||
|
||||
private byte[] Bytes(string path) =>
|
||||
_files.TryGetValue(Normalize(path), out var bytes)
|
||||
public string? LinkTarget(string path) => _links.GetValueOrDefault(Normalize(path));
|
||||
|
||||
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
|
||||
: throw new FileNotFoundException(path);
|
||||
}
|
||||
|
||||
private void AddParents(string key)
|
||||
{
|
||||
|
||||
@@ -37,6 +37,7 @@ public sealed class TestInstallation
|
||||
|
||||
Executables = new ExecutableLocator(Files, Paths, Environment, Log);
|
||||
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; }
|
||||
@@ -57,6 +58,8 @@ public sealed class TestInstallation
|
||||
public SettingsPaths SettingsPaths { get; }
|
||||
public ExecutableLocator Executables { get; }
|
||||
public SettingsStore SettingsStore { get; }
|
||||
public HaslerTapes Tapes { get; }
|
||||
public bool SimulatorRunning { get; set; }
|
||||
|
||||
/// <summary>Path of a file inside the fake installation.</summary>
|
||||
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.Domain.Sceneries;
|
||||
using StarterNG.Tests.Fakes;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
@@ -35,4 +34,43 @@ public class FavoriteSceneriesTests
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
[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]
|
||||
public void A_description_without_polish_letters_matches_the_bundled_one() =>
|
||||
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"));
|
||||
}
|
||||
|
||||
[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]
|
||||
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;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public class SceneryExportTests
|
||||
{
|
||||
private static SceneryParser Parser() =>
|
||||
new(new FixedClock(new DateTime(2026, 1, 1, 12, 0, 0)), new StubRandom());
|
||||
|
||||
[Fact]
|
||||
public void An_untouched_scenery_exports_its_trainsets_back_verbatim()
|
||||
{
|
||||
@@ -18,7 +14,7 @@ public class SceneryExportTests
|
||||
endtrainset
|
||||
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());
|
||||
|
||||
@@ -27,6 +23,45 @@ public class SceneryExportTests
|
||||
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]
|
||||
public void Selected_optional_includes_are_written_back_before_FirstInit()
|
||||
{
|
||||
@@ -36,7 +71,7 @@ public class SceneryExportTests
|
||||
endoptional
|
||||
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());
|
||||
|
||||
@@ -54,7 +89,7 @@ public class SceneryExportTests
|
||||
endoptional
|
||||
FirstInit
|
||||
""";
|
||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
||||
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||
scenery.Includes[0].Selected = false;
|
||||
|
||||
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
|
||||
@@ -71,7 +106,7 @@ public class SceneryExportTests
|
||||
endoptional
|
||||
FirstInit
|
||||
""";
|
||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
||||
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||
|
||||
scenery.HasCompanionTimetable = false;
|
||||
Assert.Contains("include rozklad.inc",
|
||||
@@ -95,7 +130,7 @@ public class SceneryExportTests
|
||||
time 8:30 0 0 endtime
|
||||
FirstInit
|
||||
""";
|
||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
||||
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||
|
||||
scenery.Weather.Day = 200;
|
||||
scenery.Weather.Temperature = 25;
|
||||
@@ -120,7 +155,7 @@ public class SceneryExportTests
|
||||
endtrainset
|
||||
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.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
|
||||
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());
|
||||
|
||||
Assert.Contains("wagon_1", 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;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public class SceneryParserTests
|
||||
{
|
||||
private static SceneryParser Parser(DateTime? now = null) =>
|
||||
new(new FixedClock(now ?? new DateTime(2026, 5, 4, 9, 15, 0)), new StubRandom());
|
||||
|
||||
[Fact]
|
||||
public void Directives_become_the_scenery_headline()
|
||||
{
|
||||
@@ -19,7 +16,7 @@ public class SceneryParserTests
|
||||
//$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("Osobowy do Katowic", scenery.Name);
|
||||
@@ -32,7 +29,7 @@ public class SceneryParserTests
|
||||
[Fact]
|
||||
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);
|
||||
}
|
||||
@@ -46,7 +43,7 @@ public class SceneryParserTests
|
||||
//$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,
|
||||
first =>
|
||||
@@ -76,7 +73,7 @@ public class SceneryParserTests
|
||||
FirstInit
|
||||
""";
|
||||
|
||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
||||
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||
|
||||
var include = Assert.Single(scenery.Includes);
|
||||
Assert.Equal("zima.inc", include.FilePath);
|
||||
@@ -95,7 +92,7 @@ public class SceneryParserTests
|
||||
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.False(include.Selected);
|
||||
@@ -112,7 +109,7 @@ public class SceneryParserTests
|
||||
FirstInit
|
||||
""";
|
||||
|
||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
||||
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||
|
||||
var trainset = Assert.Single(scenery.Trainsets);
|
||||
Assert.True(trainset.Parsed);
|
||||
@@ -126,11 +123,11 @@ public class SceneryParserTests
|
||||
public void Vehicles_outside_a_trainset_are_collected_as_loose()
|
||||
{
|
||||
const string scn = """
|
||||
node -1 -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 enddynamic
|
||||
node -1 -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 0 enddynamic
|
||||
FirstInit
|
||||
""";
|
||||
|
||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
||||
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||
|
||||
var vehicle = Assert.Single(scenery.LooseVehicles);
|
||||
Assert.Equal("wagon_1", vehicle.Name);
|
||||
@@ -142,13 +139,43 @@ public class SceneryParserTests
|
||||
[Fact]
|
||||
public void A_malformed_loose_vehicle_is_reported_and_left_in_place()
|
||||
{
|
||||
const string scn = "node x -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 enddynamic\n";
|
||||
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.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);
|
||||
}
|
||||
|
||||
[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.Infrastructure.Sceneries;
|
||||
using StarterNG.Tests.Fakes;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public class SceneryWeatherTests
|
||||
{
|
||||
private static SceneryParser Parser(DateTime now) => new(new FixedClock(now), new StubRandom());
|
||||
|
||||
[Fact]
|
||||
public void Authored_weather_is_read_from_the_config_block()
|
||||
{
|
||||
@@ -21,7 +18,7 @@ public class SceneryWeatherTests
|
||||
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.Equal(172, weather.Day);
|
||||
@@ -35,7 +32,7 @@ public class SceneryWeatherTests
|
||||
[Fact]
|
||||
public void A_scenery_without_a_time_starts_at_the_players_clock()
|
||||
{
|
||||
var weather = Parser(new DateTime(2026, 5, 4, 17, 42, 0)).Parse("scenery/x.scn", "//$n Test").Weather;
|
||||
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.False(weather.IsAuthored);
|
||||
@@ -44,7 +41,7 @@ public class SceneryWeatherTests
|
||||
[Fact]
|
||||
public void Freshly_parsed_weather_counts_as_unchanged()
|
||||
{
|
||||
var weather = Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", "movelight 100\n").Weather;
|
||||
var weather = TestScenery.Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", "movelight 100\n").Weather;
|
||||
|
||||
Assert.False(weather.Changed);
|
||||
}
|
||||
@@ -52,7 +49,7 @@ public class SceneryWeatherTests
|
||||
[Fact]
|
||||
public void Editing_then_restoring_returns_to_the_authored_values()
|
||||
{
|
||||
var weather = Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", "movelight 100\n").Weather;
|
||||
var weather = TestScenery.Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", "movelight 100\n").Weather;
|
||||
|
||||
weather.Day = 5;
|
||||
weather.Temperature = -12;
|
||||
@@ -84,4 +81,168 @@ public class SceneryWeatherTests
|
||||
Assert.Equal(-1.5, weather.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()
|
||||
.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);
|
||||
}
|
||||
|
||||
[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" })
|
||||
: ("/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);
|
||||
await process!.WaitForExitAsync();
|
||||
|
||||
Assert.Equal(3, process.ExitCode);
|
||||
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.Abstractions;
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Infrastructure;
|
||||
using StarterNG.Infrastructure.Sceneries;
|
||||
using StarterNG.Tests.Fakes;
|
||||
|
||||
@@ -23,6 +25,58 @@ public class StartSimulationTests
|
||||
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]
|
||||
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);
|
||||
}
|
||||
|
||||
[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]
|
||||
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")));
|
||||
}
|
||||
|
||||
[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]
|
||||
public void A_file_that_cannot_be_run_is_not()
|
||||
{
|
||||
@@ -92,8 +211,134 @@ public class StartSimulationTests
|
||||
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(
|
||||
Action<InMemoryFileSystem> addSimulator)
|
||||
Action<InMemoryFileSystem> addSimulator, Action<AppState>? arrange = null, string sceneryFile = "td.scn",
|
||||
string sceneryClock = "10:30")
|
||||
{
|
||||
var installation = new TestInstallation();
|
||||
addSimulator(installation.Files);
|
||||
@@ -103,7 +348,7 @@ public class StartSimulationTests
|
||||
settings.ExecutablePath = Build;
|
||||
|
||||
var scenery = new SceneryParser(installation.Clock, installation.Random).Parse(
|
||||
TestInstallation.At("scenery", "td.scn"),
|
||||
TestInstallation.At("scenery", sceneryFile),
|
||||
"""
|
||||
//$n Test
|
||||
trainset pociag tor_1 10 0
|
||||
@@ -111,12 +356,20 @@ public class StartSimulationTests
|
||||
endtrainset
|
||||
FirstInit
|
||||
""");
|
||||
scenery.Weather.ScenarioTimeOverride = sceneryClock;
|
||||
var state = new AppState { CurrentScenery = scenery, CurrentTrainset = scenery.Trainsets[0] };
|
||||
arrange?.Invoke(state);
|
||||
|
||||
var launcher = new RecordingLauncher();
|
||||
var start = new StartSimulation(state, installation.SettingsStore, installation.Library.Vehicles,
|
||||
installation.Executables, installation.Files, installation.Paths, launcher,
|
||||
installation.Random, installation.Log);
|
||||
installation.Executables,
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -130,9 +383,13 @@ public class StartSimulationTests
|
||||
|
||||
public bool? CapturedErrors { get; private set; }
|
||||
|
||||
public IReadOnlyDictionary<string, string>? Environment { get; private set; }
|
||||
|
||||
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;
|
||||
CapturedErrors = captureErrors;
|
||||
Arguments = arguments;
|
||||
@@ -144,6 +401,8 @@ public class StartSimulationTests
|
||||
public bool OpenInShell(string pathOrUrl) => false;
|
||||
|
||||
public IProcessHandle? FindRunning(string processName) => null;
|
||||
|
||||
public bool IsRunning(string processName) => false;
|
||||
}
|
||||
|
||||
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/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]">
|
||||
<Setter Property="IsVisible" Value="False" />
|
||||
</Style>
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Threading;
|
||||
@@ -30,6 +27,7 @@ public partial class App : Avalonia.Application
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
|
||||
AppServices.Current.SettingsStore.Load();
|
||||
AppServices.Current.SettingsStore.StartAtHighWithoutOwnSettings();
|
||||
|
||||
KeyboardConfig.Instance.Load();
|
||||
|
||||
@@ -48,6 +46,10 @@ public partial class App : Avalonia.Application
|
||||
// actually display.
|
||||
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);
|
||||
|
||||
// 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)
|
||||
{
|
||||
|
||||
var resolvedName = Loc.Load(langNameOrCode);
|
||||
|
||||
// The service is the authority on which language actually loaded: the code
|
||||
// goes to eu07.ini for the simulator, the name to the picker.
|
||||
AppServices.Current.Settings.Language = resolvedName;
|
||||
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)
|
||||
@@ -88,10 +101,11 @@ public partial class App : Avalonia.Application
|
||||
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.DefaultThreadCurrentUICulture = culture;
|
||||
CultureInfo.CurrentCulture = culture;
|
||||
CultureInfo.CurrentUICulture = culture;
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
@@ -187,6 +201,9 @@ public partial class App : Avalonia.Application
|
||||
|
||||
if (check.ExecutableMissing())
|
||||
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);
|
||||
|
||||
/// <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 WriteAllBytes(string path, byte[] bytes);
|
||||
@@ -46,4 +56,32 @@ public interface IFileSystem
|
||||
long GetFileSize(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
|
||||
{
|
||||
/// <summary>
|
||||
/// Starts a process. With captureErrors its error stream is kept for the
|
||||
/// handle - only for a process the starter outlives: a write to a pipe nobody
|
||||
/// reads any more can kill the writer.
|
||||
/// Starts a process, with <paramref name="environment"/> added to the starter's
|
||||
/// own. With captureErrors its error stream is kept for the handle - only for a
|
||||
/// process the starter outlives: a write to a pipe nobody reads any more can
|
||||
/// kill the writer.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
IProcessHandle? FindRunning(string processName);
|
||||
|
||||
bool IsRunning(string processName);
|
||||
}
|
||||
|
||||
@@ -11,4 +11,7 @@ public interface ISceneryRepository
|
||||
IReadOnlyList<Scenery> LoadAll(IProgress<SceneryLoadProgress>? progress = null);
|
||||
|
||||
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.IO;
|
||||
using StarterNG.Application.Abstractions;
|
||||
using StarterNG.Domain.Settings;
|
||||
using StarterNG.Infrastructure;
|
||||
using StarterNG.Infrastructure.Adapters;
|
||||
using StarterNG.Infrastructure.Sceneries;
|
||||
using StarterNG.Infrastructure.Settings;
|
||||
@@ -25,37 +27,53 @@ public sealed class AppServices
|
||||
|
||||
var log = new FileDiagnosticsLog(files, clock, paths);
|
||||
Log = log;
|
||||
Random = new SystemRandomSource();
|
||||
var random = new SystemRandomSource();
|
||||
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);
|
||||
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
|
||||
// and rebuilds that copy whenever the installation has moved on.
|
||||
TexturesTxt = new TexturesTxtVehicleRepository(files, paths, log, new TexturesTxtParser());
|
||||
VehicleJson = new VehicleJsonSerializer();
|
||||
VehicleCache = new VehicleCacheRepository(files, paths, log, TexturesTxt, VehicleJson,
|
||||
settingsPaths.VehicleCacheDirectory());
|
||||
Vehicles = VehicleCache;
|
||||
var texturesTxt = new TexturesTxtVehicleRepository(files, paths, log, new TexturesTxtParser());
|
||||
var vehicleJson = new VehicleJsonSerializer();
|
||||
Vehicles = new VehicleCacheRepository(files, paths, log, texturesTxt, vehicleJson,
|
||||
settingsPaths.VehicleCacheDirectory());
|
||||
Physics = new FizPhysicsRepository(files, paths, log);
|
||||
MissingAssets = new MissingAssetScanner(files, paths);
|
||||
var missingAssets = new MissingAssetScanner(files, paths);
|
||||
SceneryTexts = new SceneryTranslations(files, log);
|
||||
Timetables = new TimetableLocator(files);
|
||||
LoadWeights = new LoadWeightsRepository(files, paths, log);
|
||||
Library = new GameLibrary(Vehicles, Sceneries, MiniTextures, Physics, log);
|
||||
State = new AppState();
|
||||
SceneryFiles = new SceneryFileWatch(files, Sceneries, Library, State);
|
||||
|
||||
Executables = new ExecutableLocator(files, paths, environment, log);
|
||||
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());
|
||||
StartSimulation = new StartSimulation(State, SettingsStore, Library.Vehicles, Executables, files, paths,
|
||||
Processes, Random, log);
|
||||
Tapes = new HaslerTapes(files, paths, log, clock, Timetables,
|
||||
() => 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);
|
||||
SaveTrainset = new SaveSceneryTrainset(files, log);
|
||||
}
|
||||
|
||||
public static AppServices Current =>
|
||||
@@ -85,28 +103,18 @@ public sealed class AppServices
|
||||
|
||||
public IProcessLauncher Processes { get; }
|
||||
|
||||
public IRandomSource Random { get; }
|
||||
|
||||
public ISceneryRepository Sceneries { get; }
|
||||
|
||||
public SceneryFileWatch SceneryFiles { get; }
|
||||
|
||||
public SceneryImageLocator SceneryImages { get; }
|
||||
|
||||
public IMiniTextureIndex MiniTextures { 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 MissingAssetScanner MissingAssets { get; }
|
||||
|
||||
public ISceneryTranslations SceneryTexts { get; }
|
||||
|
||||
public TimetableLocator Timetables { get; }
|
||||
@@ -123,6 +131,10 @@ public sealed class AppServices
|
||||
|
||||
public ExecutableLocator Executables { get; }
|
||||
|
||||
public LinuxGpus Gpus { get; }
|
||||
|
||||
public Displays Displays { get; }
|
||||
|
||||
public SettingsPaths SettingsPaths { get; }
|
||||
|
||||
public MissingVehicleLog MissingVehicleLog { get; }
|
||||
@@ -133,5 +145,9 @@ public sealed class AppServices
|
||||
|
||||
public SimulatorAftermath Aftermath { get; }
|
||||
|
||||
public HaslerTapes Tapes { get; }
|
||||
|
||||
public SaveSceneryTrainset SaveTrainset { get; }
|
||||
|
||||
public LocalizationService Localization { get; }
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ public sealed class FavoriteSceneries
|
||||
private readonly string _path;
|
||||
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)
|
||||
{
|
||||
_files = files;
|
||||
@@ -52,6 +55,7 @@ public sealed class FavoriteSceneries
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Log($"reading {_path}", ex);
|
||||
_unread = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +66,12 @@ public sealed class FavoriteSceneries
|
||||
if (Path.GetDirectoryName(_path) is { Length: > 0 } 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)
|
||||
{
|
||||
|
||||
@@ -47,10 +47,19 @@ public sealed class GameLibrary
|
||||
return;
|
||||
|
||||
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
|
||||
? 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.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using StarterNG.Application.Abstractions;
|
||||
using StarterNG.Classes;
|
||||
@@ -12,8 +14,20 @@ namespace StarterNG.Application;
|
||||
public interface ISettingsCapture
|
||||
{
|
||||
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
|
||||
{
|
||||
private readonly IFileSystem _files;
|
||||
@@ -29,6 +43,11 @@ public sealed class SettingsStore
|
||||
private DateTime _configAgeUtc = DateTime.MinValue;
|
||||
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,
|
||||
SettingsSerializer serializer, ExecutableLocator executables)
|
||||
{
|
||||
@@ -45,6 +64,15 @@ public sealed class SettingsStore
|
||||
|
||||
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 void RegisterCapture(ISettingsCapture capture) => _capture = capture;
|
||||
@@ -57,6 +85,16 @@ public sealed class SettingsStore
|
||||
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)
|
||||
{
|
||||
try
|
||||
@@ -64,17 +102,38 @@ public sealed class SettingsStore
|
||||
_config = _files.FileExists(path)
|
||||
? ConfigFile.Parse(_files.ReadAllText(path, _encoding))
|
||||
: new ConfigFile();
|
||||
LoadFailed = false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Log($"reading {path}", ex);
|
||||
_config = new ConfigFile();
|
||||
LoadFailed = true;
|
||||
}
|
||||
|
||||
_serializer.Read(Settings, _config);
|
||||
_synced = Render(Settings);
|
||||
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()
|
||||
{
|
||||
_capture?.CaptureInto(Settings);
|
||||
@@ -91,7 +150,21 @@ public sealed class SettingsStore
|
||||
|
||||
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
|
||||
{
|
||||
@@ -99,8 +172,9 @@ public sealed class SettingsStore
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
_files.CreateDirectory(directory);
|
||||
|
||||
_files.WriteAllText(path, _config.ToText(), _encoding);
|
||||
_files.WriteAllBytesAtomic(path, _encoding.GetBytes(_config.ToText()));
|
||||
TouchConfigAge();
|
||||
LastSaveFailed = false;
|
||||
}
|
||||
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()
|
||||
{
|
||||
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>
|
||||
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 IGamePaths _paths;
|
||||
private readonly IDiagnosticsLog _log;
|
||||
@@ -42,8 +45,7 @@ public sealed class SimulatorAftermath
|
||||
if (_files.DirectoryExists(crashpad))
|
||||
dumps.AddRange(_files.GetFilesRecursive(crashpad, "*.dmp"));
|
||||
|
||||
// A little slack: the start is taken just after the launch, not before it.
|
||||
var since = startedUtc - TimeSpan.FromSeconds(5);
|
||||
var since = startedUtc - StartSlack;
|
||||
return dumps.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Where(dump => _files.GetLastWriteTimeUtc(dump) >= since)
|
||||
.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>
|
||||
public IReadOnlyList<string> SimulatorLogTail(int count)
|
||||
/// <summary>
|
||||
/// 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");
|
||||
try
|
||||
{
|
||||
if (!_files.FileExists(path))
|
||||
if (!_files.FileExists(path) || _files.GetLastWriteTimeUtc(path) < startedUtc - StartSlack)
|
||||
return Array.Empty<string>();
|
||||
|
||||
var lines = LegacyText.Decode(_files.ReadAllBytes(path))
|
||||
|
||||
@@ -2,12 +2,12 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using StarterNG.Application.Abstractions;
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Domain;
|
||||
using StarterNG.Domain.Settings;
|
||||
using StarterNG.Domain.Vehicles;
|
||||
using StarterNG.Infrastructure;
|
||||
using StarterNG.Infrastructure.Adapters;
|
||||
using StarterNG.Infrastructure.Settings;
|
||||
|
||||
@@ -47,11 +47,21 @@ public sealed class StartSimulation
|
||||
private readonly IProcessLauncher _processes;
|
||||
private readonly IRandomSource _random;
|
||||
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,
|
||||
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;
|
||||
_settings = settings;
|
||||
_vehicles = vehicles;
|
||||
@@ -63,11 +73,12 @@ public sealed class StartSimulation
|
||||
_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) =>
|
||||
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 =>
|
||||
string.Equals(vehicle.Name, preferred, StringComparison.OrdinalIgnoreCase));
|
||||
if (picked is not null && CanStart(picked))
|
||||
return picked.Name;
|
||||
if (picked is not null)
|
||||
return CanStart(picked);
|
||||
}
|
||||
|
||||
return trainset.Vehicles.FirstOrDefault(CanStart)?.Name;
|
||||
return trainset.Vehicles.Any(CanStart);
|
||||
}
|
||||
|
||||
public SimulationStartResult Execute(bool freeFly, bool saveSettings)
|
||||
{
|
||||
var scenery = _state.CurrentScenery;
|
||||
var trainset = _state.CurrentTrainset;
|
||||
if (scenery is null || trainset is null)
|
||||
if (scenery is null)
|
||||
return new SimulationStartResult(SimulationStartOutcome.NothingSelected);
|
||||
|
||||
// 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)
|
||||
// A scenery with no consist picked starts with the free camera.
|
||||
string? vehicle = null;
|
||||
if (trainset is null)
|
||||
{
|
||||
case ConsistReadyOverride.AlwaysCold:
|
||||
trainset.ReadyToGo = false;
|
||||
break;
|
||||
case ConsistReadyOverride.AlwaysReady:
|
||||
trainset.ReadyToGo = true;
|
||||
break;
|
||||
freeFly = true;
|
||||
}
|
||||
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)
|
||||
?? StartableVehicle(trainset, _state.StartingVehicleName);
|
||||
_state.StartingVehicleName = vehicle;
|
||||
vehicle = TrainsetDisplay.UniquifyForLaunch(trainset, scenery, _state.StartingVehicleName, _vehicles);
|
||||
_state.StartingVehicleName = vehicle;
|
||||
}
|
||||
|
||||
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);
|
||||
try
|
||||
{
|
||||
@@ -125,6 +146,9 @@ public sealed class StartSimulation
|
||||
if (saveSettings)
|
||||
_settings.CaptureAndSave();
|
||||
|
||||
if (_settings.Settings.PhysicsLog)
|
||||
_tapes.RecordLaunch(scenery, trainset, freeFly ? null : vehicle);
|
||||
|
||||
string[] arguments = freeFly || string.IsNullOrEmpty(vehicle)
|
||||
? new[] { "-s", exportName }
|
||||
: new[] { "-s", exportName, "-v", vehicle };
|
||||
@@ -134,15 +158,16 @@ public sealed class StartSimulation
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
public SimulationStartResult ExecuteEditor()
|
||||
{
|
||||
if (_state.CurrentScenery is not { } scenery)
|
||||
return new SimulationStartResult(SimulationStartOutcome.NothingSelected);
|
||||
public SimulationStartResult ExecuteEditor() =>
|
||||
Launch(_state.CurrentScenery is { } scenery
|
||||
? new[] { "-edit", Path.GetFileName(scenery.Path) }
|
||||
: 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)
|
||||
{
|
||||
@@ -157,10 +182,14 @@ public sealed class StartSimulation
|
||||
// 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.
|
||||
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
|
||||
// now, leaving nobody to read it or to report.
|
||||
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)
|
||||
return new SimulationStartResult(SimulationStartOutcome.LaunchFailed, ExecutablePath: executable,
|
||||
Detail: error);
|
||||
@@ -168,4 +197,30 @@ public sealed class StartSimulation
|
||||
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.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace StarterNG.Classes;
|
||||
@@ -12,6 +13,12 @@ public sealed class ConfigFile
|
||||
private readonly Dictionary<string, int> _index =
|
||||
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)
|
||||
{
|
||||
var cfg = new ConfigFile();
|
||||
@@ -24,14 +31,44 @@ public sealed class ConfigFile
|
||||
|
||||
private void AddLine(string raw)
|
||||
{
|
||||
int idx = _lines.Count;
|
||||
_lines.Add(raw);
|
||||
if (TryParseKey(raw, out string key))
|
||||
_index[key] = idx;
|
||||
IndexLine(_lines.Count - 1);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
/// <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)
|
||||
{
|
||||
if (!_index.TryGetValue(key, out int i))
|
||||
@@ -83,35 +120,61 @@ public sealed class ConfigFile
|
||||
if (_index.TryGetValue(key, out int i))
|
||||
{
|
||||
SplitLine(_lines[i], out string indent, out _, out _, out string comment);
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(indent).Append(key);
|
||||
if (value.Length > 0)
|
||||
sb.Append(' ').Append(value);
|
||||
if (comment.Length > 0)
|
||||
sb.Append('\t').Append(comment);
|
||||
_lines[i] = sb.ToString();
|
||||
string line = Line(indent, key, value, comment);
|
||||
|
||||
if (i > _end && _end >= 0)
|
||||
{
|
||||
_lines.RemoveAt(i);
|
||||
InsertBeforeEnd(line);
|
||||
}
|
||||
else
|
||||
{
|
||||
_lines[i] = line;
|
||||
}
|
||||
}
|
||||
else if (_end >= 0)
|
||||
{
|
||||
InsertBeforeEnd(Line("", key, value, ""));
|
||||
}
|
||||
else
|
||||
{
|
||||
int idx = _lines.Count;
|
||||
_lines.Add(value.Length > 0 ? $"{key} {value}" : key);
|
||||
_lines.Add(Line("", key, value, ""));
|
||||
_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();
|
||||
sb.Append(indent).Append("// ").Append(key);
|
||||
sb.Append(indent).Append(key);
|
||||
if (value.Length > 0)
|
||||
sb.Append(' ').Append(value);
|
||||
if (comment.Length > 0)
|
||||
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);
|
||||
}
|
||||
@@ -133,7 +196,7 @@ public sealed class ConfigFile
|
||||
private static void SplitLine(string raw, out string indent, out string key,
|
||||
out string value, out string comment)
|
||||
{
|
||||
indent = key = value = comment = string.Empty;
|
||||
key = value = comment = string.Empty;
|
||||
|
||||
int start = 0;
|
||||
while (start < raw.Length && char.IsWhiteSpace(raw[start]))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Input;
|
||||
|
||||
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 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)
|
||||
{
|
||||
@@ -75,6 +114,16 @@ public static class KeyMap
|
||||
Key.OemSemicolon => ";",
|
||||
Key.OemQuotes => "'",
|
||||
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.F5 => "f5", Key.F6 => "f6", Key.F7 => "f7", Key.F8 => "f8",
|
||||
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.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)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token) ||
|
||||
token.Equals("none", StringComparison.OrdinalIgnoreCase))
|
||||
if (IsNoKey(token))
|
||||
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
|
||||
{
|
||||
"enter" => "Num Enter",
|
||||
@@ -116,8 +179,9 @@ public static class KeyMap
|
||||
"end" => "End",
|
||||
"insert" => "Insert",
|
||||
"delete" => "Delete",
|
||||
"pageup" => "Page Up",
|
||||
"pagedown" => "Page Down",
|
||||
"page_up" => "Page Up",
|
||||
"page_down" => "Page Down",
|
||||
"esc" => "Esc",
|
||||
"backspace" => "Backspace",
|
||||
"pause" => "Pause",
|
||||
"\\" => "\\",
|
||||
@@ -131,10 +195,10 @@ public static class KeyMap
|
||||
|
||||
new[]
|
||||
{
|
||||
new KeyCap("Esc", null, 1.4),
|
||||
new KeyCap("F1"), new KeyCap("F2"), new KeyCap("F3"), new KeyCap("F4"),
|
||||
new KeyCap("F5"), new KeyCap("F6"), new KeyCap("F7"), new KeyCap("F8"),
|
||||
new KeyCap("F9"), new KeyCap("F10"), new KeyCap("F11"), new KeyCap("F12"),
|
||||
new KeyCap("Esc", "esc", 1.4),
|
||||
new KeyCap("F1", "f1"), new KeyCap("F2", "f2"), new KeyCap("F3", "f3"), new KeyCap("F4", "f4"),
|
||||
new KeyCap("F5", "f5"), new KeyCap("F6", "f6"), new KeyCap("F7", "f7"), new KeyCap("F8", "f8"),
|
||||
new KeyCap("F9", "f9"), new KeyCap("F10", "f10"), new KeyCap("F11", "f11"), new KeyCap("F12", "f12"),
|
||||
},
|
||||
new[]
|
||||
{
|
||||
@@ -147,11 +211,11 @@ public static class KeyMap
|
||||
},
|
||||
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("T", "t"), new KeyCap("Y", "y"), new KeyCap("U", "u"), new KeyCap("I", "i"),
|
||||
new KeyCap("O", "o"), new KeyCap("P", "p"),
|
||||
new KeyCap("[", null), new KeyCap("]", null),
|
||||
new KeyCap("[", "["), new KeyCap("]", "]"),
|
||||
new KeyCap("\\", "\\", 1.5),
|
||||
},
|
||||
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("L", "l"),
|
||||
new KeyCap(";", ";"), new KeyCap("'", "'"),
|
||||
new KeyCap("Enter", null, 2.2),
|
||||
new KeyCap("Enter", "enter", 2.2),
|
||||
},
|
||||
new[]
|
||||
{
|
||||
@@ -215,11 +279,19 @@ public static class KeyMap
|
||||
|
||||
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 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.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using StarterNG.Infrastructure.Adapters;
|
||||
|
||||
@@ -23,6 +24,20 @@ public sealed class KeyBinding
|
||||
public bool IsAssigned =>
|
||||
!string.IsNullOrEmpty(Key) &&
|
||||
!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
|
||||
@@ -119,19 +134,66 @@ public sealed class KeyboardConfig
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The description to show: the bundled one with Polish letters when the file's
|
||||
/// is the same text without them, the file's own when someone wrote their own.
|
||||
/// The description to show: the file's own when someone wrote their own, else
|
||||
/// the <paramref name="translated"/> one for the starter's language, else the
|
||||
/// bundled one with Polish letters.
|
||||
/// </summary>
|
||||
public string DescriptionOf(KeyBinding binding)
|
||||
public string DescriptionOf(KeyBinding binding, Func<string, string>? translated = null)
|
||||
{
|
||||
if (_polishDescriptions.TryGetValue(binding.Command, out string? polish) &&
|
||||
(string.IsNullOrEmpty(binding.Description) ||
|
||||
string.Equals(StripPolish(polish), binding.Description.Trim(), StringComparison.OrdinalIgnoreCase)))
|
||||
return polish;
|
||||
bool stock = string.IsNullOrEmpty(binding.Description);
|
||||
if (_polishDescriptions.TryGetValue(binding.Command, out string? polish))
|
||||
stock |= string.Equals(StripPolish(polish), binding.Description.Trim(), StringComparison.OrdinalIgnoreCase);
|
||||
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)
|
||||
{
|
||||
var sb = new StringBuilder(text.Length);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using System.Text.RegularExpressions;
|
||||
using StarterNG.Application;
|
||||
using StarterNG.Application.Abstractions;
|
||||
using StarterNG.Infrastructure.Adapters;
|
||||
|
||||
namespace StarterNG.Classes;
|
||||
@@ -54,18 +54,14 @@ public static class PresetStore
|
||||
|
||||
public static PresetSort SortMode { get; set; } = PresetSort.Name;
|
||||
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
TypeInfoResolver = PresetJsonContext.Default,
|
||||
};
|
||||
private static PresetFile? _disk;
|
||||
|
||||
private static JsonTypeInfo<PresetCollection> TypeInfo =>
|
||||
(JsonTypeInfo<PresetCollection>)Options.GetTypeInfo(typeof(PresetCollection));
|
||||
private static PresetFile Disk =>
|
||||
_disk ??= new PresetFile(AppServices.Current.Files, AppServices.Current.Log, FilePath);
|
||||
|
||||
public static IReadOnlyList<TrainsetPreset> All()
|
||||
{
|
||||
var list = Load().Presets.ToList();
|
||||
var list = Disk.Presets();
|
||||
if (SortMode == PresetSort.Track)
|
||||
list.Sort((a, b) => string.Compare(TrackOf(a), TrackOf(b), StringComparison.OrdinalIgnoreCase));
|
||||
else
|
||||
@@ -73,25 +69,9 @@ public static class PresetStore
|
||||
return list;
|
||||
}
|
||||
|
||||
public static void Save(string? name, string? entry)
|
||||
{
|
||||
name = name?.Trim() ?? "";
|
||||
if (name.Length == 0 || string.IsNullOrWhiteSpace(entry))
|
||||
return;
|
||||
public static void Save(string? name, string? entry) => Disk.Save(name, entry);
|
||||
|
||||
var col = Load();
|
||||
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 void Delete(string name) => Disk.Delete(name);
|
||||
|
||||
public static int ImportMagazyn(string? path = null)
|
||||
{
|
||||
@@ -162,35 +142,95 @@ public static class PresetStore
|
||||
|
||||
private static string Sanitize(string name) =>
|
||||
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
|
||||
{
|
||||
if (File.Exists(FilePath))
|
||||
{
|
||||
var col = JsonSerializer.Deserialize(File.ReadAllText(FilePath), TypeInfo);
|
||||
if (col?.Presets != null)
|
||||
return col;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StarterNG.Infrastructure.Diagnostics.Log($"reading {FilePath}", ex);
|
||||
}
|
||||
return new PresetCollection();
|
||||
WriteIndented = true,
|
||||
TypeInfoResolver = PresetJsonContext.Default,
|
||||
};
|
||||
|
||||
private static JsonTypeInfo<PresetCollection> TypeInfo =>
|
||||
(JsonTypeInfo<PresetCollection>)Options.GetTypeInfo(typeof(PresetCollection));
|
||||
|
||||
private readonly IFileSystem _files;
|
||||
private readonly IDiagnosticsLog _log;
|
||||
private readonly string _path;
|
||||
|
||||
public PresetFile(IFileSystem files, IDiagnosticsLog log, string path)
|
||||
{
|
||||
_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
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!);
|
||||
File.WriteAllText(FilePath, JsonSerializer.Serialize(col, TypeInfo));
|
||||
if (!_files.FileExists(_path))
|
||||
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)
|
||||
{
|
||||
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.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using StarterNG.Domain.Sceneries;
|
||||
|
||||
namespace StarterNG.Classes;
|
||||
|
||||
@@ -58,6 +59,28 @@ public class Trainset
|
||||
|
||||
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)
|
||||
{
|
||||
RawEntry = trainsetEntry;
|
||||
@@ -77,11 +100,7 @@ public class Trainset
|
||||
|
||||
private void ParseEntry(string trainsetEntry)
|
||||
{
|
||||
List<string> tokens = Regex
|
||||
.Matches(trainsetEntry, @"/\*[\s\S]*?\*/|//[^\r\n]*|[^\s\r\n]+")
|
||||
.Select(m => m.Value)
|
||||
.Where(t => !t.StartsWith("//") && !t.StartsWith("/*"))
|
||||
.ToList();
|
||||
List<SceneryCode.Token> tokens = SceneryCode.Tokens(SceneryCode.Mask(trainsetEntry, out _));
|
||||
|
||||
var match = Regex.Match(
|
||||
trainsetEntry,
|
||||
@@ -99,66 +118,92 @@ public class Trainset
|
||||
if (mini.Success)
|
||||
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++)
|
||||
{
|
||||
if (tokens[i] == "endtrainset")
|
||||
break;
|
||||
|
||||
if (tokens[i] == "trainset")
|
||||
if (Is(i, "endtrainset"))
|
||||
{
|
||||
this.Name = tokens[++i];
|
||||
this.Track = tokens[++i];
|
||||
this.Offset = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
|
||||
this.Velocity = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
|
||||
end = tokens[i].Index;
|
||||
break;
|
||||
}
|
||||
|
||||
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;
|
||||
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")
|
||||
i++;
|
||||
i++;
|
||||
Reversed |= Vehicles.Count == 0;
|
||||
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();
|
||||
nodeDynamic.RangeMax = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
|
||||
nodeDynamic.RangeMin = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
|
||||
nodeDynamic.Name = tokens[++i];
|
||||
nodeDynamic.RangeMax = float.Parse(tokens[++i].Text, CultureInfo.InvariantCulture);
|
||||
nodeDynamic.RangeMin = float.Parse(tokens[++i].Text, CultureInfo.InvariantCulture);
|
||||
nodeDynamic.Name = tokens[++i].Text;
|
||||
i++;
|
||||
nodeDynamic.DataFolder = tokens[++i];
|
||||
nodeDynamic.SkinFile = Dynamic.StripSkinExtension(tokens[++i]);
|
||||
nodeDynamic.MmdFile = tokens[++i];
|
||||
nodeDynamic.Offset = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
|
||||
nodeDynamic.DataFolder = tokens[++i].Text;
|
||||
nodeDynamic.SkinFile = Dynamic.StripSkinExtension(tokens[++i].Text);
|
||||
nodeDynamic.MmdFile = tokens[++i].Text;
|
||||
nodeDynamic.Offset = float.Parse(tokens[++i].Text, CultureInfo.InvariantCulture);
|
||||
|
||||
nodeDynamic.DriverType = tokens[++i] switch
|
||||
{
|
||||
"headdriver" => eDriverType.Headdriver,
|
||||
"reardriver" => eDriverType.Reardriver,
|
||||
"passenger" => eDriverType.Passenger,
|
||||
_ => eDriverType.Nobody
|
||||
};
|
||||
nodeDynamic.DriverType = Dynamic.ParseDriver(tokens[++i].Text);
|
||||
|
||||
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>();
|
||||
while (i + 1 < tokens.Count && tokens[i + 1] != "enddynamic")
|
||||
trailing.Add(tokens[++i]);
|
||||
while (i + 1 < tokens.Count && !Is(i + 1, "enddynamic") && !Is(i + 1, "node") &&
|
||||
!Is(i + 1, "endtrainset"))
|
||||
trailing.Add(tokens[++i].Text);
|
||||
nodeDynamic.ReadTrailing(trailing);
|
||||
|
||||
i++;
|
||||
if (i + 1 < tokens.Count && Is(i + 1, "enddynamic"))
|
||||
i++;
|
||||
|
||||
Vehicles.Add(nodeDynamic);
|
||||
spans.Add((start, tokens[i].End));
|
||||
}
|
||||
}
|
||||
|
||||
Layout = (speed, spans, end);
|
||||
}
|
||||
|
||||
public string ToSceneryEntry() =>
|
||||
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()
|
||||
{
|
||||
|
||||
@@ -177,13 +222,11 @@ public class Trainset
|
||||
sb.Append(Track).Append(' ');
|
||||
sb.Append(Offset.ToString(CultureInfo.InvariantCulture)).Append(' ');
|
||||
sb.Append(Velocity.ToString(CultureInfo.InvariantCulture));
|
||||
if (Reversed)
|
||||
sb.Append(" reversed");
|
||||
sb.Append('\n');
|
||||
|
||||
string entry = sb.ToString();
|
||||
foreach (Dynamic vehicle in Vehicles)
|
||||
entry += vehicle.ToTrainsetNode();
|
||||
|
||||
return entry;
|
||||
return sb.Append(Dynamic.ToTrainsetNodes(Vehicles)).ToString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,12 +244,6 @@ public class Dynamic
|
||||
|
||||
public Coupling Coupling = new();
|
||||
|
||||
public byte couplingData
|
||||
{
|
||||
get => (byte)(Coupling.AbsFlags & 0xFF);
|
||||
set => Coupling.Flags = value;
|
||||
}
|
||||
|
||||
public const string PantState = "pantstate";
|
||||
|
||||
public const int PantStateMax = 3;
|
||||
@@ -244,8 +281,6 @@ public class Dynamic
|
||||
public static bool IsPantStateType(string? type) =>
|
||||
string.Equals(type, PantState, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public bool IsPantState => IsPantStateType(LoadType);
|
||||
|
||||
internal void ReadTrailing(List<string> t)
|
||||
{
|
||||
int p = 0;
|
||||
@@ -278,14 +313,33 @@ public class Dynamic
|
||||
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()
|
||||
{
|
||||
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));
|
||||
|
||||
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);
|
||||
|
||||
return sb.ToString();
|
||||
@@ -314,37 +368,26 @@ public class Dynamic
|
||||
|
||||
public string ToTrainsetNode()
|
||||
{
|
||||
string driver = DriverType switch
|
||||
{
|
||||
eDriverType.Headdriver => "headdriver",
|
||||
eDriverType.Reardriver => "reardriver",
|
||||
eDriverType.Passenger => "passenger",
|
||||
_ => "nobody"
|
||||
};
|
||||
|
||||
return
|
||||
$"node {RangeMax} {RangeMin} {Name} dynamic " +
|
||||
$"node {RangeMax.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"{RangeMin.ToString(CultureInfo.InvariantCulture)} {Name} dynamic " +
|
||||
$"{DataFolder} {SkinFile} {MmdFile} " +
|
||||
$"{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()
|
||||
{
|
||||
string driver = DriverType switch
|
||||
{
|
||||
eDriverType.Headdriver => "headdriver",
|
||||
eDriverType.Reardriver => "reardriver",
|
||||
eDriverType.Passenger => "passenger",
|
||||
_ => "nobody"
|
||||
};
|
||||
string path = string.IsNullOrEmpty(PathName) ? "none" : PathName;
|
||||
string vel = (HasVelocity ? Velocity : 0f).ToString(CultureInfo.InvariantCulture);
|
||||
return
|
||||
$"node {RangeMax.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"{RangeMin.ToString(CultureInfo.InvariantCulture)} {Name} dynamic " +
|
||||
$"{DataFolder} {SkinFile} {MmdFile} {path} " +
|
||||
$"{Offset.ToString(CultureInfo.InvariantCulture)} {driver} {vel}" +
|
||||
$"{Offset.ToString(CultureInfo.InvariantCulture)} {DriverWord} {vel}" +
|
||||
$"{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 =
|
||||
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
|
||||
{
|
||||
get => GetValue(LabelProperty);
|
||||
@@ -41,6 +48,12 @@ public class SettingRow : ContentControl
|
||||
set => SetValue(ValueTextProperty, value);
|
||||
}
|
||||
|
||||
public string? IdleNote
|
||||
{
|
||||
get => GetValue(IdleNoteProperty);
|
||||
set => SetValue(IdleNoteProperty, value);
|
||||
}
|
||||
|
||||
public SettingRow()
|
||||
{
|
||||
UpdatePseudoClasses();
|
||||
@@ -54,8 +67,12 @@ public class SettingRow : ContentControl
|
||||
change.Property == DescriptionProperty ||
|
||||
change.Property == ConfigKeyProperty ||
|
||||
change.Property == ValueTextProperty ||
|
||||
change.Property == IdleNoteProperty ||
|
||||
change.Property == ContentProperty)
|
||||
UpdatePseudoClasses();
|
||||
|
||||
if (change.Property == IdleNoteProperty)
|
||||
ToolTip.SetTip(this, string.IsNullOrEmpty(IdleNote) ? null : IdleNote);
|
||||
}
|
||||
|
||||
private void UpdatePseudoClasses()
|
||||
@@ -65,5 +82,6 @@ public class SettingRow : ContentControl
|
||||
PseudoClasses.Set(":no-key", string.IsNullOrEmpty(ConfigKey));
|
||||
PseudoClasses.Set(":no-value", string.IsNullOrEmpty(ValueText));
|
||||
PseudoClasses.Set(":no-content", Content is null);
|
||||
PseudoClasses.Set(":idle", !string.IsNullOrEmpty(IdleNote));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
|
||||
namespace StarterNG.Controls;
|
||||
@@ -8,9 +10,56 @@ public class SettingsSection : HeaderedContentControl
|
||||
public static readonly StyledProperty<object?> ToggleProperty =
|
||||
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
|
||||
{
|
||||
get => GetValue(ToggleProperty);
|
||||
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)
|
||||
{
|
||||
int max = MaxFor(car);
|
||||
string? type = TypeToLoad(car);
|
||||
if (string.IsNullOrEmpty(type)) return;
|
||||
int max = MaxFor(car, type);
|
||||
if (max <= 0) return;
|
||||
if (string.IsNullOrEmpty(car.LoadType))
|
||||
car.LoadType = AcceptedTypes(car).FirstOrDefault();
|
||||
if (string.IsNullOrEmpty(car.LoadType)) return;
|
||||
car.LoadType = type;
|
||||
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)
|
||||
{
|
||||
int idx = IndexOf(consist, item);
|
||||
@@ -103,7 +107,7 @@ public sealed class Cargo
|
||||
if (!AcceptedTypes(c).Contains(type!, StringComparer.OrdinalIgnoreCase))
|
||||
continue;
|
||||
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);
|
||||
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))
|
||||
{
|
||||
int max = MaxFor(c);
|
||||
string? type = TypeToLoad(c);
|
||||
if (string.IsNullOrEmpty(type)) continue;
|
||||
int max = MaxFor(c, type);
|
||||
if (max <= 0) continue;
|
||||
if (string.IsNullOrEmpty(c.LoadType))
|
||||
c.LoadType = AcceptedTypes(c).FirstOrDefault();
|
||||
if (string.IsNullOrEmpty(c.LoadType)) continue;
|
||||
c.LoadType = type;
|
||||
int min = Math.Max(1, max / 10);
|
||||
c.LoadCount = _rng.Next(min, max + 1);
|
||||
}
|
||||
|
||||
@@ -52,12 +52,21 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
|
||||
|
||||
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;
|
||||
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();
|
||||
|
||||
var cars = trainset.Vehicles.Select(v =>
|
||||
var cars = vehicles.Select(v =>
|
||||
{
|
||||
var c = v.Clone();
|
||||
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]))
|
||||
locked.Add(cars[++j]);
|
||||
|
||||
_items.Add(new ConsistItem
|
||||
{
|
||||
Cars = locked,
|
||||
Grouped = true,
|
||||
Flipped = locked[0].Offset < 0,
|
||||
Driver = UnitDriver(locked)
|
||||
});
|
||||
_items.Add(Unit(locked));
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
@@ -106,13 +109,7 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
|
||||
|
||||
if (group.Count >= 2)
|
||||
{
|
||||
_items.Add(new ConsistItem
|
||||
{
|
||||
Cars = group,
|
||||
Grouped = true,
|
||||
Flipped = group[0].Offset < 0,
|
||||
Driver = UnitDriver(group)
|
||||
});
|
||||
_items.Add(Unit(group));
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
@@ -128,13 +125,7 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
|
||||
|
||||
if (group.Count >= 2)
|
||||
{
|
||||
_items.Add(new ConsistItem
|
||||
{
|
||||
Cars = group,
|
||||
Grouped = true,
|
||||
Flipped = group[0].Offset < 0,
|
||||
Driver = UnitDriver(group)
|
||||
});
|
||||
_items.Add(Unit(group));
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
@@ -149,9 +140,34 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
|
||||
});
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
Selected = _items.FirstOrDefault();
|
||||
SyncStartingVehicle();
|
||||
/// <summary>
|
||||
/// 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();
|
||||
}
|
||||
|
||||
@@ -161,16 +177,49 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
|
||||
public static IEnumerable<Dynamic> RunningOrder(ConsistItem item) =>
|
||||
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()
|
||||
{
|
||||
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;
|
||||
|
||||
var driven = item.Cars.FirstOrDefault(c =>
|
||||
c.DriverType is eDriverType.Headdriver or eDriverType.Reardriver);
|
||||
AppServices.Current.State.StartingVehicleName = (driven ?? item.Cars[0]).Name;
|
||||
if (StartingVehicle(_items, Selected, state.StartingVehicleName) is { } pick)
|
||||
state.StartingVehicleName = pick;
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
string skin = Base(texture.Skinfile);
|
||||
@@ -340,8 +389,7 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// front, from cab A.
|
||||
/// the one it put behind, in the same cab, and that one is left unstaffed.
|
||||
/// </summary>
|
||||
private void HandOverLead(ConsistItem previousHead)
|
||||
{
|
||||
@@ -350,11 +398,55 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
|
||||
previousHead.Driver is not (eDriverType.Headdriver or eDriverType.Reardriver))
|
||||
return;
|
||||
|
||||
var crew = previousHead.Driver;
|
||||
previousHead.Driver = eDriverType.Nobody;
|
||||
foreach (var car in previousHead.Cars)
|
||||
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)
|
||||
@@ -673,6 +765,11 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
|
||||
public static bool HoldsTail(ConsistItem 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) =>
|
||||
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.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using StarterNG.Application.Abstractions;
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Domain;
|
||||
|
||||
namespace StarterNG.Domain.Sceneries;
|
||||
|
||||
@@ -59,10 +57,16 @@ public sealed class Scenery
|
||||
|
||||
public List<string> LooseVehicleNames { get; } = new();
|
||||
|
||||
public List<string> Faults { get; } = new();
|
||||
public List<SceneryFault> Faults { 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 LocalizedDescription(ISceneryTranslations translations) =>
|
||||
@@ -81,7 +85,9 @@ public sealed class Scenery
|
||||
|
||||
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
|
||||
: Trainsets[i].ToSceneryEntry();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
FogStart = Math.Clamp(start, FogMin, FogMax);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using StarterNG.Application.Abstractions;
|
||||
@@ -8,9 +8,26 @@ namespace StarterNG.Domain.Sceneries;
|
||||
|
||||
public static class SceneryWeatherWriter
|
||||
{
|
||||
private static readonly Regex ManagedConfigLine = new(
|
||||
@"^\s*(movelight|scenario\.weather\.temperature|scenario\.time\.override)\b",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
// Whole words, as the simulator splits them: "atmo/lasy.scm" is a path, no atmo.
|
||||
private const string WordStart = @"(?<![^\s;])";
|
||||
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)
|
||||
{
|
||||
@@ -19,12 +36,8 @@ public static class SceneryWeatherWriter
|
||||
string carried = CarriedConfigLines(text);
|
||||
|
||||
string stripped = text;
|
||||
stripped = Regex.Replace(stripped, @"(?is)\bconfig\b.*?\bendconfig\b", " ");
|
||||
stripped = Regex.Replace(stripped, @"(?is)\btime\b\s+\d[^\r\n]*?\bendtime\b", " ");
|
||||
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+", " ");
|
||||
foreach (var pattern in Replaced)
|
||||
stripped = Cut(stripped, pattern);
|
||||
|
||||
var inv = CultureInfo.InvariantCulture;
|
||||
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)
|
||||
{
|
||||
int colon = hhmm.IndexOf(':');
|
||||
@@ -57,20 +102,17 @@ public static class SceneryWeatherWriter
|
||||
: "0" + hhmm[colon..];
|
||||
}
|
||||
|
||||
/// <summary>The settings of every config block but those the starter writes itself.</summary>
|
||||
private static string CarriedConfigLines(string text)
|
||||
{
|
||||
var block = Regex.Match(text, @"(?is)\bconfig\b(.*?)\bendconfig\b");
|
||||
if (!block.Success)
|
||||
return "";
|
||||
|
||||
var sb = new StringBuilder();
|
||||
foreach (string raw in block.Groups[1].Value.Split('\n'))
|
||||
{
|
||||
string line = raw.TrimEnd('\r').Trim();
|
||||
if (line.Length == 0 || ManagedConfigLine.IsMatch(line))
|
||||
continue;
|
||||
sb.Append(line).Append("\r\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.Trim();
|
||||
if (line.Length > 0)
|
||||
sb.Append(line).Append("\r\n");
|
||||
}
|
||||
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.Collections.Generic;
|
||||
|
||||
namespace StarterNG.Domain.Settings;
|
||||
|
||||
// Values match the key eu07.ini has carried since the old starter.
|
||||
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 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 LanguageCode = "";
|
||||
@@ -26,16 +33,20 @@ public sealed class SimulatorSettings
|
||||
public int FeedbackPort = 888;
|
||||
public bool UartEnabled;
|
||||
public string UartPort = "";
|
||||
public int UartBaud = Uart.DefaultBaud;
|
||||
public double UartUpdateTime = Uart.DefaultUpdateTime;
|
||||
public string UartTune = "";
|
||||
public bool UartDebug;
|
||||
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 string ExecutablePath = "eu07.exe";
|
||||
public bool DebugMode;
|
||||
public bool VirtualShunting = true;
|
||||
public bool LogMissingVehicleFiles;
|
||||
public bool DeveloperTools;
|
||||
|
||||
public int RenderEngine;
|
||||
public int Width = 1280;
|
||||
@@ -43,7 +54,7 @@ public sealed class SimulatorSettings
|
||||
public int BufferScalePercent = 100;
|
||||
public int MaxTextureSize = 4096;
|
||||
public int MaxCabTextureSize = 4096;
|
||||
public int TextureFiltering = 8;
|
||||
public double TextureFiltering = 8;
|
||||
public int Multisampling = 2;
|
||||
public int DynamicLights = 7;
|
||||
public double DrawRangeFactor = 3.0;
|
||||
@@ -56,8 +67,10 @@ public sealed class SimulatorSettings
|
||||
public bool EnvMap = true;
|
||||
public bool UseVbo = 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;
|
||||
/// <summary>The third number of shadowtune; the second and fourth go unread.</summary>
|
||||
public int ShadowProjectionRange = 250;
|
||||
public int CabShadowsRange;
|
||||
public int ShadowRankCutoff = 3;
|
||||
@@ -66,7 +79,7 @@ public sealed class SimulatorSettings
|
||||
public bool PythonScreens = true;
|
||||
public bool PythonThreadedUpload = true;
|
||||
public bool FpsLimitEnabled;
|
||||
public int FpsLimit = 30;
|
||||
public double FpsLimit = 30;
|
||||
public double ShadowAngleLimit = 0.2;
|
||||
public bool FullscreenWindowed;
|
||||
public bool RenderAngleVulkan;
|
||||
@@ -110,6 +123,12 @@ public sealed class SimulatorSettings
|
||||
public bool LargeThumbnails;
|
||||
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 bool ShowAiVehicles = true;
|
||||
@@ -120,12 +139,62 @@ public sealed class SimulatorSettings
|
||||
|
||||
public bool WindowMaximized;
|
||||
|
||||
public static readonly string[] RenderEngines =
|
||||
{ "full", "legacy", "simpleshader", "simple", "off", "experimental" };
|
||||
/// <summary>
|
||||
/// 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 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 unit = set ?? new List<VehicleTexture> { pick };
|
||||
consist[i] = new ConsistItem
|
||||
var replacement = new ConsistItem
|
||||
{
|
||||
Cars = unit.Select(makeCar).ToList(),
|
||||
Grouped = unit.Count > 1,
|
||||
Driver = item.Driver,
|
||||
Flipped = item.Flipped
|
||||
};
|
||||
CarryOver(item, replacement);
|
||||
consist[i] = replacement;
|
||||
if (ReferenceEquals(consist.Selected, item))
|
||||
consist.Selected = replacement;
|
||||
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)
|
||||
{
|
||||
if (current != null)
|
||||
|
||||
@@ -17,6 +17,27 @@ public static class TrainsetDisplay
|
||||
trainset.Vehicles.Count == 0 ||
|
||||
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)
|
||||
{
|
||||
if (IsEmpty(trainset))
|
||||
|
||||
@@ -106,13 +106,7 @@ public static class UDepot
|
||||
d.SkinFile = Dynamic.StripSkinExtension(t[p++]);
|
||||
d.MmdFile = t[p++];
|
||||
d.Offset = float.Parse(t[p++], inv);
|
||||
d.DriverType = t[p++].ToLowerInvariant() switch
|
||||
{
|
||||
"headdriver" => eDriverType.Headdriver,
|
||||
"reardriver" => eDriverType.Reardriver,
|
||||
"passenger" => eDriverType.Passenger,
|
||||
_ => eDriverType.Nobody
|
||||
};
|
||||
d.DriverType = Dynamic.ParseDriver(t[p++]);
|
||||
d.Coupling = Coupling.Parse(t[p++]);
|
||||
|
||||
var trailing = new List<string>();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user