From cf5cea26adaa3600f8e8834ae8455ba7fdcb32a1 Mon Sep 17 00:00:00 2001 From: maj00r Date: Wed, 11 Nov 2026 18:12:11 +0100 Subject: [PATCH] fix(tapes): a black box goes with the launch it started after and of its own scenery, not every one written after the last launch --- .../BlackBox/BlackBoxListingTests.cs | 67 ++++++++++++++++--- StarterNG/Application/HaslerTapes.cs | 48 ++++++++++++- 2 files changed, 105 insertions(+), 10 deletions(-) diff --git a/StarterNG.Tests/BlackBox/BlackBoxListingTests.cs b/StarterNG.Tests/BlackBox/BlackBoxListingTests.cs index 7396ab9..420be18 100644 --- a/StarterNG.Tests/BlackBox/BlackBoxListingTests.cs +++ b/StarterNG.Tests/BlackBox/BlackBoxListingTests.cs @@ -9,30 +9,35 @@ public class BlackBoxListingTests private static string Log(string name) => TestInstallation.At("physicslog", name); + /// A black box's name: the simulator stamps it with the local time it started at. + private static string Box(string scenery, DateTime startedUtc) => + $"{scenery}_{startedUtc.ToLocalTime():yyyy-MM-dd_HH-mm-ss}.blackbox"; + [Fact] public void Each_run_keeps_its_own_black_box_next_to_its_tapes() { + string first = Box("td", Launched.AddSeconds(5)), second = Box("td", Launched.AddHours(1).AddSeconds(5)); var files = new InMemoryFileSystem() .WithFile(Log("ra.dat"), "t") - .WithFile(Log("td_2025-12-31_23-00-05.blackbox"), "b") - .WithFile(Log("td_2026-01-01_00-00-05.blackbox"), "b"); + .WithFile(Log(first), "b") + .WithFile(Log(second), "b"); var installation = new TestInstallation(files); var tapes = installation.Tapes; installation.Clock.Now = Launched; tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty(), "ra", new[] { "ra" }, "A"); installation.Clock.Now = Launched.AddHours(1); tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty(), "ra", new[] { "ra" }, "B"); - files.WrittenAt(Log("td_2025-12-31_23-00-05.blackbox"), Launched.AddMinutes(30)) - .WrittenAt(Log("td_2026-01-01_00-00-05.blackbox"), Launched.AddMinutes(90)) + files.WrittenAt(Log(first), Launched.AddMinutes(30)) + .WrittenAt(Log(second), Launched.AddMinutes(90)) .WrittenAt(Log("ra.dat"), Launched.AddMinutes(90)); var sessions = tapes.Sessions(); Assert.Equal(new[] { "B", "A" }, sessions.Select(session => session.Launch!.SceneryName)); - Assert.EndsWith("00-00-05.blackbox", Assert.Single(sessions[0].BlackBoxes).Path); + Assert.EndsWith(second, Assert.Single(sessions[0].BlackBoxes).Path); Assert.Single(sessions[0].Tapes); // The tape of the first run was written over by the second; its black box was not. - Assert.EndsWith("23-00-05.blackbox", Assert.Single(sessions[1].BlackBoxes).Path); + Assert.EndsWith(first, Assert.Single(sessions[1].BlackBoxes).Path); Assert.Empty(sessions[1].Tapes); Assert.Equal(Launched.AddMinutes(30), sessions[1].LastWrittenUtc); } @@ -83,8 +88,9 @@ public class BlackBoxListingTests installation.Clock.Now = Launched; var tapes = installation.Tapes; tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty(), "ra"); - files.WithFile(Log("td_2025-12-31_23-00-20.blackbox"), "b") - .WrittenAt(Log("td_2025-12-31_23-00-20.blackbox"), Launched.AddMinutes(5)); + string box = Box("td", Launched.AddSeconds(20)); + files.WithFile(Log(box), "b") + .WrittenAt(Log(box), Launched.AddMinutes(5)); installation.SimulatorRunning = true; var running = Assert.Single(tapes.Sessions()); @@ -93,4 +99,49 @@ public class BlackBoxListingTests Assert.Empty(running.Tapes); Assert.Single(running.BlackBoxes); } + + [Fact] + public void A_black_box_of_another_scenery_written_after_a_launch_is_not_taken_for_its_run() + { + // Copied in, or a run started without the starter: written after the last launch, but of another scenery. + var files = new InMemoryFileSystem(); + var installation = new TestInstallation(files); + installation.Clock.Now = Launched; + installation.Tapes.RecordLaunch("$baltyk.scn", TimeSpan.Zero, Array.Empty(), "ra", new[] { "ra" }, "Bałtyk"); + string own = Box("baltyk", Launched.AddSeconds(10)), other = Box("l053_poludnie", Launched.AddHours(3)); + files.WithFile(Log(own), "b").WrittenAt(Log(own), Launched.AddMinutes(40)) + .WithFile(Log(other), "b").WrittenAt(Log(other), Launched.AddHours(4)); + + var sessions = installation.Tapes.Sessions(); + + Assert.Equal(2, sessions.Count); + var loose = Assert.Single(sessions, session => session.Launch is null); + Assert.EndsWith(other, Assert.Single(loose.BlackBoxes).Path); + var launched = Assert.Single(sessions, session => session.Launch is not null); + Assert.EndsWith(own, Assert.Single(launched.BlackBoxes).Path); + } + + [Fact] + public void A_black_box_started_before_a_launch_is_not_taken_for_it() + { + // Started earlier, and only finished writing after the starter launched the same scenery again. + var files = new InMemoryFileSystem(); + var installation = new TestInstallation(files); + installation.Clock.Now = Launched; + installation.Tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty(), "ra"); + string early = Box("td", Launched.AddHours(-2)); + files.WithFile(Log(early), "b").WrittenAt(Log(early), Launched.AddMinutes(1)); + + var session = Assert.Single(installation.Tapes.Sessions()); + + Assert.Null(session.Launch); + Assert.Single(session.BlackBoxes); + } + + [Theory] + [InlineData("$td.scn", "td")] + [InlineData("l053_poludnie.scn", "l053_poludnie")] + [InlineData("Bałtyk zima.scn", "Ba_tyk_zima")] + public void The_scenery_is_named_as_the_simulator_names_the_black_box(string scenery, string stem) => + Assert.Equal(stem, StarterNG.Application.HaslerTapes.SceneryStem(scenery)); } diff --git a/StarterNG/Application/HaslerTapes.cs b/StarterNG/Application/HaslerTapes.cs index bc1dc7e..4a8e38a 100644 --- a/StarterNG/Application/HaslerTapes.cs +++ b/StarterNG/Application/HaslerTapes.cs @@ -260,7 +260,7 @@ public sealed class HaslerTapes var boxes = _files.DirectoryExists(Folder) ? _files.GetFiles(Folder, "*.blackbox").Select(ReadBlackBox).ToList() : new List(); - var boxesBehind = boxes.GroupBy(box => launches.LastOrDefault(l => l.Covers(box.LastWriteUtc))) + var boxesBehind = boxes.GroupBy(box => LaunchOf(box, launches)) .Where(group => group.Key is not null) .ToDictionary(group => group.Key!, group => group.ToList()); @@ -281,7 +281,7 @@ public sealed class HaslerTapes // What no launch covers goes together by when it was written: a run writes it all at its end. var loose = tapes.Where(t => !launches.Any(l => l.Covers(t.LastWriteUtc))) .Select(t => (Written: t.LastWriteUtc, Tape: (TapeFile?)t, Box: (BlackBoxRecording?)null)) - .Concat(boxes.Where(b => !launches.Any(l => l.Covers(b.LastWriteUtc))) + .Concat(boxes.Where(b => LaunchOf(b, launches) is null) .Select(b => (Written: b.LastWriteUtc, Tape: (TapeFile?)null, Box: (BlackBoxRecording?)b))) .OrderBy(file => file.Written); var run = new List(); @@ -318,6 +318,50 @@ public sealed class HaslerTapes private TapeFile ReadTape(string path) => new(path, Path.GetFileNameWithoutExtension(path), _files.GetLastWriteTimeUtc(path), _files.GetFileSize(path)); + /// + /// The launch a black box came from. Unlike a tape, it says which: its name has the scenery and the moment + /// the recording started. So it is the last launch before that moment, and only if that launch was of the + /// same scenery; one the starter didn't launch, copied in or started on its own, goes with no launch. + /// + public static TapeLaunch? LaunchOf(BlackBoxRecording box, IReadOnlyList launches) + { + if (NamedBy(box.Path) is not { } named) + return launches.LastOrDefault(l => l.Covers(box.LastWriteUtc)); + var launch = launches.LastOrDefault(l => l.Covers(named.StartedUtc)); + return launch is not null && string.Equals(SceneryStem(launch.Scenery), named.Scenery, StringComparison.OrdinalIgnoreCase) + ? launch + : null; + } + + private const string BlackBoxStamp = "yyyy-MM-dd_HH-mm-ss"; + + /// + /// The scenery and the start a black box is named by, scenery_YYYY-MM-DD_HH-MM-SS in the local time + /// of the computer that ran it; null for a name not made so. + /// + public static (string Scenery, DateTime StartedUtc)? NamedBy(string path) + { + string name = Path.GetFileNameWithoutExtension(path); + int stamp = BlackBoxStamp.Length; + if (name.Length < stamp + 2 || name[^(stamp + 1)] != '_' + || !DateTime.TryParseExact(name[^stamp..], BlackBoxStamp, CultureInfo.InvariantCulture, + DateTimeStyles.AssumeLocal, out var local)) + return null; + return (name[..^(stamp + 1)], local.ToUniversalTime()); + } + + /// A scenery's file as the simulator names its black box: no '$' in front, but for ASCII letters, digits, '-' and '_' all '_'. + public static string SceneryStem(string scenery) + { + string stem = Path.GetFileNameWithoutExtension(scenery); + if (stem.StartsWith('$')) + stem = stem[1..]; + var text = new StringBuilder(stem.Length); + foreach (char c in stem) + text.Append(char.IsAsciiLetterOrDigit(c) || c is '-' or '_' ? c : '_'); + return text.Length == 0 ? "scenery" : text.ToString(); + } + private BlackBoxRecording ReadBlackBox(string path) => new(path, _files.GetLastWriteTimeUtc(path), _files.GetFileSize(path));