diff --git a/StarterNG.Tests/CargoTests.cs b/StarterNG.Tests/CargoTests.cs new file mode 100644 index 0000000..5d39ef9 --- /dev/null +++ b/StarterNG.Tests/CargoTests.cs @@ -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 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 { new() { Cars = new List { 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); + } +} diff --git a/StarterNG.Tests/ConfigFileTests.cs b/StarterNG.Tests/ConfigFileTests.cs new file mode 100644 index 0000000..6618e99 --- /dev/null +++ b/StarterNG.Tests/ConfigFileTests.cs @@ -0,0 +1,186 @@ +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 A_line_above_another_moves_right_below_it() + { + var config = ConfigFile.Parse("gfx.antialiasing taa\nwidth 800\nmultisampling 2\nheight 600\n"); + + config.MoveAfter("gfx.antialiasing", "multisampling"); + + Assert.Equal(new[] { "width 800", "multisampling 2", "gfx.antialiasing taa", "height 600", "" }, Lines(config)); + Assert.Equal("taa", config.GetRaw("gfx.antialiasing")); + Assert.Equal("600", config.GetRaw("height")); + } + + [Theory] + [InlineData("multisampling 2\nwidth 800\ngfx.antialiasing taa\n")] + [InlineData("multisampling 2\n")] + [InlineData("gfx.antialiasing taa\n")] + public void A_line_below_or_alone_stays_where_it_is(string text) + { + var config = ConfigFile.Parse(text); + + config.MoveAfter("gfx.antialiasing", "multisampling"); + + Assert.Equal(ConfigFile.Parse(text).ToText(), config.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)); + } +} diff --git a/StarterNG.Tests/ConsistCrewTests.cs b/StarterNG.Tests/ConsistCrewTests.cs index 5e6dcd5..fcd7101 100644 --- a/StarterNG.Tests/ConsistCrewTests.cs +++ b/StarterNG.Tests/ConsistCrewTests.cs @@ -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 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(), 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(), 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] diff --git a/StarterNG.Tests/ConsistHistoryTests.cs b/StarterNG.Tests/ConsistHistoryTests.cs new file mode 100644 index 0000000..29f497a --- /dev/null +++ b/StarterNG.Tests/ConsistHistoryTests.cs @@ -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 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)); + } +} diff --git a/StarterNG.Tests/ConsistOrientationTests.cs b/StarterNG.Tests/ConsistOrientationTests.cs index 42abd82..558cd37 100644 --- a/StarterNG.Tests/ConsistOrientationTests.cs +++ b/StarterNG.Tests/ConsistOrientationTests.cs @@ -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)); diff --git a/StarterNG.Tests/ConsistTailTests.cs b/StarterNG.Tests/ConsistTailTests.cs index 1da3fe2..8924518 100644 --- a/StarterNG.Tests/ConsistTailTests.cs +++ b/StarterNG.Tests/ConsistTailTests.cs @@ -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() { diff --git a/StarterNG.Tests/DisplaysTests.cs b/StarterNG.Tests/DisplaysTests.cs new file mode 100644 index 0000000..18d83d5 --- /dev/null +++ b/StarterNG.Tests/DisplaysTests.cs @@ -0,0 +1,100 @@ +using StarterNG.Infrastructure; +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class DisplaysTests +{ + /// A 128-byte block with an 18-byte timing at 54, as a panel's base EDID has it. + 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; + } + + /// A DisplayID 2.0 block with one type VII timing (kHz, sizes less one). + 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()); + } +} diff --git a/StarterNG.Tests/ExecutableLocatorTests.cs b/StarterNG.Tests/ExecutableLocatorTests.cs index 01f45a1..ccbe4bd 100644 --- a/StarterNG.Tests/ExecutableLocatorTests.cs +++ b/StarterNG.Tests/ExecutableLocatorTests.cs @@ -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()).FileName); + Assert.False(executables.IsWindowsBuild("eu07.exe")); + Assert.Equal(nativeBuild, executables.LaunchCommand(nativeBuild, Array.Empty()).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()).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() { diff --git a/StarterNG.Tests/Fakes/InMemoryFileSystem.cs b/StarterNG.Tests/Fakes/InMemoryFileSystem.cs index fca2cbd..79108cb 100644 --- a/StarterNG.Tests/Fakes/InMemoryFileSystem.cs +++ b/StarterNG.Tests/Fakes/InMemoryFileSystem.cs @@ -9,16 +9,61 @@ namespace StarterNG.Tests.Fakes; /// /// /// 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. /// public sealed class InMemoryFileSystem : IFileSystem { - private readonly Dictionary _files = new(StringComparer.OrdinalIgnoreCase); - private readonly HashSet _directories = new(StringComparer.OrdinalIgnoreCase); - private readonly HashSet _executable = new(StringComparer.OrdinalIgnoreCase); + private readonly StringComparison _comparison; + private readonly Dictionary _files; + private readonly HashSet _directories; + private readonly HashSet _executable; + private readonly Dictionary _links; + private readonly HashSet _unreadable; + private readonly HashSet _unlistable; + private readonly Dictionary _written; + + public InMemoryFileSystem(bool caseSensitive = false) + { + _comparison = caseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; + var comparer = StringComparer.FromComparison(_comparison); + _files = new Dictionary(comparer); + _directories = new HashSet(comparer); + _executable = new HashSet(comparer); + _links = new Dictionary(comparer); + _unreadable = new HashSet(comparer); + _unlistable = new HashSet(comparer); + _written = new Dictionary(comparer); + } public DateTime LastWriteTimeUtc { get; set; } = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + /// Making a link throws, as on a disk that cannot hold one. + public bool LinksFail { get; set; } + + /// A folder moved to a destination this says yes to, its path with forward slashes, throws. + public Func? MoveFails { get; set; } + + /// The file stays, but reading or copying it throws, as a locked one does; writing it still goes through. + public InMemoryFileSystem FailReading(string path) + { + _unreadable.Add(Normalize(path)); + return this; + } + + public InMemoryFileSystem AllowReading(string path) + { + _unreadable.Remove(Normalize(path)); + return this; + } + + /// The folder is there, but listing it throws, as one without the permission does. + 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)); + /// A link counts as the directory it points at, as the disk has it. + 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 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,19 +176,33 @@ public sealed class InMemoryFileSystem : IFileSystem public IReadOnlyList 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; + + /// One file's own time, in place of . + public InMemoryFileSystem WrittenAt(string path, DateTime utc) + { + _written[Normalize(path)] = utc; + return this; + } /// Files added through carry the execute bit. public long GetFileSize(string path) => Bytes(path).LongLength; + public (DateTime LastWriteUtc, long Size)? LastWriteAndSize(string path) => + FileExists(path) ? (GetLastWriteTimeUtc(path), GetFileSize(path)) : null; + public bool IsExecutable(string path) => _executable.Contains(Normalize(path)); public InMemoryFileSystem WithExecutable(string path, string contents = "\u007fELF") @@ -145,10 +212,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) { diff --git a/StarterNG.Tests/Fakes/TestInstallation.cs b/StarterNG.Tests/Fakes/TestInstallation.cs index fbb39a3..3f4a791 100644 --- a/StarterNG.Tests/Fakes/TestInstallation.cs +++ b/StarterNG.Tests/Fakes/TestInstallation.cs @@ -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; } /// Path of a file inside the fake installation. public static string At(params string[] segments) => Path.Combine(new[] { Root }.Concat(segments).ToArray()); diff --git a/StarterNG.Tests/Fakes/TestLog.cs b/StarterNG.Tests/Fakes/TestLog.cs new file mode 100644 index 0000000..7ea6fcc --- /dev/null +++ b/StarterNG.Tests/Fakes/TestLog.cs @@ -0,0 +1,12 @@ +using StarterNG.Application.Abstractions; +using StarterNG.Infrastructure.Adapters; + +namespace StarterNG.Tests.Fakes; + +/// The diagnostics log for a test that only wants one to write to or read back. +public static class TestLog +{ + /// Where the log file goes; a disk of its own when left out. + public static FileDiagnosticsLog Over(IFileSystem? files = null) => + new(files ?? new InMemoryFileSystem(), new FixedClock(new DateTime(2026, 10, 13)), new GamePaths("game")); +} diff --git a/StarterNG.Tests/Fakes/TestScenery.cs b/StarterNG.Tests/Fakes/TestScenery.cs new file mode 100644 index 0000000..79a2687 --- /dev/null +++ b/StarterNG.Tests/Fakes/TestScenery.cs @@ -0,0 +1,16 @@ +using StarterNG.Application.Abstractions; +using StarterNG.Infrastructure.Adapters; +using StarterNG.Infrastructure.Sceneries; + +namespace StarterNG.Tests.Fakes; + +/// The scenery reader over a fake clock and dice, for tests of what it makes of a text. +public static class TestScenery +{ + /// The hour a scenery that names none starts at. + 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()); +} diff --git a/StarterNG.Tests/FavoriteSceneriesTests.cs b/StarterNG.Tests/FavoriteSceneriesTests.cs index 098fb8c..244b5dd 100644 --- a/StarterNG.Tests/FavoriteSceneriesTests.cs +++ b/StarterNG.Tests/FavoriteSceneriesTests.cs @@ -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); + } } diff --git a/StarterNG.Tests/FileDiagnosticsLogTests.cs b/StarterNG.Tests/FileDiagnosticsLogTests.cs index aa3f629..e918a77 100644 --- a/StarterNG.Tests/FileDiagnosticsLogTests.cs +++ b/StarterNG.Tests/FileDiagnosticsLogTests.cs @@ -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); + } + } } diff --git a/StarterNG.Tests/GameLibraryTests.cs b/StarterNG.Tests/GameLibraryTests.cs index 1efe8a5..d2a2dfd 100644 --- a/StarterNG.Tests/GameLibraryTests.cs +++ b/StarterNG.Tests/GameLibraryTests.cs @@ -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"); + } } diff --git a/StarterNG.Tests/GpuOffloadTests.cs b/StarterNG.Tests/GpuOffloadTests.cs new file mode 100644 index 0000000..d4fbb9a --- /dev/null +++ b/StarterNG.Tests/GpuOffloadTests.cs @@ -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 For(GpuPreference preference, params GpuDevice[] gpus) => + Inheriting(Unset, preference, gpus); + + private static IReadOnlyDictionary Inheriting(Func inherited, + GpuPreference preference, + params GpuDevice[] gpus) => + GpuOffload.Target(gpus, preference, inherited) is { } target + ? GpuOffload.EnvironmentFor(target) + : new Dictionary(); + + [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(), GpuPreference.Discrete, Unset)); + Assert.Equal(GpuNote.Unread, GpuOffload.NoteFor(Array.Empty(), 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()); + } +} diff --git a/StarterNG.Tests/GraphicsCardsTests.cs b/StarterNG.Tests/GraphicsCardsTests.cs new file mode 100644 index 0000000..f793054 --- /dev/null +++ b/StarterNG.Tests/GraphicsCardsTests.cs @@ -0,0 +1,156 @@ +using StarterNG.Domain.Settings; +using StarterNG.Infrastructure; +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class GraphicsCardsTests +{ + private const long GiB = 1L << 30; + + private const string PciIds = + "# List of PCI ID's\n" + + "\n" + + "10de NVIDIA Corporation\n" + + "\t1000 GK110 [Nothing Here]\n" + + "\t2d19 GB206M [GeForce RTX 5060 Max-Q / Mobile]\n" + + "\t\t1043 1e0c A subsystem\n" + + "1002 Advanced Micro Devices, Inc. [AMD/ATI]\n" + + "\t1900 Phoenix1\n" + + "\t2d19 Not this vendor's card\n" + + "8086 Intel Corporation\n" + + "\t46a6 Alder Lake-P GT2 [Iris Xe Graphics]\n" + + "C 00 Unclassified device\n" + + "\t2d19 Not a card at all\n"; + + private static GraphicsCards Reader(InMemoryFileSystem files, FakeEnvironment environment, + params string[] nvidiaSmi) => + new(files, environment, new LinuxGpus(files, environment), () => nvidiaSmi); + + private static InMemoryFileSystem Card(InMemoryFileSystem files, string card, string driver, string id, string slot, + long memory = 0) + { + string device = $"/sys/class/drm/{card}/device"; + files.WithDirectory($"/sys/class/drm/{card}") + .WithFile($"{device}/uevent", $"DRIVER={driver}\nPCI_ID={id}\nPCI_SLOT_NAME={slot}\n"); + if (memory > 0) + files.WithFile($"{device}/mem_info_vram_total", memory + "\n"); + return files; + } + + [Theory] + [InlineData("10DE:2D19", "GeForce RTX 5060 Max-Q / Mobile")] + [InlineData("1002:1900", "Phoenix1")] + [InlineData("8086:46A6", "Iris Xe Graphics")] + [InlineData("10DE:FFFF", null)] + [InlineData("1234:2D19", null)] + [InlineData("nonsense", null)] + public void The_pci_list_names_a_card_by_its_vendor_and_device(string id, string? expected) + { + Assert.Equal(expected, GraphicsCards.ModelInPciList(PciIds.Split('\n'), id)); + } + + [Fact] + public void A_linux_card_takes_its_name_from_the_pci_list_and_its_memory_from_the_driver() + { + var files = Card(new InMemoryFileSystem(), "card0", "amdgpu", "1002:73DF", "0000:03:00.0", 12 * GiB) + .WithFile("/usr/share/hwdata/pci.ids", "1002 AMD\n\t73df Navi 22 [Radeon RX 6700 XT]\n"); + + var card = Assert.Single(Reader(files, new FakeEnvironment()).List()); + + Assert.Equal("AMD Radeon RX 6700 XT", card.Name); + Assert.Equal(12, card.MemoryGb); + } + + [Fact] + public void The_proprietary_driver_names_its_card_and_nvidia_smi_tells_its_memory() + { + var files = Card(new InMemoryFileSystem(), "card0", "nvidia", "10DE:2D19", "0000:01:00.0") + .WithFile("/proc/driver/nvidia/gpus/0000:01:00.0/information", + "Model: \t\t NVIDIA GeForce RTX 5060 Laptop GPU\nIRQ: \t\t 182\n"); + + var card = Assert.Single(Reader(files, new FakeEnvironment(), "00000000:01:00.0, 8188").List()); + + Assert.Equal("NVIDIA GeForce RTX 5060 Laptop GPU", card.Name); + Assert.Equal(8, card.MemoryGb); + } + + [Fact] + public void A_card_keeps_its_name_when_nothing_tells_its_memory() + { + var files = Card(new InMemoryFileSystem(), "card0", "nouveau", "10DE:2D19", "0000:01:00.0"); + + var card = Assert.Single(Reader(files, new FakeEnvironment()).List()); + + Assert.Equal("NVIDIA 10DE:2D19", card.Name); + Assert.Equal(0, card.MemoryBytes); + } + + [Fact] + public void The_discrete_card_comes_first_whatever_order_the_system_lists_them() + { + var files = new InMemoryFileSystem().WithFile( + "/usr/share/misc/pci.ids", "1002 AMD\n\t1900 Rembrandt [Radeon 780M]\n\t73df Navi 22 [Radeon RX 6700 XT]\n"); + Card(files, "card0", "amdgpu", "1002:1900", "0000:01:00.0", GiB / 2); + Card(files, "card1", "amdgpu", "1002:73DF", "0000:65:00.0", 12 * GiB); + + var cards = Reader(files, new FakeEnvironment()).List(); + + Assert.Equal(new[] { "AMD Radeon RX 6700 XT", "AMD Radeon 780M" }, cards.Select(card => card.Name)); + } + + [Fact] + public void There_are_no_cards_on_a_mac() + { + var files = Card(new InMemoryFileSystem(), "card0", "amdgpu", "1002:73DF", "0000:03:00.0", 12 * GiB); + + Assert.Empty(Reader(files, new FakeEnvironment { IsLinux = false, IsMacOS = true }).List()); + } + + [Theory] + [InlineData("00000000:01:00.0, 8188", "0000:01:00.0", 8188L << 20)] + [InlineData("0000:65:00.0, 16384", "0000:65:00.0", 16384L << 20)] + public void Nvidia_smi_is_read_by_the_address_sys_gives(string line, string slot, long bytes) + { + Assert.Equal(bytes, GraphicsCards.ParseNvidiaMemory(new[] { line })[slot]); + } + + [Theory] + [InlineData("Failed to initialize NVML")] + [InlineData("00000000:01:00.0, [N/A]")] + public void Nvidia_smi_saying_something_else_tells_nothing(string line) + { + Assert.Empty(GraphicsCards.ParseNvidiaMemory(new[] { line })); + } + + [Theory] + [InlineData("AMD Radeon 780M Graphics", true)] + [InlineData("AMD Radeon(TM) Graphics", true)] + [InlineData("AMD Radeon(TM) Vega 8 Graphics", true)] + [InlineData("AMD Radeon RX Vega 10 Graphics", true)] + [InlineData("AMD Radeon Vega Series / Radeon Vega Mobile Series", true)] + [InlineData("Intel(R) UHD Graphics 770", true)] + [InlineData("Intel(R) Iris(R) Xe Graphics", true)] + [InlineData("Intel(R) Arc(TM) Graphics", true)] + [InlineData("Intel(R) Arc(TM) A770 Graphics", false)] + [InlineData("Intel Arc B580", false)] + [InlineData("AMD Radeon RX 6800M", false)] + [InlineData("AMD Radeon RX Vega 56", false)] + [InlineData("NVIDIA GeForce RTX 5060 Laptop GPU", false)] + public void A_card_in_a_processor_is_told_by_its_name(string name, bool shared) + { + Assert.Equal(shared, new GraphicsCard(name, 8 * GiB).SharedMemory); + } + + [Fact] + public void A_card_reporting_under_a_gigabyte_counts_as_one_in_a_processor() + { + Assert.True(new GraphicsCard("AMD Phoenix1", GiB / 2).SharedMemory); + } + + [Fact] + public void The_reported_memory_is_rounded_the_way_a_box_says_it() + { + Assert.Equal(8, new GraphicsCard("NVIDIA GeForce RTX 5060 Laptop GPU", 8546942976).MemoryGb); + } +} diff --git a/StarterNG.Tests/GraphicsPresetTests.cs b/StarterNG.Tests/GraphicsPresetTests.cs new file mode 100644 index 0000000..fa55f64 --- /dev/null +++ b/StarterNG.Tests/GraphicsPresetTests.cs @@ -0,0 +1,743 @@ +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(); + // Ray tracing is one of them, and only Vulkan has it. + low.RenderEngine = top.RenderEngine = SimulatorSettings.VulkanEngine; + (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 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 => (int)p.AntiAliasing, 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 fidelity in new Func[] { 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) && key != "gfx.buffer.percent"); + + 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 High_is_what_the_new_simulator_does_with_nothing_set() + { + Assert.Equal(GraphicsPreset.High.NewRenderer, GraphicsNewRenderer.Of(Defaults())); + } + + [Fact] + public void The_new_renderers_scales_never_go_down() + { + void NeverDown(Func value) => Climbs(p => value(p.NewRenderer), 5); + + void TurnsOn(Func on) + { + var values = GraphicsPreset.All.Select(p => on(p.NewRenderer)).ToList(); + Assert.Equal(values.OrderBy(value => value), values); + } + + NeverDown(r => r.LampShadows); + NeverDown(r => r.ContactShadows); + NeverDown(r => r.Occlusion); + NeverDown(r => r.OcclusionSlices); + NeverDown(r => r.OcclusionSteps); + NeverDown(r => r.CabProbe); + NeverDown(r => r.ScreenReflections); + NeverDown(r => r.FogQuality); + NeverDown(r => r.Clouds); + + TurnsOn(r => r.OcclusionLighting); + TurnsOn(r => r.CabProbeReflections); + TurnsOn(r => r.RayTracing); + TurnsOn(r => r.VolumetricFog); + TurnsOn(r => r.CloudShadows); + TurnsOn(r => r.Grass); + TurnsOn(r => r.Bloom); + + Climbs(p => p.NewRenderer.GrassDistance, 0); + Climbs(p => p.NewRenderer.GrassDensity, 0); + Climbs(p => p.NewRenderer.Precipitation, 1); + // Frames between refreshes, so the dearer the fewer. + Climbs(p => -p.NewRenderer.CabProbeInterval, 2); + } + + [Fact] + public void Ultra_is_the_top_of_the_new_renderers_scales() + { + var ultra = GraphicsPreset.Ultra.NewRenderer; + + Assert.Equal(3, ultra.LampShadows); + Assert.Equal(3, ultra.ContactShadows); + Assert.Equal(2, ultra.Occlusion); + Assert.Equal((6, 8), (ultra.OcclusionSlices, ultra.OcclusionSteps)); + Assert.Equal(3, ultra.CabProbe); + Assert.Equal(3, ultra.ScreenReflections); + Assert.True(ultra.RayTracing && ultra.VolumetricFog); + Assert.Equal(4, ultra.FogQuality); + Assert.Equal(4, ultra.Clouds); + Assert.Equal(300, ultra.GrassDistance); + Assert.Equal(2.0, ultra.GrassDensity); + Assert.Equal(262144, ultra.Precipitation); + } + + [Fact] + public void Grass_fades_out_beyond_where_it_is_drawn_in_full() + { + var store = new TestInstallation().SettingsStore; + foreach (var preset in GraphicsPreset.All) + { + var settings = Defaults().Copy(); + preset.ApplyTo(settings); + + Assert.True(int.Parse(store.Render(settings)["gfx.grass.fadedistance"]) > preset.NewRenderer.GrassDistance, preset.NameKey); + } + } + + [Fact] + public void A_level_set_in_the_new_renderers_rows_is_told_from_the_others() + { + var settings = Defaults().Copy(); + GraphicsPreset.Medium.ApplyTo(settings); + Assert.Same(GraphicsPreset.Medium, GraphicsPreset.Matching(settings)); + + settings.FogQuality = 4; + + Assert.Null(GraphicsPreset.Matching(settings)); + } + + [Fact] + public void What_is_left_to_the_player_stays_through_a_level() + { + var settings = Defaults().Copy(); + settings.RenderEngine = SimulatorSettings.VulkanEngine; + settings.AntiAliasing = AntiAliasingMode.Dlss; + settings.FrameGeneration = 2; + settings.VulkanDevice = "RTX"; + + foreach (var preset in GraphicsPreset.All) + { + preset.ApplyTo(settings); + + Assert.Same(preset, GraphicsPreset.Matching(settings)); + Assert.Equal(AntiAliasingMode.Dlss, settings.AntiAliasing); + Assert.Equal(2, settings.FrameGeneration); + Assert.Equal("RTX", settings.VulkanDevice); + } + } + + [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(AntiAliasingMode.Msaa8, ultra.AntiAliasing); + 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.InRange(preset.NewRenderer.CabProbeInterval, 1, 120); + Assert.InRange(preset.NewRenderer.GrassDistance, 20, 300); + Assert.Equal(0, preset.NewRenderer.GrassDistance % 10); + Assert.Equal(0, preset.NewRenderer.GrassDensity % 0.25); + Assert.Contains(preset.NewRenderer.Precipitation, new[] { 16384, 32768, 65536, 131072, 262144 }); + Assert.True(Steps.IndexOf(Steps.Anisotropy, preset.Anisotropy) >= 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_those_of_the_whole_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[] { "gfx.antialiasing", "gfx.buffer.percent", "gfx.extraeffects", "gfx.postfx.bloom", "multisampling", "starter.bufferscale" }, + GraphicsPreset.Keys.Except(inGroups).OrderBy(key => key, StringComparer.Ordinal)); + } + + [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.Lights]); + 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; + settings.AntiAliasing = level.AntiAliasing; + + 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; + + settings.Smoke = false; + + var nature = Groups().Apply(GraphicsGroup.Nature, GraphicsPreset.VeryHigh, settings); + Assert.False(nature.Smoke); + Assert.Equal(GraphicsPreset.VeryHigh.SmokeFidelity, nature.SmokeFidelity); + } +} diff --git a/StarterNG.Tests/GraphicsSettingsTests.cs b/StarterNG.Tests/GraphicsSettingsTests.cs new file mode 100644 index 0000000..9ecfcf6 --- /dev/null +++ b/StarterNG.Tests/GraphicsSettingsTests.cs @@ -0,0 +1,593 @@ +using StarterNG.Classes; +using StarterNG.Domain.Settings; +using StarterNG.Infrastructure.Settings; +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class GraphicsSettingsTests +{ + private static readonly string UserIni = Path.Combine("/home/kolejarz", ".config", "MaSzyna", "eu07.ini"); + + private static TestInstallation WithIni(string text) + { + var installation = new TestInstallation(new InMemoryFileSystem().WithFile(UserIni, text)); + installation.Environment.With("HOME", "/home/kolejarz"); + installation.SettingsStore.Load(); + return installation; + } + + private static SimulatorSettings Read(string text, bool dlssPlatform = true) + { + var s = new SimulatorSettings(); + new SettingsSerializer(dlssPlatform).Read(s, ConfigFile.Parse(text)); + return s; + } + + private static string Write(SimulatorSettings s, string text = "", bool dlssPlatform = true) + { + var c = ConfigFile.Parse(text); + new SettingsSerializer(dlssPlatform).Write(s, c); + return c.ToText(); + } + + private static string[] Lines(string text) => + text.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None); + + private static string? Line(string text, string key) => + Lines(text).FirstOrDefault(l => l == key || l.StartsWith(key + " ", StringComparison.Ordinal)); + + [Theory] + [InlineData("full", GraphicsBackend.OpenGL)] + [InlineData("default", GraphicsBackend.OpenGL)] + [InlineData("gl46", GraphicsBackend.OpenGL)] + [InlineData("legacy", GraphicsBackend.OpenGL)] + [InlineData("experimental", GraphicsBackend.OpenGL)] + [InlineData("vulkan", GraphicsBackend.Vulkan)] + [InlineData("vulkan14", GraphicsBackend.Vulkan)] + public void The_renderer_is_read_with_the_aliases_the_simulator_takes(string value, GraphicsBackend expected) + { + Assert.Equal(expected, Read($"gfxrenderer {value}\n").Backend); + } + + [Theory] + [InlineData("full", "full")] + [InlineData("gl46", "full")] + [InlineData("legacy", "legacy")] + [InlineData("simpleshader", "legacy")] + [InlineData("simple", "simple")] + [InlineData("experimental", "experimental")] + [InlineData("vulkan14", "vulkan")] + public void A_renderer_is_written_by_its_name(string read, string written) + { + Assert.Equal($"gfxrenderer {written}", Line(Write(Read($"gfxrenderer {read}\n")), "gfxrenderer")); + } + + [Fact] + public void An_older_renderer_stays_chosen_and_can_be_chosen_again() + { + var installation = WithIni("gfxrenderer legacy\n"); + var store = installation.SettingsStore; + + store.Settings.Clouds = 3; + store.Save(); + Assert.Contains("gfxrenderer legacy", installation.Files.ReadAllText(UserIni)); + + store.Settings.RenderEngine = SimulatorSettings.VulkanEngine; + store.Save(); + Assert.Contains("gfxrenderer vulkan", installation.Files.ReadAllText(UserIni)); + Assert.Equal(GraphicsBackend.Vulkan, store.Settings.Backend); + + store.Settings.RenderEngine = SimulatorSettings.RenderEngineOf("simple"); + store.Save(); + Assert.Contains("gfxrenderer simple", installation.Files.ReadAllText(UserIni)); + Assert.Equal(GraphicsBackend.OpenGL, store.Settings.Backend); + } + + [Fact] + public void Keys_only_an_older_simulator_reads_stay_in_the_file() + { + string[] old = + { + "usevbo no", "gfx.angleplatform vulkan", "gfx.usegles yes", + "gfx.framebuffer.width 1920", "gfx.framebuffer.height 1080", "gfx.shadows.cab.range 20" + }; + var installation = WithIni(string.Join("\n", old) + "\n"); + installation.SettingsStore.Settings.Clouds = 3; + + installation.SettingsStore.Save(); + + string saved = installation.Files.ReadAllText(UserIni); + Assert.All(old, line => Assert.Contains(line, saved)); + } + + [Fact] + public void Levels_leave_the_switches_only_an_older_simulator_reads() + { + var s = new SimulatorSettings { UseVbo = false, UseGLES = true, RenderAngleVulkan = true }; + + GraphicsPreset.Ultra.ApplyTo(s); + + Assert.Equal((false, true, true), (s.UseVbo, s.UseGLES, s.RenderAngleVulkan)); + } + + [Fact] + public void The_new_graphics_keys_are_read() + { + var s = Read("gfxrenderer vulkan\n" + + "gfx.antialiasing dlss\ngfx.dlss.preset K\ngfx.framegeneration x3\ngfx.reflex boost\n" + + "gfx.raytracing yes\ngfx.raytracing.range 800\ngfx.ssr high\ngfx.postfx.ao ssao\ngfx.postfx.ao.quality 4 6\n" + + "gfx.lights.shadows low\ngfx.contactshadows 3\ngfx.fog analytic\ngfx.fog.quality ultra\n" + + "gfx.clouds ultra\ngfx.clouds.shadows no\ngfx.grass no\ngfx.grass.distance 120\ngfx.grass.density 1.5\n" + + "gfx.buffer.percent 0.67\ngfx.vulkan.device RTX\n"); + + Assert.Equal(GraphicsBackend.Vulkan, s.Backend); + Assert.Equal(AntiAliasingMode.Dlss, s.AntiAliasing); + Assert.Equal("k", s.DlssPreset); + Assert.Equal(3, s.FrameGeneration); + Assert.Equal(2, s.Reflex); + Assert.True(s.RayTracing); + Assert.Equal(800, s.RayTracingRange); + Assert.Equal(3, s.Ssr); + Assert.Equal(1, s.AmbientOcclusion); + Assert.Equal((4, 6), (s.AoSlices, s.AoSteps)); + Assert.Equal(1, s.LightShadows); + Assert.Equal(3, s.ContactShadows); + Assert.False(s.VolumetricFog); + Assert.Equal(4, s.FogQuality); + Assert.Equal(4, s.Clouds); + Assert.False(s.CloudShadows); + Assert.False(s.Grass); + Assert.Equal(120, s.GrassDistance); + Assert.Equal(1.5, s.GrassDensity); + Assert.Equal(67, s.BufferScalePercent); + Assert.Equal("RTX", s.VulkanDevice); + } + + [Fact] + public void Missing_keys_take_the_simulators_defaults() + { + var s = Read(""); + + Assert.Equal(GraphicsBackend.OpenGL, s.Backend); + Assert.Equal(AntiAliasingMode.Msaa4, s.AntiAliasing); + Assert.Equal(2, s.Ssr); + Assert.Equal(2, s.AmbientOcclusion); + Assert.Equal(2, s.LightShadows); + Assert.Equal(0, s.ContactShadows); + Assert.True(s.VolumetricFog); + Assert.Equal(2, s.FogQuality); + Assert.Equal(2, s.Clouds); + Assert.False(s.RayTracing); + Assert.Equal(1, s.FrameGeneration); + } + + [Fact] + public void The_tokens_written_are_the_ones_the_simulator_parses() + { + var s = Read("gfxrenderer vulkan\n"); + s.AntiAliasing = AntiAliasingMode.Dlaa; + s.FrameGeneration = 4; + s.Reflex = 1; + s.LightShadows = 3; + s.ContactShadows = 0; + s.Clouds = 4; + s.VolumetricFog = false; + s.FogQuality = 1; + s.AmbientOcclusion = 1; + s.Ssr = 2; + s.RayTracing = true; + + string text = Write(s); + + Assert.Equal("gfx.antialiasing dlaa", Line(text, "gfx.antialiasing")); + Assert.Equal("multisampling 0", Line(text, "multisampling")); + Assert.Equal("gfx.framegeneration x4", Line(text, "gfx.framegeneration")); + Assert.Equal("gfx.reflex on", Line(text, "gfx.reflex")); + Assert.Equal("gfx.lights.shadows high", Line(text, "gfx.lights.shadows")); + Assert.Equal("gfx.contactshadows no", Line(text, "gfx.contactshadows")); + Assert.Equal("gfx.clouds ultra", Line(text, "gfx.clouds")); + Assert.Equal("gfx.fog analytic", Line(text, "gfx.fog")); + Assert.Equal("gfx.fog.quality low", Line(text, "gfx.fog.quality")); + Assert.Equal("gfx.postfx.ao ssao", Line(text, "gfx.postfx.ao")); + Assert.Equal("gfx.ssr medium", Line(text, "gfx.ssr")); + Assert.Equal("gfx.raytracing yes", Line(text, "gfx.raytracing")); + Assert.Equal("gfx.buffer.percent 100", Line(text, "gfx.buffer.percent")); + } + + [Fact] + public void Msaa_is_written_to_both_keys_for_older_simulators() + { + var s = Read("multisampling 1\n"); + Assert.Equal(AntiAliasingMode.Msaa2, s.AntiAliasing); + + string text = Write(s); + + Assert.Equal("multisampling 1", Line(text, "multisampling")); + Assert.Equal("gfx.antialiasing msaax2", Line(text, "gfx.antialiasing")); + } + + [Fact] + public void Of_the_two_anti_aliasing_keys_the_later_one_wins_as_in_the_simulator() + { + Assert.Equal(AntiAliasingMode.Taa, Read("multisampling 2\ngfx.antialiasing taa\n").AntiAliasing); + Assert.Equal(AntiAliasingMode.Msaa4, Read("gfx.antialiasing taa\nmultisampling 2\n").AntiAliasing); + } + + [Fact] + public void On_opengl_the_vulkan_only_features_are_written_off() + { + var s = Read("gfxrenderer full\ngfx.antialiasing dlss\ngfx.framegeneration x2\ngfx.reflex on\ngfx.raytracing yes\n"); + + Assert.Equal(AntiAliasingMode.Taa, s.AntiAliasing); + Assert.Equal(1, s.FrameGeneration); + Assert.Equal(0, s.Reflex); + Assert.False(s.RayTracing); + + string text = Write(s); + Assert.Equal("gfx.antialiasing taa", Line(text, "gfx.antialiasing")); + Assert.Equal("gfx.framegeneration off", Line(text, "gfx.framegeneration")); + Assert.Equal("gfx.reflex off", Line(text, "gfx.reflex")); + Assert.Equal("gfx.raytracing no", Line(text, "gfx.raytracing")); + } + + [Fact] + public void Frame_generation_needs_dlss_even_on_vulkan() + { + var s = Read("gfxrenderer vulkan\ngfx.antialiasing taa\ngfx.framegeneration x2\ngfx.reflex on\n"); + + Assert.Equal(1, s.FrameGeneration); + Assert.Equal(1, s.Reflex); + Assert.False(GraphicsRules.ReflexForced(s, true)); + } + + [Fact] + public void Frame_generation_turns_reflex_on() + { + var s = Read("gfxrenderer vulkan\ngfx.antialiasing dlss\ngfx.framegeneration x2\n"); + + Assert.True(GraphicsRules.ReflexForced(s, true)); + } + + [Fact] + public void Without_a_dlss_platform_dlss_falls_back_to_taa() + { + var s = Read("gfxrenderer vulkan\ngfx.antialiasing dlss\n", dlssPlatform: false); + + Assert.Equal(AntiAliasingMode.Taa, s.AntiAliasing); + } + + [Fact] + public void Dlaa_ignores_the_render_scale() + { + var s = Read("gfxrenderer vulkan\ngfx.antialiasing dlaa\n"); + Assert.False(GraphicsRules.BufferScaleUsed(s, true)); + + s.AntiAliasing = AntiAliasingMode.Dlss; + Assert.True(GraphicsRules.BufferScaleUsed(s, true)); + } + + [Fact] + public void Sizes_are_rounded_down_to_what_the_simulator_uses() + { + var s = Read("maxtexturesize 16384\nmaxcabtexturesize 3000\nshadowtune 3000 0 250 0\n"); + + Assert.Equal(8192, s.MaxTextureSize); + Assert.Equal(2048, s.MaxCabTextureSize); + Assert.Equal(2048, s.ShadowMapResolution); + } + + [Fact] + public void The_render_scale_reads_the_old_starter_key_when_the_new_one_is_missing() + { + Assert.Equal(75, Read("starter.bufferscale 75\n").BufferScalePercent); + Assert.Equal(50, Read("starter.bufferscale 75\ngfx.buffer.percent 50\n").BufferScalePercent); + } + + [Fact] + public void A_level_is_recognised_after_it_is_applied_and_survives_a_save() + { + for (int engine = 0; engine < SimulatorSettings.RenderEngines.Length; engine++) + { + foreach (var level in GraphicsPreset.All) + { + var installation = WithIni(""); + var store = installation.SettingsStore; + store.Settings.RenderEngine = engine; + level.ApplyTo(store.Settings); + Assert.Same(level, GraphicsPreset.Matching(store.Settings)); + + store.Save(); + + var again = new TestInstallation(installation.Files); + again.Environment.With("HOME", "/home/kolejarz"); + again.SettingsStore.Load(); + Assert.Same(level, GraphicsPreset.Matching(again.SettingsStore.Settings)); + } + } + } + + [Fact] + public void A_change_after_a_level_leaves_the_level() + { + var s = new SimulatorSettings(); + GraphicsPreset.High.ApplyTo(s); + s.Clouds = 4; + + Assert.Null(GraphicsPreset.Matching(s)); + } + + [Fact] + public void Levels_leave_alone_what_is_not_a_matter_of_quality() + { + var s = new SimulatorSettings { Width = 2560, Height = 1440, VSync = true, FieldOfView = 60, MotionBlur = false }; + + GraphicsPreset.Low.ApplyTo(s); + + Assert.Equal((2560, 1440, true, 60, false), (s.Width, s.Height, s.VSync, s.FieldOfView, s.MotionBlur)); + } + + [Fact] + public void Ray_tracing_comes_with_ultra_on_vulkan_only() + { + var vulkan = new SimulatorSettings { RenderEngine = SimulatorSettings.VulkanEngine }; + var opengl = new SimulatorSettings(); + + GraphicsPreset.Ultra.ApplyTo(vulkan); + GraphicsPreset.Ultra.ApplyTo(opengl); + + Assert.True(vulkan.RayTracing); + Assert.False(opengl.RayTracing); + Assert.Same(GraphicsPreset.Ultra, GraphicsPreset.Matching(opengl)); + } + + [Fact] + public void A_level_keeps_dlss_on_vulkan() + { + var s = new SimulatorSettings { RenderEngine = SimulatorSettings.VulkanEngine, AntiAliasing = AntiAliasingMode.Dlss }; + + GraphicsPreset.Ultra.ApplyTo(s); + + Assert.Equal(AntiAliasingMode.Dlss, s.AntiAliasing); + Assert.Same(GraphicsPreset.Ultra, GraphicsPreset.Matching(s)); + } + + [Fact] + public void A_level_replaces_taa_with_its_own_multisampling() + { + var s = new SimulatorSettings { AntiAliasing = AntiAliasingMode.Taa }; + + GraphicsPreset.Medium.ApplyTo(s); + + Assert.Equal(AntiAliasingMode.Msaa2, s.AntiAliasing); + } + + [Fact] + public void An_import_takes_the_settings_but_keeps_this_installations_simulator_and_the_unmanaged_lines() + { + var installation = WithIni("starter.exe.auto no\nstarter.exe.path eu07_local.exe\ncalibratein 0 0 0 0\ngfx.clouds low\n"); + installation.Files.WithFile("/backup/eu07.ini", "starter.exe.auto no\nstarter.exe.path eu07_other.exe\ngfx.clouds ultra\ngfxrenderer vulkan\n"); + + Assert.True(installation.SettingsStore.ImportFrom("/backup/eu07.ini")); + var s = installation.SettingsStore.Settings; + Assert.Equal(4, s.Clouds); + Assert.Equal(GraphicsBackend.Vulkan, s.Backend); + Assert.Equal("eu07_local.exe", s.ExecutablePath); + + // Nothing is written until saved, and the save keeps the user's own lines. + Assert.Contains("gfx.clouds low", installation.Files.ReadAllText(UserIni)); + installation.SettingsStore.Save(); + string saved = installation.Files.ReadAllText(UserIni); + Assert.Contains("calibratein 0 0 0 0", saved); + Assert.Contains("gfx.clouds ultra", saved); + Assert.Contains("starter.exe.path eu07_local.exe", saved); + } + + [Fact] + public void An_import_of_a_missing_file_changes_nothing() + { + var installation = WithIni("gfx.clouds low\n"); + + Assert.False(installation.SettingsStore.ImportFrom("/nowhere/eu07.ini")); + Assert.Equal(1, installation.SettingsStore.Settings.Clouds); + } + + [Fact] + public void An_export_writes_a_complete_file_elsewhere_and_leaves_the_users_one_alone() + { + var installation = WithIni("calibratein 0 0 0 0\ngfx.clouds low\n"); + installation.SettingsStore.Settings.Clouds = 3; + + Assert.True(installation.SettingsStore.ExportTo("/export/eu07.ini")); + + string exported = installation.Files.ReadAllText("/export/eu07.ini"); + Assert.Contains("calibratein 0 0 0 0", exported); + Assert.Contains("gfx.clouds high", exported); + Assert.Contains("gfx.clouds low", installation.Files.ReadAllText(UserIni)); + } + + [Fact] + public void An_export_keeps_the_older_renderer_word_of_the_file() + { + var installation = WithIni("gfxrenderer legacy\n"); + + Assert.True(installation.SettingsStore.ExportTo("/export/eu07.ini")); + + Assert.Contains("gfxrenderer legacy", installation.Files.ReadAllText("/export/eu07.ini")); + } + + [Fact] + public void The_dlss_libraries_are_looked_for_next_to_a_windows_build() + { + var files = new InMemoryFileSystem() + .WithDirectory(TestInstallation.Root) + .WithExecutable(TestInstallation.At("eu07.exe"), "MZ binary") + .WithFile(TestInstallation.At("nvngx_dlss.dll"), "dll"); + var installation = new TestInstallation(files); + + Assert.Equal((true, false), installation.Executables.DlssLibraries(TestInstallation.At("eu07.exe"))); + + files.WithFile(TestInstallation.At("nvngx_dlssg.dll"), "dll"); + Assert.Equal((true, true), installation.Executables.DlssLibraries(TestInstallation.At("eu07.exe"))); + } + + [Fact] + public void The_dlss_libraries_of_a_linux_build_are_the_shared_objects() + { + var files = new InMemoryFileSystem() + .WithDirectory(TestInstallation.Root) + .WithExecutable(TestInstallation.At("eu07"), "\u007fELF binary") + .WithFile(TestInstallation.At("libnvidia-ngx-dlssg.so.310.4.0"), "so"); + var installation = new TestInstallation(files); + + Assert.Equal((false, true), installation.Executables.DlssLibraries(TestInstallation.At("eu07"))); + } + + [Fact] + public void A_later_line_moved_up_keeps_the_index_of_every_key() + { + var c = ConfigFile.Parse("a 1\nb 2\nc 3\n"); + + c.MoveAfter("a", "c"); + + Assert.Equal("b 2\nc 3\na 1\n", c.ToText().Replace("\r\n", "\n")); + Assert.True(c.LineOf("a") > c.LineOf("c")); + c.Set("b", "9"); + Assert.Equal("b 9\nc 3\na 1\n", c.ToText().Replace("\r\n", "\n")); + } + + [Fact] + public void The_ao_lighting_and_cab_probe_keys_survive_a_round_trip_in_the_simulators_form() + { + var s = Read("gfx.postfx.ao.lighting no\ngfx.cabprobe high\ngfx.cabprobe.interval 12\ngfx.cabprobe.bounce 2.5\ngfx.cabprobe.reflections no\n"); + + Assert.False(s.AoLighting); + Assert.Equal(3, s.CabProbe); + Assert.Equal(12, s.CabProbeInterval); + Assert.Equal(2.5, s.CabProbeBounce); + Assert.False(s.CabProbeReflections); + + string text = Write(s); + Assert.Equal("gfx.postfx.ao.lighting no", Line(text, "gfx.postfx.ao.lighting")); + Assert.Equal("gfx.cabprobe high", Line(text, "gfx.cabprobe")); + Assert.Equal("gfx.cabprobe.interval 12", Line(text, "gfx.cabprobe.interval")); + Assert.Equal("gfx.cabprobe.bounce 2.5", Line(text, "gfx.cabprobe.bounce")); + Assert.Equal("gfx.cabprobe.reflections no", Line(text, "gfx.cabprobe.reflections")); + + var again = Read(text); + Assert.Equal((s.AoLighting, s.CabProbe, s.CabProbeInterval, s.CabProbeBounce, s.CabProbeReflections), + (again.AoLighting, again.CabProbe, again.CabProbeInterval, again.CabProbeBounce, again.CabProbeReflections)); + } + + [Fact] + public void Missing_ao_lighting_and_cab_probe_keys_take_the_simulators_defaults_and_are_written_like_its_export() + { + var s = Read(""); + + Assert.Equal((true, 2, 4, 1.0, true), (s.AoLighting, s.CabProbe, s.CabProbeInterval, s.CabProbeBounce, s.CabProbeReflections)); + + string text = Write(s); + Assert.Equal("gfx.postfx.ao.lighting yes", Line(text, "gfx.postfx.ao.lighting")); + Assert.Equal("gfx.cabprobe medium", Line(text, "gfx.cabprobe")); + Assert.Equal("gfx.cabprobe.interval 4", Line(text, "gfx.cabprobe.interval")); + Assert.Equal("gfx.cabprobe.bounce 1", Line(text, "gfx.cabprobe.bounce")); + Assert.Equal("gfx.cabprobe.reflections yes", Line(text, "gfx.cabprobe.reflections")); + } + + [Fact] + public void Unknown_ao_lighting_and_cab_probe_values_fall_back_to_the_simulators_defaults() + { + var s = Read("gfx.postfx.ao.lighting maybe\ngfx.cabprobe bogus\ngfx.cabprobe.interval abc\ngfx.cabprobe.bounce x\ngfx.cabprobe.reflections ?\n"); + + Assert.Equal((true, 2, 4, 1.0, true), (s.AoLighting, s.CabProbe, s.CabProbeInterval, s.CabProbeBounce, s.CabProbeReflections)); + } + + [Theory] + [InlineData("no", 0)] + [InlineData("0", 0)] + [InlineData("low", 1)] + [InlineData("1", 1)] + [InlineData("medium", 2)] + [InlineData("yes", 2)] + [InlineData("2", 2)] + [InlineData("high", 3)] + [InlineData("3", 3)] + public void The_cab_probe_level_is_read_with_the_aliases_the_simulator_takes(string value, int expected) + { + Assert.Equal(expected, Read($"gfx.cabprobe {value}\n").CabProbe); + } + + [Fact] + public void The_cab_probe_numbers_are_kept_within_the_simulators_ranges() + { + var s = Read("gfx.cabprobe.interval 500\ngfx.cabprobe.bounce 9\n"); + Assert.Equal((120, 4.0), (s.CabProbeInterval, s.CabProbeBounce)); + + s = Read("gfx.cabprobe.interval 0\ngfx.cabprobe.bounce -1\n"); + Assert.Equal((1, 0.0), (s.CabProbeInterval, s.CabProbeBounce)); + } + + [Fact] + public void The_ao_lighting_counts_with_gtao_only() + { + Assert.True(GraphicsRules.AoLightingUsed(Read("gfx.postfx.ao gtao\n"))); + Assert.False(GraphicsRules.AoLightingUsed(Read("gfx.postfx.ao ssao\n"))); + Assert.False(GraphicsRules.AoLightingUsed(Read("gfx.postfx.ao no\n"))); + } + + [Theory] + [InlineData("gfx.ssr no\ngfx.contactshadows no\n", true)] + [InlineData("gfx.ssr low\ngfx.contactshadows no\n", false)] + [InlineData("gfx.ssr no\ngfx.contactshadows low\n", false)] + [InlineData("gfx.ssr no\ngfx.contactshadows no\ngfx.depthprepass.scene yes\n", false)] + [InlineData("gfx.ssr no\ngfx.contactshadows no\ngfx.postfx.ao.lighting no\n", false)] + [InlineData("gfx.ssr no\ngfx.contactshadows no\ngfx.postfx.ao ssao\n", false)] + public void The_ao_lighting_adds_the_depth_pass_only_when_nothing_else_draws_it(string text, bool expected) + { + Assert.Equal(expected, GraphicsRules.AoLightingAddsDepthPrepass(Read(text))); + } + + [Theory] + [InlineData("gfx.cabprobe medium\n", true, true)] + [InlineData("gfx.cabprobe no\n", false, false)] + [InlineData("gfx.cabprobe low\ngfx.lights.clustered no\n", true, false)] + [InlineData("gfx.cabprobe high\ngfx.lights.cab no\n", true, false)] + public void The_cab_probe_lights_the_cab_only_with_its_lamp_as_a_light(string text, bool used, bool lights) + { + var s = Read(text); + + Assert.Equal(used, GraphicsRules.CabProbeUsed(s)); + Assert.Equal(lights, GraphicsRules.CabProbeLights(s)); + } + + [Theory] + [InlineData(0, false, 0, 8, false)] + [InlineData(1, false, 0, 8, false)] + [InlineData(2, false, 1, 8, true)] + [InlineData(3, true, 2, 4, true)] + [InlineData(4, true, 3, 3, true)] + [InlineData(5, true, 3, 2, true)] + public void A_level_sets_the_ao_lighting_and_the_cab_probe(int place, bool aoLighting, int probe, int interval, bool reflections) + { + var level = GraphicsPreset.All[place]; + var s = new SimulatorSettings { CabProbeBounce = 2.5 }; + + level.ApplyTo(s); + + Assert.Equal((aoLighting, probe, interval, reflections), (s.AoLighting, s.CabProbe, s.CabProbeInterval, s.CabProbeReflections)); + Assert.Equal(2.5, s.CabProbeBounce); + Assert.Same(level, GraphicsPreset.Matching(s)); + } + + [Fact] + public void An_import_keeps_the_lamp_switch_of_the_users_own_file() + { + var installation = WithIni("gfx.lights.cab no\n"); + installation.Files.WithFile("/backup/eu07.ini", "gfx.lights.cab yes\ngfx.cabprobe high\n"); + + Assert.True(installation.SettingsStore.ImportFrom("/backup/eu07.ini")); + + Assert.False(installation.SettingsStore.Settings.CabLampLight); + Assert.Equal(3, installation.SettingsStore.Settings.CabProbe); + } +} diff --git a/StarterNG.Tests/HaslerTapeTests.cs b/StarterNG.Tests/HaslerTapeTests.cs new file mode 100644 index 0000000..6b752b9 --- /dev/null +++ b/StarterNG.Tests/HaslerTapeTests.cs @@ -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); + } +} diff --git a/StarterNG.Tests/KeyMapTests.cs b/StarterNG.Tests/KeyMapTests.cs new file mode 100644 index 0000000..afd1351 --- /dev/null +++ b/StarterNG.Tests/KeyMapTests.cs @@ -0,0 +1,119 @@ +using StarterNG.Classes; + +namespace StarterNG.Tests; + +public class KeyMapTests +{ + /// The names of the simulator's key table, keytonamemap in input/keyboardinput.cpp. + private static readonly HashSet 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(); + + 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)); + } +} diff --git a/StarterNG.Tests/KeyboardConfigTests.cs b/StarterNG.Tests/KeyboardConfigTests.cs index 7f20bac..b5c12cd 100644 --- a/StarterNG.Tests/KeyboardConfigTests.cs +++ b/StarterNG.Tests/KeyboardConfigTests.cs @@ -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()); + } } diff --git a/StarterNG.Tests/LocalizationTests.cs b/StarterNG.Tests/LocalizationTests.cs new file mode 100644 index 0000000..cdb5b90 --- /dev/null +++ b/StarterNG.Tests/LocalizationTests.cs @@ -0,0 +1,251 @@ +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 Strings(string path) => + XDocument.Load(path).Descendants("String") + .ToDictionary(s => (string)s.Attribute("key")!, s => s.Value); + + public static TheoryData Languages() + { + var data = new TheoryData(); + 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 = + "Hello"; + + /// A file cut off right after its header: it still looks like a language. + private const string CutShort = ""; + + 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", "Ahoj"), + ("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); + } + } + + /// A player's folder, the bundled one with English in it, the old starter's and the simulator's. + 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"), + "Czesc"); + File.WriteAllText(Path.Combine(folders.Own, "moj.xml"), + "Witaj"); + 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 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 An_old_starters_key_descriptions_are_read_in_its_own_code_page() + { + using var folders = new Folders(); + File.WriteAllText(Path.Combine(folders.Bundled, "en.xml"), English); + File.WriteAllBytes(Path.Combine(folders.OldStarter, "lang-cz.txt"), LegacyText.CodePage1250.GetBytes( + "[labels]\r\n 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"), + "Ine"); + var loc = folders.Service(); + + loc.Load("sk"); + + Assert.Equal("Hello", loc["Hello"]); + Assert.Equal("Ine", loc["Other"]); + } +} diff --git a/StarterNG.Tests/PhysicalFileSystemTests.cs b/StarterNG.Tests/PhysicalFileSystemTests.cs new file mode 100644 index 0000000..1d0e85c --- /dev/null +++ b/StarterNG.Tests/PhysicalFileSystemTests.cs @@ -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(() => _files.WriteAllTextAtomic(path, "half")); + + Assert.Equal("what stood there", File.ReadAllText(path)); + } +} diff --git a/StarterNG.Tests/PhysicsAndAssetsTests.cs b/StarterNG.Tests/PhysicsAndAssetsTests.cs index 1d38874..4661e24 100644 --- a/StarterNG.Tests/PhysicsAndAssetsTests.cs +++ b/StarterNG.Tests/PhysicsAndAssetsTests.cs @@ -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() { diff --git a/StarterNG.Tests/PresetFileTests.cs b/StarterNG.Tests/PresetFileTests.cs new file mode 100644 index 0000000..7886e7c --- /dev/null +++ b/StarterNG.Tests/PresetFileTests.cs @@ -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); + } + + /// Lets the file go the moment its read has failed and been logged. + 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 messages) => _files.AllowReading(_path); + public void Clear() { } + public void Flush() { } + } +} diff --git a/StarterNG.Tests/ResolutionChoicesTests.cs b/StarterNG.Tests/ResolutionChoicesTests.cs new file mode 100644 index 0000000..668e132 --- /dev/null +++ b/StarterNG.Tests/ResolutionChoicesTests.cs @@ -0,0 +1,103 @@ +using StarterNG.Domain.Settings; + +namespace StarterNG.Tests; + +public class ResolutionChoicesTests +{ + private static List 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(), 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); + } +} diff --git a/StarterNG.Tests/RevisionSpanTests.cs b/StarterNG.Tests/RevisionSpanTests.cs new file mode 100644 index 0000000..c6bf08c --- /dev/null +++ b/StarterNG.Tests/RevisionSpanTests.cs @@ -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)); +} diff --git a/StarterNG.Tests/SceneryExportTests.cs b/StarterNG.Tests/SceneryExportTests.cs index 4fdbb1c..2bc93e9 100644 --- a/StarterNG.Tests/SceneryExportTests.cs +++ b/StarterNG.Tests/SceneryExportTests.cs @@ -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()); + } } diff --git a/StarterNG.Tests/SceneryFaultTextTests.cs b/StarterNG.Tests/SceneryFaultTextTests.cs new file mode 100644 index 0000000..9a94b0a --- /dev/null +++ b/StarterNG.Tests/SceneryFaultTextTests.cs @@ -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); + } +} diff --git a/StarterNG.Tests/SceneryFileWatchTests.cs b/StarterNG.Tests/SceneryFileWatchTests.cs new file mode 100644 index 0000000..d33bd3f --- /dev/null +++ b/StarterNG.Tests/SceneryFileWatchTests.cs @@ -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); + } +} diff --git a/StarterNG.Tests/SceneryParserTests.cs b/StarterNG.Tests/SceneryParserTests.cs index 5a5dd84..75cd996 100644 --- a/StarterNG.Tests/SceneryParserTests.cs +++ b/StarterNG.Tests/SceneryParserTests.cs @@ -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())); + } } diff --git a/StarterNG.Tests/SceneryRepositoryTests.cs b/StarterNG.Tests/SceneryRepositoryTests.cs index facaa7e..08dbfcb 100644 --- a/StarterNG.Tests/SceneryRepositoryTests.cs +++ b/StarterNG.Tests/SceneryRepositoryTests.cs @@ -98,4 +98,25 @@ public class SceneryRepositoryTests Assert.Null(new SceneryImageLocator(files).Resolve(scenery)); } + + [Fact] + public void The_copy_written_for_a_start_keeps_each_file_in_its_own_encoding() + { + const string cars = + "trainset none droga 0 0\r\n" + + "node -1 0 żuk dynamic road\\zuk zuk zuk 0 headdriver 0 0 enddynamic\r\n" + + "endtrainset\r\n"; + var files = new InMemoryFileSystem() + .WithLegacyFile(Path.Combine("game", "scenery", "kato.scn"), + "//$n Łódź\r\nFirstInit\r\ninclude auta.scm end\r\n") + .WithFile(Path.Combine("game", "scenery", "auta.scm"), cars); + var scenery = Assert.Single(Build(files, out _).LoadAll()); + + byte[] exported = scenery.BuildExportBytes(skipDecorTrainsets: false, new StubRandom()); + + static bool Has(byte[] text, byte[] part) => text.AsSpan().IndexOf(part) >= 0; + Assert.True(Has(exported, LegacyText.CodePage1250.GetBytes("//$n Łódź\r\n"))); + Assert.True(Has(exported, Encoding.UTF8.GetBytes("żuk dynamic"))); + Assert.False(Has(exported, Encoding.UTF8.GetBytes("Łódź"))); + } } diff --git a/StarterNG.Tests/ScenerySyntaxTests.cs b/StarterNG.Tests/ScenerySyntaxTests.cs new file mode 100644 index 0000000..3052cd3 --- /dev/null +++ b/StarterNG.Tests/ScenerySyntaxTests.cs @@ -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); + } +} diff --git a/StarterNG.Tests/SceneryTrainsetSaveTests.cs b/StarterNG.Tests/SceneryTrainsetSaveTests.cs new file mode 100644 index 0000000..2f18b08 --- /dev/null +++ b/StarterNG.Tests/SceneryTrainsetSaveTests.cs @@ -0,0 +1,580 @@ +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"; + + // The application's own, which registers the code pages first; this class may be + // the first to need them, before any InMemoryFileSystem exists. + private static Encoding Cp1250 => LegacyText.CodePage1250; + + 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? 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 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); + } + + /// A disk that gives out on whatever is being written; what it does to the file is ' business. + 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(); + ((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()), + 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[] 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")); + } +} diff --git a/StarterNG.Tests/SceneryWeatherTests.cs b/StarterNG.Tests/SceneryWeatherTests.cs index 4e968c4..35a313e 100644 --- a/StarterNG.Tests/SceneryWeatherTests.cs +++ b/StarterNG.Tests/SceneryWeatherTests.cs @@ -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 _); + + /// The block the writer puts at the top of the text, and the rest. + 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); + } } diff --git a/StarterNG.Tests/SettingReachTests.cs b/StarterNG.Tests/SettingReachTests.cs new file mode 100644 index 0000000..68d633e --- /dev/null +++ b/StarterNG.Tests/SettingReachTests.cs @@ -0,0 +1,154 @@ +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("vulkan", IdleReason.None)] + [InlineData("vulkan14", IdleReason.None)] + [InlineData("full", IdleReason.VulkanOnly)] + [InlineData("gl46", IdleReason.VulkanOnly)] + [InlineData("legacy", IdleReason.VulkanOnly)] + public void What_only_vulkan_reads_follows_the_renderer(string renderer, IdleReason expected) + { + Assert.Equal(expected, SettingReach.Why("gfx.dlss.preset", Linux(renderer))); + Assert.Equal(expected, SettingReach.Why("gfx.framegeneration", Linux(renderer))); + Assert.Equal(expected, SettingReach.Why("gfx.raytracing", Linux(renderer))); + // Both renderers of the new simulator read this one. + Assert.Equal(IdleReason.None, SettingReach.Why("gfx.postfx.ao", Linux(renderer))); + } + + [Theory] + [InlineData("vulkan")] + [InlineData("vulkan14")] + [InlineData("opengl")] + [InlineData("gl46")] + public void The_new_simulators_renderers_read_what_the_full_one_does(string renderer) + { + Assert.Equal(IdleReason.None, SettingReach.Why("gfx.envmap.enabled", Linux(renderer))); + Assert.Equal(IdleReason.None, SettingReach.Why("multisampling", 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); + } +} diff --git a/StarterNG.Tests/SettingsStoreTests.cs b/StarterNG.Tests/SettingsStoreTests.cs index f5501e5..66ce35c 100644 --- a/StarterNG.Tests/SettingsStoreTests.cs +++ b/StarterNG.Tests/SettingsStoreTests.cs @@ -1,3 +1,4 @@ +using StarterNG.Application; using StarterNG.Domain.Settings; using StarterNG.Tests.Fakes; @@ -15,6 +16,30 @@ public class SettingsStoreTests return installation; } + /// + /// The saved file with each of its lines ended by \n, whichever ending the host + /// writes, so a check for "key value\n" holds the line to exactly that. + /// + private static string SavedLines(TestInstallation installation) => + installation.Files.ReadAllText(UserIni).Replace("\r\n", "\n") + "\n"; + + /// What the file says for a key, its value included; null when no line sets it. + private static string? LineFor(TestInstallation installation, string key) => + SavedLines(installation).Split('\n') + .SingleOrDefault(line => line == key || line.StartsWith(key + " ")); + + /// + /// Simulator keys left with no value. The simulator would take the next word of + /// the file for it; a starter.* key is exempt, as it skips those. + /// + private static string[] BareSimulatorKeys(TestInstallation installation) => + SavedLines(installation).Split('\n') + .Select(line => line.Trim()) + .Where(line => line.Length > 0 && !line.StartsWith("//") && !line.Any(char.IsWhiteSpace)) + .Where(key => !key.StartsWith("starter.", StringComparison.OrdinalIgnoreCase) && + !key.Equals("endconfig", StringComparison.OrdinalIgnoreCase)) + .ToArray(); + [Fact] public void Settings_are_read_from_the_users_ini() { @@ -60,6 +85,54 @@ public class SettingsStoreTests Assert.Contains("lang zh", installation.Files.ReadAllText(UserIni)); } + [Fact] + public void Uart_features_with_nothing_selected_are_written_out_in_full() + { + var installation = WithHome(); + installation.SettingsStore.Load(); + var settings = installation.SettingsStore.Settings; + settings.UartMain = settings.UartScnd = settings.UartTrain = settings.UartLocal = false; + + installation.SettingsStore.Save(); + + var lines = installation.Files.ReadAllText(UserIni).Split('\n').Select(l => l.Trim()); + Assert.Contains("uartfeature no no no no", lines); + Assert.DoesNotContain("uartfeature", lines); + } + + [Theory] + [InlineData("uartfeature\nuartdebug no\n", false, false)] + [InlineData("uartfeature none\n", false, false)] + [InlineData("uartfeature no no no no\n", false, false)] + [InlineData("uartfeature yes yes yes yes\n", true, true)] + [InlineData("uartfeature YES no no no\n", true, false)] + [InlineData("uartfeature main|scnd|\n", true, true)] + public void Uart_features_are_read_in_every_form_the_simulator_takes(string file, bool main, bool scnd) + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, file)); + + installation.SettingsStore.Load(); + + Assert.Equal(main, installation.SettingsStore.Settings.UartMain); + Assert.Equal(scnd, installation.SettingsStore.Settings.UartScnd); + } + + [Theory] + [InlineData("")] + [InlineData("uartdebug no")] + [InlineData("1 2 3")] + public void A_uart_tune_that_is_not_eighteen_numbers_is_not_written(string tune) + { + var installation = WithHome(); + installation.SettingsStore.Load(); + installation.SettingsStore.Settings.UartTune = tune; + + installation.SettingsStore.Save(); + + Assert.DoesNotContain(installation.Files.ReadAllText(UserIni).Split('\n'), + line => line.TrimStart().StartsWith("uarttune", StringComparison.Ordinal)); + } + [Fact] public void With_no_language_recorded_the_file_still_names_one() { @@ -150,6 +223,519 @@ public class SettingsStoreTests Assert.Equal(60, reopened.SettingsStore.Settings.FieldOfView); } + [Theory] + [InlineData("uarttune uartdebug no\n", "")] + [InlineData("uarttune 1 2 3\n", "1 2 3")] + public void A_calibration_is_shown_only_if_the_file_holds_numbers_for_it(string line, string shown) + { + // The next key of the file on the same line is no calibration, but a wrong count of numbers is one to fix. + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, line)); + + installation.SettingsStore.Load(); + + Assert.Equal(shown, installation.SettingsStore.Settings.UartTune); + } + + [Theory] + [InlineData("uarttune COM3\n", null)] + [InlineData("", null)] + [InlineData("uarttune 0 65535 0 65535 10 65535 10 65535 10 65535 10 65535 100000 65535 10000 65535 150 65535\n", + "uarttune 0 65535 0 65535 10 65535 10 65535 10 65535 10 65535 100000 65535 10000 65535 150 65535")] + public void The_uart_calibration_is_written_only_as_its_eighteen_numbers(string line, string? written) + { + // Anything else and the simulator reads the next keys of the file for them: + // maxcabtexturesize 2048 went so into one. + var installation = WithHome(new InMemoryFileSystem() + .WithFile(UserIni, line + "maxcabtexturesize 2048\n")); + installation.SettingsStore.Load(); + + installation.SettingsStore.Save(); + + var lines = installation.Files.ReadAllText(UserIni).Split('\n').Select(l => l.TrimEnd('\r')); + Assert.Equal(written, lines.SingleOrDefault(l => l.StartsWith("uarttune"))); + } + + [Fact] + public void The_uart_line_names_port_speed_and_interval() + { + var installation = WithHome(new InMemoryFileSystem() + .WithFile(UserIni, "uart /dev/ttyACM0 115200 0.05\n")); + installation.SettingsStore.Load(); + var s = installation.SettingsStore.Settings; + + Assert.True(s.UartEnabled); + Assert.Equal("/dev/ttyACM0", s.UartPort); + Assert.Equal(115200, s.UartBaud); + Assert.Equal(0.05, s.UartUpdateTime); + + s.UartPort = "COM3"; + installation.SettingsStore.Save(); + + Assert.Contains("uart COM3 115200 0.05", installation.Files.ReadAllText(UserIni)); + } + + [Fact] + public void A_bare_uart_port_is_completed_rather_than_left_to_swallow_the_next_keys() + { + // The simulator reads three words; "uart COM3" alone took the next line's key + // and value for the speed and interval. + var installation = WithHome(new InMemoryFileSystem() + .WithFile(UserIni, "uart COM3\nmaxcabtexturesize 2048\n")); + installation.SettingsStore.Load(); + + installation.SettingsStore.Save(); + + var text = installation.Files.ReadAllText(UserIni); + Assert.Contains($"uart COM3 {Uart.DefaultBaud} 0.1", text); + Assert.Contains("maxcabtexturesize 2048", text); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("COM 3")] + public void Without_a_one_word_port_no_uart_line_is_written(string port) + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "uart COM3 57600 0.1\n")); + installation.SettingsStore.Load(); + installation.SettingsStore.Settings.UartPort = port; + + installation.SettingsStore.Save(); + + var lines = installation.Files.ReadAllText(UserIni).Split('\n'); + Assert.DoesNotContain(lines, l => l.StartsWith("uart ")); + } + + [Fact] + public void A_disabled_uart_leaves_no_uart_line() + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "uart COM3 57600 0.1\n")); + installation.SettingsStore.Load(); + installation.SettingsStore.Settings.UartEnabled = false; + + installation.SettingsStore.Save(); + + var lines = installation.Files.ReadAllText(UserIni).Split('\n'); + Assert.DoesNotContain(lines, l => l.StartsWith("uart ")); + } + + [Fact] + public void A_disabled_uart_is_one_change() + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "uart COM3 57600 0.1\n")); + installation.SettingsStore.Load(); + installation.SettingsStore.Settings.UartEnabled = false; + + var changes = installation.SettingsStore.Changes(installation.SettingsStore.Settings); + + Assert.Equal(new IniChange("uart", "COM3 57600 0.1", null), Assert.Single(changes, c => c.Key == "uart")); + } + + [Fact] + public void The_graphics_card_choice_survives_a_round_trip() + { + var installation = WithHome(); + installation.SettingsStore.Load(); + Assert.Equal(GpuPreference.Discrete, installation.SettingsStore.Settings.Gpu); + + installation.SettingsStore.Settings.Gpu = GpuPreference.System; + installation.SettingsStore.Save(); + + Assert.Contains("starter.gpu system", installation.Files.ReadAllText(UserIni)); + var reopened = WithHome(installation.Files); + reopened.SettingsStore.Load(); + Assert.Equal(GpuPreference.System, reopened.SettingsStore.Settings.Gpu); + } + + [Fact] + public void A_silenced_processor_notice_stays_silent() + { + var installation = WithHome(); + installation.SettingsStore.Load(); + Assert.True(installation.SettingsStore.Settings.CpuNotice); + + installation.SettingsStore.Settings.CpuNotice = false; + installation.SettingsStore.Save(); + + var reopened = WithHome(installation.Files); + reopened.SettingsStore.Load(); + Assert.False(reopened.SettingsStore.Settings.CpuNotice); + } + + [Fact] + public void Uart_features_all_off_are_written_as_four_noes() + { + // "none" only newer simulators read; every build reads the four words. + var installation = WithHome(); + installation.SettingsStore.Load(); + var s = installation.SettingsStore.Settings; + s.UartMain = s.UartScnd = s.UartTrain = s.UartLocal = s.UartRadioVolume = s.UartRadioChannel = false; + + installation.SettingsStore.Save(); + + Assert.Equal("uartfeature no no no no", LineFor(installation, "uartfeature")); + } + + [Fact] + public void Dynamic_brake_alone_is_written_in_the_pipe_form() + { + // The four words cannot say it, and would switch it off. + var installation = WithHome(); + installation.SettingsStore.Load(); + var s = installation.SettingsStore.Settings; + s.UartMain = s.UartScnd = s.UartTrain = s.UartLocal = false; + s.UartDynamicBrake = true; + + installation.SettingsStore.Save(); + + Assert.Equal("uartfeature dynamicbrake|", LineFor(installation, "uartfeature")); + } + + [Theory] + // Globals.cpp: a first word with a pipe, "none" or a feature's name is the list. + [InlineData("main|scnd|train|local|radiovolume|radiochannel|dynamicbrake", "main scnd train local radiovolume radiochannel dynamicbrake")] + [InlineData("main|dynamicbrake|", "main dynamicbrake")] + [InlineData("MAIN|Train", "main train")] + [InlineData("main", "main")] + [InlineData("dynamicbrake", "dynamicbrake")] + [InlineData("nonsense|local", "local")] + [InlineData("none", "")] + [InlineData("NONE", "")] + // Anything else four words, for the first four features: parser.cpp takes true, yes and 1. + [InlineData("yes yes yes yes", "main scnd train local")] + [InlineData("no no no no", "")] + [InlineData("YES no Yes NO", "main train")] + [InlineData("1 0 1 0", "main train")] + [InlineData("true false false true", "main local")] + // Bare: nothing selected, whatever the next line says. + [InlineData("", "")] + public void A_uartfeature_line_is_read_as_the_simulator_reads_it(string value, string features) + { + var installation = WithHome(new InMemoryFileSystem() + .WithFile(UserIni, $"uartfeature {value}\nwidth 800\n")); + + installation.SettingsStore.Load(); + + var s = installation.SettingsStore.Settings; + var on = new List(); + if (s.UartMain) on.Add("main"); + if (s.UartScnd) on.Add("scnd"); + if (s.UartTrain) on.Add("train"); + if (s.UartLocal) on.Add("local"); + if (s.UartRadioVolume) on.Add("radiovolume"); + if (s.UartRadioChannel) on.Add("radiochannel"); + if (s.UartDynamicBrake) on.Add("dynamicbrake"); + Assert.Equal(features, string.Join(' ', on)); + Assert.Equal(800, s.Width); + } + + [Theory] + [InlineData("main|scnd|train|local|radiovolume|radiochannel|dynamicbrake")] + [InlineData("main|dynamicbrake")] + [InlineData("dynamicbrake")] + [InlineData("yes yes yes yes")] + [InlineData("no yes no YES")] + [InlineData("no no no no")] + public void A_uartfeature_line_nobody_changed_stays_as_the_file_has_it(string value) + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, $"uartfeature {value}\n")); + installation.SettingsStore.Load(); + + installation.SettingsStore.Save(); + + Assert.Equal($"uartfeature {value}", LineFor(installation, "uartfeature")); + } + + [Theory] + [InlineData("uartfeature none", "uartfeature no no no no")] + [InlineData("uartfeature", "uartfeature no no no no")] + // Short of its four words, the simulator would read the next key for the rest. + [InlineData("uartfeature yes yes", "uartfeature main|scnd|")] + [InlineData("uartfeature nonsense", "uartfeature no no no no")] + public void A_uartfeature_line_not_every_simulator_reads_whole_is_put_right(string line, string written) + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, line + "\nmaxcabtexturesize 2048\n")); + installation.SettingsStore.Load(); + + installation.SettingsStore.Save(); + + Assert.Equal(written, LineFor(installation, "uartfeature")); + Assert.Equal("maxcabtexturesize 2048", LineFor(installation, "maxcabtexturesize")); + } + + [Theory] + [InlineData("yes yes yes yes", "uartfeature scnd|train|local|")] + [InlineData("main|dynamicbrake", "uartfeature dynamicbrake|")] + public void A_uart_feature_taken_off_rewrites_the_line_in_the_pipe_form(string value, string written) + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, $"uartfeature {value}\n")); + installation.SettingsStore.Load(); + installation.SettingsStore.Settings.UartMain = false; + + installation.SettingsStore.Save(); + + Assert.Equal(written, LineFor(installation, "uartfeature")); + } + + [Fact] + public void The_uart_interval_is_shown_within_what_its_box_holds() + { + Assert.Equal(0.05m, Uart.ShownUpdateTime(0.05)); + // 1e30 is more than a decimal can hold: the window died on opening. + Assert.Equal(10m, Uart.ShownUpdateTime(1e30)); + Assert.Equal(10m, Uart.ShownUpdateTime(double.PositiveInfinity)); + Assert.Equal((decimal)Uart.DefaultUpdateTime, Uart.ShownUpdateTime(double.NaN)); + Assert.Equal(0m, Uart.ShownUpdateTime(-1)); + } + + [Theory] + [InlineData("mousescale 1.5 0.5", false, true, "mousescale 1.5 -0.5")] + [InlineData("mousescale 1.5 0.5", true, false, "mousescale -1.5 0.5")] + [InlineData("mousescale -1.5 -0.5", false, false, "mousescale 1.5 0.5")] + [InlineData("mousescale 2.5 0.123456", false, true, "mousescale 2.5 -0.123456")] + // Further than the slider goes. + [InlineData("mousescale 12 0.5", true, false, "mousescale -12 0.5")] + public void A_mouse_scale_keeps_its_numbers_when_an_axis_is_inverted( + string line, bool invertX, bool invertY, string written) + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, line + "\n")); + installation.SettingsStore.Load(); + installation.SettingsStore.Settings.InvertMouseHorizontal = invertX; + installation.SettingsStore.Settings.InvertMouseVertical = invertY; + + installation.SettingsStore.Save(); + + Assert.Equal(written, LineFor(installation, "mousescale")); + } + + [Fact] + public void A_mouse_scale_nobody_changed_stays_as_the_file_has_it() + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "mousescale 1.5 0.5\n")); + installation.SettingsStore.Load(); + + installation.SettingsStore.Save(); + + Assert.Equal("mousescale 1.5 0.5", LineFor(installation, "mousescale")); + } + + [Fact] + public void Moving_the_sensitivity_replaces_the_scale_it_stood_for() + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "mousescale 1.5 0.5\n")); + installation.SettingsStore.Load(); + Assert.Equal(2, installation.SettingsStore.Settings.CursorSensitivity); + installation.SettingsStore.Settings.CursorSensitivity = 3; + + installation.SettingsStore.Save(); + + Assert.Equal("mousescale 3 0.5", LineFor(installation, "mousescale")); + } + + [Fact] + public void The_default_mouse_scale_is_written_as_before() + { + var installation = WithHome(); + installation.SettingsStore.Load(); + + installation.SettingsStore.Save(); + + Assert.Equal("mousescale 2 0.2", LineFor(installation, "mousescale")); + } + + // ── A file that could not be read ──────────────────────────────────────── + + [Fact] + public void A_file_that_could_not_be_read_is_not_saved_over() + { + const string held = "width 1920\nsomethingexotic 42\n"; + var files = new InMemoryFileSystem().WithFile(UserIni, held).FailReading(UserIni); + var installation = WithHome(files); + installation.SettingsStore.Load(); + Assert.True(installation.SettingsStore.LoadFailed); + Assert.Equal(1280, installation.SettingsStore.Settings.Width); + + installation.SettingsStore.Save(); + installation.SettingsStore.CaptureAndSave(); + + files.AllowReading(UserIni); + Assert.Equal(held, files.ReadAllText(UserIni)); + Assert.Contains("not saved", installation.Log.Text); + // The screen must not say "saved" over this. + Assert.True(installation.SettingsStore.LastSaveFailed); + + installation.SettingsStore.Load(); + installation.SettingsStore.Save(); + Assert.False(installation.SettingsStore.LastSaveFailed); + } + + [Fact] + public void A_profile_of_the_settings_is_not_written_from_defaults_either() + { + const string profile = "/home/kolejarz/profile.ini"; + var files = new InMemoryFileSystem().WithFile(UserIni, "width 1920\n").FailReading(UserIni); + var installation = WithHome(files); + installation.SettingsStore.Load(); + + Assert.False(installation.SettingsStore.ExportTo(profile)); + + Assert.False(files.FileExists(profile)); + } + + [Fact] + public void A_profile_written_leaves_what_the_users_file_still_lacks_to_be_saved() + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "uart COM3 57600 0.1\n")); + installation.SettingsStore.Load(); + installation.SettingsStore.Settings.UartPort = "COM4"; + + Assert.True(installation.SettingsStore.ExportTo("/home/kolejarz/profile.ini")); + + Assert.Contains(new IniChange("uart", "COM3 57600 0.1", "COM4 57600 0.1"), + installation.SettingsStore.Changes(installation.SettingsStore.Settings)); + Assert.Contains("uart COM3 57600 0.1", installation.Files.ReadAllText(UserIni)); + } + + [Fact] + public void A_read_that_works_again_lets_the_settings_be_saved() + { + var files = new InMemoryFileSystem().WithFile(UserIni, "width 1920\n").FailReading(UserIni); + var installation = WithHome(files); + installation.SettingsStore.Load(); + + files.AllowReading(UserIni); + installation.SettingsStore.Load(); + Assert.False(installation.SettingsStore.LoadFailed); + installation.SettingsStore.Settings.Height = 1080; + installation.SettingsStore.Save(); + + Assert.Equal("width 1920", LineFor(installation, "width")); + Assert.Equal("height 1080", LineFor(installation, "height")); + } + + [Fact] + public void A_shipped_ini_that_could_not_be_read_leaves_no_file_of_defaults_in_its_way() + { + // The user's own file, once there, is the one read from then on. + var files = new InMemoryFileSystem() + .WithFile(TestInstallation.At("eu07.ini"), "width 800\n") + .FailReading(TestInstallation.At("eu07.ini")); + var installation = WithHome(files); + installation.SettingsStore.Load(); + + installation.SettingsStore.Save(); + + Assert.False(files.FileExists(UserIni)); + } + + // ── endconfig ──────────────────────────────────────────────────────────── + + [Fact] + public void A_key_the_file_lacks_goes_before_endconfig() + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "width 1920\nendconfig\n")); + installation.SettingsStore.Load(); + + installation.SettingsStore.Save(); + + var lines = SavedLines(installation).Split('\n').ToList(); + int end = lines.IndexOf("endconfig"); + Assert.True(end > lines.IndexOf("fullscreen no") && lines.IndexOf("fullscreen no") >= 0); + Assert.DoesNotContain(lines.Skip(end + 1), line => line.Trim().Length > 0); + } + + [Fact] + public void A_key_an_older_save_left_past_endconfig_moves_before_it() + { + // Set there by this starter's own earlier save, and never read by the simulator. + var installation = WithHome(new InMemoryFileSystem() + .WithFile(UserIni, "width 1920\nendconfig\nfieldofview 60\nnotes about this file\n")); + var store = installation.SettingsStore; + store.Load(); + Assert.Equal(60, store.Settings.FieldOfView); + Assert.Contains(new StarterNG.Application.IniChange("fieldofview", null, "60"), store.Changes(store.Settings)); + + store.Save(); + + var lines = SavedLines(installation).Split('\n').ToList(); + Assert.True(lines.IndexOf("fieldofview 60") < lines.IndexOf("endconfig")); + Assert.Single(lines, "fieldofview 60"); + // What is no key of the starter's stays where the simulator does not read it. + Assert.True(lines.IndexOf("notes about this file") > lines.IndexOf("endconfig")); + } + + [Fact] + public void A_disabled_uart_past_endconfig_is_taken_out_rather_than_moved() + { + var installation = WithHome(new InMemoryFileSystem() + .WithFile(UserIni, "endconfig\nuart COM3 57600 0.1\n")); + installation.SettingsStore.Load(); + installation.SettingsStore.Settings.UartEnabled = false; + + installation.SettingsStore.Save(); + + var lines = installation.Files.ReadAllText(UserIni).Replace("\r\n", "\n").Split('\n'); + Assert.DoesNotContain(lines, l => l.StartsWith("uart ")); + } + + // ── No bare keys ───────────────────────────────────────────────────────── + + [Fact] + public void Saving_the_default_settings_leaves_no_simulator_key_bare() + { + // A bare key takes the next word of the file for its value: uartfeature the + // next four, which is how keys after it went unread. + var installation = WithHome(); + installation.SettingsStore.Load(); + + installation.SettingsStore.Save(); + + Assert.Empty(BareSimulatorKeys(installation)); + } + + [Fact] + public void Everything_emptied_leaves_no_simulator_key_bare() + { + var installation = WithHome(); + installation.SettingsStore.Load(); + var s = installation.SettingsStore.Settings; + s.LanguageCode = " "; + s.UartEnabled = true; + s.UartPort = " "; + s.UartTune = " "; + s.UartMain = s.UartScnd = s.UartTrain = s.UartLocal = s.UartRadioVolume = s.UartRadioChannel = false; + s.ExecutablePath = ""; + s.LastScenery = ""; + foreach (var key in SimulatorKeys.All) + s.Extra[key.Key] = " "; + + installation.SettingsStore.Save(); + + Assert.Empty(BareSimulatorKeys(installation)); + } + + [Theory] + [InlineData("uart")] + [InlineData("uarttune")] + [InlineData("uartfeature")] + [InlineData("mousescale")] + [InlineData("fullscreen")] + [InlineData("width")] + [InlineData("screenshotsdir")] + [InlineData("fpslimit")] + [InlineData("gfx.angleplatform")] + public void A_key_the_file_itself_leaves_bare_does_not_stay_so(string key) + { + var installation = WithHome(new InMemoryFileSystem() + .WithFile(UserIni, $"{key}\nmaxcabtexturesize 2048\n")); + installation.SettingsStore.Load(); + + installation.SettingsStore.Save(); + + Assert.Empty(BareSimulatorKeys(installation)); + Assert.Equal("maxcabtexturesize 2048", LineFor(installation, "maxcabtexturesize")); + } + [Fact] public void Keys_the_starter_does_not_manage_survive_a_round_trip() { @@ -197,6 +783,240 @@ public class SettingsStoreTests installation.SettingsPaths.UserConfigPath()); } + [Theory] + [InlineData("144")] + [InlineData("360")] + [InlineData("59.94")] + public void A_frame_rate_limit_the_slider_once_cut_to_100_survives_a_save(string limit) + { + // The simulator takes any rate above zero. + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, $"fpslimit {limit}\n")); + installation.SettingsStore.Load(); + Assert.True(installation.SettingsStore.Settings.FpsLimitEnabled); + + installation.SettingsStore.Save(); + + Assert.Contains($"fpslimit {limit}", installation.Files.ReadAllText(UserIni)); + } + + [Fact] + public void A_frame_rate_limit_of_zero_is_no_limit() + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "fpslimit 0\n")); + installation.SettingsStore.Load(); + + Assert.False(installation.SettingsStore.Settings.FpsLimitEnabled); + } + + [Theory] + [InlineData("1")] + [InlineData("0.5")] + [InlineData("60")] + public void A_reflection_rate_below_the_old_slider_survives_a_save(string rate) + { + // The game's own eu07.ini has 1; the slider's floor of 5 raised it on any save. + var installation = WithHome(new InMemoryFileSystem() + .WithFile(UserIni, $"gfx.reflections.framerate {rate}\n")); + installation.SettingsStore.Load(); + + installation.SettingsStore.Save(); + + Assert.Contains($"gfx.reflections.framerate {rate}\n", SavedLines(installation)); + } + + [Fact] + public void Without_a_reflection_rate_the_simulators_own_is_written() + { + var installation = WithHome(); + installation.SettingsStore.Load(); + + installation.SettingsStore.Save(); + + Assert.Contains("gfx.reflections.framerate 0.003333", installation.Files.ReadAllText(UserIni)); + } + + [Fact] + public void The_shadow_range_is_the_third_number_of_shadowtune() + { + // map size, unread, projection range, unread - as the game's eu07.ini says. + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "shadowtune 1024 100 150 300\n")); + installation.SettingsStore.Load(); + Assert.Equal(1024, installation.SettingsStore.Settings.ShadowMapResolution); + Assert.Equal(150, installation.SettingsStore.Settings.ShadowProjectionRange); + + installation.SettingsStore.Settings.ShadowProjectionRange = 200; + installation.SettingsStore.Save(); + + Assert.Contains("shadowtune 1024 100 200 300", installation.Files.ReadAllText(UserIni)); + } + + [Theory] + [InlineData(1024, 75)] + [InlineData(2048, 75)] + [InlineData(4096, 150)] + [InlineData(8192, 300)] + public void The_shadow_range_floor_follows_the_map_size(int mapSize, int floor) => + Assert.Equal(floor, SimulatorSettings.ShadowRangeFloor(mapSize)); + + [Theory] + [InlineData(150, 1024, false, 150)] + [InlineData(150, 4096, false, 300)] + [InlineData(150, 4096, true, 150)] + [InlineData(100, 8192, false, 1200)] + [InlineData(400, 8192, false, 1600)] + public void Shadows_reach_as_far_as_the_renderer_stretches_them(int range, int mapSize, bool legacy, int reach) => + Assert.Equal(reach, SimulatorSettings.ShadowReach(range, mapSize, legacy)); + + [Theory] + [InlineData("anisotropicfiltering 12")] + [InlineData("multisampling 4")] + public void A_value_the_slider_does_not_offer_survives_a_save(string line) + { + // x16 multisampling and an odd anisotropy are the simulator's to take. + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, line + "\n")); + installation.SettingsStore.Load(); + + installation.SettingsStore.Save(); + + Assert.Contains(line, installation.Files.ReadAllText(UserIni)); + } + + [Theory] + [InlineData("default")] + [InlineData("simpleshader")] + [InlineData("off")] + [InlineData("opengl")] + [InlineData("gl46")] + [InlineData("vulkan14")] + [InlineData("legacy")] + [InlineData("simple")] + [InlineData("experimental")] + public void A_renderer_word_the_simulator_reads_as_another_stays_until_changed(string word) + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, $"gfxrenderer {word}\n")); + installation.SettingsStore.Load(); + + installation.SettingsStore.Save(); + + Assert.Contains($"gfxrenderer {word}\n", SavedLines(installation)); + } + + [Theory] + [InlineData(0, "full")] + [InlineData(1, "legacy")] + [InlineData(2, "simple")] + [InlineData(3, "experimental")] + [InlineData(4, "vulkan")] + public void A_chosen_renderer_is_saved_as_the_simulator_reads_it(int engine, string saved) + { + // application.cpp knows full (or default), experimental and simple; every + // other word - "simpleshader" and "off" from older starters - is legacy. + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "gfxrenderer simpleshader\n")); + installation.SettingsStore.Load(); + installation.SettingsStore.Settings.RenderEngine = engine == 1 ? 0 : engine; + installation.SettingsStore.Save(); + + installation.SettingsStore.Settings.RenderEngine = engine; + installation.SettingsStore.Save(); + + Assert.Contains($"gfxrenderer {saved}\n", SavedLines(installation)); + } + + [Theory] + [InlineData("full", 0)] + [InlineData("gl46", 0)] + [InlineData("opengl", 0)] + [InlineData("vulkan", 4)] + [InlineData("VULKAN14", 4)] + [InlineData("simpleshader", 1)] + public void A_renderer_word_stands_where_the_simulator_reads_it(string word, int engine) + { + Assert.Equal(engine, SimulatorSettings.RenderEngineOf(word)); + } + + [Fact] + public void The_buffer_is_written_for_old_and_new_simulators() + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "width 1000\nheight 500\n")); + installation.SettingsStore.Load(); + installation.SettingsStore.Settings.BufferScalePercent = 50; + + installation.SettingsStore.Save(); + + string saved = SavedLines(installation); + Assert.Contains("gfx.buffer.percent 50\n", saved); + Assert.Contains("gfx.framebuffer.width 500\n", saved); + Assert.Contains("gfx.framebuffer.height 250\n", saved); + } + + [Theory] + [InlineData("gfx.buffer.percent 0.5\n", 50)] + [InlineData("gfx.buffer.percent 75\n", 75)] + [InlineData("gfx.buffer.percent 10\n", 25)] + [InlineData("gfx.buffer.percent 300\n", 200)] + [InlineData("starter.bufferscale 60\n", 60)] + [InlineData("starter.bufferscale 60\ngfx.buffer.percent 80\n", 80)] + public void The_buffer_is_read_from_the_key_the_new_simulator_goes_by(string ini, int percent) + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, ini)); + + installation.SettingsStore.Load(); + + Assert.Equal(percent, installation.SettingsStore.Settings.BufferScalePercent); + } + + [Fact] + public void The_two_anti_aliasing_keys_untouched_keep_their_order() + { + // Moving them would change which of the two the simulator goes by. + const string ini = "gfx.antialiasing taa\nwidth 800\nmultisampling 2\n"; + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, ini)); + installation.SettingsStore.Load(); + + installation.SettingsStore.Save(); + + string saved = SavedLines(installation); + Assert.True(saved.IndexOf("gfx.antialiasing taa", StringComparison.Ordinal) < saved.IndexOf("multisampling 2", StringComparison.Ordinal), saved); + } + + [Fact] + public void A_chosen_anti_aliasing_is_saved_below_multisampling_for_the_later_line_to_count() + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "gfx.antialiasing msaax4\nwidth 800\nmultisampling 2\n")); + installation.SettingsStore.Load(); + + installation.SettingsStore.Settings.AntiAliasing = AntiAliasingMode.Taa; + installation.SettingsStore.Save(); + + string saved = SavedLines(installation); + Assert.True(saved.IndexOf("multisampling ", StringComparison.Ordinal) < saved.IndexOf("gfx.antialiasing taa", StringComparison.Ordinal), saved); + var again = WithHome(installation.Files); + again.SettingsStore.Load(); + Assert.Equal(AntiAliasingMode.Taa, again.SettingsStore.Settings.AntiAliasing); + } + + [Fact] + public void A_field_of_view_down_to_ten_degrees_survives_a_save() + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "fieldofview 10\n")); + installation.SettingsStore.Load(); + + installation.SettingsStore.Save(); + + Assert.Contains("fieldofview 10", installation.Files.ReadAllText(UserIni)); + } + + [Fact] + public void A_silenced_radio_stays_silent() + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "sound.volume.radio 0\n")); + installation.SettingsStore.Load(); + + installation.SettingsStore.Save(); + + Assert.Contains("sound.volume.radio 0\n", SavedLines(installation)); + } + /// Stands in for the settings screen holding an unsaved edit. private sealed class WidthCapture : StarterNG.Application.ISettingsCapture { @@ -206,4 +1026,280 @@ public class SettingsStoreTests public void CaptureInto(SimulatorSettings settings) => settings.Width = _width; } + + [Fact] + public void A_simulator_key_nobody_touched_stays_out_of_the_file() + { + var installation = WithHome(); + installation.SettingsStore.Load(); + + installation.SettingsStore.Save(); + + Assert.DoesNotContain("basedrawrange", installation.Files.ReadAllText(UserIni)); + Assert.DoesNotContain("ui.scale", installation.Files.ReadAllText(UserIni)); + } + + [Fact] + public void A_simulator_key_keeps_its_value_and_comment_through_a_save() + { + var installation = WithHome(new InMemoryFileSystem() + .WithFile(UserIni, "basedrawrange 15000\t// further than the slider goes\n")); + installation.SettingsStore.Load(); + + Assert.Equal("15000", installation.SettingsStore.Settings.Extra["basedrawrange"]); + + installation.SettingsStore.Save(); + + Assert.Contains("basedrawrange 15000\t// further than the slider goes", + installation.Files.ReadAllText(UserIni)); + } + + [Fact] + public void A_changed_simulator_key_is_written() + { + var installation = WithHome(); + installation.SettingsStore.Load(); + installation.SettingsStore.Settings.Extra["shakefactor"] = + SimulatorKeys.All.Single(k => k.Key == "shakefactor").FormatNumber(0.5); + + installation.SettingsStore.Save(); + + Assert.Contains("shakefactor 0.5 0.5 0.5", installation.Files.ReadAllText(UserIni)); + } + + [Fact] + public void An_emptied_word_is_taken_out_rather_than_left_bare() + { + // A bare "screenshotsdir" would swallow the next line's key as its value. + var installation = WithHome(new InMemoryFileSystem() + .WithFile(UserIni, "screenshotsdir shots\nfullscreen yes\n")); + installation.SettingsStore.Load(); + installation.SettingsStore.Settings.Extra["screenshotsdir"] = ""; + + installation.SettingsStore.Save(); + + string text = installation.Files.ReadAllText(UserIni); + Assert.Contains("// screenshotsdir shots", text); + Assert.Contains("fullscreen yes", text); + } + + // ── A save changes only what was changed ───────────────────────────────── + + [Theory] + [InlineData("fieldofview 80")] + [InlineData("dynamiclights 9")] + [InlineData("anisotropicfiltering 16.0\t// as far as it goes")] + [InlineData("gfx.smoke.fidelity 1.5")] + public void A_line_nobody_changed_stays_as_the_file_has_it(string line) + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, line + "\n")); + installation.SettingsStore.Load(); + + installation.SettingsStore.Save(); + + Assert.Contains(line + "\n", SavedLines(installation)); + } + + [Fact] + public void A_changed_value_replaces_the_files() + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "fieldofview 80\n")); + installation.SettingsStore.Load(); + installation.SettingsStore.Settings.FieldOfView = 50; + + installation.SettingsStore.Save(); + + Assert.Contains("fieldofview 50", installation.Files.ReadAllText(UserIni)); + } + + [Fact] + public void A_key_the_file_lacks_is_written_out() + { + // So the simulator and the screen go by the same value. + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "fieldofview 45\n")); + installation.SettingsStore.Load(); + + installation.SettingsStore.Save(); + + Assert.Contains("fullscreen no", installation.Files.ReadAllText(UserIni)); + } + + [Fact] + public void A_save_reports_what_it_would_change_before_making_it() + { + var installation = WithHome(new InMemoryFileSystem() + .WithFile(UserIni, "fullscreen no\nfieldofview 80\ngfx.postfx.tonemapping yes\n")); + var store = installation.SettingsStore; + store.Load(); + store.Settings.Fullscreen = true; + + var changes = store.Changes(store.Settings); + + Assert.Contains(new StarterNG.Application.IniChange("fullscreen", "no", "yes"), changes); + Assert.Contains(new StarterNG.Application.IniChange("gfx.postfx.tonemapping", "yes", null), changes); + Assert.DoesNotContain(changes, change => change.Key == "fieldofview"); + Assert.Contains("fullscreen no", installation.Files.ReadAllText(UserIni)); + } + + /// Stands in for a screen whose combo box cannot show the file's shadow rank. + private sealed class ShadowRankScreen : StarterNG.Application.ISettingsCapture + { + public int Shown = 3; + + public void CaptureInto(SimulatorSettings settings) => settings.ShadowRankCutoff = Shown; + + public IReadOnlyDictionary ShownUnlikeSettings { get; } = + new Dictionary + { + ["gfx.shadow.rank.cutoff"] = new("3", "5") + }; + } + + [Fact] + public void A_value_the_screen_could_not_show_survives_until_moved() + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "gfx.shadow.rank.cutoff 5\n")); + var store = installation.SettingsStore; + var screen = new ShadowRankScreen(); + store.RegisterCapture(screen); + store.Load(); + + store.CaptureAndSave(); + Assert.Contains("gfx.shadow.rank.cutoff 5", installation.Files.ReadAllText(UserIni)); + + screen.Shown = 2; + store.CaptureAndSave(); + Assert.Contains("gfx.shadow.rank.cutoff 2", installation.Files.ReadAllText(UserIni)); + + // Moved back to what it first showed, it is a choice like any other. + screen.Shown = 3; + store.CaptureAndSave(); + Assert.Contains("gfx.shadow.rank.cutoff 3", installation.Files.ReadAllText(UserIni)); + } + + [Fact] + public void A_section_put_back_takes_the_simulators_own_values_for_its_keys_alone() + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, + "fieldofview 60\nmaxtexturesize 1024\nshadowtune 4096 250 500 300\nbasedrawrange 4000\n")); + var store = installation.SettingsStore; + store.Load(); + + var reset = store.WithDefaults(store.Settings, new[] { "fieldofview", "shadowtune", "basedrawrange" }); + + Assert.Equal(45, reset.FieldOfView); + Assert.Equal(2048, reset.ShadowMapResolution); + Assert.Equal(250, reset.ShadowProjectionRange); + Assert.False(reset.Extra.ContainsKey("basedrawrange")); + Assert.Equal(1024, reset.MaxTextureSize); + // The settings themselves wait for the screen's save. + Assert.Equal(60, store.Settings.FieldOfView); + } + + // ── What the screen shows otherwise ────────────────────────────────────── + + [Fact] + public void Settings_shown_as_they_are_held_have_nothing_unlike() + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "fieldofview 60\nbasedrawrange 4000\n")); + var store = installation.SettingsStore; + store.Load(); + + Assert.Empty(store.ShownUnlike(store.Settings.Copy())); + } + + [Fact] + public void A_value_shown_otherwise_comes_with_both_sides_under_any_spelling_of_its_key() + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "fieldofview 60\n")); + var store = installation.SettingsStore; + store.Load(); + var shown = store.Settings.Copy(); + shown.FieldOfView = 45; + + var unlike = store.ShownUnlike(shown); + + Assert.Equal("fieldofview", Assert.Single(unlike).Key); + Assert.Equal(new StarterNG.Application.ShownValue("45", "60"), unlike["FIELDOFVIEW"]); + } + + [Fact] + public void A_key_on_one_side_alone_shows_as_null_on_the_other() + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "basedrawrange 4000\n")); + var store = installation.SettingsStore; + store.Load(); + + var added = store.Settings.Copy(); + added.Extra["heightbase"] = "2"; + Assert.Equal(new StarterNG.Application.ShownValue("2", null), store.ShownUnlike(added)["heightbase"]); + + var taken = store.Settings.Copy(); + taken.Extra.Remove("basedrawrange"); + Assert.Equal(new StarterNG.Application.ShownValue(null, "4000"), store.ShownUnlike(taken)["basedrawrange"]); + } + + // ── Applying a profile ─────────────────────────────────────────────────── + + private static readonly string Profile = Path.Combine("/home/kolejarz", "profiles", "wieczor.ini"); + + [Fact] + public void A_readable_profile_is_loaded() + { + var installation = WithHome(new InMemoryFileSystem() + .WithFile(UserIni, "fieldofview 60\n").WithFile(Profile, "fieldofview 50\n")); + var store = installation.SettingsStore; + store.Load(); + + Assert.True(store.TryLoadProfile(Profile)); + + Assert.Equal(50, store.Settings.FieldOfView); + Assert.False(store.LoadFailed); + } + + [Fact] + public void A_missing_profile_leaves_the_settings_as_they_are() + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "fieldofview 60\n")); + var store = installation.SettingsStore; + store.Load(); + store.Settings.FieldOfView = 70; + + Assert.False(store.TryLoadProfile(Profile)); + + Assert.Equal(70, store.Settings.FieldOfView); + } + + [Fact] + public void An_unreadable_profile_leaves_the_real_settings_not_defaults_to_save_over_them() + { + var installation = WithHome(new InMemoryFileSystem() + .WithFile(UserIni, "fieldofview 60\n").WithFile(Profile, "fieldofview 50\n").FailReading(Profile)); + var store = installation.SettingsStore; + store.Load(); + + Assert.False(store.TryLoadProfile(Profile)); + + Assert.False(store.LoadFailed); + Assert.Equal(60, store.Settings.FieldOfView); + store.Save(); + Assert.False(store.LastSaveFailed); + Assert.Contains("fieldofview 60", installation.Files.ReadAllText(UserIni)); + } + + [Fact] + public void A_missing_profile_gives_settings_that_could_not_be_read_another_try() + { + var files = new InMemoryFileSystem().WithFile(UserIni, "fieldofview 60\n").FailReading(UserIni); + var installation = WithHome(files); + var store = installation.SettingsStore; + store.Load(); + Assert.True(store.LoadFailed); + + files.AllowReading(UserIni); + Assert.False(store.TryLoadProfile(Profile)); + + Assert.False(store.LoadFailed); + Assert.Equal(60, store.Settings.FieldOfView); + } } diff --git a/StarterNG.Tests/SimulatorAftermathTests.cs b/StarterNG.Tests/SimulatorAftermathTests.cs index ecbdf93..1c3f31b 100644 --- a/StarterNG.Tests/SimulatorAftermathTests.cs +++ b/StarterNG.Tests/SimulatorAftermathTests.cs @@ -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); + } } diff --git a/StarterNG.Tests/SimulatorExitTests.cs b/StarterNG.Tests/SimulatorExitTests.cs index ab905aa..7cb0f68 100644 --- a/StarterNG.Tests/SimulatorExitTests.cs +++ b/StarterNG.Tests/SimulatorExitTests.cs @@ -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(), 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 { ["__NV_PRIME_RENDER_OFFLOAD"] = "1" }, + captureErrors: true, out _); + await process!.WaitForExitAsync(); + + Assert.Equal("1", Assert.Single(process.ErrorTail).Trim()); + } } diff --git a/StarterNG.Tests/StartSimulationTests.cs b/StarterNG.Tests/StartSimulationTests.cs index 2940fa6..359c73c 100644 --- a/StarterNG.Tests/StartSimulationTests.cs +++ b/StarterNG.Tests/StartSimulationTests.cs @@ -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 addSimulator) + Action addSimulator, Action? 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? Environment { get; private set; } + public IProcessHandle? Start(string executablePath, IReadOnlyList arguments, string? workingDirectory, - bool captureErrors, out string? error) + IReadOnlyDictionary environment, bool captureErrors, + out string? error) { + Environment = environment; Executable = executablePath; CapturedErrors = captureErrors; Arguments = arguments; diff --git a/StarterNG.Tests/StepsTests.cs b/StarterNG.Tests/StepsTests.cs new file mode 100644 index 0000000..c46fc24 --- /dev/null +++ b/StarterNG.Tests/StepsTests.cs @@ -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)); + } +} diff --git a/StarterNG.Tests/TapeLaunchTests.cs b/StarterNG.Tests/TapeLaunchTests.cs new file mode 100644 index 0000000..a1285fa --- /dev/null +++ b/StarterNG.Tests/TapeLaunchTests.cs @@ -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(), + "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(), + 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(), "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(), "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()); + + 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(), "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(), "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(), + 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(), "ra", new[] { "ra" }, "A", "T1"); + installation.Clock.Now = Launched.AddHours(1); + tapes.RecordLaunch("b.scn", new TimeSpan(9, 0, 0), Array.Empty(), "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(), "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(), "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(), "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()); + } + + 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(), + "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(), "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(), "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(), "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(), "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()); + + 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()); + + Assert.Null(tapes.ContextOf(tapes.List().Single()).StartClock); + } +} diff --git a/StarterNG.Tests/TexRandomizerTests.cs b/StarterNG.Tests/TexRandomizerTests.cs new file mode 100644 index 0000000..7d01816 --- /dev/null +++ b/StarterNG.Tests/TexRandomizerTests.cs @@ -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; + } + + /// The randomizer with the next pick in line always the other livery of two. + 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); + } +} diff --git a/StarterNG.Tests/TrainsetDisplayTests.cs b/StarterNG.Tests/TrainsetDisplayTests.cs new file mode 100644 index 0000000..fdadd3b --- /dev/null +++ b/StarterNG.Tests/TrainsetDisplayTests.cs @@ -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)); +} diff --git a/StarterNG.Tests/VehicleBaseQueryTests.cs b/StarterNG.Tests/VehicleBaseQueryTests.cs new file mode 100644 index 0000000..3c0f87e --- /dev/null +++ b/StarterNG.Tests/VehicleBaseQueryTests.cs @@ -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 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 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)); + } +} diff --git a/StarterNG.Tests/WarehouseEditTests.cs b/StarterNG.Tests/WarehouseEditTests.cs new file mode 100644 index 0000000..63cdcd5 --- /dev/null +++ b/StarterNG.Tests/WarehouseEditTests.cs @@ -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"))); + } +} diff --git a/StarterNG.Tests/WineConfigLinkTests.cs b/StarterNG.Tests/WineConfigLinkTests.cs new file mode 100644 index 0000000..bb2302c --- /dev/null +++ b/StarterNG.Tests/WineConfigLinkTests.cs @@ -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? 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); + } +} diff --git a/StarterNG/App.axaml b/StarterNG/App.axaml index 99f30bc..7554bf3 100644 --- a/StarterNG/App.axaml +++ b/StarterNG/App.axaml @@ -12,6 +12,17 @@ + + + diff --git a/StarterNG/App.axaml.cs b/StarterNG/App.axaml.cs index 283d5bf..76778b8 100644 --- a/StarterNG/App.axaml.cs +++ b/StarterNG/App.axaml.cs @@ -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); + /// + /// 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. + /// + 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(); }); }); } diff --git a/StarterNG/Application/Abstractions/IFileSystem.cs b/StarterNG/Application/Abstractions/IFileSystem.cs index b30f657..e04f862 100644 --- a/StarterNG/Application/Abstractions/IFileSystem.cs +++ b/StarterNG/Application/Abstractions/IFileSystem.cs @@ -25,6 +25,16 @@ public interface IFileSystem void WriteAllText(string path, string contents, Encoding encoding); + /// + /// 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. + /// + void WriteAllBytesAtomic(string path, byte[] bytes); + + /// As UTF-8 without a mark, by . + void WriteAllTextAtomic(string path, string contents); + void WriteAllLines(string path, IEnumerable lines); void WriteAllBytes(string path, byte[] bytes); @@ -45,5 +55,36 @@ public interface IFileSystem long GetFileSize(string path); + /// A file's time and size in one look; null when there is no file. + (DateTime LastWriteUtc, long Size)? LastWriteAndSize(string path); + bool IsExecutable(string path); + + /// Where the symbolic link at points, or null for anything else. + string? LinkTarget(string path); + + /// Makes a symbolic link to the directory . + void CreateDirectoryLink(string path, string target); + + void MoveDirectory(string sourcePath, string destinationPath); +} + +public static class FileSystemExtensions +{ + /// + /// 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. + /// + 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}"); + } } diff --git a/StarterNG/Application/Abstractions/IProcessLauncher.cs b/StarterNG/Application/Abstractions/IProcessLauncher.cs index a62eacf..9131240 100644 --- a/StarterNG/Application/Abstractions/IProcessLauncher.cs +++ b/StarterNG/Application/Abstractions/IProcessLauncher.cs @@ -20,12 +20,13 @@ public interface IProcessHandle public interface IProcessLauncher { /// - /// 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 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. /// IProcessHandle? Start(string executablePath, IReadOnlyList arguments, string? workingDirectory, - bool captureErrors, out string? error); + IReadOnlyDictionary environment, bool captureErrors, out string? error); bool OpenInShell(string pathOrUrl); diff --git a/StarterNG/Application/Abstractions/ISceneryRepository.cs b/StarterNG/Application/Abstractions/ISceneryRepository.cs index 031b05b..4990c73 100644 --- a/StarterNG/Application/Abstractions/ISceneryRepository.cs +++ b/StarterNG/Application/Abstractions/ISceneryRepository.cs @@ -11,4 +11,7 @@ public interface ISceneryRepository IReadOnlyList LoadAll(IProgress? progress = null); Scenery? Load(string path); + + /// The scenery files there are now; null while their folder is away. + IReadOnlyList? ListFiles(); } diff --git a/StarterNG/Application/AppServices.cs b/StarterNG/Application/AppServices.cs index 744912b..7f7b6ce 100644 --- a/StarterNG/Application/AppServices.cs +++ b/StarterNG/Application/AppServices.cs @@ -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); + SettingsStore = new SettingsStore(files, clock, log, settingsPaths, new SettingsSerializer(dlssPlatform: !environment.IsMacOS), Executables); + 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, () => State.SimulatorRunning); + Gpus = new LinuxGpus(files, environment); + GraphicsCards = new GraphicsCards(files, environment, Gpus); + 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; } - /// The same object as , typed for the developer tab. - public VehicleCacheRepository VehicleCache { get; } - - /// The legacy reader behind the cache, for tools that go to the source. - 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,12 @@ public sealed class AppServices public ExecutableLocator Executables { get; } + public LinuxGpus Gpus { get; } + + public GraphicsCards GraphicsCards { get; } + + public Displays Displays { get; } + public SettingsPaths SettingsPaths { get; } public MissingVehicleLog MissingVehicleLog { get; } @@ -133,5 +147,9 @@ public sealed class AppServices public SimulatorAftermath Aftermath { get; } + public HaslerTapes Tapes { get; } + + public SaveSceneryTrainset SaveTrainset { get; } + public LocalizationService Localization { get; } } diff --git a/StarterNG/Application/AppState.cs b/StarterNG/Application/AppState.cs index 78ab716..bd5cbba 100644 --- a/StarterNG/Application/AppState.cs +++ b/StarterNG/Application/AppState.cs @@ -46,5 +46,17 @@ public sealed class AppState } } + private volatile bool _simulatorRunning; + + /// + /// The simulator the starter watches is still running: the one it started, not + /// any of the same name. Read off the UI thread too. + /// + public bool SimulatorRunning + { + get => _simulatorRunning; + set => _simulatorRunning = value; + } + public void NotifyChanged() => Changed?.Invoke(); } diff --git a/StarterNG/Application/FavoriteSceneries.cs b/StarterNG/Application/FavoriteSceneries.cs index 4a00831..1180953 100644 --- a/StarterNG/Application/FavoriteSceneries.cs +++ b/StarterNG/Application/FavoriteSceneries.cs @@ -18,6 +18,9 @@ public sealed class FavoriteSceneries private readonly string _path; private readonly HashSet _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) { diff --git a/StarterNG/Application/GameLibrary.cs b/StarterNG/Application/GameLibrary.cs index 1f94b67..4b76b31 100644 --- a/StarterNG/Application/GameLibrary.cs +++ b/StarterNG/Application/GameLibrary.cs @@ -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 diff --git a/StarterNG/Application/GraphicsGroupLevels.cs b/StarterNG/Application/GraphicsGroupLevels.cs new file mode 100644 index 0000000..4d7937a --- /dev/null +++ b/StarterNG/Application/GraphicsGroupLevels.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StarterNG.Domain.Settings; + +namespace StarterNG.Application; + +/// Levels of detail part by part: what a level holds for one , the rest as it is. +public sealed class GraphicsGroupLevels +{ + private readonly SettingsStore _store; + + public GraphicsGroupLevels(SettingsStore store) => _store = store; + + /// + /// The level each group is at in , 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. + /// + public IReadOnlyDictionary 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); + } + + /// + /// A copy of with the group at . What only + /// an end of the scale sets goes back to the simulator's own on leaving that end. + /// + 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 RenderAt(GraphicsPreset level, SimulatorSettings settings) + { + var copy = settings.Copy(); + level.ApplyTo(copy); + return _store.Render(copy); + } + + private static bool Same(GraphicsGroup group, IReadOnlyDictionary one, IReadOnlyDictionary 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 keys, string key) => + keys.TryGetValue(key, out string? value) ? value : SimulatorKeys.All.FirstOrDefault(known => known.Key == key)?.Default; +} diff --git a/StarterNG/Application/HaslerTapes.cs b/StarterNG/Application/HaslerTapes.cs new file mode 100644 index 0000000..3d0b464 --- /dev/null +++ b/StarterNG/Application/HaslerTapes.cs @@ -0,0 +1,319 @@ +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); + +/// One run of the simulator and what is left of its tapes: a later run overwrites those of the vehicles it has too. +public sealed record TapeSession(TapeLaunch? Launch, DateTime StartedUtc, DateTime LastWrittenUtc, bool Running, + IReadOnlyList Tapes); + +/// 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. +public sealed record TapeContext(TimeSpan? StartClock, RecorderKind Recorder, + string? Scenery = null, string? Train = null); + +/// The tapes the simulator left in physicslog/ - one per driven vehicle, overwritten every run. +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 _simulatorRunning; + private readonly object _playerLock = new(); + private IReadOnlyList? _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 simulatorRunning) + { + _files = files; + _paths = paths; + _log = log; + _clock = clock; + _timetables = timetables; + _simulatorRunning = simulatorRunning; + } + + public string Folder => _paths.FromRoot("physicslog"); + + /// Not a .dat, so the simulator's own logs are never mistaken for it, nor it for one. + private string LaunchFile => Path.Combine(Folder, "starter-launch.txt"); + + /// + /// 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. + /// + 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(); + 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)); + } + + /// Notes down, next to the logs the simulator is about to write, what only the starter knows. + public void RecordLaunch(string scenery, TimeSpan startClock, IEnumerable vehicles, + string? driven = null, IEnumerable? 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.WriteAllTextAtomic(LaunchFile, + string.Concat(noted.TakeLast(RememberedLaunches).Select(l => l.Format()))); + lock (_playerLock) + _launches = null; + } + catch (Exception ex) + { + _log.Log($"writing {LaunchFile}", ex); + } + } + + /// + /// Reads what the log of the last launch has gained since the last look. A start + /// notes that down before the log begins anew, and after a long run, with nothing + /// read of it yet, it would wait on the whole log. + /// + public void FollowLastRun() + { + if (Launches().LastOrDefault() is { } launch) + PlayerIn(launch); + } + + /// + /// 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. + /// + private IReadOnlyList Launches() + { + lock (_playerLock) + { + try + { + if (!_files.FileExists(LaunchFile)) + return Array.Empty(); + + 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(); + } + } + } + + /// The simulator's log of this launch: log.txt or, with logs kept, one of its own in logs/. + private string? LogOf(TapeLaunch launch) + { + var logs = new List { _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(); + } + + /// + /// 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. + /// + 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; + } + } + + /// The vehicle the player is in, or went off in while the log has not said otherwise, if the starter launched one. + 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); + } + + /// Every tape, session by session. + public IReadOnlyList List() => Sessions().SelectMany(session => session.Tapes).ToList(); + + /// + /// 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. + /// + public IReadOnlyList Sessions() + { + try + { + var launches = Launches(); + var tapes = _files.DirectoryExists(Folder) + ? _files.GetFiles(Folder, "*.dat").Select(ReadTape).ToList() + : new List(); + 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(); + 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()) + .Select(t => t with { Role = launch.RoleOf(t.Vehicle, player) }))); + } + + var run = new List(); + 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(); + } + 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(); + } + } + + private TapeFile ReadTape(string path) => + new(path, Path.GetFileNameWithoutExtension(path), _files.GetLastWriteTimeUtc(path), _files.GetFileSize(path)); + + /// The player's own tapes first: every manned vehicle leaves one. + private static TapeSession Session(TapeLaunch? launch, bool running, IEnumerable 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; + } + } +} diff --git a/StarterNG/Application/SaveSceneryTrainset.cs b/StarterNG/Application/SaveSceneryTrainset.cs new file mode 100644 index 0000000..01d281d --- /dev/null +++ b/StarterNG/Application/SaveSceneryTrainset.cs @@ -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, + + /// The file no longer holds the trainset as it was read - edited outside meanwhile. + NotFound, + + Failed +} + +/// +/// 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 .scn.bak there and rewrites +/// it on every save. +/// +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(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); + } + } + + /// + /// The scenery file, or the included one the trainset came from - named, as + /// includes are, from the scenery folder the scenery itself is in. + /// + 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; +} diff --git a/StarterNG/Application/SceneryFileWatch.cs b/StarterNG/Application/SceneryFileWatch.cs new file mode 100644 index 0000000..fc49207 --- /dev/null +++ b/StarterNG/Application/SceneryFileWatch.cs @@ -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; + +/// What became of a scenery's files since they were read. +/// The scenery as it is held; null for a file new in the folder. +/// As its files have it now; null for one gone from the folder. +public sealed record SceneryChange(Scenery? Current, Scenery? Fresh); + +public enum SceneryVerdict +{ + /// Nothing to do about it, for now or for good. + Leave, + + /// The library can take it in. + Take, + + /// Taking it in loses edits, so that is for someone to say. + AskFirst +} + +/// What is to become of a change. +/// The consists with unsaved edits that is about. +public sealed record SceneryJudgement(SceneryVerdict Verdict, IReadOnlyList Lost) +{ + public static readonly SceneryJudgement Leave = new(SceneryVerdict.Leave, Array.Empty()); + public static readonly SceneryJudgement Take = new(SceneryVerdict.Take, Array.Empty()); +} + +/// +/// 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. +/// +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 _unreadable = new(); + + private HashSet _away = new(); + + public SceneryFileWatch(IFileSystem files, ISceneryRepository sceneries, GameLibrary library, AppState state) + { + _files = files; + _sceneries = sceneries; + _library = library; + _state = state; + } + + /// What has changed among the files of . Off the UI thread, one look at a time. + public IReadOnlyList Look(IReadOnlyList held) + { + var changes = new List(); + if (_sceneries.ListFiles() is not { } files) + return changes; + + var there = files.ToHashSet(); + var away = new HashSet(); + 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; + } + + /// Whether any of the files it was read from is no longer as it was. + public bool IsStale(Scenery scenery) => + scenery.Sources.Any(source => FileStamp.Of(_files, source.Key) != source.Value); + + private Scenery? LoadOnce(string path, IEnumerable 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; + } + + /// + /// 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. + /// + /// The consists with unsaved edits that have no place there: their text changed too. + public static IReadOnlyList 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(); + } + + /// + /// 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. + /// + 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; + } + + /// On the UI thread: the library, and what is picked in it, take the change in. + 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; + } +} diff --git a/StarterNG/Application/SettingsStore.cs b/StarterNG/Application/SettingsStore.cs index bb56352..f538db1 100644 --- a/StarterNG/Application/SettingsStore.cs +++ b/StarterNG/Application/SettingsStore.cs @@ -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); + + /// + /// Keys the screen shows otherwise than the settings hold them - a value its + /// control cannot show - by key, in the file's terms. + /// + IReadOnlyDictionary ShownUnlikeSettings => new Dictionary(); } +/// A key as the screen shows it, and as the settings held it when it was put there; null for absent. +public sealed record ShownValue(string? Shown, string? Held); + +/// A key a save changes: its value in the file before and after; null where it has none. +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 _synced = new Dictionary(); + 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; + /// + /// The last read failed, so holds defaults, not the file's. + /// Nothing is saved until a read works: that would put them in its place. + /// + public bool LoadFailed { get; private set; } + + /// The last save did not reach the file; the log says why. + 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); } + /// A player with no settings of their own yet starts at High, in the file at once so the simulator goes by it too. + 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(); } + /// + /// 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. + /// + 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); @@ -89,9 +148,17 @@ public sealed class SettingsStore SaveTo(_savePath); } - public void SaveTo(string path) + private 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; + } + + ApplyChanges(_config, Changes(Settings)); + _synced = Render(Settings); try { @@ -99,8 +166,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 +176,209 @@ public sealed class SettingsStore } } + private static void ApplyChanges(ConfigFile config, IReadOnlyList changes) + { + foreach (var change in changes) + { + if (change.After is null) + config.Remove(change.Key); + else + config.Set(change.Key, change.After); + } + + // Each sets the mode as the simulator reads it, so the new one has to come later to count. + // Untouched, the two keep their order: moving them would change which one decides. + if (changes.Any(change => change.Key is "gfx.antialiasing" or "multisampling")) + config.MoveAfter("gfx.antialiasing", "multisampling"); + } + + /// Whether the simulator can have DLSS on this platform at all. + public bool DlssPlatform => _serializer.DlssPlatform; + + /// + /// Takes the settings of another eu07.ini (a backup, a file from another machine) + /// into the current ones, keeping what belongs to this installation: the chosen + /// simulator, its card, the last scenery and the window state. The lines of this + /// user's file the starter does not manage stay as they are. Nothing is written until Save. + /// + public bool ImportFrom(string path) + { + ConfigFile imported; + try + { + imported = ConfigFile.Parse(_files.ReadAllText(path, _encoding)); + } + catch (Exception ex) + { + _log.Log($"importing {path}", ex); + return false; + } + + var s = Settings; + bool selectAutomatically = s.SelectExeAutomatically; + string executable = s.ExecutablePath; + var gpu = s.Gpu; + string lastScenery = s.LastScenery; + bool maximized = s.WindowMaximized; + // Only read for the dependencies and never written, so they stay as this user's file has them. + bool cabLampLight = s.CabLampLight; + bool depthPrepassScene = s.DepthPrepassScene; + + _serializer.Read(s, imported); + + s.SelectExeAutomatically = selectAutomatically; + s.ExecutablePath = executable; + s.Gpu = gpu; + s.LastScenery = lastScenery; + s.WindowMaximized = maximized; + s.CabLampLight = cabLampLight; + s.DepthPrepassScene = depthPrepassScene; + return true; + } + + /// + /// Writes the settings as a complete eu07.ini somewhere else - this user's file + /// with the settings applied - leaving the file itself and its state alone. + /// + public bool ExportTo(string path) + { + if (LoadFailed) + { + _log.Log($"{path} not written: the settings could not be read, what is held is defaults"); + return false; + } + + var copy = ConfigFile.Parse(_config.ToText()); + ApplyChanges(copy, Changes(Settings)); + + try + { + string? directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory)) + _files.CreateDirectory(directory); + + _files.WriteAllBytesAtomic(path, _encoding.GetBytes(copy.ToText())); + return true; + } + catch (Exception ex) + { + _log.Log($"exporting {path}", ex); + return false; + } + } + + /// Every key as would put it, in the order they are written. + public IReadOnlyDictionary Render(SimulatorSettings settings) + { + var config = new ConfigFile(); + _serializer.Write(settings, config); + var keys = new OrderedDictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (key, value) in config.Entries()) + keys[key] = value; + return keys; + } + + /// + /// What saving 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. + /// + public IReadOnlyList Changes(SimulatorSettings settings) + { + var now = Render(settings); + var shown = _capture?.ShownUnlikeSettings ?? new Dictionary(); + var changes = new List(); + var listed = new HashSet(StringComparer.OrdinalIgnoreCase); + + 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); + // A key gone from the file can be found gone more ways than one. + if (before != value && listed.Add(key)) + changes.Add(new IniChange(key, before, value)); + } + } + + /// + /// The keys puts otherwise than the settings hold them, + /// with both values, in the file's terms. + /// + public IReadOnlyDictionary ShownUnlike(SimulatorSettings shown) + { + var held = Render(Settings); + var now = Render(shown); + var unlike = new Dictionary(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; + } + + /// + /// A copy of with back at + /// what the simulator goes by when eu07.ini does not set them. + /// + public SimulatorSettings WithDefaults(SimulatorSettings settings, IReadOnlyCollection keys) + { + var defaults = new SimulatorSettings(); + _serializer.Read(defaults, new ConfigFile()); + return WithKeysOf(settings, defaults, keys); + } + + /// A copy of with as has them. + public SimulatorSettings WithKeysOf(SimulatorSettings settings, SimulatorSettings source, IReadOnlyCollection 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; diff --git a/StarterNG/Application/SimulatorAftermath.cs b/StarterNG/Application/SimulatorAftermath.cs index 90f63c7..ad39a80 100644 --- a/StarterNG/Application/SimulatorAftermath.cs +++ b/StarterNG/Application/SimulatorAftermath.cs @@ -10,6 +10,9 @@ namespace StarterNG.Application; /// What a simulator that went down left behind: its log and its crash dumps. 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 } } - /// The end of the simulator's own log, for when it died without a word on its error stream. - public IReadOnlyList SimulatorLogTail(int count) + /// + /// The end of the simulator's own log, for when it died without a word on its + /// error stream. A log not written since the start is the session before's, the + /// simulator having died before opening its own: it says nothing about this one. + /// + public IReadOnlyList 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(); var lines = LegacyText.Decode(_files.ReadAllBytes(path)) diff --git a/StarterNG/Application/StartSimulation.cs b/StarterNG/Application/StartSimulation.cs index d8da998..6cacc79 100644 --- a/StarterNG/Application/StartSimulation.cs +++ b/StarterNG/Application/StartSimulation.cs @@ -2,13 +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.Adapters; +using StarterNG.Infrastructure; using StarterNG.Infrastructure.Settings; namespace StarterNG.Application; @@ -47,11 +46,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 +72,12 @@ public sealed class StartSimulation _log = log; } - public static string? StartableVehicle(Trainset? trainset, string? preferred) + /// + /// 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. + /// + 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,45 +85,54 @@ 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 { - _files.WriteAllText(exportPath, - scenery.BuildExportContent(_settings.Settings.IgnoreIrrelevantTrains, _random), - LegacyText.CodePage1250); + _files.WriteAllBytes(exportPath, + scenery.BuildExportBytes(_settings.Settings.IgnoreIrrelevantTrains, _random)); } catch (Exception ex) { @@ -125,6 +144,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 +156,16 @@ public sealed class StartSimulation /// /// 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. /// - 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) }); - } + /// The simulator's wizard of a new scenery, whichever one is selected. + public SimulationStartResult ExecuteNewSceneryEditor() => Launch(new[] { "-edit" }); private SimulationStartResult Launch(IReadOnlyList arguments) { @@ -157,10 +180,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 +195,30 @@ public sealed class StartSimulation return new SimulationStartResult(SimulationStartOutcome.Started, process, executable); } + /// + /// 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. + /// + private IReadOnlyDictionary GraphicsCard() + { + var gpus = _gpus.List(); + if (gpus.Count < 2) + return new Dictionary(); + + 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(); + } + + var environment = GpuOffload.EnvironmentFor(target); + _log.Log($"GPU: {cards}; simulator on {target} with " + + string.Join(' ', environment.Select(pair => $"{pair.Key}={pair.Value}"))); + return environment; + } + } diff --git a/StarterNG/Application/WarehouseEdit.cs b/StarterNG/Application/WarehouseEdit.cs new file mode 100644 index 0000000..04d5a34 --- /dev/null +++ b/StarterNG/Application/WarehouseEdit.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using StarterNG.Classes; + +namespace StarterNG.Application; + +/// +/// 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. +/// +public sealed class WarehouseEdit +{ + /// One trainset per warehouse consist, so its undo history outlives a switch. + private readonly Dictionary _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; + } +} diff --git a/StarterNG/Classes/ConfigFile.cs b/StarterNG/Classes/ConfigFile.cs index e551b2c..71f3459 100644 --- a/StarterNG/Classes/ConfigFile.cs +++ b/StarterNG/Classes/ConfigFile.cs @@ -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 _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,47 @@ 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); + /// The line the key is on, or -1. The simulator reads the file top down, so a later line wins. + public int LineOf(string key) => _index.TryGetValue(key, out int i) ? i : -1; + + /// Whether the line setting the key is one the simulator never gets to. + public bool IsPastEnd(string key) => + _end >= 0 && _index.TryGetValue(key, out int line) && line > _end; + + /// Every key the file sets, in the order of its lines, with its value. + public IEnumerable> Entries() + { + foreach (var (key, _) in _index.OrderBy(pair => pair.Value)) + yield return new KeyValuePair(key, GetRaw(key) ?? string.Empty); + } + public string? GetRaw(string key) { if (!_index.TryGetValue(key, out int i)) @@ -83,35 +123,76 @@ 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(); + } + + /// + /// Puts the line of right below that of when + /// it is above it, for keys of which the simulator, reading top down, heeds the later. + /// + public void MoveAfter(string key, string anchor) + { + if (!_index.TryGetValue(key, out int from) || !_index.TryGetValue(anchor, out int to) || from > to) + return; + + string line = _lines[from]; + _lines.RemoveAt(from); + _lines.Insert(to, line); + Reindex(); + } + + /// Comments out every line setting the key: the last one alone would bring the one before it back. + 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 +214,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])) diff --git a/StarterNG/Classes/KeyMap.cs b/StarterNG/Classes/KeyMap.cs index 741c606..f10f445 100644 --- a/StarterNG/Classes/KeyMap.cs +++ b/StarterNG/Classes/KeyMap.cs @@ -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 } } +/// What the simulator does with a key combination on its own. +/// What that is, as a translation key. +/// +/// 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. +/// +public readonly record struct SimulatorKeyUse(string TextKey, bool Blocks); + public static class KeyMap { + public const string NumpadPrefix = "num_"; + + /// + /// 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. + /// + 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; + /// A key combination as it reads on screen; a dash for no key, whatever the modifiers. + public static string ComboText(string? token, bool shift, bool ctrl) + { + if (IsNoKey(token)) + return "—"; + var parts = new List(); + 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"), }, }; } diff --git a/StarterNG/Classes/KeyboardConfig.cs b/StarterNG/Classes/KeyboardConfig.cs index 99acdaa..cd9a726 100644 --- a/StarterNG/Classes/KeyboardConfig.cs +++ b/StarterNG/Classes/KeyboardConfig.cs @@ -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); + + /// The combination in a form to compare, however the key is spelled. + 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 } /// - /// 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 one for the starter's language, else the + /// bundled one with Polish letters. /// - public string DescriptionOf(KeyBinding binding) + public string DescriptionOf(KeyBinding binding, Func? 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)); + + /// + /// Puts 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. + /// + 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; + } + + /// The of each combination more than one binding is on. + public HashSet ConflictingCombos() + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var dup = new HashSet(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); diff --git a/StarterNG/Classes/PresetStore.cs b/StarterNG/Classes/PresetStore.cs index b03a748..69b7e82 100644 --- a/StarterNG/Classes/PresetStore.cs +++ b/StarterNG/Classes/PresetStore.cs @@ -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 TypeInfo => - (JsonTypeInfo)Options.GetTypeInfo(typeof(PresetCollection)); + private static PresetFile Disk => + _disk ??= new PresetFile(AppServices.Current.Files, AppServices.Current.Log, FilePath); public static IReadOnlyList 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() +/// The presets as one file holds them; is the player's own. +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 TypeInfo => + (JsonTypeInfo)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 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); + } + + /// The file's presets; false, with none, when there is a file but they cannot be had from it. + 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); } } } diff --git a/StarterNG/Classes/Trainset.cs b/StarterNG/Classes/Trainset.cs index e17c464..0223661 100644 --- a/StarterNG/Classes/Trainset.cs +++ b/StarterNG/Classes/Trainset.cs @@ -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; + /// + /// "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. + /// + public bool Reversed { get; private set; } + + /// + /// The file the trainset is written in, as the scenery's include names it, for + /// one from a file put in place by ; + /// null for the scenery's own. + /// + public string? SourceFile { get; init; } + + /// + /// Where the speed, each vehicle and the endtrainset 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 endtrainset the end is the end of the text. + /// Known only of a trainset that was . + /// + 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 tokens = Regex - .Matches(trainsetEntry, @"/\*[\s\S]*?\*/|//[^\r\n]*|[^\s\r\n]+") - .Select(m => m.Value) - .Where(t => !t.StartsWith("//") && !t.StartsWith("/*")) - .ToList(); + List 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(); - 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; + /// No vehicles, in the export too: an entry that could not be read goes out as its raw text. + public void RemoveVehicles() + { + Vehicles = new List(); + 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 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 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"; } } diff --git a/StarterNG/Controls/KeySlot.cs b/StarterNG/Controls/KeySlot.cs new file mode 100644 index 0000000..c274075 --- /dev/null +++ b/StarterNG/Controls/KeySlot.cs @@ -0,0 +1,21 @@ +using Avalonia; +using Avalonia.Controls; + +namespace StarterNG.Controls; + +/// +/// 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. +/// +public sealed class KeySlot : Decorator +{ + public static readonly StyledProperty KeyProperty = + AvaloniaProperty.Register(nameof(Key)); + + /// The eu07.ini key whose row goes here. + public string? Key + { + get => GetValue(KeyProperty); + set => SetValue(KeyProperty, value); + } +} diff --git a/StarterNG/Controls/SettingRow.cs b/StarterNG/Controls/SettingRow.cs index bee10d4..42a993b 100644 --- a/StarterNG/Controls/SettingRow.cs +++ b/StarterNG/Controls/SettingRow.cs @@ -17,6 +17,13 @@ public class SettingRow : ContentControl public static readonly StyledProperty ValueTextProperty = AvaloniaProperty.Register(nameof(ValueText)); + /// + /// 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. + /// + public static readonly StyledProperty IdleNoteProperty = + AvaloniaProperty.Register(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)); } } diff --git a/StarterNG/Controls/SettingsSection.cs b/StarterNG/Controls/SettingsSection.cs index 7cb092d..02fd743 100644 --- a/StarterNG/Controls/SettingsSection.cs +++ b/StarterNG/Controls/SettingsSection.cs @@ -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 ToggleProperty = AvaloniaProperty.Register(nameof(Toggle)); + public static readonly StyledProperty ToggleKeyProperty = + AvaloniaProperty.Register(nameof(ToggleKey)); + + public static readonly StyledProperty ResetLabelProperty = + AvaloniaProperty.Register(nameof(ResetLabel)); + + public static readonly StyledProperty LevelProperty = + AvaloniaProperty.Register(nameof(Level)); + + private Button? _reset; + + /// The header's choice of a level of detail for this section alone; none where it has no such thing. + public object? Level + { + get => GetValue(LevelProperty); + set => SetValue(LevelProperty, value); + } + public object? Toggle { get => GetValue(ToggleProperty); set => SetValue(ToggleProperty, value); } + + /// The eu07.ini key the header's switch sets. + public string? ToggleKey + { + get => GetValue(ToggleKeyProperty); + set => SetValue(ToggleKeyProperty, value); + } + + /// The words on the header's button that puts the section back as the simulator has it; none, no button. + public string? ResetLabel + { + get => GetValue(ResetLabelProperty); + set => SetValue(ResetLabelProperty, value); + } + + /// Raised when the header's reset button is pressed. + public event Action? ResetRequested; + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + if (_reset is not null) + _reset.Click -= OnResetClick; + _reset = e.NameScope.Find + - - - - + + - - - - - - + + + + + + + + diff --git a/StarterNG/MainWindow.axaml.cs b/StarterNG/MainWindow.axaml.cs index a827c5a..1956dd0 100644 --- a/StarterNG/MainWindow.axaml.cs +++ b/StarterNG/MainWindow.axaml.cs @@ -3,22 +3,20 @@ using System.Diagnostics; using System.IO; using System.Linq; using System.Text; -using System.Threading; using System.Threading.Tasks; using Avalonia; using Avalonia.Controls; +using Avalonia.Controls.Primitives; using Avalonia.Input; using Avalonia.Interactivity; +using Avalonia.Layout; using Avalonia.Threading; using Avalonia.VisualTree; using StarterNG.Classes; -using StarterNG.Domain; using StarterNG.Infrastructure; -using StarterNG.Services; using StarterNG.Views; using StarterNG.Application; using StarterNG.Application.Abstractions; -using StarterNG.Domain.Sceneries; using StarterNG.Domain.Settings; namespace StarterNG; @@ -28,7 +26,21 @@ public partial class MainWindow : Window private const int TitleBarHeight = 32; + // The smallest window the pages are laid out for, as MainWindow.axaml sets it. + private const double DesignMinWidth = 1010; + private const double DesignMinHeight = 666; + + // The system frame, which a window's size does not count: at most 15 by 8 on Windows. + private const double FrameAllowance = 16; + + // A start is over at once: a double click or a held key arrives after it. + private static readonly TimeSpan StartCooldown = TimeSpan.FromSeconds(3); + private DateTime _openedUtc = DateTime.UtcNow; + private DateTime _startedUtc = DateTime.MinValue; + private bool _starting; + private bool _lookingAtSceneryFiles; + private readonly DispatcherTimer _sceneryFilesTimer = new() { Interval = TimeSpan.FromSeconds(2) }; public MainWindow() { @@ -38,6 +50,12 @@ public partial class MainWindow : Window ExtendClientAreaToDecorationsHint = true; ExtendClientAreaTitleBarHeightHint = TitleBarHeight; + FitToScreen(); + // Ahead of the layout a new size starts: changed during it, the bar keeps the old width. + Resized += (_, e) => FitTopBar(e.ClientSize.Width); + Opened += (_, _) => FitTopBar(ClientSize.Width); + PageScroll.SizeChanged += (_, _) => KeepPagesUsable(); + if (AppServices.Current.Settings.WindowMaximized || wantsMaximized()) WindowState = WindowState.Maximized; @@ -56,7 +74,6 @@ public partial class MainWindow : Window startButton.AddHandler(PointerReleasedEvent, StartButton_OnRightClick, RoutingStrategies.Tunnel); - SettingsView.DeveloperToolsChanged += ApplyDeveloperTab; SettingsView.ThumbnailSizeChanged += () => { @@ -85,12 +102,109 @@ public partial class MainWindow : Window _openedUtc = DateTime.UtcNow; UpdateStartButton(); + _sceneryFilesTimer.Start(); + FollowLastRun(); }; Activated += (_, _) => CheckExternalSettings(); + _sceneryFilesTimer.Tick += async (_, _) => await LookAtSceneryFilesAsync(); + Closed += (_, _) => _sceneryFilesTimer.Stop(); } + /// + /// Scenery files changed outside the starter are picked up without a reload. Not + /// with the simulator running: it is left in peace, and what it changes comes in after it. + /// + private async Task LookAtSceneryFilesAsync() + { + if (_lookingAtSceneryFiles || AppServices.Current.State.SimulatorRunning || !IsVisible || WindowState == WindowState.Minimized) + return; + + _lookingAtSceneryFiles = true; + try + { + var services = AppServices.Current; + var held = services.Library.Sceneries.ToList(); + var changes = await Task.Run(() => services.SceneryFiles.Look(held)); + if (changes.Count == 0) + return; + + await ScenariosView.FollowSceneryFilesAsync(changes, mayAsk: IsActive); + DepotView.FollowSceneryFiles(); + } + catch (Exception ex) + { + Diagnostics.Log("Following the scenery files", ex); + } + finally + { + _lookingAtSceneryFiles = false; + } + } + + /// + /// Holds the window to what the screen shows of it, so a small or heavily scaled + /// one (1366x768, or 1080p at 150%) keeps Start above the taskbar. The system lets + /// a window open larger than its screen, and centring one then puts its top off it. + /// + private void FitToScreen() + { + if ((Screens.ScreenFromPoint(Position) ?? Screens.Primary) is not { } screen) + return; + + double width = screen.WorkingArea.Width / screen.Scaling - FrameAllowance; + double height = screen.WorkingArea.Height / screen.Scaling - FrameAllowance; + MinWidth = Math.Min(MinWidth, width); + MinHeight = Math.Min(MinHeight, height); + Width = Math.Min(Width, width); + Height = Math.Min(Height, height); + } + + /// + /// Below the size the pages are laid out for they keep it and scroll, rather than + /// squeeze their controls over each other. Start is outside this and stays in view. + /// + private void KeepPagesUsable() + { + double floorWidth = DesignMinWidth - (ClientSize.Width - PageScroll.Bounds.Width); + double floorHeight = DesignMinHeight - (ClientSize.Height - PageScroll.Bounds.Height); + bool narrow = PageScroll.Bounds.Width < floorWidth; + bool low = PageScroll.Bounds.Height < floorHeight; + + PageHost.Width = narrow ? floorWidth : double.NaN; + PageHost.Height = low ? floorHeight : double.NaN; + PageScroll.HorizontalScrollBarVisibility = narrow ? ScrollBarVisibility.Auto : ScrollBarVisibility.Disabled; + PageScroll.VerticalScrollBarVisibility = low ? ScrollBarVisibility.Auto : ScrollBarVisibility.Disabled; + } + + // The version is in the About window too, and the logo is one more link. What fits goes + // by what is there: the version's length depends on the build, the rest on the language. + private void FitTopBar(double width) + { + static double Needed(Control control) + { + control.Measure(Size.Infinity); + return control.DesiredSize.Width; + } + + VersionPanel.IsVisible = true; + LogoLink.IsVisible = true; + double room = width - Needed(TopBarLeft); + if (Needed(TopBarRight) > room) + VersionPanel.IsVisible = false; + if (Needed(TopBarRight) > room) + LogoLink.IsVisible = false; + } + + private void FitTopBarAfterLayout() => + Dispatcher.UIThread.Post(() => FitTopBar(ClientSize.Width), DispatcherPriority.Loaded); + private void TitleBar_OnPointerPressed(object? sender, PointerPressedEventArgs e) { + // A combo box opens on the release and leaves the press to pass on; a window + // move started here would swallow that release. + if (e.Source is Visual source && source.FindAncestorOfType(includeSelf: true) is not null) + return; + if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) BeginMoveDrag(e); } @@ -100,18 +214,24 @@ public partial class MainWindow : Window if (e.InitialPressMouseButton != MouseButton.Right) return; - var win = new Views.AdvancedSettingsWindow(); - win.ToolRequested += tool => + OpenLogAndTools(); + e.Handled = true; + } + + public void OpenLogAndTools() + { + var win = new Views.LogAndToolsWindow(); + win.ToolRequested += async tool => { switch (tool) { case "category": DepotView.ToolAddAllCategory(); break; case "mmd": DepotView.ToolAddUniqueMmd(); break; - case "removeall": DepotView.ToolRemoveAllTrains(); break; + case "removeall": await DepotView.ToolRemoveAllTrainsAsync(); break; } + ScenariosView.RefreshConsistView(); }; win.ShowDialog(this); - e.Handled = true; } private void OnShortcutKey(object? sender, KeyEventArgs e) @@ -124,9 +244,12 @@ public partial class MainWindow : Window if (Shortcuts.Match(e.Key, mods) is not { } id) return; - // Bare keys belong to whatever is being typed into; Ctrl/Alt combinations do not - // collide with text entry, so those stay live everywhere. - if (mods == KeyModifiers.None && IsTextEntryFocused()) + // Bare keys belong to whatever is being typed into, and so do undo, redo and + // Ctrl+Delete, which there takes a word and not the whole consist; other + // Ctrl/Alt combinations do not collide with text entry, so those stay live + // everywhere. + if ((mods == KeyModifiers.None || id is Shortcuts.Undo or Shortcuts.Redo or Shortcuts.ClearConsist) && + IsTextEntryFocused()) return; switch (id) @@ -153,6 +276,9 @@ public partial class MainWindow : Window case Shortcuts.RemoveVehicle: DepotView.KeyRemoveVehicle(); break; case Shortcuts.ClearConsist: DepotView.KeyClearConsist(); break; case Shortcuts.SetNumber: DepotView.KeySetWagonNumber(); break; + case Shortcuts.Undo: DepotView.Undo(); break; + case Shortcuts.Redo: DepotView.Redo(); break; + case Shortcuts.SaveToScenery: DepotView.KeySaveToScenery(); break; default: return; } break; @@ -174,7 +300,7 @@ public partial class MainWindow : Window if (sender is not RadioButton { IsChecked: true } rb) return; - if (ScenariosView is null || DepotView is null || SettingsView is null || DeveloperView is null) + if (ScenariosView is null || DepotView is null || SettingsView is null) return; ShowPage(rb.Tag as string); @@ -186,31 +312,12 @@ public partial class MainWindow : Window ScenariosView.IsVisible = page == "scenarios"; DepotView.IsVisible = page == "depot"; SettingsView.IsVisible = page == "settings"; - DeveloperView.IsVisible = page == "developer"; - - if (DeveloperView.IsVisible) - DeveloperView.Refresh(); - } - - /// - /// Shows or hides the developer tab. Switching it off while it is the open one - /// hands the window back to the scenarios, so the top bar never ends up with - /// nothing selected. - /// - private void ApplyDeveloperTab() - { - bool wanted = AppServices.Current.Settings.DeveloperTools; - NavDeveloper.IsVisible = wanted; - - if (!wanted && NavDeveloper.IsChecked == true) - NavScenarios.IsChecked = true; + PageScroll.Offset = default; } private void ApplyInitialNav() { - ApplyDeveloperTab(); - - foreach (var rb in new[] { NavScenarios, NavDepot, NavSettings, NavDeveloper }) + foreach (var rb in new[] { NavScenarios, NavDepot, NavSettings }) if (rb.IsChecked == true) { ShowPage(rb.Tag as string); @@ -222,7 +329,7 @@ public partial class MainWindow : Window private void PopulateLangCombo() { LangCombo.Items.Clear(); - foreach (var lang in LocalizationService.AvailableLanguages()) + foreach (var lang in App.Loc.Languages()) LangCombo.Items.Add(new ComboBoxItem { Content = lang.Name, Tag = lang.Code }); if (LangCombo.ItemCount == 0) LangCombo.Items.Add(new ComboBoxItem { Content = "English", Tag = "en" }); @@ -243,13 +350,8 @@ public partial class MainWindow : Window private void LangCombo_OnSelectionChanged(object? sender, SelectionChangedEventArgs e) { - if ((LangCombo.SelectedItem as ComboBoxItem)?.Tag is not string code) - return; - - if (string.Equals(code, App.Loc.CurrentLangCode, StringComparison.OrdinalIgnoreCase)) - return; - - App.ApplyLanguage(code); + if ((LangCombo.SelectedItem as ComboBoxItem)?.Tag is string code) + App.ChangeLanguage(code); } private void OnLanguageChanged() @@ -258,33 +360,28 @@ public partial class MainWindow : Window ScenariosView?.RebuildAfterLanguageChange(); DepotView?.RebuildAfterLanguageChange(); UpdateStartButton(); + FitTopBarAfterLayout(); + + // A page not on the screen keeps what it measured: back in view, a tab strip + // in a fixed column went on clipping its new headers at the old words' widths. + foreach (var control in PageHost.GetVisualDescendants().OfType()) + control.InvalidateMeasure(); } private void HelpButton_OnClick(object? sender, RoutedEventArgs e) { var menu = new ContextMenu(); - var keys = new MenuItem - { - Header = App.Loc["ShortcutsTitle"], - InputGesture = new KeyGesture(Key.F1) - }; - keys.Click += (_, _) => Views.ShortcutsWindow.Create().ShowDialog(this); - menu.Items.Add(keys); + menu.AddItem(App.Loc["ShortcutsTitle"], () => Views.ShortcutsWindow.Create().ShowDialog(this), + gesture: new KeyGesture(Key.F1)); menu.Items.Add(new Separator()); - var wiki = new MenuItem { Header = App.Loc["HelpWiki"] }; - wiki.Click += (_, _) => OpenPathOrUrl("https://wiki.eu07.pl/"); - menu.Items.Add(wiki); + menu.AddItem(App.Loc["HelpWiki"], () => OpenPathOrUrl("https://wiki.eu07.pl/")); string readme = string.Equals(App.Loc.CurrentLangCode, "pl", StringComparison.OrdinalIgnoreCase) ? "readme.html" : "en-readme.html"; if (File.Exists(readme) || File.Exists(Path.Combine(Directory.GetCurrentDirectory(), readme))) - { - var mi = new MenuItem { Header = App.Loc["HelpReadme"] }; - mi.Click += (_, _) => OpenPathOrUrl(readme); - menu.Items.Add(mi); - } + menu.AddItem(App.Loc["HelpReadme"], () => OpenPathOrUrl(readme)); string regs = Path.Combine(Directory.GetCurrentDirectory(), "przepisy_kolejowe"); if (Directory.Exists(regs)) @@ -293,12 +390,13 @@ public partial class MainWindow : Window foreach (string pdf in Directory.GetFiles(regs, "*.pdf").OrderBy(Path.GetFileName)) { string file = pdf; - var mi = new MenuItem { Header = Path.GetFileName(file) }; - mi.Click += (_, _) => OpenPathOrUrl(file); - menu.Items.Add(mi); + menu.AddItem(Path.GetFileName(file), () => OpenPathOrUrl(file)); } } + menu.Items.Add(new Separator()); + menu.AddItem(App.Loc["LogAndTools"], OpenLogAndTools); + // "About" belongs with the other informational entries, not next to the // Save/Reset buttons of the settings panel where it used to sit. menu.Items.Add(new Separator()); @@ -355,23 +453,14 @@ public partial class MainWindow : Window } } - private async void openLastLogClick(object? sender, RoutedEventArgs e) - { - string logPath = Path.GetFullPath("log.txt"); - if (!File.Exists(logPath)) - { - await Diagnostics.ReportAsync(string.Format(App.Loc["FaultFileNotFound"], logPath)); - return; - } + private void openLastLogClick(object? sender, RoutedEventArgs e) => + Diagnostics.OpenOrReport(Path.GetFullPath("log.txt")); - try - { - Process.Start(new ProcessStartInfo(logPath) { UseShellExecute = true }); - } - catch (Exception ex) - { - await Diagnostics.ReportAsync(logPath, ex); - } + private void openHaslerTapesClick(object? sender, RoutedEventArgs e) + { + var window = new Views.Tapes.HaslerTapeWindow(); + window.LogEnabled += SettingsView.SyncLogs; + window.Show(this); } private void UpdateStartButton() @@ -380,6 +469,9 @@ public partial class MainWindow : Window var scenery = AppServices.Current.State.CurrentScenery; var trainset = AppServices.Current.State.CurrentTrainset; + startEditorButton.Content = App.Loc[scenery is null ? "StartEditorWizard" : "StartEditorShort"]; + ToolTip.SetTip(startEditorButton, App.Loc[scenery is null ? "StartEditorWizardTip" : "StartEditor"]); + startEditorMoreButton.IsVisible = scenery is not null; if (scenery is null) { @@ -389,8 +481,11 @@ public partial class MainWindow : Window } if (trainset is null) { - startButton.Content = App.Loc["StartNoTrain"]; - startButton.IsEnabled = false; + // Nothing to drive in this scenery - none listed, or none the filters + // show: the free camera is what a start gives. + startButton.Content = App.Loc["StartFreeFly"]; + ToolTip.SetTip(startButton, null); + startButton.IsEnabled = true; return; } @@ -400,26 +495,34 @@ public partial class MainWindow : Window startButton.IsEnabled = true; } - private static bool HasStartableVehicle(Trainset trainset) - { - static bool CanStart(Dynamic v) => - v.DriverType is eDriverType.Headdriver or eDriverType.Reardriver or eDriverType.Passenger; - - string? selected = AppServices.Current.State.StartingVehicleName; - if (!string.IsNullOrEmpty(selected)) - { - var pick = trainset.Vehicles.FirstOrDefault(v => - string.Equals(v.Name, selected, StringComparison.OrdinalIgnoreCase)); - if (pick != null) - return CanStart(pick); - } - return trainset.Vehicles.Any(CanStart); - } + private static bool HasStartableVehicle(Trainset trainset) => + StartSimulation.HasCrewToStart(trainset, AppServices.Current.State.StartingVehicleName); private Task ShowExeProblem(ExeProblem problem, string exe) => MessageBox.Show(this, $"{exe}\n\n{App.Loc[ExeProblemText.Key(problem)]}", App.Loc["ExeLaunchFailed"], MessageBoxButtons.Ok); + private const string SimulatorBuildGuide = "https://github.com/MaSzyna-EU07/maszyna#getting-started"; + + /// + /// Tells a user whose processor lacks AVX2 that the published simulator will + /// not run, and where the instructions for a build of their own are. + /// + public async Task ShowCpuNoticeAsync() + { + var (open, silence) = await MessageBox.ShowNotice( + this, string.Format(App.Loc["CpuNoAvx2"], SimulatorBuildGuide), App.Loc["CpuNoAvx2Title"], + App.Loc["CpuNoAvx2Open"], App.Loc["DontShowAgain"]); + + if (open) + OpenPathOrUrl(SimulatorBuildGuide); + if (silence) + { + AppServices.Current.Settings.CpuNotice = false; + AppServices.Current.SettingsStore.Save(); + } + } + /// /// Asks for the simulator when the installation holds none, and keeps the pick /// straight away, so the question is not back on the next start. @@ -450,26 +553,33 @@ public partial class MainWindow : Window if (e.InitialPressMouseButton != MouseButton.Right) return; + OpenStartMenu(startButton); + } + + private void StartMoreButton_OnClick(object? sender, RoutedEventArgs e) => OpenStartMenu(startMoreButton); + + private async void StartEditorButton_OnClick(object? sender, RoutedEventArgs e) => await LaunchEditorAsync(); + + private void StartEditorMoreButton_OnClick(object? sender, RoutedEventArgs e) + { + var create = new MenuItem { Header = App.Loc["StartEditorWizard"] }; + create.Click += async (_, _) => await FollowStartAsync(AppServices.Current.StartSimulation.ExecuteNewSceneryEditor); + var menu = new ContextMenu(); + menu.Items.Add(create); + menu.Open(startEditorMoreButton); + } + + private void OpenStartMenu(Control anchor) + { var menu = new ContextMenu(); - var noSave = new MenuItem { Header = App.Loc["StartWithoutSaving"] }; - noSave.Click += async (_, _) => await LaunchAsync(saveSettings: false, freeFly: false); - menu.Items.Add(noSave); + // Both need a consist to start in, as the start button itself does. + menu.AddItem(App.Loc["StartWithoutSaving"], async () => await LaunchAsync(saveSettings: false, freeFly: false), + startButton.IsEnabled); + menu.AddItem(App.Loc["StartFreeFly"], async () => await LaunchAsync(saveSettings: true, freeFly: true), + startButton.IsEnabled); - var freeFly = new MenuItem { Header = App.Loc["StartFreeFly"] }; - freeFly.Click += async (_, _) => await LaunchAsync(saveSettings: true, freeFly: true); - menu.Items.Add(freeFly); - - menu.Items.Add(new Separator()); - var editor = new MenuItem - { - Header = App.Loc["StartEditor"], - IsEnabled = AppServices.Current.State.CurrentScenery is not null - }; - editor.Click += async (_, _) => await LaunchEditorAsync(); - menu.Items.Add(editor); - - menu.Open(startButton); + menu.Open(anchor); } /// @@ -478,40 +588,56 @@ public partial class MainWindow : Window /// private async Task LaunchAsync(bool saveSettings, bool freeFly) { - var scenery = AppServices.Current.State.CurrentScenery; - var trainset = AppServices.Current.State.CurrentTrainset; - if (scenery is null || trainset is null) + // A second press while the first is still on its way, or right after it, + // would start a second simulator. + if (_starting || DateTime.UtcNow - _startedUtc < StartCooldown) return; - if (!freeFly && !HasStartableVehicle(trainset) && - !await MessageBox.Show(this, App.Loc["StartNoStaffConfirm"], - App.Loc["StartNoStaffTitle"], MessageBoxButtons.YesNo)) - return; + _starting = true; + try + { + var scenery = AppServices.Current.State.CurrentScenery; + var trainset = AppServices.Current.State.CurrentTrainset; + if (scenery is null) + return; + freeFly |= trainset is null; - LoadingScreen.Prepare(trainset.Logo, Path.GetFileNameWithoutExtension(scenery.Path)); + if (!freeFly && trainset != null && !HasStartableVehicle(trainset) && + !await MessageBox.Show(this, App.Loc["StartNoStaffConfirm"], + App.Loc["StartNoStaffTitle"], MessageBoxButtons.YesNo)) + return; - await FollowStartAsync(AppServices.Current.StartSimulation.Execute(freeFly, saveSettings)); + LoadingScreen.Prepare(trainset?.Logo, Path.GetFileNameWithoutExtension(scenery.Path)); + + await FollowStartAsync(() => AppServices.Current.StartSimulation.Execute(freeFly, saveSettings)); + } + finally + { + _starting = false; + } } /// /// The scenery editor, which only some simulator builds have - said up front, - /// since one without it simply does not open it. + /// since one without it simply does not open it, and it writes to the scenery + /// itself. With none selected there is nothing to write to: the simulator's + /// wizard makes a new one. /// private async Task LaunchEditorAsync() { - if (AppServices.Current.State.CurrentScenery is not { } scenery) - return; - - if (!await MessageBox.Show(this, string.Format(App.Loc["StartEditorWarning"], Path.GetFileName(scenery.Path)), + if (AppServices.Current.State.CurrentScenery is { } scenery && + !await MessageBox.Show(this, string.Format(App.Loc["StartEditorWarning"], Path.GetFileName(scenery.Path)), App.Loc["StartEditor"], MessageBoxButtons.YesNo)) return; - await FollowStartAsync(AppServices.Current.StartSimulation.ExecuteEditor()); + await FollowStartAsync(AppServices.Current.StartSimulation.ExecuteEditor); } /// Reports a start that failed, or steps aside for the simulator that started. - private async Task FollowStartAsync(SimulationStartResult result) + /// The start, run again once what stopped it is put right. + private async Task FollowStartAsync(Func start) { + var result = start(); switch (result.Outcome) { case SimulationStartOutcome.NothingSelected: @@ -533,6 +659,8 @@ public partial class MainWindow : Window return; } + _startedUtc = DateTime.UtcNow; + if (AppServices.Current.Settings.AutoCloseStarter) { Close(); @@ -562,13 +690,26 @@ public partial class MainWindow : Window return; } + AppServices.Current.State.SimulatorRunning = true; _ = simulator.WaitForExitAsync().ContinueWith(_ => Dispatcher.UIThread.Post(() => { + AppServices.Current.State.SimulatorRunning = false; + FollowLastRun(); RestoreFromSimulator(); ReportIfCrashed(simulator, executable, started); }), TaskScheduler.Default); } + /// + /// The player's vehicles in the last run, read from its log off the UI thread + /// while nobody waits: the next start has to have them before the log begins anew. + /// + private static void FollowLastRun() + { + if (AppServices.Current.Settings.PhysicsLog) + _ = Task.Run(AppServices.Current.Tapes.FollowLastRun); + } + /// /// Says why the simulator is gone when it did not end the way a session does: /// an error code, or a close so soon after the start that nobody played - a @@ -588,7 +729,7 @@ public partial class MainWindow : Window var words = exit.CleanErrors.Count > 0 ? exit.CleanErrors - : AppServices.Current.Aftermath.SimulatorLogTail(12); + : AppServices.Current.Aftermath.SimulatorLogTail(12, startedUtc); var message = new StringBuilder(); message.AppendLine(string.Format(App.Loc["SimExitCode"], exit.DescribeCode())); @@ -614,25 +755,36 @@ public partial class MainWindow : Window AppServices.Current.Processes.OpenInShell(folder); } + // The window is activated again as the question closes, before the answer is in. + private bool _askingAboutSettings; + private async void CheckExternalSettings() { - if ((DateTime.UtcNow - _openedUtc).TotalSeconds < 3) + if (_askingAboutSettings || (DateTime.UtcNow - _openedUtc).TotalSeconds < 3) return; if (!AppServices.Current.SettingsStore.IsConfigNewerOnDisk()) return; - var result = await MessageBox.Show( - this, - App.Loc["SettingsFileChanged"], - App.Loc["SettingsFileChangedTitle"], - MessageBoxButtons.YesNo); - if (result) + _askingAboutSettings = true; + try { - AppServices.Current.SettingsStore.Load(); - SettingsView?.ReloadFromSettings(); + var result = await MessageBox.Show( + this, + App.Loc["SettingsFileChanged"], + App.Loc["SettingsFileChangedTitle"], + MessageBoxButtons.YesNo); + if (result) + { + AppServices.Current.SettingsStore.Load(); + SettingsView?.ReloadFromSettings(); + } + else + AppServices.Current.SettingsStore.TouchConfigAge(); + } + finally + { + _askingAboutSettings = false; } - else - AppServices.Current.SettingsStore.TouchConfigAge(); } private void RestoreFromSimulator() diff --git a/StarterNG/Program.cs b/StarterNG/Program.cs index 635bec6..2c1beca 100644 --- a/StarterNG/Program.cs +++ b/StarterNG/Program.cs @@ -1,4 +1,5 @@ using Avalonia; +using Avalonia.Threading; using System; using System.Threading.Tasks; using StarterNG.Application; @@ -50,9 +51,22 @@ static class Program return builder; } + // After setup: the dispatcher takes its platform's implementation when first asked + // for. Not marked handled: a handler that failed half way leaves the state unknown, + // and the exception goes on to end the program as before, only no longer unexplained. + private static void LogUiExceptions() + { + Dispatcher.UIThread.UnhandledException += (_, e) => + { + Diagnostics.Log("Unhandled UI exception", e.Exception); + Diagnostics.Flush(); + }; + } + public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure() .UsePlatformDetect() .WithInterFont() - .WithDiagnosticsLogging(); + .WithDiagnosticsLogging() + .AfterSetup(_ => LogUiExceptions()); } diff --git a/StarterNG/Services/LocalizationService.cs b/StarterNG/Services/LocalizationService.cs index 8357d95..f8b16ff 100644 --- a/StarterNG/Services/LocalizationService.cs +++ b/StarterNG/Services/LocalizationService.cs @@ -2,11 +2,22 @@ using System; using System.Collections.Generic; using System.ComponentModel; using System.IO; +using System.Linq; +using System.Text.RegularExpressions; using System.Xml; using StarterNG.Application.Abstractions; +using StarterNG.Infrastructure.Adapters; namespace StarterNG.Services; +/// +/// Where languages other than the bundled ones come from. +/// +/// Folders of *.xml, the first holding a code winning. +/// Where the old starter kept its lang-XX.txt. +/// The simulator's lang folder, with its XX.po. +public sealed record LanguageFolders(IReadOnlyList Translations, string? OldStarter, string? Simulator); + /// /// Loads the translation table from startercfg/lang/*.xml and exposes it as /// . Stays a change-notifying class because the @@ -18,12 +29,19 @@ public class LocalizationService : INotifyPropertyChanged, ILocalizedStrings Path.Combine(AppContext.BaseDirectory, "startercfg", "lang"); private readonly IDiagnosticsLog _log; + private readonly string _directory; + private readonly LanguageFolders _folders; + + // Codes eu07.ini named that nothing else has, as the old starter added them to its list. + private readonly List _kept = new(); private Dictionary _strings = new(); - public LocalizationService(IDiagnosticsLog log) + public LocalizationService(IDiagnosticsLog log, string? directory = null, LanguageFolders? folders = null) { _log = log; + _directory = directory ?? LangDirectory; + _folders = folders ?? new LanguageFolders(new[] { _directory }, null, null); } public event PropertyChangedEventHandler? PropertyChanged; @@ -34,6 +52,9 @@ public class LocalizationService : INotifyPropertyChanged, ILocalizedStrings public string CurrentLangCode { get; private set; } = "en"; + /// Whether this language has the starter's own texts; without them they are in English. + public bool HasOwnTexts { get; private set; } = true; + private readonly HashSet _reportedMisses = new(StringComparer.Ordinal); /// @@ -58,7 +79,8 @@ public class LocalizationService : INotifyPropertyChanged, ILocalizedStrings if (_reportedMisses.Add(key)) _log.Log($"lang/{CurrentLangCode}: missing key \"{key}\""); - return key; + // A translation someone prepared for an earlier starter lacks the newer texts. + return English().TryGetValue(key, out value) ? value : key; } } @@ -69,15 +91,46 @@ public class LocalizationService : INotifyPropertyChanged, ILocalizedStrings public string Opt(string key) => _strings.TryGetValue(key, out var value) ? value : string.Empty; + private Dictionary? _english; + + /// + /// , falling back to English for text only the English file + /// carries - the key descriptions, which read better in English than in Polish + /// to someone who picked Czech. + /// + public string OptOrEnglish(string key) + { + if (_strings.TryGetValue(key, out var value)) + return value; + + return English().TryGetValue(key, out value) ? value : string.Empty; + } + + private Dictionary English() + { + if (_english is null) + { + _english = new Dictionary(StringComparer.Ordinal); + if (WithCode(AvailableLanguages(_log, _directory), "en") is { } lang) + { + try { _english = ReadStrings(lang.Path); } + catch (Exception ex) { _log.Log($"lang/{Path.GetFileName(lang.Path)}", ex); } + } + } + return _english; + } + + /// The translation, empty for a language known by its code alone. public readonly record struct LanguageInfo(string Code, string Name, string Path); - public static IReadOnlyList AvailableLanguages(IDiagnosticsLog? log = null) + public static IReadOnlyList AvailableLanguages(IDiagnosticsLog? log = null, string? directory = null) { + directory ??= LangDirectory; var list = new List(); - if (!Directory.Exists(LangDirectory)) + if (!Directory.Exists(directory)) return list; - foreach (var path in Directory.EnumerateFiles(LangDirectory, "*.xml")) + foreach (var path in Directory.EnumerateFiles(directory, "*.xml")) { try { @@ -93,41 +146,108 @@ public class LocalizationService : INotifyPropertyChanged, ILocalizedStrings return list; } - public string Load(string codeOrName) + /// + /// Every language there is to pick: the translations, the player's own first, then + /// those only a code is known of - the old starter's lang-XX.txt, the simulator's + /// XX.po and what eu07.ini names. Picked, those leave this starter in English and + /// go to the simulator as they are. + /// + public IReadOnlyList Languages() { - var languages = AvailableLanguages(_log); + var found = new SortedDictionary(StringComparer.OrdinalIgnoreCase); - LanguageInfo? match = null; - foreach (var lang in languages) + foreach (string folder in _folders.Translations) + foreach (var lang in AvailableLanguages(_log, folder)) + found.TryAdd(lang.Code, lang); + + foreach (string code in CodesOnly()) + found.TryAdd(code, new LanguageInfo(code.ToLowerInvariant(), code.ToUpperInvariant(), string.Empty)); + + return found.Values.ToList(); + } + + private IEnumerable CodesOnly() + { + foreach (string name in FileNames(_folders.OldStarter, "lang-*.txt")) + yield return name["lang-".Length..]; + + // lang/ also holds editor_pl.po and template.po. + foreach (string name in FileNames(_folders.Simulator, "*.po")) + if (Regex.IsMatch(name, "^[A-Za-z]{2,3}$")) + yield return name; + + foreach (string code in _kept) + yield return code; + } + + private IEnumerable FileNames(string? folder, string pattern) + { + if (string.IsNullOrEmpty(folder) || !Directory.Exists(folder)) + return Array.Empty(); + + try { + return Directory.GetFiles(folder, pattern).Select(Path.GetFileNameWithoutExtension).ToList()!; + } + catch (Exception ex) + { + _log.Log($"looking through {folder}", ex); + return Array.Empty(); + } + } + + /// A language eu07.ini names stays on the list, known or not. + public void Keep(string? codeOrName) + { + if (string.IsNullOrWhiteSpace(codeOrName)) + return; + + string wanted = codeOrName.Trim(); + if (Find(Languages(), wanted) is null) + _kept.Add(wanted); + } + + private static LanguageInfo? Find(IReadOnlyList languages, string codeOrName) + { + foreach (var lang in languages) if (string.Equals(lang.Code, codeOrName, StringComparison.OrdinalIgnoreCase) || string.Equals(lang.Name, codeOrName, StringComparison.OrdinalIgnoreCase)) - { - match = lang; - break; - } - } + return lang; + return null; + } + private static LanguageInfo? WithCode(IEnumerable languages, string code) + { + foreach (var lang in languages) + if (string.Equals(lang.Code, code, StringComparison.OrdinalIgnoreCase)) + return lang; + return null; + } + + public string Load(string codeOrName) + { + var languages = Languages(); + + LanguageInfo? match = Find(languages, codeOrName); if (match is null) { - foreach (var lang in languages) - if (string.Equals(lang.Code, "en", StringComparison.OrdinalIgnoreCase)) - { - match = lang; - break; - } + match = WithCode(languages, "en"); if (match is null && languages.Count > 0) match = languages[0]; } - if (match is { } info) - LoadFile(info.Path, info.Code, info.Name); - else + HasOwnTexts = true; + if (match is { Path.Length: 0 } codeOnly) { - - _strings = new Dictionary(); - CurrentLangCode = "en"; - CurrentLanguage = "English"; + LoadEnglish(languages); + CurrentLangCode = codeOnly.Code; + CurrentLanguage = codeOnly.Name; + HasOwnTexts = false; + ReadOldKeyDescriptions(codeOnly.Code); + } + else if (match is not { } info || !TryLoadFile(info)) + { + LoadEnglish(languages); } PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Item[]")); @@ -136,7 +256,82 @@ public class LocalizationService : INotifyPropertyChanged, ILocalizedStrings return CurrentLanguage; } - private void LoadFile(string path, string code, string name) + // Without the language asked for, English; without that, the keys themselves. The + // starter comes up either way. + private void LoadEnglish(IReadOnlyList languages) + { + // Languages() holds one language to a code. + if (WithCode(languages, "en") is { Path.Length: > 0 } english && TryLoadFile(english)) + return; + + _strings = new Dictionary(); + CurrentLangCode = "en"; + CurrentLanguage = "English"; + } + + /// + /// The one part of an old starter's language file this one can use: the rest names + /// that program's controls. Its key descriptions follow a line with the file's name. + /// + private void ReadOldKeyDescriptions(string code) + { + if (string.IsNullOrEmpty(_folders.OldStarter) || !Directory.Exists(_folders.OldStarter)) + return; + + try + { + // The list holds codes in lower case and the file may be lang-ZH.txt; on Linux those differ. + string? path = Directory.EnumerateFiles(_folders.OldStarter, "lang-*.txt") + .FirstOrDefault(file => string.Equals(Path.GetFileNameWithoutExtension(file), $"lang-{code}", StringComparison.OrdinalIgnoreCase)); + if (path is null) + return; + + // The old starter's own files are code page 1250 as often as UTF-8. + bool inDescriptions = false; + foreach (string line in LegacyText.Decode(File.ReadAllBytes(path)).ReplaceLineEndings("\n").Split('\n')) + { + if (line.Contains("A file cut short has a header like any other and fails only here: it counts as missing. + private bool TryLoadFile(LanguageInfo language) + { + try + { + _strings = ReadStrings(language.Path); + } + catch (Exception ex) + { + _log.Log($"lang/{Path.GetFileName(language.Path)}", ex); + return false; + } + + lock (_reportedMisses) + _reportedMisses.Clear(); + CurrentLangCode = language.Code; + CurrentLanguage = language.Name; + return true; + } + + private static Dictionary ReadStrings(string path) { var dict = new Dictionary(StringComparer.Ordinal); @@ -158,11 +353,7 @@ public class LocalizationService : INotifyPropertyChanged, ILocalizedStrings } } - _strings = dict; - lock (_reportedMisses) - _reportedMisses.Clear(); - CurrentLangCode = code; - CurrentLanguage = name; + return dict; } private static (string code, string name) ReadHeader(string path) diff --git a/StarterNG/StarterNG.csproj b/StarterNG/StarterNG.csproj index bd5b1d8..c24c230 100644 --- a/StarterNG/StarterNG.csproj +++ b/StarterNG/StarterNG.csproj @@ -12,7 +12,7 @@ true - 1.4 + 1.5 Starter eu07.pl @@ -24,10 +24,10 @@ - - - - + + + + none diff --git a/StarterNG/Styles/Carbon.axaml b/StarterNG/Styles/Carbon.axaml index e58c9a5..b44937d 100644 --- a/StarterNG/Styles/Carbon.axaml +++ b/StarterNG/Styles/Carbon.axaml @@ -20,6 +20,12 @@ + + + + + + @@ -43,6 +49,7 @@ VerticalAlignment="Center" /> @@ -311,6 +318,7 @@ + @@ -325,9 +333,6 @@ - + + + + diff --git a/StarterNG/Styles/Settings.axaml b/StarterNG/Styles/Settings.axaml index c5a8ca4..dc938c0 100644 --- a/StarterNG/Styles/Settings.axaml +++ b/StarterNG/Styles/Settings.axaml @@ -11,7 +11,7 @@ - + + + @@ -99,6 +111,24 @@ + + + + + + @@ -118,6 +148,13 @@ + + + + + + + + + + + + + + + + + @@ -220,8 +314,7 @@ TextWrapping="Wrap" MaxWidth="520" Opacity="0.6" TextAlignment="Center" Text="{Binding [ConsistEmpty], Source={x:Static starterNg:App.Loc}}" /> + HorizontalScrollBarVisibility="Visible" VerticalScrollBarVisibility="Disabled"> diff --git a/StarterNG/Views/Depot.axaml.cs b/StarterNG/Views/Depot.axaml.cs index af9f5fe..5d3efe9 100644 --- a/StarterNG/Views/Depot.axaml.cs +++ b/StarterNG/Views/Depot.axaml.cs @@ -1,19 +1,12 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.IO; using System.Linq; -using System.Text; using System.Threading.Tasks; using Avalonia; using Avalonia.Controls; -using Avalonia.Controls.Primitives; -using Avalonia.Controls.Templates; using Avalonia.Input; -using Avalonia.Input.Platform; -using Avalonia.Layout; using Avalonia.Media; -using Avalonia.Media.Imaging; using Avalonia.Threading; using Avalonia.VisualTree; using StarterNG.Classes; @@ -54,6 +47,16 @@ public partial class Depot : UserControl private Scenery? _listScenery; + private readonly ConsistHistory _history = new(); + + /// Set while an undo or redo puts a state back, which is not a change of its own. + private bool _restoring; + + private readonly WarehouseEdit _warehouseEdit = new(); + + private bool EditingWarehouse => + _warehouseEdit.Trainset is not null && ReferenceEquals(_consist.EditingTrainset, _warehouseEdit.Trainset); + public Depot() { _info = new VehicleInfo(_db, AppServices.Current.Physics); @@ -67,13 +70,19 @@ public partial class Depot : UserControl return; _warehouse = new Warehouse(warehouseList, removeFromWarehouseButton, _hand, - () => _consist.EditingTrainset, ApplyConsist); + () => AppServices.Current.State.CurrentTrainset, ApplyFromWarehouse); + _warehouse.SelectionChanged += () => + { + if (WarehouseTabOpen) + EditWarehouseSelection(); + }; + _warehouse.Renamed += _warehouseEdit.Renamed; _browser = new VehicleBrowser(categoryCombo, classCombo, vehicleListBox, searchBox, - hideArchivalCheck, miniPreview, addVehicleButton, _db, _minis, - () => TextureBaseButton_OnClick(null, null!)) + hideArchivalCheck, miniPreview, addVehicleButton, _db, _minis, OpenVehicleBase) { - AddMany = (texture, count) => AddTexture(texture, count) + AddMany = (texture, count) => AddTexture(texture, count), + Replace = ReplaceSelected }; _drag = new ConsistDragging(consistStack, consistScroll, consistOverlay, @@ -83,7 +92,13 @@ public partial class Depot : UserControl _cards = new VehicleCards(consistStack, _db, _consist, _cargo, _minis, _hand, RebuildConsist, () => _details.Refresh(), _browser.SelectInBrowser, _drag.ArmCardDrag) { - AddCopies = AddCopiesAfter + AddCopies = AddCopiesAfter, + BrowserSelection = () => _browser.Selected, + ReplaceWith = (item, texture) => + { + _consist.Selected = item; + ReplaceSelected(texture); + } }; // Hidden still scrolls - it only drops the bar itself. @@ -92,8 +107,16 @@ public partial class Depot : UserControl _details = new VehicleDetails( generalPanel, consistTexturePanel, couplerPanel, couplerActions, brakesPanel, loadsPanel, damagePanel, removeVehicleButton, - _db, _info, _consist, _cargo, _hand, - RebuildConsist, _cards.RefreshMemberChrome, _browser.ShowTextureInfo); + _info, _consist, _cargo, _hand, + RebuildConsist, _cards.RefreshMemberChrome, _browser.ShowTextureInfo) + { + // A warehouse consist is kept in its file; a scenery one is the list itself. + Edited = () => + { + if (EditingWarehouse) + _warehouseEdit.Save(); + } + }; _browser.TextureSelected = _details.ShowTexture; @@ -112,22 +135,101 @@ public partial class Depot : UserControl AttachedToVisualTree += (_, _) => SyncFromSelection(); PropertyChanged += (_, e) => { - if (e.Property == IsVisibleProperty && IsVisible) + if (e.Property != IsVisibleProperty) + return; + if (!IsVisible) { - SyncFromSelection(); - _warehouse.Refresh(); + RememberConsistOnLeaving(); + return; } + if (ConsistMovedSinceLeaving()) + ShowSceneryTab(); + SyncFromSelection(); + _warehouse.Refresh(); }; } + private Trainset? _leftTrainset; + + private List _leftVehicles = new(); + + private void RememberConsistOnLeaving() + { + _leftTrainset = AppServices.Current.State.CurrentTrainset; + _leftVehicles = _leftTrainset?.Vehicles.ToList() ?? new List(); + } + + /// Another consist was picked or this one changed on another page: the warehouse tab left open would hide it. + private bool ConsistMovedSinceLeaving() => + AppServices.Current.State.CurrentTrainset is { } trainset && + (!ReferenceEquals(trainset, _leftTrainset) || !trainset.Vehicles.SequenceEqual(_leftVehicles)); + private void AddToWarehouseButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => - _ = _warehouse.SaveCurrentAsync(); + _warehouse.SaveCurrent((Control)sender!); private void RemoveFromWarehouseButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => _ = _warehouse.RemoveSelectedAsync(); - private void WarehouseList_OnDoubleTapped(object? sender, Avalonia.Input.TappedEventArgs e) => - _warehouse.ApplySelected(); + private bool WarehouseTabOpen => ReferenceEquals(consistSourceTabs.SelectedItem, warehouseTab); + + private void ConsistSourceTabs_OnSelectionChanged(object? sender, SelectionChangedEventArgs e) + { + // The lists inside the tabs raise the same event, and it bubbles up here. + if (!ReferenceEquals(e.Source, consistSourceTabs) || Design.IsDesignMode) + return; + + if (WarehouseTabOpen) + { + // A consist saved from a menu elsewhere went in while this list was not shown. + _warehouse.Refresh(); + EditWarehouseSelection(); + } + else + EditSceneryConsist(); + } + + /// The strip takes the warehouse consist selected, or goes empty without one. + private void EditWarehouseSelection() + { + if (_warehouse.SelectedPreset() is not { } preset) + { + _warehouseEdit.Close(); + _consist.Unload(); + return; + } + + var trainset = _warehouseEdit.Open(preset); + if (ReferenceEquals(_consist.EditingTrainset, trainset)) + RebuildConsist(); + else + _consist.LoadFrom(trainset); + } + + /// Back on the scenery tab, the strip edits the consist that will be started. + private void EditSceneryConsist() + { + _warehouseEdit.Close(); + if (AppServices.Current.State.CurrentTrainset is { } trainset) + _consist.LoadFrom(trainset); + else + _consist.Unload(); + } + + private void ShowSceneryTab() + { + if (WarehouseTabOpen) + consistSourceTabs.SelectedItem = sceneryConsistsTab; + } + + /// From the warehouse's menu: shown where it went, on the scenery tab. + private void ApplyFromWarehouse(Trainset target, List vehicles) + { + ShowSceneryTab(); + ApplyConsist(target, vehicles); + } + + private bool IsSceneryTrainset(Trainset trainset) => + _listScenery?.Trainsets.Contains(trainset) == true; private void CategoryCombo_OnSelectionChanged(object? sender, SelectionChangedEventArgs e) => _browser.CategoryCombo_OnSelectionChanged(sender, e); @@ -169,6 +271,13 @@ public partial class Depot : UserControl private Dynamic NewVehicle(VehicleTexture texture) => _consist.MakeDynamic(texture, _listScenery ?? AppServices.Current.State.CurrentScenery); + /// The scenery files changed under the page in view; out of view it catches up on its way back. + public void FollowSceneryFiles() + { + if (IsVisible) + SyncFromSelection(); + } + private void SyncFromSelection() { @@ -177,25 +286,48 @@ public partial class Depot : UserControl PopulateSceneryConsists(); var trainset = AppServices.Current.State.CurrentTrainset; - if (trainset != null && !ReferenceEquals(trainset, _consist.EditingTrainset)) - _consist.LoadFrom(trainset); - else if (_consist.EditingTrainset != null) + if (WarehouseTabOpen) RebuildConsist(); + else if (trainset is null) + { + // Another scenery, with no consist picked: the old one's is not edited on. + if (_consist.EditingTrainset != null) + _consist.Unload(); + } + else if (!ReferenceEquals(trainset, _consist.EditingTrainset)) + _consist.LoadFrom(trainset); + else + { + ReloadIfEditedElsewhere(); + RebuildConsist(); + } + } + + /// + /// The strip holds the vehicles written back last. A list that no longer holds + /// them was changed on the scenarios page or by a tool, and the next write-back + /// would put the strip's old vehicles over that change. + /// + private void ReloadIfEditedElsewhere() + { + if (_consist.EditingTrainset is { } trainset && !_consist.Flatten().SequenceEqual(trainset.Vehicles)) + _consist.LoadFrom(trainset, _consist.Selected is { } selected ? Math.Max(_consist.IndexOf(selected), 0) : 0); } public void RebuildAfterLanguageChange() { _browser.PopulateCategoryCombo(); PopulateSceneryConsists(); + ReloadIfEditedElsewhere(); RebuildConsist(); _details.Refresh(); } - /// Redraws the consist cards, e.g. after the thumbnail size changed. /// Redraws what depends on the thumbnail size: the consist and the class list. public void RefreshConsistView() { _browser.InitClassComboTemplates(); + ReloadIfEditedElsewhere(); RebuildConsist(); } @@ -217,6 +349,9 @@ public partial class Depot : UserControl } emptyHint.IsVisible = _consist.Count == 0; + emptyHint.Text = _consist.EditingTrainset is not null + ? App.Loc["ConsistEmpty"] + : App.Loc[WarehouseTabOpen ? "WarehousePickHint" : "ConsistPickHint"]; // A selection the consist moved on its own - after a removal, say - is only // highlighted; activating it runs what a click would have run. @@ -226,8 +361,10 @@ public partial class Depot : UserControl // The stats are read off the trainset, so it is written back first. WriteBackToScenery(); + RecordHistory(); _details.Refresh(); UpdateTrainStats(); + UpdateConsistChrome(); // The cards are thrown away and rebuilt on every change, so a keyboard-driven // edit has to be handed its card back or focus escapes the strip. @@ -240,7 +377,154 @@ public partial class Depot : UserControl return; _consist.EditingTrainset.Vehicles = _consist.Flatten(); + if (EditingWarehouse) + { + _warehouseEdit.Save(); + return; + } + RefreshConsistListLabels(); + + // The start button reads the crew off the trainset, which was not written + // back yet when the selection named the vehicle to start in - without this + // it went on saying "no crew" until a vehicle was clicked on the scenarios tab. + if (ReferenceEquals(_consist.EditingTrainset, AppServices.Current.State.CurrentTrainset)) + AppServices.Current.State.NotifyChanged(); + } + + /// + /// The title says which consist the strip edits; the scenery file's tools and + /// "add to warehouse" are for a scenery consist only. + /// + private void UpdateConsistChrome() + { + bool warehouse = EditingWarehouse; + consistGroup.Header = warehouse + ? string.Format(App.Loc["ConsistFromWarehouse"], _warehouseEdit.Name) + : App.Loc["Consist"]; + + var trainset = _consist.EditingTrainset; + bool scenery = trainset is not null && !warehouse && IsSceneryTrainset(trainset); + bool modified = scenery && SceneryTrainsetText.IsModified(trainset!); + bool sceneryTab = !WarehouseTabOpen; + saveToSceneryButton.IsVisible = sceneryTab; + revertSceneryButton.IsVisible = sceneryTab; + addToWarehouseToolButton.IsVisible = sceneryTab; + saveToSceneryButton.IsEnabled = modified; + revertSceneryButton.IsEnabled = modified; + + reversedBadge.IsVisible = trainset?.Reversed == true; + } + + private void SaveToSceneryButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => + _ = SaveToSceneryAsync(_consist.EditingTrainset); + + private void RevertSceneryButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => + RevertFromScenery(_consist.EditingTrainset); + + public void KeySaveToScenery() + { + if (!EditingWarehouse) + _ = SaveToSceneryAsync(_consist.EditingTrainset); + } + + /// + /// Writes the consist into the scenery file for good. Asked first only while + /// the file has no backup yet - the first time it is about to change. + /// + private async Task SaveToSceneryAsync(Trainset? trainset) + { + if (trainset is null || _listScenery is not { } scenery || !IsSceneryTrainset(trainset) || + !SceneryTrainsetText.IsModified(trainset) || TopLevel.GetTopLevel(this) is not Window owner) + return; + + // A consist from an included file is saved there - shared, maybe, by other scenarios. + string path = SaveSceneryTrainset.FileOf(scenery, trainset); + string file = trainset.SourceFile ?? Path.GetFileName(scenery.Path); + if (!AppServices.Current.Files.FileExists(path + SaveSceneryTrainset.BackupSuffix) && + !await MessageBox.Show(owner, + string.Format(App.Loc["SaveToSceneryConfirm"], file, file + SaveSceneryTrainset.BackupSuffix), + App.Loc["SaveToScenery"], MessageBoxButtons.YesNo)) + return; + + var (outcome, detail) = AppServices.Current.SaveTrainset.Execute(scenery, trainset); + switch (outcome) + { + case SceneryTrainsetSaveOutcome.NotFound: + await StarterNG.Infrastructure.Diagnostics.ReportAsync( + string.Format(App.Loc["SaveToSceneryNotFound"], file)); + break; + case SceneryTrainsetSaveOutcome.Failed: + await StarterNG.Infrastructure.Diagnostics.ReportAsync( + $"{path}{Environment.NewLine}{Environment.NewLine}{App.Loc["FaultDetail"]} {detail}"); + break; + } + + RefreshConsistListLabels(); + UpdateConsistChrome(); + } + + /// The consist as its scenery file has it - one more step to undo. + private void RevertFromScenery(Trainset? trainset) + { + if (trainset is null || !IsSceneryTrainset(trainset) || !SceneryTrainsetText.IsModified(trainset)) + return; + + var saved = new Trainset(trainset.RawEntry); + trainset.Vehicles = saved.Vehicles; + trainset.Velocity = saved.Velocity; + if (ReferenceEquals(_consist.EditingTrainset, trainset)) + _consist.LoadFrom(trainset); + else + RefreshConsistListLabels(); + } + + private int SelectedIndex() => + _consist.Selected is { } item ? _consist.IndexOf(item) : 0; + + private void RecordHistory() + { + if (_consist.EditingTrainset is { } trainset) + _history.Record(trainset, SelectedIndex(), rebase: _restoring); + + var editing = _consist.EditingTrainset; + undoButton.IsEnabled = editing is not null && _history.CanUndo(editing); + redoButton.IsEnabled = editing is not null && _history.CanRedo(editing); + } + + public void Undo() => Restore(_history.Undo); + + public void Redo() => Restore(_history.Redo); + + private void Restore(Func step) + { + if (_consist.EditingTrainset is not { } trainset || + step(trainset, SelectedIndex()) is not { } state) + return; + + trainset.Vehicles = state.Vehicles.ToList(); + _restoring = true; + try + { + _consist.LoadFrom(trainset, state.Selected); + } + finally + { + _restoring = false; + } + } + + private void UndoButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => Undo(); + + private void RedoButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => Redo(); + + private void ClearConsistButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => + KeyClearConsist(); + + private void ReplaceVehicleButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + { + if (_browser.Selected != null) + ReplaceSelected(_browser.Selected); } private void UpdateTrainStats() @@ -254,15 +538,12 @@ public partial class Depot : UserControl StarterNG.Infrastructure.StatsBar.Fill(trainStats, TrainsetDisplay.StatsFields( _consist.EditingTrainset, _info.PhysicsFor, - car => _db.TextureFor(car.DataFolder, car.SkinFile, car.MmdFile) is { } t ? VehicleInfo.CategoryOf(t) : null, + _info.CategoryOf, AppServices.Current.LoadWeights.Table.WeightOf)); } - private void RemoveVehicleButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) - { - if (_consist.Selected is { } item) - _consist.Remove(item); - } + private void RemoveVehicleButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => + KeyRemoveVehicle(); private void ConsistFilter_OnChanged(object? sender, Avalonia.Interactivity.RoutedEventArgs e) { @@ -292,8 +573,8 @@ public partial class Depot : UserControl foreach (var trainset in _listScenery.Trainsets) { - if (IsAiTrainset(trainset) && !showAi) continue; - if (drivableOnly && !UData.StaffedTrain(trainset)) continue; + if (!TrainsetDisplay.ListedIn(trainset, showAi, drivableOnly, AppServices.Current.State.CurrentTrainset)) + continue; var entry = new ListBoxItem { @@ -302,13 +583,29 @@ public partial class Depot : UserControl }; var ts = trainset; + + // The random tools work on the consist being edited, which is not + // necessarily the entry the menu was opened on. + void Edit(Action run) + { + if (!ReferenceEquals(_consist.EditingTrainset, ts)) + sceneryConsistList.SelectedItem = entry; + run(); + } + entry.ContextMenu = ConsistContextMenu.Build( entry, () => ts, vehicles => ApplyConsist(ts, vehicles), () => RemoveAllVehicles(ts), - randomOrder: ToolRandomOrder, - randomTurn: ToolRandomOrientation); + randomOrder: () => Edit(() => _consist.RandomizeOrder(_rng)), + randomTurn: () => Edit(() => _consist.RandomizeOrientation(_rng)), + randomTextures: () => Edit(() => _ = RunTextureRandomizer()), + removeAllGesture: new KeyGesture(Key.Delete, KeyModifiers.Control), + sceneryFile: new ConsistContextMenu.SceneryFileActions( + () => SceneryTrainsetText.IsModified(ts), + () => _ = SaveToSceneryAsync(ts), + () => RevertFromScenery(ts))); sceneryConsistList.Items.Add(entry); if (ReferenceEquals(trainset, AppServices.Current.State.CurrentTrainset)) @@ -330,10 +627,6 @@ public partial class Depot : UserControl _consist.LoadFrom(trainset); } - private static bool IsAiTrainset(Trainset trainset) => - !string.IsNullOrEmpty(trainset.Description) && - trainset.Description.TrimStart().StartsWith("-", StringComparison.Ordinal); - private void SceneryConsistList_OnKeyDown(object? sender, KeyEventArgs e) { if (e.Key == Key.Delete && e.KeyModifiers.HasFlag(KeyModifiers.Shift) && @@ -348,35 +641,52 @@ public partial class Depot : UserControl private void ApplyConsist(Trainset target, List vehicles) { target.Vehicles = vehicles; - AppServices.Current.State.CurrentScenery = _listScenery; - AppServices.Current.State.CurrentTrainset = target; - _consist.LoadFrom(target); - PopulateSceneryConsists(); + EditTrainset(target); } private void RemoveAllVehicles(Trainset target) { target.Vehicles = new List(); + EditTrainset(target); + } + + /// + /// A scenery consist becomes the one started as well; a warehouse consist is + /// only edited. + /// + private void EditTrainset(Trainset target) + { + if (!IsSceneryTrainset(target)) + { + _consist.LoadFrom(target); + return; + } + AppServices.Current.State.CurrentScenery = _listScenery; AppServices.Current.State.CurrentTrainset = target; _consist.LoadFrom(target); PopulateSceneryConsists(); } - private static Control ConsistListContent(Trainset trainset) => - new TextBlock - { - Text = ConsistLabel(trainset), - TextTrimming = TextTrimming.CharacterEllipsis, - TextWrapping = TextWrapping.NoWrap - }; - - private static string ConsistLabel(Trainset trainset) + /// The composition, after a dot for a consist the scenery file does not have yet. + private static Control ConsistListContent(Trainset trainset) { - string? text = TrainsetDisplay.CompositionText(trainset); - if (text != null) - return text; - return string.Format(App.Loc["EmptyTrainset"], trainset.Track ?? ""); + var label = Scenarios.TrainsetListContent(trainset); + if (!SceneryTrainsetText.IsModified(trainset)) + return label; + + var dot = new TextBlock + { + Text = "●", + Margin = new Thickness(0, 0, 6, 0), + Foreground = new SolidColorBrush(Color.Parse("#E0A030")) + }; + ToolTip.SetTip(dot, App.Loc["ConsistUnsaved"]); + var row = new DockPanel(); + DockPanel.SetDock(dot, Dock.Left); + row.Children.Add(dot); + row.Children.Add(label); + return row; } private void RefreshConsistListLabels() @@ -388,22 +698,6 @@ public partial class Depot : UserControl } } - public async Task RandomizeCurrentTrainsetAsync() - { - if (AppServices.Current.State.CurrentTrainset is not { } trainset) - return; - - if (!ReferenceEquals(trainset, _consist.EditingTrainset)) - { - PopulateSceneryConsists(); - _consist.LoadFrom(trainset); - } - await RunTextureRandomizer(); - } - - public void ToolRandomOrder() => RandomOrderButton_OnClick(null, null!); - public void ToolRandomOrientation() => RandomOrientButton_OnClick(null, null!); - private async void RandomTexturesButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => await RunTextureRandomizer(); @@ -464,20 +758,27 @@ public partial class Depot : UserControl private void VehicleListBox_OnDoubleTapped(object? sender, Avalonia.Input.TappedEventArgs e) { - if (_browser.Selected != null) + // On a row only: the list's scrollbar takes double clicks too. + if (_browser.Selected != null && + (e.Source as Visual)?.FindAncestorOfType(includeSelf: true) != null) ReplaceSelected(_browser.Selected); } - private void AddVehicleButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + private void AddVehicleButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => + KeyAddVehicle(); + + /// Keyboard shortcut entry points. See . + public void KeyAddVehicle() { if (_browser.Selected != null) AddTexture(_browser.Selected); } - /// Keyboard shortcut entry points. See . - public void KeyAddVehicle() => AddVehicleButton_OnClick(null, null!); - - public void KeyRemoveVehicle() => RemoveVehicleButton_OnClick(null, null!); + public void KeyRemoveVehicle() + { + if (_consist.Selected is { } item) + _consist.Remove(item); + } public void KeyClearConsist() { @@ -493,36 +794,36 @@ public partial class Depot : UserControl searchBox.SelectAll(); } - public void ToolAddAllCategory() => AddAllCategoryButton_OnClick(null, null!); + // The tools are reached from any page, and this view catches up with the + // selection only when shown - so they do that first, or they would work on the + // scenery it showed last. They are about the scenery, so never on the warehouse's. + private void PrepareForTool() + { + ShowSceneryTab(); + SyncFromSelection(); + } - public void ToolAddUniqueMmd() => AddUniqueMmdButton_OnClick(null, null!); - - public void ToolRemoveAllTrains() => RemoveAllTrainsButton_OnClick(null, null!); - - private async void AddAllCategoryButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + /// What the browser lists, for a tool to add; with nothing listed it says what to pick. + private List TexturesForTool() { var all = _browser.CurrentTextures(); if (all.Count == 0) - { - await StarterNG.Infrastructure.Diagnostics.ReportAsync(App.Loc["PickCategoryFirst"]); - return; - } + _ = StarterNG.Infrastructure.Diagnostics.ReportAsync(App.Loc["PickCategoryFirst"]); + return all; + } - foreach (var t in all) + public void ToolAddAllCategory() + { + PrepareForTool(); + foreach (var t in TexturesForTool()) InsertTextureAt(t, _consist.Count); } - private async void AddUniqueMmdButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + public void ToolAddUniqueMmd() { - var all = _browser.CurrentTextures(); - if (all.Count == 0) - { - await StarterNG.Infrastructure.Diagnostics.ReportAsync(App.Loc["PickCategoryFirst"]); - return; - } - + PrepareForTool(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var t in all) + foreach (var t in TexturesForTool()) { foreach (string model in ModelsOf(t)) { @@ -547,22 +848,34 @@ public partial class Depot : UserControl yield return alias.Model!; } - private async void RemoveAllTrainsButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + public async Task ToolRemoveAllTrainsAsync() { - if (_listScenery is null || TopLevel.GetTopLevel(this) is not Window owner) + PrepareForTool(); + if (TopLevel.GetTopLevel(this) is not Window owner) return; + // Not the first one on the list: a scenery nobody picked is not emptied. + var state = AppServices.Current.State; + if (state.CurrentScenery is not { } scenery) + { + await StarterNG.Infrastructure.Diagnostics.ReportAsync(App.Loc["NoSceneryConsists"]); + return; + } + if (!await MessageBox.Show(owner, App.Loc["RemoveAllTrainsConfirm"], App.Loc["RemoveAllTrains"], MessageBoxButtons.YesNo)) return; - foreach (var ts in _listScenery.Trainsets) - ts.Vehicles = new List(); + foreach (var ts in scenery.Trainsets) + ts.RemoveVehicles(); - if (_consist.EditingTrainset != null) - _consist.LoadFrom(_consist.EditingTrainset); + // As in the old starter nothing stays picked, so a start is the free camera. + state.StartingVehicleName = null; + state.CurrentTrainset = null; + _consist.Unload(); PopulateSceneryConsists(); RebuildConsist(); + state.NotifyChanged(); } private void ReplaceSelected(VehicleTexture texture) @@ -607,7 +920,7 @@ public partial class Depot : UserControl private void AddCopiesAfter(ConsistItem item, int count) { int index = _consist.IndexOf(item); - if (index < 0 || item.Cars.Count == 0 || _db.TextureFor(item.Cars[0].DataFolder, item.Cars[0].SkinFile, item.Cars[0].MmdFile) is not { } texture) + if (index < 0 || item.Cars.Count == 0 || _info.TextureFor(item.Cars[0]) is not { } texture) return; InsertTexturesAt(texture, index + 1, count); @@ -618,6 +931,11 @@ public partial class Depot : UserControl /// The vehicle count times in a row from at - each staffed as added - and one redraw. private void InsertTexturesAt(VehicleTexture texture, int at, int count) { + // With no consist picked the vehicles would be nowhere but on the strip, and + // gone with its next load. The strip's hint says what to pick. + if (_consist.EditingTrainset is null) + return; + var set = _db.ResolveSet(texture); var unit = set ?? new List { texture }; @@ -641,12 +959,21 @@ public partial class Depot : UserControl RebuildConsist(); } - private async void TextureBaseButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + private void TextureBaseButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => + OpenVehicleBase(); + + private async void OpenVehicleBase() { if (TopLevel.GetTopLevel(this) is not Window owner) return; - var win = new TextureBaseWindow(); + var win = new VehicleBaseWindow(); await win.ShowDialog(owner); - if (win.Picked != null) + _browser.SyncHideArchival(); + if (win.Picked is null) + return; + + if (win.PickedToAdd) + AddTexture(win.Picked); + else ReplaceSelected(win.Picked); } } diff --git a/StarterNG/Views/Depot/ConsistDragging.cs b/StarterNG/Views/Depot/ConsistDragging.cs index 04711f2..181e29d 100644 --- a/StarterNG/Views/Depot/ConsistDragging.cs +++ b/StarterNG/Views/Depot/ConsistDragging.cs @@ -39,7 +39,6 @@ public sealed class ConsistDragging private PointerPressedEventArgs? _pendingDragPress; private Point _pendingDragOrigin; private VehicleTexture? _pendingDragTexture; - private int _pendingDragConsistIndex = -1; private ConsistItem? _pendingBrowserSync; private bool _dragSessionActive; @@ -124,14 +123,12 @@ public sealed class ConsistDragging _pendingDragPress = e; _pendingDragOrigin = e.GetPosition(relativeTo); _pendingDragTexture = texture; - _pendingDragConsistIndex = -1; } private void ClearPendingDrag() { _pendingDragPress = null; _pendingDragTexture = null; - _pendingDragConsistIndex = -1; _pendingBrowserSync = null; _dragSessionActive = false; } @@ -152,7 +149,6 @@ public sealed class ConsistDragging var press = _pendingDragPress; var texture = _pendingDragTexture; - int consistIndex = _pendingDragConsistIndex; _pendingBrowserSync = null; _pendingDragPress = null; diff --git a/StarterNG/Views/Depot/CouplingBits.cs b/StarterNG/Views/Depot/CouplingBits.cs index 3876c7d..253163f 100644 --- a/StarterNG/Views/Depot/CouplingBits.cs +++ b/StarterNG/Views/Depot/CouplingBits.cs @@ -1,3 +1,6 @@ +using System; +using StarterNG.Classes; + namespace StarterNG.Views; public static class CouplingBits @@ -6,3 +9,17 @@ public static class CouplingBits { "CplMechanical", "CplBrake", "CplControl", "CplHighVoltage", "CplGangway", "CplAuxPneumatic", "CplHeating", "CplWorkshop" }; } + +public static class CrewChoices +{ + public static readonly (eDriverType Type, string Key)[] All = + { + (eDriverType.Headdriver, "DriverHead"), (eDriverType.Reardriver, "DriverRear"), + (eDriverType.Passenger, "DriverPassenger"), (eDriverType.Nobody, "DriverNobody") + }; + + public static int IndexOf(eDriverType type) => Array.FindIndex(All, choice => choice.Type == type); + + /// The crew in that place of the list; nobody for no place at all, as a list with nothing selected has. + public static eDriverType At(int index) => index >= 0 && index < All.Length ? All[index].Type : eDriverType.Nobody; +} diff --git a/StarterNG/Views/Depot/TextureMenuItems.cs b/StarterNG/Views/Depot/TextureMenuItems.cs new file mode 100644 index 0000000..b54810e --- /dev/null +++ b/StarterNG/Views/Depot/TextureMenuItems.cs @@ -0,0 +1,29 @@ +using System.IO; +using Avalonia.Controls; +using Avalonia.Input.Platform; +using StarterNG.Domain; +using StarterNG.Domain.Vehicles; + +namespace StarterNG.Views; + +/// What the lists of liveries offer about a livery itself. +internal static class TextureMenuItems +{ + /// A control of the window, for its clipboard. + public static void Add(ContextMenu menu, Control owner, VehicleTexture texture) + { + menu.AddItem(App.Loc["CopyTextureName"], async () => + { + if (TopLevel.GetTopLevel(owner)?.Clipboard is { } clipboard) + await clipboard.SetTextAsync(Consist.Base(texture.Skinfile)); + }); + menu.AddItem(App.Loc["OpenTextureFolder"], () => Infrastructure.Diagnostics.OpenOrReport(FolderOf(texture))); + } + + private static string FolderOf(VehicleTexture texture) + { + string dir = Path.Combine(Directory.GetCurrentDirectory(), "dynamic", + texture.Directory.Replace('/', Path.DirectorySeparatorChar).TrimEnd('\\', '/')); + return Directory.Exists(dir) ? dir : Path.Combine(Directory.GetCurrentDirectory(), texture.Directory); + } +} diff --git a/StarterNG/Views/Depot/TextureRandomizerDialog.cs b/StarterNG/Views/Depot/TextureRandomizerDialog.cs index 4550ecb..ec9efd8 100644 --- a/StarterNG/Views/Depot/TextureRandomizerDialog.cs +++ b/StarterNG/Views/Depot/TextureRandomizerDialog.cs @@ -80,28 +80,23 @@ public sealed class TextureRandomizerDialog revDiff.IsCheckedChanged += (_, _) => age.IsEnabled = revDiff.IsChecked == true; vsYear.IsCheckedChanged += (_, _) => year.IsEnabled = vsYear.IsChecked == true; - var win = new Window - { - Title = App.Loc["RandomizeTexturesTitle"], - Width = 420, - SizeToContent = SizeToContent.Height, - CanResize = false, - WindowStartupLocation = WindowStartupLocation.CenterOwner - }; + var win = MessageBox.FixedDialog(App.Loc["RandomizeTexturesTitle"], 420); var rulesBtn = new Button { Content = App.Loc["RandomizeTexturesRules"], HorizontalAlignment = HorizontalAlignment.Stretch, + HorizontalContentAlignment = HorizontalAlignment.Center, Cursor = _hand }; - rulesBtn.Classes.Add("Basic"); + rulesBtn.Classes.Add("Flat"); rulesBtn.Click += async (_, _) => await new RulesWindow().ShowDialog(win); var ok = new Button { Content = App.Loc["RandomizeTexturesApply"], MinWidth = 90, + HorizontalContentAlignment = HorizontalAlignment.Center, Cursor = _hand }; ok.Classes.Add("Accent"); @@ -114,7 +109,13 @@ public sealed class TextureRandomizerDialog win.Close(true); }; - var cancel = new Button { Content = App.Loc["Cancel"], MinWidth = 90, Cursor = _hand }; + var cancel = new Button + { + Content = App.Loc["Cancel"], + MinWidth = 90, + HorizontalContentAlignment = HorizontalAlignment.Center, + Cursor = _hand + }; cancel.Classes.Add("Flat"); cancel.Click += (_, _) => win.Close(false); @@ -152,13 +153,7 @@ public sealed class TextureRandomizerDialog }, noArch, rulesBtn, - new StackPanel - { - Orientation = Orientation.Horizontal, - HorizontalAlignment = HorizontalAlignment.Right, - Spacing = 8, - Children = { ok, cancel } - } + MessageBox.ButtonRow(ok, cancel) } }; return win; diff --git a/StarterNG/Views/Depot/VehicleBrowser.cs b/StarterNG/Views/Depot/VehicleBrowser.cs index b597cfd..31e4ae6 100644 --- a/StarterNG/Views/Depot/VehicleBrowser.cs +++ b/StarterNG/Views/Depot/VehicleBrowser.cs @@ -1,21 +1,17 @@ using System; using System.Collections.Generic; -using System.IO; using System.Linq; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Templates; using Avalonia.Input; -using Avalonia.Input.Platform; using Avalonia.Layout; using Avalonia.Media; -using Avalonia.Media.Imaging; using Avalonia.Threading; using Avalonia.VisualTree; using StarterNG.Classes; using StarterNG.Domain; -using StarterNG.Domain.Sceneries; using StarterNG.Domain.Vehicles; using StarterNG.Application; using StarterNG.Controls; @@ -45,7 +41,7 @@ public sealed class VehicleBrowser _suppress = true; hideArchivalCheck.IsChecked = hideArchival; _suppress = false; - Rebuild(); + PopulateCategoryCombo(); } public VehicleTexture? Selected { get; private set; } @@ -55,6 +51,9 @@ public sealed class VehicleBrowser /// Adds the vehicle to the consist the given number of times. public Action? AddMany { get; set; } + /// Puts the vehicle in place of the one selected in the consist. + public Action? Replace { get; set; } + private bool _suppress; private bool _syncingCombos; @@ -96,7 +95,7 @@ public sealed class VehicleBrowser // In the old starter's order: traction first, then the wagons, then the rest. - private static readonly (string LocKey, Func Match)[] CategoryDefs = + internal static readonly (string LocKey, Func Match)[] CategoryDefs = { ("CatElectricLoco", c => c == "e"), ("CatDieselLoco", c => c == "s"), @@ -202,10 +201,10 @@ public sealed class VehicleBrowser !(AppServices.Current.Settings.HideArchivalVehicles && texture.ResolvedArchived); /// - /// The classes with their thumbnail above the name: in the open list at the size - /// the consist cards use, called again when that size changes; in the closed box - /// at a fixed modest one, so the class picked is seen, as in the old starter, - /// without taking the height the vehicle list needs. + /// The classes with their thumbnail: above the name in the open list, at the size + /// the consist cards use, called again when that size changes; beside it in the + /// closed box at a fixed modest one, so the class picked is seen, as in the old + /// starter, without taking the height the vehicle list needs. /// public void InitClassComboTemplates() { @@ -213,7 +212,7 @@ public sealed class VehicleBrowser classCombo.ItemTemplate = new FuncDataTemplate( (cls, _) => ClassComboContent(cls, height, stacked: true), false); classCombo.SelectionBoxItemTemplate = new FuncDataTemplate( - (cls, _) => ClassComboContent(cls, PickedClassThumbHeight, stacked: true), false); + (cls, _) => ClassComboContent(cls, PickedClassThumbHeight, stacked: false), false); } private const int PickedClassThumbHeight = 30; @@ -363,6 +362,7 @@ public sealed class VehicleBrowser var row = new ListBoxItem { Content = BrowserLabel(texture, _db.ResolveSet(texture)), + HorizontalContentAlignment = HorizontalAlignment.Stretch, Tag = texture }; vehicleListBox.Items.Add(row); @@ -415,60 +415,26 @@ public sealed class VehicleBrowser { var menu = new ContextMenu(); - var addMany = new MenuItem { Header = App.Loc["AddMany"], IsEnabled = AddMany is not null }; - addMany.Click += (_, _) => - AddManyFlyout.Show(anchor, PlacementMode.Right, count => AddMany?.Invoke(texture, count)); - menu.Items.Add(addMany); + // What the buttons under the list do, for the vehicle the menu was opened on. + menu.AddItem(App.Loc["AddVehicle"], () => AddMany?.Invoke(texture, 1), AddMany is not null, + new KeyGesture(Key.Insert)); + menu.AddItem(App.Loc["ReplaceVehicle"], () => Replace?.Invoke(texture), Replace is not null); + menu.AddItem(App.Loc["AddMany"], + () => AddManyFlyout.Show(anchor, PlacementMode.Right, count => AddMany?.Invoke(texture, count)), + AddMany is not null); menu.Items.Add(new Separator()); - var copy = new MenuItem { Header = App.Loc["CopyTextureName"] }; - copy.Click += async (_, _) => - { - var top = TopLevel.GetTopLevel(vehicleListBox); - if (top?.Clipboard != null) - await top.Clipboard.SetTextAsync(BrowserName(texture)); - }; - menu.Items.Add(copy); - - var open = new MenuItem { Header = App.Loc["OpenTextureFolder"] }; - open.Click += (_, _) => - { - string dir = Path.Combine(Directory.GetCurrentDirectory(), "dynamic", - texture.Directory.Replace('/', Path.DirectorySeparatorChar).TrimEnd('\\', '/')); - if (!Directory.Exists(dir)) - dir = Path.Combine(Directory.GetCurrentDirectory(), texture.Directory); - if (!Directory.Exists(dir)) - { - StarterNG.Infrastructure.Diagnostics.ReportOnUiThread( - string.Format(App.Loc["FaultFileNotFound"], dir)); - return; - } - try - { - System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(dir) - { - UseShellExecute = true - }); - } - catch (Exception ex) - { - StarterNG.Infrastructure.Diagnostics.ReportOnUiThread( - $"{dir}{Environment.NewLine}{Environment.NewLine}{App.Loc["FaultDetail"]} {ex.Message}"); - } - }; - menu.Items.Add(open); + TextureMenuItems.Add(menu, vehicleListBox, texture); menu.Items.Add(new Separator()); - var baseItem = new MenuItem { Header = App.Loc["OpenTextureBase"] }; - baseItem.Click += (_, _) => _openTextureBase(); - menu.Items.Add(baseItem); + menu.AddItem(App.Loc["OpenTextureBase"], _openTextureBase); return menu; } /// - /// Fills a detail panel with what is known about a livery: the skin itself, - /// then whatever its credit line carries. + /// Fills a detail panel with what is known about a livery: the model it + /// dresses, then whatever its credit line carries. /// /// /// Built from the same as the general tab, so the @@ -491,7 +457,7 @@ public sealed class VehicleBrowser tooltip.Add($"{label}: {value}"); } - Reading(App.Loc["TextureInfo"], Consist.Base(texture.Skinfile)); + // No line for the skin's name: the general tab and the list both carry it. Reading(App.Loc["Model"], texture.Directory + texture.Model); if (texture.Meta is { } meta) @@ -557,20 +523,35 @@ public sealed class VehicleBrowser // called a vehicle something none of the other panels did. private static string BrowserName(VehicleTexture texture) => Consist.Base(texture.Skinfile); - // The file name alone, as the old starter listed it - the operator is in the - // texture panel - with a faint count for a set of cars. + // The file name, as the old starter listed it, with a faint count for a set + // of cars and the operator at the far end - liveries of one vehicle differ + // mostly by who runs them. private static Control BrowserLabel(VehicleTexture texture, IReadOnlyList? set) { - var name = new TextBlock { Text = BrowserName(texture) }; - if (set is not { Count: > 1 }) - return name; + // Clear of the scroll bar, which lies over the right edge of the list. + var row = new DockPanel { Margin = new Thickness(0, 0, 10, 0) }; - return new StackPanel + string? op = texture.Meta?.Operator; + if (!string.IsNullOrWhiteSpace(op) && op != "?") { - Orientation = Orientation.Horizontal, - Spacing = 6, - Children = { name, new TextBlock { Text = $"×{set.Count}", Opacity = 0.55 } } - }; + var operatorText = new TextBlock + { + Text = op, + Opacity = 0.55, + Margin = new Thickness(8, 0, 0, 0), + TextTrimming = TextTrimming.CharacterEllipsis + }; + DockPanel.SetDock(operatorText, Dock.Right); + row.Children.Add(operatorText); + } + + var name = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 6 }; + name.Children.Add(new TextBlock { Text = BrowserName(texture) }); + if (set is { Count: > 1 }) + name.Children.Add(new TextBlock { Text = $"×{set.Count}", Opacity = 0.55 }); + row.Children.Add(name); + + return row; } public void SearchBox_OnTextChanged(object? sender, TextChangedEventArgs e) diff --git a/StarterNG/Views/Depot/VehicleCards.cs b/StarterNG/Views/Depot/VehicleCards.cs index 5d9c0ad..cf8929c 100644 --- a/StarterNG/Views/Depot/VehicleCards.cs +++ b/StarterNG/Views/Depot/VehicleCards.cs @@ -36,6 +36,11 @@ public sealed class VehicleCards /// Puts copies of the vehicle on a card right after it, the given number of times. public Action? AddCopies { get; set; } + /// The livery picked in the vehicle browser, offered as the card's replacement. + public Func? BrowserSelection { get; set; } + + public Action? ReplaceWith { get; set; } + private readonly List<(Button Badge, ConsistItem Item)> _driverChrome = new(); public Dynamic? PressedCar { get; set; } @@ -113,9 +118,7 @@ public sealed class VehicleCards if (item.Grouped && item.Cars.Count > 1) controls.Children.Add(SmallButton(MaterialIconKind.CallSplit, App.Loc["TipSplit"], () => _consist.Split(item))); - else if ((index + 1 < _consist.Count && - (Consist.HoldsTail(item) || _consist.CanFormUnit(item, _consist[index + 1]))) || - (index > 0 && Consist.HoldsTail(_consist[index - 1]))) + else if (_consist.CanJoin(item, index)) controls.Children.Add(SmallButton(MaterialIconKind.Link, App.Loc["TipJoin"], () => _consist.Join(item))); var remove = SmallButton(MaterialIconKind.Close, App.Loc["RemoveSelectedVehicle"], () => _consist.Remove(item)); @@ -147,6 +150,14 @@ public sealed class VehicleCards card.KeyDown += (_, e) => OnCardKey(card, item, e); card.PointerPressed += (_, e) => { + // The menu is about the card it opened on, so that card is selected - + // without moving the browser, whose pick the menu offers as a replacement. + if (e.GetCurrentPoint(card).Properties.IsRightButtonPressed) + { + Select(item, PressedCar, syncBrowser: false); + PressedCar = null; + return; + } if (!e.GetCurrentPoint(card).Properties.IsLeftButtonPressed) return; @@ -244,7 +255,7 @@ public sealed class VehicleCards /// public void Activate(ConsistItem item) => Select(item, null); - private void Select(ConsistItem item, Dynamic? car) + private void Select(ConsistItem item, Dynamic? car, bool syncBrowser = true) { var target = car ?? (ReferenceEquals(_consist.Selected, item) ? _consist.ActiveCar(item) @@ -258,7 +269,8 @@ public sealed class VehicleCards _consist.SyncStartingVehicle(); RefreshCardSelectionChrome(); _refreshDetails(); - _selectInBrowser(target); + if (syncBrowser) + _selectInBrowser(target); } private Border? CardFor(ConsistItem item) => @@ -335,8 +347,9 @@ public sealed class VehicleCards Redraw(keepFocus: true); break; + // The way a right click does it: the menu fills itself up as it is requested. case Key.Enter: - card.ContextMenu?.Open(card); + card.RaiseEvent(new ContextRequestedEventArgs()); break; default: @@ -612,48 +625,65 @@ public sealed class VehicleCards btn.Foreground = new SolidColorBrush(Color.Parse(color)); } + /// + /// Everything that can be done to one card, the keys that do it alongside: the + /// card's own buttons and keys were the only way to most of it before. + /// private ContextMenu BuildCardMenu(Control anchor, ConsistItem item) { var menu = new ContextMenu(); menu.Opening += (_, _) => { menu.Items.Clear(); + int index = _consist.IndexOf(item); - var addMany = new MenuItem { Header = App.Loc["AddMany"], IsEnabled = AddCopies is not null }; - addMany.Click += (_, _) => - AddManyFlyout.Show(anchor, PlacementMode.Top, count => AddCopies?.Invoke(item, count)); - menu.Items.Add(addMany); - menu.Items.Add(new Separator()); - - var num = new MenuItem { Header = App.Loc["WagonNumber"] }; - num.Click += (_, _) => ShowWagonNumberFlyout(anchor, item.Cars[0]); - menu.Items.Add(num); + // Clicking a card shows its own livery in the browser; offering that + // as the replacement would replace the vehicle with itself. + var picked = BrowserSelection?.Invoke(); + var active = _consist.ActiveCar(item); + if (picked is not null && + ReferenceEquals(picked, _db.TextureFor(active.DataFolder, active.SkinFile, active.MmdFile))) + picked = null; + menu.AddItem(picked is null + ? App.Loc["ReplaceWithBrowser"] + : $"{App.Loc["ReplaceWithBrowser"]}: {Consist.Base(picked.Skinfile)}", + () => { if (picked is not null) ReplaceWith?.Invoke(item, picked); }, + picked is not null && ReplaceWith is not null); + menu.AddItem(App.Loc["AddMany"], + () => AddManyFlyout.Show(anchor, PlacementMode.Top, count => AddCopies?.Invoke(item, count)), + AddCopies is not null); menu.Items.Add(new Separator()); + menu.AddItem(App.Loc["TipFlip"], () => { _consist.Flip(item); Redraw(keepFocus: true); }, + gesture: new KeyGesture(Key.Space)); + menu.AddItem(App.Loc["TipMoveLeft"], () => { _consist.MoveLeft(item); Redraw(keepFocus: true); }, + index > 0, new KeyGesture(Key.Left, KeyModifiers.Control)); + menu.AddItem(App.Loc["TipMoveRight"], () => { _consist.MoveRight(item); Redraw(keepFocus: true); }, + index >= 0 && index < _consist.Count - 1, new KeyGesture(Key.Right, KeyModifiers.Control)); - var copyPrev = new MenuItem - { - Header = App.Loc["LoadCopyPrev"], - IsEnabled = _consist.IndexOf(item) > 0 - }; - copyPrev.Click += (_, _) => { _cargo.CopyFromPrevious(_consist, item); _redraw(); }; - menu.Items.Add(copyPrev); + if (item.Grouped && item.Cars.Count > 1) + menu.AddItem(App.Loc["TipSplit"], () => _consist.Split(item)); + else if (_consist.CanJoin(item, index)) + menu.AddItem(App.Loc["TipJoin"], () => _consist.Join(item)); - var copyToAll = new MenuItem - { - Header = App.Loc["LoadCopyToAll"], - IsEnabled = _cargo.IsLoadable(item) - }; - copyToAll.Click += (_, _) => { _cargo.CopyToFollowing(_consist, item); _redraw(); }; - menu.Items.Add(copyToAll); + var crew = new MenuItem { Header = App.Loc["CrewLabel"] }; + foreach (var (type, key) in CrewChoices.All) + crew.AddItem((item.Driver == type ? "● " : "○ ") + App.Loc[key], () => _consist.SetCrew(item, type)); + menu.Items.Add(crew); - var max = new MenuItem - { - Header = App.Loc["LoadMax"], - IsEnabled = _cargo.IsLoadable(item) - }; - max.Click += (_, _) => { _cargo.FillUnit(item); _redraw(); }; - menu.Items.Add(max); + // The member the panels show, not always the first car of a unit. + menu.AddItem(App.Loc["WagonNumber"] + "…", + () => ShowWagonNumberFlyout(anchor, _consist.ActiveCar(item)), + gesture: new KeyGesture(Key.N, KeyModifiers.Control)); + + menu.Items.Add(new Separator()); + menu.AddItem(App.Loc["LoadCopyPrev"], () => { _cargo.CopyFromPrevious(_consist, item); _redraw(); }, index > 0); + menu.AddItem(App.Loc["LoadCopyToAll"], () => { _cargo.CopyToFollowing(_consist, item); _redraw(); }, + _cargo.IsLoadable(item)); + menu.AddItem(App.Loc["LoadMax"], () => { _cargo.FillUnit(item); _redraw(); }, _cargo.IsLoadable(item)); + + menu.Items.Add(new Separator()); + menu.AddItem(App.Loc["RemoveSelectedVehicle"], () => _consist.Remove(item), gesture: new KeyGesture(Key.Delete)); }; return menu; } diff --git a/StarterNG/Views/Depot/VehicleDetails.cs b/StarterNG/Views/Depot/VehicleDetails.cs index c170b7a..b87cb2a 100644 --- a/StarterNG/Views/Depot/VehicleDetails.cs +++ b/StarterNG/Views/Depot/VehicleDetails.cs @@ -1,10 +1,8 @@ using System; -using System.Collections.Generic; using System.Globalization; using System.Linq; using Avalonia; using Avalonia.Controls; -using Avalonia.Controls.Primitives; using Avalonia.Input; using Avalonia.Layout; using Avalonia.Media; @@ -29,7 +27,6 @@ public sealed class VehicleDetails private readonly StackPanel damagePanel; private readonly Button removeVehicleButton; - private readonly VehicleCatalog _db; private readonly VehicleInfo _info; private readonly Consist _consist; private readonly Cargo _cargo; @@ -39,12 +36,15 @@ public sealed class VehicleDetails private readonly Action _refreshChrome; private readonly Action _showTextureInfo; + /// A setting changed on the cars themselves, which redraws nothing. + public Action? Edited { get; init; } + public VehicleDetails( StackPanel generalPanel, StackPanel consistTexturePanel, StackPanel couplerPanel, Panel couplerActions, StackPanel brakesPanel, StackPanel loadsPanel, StackPanel damagePanel, Button removeVehicleButton, - VehicleCatalog db, VehicleInfo info, Consist consist, Cargo cargo, Cursor hand, + VehicleInfo info, Consist consist, Cargo cargo, Cursor hand, Action redraw, Action refreshChrome, Action showTextureInfo) { this.generalPanel = generalPanel; @@ -56,7 +56,6 @@ public sealed class VehicleDetails this.damagePanel = damagePanel; this.removeVehicleButton = removeVehicleButton; - _db = db; _info = info; _consist = consist; _cargo = cargo; @@ -97,7 +96,7 @@ public sealed class VehicleDetails BuildGeneralInfo(_consist.Selected); - if (_db.TextureFor(_consist.ActiveCar(_consist.Selected).DataFolder, _consist.ActiveCar(_consist.Selected).SkinFile, _consist.ActiveCar(_consist.Selected).MmdFile) is { } tex) + if (_info.TextureFor(_consist.ActiveCar(_consist.Selected)) is { } tex) _showTextureInfo(tex, consistTexturePanel); else consistTexturePanel.Children.Add(PanelNote(App.Loc["NoTextureInfo"])); @@ -269,24 +268,12 @@ public sealed class VehicleDetails private Control BuildVehicleSection(ConsistItem item) { var driver = new ComboBox { FontSize = 12, MinWidth = 0 }; - foreach (string key in new[] { "DriverHead", "DriverRear", "DriverPassenger", "DriverNobody" }) + foreach (var (_, key) in CrewChoices.All) driver.Items.Add(new ComboBoxItem { Content = App.Loc[key] }); - driver.SelectedIndex = item.Driver switch - { - eDriverType.Headdriver => 0, - eDriverType.Reardriver => 1, - eDriverType.Passenger => 2, - _ => 3 - }; + driver.SelectedIndex = CrewChoices.IndexOf(item.Driver); driver.SelectionChanged += (_, _) => { - var picked = driver.SelectedIndex switch - { - 0 => eDriverType.Headdriver, - 1 => eDriverType.Reardriver, - 2 => eDriverType.Passenger, - _ => eDriverType.Nobody - }; + var picked = CrewChoices.At(driver.SelectedIndex); if (picked == item.Driver) return; _consist.SetCrew(item, picked); @@ -338,7 +325,11 @@ public sealed class VehicleDetails IsChecked = d.Coupling.ThermoDynamic, FontSize = 12 }; - thermo.IsCheckedChanged += (_, _) => d.Coupling.ThermoDynamic = thermo.IsChecked == true; + thermo.IsCheckedChanged += (_, _) => + { + d.Coupling.ThermoDynamic = thermo.IsChecked == true; + Edited?.Invoke(); + }; // Label-less row, so the box indents to the control column with everything // else instead of starting at the panel's left edge on its own. @@ -436,6 +427,7 @@ public sealed class VehicleDetails Load = (loadCombo.SelectedItem as ComboBoxItem)?.Tag as string }; foreach (var c in Consist.UnitTargets(item)) c.Coupling.SetBrake(b); + Edited?.Invoke(); } modeCombo.SelectionChanged += (_, _) => Apply(); @@ -501,6 +493,7 @@ public sealed class VehicleDetails }; foreach (var c in _consist.MemberTargets(item)) c.Coupling.SetWheels(ws); + Edited?.Invoke(); } sway.ValueChanged += (_, _) => Apply(); @@ -739,6 +732,7 @@ public sealed class VehicleDetails c.LoadType = string.IsNullOrEmpty(type) ? null : type; c.LoadCount = string.IsNullOrEmpty(type) ? 0 : Math.Min(count, _cargo.LimitFor(c, type)); } + Edited?.Invoke(); } // Seed the pantograph combo from the model before any handler is listening. diff --git a/StarterNG/Views/Depot/Warehouse.cs b/StarterNG/Views/Depot/Warehouse.cs index fd4b577..fb4269b 100644 --- a/StarterNG/Views/Depot/Warehouse.cs +++ b/StarterNG/Views/Depot/Warehouse.cs @@ -11,6 +11,10 @@ using StarterNG.Classes; namespace StarterNG.Views; +/// +/// The warehouse tab: consists kept for later. The one selected is the one the +/// consist strip edits, and the menu puts it into the scenery's selected consist. +/// public sealed class Warehouse { private readonly ListBox _list; @@ -19,6 +23,16 @@ public sealed class Warehouse private readonly Func _target; private readonly Action> _apply; + /// Set while the list is rebuilt, which clears and restores the selection. + private bool _refreshing; + + /// Another consist is selected, or none - not raised mid-rebuild. + public event Action? SelectionChanged; + + /// A consist got a new name: the old one, then the new one. + public event Action? Renamed; + + /// The scenery consist the menu's actions put a warehouse consist into. public Warehouse(ListBox list, Button removeButton, Cursor hand, Func target, Action> apply) { @@ -27,11 +41,20 @@ public sealed class Warehouse _hand = hand; _target = target; _apply = apply; + + _list.SelectionChanged += (_, _) => + { + if (!_refreshing) + SelectionChanged?.Invoke(); + }; } - public void Refresh() + /// The consist to select; the one selected now otherwise. + public void Refresh(string? select = null) { - string? current = SelectedName(); + string? current = select ?? SelectedName(); + string? before = SelectedName(); + _refreshing = true; _list.Items.Clear(); foreach (var preset in PresetStore.All()) @@ -52,10 +75,16 @@ public sealed class Warehouse _list.ContextMenu ??= BuildMenu(); _removeButton.IsEnabled = _list.Items.Count > 0; + _refreshing = false; + + if (!string.Equals(before, SelectedName(), StringComparison.Ordinal)) + SelectionChanged?.Invoke(); } private string? SelectedName() => (_list.SelectedItem as ListBoxItem)?.Tag as string; + public TrainsetPreset? SelectedPreset() => SelectedName() is { } name ? PresetNamed(name) : null; + private static TrainsetPreset? PresetNamed(string name) => PresetStore.All().FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.Ordinal)); @@ -68,20 +97,14 @@ public sealed class Warehouse bool hasPreset = SelectedName() != null; bool hasTarget = _target() != null; - void Add(string key, Action run, bool enabled = true) - { - var mi = new MenuItem { Header = App.Loc[key], IsEnabled = enabled }; - mi.Click += (_, _) => run(); - menu.Items.Add(mi); - } - - Add("WarehouseApply", ApplySelected, hasPreset && hasTarget); - Add("WarehouseAppend", AppendSelected, hasPreset && hasTarget); - Add("ChangeName", RenameSelected, hasPreset); + menu.AddItem(App.Loc["WarehouseUseInScenery"], ApplySelected, hasPreset && hasTarget); + menu.AddItem(App.Loc["WarehouseAppendToScenery"], AppendSelected, hasPreset && hasTarget); + menu.AddItem(App.Loc["ChangeName"], RenameSelected, hasPreset); + menu.AddItem(App.Loc["RemoveFromWarehouse"], () => _ = RemoveSelectedAsync(), hasPreset); menu.Items.Add(new Separator()); - Add("PresetImportMagazyn", () => _ = ImportMagazynAsync()); - Add("WarehouseImportFile", () => _ = ImportFileAsync()); + menu.AddItem(App.Loc["PresetImportMagazyn"], () => _ = ImportMagazynAsync()); + menu.AddItem(App.Loc["WarehouseImportFile"], () => _ = ImportFileAsync()); menu.Items.Add(new Separator()); var sort = new MenuItem { Header = App.Loc["PresetSort"] }; @@ -92,68 +115,41 @@ public sealed class Warehouse return menu; } - private MenuItem SortItem(PresetSort mode, string locKey) - { - var item = new MenuItem - { - Header = (PresetStore.SortMode == mode ? "● " : "○ ") + App.Loc[locKey] - }; - item.Click += (_, _) => { PresetStore.SortMode = mode; Refresh(); }; - return item; - } + private MenuItem SortItem(PresetSort mode, string locKey) => + MenuItems.Item((PresetStore.SortMode == mode ? "● " : "○ ") + App.Loc[locKey], + () => { PresetStore.SortMode = mode; Refresh(); }); - public async Task SaveCurrentAsync() - { - if (_target() is not { } trainset) - return; - - string name = trainset.Name; - if (string.IsNullOrWhiteSpace(name)) - name = App.Loc["PresetEmpty"]; - - try - { - PresetStore.Save(name, ConsistText.Serialize(trainset)); - } - catch (Exception ex) - { - await Infrastructure.Diagnostics.ReportAsync(PresetStore.FilePath, ex); - return; - } - Refresh(); - } + /// Asks for a name at the button pressed, and says where the consist went. + public void SaveCurrent(Control anchor) => + ConsistContextMenu.ShowSaveFlyout(anchor, _target(), atPointer: false, saved: name => Refresh(name)); public void ApplySelected() { - if (SelectedName() is not { } name || _target() is not { } target) - return; - - if (PresetNamed(name) is not { } preset) + if (_target() is not { } target || SelectedPreset() is not { } preset) return; if (ConsistText.VehiclesFrom(preset.Entry) is { } vehicles) - _apply(target, vehicles); + _apply(target, ConsistContextMenu.KeepCrew(target, vehicles)); } public void AppendSelected() { - if (SelectedName() is not { } name || _target() is not { } target) + if (_target() is not { } target) return; - var vehicles = ConsistText.VehiclesFrom(PresetNamed(name)?.Entry); + var vehicles = ConsistText.VehiclesFrom(SelectedPreset()?.Entry); if (vehicles is null || vehicles.Count == 0) return; - var merged = new List(target.Vehicles); - merged.AddRange(vehicles); - _apply(target, merged); + _apply(target, Domain.Consist.Joined(target.Vehicles, vehicles)); } public void RenameSelected() { - if (SelectedName() is not { } name || PresetNamed(name) is not { } preset) + if (SelectedPreset() is not { } preset) return; + string name = preset.Name; var nameBox = new TextBox { Text = name, MinWidth = 240 }; var ok = new Button { Content = App.Loc["Ok"], Cursor = _hand }; ok.Classes.Add("Accent"); @@ -169,16 +165,23 @@ public sealed class Warehouse panel.Children.Add(ok); var flyout = new Flyout { Content = panel }; - ok.Click += (_, _) => + ok.Click += async (_, _) => { string? fresh = nameBox.Text?.Trim(); - if (!string.IsNullOrEmpty(fresh) && !string.Equals(fresh, name, StringComparison.Ordinal)) - { - PresetStore.Save(fresh, preset.Entry); - PresetStore.Delete(name); - Refresh(); - } flyout.Hide(); + if (string.IsNullOrEmpty(fresh) || string.Equals(fresh, name, StringComparison.Ordinal)) + return; + + // Differing only in case is this very entry, not another one written over. + if (!string.Equals(fresh, name, StringComparison.OrdinalIgnoreCase) && + !await ConsistContextMenu.MayOverwritePresetAsync(_list, fresh)) + return; + + // Deleted first: a new name differing only in case is the same entry. + PresetStore.Delete(name); + PresetStore.Save(fresh, preset.Entry); + Renamed?.Invoke(name, fresh); + Refresh(fresh); }; flyout.ShowAt(_list); nameBox.Focus(); @@ -214,7 +217,7 @@ public sealed class Warehouse public async Task RemoveSelectedAsync() { - if (SelectedName() is not { } name) + if (SelectedName() is not { } name || !await ConsistContextMenu.MayRemovePresetAsync(_list, name)) return; try diff --git a/StarterNG/Views/Developer.axaml b/StarterNG/Views/Developer.axaml deleted file mode 100644 index 3dc1bc7..0000000 --- a/StarterNG/Views/Developer.axaml +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -