fix(tapes): a tape written after another run began is not the launch's before it, and a session with only a black box shows no other session's tape

This commit is contained in:
maj00r
2026-11-11 18:21:27 +01:00
parent ef551a4ee7
commit 9b5848042a
8 changed files with 86 additions and 17 deletions

View File

@@ -121,6 +121,30 @@ public class BlackBoxListingTests
Assert.EndsWith(own, Assert.Single(launched.BlackBoxes).Path);
}
[Fact]
public void A_tape_written_after_another_run_began_is_not_taken_for_the_launch_before()
{
// The starter launched Bałtyk; later a run of another scenery, not launched from it, wrote its tape and black box.
var files = new InMemoryFileSystem();
var installation = new TestInstallation(files);
installation.Clock.Now = Launched;
installation.Tapes.RecordLaunch("baltyk.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "ep07", new[] { "ep07" }, "Bałtyk");
string box = Box("td", Launched.AddHours(5));
files.WithFile(Log("ep07.dat"), "t").WrittenAt(Log("ep07.dat"), Launched.AddMinutes(50))
.WithFile(Log("bb-su45.dat"), "t").WrittenAt(Log("bb-su45.dat"), Launched.AddHours(5).AddMinutes(8))
.WithFile(Log(box), "b").WrittenAt(Log(box), Launched.AddHours(5).AddMinutes(8));
var sessions = installation.Tapes.Sessions();
var baltyk = Assert.Single(sessions, session => session.Launch is not null);
Assert.Equal("ep07", Assert.Single(baltyk.Tapes).Vehicle);
Assert.Empty(baltyk.BlackBoxes);
var other = Assert.Single(sessions, session => session.Launch is null);
Assert.Equal("bb-su45", Assert.Single(other.Tapes).Vehicle);
Assert.Single(other.BlackBoxes);
Assert.Null(installation.Tapes.ContextOf(other.Tapes[0]).StartClock);
}
[Fact]
public void A_black_box_started_before_a_launch_is_not_taken_for_it()
{

View File

@@ -227,7 +227,7 @@ public sealed class HaslerTapes
public TapeContext ContextOf(TapeFile file)
{
var launch = Launches().LastOrDefault(l => l.Covers(file.LastWriteUtc));
var launch = LaunchOf(file, Launches(), ReadBlackBoxes());
if (launch is null)
return new TapeContext(null, Recorders.Guess(null, file.Vehicle));
@@ -243,8 +243,9 @@ public sealed class HaslerTapes
/// <summary>
/// The runs the tapes came from, the running or latest one first. A tape belongs
/// to the last launch before it was written; one written before any known launch
/// goes with those written about the same time, as a run writes them all at its end.
/// to the last launch before it was written, unless a run the starter didn't launch
/// began between them; one with no launch goes with those written about the same
/// time, as a run writes them all at its end.
/// </summary>
public IReadOnlyList<TapeSession> Sessions()
{
@@ -254,12 +255,11 @@ public sealed class HaslerTapes
var tapes = _files.DirectoryExists(Folder)
? _files.GetFiles(Folder, "*.dat").Select(ReadTape).ToList()
: new List<TapeFile>();
var behind = tapes.GroupBy(tape => launches.LastOrDefault(l => l.Covers(tape.LastWriteUtc)))
var boxes = ReadBlackBoxes();
var tapeLaunch = tapes.ToDictionary(tape => tape, tape => LaunchOf(tape, launches, boxes));
var behind = tapes.GroupBy(tape => tapeLaunch[tape])
.Where(group => group.Key is not null)
.ToDictionary(group => group.Key!, group => group.ToList());
var boxes = _files.DirectoryExists(Folder)
? _files.GetFiles(Folder, "*.blackbox").Select(ReadBlackBox).ToList()
: new List<BlackBoxRecording>();
var boxesBehind = boxes.GroupBy(box => LaunchOf(box, launches))
.Where(group => group.Key is not null)
.ToDictionary(group => group.Key!, group => group.ToList());
@@ -279,7 +279,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)))
var loose = tapes.Where(t => tapeLaunch[t] is null)
.Select(t => (Written: t.LastWriteUtc, Tape: (TapeFile?)t, Box: (BlackBoxRecording?)null))
.Concat(boxes.Where(b => LaunchOf(b, launches) is null)
.Select(b => (Written: b.LastWriteUtc, Tape: (TapeFile?)null, Box: (BlackBoxRecording?)b)))
@@ -318,6 +318,27 @@ public sealed class HaslerTapes
private TapeFile ReadTape(string path) =>
new(path, Path.GetFileNameWithoutExtension(path), _files.GetLastWriteTimeUtc(path), _files.GetFileSize(path));
private List<BlackBoxRecording> ReadBlackBoxes() =>
_files.DirectoryExists(Folder)
? _files.GetFiles(Folder, "*.blackbox").Select(ReadBlackBox).ToList()
: new List<BlackBoxRecording>();
/// <summary>
/// The launch a tape came from. A tape tells nothing of its run but when it was written: the last launch
/// before then, unless a black box shows another run, one the starter didn't launch, began between the two.
/// That run wrote the tape, not the launch's.
/// </summary>
public static TapeLaunch? LaunchOf(TapeFile tape, IReadOnlyList<TapeLaunch> launches, IReadOnlyList<BlackBoxRecording> boxes)
{
var launch = launches.LastOrDefault(l => l.Covers(tape.LastWriteUtc));
if (launch is null)
return null;
bool another = boxes.Any(box => NamedBy(box.Path) is { } named
&& named.StartedUtc > launch.LaunchedUtc && named.StartedUtc < tape.LastWriteUtc
&& LaunchOf(box, launches) != launch);
return another ? null : launch;
}
/// <summary>
/// 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

View File

@@ -121,19 +121,28 @@ public sealed class HaslerTapeWindow : Window
};
_list.SelectionChanged += async (_, _) =>
{
// A black box opens in a window of its own on a click or Enter, not when the arrows go over it; the tape shown stays.
if (_list.SelectedItem is TreeViewItem { Tag: BlackBoxRecording })
return;
// A session stands for its first tape, the player's own if it has one.
if (_list.SelectedItem is TreeViewItem { Tag: TapeSession } session)
session.IsExpanded = true;
var file = _list.SelectedItem switch
if (_list.SelectedItem is TreeViewItem { Tag: TapeSession } node)
node.IsExpanded = true;
// A black box opens in a window of its own on a click or Enter, not when the arrows go over it; the
// tape beside it is one of its session's, the one shown if it is.
var session = _list.SelectedItem switch
{
TreeViewItem { Tag: TapeFile tape } => tape,
TreeViewItem { Tag: TapeSession { Tapes.Count: > 0 } run } => run.Tapes[0],
TreeViewItem { Tag: TapeSession run } => run,
TreeViewItem { Tag: BlackBoxRecording box } =>
_sessions.FirstOrDefault(s => s.BlackBoxes.Any(b => b.Path == box.Path)),
_ => null
};
if (file is null || file == _shown)
if (_list.SelectedItem is TreeViewItem { Tag: BlackBoxRecording } && session?.Tapes.Any(t => t.Path == _shown?.Path) == true)
return;
var file = _list.SelectedItem is TreeViewItem { Tag: TapeFile tape } ? tape : session?.Tapes.FirstOrDefault();
if (file is null)
{
if (session is not null)
ShowNoTape();
return;
}
if (file == _shown)
return;
ApplyContext(_tapes.ContextOf(file));
@@ -586,6 +595,16 @@ public sealed class HaslerTapeWindow : Window
_recorder.SelectedIndex = context.Recorder == RecorderKind.Electronic ? 1 : 0;
}
/// <summary>A session with a black box and no tape: no tape of another session left beside it.</summary>
private void ShowNoTape()
{
_shown = null;
_view.Tape = null;
_view.Mark = null;
_cursor.Text = "";
_summary.Text = App.Loc["HaslerSessionNoTape"];
}
private async Task ShowAsync(TapeFile file, bool scrollToEnd)
{
if (_loading)

View File

@@ -853,6 +853,7 @@
<String key="HaslerBlackBox">Černá skříňka</String>
<String key="HaslerBlackBoxTip">Celá relace na mapě: jízda oproti AI, povely, návěstidla, kabina</String>
<String key="HaslerClickBlackBox">klikněte na pásku: černá skříňka v tom okamžiku</String>
<String key="HaslerSessionNoTape">Tato relace nemá pásku, jen černou skříňku.</String>
<String key="WarehouseSaved">Ve skladu: {0}</String>
<String key="WarehouseNothingToAdd">Vyberte soupravu s vozidly</String>
<String key="GfxGroupLevelTip">Úroveň jen pro tuto část grafiky</String>

View File

@@ -1082,6 +1082,7 @@
<String key="HaslerBlackBox">Black box</String>
<String key="HaslerBlackBoxTip">The whole session on a map: the driving against the AI, the commands, the signals, the cab</String>
<String key="HaslerClickBlackBox">click the tape: the black box at that moment</String>
<String key="HaslerSessionNoTape">This session has no tape, only the black box.</String>
<String key="WarehouseSaved">In the warehouse: {0}</String>
<String key="WarehouseNothingToAdd">Pick a consist with vehicles</String>
<String key="GfxGroupLevelTip">Level for this part of the graphics only</String>

View File

@@ -853,6 +853,7 @@
<String key="HaslerBlackBox">Fekete doboz</String>
<String key="HaslerBlackBoxTip">A teljes menet a térképen: vezetés az AI-hoz képest, parancsok, jelzők, vezetőfülke</String>
<String key="HaslerClickBlackBox">kattintson a szalagra: a fekete doboz abban a pillanatban</String>
<String key="HaslerSessionNoTape">Ebben a munkamenetben nincs szalag, csak a fekete doboz.</String>
<String key="WarehouseSaved">A raktárban: {0}</String>
<String key="WarehouseNothingToAdd">Válassz járműveket tartalmazó szerelvényt</String>
<String key="GfxGroupLevelTip">Csak a grafika ezen részének szintje</String>

View File

@@ -904,6 +904,7 @@
<String key="HaslerBlackBox">Czarna skrzynka</String>
<String key="HaslerBlackBoxTip">Cała sesja na mapie: jazda względem AI, komendy, sygnalizatory, kabina</String>
<String key="HaslerClickBlackBox">kliknij taśmę: czarna skrzynka w tym momencie</String>
<String key="HaslerSessionNoTape">W tej sesji nie ma taśmy, jest tylko czarna skrzynka.</String>
<String key="WarehouseSaved">W magazynie: {0}</String>
<String key="WarehouseNothingToAdd">Wybierz skład z pojazdami</String>
<String key="GfxGroupLevelTip">Poziom tylko tej części grafiki</String>

View File

@@ -853,6 +853,7 @@
<String key="HaslerBlackBox">Чёрный ящик</String>
<String key="HaslerBlackBoxTip">Вся сессия на карте: езда по сравнению с ИИ, команды, светофоры, кабина</String>
<String key="HaslerClickBlackBox">щёлкните по ленте: чёрный ящик в этот момент</String>
<String key="HaslerSessionNoTape">В этом сеансе нет ленты, только чёрный ящик.</String>
<String key="WarehouseSaved">На складе: {0}</String>
<String key="WarehouseNothingToAdd">Выберите состав с транспортом</String>
<String key="GfxGroupLevelTip">Уровень только для этой части графики</String>