mirror of
https://github.com/MaSzyna-EU07/StarterNG.git
synced 2026-11-11 16:57:11 +01:00
Compare commits
52 Commits
refactor/s
...
feature/bl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9872b316c2 | ||
|
|
ed9c1394bd | ||
|
|
0d16524953 | ||
|
|
4dcfd1c4e3 | ||
|
|
9ee059bc16 | ||
|
|
88adf5ee0f | ||
|
|
de3f088ba8 | ||
|
|
532875fc7b | ||
|
|
e530b95352 | ||
| e7d8596652 | |||
|
|
cb092784ae | ||
|
|
d68999dfb7 | ||
|
|
9eb10fc9bd | ||
|
|
71c32c5b9a | ||
|
|
cf1ba319c1 | ||
|
|
02ea1277fc | ||
|
|
306bdc44df | ||
|
|
d95182f17c | ||
|
|
c10d771f00 | ||
|
|
2cfb4e4fec | ||
|
|
a8f8cb0b74 | ||
|
|
32d7417c22 | ||
|
|
16d665156c | ||
|
|
39d4ef0bc4 | ||
|
|
6d8a6c36e7 | ||
|
|
96cfe2503e | ||
|
|
b12189b2d5 | ||
|
|
42b2259b7c | ||
|
|
a7c9cdb97f | ||
|
|
0d2e5de110 | ||
|
|
1f231423f3 | ||
|
|
b60c176533 | ||
|
|
212e11278f | ||
|
|
81c7e8ba95 | ||
|
|
b56a2a0ae6 | ||
|
|
80115b7a7e | ||
|
|
ceab05462a | ||
|
|
2ebff91316 | ||
|
|
6e45f5de59 | ||
|
|
82429010d4 | ||
|
|
f152a020e3 | ||
|
|
3b21583b53 | ||
|
|
69aa075bfa | ||
|
|
b6735fe345 | ||
|
|
07987a60a9 | ||
|
|
6948b13503 | ||
| f8617942ea | |||
|
|
22c51ddf3c | ||
| d222f49179 | |||
| 8d6b4805c8 | |||
| 2befefb547 | |||
| 5929a4ef4f |
30
Eu07.BlackBox.Viewer/Eu07.BlackBox.Viewer.csproj
Normal file
30
Eu07.BlackBox.Viewer/Eu07.BlackBox.Viewer.csproj
Normal file
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- The black box on its own, without the starter: opens the file given on the
|
||||
command line, or one picked. -->
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<PublishAot>true</PublishAot>
|
||||
<PublishTrimmed>true</PublishTrimmed>
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<ApplicationIcon>..\StarterNG\Assets\starter.ico</ApplicationIcon>
|
||||
<AssemblyName>BlackBoxViewer</AssemblyName>
|
||||
<RootNamespace>Eu07.BlackBox.Viewer</RootNamespace>
|
||||
<VersionPrefix>1.0</VersionPrefix>
|
||||
<Company>eu07.pl</Company>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.1.4" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.1.4" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.4" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Eu07.BlackBox.Views\Eu07.BlackBox.Views.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
109
Eu07.BlackBox.Viewer/Program.cs
Normal file
109
Eu07.BlackBox.Viewer/Program.cs
Normal file
@@ -0,0 +1,109 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Themes.Fluent;
|
||||
using Eu07.BlackBox.Views;
|
||||
|
||||
namespace Eu07.BlackBox.Viewer;
|
||||
|
||||
/// <summary>
|
||||
/// BlackBoxViewer [file.blackbox] [--live] [--time seconds] [--follow] [--timetable | --summary] [--screenshot out.png]
|
||||
/// --live follows a file the simulator still writes; --screenshot saves the window
|
||||
/// at the moment given and quits, for documentation and checks.
|
||||
/// </summary>
|
||||
internal static class Program
|
||||
{
|
||||
[STAThread]
|
||||
public static int Main(string[] args)
|
||||
{
|
||||
return BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
}
|
||||
|
||||
public static AppBuilder BuildAvaloniaApp() =>
|
||||
AppBuilder.Configure<ViewerApp>()
|
||||
.UsePlatformDetect()
|
||||
.WithInterFont();
|
||||
}
|
||||
|
||||
internal sealed class ViewerApp : Application
|
||||
{
|
||||
public override void Initialize()
|
||||
{
|
||||
Styles.Add(new FluentTheme());
|
||||
RequestedThemeVariant = ThemeVariant.Dark;
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
var options = ViewerOptions.Parse(desktop.Args ?? Array.Empty<string>());
|
||||
var window = new BlackBoxWindow { WindowStartupLocation = Avalonia.Controls.WindowStartupLocation.CenterScreen };
|
||||
window.Live = options.Live;
|
||||
desktop.MainWindow = window;
|
||||
window.Opened += async (_, _) =>
|
||||
{
|
||||
if (options.File is { } file)
|
||||
await window.OpenAsync(Path.GetFullPath(file));
|
||||
window.ShowTimetable = options.Timetable;
|
||||
if (options.Summary)
|
||||
window.SummaryShown = true;
|
||||
if (options.Screenshot is { } shot)
|
||||
{
|
||||
await Screenshot.SaveAsync(window, shot, options.Time, options.Follow);
|
||||
return;
|
||||
}
|
||||
if (options.Time is { } time)
|
||||
window.SeekTo(time);
|
||||
window.FollowTrain = options.Follow;
|
||||
};
|
||||
}
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record ViewerOptions(string? File, bool Live, double? Time, string? Screenshot, bool Follow, bool Timetable,
|
||||
bool Summary = false)
|
||||
{
|
||||
public static ViewerOptions Parse(string[] args)
|
||||
{
|
||||
string? file = null, screenshot = null;
|
||||
double? time = null;
|
||||
bool live = false, follow = false, timetable = false, summary = false;
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
{
|
||||
switch (args[i])
|
||||
{
|
||||
case "--live":
|
||||
live = true;
|
||||
break;
|
||||
case "--follow":
|
||||
follow = true;
|
||||
break;
|
||||
case "--timetable":
|
||||
timetable = true;
|
||||
break;
|
||||
case "--summary":
|
||||
summary = true;
|
||||
break;
|
||||
case "--time" when i + 1 < args.Length &&
|
||||
double.TryParse(args[i + 1], System.Globalization.NumberStyles.Float,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var t):
|
||||
time = t;
|
||||
++i;
|
||||
break;
|
||||
case "--screenshot" when i + 1 < args.Length:
|
||||
screenshot = args[++i];
|
||||
break;
|
||||
default:
|
||||
if (!args[i].StartsWith("--", StringComparison.Ordinal))
|
||||
file ??= args[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
return new ViewerOptions(file, live, time, screenshot, follow, timetable, summary);
|
||||
}
|
||||
}
|
||||
29
Eu07.BlackBox.Viewer/Screenshot.cs
Normal file
29
Eu07.BlackBox.Viewer/Screenshot.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Eu07.BlackBox.Views;
|
||||
|
||||
namespace Eu07.BlackBox.Viewer;
|
||||
|
||||
/// <summary>The window as it shows a moment, saved to a PNG; then the viewer closes.</summary>
|
||||
internal static class Screenshot
|
||||
{
|
||||
public static async Task SaveAsync(BlackBoxWindow window, string path, double? time, bool follow)
|
||||
{
|
||||
if (time is { } t)
|
||||
window.SeekTo(t);
|
||||
window.FollowTrain = follow;
|
||||
// A few frames for the layout and the first render.
|
||||
await Task.Delay(1000);
|
||||
// Not laid out (a minimised window): nothing to save, and the viewer still closes.
|
||||
if (window.Content is Control content && content.Bounds.Width >= 1 && content.Bounds.Height >= 1)
|
||||
{
|
||||
var size = new PixelSize((int)content.Bounds.Width, (int)content.Bounds.Height);
|
||||
using var bitmap = new RenderTargetBitmap(size);
|
||||
bitmap.Render(content);
|
||||
bitmap.Save(path, new PngBitmapEncoderOptions());
|
||||
}
|
||||
window.Close();
|
||||
}
|
||||
}
|
||||
18
Eu07.BlackBox.Viewer/app.manifest
Normal file
18
Eu07.BlackBox.Viewer/app.manifest
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<!-- This manifest is used on Windows only.
|
||||
Don't remove it as it might cause problems with window transparency and embedded controls.
|
||||
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
|
||||
<assemblyIdentity version="1.0.0.0" name="Eu07.BlackBoxViewer"/>
|
||||
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- A list of the Windows versions that this application has been tested on
|
||||
and is designed to work with. Uncomment the appropriate elements
|
||||
and Windows will automatically select the most compatible environment. -->
|
||||
|
||||
<!-- Windows 10 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
579
Eu07.BlackBox.Views/BlackBoxStrings.cs
Normal file
579
Eu07.BlackBox.Views/BlackBoxStrings.cs
Normal file
@@ -0,0 +1,579 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
|
||||
namespace Eu07.BlackBox.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The texts of the window, by key. The starter gives its own translations; on its
|
||||
/// own the window speaks Polish or English, as the system does.
|
||||
/// </summary>
|
||||
public interface IBlackBoxStrings
|
||||
{
|
||||
string this[string key] { get; }
|
||||
}
|
||||
|
||||
public sealed class DefaultBlackBoxStrings : IBlackBoxStrings
|
||||
{
|
||||
private static readonly Dictionary<string, string> English = new()
|
||||
{
|
||||
["Title"] = "Black box",
|
||||
["TitleOf"] = "Black box – {0}",
|
||||
["Open"] = "Open…",
|
||||
["Reload"] = "Reload",
|
||||
["Live"] = "Follow the file",
|
||||
["LiveTip"] = "Reads what the simulator adds to the file every 2 seconds",
|
||||
["Play"] = "Play",
|
||||
["Pause"] = "Pause",
|
||||
["Follow"] = "Follow the train",
|
||||
["Fit"] = "Whole map",
|
||||
["NoFile"] = "Open a black box: physicslog/*.blackbox, written with the physics logs on.",
|
||||
["Unreadable"] = "Can't read {0}: {1}",
|
||||
["Reading"] = "Reading {0}…",
|
||||
["Summary"] = "{0} · {1:0.0} km · max {2:0} km/h · driven {3} · autopilot {4} · over the AI {5} ({6} by more than {7:0} km/h, at most +{8:0} km/h) · signals {9} · at danger {10} · commands {11}",
|
||||
["NotComplete"] = "still being written, or cut short",
|
||||
["Cursor"] = "{0} · {1:0} km/h · AI {2} · limit {3}",
|
||||
["CursorNoAi"] = "{0} · {1:0} km/h · no AI data",
|
||||
["CursorOut"] = "{0} · not in a vehicle",
|
||||
["None"] = "–",
|
||||
["LimitAt"] = "{0:0} km/h in {1:0} m",
|
||||
["EventCommand"] = "{0}",
|
||||
["EventAutopilotOn"] = "Autopilot on",
|
||||
["EventAutopilotOff"] = "Autopilot off",
|
||||
["EventPassed"] = "Signal {0}: {1}, {2:0} km/h",
|
||||
["EventDanger"] = "PASSED AT DANGER {0}, {1:0} km/h, {2:0} m beyond",
|
||||
["EventInVehicle"] = "In {0}",
|
||||
["EventOutside"] = "Out of the cab of {0}",
|
||||
["EventNoVehicle"] = "Not in any vehicle",
|
||||
["EventEnd"] = "End of the recording",
|
||||
["AspectStop"] = "stop",
|
||||
["AspectFree"] = "go",
|
||||
["AspectSpeed"] = "{0:0}/{1}",
|
||||
["LegendTitle"] = "The train's way: its speed against the AI's",
|
||||
["LegendWithin"] = "no faster than the AI",
|
||||
["LegendSlightly"] = "up to {0:0} km/h faster than the AI",
|
||||
["LegendOver"] = "over {0:0} km/h faster than the AI",
|
||||
["LegendAutopilot"] = "the autopilot drove",
|
||||
["LegendNoData"] = "no AI to compare with",
|
||||
["LegendMarks"] = "Signs on the map",
|
||||
["TimelineSpeed"] = "speed",
|
||||
["TimelineTarget"] = "AI",
|
||||
["TimelineLimit"] = "next limit",
|
||||
["BandEvents"] = "events",
|
||||
["KeyStation"] = "station",
|
||||
["KeyHorn"] = "W6 horn",
|
||||
["KeySignal"] = "signal passed",
|
||||
["KeyCoupling"] = "coupling",
|
||||
["KeyDriver"] = "autopilot on/off",
|
||||
["KeyCommand"] = "command",
|
||||
["TlSignal"] = "signal {0} passed: {1}",
|
||||
["TlSignalDanger"] = "signal {0} passed at stop",
|
||||
["TlCoupled"] = "coupled",
|
||||
["TlUncoupled"] = "uncoupled",
|
||||
["TlCouplingChanged"] = "a coupling changed",
|
||||
["TlAutopilotOn"] = "the autopilot switched on",
|
||||
["TlAutopilotOff"] = "the user takes over",
|
||||
["TlStationStopped"] = "{0}: stopped",
|
||||
["TlStationPassed"] = "{0}: passed",
|
||||
["TlStationMissed"] = "{0}: missed",
|
||||
["TlHornSounded"] = "W6: the horn sounded",
|
||||
["TlHornSilent"] = "W6: no horn",
|
||||
["TlCommand"] = "command: {0}",
|
||||
["BandDriving"] = "driving",
|
||||
["BandBrakes"] = "brakes",
|
||||
["BandAlerter"] = "alerter",
|
||||
["BandController"] = "controller",
|
||||
["Cab"] = "Cab",
|
||||
["CabNoData"] = "The user was in no vehicle then.",
|
||||
["CabController"] = "Master controller",
|
||||
["CabShunt"] = "Field shunting",
|
||||
["CabReverser"] = "Reverser",
|
||||
["CabForward"] = "forward",
|
||||
["CabBackward"] = "backward",
|
||||
["CabNeutral"] = "0",
|
||||
["CabTrainBrake"] = "Driver's brake valve",
|
||||
["CabLocalBrake"] = "Local brake",
|
||||
["CabReservoir"] = "Main reservoir",
|
||||
["CabCylinder"] = "Brake cylinders",
|
||||
["CabPipe"] = "Brake pipe",
|
||||
["CabCurrent"] = "Current",
|
||||
["CabVoltage"] = "Voltage",
|
||||
["CabRpm"] = "Revolutions",
|
||||
["CabLights"] = "Lights",
|
||||
["CabLightsFront"] = "front",
|
||||
["CabLightsRear"] = "rear",
|
||||
["DevBattery"] = "Battery",
|
||||
["DevMainBreaker"] = "Main breaker",
|
||||
["DevPantographFront"] = "Front pantograph",
|
||||
["DevPantographRear"] = "Rear pantograph",
|
||||
["DevConverter"] = "Converter",
|
||||
["DevCompressor"] = "Compressor",
|
||||
["DevLineContactors"] = "Line contactors",
|
||||
["DevHeating"] = "Train heating",
|
||||
["DevRadio"] = "Radio",
|
||||
["DevShuntMode"] = "Shunting mode",
|
||||
["DevDoorsLeft"] = "Left doors open",
|
||||
["DevDoorsRight"] = "Right doors open",
|
||||
["DevSanding"] = "Sanding",
|
||||
["DevHorn"] = "Horn",
|
||||
["DevSpringBrake"] = "Spring brake",
|
||||
["DevAlerter"] = "Alerter",
|
||||
["DevWheelSlip"] = "Wheel slip",
|
||||
["DevOverloadRelay"] = "Overload relay",
|
||||
["DevRadioStop"] = "Radio-stop",
|
||||
["DevAlarmChain"] = "Emergency brake",
|
||||
["TabEvents"] = "Events",
|
||||
["TabTimetable"] = "Timetable",
|
||||
["TabSummary"] = "Summary",
|
||||
["PanelMap"] = "Map",
|
||||
["PanelTimeline"] = "Run",
|
||||
["Relation"] = "{0} → {1}",
|
||||
["StatDistance"] = "Distance",
|
||||
["StatMaxSpeed"] = "Top speed",
|
||||
["StatDriven"] = "Driven by the user",
|
||||
["StatAutopilotSub"] = "autopilot {0}",
|
||||
["StatOverAi"] = "Over the AI",
|
||||
["StatOverAiSub"] = "{0} by >{1:0} km/h · up to +{2:0}",
|
||||
["StatSignals"] = "Signals",
|
||||
["StatSignalsSub"] = "{0} at danger",
|
||||
["StatStops"] = "Stops",
|
||||
["StatStopsSub"] = "{0} missed",
|
||||
["StatDelay"] = "Largest delay",
|
||||
["StatDelaySub"] = "leaving a station: {0} at most",
|
||||
["StatPlatform"] = "Stops off the platform",
|
||||
["StatLights"] = "Head and tail signals",
|
||||
["StatLightsRight"] = "right",
|
||||
["StatLightsSub"] = "as required all the {0} on the move",
|
||||
["StatLightsHead"] = "head {0} instead of {1}",
|
||||
["StatLightsTail"] = "tail {0} instead of {1}",
|
||||
["StatPlatformAt"] = "{0} {1:0.0} m",
|
||||
["StatPlatformNone"] = "the whole train along the platform at every stop",
|
||||
["StatHorn"] = "Horn at W6",
|
||||
["StatHornSub"] = "{0} without the horn",
|
||||
["StatAcceleration"] = "Acceleration",
|
||||
["StatAccelerationSub"] = "lateral up to {0:0.00} m/s²",
|
||||
["StatSecurity"] = "CA/SHP braking",
|
||||
["StatRecording"] = "Recording",
|
||||
["StatComplete"] = "complete",
|
||||
["StatIncomplete"] = "being written or cut short",
|
||||
["Minutes"] = "{0:+0.0;−0.0;0} min",
|
||||
["OnTime"] = "on time",
|
||||
["DelayNow"] = "delay {0}",
|
||||
["DelayStanding"] = "{0} standing at {1}",
|
||||
["DelayAfter"] = "{0} after {1}",
|
||||
["TimetableNone"] = "The user's train had no timetable.",
|
||||
["TtArrived"] = "arr. {0}",
|
||||
["TtDeparted"] = "dep. {0}",
|
||||
["TtPassed"] = "passed {0}",
|
||||
["TtMissed"] = "stop missed",
|
||||
["TtLeftEarly"] = "left early",
|
||||
["TtStopShort"] = "{0:0.0} m short",
|
||||
["TtStopPast"] = "{0:0.0} m past",
|
||||
["TtStopExact"] = "on the spot",
|
||||
["TtOnPlatform"] = "along the platform",
|
||||
["TtOffPlatform"] = "{0:0.0} m of the train off the platform",
|
||||
["TtTechnical"] = "technical stop",
|
||||
["TtStanding"] = "standing",
|
||||
["EventStopped"] = "Stop at {0}, {1}",
|
||||
["EventStationPassed"] = "Passed {0}",
|
||||
["EventStationMissed"] = "STOP MISSED: {0}",
|
||||
["EventReady"] = "{0}: time to leave",
|
||||
["EventHornSounded"] = "W6: horn sounded",
|
||||
["EventHornMissed"] = "W6: NO HORN",
|
||||
["EventCab"] = "Cab {0} of {1}",
|
||||
["EventSecurityBraking"] = "BRAKING BY {0}",
|
||||
["EventTimetable"] = "Timetable {0}, {1}",
|
||||
["CabSpeed"] = "Speed",
|
||||
["CabAiTarget"] = "AI {0:0} km/h",
|
||||
["CabAcceleration"] = "Along",
|
||||
["CabLateral"] = "Lateral",
|
||||
["CabEndFront"] = "A",
|
||||
["CabEndRear"] = "B",
|
||||
["CabDriverUser"] = "user drives",
|
||||
["CabDriverAi"] = "autopilot",
|
||||
["CabOutside"] = "outside the cab",
|
||||
["CabLightsHead"] = "head",
|
||||
["CabLightsTail"] = "tail",
|
||||
["CabSectionBrakes"] = "Brakes",
|
||||
["CabSectionDrive"] = "Traction",
|
||||
["CabSectionControls"] = "Controls",
|
||||
["CabSectionDevices"] = "Devices",
|
||||
["CabGroupPower"] = "Power",
|
||||
["CabGroupDriving"] = "Driving",
|
||||
["CabGroupSafety"] = "Safety",
|
||||
["CabLightsWant"] = "{0}, should be {1}",
|
||||
["CabLightsNone"] = "no lamps",
|
||||
["CabBrakeSetting"] = "setting {0}",
|
||||
["CabDriverValve"] = "Driver's valve",
|
||||
["CabFeedPipe"] = "Main reservoir pipe",
|
||||
["CabConsistCylinder"] = "Cylinders, train max",
|
||||
["CabPower"] = "Power",
|
||||
["CabSpeedControl"] = "Cruise control",
|
||||
["CabOilPressure"] = "Oil pressure",
|
||||
["CabWater"] = "Water",
|
||||
["CabOil"] = "Oil",
|
||||
["CabEngine"] = "Engine",
|
||||
["CabNoDrive"] = "no drive of its own",
|
||||
["CabClutch"] = "Clutch",
|
||||
["CabConverterFill"] = "Converter",
|
||||
["CabIntegrated"] = "Driving handle",
|
||||
["CabGroupDoors"] = "Doors",
|
||||
["DevDoorPermitLeft"] = "Left doors permitted",
|
||||
["DevDoorPermitRight"] = "Right doors permitted",
|
||||
["DevDepartureSignal"] = "Departure signal",
|
||||
["DevDoorStep"] = "Steps",
|
||||
["DevDoorLock"] = "Door lock",
|
||||
["DevDoorsLocked"] = "Doors locked",
|
||||
["DevBrakeReleaser"] = "Brake releaser",
|
||||
["DevFuelPump"] = "Fuel pump",
|
||||
["DevOilPump"] = "Oil pump",
|
||||
["DevMotorBlowers"] = "Motor fans",
|
||||
["DevEngineHot"] = "Engine overheating",
|
||||
["DevSpeedControl"] = "Cruise control",
|
||||
["DevRadioChannel"] = "Radio, channel {0}",
|
||||
["DevHandBrake"] = "Hand brake",
|
||||
["DevHornTone"] = "Horn ({0})",
|
||||
["HornLow"] = "low",
|
||||
["HornHigh"] = "high",
|
||||
["HornBoth"] = "both",
|
||||
["EventDeviceOn"] = "{0}: on",
|
||||
["EventDeviceOff"] = "{0}: off",
|
||||
["EventDoorsLeftClosed"] = "Left doors closed",
|
||||
["EventDoorsRightClosed"] = "Right doors closed",
|
||||
["EventMovingDoorsOpen"] = "Moving with the doors open ({0:0} km/h)",
|
||||
["EventHandBrakeOn"] = "Hand brake applied",
|
||||
["EventHandBrakeOff"] = "Hand brake released",
|
||||
["EventBrakeSetting"] = "Brake setting: {0}",
|
||||
["EventRadioChannel"] = "Radio: channel {0}",
|
||||
["EventSpeedControlSet"] = "Cruise control: {0:0} km/h",
|
||||
["DevVigilance"] = "CA",
|
||||
["DevCabSignal"] = "SHP",
|
||||
["DevSecurityBraking"] = "CA/SHP braking",
|
||||
["LegendStopPoint"] = "W4 stop point",
|
||||
["LegendShortStop"] = "W32 stop for shorter trains",
|
||||
["ShortStopHover"] = "W32 {0}",
|
||||
["LegendHorn"] = "W6 horn point",
|
||||
["BandVigilance"] = "CA",
|
||||
["BandCabSignal"] = "SHP",
|
||||
["StopPointHover"] = "W4 {0}",
|
||||
["PlatformHover"] = "platform {0:0} m",
|
||||
["ShortTrainsHover"] = "for trains up to {0:0} m",
|
||||
["StopHeadHover"] = "the head to stop {0:0} m before it",
|
||||
["StopMiddleHover"] = "the middle of the train to stop {0:0} m before it",
|
||||
["HornHover"] = "W6 {0} · {1:0} s",
|
||||
["ZoomIn"] = "Zoom in",
|
||||
["ZoomOut"] = "Zoom out",
|
||||
["EventCamera"] = "Camera: {0}",
|
||||
["CameraCab"] = "in the cab",
|
||||
["CameraExternal"] = "outside view",
|
||||
["CameraFree"] = "free camera",
|
||||
["CameraDebug"] = "debug camera",
|
||||
["EventCoupled"] = "Coupled {0} to {1}",
|
||||
["EventUncoupled"] = "Uncoupled {0} from {1}",
|
||||
["EventCouplingChanged"] = "{0} – {1}: {2}",
|
||||
["CouplingCoupler"] = "coupler",
|
||||
["CouplingBrakePipe"] = "brake pipe",
|
||||
["CouplingControl"] = "control",
|
||||
["CouplingPower"] = "high voltage",
|
||||
["CouplingGangway"] = "gangway",
|
||||
["CouplingMainPipe"] = "main pipe",
|
||||
["CouplingHeating"] = "heating",
|
||||
["CouplingNothing"] = "nothing",
|
||||
["BandCamera"] = "camera",
|
||||
["LegendCamera"] = "camera",
|
||||
["Events"] = "Events",
|
||||
["OnlyUser"] = "only the user's",
|
||||
["FileFilter"] = "Black box"
|
||||
};
|
||||
|
||||
private static readonly Dictionary<string, string> Polish = new()
|
||||
{
|
||||
["Title"] = "Czarna skrzynka",
|
||||
["TitleOf"] = "Czarna skrzynka – {0}",
|
||||
["Open"] = "Otwórz…",
|
||||
["Reload"] = "Odśwież",
|
||||
["Live"] = "Śledź plik",
|
||||
["LiveTip"] = "Co 2 sekundy doczytuje to, co symulator dopisał do pliku",
|
||||
["Play"] = "Odtwarzaj",
|
||||
["Pause"] = "Pauza",
|
||||
["Follow"] = "Śledź pociąg",
|
||||
["Fit"] = "Cała mapa",
|
||||
["NoFile"] = "Otwórz czarną skrzynkę: physicslog/*.blackbox, zapisywaną razem z logami fizyki.",
|
||||
["Unreadable"] = "Nie można odczytać {0}: {1}",
|
||||
["Reading"] = "Czytanie {0}…",
|
||||
["Summary"] = "{0} · {1:0.0} km · maks. {2:0} km/h · jazda {3} · autopilot {4} · ponad AI {5} (w tym {6} o więcej niż {7:0} km/h, najwyżej +{8:0} km/h) · sygnalizatory {9} · minięte na „Stój” {10} · komendy {11}",
|
||||
["NotComplete"] = "wciąż zapisywana albo ucięta",
|
||||
["Cursor"] = "{0} · {1:0} km/h · AI {2} · ograniczenie {3}",
|
||||
["CursorNoAi"] = "{0} · {1:0} km/h · brak danych AI",
|
||||
["CursorOut"] = "{0} · poza pojazdem",
|
||||
["None"] = "–",
|
||||
["LimitAt"] = "{0:0} km/h za {1:0} m",
|
||||
["EventCommand"] = "{0}",
|
||||
["EventAutopilotOn"] = "Autopilot włączony",
|
||||
["EventAutopilotOff"] = "Autopilot wyłączony",
|
||||
["EventPassed"] = "Sygnalizator {0}: {1}, {2:0} km/h",
|
||||
["EventDanger"] = "MINIĘTY NA „STÓJ” {0}, {1:0} km/h, {2:0} m za nim",
|
||||
["EventInVehicle"] = "W {0}",
|
||||
["EventOutside"] = "Poza kabiną {0}",
|
||||
["EventNoVehicle"] = "Poza pojazdami",
|
||||
["EventEnd"] = "Koniec nagrania",
|
||||
["AspectStop"] = "stój",
|
||||
["AspectFree"] = "wolna droga",
|
||||
["AspectSpeed"] = "{0:0}/{1}",
|
||||
["LegendTitle"] = "Ślad pociągu: prędkość wobec AI",
|
||||
["LegendWithin"] = "nie szybciej niż AI",
|
||||
["LegendSlightly"] = "do {0:0} km/h szybciej niż AI",
|
||||
["LegendOver"] = "ponad {0:0} km/h szybciej niż AI",
|
||||
["LegendAutopilot"] = "prowadził autopilot",
|
||||
["LegendNoData"] = "brak AI do porównania",
|
||||
["LegendMarks"] = "Znaki na mapie",
|
||||
["TimelineSpeed"] = "prędkość",
|
||||
["TimelineTarget"] = "AI",
|
||||
["TimelineLimit"] = "następne ograniczenie",
|
||||
["BandEvents"] = "zdarzenia",
|
||||
["KeyStation"] = "stacja",
|
||||
["KeyHorn"] = "W6 trąbienie",
|
||||
["KeySignal"] = "minięty semafor",
|
||||
["KeyCoupling"] = "sprzęg",
|
||||
["KeyDriver"] = "autopilot wł./wył.",
|
||||
["KeyCommand"] = "polecenie",
|
||||
["TlSignal"] = "semafor {0} minięty: {1}",
|
||||
["TlSignalDanger"] = "semafor {0} minięty na „Stój”",
|
||||
["TlCoupled"] = "sprzęg połączony",
|
||||
["TlUncoupled"] = "sprzęg rozłączony",
|
||||
["TlCouplingChanged"] = "zmiana na sprzęgu",
|
||||
["TlAutopilotOn"] = "autopilot włączony",
|
||||
["TlAutopilotOff"] = "prowadzenie przejmuje użytkownik",
|
||||
["TlStationStopped"] = "{0}: postój",
|
||||
["TlStationPassed"] = "{0}: przejazd",
|
||||
["TlStationMissed"] = "{0}: pominięta",
|
||||
["TlHornSounded"] = "W6: zatrąbiono",
|
||||
["TlHornSilent"] = "W6: bez trąbienia",
|
||||
["TlCommand"] = "polecenie: {0}",
|
||||
["BandDriving"] = "jazda",
|
||||
["BandBrakes"] = "hamulce",
|
||||
["BandAlerter"] = "czuwak",
|
||||
["BandController"] = "nastawnik",
|
||||
["Cab"] = "Kabina",
|
||||
["CabNoData"] = "Użytkownik nie był wtedy w żadnym pojeździe.",
|
||||
["CabController"] = "Nastawnik",
|
||||
["CabShunt"] = "Bocznik",
|
||||
["CabReverser"] = "Kierunek",
|
||||
["CabForward"] = "naprzód",
|
||||
["CabBackward"] = "wstecz",
|
||||
["CabNeutral"] = "0",
|
||||
["CabTrainBrake"] = "Hamulec zespolony",
|
||||
["CabLocalBrake"] = "Hamulec pomocniczy",
|
||||
["CabReservoir"] = "Zbiornik główny",
|
||||
["CabCylinder"] = "Cylindry",
|
||||
["CabPipe"] = "Przewód główny",
|
||||
["CabCurrent"] = "Prąd",
|
||||
["CabVoltage"] = "Napięcie",
|
||||
["CabRpm"] = "Obroty",
|
||||
["CabLights"] = "Światła",
|
||||
["CabLightsFront"] = "przód",
|
||||
["CabLightsRear"] = "tył",
|
||||
["DevBattery"] = "Baterie",
|
||||
["DevMainBreaker"] = "WS",
|
||||
["DevPantographFront"] = "Pantograf przedni",
|
||||
["DevPantographRear"] = "Pantograf tylny",
|
||||
["DevConverter"] = "Przetwornica",
|
||||
["DevCompressor"] = "Sprężarka",
|
||||
["DevLineContactors"] = "Styczniki liniowe",
|
||||
["DevHeating"] = "Ogrzewanie",
|
||||
["DevRadio"] = "Radiotelefon",
|
||||
["DevShuntMode"] = "Jazda manewrowa",
|
||||
["DevDoorsLeft"] = "Drzwi lewe otwarte",
|
||||
["DevDoorsRight"] = "Drzwi prawe otwarte",
|
||||
["DevSanding"] = "Piasecznica",
|
||||
["DevHorn"] = "Syrena",
|
||||
["DevSpringBrake"] = "Hamulec sprężynowy",
|
||||
["DevAlerter"] = "Czuwak",
|
||||
["DevWheelSlip"] = "Poślizg",
|
||||
["DevOverloadRelay"] = "Nadmiarowy",
|
||||
["DevRadioStop"] = "Radiostop",
|
||||
["DevAlarmChain"] = "Hamulec bezpieczeństwa",
|
||||
["TabEvents"] = "Zdarzenia",
|
||||
["TabTimetable"] = "Rozkład",
|
||||
["TabSummary"] = "Podsumowanie",
|
||||
["PanelMap"] = "Mapa",
|
||||
["PanelTimeline"] = "Przebieg jazdy",
|
||||
["Relation"] = "{0} → {1}",
|
||||
["StatDistance"] = "Dystans",
|
||||
["StatMaxSpeed"] = "Prędkość maks.",
|
||||
["StatDriven"] = "Jazda użytkownika",
|
||||
["StatAutopilotSub"] = "autopilot {0}",
|
||||
["StatOverAi"] = "Ponad AI",
|
||||
["StatOverAiSub"] = "{0} o >{1:0} km/h · do +{2:0}",
|
||||
["StatSignals"] = "Sygnalizatory",
|
||||
["StatSignalsSub"] = "{0} na „Stój”",
|
||||
["StatStops"] = "Postoje",
|
||||
["StatStopsSub"] = "{0} pominięte",
|
||||
["StatDelay"] = "Największe opóźnienie",
|
||||
["StatDelaySub"] = "przy odjeździe: najwyżej {0}",
|
||||
["StatPlatform"] = "Postoje poza peronem",
|
||||
["StatLights"] = "Sygnały czoła i końca",
|
||||
["StatLightsRight"] = "zgodne",
|
||||
["StatLightsSub"] = "jak trzeba przez całe {0} jazdy",
|
||||
["StatLightsHead"] = "czoło {0} zamiast {1}",
|
||||
["StatLightsTail"] = "koniec {0} zamiast {1}",
|
||||
["StatPlatformAt"] = "{0} {1:0.0} m",
|
||||
["StatPlatformNone"] = "na każdym postoju cały skład przy peronie",
|
||||
["StatHorn"] = "Trąbienie na W6",
|
||||
["StatHornSub"] = "{0} bez trąbienia",
|
||||
["StatAcceleration"] = "Przyspieszenie",
|
||||
["StatAccelerationSub"] = "boczne do {0:0.00} m/s²",
|
||||
["StatSecurity"] = "Hamowania CA/SHP",
|
||||
["StatRecording"] = "Nagranie",
|
||||
["StatComplete"] = "pełne",
|
||||
["StatIncomplete"] = "w trakcie zapisu lub ucięte",
|
||||
["Minutes"] = "{0:+0.0;−0.0;0} min",
|
||||
["OnTime"] = "planowo",
|
||||
["DelayNow"] = "opóźnienie {0}",
|
||||
["DelayStanding"] = "{0} stoi w: {1}",
|
||||
["DelayAfter"] = "{0} za: {1}",
|
||||
["TimetableNone"] = "Pociąg użytkownika nie miał rozkładu.",
|
||||
["TtArrived"] = "przyj. {0}",
|
||||
["TtDeparted"] = "odj. {0}",
|
||||
["TtPassed"] = "przejazd {0}",
|
||||
["TtMissed"] = "pominięty postój",
|
||||
["TtLeftEarly"] = "odjazd przed czasem",
|
||||
["TtStopShort"] = "{0:0.0} m przed miejscem",
|
||||
["TtStopPast"] = "{0:0.0} m za miejscem",
|
||||
["TtStopExact"] = "w punkt",
|
||||
["TtOnPlatform"] = "cały przy peronie",
|
||||
["TtOffPlatform"] = "{0:0.0} m składu poza peronem",
|
||||
["TtTechnical"] = "postój techniczny",
|
||||
["TtStanding"] = "stoi",
|
||||
["EventStopped"] = "Postój: {0}, {1}",
|
||||
["EventStationPassed"] = "Przejazd: {0}",
|
||||
["EventStationMissed"] = "POMINIĘTY POSTÓJ: {0}",
|
||||
["EventReady"] = "{0}: czas odjazdu",
|
||||
["EventHornSounded"] = "W6: zatrąbił",
|
||||
["EventHornMissed"] = "W6: BEZ TRĄBIENIA",
|
||||
["EventCab"] = "Kabina {0} w {1}",
|
||||
["EventSecurityBraking"] = "HAMOWANIE PRZEZ {0}",
|
||||
["EventTimetable"] = "Rozkład {0}, {1}",
|
||||
["CabSpeed"] = "Prędkość",
|
||||
["CabAiTarget"] = "AI {0:0} km/h",
|
||||
["CabAcceleration"] = "Wzdłużne",
|
||||
["CabLateral"] = "Boczne",
|
||||
["CabEndFront"] = "A",
|
||||
["CabEndRear"] = "B",
|
||||
["CabDriverUser"] = "prowadzi użytkownik",
|
||||
["CabDriverAi"] = "autopilot",
|
||||
["CabOutside"] = "poza kabiną",
|
||||
["CabLightsHead"] = "czoło",
|
||||
["CabLightsTail"] = "koniec",
|
||||
["CabSectionBrakes"] = "Hamulce",
|
||||
["CabSectionDrive"] = "Napęd",
|
||||
["CabSectionControls"] = "Nastawniki",
|
||||
["CabSectionDevices"] = "Urządzenia",
|
||||
["CabGroupPower"] = "Zasilanie",
|
||||
["CabGroupDriving"] = "Jazda",
|
||||
["CabGroupSafety"] = "Bezpieczeństwo",
|
||||
["CabLightsWant"] = "{0}, a ma być {1}",
|
||||
["CabLightsNone"] = "brak lamp",
|
||||
["CabBrakeSetting"] = "nastawa {0}",
|
||||
["CabDriverValve"] = "Zawór maszynisty",
|
||||
["CabFeedPipe"] = "Przewód zasilający",
|
||||
["CabConsistCylinder"] = "Cylindry, skład max",
|
||||
["CabPower"] = "Moc",
|
||||
["CabSpeedControl"] = "Tempomat",
|
||||
["CabOilPressure"] = "Ciśn. oleju",
|
||||
["CabWater"] = "Woda",
|
||||
["CabOil"] = "Olej",
|
||||
["CabEngine"] = "Silnik",
|
||||
["CabNoDrive"] = "brak własnego napędu",
|
||||
["CabClutch"] = "Sprzęgło",
|
||||
["CabConverterFill"] = "Przekładnia",
|
||||
["CabIntegrated"] = "Zadajnik",
|
||||
["CabGroupDoors"] = "Drzwi",
|
||||
["DevDoorPermitLeft"] = "Zezwolenie lewe",
|
||||
["DevDoorPermitRight"] = "Zezwolenie prawe",
|
||||
["DevDepartureSignal"] = "Sygnał odjazdu",
|
||||
["DevDoorStep"] = "Stopień",
|
||||
["DevDoorLock"] = "Blokada drzwi",
|
||||
["DevDoorsLocked"] = "Drzwi zablokowane",
|
||||
["DevBrakeReleaser"] = "Odluźniacz",
|
||||
["DevFuelPump"] = "Pompa paliwa",
|
||||
["DevOilPump"] = "Pompa oleju",
|
||||
["DevMotorBlowers"] = "Wentylatory silników",
|
||||
["DevEngineHot"] = "Przegrzanie silnika",
|
||||
["DevSpeedControl"] = "Tempomat",
|
||||
["DevRadioChannel"] = "Radio, kanał {0}",
|
||||
["DevHandBrake"] = "Hamulec ręczny",
|
||||
["DevHornTone"] = "Syrena ({0})",
|
||||
["HornLow"] = "niski",
|
||||
["HornHigh"] = "wysoki",
|
||||
["HornBoth"] = "oba",
|
||||
["EventDeviceOn"] = "{0}: wł.",
|
||||
["EventDeviceOff"] = "{0}: wył.",
|
||||
["EventDoorsLeftClosed"] = "Drzwi lewe zamknięte",
|
||||
["EventDoorsRightClosed"] = "Drzwi prawe zamknięte",
|
||||
["EventMovingDoorsOpen"] = "Jazda z otwartymi drzwiami ({0:0} km/h)",
|
||||
["EventHandBrakeOn"] = "Hamulec ręczny zaciągnięty",
|
||||
["EventHandBrakeOff"] = "Hamulec ręczny zwolniony",
|
||||
["EventBrakeSetting"] = "Nastawa hamulca: {0}",
|
||||
["EventRadioChannel"] = "Radiotelefon: kanał {0}",
|
||||
["EventSpeedControlSet"] = "Tempomat: {0:0} km/h",
|
||||
["DevVigilance"] = "CA",
|
||||
["DevCabSignal"] = "SHP",
|
||||
["DevSecurityBraking"] = "Hamowanie CA/SHP",
|
||||
["LegendStopPoint"] = "W4 – wskaźnik zatrzymania",
|
||||
["LegendShortStop"] = "W32 – zatrzymanie krótszych pociągów",
|
||||
["ShortStopHover"] = "W32 {0}",
|
||||
["LegendHorn"] = "W6 – „trąbić”",
|
||||
["BandVigilance"] = "CA",
|
||||
["BandCabSignal"] = "SHP",
|
||||
["StopPointHover"] = "W4 {0}",
|
||||
["PlatformHover"] = "peron {0:0} m",
|
||||
["ShortTrainsHover"] = "dla pociągów do {0:0} m",
|
||||
["StopHeadHover"] = "czoło ma stanąć {0:0} m przed W4",
|
||||
["StopMiddleHover"] = "środek składu ma stanąć {0:0} m przed W4",
|
||||
["HornHover"] = "W6 {0} · {1:0} s",
|
||||
["ZoomIn"] = "Przybliż",
|
||||
["ZoomOut"] = "Oddal",
|
||||
["EventCamera"] = "Kamera: {0}",
|
||||
["CameraCab"] = "w kabinie",
|
||||
["CameraExternal"] = "widok zewnętrzny",
|
||||
["CameraFree"] = "wolna kamera",
|
||||
["CameraDebug"] = "kamera debugowania",
|
||||
["EventCoupled"] = "Sprzęgnięto {0} z {1}",
|
||||
["EventUncoupled"] = "Rozprzęgnięto {0} od {1}",
|
||||
["EventCouplingChanged"] = "{0} – {1}: {2}",
|
||||
["CouplingCoupler"] = "sprzęg",
|
||||
["CouplingBrakePipe"] = "przewód hamulcowy",
|
||||
["CouplingControl"] = "sterowanie",
|
||||
["CouplingPower"] = "WN",
|
||||
["CouplingGangway"] = "mostek",
|
||||
["CouplingMainPipe"] = "przewód zasilający",
|
||||
["CouplingHeating"] = "ogrzewanie",
|
||||
["CouplingNothing"] = "nic",
|
||||
["BandCamera"] = "kamera",
|
||||
["LegendCamera"] = "kamera",
|
||||
["Events"] = "Zdarzenia",
|
||||
["OnlyUser"] = "tylko użytkownika",
|
||||
["FileFilter"] = "Czarna skrzynka"
|
||||
};
|
||||
|
||||
private readonly Dictionary<string, string> _texts;
|
||||
|
||||
public DefaultBlackBoxStrings() : this(CultureInfo.CurrentUICulture)
|
||||
{
|
||||
}
|
||||
|
||||
public DefaultBlackBoxStrings(CultureInfo culture) : this(culture.TwoLetterISOLanguageName)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Polish for "pl", English for any other language.</summary>
|
||||
public DefaultBlackBoxStrings(string languageCode)
|
||||
{
|
||||
_texts = languageCode == "pl" ? Polish : English;
|
||||
}
|
||||
|
||||
/// <summary>The keys there are texts for, for a translation to give them all.</summary>
|
||||
public static IEnumerable<string> Keys => English.Keys;
|
||||
|
||||
public string this[string key] => _texts.TryGetValue(key, out var text) ? text : English.GetValueOrDefault(key, key);
|
||||
}
|
||||
571
Eu07.BlackBox.Views/BlackBoxWindow.cs
Normal file
571
Eu07.BlackBox.Views/BlackBoxWindow.cs
Normal file
@@ -0,0 +1,571 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace Eu07.BlackBox.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Replays a black box. Above: the train and the scenery, playing and how late the train
|
||||
/// was. In the middle the map at the moment played, the timeline of the user's train under
|
||||
/// it; beside them the events, the timetable or the figures of the driving, and the cab. A file
|
||||
/// the simulator still writes can be followed: what it adds is read every two seconds, and
|
||||
/// playing at its end keeps up with it.
|
||||
/// </summary>
|
||||
public sealed class BlackBoxWindow : Window
|
||||
{
|
||||
private static readonly double[] Speeds = { 1, 2, 5, 10, 30, 60 };
|
||||
|
||||
private readonly IBlackBoxStrings _strings;
|
||||
private readonly MapView _map;
|
||||
private readonly TimelineView _timeline;
|
||||
private readonly EventList _events;
|
||||
private readonly TimetablePanel _timetable;
|
||||
private readonly ScrollViewer _timetableScroll;
|
||||
private readonly ScrollViewer _summaryScroll;
|
||||
private readonly CabPanel _cab;
|
||||
private readonly UiKit.Segmented _tabs;
|
||||
private readonly TextBlock _title = new() { FontSize = 14, FontWeight = FontWeight.SemiBold, Foreground = Palette.Text, TextTrimming = TextTrimming.CharacterEllipsis };
|
||||
private readonly TextBlock _subtitle = new() { FontSize = 11, Foreground = Palette.DimText, TextTrimming = TextTrimming.CharacterEllipsis };
|
||||
private readonly TextBlock _clock = new() { VerticalAlignment = VerticalAlignment.Center, FontSize = 18, FontWeight = FontWeight.SemiBold, Foreground = Palette.Text, MinWidth = 86, TextAlignment = TextAlignment.Center };
|
||||
private readonly TextBlock _delayText = new() { FontSize = 12, FontWeight = FontWeight.SemiBold, VerticalAlignment = VerticalAlignment.Center, MinWidth = 120 };
|
||||
private readonly TextBlock _readout = new() { VerticalAlignment = VerticalAlignment.Center, FontSize = 11, Foreground = Palette.DimText, TextTrimming = TextTrimming.CharacterEllipsis };
|
||||
private readonly TextBlock _fileState = new() { VerticalAlignment = VerticalAlignment.Center, FontSize = 11, Foreground = Palette.FaintText };
|
||||
private readonly TextBlock _message = new() { FontSize = 14, Foreground = Palette.DimText, TextWrapping = TextWrapping.Wrap, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, TextAlignment = TextAlignment.Center, Margin = new Thickness(40) };
|
||||
private readonly StackPanel _tiles = new();
|
||||
private readonly UiKit.Tile _distance, _maxSpeed, _driven, _overAi, _signals, _stops, _delay, _platform, _lights, _horn, _acceleration, _security, _state;
|
||||
private readonly Button _open;
|
||||
private readonly Button _play;
|
||||
private readonly ComboBox _speed = new() { VerticalAlignment = VerticalAlignment.Center, MinWidth = 64, FontSize = 12, Padding = new Thickness(8, 3) };
|
||||
private readonly Avalonia.Controls.Primitives.ToggleButton _follow;
|
||||
private readonly Avalonia.Controls.Primitives.ToggleButton _live;
|
||||
private readonly DispatcherTimer _tick = new() { Interval = TimeSpan.FromMilliseconds(40) };
|
||||
private readonly DispatcherTimer _reload = new() { Interval = TimeSpan.FromSeconds(2) };
|
||||
private readonly Stopwatch _played = new();
|
||||
|
||||
private BlackBoxReader? _reader;
|
||||
private string? _path;
|
||||
private double _time;
|
||||
private bool _reading;
|
||||
|
||||
public BlackBoxWindow() : this(null)
|
||||
{
|
||||
}
|
||||
|
||||
public BlackBoxWindow(IBlackBoxStrings? strings)
|
||||
{
|
||||
_strings = strings ?? new DefaultBlackBoxStrings();
|
||||
Title = _strings["Title"];
|
||||
Width = 1440;
|
||||
Height = 900;
|
||||
MinWidth = 960;
|
||||
MinHeight = 600;
|
||||
Background = Palette.Background;
|
||||
UiKit.Accent(this);
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner;
|
||||
|
||||
_map = new MapView { Strings = _strings };
|
||||
_timeline = new TimelineView { Strings = _strings };
|
||||
_events = new EventList { Strings = _strings, Background = Brushes.Transparent };
|
||||
_timetable = new TimetablePanel { Strings = _strings };
|
||||
_timetableScroll = new ScrollViewer { Content = _timetable, IsVisible = false };
|
||||
_summaryScroll = new ScrollViewer { Content = _tiles, IsVisible = false };
|
||||
_cab = new CabPanel { Strings = _strings };
|
||||
|
||||
// ----- the bar on top: the train, playing, the switches -----
|
||||
_open = UiKit.Button(_strings["Open"]);
|
||||
_open.Click += async (_, _) => await PickAsync();
|
||||
var reload = UiKit.Button(_strings["Reload"]);
|
||||
reload.Click += (_, _) => ReadMore();
|
||||
var fit = UiKit.Button(_strings["Fit"]);
|
||||
fit.Click += (_, _) => ShowWhole();
|
||||
_follow = UiKit.Toggle(_strings["Follow"]);
|
||||
_follow.IsCheckedChanged += (_, _) => _map.Follow = _follow.IsChecked == true;
|
||||
_map.FollowStopped += () => _follow.IsChecked = false;
|
||||
_live = UiKit.Toggle(_strings["Live"], _strings["LiveTip"]);
|
||||
_live.IsCheckedChanged += (_, _) =>
|
||||
{
|
||||
if (_live.IsChecked == true)
|
||||
_reload.Start();
|
||||
else
|
||||
_reload.Stop();
|
||||
};
|
||||
|
||||
_play = UiKit.PlayButton();
|
||||
_play.Content = UiKit.PlayGlyph(false);
|
||||
ToolTip.SetTip(_play, _strings["Play"]);
|
||||
_play.Click += (_, _) => Playing = !Playing;
|
||||
_speed.ItemsSource = Speeds.Select(s => $"×{s.ToString(CultureInfo.InvariantCulture)}").ToArray();
|
||||
_speed.SelectedIndex = 3;
|
||||
|
||||
var titles = new StackPanel { VerticalAlignment = VerticalAlignment.Center, Children = { _title, _subtitle } };
|
||||
var transport = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 10, VerticalAlignment = VerticalAlignment.Center, Children = { _play, _clock, _speed, _delayText } };
|
||||
var switches = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 2, VerticalAlignment = VerticalAlignment.Center, Children = { _follow, _live, fit, reload, _open } };
|
||||
var header = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto,*") };
|
||||
Place(header, titles, 0, 0);
|
||||
Place(header, transport, 0, 1);
|
||||
switches.HorizontalAlignment = HorizontalAlignment.Right;
|
||||
Place(header, switches, 0, 2);
|
||||
titles.Margin = new Thickness(0, 0, 16, 0);
|
||||
var headerBar = new Border
|
||||
{
|
||||
Background = Palette.Panel,
|
||||
BorderBrush = Palette.CardBorder,
|
||||
BorderThickness = new Thickness(0, 0, 0, 1),
|
||||
Padding = new Thickness(8, 0),
|
||||
Height = 44,
|
||||
Child = header
|
||||
};
|
||||
|
||||
// ----- the figures, a table in a tab of their own -----
|
||||
_distance = Tile("StatDistance");
|
||||
_maxSpeed = Tile("StatMaxSpeed");
|
||||
_driven = Tile("StatDriven");
|
||||
_overAi = Tile("StatOverAi");
|
||||
_signals = Tile("StatSignals");
|
||||
_stops = Tile("StatStops");
|
||||
_delay = Tile("StatDelay");
|
||||
_platform = Tile("StatPlatform");
|
||||
_lights = Tile("StatLights");
|
||||
_horn = Tile("StatHorn");
|
||||
_acceleration = Tile("StatAcceleration");
|
||||
_security = Tile("StatSecurity");
|
||||
_state = Tile("StatRecording");
|
||||
|
||||
_timeline.Seek += t => SetTime(t);
|
||||
_timeline.Hovered += ShowReadout;
|
||||
_events.Seek += t => SetTime(t);
|
||||
_timetable.Seek += t => SetTime(t);
|
||||
_tick.Tick += (_, _) => Advance();
|
||||
_reload.Tick += (_, _) => ReadMore();
|
||||
|
||||
// ----- the map with its zoom, the timeline, the side -----
|
||||
var zoomIn = MapButton("+", _strings["ZoomIn"], () => _map.ZoomBy(1.6));
|
||||
var zoomOut = MapButton("−", _strings["ZoomOut"], () => _map.ZoomBy(1 / 1.6));
|
||||
var zoomFit = MapButton("⤢", _strings["Fit"], ShowWhole);
|
||||
var zoom = new StackPanel
|
||||
{
|
||||
Spacing = 1,
|
||||
Margin = new Thickness(8),
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
VerticalAlignment = VerticalAlignment.Top,
|
||||
Children = { zoomIn, zoomOut, zoomFit }
|
||||
};
|
||||
var mapArea = new Panel { Children = { _map, zoom, _message } };
|
||||
_map.ClipToBounds = true;
|
||||
|
||||
_tabs = new UiKit.Segmented(_strings["TabEvents"], _strings["TabTimetable"], _strings["TabSummary"]);
|
||||
_tabs.SelectedChanged += index =>
|
||||
{
|
||||
_events.IsVisible = index == 0;
|
||||
_timetableScroll.IsVisible = index == 1;
|
||||
_summaryScroll.IsVisible = index == 2;
|
||||
};
|
||||
var side = new DockPanel();
|
||||
DockPanel.SetDock(_tabs, Dock.Top);
|
||||
side.Children.Add(_tabs);
|
||||
side.Children.Add(new Panel { Children = { _events, _timetableScroll, _summaryScroll } });
|
||||
var cabCard = UiKit.Group(_strings["Cab"], new ScrollViewer { Content = _cab, HorizontalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Disabled });
|
||||
cabCard.Margin = new Thickness(0, UiKit.Gap, 0, 0);
|
||||
var sideColumn = new DockPanel();
|
||||
// On a short window the cab scrolls, so the list above keeps room for a few rows.
|
||||
sideColumn.SizeChanged += (_, e) => cabCard.MaxHeight = Math.Max(160, e.NewSize.Height - 160);
|
||||
DockPanel.SetDock(cabCard, Dock.Bottom);
|
||||
sideColumn.Children.Add(cabCard);
|
||||
sideColumn.Children.Add(UiKit.Frame(side));
|
||||
|
||||
var body = new Grid
|
||||
{
|
||||
ColumnDefinitions = new ColumnDefinitions($"*,{UiKit.Gap.ToString(CultureInfo.InvariantCulture)},400"),
|
||||
RowDefinitions = new RowDefinitions($"*,{UiKit.Gap.ToString(CultureInfo.InvariantCulture)},230")
|
||||
};
|
||||
Place(body, UiKit.Group(_strings["PanelMap"], mapArea), 0, 0);
|
||||
Place(body, new GridSplitter { Background = Brushes.Transparent, ResizeDirection = GridResizeDirection.Rows, MinHeight = UiKit.Gap }, 1, 0);
|
||||
Place(body, UiKit.Group(_strings["PanelTimeline"], _timeline), 2, 0);
|
||||
var columns = new GridSplitter { Background = Brushes.Transparent, ResizeDirection = GridResizeDirection.Columns, MinWidth = UiKit.Gap };
|
||||
Place(body, columns, 0, 1);
|
||||
Grid.SetRowSpan(columns, 3);
|
||||
Place(body, sideColumn, 0, 2);
|
||||
Grid.SetRowSpan(sideColumn, 3);
|
||||
|
||||
// ----- the line at the bottom: the moment under the pointer, the state of the file -----
|
||||
var status = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto") };
|
||||
Place(status, _readout, 0, 0);
|
||||
Place(status, _fileState, 0, 1);
|
||||
var statusBar = new Border
|
||||
{
|
||||
Background = Palette.Panel,
|
||||
BorderBrush = Palette.CardBorder,
|
||||
BorderThickness = new Thickness(0, 1, 0, 0),
|
||||
Padding = new Thickness(12, 3),
|
||||
Child = status
|
||||
};
|
||||
|
||||
body.Margin = new Thickness(UiKit.Gap);
|
||||
var root = new DockPanel { Background = Palette.Background };
|
||||
DockPanel.SetDock(headerBar, Dock.Top);
|
||||
DockPanel.SetDock(statusBar, Dock.Bottom);
|
||||
root.Children.Add(headerBar);
|
||||
root.Children.Add(statusBar);
|
||||
root.Children.Add(body);
|
||||
Content = root;
|
||||
|
||||
_title.Text = _strings["Title"];
|
||||
_subtitle.Text = _strings["NoFile"];
|
||||
_message.Text = _strings["NoFile"];
|
||||
Closed += (_, _) =>
|
||||
{
|
||||
_tick.Stop();
|
||||
_reload.Stop();
|
||||
};
|
||||
}
|
||||
|
||||
// When and at which end the signals were wrong: "10:55:12 head Tb 1 instead of Pc 1".
|
||||
private string LightsFaultText(Recording recording, LightsFault fault, CultureInfo culture)
|
||||
{
|
||||
var ends = new List<string>();
|
||||
if (fault.HeadWrong)
|
||||
ends.Add(string.Format(culture, _strings["StatLightsHead"], HeadCodes.Name(fault.Head), HeadCodes.Name(fault.HeadRequired)));
|
||||
if (fault.TailWrong)
|
||||
ends.Add(string.Format(culture, _strings["StatLightsTail"], HeadCodes.Name(fault.Tail), HeadCodes.Name(fault.TailRequired)));
|
||||
return $"{Clock(recording.ClockAt(fault.Start).TotalSeconds)} {string.Join(", ", ends)}";
|
||||
}
|
||||
|
||||
private UiKit.Tile Tile(string key)
|
||||
{
|
||||
var tile = new UiKit.Tile(_strings[key]);
|
||||
_tiles.Children.Add(tile);
|
||||
return tile;
|
||||
}
|
||||
|
||||
private static Button MapButton(string text, string tip, Action action)
|
||||
{
|
||||
var button = UiKit.Button(text, tip);
|
||||
button.Width = 28;
|
||||
button.Height = 28;
|
||||
button.Padding = new Thickness(0);
|
||||
button.FontSize = 15;
|
||||
button.HorizontalContentAlignment = HorizontalAlignment.Center;
|
||||
button.VerticalContentAlignment = VerticalAlignment.Center;
|
||||
button.Background = Palette.Panel;
|
||||
button.Click += (_, _) => action();
|
||||
return button;
|
||||
}
|
||||
|
||||
private static void Place(Grid grid, Control control, int row, int column)
|
||||
{
|
||||
Grid.SetRow(control, row);
|
||||
Grid.SetColumn(control, column);
|
||||
grid.Children.Add(control);
|
||||
}
|
||||
|
||||
/// <summary>The button for opening another file: the starter opens the one picked in its list.</summary>
|
||||
public bool CanOpenFiles
|
||||
{
|
||||
get => _open.IsVisible;
|
||||
set => _open.IsVisible = value;
|
||||
}
|
||||
|
||||
/// <summary>Reads what the simulator adds to the file every two seconds.</summary>
|
||||
public bool Live
|
||||
{
|
||||
get => _live.IsChecked == true;
|
||||
set => _live.IsChecked = value;
|
||||
}
|
||||
|
||||
public string? FilePath => _path;
|
||||
|
||||
public Recording? Recording => _reader?.Recording;
|
||||
|
||||
public double CurrentTime => _time;
|
||||
|
||||
/// <summary>Goes to a moment of the recording, in seconds from its start.</summary>
|
||||
public void SeekTo(double t) => SetTime(t);
|
||||
|
||||
/// <summary>Keeps the user's train in the middle of the map.</summary>
|
||||
public bool FollowTrain
|
||||
{
|
||||
get => _follow.IsChecked == true;
|
||||
set => _follow.IsChecked = value;
|
||||
}
|
||||
|
||||
/// <summary>The timetable beside the map instead of the events.</summary>
|
||||
public bool ShowTimetable
|
||||
{
|
||||
get => _tabs.Selected == 1;
|
||||
set => _tabs.Selected = value ? 1 : 0;
|
||||
}
|
||||
|
||||
/// <summary>The summary of the driving beside the map instead of the events.</summary>
|
||||
public bool SummaryShown
|
||||
{
|
||||
get => _tabs.Selected == 2;
|
||||
set => _tabs.Selected = value ? 2 : 0;
|
||||
}
|
||||
|
||||
private bool Playing
|
||||
{
|
||||
get => _tick.IsEnabled;
|
||||
set
|
||||
{
|
||||
if (value == _tick.IsEnabled)
|
||||
return;
|
||||
if (value)
|
||||
{
|
||||
if (Recording is { } r && _time >= r.Duration - 0.05 && !Live)
|
||||
SetTime(0);
|
||||
_played.Restart();
|
||||
_tick.Start();
|
||||
}
|
||||
else
|
||||
_tick.Stop();
|
||||
_play.Content = UiKit.PlayGlyph(value);
|
||||
ToolTip.SetTip(_play, _strings[value ? "Pause" : "Play"]);
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowWhole()
|
||||
{
|
||||
_follow.IsChecked = false;
|
||||
_map.Fit();
|
||||
_timeline.Fit();
|
||||
}
|
||||
|
||||
/// <summary>Opens a black box, read whole on another thread.</summary>
|
||||
public async Task OpenAsync(string path)
|
||||
{
|
||||
Playing = false;
|
||||
ShowMessage(string.Format(CultureInfo.CurrentCulture, _strings["Reading"], Path.GetFileName(path)));
|
||||
try
|
||||
{
|
||||
var reader = await Task.Run(() =>
|
||||
{
|
||||
var r = new BlackBoxReader();
|
||||
r.ReadMore(path);
|
||||
return r;
|
||||
});
|
||||
_reader = reader;
|
||||
_path = path;
|
||||
Title = string.Format(CultureInfo.CurrentCulture, _strings["TitleOf"], Path.GetFileName(path));
|
||||
_map.Recording = reader.Recording;
|
||||
_timeline.Recording = reader.Recording;
|
||||
_events.Recording = reader.Recording;
|
||||
_timetable.Recording = reader.Recording;
|
||||
_cab.Recording = reader.Recording;
|
||||
_message.IsVisible = false;
|
||||
// A file still written opens at its end, where the train is now.
|
||||
SetTime(Live ? reader.Recording.Duration : 0);
|
||||
ShowSummary();
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException)
|
||||
{
|
||||
ShowMessage(string.Format(CultureInfo.CurrentCulture, _strings["Unreadable"], path, ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowMessage(string text)
|
||||
{
|
||||
_message.Text = text;
|
||||
_message.IsVisible = true;
|
||||
_subtitle.Text = text;
|
||||
_fileState.Text = "";
|
||||
}
|
||||
|
||||
/// <summary>What the simulator added since the last read. Small, so read here, between frames.</summary>
|
||||
public void ReadMore()
|
||||
{
|
||||
if (_reader is not { } reader || _path is null || _reading)
|
||||
return;
|
||||
_reading = true;
|
||||
try
|
||||
{
|
||||
bool atEnd = _time >= reader.Recording.Duration - 0.5;
|
||||
if (reader.ReadMore(_path) == 0)
|
||||
return;
|
||||
_events.Refresh();
|
||||
if (atEnd && !Playing)
|
||||
SetTime(reader.Recording.Duration);
|
||||
else
|
||||
SetTime(_time);
|
||||
ShowSummary();
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException)
|
||||
{
|
||||
ShowMessage(string.Format(CultureInfo.CurrentCulture, _strings["Unreadable"], _path, ex.Message));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_reading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PickAsync()
|
||||
{
|
||||
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||
{
|
||||
Title = _strings["Open"],
|
||||
AllowMultiple = false,
|
||||
FileTypeFilter = new[] { new FilePickerFileType(_strings["FileFilter"]) { Patterns = new[] { "*.blackbox" } } }
|
||||
});
|
||||
if (files.Count > 0 && files[0].TryGetLocalPath() is { } path)
|
||||
await OpenAsync(path);
|
||||
}
|
||||
|
||||
private void Advance()
|
||||
{
|
||||
if (Recording is not { } recording)
|
||||
return;
|
||||
double real = _played.Elapsed.TotalSeconds;
|
||||
_played.Restart();
|
||||
double speed = Speeds[Math.Clamp(_speed.SelectedIndex, 0, Speeds.Length - 1)];
|
||||
double next = _time + real * speed;
|
||||
if (next >= recording.Duration)
|
||||
{
|
||||
next = recording.Duration;
|
||||
// At the end of a file still written it waits for more.
|
||||
if (!Live)
|
||||
Playing = false;
|
||||
}
|
||||
SetTime(next);
|
||||
}
|
||||
|
||||
private void SetTime(double t)
|
||||
{
|
||||
var recording = Recording;
|
||||
double duration = recording?.Duration ?? 0;
|
||||
_time = Math.Clamp(t, 0, duration);
|
||||
_map.Time = _time;
|
||||
_timeline.Time = _time;
|
||||
_cab.Time = _time;
|
||||
_timetable.Time = _time;
|
||||
if (recording is null)
|
||||
return;
|
||||
_clock.Text = recording.ClockAt(_time).ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture);
|
||||
_events.ShowTime(_time);
|
||||
ShowReadout(null);
|
||||
ShowDelay(recording);
|
||||
}
|
||||
|
||||
/// <summary>How late the user's train was at the moment played, by the clock.</summary>
|
||||
private void ShowDelay(Recording recording)
|
||||
{
|
||||
if (StationProgress.DelayAt(recording, _time) is not { } delay)
|
||||
{
|
||||
_delayText.Text = "";
|
||||
return;
|
||||
}
|
||||
var culture = CultureInfo.CurrentCulture;
|
||||
var brush = Palette.Delay(delay.Minutes);
|
||||
_delayText.Text = Math.Abs(delay.Minutes) < 0.5
|
||||
? _strings["OnTime"]
|
||||
: string.Format(culture, _strings["DelayNow"], string.Format(culture, _strings["Minutes"], delay.Minutes));
|
||||
_delayText.Foreground = brush;
|
||||
ToolTip.SetTip(_delayText, string.Format(culture, _strings[delay.Standing ? "DelayStanding" : "DelayAfter"], delay.Train,
|
||||
StationNames.Display(delay.Station)));
|
||||
}
|
||||
|
||||
/// <summary>The user's train at the moment under the pointer of the timeline, or at the moment played.</summary>
|
||||
private void ShowReadout(double? hovered)
|
||||
{
|
||||
if (Recording is not { } recording)
|
||||
return;
|
||||
double t = hovered ?? _time;
|
||||
string clock = recording.ClockAt(t).ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture);
|
||||
var culture = CultureInfo.CurrentCulture;
|
||||
if (recording.CabAt(t) is not { } s || t - s.Time > 1.0)
|
||||
{
|
||||
_readout.Text = string.Format(culture, _strings["CursorOut"], clock);
|
||||
return;
|
||||
}
|
||||
if (!s.AiValid)
|
||||
{
|
||||
_readout.Text = string.Format(culture, _strings["CursorNoAi"], clock, s.AbsSpeedKmh);
|
||||
return;
|
||||
}
|
||||
string limit = s.Ai.HasNextLimit
|
||||
? string.Format(culture, _strings["LimitAt"], s.Ai.NextLimitKmh, s.Ai.NextLimitDistance)
|
||||
: _strings["None"];
|
||||
_readout.Text = string.Format(culture, _strings["Cursor"], clock, s.AbsSpeedKmh,
|
||||
s.Ai.TargetKmh.ToString("0", culture) + " km/h", limit);
|
||||
}
|
||||
|
||||
private void ShowSummary()
|
||||
{
|
||||
if (Recording is not { } recording)
|
||||
return;
|
||||
var summary = recording.Summarize();
|
||||
var culture = CultureInfo.CurrentCulture;
|
||||
|
||||
// The scenery, the train and the vehicle the user started in.
|
||||
string scenery = recording.Info?.Scenery ?? Path.GetFileName(_path) ?? "";
|
||||
_title.Text = Path.GetFileNameWithoutExtension(scenery.TrimStart('$'));
|
||||
var timetable = recording.TimetableAt(_time);
|
||||
string vehicle = recording.Cab.Count > 0 ? recording.Vehicle(recording.Cab[^1].Vehicle)?.Name ?? "" : recording.Info?.StartVehicle ?? "";
|
||||
var parts = new[]
|
||||
{
|
||||
timetable is null ? null : $"{timetable.Category} {timetable.Train}".Trim(),
|
||||
timetable is null || timetable.From.Length + timetable.To.Length == 0 ? null : string.Format(culture, _strings["Relation"], timetable.From, timetable.To),
|
||||
vehicle,
|
||||
recording.Info?.Started.Replace('T', ' ')
|
||||
};
|
||||
_subtitle.Text = string.Join(" · ", parts.Where(p => !string.IsNullOrWhiteSpace(p)));
|
||||
|
||||
_distance.Show($"{summary.DistanceKm:0.0} km");
|
||||
_maxSpeed.Show($"{summary.MaxSpeedKmh:0} km/h");
|
||||
_driven.Show(Clock(summary.DrivenSeconds), string.Format(culture, _strings["StatAutopilotSub"], Clock(summary.AutopilotSeconds)));
|
||||
_overAi.Show(Clock(summary.OverAiSeconds),
|
||||
string.Format(culture, _strings["StatOverAiSub"], Clock(summary.WellOverAiSeconds), DrivingSummary.DefaultTolerance, summary.MaxOverAiKmh),
|
||||
summary.WellOverAiSeconds > 0 ? Palette.Bad : summary.OverAiSeconds > 0 ? Palette.Warn : Palette.Good);
|
||||
_signals.Show(summary.SignalsPassed.ToString(culture), string.Format(culture, _strings["StatSignalsSub"], summary.PassedAtDanger.Count),
|
||||
summary.PassedAtDanger.Count > 0 ? Palette.Bad : null);
|
||||
_stops.IsVisible = recording.Timetables.Count > 0;
|
||||
_stops.Show(summary.Stops.ToString(culture), string.Format(culture, _strings["StatStopsSub"], summary.StopsMissed),
|
||||
summary.StopsMissed > 0 ? Palette.Bad : null);
|
||||
_delay.IsVisible = summary.MaxDelayMinutes is not null;
|
||||
if (summary.MaxDelayMinutes is { } delay)
|
||||
_delay.Show(string.Format(culture, _strings["Minutes"], delay),
|
||||
summary.MaxDepartureDelayMinutes is { } departure
|
||||
? string.Format(culture, _strings["StatDelaySub"], string.Format(culture, _strings["Minutes"], departure))
|
||||
: null,
|
||||
Palette.Delay(delay));
|
||||
_platform.IsVisible = summary.Stops > 0;
|
||||
_platform.Show(summary.OffPlatform.Count.ToString(culture),
|
||||
summary.OffPlatform.Count > 0
|
||||
? string.Join(" · ", summary.OffPlatform.Select(p => string.Format(culture, _strings["StatPlatformAt"], p.Station, p.Metres)))
|
||||
: _strings["StatPlatformNone"],
|
||||
summary.OffPlatform.Count > 0 ? Palette.Bad : Palette.Good);
|
||||
_lights.IsVisible = summary.LightsJudgedSeconds > 0;
|
||||
_lights.Show(summary.WrongLightsSeconds > 0 ? Clock(summary.WrongLightsSeconds) : _strings["StatLightsRight"],
|
||||
summary.LightsFaults.Count > 0
|
||||
? string.Join(" · ", summary.LightsFaults.Take(3).Select(f => LightsFaultText(recording, f, culture)))
|
||||
+ (summary.LightsFaults.Count > 3 ? " …" : "")
|
||||
: string.Format(culture, _strings["StatLightsSub"], Clock(summary.LightsJudgedSeconds)),
|
||||
summary.WrongLightsSeconds > 0 ? Palette.Bad : Palette.Good);
|
||||
_horn.IsVisible = summary.HornPoints > 0;
|
||||
_horn.Show($"{summary.HornPoints - summary.HornsMissed}/{summary.HornPoints}", string.Format(culture, _strings["StatHornSub"], summary.HornsMissed),
|
||||
summary.HornsMissed > 0 ? Palette.Bad : Palette.Good);
|
||||
_acceleration.Show($"+{summary.MaxAcceleration:0.00} / −{summary.MaxDeceleration:0.00}",
|
||||
string.Format(culture, _strings["StatAccelerationSub"], summary.MaxLateralAcceleration));
|
||||
_security.Show(summary.SecurityBrakings.ToString(culture), null, summary.SecurityBrakings > 0 ? Palette.Bad : Palette.Good);
|
||||
_state.Show(Clock(recording.Duration), _strings[recording.Complete ? "StatComplete" : "StatIncomplete"],
|
||||
recording.Complete ? null : Palette.Warn);
|
||||
_fileState.Text = $"{Path.GetFileName(_path)} · {Clock(recording.Duration)} · {_strings[recording.Complete ? "StatComplete" : "StatIncomplete"]}";
|
||||
}
|
||||
|
||||
private static string Clock(double seconds)
|
||||
{
|
||||
var span = TimeSpan.FromSeconds(Math.Round(seconds));
|
||||
return span.TotalHours >= 1
|
||||
? span.ToString(@"h\:mm\:ss", CultureInfo.InvariantCulture)
|
||||
: span.ToString(@"m\:ss", CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
567
Eu07.BlackBox.Views/CabPanel.cs
Normal file
567
Eu07.BlackBox.Views/CabPanel.cs
Normal file
@@ -0,0 +1,567 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace Eu07.BlackBox.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The cab of the user's train at the moment played, for checking what the driver did: the
|
||||
/// speed against the AI's, the accelerations, the controllers, the brakes (the pressures as
|
||||
/// gauges, the setting), the traction as fits what drives the vehicle (the voltage and the
|
||||
/// current of an electric one, the oil and the temperatures of a diesel), the lights at the
|
||||
/// head and the tail of the train with what they are to show, and a lamp for every device in
|
||||
/// groups (power, driving, safety, doors), the alerter (CA) and the cab signalling (SHP) apart.
|
||||
/// </summary>
|
||||
public sealed class CabPanel : Control
|
||||
{
|
||||
private const double Pad = 14;
|
||||
private const double LampRow = 14;
|
||||
private const int DoorColumns = 2;
|
||||
private const double DriveRow = 34;
|
||||
|
||||
private Recording? _recording;
|
||||
private double _time;
|
||||
private int _measured = -1;
|
||||
private int _controllerTop = 1;
|
||||
private readonly HashSet<int> _powered = new();
|
||||
private readonly HashSet<int> _feedPipe = new();
|
||||
private readonly HashSet<int> _integrated = new();
|
||||
private readonly HashSet<int> _hydraulic = new();
|
||||
|
||||
public CabPanel()
|
||||
{
|
||||
ClipToBounds = true;
|
||||
Height = 696;
|
||||
}
|
||||
|
||||
public IBlackBoxStrings Strings { get; set; } = new DefaultBlackBoxStrings();
|
||||
|
||||
public Recording? Recording
|
||||
{
|
||||
get => _recording;
|
||||
set
|
||||
{
|
||||
_recording = value;
|
||||
_measured = -1;
|
||||
InvalidateVisual();
|
||||
}
|
||||
}
|
||||
|
||||
public double Time
|
||||
{
|
||||
get => _time;
|
||||
set
|
||||
{
|
||||
_time = value;
|
||||
InvalidateVisual();
|
||||
}
|
||||
}
|
||||
|
||||
public override void Render(DrawingContext context)
|
||||
{
|
||||
var size = Bounds.Size;
|
||||
double width = size.Width - 2 * Pad, right = size.Width - Pad;
|
||||
|
||||
// The last sample, unless the train was out of sight for longer than a moment.
|
||||
if (_recording is not { } recording || recording.CabAt(_time) is not { } s || _time - s.Time > 1.5)
|
||||
{
|
||||
context.DrawText(MapView.Text(Strings["CabNoData"], Palette.DimText, 12), new Point(Pad, 40));
|
||||
return;
|
||||
}
|
||||
Measure(recording);
|
||||
var culture = CultureInfo.CurrentCulture;
|
||||
var extra = s.Extra;
|
||||
|
||||
// The vehicle at the left; who drives, from which cab, at the right.
|
||||
double chipX = right;
|
||||
if (s.CabEnd != 0)
|
||||
chipX -= ChipLeft(context, Strings[s.CabEnd > 0 ? "CabEndFront" : "CabEndRear"], Palette.Text, chipX, 4) + 5;
|
||||
var (driverText, driverBrush) = !s.InCab ? (Strings["CabOutside"], Palette.DimText)
|
||||
: s.Driving == Driver.Ai ? (Strings["CabDriverAi"], Palette.Autopilot)
|
||||
: (Strings["CabDriverUser"], Palette.UserTrain);
|
||||
chipX -= ChipLeft(context, driverText, driverBrush, chipX, 4) + 10;
|
||||
using (context.PushClip(new Rect(Pad, 0, Math.Max(0, chipX - Pad), 28)))
|
||||
context.DrawText(MapView.Text(recording.Vehicle(s.Vehicle)?.Name ?? "", Palette.Text, 12, FontWeight.SemiBold), new Point(Pad, 6));
|
||||
|
||||
// The speed, large, what the AI would do under it, the accelerations at the right.
|
||||
var judgement = DrivingSummary.Judge(s);
|
||||
var speed = MapView.Text(s.AbsSpeedKmh.ToString("0", culture), judgement is SpeedJudgement.Over ? Palette.Bad
|
||||
: judgement is SpeedJudgement.Slightly ? Palette.Warn : Palette.Text, 36, FontWeight.SemiBold);
|
||||
context.DrawText(speed, new Point(Pad, 24));
|
||||
context.DrawText(MapView.Text("km/h", Palette.DimText, 12), new Point(Pad + speed.Width + 4, 24 + speed.Height - 21));
|
||||
double gaugeX = Pad + Math.Max(150, width * 0.42);
|
||||
if (s.AiValid)
|
||||
{
|
||||
using (context.PushClip(new Rect(Pad, 76, gaugeX - Pad - 8, 40)))
|
||||
{
|
||||
context.DrawText(MapView.Text(string.Format(culture, Strings["CabAiTarget"], s.Ai.TargetKmh), Palette.Target, 12), new Point(Pad, 76));
|
||||
if (s.Ai.HasNextLimit)
|
||||
context.DrawText(MapView.Text(string.Format(culture, Strings["LimitAt"], s.Ai.NextLimitKmh, s.Ai.NextLimitDistance), Palette.Limit, 11),
|
||||
new Point(Pad, 93));
|
||||
}
|
||||
}
|
||||
double gaugeWidth = right - gaugeX;
|
||||
Balance(context, Strings["CabAcceleration"], s.Acceleration, 1.5, gaugeX, 34, gaugeWidth);
|
||||
Balance(context, Strings["CabLateral"], s.LateralAcceleration, 1.5, gaugeX, 70, gaugeWidth);
|
||||
|
||||
// The controllers.
|
||||
double top = Section(context, Strings["CabSectionControls"], Pad, 118, right);
|
||||
double column = width / 4;
|
||||
Value(context, Strings["CabController"], s.MainController.ToString(culture), Pad, top);
|
||||
Steps(context, s.MainController, _controllerTop, Pad, top + 34, column - 14);
|
||||
if (extra is { } handle && _integrated.Contains(s.Vehicle))
|
||||
Value(context, Strings["CabIntegrated"], (handle.IntegratedController * 100).ToString("+0;−0;0", culture) + " %", Pad + column, top);
|
||||
else
|
||||
Value(context, Strings["CabShunt"], s.ScndController.ToString(culture), Pad + column, top);
|
||||
Value(context, Strings["CabReverser"], Strings[s.Reverser > 0 ? "CabForward" : s.Reverser < 0 ? "CabBackward" : "CabNeutral"], Pad + 2 * column, top);
|
||||
Value(context, Strings["CabTrainBrake"], s.TrainBrake.ToString("0.0", culture), Pad + 3 * column, top);
|
||||
|
||||
// The brakes, as gauges, the setting (G, P, R) at the right of the title.
|
||||
string? setting = extra is { } e && (e.BrakeDelay | e.BrakeMode) != 0
|
||||
? string.Format(culture, Strings["CabBrakeSetting"], string.Join(" · ", new[] { BrakeSetting(e.BrakeDelay), BrakeMode(e.BrakeMode) }.Where(t => t.Length > 0)))
|
||||
: null;
|
||||
top = Section(context, Strings["CabSectionBrakes"], Pad, 180, right, setting);
|
||||
const double gauge = 17;
|
||||
Gauge(context, Strings["CabPipe"], s.BrakePipe, 6, 5, $"{s.BrakePipe:0.00} bar", Palette.Station, Pad, top, width);
|
||||
double y = top + gauge;
|
||||
if (extra is { } x1)
|
||||
{
|
||||
// what the driver's brake valve shows: its equalizing reservoir, the FV4a's time reservoir
|
||||
Gauge(context, Strings["CabDriverValve"], x1.DriverValve, 6, null, $"{x1.DriverValve:0.00} bar", Palette.Target, Pad, y, width);
|
||||
y += gauge;
|
||||
}
|
||||
Gauge(context, Strings["CabCylinder"], s.BrakeCylinder, 6, null, $"{s.BrakeCylinder:0.0} bar", Palette.Brakes, Pad, y, width);
|
||||
y += gauge;
|
||||
if (extra is { } x3)
|
||||
{
|
||||
// the most braked vehicle of the train
|
||||
Gauge(context, Strings["CabConsistCylinder"], x3.ConsistBrakeCylinder, 6, null, $"{x3.ConsistBrakeCylinder:0.0} bar", Palette.Brakes, Pad, y, width);
|
||||
y += gauge;
|
||||
}
|
||||
Gauge(context, Strings["CabReservoir"], s.MainReservoir, 10, null, $"{s.MainReservoir:0.0} bar", Palette.Controller, Pad, y, width);
|
||||
y += gauge;
|
||||
if (extra is { } x2)
|
||||
{
|
||||
bool has = _feedPipe.Contains(s.Vehicle);
|
||||
Gauge(context, Strings["CabFeedPipe"], has ? x2.FeedPipe : 0, 10, null, has ? $"{x2.FeedPipe:0.0} bar" : "—", Palette.Controller, Pad, y, width);
|
||||
y += gauge;
|
||||
}
|
||||
Gauge(context, Strings["CabLocalBrake"], s.LocalBrake, 1, null, $"{s.LocalBrake * 100:0} %", Palette.Brakes, Pad, y, width);
|
||||
|
||||
// The traction at the left, as fits what drives the vehicle; the lights of both ends of the train at the right.
|
||||
double half = Pad + width / 2;
|
||||
double driveTop = y + gauge + 4;
|
||||
top = Section(context, Strings["CabSectionDrive"], Pad, driveTop, half - 10);
|
||||
Section(context, Strings["CabLights"], half + 4, driveTop, right);
|
||||
DrawDrive(context, s, top, half - 10 - Pad, culture);
|
||||
double row = top;
|
||||
foreach (var (key, lights, equipped, required, isRight) in new[]
|
||||
{
|
||||
("CabLightsHead", s.LightsHead, s.EquippedHead, HeadCodes.RequiredHead(s), HeadCodes.HeadRight(s)),
|
||||
("CabLightsTail", s.LightsTail, s.EquippedTail, HeadCodes.RequiredTail(s), HeadCodes.TailRight(s))
|
||||
})
|
||||
{
|
||||
DrawFace(context, lights, new Point(half + 4 + 17, row + 15));
|
||||
// the end, and under it the signal it shows: green as it is to be (with the lamps the vehicle has),
|
||||
// red otherwise with the one it is to show
|
||||
var shown = HeadCodes.Of(lights);
|
||||
var text = HeadCodes.NoLamps(equipped) ? Strings["CabLightsNone"]
|
||||
: isRight == false && required is { } want
|
||||
? string.Format(culture, Strings["CabLightsWant"], HeadCodes.Name(shown), HeadCodes.Name(want))
|
||||
: isRight == true && required is { } met ? HeadCodes.Name(met) : HeadCodes.Name(shown);
|
||||
var brush = isRight is null ? Palette.DimText : isRight.Value ? Palette.Good : Palette.Bad;
|
||||
using (context.PushClip(new Rect(half + 44, row, right - half - 44, 28)))
|
||||
{
|
||||
context.DrawText(MapView.Text(Strings[key], Palette.DimText, 10), new Point(half + 46, row));
|
||||
context.DrawText(MapView.Text(text, brush, 12, FontWeight.SemiBold), new Point(half + 46, row + 11));
|
||||
}
|
||||
row += 27;
|
||||
}
|
||||
|
||||
// The devices, in three groups side by side: lit as their lamps are in the cab, the ones off faint.
|
||||
top = Section(context, Strings["CabSectionDevices"], Pad, top + 3 * DriveRow + 6, right);
|
||||
var groups = Devices(s, culture);
|
||||
var widths = new double[groups.Length];
|
||||
for (int g = 0; g < groups.Length; g++)
|
||||
{
|
||||
widths[g] = MapView.Text(Strings[groups[g].Title], Palette.DimText, 10, FontWeight.SemiBold).Width;
|
||||
foreach (var (text, _) in groups[g].Lamps)
|
||||
widths[g] = Math.Max(widths[g], 14 + MapView.Text(text, Palette.Text, 10.5).Width);
|
||||
}
|
||||
// each group as wide as its longest name, what is left shared between them
|
||||
double spare = (width - widths.Sum()) / groups.Length;
|
||||
double x = Pad, bottom = top;
|
||||
for (int g = 0; g < groups.Length; g++)
|
||||
{
|
||||
double groupWidth = Math.Max(20, widths[g] + spare);
|
||||
using (context.PushClip(new Rect(x, top, groupWidth, size.Height - top)))
|
||||
{
|
||||
context.DrawText(MapView.Text(Strings[groups[g].Title], Palette.DimText, 10, FontWeight.SemiBold), new Point(x, top));
|
||||
double lampY = top + 16;
|
||||
foreach (var (text, brush) in groups[g].Lamps)
|
||||
{
|
||||
Lamp(context, text, brush, x, lampY);
|
||||
lampY += LampRow;
|
||||
}
|
||||
bottom = Math.Max(bottom, lampY);
|
||||
}
|
||||
x += groupWidth;
|
||||
}
|
||||
|
||||
// The doors under them, in two columns: open, permitted, the departure signal, the steps and the lock.
|
||||
top = Math.Max(bottom, top + 16 + 9 * LampRow) + 6;
|
||||
context.DrawText(MapView.Text(Strings["CabGroupDoors"], Palette.DimText, 10, FontWeight.SemiBold), new Point(Pad, top));
|
||||
var doors = Doors(s);
|
||||
double doorColumn = width / DoorColumns;
|
||||
for (int i = 0; i < doors.Length; i++)
|
||||
{
|
||||
double doorX = Pad + i % DoorColumns * doorColumn;
|
||||
using (context.PushClip(new Rect(doorX, top, doorColumn, size.Height - top)))
|
||||
Lamp(context, doors[i].Text, doors[i].Lit, doorX, top + 16 + i / DoorColumns * LampRow);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The traction, in two columns, as fits what drives the vehicle: the voltage, the current, the revolutions,
|
||||
/// the power and the cruise control of an electric one; the revolutions, the current (or the clutch, or the
|
||||
/// torque converter), the oil pressure and the temperatures of a diesel. A recording made before the engine
|
||||
/// was written: those of an electric one it had then, no voltage for a vehicle which never had any.
|
||||
/// </summary>
|
||||
private void DrawDrive(DrawingContext context, CabSample s, double top, double width, CultureInfo culture)
|
||||
{
|
||||
var cells = new List<(string Label, string Value, IBrush? Brush)>();
|
||||
void Add(string key, string value, IBrush? brush = null) => cells.Add((Strings[key], value, brush));
|
||||
|
||||
string voltage = _powered.Contains(s.Vehicle) ? $"{s.Voltage:0} V" : "—";
|
||||
switch (s.Extra?.Engine)
|
||||
{
|
||||
case null:
|
||||
Add("CabVoltage", voltage);
|
||||
Add("CabCurrent", $"{s.MotorCurrent:0} A");
|
||||
Add("CabRpm", $"{s.EngineRpm:0}");
|
||||
break;
|
||||
case EngineKind.Electric:
|
||||
case EngineKind.ElectricInduction:
|
||||
{
|
||||
var e = s.Extra.Value;
|
||||
Add("CabVoltage", voltage);
|
||||
Add("CabCurrent", $"{s.MotorCurrent:0} A");
|
||||
Add("CabRpm", $"{s.EngineRpm:0}");
|
||||
Add("CabPower", $"{e.EnginePower:0} kW");
|
||||
Add("CabSpeedControl", s.Has(CabDevices.SpeedControl) ? $"{e.SpeedControlKmh:0} km/h" : "—");
|
||||
break;
|
||||
}
|
||||
case EngineKind.DieselElectric:
|
||||
case EngineKind.Diesel:
|
||||
{
|
||||
var e = s.Extra.Value;
|
||||
Add("CabRpm", $"{s.EngineRpm:0}");
|
||||
if (e.Engine == EngineKind.DieselElectric)
|
||||
Add("CabCurrent", $"{s.MotorCurrent:0} A");
|
||||
else if (_hydraulic.Contains(s.Vehicle))
|
||||
Add("CabConverterFill", $"{e.ConverterFill * 100:0} %");
|
||||
else
|
||||
Add("CabClutch", $"{e.Clutch * 100:0} %");
|
||||
Add("CabOilPressure", string.Format(culture, "{0:0.00} MPa", e.OilPressure));
|
||||
// the temperatures, warm when the engine is too hot
|
||||
var heat = s.Has(CabDevices.EngineHot) ? Palette.Bad : null;
|
||||
Add("CabWater", $"{e.WaterTemperature:0} °C", heat);
|
||||
Add("CabOil", $"{e.OilTemperature:0} °C", heat);
|
||||
Add("CabEngine", $"{e.EngineTemperature:0} °C", heat);
|
||||
break;
|
||||
}
|
||||
case EngineKind.Steam:
|
||||
Add("CabRpm", $"{s.EngineRpm:0}");
|
||||
break;
|
||||
default:
|
||||
context.DrawText(MapView.Text(Strings["CabNoDrive"], Palette.DimText, 11), new Point(Pad, top + 2));
|
||||
return;
|
||||
}
|
||||
double column = width / 2;
|
||||
for (int i = 0; i < cells.Count; i++)
|
||||
{
|
||||
double x = Pad + i % 2 * column, y = top + i / 2 * DriveRow;
|
||||
using (context.PushClip(new Rect(x, y, column - 4, DriveRow)))
|
||||
Value(context, cells[i].Label, cells[i].Value, x, y, cells[i].Brush);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The setting of the brakes from its bits: G, P, R, R+Mg.</summary>
|
||||
public static string BrakeSetting(byte bits)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if ((bits & 1) != 0)
|
||||
parts.Add("G");
|
||||
if ((bits & 2) != 0)
|
||||
parts.Add("P");
|
||||
if ((bits & 4) != 0)
|
||||
parts.Add("R");
|
||||
if ((bits & 8) != 0)
|
||||
parts.Add("Mg");
|
||||
return string.Join("+", parts);
|
||||
}
|
||||
|
||||
/// <summary>The mode of the brakes from its bits: PS, PN, EP, MED.</summary>
|
||||
public static string BrakeMode(byte bits)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if ((bits & 1) != 0)
|
||||
parts.Add("PS");
|
||||
if ((bits & 2) != 0)
|
||||
parts.Add("PN");
|
||||
if ((bits & 4) != 0)
|
||||
parts.Add("EP");
|
||||
if ((bits & 8) != 0)
|
||||
parts.Add("MED");
|
||||
return string.Join("+", parts);
|
||||
}
|
||||
|
||||
/// <summary>The title of a part of the panel, with a line from it to the right (and a note at its end); the top of the part returned.</summary>
|
||||
private static double Section(DrawingContext context, string title, double x, double y, double right, string? note = null)
|
||||
{
|
||||
var text = MapView.Text(title.ToUpper(CultureInfo.CurrentCulture), Palette.DimText, 9, FontWeight.SemiBold);
|
||||
context.DrawText(text, new Point(x, y));
|
||||
double end = right;
|
||||
if (note is not null)
|
||||
{
|
||||
var noted = MapView.Text(note, Palette.Text, 10, FontWeight.SemiBold);
|
||||
end = right - noted.Width - 8;
|
||||
context.DrawText(noted, new Point(right - noted.Width, y + (text.Height - noted.Height) / 2));
|
||||
}
|
||||
double line = Math.Round(y + text.Height / 2) + 0.5;
|
||||
if (x + text.Width + 8 < end)
|
||||
context.DrawLine(new Pen(Palette.Grid, 1), new Point(x + text.Width + 8, line), new Point(end, line));
|
||||
return y + 17;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What is the same all the recording long for each vehicle: the highest step of the controller used, for
|
||||
/// its bar, and which vehicles ever had a voltage, a main reservoir pipe, an integrated controller moved off
|
||||
/// nought and a torque converter filled, to show the field shunting, the clutch or none for the others.
|
||||
/// </summary>
|
||||
private void Measure(Recording recording)
|
||||
{
|
||||
if (_measured == recording.Revision)
|
||||
return;
|
||||
int top = 1;
|
||||
_powered.Clear();
|
||||
_feedPipe.Clear();
|
||||
_integrated.Clear();
|
||||
_hydraulic.Clear();
|
||||
foreach (var sample in recording.Cab)
|
||||
{
|
||||
top = Math.Max(top, sample.MainController);
|
||||
if (sample.Voltage > 1)
|
||||
_powered.Add(sample.Vehicle);
|
||||
if (sample.Extra is not { } e)
|
||||
continue;
|
||||
if (e.FeedPipe > 0.1f)
|
||||
_feedPipe.Add(sample.Vehicle);
|
||||
if (Math.Abs(e.IntegratedController) > 0.01f)
|
||||
_integrated.Add(sample.Vehicle);
|
||||
if (e.ConverterFill > 0.01f)
|
||||
_hydraulic.Add(sample.Vehicle);
|
||||
}
|
||||
_controllerTop = top;
|
||||
_measured = recording.Revision;
|
||||
}
|
||||
|
||||
private static void Value(DrawingContext context, string label, string value, double x, double y, IBrush? brush = null)
|
||||
{
|
||||
context.DrawText(MapView.Text(label, Palette.DimText, 10), new Point(x, y));
|
||||
context.DrawText(MapView.Text(value, brush ?? Palette.Text, 14, FontWeight.SemiBold), new Point(x, y + 13));
|
||||
}
|
||||
|
||||
/// <summary>The steps of the controller as a row of cells, lit up to the one it stands at.</summary>
|
||||
private static void Steps(DrawingContext context, int step, int top, double x, double y, double width)
|
||||
{
|
||||
int cells = Math.Clamp(top, 1, 40);
|
||||
double cell = width / cells;
|
||||
for (int i = 0; i < cells; i++)
|
||||
context.DrawRectangle(i < step ? Palette.Controller : Palette.LampOff, null,
|
||||
new Rect(x + i * cell, y, Math.Max(1, cell - (cells > 20 ? 0.5 : 1.5)), 4), 1, 1);
|
||||
}
|
||||
|
||||
/// <summary>A gauge: its name, a bar up to the value against the scale, a mark where it should stand, the value.</summary>
|
||||
private static void Gauge(DrawingContext context, string label, float value, double scale, double? mark, string text,
|
||||
IBrush brush, double x, double y, double width)
|
||||
{
|
||||
const double labelWidth = 118, valueWidth = 64;
|
||||
context.DrawText(MapView.Text(label, Palette.DimText, 11), new Point(x, y));
|
||||
double barX = x + labelWidth, barWidth = width - labelWidth - valueWidth;
|
||||
var track = new Rect(barX, y + 5, barWidth, 6);
|
||||
context.DrawRectangle(Palette.LampOff, null, track, 3, 3);
|
||||
double filled = Math.Clamp(value / scale, 0, 1) * barWidth;
|
||||
if (filled > 0.5)
|
||||
context.DrawRectangle(brush, null, new Rect(barX, y + 5, filled, 6), 3, 3);
|
||||
if (mark is { } m)
|
||||
{
|
||||
double mx = barX + m / scale * barWidth;
|
||||
context.DrawLine(new Pen(Palette.Text, 1.5), new Point(mx, y + 2), new Point(mx, y + 14));
|
||||
}
|
||||
var v = MapView.Text(text, Palette.Text, 11, FontWeight.SemiBold);
|
||||
context.DrawText(v, new Point(x + width - v.Width, y));
|
||||
}
|
||||
|
||||
/// <summary>A value either way of nought: a bar from the middle, to the right above it.</summary>
|
||||
private static void Balance(DrawingContext context, string label, float value, double scale, double x, double y, double width)
|
||||
{
|
||||
var culture = CultureInfo.CurrentCulture;
|
||||
context.DrawText(MapView.Text(label, Palette.DimText, 10), new Point(x, y));
|
||||
var v = MapView.Text($"{value.ToString("+0.00;−0.00;0.00", culture)} m/s²", Palette.Text, 11, FontWeight.SemiBold);
|
||||
context.DrawText(v, new Point(x + width - v.Width, y));
|
||||
double barY = y + 17, middle = x + width / 2;
|
||||
context.DrawRectangle(Palette.LampOff, null, new Rect(x, barY, width, 5), 2.5, 2.5);
|
||||
double length = Math.Clamp(value / scale, -1, 1) * width / 2;
|
||||
var brush = Math.Abs(value) > scale * 0.7 ? Palette.Bad : value >= 0 ? Palette.Good : Palette.Brakes;
|
||||
if (Math.Abs(length) > 0.5)
|
||||
context.DrawRectangle(brush, null, new Rect(Math.Min(middle, middle + length), barY, Math.Abs(length), 5), 2.5, 2.5);
|
||||
context.DrawLine(new Pen(Palette.DimText, 1), new Point(middle, barY - 2), new Point(middle, barY + 7));
|
||||
}
|
||||
|
||||
/// <summary>A chip ending at x, its width returned.</summary>
|
||||
private static double ChipLeft(DrawingContext context, string text, IBrush brush, double right, double y)
|
||||
{
|
||||
var label = MapView.Text(text, brush, 11, FontWeight.SemiBold);
|
||||
var box = new Rect(right - label.Width - 12, y, label.Width + 12, label.Height + 4);
|
||||
context.DrawRectangle(Palette.Faded(brush, 0.14), null, box, 4, 4);
|
||||
context.DrawText(label, new Point(box.X + 6, y + 2));
|
||||
return box.Width;
|
||||
}
|
||||
|
||||
/// <summary>The lights of an end as they sit on the front of a locomotive: one up in the middle, two below.</summary>
|
||||
private static void DrawFace(DrawingContext context, Lights lights, Point center)
|
||||
{
|
||||
context.DrawRectangle(null, new Pen(Palette.Grid, 1), new Rect(center.X - 17, center.Y - 15, 34, 22), 5, 5);
|
||||
bool rearEnd = lights.HasFlag(Lights.RearEndSignals);
|
||||
Light(context, center + new Vector(0, -8), lights.HasFlag(Lights.HeadlightUpper), false, false);
|
||||
Light(context, center + new Vector(-10, 1), lights.HasFlag(Lights.HeadlightLeft) || lights.HasFlag(Lights.AuxiliaryLeft),
|
||||
lights.HasFlag(Lights.RedMarkerLeft) || rearEnd, lights.HasFlag(Lights.HighBeamLeft));
|
||||
Light(context, center + new Vector(10, 1), lights.HasFlag(Lights.HeadlightRight) || lights.HasFlag(Lights.AuxiliaryRight),
|
||||
lights.HasFlag(Lights.RedMarkerRight) || rearEnd, lights.HasFlag(Lights.HighBeamRight));
|
||||
}
|
||||
|
||||
private static void Light(DrawingContext context, Point center, bool white, bool red, bool high)
|
||||
{
|
||||
var fill = white ? Palette.LightWhite : red ? Palette.LightRed : Palette.LampOff;
|
||||
double radius = high ? 4.5 : 3.5;
|
||||
if (white || red)
|
||||
context.DrawEllipse(Palette.Faded(fill, 0.25), null, center, radius + 3, radius + 3);
|
||||
context.DrawEllipse(fill, null, center, radius, radius);
|
||||
}
|
||||
|
||||
/// <summary>A device's lamp and its name: lit in its colour, the name bright; off, both faint.</summary>
|
||||
private static void Lamp(DrawingContext context, string text, IBrush? lit, double x, double y)
|
||||
{
|
||||
var center = new Point(x + 4, y + 7);
|
||||
if (lit is not null)
|
||||
context.DrawEllipse(Palette.Faded(lit, 0.3), null, center, 6, 6);
|
||||
context.DrawEllipse(lit ?? Palette.LampOff, null, center, 3.5, 3.5);
|
||||
context.DrawText(MapView.Text(text, lit is null ? Palette.FaintText : Palette.Text, 10.5), new Point(x + 14, y));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every device but the doors, with the colour of its lamp if it was on, in the groups the panel shows. A
|
||||
/// diesel has its pumps where an electric vehicle has its pantographs; a recording made before the engine
|
||||
/// was written has the devices it had then.
|
||||
/// </summary>
|
||||
private (string Title, (string Text, IBrush? Lit)[] Lamps)[] Devices(CabSample s, CultureInfo culture)
|
||||
{
|
||||
(string, IBrush?) On(string key, CabDevices device, IBrush colour) => (Strings[key], s.Has(device) ? colour : null);
|
||||
(string, IBrush?) Flag(string key, CabFlags flag, IBrush colour) => (Strings[key], (s.Flags & flag) != 0 ? colour : null);
|
||||
IBrush? Security(SecurityFlags flag, IBrush colour) => s.Has(flag) ? colour : null;
|
||||
var extra = s.Extra;
|
||||
bool diesel = extra is { Engine: EngineKind.Diesel or EngineKind.DieselElectric };
|
||||
(string, IBrush?) Horn()
|
||||
{
|
||||
if (!s.Has(CabDevices.Horn))
|
||||
return (Strings["DevHorn"], null);
|
||||
string? tone = extra?.Horn switch
|
||||
{
|
||||
1 => Strings["HornLow"],
|
||||
2 => Strings["HornHigh"],
|
||||
3 => Strings["HornBoth"],
|
||||
_ => null
|
||||
};
|
||||
return (tone is null ? Strings["DevHorn"] : string.Format(culture, Strings["DevHornTone"], tone), Palette.LampWarning);
|
||||
}
|
||||
bool electric = extra is null or { Engine: EngineKind.Electric or EngineKind.ElectricInduction };
|
||||
|
||||
var power = new List<(string, IBrush?)> { On("DevBattery", CabDevices.Battery, Palette.LampOn), On("DevMainBreaker", CabDevices.MainBreaker, Palette.LampOn) };
|
||||
if (electric)
|
||||
{
|
||||
power.Add(On("DevPantographFront", CabDevices.PantographFront, Palette.LampOn));
|
||||
power.Add(On("DevPantographRear", CabDevices.PantographRear, Palette.LampOn));
|
||||
}
|
||||
if (diesel)
|
||||
{
|
||||
power.Add(On("DevFuelPump", CabDevices.FuelPump, Palette.LampOn));
|
||||
power.Add(On("DevOilPump", CabDevices.OilPump, Palette.LampOn));
|
||||
}
|
||||
power.Add(On("DevConverter", CabDevices.Converter, Palette.LampOn));
|
||||
power.Add(On("DevCompressor", CabDevices.Compressor, Palette.LampOn));
|
||||
power.Add(On("DevLineContactors", CabDevices.LineContactors, Palette.LampOn));
|
||||
if (extra is not null)
|
||||
power.Add(On("DevMotorBlowers", CabDevices.MotorBlowers, Palette.LampOn));
|
||||
power.Add(On("DevHeating", CabDevices.Heating, Palette.LampOn));
|
||||
|
||||
// the radio with its channel
|
||||
var radio = extra is { RadioChannel: > 0 } r
|
||||
? (string.Format(culture, Strings["DevRadioChannel"], r.RadioChannel), s.Has(CabDevices.Radio) ? Palette.LampOn : null)
|
||||
: On("DevRadio", CabDevices.Radio, Palette.LampOn);
|
||||
var driving = new List<(string, IBrush?)>
|
||||
{
|
||||
On("DevShuntMode", CabDevices.ShuntMode, Palette.LampOn), radio,
|
||||
On("DevSanding", CabDevices.Sanding, Palette.LampWarning), Horn(),
|
||||
On("DevSpringBrake", CabDevices.SpringBrake, Palette.LampWarning)
|
||||
};
|
||||
if (extra is not null)
|
||||
{
|
||||
driving.Add((Strings["DevHandBrake"], extra.Value.HandBrake > 0 ? Palette.LampWarning : null));
|
||||
driving.Add(On("DevBrakeReleaser", CabDevices.BrakeReleaser, Palette.LampWarning));
|
||||
driving.Add(On("DevSpeedControl", CabDevices.SpeedControl, Palette.LampOn));
|
||||
}
|
||||
|
||||
var safety = new List<(string, IBrush?)>();
|
||||
if (s.Security != SecurityFlags.None || s.CabEnd != 0)
|
||||
{
|
||||
// A recording which tells them apart.
|
||||
safety.Add((Strings["DevVigilance"], Security(SecurityFlags.VigilanceBeeping, Palette.LampAlarm) ?? Security(SecurityFlags.Vigilance, Palette.Vigilance)));
|
||||
safety.Add((Strings["DevCabSignal"], Security(SecurityFlags.CabSignalBeeping, Palette.LampAlarm) ?? Security(SecurityFlags.CabSignal, Palette.CabSignal)));
|
||||
safety.Add((Strings["DevSecurityBraking"], Security(SecurityFlags.Braking, Palette.LampAlarm)));
|
||||
}
|
||||
else
|
||||
safety.Add((Strings["DevAlerter"], (s.Flags & CabFlags.AlerterBeeping) != 0 ? Palette.LampAlarm
|
||||
: (s.Flags & CabFlags.AlerterBlinking) != 0 ? Palette.LampWarning : null));
|
||||
safety.Add(Flag("DevWheelSlip", CabFlags.WheelSlip, Palette.LampAlarm));
|
||||
safety.Add(On("DevOverloadRelay", CabDevices.OverloadRelay, Palette.LampAlarm));
|
||||
safety.Add(Flag("DevRadioStop", CabFlags.RadioStop, Palette.LampAlarm));
|
||||
safety.Add(Flag("DevAlarmChain", CabFlags.AlarmChain, Palette.LampAlarm));
|
||||
if (diesel)
|
||||
safety.Add(On("DevEngineHot", CabDevices.EngineHot, Palette.LampAlarm));
|
||||
return [("CabGroupPower", power.ToArray()), ("CabGroupDriving", driving.ToArray()), ("CabGroupSafety", safety.ToArray())];
|
||||
}
|
||||
|
||||
/// <summary>The doors of the vehicle the user is in, in rows of two: open, the lock, the permits, the steps, the departure signal.</summary>
|
||||
private (string Text, IBrush? Lit)[] Doors(CabSample s)
|
||||
{
|
||||
(string, IBrush?) On(string key, CabDevices device, IBrush colour) => (Strings[key], s.Has(device) ? colour : null);
|
||||
var doors = new List<(string, IBrush?)>
|
||||
{
|
||||
On("DevDoorsLeft", CabDevices.DoorsLeft, Palette.LampWarning), On("DevDoorsRight", CabDevices.DoorsRight, Palette.LampWarning)
|
||||
};
|
||||
if (s.Extra is null)
|
||||
return doors.ToArray();
|
||||
doors.Add(On("DevDoorLock", CabDevices.DoorLock, Palette.LampOn));
|
||||
doors.Add(On("DevDoorPermitLeft", CabDevices.DoorPermitLeft, Palette.LampOn));
|
||||
doors.Add(On("DevDoorPermitRight", CabDevices.DoorPermitRight, Palette.LampOn));
|
||||
doors.Add(On("DevDoorsLocked", CabDevices.DoorsLocked, Palette.LampOn));
|
||||
doors.Add(On("DevDoorStep", CabDevices.DoorStep, Palette.LampOn));
|
||||
doors.Add(On("DevDepartureSignal", CabDevices.DepartureSignal, Palette.LampWarning));
|
||||
return doors.ToArray();
|
||||
}
|
||||
}
|
||||
20
Eu07.BlackBox.Views/Eu07.BlackBox.Views.csproj
Normal file
20
Eu07.BlackBox.Views/Eu07.BlackBox.Views.csproj
Normal file
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- The window that replays a black box: the map, the timeline, the events. Code
|
||||
only, no XAML loaded at run time, so it goes through NativeAOT and trimming
|
||||
in the starter and in the viewer alike. -->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
<IsTrimmable>true</IsTrimmable>
|
||||
<RootNamespace>Eu07.BlackBox.Views</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Eu07.BlackBox\Eu07.BlackBox.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
382
Eu07.BlackBox.Views/EventList.cs
Normal file
382
Eu07.BlackBox.Views/EventList.cs
Normal file
@@ -0,0 +1,382 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace Eu07.BlackBox.Views;
|
||||
|
||||
/// <summary>One line of the list of events.</summary>
|
||||
public sealed record BlackBoxEvent(double Time, string Text, IBrush Brush, bool Important);
|
||||
|
||||
/// <summary>
|
||||
/// What happened to the user's train, by the clock: the commands, the autopilot, the
|
||||
/// signals passed, the stations, the horn points, the couplings, the train protection,
|
||||
/// the cab and the camera of the user. A click goes to its moment.
|
||||
/// </summary>
|
||||
public sealed class EventList : ListBox
|
||||
{
|
||||
private Recording? _recording;
|
||||
private int _shownRevision = -1;
|
||||
private List<BlackBoxEvent> _events = new();
|
||||
private bool _picking;
|
||||
|
||||
public event Action<double>? Seek;
|
||||
|
||||
// The look of a list box: a control of a type of its own has no theme otherwise, and shows nothing.
|
||||
protected override Type StyleKeyOverride => typeof(ListBox);
|
||||
|
||||
public IBlackBoxStrings Strings { get; set; } = new DefaultBlackBoxStrings();
|
||||
|
||||
public EventList()
|
||||
{
|
||||
SelectionMode = SelectionMode.Single;
|
||||
Padding = new Thickness(4, 0, 4, 6);
|
||||
// The picked line in the starter's green.
|
||||
Resources["ListBoxItemBackgroundSelected"] = Palette.AccentFill;
|
||||
Resources["ListBoxItemBackgroundSelectedPointerOver"] = Palette.AccentFillHover;
|
||||
Resources["ListBoxItemBackgroundSelectedPressed"] = Palette.AccentFill;
|
||||
Resources["ListBoxItemBackgroundPointerOver"] = Palette.CardHover;
|
||||
SelectionChanged += (_, _) =>
|
||||
{
|
||||
if (!_picking && SelectedIndex >= 0 && SelectedIndex < _events.Count)
|
||||
Seek?.Invoke(_events[SelectedIndex].Time);
|
||||
};
|
||||
}
|
||||
|
||||
public Recording? Recording
|
||||
{
|
||||
get => _recording;
|
||||
set
|
||||
{
|
||||
_recording = value;
|
||||
_shownRevision = -1;
|
||||
Refresh();
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<BlackBoxEvent> Events => _events;
|
||||
|
||||
/// <summary>Takes in what the recording gained; the list grows at its end, so what is scrolled to stays.</summary>
|
||||
public void Refresh()
|
||||
{
|
||||
if (_recording is not { } recording)
|
||||
{
|
||||
_events.Clear();
|
||||
Items.Clear();
|
||||
return;
|
||||
}
|
||||
if (recording.Revision == _shownRevision)
|
||||
return;
|
||||
_shownRevision = recording.Revision;
|
||||
|
||||
var events = Collect(recording, Strings);
|
||||
bool grown = events.Count >= _events.Count &&
|
||||
_events.Select((e, i) => e == events[i]).All(same => same);
|
||||
if (!grown)
|
||||
Items.Clear();
|
||||
for (int i = grown ? _events.Count : 0; i < events.Count; i++)
|
||||
Items.Add(new ListBoxItem
|
||||
{
|
||||
Content = Line(recording, events[i]),
|
||||
Padding = new Thickness(8, 4),
|
||||
MinHeight = 0,
|
||||
CornerRadius = new CornerRadius(6),
|
||||
Margin = new Thickness(0, 1)
|
||||
});
|
||||
_events = events;
|
||||
}
|
||||
|
||||
/// <summary>The event at or just before t picked, without going to it.</summary>
|
||||
public void ShowTime(double t)
|
||||
{
|
||||
int index = -1;
|
||||
for (int i = 0; i < _events.Count && _events[i].Time <= t + 0.05; i++)
|
||||
index = i;
|
||||
if (index == SelectedIndex)
|
||||
return;
|
||||
_picking = true;
|
||||
try
|
||||
{
|
||||
SelectedIndex = index;
|
||||
if (index >= 0)
|
||||
ScrollIntoView(index);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_picking = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static Control Line(Recording recording, BlackBoxEvent e)
|
||||
{
|
||||
var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("58,14,*") };
|
||||
var time = new TextBlock
|
||||
{
|
||||
Text = recording.ClockAt(e.Time).ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture),
|
||||
Foreground = Palette.FaintText,
|
||||
FontSize = 11.5,
|
||||
VerticalAlignment = VerticalAlignment.Top,
|
||||
Margin = new Thickness(0, 1, 0, 0)
|
||||
};
|
||||
var dot = new Border
|
||||
{
|
||||
Width = e.Important ? 8 : 6,
|
||||
Height = e.Important ? 8 : 6,
|
||||
CornerRadius = new CornerRadius(4),
|
||||
Background = e.Brush,
|
||||
VerticalAlignment = VerticalAlignment.Top,
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
Margin = new Thickness(0, e.Important ? 5 : 6, 0, 0)
|
||||
};
|
||||
var text = new TextBlock
|
||||
{
|
||||
Text = e.Text,
|
||||
Foreground = e.Important ? Palette.Text : Palette.DimText,
|
||||
FontSize = 12.5,
|
||||
FontWeight = e.Important ? FontWeight.SemiBold : FontWeight.Normal,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
Grid.SetColumn(dot, 1);
|
||||
Grid.SetColumn(text, 2);
|
||||
grid.Children.Add(time);
|
||||
grid.Children.Add(dot);
|
||||
grid.Children.Add(text);
|
||||
return grid;
|
||||
}
|
||||
|
||||
/// <summary>The events of the user's train, in the order they happened.</summary>
|
||||
public static List<BlackBoxEvent> Collect(Recording recording, IBlackBoxStrings strings)
|
||||
{
|
||||
var culture = CultureInfo.CurrentCulture;
|
||||
var events = new List<BlackBoxEvent>();
|
||||
foreach (var c in recording.UserCommands())
|
||||
events.Add(new BlackBoxEvent(c.Time, string.Format(culture, strings["EventCommand"], c.Name), Palette.Command, false));
|
||||
foreach (var d in recording.DriverChanges)
|
||||
{
|
||||
if (recording.CabAt(d.Time + 0.5) is not { } s || s.Vehicle != d.Vehicle)
|
||||
continue;
|
||||
events.Add(new BlackBoxEvent(d.Time, strings[d.Driving == Driver.Ai ? "EventAutopilotOn" : "EventAutopilotOff"],
|
||||
Palette.Autopilot, true));
|
||||
}
|
||||
foreach (var p in recording.UserPassings())
|
||||
{
|
||||
string name = recording.Signal(p.Signal)?.Name ?? p.Event;
|
||||
events.Add(p.AtDanger
|
||||
? new BlackBoxEvent(p.Time, string.Format(culture, strings["EventDanger"], name, Math.Abs(p.SpeedKmh), p.Overrun), Palette.Over, true)
|
||||
: new BlackBoxEvent(p.Time, string.Format(culture, strings["EventPassed"], name, MapView.AspectText(strings, p.Speed1, p.Speed2),
|
||||
Math.Abs(p.SpeedKmh)), Palette.Of(new AspectChange(p.Time, p.Signal, p.Speed1, p.Speed2, "")), false));
|
||||
}
|
||||
AddUserVehicles(recording, strings, events, culture);
|
||||
AddStations(recording, strings, events, culture);
|
||||
foreach (var horn in recording.UserHornChecks())
|
||||
events.Add(new BlackBoxEvent(horn.Request.Time, strings[horn.Sounded ? "EventHornSounded" : "EventHornMissed"],
|
||||
horn.Sounded ? Palette.HornPoint : Palette.Bad, !horn.Sounded));
|
||||
foreach (var span in recording.SecurityBrakings())
|
||||
{
|
||||
// Which of them was not acknowledged: the lamps blinking when it braked.
|
||||
string who = recording.CabAt(span.Start) is { } s
|
||||
? s.Has(SecurityFlags.CabSignal) && s.Has(SecurityFlags.Vigilance) ? "CA/SHP" : s.Has(SecurityFlags.CabSignal) ? "SHP" : "CA"
|
||||
: "CA";
|
||||
events.Add(new BlackBoxEvent(span.Start, string.Format(culture, strings["EventSecurityBraking"], who), Palette.Bad, true));
|
||||
}
|
||||
AddCameras(recording, strings, events, culture);
|
||||
AddCouplings(recording, strings, events, culture);
|
||||
AddCabChanges(recording, strings, events, culture);
|
||||
foreach (var t in recording.Timetables)
|
||||
events.Add(new BlackBoxEvent(t.Time, string.Format(culture, strings["EventTimetable"], $"{t.Category} {t.Train}".Trim(),
|
||||
string.Format(culture, strings["Relation"], t.From, t.To)), Palette.Station, false));
|
||||
if (recording.EndTime is { } end)
|
||||
events.Add(new BlackBoxEvent(end, strings["EventEnd"], Palette.FaintText, false));
|
||||
// Stable: what happened at the same moment stays in the order of its kind.
|
||||
return events.OrderBy(e => e.Time).ToList();
|
||||
}
|
||||
|
||||
private static void AddUserVehicles(Recording recording, IBlackBoxStrings strings, List<BlackBoxEvent> events, CultureInfo culture)
|
||||
{
|
||||
UserVehicleChange? before = null;
|
||||
foreach (var u in recording.UserVehicles)
|
||||
{
|
||||
string vehicle = recording.Vehicle(u.Vehicle)?.Name ?? "";
|
||||
// Only the cab changed: told as such.
|
||||
if (before is { } b && b.Vehicle == u.Vehicle && b.InCab == u.InCab && b.CabEnd != u.CabEnd && u.CabEnd != 0)
|
||||
events.Add(new BlackBoxEvent(u.Time, string.Format(culture, strings["EventCab"], strings[u.CabEnd > 0 ? "CabEndFront" : "CabEndRear"], vehicle),
|
||||
Palette.UserTrain, true));
|
||||
else
|
||||
{
|
||||
string text = u.Vehicle == 0
|
||||
? strings["EventNoVehicle"]
|
||||
: string.Format(culture, strings[u.InCab ? "EventInVehicle" : "EventOutside"], vehicle);
|
||||
events.Add(new BlackBoxEvent(u.Time, text, u.InCab ? Palette.UserTrain : Palette.DimText, true));
|
||||
}
|
||||
before = u;
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddStations(Recording recording, IBlackBoxStrings strings, List<BlackBoxEvent> events, CultureInfo culture)
|
||||
{
|
||||
foreach (var st in recording.UserStations())
|
||||
{
|
||||
switch (st.Kind)
|
||||
{
|
||||
case StationKind.Stopped:
|
||||
{
|
||||
float error = st.StopError;
|
||||
string where = Math.Abs(error) < 1
|
||||
? strings["TtStopExact"]
|
||||
: string.Format(culture, strings[error > 0 ? "TtStopShort" : "TtStopPast"], Math.Abs(error));
|
||||
events.Add(new BlackBoxEvent(st.Time, string.Format(culture, strings["EventStopped"], StationNames.Display(st.Station), where), Palette.Station, true));
|
||||
break;
|
||||
}
|
||||
case StationKind.Passed:
|
||||
events.Add(new BlackBoxEvent(st.Time, string.Format(culture, strings["EventStationPassed"], StationNames.Display(st.Station)), Palette.Station, false));
|
||||
break;
|
||||
case StationKind.Missed:
|
||||
events.Add(new BlackBoxEvent(st.Time, string.Format(culture, strings["EventStationMissed"], StationNames.Display(st.Station)), Palette.Bad, true));
|
||||
break;
|
||||
case StationKind.Ready:
|
||||
events.Add(new BlackBoxEvent(st.Time, string.Format(culture, strings["EventReady"], StationNames.Display(st.Station)), Palette.Station, false));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The camera of the user going to another mode: the cab, outside, free.</summary>
|
||||
private static void AddCameras(Recording recording, IBlackBoxStrings strings, List<BlackBoxEvent> events, CultureInfo culture)
|
||||
{
|
||||
CameraMode? mode = null;
|
||||
foreach (var c in recording.Cameras)
|
||||
{
|
||||
if (c.Mode == mode)
|
||||
continue;
|
||||
if (mode is not null)
|
||||
events.Add(new BlackBoxEvent(c.Time, string.Format(culture, strings["EventCamera"], strings[CameraKey(c.Mode)]), Palette.DimText, false));
|
||||
mode = c.Mode;
|
||||
}
|
||||
}
|
||||
|
||||
public static string CameraKey(CameraMode mode) => mode switch
|
||||
{
|
||||
CameraMode.Cab => "CameraCab",
|
||||
CameraMode.External => "CameraExternal",
|
||||
CameraMode.Debug => "CameraDebug",
|
||||
_ => "CameraFree"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The switches the driver worked, as the cab tells them: what came on and went off. Not the compressor and the
|
||||
/// motor fans, which go on and off by themselves.
|
||||
/// </summary>
|
||||
private static readonly (CabDevices Device, string Key)[] Switched =
|
||||
[
|
||||
(CabDevices.MainBreaker, "DevMainBreaker"), (CabDevices.PantographFront, "DevPantographFront"),
|
||||
(CabDevices.PantographRear, "DevPantographRear"), (CabDevices.FuelPump, "DevFuelPump"), (CabDevices.OilPump, "DevOilPump"),
|
||||
(CabDevices.Converter, "DevConverter"),
|
||||
(CabDevices.Heating, "DevHeating"), (CabDevices.Radio, "DevRadio"), (CabDevices.ShuntMode, "DevShuntMode"),
|
||||
(CabDevices.SpringBrake, "DevSpringBrake"), (CabDevices.BrakeReleaser, "DevBrakeReleaser"),
|
||||
(CabDevices.DoorPermitLeft, "DevDoorPermitLeft"), (CabDevices.DoorPermitRight, "DevDoorPermitRight"),
|
||||
(CabDevices.DoorStep, "DevDoorStep"), (CabDevices.DoorLock, "DevDoorLock"), (CabDevices.SpeedControl, "DevSpeedControl")
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// What the driver did in the cab, from one look to the next in the same vehicle: the switches on and off, the
|
||||
/// doors opened and closed, the departure signal, the hand brake, the brakes' setting, the radio's channel, the
|
||||
/// speed the cruise control is set to; and, as faults, moving with the doors open and the engine overheating.
|
||||
/// </summary>
|
||||
private static void AddCabChanges(Recording recording, IBlackBoxStrings strings, List<BlackBoxEvent> events, CultureInfo culture)
|
||||
{
|
||||
CabSample? last = null;
|
||||
bool movingOpen = false;
|
||||
foreach (var s in recording.Cab)
|
||||
{
|
||||
if (last is not { } b || b.Vehicle != s.Vehicle)
|
||||
{
|
||||
last = s;
|
||||
movingOpen = false;
|
||||
continue;
|
||||
}
|
||||
bool Came(CabDevices device) => !b.Has(device) && s.Has(device);
|
||||
bool Went(CabDevices device) => b.Has(device) && !s.Has(device);
|
||||
foreach (var (device, key) in Switched)
|
||||
{
|
||||
if (Came(device))
|
||||
events.Add(new BlackBoxEvent(s.Time, string.Format(culture, strings["EventDeviceOn"], strings[key]), Palette.LampOn, false));
|
||||
else if (Went(device))
|
||||
events.Add(new BlackBoxEvent(s.Time, string.Format(culture, strings["EventDeviceOff"], strings[key]), Palette.FaintText, false));
|
||||
}
|
||||
foreach (var (device, open, closed) in new[] { (CabDevices.DoorsLeft, "DevDoorsLeft", "EventDoorsLeftClosed"), (CabDevices.DoorsRight, "DevDoorsRight", "EventDoorsRightClosed") })
|
||||
{
|
||||
if (Came(device))
|
||||
events.Add(new BlackBoxEvent(s.Time, strings[open], Palette.LampWarning, false));
|
||||
else if (Went(device))
|
||||
events.Add(new BlackBoxEvent(s.Time, strings[closed], Palette.FaintText, false));
|
||||
}
|
||||
if (Came(CabDevices.DepartureSignal))
|
||||
events.Add(new BlackBoxEvent(s.Time, strings["DevDepartureSignal"], Palette.LampWarning, false));
|
||||
if (Came(CabDevices.EngineHot))
|
||||
events.Add(new BlackBoxEvent(s.Time, strings["DevEngineHot"], Palette.Bad, true));
|
||||
// moving with the doors open: once, until they are closed or the train stands
|
||||
bool doorsOpen = s.Has(CabDevices.DoorsLeft) || s.Has(CabDevices.DoorsRight);
|
||||
if (doorsOpen && s.AbsSpeedKmh > 2 && !movingOpen)
|
||||
{
|
||||
events.Add(new BlackBoxEvent(s.Time, string.Format(culture, strings["EventMovingDoorsOpen"], s.AbsSpeedKmh), Palette.Bad, true));
|
||||
movingOpen = true;
|
||||
}
|
||||
else if (!doorsOpen || s.AbsSpeedKmh < 0.5)
|
||||
movingOpen = false;
|
||||
if (b.Extra is { } x && s.Extra is { } y)
|
||||
{
|
||||
if (x.HandBrake <= 0 && y.HandBrake > 0)
|
||||
events.Add(new BlackBoxEvent(s.Time, strings["EventHandBrakeOn"], Palette.LampWarning, false));
|
||||
else if (x.HandBrake > 0 && y.HandBrake <= 0)
|
||||
events.Add(new BlackBoxEvent(s.Time, strings["EventHandBrakeOff"], Palette.FaintText, false));
|
||||
if (x.BrakeDelay != y.BrakeDelay || x.BrakeMode != y.BrakeMode)
|
||||
{
|
||||
string setting = string.Join(" · ", new[] { CabPanel.BrakeSetting(y.BrakeDelay), CabPanel.BrakeMode(y.BrakeMode) }.Where(t => t.Length > 0));
|
||||
events.Add(new BlackBoxEvent(s.Time, string.Format(culture, strings["EventBrakeSetting"], setting), Palette.Brakes, false));
|
||||
}
|
||||
if (x.RadioChannel != y.RadioChannel && y.RadioChannel > 0)
|
||||
events.Add(new BlackBoxEvent(s.Time, string.Format(culture, strings["EventRadioChannel"], y.RadioChannel), Palette.LampOn, false));
|
||||
// the speed set, while the cruise control holds one
|
||||
if (s.Has(CabDevices.SpeedControl) && (Came(CabDevices.SpeedControl) || Math.Abs(x.SpeedControlKmh - y.SpeedControlKmh) >= 1))
|
||||
events.Add(new BlackBoxEvent(s.Time, string.Format(culture, strings["EventSpeedControlSet"], y.SpeedControlKmh), Palette.Target, false));
|
||||
}
|
||||
last = s;
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddCouplings(Recording recording, IBlackBoxStrings strings, List<BlackBoxEvent> events, CultureInfo culture)
|
||||
{
|
||||
string Name(int id) => recording.Vehicle(id)?.Name ?? id.ToString(culture);
|
||||
foreach (var c in recording.UserCouplings())
|
||||
{
|
||||
if (c.Coupled)
|
||||
events.Add(new BlackBoxEvent(c.Time, string.Format(culture, strings["EventCoupled"], Name(c.Vehicle), Name(c.Other)), Palette.Good, true));
|
||||
else if (c.Uncoupled)
|
||||
events.Add(new BlackBoxEvent(c.Time, string.Format(culture, strings["EventUncoupled"], Name(c.Vehicle), Name(c.PreviousOther)), Palette.Warn, true));
|
||||
else
|
||||
events.Add(new BlackBoxEvent(c.Time, string.Format(culture, strings["EventCouplingChanged"], Name(c.Vehicle), Name(c.Other),
|
||||
CouplingText(strings, c.Flags)), Palette.DimText, false));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>What a coupling joins, in words.</summary>
|
||||
public static string CouplingText(IBlackBoxStrings strings, CouplingFlags flags)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
foreach (var (flag, key) in new[]
|
||||
{
|
||||
(CouplingFlags.Coupler, "CouplingCoupler"), (CouplingFlags.BrakePipe, "CouplingBrakePipe"),
|
||||
(CouplingFlags.MainPipe, "CouplingMainPipe"), (CouplingFlags.Control, "CouplingControl"),
|
||||
(CouplingFlags.Power, "CouplingPower"), (CouplingFlags.Heating, "CouplingHeating"),
|
||||
(CouplingFlags.Gangway, "CouplingGangway")
|
||||
})
|
||||
if ((flags & flag) != 0)
|
||||
parts.Add(strings[key]);
|
||||
return parts.Count == 0 ? strings["CouplingNothing"] : string.Join(", ", parts);
|
||||
}
|
||||
}
|
||||
766
Eu07.BlackBox.Views/MapView.cs
Normal file
766
Eu07.BlackBox.Views/MapView.cs
Normal file
@@ -0,0 +1,766 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace Eu07.BlackBox.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The map of the scenery at a moment of the recording: the rail tracks with the switches
|
||||
/// as they lay, the signals in the colour of what they showed, every vehicle where it was,
|
||||
/// and the way the user's train went, coloured by how its speed stood against the AI's.
|
||||
/// North is up, as on the map in the simulator. Dragged to move, the wheel zooms, a
|
||||
/// double click shows it all.
|
||||
/// </summary>
|
||||
public sealed class MapView : Control
|
||||
{
|
||||
private const double MinScale = 0.002;
|
||||
private const double MaxScale = 40;
|
||||
private const double FollowScale = 1.2;
|
||||
|
||||
private Recording? _recording;
|
||||
private int _builtRevision = -1;
|
||||
private int _builtTracks = -1;
|
||||
private StreamGeometry? _tracks;
|
||||
private readonly List<(int Index, int Branch, StreamGeometry Way)> _switchWays = new();
|
||||
private readonly StreamGeometry?[] _trail = new StreamGeometry?[5];
|
||||
private Rect _world;
|
||||
|
||||
private double _centerX, _centerZ;
|
||||
private double _scale = 0.2; // pixels per metre
|
||||
private bool _fitted;
|
||||
|
||||
private Point? _dragFrom;
|
||||
private (double X, double Z) _dragCenter;
|
||||
private Point? _pointer;
|
||||
private double _time;
|
||||
private bool _follow;
|
||||
|
||||
/// <summary>The user moved the map away from the train: following is off.</summary>
|
||||
public event Action? FollowStopped;
|
||||
|
||||
public MapView()
|
||||
{
|
||||
ClipToBounds = true;
|
||||
Focusable = true;
|
||||
}
|
||||
|
||||
public Recording? Recording
|
||||
{
|
||||
get => _recording;
|
||||
set
|
||||
{
|
||||
_recording = value;
|
||||
_builtRevision = -1;
|
||||
_builtTracks = -1;
|
||||
_fitted = false;
|
||||
InvalidateVisual();
|
||||
}
|
||||
}
|
||||
|
||||
public double Time
|
||||
{
|
||||
get => _time;
|
||||
set
|
||||
{
|
||||
_time = value;
|
||||
InvalidateVisual();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Keeps the user's train in the middle.</summary>
|
||||
public bool Follow
|
||||
{
|
||||
get => _follow;
|
||||
set
|
||||
{
|
||||
// Close enough to see the train go, unless already closer.
|
||||
if (value && !_follow)
|
||||
_scale = Math.Max(_scale, FollowScale);
|
||||
_follow = value;
|
||||
InvalidateVisual();
|
||||
}
|
||||
}
|
||||
|
||||
public IBlackBoxStrings Strings { get; set; } = new DefaultBlackBoxStrings();
|
||||
|
||||
/// <summary>Closer by the factor given, about the middle.</summary>
|
||||
public void ZoomBy(double factor)
|
||||
{
|
||||
_scale = Math.Clamp(_scale * factor, MinScale, MaxScale);
|
||||
InvalidateVisual();
|
||||
}
|
||||
|
||||
/// <summary>The map as a whole in view.</summary>
|
||||
public void Fit()
|
||||
{
|
||||
FitWorld();
|
||||
InvalidateVisual();
|
||||
}
|
||||
|
||||
// Also from the render, which must not ask for another one.
|
||||
private void FitWorld()
|
||||
{
|
||||
if (_world.Width <= 0 && _world.Height <= 0)
|
||||
return;
|
||||
var size = Bounds.Size;
|
||||
if (size.Width <= 0 || size.Height <= 0)
|
||||
return;
|
||||
_centerX = -_world.Center.X;
|
||||
_centerZ = -_world.Center.Y;
|
||||
_scale = Math.Clamp(Math.Min(size.Width / Math.Max(_world.Width, 50), size.Height / Math.Max(_world.Height, 50)) * 0.92,
|
||||
MinScale, MaxScale);
|
||||
if (_follow)
|
||||
_scale = Math.Max(_scale, FollowScale);
|
||||
_fitted = true;
|
||||
}
|
||||
|
||||
// The map's plane as the simulator's map draws it, seen from above: x to the left (the game's x runs the
|
||||
// other way than a map's east), z up the screen.
|
||||
private static Point Plane(double x, double z) => new(-x, -z);
|
||||
|
||||
private Point ToScreen(double x, double z) =>
|
||||
new((_centerX - x) * _scale + Bounds.Width / 2, (_centerZ - z) * _scale + Bounds.Height / 2);
|
||||
|
||||
private (double X, double Z) ToWorld(Point screen) =>
|
||||
(_centerX - (screen.X - Bounds.Width / 2) / _scale, _centerZ - (screen.Y - Bounds.Height / 2) / _scale);
|
||||
|
||||
private void Build(Recording recording)
|
||||
{
|
||||
if (recording.Tracks.Count != _builtTracks)
|
||||
{
|
||||
_builtTracks = recording.Tracks.Count;
|
||||
_switchWays.Clear();
|
||||
var tracks = new StreamGeometry();
|
||||
double left = double.MaxValue, top = double.MaxValue, right = double.MinValue, bottom = double.MinValue;
|
||||
using (var g = tracks.Open())
|
||||
{
|
||||
foreach (var track in recording.Tracks)
|
||||
{
|
||||
if (track.Points.Count < 2)
|
||||
continue;
|
||||
foreach (var p in track.Points)
|
||||
{
|
||||
left = Math.Min(left, -p.X);
|
||||
right = Math.Max(right, -p.X);
|
||||
top = Math.Min(top, -p.Z);
|
||||
bottom = Math.Max(bottom, -p.Z);
|
||||
}
|
||||
if (track.Kind == TrackKind.SwitchBranch)
|
||||
{
|
||||
var way = new StreamGeometry();
|
||||
using (var w = way.Open())
|
||||
Polyline(w, track.Points);
|
||||
_switchWays.Add((track.Index, track.Branch, way));
|
||||
}
|
||||
else
|
||||
Polyline(g, track.Points);
|
||||
}
|
||||
}
|
||||
_tracks = tracks;
|
||||
_world = left <= right ? new Rect(new Point(left, top), new Point(right, bottom)) : default;
|
||||
}
|
||||
BuildTrail(recording);
|
||||
_builtRevision = recording.Revision;
|
||||
}
|
||||
|
||||
private static void Polyline(StreamGeometryContext g, IReadOnlyList<System.Numerics.Vector3> points)
|
||||
{
|
||||
g.BeginFigure(Plane(points[0].X, points[0].Z), false);
|
||||
for (int i = 1; i < points.Count; i++)
|
||||
g.LineTo(Plane(points[i].X, points[i].Z));
|
||||
g.EndFigure(false);
|
||||
}
|
||||
|
||||
/// <summary>The way the user's train went, a geometry for each judgement of its speed.</summary>
|
||||
private void BuildTrail(Recording recording)
|
||||
{
|
||||
var lines = new List<List<Point>>[_trail.Length];
|
||||
for (int i = 0; i < lines.Length; i++)
|
||||
lines[i] = new List<List<Point>>();
|
||||
var cab = recording.Cab;
|
||||
int open = -1;
|
||||
for (int i = 1; i < cab.Count; i++)
|
||||
{
|
||||
var before = cab[i - 1];
|
||||
var sample = cab[i];
|
||||
if (sample.Vehicle != before.Vehicle || sample.Time - before.Time > 1.0)
|
||||
{
|
||||
open = -1;
|
||||
continue;
|
||||
}
|
||||
if (before.Position.DistanceTo(sample.Position) < 0.5 && open >= 0)
|
||||
continue;
|
||||
int kind = (int)DrivingSummary.Judge(sample);
|
||||
if (kind != open)
|
||||
{
|
||||
lines[kind].Add(new List<Point> { Plane(before.Position.X, before.Position.Z) });
|
||||
open = kind;
|
||||
}
|
||||
lines[kind][^1].Add(Plane(sample.Position.X, sample.Position.Z));
|
||||
}
|
||||
for (int kind = 0; kind < _trail.Length; kind++)
|
||||
{
|
||||
if (lines[kind].Count == 0)
|
||||
{
|
||||
_trail[kind] = null;
|
||||
continue;
|
||||
}
|
||||
var geometry = new StreamGeometry();
|
||||
using (var g = geometry.Open())
|
||||
{
|
||||
foreach (var line in lines[kind].Where(l => l.Count > 1))
|
||||
{
|
||||
g.BeginFigure(line[0], false);
|
||||
foreach (var p in line.Skip(1))
|
||||
g.LineTo(p);
|
||||
g.EndFigure(false);
|
||||
}
|
||||
}
|
||||
_trail[kind] = geometry;
|
||||
}
|
||||
}
|
||||
|
||||
public override void Render(DrawingContext context)
|
||||
{
|
||||
var size = Bounds.Size;
|
||||
context.FillRectangle(Palette.MapBackground, new Rect(size));
|
||||
var recording = _recording;
|
||||
if (recording is null)
|
||||
return;
|
||||
if (recording.Revision != _builtRevision)
|
||||
Build(recording);
|
||||
if (!_fitted)
|
||||
FitWorld();
|
||||
if (_follow && recording.CabPositionAt(_time) is { } followed)
|
||||
{
|
||||
_centerX = followed.X;
|
||||
_centerZ = followed.Z;
|
||||
}
|
||||
|
||||
var toScreen = Matrix.CreateTranslation(_centerX, _centerZ) * Matrix.CreateScale(_scale, _scale) *
|
||||
Matrix.CreateTranslation(size.Width / 2, size.Height / 2);
|
||||
double pixel = 1 / _scale;
|
||||
using (context.PushTransform(toScreen))
|
||||
{
|
||||
// The tracks on a dark casing, so they stand out where they cross and run close.
|
||||
var casing = new Pen(Palette.TrackCasing, 5 * pixel, lineCap: PenLineCap.Round);
|
||||
if (_tracks is not null)
|
||||
context.DrawGeometry(null, casing, _tracks);
|
||||
foreach (var (_, _, way) in _switchWays)
|
||||
context.DrawGeometry(null, casing, way);
|
||||
if (_tracks is not null)
|
||||
context.DrawGeometry(null, new Pen(Palette.Track, 1.7 * pixel, lineCap: PenLineCap.Round), _tracks);
|
||||
var off = new Pen(Palette.SwitchOff, 1.7 * pixel, lineCap: PenLineCap.Round);
|
||||
var on = new Pen(Palette.Track, 1.7 * pixel, lineCap: PenLineCap.Round);
|
||||
// The ways not taken under those taken.
|
||||
foreach (var (index, branch, way) in _switchWays)
|
||||
{
|
||||
int state = recording.SwitchAt(index, _time);
|
||||
if (state >= 0 && state != branch)
|
||||
context.DrawGeometry(null, off, way);
|
||||
}
|
||||
foreach (var (index, branch, way) in _switchWays)
|
||||
{
|
||||
int state = recording.SwitchAt(index, _time);
|
||||
if (state < 0 || state == branch)
|
||||
context.DrawGeometry(null, on, way);
|
||||
}
|
||||
// The way the train went, in the colour of the judgement.
|
||||
for (int kind = 0; kind < _trail.Length; kind++)
|
||||
if (_trail[kind] is { } trail)
|
||||
context.DrawGeometry(null, new Pen(Palette.Of((SpeedJudgement)kind), 3.5 * pixel, lineCap: PenLineCap.Round, lineJoin: PenLineJoin.Round), trail);
|
||||
}
|
||||
|
||||
DrawStopPoints(context, recording);
|
||||
DrawHornPoints(context, recording);
|
||||
DrawSignals(context, recording);
|
||||
DrawPassings(context, recording);
|
||||
DrawVehicles(context, recording);
|
||||
DrawUserTrain(context, recording);
|
||||
DrawCamera(context, recording);
|
||||
DrawScale(context, size);
|
||||
DrawLegend(context, size);
|
||||
DrawHover(context, recording);
|
||||
}
|
||||
|
||||
private bool Visible(Point p, double margin = 20) =>
|
||||
p.X >= -margin && p.Y >= -margin && p.X <= Bounds.Width + margin && p.Y <= Bounds.Height + margin;
|
||||
|
||||
private void DrawSignals(DrawingContext context, Recording recording)
|
||||
{
|
||||
double radius = _scale > 0.5 ? 4.5 : _scale > 0.1 ? 3.5 : 2.5;
|
||||
var outline = new Pen(Palette.Background, 1);
|
||||
foreach (var signal in recording.Signals)
|
||||
{
|
||||
var p = ToScreen(signal.Position.X, signal.Position.Z);
|
||||
if (!Visible(p))
|
||||
continue;
|
||||
context.DrawEllipse(Palette.Of(recording.AspectAt(signal.Index, _time)), outline, p, radius, radius);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The stop points (W4): a white plate, blue where the user's train stopped, red where it
|
||||
/// missed the stop; the name of the station once close enough to read it.
|
||||
/// </summary>
|
||||
// The way the trains a stop point is for go: a wedge ahead of the plate, as a train sees the W4 coming.
|
||||
private static void DrawStopWay(DrawingContext context, Point plate, float heading, double size, IBrush fill, IPen outline)
|
||||
{
|
||||
// the map shows x to the left, z up: a heading from +z towards +x turns up the screen and to the left
|
||||
var ahead = new Vector(-Math.Sin(heading), -Math.Cos(heading));
|
||||
var across = new Vector(-ahead.Y, ahead.X);
|
||||
var tip = plate + ahead * (size * 4.2);
|
||||
var root = plate + ahead * (size * 1.8);
|
||||
var geometry = new StreamGeometry();
|
||||
using (var g = geometry.Open())
|
||||
{
|
||||
g.BeginFigure(tip, true);
|
||||
g.LineTo(root + across * (size * 1.3));
|
||||
g.LineTo(root - across * (size * 1.3));
|
||||
g.EndFigure(true);
|
||||
}
|
||||
context.DrawGeometry(fill, outline, geometry);
|
||||
}
|
||||
|
||||
private void DrawStopPoints(DrawingContext context, Recording recording)
|
||||
{
|
||||
if (recording.StopPoints.Count == 0)
|
||||
return;
|
||||
var outcomes = new Dictionary<(double, double), StationKind>();
|
||||
foreach (var station in recording.UserStations())
|
||||
{
|
||||
if (station.Time > _time || station.Kind is not (StationKind.Stopped or StationKind.Missed))
|
||||
continue;
|
||||
var key = (Math.Round(station.Position.X, 1), Math.Round(station.Position.Z, 1));
|
||||
if (!outcomes.TryGetValue(key, out var was) || was != StationKind.Missed)
|
||||
outcomes[key] = station.Kind;
|
||||
}
|
||||
// Close up every plate is named; from afar each station once, where the names don't cover each other.
|
||||
bool near = _scale > 0.35;
|
||||
double size = _scale > 0.5 ? 4 : 3;
|
||||
var outline = new Pen(Palette.MapBackground, 1);
|
||||
var labels = new List<(Point At, string Name, IBrush Fill)>();
|
||||
foreach (var point in recording.StopPoints)
|
||||
{
|
||||
var p = ToScreen(point.Position.X, point.Position.Z);
|
||||
if (!Visible(p))
|
||||
continue;
|
||||
var fill = outcomes.TryGetValue((Math.Round(point.Position.X, 1), Math.Round(point.Position.Z, 1)), out var kind)
|
||||
? kind == StationKind.Missed ? Palette.Bad : Palette.Station
|
||||
: Palette.StopPoint;
|
||||
var plate = new Rect(p.X - size, p.Y - size * 1.3, size * 2, size * 2.6);
|
||||
// a W32 hollow, in the colour a W4 would be filled with
|
||||
if (point.ForShortTrains)
|
||||
context.DrawRectangle(Palette.MapBackground, new Pen(fill, 1.5), plate, 1.5, 1.5);
|
||||
else
|
||||
context.DrawRectangle(fill, outline, plate, 1.5, 1.5);
|
||||
if (point.HasHeading)
|
||||
DrawStopWay(context, p, point.Heading, size, fill, outline);
|
||||
labels.Add((p, StationNames.Display(point.Station), fill));
|
||||
}
|
||||
var delays = StationProgress.StationDelays(recording, _time);
|
||||
var named = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var taken = new List<Rect>();
|
||||
// The label of the user's train stays clear.
|
||||
if (recording.CabPositionAt(_time) is { } train)
|
||||
{
|
||||
var mark = ToScreen(train.X, train.Z);
|
||||
taken.Add(new Rect(mark.X - 10, mark.Y - 24, 170, 34));
|
||||
}
|
||||
var culture = System.Globalization.CultureInfo.CurrentCulture;
|
||||
foreach (var (p, name, fill) in labels)
|
||||
{
|
||||
if (!near && !named.Add(name))
|
||||
continue;
|
||||
var text = Text(name, Palette.Text, near ? 11 : 10);
|
||||
FormattedText? late = delays.TryGetValue(name, out var minutes)
|
||||
? Text(string.Format(culture, Strings["Minutes"], minutes), Palette.Delay(minutes), near ? 11 : 10)
|
||||
: null;
|
||||
double width = text.Width + (late is null ? 0 : late.Width + 6) + 8;
|
||||
var box = new Rect(p.X + size + 3, p.Y - text.Height / 2 - 1, width, text.Height + 2);
|
||||
if (taken.Exists(r => r.Inflate(2).Intersects(box)))
|
||||
continue;
|
||||
taken.Add(box);
|
||||
context.DrawRectangle(Palette.Faded(Palette.MapBackground, 0.8), null, box);
|
||||
context.DrawText(text, new Point(box.X + 4, box.Y + 1));
|
||||
if (late is not null)
|
||||
context.DrawText(late, new Point(box.X + 4 + text.Width + 6, box.Y + 1));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The horn points (W6): a diamond, green where the user sounded the horn, red where not.</summary>
|
||||
private void DrawHornPoints(DrawingContext context, Recording recording)
|
||||
{
|
||||
if (recording.HornPoints.Count == 0)
|
||||
return;
|
||||
var checks = recording.UserHornChecks().Where(c => c.Request.Time <= _time).ToList();
|
||||
double size = _scale > 0.5 ? 5 : 3.5;
|
||||
var outline = new Pen(Palette.MapBackground, 1);
|
||||
foreach (var point in recording.HornPoints)
|
||||
{
|
||||
var p = ToScreen(point.Position.X, point.Position.Z);
|
||||
if (!Visible(p))
|
||||
continue;
|
||||
var check = checks.LastOrDefault(c => c.Request.Position.DistanceTo(point.Position) < 1);
|
||||
var fill = check is null ? Palette.HornPoint : check.Sounded ? Palette.Good : Palette.Bad;
|
||||
context.DrawGeometry(fill, outline, Diamond(p, size));
|
||||
}
|
||||
}
|
||||
|
||||
internal static StreamGeometry Diamond(Point p, double size)
|
||||
{
|
||||
var geometry = new StreamGeometry();
|
||||
using (var g = geometry.Open())
|
||||
{
|
||||
g.BeginFigure(p + new Vector(0, -size), true);
|
||||
g.LineTo(p + new Vector(size, 0));
|
||||
g.LineTo(p + new Vector(0, size));
|
||||
g.LineTo(p + new Vector(-size, 0));
|
||||
g.EndFigure(true);
|
||||
}
|
||||
return geometry;
|
||||
}
|
||||
|
||||
/// <summary>Where the user looked from, when not from the cab: a dot with a wedge of where it looked.</summary>
|
||||
private void DrawCamera(DrawingContext context, Recording recording)
|
||||
{
|
||||
if (recording.CameraAt(_time) is not { } camera || camera.Mode == CameraMode.Cab)
|
||||
return;
|
||||
var p = ToScreen(camera.Position.X, camera.Position.Z);
|
||||
if (!Visible(p, 60))
|
||||
return;
|
||||
// The heading from +z towards +x: up the screen and to the left.
|
||||
const double reach = 46, half = 0.45;
|
||||
var geometry = new StreamGeometry();
|
||||
using (var g = geometry.Open())
|
||||
{
|
||||
g.BeginFigure(p, true);
|
||||
g.LineTo(p + new Vector(-Math.Sin(camera.Heading - half), -Math.Cos(camera.Heading - half)) * reach);
|
||||
g.ArcTo(p + new Vector(-Math.Sin(camera.Heading + half), -Math.Cos(camera.Heading + half)) * reach, new Size(reach, reach), 0,
|
||||
false, SweepDirection.CounterClockwise);
|
||||
g.EndFigure(true);
|
||||
}
|
||||
context.DrawGeometry(Palette.Faded(Palette.HornPoint, 0.18), new Pen(Palette.Faded(Palette.HornPoint, 0.5), 1), geometry);
|
||||
context.DrawEllipse(Palette.HornPoint, new Pen(Palette.MapBackground, 1.5), p, 4.5, 4.5);
|
||||
}
|
||||
|
||||
/// <summary>Where the user's train passed signals; those at danger ringed red.</summary>
|
||||
private void DrawPassings(DrawingContext context, Recording recording)
|
||||
{
|
||||
var passed = new Pen(Palette.Within, 1.5);
|
||||
var danger = new Pen(Palette.Over, 2.5);
|
||||
foreach (var passing in recording.UserPassings())
|
||||
{
|
||||
if (passing.Time > _time)
|
||||
continue;
|
||||
var p = ToScreen(passing.Position.X, passing.Position.Z);
|
||||
if (!Visible(p))
|
||||
continue;
|
||||
if (passing.AtDanger)
|
||||
context.DrawEllipse(null, danger, p, 10, 10);
|
||||
else
|
||||
context.DrawEllipse(null, passed, p, 7, 7);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawVehicles(DrawingContext context, Recording recording)
|
||||
{
|
||||
int user = recording.CabAt(_time) is { } sample && _time - sample.Time < 1.5 ? sample.Vehicle : 0;
|
||||
foreach (var state in recording.VehiclesAt(_time))
|
||||
{
|
||||
var info = recording.Vehicle(state.Id);
|
||||
var brush = state.Id == user ? Palette.UserTrain : state.Driving == Driver.Ai ? Palette.VehicleAi : Palette.Vehicle;
|
||||
DrawVehicle(context, state.Position.X, state.Position.Z, state.Heading, info?.Length ?? 15, info?.Width ?? 3, brush);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawVehicle(DrawingContext context, double x, double z, double heading, double length, double width,
|
||||
IBrush brush)
|
||||
{
|
||||
var center = ToScreen(x, z);
|
||||
if (!Visible(center, length * _scale + 10))
|
||||
return;
|
||||
// The front from +z towards +x: up the screen and to the left.
|
||||
double fx = Math.Sin(heading), fz = Math.Cos(heading);
|
||||
double half = Math.Max(length * _scale, 5) / 2, side = Math.Max(width * _scale, 3) / 2;
|
||||
var front = new Vector(-fx, -fz);
|
||||
var left = new Vector(-fz, fx);
|
||||
var geometry = new StreamGeometry();
|
||||
using (var g = geometry.Open())
|
||||
{
|
||||
g.BeginFigure(center + front * half + left * side, true);
|
||||
g.LineTo(center + front * half - left * side);
|
||||
g.LineTo(center - front * half - left * side);
|
||||
g.LineTo(center - front * half + left * side);
|
||||
g.EndFigure(true);
|
||||
}
|
||||
context.DrawGeometry(brush, new Pen(Palette.Background, 0.8), geometry);
|
||||
}
|
||||
|
||||
/// <summary>The head of the user's train between its samples, ten a second, on top of everything.</summary>
|
||||
private void DrawUserTrain(DrawingContext context, Recording recording)
|
||||
{
|
||||
if (recording.CabPositionAt(_time) is not { } position)
|
||||
return;
|
||||
var p = ToScreen(position.X, position.Z);
|
||||
context.DrawEllipse(Palette.MapBackground, new Pen(Palette.UserTrain, 2), p, 7, 7);
|
||||
context.DrawEllipse(Palette.UserTrain, null, p, 3, 3);
|
||||
// The train and how late it is, by its mark.
|
||||
if (StationProgress.DelayAt(recording, _time) is not { } delay)
|
||||
return;
|
||||
var culture = System.Globalization.CultureInfo.CurrentCulture;
|
||||
var train = Text(delay.Train, Palette.UserTrain, 11, FontWeight.SemiBold);
|
||||
var late = Text(Math.Abs(delay.Minutes) < 0.5 ? Strings["OnTime"] : string.Format(culture, Strings["Minutes"], delay.Minutes),
|
||||
Palette.Delay(delay.Minutes), 11, FontWeight.SemiBold);
|
||||
var box = TrainLabel(p, train, late);
|
||||
context.DrawRectangle(Palette.Faded(Palette.MapBackground, 0.85), null, box);
|
||||
context.DrawText(train, new Point(box.X + 4, box.Y + 2));
|
||||
context.DrawText(late, new Point(box.X + train.Width + 12, box.Y + 2));
|
||||
}
|
||||
|
||||
/// <summary>Where the label of the user's train goes, by its mark.</summary>
|
||||
private static Rect TrainLabel(Point mark, FormattedText train, FormattedText late) =>
|
||||
new(mark.X + 12, mark.Y - 22, train.Width + late.Width + 16, train.Height + 4);
|
||||
|
||||
private void DrawScale(DrawingContext context, Size size)
|
||||
{
|
||||
double metres = Nice(120 / _scale);
|
||||
double length = metres * _scale;
|
||||
var y = size.Height - 14;
|
||||
var pen = new Pen(Palette.DimText, 1.5);
|
||||
context.DrawLine(pen, new Point(size.Width - 16 - length, y), new Point(size.Width - 16, y));
|
||||
context.DrawLine(pen, new Point(size.Width - 16 - length, y - 4), new Point(size.Width - 16 - length, y + 1));
|
||||
context.DrawLine(pen, new Point(size.Width - 16, y - 4), new Point(size.Width - 16, y + 1));
|
||||
var text = Text(metres >= 1000 ? $"{metres / 1000:0.#} km" : $"{metres:0} m", Palette.DimText, 11);
|
||||
context.DrawText(text, new Point(size.Width - 16 - length / 2 - text.Width / 2, y - 18));
|
||||
}
|
||||
|
||||
private void DrawLegend(DrawingContext context, Size size)
|
||||
{
|
||||
var rows = new (IBrush Brush, string Text)[]
|
||||
{
|
||||
(Palette.Within, Strings["LegendWithin"]),
|
||||
(Palette.Slightly, string.Format(CultureInfo.CurrentCulture, Strings["LegendSlightly"], DrivingSummary.DefaultTolerance)),
|
||||
(Palette.Over, string.Format(CultureInfo.CurrentCulture, Strings["LegendOver"], DrivingSummary.DefaultTolerance)),
|
||||
(Palette.Autopilot, Strings["LegendAutopilot"]),
|
||||
(Palette.NoData, Strings["LegendNoData"])
|
||||
};
|
||||
var marks = new List<(IBrush Brush, string Text, int Shape)>();
|
||||
if (_recording is { } r)
|
||||
{
|
||||
if (r.StopPoints.Any(point => !point.ForShortTrains))
|
||||
marks.Add((Palette.StopPoint, Strings["LegendStopPoint"], 0));
|
||||
if (r.StopPoints.Any(point => point.ForShortTrains))
|
||||
marks.Add((Palette.StopPoint, Strings["LegendShortStop"], 3));
|
||||
if (r.HornPoints.Count > 0)
|
||||
marks.Add((Palette.HornPoint, Strings["LegendHorn"], 1));
|
||||
if (r.Cameras.Count > 0)
|
||||
marks.Add((Palette.HornPoint, Strings["LegendCamera"], 2));
|
||||
}
|
||||
// Each part under a title of its own, in the text's colour: the dim one is hard to read on the map.
|
||||
var title = Text(Strings["LegendTitle"], Palette.DimText, 11);
|
||||
var marksTitle = Text(Strings["LegendMarks"], Palette.DimText, 11);
|
||||
var texts = rows.Select(row => Text(row.Text, Palette.Text, 12)).Concat(marks.Select(m => Text(m.Text, Palette.Text, 12))).ToList();
|
||||
const double line = 19, heading = 18, gap = 8;
|
||||
double width = Math.Max(texts.Max(t => t.Width) + 44, Math.Max(title.Width, marks.Count > 0 ? marksTitle.Width : 0) + 24);
|
||||
double height = 10 + heading + rows.Length * line + (marks.Count > 0 ? gap + heading + marks.Count * line : 0) + 6;
|
||||
var box = new Rect(10, size.Height - height - 10, width, height);
|
||||
context.DrawRectangle(Palette.Faded(Palette.Card, 0.96), new Pen(Palette.CardBorder, 1), box, 3, 3);
|
||||
double y = box.Top + 8;
|
||||
context.DrawText(title, new Point(box.Left + 12, y));
|
||||
y += heading;
|
||||
for (int i = 0; i < rows.Length; i++, y += line)
|
||||
{
|
||||
context.DrawLine(new Pen(rows[i].Brush, 5, lineCap: PenLineCap.Round), new Point(box.Left + 13, y + 9), new Point(box.Left + 27, y + 9));
|
||||
context.DrawText(texts[i], new Point(box.Left + 36, y));
|
||||
}
|
||||
if (marks.Count > 0)
|
||||
{
|
||||
y += gap;
|
||||
context.DrawLine(new Pen(Palette.Grid, 1), new Point(box.Left + 10, y - gap / 2), new Point(box.Right - 10, y - gap / 2));
|
||||
context.DrawText(marksTitle, new Point(box.Left + 12, y));
|
||||
y += heading;
|
||||
}
|
||||
for (int i = 0; i < marks.Count; i++, y += line)
|
||||
{
|
||||
var c = new Point(box.Left + 20, y + 9);
|
||||
if (marks[i].Shape == 0)
|
||||
context.DrawRectangle(marks[i].Brush, null, new Rect(c.X - 3, c.Y - 4, 6, 8), 1.5, 1.5);
|
||||
else if (marks[i].Shape == 3)
|
||||
context.DrawRectangle(null, new Pen(marks[i].Brush, 1.5), new Rect(c.X - 3, c.Y - 4, 6, 8), 1.5, 1.5);
|
||||
else if (marks[i].Shape == 1)
|
||||
context.DrawGeometry(marks[i].Brush, null, Diamond(c, 4.5));
|
||||
else
|
||||
context.DrawEllipse(marks[i].Brush, null, c, 4, 4);
|
||||
context.DrawText(texts[rows.Length + i], new Point(box.Left + 36, y));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The name of the signal or the vehicle under the pointer.</summary>
|
||||
private void DrawHover(DrawingContext context, Recording recording)
|
||||
{
|
||||
if (_pointer is not { } pointer || _dragFrom is not null)
|
||||
return;
|
||||
string? label = null;
|
||||
double best = 10;
|
||||
foreach (var signal in recording.Signals)
|
||||
{
|
||||
double d = Distance(ToScreen(signal.Position.X, signal.Position.Z), pointer);
|
||||
if (d < best)
|
||||
{
|
||||
best = d;
|
||||
var aspect = recording.AspectAt(signal.Index, _time);
|
||||
label = aspect is { } a ? $"{signal.Name} · {AspectText(Strings, a.Speed1, a.Speed2)}" : signal.Name;
|
||||
}
|
||||
}
|
||||
var culture = CultureInfo.CurrentCulture;
|
||||
foreach (var point in recording.StopPoints)
|
||||
{
|
||||
double d = Distance(ToScreen(point.Position.X, point.Position.Z), pointer);
|
||||
if (d < best)
|
||||
{
|
||||
best = d;
|
||||
label = string.Format(culture, Strings[point.ForShortTrains ? "ShortStopHover" : "StopPointHover"], StationNames.Display(point.Station));
|
||||
if (point.Platform > 0)
|
||||
label += " · " + string.Format(culture, Strings["PlatformHover"], point.PlatformLength);
|
||||
else if (point.Platform < 0)
|
||||
label += " · " + string.Format(culture, Strings["ShortTrainsHover"], point.PlatformLength);
|
||||
// the preferred place to stop, as the driver reads it: the head, or the middle of the train
|
||||
if (point.StopAt < 0)
|
||||
label += " · " + string.Format(culture, Strings["StopHeadHover"], -point.StopAt);
|
||||
else if (point.StopAt > 0)
|
||||
label += " · " + string.Format(culture, Strings["StopMiddleHover"], point.StopAt);
|
||||
}
|
||||
}
|
||||
foreach (var point in recording.HornPoints)
|
||||
{
|
||||
double d = Distance(ToScreen(point.Position.X, point.Position.Z), pointer);
|
||||
if (d < best)
|
||||
{
|
||||
best = d;
|
||||
label = string.Format(culture, Strings["HornHover"], point.Name, point.Duration);
|
||||
}
|
||||
}
|
||||
foreach (var state in recording.VehiclesAt(_time))
|
||||
{
|
||||
double d = Distance(ToScreen(state.Position.X, state.Position.Z), pointer);
|
||||
if (d < best && recording.Vehicle(state.Id) is { } info)
|
||||
{
|
||||
best = d;
|
||||
label = string.IsNullOrEmpty(info.Train)
|
||||
? $"{info.Name} · {Math.Abs(state.SpeedKmh):0} km/h"
|
||||
: $"{info.Name} ({info.Train}) · {Math.Abs(state.SpeedKmh):0} km/h";
|
||||
}
|
||||
}
|
||||
if (label is null)
|
||||
return;
|
||||
var text = Text(label, Palette.Text, 12);
|
||||
var box = new Rect(pointer.X + 14, pointer.Y - 12 - text.Height, text.Width + 16, text.Height + 8);
|
||||
context.DrawRectangle(Palette.Faded(Palette.Card, 0.95), new Pen(Palette.CardBorder, 1), box, 6, 6);
|
||||
context.DrawText(text, new Point(box.Left + 8, box.Top + 4));
|
||||
}
|
||||
|
||||
/// <summary>An aspect as the speeds it gives: stop, a speed, or go.</summary>
|
||||
public static string AspectText(IBlackBoxStrings strings, float speed1, float speed2)
|
||||
{
|
||||
if (speed1 == 0)
|
||||
return strings["AspectStop"];
|
||||
if (speed1 < 0 && speed2 < 0)
|
||||
return strings["AspectFree"];
|
||||
string first = speed1 < 0 ? "∞" : speed1.ToString("0", CultureInfo.InvariantCulture);
|
||||
string second = speed2 < 0 ? "∞" : speed2.ToString("0", CultureInfo.InvariantCulture);
|
||||
return $"{first}/{second}";
|
||||
}
|
||||
|
||||
private static double Distance(Point a, Point b) => Math.Sqrt((a.X - b.X) * (a.X - b.X) + (a.Y - b.Y) * (a.Y - b.Y));
|
||||
|
||||
private static double Nice(double value)
|
||||
{
|
||||
double power = Math.Pow(10, Math.Floor(Math.Log10(value)));
|
||||
double unit = value / power;
|
||||
return (unit < 2 ? 1 : unit < 5 ? 2 : 5) * power;
|
||||
}
|
||||
|
||||
internal static FormattedText Text(string text, IBrush brush, double size, FontWeight weight = FontWeight.Normal) =>
|
||||
new(text, CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
|
||||
weight == FontWeight.Normal ? Typeface.Default : new Typeface(Typeface.Default.FontFamily, FontStyle.Normal, weight),
|
||||
size, brush);
|
||||
|
||||
protected override void OnPointerPressed(PointerPressedEventArgs e)
|
||||
{
|
||||
base.OnPointerPressed(e);
|
||||
var point = e.GetCurrentPoint(this);
|
||||
if (!point.Properties.IsLeftButtonPressed)
|
||||
return;
|
||||
if (e.ClickCount == 2)
|
||||
{
|
||||
if (_follow)
|
||||
StopFollowing();
|
||||
Fit();
|
||||
return;
|
||||
}
|
||||
_dragFrom = point.Position;
|
||||
_dragCenter = (_centerX, _centerZ);
|
||||
e.Pointer.Capture(this);
|
||||
}
|
||||
|
||||
protected override void OnPointerMoved(PointerEventArgs e)
|
||||
{
|
||||
base.OnPointerMoved(e);
|
||||
var position = e.GetPosition(this);
|
||||
_pointer = position;
|
||||
if (_dragFrom is { } from)
|
||||
{
|
||||
var moved = position - from;
|
||||
if (_follow && (Math.Abs(moved.X) > 3 || Math.Abs(moved.Y) > 3))
|
||||
StopFollowing();
|
||||
if (!_follow)
|
||||
{
|
||||
_centerX = _dragCenter.X + moved.X / _scale;
|
||||
_centerZ = _dragCenter.Z + moved.Y / _scale;
|
||||
}
|
||||
}
|
||||
InvalidateVisual();
|
||||
}
|
||||
|
||||
protected override void OnPointerReleased(PointerReleasedEventArgs e)
|
||||
{
|
||||
base.OnPointerReleased(e);
|
||||
_dragFrom = null;
|
||||
e.Pointer.Capture(null);
|
||||
}
|
||||
|
||||
protected override void OnPointerExited(PointerEventArgs e)
|
||||
{
|
||||
base.OnPointerExited(e);
|
||||
_pointer = null;
|
||||
InvalidateVisual();
|
||||
}
|
||||
|
||||
protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
|
||||
{
|
||||
base.OnPointerWheelChanged(e);
|
||||
var at = e.GetPosition(this);
|
||||
var (x, z) = ToWorld(at);
|
||||
_scale = Math.Clamp(_scale * Math.Pow(1.25, e.Delta.Y), MinScale, MaxScale);
|
||||
if (!_follow)
|
||||
{
|
||||
// The place under the pointer stays under it.
|
||||
_centerX = x + (at.X - Bounds.Width / 2) / _scale;
|
||||
_centerZ = z + (at.Y - Bounds.Height / 2) / _scale;
|
||||
}
|
||||
InvalidateVisual();
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void StopFollowing()
|
||||
{
|
||||
_follow = false;
|
||||
FollowStopped?.Invoke();
|
||||
}
|
||||
}
|
||||
113
Eu07.BlackBox.Views/Palette.cs
Normal file
113
Eu07.BlackBox.Views/Palette.cs
Normal file
@@ -0,0 +1,113 @@
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace Eu07.BlackBox.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The colours the map, the timeline and the panels share: those of the starter's Carbon
|
||||
/// look for the window, its green for what is picked, and colours of their own for the data.
|
||||
/// </summary>
|
||||
public static class Palette
|
||||
{
|
||||
// The window, the cards on it and the lines between.
|
||||
public static readonly IBrush Background = Brush("#15171B");
|
||||
public static readonly IBrush Panel = Brush("#1E2327");
|
||||
public static readonly IBrush Card = Brush("#1E2327");
|
||||
public static readonly IBrush CardHover = Brush("#2A3036");
|
||||
public static readonly IBrush CardBorder = Brush("#3A424A");
|
||||
public static readonly IBrush Grid = Brush("#2A3036");
|
||||
public static readonly IBrush MapBackground = Brush("#121417");
|
||||
public static readonly IBrush Text = Brush("#E6E8EA");
|
||||
public static readonly IBrush DimText = Brush("#9098A0");
|
||||
public static readonly IBrush FaintText = Brush("#646C74");
|
||||
/// <summary>The headers of the panels, as the group boxes of the starter.</summary>
|
||||
public static readonly IBrush Header = Brush("#2A3036");
|
||||
/// <summary>The green of what is picked or on: bright for lines and text, deep for fills.</summary>
|
||||
public static readonly IBrush Accent = Brush("#41C400");
|
||||
public static readonly IBrush AccentFill = Brush("#177F00");
|
||||
public static readonly IBrush AccentFillHover = Brush("#1E9000");
|
||||
public static readonly IBrush AccentText = Brush("#FFFFFF");
|
||||
public static readonly IBrush Good = Brush("#4cc46e");
|
||||
|
||||
/// <summary>A delay: green on time, yellow a little late, red late, blue early.</summary>
|
||||
public static IBrush Delay(double? minutes) => minutes switch
|
||||
{
|
||||
null => Good,
|
||||
> 3 => Bad,
|
||||
> 0.5 => Warn,
|
||||
< -0.5 => Station,
|
||||
_ => Good
|
||||
};
|
||||
|
||||
public static readonly IBrush Warn = Brush("#e8c547");
|
||||
public static readonly IBrush Bad = Brush("#f0584a");
|
||||
|
||||
public static readonly IBrush Track = Brush("#6f7b88");
|
||||
public static readonly IBrush TrackCasing = Brush("#1d232b");
|
||||
public static readonly IBrush SwitchOff = Brush("#323a44");
|
||||
public static readonly IBrush Vehicle = Brush("#c9a26b");
|
||||
public static readonly IBrush VehicleAi = Brush("#9fb0c3");
|
||||
public static readonly IBrush UserTrain = Brush("#ff9d1c");
|
||||
public static readonly IBrush Cursor = Brush("#ffb000");
|
||||
|
||||
public static readonly IBrush Within = Brush("#4cc46e");
|
||||
public static readonly IBrush Slightly = Brush("#e8c547");
|
||||
public static readonly IBrush Over = Brush("#f0584a");
|
||||
public static readonly IBrush Autopilot = Brush("#4a90e2");
|
||||
public static readonly IBrush NoData = Brush("#5f6871");
|
||||
|
||||
public static readonly IBrush Speed = Brush("#f2f4f6");
|
||||
public static readonly IBrush SpeedFill = new SolidColorBrush(Color.Parse("#f2f4f6"), 0.06).ToImmutable();
|
||||
public static readonly IBrush Target = Brush("#4cc46e");
|
||||
public static readonly IBrush Limit = Brush("#ff8c2a");
|
||||
|
||||
public static readonly IBrush SignalStop = Brush("#ff3b30");
|
||||
public static readonly IBrush SignalLimited = Brush("#ffcc00");
|
||||
public static readonly IBrush SignalFree = Brush("#34c759");
|
||||
public static readonly IBrush SignalShunt = Brush("#e8f0ff");
|
||||
public static readonly IBrush SignalShuntStop = Brush("#4a6cff");
|
||||
public static readonly IBrush SignalUnknown = Brush("#808890");
|
||||
|
||||
public static readonly IBrush StopPoint = Brush("#f4f6f8");
|
||||
public static readonly IBrush Station = Brush("#5aa9ff");
|
||||
public static readonly IBrush HornPoint = Brush("#b9a6ff");
|
||||
|
||||
public static readonly IBrush Brakes = Brush("#e05a52");
|
||||
public static readonly IBrush Controller = Brush("#2bb3a3");
|
||||
public static readonly IBrush Vigilance = Brush("#e8c547");
|
||||
public static readonly IBrush CabSignal = Brush("#f08a3a");
|
||||
public static readonly IBrush LampOff = Brush("#252c35");
|
||||
public static readonly IBrush LampOn = Brush("#4cc46e");
|
||||
public static readonly IBrush LampWarning = Brush("#e8c547");
|
||||
public static readonly IBrush LampAlarm = Brush("#f0584a");
|
||||
public static readonly IBrush LightWhite = Brush("#fff6d8");
|
||||
public static readonly IBrush LightRed = Brush("#ff3b30");
|
||||
public static readonly IBrush Command = Brush("#8b96a3");
|
||||
|
||||
public static IBrush Of(SpeedJudgement judgement) => judgement switch
|
||||
{
|
||||
SpeedJudgement.Within => Within,
|
||||
SpeedJudgement.Slightly => Slightly,
|
||||
SpeedJudgement.Over => Over,
|
||||
SpeedJudgement.Autopilot => Autopilot,
|
||||
_ => NoData
|
||||
};
|
||||
|
||||
/// <summary>What a signal shows: the colour of its main light, as the in-game map draws it.</summary>
|
||||
public static IBrush Of(AspectChange? aspect)
|
||||
{
|
||||
if (aspect is not { } a)
|
||||
return SignalUnknown;
|
||||
bool shunt = a.Command.StartsWith("Shunt", System.StringComparison.OrdinalIgnoreCase);
|
||||
if (a.Speed1 == 0)
|
||||
return shunt ? SignalShuntStop : SignalStop;
|
||||
if (shunt)
|
||||
return SignalShunt;
|
||||
return a.Speed1 < 0 && a.Speed2 != 0 ? SignalFree : SignalLimited;
|
||||
}
|
||||
|
||||
/// <summary>A colour seen through: for fills under lines and glows.</summary>
|
||||
public static IBrush Faded(IBrush brush, double opacity) =>
|
||||
new SolidColorBrush(((ISolidColorBrush)brush).Color, opacity).ToImmutable();
|
||||
|
||||
private static IBrush Brush(string color) => new SolidColorBrush(Color.Parse(color)).ToImmutable();
|
||||
}
|
||||
704
Eu07.BlackBox.Views/TimelineView.cs
Normal file
704
Eu07.BlackBox.Views/TimelineView.cs
Normal file
@@ -0,0 +1,704 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace Eu07.BlackBox.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The user's train over the time of the recording, by the clock of the simulation: its
|
||||
/// speed, the speed the AI would hold and the next limit it saw, below them bands of who
|
||||
/// drove and how against the AI, of the controller, the brakes, the alerter (CA), the cab
|
||||
/// signalling (SHP) and the camera, and marks of the commands, the signals passed, the
|
||||
/// stations, the horn points and the couplings. A click goes to its moment; the wheel zooms
|
||||
/// the time, dragging moves it.
|
||||
/// </summary>
|
||||
public sealed class TimelineView : Control
|
||||
{
|
||||
private const double Left = 56;
|
||||
private const double Right = 12;
|
||||
private const double Top = 36;
|
||||
private const double BandHeight = 8;
|
||||
private const double BandGap = 3;
|
||||
private const double MarksHeight = 16;
|
||||
private const double AxisHeight = 20;
|
||||
private const int Bands = 6;
|
||||
|
||||
private Recording? _recording;
|
||||
private double _time;
|
||||
private double _start;
|
||||
private double _secondsPerPixel = -1;
|
||||
private Point? _pressed;
|
||||
private double _pressedStart;
|
||||
private bool _dragged;
|
||||
private double? _hover;
|
||||
private Point? _pointer;
|
||||
private int _measured = -1;
|
||||
private Recording? _measuredOf;
|
||||
private double _speedTop = 40;
|
||||
private int _controllerTop = 1;
|
||||
|
||||
/// <summary>A moment was clicked.</summary>
|
||||
public event Action<double>? Seek;
|
||||
|
||||
/// <summary>The moment under the pointer, null once it leaves.</summary>
|
||||
public event Action<double?>? Hovered;
|
||||
|
||||
public TimelineView()
|
||||
{
|
||||
ClipToBounds = true;
|
||||
MinHeight = 160;
|
||||
}
|
||||
|
||||
public IBlackBoxStrings Strings { get; set; } = new DefaultBlackBoxStrings();
|
||||
|
||||
public Recording? Recording
|
||||
{
|
||||
get => _recording;
|
||||
set
|
||||
{
|
||||
_recording = value;
|
||||
_secondsPerPixel = -1;
|
||||
_start = 0;
|
||||
InvalidateVisual();
|
||||
}
|
||||
}
|
||||
|
||||
public double Time
|
||||
{
|
||||
get => _time;
|
||||
set
|
||||
{
|
||||
_time = value;
|
||||
// Kept in view while it plays.
|
||||
if (_secondsPerPixel > 0 && _recording is not null)
|
||||
{
|
||||
double end = _start + PlotWidth * _secondsPerPixel;
|
||||
if (value > end || value < _start)
|
||||
_start = Math.Max(0, value - PlotWidth * _secondsPerPixel * 0.1);
|
||||
}
|
||||
InvalidateVisual();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>All of the recording in view.</summary>
|
||||
public void Fit()
|
||||
{
|
||||
_secondsPerPixel = -1;
|
||||
_start = 0;
|
||||
InvalidateVisual();
|
||||
}
|
||||
|
||||
private double PlotWidth => Math.Max(10, Bounds.Width - Left - Right);
|
||||
private double ChartBottom => Bounds.Height - AxisHeight - MarksHeight - Bands * (BandHeight + BandGap) - 6;
|
||||
private double X(double t) => Left + (t - _start) / _secondsPerPixel;
|
||||
private double TimeAt(double x) => _start + (x - Left) * _secondsPerPixel;
|
||||
|
||||
public override void Render(DrawingContext context)
|
||||
{
|
||||
var size = Bounds.Size;
|
||||
var recording = _recording;
|
||||
if (recording is null || recording.Duration <= 0)
|
||||
return;
|
||||
if (_secondsPerPixel <= 0)
|
||||
_secondsPerPixel = Math.Max(recording.Duration, 60) / PlotWidth;
|
||||
|
||||
double end = _start + PlotWidth * _secondsPerPixel;
|
||||
double chartBottom = ChartBottom;
|
||||
Measure(recording);
|
||||
double top = _speedTop;
|
||||
double Y(double kmh) => chartBottom - Math.Clamp(kmh / top, 0, 1.05) * (chartBottom - Top);
|
||||
|
||||
using (context.PushClip(new Rect(Left, 0, PlotWidth, size.Height)))
|
||||
{
|
||||
DrawGrid(context, top, Y, size);
|
||||
DrawCurves(context, recording, Y, end);
|
||||
double bands = chartBottom + 6;
|
||||
DrawBands(context, recording, bands, end);
|
||||
DrawMarks(context, recording, bands + Bands * (BandHeight + BandGap), end);
|
||||
DrawAxis(context, recording, size.Height - AxisHeight, end);
|
||||
if (_hover is { } hover)
|
||||
context.DrawLine(new Pen(Palette.DimText, 1, new DashStyle(new double[] { 2, 3 }, 0)),
|
||||
new Point(X(hover), Top), new Point(X(hover), size.Height - AxisHeight));
|
||||
double cursor = X(_time);
|
||||
context.DrawLine(new Pen(Palette.Cursor, 1.5), new Point(cursor, Top - 6), new Point(cursor, size.Height - AxisHeight));
|
||||
// The moment played, on the axis.
|
||||
var clock = MapView.Text(recording.ClockAt(_time).ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture), Palette.Cursor, 10, FontWeight.SemiBold);
|
||||
var bubble = new Rect(Math.Max(Left, Math.Min(cursor - clock.Width / 2 - 5, size.Width - Right - clock.Width - 10)), size.Height - AxisHeight + 2,
|
||||
clock.Width + 10, clock.Height + 2);
|
||||
context.DrawRectangle(Palette.Card, new Pen(Palette.Cursor, 1), bubble);
|
||||
context.DrawText(clock, new Point(bubble.X + 5, bubble.Y + 1));
|
||||
}
|
||||
DrawSpeedLabels(context, top, Y);
|
||||
DrawBandLabels(context, chartBottom + 6);
|
||||
DrawKey(context);
|
||||
if (MarkUnderPointer(recording, chartBottom) is { } mark)
|
||||
DrawMarkLabel(context, mark, size);
|
||||
}
|
||||
|
||||
/// <summary>The top of the speed scale and the highest step of the controller, once a reading.</summary>
|
||||
private void Measure(Recording recording)
|
||||
{
|
||||
if (_measured == recording.Revision && ReferenceEquals(_measuredOf, recording))
|
||||
return;
|
||||
float max = 40;
|
||||
int controller = 1;
|
||||
foreach (var s in recording.Cab)
|
||||
{
|
||||
max = Math.Max(max, s.AbsSpeedKmh);
|
||||
if (s.AiValid)
|
||||
max = Math.Max(max, s.Ai.TargetKmh);
|
||||
controller = Math.Max(controller, (int)s.MainController);
|
||||
}
|
||||
_speedTop = Math.Ceiling(max * 1.1 / 20) * 20;
|
||||
_controllerTop = controller;
|
||||
_measured = recording.Revision;
|
||||
_measuredOf = recording;
|
||||
}
|
||||
|
||||
private void DrawGrid(DrawingContext context, double top, Func<double, double> y, Size size)
|
||||
{
|
||||
var pen = new Pen(Palette.Grid, 1);
|
||||
for (double v = 0; v <= top; v += top > 160 ? 40 : 20)
|
||||
context.DrawLine(pen, new Point(Left, y(v)), new Point(size.Width - Right, y(v)));
|
||||
}
|
||||
|
||||
private void DrawSpeedLabels(DrawingContext context, double top, Func<double, double> y)
|
||||
{
|
||||
for (double v = 0; v <= top; v += top > 160 ? 40 : 20)
|
||||
{
|
||||
var text = MapView.Text(v.ToString("0", CultureInfo.InvariantCulture), Palette.DimText, 10);
|
||||
context.DrawText(text, new Point(Left - 6 - text.Width, y(v) - text.Height / 2));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Each curve by columns of pixels: in a column the lowest and the highest speed,
|
||||
/// so an hour draws as fast as a minute.
|
||||
/// </summary>
|
||||
private void DrawCurves(DrawingContext context, Recording recording, Func<double, double> y, double end)
|
||||
{
|
||||
var cab = recording.Cab;
|
||||
int first = Math.Max(0, recording.CabIndexAt(_start));
|
||||
var speed = new Curve();
|
||||
var target = new Curve();
|
||||
var limits = new List<Point>();
|
||||
int column = int.MinValue;
|
||||
CabSample? before = null;
|
||||
for (int i = first; i < cab.Count; i++)
|
||||
{
|
||||
var s = cab[i];
|
||||
if (s.Time > end + _secondsPerPixel * 2)
|
||||
break;
|
||||
bool gap = before is { } b && (s.Time - b.Time > 1.0 || s.Vehicle != b.Vehicle);
|
||||
if (gap)
|
||||
{
|
||||
speed.Break();
|
||||
target.Break();
|
||||
}
|
||||
int c = (int)Math.Round(X(s.Time));
|
||||
speed.Add(c, y(s.AbsSpeedKmh));
|
||||
// Under the autopilot too: what it aimed at.
|
||||
if (s.AiValid)
|
||||
target.Add(c, y(s.Ai.TargetKmh));
|
||||
else
|
||||
target.Break();
|
||||
if (c != column && s.AiValid && s.Ai.HasNextLimit)
|
||||
limits.Add(new Point(c, y(s.Ai.NextLimitKmh)));
|
||||
column = c;
|
||||
before = s;
|
||||
}
|
||||
if (speed.Fill(y(0)) is { } fill)
|
||||
context.DrawGeometry(Palette.SpeedFill, null, fill);
|
||||
if (target.Geometry() is { } t)
|
||||
context.DrawGeometry(null, new Pen(Palette.Target, 1.5), t);
|
||||
var limitPen = new Pen(Palette.Limit, 2);
|
||||
foreach (var p in limits)
|
||||
context.DrawLine(limitPen, p, p + new Vector(1, 0));
|
||||
if (speed.Geometry() is { } v)
|
||||
context.DrawGeometry(null, new Pen(Palette.Speed, 1.5), v);
|
||||
}
|
||||
|
||||
private void DrawBands(DrawingContext context, Recording recording, double top, double end)
|
||||
{
|
||||
var cab = recording.Cab;
|
||||
int first = Math.Max(0, recording.CabIndexAt(_start));
|
||||
var cameras = recording.Cameras;
|
||||
int camera = -1;
|
||||
var rows = new BandRun[Bands];
|
||||
for (int row = 0; row < rows.Length; row++)
|
||||
rows[row] = new BandRun(context, top + row * (BandHeight + BandGap));
|
||||
for (int i = first; i < cab.Count; i++)
|
||||
{
|
||||
var s = cab[i];
|
||||
if (s.Time > end)
|
||||
break;
|
||||
double next = i + 1 < cab.Count && cab[i + 1].Time - s.Time <= 1.0 ? cab[i + 1].Time : s.Time + 0.1;
|
||||
double x0 = X(s.Time), x1 = Math.Max(X(next), x0 + 0.6);
|
||||
rows[0].Add(x0, x1, Palette.Of(DrivingSummary.Judge(s)), BandHeight);
|
||||
// The controller as high as its step stands against the highest one used.
|
||||
rows[1].Add(x0, x1, s.MainController > 0 ? Palette.Controller : null,
|
||||
Math.Max(1.5, BandHeight * s.MainController / _controllerTop));
|
||||
rows[2].Add(x0, x1, s.BrakeCylinder > 0.2f ? BrakeShade(s.BrakeCylinder) : null, BandHeight);
|
||||
if (s.Security != SecurityFlags.None || s.CabEnd != 0)
|
||||
{
|
||||
// The alerter and the cab signalling apart, red when not acknowledged in time.
|
||||
rows[3].Add(x0, x1, s.Has(SecurityFlags.VigilanceBeeping) ? Palette.LampAlarm
|
||||
: s.Has(SecurityFlags.Vigilance) ? Palette.Vigilance : null, BandHeight);
|
||||
rows[4].Add(x0, x1, s.Has(SecurityFlags.CabSignalBeeping) ? Palette.LampAlarm
|
||||
: s.Has(SecurityFlags.CabSignal) ? Palette.CabSignal : null, BandHeight);
|
||||
if (s.Has(SecurityFlags.Braking))
|
||||
rows[s.Has(SecurityFlags.CabSignal) && !s.Has(SecurityFlags.Vigilance) ? 4 : 3].Add(x0, x1, Palette.Bad, BandHeight);
|
||||
}
|
||||
else
|
||||
rows[3].Add(x0, x1, (s.Flags & CabFlags.AlerterBeeping) != 0 ? Palette.LampAlarm
|
||||
: (s.Flags & CabFlags.AlerterBlinking) != 0 ? Palette.Vigilance : null, BandHeight);
|
||||
// Where the user looked from: the samples go on in time, and so does the camera.
|
||||
while (camera + 1 < cameras.Count && cameras[camera + 1].Time <= s.Time)
|
||||
camera++;
|
||||
rows[5].Add(x0, x1, camera < 0 ? null : CameraBrush(cameras[camera].Mode), BandHeight);
|
||||
}
|
||||
foreach (var row in rows)
|
||||
row.Flush();
|
||||
}
|
||||
|
||||
private static readonly IBrush CameraCab = Palette.Faded(Palette.UserTrain, 0.45);
|
||||
private static readonly IBrush CameraOutside = Palette.Faded(Palette.HornPoint, 0.75);
|
||||
private static readonly IBrush CameraFree = Palette.Faded(Palette.Station, 0.75);
|
||||
|
||||
private static IBrush CameraBrush(CameraMode mode) => mode switch
|
||||
{
|
||||
CameraMode.Cab => CameraCab,
|
||||
CameraMode.External => CameraOutside,
|
||||
_ => CameraFree
|
||||
};
|
||||
|
||||
private static readonly IBrush[] BrakeShades = Shades(Palette.Brakes, 8);
|
||||
|
||||
private static IBrush[] Shades(IBrush brush, int count)
|
||||
{
|
||||
var colour = ((ISolidColorBrush)brush).Color;
|
||||
var shades = new IBrush[count];
|
||||
for (int i = 0; i < count; i++)
|
||||
shades[i] = new SolidColorBrush(colour, 0.35 + 0.65 * i / (count - 1));
|
||||
return shades;
|
||||
}
|
||||
|
||||
/// <summary>The harder the brakes hold, the deeper the red.</summary>
|
||||
private static IBrush BrakeShade(float cylinderBar) =>
|
||||
BrakeShades[(int)Math.Round(Math.Clamp(cylinderBar / 5.0, 0, 1) * (BrakeShades.Length - 1))];
|
||||
|
||||
/// <summary>
|
||||
/// The samples of a band drawn as few rectangles as the band changes: ten a second, each
|
||||
/// narrower than a pixel, they would leave seams between them.
|
||||
/// </summary>
|
||||
private sealed class BandRun(DrawingContext context, double top)
|
||||
{
|
||||
private IBrush? _brush;
|
||||
private double _height, _from, _to;
|
||||
|
||||
public void Add(double x0, double x1, IBrush? brush, double height)
|
||||
{
|
||||
if (brush is not null && ReferenceEquals(brush, _brush) && height == _height && x0 <= _to + 0.5)
|
||||
{
|
||||
_to = Math.Max(_to, x1);
|
||||
return;
|
||||
}
|
||||
Flush();
|
||||
_brush = brush;
|
||||
_height = height;
|
||||
_from = x0;
|
||||
_to = x1;
|
||||
}
|
||||
|
||||
public void Flush()
|
||||
{
|
||||
if (_brush is not null)
|
||||
context.FillRectangle(_brush, new Rect(_from, top + BandHeight - _height, _to - _from, _height));
|
||||
_brush = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawBandLabels(DrawingContext context, double top)
|
||||
{
|
||||
string[] names = { Strings["BandDriving"], Strings["BandController"], Strings["BandBrakes"], Strings["BandVigilance"],
|
||||
Strings["BandCabSignal"], Strings["BandCamera"] };
|
||||
for (int i = 0; i < names.Length; i++)
|
||||
{
|
||||
double y = top + i * (BandHeight + BandGap);
|
||||
context.DrawRectangle(Palette.Faded(Palette.Grid, 0.5), null, new Rect(Left, y, Bounds.Width - Left - Right, BandHeight));
|
||||
var text = MapView.Text(names[i], Palette.FaintText, 9);
|
||||
context.DrawText(text, new Point(Left - 6 - text.Width, y + (BandHeight - text.Height) / 2));
|
||||
}
|
||||
var events = MapView.Text(Strings["BandEvents"], Palette.FaintText, 9);
|
||||
double marks = top + Bands * (BandHeight + BandGap);
|
||||
context.DrawText(events, new Point(Left - 6 - events.Width, marks + (MarksHeight - events.Height) / 2));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The commands as short grey ticks, the autopilot switched in blue, the signals passed in
|
||||
/// green, at danger tall and red; the stations as blue plates, the horn points as diamonds
|
||||
/// green or red as the horn sounded, the couplings as yellow ticks.
|
||||
/// </summary>
|
||||
private void DrawMarks(DrawingContext context, Recording recording, double top, double end)
|
||||
{
|
||||
foreach (var station in recording.UserStations())
|
||||
{
|
||||
if (station.Time < _start || station.Time > end || station.Kind == StationKind.Ready)
|
||||
continue;
|
||||
double x = X(station.Time);
|
||||
var brush = station.Kind == StationKind.Missed ? Palette.Bad : Palette.Station;
|
||||
context.DrawLine(new Pen(Palette.Faded(brush, 0.35), 1), new Point(x, Top), new Point(x, top));
|
||||
context.DrawRectangle(brush, null, new Rect(x - 3, Top - 13, 6, 8), 1.5, 1.5);
|
||||
}
|
||||
foreach (var horn in recording.UserHornChecks())
|
||||
{
|
||||
if (horn.Request.Time < _start || horn.Request.Time > end)
|
||||
continue;
|
||||
double x = X(horn.Request.Time);
|
||||
var brush = horn.Sounded ? Palette.Good : Palette.Bad;
|
||||
var geometry = new StreamGeometry();
|
||||
using (var g = geometry.Open())
|
||||
{
|
||||
g.BeginFigure(new Point(x, Top - 14), true);
|
||||
g.LineTo(new Point(x + 4, Top - 10));
|
||||
g.LineTo(new Point(x, Top - 6));
|
||||
g.LineTo(new Point(x - 4, Top - 10));
|
||||
g.EndFigure(true);
|
||||
}
|
||||
context.DrawGeometry(brush, null, geometry);
|
||||
}
|
||||
var coupling = new Pen(Palette.Warn, 2);
|
||||
foreach (var c in recording.UserCouplings())
|
||||
if (c.Time >= _start && c.Time <= end)
|
||||
context.DrawLine(coupling, new Point(X(c.Time), top + 1), new Point(X(c.Time), top + MarksHeight));
|
||||
var command = new Pen(Palette.Command, 1);
|
||||
foreach (var c in recording.UserCommands())
|
||||
if (c.Time >= _start && c.Time <= end)
|
||||
context.DrawLine(command, new Point(X(c.Time), top + MarksHeight - 5), new Point(X(c.Time), top + MarksHeight));
|
||||
var autopilot = new Pen(Palette.Autopilot, 2);
|
||||
foreach (var d in recording.DriverChanges)
|
||||
if (d.Time >= _start && d.Time <= end)
|
||||
context.DrawLine(autopilot, new Point(X(d.Time), top + 2), new Point(X(d.Time), top + MarksHeight));
|
||||
foreach (var p in recording.UserPassings())
|
||||
{
|
||||
if (p.Time < _start || p.Time > end)
|
||||
continue;
|
||||
double x = X(p.Time);
|
||||
if (p.AtDanger)
|
||||
{
|
||||
var geometry = new StreamGeometry();
|
||||
using (var g = geometry.Open())
|
||||
{
|
||||
g.BeginFigure(new Point(x, Top - 4), true);
|
||||
g.LineTo(new Point(x - 5, Top - 13));
|
||||
g.LineTo(new Point(x + 5, Top - 13));
|
||||
g.EndFigure(true);
|
||||
}
|
||||
context.DrawGeometry(Palette.Over, null, geometry);
|
||||
context.DrawLine(new Pen(Palette.Over, 1.5), new Point(x, Top - 4), new Point(x, top + MarksHeight));
|
||||
}
|
||||
else
|
||||
context.DrawLine(new Pen(Palette.Within, 1.5), new Point(x, top + 1), new Point(x, top + MarksHeight - 6));
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawAxis(DrawingContext context, Recording recording, double y, double end)
|
||||
{
|
||||
double[] steps = { 10, 30, 60, 120, 300, 600, 900, 1800, 3600, 7200 };
|
||||
double step = steps[^1];
|
||||
foreach (double candidate in steps)
|
||||
if (candidate / _secondsPerPixel >= 80)
|
||||
{
|
||||
step = candidate;
|
||||
break;
|
||||
}
|
||||
var pen = new Pen(Palette.DimText, 1);
|
||||
// On the clock of the simulation: the marks fall on whole minutes and hours of it.
|
||||
double offset = recording.ClockAt(0).TotalSeconds;
|
||||
double first = Math.Ceiling((_start + offset) / step) * step - offset;
|
||||
for (double t = first; t <= end; t += step)
|
||||
{
|
||||
double x = X(t);
|
||||
context.DrawLine(pen, new Point(x, y), new Point(x, y + 4));
|
||||
var clock = recording.ClockAt(t);
|
||||
string label = step < 60 ? clock.ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture) : clock.ToString(@"hh\:mm", CultureInfo.InvariantCulture);
|
||||
var text = MapView.Text(label, Palette.DimText, 10);
|
||||
context.DrawText(text, new Point(x - text.Width / 2, y + 4));
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawKey(DrawingContext context)
|
||||
{
|
||||
const double y = 10;
|
||||
double x = Left + 4, limit = Bounds.Width - Right;
|
||||
bool Room(string key) => x + 18 + MapView.Text(Strings[key], Palette.DimText, 10).Width <= limit;
|
||||
void Name(string key)
|
||||
{
|
||||
var text = MapView.Text(Strings[key], Palette.DimText, 10);
|
||||
context.DrawText(text, new Point(x + 18, y - text.Height / 2));
|
||||
x += 18 + text.Width + 14;
|
||||
}
|
||||
foreach (var (brush, key) in new[] { (Palette.Speed, "TimelineSpeed"), (Palette.Target, "TimelineTarget"), (Palette.Limit, "TimelineLimit") })
|
||||
{
|
||||
if (!Room(key))
|
||||
return;
|
||||
context.DrawLine(new Pen(brush, 2), new Point(x, y), new Point(x + 14, y));
|
||||
Name(key);
|
||||
}
|
||||
// the marks, as they are drawn above the chart and in the row of the events
|
||||
if (Room("KeyStation"))
|
||||
{
|
||||
context.DrawRectangle(Palette.Station, null, new Rect(x + 4, y - 4, 6, 8), 1.5, 1.5);
|
||||
Name("KeyStation");
|
||||
}
|
||||
if (Room("KeyHorn"))
|
||||
{
|
||||
context.DrawGeometry(Palette.Good, null, MapView.Diamond(new Point(x + 7, y), 4));
|
||||
Name("KeyHorn");
|
||||
}
|
||||
foreach (var (brush, key) in new[] { (Palette.Within, "KeySignal"), (Palette.Warn, "KeyCoupling"), (Palette.Autopilot, "KeyDriver"), (Palette.Command, "KeyCommand") })
|
||||
{
|
||||
if (!Room(key))
|
||||
return;
|
||||
context.DrawLine(new Pen(brush, 2), new Point(x + 7, y - 5), new Point(x + 7, y + 5));
|
||||
Name(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>What the mark under the pointer is: the nearest within a few pixels, in the row of the events or the plates above.</summary>
|
||||
private string? MarkUnderPointer(Recording recording, double chartBottom)
|
||||
{
|
||||
if (_pointer is not { } pointer)
|
||||
return null;
|
||||
double marks = chartBottom + 6 + Bands * (BandHeight + BandGap);
|
||||
bool inPlates = pointer.Y >= Top - 16 && pointer.Y <= Top - 2;
|
||||
bool inMarks = pointer.Y >= marks - 2 && pointer.Y <= marks + MarksHeight + 2;
|
||||
if (!inPlates && !inMarks || pointer.X < Left || pointer.X > Left + PlotWidth)
|
||||
return null;
|
||||
double end = _start + PlotWidth * _secondsPerPixel;
|
||||
var culture = CultureInfo.CurrentCulture;
|
||||
string? found = null;
|
||||
double best = 5;
|
||||
void Consider(double time, Func<string> text)
|
||||
{
|
||||
if (time < _start || time > end)
|
||||
return;
|
||||
double d = Math.Abs(X(time) - pointer.X);
|
||||
if (d < best)
|
||||
{
|
||||
best = d;
|
||||
found = text();
|
||||
}
|
||||
}
|
||||
if (inPlates)
|
||||
{
|
||||
foreach (var station in recording.UserStations())
|
||||
if (station.Kind != StationKind.Ready)
|
||||
Consider(station.Time, () => string.Format(culture, Strings[station.Kind switch
|
||||
{
|
||||
StationKind.Stopped => "TlStationStopped",
|
||||
StationKind.Missed => "TlStationMissed",
|
||||
_ => "TlStationPassed"
|
||||
}], StationNames.Display(station.Station)));
|
||||
foreach (var horn in recording.UserHornChecks())
|
||||
Consider(horn.Request.Time, () => Strings[horn.Sounded ? "TlHornSounded" : "TlHornSilent"]);
|
||||
}
|
||||
foreach (var p in recording.UserPassings())
|
||||
{
|
||||
if (!inMarks && !p.AtDanger)
|
||||
continue;
|
||||
Consider(p.Time, () =>
|
||||
{
|
||||
string name = recording.Signal(p.Signal)?.Name ?? p.Event;
|
||||
return p.AtDanger
|
||||
? string.Format(culture, Strings["TlSignalDanger"], name)
|
||||
: string.Format(culture, Strings["TlSignal"], name, MapView.AspectText(Strings, p.Speed1, p.Speed2));
|
||||
});
|
||||
}
|
||||
if (inMarks)
|
||||
{
|
||||
foreach (var c in recording.UserCouplings())
|
||||
Consider(c.Time, () => Strings[c.Coupled ? "TlCoupled" : c.Uncoupled ? "TlUncoupled" : "TlCouplingChanged"]);
|
||||
foreach (var d in recording.DriverChanges)
|
||||
Consider(d.Time, () => Strings[d.Driving == Driver.Ai ? "TlAutopilotOn" : "TlAutopilotOff"]);
|
||||
foreach (var c in recording.UserCommands())
|
||||
Consider(c.Time, () => string.Format(culture, Strings["TlCommand"], c.Name));
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
private void DrawMarkLabel(DrawingContext context, string text, Size size)
|
||||
{
|
||||
if (_pointer is not { } pointer)
|
||||
return;
|
||||
var label = MapView.Text(text, Palette.Text, 11);
|
||||
double x = Math.Max(Left, Math.Min(pointer.X + 10, size.Width - Right - label.Width - 12));
|
||||
double y = Math.Max(2, pointer.Y - label.Height - 12);
|
||||
var box = new Rect(x, y, label.Width + 12, label.Height + 6);
|
||||
context.DrawRectangle(Palette.Card, new Pen(Palette.CardBorder, 1), box, 3, 3);
|
||||
context.DrawText(label, new Point(x + 6, y + 3));
|
||||
}
|
||||
|
||||
protected override void OnPointerPressed(PointerPressedEventArgs e)
|
||||
{
|
||||
base.OnPointerPressed(e);
|
||||
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||
return;
|
||||
if (e.ClickCount == 2)
|
||||
{
|
||||
Fit();
|
||||
return;
|
||||
}
|
||||
_pressed = e.GetPosition(this);
|
||||
_pressedStart = _start;
|
||||
_dragged = false;
|
||||
e.Pointer.Capture(this);
|
||||
}
|
||||
|
||||
protected override void OnPointerMoved(PointerEventArgs e)
|
||||
{
|
||||
base.OnPointerMoved(e);
|
||||
if (_recording is null || _secondsPerPixel <= 0)
|
||||
return;
|
||||
var position = e.GetPosition(this);
|
||||
if (_pressed is { } from)
|
||||
{
|
||||
double moved = position.X - from.X;
|
||||
if (Math.Abs(moved) > 3)
|
||||
_dragged = true;
|
||||
if (_dragged)
|
||||
_start = Math.Clamp(_pressedStart - moved * _secondsPerPixel, 0, Math.Max(0, _recording.Duration - PlotWidth * _secondsPerPixel * 0.5));
|
||||
}
|
||||
_hover = position.X >= Left ? Math.Clamp(TimeAt(position.X), 0, _recording.Duration) : null;
|
||||
_pointer = position;
|
||||
Hovered?.Invoke(_hover);
|
||||
InvalidateVisual();
|
||||
}
|
||||
|
||||
protected override void OnPointerReleased(PointerReleasedEventArgs e)
|
||||
{
|
||||
base.OnPointerReleased(e);
|
||||
if (_pressed is not null && !_dragged && _recording is not null && _secondsPerPixel > 0)
|
||||
Seek?.Invoke(Math.Clamp(TimeAt(e.GetPosition(this).X), 0, _recording.Duration));
|
||||
_pressed = null;
|
||||
e.Pointer.Capture(null);
|
||||
}
|
||||
|
||||
protected override void OnPointerExited(PointerEventArgs e)
|
||||
{
|
||||
base.OnPointerExited(e);
|
||||
_hover = null;
|
||||
_pointer = null;
|
||||
Hovered?.Invoke(null);
|
||||
InvalidateVisual();
|
||||
}
|
||||
|
||||
protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
|
||||
{
|
||||
base.OnPointerWheelChanged(e);
|
||||
if (_recording is null || _secondsPerPixel <= 0)
|
||||
return;
|
||||
double x = e.GetPosition(this).X;
|
||||
double at = TimeAt(x);
|
||||
double whole = Math.Max(_recording.Duration, 60) / PlotWidth;
|
||||
_secondsPerPixel = Math.Clamp(_secondsPerPixel / Math.Pow(1.3, e.Delta.Y), 0.02, whole);
|
||||
_start = Math.Max(0, at - (x - Left) * _secondsPerPixel);
|
||||
InvalidateVisual();
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
/// <summary>A line through columns of pixels: in each the first, the lowest, the highest and the last value.</summary>
|
||||
private sealed class Curve
|
||||
{
|
||||
private readonly List<List<Point>> _lines = new();
|
||||
private List<Point>? _line;
|
||||
private int _column = int.MinValue;
|
||||
private double _first, _low, _high, _last;
|
||||
|
||||
public void Add(int column, double y)
|
||||
{
|
||||
if (column != _column)
|
||||
{
|
||||
Flush();
|
||||
_column = column;
|
||||
_first = _low = _high = y;
|
||||
}
|
||||
_low = Math.Min(_low, y);
|
||||
_high = Math.Max(_high, y);
|
||||
_last = y;
|
||||
}
|
||||
|
||||
public void Break()
|
||||
{
|
||||
Flush();
|
||||
_line = null;
|
||||
_column = int.MinValue;
|
||||
}
|
||||
|
||||
private void Flush()
|
||||
{
|
||||
if (_column == int.MinValue)
|
||||
return;
|
||||
if (_line is null)
|
||||
_lines.Add(_line = new List<Point>());
|
||||
var (a, b) = _first <= _last ? (_low, _high) : (_high, _low);
|
||||
_line.Add(new Point(_column, _first));
|
||||
if (Math.Abs(_high - _low) > 0.5)
|
||||
{
|
||||
_line.Add(new Point(_column, a));
|
||||
_line.Add(new Point(_column, b));
|
||||
}
|
||||
_line.Add(new Point(_column, _last));
|
||||
}
|
||||
|
||||
/// <summary>The area under the line down to the bottom given, for a soft fill.</summary>
|
||||
public StreamGeometry? Fill(double bottom)
|
||||
{
|
||||
Flush();
|
||||
_column = int.MinValue;
|
||||
var geometry = new StreamGeometry();
|
||||
bool any = false;
|
||||
using (var g = geometry.Open())
|
||||
{
|
||||
foreach (var line in _lines)
|
||||
{
|
||||
if (line.Count < 2)
|
||||
continue;
|
||||
g.BeginFigure(new Point(line[0].X, bottom), true);
|
||||
foreach (var point in line)
|
||||
g.LineTo(point);
|
||||
g.LineTo(new Point(line[^1].X, bottom));
|
||||
g.EndFigure(true);
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
return any ? geometry : null;
|
||||
}
|
||||
|
||||
public StreamGeometry? Geometry()
|
||||
{
|
||||
Flush();
|
||||
_column = int.MinValue;
|
||||
var geometry = new StreamGeometry();
|
||||
bool any = false;
|
||||
using (var g = geometry.Open())
|
||||
{
|
||||
foreach (var line in _lines)
|
||||
{
|
||||
if (line.Count < 2)
|
||||
continue;
|
||||
g.BeginFigure(line[0], false);
|
||||
for (int i = 1; i < line.Count; i++)
|
||||
g.LineTo(line[i]);
|
||||
g.EndFigure(false);
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
return any ? geometry : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
269
Eu07.BlackBox.Views/TimetablePanel.cs
Normal file
269
Eu07.BlackBox.Views/TimetablePanel.cs
Normal file
@@ -0,0 +1,269 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace Eu07.BlackBox.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The timetable of the user's train, a row a station: what the timetable said and what
|
||||
/// happened, by the end of the recording read so far. A stop shows the time of arrival and
|
||||
/// of departure with the delay, how well the train was stopped against the stop point and
|
||||
/// whether it stood along the platform; a station passed through the time of passing. The
|
||||
/// station of the moment played is marked; a click goes to the moment the train was there.
|
||||
/// </summary>
|
||||
public sealed class TimetablePanel : Control
|
||||
{
|
||||
private const double Pad = 12;
|
||||
private const double HeaderHeight = 50;
|
||||
private const double RowHeight = 54;
|
||||
|
||||
private Recording? _recording;
|
||||
private double _time;
|
||||
private int _builtRevision = -1;
|
||||
private IReadOnlyList<StationProgress> _rows = Array.Empty<StationProgress>();
|
||||
private Timetable? _timetable;
|
||||
private int _hover = -1;
|
||||
|
||||
public event Action<double>? Seek;
|
||||
|
||||
public IBlackBoxStrings Strings { get; set; } = new DefaultBlackBoxStrings();
|
||||
|
||||
public TimetablePanel()
|
||||
{
|
||||
ClipToBounds = true;
|
||||
Cursor = new Cursor(StandardCursorType.Hand);
|
||||
}
|
||||
|
||||
public Recording? Recording
|
||||
{
|
||||
get => _recording;
|
||||
set
|
||||
{
|
||||
_recording = value;
|
||||
_builtRevision = -1;
|
||||
Rebuild();
|
||||
}
|
||||
}
|
||||
|
||||
public double Time
|
||||
{
|
||||
get => _time;
|
||||
set
|
||||
{
|
||||
_time = value;
|
||||
Rebuild();
|
||||
InvalidateVisual();
|
||||
}
|
||||
}
|
||||
|
||||
private void Rebuild()
|
||||
{
|
||||
if (_recording is not { } recording)
|
||||
{
|
||||
_rows = Array.Empty<StationProgress>();
|
||||
_timetable = null;
|
||||
InvalidateMeasure();
|
||||
return;
|
||||
}
|
||||
var timetable = recording.TimetableAt(_time);
|
||||
if (recording.Revision == _builtRevision && ReferenceEquals(timetable, _timetable))
|
||||
return;
|
||||
_builtRevision = recording.Revision;
|
||||
_timetable = timetable;
|
||||
_rows = StationProgress.Of(recording, _time);
|
||||
InvalidateMeasure();
|
||||
InvalidateVisual();
|
||||
}
|
||||
|
||||
protected override Size MeasureOverride(Size availableSize) =>
|
||||
new(0, _timetable is null ? 80 : HeaderHeight + _rows.Count * RowHeight + Pad);
|
||||
|
||||
/// <summary>The row of the station the train was at, or made for, at the moment played.</summary>
|
||||
private int Current()
|
||||
{
|
||||
int current = -1;
|
||||
for (int i = 0; i < _rows.Count; i++)
|
||||
{
|
||||
var row = _rows[i];
|
||||
double? at = row.ArrivedAt ?? row.DepartedAt;
|
||||
if (at is { } t && t <= _time + 0.05)
|
||||
current = i;
|
||||
}
|
||||
// Still standing there, or on the way to the next one.
|
||||
if (current >= 0 && _rows[current].DepartedAt is { } left && left <= _time && current + 1 < _rows.Count)
|
||||
return current + 1;
|
||||
return current < 0 ? (_rows.Count > 0 ? 0 : -1) : current;
|
||||
}
|
||||
|
||||
public override void Render(DrawingContext context)
|
||||
{
|
||||
var size = Bounds.Size;
|
||||
var culture = CultureInfo.CurrentCulture;
|
||||
if (_timetable is not { } timetable)
|
||||
{
|
||||
context.DrawText(MapView.Text(Strings["TimetableNone"], Palette.DimText, 12), new Point(Pad, Pad));
|
||||
return;
|
||||
}
|
||||
|
||||
// The train: its number and category, from where to where.
|
||||
var train = MapView.Text($"{timetable.Category} {timetable.Train}".Trim(), Palette.Text, 15, FontWeight.SemiBold);
|
||||
context.DrawText(train, new Point(Pad, 6));
|
||||
string relation = string.Format(culture, Strings["Relation"], timetable.From, timetable.To);
|
||||
if (timetable.Vmax > 0)
|
||||
relation += $" · {timetable.Vmax:0} km/h";
|
||||
context.DrawText(MapView.Text(relation, Palette.DimText, 11), new Point(Pad, 6 + train.Height + 1));
|
||||
|
||||
int current = Current();
|
||||
double rail = Pad + 6;
|
||||
for (int i = 0; i < _rows.Count; i++)
|
||||
{
|
||||
double top = HeaderHeight + i * RowHeight;
|
||||
var row = _rows[i];
|
||||
if (i == current)
|
||||
{
|
||||
context.DrawRectangle(Palette.CardHover, null, new Rect(0, top, size.Width, RowHeight - 1));
|
||||
context.DrawRectangle(Palette.Accent, null, new Rect(0, top, 2, RowHeight - 1));
|
||||
}
|
||||
else if (i == _hover)
|
||||
context.DrawRectangle(Palette.Faded(Palette.CardHover, 0.6), null, new Rect(0, top, size.Width, RowHeight - 1));
|
||||
|
||||
// The line of the route through the stations, a dot each: hollow ahead, filled when done.
|
||||
var outcome = row.Outcome;
|
||||
var colour = outcome switch
|
||||
{
|
||||
StationOutcome.Missed => Palette.Bad,
|
||||
StationOutcome.Ahead => Palette.FaintText,
|
||||
_ => DelayBrush(row.ArrivalDelay ?? row.DepartureDelay)
|
||||
};
|
||||
double dotY = top + 15;
|
||||
if (i + 1 < _rows.Count)
|
||||
context.DrawLine(new Pen(Palette.Grid, 2), new Point(rail, dotY), new Point(rail, dotY + RowHeight));
|
||||
bool stop = row.Row.IsStop;
|
||||
double radius = stop ? 5.5 : 3.5;
|
||||
context.DrawEllipse(outcome == StationOutcome.Ahead ? Palette.Card : colour, new Pen(colour, 2), new Point(rail, dotY), radius, radius);
|
||||
|
||||
// The station and what the timetable said.
|
||||
double x = rail + 16;
|
||||
var name = MapView.Text(StationNames.Display(row.Row.Station), outcome == StationOutcome.Ahead ? Palette.DimText : Palette.Text, 13,
|
||||
stop ? FontWeight.SemiBold : FontWeight.Normal);
|
||||
context.DrawText(name, new Point(x, dotY - name.Height / 2));
|
||||
string planned = stop
|
||||
? $"{Minutes(row.Row.Arrival)} → {Minutes(row.Row.Departure)}"
|
||||
: Minutes(row.Row.Departure);
|
||||
var plan = MapView.Text(planned, Palette.DimText, 12);
|
||||
context.DrawText(plan, new Point(size.Width - Pad - plan.Width, dotY - plan.Height / 2));
|
||||
if (row.Row.Km > 0 || i > 0)
|
||||
{
|
||||
var km = MapView.Text($"km {row.Row.Km:0.0}", Palette.FaintText, 10);
|
||||
double kmX = size.Width - Pad - plan.Width - 12 - km.Width;
|
||||
if (kmX > x + name.Width + 8)
|
||||
context.DrawText(km, new Point(kmX, dotY - km.Height / 2));
|
||||
}
|
||||
|
||||
// What happened there, under it: each part in its colour, on to a second line when long.
|
||||
double partX = x, partY = top + 25;
|
||||
bool first = true;
|
||||
foreach (var (text, brush) in Chips(row, culture))
|
||||
{
|
||||
var part = MapView.Text(first ? text : "· " + text, brush, 11);
|
||||
if (!first && partX + part.Width > size.Width - Pad)
|
||||
{
|
||||
partX = x;
|
||||
partY += 14;
|
||||
part = MapView.Text(text, brush, 11);
|
||||
}
|
||||
context.DrawText(part, new Point(partX, partY));
|
||||
partX += part.Width + 6;
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>What happened at a station, each part with its colour.</summary>
|
||||
private IEnumerable<(string Text, IBrush Brush)> Chips(StationProgress row, CultureInfo culture)
|
||||
{
|
||||
switch (row.Outcome)
|
||||
{
|
||||
case StationOutcome.Ahead:
|
||||
if (row.Row.Maintenance)
|
||||
yield return (Strings["TtTechnical"], Palette.FaintText);
|
||||
yield break;
|
||||
case StationOutcome.Passed:
|
||||
yield return (string.Format(culture, Strings["TtPassed"], Clock(row.DepartureClock)), Palette.DimText);
|
||||
if (row.DepartureDelay is { } passDelay)
|
||||
yield return (string.Format(culture, Strings["Minutes"], passDelay), DelayBrush(passDelay));
|
||||
yield break;
|
||||
}
|
||||
if (row.ArrivedAt is null)
|
||||
{
|
||||
yield return (Strings["TtMissed"], Palette.Bad);
|
||||
yield break;
|
||||
}
|
||||
yield return (string.Format(culture, Strings["TtArrived"], Clock(row.ArrivalClock)), Palette.DimText);
|
||||
if (row.ArrivalDelay is { } arrival)
|
||||
yield return (string.Format(culture, Strings["Minutes"], arrival), DelayBrush(arrival));
|
||||
if (row.Outcome == StationOutcome.Missed)
|
||||
yield return (Strings["TtLeftEarly"], Palette.Bad);
|
||||
else if (row.DepartedAt is not null)
|
||||
{
|
||||
yield return (string.Format(culture, Strings["TtDeparted"], Clock(row.DepartureClock)), Palette.DimText);
|
||||
if (row.DepartureDelay is { } departure)
|
||||
yield return (string.Format(culture, Strings["Minutes"], departure), DelayBrush(departure));
|
||||
}
|
||||
else
|
||||
yield return (Strings["TtStanding"], Palette.Station);
|
||||
if (row.StopError is { } error)
|
||||
yield return Math.Abs(error) < 1
|
||||
? (Strings["TtStopExact"], Palette.Good)
|
||||
: (string.Format(culture, Strings[error > 0 ? "TtStopShort" : "TtStopPast"], Math.Abs(error)),
|
||||
Math.Abs(error) <= 5 ? Palette.Good : Math.Abs(error) <= 15 ? Palette.Warn : Palette.Bad);
|
||||
if (row.OnPlatform is { } along)
|
||||
yield return along
|
||||
? (Strings["TtOnPlatform"], Palette.Good)
|
||||
: (string.Format(culture, Strings["TtOffPlatform"], row.OffPlatform ?? 0), Palette.Bad);
|
||||
}
|
||||
|
||||
private static IBrush DelayBrush(double? minutes) => Palette.Delay(minutes);
|
||||
|
||||
private static string Minutes(float minutes) =>
|
||||
minutes < 0 ? "–" : TimeSpan.FromMinutes(minutes).ToString(@"hh\:mm", CultureInfo.InvariantCulture);
|
||||
|
||||
private static string Clock(double? minutes) =>
|
||||
minutes is { } m ? TimeSpan.FromMinutes(m % 1440).ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture) : "–";
|
||||
|
||||
private int RowAt(Point p)
|
||||
{
|
||||
int i = (int)Math.Floor((p.Y - HeaderHeight) / RowHeight);
|
||||
return p.Y >= HeaderHeight && i >= 0 && i < _rows.Count ? i : -1;
|
||||
}
|
||||
|
||||
protected override void OnPointerMoved(PointerEventArgs e)
|
||||
{
|
||||
base.OnPointerMoved(e);
|
||||
int hover = RowAt(e.GetPosition(this));
|
||||
if (hover != _hover)
|
||||
{
|
||||
_hover = hover;
|
||||
InvalidateVisual();
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnPointerExited(PointerEventArgs e)
|
||||
{
|
||||
base.OnPointerExited(e);
|
||||
_hover = -1;
|
||||
InvalidateVisual();
|
||||
}
|
||||
|
||||
protected override void OnPointerPressed(PointerPressedEventArgs e)
|
||||
{
|
||||
base.OnPointerPressed(e);
|
||||
int i = RowAt(e.GetPosition(this));
|
||||
if (i >= 0 && (_rows[i].ArrivedAt ?? _rows[i].DepartedAt) is { } t)
|
||||
Seek?.Invoke(Math.Max(0, t - 5));
|
||||
}
|
||||
}
|
||||
283
Eu07.BlackBox.Views/UiKit.cs
Normal file
283
Eu07.BlackBox.Views/UiKit.cs
Normal file
@@ -0,0 +1,283 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace Eu07.BlackBox.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The pieces the window is built of, in one plain look: flat panels side by side, flat
|
||||
/// buttons, a table of figures and tabs underlined.
|
||||
/// </summary>
|
||||
internal static class UiKit
|
||||
{
|
||||
/// <summary>The gutter between the panels.</summary>
|
||||
public const double Gap = 6;
|
||||
public static readonly CornerRadius Small = new(2);
|
||||
|
||||
/// <summary>
|
||||
/// The theme's accent (the slider, the picked lines, the focus) in the orange of the viewer
|
||||
/// instead of the system colour, for the window given and everything in it.
|
||||
/// </summary>
|
||||
public static void Accent(Control control)
|
||||
{
|
||||
// As the starter's App.axaml sets them, for the viewer on its own.
|
||||
(string Key, string Colour)[] accents =
|
||||
{
|
||||
("SystemAccentColor", "#177f00"), ("SystemAccentColorDark1", "#136B00"), ("SystemAccentColorDark2", "#0F5600"),
|
||||
("SystemAccentColorDark3", "#0B4200"), ("SystemAccentColorLight1", "#2A9A0F"), ("SystemAccentColorLight2", "#41C400"),
|
||||
("SystemAccentColorLight3", "#5FD42A")
|
||||
};
|
||||
foreach (var (key, colour) in accents)
|
||||
control.Resources[key] = Color.Parse(colour);
|
||||
control.Resources["AccentFillColorDefaultBrush"] = Palette.AccentFill;
|
||||
control.Resources["AccentFillColorSecondaryBrush"] = Palette.AccentFillHover;
|
||||
control.Resources["SystemControlHighlightListAccentLowBrush"] = Palette.AccentFill;
|
||||
control.Resources["SystemControlHighlightListAccentMediumBrush"] = Palette.AccentFillHover;
|
||||
control.Resources["ListBoxItemBackgroundSelected"] = Palette.AccentFill;
|
||||
control.Resources["ListBoxItemBackgroundSelectedPointerOver"] = Palette.AccentFillHover;
|
||||
control.Resources["ListBoxItemBackgroundSelectedPressed"] = Palette.AccentFill;
|
||||
control.Resources["ListBoxItemBackgroundPointerOver"] = Palette.CardHover;
|
||||
}
|
||||
|
||||
/// <summary>A panel with its title on a bar, as the group boxes of the starter; what goes right of the title, if anything.</summary>
|
||||
public static Border Group(string title, Control content, Control? tag = null)
|
||||
{
|
||||
var bar = new DockPanel();
|
||||
if (tag is not null)
|
||||
{
|
||||
DockPanel.SetDock(tag, Dock.Right);
|
||||
tag.VerticalAlignment = VerticalAlignment.Center;
|
||||
bar.Children.Add(tag);
|
||||
}
|
||||
bar.Children.Add(new TextBlock
|
||||
{
|
||||
Text = title,
|
||||
FontSize = 12,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
Foreground = Palette.Text,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
});
|
||||
var header = new Border
|
||||
{
|
||||
Background = Palette.Header,
|
||||
BorderBrush = Palette.CardBorder,
|
||||
BorderThickness = new Thickness(0, 0, 0, 1),
|
||||
Padding = new Thickness(8, 3),
|
||||
MinHeight = 24,
|
||||
Child = bar
|
||||
};
|
||||
var panel = new DockPanel();
|
||||
DockPanel.SetDock(header, Dock.Top);
|
||||
panel.Children.Add(header);
|
||||
panel.Children.Add(content);
|
||||
return Frame(panel);
|
||||
}
|
||||
|
||||
/// <summary>A panel in the frame of the starter's group boxes, without a title.</summary>
|
||||
public static Border Frame(Control content) => new()
|
||||
{
|
||||
Background = Palette.Panel,
|
||||
BorderBrush = Palette.CardBorder,
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = Small,
|
||||
ClipToBounds = true,
|
||||
Child = content
|
||||
};
|
||||
|
||||
/// <summary>A flat panel the views sit on.</summary>
|
||||
public static Border Card(Control content, Thickness? padding = null) => new()
|
||||
{
|
||||
Background = Palette.Card,
|
||||
Padding = padding ?? new Thickness(0),
|
||||
ClipToBounds = true,
|
||||
Child = content
|
||||
};
|
||||
|
||||
/// <summary>A button of the toolbar: text on the bar, a lighter ground under the pointer.</summary>
|
||||
public static Button Button(string text, string? tip = null)
|
||||
{
|
||||
var button = new Button
|
||||
{
|
||||
Content = text,
|
||||
Padding = new Thickness(10, 2),
|
||||
MinHeight = 25,
|
||||
CornerRadius = Small,
|
||||
FontSize = 12,
|
||||
VerticalContentAlignment = VerticalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
if (tip is not null)
|
||||
ToolTip.SetTip(button, tip);
|
||||
return button;
|
||||
}
|
||||
|
||||
/// <summary>The button of playing.</summary>
|
||||
public static Button PlayButton()
|
||||
{
|
||||
var button = new Button
|
||||
{
|
||||
Width = 32,
|
||||
Height = 25,
|
||||
CornerRadius = Small,
|
||||
Background = Palette.AccentFill,
|
||||
Padding = new Thickness(0),
|
||||
BorderThickness = new Thickness(0),
|
||||
HorizontalContentAlignment = HorizontalAlignment.Center,
|
||||
VerticalContentAlignment = VerticalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
// As the starter's start button: its green, brighter under the pointer.
|
||||
Hover(button, Palette.Accent, Brushes.White);
|
||||
return button;
|
||||
}
|
||||
|
||||
/// <summary>The glyph of play or pause.</summary>
|
||||
public static Control PlayGlyph(bool playing)
|
||||
{
|
||||
var geometry = playing
|
||||
? Geometry.Parse("M0,0 H4 V12 H0 Z M7,0 H11 V12 H7 Z")
|
||||
: Geometry.Parse("M1,0 L11,6 L1,12 Z");
|
||||
return new Avalonia.Controls.Shapes.Path
|
||||
{
|
||||
Data = geometry,
|
||||
Fill = Brushes.White,
|
||||
Width = 12,
|
||||
Height = 12,
|
||||
Stretch = Stretch.None,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>A toggle of the toolbar: on, its text takes the accent.</summary>
|
||||
public static ToggleButton Toggle(string text, string? tip = null)
|
||||
{
|
||||
var toggle = new ToggleButton
|
||||
{
|
||||
Content = text,
|
||||
Padding = new Thickness(10, 2),
|
||||
MinHeight = 25,
|
||||
CornerRadius = Small,
|
||||
FontSize = 12,
|
||||
VerticalContentAlignment = VerticalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
foreach (var state in new[] { "", "PointerOver", "Pressed" })
|
||||
{
|
||||
toggle.Resources["ToggleButtonBackgroundChecked" + state] = state == "PointerOver" ? Palette.AccentFillHover : Palette.AccentFill;
|
||||
toggle.Resources["ToggleButtonForegroundChecked" + state] = Brushes.White;
|
||||
}
|
||||
if (tip is not null)
|
||||
ToolTip.SetTip(toggle, tip);
|
||||
return toggle;
|
||||
}
|
||||
|
||||
// The theme takes its own colours under the pointer and when pressed, unless the control gives them.
|
||||
private static void Hover(Control control, IBrush background, IBrush foreground)
|
||||
{
|
||||
foreach (var kind in new[] { "Button", "ToggleButton" })
|
||||
{
|
||||
control.Resources[kind + "BackgroundPointerOver"] = background;
|
||||
control.Resources[kind + "BackgroundPressed"] = background;
|
||||
control.Resources[kind + "ForegroundPointerOver"] = foreground;
|
||||
control.Resources[kind + "ForegroundPressed"] = foreground;
|
||||
control.Resources[kind + "BorderBrushPointerOver"] = Brushes.Transparent;
|
||||
control.Resources[kind + "BorderBrushPressed"] = Brushes.Transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A line of the table of figures: what it is on the left, its value on the right, a note under it.</summary>
|
||||
public sealed class Tile : Border
|
||||
{
|
||||
private readonly TextBlock _value = new() { FontSize = 12, FontWeight = FontWeight.SemiBold, Foreground = Palette.Text, HorizontalAlignment = HorizontalAlignment.Right };
|
||||
private readonly TextBlock _sub = new() { FontSize = 11, Foreground = Palette.FaintText, TextTrimming = TextTrimming.CharacterEllipsis };
|
||||
|
||||
public Tile(string caption)
|
||||
{
|
||||
BorderBrush = Palette.Grid;
|
||||
BorderThickness = new Thickness(0, 0, 0, 1);
|
||||
Padding = new Thickness(8, 4);
|
||||
var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto"), RowDefinitions = new RowDefinitions("Auto,Auto") };
|
||||
var name = new TextBlock { Text = caption, FontSize = 12, Foreground = Palette.DimText, VerticalAlignment = VerticalAlignment.Center };
|
||||
Grid.SetColumn(_value, 1);
|
||||
Grid.SetRow(_sub, 1);
|
||||
Grid.SetColumnSpan(_sub, 2);
|
||||
grid.Children.Add(name);
|
||||
grid.Children.Add(_value);
|
||||
grid.Children.Add(_sub);
|
||||
Child = grid;
|
||||
}
|
||||
|
||||
public void Show(string value, string? sub = null, IBrush? brush = null)
|
||||
{
|
||||
_value.Text = value;
|
||||
_value.Foreground = brush ?? Palette.Text;
|
||||
_sub.Text = sub ?? "";
|
||||
_sub.IsVisible = !string.IsNullOrEmpty(sub);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Tabs side by side as the starter's: the one on on the panel's ground, with a bar of the accent on top.</summary>
|
||||
public sealed class Segmented : Border
|
||||
{
|
||||
private readonly StackPanel _row = new() { Orientation = Orientation.Horizontal };
|
||||
private int _selected = -1;
|
||||
|
||||
public event Action<int>? SelectedChanged;
|
||||
|
||||
public Segmented(params string[] choices)
|
||||
{
|
||||
Background = Palette.Background;
|
||||
BorderBrush = Palette.CardBorder;
|
||||
BorderThickness = new Thickness(0, 0, 0, 1);
|
||||
Child = _row;
|
||||
for (int i = 0; i < choices.Length; i++)
|
||||
{
|
||||
int index = i;
|
||||
var button = new Button
|
||||
{
|
||||
Content = choices[i],
|
||||
Padding = new Thickness(14, 3),
|
||||
MinHeight = 24,
|
||||
CornerRadius = new CornerRadius(0),
|
||||
FontSize = 12,
|
||||
BorderThickness = new Thickness(0, 2, 0, 0)
|
||||
};
|
||||
button.Resources["ButtonBackgroundPointerOver"] = Palette.CardHover;
|
||||
button.Resources["ButtonBackgroundPressed"] = Palette.CardHover;
|
||||
button.Resources["ButtonForegroundPointerOver"] = Palette.Text;
|
||||
button.Resources["ButtonForegroundPressed"] = Palette.Text;
|
||||
button.Click += (_, _) => Selected = index;
|
||||
_row.Children.Add(button);
|
||||
}
|
||||
Selected = 0;
|
||||
}
|
||||
|
||||
public int Selected
|
||||
{
|
||||
get => _selected;
|
||||
set
|
||||
{
|
||||
if (value == _selected)
|
||||
return;
|
||||
_selected = value;
|
||||
for (int i = 0; i < _row.Children.Count; i++)
|
||||
{
|
||||
var button = (Button)_row.Children[i];
|
||||
bool on = i == value;
|
||||
var line = on ? Palette.Accent : Brushes.Transparent;
|
||||
button.Background = on ? Palette.Panel : Brushes.Transparent;
|
||||
button.BorderBrush = line;
|
||||
button.Resources["ButtonBorderBrushPointerOver"] = line;
|
||||
button.Resources["ButtonBorderBrushPressed"] = line;
|
||||
button.Foreground = on ? Palette.Text : Palette.DimText;
|
||||
}
|
||||
SelectedChanged?.Invoke(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
331
Eu07.BlackBox/BlackBoxReader.cs
Normal file
331
Eu07.BlackBox/BlackBoxReader.cs
Normal file
@@ -0,0 +1,331 @@
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Numerics;
|
||||
using System.Text;
|
||||
|
||||
namespace Eu07.BlackBox;
|
||||
|
||||
/// <summary>
|
||||
/// Reads a black box into a <see cref="Recording"/>, as much of it as is there. A file
|
||||
/// the simulator still writes is read again from where the last read stopped: a record
|
||||
/// cut short waits for the next read, so a running session can be followed every few
|
||||
/// seconds without reading it from the start.
|
||||
/// </summary>
|
||||
public sealed class BlackBoxReader
|
||||
{
|
||||
public static ReadOnlySpan<byte> Magic => "EU07BBX\0"u8;
|
||||
public const int FormatVersion = 1;
|
||||
public const int HeaderSize = 12;
|
||||
public const int RecordHeaderSize = 5;
|
||||
|
||||
private long _offset;
|
||||
|
||||
public Recording Recording { get; } = new();
|
||||
|
||||
/// <summary>Bytes taken so far: the header and the whole records.</summary>
|
||||
public long Offset => _offset;
|
||||
|
||||
/// <summary>Opens the file shared, as the simulator may be writing it, and reads what is new.</summary>
|
||||
public int ReadMore(string path)
|
||||
{
|
||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
|
||||
return ReadMore(stream);
|
||||
}
|
||||
|
||||
/// <summary>Reads the whole records added since the last read. Returns how many were read.</summary>
|
||||
/// <exception cref="InvalidDataException">Not a black box, or of a version this reader doesn't know.</exception>
|
||||
public int ReadMore(Stream stream)
|
||||
{
|
||||
if (stream.CanSeek)
|
||||
{
|
||||
if (stream.Length < _offset)
|
||||
throw new InvalidDataException("The black box got shorter than what was read of it: it was written anew.");
|
||||
stream.Seek(_offset, SeekOrigin.Begin);
|
||||
}
|
||||
|
||||
using var added = new MemoryStream();
|
||||
stream.CopyTo(added);
|
||||
var bytes = added.GetBuffer().AsSpan(0, (int)added.Length);
|
||||
|
||||
int at = 0;
|
||||
if (_offset == 0)
|
||||
{
|
||||
if (bytes.Length < HeaderSize)
|
||||
return 0;
|
||||
ReadHeader(bytes[..HeaderSize]);
|
||||
at = HeaderSize;
|
||||
}
|
||||
|
||||
int read = 0;
|
||||
while (bytes.Length - at >= RecordHeaderSize)
|
||||
{
|
||||
var type = (RecordType)bytes[at];
|
||||
uint length = BinaryPrimitives.ReadUInt32LittleEndian(bytes.Slice(at + 1, 4));
|
||||
if (length > int.MaxValue || bytes.Length - at - RecordHeaderSize < length)
|
||||
break;
|
||||
|
||||
var payload = bytes.Slice(at + RecordHeaderSize, (int)length);
|
||||
try
|
||||
{
|
||||
Take(type, new PayloadReader(payload));
|
||||
}
|
||||
catch (EndOfStreamException)
|
||||
{
|
||||
// Shorter than its type says: passed over, as one of an unknown type is.
|
||||
Recording.Malformed++;
|
||||
}
|
||||
at += RecordHeaderSize + (int)length;
|
||||
++read;
|
||||
}
|
||||
|
||||
_offset += at;
|
||||
if (read > 0)
|
||||
Recording.Changed();
|
||||
return read;
|
||||
}
|
||||
|
||||
/// <summary>Reads a whole file at once.</summary>
|
||||
public static Recording Read(string path)
|
||||
{
|
||||
var reader = new BlackBoxReader();
|
||||
reader.ReadMore(path);
|
||||
return reader.Recording;
|
||||
}
|
||||
|
||||
public static Recording Read(Stream stream)
|
||||
{
|
||||
var reader = new BlackBoxReader();
|
||||
reader.ReadMore(stream);
|
||||
return reader.Recording;
|
||||
}
|
||||
|
||||
/// <summary>Whether the bytes start as a black box does.</summary>
|
||||
public static bool LooksLikeBlackBox(ReadOnlySpan<byte> start) => start.Length >= Magic.Length && start[..Magic.Length].SequenceEqual(Magic);
|
||||
|
||||
private void ReadHeader(ReadOnlySpan<byte> header)
|
||||
{
|
||||
if (!LooksLikeBlackBox(header))
|
||||
throw new InvalidDataException("Not a black box of the simulator.");
|
||||
int version = BinaryPrimitives.ReadUInt16LittleEndian(header.Slice(8, 2));
|
||||
if (version != FormatVersion)
|
||||
throw new InvalidDataException($"A black box of version {version}, this reader knows version {FormatVersion}.");
|
||||
Recording.Version = version;
|
||||
}
|
||||
|
||||
private void Take(RecordType type, PayloadReader r)
|
||||
{
|
||||
var rec = Recording;
|
||||
switch (type)
|
||||
{
|
||||
case RecordType.Info:
|
||||
rec.Info = new RecordingInfo(r.Text(), r.Text(), r.U16(), r.U16(), r.F64(), r.Text(), r.Text(),
|
||||
r.F32(), r.F32(), r.F32());
|
||||
break;
|
||||
case RecordType.Track:
|
||||
{
|
||||
int index = (int)r.U32();
|
||||
string name = r.Text();
|
||||
var kind = (TrackKind)r.U8();
|
||||
int branch = r.U8();
|
||||
int category = r.U8();
|
||||
uint count = r.U32();
|
||||
if (count > r.Remaining / 12)
|
||||
throw new EndOfStreamException();
|
||||
var points = new Vector3[count];
|
||||
for (int i = 0; i < points.Length; i++)
|
||||
points[i] = r.Vec3();
|
||||
rec.AddTrack(new MapTrack(index, name, kind, branch, category, points));
|
||||
break;
|
||||
}
|
||||
case RecordType.Signal:
|
||||
rec.AddSignal(new MapSignal((int)r.U32(), r.Text(), r.DVec3()));
|
||||
break;
|
||||
case RecordType.Vehicle:
|
||||
rec.AddVehicle(new VehicleInfo((int)r.U32(), r.Text(), r.Text(), r.Text(), r.F32(), r.F32(), r.U8()));
|
||||
break;
|
||||
case RecordType.VehicleRemoved:
|
||||
rec.RemoveVehicle(r.F64(), (int)r.U32());
|
||||
break;
|
||||
case RecordType.Cab:
|
||||
{
|
||||
var sample = new CabSample(r.F64(), (int)r.U32(), r.DVec3(), r.F32(), r.F32(), r.F32(), (Driver)r.U8(),
|
||||
(CabFlags)r.U8(), r.I8(), r.I8(), r.I8(), r.F32(), r.F32(), r.F32(), r.F32(),
|
||||
r.F32(),
|
||||
new AiReference(r.F32(), r.F32(), r.F32(), r.F32(), r.F32(), r.F32(), r.F32()),
|
||||
(CabDevices)r.U32(), r.F32(), r.F32(), r.F32(), (Lights)r.U16(), (Lights)r.U16());
|
||||
// The cab, the train protection and the lateral acceleration came later: a recording
|
||||
// made before them ends here.
|
||||
if (r.Remaining >= 6)
|
||||
sample = sample with { CabEnd = r.I8(), Security = (SecurityFlags)r.U8(), LateralAcceleration = r.F32() };
|
||||
// What the driver went by and the lights asked for came later still.
|
||||
if (r.Remaining >= 5)
|
||||
sample = sample with { Movement = (Movement)r.U8(), LightHintHead = (short)r.U16(), LightHintTail = (short)r.U16() };
|
||||
// The lamps the vehicles at the ends have, which a pattern is checked against, later again.
|
||||
if (r.Remaining >= 4)
|
||||
sample = sample with { EquippedHead = (Lights)r.U16(), EquippedTail = (Lights)r.U16() };
|
||||
// What drives it, the doors' permits and the rest, later again; the arguments are read in their order.
|
||||
if (r.Remaining >= 54)
|
||||
sample = sample with
|
||||
{
|
||||
Extra = new CabExtra((EngineKind)r.U8(), r.U8(), r.U8(), r.F32(), r.F32(), r.F32(), r.F32(), r.F32(),
|
||||
r.F32(), r.F32(), r.F32(), r.I8(), r.U8(), r.U8(), r.F32(), r.F32(), r.F32(), r.F32())
|
||||
};
|
||||
rec.AddCab(sample);
|
||||
break;
|
||||
}
|
||||
case RecordType.StopPoint:
|
||||
{
|
||||
var point = new MapStopPoint((int)r.U32(), r.Text(), r.DVec3(), r.F32(), r.F32());
|
||||
// The heading came later: a recording made before it ends here.
|
||||
if (r.Remaining >= 4)
|
||||
point = point with { Heading = r.F32() };
|
||||
rec.AddStopPoint(point);
|
||||
break;
|
||||
}
|
||||
case RecordType.HornPoint:
|
||||
rec.AddHornPoint(new MapHornPoint((int)r.U32(), r.Text(), r.DVec3(), r.F32(), r.F32()));
|
||||
break;
|
||||
case RecordType.Timetable:
|
||||
{
|
||||
double time = r.F64();
|
||||
int vehicle = (int)r.U32();
|
||||
string train = r.Text(), category = r.Text(), from = r.Text(), to = r.Text();
|
||||
float vmax = r.F32();
|
||||
uint count = r.U32();
|
||||
// A row holds at least its empty name, four numbers and a flag.
|
||||
if (count > r.Remaining / 18)
|
||||
throw new EndOfStreamException();
|
||||
var rows = new TimetableRow[count];
|
||||
for (int i = 0; i < rows.Length; i++)
|
||||
rows[i] = new TimetableRow(r.Text(), r.F32(), r.F32(), r.F32(), r.F32(), r.U8() != 0);
|
||||
rec.AddTimetable(new Timetable(time, vehicle, train, category, from, to, vmax, rows));
|
||||
break;
|
||||
}
|
||||
case RecordType.Station:
|
||||
rec.AddStation(new StationEvent(r.F64(), (int)r.U32(), (StationKind)r.U8(), r.U16(), r.Text(), r.DVec3(),
|
||||
r.F32(), r.F32(), r.F32(), r.F32(), r.F32(), r.F32()));
|
||||
break;
|
||||
case RecordType.Camera:
|
||||
rec.AddCamera(new CameraState(r.F64(), (CameraMode)r.U8(), r.U8(), (int)r.U32(), r.DVec3(), r.F32(), r.F32()));
|
||||
break;
|
||||
case RecordType.Coupling:
|
||||
rec.AddCoupling(new CouplingChange(r.F64(), (int)r.U32(), r.U8(), (int)r.U32(), r.U8(), (CouplingFlags)r.U8(),
|
||||
(CouplingFlags)r.U8(), (int)r.U32(), r.DVec3()));
|
||||
break;
|
||||
case RecordType.HornRequest:
|
||||
rec.AddHornRequest(new HornRequest(r.F64(), (int)r.U32(), r.F32(), r.F32(), r.DVec3()));
|
||||
break;
|
||||
case RecordType.Vehicles:
|
||||
{
|
||||
double time = r.F64();
|
||||
bool keyframe = r.U8() != 0;
|
||||
uint count = r.U32();
|
||||
if (count > r.Remaining / 25)
|
||||
throw new EndOfStreamException();
|
||||
var states = new VehicleState[count];
|
||||
for (int i = 0; i < states.Length; i++)
|
||||
states[i] = new VehicleState((int)r.U32(), r.Vec3(), r.F32(), r.F32(), (Driver)r.U8());
|
||||
rec.AddLook(new VehicleLook(time, keyframe, states));
|
||||
break;
|
||||
}
|
||||
case RecordType.SwitchState:
|
||||
rec.AddSwitch(new SwitchChange(r.F64(), (int)r.U32(), r.U8()));
|
||||
break;
|
||||
case RecordType.SignalAspect:
|
||||
rec.AddAspect(new AspectChange(r.F64(), (int)r.U32(), r.F32(), r.F32(), r.Text()));
|
||||
break;
|
||||
case RecordType.Command:
|
||||
{
|
||||
double time = r.F64();
|
||||
int vehicle = (int)r.U32();
|
||||
bool pressed = r.U8() != 0;
|
||||
double param1 = r.F64(), param2 = r.F64();
|
||||
rec.AddCommand(new CommandEntry(time, vehicle, pressed, param1, param2, r.Text()));
|
||||
break;
|
||||
}
|
||||
case RecordType.DriverChange:
|
||||
rec.AddDriverChange(new DriverChange(r.F64(), (int)r.U32(), (Driver)r.U8()));
|
||||
break;
|
||||
case RecordType.SignalPassed:
|
||||
{
|
||||
double time = r.F64();
|
||||
int vehicle = (int)r.U32();
|
||||
int signal = unchecked((int)r.U32());
|
||||
rec.AddPassing(new SignalPassing(time, vehicle, signal, r.Text(), r.DVec3(), r.F32(), r.F32(), r.F32(),
|
||||
r.F32(), (PassingFlags)r.U8()));
|
||||
break;
|
||||
}
|
||||
case RecordType.UserVehicle:
|
||||
{
|
||||
var change = new UserVehicleChange(r.F64(), (int)r.U32(), r.U8() != 0);
|
||||
if (r.Remaining >= 1)
|
||||
change = change with { CabEnd = r.I8() };
|
||||
rec.AddUserVehicle(change);
|
||||
break;
|
||||
}
|
||||
case RecordType.Clock:
|
||||
rec.AddClock(new ClockEntry(r.F64(), r.U16(), r.U16(), r.F64()));
|
||||
break;
|
||||
case RecordType.End:
|
||||
rec.EndTime = r.F64();
|
||||
break;
|
||||
default:
|
||||
// A type of a later version: its length lets it be passed over.
|
||||
rec.Unknown++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The fields of a record in the order they come, little-endian. A field past the end throws.</summary>
|
||||
private ref struct PayloadReader
|
||||
{
|
||||
private readonly ReadOnlySpan<byte> _bytes;
|
||||
private int _at;
|
||||
|
||||
public PayloadReader(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
_bytes = bytes;
|
||||
_at = 0;
|
||||
}
|
||||
|
||||
public int Remaining => _bytes.Length - _at;
|
||||
|
||||
private ReadOnlySpan<byte> Take(int count)
|
||||
{
|
||||
if (Remaining < count)
|
||||
throw new EndOfStreamException();
|
||||
var span = _bytes.Slice(_at, count);
|
||||
_at += count;
|
||||
return span;
|
||||
}
|
||||
|
||||
public byte U8() => Take(1)[0];
|
||||
public sbyte I8() => unchecked((sbyte)Take(1)[0]);
|
||||
public ushort U16() => BinaryPrimitives.ReadUInt16LittleEndian(Take(2));
|
||||
public uint U32() => BinaryPrimitives.ReadUInt32LittleEndian(Take(4));
|
||||
public float F32() => BinaryPrimitives.ReadSingleLittleEndian(Take(4));
|
||||
public double F64() => BinaryPrimitives.ReadDoubleLittleEndian(Take(8));
|
||||
public Vector3 Vec3() => new(F32(), F32(), F32());
|
||||
public Position DVec3() => new(F64(), F64(), F64());
|
||||
|
||||
/// <summary>UTF-8 after its length in 7 bit groups, low first, as BinaryWriter writes a string.</summary>
|
||||
public string Text()
|
||||
{
|
||||
int length = 0;
|
||||
for (int shift = 0; ; shift += 7)
|
||||
{
|
||||
if (shift > 28)
|
||||
throw new EndOfStreamException();
|
||||
byte b = U8();
|
||||
length |= (b & 0x7f) << shift;
|
||||
if (b < 0x80)
|
||||
break;
|
||||
}
|
||||
if (length < 0)
|
||||
throw new EndOfStreamException();
|
||||
return length == 0 ? "" : Encoding.UTF8.GetString(Take(length));
|
||||
}
|
||||
}
|
||||
}
|
||||
196
Eu07.BlackBox/DrivingSummary.cs
Normal file
196
Eu07.BlackBox/DrivingSummary.cs
Normal file
@@ -0,0 +1,196 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace Eu07.BlackBox;
|
||||
|
||||
/// <summary>How a sample of the user's train stands against what the AI would have done.</summary>
|
||||
public enum SpeedJudgement
|
||||
{
|
||||
/// <summary>Nothing to set it against: the consist not started, or the user out of the cab.</summary>
|
||||
NoData,
|
||||
/// <summary>The AI drove.</summary>
|
||||
Autopilot,
|
||||
/// <summary>At most the speed the AI would hold.</summary>
|
||||
Within,
|
||||
/// <summary>Faster, by no more than the tolerance.</summary>
|
||||
Slightly,
|
||||
/// <summary>Faster by more than the tolerance.</summary>
|
||||
Over
|
||||
}
|
||||
|
||||
/// <summary>What the user's driving came to, set against the AI's.</summary>
|
||||
public sealed record DrivingSummary(
|
||||
double Duration,
|
||||
double DrivenSeconds,
|
||||
double AutopilotSeconds,
|
||||
double DistanceKm,
|
||||
float MaxSpeedKmh,
|
||||
double OverAiSeconds,
|
||||
double WellOverAiSeconds,
|
||||
float MaxOverAiKmh,
|
||||
double MaxOverAiAt,
|
||||
int SignalsPassed,
|
||||
IReadOnlyList<SignalPassing> PassedAtDanger,
|
||||
int Commands,
|
||||
int AutopilotSwitches,
|
||||
float MaxAcceleration,
|
||||
float MaxDeceleration,
|
||||
float MaxLateralAcceleration,
|
||||
int Stops,
|
||||
int StopsMissed,
|
||||
double? MaxDelayMinutes,
|
||||
int HornPoints,
|
||||
int HornsMissed,
|
||||
int SecurityBrakings)
|
||||
{
|
||||
/// <summary>The largest delay the user's train left a station or passed it with [min], null without any.</summary>
|
||||
public double? MaxDepartureDelayMinutes { get; init; }
|
||||
|
||||
/// <summary>How long the train moved with its light signals known to be right or wrong [s].</summary>
|
||||
public double LightsJudgedSeconds { get; init; }
|
||||
|
||||
/// <summary>How long of that its head or its tail showed other signals than it was to [s].</summary>
|
||||
public double WrongLightsSeconds { get; init; }
|
||||
|
||||
/// <summary>Each stretch the train moved with the wrong signals, as they were.</summary>
|
||||
public IReadOnlyList<LightsFault> LightsFaults { get; init; } = Array.Empty<LightsFault>();
|
||||
|
||||
/// <summary>The stops where the train did not fit along the platform, and how far its tail stood off it [m].</summary>
|
||||
public IReadOnlyList<(string Station, double Metres)> OffPlatform { get; init; } = Array.Empty<(string, double)>();
|
||||
|
||||
/// <summary>[km/h] over the speed of the AI before it counts as well over.</summary>
|
||||
public const float DefaultTolerance = 5f;
|
||||
|
||||
/// <summary>
|
||||
/// The speeds are whole km/h for the AI and the needle of the user's train shakes:
|
||||
/// half a km/h over is still within.
|
||||
/// </summary>
|
||||
private const float Rounding = 0.5f;
|
||||
|
||||
public static SpeedJudgement Judge(CabSample sample, float tolerance = DefaultTolerance)
|
||||
{
|
||||
if (sample.Driving == Driver.Ai)
|
||||
return SpeedJudgement.Autopilot;
|
||||
if (!sample.AiValid || sample.Driving != Driver.Human)
|
||||
return SpeedJudgement.NoData;
|
||||
float over = sample.AbsSpeedKmh - sample.Ai.TargetKmh;
|
||||
return over <= Rounding ? SpeedJudgement.Within : over <= tolerance ? SpeedJudgement.Slightly : SpeedJudgement.Over;
|
||||
}
|
||||
|
||||
internal static DrivingSummary Of(Recording recording, float tolerance)
|
||||
{
|
||||
var cab = recording.Cab;
|
||||
double driven = 0, autopilot = 0, distance = 0, over = 0, wellOver = 0, maxOverAt = 0;
|
||||
float maxSpeed = 0, maxOver = 0, maxAcceleration = 0, maxDeceleration = 0, maxLateral = 0;
|
||||
// The accelerations over half a second: a sample alone jumps with each jolt of the couplers.
|
||||
var window = new Queue<(float Along, float Across)>();
|
||||
float sumAlong = 0, sumAcross = 0;
|
||||
double lightsJudged = 0, lightsWrong = 0;
|
||||
var faults = new List<LightsFault>();
|
||||
// A step longer than this is a gap in the samples, not driving.
|
||||
double gap = (recording.Info is { CabInterval: > 0 } info ? info.CabInterval : 0.1) * 5;
|
||||
for (int i = 1; i < cab.Count; i++)
|
||||
{
|
||||
var before = cab[i - 1];
|
||||
var sample = cab[i];
|
||||
double step = sample.Time - before.Time;
|
||||
if (step <= 0 || step > gap || sample.Vehicle != before.Vehicle)
|
||||
{
|
||||
window.Clear();
|
||||
sumAlong = sumAcross = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
distance += (before.AbsSpeedKmh + sample.AbsSpeedKmh) / 2 / 3.6 * step / 1000;
|
||||
window.Enqueue((sample.Acceleration, sample.LateralAcceleration));
|
||||
sumAlong += sample.Acceleration;
|
||||
sumAcross += sample.LateralAcceleration;
|
||||
if (window.Count > 5)
|
||||
{
|
||||
var (along, across) = window.Dequeue();
|
||||
sumAlong -= along;
|
||||
sumAcross -= across;
|
||||
}
|
||||
if (window.Count == 5 && sample.AbsSpeedKmh > 1)
|
||||
{
|
||||
maxAcceleration = Math.Max(maxAcceleration, sumAlong / 5);
|
||||
maxDeceleration = Math.Max(maxDeceleration, -sumAlong / 5);
|
||||
maxLateral = Math.Max(maxLateral, Math.Abs(sumAcross / 5));
|
||||
}
|
||||
maxSpeed = Math.Max(maxSpeed, sample.AbsSpeedKmh);
|
||||
if (sample.Driving == Driver.Ai)
|
||||
autopilot += step;
|
||||
else if (sample.Driving == Driver.Human && sample.AbsSpeedKmh > Rounding)
|
||||
driven += step;
|
||||
|
||||
// the light signals at both ends, while the user drives the train and it moves: the autopilot's are its own
|
||||
if (sample.Driving == Driver.Human && sample.AbsSpeedKmh > 1 &&
|
||||
HeadCodes.HeadRight(sample) is { } headRight && HeadCodes.TailRight(sample) is { } tailRight)
|
||||
{
|
||||
lightsJudged += step;
|
||||
if (!headRight || !tailRight)
|
||||
{
|
||||
lightsWrong += step;
|
||||
var fault = new LightsFault(before.Time, sample.Time, HeadCodes.Of(sample.LightsHead), HeadCodes.RequiredHead(sample)!.Value,
|
||||
!headRight, HeadCodes.Of(sample.LightsTail), HeadCodes.RequiredTail(sample)!.Value, !tailRight);
|
||||
if (faults.Count > 0 && faults[^1].SameAs(fault) && fault.Start - faults[^1].End < 2)
|
||||
faults[^1] = faults[^1] with { End = sample.Time };
|
||||
else
|
||||
faults.Add(fault);
|
||||
}
|
||||
}
|
||||
|
||||
var judgement = Judge(sample, tolerance);
|
||||
if (judgement is SpeedJudgement.Slightly or SpeedJudgement.Over)
|
||||
over += step;
|
||||
if (judgement == SpeedJudgement.Over)
|
||||
wellOver += step;
|
||||
if (judgement is SpeedJudgement.Slightly or SpeedJudgement.Over)
|
||||
{
|
||||
float by = sample.AbsSpeedKmh - sample.Ai.TargetKmh;
|
||||
if (by > maxOver)
|
||||
{
|
||||
maxOver = by;
|
||||
maxOverAt = sample.Time;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var passed = recording.UserPassings().ToList();
|
||||
int switches = recording.DriverChanges.Count(change =>
|
||||
recording.CabAt(change.Time + 0.5) is { } s && s.Vehicle == change.Vehicle);
|
||||
// Every timetable the user's train had, as one going on from another at a terminus.
|
||||
var stations = recording.Timetables.SelectMany(timetable => StationProgress.Of(recording, timetable)).ToList();
|
||||
// the largest delay at any station, coming to it or leaving it
|
||||
var delays = stations.SelectMany(s => new[] { s.ArrivalDelay, s.DepartureDelay }).OfType<double>().ToList();
|
||||
var departures = stations.Select(s => s.DepartureDelay).OfType<double>().ToList();
|
||||
var offPlatform = stations.Where(s => s.OnPlatform == false)
|
||||
.Select(s => (StationNames.Display(s.Row.Station), s.OffPlatform ?? 0))
|
||||
.ToList();
|
||||
var horns = recording.UserHornChecks().ToList();
|
||||
return new DrivingSummary(recording.Duration, driven, autopilot, distance, maxSpeed, over, wellOver, maxOver,
|
||||
maxOverAt, passed.Count, passed.Where(p => p.AtDanger).ToList(),
|
||||
recording.UserCommands().Count(), switches, maxAcceleration, maxDeceleration, maxLateral,
|
||||
stations.Count(s => s.Outcome == StationOutcome.Stopped),
|
||||
stations.Count(s => s.Outcome == StationOutcome.Missed),
|
||||
delays.Count > 0 ? delays.Max() : null,
|
||||
horns.Count, horns.Count(h => !h.Sounded), recording.SecurityBrakings().Count)
|
||||
{
|
||||
MaxDepartureDelayMinutes = departures.Count > 0 ? departures.Max() : null,
|
||||
LightsJudgedSeconds = lightsJudged,
|
||||
WrongLightsSeconds = lightsWrong,
|
||||
LightsFaults = faults,
|
||||
OffPlatform = offPlatform
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A stretch the user's train moved with other light signals than it was to show, at its head and its tail.</summary>
|
||||
public sealed record LightsFault(double Start, double End, HeadCode Head, HeadCode HeadRequired, bool HeadWrong,
|
||||
HeadCode Tail, HeadCode TailRequired, bool TailWrong)
|
||||
{
|
||||
public bool SameAs(LightsFault other) =>
|
||||
Head == other.Head && HeadRequired == other.HeadRequired && HeadWrong == other.HeadWrong &&
|
||||
Tail == other.Tail && TailRequired == other.TailRequired && TailWrong == other.TailWrong;
|
||||
}
|
||||
14
Eu07.BlackBox/Eu07.BlackBox.csproj
Normal file
14
Eu07.BlackBox/Eu07.BlackBox.csproj
Normal file
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- Reads the black box the simulator writes to physicslog/*.blackbox. Only the
|
||||
base library: the starter, the viewer and any other program can use it, and
|
||||
it goes through NativeAOT and trimming as they do. -->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
<IsTrimmable>true</IsTrimmable>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>Eu07.BlackBox</RootNamespace>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
167
Eu07.BlackBox/HeadCodes.cs
Normal file
167
Eu07.BlackBox/HeadCodes.cs
Normal file
@@ -0,0 +1,167 @@
|
||||
using System;
|
||||
|
||||
namespace Eu07.BlackBox;
|
||||
|
||||
/// <summary>What the driver of the user's train went by: the lights it is to show depend on it.</summary>
|
||||
public enum Movement : byte
|
||||
{
|
||||
/// <summary>No driver, or standing by.</summary>
|
||||
Other = 0,
|
||||
/// <summary>A train run: Pc 1 or Pc 2 at the head, Pc 5 at the tail.</summary>
|
||||
Train = 1,
|
||||
/// <summary>Shunting: Tb 1 at both ends.</summary>
|
||||
Shunting = 2,
|
||||
/// <summary>A recording made before it was written.</summary>
|
||||
Unknown = 0xFF
|
||||
}
|
||||
|
||||
/// <summary>The light signals at an end of a train.</summary>
|
||||
public enum HeadCode
|
||||
{
|
||||
Off,
|
||||
/// <summary>The head of a train: both headlights and the upper one.</summary>
|
||||
Pc1,
|
||||
/// <summary>The head of a train on the wrong track: the left red marker, the right headlight and the upper one.</summary>
|
||||
Pc2,
|
||||
/// <summary>The tail of a train: both red markers.</summary>
|
||||
Pc5,
|
||||
/// <summary>Shunting: one headlight.</summary>
|
||||
Tb1,
|
||||
/// <summary>Any other pattern.</summary>
|
||||
Other
|
||||
}
|
||||
|
||||
/// <summary>The light signals a train shows and those it is to show, as the simulator's driver sets them.</summary>
|
||||
public static class HeadCodes
|
||||
{
|
||||
private const Lights Signals = Lights.HeadlightLeft | Lights.RedMarkerLeft | Lights.HeadlightUpper | Lights.HeadlightRight |
|
||||
Lights.RedMarkerRight | Lights.RearEndSignals;
|
||||
|
||||
/// <summary>The signal the lamps given show; the high beams and the auxiliary lamps tell nothing.</summary>
|
||||
public static HeadCode Of(Lights lights)
|
||||
{
|
||||
var lit = lights & Signals;
|
||||
if ((lit & Lights.RearEndSignals) != 0)
|
||||
lit = (lit & ~Lights.RearEndSignals) | Lights.RedMarkerLeft | Lights.RedMarkerRight;
|
||||
return lit switch
|
||||
{
|
||||
Lights.None => HeadCode.Off,
|
||||
Lights.HeadlightLeft | Lights.HeadlightRight | Lights.HeadlightUpper => HeadCode.Pc1,
|
||||
Lights.RedMarkerLeft | Lights.HeadlightRight | Lights.HeadlightUpper => HeadCode.Pc2,
|
||||
Lights.RedMarkerLeft | Lights.RedMarkerRight => HeadCode.Pc5,
|
||||
Lights.HeadlightLeft or Lights.HeadlightRight => HeadCode.Tb1,
|
||||
_ => HeadCode.Other
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>The signal the head of the train is to show, null when nothing tells it.</summary>
|
||||
public static HeadCode? RequiredHead(CabSample sample) => sample.Movement switch
|
||||
{
|
||||
Movement.Train => sample.LightHintHead < 0 ? HeadCode.Pc1 : Of((Lights)sample.LightHintHead),
|
||||
Movement.Shunting => HeadCode.Tb1,
|
||||
_ => null
|
||||
};
|
||||
|
||||
/// <summary>The signal the tail of the train is to show, null when nothing tells it.</summary>
|
||||
public static HeadCode? RequiredTail(CabSample sample) => sample.Movement switch
|
||||
{
|
||||
Movement.Train => sample.LightHintTail < 0 ? HeadCode.Pc5 : Of((Lights)sample.LightHintTail),
|
||||
Movement.Shunting => HeadCode.Tb1,
|
||||
_ => null
|
||||
};
|
||||
|
||||
/// <summary>Whether the head of the train showed what it was to; null when nothing tells it.</summary>
|
||||
public static bool? HeadRight(CabSample sample) => NoLamps(sample.EquippedHead) ? null : sample.Movement switch
|
||||
{
|
||||
Movement.Train => Meets(sample.LightsHead, sample.EquippedHead, sample.LightHintHead, HeadCode.Pc1),
|
||||
Movement.Shunting => Shows(HeadCode.Tb1, sample.LightsHead, sample.EquippedHead),
|
||||
_ => null
|
||||
};
|
||||
|
||||
/// <summary>Whether the tail of the train showed what it was to; null when nothing tells it.</summary>
|
||||
public static bool? TailRight(CabSample sample) => NoLamps(sample.EquippedTail) ? null : sample.Movement switch
|
||||
{
|
||||
Movement.Train => Meets(sample.LightsTail, sample.EquippedTail, sample.LightHintTail, HeadCode.Pc5),
|
||||
Movement.Shunting => Shows(HeadCode.Tb1, sample.LightsTail, sample.EquippedTail),
|
||||
_ => null
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// An end with no lamp at all, a draisine's: the simulator still asks it for a signal it can't show, so it
|
||||
/// is not judged. A recording made before the lamps were written can't tell.
|
||||
/// </summary>
|
||||
public static bool NoLamps(Lights? equipped) => equipped == Lights.None;
|
||||
|
||||
/// <summary>Whether both ends showed what they were to; null when nothing tells it.</summary>
|
||||
public static bool? Right(CabSample sample) =>
|
||||
HeadRight(sample) is { } head && TailRight(sample) is { } tail ? head && tail : null;
|
||||
|
||||
private const Lights HighBeams = Lights.HighBeamLeft | Lights.HighBeamRight;
|
||||
private const Lights Auxiliary = Lights.AuxiliaryLeft | Lights.AuxiliaryRight;
|
||||
|
||||
// the pattern asked for at an end, the usual one when none is: the driver sets one asked for as it is
|
||||
private static bool Meets(Lights lit, Lights? equipped, short hint, HeadCode usual)
|
||||
{
|
||||
if (hint < 0)
|
||||
return Shows(usual, lit, equipped);
|
||||
var pattern = (Lights)hint;
|
||||
if (usual == HeadCode.Pc1 && pattern == (Lights.RedMarkerLeft | Lights.HeadlightRight | Lights.HeadlightUpper))
|
||||
return Shows(HeadCode.Pc2, lit, equipped);
|
||||
// a recording made before the lamps were written: the signal the pattern shows, as before
|
||||
return equipped is { } lamps ? (lit & ~HighBeams) == (pattern & lamps) : Of(lit) == Of(pattern);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the lamps lit at an end show the signal, as the simulator's driver checks it against the lamps
|
||||
/// the vehicle has there: a vehicle without the upper headlight shows Pc 1 with the two others, one with
|
||||
/// auxiliary lamps may show them instead, and one with no lamps for it the rear-end signals. A recording
|
||||
/// made before the lamps were written has the pattern alone. The high beams tell nothing.
|
||||
/// </summary>
|
||||
public static bool Shows(HeadCode code, Lights lit, Lights? equipped)
|
||||
{
|
||||
if (equipped is not { } lamps)
|
||||
return Of(lit) == code;
|
||||
lit &= ~HighBeams;
|
||||
bool auxiliary = (lamps & Auxiliary) != 0;
|
||||
switch (code)
|
||||
{
|
||||
case HeadCode.Pc1:
|
||||
{
|
||||
var pattern = lamps & (Lights.HeadlightLeft | Lights.HeadlightRight | Lights.HeadlightUpper);
|
||||
var fallback = lamps & (Auxiliary | Lights.HeadlightUpper);
|
||||
return (pattern != 0 && lit == pattern) || (auxiliary && lit == fallback) ||
|
||||
(pattern == 0 && fallback == 0 && lit == Lights.RearEndSignals);
|
||||
}
|
||||
case HeadCode.Pc2:
|
||||
{
|
||||
var pattern = lamps & (Lights.RedMarkerLeft | Lights.HeadlightRight | Lights.HeadlightUpper);
|
||||
var fallback = lamps & (Lights.RedMarkerLeft | Lights.AuxiliaryRight | Lights.HeadlightUpper);
|
||||
return lit == pattern || (auxiliary && lit == fallback);
|
||||
}
|
||||
case HeadCode.Pc5:
|
||||
{
|
||||
var pattern = lamps & (Lights.RedMarkerLeft | Lights.RedMarkerRight);
|
||||
var fallback = lamps & Lights.RearEndSignals;
|
||||
return (pattern != 0 && lit == pattern) || (fallback != 0 && lit == fallback);
|
||||
}
|
||||
case HeadCode.Tb1:
|
||||
// one headlight, of those there are
|
||||
return lit == (lamps & Lights.HeadlightLeft) || lit == (lamps & Lights.HeadlightRight);
|
||||
case HeadCode.Off:
|
||||
return lit == Lights.None;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The name of a signal as the rules give it.</summary>
|
||||
public static string Name(HeadCode code) => code switch
|
||||
{
|
||||
HeadCode.Pc1 => "Pc 1",
|
||||
HeadCode.Pc2 => "Pc 2",
|
||||
HeadCode.Pc5 => "Pc 5",
|
||||
HeadCode.Tb1 => "Tb 1",
|
||||
HeadCode.Off => "—",
|
||||
_ => "?"
|
||||
};
|
||||
}
|
||||
532
Eu07.BlackBox/Recording.cs
Normal file
532
Eu07.BlackBox/Recording.cs
Normal file
@@ -0,0 +1,532 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
|
||||
namespace Eu07.BlackBox;
|
||||
|
||||
/// <summary>A span of time, start included, end not.</summary>
|
||||
public readonly record struct TimeSpanRange(double Start, double End)
|
||||
{
|
||||
public double Length => End - Start;
|
||||
public bool Contains(double t) => t >= Start && t < End;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What a black box holds, as far as it has been read: the map, the vehicles and what
|
||||
/// happened to them over the time of the recording, which runs from 0 in seconds of
|
||||
/// simulation. The lists grow as more of a file still written is read; the queries
|
||||
/// answer for any moment of the time read so far.
|
||||
/// </summary>
|
||||
public sealed class Recording
|
||||
{
|
||||
private readonly List<MapTrack> _tracks = new();
|
||||
private readonly Dictionary<int, List<MapTrack>> _trackIndex = new();
|
||||
private readonly List<MapSignal> _signals = new();
|
||||
private readonly Dictionary<int, MapSignal> _signalIndex = new();
|
||||
private readonly Dictionary<int, VehicleInfo> _vehicles = new();
|
||||
private readonly List<CabSample> _cab = new();
|
||||
private readonly List<VehicleLook> _looks = new();
|
||||
private readonly Dictionary<int, List<(double Time, VehicleState State)>> _history = new();
|
||||
private readonly List<SwitchChange> _switches = new();
|
||||
private readonly Dictionary<int, List<SwitchChange>> _switchIndex = new();
|
||||
private readonly List<AspectChange> _aspects = new();
|
||||
private readonly Dictionary<int, List<AspectChange>> _aspectIndex = new();
|
||||
private readonly List<CommandEntry> _commands = new();
|
||||
private readonly List<DriverChange> _driverChanges = new();
|
||||
private readonly List<SignalPassing> _passings = new();
|
||||
private readonly List<UserVehicleChange> _userVehicles = new();
|
||||
private readonly List<ClockEntry> _clocks = new();
|
||||
private readonly List<MapStopPoint> _stopPoints = new();
|
||||
private readonly List<MapHornPoint> _hornPoints = new();
|
||||
private readonly List<Timetable> _timetables = new();
|
||||
private readonly List<StationEvent> _stations = new();
|
||||
private readonly List<HornRequest> _hornRequests = new();
|
||||
private readonly List<CameraState> _cameras = new();
|
||||
private readonly List<CouplingChange> _couplings = new();
|
||||
private double _lastTime;
|
||||
|
||||
public int Version { get; internal set; }
|
||||
public RecordingInfo? Info { get; internal set; }
|
||||
|
||||
/// <summary>The time of the end record: null while the simulator still writes, or if it didn't end the recording.</summary>
|
||||
public double? EndTime { get; internal set; }
|
||||
|
||||
public bool Complete => EndTime is not null;
|
||||
|
||||
/// <summary>Records of types this reader doesn't know, passed over.</summary>
|
||||
public int Unknown { get; internal set; }
|
||||
|
||||
/// <summary>Records shorter than their type says, passed over.</summary>
|
||||
public int Malformed { get; internal set; }
|
||||
|
||||
/// <summary>Goes up with every read that added something, for views to tell they have to look again.</summary>
|
||||
public int Revision { get; private set; }
|
||||
|
||||
public IReadOnlyList<MapTrack> Tracks => _tracks;
|
||||
public IReadOnlyList<MapSignal> Signals => _signals;
|
||||
public IReadOnlyDictionary<int, VehicleInfo> Vehicles => _vehicles;
|
||||
public IReadOnlyList<CabSample> Cab => _cab;
|
||||
public IReadOnlyList<VehicleLook> Looks => _looks;
|
||||
public IReadOnlyList<SwitchChange> Switches => _switches;
|
||||
public IReadOnlyList<AspectChange> Aspects => _aspects;
|
||||
public IReadOnlyList<CommandEntry> Commands => _commands;
|
||||
public IReadOnlyList<DriverChange> DriverChanges => _driverChanges;
|
||||
public IReadOnlyList<SignalPassing> Passings => _passings;
|
||||
public IReadOnlyList<UserVehicleChange> UserVehicles => _userVehicles;
|
||||
public IReadOnlyList<ClockEntry> Clocks => _clocks;
|
||||
public IReadOnlyList<MapStopPoint> StopPoints => _stopPoints;
|
||||
public IReadOnlyList<MapHornPoint> HornPoints => _hornPoints;
|
||||
public IReadOnlyList<Timetable> Timetables => _timetables;
|
||||
public IReadOnlyList<StationEvent> Stations => _stations;
|
||||
public IReadOnlyList<HornRequest> HornRequests => _hornRequests;
|
||||
public IReadOnlyList<CameraState> Cameras => _cameras;
|
||||
public IReadOnlyList<CouplingChange> Couplings => _couplings;
|
||||
|
||||
/// <summary>The time the recording covers so far.</summary>
|
||||
public double Duration => Math.Max(EndTime ?? 0, _lastTime);
|
||||
|
||||
/// <summary>The seconds between two looks at the vehicles.</summary>
|
||||
public double LookInterval => Info is { VehiclesInterval: > 0 } info ? info.VehiclesInterval : 1.0;
|
||||
|
||||
// ----- filled by the reader -----
|
||||
|
||||
internal void Changed()
|
||||
{
|
||||
++Revision;
|
||||
_memo.Clear();
|
||||
_consists.Clear();
|
||||
}
|
||||
|
||||
// What the questions about the user's train worked out, kept until a read adds something: the views ask them every frame.
|
||||
private readonly Dictionary<object, object> _memo = new();
|
||||
private readonly Dictionary<(int Vehicle, int Coupling), IReadOnlySet<int>> _consists = new();
|
||||
private static readonly object UserStationsKey = new(), UserPassingsKey = new(), UserHornChecksKey = new(), UserCouplingsKey = new();
|
||||
|
||||
/// <summary>What make gives, worked out once until the recording changes.</summary>
|
||||
internal T Memo<T>(object key, Func<T> make) where T : class
|
||||
{
|
||||
if (_memo.TryGetValue(key, out var kept))
|
||||
return (T)kept;
|
||||
var made = make();
|
||||
_memo[key] = made;
|
||||
return made;
|
||||
}
|
||||
|
||||
private void Saw(double time) => _lastTime = Math.Max(_lastTime, time);
|
||||
|
||||
internal void AddTrack(MapTrack track)
|
||||
{
|
||||
_tracks.Add(track);
|
||||
if (!_trackIndex.TryGetValue(track.Index, out var ways))
|
||||
_trackIndex[track.Index] = ways = new List<MapTrack>(1);
|
||||
ways.Add(track);
|
||||
}
|
||||
|
||||
internal void AddSignal(MapSignal signal)
|
||||
{
|
||||
_signals.Add(signal);
|
||||
_signalIndex[signal.Index] = signal;
|
||||
}
|
||||
|
||||
internal void AddVehicle(VehicleInfo vehicle) => _vehicles[vehicle.Id] = vehicle;
|
||||
|
||||
internal void RemoveVehicle(double time, int id)
|
||||
{
|
||||
Saw(time);
|
||||
if (_vehicles.TryGetValue(id, out var vehicle))
|
||||
vehicle.RemovedAt = time;
|
||||
}
|
||||
|
||||
internal void AddCab(CabSample sample)
|
||||
{
|
||||
Saw(sample.Time);
|
||||
_cab.Add(sample);
|
||||
}
|
||||
|
||||
internal void AddLook(VehicleLook look)
|
||||
{
|
||||
Saw(look.Time);
|
||||
_looks.Add(look);
|
||||
foreach (var state in look.States)
|
||||
{
|
||||
if (!_history.TryGetValue(state.Id, out var history))
|
||||
_history[state.Id] = history = new List<(double, VehicleState)>();
|
||||
history.Add((look.Time, state));
|
||||
}
|
||||
}
|
||||
|
||||
internal void AddSwitch(SwitchChange change)
|
||||
{
|
||||
Saw(change.Time);
|
||||
_switches.Add(change);
|
||||
if (!_switchIndex.TryGetValue(change.Track, out var list))
|
||||
_switchIndex[change.Track] = list = new List<SwitchChange>();
|
||||
list.Add(change);
|
||||
}
|
||||
|
||||
internal void AddAspect(AspectChange change)
|
||||
{
|
||||
Saw(change.Time);
|
||||
_aspects.Add(change);
|
||||
if (!_aspectIndex.TryGetValue(change.Signal, out var list))
|
||||
_aspectIndex[change.Signal] = list = new List<AspectChange>();
|
||||
list.Add(change);
|
||||
}
|
||||
|
||||
internal void AddCommand(CommandEntry command)
|
||||
{
|
||||
Saw(command.Time);
|
||||
_commands.Add(command);
|
||||
}
|
||||
|
||||
internal void AddDriverChange(DriverChange change)
|
||||
{
|
||||
Saw(change.Time);
|
||||
_driverChanges.Add(change);
|
||||
}
|
||||
|
||||
internal void AddPassing(SignalPassing passing)
|
||||
{
|
||||
Saw(passing.Time);
|
||||
_passings.Add(passing);
|
||||
}
|
||||
|
||||
internal void AddUserVehicle(UserVehicleChange change)
|
||||
{
|
||||
Saw(change.Time);
|
||||
_userVehicles.Add(change);
|
||||
}
|
||||
|
||||
internal void AddClock(ClockEntry clock)
|
||||
{
|
||||
Saw(clock.Time);
|
||||
_clocks.Add(clock);
|
||||
}
|
||||
|
||||
internal void AddStopPoint(MapStopPoint point) => _stopPoints.Add(point);
|
||||
|
||||
internal void AddHornPoint(MapHornPoint point) => _hornPoints.Add(point);
|
||||
|
||||
internal void AddTimetable(Timetable timetable)
|
||||
{
|
||||
Saw(timetable.Time);
|
||||
_timetables.Add(timetable);
|
||||
}
|
||||
|
||||
internal void AddStation(StationEvent station)
|
||||
{
|
||||
Saw(station.Time);
|
||||
_stations.Add(station);
|
||||
}
|
||||
|
||||
internal void AddCamera(CameraState camera)
|
||||
{
|
||||
Saw(camera.Time);
|
||||
_cameras.Add(camera);
|
||||
}
|
||||
|
||||
internal void AddCoupling(CouplingChange change)
|
||||
{
|
||||
Saw(change.Time);
|
||||
_couplings.Add(change);
|
||||
}
|
||||
|
||||
internal void AddHornRequest(HornRequest request)
|
||||
{
|
||||
Saw(request.Time);
|
||||
_hornRequests.Add(request);
|
||||
}
|
||||
|
||||
// ----- queries -----
|
||||
|
||||
public IReadOnlyList<MapTrack> TrackWays(int index) =>
|
||||
_trackIndex.TryGetValue(index, out var ways) ? ways : Array.Empty<MapTrack>();
|
||||
|
||||
public MapSignal? Signal(int index) => _signalIndex.GetValueOrDefault(index);
|
||||
|
||||
public VehicleInfo? Vehicle(int id) => _vehicles.GetValueOrDefault(id);
|
||||
|
||||
/// <summary>The last of a list sorted by time at or before t: -1 if none is.</summary>
|
||||
private static int LastAtOrBefore<T>(IReadOnlyList<T> list, double t, Func<T, double> time)
|
||||
{
|
||||
int low = 0, high = list.Count - 1, found = -1;
|
||||
while (low <= high)
|
||||
{
|
||||
int middle = low + (high - low) / 2;
|
||||
if (time(list[middle]) <= t)
|
||||
{
|
||||
found = middle;
|
||||
low = middle + 1;
|
||||
}
|
||||
else
|
||||
high = middle - 1;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/// <summary>The index of the sample of the user's train at or before t, -1 before the first.</summary>
|
||||
public int CabIndexAt(double t) => LastAtOrBefore(_cab, t, s => s.Time);
|
||||
|
||||
/// <summary>The sample of the user's train at or before t.</summary>
|
||||
public CabSample? CabAt(double t) => CabIndexAt(t) is var i and >= 0 ? _cab[i] : null;
|
||||
|
||||
/// <summary>
|
||||
/// Where the user's train was at t, between the two samples around it. Null before
|
||||
/// the first sample, and in a gap: the user out of any vehicle.
|
||||
/// </summary>
|
||||
public Position? CabPositionAt(double t)
|
||||
{
|
||||
int i = CabIndexAt(t);
|
||||
if (i < 0)
|
||||
return null;
|
||||
var before = _cab[i];
|
||||
if (i + 1 >= _cab.Count)
|
||||
return t - before.Time <= 1.0 ? before.Position : null;
|
||||
var after = _cab[i + 1];
|
||||
if (after.Vehicle != before.Vehicle || after.Time - before.Time > 1.0)
|
||||
return t - before.Time <= 1.0 ? before.Position : null;
|
||||
double f = (t - before.Time) / Math.Max(1e-9, after.Time - before.Time);
|
||||
return Position.Lerp(before.Position, after.Position, Math.Clamp(f, 0, 1));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every vehicle on the scenery at t, where it was. A vehicle is listed only when it
|
||||
/// moved, so between two listings far apart it stood at the first place until the
|
||||
/// look before the second.
|
||||
/// </summary>
|
||||
public IReadOnlyList<VehicleState> VehiclesAt(double t)
|
||||
{
|
||||
var states = new List<VehicleState>(_history.Count);
|
||||
double interval = LookInterval;
|
||||
foreach (var (id, history) in _history)
|
||||
{
|
||||
if (_vehicles.TryGetValue(id, out var info) && info.RemovedAt is { } removed && removed <= t)
|
||||
continue;
|
||||
int i = LastAtOrBefore(history, t, entry => entry.Time);
|
||||
if (i < 0)
|
||||
continue;
|
||||
var (time, state) = history[i];
|
||||
if (i + 1 < history.Count)
|
||||
{
|
||||
var (nextTime, next) = history[i + 1];
|
||||
double from = Math.Max(time, nextTime - interval * 1.5);
|
||||
if (t > from)
|
||||
state = Lerp(state, next, (t - from) / Math.Max(1e-9, nextTime - from));
|
||||
}
|
||||
states.Add(state);
|
||||
}
|
||||
return states;
|
||||
}
|
||||
|
||||
private static VehicleState Lerp(VehicleState a, VehicleState b, double f)
|
||||
{
|
||||
float k = (float)Math.Clamp(f, 0, 1);
|
||||
float turn = b.Heading - a.Heading;
|
||||
while (turn > MathF.PI) turn -= 2 * MathF.PI;
|
||||
while (turn < -MathF.PI) turn += 2 * MathF.PI;
|
||||
return a with
|
||||
{
|
||||
Position = Vector3.Lerp(a.Position, b.Position, k),
|
||||
Heading = a.Heading + turn * k,
|
||||
SpeedKmh = a.SpeedKmh + (b.SpeedKmh - a.SpeedKmh) * k,
|
||||
Driving = k < 0.5f ? a.Driving : b.Driving
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>The aspect a signal showed at t, null before it was first seen.</summary>
|
||||
public AspectChange? AspectAt(int signal, double t) =>
|
||||
_aspectIndex.TryGetValue(signal, out var list) && LastAtOrBefore(list, t, c => c.Time) is var i and >= 0
|
||||
? list[i]
|
||||
: null;
|
||||
|
||||
/// <summary>The position of a switch at t, -1 if unknown.</summary>
|
||||
public int SwitchAt(int track, double t) =>
|
||||
_switchIndex.TryGetValue(track, out var list) && LastAtOrBefore(list, t, c => c.Time) is var i and >= 0
|
||||
? list[i].State
|
||||
: -1;
|
||||
|
||||
/// <summary>The time of day of the simulation at t.</summary>
|
||||
public TimeSpan ClockAt(double t)
|
||||
{
|
||||
int i = LastAtOrBefore(_clocks, t, c => c.Time);
|
||||
double second = i >= 0
|
||||
? _clocks[i].SecondOfDay + (t - _clocks[i].Time)
|
||||
: (Info?.SecondOfDay ?? 0) + t;
|
||||
second %= 86400;
|
||||
if (second < 0)
|
||||
second += 86400;
|
||||
return TimeSpan.FromSeconds(second);
|
||||
}
|
||||
|
||||
/// <summary>The vehicle the user was in at t, and whether in its cab; null before it was known.</summary>
|
||||
public UserVehicleChange? UserVehicleAt(double t) =>
|
||||
LastAtOrBefore(_userVehicles, t, c => c.Time) is var i and >= 0 ? _userVehicles[i] : null;
|
||||
|
||||
/// <summary>The stretches of the samples of the user's train the condition holds over.</summary>
|
||||
public IReadOnlyList<TimeSpanRange> CabSpans(Func<CabSample, bool> condition)
|
||||
{
|
||||
var spans = new List<TimeSpanRange>();
|
||||
double? start = null;
|
||||
double gap = (Info is { CabInterval: > 0 } info ? info.CabInterval : 0.1) * 5;
|
||||
for (int i = 0; i < _cab.Count; i++)
|
||||
{
|
||||
var sample = _cab[i];
|
||||
bool holds = condition(sample);
|
||||
bool broken = i > 0 && sample.Time - _cab[i - 1].Time > gap;
|
||||
if (start is { } open && (!holds || broken))
|
||||
{
|
||||
spans.Add(new TimeSpanRange(open, broken ? _cab[i - 1].Time : sample.Time));
|
||||
start = null;
|
||||
}
|
||||
if (holds && start is null)
|
||||
start = sample.Time;
|
||||
}
|
||||
if (start is { } last && _cab.Count > 0)
|
||||
spans.Add(new TimeSpanRange(last, _cab[^1].Time));
|
||||
return spans;
|
||||
}
|
||||
|
||||
/// <summary>When the AI drove the user's train: the autopilot.</summary>
|
||||
public IReadOnlyList<TimeSpanRange> AutopilotSpans() => CabSpans(s => s.Driving == Driver.Ai);
|
||||
|
||||
/// <summary>The signals the user's train passed: those passed by the vehicle the samples are of at the time.</summary>
|
||||
public IReadOnlyList<SignalPassing> UserPassings() =>
|
||||
Memo(UserPassingsKey, () => _passings.Where(p => IsUserTrain(p.Time + 0.5, p.Vehicle, p.Position, Math.Max(0, p.Overrun) + 250)).ToList());
|
||||
|
||||
/// <summary>
|
||||
/// Whether a vehicle was of the user's train at t. The records of a train give the
|
||||
/// vehicle at its head, which is not the one the user is in when the train is driven
|
||||
/// from its other end or from a car in the middle: those count when the place the
|
||||
/// record is about lies within reach of the user's vehicle.
|
||||
/// </summary>
|
||||
public bool IsUserTrain(double t, int vehicle, Position place, double reach)
|
||||
{
|
||||
if (CabAt(t) is not { } s || t - s.Time > 1.5)
|
||||
return false;
|
||||
if (s.Vehicle == vehicle)
|
||||
return true;
|
||||
// The couplings tell the train exactly; a recording made before them has the distance only.
|
||||
return CouplingsWritten ? ConsistAt(s.Vehicle, t).Contains(vehicle) : s.Position.DistanceTo(place) <= reach;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the couplings were written: a recording made since has the movement in its cab samples, and one with
|
||||
/// no train of more than one vehicle has no coupling at all.
|
||||
/// </summary>
|
||||
private bool CouplingsWritten => _couplings.Count > 0 || (_cab.Count > 0 && _cab[0].Movement != Movement.Unknown);
|
||||
|
||||
/// <summary>The vehicles coupled into one train with the one given at t, itself included.</summary>
|
||||
public IReadOnlySet<int> ConsistAt(int vehicle, double t)
|
||||
{
|
||||
// The trains change only at a coupling: the consist is the same until the next one.
|
||||
int last = LastAtOrBefore(_couplings, t, c => c.Time);
|
||||
if (_consists.TryGetValue((vehicle, last), out var kept))
|
||||
return kept;
|
||||
var consist = Consist(vehicle, last);
|
||||
_consists[(vehicle, last)] = consist;
|
||||
return consist;
|
||||
}
|
||||
|
||||
private HashSet<int> Consist(int vehicle, int last)
|
||||
{
|
||||
// What each coupler held after the coupling given: the couplings in the order written, each telling both ends.
|
||||
var held = new Dictionary<(int Vehicle, int Side), (int Other, int OtherSide)>();
|
||||
for (int i = 0; i <= last; i++)
|
||||
{
|
||||
var change = _couplings[i];
|
||||
if (held.Remove((change.Vehicle, change.Side), out var before))
|
||||
held.Remove((before.Other, before.OtherSide));
|
||||
if (change.Other != 0 && (change.Flags & CouplingFlags.Coupler) != 0)
|
||||
{
|
||||
held[(change.Vehicle, change.Side)] = (change.Other, change.OtherSide);
|
||||
held[(change.Other, change.OtherSide)] = (change.Vehicle, change.Side);
|
||||
}
|
||||
}
|
||||
var consist = new HashSet<int> { vehicle };
|
||||
var open = new Stack<int>();
|
||||
open.Push(vehicle);
|
||||
while (open.Count > 0)
|
||||
{
|
||||
int at = open.Pop();
|
||||
for (int side = 0; side < 2; side++)
|
||||
if (held.TryGetValue((at, side), out var next) && consist.Add(next.Other))
|
||||
open.Push(next.Other);
|
||||
}
|
||||
return consist;
|
||||
}
|
||||
|
||||
/// <summary>Where the user looked from at t, null before it was known.</summary>
|
||||
public CameraState? CameraAt(double t) =>
|
||||
LastAtOrBefore(_cameras, t, c => c.Time) is var i and >= 0 ? _cameras[i] : null;
|
||||
|
||||
/// <summary>
|
||||
/// The couplings made and undone after the start: those at t = 0 are what the trains
|
||||
/// were made of, not something done.
|
||||
/// </summary>
|
||||
public IEnumerable<CouplingChange> CouplingEvents() =>
|
||||
_couplings.Where(c => c.Time > 0.01 && (c.Coupled || c.Uncoupled || c.Flags != c.PreviousFlags));
|
||||
|
||||
/// <summary>The couplings the user's train took part in, before or after them.</summary>
|
||||
public IReadOnlyList<CouplingChange> UserCouplings() =>
|
||||
Memo(UserCouplingsKey, () => CouplingEvents().Where(c => CabAt(c.Time) is { } s && c.Time - s.Time <= 1.5 &&
|
||||
(ConsistAt(s.Vehicle, c.Time - 0.01).Contains(c.Vehicle) ||
|
||||
ConsistAt(s.Vehicle, c.Time + 0.01).Contains(c.Vehicle)))
|
||||
.ToList());
|
||||
|
||||
/// <summary>
|
||||
/// The timetable of the train the user was in at t: the last one written by then, else the first; none when the
|
||||
/// user had gone over to a vehicle of another consist, a train without a timetable or one standing on its own.
|
||||
/// </summary>
|
||||
public Timetable? TimetableAt(double t)
|
||||
{
|
||||
var timetable = LastAtOrBefore(_timetables, t, x => x.Time) is var i and >= 0 ? _timetables[i] : _timetables.FirstOrDefault();
|
||||
// A recording made before the couplings were written can't tell the trains apart: the timetable stays.
|
||||
if (timetable is null || CabAt(t) is not { } sample || sample.Vehicle == timetable.Vehicle || !CouplingsWritten)
|
||||
return timetable;
|
||||
// another vehicle of the same train (the other cab, a car of it) keeps the timetable
|
||||
return ConsistAt(sample.Vehicle, t).Contains(timetable.Vehicle) ? timetable : null;
|
||||
}
|
||||
|
||||
/// <summary>The stations of the timetable of the user's train, as it came to them.</summary>
|
||||
public IReadOnlyList<StationEvent> UserStations() =>
|
||||
Memo(UserStationsKey, () => _stations.Where(s => IsUserTrain(s.Time, s.Vehicle, s.Position, Math.Max(s.TrainLength, 50) + 150)).ToList());
|
||||
|
||||
/// <summary>The horn points the user's train passed, and whether the horn sounded there.</summary>
|
||||
public IReadOnlyList<HornCheck> UserHornChecks() => Memo(UserHornChecksKey, () => HornChecks().ToList());
|
||||
|
||||
private IEnumerable<HornCheck> HornChecks()
|
||||
{
|
||||
foreach (var request in _hornRequests)
|
||||
{
|
||||
if (!IsUserTrain(request.Time, request.Vehicle, request.Position, 300))
|
||||
continue;
|
||||
bool autopilot = CabAt(request.Time) is { Driving: Driver.Ai };
|
||||
yield return new HornCheck(request, HornSounded(request), autopilot);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The time given to sound the horn after a horn point, beyond how long it is to sound.</summary>
|
||||
public const double HornGrace = 8;
|
||||
|
||||
/// <summary>Whether the horn of the user's train sounded from just before the horn point until a while after.</summary>
|
||||
private bool HornSounded(HornRequest request)
|
||||
{
|
||||
double until = request.Time + Math.Max(request.Duration, 1) + HornGrace;
|
||||
for (int i = Math.Max(0, CabIndexAt(request.Time - 2)); i < _cab.Count && _cab[i].Time <= until; i++)
|
||||
if (_cab[i].Has(CabDevices.Horn))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>When the train protection braked the user's train: the alerter or the cab signalling not acknowledged.</summary>
|
||||
public IReadOnlyList<TimeSpanRange> SecurityBrakings() => CabSpans(s => s.Has(SecurityFlags.Braking));
|
||||
|
||||
/// <summary>The commands the user gave in a vehicle, presses only.</summary>
|
||||
public IEnumerable<CommandEntry> UserCommands() => _commands.Where(c => c.Pressed && c.Vehicle != 0);
|
||||
|
||||
public DrivingSummary Summarize(float tolerance = DrivingSummary.DefaultTolerance) => DrivingSummary.Of(this, tolerance);
|
||||
}
|
||||
|
||||
/// <summary>A horn point the user's train passed: whether the horn sounded, and whether the autopilot drove then.</summary>
|
||||
public sealed record HornCheck(HornRequest Request, bool Sounded, bool Autopilot);
|
||||
406
Eu07.BlackBox/Records.cs
Normal file
406
Eu07.BlackBox/Records.cs
Normal file
@@ -0,0 +1,406 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
|
||||
namespace Eu07.BlackBox;
|
||||
|
||||
/// <summary>The types of the records, as the simulator writes them (blackbox_doc.md in its repository).</summary>
|
||||
public enum RecordType : byte
|
||||
{
|
||||
Info = 0x01,
|
||||
Track = 0x10,
|
||||
Signal = 0x11,
|
||||
Vehicle = 0x12,
|
||||
VehicleRemoved = 0x13,
|
||||
StopPoint = 0x14,
|
||||
HornPoint = 0x15,
|
||||
Cab = 0x20,
|
||||
Vehicles = 0x21,
|
||||
SwitchState = 0x22,
|
||||
SignalAspect = 0x23,
|
||||
Timetable = 0x24,
|
||||
Camera = 0x25,
|
||||
Command = 0x30,
|
||||
DriverChange = 0x31,
|
||||
SignalPassed = 0x32,
|
||||
UserVehicle = 0x33,
|
||||
Clock = 0x34,
|
||||
Station = 0x35,
|
||||
HornRequest = 0x36,
|
||||
Coupling = 0x37,
|
||||
End = 0x3f
|
||||
}
|
||||
|
||||
/// <summary>Who drives a vehicle.</summary>
|
||||
public enum Driver : byte
|
||||
{
|
||||
None = 0,
|
||||
Human = 1,
|
||||
Ai = 2
|
||||
}
|
||||
|
||||
[Flags]
|
||||
public enum CabFlags : byte
|
||||
{
|
||||
None = 0,
|
||||
/// <summary>The user is in the cab, not looking around outside.</summary>
|
||||
InCab = 0x01,
|
||||
/// <summary>The consist is started, so what the AI would do is worked out.</summary>
|
||||
AiValid = 0x02,
|
||||
/// <summary>The alerter or the cab signalling blinks.</summary>
|
||||
AlerterBlinking = 0x04,
|
||||
/// <summary>The alerter or the cab signalling beeps.</summary>
|
||||
AlerterBeeping = 0x08,
|
||||
WheelSlip = 0x10,
|
||||
RadioStop = 0x20,
|
||||
AlarmChain = 0x40
|
||||
}
|
||||
|
||||
/// <summary>What is switched on in the user's train.</summary>
|
||||
[Flags]
|
||||
public enum CabDevices : uint
|
||||
{
|
||||
None = 0,
|
||||
Battery = 0x0001,
|
||||
MainBreaker = 0x0002,
|
||||
PantographFront = 0x0004,
|
||||
PantographRear = 0x0008,
|
||||
Converter = 0x0010,
|
||||
Compressor = 0x0020,
|
||||
/// <summary>The motors are connected.</summary>
|
||||
LineContactors = 0x0040,
|
||||
/// <summary>Tripped.</summary>
|
||||
OverloadRelay = 0x0080,
|
||||
Sanding = 0x0100,
|
||||
Horn = 0x0200,
|
||||
DoorsLeft = 0x0400,
|
||||
DoorsRight = 0x0800,
|
||||
Radio = 0x1000,
|
||||
Heating = 0x2000,
|
||||
/// <summary>Applied.</summary>
|
||||
SpringBrake = 0x4000,
|
||||
ShuntMode = 0x8000,
|
||||
/// <summary>Sounding.</summary>
|
||||
DepartureSignal = 0x0001_0000,
|
||||
/// <summary>The doors may be opened.</summary>
|
||||
DoorPermitLeft = 0x0002_0000,
|
||||
DoorPermitRight = 0x0004_0000,
|
||||
/// <summary>The steps come out with the doors.</summary>
|
||||
DoorStep = 0x0008_0000,
|
||||
/// <summary>The doors lock above a speed.</summary>
|
||||
DoorLock = 0x0010_0000,
|
||||
/// <summary>Locked now.</summary>
|
||||
DoorsLocked = 0x0020_0000,
|
||||
/// <summary>Releasing the brakes of the vehicle.</summary>
|
||||
BrakeReleaser = 0x0040_0000,
|
||||
FuelPump = 0x0080_0000,
|
||||
OilPump = 0x0100_0000,
|
||||
/// <summary>The fans of the traction motors.</summary>
|
||||
MotorBlowers = 0x0200_0000,
|
||||
/// <summary>The diesel engine is over its temperature.</summary>
|
||||
EngineHot = 0x0400_0000,
|
||||
/// <summary>The cruise control holds a speed.</summary>
|
||||
SpeedControl = 0x0800_0000
|
||||
}
|
||||
|
||||
/// <summary>What drives the vehicle whose motors the cab drives.</summary>
|
||||
public enum EngineKind : byte
|
||||
{
|
||||
/// <summary>Nothing of its own: a draisine, a car.</summary>
|
||||
None = 0,
|
||||
Electric = 1,
|
||||
ElectricInduction = 2,
|
||||
/// <summary>With a mechanical or a hydraulic transmission.</summary>
|
||||
Diesel = 3,
|
||||
DieselElectric = 4,
|
||||
Steam = 5
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The rest of a look at the user's train, written from the same version as the doors' permits: what drives it,
|
||||
/// the brakes' setting and mode, the hand brake, the radio, the horn, the pressure the driver's brake valve shows
|
||||
/// (its equalizing reservoir, the FV4a's time reservoir) and that of the main reservoir pipe, a diesel's oil, temperatures, clutch and torque converter, the cruise control,
|
||||
/// the power and the integrated controller of a unit, the highest brake cylinder pressure of the train. The
|
||||
/// temperatures and the oil mean something for a diesel only.
|
||||
/// </summary>
|
||||
public readonly record struct CabExtra(EngineKind Engine, byte BrakeDelay, byte RadioChannel, float DriverValve,
|
||||
float FeedPipe, float OilPressure, float WaterTemperature, float OilTemperature,
|
||||
float EngineTemperature, float SpeedControlKmh, float EnginePower, sbyte HandBrake,
|
||||
byte BrakeMode, byte Horn, float IntegratedController, float Clutch, float ConverterFill,
|
||||
float ConsistBrakeCylinder);
|
||||
|
||||
/// <summary>The train protection: the alerter (CA) and the cab signalling (SHP) apart.</summary>
|
||||
[Flags]
|
||||
public enum SecurityFlags : byte
|
||||
{
|
||||
None = 0,
|
||||
/// <summary>The alerter lamp blinks: the driver is to acknowledge.</summary>
|
||||
Vigilance = 0x01,
|
||||
/// <summary>The cab signalling lamp blinks: an SHP magnet was passed.</summary>
|
||||
CabSignal = 0x02,
|
||||
VigilanceBeeping = 0x04,
|
||||
CabSignalBeeping = 0x08,
|
||||
/// <summary>Not acknowledged in time: the train protection brakes the train.</summary>
|
||||
Braking = 0x10
|
||||
}
|
||||
|
||||
/// <summary>What a train did at a station of its timetable.</summary>
|
||||
public enum StationKind : byte
|
||||
{
|
||||
/// <summary>Stopped at the stop point of a stop of its timetable.</summary>
|
||||
Stopped = 1,
|
||||
/// <summary>Passed a station where the timetable has no stop.</summary>
|
||||
Passed = 2,
|
||||
/// <summary>Left the stop point behind before it was time to go: without stopping, or leaving early.</summary>
|
||||
Missed = 3,
|
||||
/// <summary>Still at the stop point when it was time to go.</summary>
|
||||
Ready = 4
|
||||
}
|
||||
|
||||
/// <summary>The lights lit at an end of a vehicle.</summary>
|
||||
[Flags]
|
||||
public enum Lights : ushort
|
||||
{
|
||||
None = 0,
|
||||
HeadlightLeft = 0x001,
|
||||
RedMarkerLeft = 0x002,
|
||||
HeadlightUpper = 0x004,
|
||||
HeadlightRight = 0x010,
|
||||
RedMarkerRight = 0x020,
|
||||
RearEndSignals = 0x040,
|
||||
AuxiliaryLeft = 0x080,
|
||||
AuxiliaryRight = 0x100,
|
||||
HighBeamLeft = 0x200,
|
||||
HighBeamRight = 0x400
|
||||
}
|
||||
|
||||
[Flags]
|
||||
public enum PassingFlags : byte
|
||||
{
|
||||
None = 0,
|
||||
/// <summary>A shunting signal.</summary>
|
||||
Shunt = 0x01,
|
||||
/// <summary>Passed showing stop: a signal passed at danger.</summary>
|
||||
AtStop = 0x02,
|
||||
/// <summary>Stop of an automatic block signal, which may be passed slowly after a stop.</summary>
|
||||
AutomaticBlock = 0x04
|
||||
}
|
||||
|
||||
public enum TrackKind : byte
|
||||
{
|
||||
Normal = 0,
|
||||
SwitchBranch = 1
|
||||
}
|
||||
|
||||
/// <summary>A position in the frame of the scenery: metres, y up.</summary>
|
||||
public readonly record struct Position(double X, double Y, double Z)
|
||||
{
|
||||
public static Position Lerp(Position a, Position b, double f) =>
|
||||
new(a.X + (b.X - a.X) * f, a.Y + (b.Y - a.Y) * f, a.Z + (b.Z - a.Z) * f);
|
||||
|
||||
public double DistanceTo(Position other)
|
||||
{
|
||||
double dx = X - other.X, dy = Y - other.Y, dz = Z - other.Z;
|
||||
return Math.Sqrt(dx * dx + dy * dy + dz * dz);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record RecordingInfo(string Scenery, string Started, int Year, int YearDay, double SecondOfDay,
|
||||
string StartVehicle, string SimulatorVersion, float CabInterval,
|
||||
float VehiclesInterval, float KeyframeInterval)
|
||||
{
|
||||
/// <summary>The time of day the simulation started at.</summary>
|
||||
public TimeSpan StartClock => TimeSpan.FromSeconds(SecondOfDay);
|
||||
}
|
||||
|
||||
/// <summary>One rail track of the map, or one of the two ways of a switch, which share the number.</summary>
|
||||
public sealed record MapTrack(int Index, string Name, TrackKind Kind, int Branch, int Category,
|
||||
IReadOnlyList<Vector3> Points);
|
||||
|
||||
public sealed record MapSignal(int Index, string Name, Position Position);
|
||||
|
||||
/// <summary>
|
||||
/// A stop point of a passenger train (W4). StopAt: below 0 the head stops that many metres
|
||||
/// before it, above 0 the middle of the train. Platform: above 0 its length, below 0 the
|
||||
/// stop is for trains shorter than that (W32), 0 not given; its last digit also tells the side of
|
||||
/// the platform (see <see cref="Platforms"/>).
|
||||
/// </summary>
|
||||
public sealed record MapStopPoint(int Index, string Station, Position Position, float StopAt, float Platform)
|
||||
{
|
||||
/// <summary>
|
||||
/// The heading of the trains it is for [rad, from +z towards +x], NaN when not known: on no track, on tracks of
|
||||
/// both ways, or in a recording made before it was written.
|
||||
/// </summary>
|
||||
public float Heading { get; init; } = float.NaN;
|
||||
|
||||
public bool HasHeading => !float.IsNaN(Heading);
|
||||
|
||||
public bool HasPlatform => Platform > 0;
|
||||
|
||||
/// <summary>A W32: the stop of the trains shorter than its length; the longer ones go on to another stop point.</summary>
|
||||
public bool ForShortTrains => Platform < 0;
|
||||
|
||||
/// <summary>The length of the platform, or of the trains a W32 is for [m].</summary>
|
||||
public double PlatformLength => Platforms.Length(Platform);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The second value of a stop point, as the simulator's driver reads it: the whole of it the metres of the
|
||||
/// platform, and its last digit, besides, the side the doors open on (1, 2, both 3); 201 is 201 m.
|
||||
/// </summary>
|
||||
public static class Platforms
|
||||
{
|
||||
public static double Length(float value) => Math.Abs(value);
|
||||
|
||||
public static int Side(float value) => (int)Math.Floor(Math.Abs(value)) % 10;
|
||||
}
|
||||
|
||||
/// <summary>A horn point (W6): the trains passing it are to sound the horn this long, in this tone (1 low, 2 high).</summary>
|
||||
public sealed record MapHornPoint(int Index, string Name, Position Position, float Duration, float Tone);
|
||||
|
||||
/// <summary>A station of a timetable; the times in minutes of the day, below 0 none.</summary>
|
||||
public sealed record TimetableRow(string Station, float Km, float Vmax, float Arrival, float Departure, bool Maintenance)
|
||||
{
|
||||
/// <summary>The train stops there: a station passed through has a departure only.</summary>
|
||||
public bool IsStop => Arrival >= 0;
|
||||
}
|
||||
|
||||
/// <summary>The timetable of the train the user was in. The rows are numbered from 1, the station records give their number.</summary>
|
||||
public sealed record Timetable(double Time, int Vehicle, string Train, string Category, string From, string To, float Vmax,
|
||||
IReadOnlyList<TimetableRow> Rows)
|
||||
{
|
||||
public TimetableRow? Row(int number) => number >= 1 && number <= Rows.Count ? Rows[number - 1] : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A train at a station of its timetable, at its stop point. Distance: of its head before
|
||||
/// the stop point, below 0 past it; StopAt: where the head was to stop, that many metres before it.
|
||||
/// </summary>
|
||||
public sealed record StationEvent(double Time, int Vehicle, StationKind Kind, int Row, string Station, Position Position,
|
||||
float Distance, float StopAt, float TrainLength, float Platform, float Arrival,
|
||||
float Departure)
|
||||
{
|
||||
/// <summary>How far from the place it was to stop the head stood: above 0 short of it, below 0 past it.</summary>
|
||||
public float StopError => Distance - StopAt;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the whole train stood along the platform, when its length is known: as the driver takes it, the length of
|
||||
/// the trains a W32 is for is the room it has there too.
|
||||
/// </summary>
|
||||
public bool? OnPlatform => Platform != 0 ? -Distance <= PlatformTolerance && Distance + TrainLength - PlatformLength <= PlatformTolerance : null;
|
||||
|
||||
/// <summary>How far off the platform a train may stand at either end and still count as along it [m].</summary>
|
||||
public const double PlatformTolerance = 2;
|
||||
|
||||
/// <summary>The length of the platform [m], without the digit of its side.</summary>
|
||||
public double PlatformLength => Platforms.Length(Platform);
|
||||
|
||||
/// <summary>How much of the train stood off the platform [m]: the head past the stop point, the tail before the platform's end; 0 along it.</summary>
|
||||
public double OffPlatform => Platform != 0 ? Math.Max(0, -Distance) + Math.Max(0, Distance + TrainLength - PlatformLength) : 0;
|
||||
}
|
||||
|
||||
public enum CameraMode : byte
|
||||
{
|
||||
/// <summary>From the cab of the vehicle the user is in.</summary>
|
||||
Cab = 0,
|
||||
/// <summary>One of the outside views of the user's train.</summary>
|
||||
External = 1,
|
||||
/// <summary>The free camera.</summary>
|
||||
Free = 2,
|
||||
Debug = 3
|
||||
}
|
||||
|
||||
/// <summary>Where the user looked from. Heading as that of a vehicle: towards (sin, cos) in (x, z).</summary>
|
||||
public readonly record struct CameraState(double Time, CameraMode Mode, int View, int Vehicle, Position Position,
|
||||
float Heading, float Pitch);
|
||||
|
||||
/// <summary>What a coupling joins: the bits of the simulator's coupling.</summary>
|
||||
[Flags]
|
||||
public enum CouplingFlags : byte
|
||||
{
|
||||
None = 0,
|
||||
Coupler = 0x01,
|
||||
BrakePipe = 0x02,
|
||||
Control = 0x04,
|
||||
Power = 0x08,
|
||||
Gangway = 0x10,
|
||||
MainPipe = 0x20,
|
||||
Heating = 0x40,
|
||||
/// <summary>Not undone in shunting: between the cars of a unit.</summary>
|
||||
Permanent = 0x80
|
||||
}
|
||||
|
||||
/// <summary>A coupler coupled, uncoupled, or its hoses and cables changed. Side 0 the front coupler, 1 the rear one.</summary>
|
||||
public sealed record CouplingChange(double Time, int Vehicle, int Side, int Other, int OtherSide, CouplingFlags Flags,
|
||||
CouplingFlags PreviousFlags, int PreviousOther, Position Position)
|
||||
{
|
||||
public bool Coupled => Other != 0 && (Flags & CouplingFlags.Coupler) != 0 && PreviousOther != Other;
|
||||
public bool Uncoupled => Other == 0 && PreviousOther != 0;
|
||||
}
|
||||
|
||||
/// <summary>A train told to sound the horn at a horn point.</summary>
|
||||
public readonly record struct HornRequest(double Time, int Vehicle, float Duration, float Tone, Position Position);
|
||||
|
||||
/// <summary>A vehicle as it is first seen. Id 0 is no vehicle.</summary>
|
||||
public sealed record VehicleInfo(int Id, string Name, string Type, string Train, float Length, float Width,
|
||||
int Category)
|
||||
{
|
||||
public double? RemovedAt { get; internal set; }
|
||||
}
|
||||
|
||||
/// <summary>What the AI driver would do at that moment, from that place and at that speed.</summary>
|
||||
public readonly record struct AiReference(float TargetKmh, float NextLimitKmh, float NextLimitDistance,
|
||||
float Acceleration, float BrakingDistance, float TimetableVmaxKmh,
|
||||
float TrackVmaxKmh)
|
||||
{
|
||||
public bool HasNextLimit => NextLimitKmh >= 0 && NextLimitDistance >= 0;
|
||||
}
|
||||
|
||||
/// <summary>One look at the user's train, ten a second.</summary>
|
||||
public readonly record struct CabSample(double Time, int Vehicle, Position Position, float SpeedKmh,
|
||||
float Acceleration, float OdometerKm, Driver Driving, CabFlags Flags,
|
||||
sbyte MainController, sbyte ScndController, sbyte Reverser,
|
||||
float TrainBrake, float LocalBrake, float BrakeCylinder, float BrakePipe,
|
||||
float MotorCurrent, AiReference Ai, CabDevices Devices,
|
||||
float MainReservoir, float Voltage, float EngineRpm,
|
||||
Lights LightsHead, Lights LightsTail, sbyte CabEnd = 0,
|
||||
SecurityFlags Security = SecurityFlags.None, float LateralAcceleration = 0,
|
||||
Movement Movement = Movement.Unknown, short LightHintHead = -1, short LightHintTail = -1,
|
||||
Lights? EquippedHead = null, Lights? EquippedTail = null, CabExtra? Extra = null)
|
||||
{
|
||||
public bool Has(CabDevices device) => (Devices & device) == device;
|
||||
public bool Has(SecurityFlags flag) => (Security & flag) == flag;
|
||||
|
||||
public bool AiValid => (Flags & CabFlags.AiValid) != 0;
|
||||
public bool InCab => (Flags & CabFlags.InCab) != 0;
|
||||
public float AbsSpeedKmh => Math.Abs(SpeedKmh);
|
||||
}
|
||||
|
||||
/// <summary>One vehicle in a look at the vehicles.</summary>
|
||||
public readonly record struct VehicleState(int Id, Vector3 Position, float Heading, float SpeedKmh, Driver Driving);
|
||||
|
||||
/// <summary>A look at the vehicles: those which moved, or all of them in a keyframe.</summary>
|
||||
public sealed record VehicleLook(double Time, bool Keyframe, IReadOnlyList<VehicleState> States);
|
||||
|
||||
public readonly record struct SwitchChange(double Time, int Track, int State);
|
||||
|
||||
/// <summary>What a signal shows: the speeds its memory cell holds, 0 stop, -1 no limit, and the command.</summary>
|
||||
public readonly record struct AspectChange(double Time, int Signal, float Speed1, float Speed2, string Command);
|
||||
|
||||
public sealed record CommandEntry(double Time, int Vehicle, bool Pressed, double Param1, double Param2, string Name);
|
||||
|
||||
public readonly record struct DriverChange(double Time, int Vehicle, Driver Driving);
|
||||
|
||||
public sealed record SignalPassing(double Time, int Vehicle, int Signal, string Event, Position Position,
|
||||
float Speed1, float Speed2, float SpeedKmh, float Overrun, PassingFlags Flags)
|
||||
{
|
||||
/// <summary>No signal of the map: the passing came from an event the map doesn't hold.</summary>
|
||||
public const int NoSignal = -1;
|
||||
|
||||
public bool AtDanger => (Flags & PassingFlags.AtStop) != 0;
|
||||
}
|
||||
|
||||
/// <summary>The vehicle the user was in, whether in its cab, and which cab: 1 the front one, -1 the rear one, 0 none.</summary>
|
||||
public readonly record struct UserVehicleChange(double Time, int Vehicle, bool InCab, sbyte CabEnd = 0);
|
||||
|
||||
public readonly record struct ClockEntry(double Time, int Year, int YearDay, double SecondOfDay);
|
||||
13
Eu07.BlackBox/StationNames.cs
Normal file
13
Eu07.BlackBox/StationNames.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace Eu07.BlackBox;
|
||||
|
||||
/// <summary>The names of the stations as shown: the scenery tells the stop points of one station apart after a <c>#</c>.</summary>
|
||||
public static class StationNames
|
||||
{
|
||||
/// <summary>The name up to a <c>#</c>, with the underscores of the scenery as spaces.</summary>
|
||||
public static string Display(string name)
|
||||
{
|
||||
int hash = name.IndexOf('#');
|
||||
var shown = hash >= 0 ? name[..hash] : name;
|
||||
return shown.Replace('_', ' ').Trim();
|
||||
}
|
||||
}
|
||||
188
Eu07.BlackBox/TimetableProgress.cs
Normal file
188
Eu07.BlackBox/TimetableProgress.cs
Normal file
@@ -0,0 +1,188 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace Eu07.BlackBox;
|
||||
|
||||
/// <summary>How the user's train kept to a station of its timetable.</summary>
|
||||
public enum StationOutcome
|
||||
{
|
||||
/// <summary>Not reached yet in the recording.</summary>
|
||||
Ahead,
|
||||
/// <summary>Stopped where the timetable has a stop.</summary>
|
||||
Stopped,
|
||||
/// <summary>Passed where the timetable has no stop.</summary>
|
||||
Passed,
|
||||
/// <summary>Left a stop behind without stopping, or before the departure.</summary>
|
||||
Missed
|
||||
}
|
||||
|
||||
/// <summary>How late the user's train was at a moment [min, above 0 late], after the station named, standing there or not.</summary>
|
||||
public sealed record TrainDelay(string Train, double Minutes, string Station, bool Standing);
|
||||
|
||||
/// <summary>
|
||||
/// A row of the timetable of the user's train, with what happened there. The times are
|
||||
/// minutes of the day, as in the timetable; the delays are in minutes, above 0 late.
|
||||
/// </summary>
|
||||
public sealed record StationProgress(
|
||||
int Number,
|
||||
TimetableRow Row,
|
||||
StationOutcome Outcome,
|
||||
double? ArrivedAt,
|
||||
double? DepartedAt,
|
||||
double? ArrivalClock,
|
||||
double? DepartureClock,
|
||||
StationEvent? Event)
|
||||
{
|
||||
public double? ArrivalDelay => Delay(ArrivalClock, Row.Arrival);
|
||||
|
||||
/// <summary>Of the departure, or of the pass for a station passed through.</summary>
|
||||
public double? DepartureDelay => Delay(DepartureClock, Row.Departure);
|
||||
|
||||
/// <summary>Above 0 the head stood short of where it was to stop, below 0 past it [m].</summary>
|
||||
public float? StopError => Outcome == StationOutcome.Stopped && Event is { } e ? e.StopError : null;
|
||||
|
||||
public bool? OnPlatform => Outcome == StationOutcome.Stopped ? Event?.OnPlatform : null;
|
||||
|
||||
/// <summary>How far the tail stood off the platform [m], when it stopped.</summary>
|
||||
public double? OffPlatform => Outcome == StationOutcome.Stopped ? Event?.OffPlatform : null;
|
||||
|
||||
/// <summary>The difference of two times of the day in minutes, across midnight the short way.</summary>
|
||||
internal static double? Delay(double? actual, float planned)
|
||||
{
|
||||
if (actual is not { } a || planned < 0)
|
||||
return null;
|
||||
double d = a - planned;
|
||||
if (d > 720) d -= 1440;
|
||||
if (d < -720) d += 1440;
|
||||
return d;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The rows of the timetable the user's train had at t, each with what the train did
|
||||
/// there by the end of the recording read so far.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<StationProgress> Of(Recording recording, double t) =>
|
||||
recording.TimetableAt(t) is { } timetable ? Of(recording, timetable) : Array.Empty<StationProgress>();
|
||||
|
||||
/// <summary>The rows of a timetable of the recording, each with what the train did there.</summary>
|
||||
public static IReadOnlyList<StationProgress> Of(Recording recording, Timetable timetable) =>
|
||||
recording.Memo((typeof(StationProgress), timetable), () => Work(recording, timetable));
|
||||
|
||||
private static IReadOnlyList<StationProgress> Work(Recording recording, Timetable timetable)
|
||||
{
|
||||
// The records of this train: from its timetable on, until another one is written. A train given its next
|
||||
// timetable may come to the first station of it at once, and a recording made before the simulator wrote the
|
||||
// timetable first has that station up to a look (1 s) before it: it goes with the timetable after.
|
||||
int next = IndexOf(recording.Timetables, timetable) + 1;
|
||||
double until = next < recording.Timetables.Count ? recording.Timetables[next].Time - 1 : double.MaxValue;
|
||||
var events = recording.UserStations()
|
||||
.Where(e => e.Time >= timetable.Time - 1 && e.Time < until)
|
||||
.ToList();
|
||||
|
||||
var rows = new List<StationProgress>(timetable.Rows.Count);
|
||||
for (int number = 1; number <= timetable.Rows.Count; number++)
|
||||
{
|
||||
var row = timetable.Rows[number - 1];
|
||||
var mine = events.Where(e => e.Row == number).ToList();
|
||||
var stopped = mine.FirstOrDefault(e => e.Kind == StationKind.Stopped);
|
||||
var passed = mine.FirstOrDefault(e => e.Kind == StationKind.Passed);
|
||||
var missed = mine.FirstOrDefault(e => e.Kind == StationKind.Missed);
|
||||
if (stopped is not null)
|
||||
{
|
||||
double? departed = DepartureAfter(recording, stopped.Time);
|
||||
rows.Add(new StationProgress(number, row, missed is not null ? StationOutcome.Missed : StationOutcome.Stopped,
|
||||
stopped.Time, departed, Minutes(recording, stopped.Time),
|
||||
departed is { } d ? Minutes(recording, d) : null, stopped));
|
||||
}
|
||||
else if (passed is not null)
|
||||
rows.Add(new StationProgress(number, row, StationOutcome.Passed, null, passed.Time, null,
|
||||
Minutes(recording, passed.Time), passed));
|
||||
else if (missed is not null)
|
||||
rows.Add(new StationProgress(number, row, StationOutcome.Missed, null, missed.Time, null,
|
||||
Minutes(recording, missed.Time), missed));
|
||||
else
|
||||
rows.Add(new StationProgress(number, row, StationOutcome.Ahead, null, null, null, null, null));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// <summary>When the user's train got moving again after a stop, null while it still stood.</summary>
|
||||
private static double? DepartureAfter(Recording recording, double stoppedAt)
|
||||
{
|
||||
var cab = recording.Cab;
|
||||
for (int i = Math.Max(0, recording.CabIndexAt(stoppedAt)); i < cab.Count; i++)
|
||||
if (cab[i].Time > stoppedAt && cab[i].AbsSpeedKmh > 2)
|
||||
return cab[i].Time;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The delay of the user's train at t, from the last station of its timetable it had come to
|
||||
/// by then: of the departure or the pass once it left, of the arrival while it stands, growing
|
||||
/// while it stands past the departure, and growing on the way once it is past the time it was
|
||||
/// to be at the next station. Null before the first station or without a timetable.
|
||||
/// </summary>
|
||||
public static TrainDelay? DelayAt(Recording recording, double t)
|
||||
{
|
||||
if (recording.TimetableAt(t) is not { } timetable || t < timetable.Time - 1)
|
||||
return null;
|
||||
double clock = recording.ClockAt(t).TotalMinutes;
|
||||
TrainDelay? delay = null;
|
||||
var rows = Of(recording, timetable);
|
||||
int reached = -1;
|
||||
for (int i = 0; i < rows.Count; i++)
|
||||
{
|
||||
var row = rows[i];
|
||||
if (row.DepartedAt is { } departed && departed <= t)
|
||||
delay = new TrainDelay(timetable.Train, row.DepartureDelay ?? row.ArrivalDelay ?? delay?.Minutes ?? 0, row.Row.Station, false);
|
||||
else if (row.ArrivedAt is { } arrived && arrived <= t)
|
||||
{
|
||||
double minutes = row.ArrivalDelay ?? delay?.Minutes ?? 0;
|
||||
if (Delay(clock, row.Row.Departure) is { } overdue)
|
||||
minutes = Math.Max(minutes, overdue);
|
||||
delay = new TrainDelay(timetable.Train, minutes, row.Row.Station, true);
|
||||
}
|
||||
else
|
||||
continue;
|
||||
reached = i;
|
||||
}
|
||||
// On the way, past the time it was to be at the next station: at least that late, and more as it stays away.
|
||||
if (delay is { Standing: false } && reached + 1 < rows.Count)
|
||||
{
|
||||
var next = rows[reached + 1].Row;
|
||||
if (Delay(clock, next.Arrival >= 0 ? next.Arrival : next.Departure) is { } overdue && overdue > delay.Minutes)
|
||||
delay = delay with { Minutes = overdue };
|
||||
}
|
||||
return delay;
|
||||
}
|
||||
|
||||
/// <summary>The delays the user's train had at the stations it had left or stood at by t, by the name shown.</summary>
|
||||
public static IReadOnlyDictionary<string, double> StationDelays(Recording recording, double t)
|
||||
{
|
||||
var delays = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var timetable in recording.Timetables)
|
||||
{
|
||||
if (timetable.Time > t)
|
||||
break;
|
||||
foreach (var row in Of(recording, timetable))
|
||||
{
|
||||
double? at = row.ArrivedAt ?? row.DepartedAt;
|
||||
double? delay = row.DepartedAt <= t ? row.DepartureDelay ?? row.ArrivalDelay : row.ArrivalDelay;
|
||||
if (at <= t && delay is { } d)
|
||||
delays[StationNames.Display(row.Row.Station)] = d;
|
||||
}
|
||||
}
|
||||
return delays;
|
||||
}
|
||||
|
||||
private static int IndexOf(IReadOnlyList<Timetable> timetables, Timetable timetable)
|
||||
{
|
||||
for (int i = 0; i < timetables.Count; i++)
|
||||
if (ReferenceEquals(timetables[i], timetable))
|
||||
return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static double Minutes(Recording recording, double t) => recording.ClockAt(t).TotalMinutes;
|
||||
}
|
||||
397
StarterNG.Tests/BlackBox/BlackBoxFile.cs
Normal file
397
StarterNG.Tests/BlackBox/BlackBoxFile.cs
Normal file
@@ -0,0 +1,397 @@
|
||||
using System.Numerics;
|
||||
using System.Text;
|
||||
using Eu07.BlackBox;
|
||||
|
||||
namespace StarterNG.Tests.BlackBox;
|
||||
|
||||
/// <summary>
|
||||
/// Writes a black box the way the simulator does (blackbox_doc.md of its repository),
|
||||
/// field by field, so the reader is checked against the layout and not against itself.
|
||||
/// </summary>
|
||||
internal sealed class BlackBoxFile
|
||||
{
|
||||
private readonly MemoryStream _bytes = new();
|
||||
private readonly BinaryWriter _file;
|
||||
|
||||
public BlackBoxFile(ushort version = 1, bool header = true)
|
||||
{
|
||||
_file = new BinaryWriter(_bytes, Encoding.UTF8, leaveOpen: true);
|
||||
if (header)
|
||||
{
|
||||
_file.Write("EU07BBX\0"u8);
|
||||
_file.Write(version);
|
||||
_file.Write((ushort)0);
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] Bytes => _bytes.ToArray();
|
||||
|
||||
public MemoryStream Stream() => new(Bytes);
|
||||
|
||||
/// <summary>A record of a type, its length and what the writer puts in it.</summary>
|
||||
public BlackBoxFile Record(byte type, Action<BinaryWriter> payload)
|
||||
{
|
||||
using var body = new MemoryStream();
|
||||
using (var w = new BinaryWriter(body, Encoding.UTF8, leaveOpen: true))
|
||||
payload(w);
|
||||
_file.Write(type);
|
||||
_file.Write((uint)body.Length);
|
||||
_file.Write(body.ToArray());
|
||||
return this;
|
||||
}
|
||||
|
||||
public BlackBoxFile Info(double secondOfDay = 10 * 3600 + 34 * 60, string scenery = "td.scn") =>
|
||||
Record(0x01, w =>
|
||||
{
|
||||
w.Write(scenery);
|
||||
w.Write("2026-11-08T22:56:30");
|
||||
w.Write((ushort)2026);
|
||||
w.Write((ushort)312);
|
||||
w.Write(secondOfDay);
|
||||
w.Write("ep07-bb");
|
||||
w.Write("EU07 test");
|
||||
w.Write(0.1f);
|
||||
w.Write(1.0f);
|
||||
w.Write(60.0f);
|
||||
});
|
||||
|
||||
public BlackBoxFile Track(uint index, string name, params Vector3[] points) =>
|
||||
Track(index, name, 0, 0, points);
|
||||
|
||||
public BlackBoxFile Track(uint index, string name, byte kind, byte branch, params Vector3[] points) =>
|
||||
Record(0x10, w =>
|
||||
{
|
||||
w.Write(index);
|
||||
w.Write(name);
|
||||
w.Write(kind);
|
||||
w.Write(branch);
|
||||
w.Write((byte)1);
|
||||
w.Write((uint)points.Length);
|
||||
foreach (var p in points)
|
||||
{
|
||||
w.Write(p.X);
|
||||
w.Write(p.Y);
|
||||
w.Write(p.Z);
|
||||
}
|
||||
});
|
||||
|
||||
public BlackBoxFile Signal(uint index, string name, double x, double y, double z) =>
|
||||
Record(0x11, w =>
|
||||
{
|
||||
w.Write(index);
|
||||
w.Write(name);
|
||||
w.Write(x);
|
||||
w.Write(y);
|
||||
w.Write(z);
|
||||
});
|
||||
|
||||
public BlackBoxFile Vehicle(uint id, string name, string train = "", float length = 16.3f) =>
|
||||
Record(0x12, w =>
|
||||
{
|
||||
w.Write(id);
|
||||
w.Write(name);
|
||||
w.Write("303e-ep-tv");
|
||||
w.Write(train);
|
||||
w.Write(length);
|
||||
w.Write(2.9f);
|
||||
w.Write((byte)1);
|
||||
});
|
||||
|
||||
public BlackBoxFile Removed(double time, uint id) =>
|
||||
Record(0x13, w =>
|
||||
{
|
||||
w.Write(time);
|
||||
w.Write(id);
|
||||
});
|
||||
|
||||
public BlackBoxFile Cab(double time, uint vehicle, double z, float speed, byte driving = 1, byte flags = 0x03,
|
||||
float target = 40, float nextLimit = -1, float nextDistance = -1, float brakeCylinder = 0,
|
||||
uint devices = 0x0007, bool extended = true, sbyte cabEnd = 1, byte security = 0,
|
||||
float lateral = 0.2f, ushort lightsHead = 0x15, ushort lightsTail = 0x22, byte? movement = null,
|
||||
short hintHead = -1, short hintTail = -1, ushort? equippedHead = null, ushort? equippedTail = null,
|
||||
byte? engine = null, float oilTemperature = 70) =>
|
||||
Record(0x20, w =>
|
||||
{
|
||||
w.Write(time);
|
||||
w.Write(vehicle);
|
||||
w.Write(5.0);
|
||||
w.Write(0.4);
|
||||
w.Write(z);
|
||||
w.Write(speed);
|
||||
w.Write(0.1f); // acceleration
|
||||
w.Write(1.5f); // odometer
|
||||
w.Write(driving);
|
||||
w.Write(flags);
|
||||
w.Write((sbyte)3); // main controller
|
||||
w.Write((sbyte)0);
|
||||
w.Write((sbyte)1);
|
||||
w.Write(0.0f); // train brake
|
||||
w.Write(0.0f);
|
||||
w.Write(brakeCylinder);
|
||||
w.Write(5.0f); // brake pipe
|
||||
w.Write(320.0f); // current
|
||||
w.Write(target);
|
||||
w.Write(nextLimit);
|
||||
w.Write(nextDistance);
|
||||
w.Write(0.3f);
|
||||
w.Write(150.0f);
|
||||
w.Write(60.0f);
|
||||
w.Write(-1.0f);
|
||||
w.Write(devices);
|
||||
w.Write(8.5f); // main reservoir
|
||||
w.Write(3000.0f); // voltage
|
||||
w.Write(1100.0f); // revolutions
|
||||
w.Write(lightsHead); // lights at the head of the train
|
||||
w.Write(lightsTail);
|
||||
// The cab, the train protection and the lateral acceleration: a recording made before them ends above.
|
||||
if (extended)
|
||||
{
|
||||
w.Write(cabEnd);
|
||||
w.Write(security);
|
||||
w.Write(lateral);
|
||||
// what the driver goes by and the lights asked for: a recording made before them ends above
|
||||
if (movement is { } kind)
|
||||
{
|
||||
w.Write(kind);
|
||||
w.Write(hintHead);
|
||||
w.Write(hintTail);
|
||||
// the lamps the vehicles at the ends have: a recording made before them ends above
|
||||
if (equippedHead is { } head)
|
||||
{
|
||||
w.Write(head);
|
||||
w.Write(equippedTail ?? head);
|
||||
// what drives it and the rest: a recording made before them ends above
|
||||
if (engine is { } kind2)
|
||||
{
|
||||
w.Write(kind2);
|
||||
w.Write((byte)2); // brake setting P
|
||||
w.Write((byte)3); // radio channel
|
||||
w.Write(4.8f); // equalizing reservoir
|
||||
w.Write(8.0f); // main reservoir pipe
|
||||
w.Write(0.45f); // oil pressure
|
||||
w.Write(75.0f); // water
|
||||
w.Write(oilTemperature);
|
||||
w.Write(80.0f); // engine
|
||||
w.Write(0.0f); // cruise control
|
||||
w.Write(900.0f); // power
|
||||
w.Write((sbyte)0); // hand brake
|
||||
w.Write((byte)1); // brake mode
|
||||
w.Write((byte)0); // horn
|
||||
w.Write(0.5f); // integrated controller
|
||||
w.Write(1.0f); // clutch
|
||||
w.Write(0.0f); // torque converter
|
||||
w.Write(2.5f); // the highest brake cylinder of the train
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
public BlackBoxFile Vehicles(double time, bool keyframe, params (uint Id, float X, float Z, float Speed)[] states) =>
|
||||
Record(0x21, w =>
|
||||
{
|
||||
w.Write(time);
|
||||
w.Write((byte)(keyframe ? 1 : 0));
|
||||
w.Write((uint)states.Length);
|
||||
foreach (var s in states)
|
||||
{
|
||||
w.Write(s.Id);
|
||||
w.Write(s.X);
|
||||
w.Write(0.4f);
|
||||
w.Write(s.Z);
|
||||
w.Write(0.0f); // heading
|
||||
w.Write(s.Speed);
|
||||
w.Write((byte)2);
|
||||
}
|
||||
});
|
||||
|
||||
public BlackBoxFile Switch(double time, uint track, byte state) =>
|
||||
Record(0x22, w =>
|
||||
{
|
||||
w.Write(time);
|
||||
w.Write(track);
|
||||
w.Write(state);
|
||||
});
|
||||
|
||||
public BlackBoxFile Aspect(double time, uint signal, float speed1, float speed2, string command = "SetVelocity") =>
|
||||
Record(0x23, w =>
|
||||
{
|
||||
w.Write(time);
|
||||
w.Write(signal);
|
||||
w.Write(speed1);
|
||||
w.Write(speed2);
|
||||
w.Write(command);
|
||||
});
|
||||
|
||||
public BlackBoxFile Command(double time, uint vehicle, bool pressed, string name) =>
|
||||
Record(0x30, w =>
|
||||
{
|
||||
w.Write(time);
|
||||
w.Write(vehicle);
|
||||
w.Write((byte)(pressed ? 1 : 0));
|
||||
w.Write(1.0);
|
||||
w.Write(2.0);
|
||||
w.Write(name);
|
||||
});
|
||||
|
||||
public BlackBoxFile Driver(double time, uint vehicle, byte driving) =>
|
||||
Record(0x31, w =>
|
||||
{
|
||||
w.Write(time);
|
||||
w.Write(vehicle);
|
||||
w.Write(driving);
|
||||
});
|
||||
|
||||
public BlackBoxFile Passed(double time, uint vehicle, uint signal, string name, float speed1, float speed,
|
||||
byte flags = 0, double z = 1300) =>
|
||||
Record(0x32, w =>
|
||||
{
|
||||
w.Write(time);
|
||||
w.Write(vehicle);
|
||||
w.Write(signal);
|
||||
w.Write(name);
|
||||
w.Write(5.0);
|
||||
w.Write(0.4);
|
||||
w.Write(z);
|
||||
w.Write(speed1);
|
||||
w.Write(-1.0f);
|
||||
w.Write(speed);
|
||||
w.Write(2.5f);
|
||||
w.Write(flags);
|
||||
});
|
||||
|
||||
public BlackBoxFile User(double time, uint vehicle, bool inCab, sbyte? cabEnd = null) =>
|
||||
Record(0x33, w =>
|
||||
{
|
||||
w.Write(time);
|
||||
w.Write(vehicle);
|
||||
w.Write((byte)(inCab ? 1 : 0));
|
||||
if (cabEnd is { } end)
|
||||
w.Write(end);
|
||||
});
|
||||
|
||||
public BlackBoxFile StopPoint(uint index, string station, double z, float stopAt, float platform, float? heading = null) =>
|
||||
Record(0x14, w =>
|
||||
{
|
||||
w.Write(index);
|
||||
w.Write(station);
|
||||
w.Write(5.0);
|
||||
w.Write(0.4);
|
||||
w.Write(z);
|
||||
w.Write(stopAt);
|
||||
w.Write(platform);
|
||||
if (heading is { } way)
|
||||
w.Write(way);
|
||||
});
|
||||
|
||||
public BlackBoxFile HornPoint(uint index, string name, double z, float duration = 2, float tone = 1) =>
|
||||
Record(0x15, w =>
|
||||
{
|
||||
w.Write(index);
|
||||
w.Write(name);
|
||||
w.Write(5.0);
|
||||
w.Write(0.4);
|
||||
w.Write(z);
|
||||
w.Write(duration);
|
||||
w.Write(tone);
|
||||
});
|
||||
|
||||
public BlackBoxFile Timetable(double time, uint vehicle, string train, string from, string to,
|
||||
params (string Station, float Arrival, float Departure)[] rows) =>
|
||||
Record(0x24, w =>
|
||||
{
|
||||
w.Write(time);
|
||||
w.Write(vehicle);
|
||||
w.Write(train);
|
||||
w.Write("ROJ");
|
||||
w.Write(from);
|
||||
w.Write(to);
|
||||
w.Write(100.0f);
|
||||
w.Write((uint)rows.Length);
|
||||
float km = 0;
|
||||
foreach (var row in rows)
|
||||
{
|
||||
w.Write(row.Station);
|
||||
w.Write(km += 2.5f);
|
||||
w.Write(100.0f);
|
||||
w.Write(row.Arrival);
|
||||
w.Write(row.Departure);
|
||||
w.Write((byte)0);
|
||||
}
|
||||
});
|
||||
|
||||
public BlackBoxFile Station(double time, uint vehicle, byte kind, ushort row, string station, double z,
|
||||
float distance = 1, float stopAt = 0, float trainLength = 87, float platform = 150,
|
||||
float arrival = -1, float departure = -1) =>
|
||||
Record(0x35, w =>
|
||||
{
|
||||
w.Write(time);
|
||||
w.Write(vehicle);
|
||||
w.Write(kind);
|
||||
w.Write(row);
|
||||
w.Write(station);
|
||||
w.Write(5.0);
|
||||
w.Write(0.4);
|
||||
w.Write(z);
|
||||
w.Write(distance);
|
||||
w.Write(stopAt);
|
||||
w.Write(trainLength);
|
||||
w.Write(platform);
|
||||
w.Write(arrival);
|
||||
w.Write(departure);
|
||||
});
|
||||
|
||||
public BlackBoxFile HornRequest(double time, uint vehicle, double z, float duration = 2) =>
|
||||
Record(0x36, w =>
|
||||
{
|
||||
w.Write(time);
|
||||
w.Write(vehicle);
|
||||
w.Write(duration);
|
||||
w.Write(1.0f);
|
||||
w.Write(5.0);
|
||||
w.Write(0.4);
|
||||
w.Write(z);
|
||||
});
|
||||
|
||||
public BlackBoxFile Camera(double time, byte mode, uint vehicle, double z, byte view = 0, float heading = 0.5f) =>
|
||||
Record(0x25, w =>
|
||||
{
|
||||
w.Write(time);
|
||||
w.Write(mode);
|
||||
w.Write(view);
|
||||
w.Write(vehicle);
|
||||
w.Write(5.0);
|
||||
w.Write(2.4);
|
||||
w.Write(z);
|
||||
w.Write(heading);
|
||||
w.Write(-0.1f);
|
||||
});
|
||||
|
||||
public BlackBoxFile Coupling(double time, uint vehicle, byte side, uint other, byte otherSide, byte flags,
|
||||
byte previousFlags, uint previousOther) =>
|
||||
Record(0x37, w =>
|
||||
{
|
||||
w.Write(time);
|
||||
w.Write(vehicle);
|
||||
w.Write(side);
|
||||
w.Write(other);
|
||||
w.Write(otherSide);
|
||||
w.Write(flags);
|
||||
w.Write(previousFlags);
|
||||
w.Write(previousOther);
|
||||
w.Write(5.0);
|
||||
w.Write(0.4);
|
||||
w.Write(1150.0);
|
||||
});
|
||||
|
||||
public BlackBoxFile Clock(double time, double secondOfDay) =>
|
||||
Record(0x34, w =>
|
||||
{
|
||||
w.Write(time);
|
||||
w.Write((ushort)2026);
|
||||
w.Write((ushort)312);
|
||||
w.Write(secondOfDay);
|
||||
});
|
||||
|
||||
public BlackBoxFile End(double time) => Record(0x3f, w => w.Write(time));
|
||||
}
|
||||
253
StarterNG.Tests/BlackBox/BlackBoxJourneyTests.cs
Normal file
253
StarterNG.Tests/BlackBox/BlackBoxJourneyTests.cs
Normal file
@@ -0,0 +1,253 @@
|
||||
using Eu07.BlackBox;
|
||||
|
||||
namespace StarterNG.Tests.BlackBox;
|
||||
|
||||
/// <summary>
|
||||
/// A journey of two timetables one after another: the stations, the horn points, the train
|
||||
/// protection, the camera and the couplings, as the simulator writes them, and what the
|
||||
/// recording tells of them.
|
||||
/// </summary>
|
||||
public class BlackBoxJourneyTests
|
||||
{
|
||||
private const uint Head = 1, Car = 2, Tail = 3, Stranger = 9;
|
||||
private const byte Coupled = 0x83;
|
||||
|
||||
/// <summary>
|
||||
/// The user in vehicle 1 of a unit 1-2-3, the clock at 10:34 (634 min). ROJ1 stands at A, stops at B
|
||||
/// at t = 60 until 70, passes C at 100 without stopping; ROJ2 from t = 110 stops at D at 115.
|
||||
/// </summary>
|
||||
private static Recording Journey()
|
||||
{
|
||||
var f = new BlackBoxFile().Info()
|
||||
.StopPoint(0, "Stacja_B#1", 1300, 10, 150)
|
||||
.StopPoint(1, "Stacja_B#2", 1310, 10, 150)
|
||||
.HornPoint(0, "w6_1", 1200)
|
||||
.User(0, Head, true, -1)
|
||||
.Coupling(0, Head, 1, Car, 0, Coupled, 0, 0)
|
||||
.Coupling(0, Car, 1, Tail, 0, Coupled, 0, 0)
|
||||
.Camera(0, 0, Head, 1150)
|
||||
.Timetable(0, Head, "ROJ1", "A", "C", ("A", -1, 634), ("Stacja_B", 635, 635.5f), ("C", 637.5f, 638))
|
||||
.Station(1, Head, 4, 1, "A", 1150);
|
||||
for (double t = 0; t <= 130; t += 0.5)
|
||||
{
|
||||
float speed = t is >= 55 and < 70 || t >= 112 && t < 120 ? 0 : 40;
|
||||
uint devices = 0x0007 | (t is >= 22 and < 24 ? 0x0200u : 0);
|
||||
byte security = (byte)(t is >= 30 and < 32 ? 0x11 : 0x01);
|
||||
f.Cab(t, Head, 1150 + t * 10, speed, devices: devices, security: security, lateral: t is >= 45 and < 48 ? 1.2f : 0.1f);
|
||||
}
|
||||
f.HornRequest(20, Head, 1200)
|
||||
.HornRequest(40, Head, 1550)
|
||||
.Camera(50, 2, 0, 1650)
|
||||
.Station(60, Head, 1, 2, "Stacja_B#2", 1300)
|
||||
.Station(60, Stranger, 1, 2, "Stacja_B#1", 9000)
|
||||
.Coupling(80, Car, 1, 0, 0, 0, Coupled, Tail)
|
||||
.Station(100, Head, 3, 3, "C", 2150)
|
||||
.Timetable(110, Head, "ROJ2", "C", "D", ("C", -1, 638), ("D", 640, -1))
|
||||
.Station(115, Head, 1, 2, "D", 2300);
|
||||
return BlackBoxReader.Read(f.End(130).Stream());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_new_records_are_read_field_by_field()
|
||||
{
|
||||
var r = Journey();
|
||||
|
||||
Assert.Equal(0, r.Unknown);
|
||||
Assert.Equal(0, r.Malformed);
|
||||
Assert.Equal(2, r.StopPoints.Count);
|
||||
Assert.Equal("Stacja_B#2", r.StopPoints[1].Station);
|
||||
Assert.Equal(10f, r.StopPoints[1].StopAt);
|
||||
Assert.Equal(150f, r.StopPoints[1].Platform);
|
||||
Assert.Equal("w6_1", Assert.Single(r.HornPoints).Name);
|
||||
Assert.Equal(2, r.Timetables.Count);
|
||||
Assert.Equal("ROJ2", r.Timetables[1].Train);
|
||||
Assert.Equal(640f, r.Timetables[1].Row(2)!.Arrival);
|
||||
Assert.False(r.Timetables[1].Row(2)!.Maintenance);
|
||||
Assert.Equal(5, r.Stations.Count);
|
||||
Assert.Equal(StationKind.Missed, r.Stations.Single(s => s.Row == 3 && s.Vehicle == Head).Kind);
|
||||
Assert.Equal(2, r.HornRequests.Count);
|
||||
Assert.Equal(2, r.Cameras.Count);
|
||||
Assert.Equal(CameraMode.Free, r.Cameras[1].Mode);
|
||||
Assert.Equal(-0.1f, r.Cameras[1].Pitch);
|
||||
Assert.Equal(3, r.Couplings.Count);
|
||||
Assert.True(r.Couplings[2].Uncoupled);
|
||||
Assert.Equal(-1, Assert.Single(r.UserVehicles).CabEnd);
|
||||
|
||||
var cab = r.Cab[0];
|
||||
Assert.Equal(1, cab.CabEnd);
|
||||
Assert.Equal(SecurityFlags.Vigilance, cab.Security);
|
||||
Assert.Equal(0.1f, cab.LateralAcceleration);
|
||||
Assert.Equal(Lights.HeadlightLeft | Lights.HeadlightUpper | Lights.HeadlightRight, cab.LightsHead);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_cab_written_before_the_protection_and_the_lateral_acceleration_is_still_read()
|
||||
{
|
||||
var r = BlackBoxReader.Read(new BlackBoxFile().Info().Cab(0, Head, 1150, 40, extended: false).User(0, Head, true).End(1).Stream());
|
||||
|
||||
var cab = Assert.Single(r.Cab);
|
||||
Assert.Equal(0, cab.CabEnd);
|
||||
Assert.Equal(SecurityFlags.None, cab.Security);
|
||||
Assert.Equal(0f, cab.LateralAcceleration);
|
||||
Assert.Equal(0, Assert.Single(r.UserVehicles).CabEnd);
|
||||
Assert.Equal(0, r.Malformed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_couplings_tell_the_train_at_any_moment()
|
||||
{
|
||||
var r = Journey();
|
||||
|
||||
Assert.Equal(new HashSet<int> { 1, 2, 3 }, r.ConsistAt((int)Head, 79));
|
||||
Assert.Equal(new HashSet<int> { 1, 2 }, r.ConsistAt((int)Head, 81));
|
||||
Assert.Equal(new HashSet<int> { 3 }, r.ConsistAt((int)Tail, 81));
|
||||
// What the trains were made of at the start is no coupling done.
|
||||
var change = Assert.Single(r.CouplingEvents());
|
||||
Assert.Equal(80, change.Time);
|
||||
Assert.Single(r.UserCouplings());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_stations_of_someone_else_s_train_are_not_the_user_s()
|
||||
{
|
||||
var r = Journey();
|
||||
|
||||
Assert.DoesNotContain(r.UserStations(), s => s.Vehicle == Stranger);
|
||||
Assert.Equal(4, r.UserStations().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_timetable_shown_is_the_one_the_train_had_then()
|
||||
{
|
||||
var r = Journey();
|
||||
|
||||
Assert.Equal("ROJ1", r.TimetableAt(100)!.Train);
|
||||
Assert.Equal("ROJ2", r.TimetableAt(110)!.Train);
|
||||
|
||||
var first = StationProgress.Of(r, 50);
|
||||
Assert.Equal(3, first.Count);
|
||||
Assert.Equal(StationOutcome.Stopped, first[1].Outcome);
|
||||
Assert.Equal(60, first[1].ArrivedAt);
|
||||
Assert.Equal(70, first[1].DepartedAt);
|
||||
Assert.Equal(0, first[1].ArrivalDelay!.Value, 2);
|
||||
Assert.Equal(-1 / 3.0, first[1].DepartureDelay!.Value, 2);
|
||||
Assert.Equal(StationOutcome.Missed, first[2].Outcome);
|
||||
Assert.Equal(StationOutcome.Stopped, StationProgress.Of(r, 120)[1].Outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_delay_follows_the_train_from_station_to_station()
|
||||
{
|
||||
var r = Journey();
|
||||
|
||||
Assert.Null(StationProgress.DelayAt(r, 0.5));
|
||||
var standing = StationProgress.DelayAt(r, 65)!;
|
||||
Assert.True(standing.Standing);
|
||||
Assert.Equal("Stacja_B", standing.Station);
|
||||
Assert.Equal(0, standing.Minutes, 2);
|
||||
var left = StationProgress.DelayAt(r, 80)!;
|
||||
Assert.False(left.Standing);
|
||||
Assert.Equal(-1 / 3.0, left.Minutes, 2);
|
||||
// At D, arrived at 10:35:55 for 10:40: early, and nothing to depart by.
|
||||
var second = StationProgress.DelayAt(r, 118)!;
|
||||
Assert.Equal("ROJ2", second.Train);
|
||||
Assert.True(second.Minutes < -4);
|
||||
|
||||
// Left B at 10:35:10, C planned 10:37:30: by 10:35:50 (t = 110 of ROJ1's time) not yet overdue.
|
||||
Assert.Equal(-1 / 3.0, StationProgress.DelayAt(r, 90)!.Minutes, 2);
|
||||
|
||||
var delays = StationProgress.StationDelays(r, 80);
|
||||
Assert.Equal(-1 / 3.0, delays["Stacja B"], 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_names_of_the_stations_are_shown_without_what_tells_their_stop_points_apart()
|
||||
{
|
||||
Assert.Equal("Bałtyk Główny", StationNames.Display("Bałtyk_Główny#2"));
|
||||
Assert.Equal("Turów", StationNames.Display("Turów"));
|
||||
Assert.Equal("Psie Pole po", StationNames.Display("Psie_Pole_po#"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_horn_counts_when_it_sounded_after_the_horn_point()
|
||||
{
|
||||
var checks = Journey().UserHornChecks().ToList();
|
||||
|
||||
Assert.Equal(2, checks.Count);
|
||||
Assert.True(checks[0].Sounded);
|
||||
Assert.False(checks[1].Sounded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_camera_is_known_from_its_first_record()
|
||||
{
|
||||
var r = Journey();
|
||||
|
||||
Assert.Null(r.CameraAt(-1));
|
||||
Assert.Equal(CameraMode.Cab, r.CameraAt(49)!.Value.Mode);
|
||||
Assert.Equal(CameraMode.Free, r.CameraAt(51)!.Value.Mode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_summary_counts_the_stops_of_every_timetable_the_horns_and_the_protection()
|
||||
{
|
||||
var summary = Journey().Summarize();
|
||||
|
||||
Assert.Equal(2, summary.Stops);
|
||||
Assert.Equal(1, summary.StopsMissed);
|
||||
Assert.Equal(2, summary.HornPoints);
|
||||
Assert.Equal(1, summary.HornsMissed);
|
||||
Assert.Equal(1, summary.SecurityBrakings);
|
||||
// Taken over half a second, as the lateral acceleration jumps from sample to sample.
|
||||
Assert.Equal(1.2f, summary.MaxLateralAcceleration, 3);
|
||||
}
|
||||
}
|
||||
|
||||
public class TrainDelayTests
|
||||
{
|
||||
[Fact]
|
||||
public void A_train_held_on_the_way_grows_late_by_the_time_of_the_next_station()
|
||||
{
|
||||
// Clock 10:34 at t = 0. Departs A at 10:35 (t = 60) on time, held short of B planned at 10:37.
|
||||
var f = new BlackBoxFile().Info()
|
||||
.Timetable(0, 1, "ROJ9", "A", "B", ("A", -1, 635), ("B", 637, 638))
|
||||
.Station(1, 1, 1, 1, "A", 1150);
|
||||
for (double t = 0; t <= 400; t += 0.5)
|
||||
f.Cab(t, 1, 1150 + Math.Max(0, Math.Min(t, 90) - 60) * 10, t is > 60 and < 90 ? 40 : 0);
|
||||
var r = BlackBoxReader.Read(f.End(400).Stream());
|
||||
|
||||
Assert.Equal(0, StationProgress.DelayAt(r, 100)!.Minutes, 1);
|
||||
// 10:40 at t = 360: three minutes past B's arrival, and not there.
|
||||
var held = StationProgress.DelayAt(r, 360)!;
|
||||
Assert.False(held.Standing);
|
||||
Assert.Equal(3, held.Minutes, 2);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A train given its next timetable, one recording holding both.</summary>
|
||||
public class NextTimetableTests
|
||||
{
|
||||
[Fact]
|
||||
public void A_station_written_just_before_the_next_timetable_goes_with_it()
|
||||
{
|
||||
// ROJ1 stops at A and ends at B; at B it is given ROJ2, which starts there. A recording made before
|
||||
// the simulator wrote the timetable first has B of ROJ2 half a second ahead of ROJ2 itself (here left behind,
|
||||
// which on ROJ1 would mark A missed).
|
||||
var f = new BlackBoxFile().Info()
|
||||
.Timetable(0, 1, "ROJ1", "A", "B", ("A", -1, 635), ("B", 637, -1))
|
||||
.Station(1, 1, 1, 1, "A", 1150)
|
||||
.Station(50, 1, 1, 2, "B", 1150)
|
||||
.Station(99.5, 1, 3, 1, "B", 1150)
|
||||
.Timetable(100, 1, "ROJ2", "B", "C", ("B", -1, 640), ("C", 645, -1));
|
||||
for (double t = 0; t <= 120; t += 0.5)
|
||||
f.Cab(t, 1, 1150, 0);
|
||||
var r = BlackBoxReader.Read(f.End(120).Stream());
|
||||
|
||||
var first = StationProgress.Of(r, r.Timetables[0]);
|
||||
Assert.Equal(StationOutcome.Stopped, first[0].Outcome);
|
||||
Assert.Equal(1, first[0].Event!.Time);
|
||||
var second = StationProgress.Of(r, r.Timetables[1]);
|
||||
Assert.Equal(StationOutcome.Missed, second[0].Outcome);
|
||||
Assert.Equal(99.5, second[0].Event!.Time);
|
||||
}
|
||||
}
|
||||
96
StarterNG.Tests/BlackBox/BlackBoxListingTests.cs
Normal file
96
StarterNG.Tests/BlackBox/BlackBoxListingTests.cs
Normal file
@@ -0,0 +1,96 @@
|
||||
using StarterNG.Domain.Tapes;
|
||||
using StarterNG.Tests.Fakes;
|
||||
|
||||
namespace StarterNG.Tests.BlackBox;
|
||||
|
||||
public class BlackBoxListingTests
|
||||
{
|
||||
private static readonly DateTime Launched = new(2025, 12, 31, 23, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static string Log(string name) => TestInstallation.At("physicslog", name);
|
||||
|
||||
[Fact]
|
||||
public void Each_run_keeps_its_own_black_box_next_to_its_tapes()
|
||||
{
|
||||
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");
|
||||
var installation = new TestInstallation(files);
|
||||
var tapes = installation.Tapes;
|
||||
installation.Clock.Now = Launched;
|
||||
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "ra", new[] { "ra" }, "A");
|
||||
installation.Clock.Now = Launched.AddHours(1);
|
||||
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "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))
|
||||
.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.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.Empty(sessions[1].Tapes);
|
||||
Assert.Equal(Launched.AddMinutes(30), sessions[1].LastWrittenUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_black_box_is_not_taken_for_a_tape()
|
||||
{
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithFile(Log("ra.dat"), "t")
|
||||
.WithFile(Log("td_2026-03-01_12-00-00.blackbox"), "b"));
|
||||
|
||||
var session = Assert.Single(installation.Tapes.Sessions());
|
||||
|
||||
Assert.Equal("ra", Assert.Single(session.Tapes).Vehicle);
|
||||
Assert.Single(session.BlackBoxes);
|
||||
Assert.Equal("ra", Assert.Single(installation.Tapes.List()).Vehicle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Black_boxes_with_no_launch_noted_go_with_the_tapes_written_about_the_same_time()
|
||||
{
|
||||
var early = new DateTime(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithFile(Log("a.dat"), "t")
|
||||
.WithFile(Log("td_2026-03-01_11-00-00.blackbox"), "b")
|
||||
.WithFile(Log("td_2026-03-01_12-30-00.blackbox"), "b")
|
||||
.WrittenAt(Log("a.dat"), early)
|
||||
.WrittenAt(Log("td_2026-03-01_11-00-00.blackbox"), early.AddSeconds(2))
|
||||
.WrittenAt(Log("td_2026-03-01_12-30-00.blackbox"), early.AddHours(1)));
|
||||
|
||||
var sessions = installation.Tapes.Sessions();
|
||||
|
||||
Assert.Equal(2, sessions.Count);
|
||||
Assert.All(sessions, session => Assert.Null(session.Launch));
|
||||
// Newest first: the black box on its own, then the run with the tape.
|
||||
Assert.Empty(sessions[0].Tapes);
|
||||
Assert.EndsWith("12-30-00.blackbox", Assert.Single(sessions[0].BlackBoxes).Path);
|
||||
Assert.Single(sessions[1].Tapes);
|
||||
Assert.EndsWith("11-00-00.blackbox", Assert.Single(sessions[1].BlackBoxes).Path);
|
||||
Assert.Equal(early, sessions[1].StartedUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_black_box_of_a_running_session_is_listed_while_it_is_written()
|
||||
{
|
||||
var files = new InMemoryFileSystem();
|
||||
var installation = new TestInstallation(files);
|
||||
installation.Clock.Now = Launched;
|
||||
var tapes = installation.Tapes;
|
||||
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "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));
|
||||
|
||||
installation.SimulatorRunning = true;
|
||||
var running = Assert.Single(tapes.Sessions());
|
||||
|
||||
Assert.True(running.Running);
|
||||
Assert.Empty(running.Tapes);
|
||||
Assert.Single(running.BlackBoxes);
|
||||
}
|
||||
}
|
||||
229
StarterNG.Tests/BlackBox/BlackBoxReaderTests.cs
Normal file
229
StarterNG.Tests/BlackBox/BlackBoxReaderTests.cs
Normal file
@@ -0,0 +1,229 @@
|
||||
using System.Numerics;
|
||||
using Eu07.BlackBox;
|
||||
|
||||
namespace StarterNG.Tests.BlackBox;
|
||||
|
||||
public class BlackBoxReaderTests
|
||||
{
|
||||
private static BlackBoxFile Session() => new BlackBoxFile()
|
||||
.Info()
|
||||
.Track(0, "start", new Vector3(5, 0.4f, 1100), new Vector3(5, 0.4f, 1200))
|
||||
.Track(1, "zwr01", 1, 0, new Vector3(5, 0.4f, 1200), new Vector3(5, 0.4f, 1234))
|
||||
.Track(1, "zwr01", 1, 1, new Vector3(5, 0.4f, 1200), new Vector3(8, 0.4f, 1234))
|
||||
.Signal(0, "stacja1_a", 2.5, 3.0, 1300)
|
||||
.Vehicle(1, "ep07-bb", "ROJ65113")
|
||||
.Vehicle(2, "bdnu")
|
||||
.Switch(0, 1, 0)
|
||||
.Aspect(0, 0, 0, -1)
|
||||
.Clock(0, 10 * 3600 + 34 * 60)
|
||||
.User(0, 1, true)
|
||||
.Vehicles(0, true, (1, 5, 1150, 0), (2, 5, 1130, 0))
|
||||
.Cab(0.1, 1, 1150, 0)
|
||||
.Command(1.0, 1, true, "aidriverenable")
|
||||
.Command(1.1, 1, false, "aidriverenable")
|
||||
.Driver(1.0, 1, 2)
|
||||
.Aspect(2.0, 0, 40, -1)
|
||||
.Switch(3.0, 1, 1)
|
||||
.Passed(4.0, 1, 0, "stacja1_a_sem", 40, 38.5f, 0x01)
|
||||
.Removed(5.0, 2)
|
||||
.End(6.0);
|
||||
|
||||
[Fact]
|
||||
public void Every_record_of_a_session_is_read_as_the_simulator_wrote_it()
|
||||
{
|
||||
var r = BlackBoxReader.Read(Session().Stream());
|
||||
|
||||
Assert.Equal(1, r.Version);
|
||||
Assert.Equal("td.scn", r.Info!.Scenery);
|
||||
Assert.Equal(new TimeSpan(10, 34, 0), r.Info.StartClock);
|
||||
Assert.Equal(60f, r.Info.KeyframeInterval);
|
||||
Assert.Equal(3, r.Tracks.Count);
|
||||
Assert.Equal(2, r.TrackWays(1).Count);
|
||||
Assert.Equal(TrackKind.SwitchBranch, r.TrackWays(1)[1].Kind);
|
||||
Assert.Equal(new Vector3(8, 0.4f, 1234), r.TrackWays(1)[1].Points[1]);
|
||||
Assert.Equal("stacja1_a", r.Signal(0)!.Name);
|
||||
Assert.Equal(1300, r.Signal(0)!.Position.Z);
|
||||
Assert.Equal("ROJ65113", r.Vehicle(1)!.Train);
|
||||
Assert.Equal(5.0, r.Vehicle(2)!.RemovedAt);
|
||||
|
||||
var cab = Assert.Single(r.Cab);
|
||||
Assert.Equal(1, cab.Vehicle);
|
||||
Assert.Equal(new Position(5, 0.4, 1150), cab.Position);
|
||||
Assert.Equal(Driver.Human, cab.Driving);
|
||||
Assert.True(cab.AiValid);
|
||||
Assert.Equal(3, cab.MainController);
|
||||
Assert.Equal(5.0f, cab.BrakePipe);
|
||||
Assert.Equal(40f, cab.Ai.TargetKmh);
|
||||
Assert.Equal(60f, cab.Ai.TimetableVmaxKmh);
|
||||
Assert.False(cab.Ai.HasNextLimit);
|
||||
Assert.Equal(CabDevices.Battery | CabDevices.MainBreaker | CabDevices.PantographFront, cab.Devices);
|
||||
Assert.True(cab.Has(CabDevices.MainBreaker));
|
||||
Assert.False(cab.Has(CabDevices.Compressor));
|
||||
Assert.Equal(8.5f, cab.MainReservoir);
|
||||
Assert.Equal(3000f, cab.Voltage);
|
||||
Assert.Equal(1100f, cab.EngineRpm);
|
||||
Assert.Equal(Lights.HeadlightLeft | Lights.HeadlightUpper | Lights.HeadlightRight, cab.LightsHead);
|
||||
Assert.Equal(Lights.RedMarkerLeft | Lights.RedMarkerRight, cab.LightsTail);
|
||||
|
||||
Assert.Equal(2, r.Commands.Count);
|
||||
Assert.Equal("aidriverenable", r.Commands[0].Name);
|
||||
Assert.True(r.Commands[0].Pressed);
|
||||
Assert.Equal(2.0, r.Commands[0].Param2);
|
||||
Assert.Equal(Driver.Ai, Assert.Single(r.DriverChanges).Driving);
|
||||
var passed = Assert.Single(r.Passings);
|
||||
Assert.Equal("stacja1_a_sem", passed.Event);
|
||||
Assert.Equal(PassingFlags.Shunt, passed.Flags);
|
||||
Assert.Equal(38.5f, passed.SpeedKmh);
|
||||
Assert.False(passed.AtDanger);
|
||||
Assert.True(r.Complete);
|
||||
Assert.Equal(6.0, r.Duration);
|
||||
Assert.Equal(0, r.Unknown);
|
||||
Assert.Equal(0, r.Malformed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_switches_and_the_aspects_answer_for_any_moment()
|
||||
{
|
||||
var r = BlackBoxReader.Read(Session().Stream());
|
||||
|
||||
Assert.Equal(0, r.SwitchAt(1, 2.9));
|
||||
Assert.Equal(1, r.SwitchAt(1, 3.0));
|
||||
Assert.Equal(-1, r.SwitchAt(7, 3.0));
|
||||
Assert.Equal(0f, r.AspectAt(0, 1.9)!.Value.Speed1);
|
||||
Assert.Equal(40f, r.AspectAt(0, 2.0)!.Value.Speed1);
|
||||
Assert.Null(r.AspectAt(3, 2.0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_file_still_written_is_read_on_from_where_the_last_read_stopped()
|
||||
{
|
||||
var bytes = Session().Bytes;
|
||||
var whole = BlackBoxReader.Read(new MemoryStream(bytes));
|
||||
|
||||
// Cut at every byte: a record cut short waits for the rest, nothing is read twice.
|
||||
for (int cut = 0; cut <= bytes.Length; cut++)
|
||||
{
|
||||
var reader = new BlackBoxReader();
|
||||
reader.ReadMore(new MemoryStream(bytes, 0, cut));
|
||||
Assert.False(reader.Recording.Complete && cut < bytes.Length);
|
||||
reader.ReadMore(new MemoryStream(bytes));
|
||||
|
||||
Assert.Equal(whole.Tracks.Count, reader.Recording.Tracks.Count);
|
||||
Assert.Equal(whole.Commands.Count, reader.Recording.Commands.Count);
|
||||
Assert.Equal(whole.Cab.Count, reader.Recording.Cab.Count);
|
||||
Assert.Equal(whole.Aspects.Count, reader.Recording.Aspects.Count);
|
||||
Assert.True(reader.Recording.Complete);
|
||||
Assert.Equal(bytes.Length, reader.Offset);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_record_of_a_later_version_is_passed_over_by_its_length()
|
||||
{
|
||||
var file = new BlackBoxFile().Info()
|
||||
.Record(0x7e, w => w.Write(new byte[40]))
|
||||
.Command(1.0, 1, true, "hornhighactivate")
|
||||
.End(2.0);
|
||||
|
||||
var r = BlackBoxReader.Read(file.Stream());
|
||||
|
||||
Assert.Equal(1, r.Unknown);
|
||||
Assert.Equal("hornhighactivate", Assert.Single(r.Commands).Name);
|
||||
Assert.True(r.Complete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_record_shorter_than_its_type_is_passed_over_and_counted()
|
||||
{
|
||||
var file = new BlackBoxFile().Record(0x20, w => w.Write(1.0)).Clock(1.0, 3600).End(2.0);
|
||||
|
||||
var r = BlackBoxReader.Read(file.Stream());
|
||||
|
||||
Assert.Empty(r.Cab);
|
||||
Assert.Equal(1, r.Malformed);
|
||||
Assert.Single(r.Clocks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Texts_longer_than_a_byte_can_count_and_letters_beyond_ascii_read_back()
|
||||
{
|
||||
string name = "żółw_" + new string('x', 300);
|
||||
var r = BlackBoxReader.Read(new BlackBoxFile().Signal(0, name, 0, 0, 0).Signal(1, "", 1, 1, 1).Stream());
|
||||
|
||||
Assert.Equal(name, r.Signal(0)!.Name);
|
||||
Assert.Equal("", r.Signal(1)!.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Something_else_than_a_black_box_or_a_version_not_known_is_refused()
|
||||
{
|
||||
Assert.Throws<InvalidDataException>(() => BlackBoxReader.Read(new MemoryStream("Time[s] Velocity[m/s]\r\n"u8.ToArray())));
|
||||
Assert.Throws<InvalidDataException>(() => BlackBoxReader.Read(new BlackBoxFile(version: 2).Stream()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_file_written_anew_under_the_reader_is_told_so()
|
||||
{
|
||||
var reader = new BlackBoxReader();
|
||||
reader.ReadMore(Session().Stream());
|
||||
|
||||
Assert.Throws<InvalidDataException>(() => reader.ReadMore(new BlackBoxFile().Info().Stream()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_vehicle_listed_only_when_it_moved_stands_until_the_look_before_it_was_seen_moving()
|
||||
{
|
||||
var r = BlackBoxReader.Read(new BlackBoxFile()
|
||||
.Info()
|
||||
.Vehicle(2, "et22")
|
||||
.Vehicles(0, true, (2, 0, 0, 0))
|
||||
.Vehicles(60, true, (2, 0, 0, 0))
|
||||
.Vehicles(90, false, (2, 0, 10, 36))
|
||||
.Vehicles(91, false, (2, 0, 20, 36))
|
||||
.Stream());
|
||||
|
||||
Assert.Equal(0f, r.VehiclesAt(30).Single().Position.Z);
|
||||
// Not listed from 60 to 90: it stood until 88.5, the look interval and a half before.
|
||||
Assert.Equal(0f, r.VehiclesAt(88).Single().Position.Z);
|
||||
Assert.InRange(r.VehiclesAt(89.25).Single().Position.Z, 4.9f, 5.1f);
|
||||
Assert.InRange(r.VehiclesAt(90.5).Single().Position.Z, 14.9f, 15.1f);
|
||||
Assert.Equal(20f, r.VehiclesAt(200).Single().Position.Z);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_vehicle_removed_is_on_the_map_no_more()
|
||||
{
|
||||
var r = BlackBoxReader.Read(Session().Stream());
|
||||
|
||||
Assert.Equal(2, r.VehiclesAt(4.9).Count);
|
||||
Assert.Equal(1, Assert.Single(r.VehiclesAt(5.0)).Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_clock_goes_on_from_the_last_tick_and_over_midnight()
|
||||
{
|
||||
var r = BlackBoxReader.Read(new BlackBoxFile()
|
||||
.Info(secondOfDay: 86390)
|
||||
.Clock(0, 86390)
|
||||
.Clock(1, 86391)
|
||||
.Stream());
|
||||
|
||||
Assert.Equal(new TimeSpan(23, 59, 51), r.ClockAt(1));
|
||||
Assert.Equal(86395.5, r.ClockAt(5.5).TotalSeconds, 3);
|
||||
Assert.Equal(1.0, r.ClockAt(11).TotalSeconds, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_position_of_the_train_runs_between_its_samples_and_not_across_a_gap()
|
||||
{
|
||||
var r = BlackBoxReader.Read(new BlackBoxFile()
|
||||
.Cab(0.0, 1, 100, 36)
|
||||
.Cab(0.1, 1, 101, 36)
|
||||
.Cab(5.0, 1, 200, 36)
|
||||
.Stream());
|
||||
|
||||
Assert.Equal(100.5, r.CabPositionAt(0.05)!.Value.Z, 6);
|
||||
Assert.Null(r.CabPositionAt(3.0));
|
||||
Assert.Null(r.CabPositionAt(-1));
|
||||
}
|
||||
}
|
||||
247
StarterNG.Tests/BlackBox/BlackBoxSignalsTests.cs
Normal file
247
StarterNG.Tests/BlackBox/BlackBoxSignalsTests.cs
Normal file
@@ -0,0 +1,247 @@
|
||||
using System.Linq;
|
||||
using Eu07.BlackBox;
|
||||
|
||||
namespace StarterNG.Tests.BlackBox;
|
||||
|
||||
/// <summary>The light signals at the head and the tail of the user's train, against those it was to show.</summary>
|
||||
public class HeadCodeTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(Lights.HeadlightLeft | Lights.HeadlightRight | Lights.HeadlightUpper, HeadCode.Pc1)]
|
||||
[InlineData(Lights.HeadlightLeft | Lights.HeadlightRight | Lights.HeadlightUpper | Lights.HighBeamLeft | Lights.HighBeamRight, HeadCode.Pc1)]
|
||||
[InlineData(Lights.RedMarkerLeft | Lights.HeadlightRight | Lights.HeadlightUpper, HeadCode.Pc2)]
|
||||
[InlineData(Lights.RedMarkerLeft | Lights.RedMarkerRight, HeadCode.Pc5)]
|
||||
[InlineData(Lights.RearEndSignals, HeadCode.Pc5)]
|
||||
[InlineData(Lights.HeadlightRight, HeadCode.Tb1)]
|
||||
[InlineData(Lights.HeadlightLeft, HeadCode.Tb1)]
|
||||
[InlineData(Lights.None, HeadCode.Off)]
|
||||
[InlineData(Lights.HeadlightLeft | Lights.HeadlightRight, HeadCode.Other)]
|
||||
public void The_lamps_lit_tell_the_signal(Lights lights, HeadCode code) => Assert.Equal(code, HeadCodes.Of(lights));
|
||||
|
||||
private const ushort Pc1 = 0x15, Pc5 = 0x22, Tb1 = 0x10;
|
||||
|
||||
private static Recording Drive(params (double From, double To, ushort Head, ushort Tail, byte Movement, short HintHead)[] stretches)
|
||||
{
|
||||
var f = new BlackBoxFile().Info();
|
||||
foreach (var (from, to, head, tail, movement, hint) in stretches)
|
||||
for (double t = from; t < to; t += 0.5)
|
||||
f.Cab(t, 1, 1000 + t * 10, 40, lightsHead: head, lightsTail: tail, movement: movement, hintHead: hint);
|
||||
return BlackBoxReader.Read(f.End(stretches[^1].To).Stream());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_train_run_is_to_show_Pc1_and_Pc5()
|
||||
{
|
||||
var summary = Drive((0, 60, Pc1, Pc5, 1, -1)).Summarize();
|
||||
Assert.True(summary.LightsJudgedSeconds > 55);
|
||||
Assert.Equal(0, summary.WrongLightsSeconds);
|
||||
Assert.Empty(summary.LightsFaults);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Shunting_lights_on_a_train_run_count_as_wrong_once_for_the_whole_stretch()
|
||||
{
|
||||
var summary = Drive((0, 30, Pc1, Pc5, 1, -1), (30, 60, Tb1, Tb1, 1, -1), (60, 90, Pc1, Pc5, 1, -1)).Summarize();
|
||||
Assert.Equal(30, summary.WrongLightsSeconds, 0);
|
||||
var fault = Assert.Single(summary.LightsFaults);
|
||||
Assert.Equal(HeadCode.Tb1, fault.Head);
|
||||
Assert.Equal(HeadCode.Pc1, fault.HeadRequired);
|
||||
Assert.True(fault.HeadWrong && fault.TailWrong);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Shunting_is_to_show_Tb1()
|
||||
{
|
||||
var summary = Drive((0, 30, Tb1, Tb1, 2, -1), (30, 40, Pc1, Pc5, 2, -1)).Summarize();
|
||||
var fault = Assert.Single(summary.LightsFaults);
|
||||
Assert.Equal(HeadCode.Tb1, fault.HeadRequired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_pattern_the_timetable_asks_for_is_the_one_to_show()
|
||||
{
|
||||
// Pc 2 asked for at the head: the left red marker, the right headlight and the upper one.
|
||||
const short pc2 = 0x16;
|
||||
var summary = Drive((0, 30, Pc1, Pc5, 1, pc2)).Summarize();
|
||||
var fault = Assert.Single(summary.LightsFaults);
|
||||
Assert.Equal(HeadCode.Pc2, fault.HeadRequired);
|
||||
Assert.False(fault.TailWrong);
|
||||
}
|
||||
|
||||
private static Recording DriveWith(ushort head, ushort tail, ushort equippedHead, ushort equippedTail, byte movement = 1, byte driving = 1)
|
||||
{
|
||||
var f = new BlackBoxFile().Info();
|
||||
for (double t = 0; t < 30; t += 0.5)
|
||||
f.Cab(t, 1, 1000 + t * 10, 40, driving: driving, lightsHead: head, lightsTail: tail, movement: movement,
|
||||
equippedHead: equippedHead, equippedTail: equippedTail);
|
||||
return BlackBoxReader.Read(f.End(30).Stream());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_lamps_the_vehicles_have_are_read()
|
||||
{
|
||||
var sample = DriveWith(Pc1, Pc5, 0x1B5, 0x22).Cab[0];
|
||||
Assert.Equal((Lights)0x1B5, sample.EquippedHead);
|
||||
Assert.Equal((Lights)0x22, sample.EquippedTail);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// without the upper headlight Pc 1 is the two others
|
||||
[InlineData(0x11, 0x11)]
|
||||
// with auxiliary lamps, those and the upper headlight
|
||||
[InlineData(0x184, 0x1B5)]
|
||||
// a vehicle with no lamps for it: the rear-end signals
|
||||
[InlineData(0x40, 0x40)]
|
||||
public void Pc1_is_what_the_vehicle_can_show_of_it(ushort lit, ushort equipped)
|
||||
{
|
||||
var summary = DriveWith(lit, Pc5, equipped, Pc5).Summarize();
|
||||
Assert.True(summary.LightsJudgedSeconds > 25);
|
||||
Assert.Empty(summary.LightsFaults);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_tail_with_the_rear_end_signals_only_shows_Pc5_with_them()
|
||||
{
|
||||
Assert.Empty(DriveWith(Pc1, 0x40, Pc1, 0x40).Summarize().LightsFaults);
|
||||
var fault = Assert.Single(DriveWith(Pc1, 0x00, Pc1, 0x40).Summarize().LightsFaults);
|
||||
Assert.False(fault.HeadWrong);
|
||||
Assert.True(fault.TailWrong);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void What_drives_the_train_and_the_rest_are_read_when_written()
|
||||
{
|
||||
var f = new BlackBoxFile().Info();
|
||||
f.Cab(0, 1, 1000, 40, movement: 1, equippedHead: 0x15, equippedTail: 0x22, engine: 4, oilTemperature: 71.5f);
|
||||
f.Cab(0.5, 1, 1000, 40, movement: 1, equippedHead: 0x15, equippedTail: 0x22);
|
||||
var cab = BlackBoxReader.Read(f.End(1).Stream()).Cab;
|
||||
var extra = Assert.NotNull(cab[0].Extra);
|
||||
Assert.Equal(EngineKind.DieselElectric, extra.Engine);
|
||||
Assert.Equal(3, extra.RadioChannel);
|
||||
Assert.Equal(71.5f, extra.OilTemperature);
|
||||
Assert.Equal(1.0f, extra.Clutch);
|
||||
Assert.Equal(2.5f, extra.ConsistBrakeCylinder);
|
||||
// an older sample has none
|
||||
Assert.Null(cab[1].Extra);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_vehicle_with_no_lamps_is_not_judged()
|
||||
{
|
||||
var recording = DriveWith(0, 0, 0, 0);
|
||||
Assert.Null(HeadCodes.HeadRight(recording.Cab[0]));
|
||||
Assert.Null(HeadCodes.TailRight(recording.Cab[0]));
|
||||
var summary = recording.Summarize();
|
||||
Assert.Equal(0, summary.LightsJudgedSeconds);
|
||||
Assert.Empty(summary.LightsFaults);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_vehicle_with_all_the_lamps_is_to_light_all_three_for_Pc1()
|
||||
{
|
||||
var fault = Assert.Single(DriveWith(0x11, Pc5, 0x35, Pc5).Summarize().LightsFaults);
|
||||
Assert.True(fault.HeadWrong);
|
||||
Assert.Equal(HeadCode.Pc1, fault.HeadRequired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_lights_the_autopilot_sets_are_not_the_users()
|
||||
{
|
||||
var summary = DriveWith(Tb1, Tb1, Pc1, Pc5, driving: 2).Summarize();
|
||||
Assert.Equal(0, summary.LightsJudgedSeconds);
|
||||
Assert.Empty(summary.LightsFaults);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_older_recording_reads_a_pattern_asked_for_as_the_signal_it_shows()
|
||||
{
|
||||
// Pc 5 asked for at the tail, the rear-end signals lit, no lamps written: Pc 5 as before
|
||||
var f = new BlackBoxFile().Info();
|
||||
for (double t = 0; t < 30; t += 0.5)
|
||||
f.Cab(t, 1, 1000 + t * 10, 40, lightsHead: Pc1, lightsTail: 0x40, movement: 1, hintTail: 0x22);
|
||||
var summary = BlackBoxReader.Read(f.End(30).Stream()).Summarize();
|
||||
Assert.True(summary.LightsJudgedSeconds > 25);
|
||||
Assert.Empty(summary.LightsFaults);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_older_recording_tells_nothing_of_the_signals()
|
||||
{
|
||||
var f = new BlackBoxFile().Info();
|
||||
for (double t = 0; t < 30; t += 0.5)
|
||||
f.Cab(t, 1, 1000 + t * 10, 40, lightsHead: Tb1);
|
||||
var summary = BlackBoxReader.Read(f.End(30).Stream()).Summarize();
|
||||
Assert.Equal(0, summary.LightsJudgedSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The user going over to another vehicle: the timetable of the train is the one of the train the user is in.</summary>
|
||||
public class UserVehicleTests
|
||||
{
|
||||
private const byte Coupled = 0x83;
|
||||
|
||||
[Fact]
|
||||
public void Another_vehicle_of_the_same_train_keeps_its_timetable_another_train_has_none()
|
||||
{
|
||||
// The user in vehicle 1 of the unit 1-2 with ROJ1; at t = 50 over to the other cab, at t = 100 into vehicle 9, on its own.
|
||||
var f = new BlackBoxFile().Info()
|
||||
.Coupling(0, 1, 1, 2, 0, Coupled, 0, 0)
|
||||
.Timetable(0, 1, "ROJ1", "A", "B", ("A", -1, 635), ("B", 640, -1));
|
||||
for (double t = 0; t < 150; t += 0.5)
|
||||
f.Cab(t, t < 50 ? 1u : t < 100 ? 2u : 9u, 1000, 0);
|
||||
var r = BlackBoxReader.Read(f.End(150).Stream());
|
||||
|
||||
Assert.Equal("ROJ1", r.TimetableAt(20)!.Train);
|
||||
Assert.Equal("ROJ1", r.TimetableAt(70)!.Train);
|
||||
Assert.Null(r.TimetableAt(120));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Single_vehicles_with_no_coupling_written_are_still_told_apart()
|
||||
{
|
||||
// A recording made with the movement written, where no train is longer than one vehicle: no coupling at all.
|
||||
var f = new BlackBoxFile().Info()
|
||||
.Timetable(0, 1, "ROJ1", "A", "B", ("A", -1, 635), ("B", 640, -1));
|
||||
for (double t = 0; t < 100; t += 0.5)
|
||||
f.Cab(t, t < 50 ? 1u : 9u, 1000, 0, movement: 1);
|
||||
var r = BlackBoxReader.Read(f.End(100).Stream());
|
||||
|
||||
Assert.Equal("ROJ1", r.TimetableAt(20)!.Train);
|
||||
Assert.Null(r.TimetableAt(70));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_older_recording_with_no_coupling_keeps_the_timetable()
|
||||
{
|
||||
var f = new BlackBoxFile().Info()
|
||||
.Timetable(0, 1, "ROJ1", "A", "B", ("A", -1, 635), ("B", 640, -1));
|
||||
for (double t = 0; t < 100; t += 0.5)
|
||||
f.Cab(t, t < 50 ? 1u : 2u, 1000, 0);
|
||||
var r = BlackBoxReader.Read(f.End(100).Stream());
|
||||
|
||||
Assert.Equal("ROJ1", r.TimetableAt(70)!.Train);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The departures and the platforms in the summary.</summary>
|
||||
public class StationSummaryTests
|
||||
{
|
||||
[Fact]
|
||||
public void The_summary_tells_the_largest_delay_leaving_and_the_stops_off_the_platform()
|
||||
{
|
||||
// Clock 10:34 at t = 0, leaves A at once. Stops at B (10:36 to 10:37) from t = 120, leaves at t = 240 (10:38): a minute late.
|
||||
var f = new BlackBoxFile().Info()
|
||||
.Timetable(0, 1, "ROJ1", "A", "C", ("A", -1, 634), ("B", 636, 637), ("C", 640, -1))
|
||||
.Station(1, 1, 1, 1, "A", 1000)
|
||||
.Station(120, 1, 1, 2, "Stacja_B", 1000, distance: 74.7f, trainLength: 129.8f, platform: 201);
|
||||
for (double t = 0; t < 300; t += 0.5)
|
||||
f.Cab(t, 1, 1000, t is >= 120 and < 240 or < 2 ? 0 : 40);
|
||||
var r = BlackBoxReader.Read(f.End(300).Stream());
|
||||
var summary = r.Summarize();
|
||||
|
||||
Assert.Equal(1, summary.MaxDepartureDelayMinutes!.Value, 1);
|
||||
var off = Assert.Single(summary.OffPlatform);
|
||||
Assert.Equal("B", off.Station);
|
||||
Assert.Equal(3.5, off.Metres, 1);
|
||||
}
|
||||
}
|
||||
98
StarterNG.Tests/BlackBox/DrivingSummaryTests.cs
Normal file
98
StarterNG.Tests/BlackBox/DrivingSummaryTests.cs
Normal file
@@ -0,0 +1,98 @@
|
||||
using Eu07.BlackBox;
|
||||
|
||||
namespace StarterNG.Tests.BlackBox;
|
||||
|
||||
public class DrivingSummaryTests
|
||||
{
|
||||
private static Recording Drive(Func<BlackBoxFile, BlackBoxFile> more)
|
||||
{
|
||||
var file = new BlackBoxFile().Info();
|
||||
return BlackBoxReader.Read(more(file).Stream());
|
||||
}
|
||||
|
||||
/// <summary>Ten samples a second from t, at a speed against an AI holding target.</summary>
|
||||
private static BlackBoxFile Stretch(BlackBoxFile file, double from, double seconds, float speed, float target,
|
||||
byte driving = 1, byte flags = 0x03)
|
||||
{
|
||||
for (int i = 0; i < seconds * 10; i++)
|
||||
file.Cab(from + i * 0.1, 1, 1000 + (from + i * 0.1) * speed / 3.6, speed, driving, flags, target);
|
||||
return file;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(40f, 40f, SpeedJudgement.Within)]
|
||||
[InlineData(40.4f, 40f, SpeedJudgement.Within)]
|
||||
[InlineData(44f, 40f, SpeedJudgement.Slightly)]
|
||||
[InlineData(46f, 40f, SpeedJudgement.Over)]
|
||||
[InlineData(-46f, 40f, SpeedJudgement.Over)]
|
||||
public void A_sample_is_judged_by_how_much_faster_than_the_AI_it_went(float speed, float target, SpeedJudgement expected)
|
||||
{
|
||||
var r = Drive(f => f.Cab(0, 1, 0, speed, target: target));
|
||||
|
||||
Assert.Equal(expected, DrivingSummary.Judge(r.Cab[0]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_the_AI_worked_out_or_with_the_AI_driving_there_is_nothing_to_judge()
|
||||
{
|
||||
var r = Drive(f => f.Cab(0, 1, 0, 80, flags: 0x01, target: 40).Cab(0.1, 1, 0, 80, driving: 2, target: 40));
|
||||
|
||||
Assert.Equal(SpeedJudgement.NoData, DrivingSummary.Judge(r.Cab[0]));
|
||||
Assert.Equal(SpeedJudgement.Autopilot, DrivingSummary.Judge(r.Cab[1]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_summary_counts_the_time_over_the_AI_the_autopilot_and_the_signals_passed_at_danger()
|
||||
{
|
||||
var r = Drive(f =>
|
||||
{
|
||||
Stretch(f, 0, 10, 40, 40);
|
||||
Stretch(f, 10, 10, 50, 40);
|
||||
Stretch(f, 20, 10, 43, 40);
|
||||
Stretch(f, 30, 10, 60, 60, driving: 2);
|
||||
f.Passed(15, 1, 0, "s1", 0, 50, 0x02);
|
||||
f.Passed(25, 1, 1, "s2", 40, 43);
|
||||
// Another train, on a track of its own far away.
|
||||
f.Passed(25, 7, 2, "someone else's", 0, 20, 0x02, z: 9000);
|
||||
f.Command(5, 1, true, "mastercontrollerincrease");
|
||||
f.Command(5.1, 1, false, "mastercontrollerincrease");
|
||||
f.Command(6, 0, true, "timejump");
|
||||
return f.Driver(30, 1, 2);
|
||||
});
|
||||
|
||||
var summary = r.Summarize();
|
||||
|
||||
Assert.Equal(20, summary.OverAiSeconds, 0);
|
||||
Assert.Equal(10, summary.WellOverAiSeconds, 0);
|
||||
Assert.Equal(10, summary.MaxOverAiKmh, 3);
|
||||
Assert.InRange(summary.MaxOverAiAt, 10, 20);
|
||||
Assert.Equal(10, summary.AutopilotSeconds, 0);
|
||||
Assert.Equal(30, summary.DrivenSeconds, 0);
|
||||
Assert.Equal(60, summary.MaxSpeedKmh);
|
||||
Assert.Equal(2, summary.SignalsPassed);
|
||||
Assert.Equal("s1", Assert.Single(summary.PassedAtDanger).Event);
|
||||
Assert.Equal(1, summary.Commands);
|
||||
Assert.Equal(1, summary.AutopilotSwitches);
|
||||
// 10 s at 40, 50, 43 and 60 km/h.
|
||||
Assert.InRange(summary.DistanceKm, 0.530, 0.540);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_autopilot_is_told_as_spans_of_time()
|
||||
{
|
||||
var r = Drive(f =>
|
||||
{
|
||||
Stretch(f, 0, 5, 40, 40);
|
||||
Stretch(f, 5, 5, 40, 40, driving: 2);
|
||||
Stretch(f, 10, 5, 40, 40);
|
||||
return Stretch(f, 15, 2, 40, 40, driving: 2);
|
||||
});
|
||||
|
||||
var spans = r.AutopilotSpans();
|
||||
|
||||
Assert.Equal(2, spans.Count);
|
||||
Assert.Equal(5, spans[0].Start, 3);
|
||||
Assert.Equal(10, spans[0].End, 3);
|
||||
Assert.Equal(15, spans[1].Start, 3);
|
||||
}
|
||||
}
|
||||
106
StarterNG.Tests/BlackBox/PlatformTests.cs
Normal file
106
StarterNG.Tests/BlackBox/PlatformTests.cs
Normal file
@@ -0,0 +1,106 @@
|
||||
using System.IO;
|
||||
using Eu07.BlackBox;
|
||||
|
||||
namespace StarterNG.Tests.BlackBox;
|
||||
|
||||
/// <summary>The second value of a stop point as the driver reads it: all of it the metres, the last digit also the side.</summary>
|
||||
public class PlatformTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(201f, 201, 1)]
|
||||
[InlineData(242f, 242, 2)]
|
||||
[InlineData(553f, 553, 3)]
|
||||
[InlineData(150f, 150, 0)]
|
||||
[InlineData(-151f, 151, 1)]
|
||||
public void The_whole_value_is_metres_and_the_last_digit_the_side(float value, double length, int side)
|
||||
{
|
||||
Assert.Equal(length, Platforms.Length(value));
|
||||
Assert.Equal(side, Platforms.Side(value));
|
||||
}
|
||||
|
||||
private static StationEvent Stop(float distance, float length, float platform) =>
|
||||
new(0, 1, StationKind.Stopped, 1, "Swoszowice", new Position(0, 0, 0), distance, 63.2f, length, platform, 600, 601);
|
||||
|
||||
[Fact]
|
||||
public void A_train_whose_tail_reaches_past_the_platform_of_201_is_off_it()
|
||||
{
|
||||
// Two EN57 at Swoszowice: the head 74.7 m before the W4, the tail 204.5 m, the platform 201 m.
|
||||
var stop = Stop(74.7f, 129.8f, 201);
|
||||
Assert.False(stop.OnPlatform);
|
||||
Assert.Equal(3.5, stop.OffPlatform, 1);
|
||||
Assert.Equal(201, stop.PlatformLength);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_train_whose_head_stands_past_the_stop_point_is_off_the_platform_by_that_much()
|
||||
{
|
||||
var stop = Stop(-5f, 129.8f, 201);
|
||||
Assert.False(stop.OnPlatform);
|
||||
Assert.Equal(5, stop.OffPlatform, 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_metre_off_still_counts_as_along_the_platform()
|
||||
{
|
||||
var stop = Stop(-1f, 129.8f, 201);
|
||||
Assert.True(stop.OnPlatform);
|
||||
Assert.Equal(1, stop.OffPlatform, 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Each_end_has_its_own_tolerance()
|
||||
{
|
||||
// the head 1.5 m past the stop point, the tail 1.5 m past the platform's end
|
||||
var stop = Stop(-1.5f, 204f, 201);
|
||||
Assert.True(stop.OnPlatform);
|
||||
Assert.Equal(3, stop.OffPlatform, 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_train_within_the_platform_is_along_it()
|
||||
{
|
||||
var stop = Stop(63.2f, 129.8f, 201);
|
||||
Assert.True(stop.OnPlatform);
|
||||
Assert.Equal(0, stop.OffPlatform);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The way a stop point faces and the W32, as the simulator writes them.</summary>
|
||||
public class StopPointRecordTests
|
||||
{
|
||||
[Fact]
|
||||
public void A_stop_point_tells_the_way_of_the_trains_it_is_for()
|
||||
{
|
||||
var recording = BlackBoxReader.Read(new MemoryStream(new BlackBoxFile().Info().StopPoint(0, "Swoszowice", 100, 201, 201, 1.25f).End(10).Bytes));
|
||||
var point = Assert.Single(recording.StopPoints);
|
||||
Assert.True(point.HasHeading);
|
||||
Assert.Equal(1.25f, point.Heading);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_stop_point_of_an_older_recording_has_no_way()
|
||||
{
|
||||
var recording = BlackBoxReader.Read(new MemoryStream(new BlackBoxFile().Info().StopPoint(0, "Swoszowice", 100, 201, 201).End(10).Bytes));
|
||||
Assert.False(Assert.Single(recording.StopPoints).HasHeading);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_stop_point_on_tracks_of_both_ways_has_no_way()
|
||||
{
|
||||
var recording = BlackBoxReader.Read(new MemoryStream(new BlackBoxFile().Info().StopPoint(0, "Swoszowice", 100, 201, 201, float.NaN).End(10).Bytes));
|
||||
Assert.False(Assert.Single(recording.StopPoints).HasHeading);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_W32_is_for_the_trains_shorter_than_its_length_and_that_is_their_room()
|
||||
{
|
||||
var recording = BlackBoxReader.Read(new MemoryStream(new BlackBoxFile().Info().StopPoint(0, "Tumlin_po", 100, -1, -121).End(10).Bytes));
|
||||
var point = Assert.Single(recording.StopPoints);
|
||||
Assert.True(point.ForShortTrains);
|
||||
Assert.False(point.HasPlatform);
|
||||
Assert.Equal(121, point.PlatformLength);
|
||||
var stop = new StationEvent(0, 1, StationKind.Stopped, 1, "Tumlin_po", new Position(0, 0, 0), 60, 50, 64.9f, -121, 600, 601);
|
||||
Assert.False(stop.OnPlatform);
|
||||
Assert.Equal(3.9, stop.OffPlatform, 1);
|
||||
}
|
||||
}
|
||||
149
StarterNG.Tests/CargoTests.cs
Normal file
149
StarterNG.Tests/CargoTests.cs
Normal file
@@ -0,0 +1,149 @@
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Domain;
|
||||
using StarterNG.Domain.Vehicles;
|
||||
using StarterNG.Tests.Fakes;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public class CargoTests
|
||||
{
|
||||
private readonly Consist _consist;
|
||||
private readonly VehicleCatalog _catalog;
|
||||
private readonly VehicleInfo _info;
|
||||
|
||||
public CargoTests()
|
||||
{
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithFile(TestInstallation.At("dynamic", "pkp", "en57", "textures.txt"), "!=b\nen57-a.mat=en57,EN57")
|
||||
.WithFile(TestInstallation.At("dynamic", "pkp", "en57", "en57.fiz"),
|
||||
"Load: LoadAccepted=Passengers,Pantstate MaxLoad=50")
|
||||
.WithFile(TestInstallation.At("dynamic", "pkp", "tram", "textures.txt"), "!=b\ntram-a.mat=tram,TRAM")
|
||||
.WithFile(TestInstallation.At("dynamic", "pkp", "tram", "tram.fiz"),
|
||||
"Load: LoadAccepted=pantstate,Passengers MaxLoad=52")
|
||||
.WithFile(TestInstallation.At("dynamic", "pkp", "s1", "textures.txt"), "!=b\ns1-a.mat=s1,S1")
|
||||
.WithFile(TestInstallation.At("dynamic", "pkp", "s1", "s1.fiz"), "Load: LoadAccepted=Coal,Sand"));
|
||||
installation.Vehicles.Load(installation.Library.Vehicles);
|
||||
|
||||
_catalog = installation.Library.Vehicles;
|
||||
_info = new VehicleInfo(_catalog, installation.Physics);
|
||||
_consist = new Consist(_catalog, _info);
|
||||
}
|
||||
|
||||
private (Dynamic Car, List<ConsistItem> Consist) Wagon(string skin, string? type = null, int count = 0)
|
||||
{
|
||||
var car = _consist.MakeDynamic(_catalog.TextureForSkin(skin)!, null);
|
||||
car.LoadType = type;
|
||||
car.LoadCount = count;
|
||||
return (car, new List<ConsistItem> { new() { Cars = new List<Dynamic> { car } } });
|
||||
}
|
||||
|
||||
private Cargo Picking(int index) => new(_info, new Pick(index));
|
||||
|
||||
private sealed class Pick : Random
|
||||
{
|
||||
private readonly int _index;
|
||||
|
||||
public Pick(int index) => _index = index;
|
||||
|
||||
public override int Next(int maxValue) => _index;
|
||||
|
||||
public override int Next(int minValue, int maxValue) => maxValue - 1;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_random_type_takes_no_more_than_it_holds()
|
||||
{
|
||||
var (car, consist) = Wagon("en57-a", "Passengers", 50);
|
||||
|
||||
Picking(1).RandomTypes(consist);
|
||||
|
||||
Assert.Equal("Pantstate", car.LoadType);
|
||||
Assert.Equal(Dynamic.PantStateMax, car.LoadCount);
|
||||
Assert.Contains(" 3 Pantstate ", car.ToTrainsetNode());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_random_type_leaves_a_count_it_can_hold()
|
||||
{
|
||||
var (car, consist) = Wagon("en57-a", "Pantstate", 2);
|
||||
|
||||
Picking(0).RandomTypes(consist);
|
||||
|
||||
Assert.Equal("Passengers", car.LoadType);
|
||||
Assert.Equal(2, car.LoadCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_random_type_with_no_limit_declared_keeps_the_count()
|
||||
{
|
||||
// MaxLoad unset: nothing to hold it to.
|
||||
var (car, consist) = Wagon("s1-a", "Coal", 40);
|
||||
|
||||
Picking(1).RandomTypes(consist);
|
||||
|
||||
Assert.Equal("Sand", car.LoadType);
|
||||
Assert.Equal(40, car.LoadCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Filling_an_empty_car_goes_by_the_type_it_is_given()
|
||||
{
|
||||
// Its first accepted type is pantographs: the 52 of the .fiz is not theirs.
|
||||
var (car, _) = Wagon("tram-a");
|
||||
|
||||
Picking(0).FillToMax(car);
|
||||
|
||||
Assert.Equal("pantstate", car.LoadType);
|
||||
Assert.Equal(Dynamic.PantStateMax, car.LoadCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_random_amount_for_an_empty_car_goes_by_the_type_it_is_given()
|
||||
{
|
||||
var (car, consist) = Wagon("tram-a");
|
||||
|
||||
Picking(0).RandomAmounts(consist);
|
||||
|
||||
Assert.Equal("pantstate", car.LoadType);
|
||||
Assert.InRange(car.LoadCount, 1, Dynamic.PantStateMax);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_count_passed_on_keeps_where_the_next_car_declares_no_limit()
|
||||
{
|
||||
// MaxLoad unset: nothing to hold it to, as when taken from the car before.
|
||||
var (_, consist) = Wagon("s1-a", "Coal", 40);
|
||||
var (next, following) = Wagon("s1-a");
|
||||
consist.AddRange(following);
|
||||
|
||||
Picking(0).CopyToFollowing(consist, consist[0]);
|
||||
|
||||
Assert.Equal("Coal", next.LoadType);
|
||||
Assert.Equal(40, next.LoadCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_count_passed_on_is_held_to_what_the_next_car_declares()
|
||||
{
|
||||
var (_, consist) = Wagon("tram-a", "Passengers", 52);
|
||||
var (next, following) = Wagon("en57-a");
|
||||
consist.AddRange(following);
|
||||
|
||||
Picking(0).CopyToFollowing(consist, consist[0]);
|
||||
|
||||
Assert.Equal("Passengers", next.LoadType);
|
||||
Assert.Equal(50, next.LoadCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_car_with_nothing_declared_stays_untouched()
|
||||
{
|
||||
var (car, _) = Wagon("en57-a");
|
||||
car.MaxLoad = 0;
|
||||
|
||||
Picking(0).FillToMax(car);
|
||||
|
||||
Assert.Null(car.LoadType);
|
||||
Assert.Equal(0, car.LoadCount);
|
||||
}
|
||||
}
|
||||
186
StarterNG.Tests/ConfigFileTests.cs
Normal file
186
StarterNG.Tests/ConfigFileTests.cs
Normal file
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ public class ConsistCrewTests
|
||||
{
|
||||
private readonly Consist _consist;
|
||||
private readonly VehicleCatalog _catalog;
|
||||
private readonly VehicleInfo _info;
|
||||
|
||||
public ConsistCrewTests()
|
||||
{
|
||||
@@ -20,7 +21,129 @@ public class ConsistCrewTests
|
||||
installation.Vehicles.Load(installation.Library.Vehicles);
|
||||
|
||||
_catalog = installation.Library.Vehicles;
|
||||
_consist = new Consist(_catalog, new VehicleInfo(_catalog, installation.Physics));
|
||||
_info = new VehicleInfo(_catalog, installation.Physics);
|
||||
_consist = new Consist(_catalog, _info);
|
||||
}
|
||||
|
||||
private List<Dynamic> Cars(params string[] skins) =>
|
||||
skins.Select(skin => _consist.MakeDynamic(_catalog.TextureForSkin(skin)!, null)).ToList();
|
||||
|
||||
[Theory]
|
||||
[InlineData(eDriverType.Reardriver)]
|
||||
[InlineData(eDriverType.Passenger)]
|
||||
public void A_loaded_consist_keeps_the_kind_of_crew(eDriverType crew)
|
||||
{
|
||||
var before = Cars("ep07-001", "b111-01");
|
||||
before[0].DriverType = crew;
|
||||
var loaded = Cars("ep07-002", "b111-01");
|
||||
loaded[0].DriverType = eDriverType.Headdriver;
|
||||
|
||||
var cars = Consist.LoadedOver(before, loaded, _catalog, _info);
|
||||
|
||||
Assert.Equal(crew, cars[0].DriverType);
|
||||
Assert.Equal(eDriverType.Nobody, cars[1].DriverType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_consist_with_nobody_in_it_stays_so_after_loading()
|
||||
{
|
||||
var loaded = Cars("ep07-002");
|
||||
loaded[0].DriverType = eDriverType.Headdriver;
|
||||
|
||||
var cars = Consist.LoadedOver(Cars("ep07-001"), loaded, _catalog, _info);
|
||||
|
||||
Assert.Equal(eDriverType.Nobody, cars[0].DriverType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_driver_does_not_go_into_loaded_wagons()
|
||||
{
|
||||
var before = Cars("ep07-001");
|
||||
before[0].DriverType = eDriverType.Headdriver;
|
||||
|
||||
var cars = Consist.LoadedOver(before, Cars("b111-01"), _catalog, _info);
|
||||
|
||||
Assert.Equal(eDriverType.Nobody, cars[0].DriverType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Loaded_over_an_empty_consist_the_vehicles_keep_their_own_crew()
|
||||
{
|
||||
var loaded = Cars("ep07-002");
|
||||
loaded[0].DriverType = eDriverType.Reardriver;
|
||||
|
||||
Assert.Equal(eDriverType.Reardriver,
|
||||
Consist.LoadedOver(new List<Dynamic>(), loaded, _catalog, _info)[0].DriverType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vehicles_joining_a_consist_come_without_their_own_crew()
|
||||
{
|
||||
var before = Cars("ep07-001");
|
||||
before[0].DriverType = eDriverType.Headdriver;
|
||||
var added = Cars("ep07-002");
|
||||
added[0].DriverType = eDriverType.Headdriver;
|
||||
|
||||
var cars = Consist.Joined(before, added);
|
||||
|
||||
Assert.Equal(eDriverType.Headdriver, cars[0].DriverType);
|
||||
Assert.Equal(eDriverType.Nobody, cars[1].DriverType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_locomotive_moved_to_the_head_takes_the_driver_in_the_same_cab()
|
||||
{
|
||||
var first = Add("ep07-001", 0);
|
||||
var second = Add("ep07-002", 1);
|
||||
first.Driver = eDriverType.Reardriver;
|
||||
first.Cars[0].DriverType = eDriverType.Reardriver;
|
||||
|
||||
_consist.Move(1, 0);
|
||||
|
||||
Assert.Equal(eDriverType.Reardriver, second.Cars[0].DriverType);
|
||||
Assert.Equal(eDriverType.Nobody, first.Cars[0].DriverType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vehicles_joining_an_empty_consist_keep_their_crew()
|
||||
{
|
||||
var added = Cars("ep07-002");
|
||||
added[0].DriverType = eDriverType.Headdriver;
|
||||
|
||||
Assert.Equal(eDriverType.Headdriver, Consist.Joined(new List<Dynamic>(), added)[0].DriverType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Selecting_a_wagon_keeps_the_locomotive_as_the_starting_vehicle()
|
||||
{
|
||||
var loco = Add("ep07-001", 0);
|
||||
var wagon = Add("b111-01", 1);
|
||||
loco.Cars[0].Name = "loco";
|
||||
wagon.Cars[0].Name = "wagon";
|
||||
|
||||
Assert.Equal("loco", Consist.StartingVehicle(_consist, wagon, "loco"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_wagon_selected_after_a_stale_pick_falls_back_to_the_driver()
|
||||
{
|
||||
var loco = Add("ep07-001", 0);
|
||||
var wagon = Add("b111-01", 1);
|
||||
loco.Cars[0].Name = "loco";
|
||||
|
||||
Assert.Equal("loco", Consist.StartingVehicle(_consist, wagon, "gone"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Selecting_a_crewed_unit_makes_it_the_starting_vehicle()
|
||||
{
|
||||
var first = Add("ep07-001", 0);
|
||||
var wagon = Add("b111-01", 1);
|
||||
first.Cars[0].Name = "loco";
|
||||
wagon.Cars[0].Name = "wagon";
|
||||
wagon.Cars[0].DriverType = eDriverType.Passenger;
|
||||
|
||||
Assert.Equal("wagon", Consist.StartingVehicle(_consist, wagon, "loco"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
98
StarterNG.Tests/ConsistHistoryTests.cs
Normal file
98
StarterNG.Tests/ConsistHistoryTests.cs
Normal file
@@ -0,0 +1,98 @@
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Domain;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public class ConsistHistoryTests
|
||||
{
|
||||
private static Dynamic Car(string name) => new()
|
||||
{
|
||||
Name = name, DataFolder = "pkp", SkinFile = name, MmdFile = name, PathName = ""
|
||||
};
|
||||
|
||||
private static Trainset With(params string[] names) =>
|
||||
new("trainset none none 0 0\nendtrainset") { Vehicles = names.Select(Car).ToList() };
|
||||
|
||||
private static string[] Names(IEnumerable<Dynamic> vehicles) => vehicles.Select(v => v.Name).ToArray();
|
||||
|
||||
[Fact]
|
||||
public void A_redraw_without_a_change_leaves_nothing_to_undo()
|
||||
{
|
||||
var history = new ConsistHistory();
|
||||
var trainset = With("a", "b");
|
||||
|
||||
history.Record(trainset, 0);
|
||||
history.Record(trainset, 1);
|
||||
|
||||
Assert.False(history.CanUndo(trainset));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Undo_goes_back_one_change_and_redo_forward_again()
|
||||
{
|
||||
var history = new ConsistHistory();
|
||||
var trainset = With("a");
|
||||
history.Record(trainset, 0);
|
||||
trainset.Vehicles.Add(Car("b"));
|
||||
history.Record(trainset, 1);
|
||||
|
||||
var back = history.Undo(trainset, 1);
|
||||
|
||||
Assert.Equal(new[] { "a" }, Names(back!.Vehicles));
|
||||
Assert.True(history.CanRedo(trainset));
|
||||
|
||||
trainset.Vehicles = back.Vehicles.ToList();
|
||||
history.Record(trainset, 0, rebase: true);
|
||||
var forward = history.Redo(trainset, 0);
|
||||
|
||||
Assert.Equal(new[] { "a", "b" }, Names(forward!.Vehicles));
|
||||
Assert.Equal(1, forward.Selected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_edit_made_in_place_is_still_undone()
|
||||
{
|
||||
// The vehicles are shared with the editor, so a change to one of them must
|
||||
// not reach the state kept for undo.
|
||||
var history = new ConsistHistory();
|
||||
var trainset = With("a");
|
||||
history.Record(trainset, 0);
|
||||
|
||||
trainset.Vehicles[0].DriverType = eDriverType.Passenger;
|
||||
var back = history.Undo(trainset, 0);
|
||||
|
||||
Assert.NotEqual(eDriverType.Passenger, back!.Vehicles[0].DriverType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_new_change_drops_what_could_be_redone()
|
||||
{
|
||||
var history = new ConsistHistory();
|
||||
var trainset = With("a");
|
||||
history.Record(trainset, 0);
|
||||
trainset.Vehicles.Add(Car("b"));
|
||||
history.Record(trainset, 1);
|
||||
trainset.Vehicles = history.Undo(trainset, 1)!.Vehicles.ToList();
|
||||
history.Record(trainset, 0, rebase: true);
|
||||
|
||||
trainset.Vehicles.Add(Car("c"));
|
||||
history.Record(trainset, 1);
|
||||
|
||||
Assert.False(history.CanRedo(trainset));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Each_trainset_has_its_own_history()
|
||||
{
|
||||
var history = new ConsistHistory();
|
||||
var first = With("a");
|
||||
var second = With("x");
|
||||
history.Record(first, 0);
|
||||
first.Vehicles.Add(Car("b"));
|
||||
history.Record(first, 1);
|
||||
history.Record(second, 0);
|
||||
|
||||
Assert.True(history.CanUndo(first));
|
||||
Assert.False(history.CanUndo(second));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using StarterNG.Application;
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Domain;
|
||||
using StarterNG.Domain.Vehicles;
|
||||
@@ -87,6 +88,25 @@ public class ConsistOrientationTests
|
||||
Assert.All(consist, card => Assert.Equal(Consist.IsTurned(card.Cars[0]), card.Flipped));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_turned_unit_comes_back_as_it_was_from_every_load()
|
||||
{
|
||||
AppServices.Initialize(_installation.Paths, _installation.Files, _installation.Clock, _installation.Environment);
|
||||
var consist = NewConsist(out var unit);
|
||||
consist.Flip(unit);
|
||||
string[] turned = Describe(consist);
|
||||
var trainset = new Trainset("trainset a tor 0 0\nendtrainset");
|
||||
|
||||
for (int load = 0; load < 3; load++)
|
||||
{
|
||||
trainset.Vehicles = consist.Flatten();
|
||||
consist.LoadFrom(trainset);
|
||||
|
||||
Assert.True(Assert.Single(consist).Flipped);
|
||||
Assert.Equal(turned, Describe(consist));
|
||||
}
|
||||
}
|
||||
|
||||
private Consist NewConsist(out ConsistItem unit)
|
||||
{
|
||||
var consist = new Consist(_catalog, new VehicleInfo(_catalog, _installation.Physics));
|
||||
|
||||
@@ -21,6 +21,21 @@ public class ConsistTailTests
|
||||
_consist = new Consist(_catalog, new VehicleInfo(_catalog, installation.Physics));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_unit_can_join_the_one_it_is_held_to_from_either_side()
|
||||
{
|
||||
var first = Append("b111-01");
|
||||
var second = Append("b111-02");
|
||||
|
||||
Assert.False(_consist.CanJoin(first, 0));
|
||||
Assert.False(_consist.CanJoin(second, 1));
|
||||
|
||||
first.Cars[0].Coupling.Set(Coupling.WorkshopLock, true);
|
||||
|
||||
Assert.True(_consist.CanJoin(first, 0));
|
||||
Assert.True(_consist.CanJoin(second, 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_vehicle_count_counts_cars_not_units()
|
||||
{
|
||||
|
||||
100
StarterNG.Tests/DisplaysTests.cs
Normal file
100
StarterNG.Tests/DisplaysTests.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
using StarterNG.Infrastructure;
|
||||
using StarterNG.Tests.Fakes;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public class DisplaysTests
|
||||
{
|
||||
/// <summary>A 128-byte block with an 18-byte timing at 54, as a panel's base EDID has it.</summary>
|
||||
private static byte[] BaseBlock(int pixelClock10KHz, int hActive, int hBlank, int vActive, int vBlank,
|
||||
int extensions = 0)
|
||||
{
|
||||
var edid = new byte[128];
|
||||
new byte[] { 0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0 }.CopyTo(edid, 0);
|
||||
edid[126] = (byte)extensions;
|
||||
int at = 54;
|
||||
edid[at] = (byte)pixelClock10KHz;
|
||||
edid[at + 1] = (byte)(pixelClock10KHz >> 8);
|
||||
edid[at + 2] = (byte)hActive;
|
||||
edid[at + 3] = (byte)hBlank;
|
||||
edid[at + 4] = (byte)((hActive >> 8) << 4 | hBlank >> 8);
|
||||
edid[at + 5] = (byte)vActive;
|
||||
edid[at + 6] = (byte)vBlank;
|
||||
edid[at + 7] = (byte)((vActive >> 8) << 4 | vBlank >> 8);
|
||||
return edid;
|
||||
}
|
||||
|
||||
/// <summary>A DisplayID 2.0 block with one type VII timing (kHz, sizes less one).</summary>
|
||||
private static byte[] DisplayIdBlock(int pixelClockKHz, int hActive, int hBlank, int vActive, int vBlank)
|
||||
{
|
||||
var block = new byte[128];
|
||||
block[0] = 0x70;
|
||||
block[1] = 0x20;
|
||||
block[2] = 23; // one data block: 3 bytes of header, 20 of timing
|
||||
block[3] = 2;
|
||||
int at = 5;
|
||||
block[at] = 0x22;
|
||||
block[at + 2] = 20;
|
||||
int t = at + 3, clock = pixelClockKHz - 1;
|
||||
block[t] = (byte)clock;
|
||||
block[t + 1] = (byte)(clock >> 8);
|
||||
block[t + 2] = (byte)(clock >> 16);
|
||||
void Put(int offset, int value)
|
||||
{
|
||||
block[t + offset] = (byte)(value - 1);
|
||||
block[t + offset + 1] = (byte)((value - 1) >> 8);
|
||||
}
|
||||
Put(4, hActive);
|
||||
Put(6, hBlank);
|
||||
Put(12, vActive);
|
||||
Put(14, vBlank);
|
||||
return block;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_plain_monitor_gives_its_preferred_timing()
|
||||
{
|
||||
// 1920x1080 at 148.5 MHz: the usual 60 Hz.
|
||||
Assert.Equal(60, Edid.HighestRefreshRate(BaseBlock(14850, 1920, 280, 1080, 45)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_fast_panel_found_only_in_displayid_is_read_there()
|
||||
{
|
||||
// As the eDP panel of a gaming laptop: 60 Hz in the base block, 165 Hz in DisplayID.
|
||||
var edid = BaseBlock(15394, 1920, 160, 1200, 35, extensions: 1)
|
||||
.Concat(DisplayIdBlock(423250, 1920, 160, 1200, 33)).ToArray();
|
||||
|
||||
Assert.Equal(165, Edid.HighestRefreshRate(edid));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Something_too_short_for_an_edid_has_no_rate()
|
||||
{
|
||||
Assert.Equal(0, Edid.HighestRefreshRate(new byte[] { 0, 0xFF }));
|
||||
Assert.Equal(0, Edid.HighestRefreshRate(new byte[128]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Only_connected_screens_are_listed_by_their_connector()
|
||||
{
|
||||
var files = new InMemoryFileSystem(caseSensitive: true)
|
||||
.WithFile("/sys/class/drm/card1-eDP-1/status", "connected\n")
|
||||
.WithFile("/sys/class/drm/card1-HDMI-A-1/status", "disconnected\n")
|
||||
.WithFile("/sys/class/drm/card1/device/uevent", "DRIVER=amdgpu\n");
|
||||
files.WriteAllBytes("/sys/class/drm/card1-eDP-1/edid", BaseBlock(14850, 1920, 280, 1080, 45));
|
||||
files.WriteAllBytes("/sys/class/drm/card1-HDMI-A-1/edid", BaseBlock(14850, 1920, 280, 1080, 45));
|
||||
|
||||
var displays = new Displays(files, new FakeEnvironment()).List();
|
||||
|
||||
Assert.Equal(new[] { new Display("eDP-1", 60) }, displays);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Off_linux_without_windows_nothing_is_read()
|
||||
{
|
||||
var environment = new FakeEnvironment { IsLinux = false, IsMacOS = true };
|
||||
|
||||
Assert.Empty(new Displays(new InMemoryFileSystem(), environment).List());
|
||||
}
|
||||
}
|
||||
@@ -171,6 +171,65 @@ public class ExecutableLocatorTests
|
||||
Assert.Equal(arguments, programArguments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void What_is_inside_says_what_the_file_is_not_its_name()
|
||||
{
|
||||
string windowsBuild = TestInstallation.At("eu07-win");
|
||||
string nativeBuild = TestInstallation.At("eu07.exe");
|
||||
var installation = WithWine(new InMemoryFileSystem()
|
||||
.WithFile(windowsBuild, PortableExecutable)
|
||||
.WithExecutable(nativeBuild, Elf));
|
||||
var executables = installation.Executables;
|
||||
|
||||
Assert.True(executables.IsWindowsBuild("eu07-win"));
|
||||
Assert.Equal(Wine, executables.LaunchCommand(windowsBuild, Array.Empty<string>()).FileName);
|
||||
Assert.False(executables.IsWindowsBuild("eu07.exe"));
|
||||
Assert.Equal(nativeBuild, executables.LaunchCommand(nativeBuild, Array.Empty<string>()).FileName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_script_on_linux_starts_itself()
|
||||
{
|
||||
string script = TestInstallation.At("eu07-start");
|
||||
var installation = WithWine(new InMemoryFileSystem().WithExecutable(script, "#!/bin/sh\nwine eu07.exe\n"));
|
||||
|
||||
string resolved = installation.Executables.Resolve(new SimulatorSettings(), out var problem);
|
||||
|
||||
Assert.EndsWith("eu07-start", resolved);
|
||||
Assert.Equal(ExeProblem.None, problem);
|
||||
Assert.Equal(script, installation.Executables.LaunchCommand(script, Array.Empty<string>()).FileName);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("notatki do eu07\n")]
|
||||
[InlineData("")]
|
||||
public void On_linux_the_execute_bit_alone_does_not_make_a_program(string inside)
|
||||
{
|
||||
// On a Windows partition every file has the bit.
|
||||
string note = TestInstallation.At("eu07_opis");
|
||||
var installation = WithWine(new InMemoryFileSystem()
|
||||
.WithExecutable(note, inside)
|
||||
.WithFile(TestInstallation.At("eu07.exe"), PortableExecutable));
|
||||
|
||||
string resolved = installation.Executables.Resolve(new SimulatorSettings(), out var problem);
|
||||
|
||||
Assert.EndsWith("eu07.exe", resolved);
|
||||
Assert.Equal(ExeProblem.WrongPlatform, problem);
|
||||
Assert.Equal(new[] { "eu07.exe" }, installation.Executables.ListCandidates());
|
||||
Assert.Equal(ExeProblem.NotAProgram, installation.Executables.Validate(note));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_mac_binary_is_not_taken_for_a_note()
|
||||
{
|
||||
string build = TestInstallation.At("eu07");
|
||||
var installation = new TestInstallation(new InMemoryFileSystem().WithExecutable(build, "Ïúíþ"));
|
||||
installation.Environment.IsLinux = false;
|
||||
installation.Environment.IsMacOS = true;
|
||||
|
||||
Assert.Equal(ExeProblem.None, installation.Executables.Validate(build));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Wine_is_never_looked_for_on_windows()
|
||||
{
|
||||
|
||||
@@ -9,16 +9,61 @@ namespace StarterNG.Tests.Fakes;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Paths are normalised to forward slashes and compared case-insensitively,
|
||||
/// matching how the parsers treat installation paths on both platforms.
|
||||
/// matching how the parsers treat installation paths on both platforms - or,
|
||||
/// asked for, case-sensitively, as a Linux disk holds them.
|
||||
/// </remarks>
|
||||
public sealed class InMemoryFileSystem : IFileSystem
|
||||
{
|
||||
private readonly Dictionary<string, byte[]> _files = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<string> _directories = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<string> _executable = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly StringComparison _comparison;
|
||||
private readonly Dictionary<string, byte[]> _files;
|
||||
private readonly HashSet<string> _directories;
|
||||
private readonly HashSet<string> _executable;
|
||||
private readonly Dictionary<string, string> _links;
|
||||
private readonly HashSet<string> _unreadable;
|
||||
private readonly HashSet<string> _unlistable;
|
||||
private readonly Dictionary<string, DateTime> _written;
|
||||
|
||||
public InMemoryFileSystem(bool caseSensitive = false)
|
||||
{
|
||||
_comparison = caseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase;
|
||||
var comparer = StringComparer.FromComparison(_comparison);
|
||||
_files = new Dictionary<string, byte[]>(comparer);
|
||||
_directories = new HashSet<string>(comparer);
|
||||
_executable = new HashSet<string>(comparer);
|
||||
_links = new Dictionary<string, string>(comparer);
|
||||
_unreadable = new HashSet<string>(comparer);
|
||||
_unlistable = new HashSet<string>(comparer);
|
||||
_written = new Dictionary<string, DateTime>(comparer);
|
||||
}
|
||||
|
||||
public DateTime LastWriteTimeUtc { get; set; } = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
/// <summary>Making a link throws, as on a disk that cannot hold one.</summary>
|
||||
public bool LinksFail { get; set; }
|
||||
|
||||
/// <summary>A folder moved to a destination this says yes to, its path with forward slashes, throws.</summary>
|
||||
public Func<string, bool>? MoveFails { get; set; }
|
||||
|
||||
/// <summary>The file stays, but reading or copying it throws, as a locked one does; writing it still goes through.</summary>
|
||||
public InMemoryFileSystem FailReading(string path)
|
||||
{
|
||||
_unreadable.Add(Normalize(path));
|
||||
return this;
|
||||
}
|
||||
|
||||
public InMemoryFileSystem AllowReading(string path)
|
||||
{
|
||||
_unreadable.Remove(Normalize(path));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>The folder is there, but listing it throws, as one without the permission does.</summary>
|
||||
public InMemoryFileSystem DenyListing(string path)
|
||||
{
|
||||
_unlistable.Add(Normalize(path));
|
||||
return this;
|
||||
}
|
||||
|
||||
static InMemoryFileSystem()
|
||||
{
|
||||
// The game's own files are code page 1250; a test that writes Polish text
|
||||
@@ -54,7 +99,10 @@ public sealed class InMemoryFileSystem : IFileSystem
|
||||
|
||||
public bool FileExists(string path) => _files.ContainsKey(Normalize(path));
|
||||
|
||||
public bool DirectoryExists(string path) => _directories.Contains(Normalize(path));
|
||||
/// <summary>A link counts as the directory it points at, as the disk has it.</summary>
|
||||
public bool DirectoryExists(string path) =>
|
||||
_directories.Contains(Normalize(path)) ||
|
||||
(_links.TryGetValue(Normalize(path), out string? target) && _directories.Contains(Normalize(target)));
|
||||
|
||||
public string ReadAllText(string path) => Encoding.UTF8.GetString(Bytes(path));
|
||||
|
||||
@@ -76,6 +124,10 @@ public sealed class InMemoryFileSystem : IFileSystem
|
||||
AddParents(key);
|
||||
}
|
||||
|
||||
public void WriteAllTextAtomic(string path, string contents) => WithFile(path, contents);
|
||||
|
||||
public void WriteAllBytesAtomic(string path, byte[] bytes) => WriteAllBytes(path, bytes);
|
||||
|
||||
public void WriteAllLines(string path, IEnumerable<string> lines) =>
|
||||
WithFile(path, string.Join("\n", lines));
|
||||
|
||||
@@ -103,7 +155,7 @@ public sealed class InMemoryFileSystem : IFileSystem
|
||||
string prefix = Normalize(path) + "/";
|
||||
var pattern = ToRegex(searchPattern);
|
||||
return _files.Keys
|
||||
.Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(key => key.StartsWith(prefix, _comparison))
|
||||
.Where(key => !key[prefix.Length..].Contains('/'))
|
||||
.Where(key => pattern.IsMatch(key[prefix.Length..]))
|
||||
.OrderBy(key => key, StringComparer.OrdinalIgnoreCase)
|
||||
@@ -115,7 +167,8 @@ public sealed class InMemoryFileSystem : IFileSystem
|
||||
string prefix = Normalize(path) + "/";
|
||||
var pattern = ToRegex(searchPattern);
|
||||
return _files.Keys
|
||||
.Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(key => key.StartsWith(prefix, _comparison))
|
||||
.Where(key => !_unlistable.Any(folder => key.StartsWith(folder + "/", _comparison)))
|
||||
.Where(key => pattern.IsMatch(key[(key.LastIndexOf('/') + 1)..]))
|
||||
.OrderBy(key => key, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
@@ -123,19 +176,33 @@ public sealed class InMemoryFileSystem : IFileSystem
|
||||
|
||||
public IReadOnlyList<string> GetDirectories(string path)
|
||||
{
|
||||
if (_unlistable.Contains(Normalize(path)))
|
||||
throw new UnauthorizedAccessException($"Access to the path '{path}' is denied.");
|
||||
|
||||
string prefix = Normalize(path) + "/";
|
||||
return _directories
|
||||
.Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(key => key.StartsWith(prefix, _comparison))
|
||||
.Where(key => !key[prefix.Length..].Contains('/'))
|
||||
.OrderBy(key => key, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public DateTime GetLastWriteTimeUtc(string path) => LastWriteTimeUtc;
|
||||
public DateTime GetLastWriteTimeUtc(string path) =>
|
||||
_written.TryGetValue(Normalize(path), out var written) ? written : LastWriteTimeUtc;
|
||||
|
||||
/// <summary>One file's own time, in place of <see cref="LastWriteTimeUtc"/>.</summary>
|
||||
public InMemoryFileSystem WrittenAt(string path, DateTime utc)
|
||||
{
|
||||
_written[Normalize(path)] = utc;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Files added through <see cref="WithExecutable"/> carry the execute bit.</summary>
|
||||
public long GetFileSize(string path) => Bytes(path).LongLength;
|
||||
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -37,6 +37,7 @@ public sealed class TestInstallation
|
||||
|
||||
Executables = new ExecutableLocator(Files, Paths, Environment, Log);
|
||||
SettingsStore = new SettingsStore(Files, Clock, Log, SettingsPaths, new SettingsSerializer(), Executables);
|
||||
Tapes = new HaslerTapes(Files, Paths, Log, Clock, new TimetableLocator(Files), () => SimulatorRunning);
|
||||
}
|
||||
|
||||
public InMemoryFileSystem Files { get; }
|
||||
@@ -57,6 +58,8 @@ public sealed class TestInstallation
|
||||
public SettingsPaths SettingsPaths { get; }
|
||||
public ExecutableLocator Executables { get; }
|
||||
public SettingsStore SettingsStore { get; }
|
||||
public HaslerTapes Tapes { get; }
|
||||
public bool SimulatorRunning { get; set; }
|
||||
|
||||
/// <summary>Path of a file inside the fake installation.</summary>
|
||||
public static string At(params string[] segments) => Path.Combine(new[] { Root }.Concat(segments).ToArray());
|
||||
|
||||
12
StarterNG.Tests/Fakes/TestLog.cs
Normal file
12
StarterNG.Tests/Fakes/TestLog.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using StarterNG.Application.Abstractions;
|
||||
using StarterNG.Infrastructure.Adapters;
|
||||
|
||||
namespace StarterNG.Tests.Fakes;
|
||||
|
||||
/// <summary>The diagnostics log for a test that only wants one to write to or read back.</summary>
|
||||
public static class TestLog
|
||||
{
|
||||
/// <param name="files">Where the log file goes; a disk of its own when left out.</param>
|
||||
public static FileDiagnosticsLog Over(IFileSystem? files = null) =>
|
||||
new(files ?? new InMemoryFileSystem(), new FixedClock(new DateTime(2026, 10, 13)), new GamePaths("game"));
|
||||
}
|
||||
16
StarterNG.Tests/Fakes/TestScenery.cs
Normal file
16
StarterNG.Tests/Fakes/TestScenery.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using StarterNG.Application.Abstractions;
|
||||
using StarterNG.Infrastructure.Adapters;
|
||||
using StarterNG.Infrastructure.Sceneries;
|
||||
|
||||
namespace StarterNG.Tests.Fakes;
|
||||
|
||||
/// <summary>The scenery reader over a fake clock and dice, for tests of what it makes of a text.</summary>
|
||||
public static class TestScenery
|
||||
{
|
||||
/// <param name="now">The hour a scenery that names none starts at.</param>
|
||||
public static SceneryParser Parser(DateTime? now = null) =>
|
||||
new(new FixedClock(now ?? new DateTime(2026, 1, 1, 12, 0, 0)), new StubRandom());
|
||||
|
||||
public static SceneryRepository Repository(IFileSystem files) =>
|
||||
new(files, new GamePaths("game"), TestLog.Over(files), Parser());
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
using StarterNG.Application;
|
||||
using StarterNG.Domain.Sceneries;
|
||||
using StarterNG.Tests.Fakes;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
@@ -35,4 +34,43 @@ public class FavoriteSceneriesTests
|
||||
|
||||
Assert.False(favorites.Contains(scenery));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_file_that_could_not_be_read_goes_aside_before_a_star_replaces_it()
|
||||
{
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithLegacyFile(TestInstallation.At("scenery", "td.scn"), "//$n Testowa")
|
||||
.WithFile(FavoritesPath, "one.scn\ntwo.scn")
|
||||
.FailReading(FavoritesPath));
|
||||
var scenery = Assert.Single(installation.Sceneries.LoadAll());
|
||||
var favorites = new FavoriteSceneries(installation.Files, installation.Log, FavoritesPath);
|
||||
|
||||
installation.Files.AllowReading(FavoritesPath);
|
||||
favorites.Toggle(scenery);
|
||||
|
||||
Assert.Equal("one.scn\ntwo.scn", installation.Files.ReadAllText(FavoritesPath + ".unreadable"));
|
||||
Assert.Equal("td.scn", installation.Files.ReadAllText(FavoritesPath));
|
||||
Assert.Contains(FavoritesPath + ".unreadable", installation.Log.Text);
|
||||
|
||||
// Set aside once: the file is the starter's own again.
|
||||
favorites.Toggle(scenery);
|
||||
Assert.False(installation.Files.FileExists(FavoritesPath + ".unreadable-2"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_locked_file_is_left_alone_when_no_copy_of_it_can_be_made()
|
||||
{
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithLegacyFile(TestInstallation.At("scenery", "td.scn"), "//$n Testowa")
|
||||
.WithFile(FavoritesPath, "one.scn\ntwo.scn")
|
||||
.FailReading(FavoritesPath));
|
||||
var scenery = Assert.Single(installation.Sceneries.LoadAll());
|
||||
var favorites = new FavoriteSceneries(installation.Files, installation.Log, FavoritesPath);
|
||||
|
||||
favorites.Toggle(scenery);
|
||||
|
||||
installation.Files.AllowReading(FavoritesPath);
|
||||
Assert.Equal("one.scn\ntwo.scn", installation.Files.ReadAllText(FavoritesPath));
|
||||
Assert.Contains("writing", installation.Log.Text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,4 +42,128 @@ public class FileDiagnosticsLogTests
|
||||
|
||||
Assert.Equal(string.Empty, log.Text);
|
||||
}
|
||||
|
||||
private static string Written(string path)
|
||||
{
|
||||
// The writer holds it open for as long as the session lasts.
|
||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
using var reader = new StreamReader(stream);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_first_entry_of_a_session_keeps_the_log_of_the_one_before()
|
||||
{
|
||||
string root = Directory.CreateTempSubdirectory("starterng-log-").FullName;
|
||||
try
|
||||
{
|
||||
var paths = new GamePaths(root);
|
||||
Directory.CreateDirectory(paths.StarterConfig);
|
||||
File.WriteAllText(paths.DiagnosticsLog, "the crash\n");
|
||||
var log = new FileDiagnosticsLog(new PhysicalFileSystem(), new FixedClock(new DateTime(2026, 3, 7)), paths);
|
||||
|
||||
log.Log("after the restart");
|
||||
log.Log("and on");
|
||||
|
||||
Assert.Equal("the crash\n", Written(paths.DiagnosticsLog + ".1"));
|
||||
string now = Written(paths.DiagnosticsLog);
|
||||
Assert.DoesNotContain("the crash", now);
|
||||
Assert.Contains("after the restart", now);
|
||||
Assert.Contains("and on", now);
|
||||
log.Clear();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Only_the_session_before_is_kept()
|
||||
{
|
||||
string root = Directory.CreateTempSubdirectory("starterng-log-").FullName;
|
||||
try
|
||||
{
|
||||
var paths = new GamePaths(root);
|
||||
Directory.CreateDirectory(paths.StarterConfig);
|
||||
File.WriteAllText(paths.DiagnosticsLog, "the last one\n");
|
||||
File.WriteAllText(paths.DiagnosticsLog + ".1", "the one before it\n");
|
||||
var log = new FileDiagnosticsLog(new PhysicalFileSystem(), new FixedClock(new DateTime(2026, 3, 7)), paths);
|
||||
|
||||
log.Log("now");
|
||||
|
||||
Assert.Equal("the last one\n", Written(paths.DiagnosticsLog + ".1"));
|
||||
log.Clear();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clearing_the_log_does_not_touch_the_session_kept()
|
||||
{
|
||||
var log = Build(out var files);
|
||||
string path = new GamePaths("game").DiagnosticsLog;
|
||||
files.WithFile(path, "the crash\n");
|
||||
|
||||
log.Log("after the restart");
|
||||
log.Clear();
|
||||
log.Log("cleared");
|
||||
|
||||
Assert.Equal("the crash\n", files.ReadAllText(path + ".1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_log_that_cannot_be_copied_is_added_to_and_not_wiped()
|
||||
{
|
||||
string root = Directory.CreateTempSubdirectory("starterng-log-").FullName;
|
||||
try
|
||||
{
|
||||
var paths = new GamePaths(root);
|
||||
Directory.CreateDirectory(paths.StarterConfig);
|
||||
File.WriteAllText(paths.DiagnosticsLog, "the crash\n");
|
||||
// A folder where the copy would go: nothing can be written in its place.
|
||||
Directory.CreateDirectory(paths.DiagnosticsLog + ".1");
|
||||
var log = new FileDiagnosticsLog(new PhysicalFileSystem(), new FixedClock(new DateTime(2026, 3, 7)), paths);
|
||||
|
||||
log.Log("after the restart");
|
||||
|
||||
string now = Written(paths.DiagnosticsLog);
|
||||
Assert.Contains("the crash", now);
|
||||
Assert.Contains("after the restart", now);
|
||||
log.Clear();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_folder_it_cannot_write_in_does_not_stop_the_program()
|
||||
{
|
||||
string root = Directory.CreateTempSubdirectory("starterng-log-").FullName;
|
||||
try
|
||||
{
|
||||
var paths = new GamePaths(root);
|
||||
// A file where the log's folder should be, as unwritable as read-only gets
|
||||
// on every platform and for every user.
|
||||
File.WriteAllText(paths.StarterConfig, "");
|
||||
var log = new FileDiagnosticsLog(new PhysicalFileSystem(), new FixedClock(new DateTime(2026, 3, 7)), paths);
|
||||
|
||||
log.Log("first");
|
||||
log.Log("second");
|
||||
log.Flush();
|
||||
log.Clear();
|
||||
log.Log("third");
|
||||
|
||||
Assert.Contains("third", log.Text);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,4 +72,45 @@ public class GameLibraryTests
|
||||
|
||||
Assert.Contains("textures.txt", installation.Log.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_folder_of_the_rolling_stock_that_cannot_be_listed_costs_only_itself()
|
||||
{
|
||||
var files = new InMemoryFileSystem()
|
||||
.WithFile(TestInstallation.At("dynamic", "aaa", "ep09", "textures.txt"), "ep09-1.mat=ep09.e3d,ep09")
|
||||
.WithFile(TestInstallation.At("dynamic", "aaa", "ep09", "ep09.fiz"), "Param: M=2")
|
||||
.WithFile(TestInstallation.At("dynamic", "pkp", "ep07", "textures.txt"), "ep07-135.mat=ep07.e3d,ep07")
|
||||
.WithFile(TestInstallation.At("dynamic", "pkp", "ep07", "ep07.fiz"), "Param: M=1")
|
||||
.WithFile(TestInstallation.At("scenery", "kato.scn"), "//$n Katowice")
|
||||
.DenyListing(TestInstallation.At("dynamic", "aaa"));
|
||||
var installation = new TestInstallation(files);
|
||||
|
||||
installation.Library.Load();
|
||||
|
||||
Assert.True(installation.Library.Loaded);
|
||||
Assert.Equal("ep07-135", Assert.Single(installation.Library.Vehicles.Textures).Skinfile);
|
||||
Assert.Equal("Katowice", Assert.Single(installation.Library.Sceneries).Name);
|
||||
Assert.Contains("aaa", installation.Log.Text);
|
||||
Assert.Equal(1, installation.Physics.For("pkp/ep07/", "ep07")!.Mass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_whole_rolling_stock_failing_still_leaves_the_sceneries()
|
||||
{
|
||||
var installation = Complete();
|
||||
var library = new GameLibrary(new FailingVehicles(), installation.Sceneries, installation.MiniTextures,
|
||||
installation.Physics, installation.Log);
|
||||
|
||||
library.Load();
|
||||
|
||||
Assert.True(library.Loaded);
|
||||
Assert.Equal("Katowice", Assert.Single(library.Sceneries).Name);
|
||||
Assert.Contains("no disk today", installation.Log.Text);
|
||||
}
|
||||
|
||||
private sealed class FailingVehicles : StarterNG.Application.Abstractions.IVehicleRepository
|
||||
{
|
||||
public int Load(StarterNG.Domain.Vehicles.VehicleCatalog catalog) =>
|
||||
throw new IOException("no disk today");
|
||||
}
|
||||
}
|
||||
|
||||
190
StarterNG.Tests/GpuOffloadTests.cs
Normal file
190
StarterNG.Tests/GpuOffloadTests.cs
Normal file
@@ -0,0 +1,190 @@
|
||||
using StarterNG.Domain.Settings;
|
||||
using StarterNG.Infrastructure;
|
||||
using StarterNG.Tests.Fakes;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public class GpuOffloadTests
|
||||
{
|
||||
// The laptop this was written on: an RTX 5060 beside a Radeon 780M that drives the screen.
|
||||
private static readonly GpuDevice Nvidia = new("0000:01:00.0", "10DE:2D19", "nvidia", BootVga: false,
|
||||
InternalPanel: true);
|
||||
private static readonly GpuDevice Radeon780M = new("0000:65:00.0", "1002:1900", "amdgpu", BootVga: true,
|
||||
InternalPanel: true);
|
||||
private static readonly GpuDevice RadeonDiscrete = new("0000:03:00.0", "1002:73DF", "amdgpu", BootVga: false);
|
||||
private static readonly GpuDevice IntelIntegrated = new("0000:00:02.0", "8086:A7A0", "i915", BootVga: true,
|
||||
InternalPanel: true);
|
||||
|
||||
private static string? Unset(string name) => null;
|
||||
|
||||
private static IReadOnlyDictionary<string, string> For(GpuPreference preference, params GpuDevice[] gpus) =>
|
||||
Inheriting(Unset, preference, gpus);
|
||||
|
||||
private static IReadOnlyDictionary<string, string> Inheriting(Func<string, string?> inherited,
|
||||
GpuPreference preference,
|
||||
params GpuDevice[] gpus) =>
|
||||
GpuOffload.Target(gpus, preference, inherited) is { } target
|
||||
? GpuOffload.EnvironmentFor(target)
|
||||
: new Dictionary<string, string>();
|
||||
|
||||
[Fact]
|
||||
public void A_proprietary_nvidia_card_gets_prime_render_offload()
|
||||
{
|
||||
var environment = For(GpuPreference.Discrete, Nvidia, Radeon780M);
|
||||
|
||||
Assert.Equal("1", environment["__NV_PRIME_RENDER_OFFLOAD"]);
|
||||
Assert.Equal("nvidia", environment["__GLX_VENDOR_LIBRARY_NAME"]);
|
||||
Assert.Equal("NVIDIA_only", environment["__VK_LAYER_NV_optimus"]);
|
||||
// DRI_PRIME on a card Mesa does not drive leaves Mesa on llvmpipe.
|
||||
Assert.False(environment.ContainsKey("DRI_PRIME"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_card_mesa_drives_is_picked_by_its_pci_address()
|
||||
{
|
||||
var environment = For(GpuPreference.Discrete, IntelIntegrated, RadeonDiscrete);
|
||||
|
||||
Assert.Equal("pci-0000_03_00_0", Assert.Single(environment).Value);
|
||||
Assert.Equal("DRI_PRIME", environment.Keys.Single());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_card_on_a_driver_of_unknown_ways_is_left_alone()
|
||||
{
|
||||
var odd = Nvidia with { Driver = "vfio-pci" };
|
||||
|
||||
Assert.Empty(For(GpuPreference.Discrete, odd, Radeon780M));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void One_card_leaves_nothing_to_choose()
|
||||
{
|
||||
Assert.Empty(For(GpuPreference.Discrete, Radeon780M));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_system_choice_changes_nothing()
|
||||
{
|
||||
Assert.Empty(For(GpuPreference.System, Nvidia, Radeon780M));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("DRI_PRIME")]
|
||||
[InlineData("__NV_PRIME_RENDER_OFFLOAD")]
|
||||
[InlineData("__GLX_VENDOR_LIBRARY_NAME")]
|
||||
public void A_choice_the_player_made_stands(string variable)
|
||||
{
|
||||
// prime-run %command% in Steam's launch options, say.
|
||||
var environment = Inheriting(name => name == variable ? "1" : null, GpuPreference.Discrete, Nvidia, Radeon780M);
|
||||
|
||||
Assert.Empty(environment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_discrete_card_already_driving_the_screen_needs_no_offload()
|
||||
{
|
||||
// A laptop with its MUX switched to the NVIDIA card.
|
||||
Assert.Empty(For(GpuPreference.Discrete, Nvidia with { BootVga = true },
|
||||
Radeon780M with { BootVga = false }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_desktop_stays_on_the_card_its_monitor_is_plugged_into()
|
||||
{
|
||||
// The monitor on a Radeon, the processor's own graphics left on: moving
|
||||
// off the boot card would land on the weaker one.
|
||||
var radeon = RadeonDiscrete with { BootVga = true };
|
||||
var intel = IntelIntegrated with { BootVga = false, InternalPanel = false };
|
||||
|
||||
Assert.Empty(For(GpuPreference.Discrete, radeon, intel));
|
||||
}
|
||||
|
||||
// ── What the settings say about it ───────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void No_card_read_is_said_whatever_was_chosen()
|
||||
{
|
||||
Assert.Equal(GpuNote.Unread, GpuOffload.NoteFor(Array.Empty<GpuDevice>(), GpuPreference.Discrete, Unset));
|
||||
Assert.Equal(GpuNote.Unread, GpuOffload.NoteFor(Array.Empty<GpuDevice>(), GpuPreference.System, name => "1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void One_card_is_said_whatever_was_chosen()
|
||||
{
|
||||
Assert.Equal(GpuNote.OneCard, GpuOffload.NoteFor(new[] { Radeon780M }, GpuPreference.Discrete, Unset));
|
||||
Assert.Equal(GpuNote.OneCard, GpuOffload.NoteFor(new[] { Radeon780M }, GpuPreference.System, name => "1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_system_choice_comes_before_what_the_player_set()
|
||||
{
|
||||
Assert.Equal(GpuNote.LeftToSystem,
|
||||
GpuOffload.NoteFor(new[] { Nvidia, Radeon780M }, GpuPreference.System, name => "1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void What_the_player_set_comes_before_the_card_it_would_be_moved_to()
|
||||
{
|
||||
static string? Inherited(string name) => name is "DRI_PRIME" or "__NV_PRIME_RENDER_OFFLOAD" ? "1" : null;
|
||||
|
||||
Assert.Equal(GpuNote.PlayerChose,
|
||||
GpuOffload.NoteFor(new[] { Nvidia, Radeon780M }, GpuPreference.Discrete, Inherited));
|
||||
Assert.Equal(new[] { "__NV_PRIME_RENDER_OFFLOAD", "DRI_PRIME" }, GpuOffload.PlayerChoices(Inherited));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_variable_set_empty_is_no_choice_of_the_player()
|
||||
{
|
||||
Assert.Empty(GpuOffload.PlayerChoices(name => string.Empty));
|
||||
Assert.Equal(GpuNote.Moved,
|
||||
GpuOffload.NoteFor(new[] { Nvidia, Radeon780M }, GpuPreference.Discrete, name => string.Empty));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_note_says_a_move_only_with_a_card_to_move_to()
|
||||
{
|
||||
Assert.Equal(GpuNote.Moved, GpuOffload.NoteFor(new[] { Nvidia, Radeon780M }, GpuPreference.Discrete, Unset));
|
||||
Assert.Equal(GpuNote.NoTarget,
|
||||
GpuOffload.NoteFor(new[] { Nvidia with { Driver = "vfio-pci" }, Radeon780M },
|
||||
GpuPreference.Discrete, Unset));
|
||||
}
|
||||
|
||||
// ── Reading the cards ────────────────────────────────────────────────────
|
||||
|
||||
private static InMemoryFileSystem Card(InMemoryFileSystem files, string card, string driver, string id,
|
||||
string slot, bool bootVga) =>
|
||||
files.WithFile($"/sys/class/drm/{card}/device/uevent",
|
||||
$"DRIVER={driver}\nPCI_CLASS=30000\nPCI_ID={id}\nPCI_SLOT_NAME={slot}\n")
|
||||
.WithFile($"/sys/class/drm/{card}/device/boot_vga", bootVga ? "1\n" : "0\n");
|
||||
|
||||
[Fact]
|
||||
public void The_cards_are_read_from_sysfs()
|
||||
{
|
||||
var files = new InMemoryFileSystem(caseSensitive: true);
|
||||
Card(files, "card0", "nvidia", "10DE:2D19", "0000:01:00.0", bootVga: false);
|
||||
Card(files, "card1", "amdgpu", "1002:1900", "0000:65:00.0", bootVga: true);
|
||||
// A connector, not a card.
|
||||
files.WithFile("/sys/class/drm/card0-eDP-2/device/uevent", "DRIVER=nvidia\nPCI_ID=10DE:2D19\nPCI_SLOT_NAME=x\n");
|
||||
|
||||
files.WithFile("/sys/class/drm/card1-eDP-1/status", "connected\n");
|
||||
|
||||
var gpus = new LinuxGpus(files, new FakeEnvironment()).List();
|
||||
|
||||
Assert.Equal(new[] { Nvidia, Radeon780M }, gpus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Off_linux_there_are_no_cards_to_move_between()
|
||||
{
|
||||
var files = new InMemoryFileSystem(caseSensitive: true);
|
||||
Card(files, "card0", "nvidia", "10DE:2D19", "0000:01:00.0", bootVga: false);
|
||||
|
||||
Assert.Empty(new LinuxGpus(files, new FakeEnvironment().AsWindows()).List());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_sysfs_there_are_no_cards()
|
||||
{
|
||||
Assert.Empty(new LinuxGpus(new InMemoryFileSystem(), new FakeEnvironment()).List());
|
||||
}
|
||||
}
|
||||
156
StarterNG.Tests/GraphicsCardsTests.cs
Normal file
156
StarterNG.Tests/GraphicsCardsTests.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
743
StarterNG.Tests/GraphicsPresetTests.cs
Normal file
743
StarterNG.Tests/GraphicsPresetTests.cs
Normal file
@@ -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<GraphicsPreset, double> value, int repeatsAllowed)
|
||||
{
|
||||
var values = GraphicsPreset.All.Select(value).ToList();
|
||||
|
||||
Assert.Equal(values.OrderBy(v => v), values);
|
||||
Assert.True(values.Count - values.Distinct().Count() <= repeatsAllowed, string.Join(", ", values));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_slider_moves_from_level_to_level_as_far_as_it_has_steps_for_six()
|
||||
{
|
||||
// Sizes and filtering run out of steps above High.
|
||||
Climbs(p => p.TextureSize, 1);
|
||||
Climbs(p => p.CabTextureSize, 1);
|
||||
Climbs(p => p.Anisotropy, 1);
|
||||
|
||||
// Four steps or three for six levels.
|
||||
Climbs(p => (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<GraphicsPreset, int> fidelity in new Func<GraphicsPreset, int>[] { p => p.SmokeFidelity, p => p.SplineFidelity })
|
||||
{
|
||||
var levels = GraphicsPreset.All.Select(fidelity).ToList();
|
||||
Assert.All(levels.Zip(levels.Skip(1)), step => Assert.InRange(step.Second - step.First, 0, 1));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void High_is_the_simulators_own_but_where_that_is_the_bottom_of_a_scale()
|
||||
{
|
||||
var store = new TestInstallation().SettingsStore;
|
||||
var own = store.Render(Defaults());
|
||||
var high = store.Render(AtHigh());
|
||||
|
||||
var differing = own.Keys.Union(high.Keys)
|
||||
.Where(key => own.GetValueOrDefault(key) != high.GetValueOrDefault(key))
|
||||
.Where(key => !GraphicsPreset.RowKeys.Contains(key))
|
||||
.Where(key => !key.StartsWith("gfx.framebuffer", StringComparison.Ordinal) && 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<GraphicsNewRenderer, double> value) => Climbs(p => value(p.NewRenderer), 5);
|
||||
|
||||
void TurnsOn(Func<GraphicsNewRenderer, bool> 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);
|
||||
}
|
||||
}
|
||||
593
StarterNG.Tests/GraphicsSettingsTests.cs
Normal file
593
StarterNG.Tests/GraphicsSettingsTests.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
168
StarterNG.Tests/HaslerTapeTests.cs
Normal file
168
StarterNG.Tests/HaslerTapeTests.cs
Normal file
@@ -0,0 +1,168 @@
|
||||
using System.Globalization;
|
||||
using StarterNG.Domain.Tapes;
|
||||
using StarterNG.Tests.Fakes;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public class HaslerTapeTests
|
||||
{
|
||||
private const string Header =
|
||||
"Time[s] Velocity[m/s] Acceleration[m/ss] Coupler.Dist[m] Coupler.Force[N] TractionForce[kN] FrictionForce[kN] " +
|
||||
"BrakeForce[kN] BrakePress[MPa] PipePress[MPa] MotorCurrent[A] MCP SCP BCP LBP Direction Command CVal1 CVal2 " +
|
||||
"Security Wheelslip EngineTemp[Deg] OilTemp[Deg] WaterTemp[Deg] WaterAuxTemp[Deg]\r\n";
|
||||
|
||||
private static string Line(double t, double v, double brake, double current, string command = "none",
|
||||
int security = 0) =>
|
||||
string.Create(CultureInfo.InvariantCulture,
|
||||
$"{t:0.0000} {v:0.0000} 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 {brake:0.0000} 0.5000 {current:0.0000} ") +
|
||||
$"3 0 0.0000 0.0000 1 {command} 0.0000 0.0000 {security} 0 0.0000 0.0000 0.0000 0.0000\r\n";
|
||||
|
||||
[Fact]
|
||||
public void A_physics_log_reads_as_speed_distance_and_the_two_hasler_marks()
|
||||
{
|
||||
var tape = HaslerTape.Parse(Header
|
||||
+ Line(10, 0, 0.45, 0)
|
||||
+ Line(20, 36, 0, 400)
|
||||
+ Line(30, 72, 0, 0, security: 1));
|
||||
|
||||
Assert.Equal(3, tape.Samples.Count);
|
||||
Assert.Equal(72, tape.MaxSpeedKmh, 6);
|
||||
Assert.Equal(20, tape.Duration, 6);
|
||||
Assert.Equal(50 + 150, tape.DistanceM, 6); // trapezoids: 0->36 km/h and 36->72 km/h over 10 s each
|
||||
Assert.True(tape.Samples[0].Braking);
|
||||
Assert.True(tape.Samples[1].Current);
|
||||
Assert.True(tape.Samples[2].SecurityAlarm);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standing_and_running_time_split_the_recording_like_the_old_analyser()
|
||||
{
|
||||
var tape = HaslerTape.Parse(Header + Line(0, 0, 0.45, 0) + Line(60, 0, 0.45, 0) + Line(120, 36, 0, 400)
|
||||
+ Line(180, 36, 0, 0));
|
||||
|
||||
Assert.Equal(60, tape.StandingTime, 6);
|
||||
Assert.Equal(120, tape.RunningTime, 6);
|
||||
Assert.Equal(900 / 180.0 * 3.6, tape.AverageWithStopsKmh, 6); // 300 m ramping up to 36 km/h, 600 m at it
|
||||
Assert.Equal(900 / 120.0 * 3.6, tape.AverageSpeedKmh, 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_line_from_the_simulator_reads_in_kmh_and_bar_whatever_its_header_says()
|
||||
{
|
||||
var tape = HaslerTape.Parse(Header +
|
||||
"107.5512 38.6099 -0.0201 -0.0002 917.2355 -0.0000 436.4420 0.0000 0.0021 4.9981 0.0000 0 0 0.0000 " +
|
||||
"0.0000 1 none 0.0000 0.0000 0 0 50.0000 45.0000 50.0000 40.0000\r\n");
|
||||
|
||||
var s = Assert.Single(tape.Samples);
|
||||
Assert.Equal(38.6099, s.SpeedKmh, 6);
|
||||
Assert.Equal(4.9981, s.PipePressBar, 6);
|
||||
Assert.False(s.Braking);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_line_still_being_written_or_a_command_with_spaces_does_not_break_the_tape()
|
||||
{
|
||||
var tape = HaslerTape.Parse(Header + Line(0, 18, 0, 0, command: "Set Velocity") + Line(1, 18, 0, 0) + "2.0500 5.0");
|
||||
|
||||
Assert.Equal(2, tape.Samples.Count);
|
||||
Assert.Equal(5, tape.DistanceM, 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_sample_under_a_point_of_the_tape_is_found_by_time_and_by_distance()
|
||||
{
|
||||
var tape = HaslerTape.Parse(Header + Line(0, 36, 0, 0) + Line(1, 36, 0, 0) + Line(2, 36, 0, 0));
|
||||
|
||||
Assert.Equal(1, tape.IndexAt(1.5, byDistance: false));
|
||||
Assert.Equal(2, tape.IndexAt(25, byDistance: true));
|
||||
Assert.Equal(0, tape.IndexAt(-3, byDistance: false));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 90)]
|
||||
[InlineData(90, 90)]
|
||||
[InlineData(90.5, 100)]
|
||||
[InlineData(120, 120)]
|
||||
[InlineData(151, 180)]
|
||||
[InlineData(180, 180)]
|
||||
[InlineData(181, 200)]
|
||||
[InlineData(205, 220)]
|
||||
public void The_scale_tops_out_at_the_speedometers_range_then_at_the_next_twenty(double max, double top) =>
|
||||
Assert.Equal(top, TapeFigures.TopSpeed(max));
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, 10)]
|
||||
[InlineData(0.8, 10)]
|
||||
[InlineData(0.79, 20)]
|
||||
[InlineData(0.4, 20)]
|
||||
[InlineData(0.16, 50)]
|
||||
[InlineData(0.08, 100)]
|
||||
[InlineData(0.04, 200)]
|
||||
[InlineData(0.039, 500)]
|
||||
public void Speed_lines_take_the_first_step_at_least_8_pixels_apart(double pxPerKmh, int step) =>
|
||||
Assert.Equal(step, TapeFigures.SpeedStep(pxPerKmh));
|
||||
|
||||
[Theory]
|
||||
[InlineData(60, 1)]
|
||||
[InlineData(59, 2)]
|
||||
[InlineData(30, 2)]
|
||||
[InlineData(12, 5)]
|
||||
[InlineData(6, 10)]
|
||||
[InlineData(4, 15)]
|
||||
[InlineData(2, 30)]
|
||||
[InlineData(1, 60)]
|
||||
[InlineData(0.99, 120)]
|
||||
public void Clock_lines_take_the_first_step_at_least_60_pixels_apart(double pxPerMinute, double step) =>
|
||||
Assert.Equal(step, TapeFigures.MinuteStep(pxPerMinute));
|
||||
|
||||
[Theory]
|
||||
[InlineData(600, 0.1)]
|
||||
[InlineData(500, 0.1)]
|
||||
[InlineData(499, 0.2)]
|
||||
[InlineData(100, 0.5)]
|
||||
[InlineData(50, 1)]
|
||||
[InlineData(25, 2)]
|
||||
[InlineData(10, 5)]
|
||||
[InlineData(5, 10)]
|
||||
[InlineData(2.5, 20)]
|
||||
[InlineData(2, 50)]
|
||||
public void Distance_marks_take_the_first_step_at_least_50_pixels_apart(double pxPerKm, double step) =>
|
||||
Assert.Equal(step, TapeFigures.KmStep(pxPerKm));
|
||||
|
||||
[Theory]
|
||||
[InlineData(-5, "0:00")]
|
||||
[InlineData(0, "0:00")]
|
||||
[InlineData(65, "1:05")]
|
||||
[InlineData(3599, "59:59")]
|
||||
[InlineData(3600, "1:00:00")]
|
||||
[InlineData(3723, "1:02:03")]
|
||||
[InlineData(90000, "25:00:00")]
|
||||
public void A_length_of_time_prints_hours_only_when_it_has_them(double seconds, string text) =>
|
||||
Assert.Equal(text, TapeFigures.Clock(seconds));
|
||||
|
||||
[Theory]
|
||||
[InlineData("7:52", 7, 52, 0)]
|
||||
[InlineData("07:52", 7, 52, 0)]
|
||||
[InlineData(" 23:05 ", 23, 5, 0)]
|
||||
[InlineData("7:52:30", 7, 52, 30)]
|
||||
[InlineData("24:00", 0, 0, 0)]
|
||||
[InlineData("noon", 0, 0, 0)]
|
||||
[InlineData("", 0, 0, 0)]
|
||||
[InlineData(null, 0, 0, 0)]
|
||||
public void A_clock_typed_in_is_a_time_of_the_day_or_nothing(string? text, int hours, int minutes, int seconds) =>
|
||||
Assert.Equal(new TimeSpan(hours, minutes, seconds), TapeFigures.ParseClock(text));
|
||||
|
||||
[Fact]
|
||||
public void Tapes_are_listed_from_physicslog_newest_first()
|
||||
{
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithFile(TestInstallation.At("physicslog", "EU07-424.dat"), Header + Line(0, 1, 0, 0)));
|
||||
var tapes = installation.Tapes;
|
||||
|
||||
var list = tapes.List();
|
||||
|
||||
Assert.Single(list);
|
||||
Assert.Equal("EU07-424", list[0].Vehicle);
|
||||
Assert.Single(tapes.Load(list[0])!.Samples);
|
||||
}
|
||||
}
|
||||
119
StarterNG.Tests/KeyMapTests.cs
Normal file
119
StarterNG.Tests/KeyMapTests.cs
Normal file
@@ -0,0 +1,119 @@
|
||||
using StarterNG.Classes;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public class KeyMapTests
|
||||
{
|
||||
/// <summary>The names of the simulator's key table, keytonamemap in input/keyboardinput.cpp.</summary>
|
||||
private static readonly HashSet<string> SimulatorKeyNames = new(StringComparer.Ordinal)
|
||||
{
|
||||
"0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "-", "=",
|
||||
"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m",
|
||||
"n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z",
|
||||
"backspace", "[", "]", "\\", ";", "'", "enter", ",", ".", "/", "space", "pause",
|
||||
"insert", "delete", "home", "end",
|
||||
"num_/", "num_*", "num_-", "num_+", "num_enter", "num_.",
|
||||
"num_0", "num_1", "num_2", "num_3", "num_4", "num_5", "num_6", "num_7", "num_8", "num_9",
|
||||
"f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9", "f10", "f11", "f12",
|
||||
"tab", "esc", "left", "right", "up", "down", "page_up", "page_down"
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[InlineData("tab", "KbUseMap")]
|
||||
[InlineData("f1", "KbUseConsist")]
|
||||
[InlineData("f2", "KbUseTimetable")]
|
||||
[InlineData("f3", "KbUseScenario")]
|
||||
[InlineData("f9", "KbUseLog")]
|
||||
[InlineData("f10", "KbUseQuit")]
|
||||
public void What_the_simulator_takes_first_blocks_only_without_a_modifier(string token, string text)
|
||||
{
|
||||
Assert.Equal(new SimulatorKeyUse(text, true), KeyMap.SimulatorUse(token, false, false));
|
||||
Assert.Null(KeyMap.SimulatorUse(token, true, false));
|
||||
Assert.Null(KeyMap.SimulatorUse(token, false, true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Shift_F12_blocks_and_F12_alone_is_free()
|
||||
{
|
||||
Assert.Equal(new SimulatorKeyUse("KbUseDebugPanel", true), KeyMap.SimulatorUse("f12", true, false));
|
||||
Assert.Null(KeyMap.SimulatorUse("f12", false, false));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("f4", false, false, "KbUseCab")]
|
||||
[InlineData("f4", true, false, "KbUseCab")]
|
||||
[InlineData("f5", false, true, "KbUseVehicle")]
|
||||
[InlineData("f6", false, false, "KbUseDebug")]
|
||||
[InlineData("f7", false, true, "KbUseDebug")]
|
||||
[InlineData("f11", false, false, "KbUseEditor")]
|
||||
[InlineData("3", true, false, "KbUseSceneryEvent")]
|
||||
[InlineData("3", false, true, "KbUseCamera")]
|
||||
public void What_is_left_to_a_key_a_binding_may_replace(string token, bool shift, bool ctrl, string text)
|
||||
{
|
||||
Assert.Equal(new SimulatorKeyUse(text, false), KeyMap.SimulatorUse(token, shift, ctrl));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("3", false, false)]
|
||||
[InlineData("a", false, false)]
|
||||
[InlineData("a", true, true)]
|
||||
[InlineData("f7", true, false)]
|
||||
[InlineData("f8", false, false)]
|
||||
[InlineData("f11", true, false)]
|
||||
[InlineData("num_5", true, false)]
|
||||
[InlineData("enter", false, false)]
|
||||
public void The_rest_the_simulator_leaves_alone(string token, bool shift, bool ctrl)
|
||||
{
|
||||
Assert.Null(KeyMap.SimulatorUse(token, shift, ctrl));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_bundled_keys_use_nothing_the_simulator_takes_first()
|
||||
{
|
||||
var config = new KeyboardConfig();
|
||||
string path = Path.Combine(AppContext.BaseDirectory, "startercfg", "eu07_input-keyboard.ini");
|
||||
config.Parse(File.ReadAllText(path));
|
||||
|
||||
var taken = config.Bindings
|
||||
.Where(b => b.IsAssigned && KeyMap.SimulatorUse(b.Key, b.Shift, b.Ctrl) is { Blocks: true })
|
||||
.Select(b => b.Command)
|
||||
.ToList();
|
||||
|
||||
Assert.Empty(taken);
|
||||
Assert.Contains(config.Bindings, b => b.IsAssigned);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_key_drawn_is_one_the_simulator_knows()
|
||||
{
|
||||
var drawn = KeyMap.MainBlock.Concat(KeyMap.NumpadBlock).Concat(KeyMap.NavBlock)
|
||||
.SelectMany(row => row)
|
||||
.Select(cap => cap.Token)
|
||||
.OfType<string>();
|
||||
|
||||
Assert.Empty(drawn.Where(token => !SimulatorKeyNames.Contains(token)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("page_up", "Page Up")]
|
||||
[InlineData("page_down", "Page Down")]
|
||||
[InlineData("esc", "Esc")]
|
||||
[InlineData("num_enter", "Num Enter")]
|
||||
public void Key_names_read_as_on_a_keyboard(string token, string expected)
|
||||
{
|
||||
Assert.Equal(expected, KeyMap.DisplayName(token));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("a", false, false, "A")]
|
||||
[InlineData("a", true, false, "Shift + A")]
|
||||
[InlineData("a", false, true, "Ctrl + A")]
|
||||
[InlineData("num_enter", true, true, "Ctrl + Shift + Num Enter")]
|
||||
[InlineData("none", true, true, "—")]
|
||||
[InlineData("NONE", false, true, "—")]
|
||||
[InlineData("", true, false, "—")]
|
||||
public void A_combination_reads_with_its_modifiers_unless_there_is_no_key(string token, bool shift, bool ctrl, string expected)
|
||||
{
|
||||
Assert.Equal(expected, KeyMap.ComboText(token, shift, ctrl));
|
||||
}
|
||||
}
|
||||
@@ -7,4 +7,151 @@ public class KeyboardConfigTests
|
||||
[Fact]
|
||||
public void A_description_without_polish_letters_matches_the_bundled_one() =>
|
||||
Assert.Equal("zwiekszenie nastawnika glownego", KeyboardConfig.StripPolish("zwiększenie nastawnika głównego"));
|
||||
|
||||
private static KeyBinding Parse(string line)
|
||||
{
|
||||
var config = new KeyboardConfig();
|
||||
config.Parse(line);
|
||||
return Assert.Single(config.Bindings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_binding_without_a_description_shows_the_translated_one()
|
||||
{
|
||||
var binding = Parse("hornhighactivate a");
|
||||
|
||||
string text = new KeyboardConfig().DescriptionOf(binding, _ => "high tone horn");
|
||||
|
||||
Assert.Equal("high tone horn", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_description_someone_wrote_wins_over_the_translation()
|
||||
{
|
||||
var binding = Parse("hornhighactivate a // my own horn");
|
||||
|
||||
string text = new KeyboardConfig().DescriptionOf(binding, _ => "high tone horn");
|
||||
|
||||
Assert.Equal("my own horn", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_a_translation_the_file_description_stays()
|
||||
{
|
||||
var binding = Parse("hornhighactivate a // syrena wysoka");
|
||||
|
||||
Assert.Equal("syrena wysoka", new KeyboardConfig().DescriptionOf(binding, _ => string.Empty));
|
||||
}
|
||||
|
||||
// ── Which binding sits where ─────────────────────────────────────────────
|
||||
|
||||
private static KeyboardConfig Config(params string[] lines)
|
||||
{
|
||||
var config = new KeyboardConfig();
|
||||
config.Parse(string.Join("\n", lines));
|
||||
return config;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_cleared_binding_is_on_no_key_and_no_modifier()
|
||||
{
|
||||
var binding = Parse("hornhighactivate shift ctrl a");
|
||||
|
||||
binding.Clear();
|
||||
|
||||
Assert.False(binding.IsAssigned);
|
||||
Assert.False(binding.Shift);
|
||||
Assert.False(binding.Ctrl);
|
||||
Assert.Equal("none", binding.Key);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("a", true, false, true)]
|
||||
[InlineData("A", true, false, true)]
|
||||
[InlineData("a", false, false, false)]
|
||||
[InlineData("a", true, true, false)]
|
||||
[InlineData("b", true, false, false)]
|
||||
public void A_binding_is_on_its_key_with_exactly_its_modifiers(string token, bool shift, bool ctrl, bool on) =>
|
||||
Assert.Equal(on, Parse("hornhighactivate shift a").IsOn(token, shift, ctrl));
|
||||
|
||||
[Fact]
|
||||
public void An_unassigned_binding_is_on_nothing_not_even_the_word_none() =>
|
||||
Assert.False(Parse("hornhighactivate none").IsOn("none", false, false));
|
||||
|
||||
[Fact]
|
||||
public void The_binding_at_a_combination_is_found_by_key_and_modifiers()
|
||||
{
|
||||
var config = Config("plain a", "shifted shift a");
|
||||
|
||||
Assert.Equal("plain", config.BindingAt("A", false, false)?.Command);
|
||||
Assert.Equal("shifted", config.BindingAt("a", true, false)?.Command);
|
||||
Assert.Null(config.BindingAt("a", false, true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_combination_put_on_a_binding_moves_there_from_the_one_that_had_it()
|
||||
{
|
||||
var config = Config("first ctrl a", "second b", "third a");
|
||||
|
||||
config.Put(config.Bindings[1], "a", shift: false, ctrl: true);
|
||||
|
||||
Assert.False(config.Bindings[0].IsAssigned);
|
||||
Assert.False(config.Bindings[0].Ctrl);
|
||||
Assert.True(config.Bindings[1].IsOn("a", false, true));
|
||||
// The same key with other modifiers is another combination.
|
||||
Assert.True(config.Bindings[2].IsOn("a", false, false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_binding_doubled_up_on_a_combination_leaves_it_but_the_one_put_there()
|
||||
{
|
||||
var config = Config("first a", "second a", "third a");
|
||||
|
||||
config.Put(config.Bindings[1], "a", shift: false, ctrl: false);
|
||||
|
||||
Assert.False(config.Bindings[0].IsAssigned);
|
||||
Assert.True(config.Bindings[1].IsOn("a", false, false));
|
||||
Assert.False(config.Bindings[2].IsAssigned);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nothing_put_on_a_combination_frees_it()
|
||||
{
|
||||
var config = Config("first shift a", "second b");
|
||||
|
||||
config.Put(null, "a", shift: true, ctrl: false);
|
||||
|
||||
Assert.False(config.Bindings[0].IsAssigned);
|
||||
Assert.True(config.Bindings[1].IsOn("b", false, false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_combination_put_back_on_the_binding_that_has_it_stays()
|
||||
{
|
||||
var config = Config("first a");
|
||||
|
||||
config.Put(config.Bindings[0], "a", shift: false, ctrl: false);
|
||||
|
||||
Assert.True(config.Bindings[0].IsOn("a", false, false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Two_bindings_on_a_combination_conflict_however_the_key_is_spelled()
|
||||
{
|
||||
var config = Config("first shift a", "second shift A", "third a", "fourth ctrl a", "fifth none", "sixth none");
|
||||
|
||||
var conflicts = config.ConflictingCombos();
|
||||
|
||||
Assert.Equal(config.Bindings[0].ComboKey, Assert.Single(conflicts));
|
||||
Assert.Contains(config.Bindings[1].ComboKey, conflicts);
|
||||
Assert.DoesNotContain(config.Bindings[2].ComboKey, conflicts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Only_bindings_on_a_key_of_the_numpad_count_as_on_it()
|
||||
{
|
||||
var config = Config("first num_1", "second shift NUM_enter", "third a", "fourth none");
|
||||
|
||||
Assert.Equal(2, config.BindingsOnNumpad());
|
||||
}
|
||||
}
|
||||
|
||||
251
StarterNG.Tests/LocalizationTests.cs
Normal file
251
StarterNG.Tests/LocalizationTests.cs
Normal file
@@ -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<string, string> Strings(string path) =>
|
||||
XDocument.Load(path).Descendants("String")
|
||||
.ToDictionary(s => (string)s.Attribute("key")!, s => s.Value);
|
||||
|
||||
public static TheoryData<string> Languages()
|
||||
{
|
||||
var data = new TheoryData<string>();
|
||||
foreach (var lang in LocalizationService.AvailableLanguages())
|
||||
if (lang.Code != "en")
|
||||
data.Add(lang.Code);
|
||||
return data;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Languages))]
|
||||
public void Every_language_has_every_key_but_the_optional_ones(string code)
|
||||
{
|
||||
var all = LocalizationService.AvailableLanguages();
|
||||
var english = Strings(all.Single(l => l.Code == "en").Path);
|
||||
var other = Strings(all.Single(l => l.Code == code).Path);
|
||||
|
||||
var missing = english.Keys
|
||||
.Where(key => !key.EndsWith("Desc", StringComparison.Ordinal) &&
|
||||
!key.StartsWith("Cmd.", StringComparison.Ordinal))
|
||||
.Where(key => !other.ContainsKey(key));
|
||||
|
||||
Assert.Empty(missing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_key_description_only_english_has_falls_back_to_english()
|
||||
{
|
||||
var loc = Build();
|
||||
loc.Load("cz");
|
||||
|
||||
Assert.Equal(string.Empty, loc.Opt("Cmd.mastercontrollerincrease"));
|
||||
Assert.Equal("master controller up", loc.OptOrEnglish("Cmd.mastercontrollerincrease"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_string_the_language_has_is_not_replaced_with_english()
|
||||
{
|
||||
var loc = Build();
|
||||
loc.Load("pl");
|
||||
|
||||
Assert.Equal(loc["Language"], loc.OptOrEnglish("Language"));
|
||||
Assert.NotEqual("Language", loc.OptOrEnglish("Language"));
|
||||
}
|
||||
|
||||
private const string English =
|
||||
"<Language code=\"en\" name=\"English\"><String key=\"Hello\">Hello</String></Language>";
|
||||
|
||||
/// <summary>A file cut off right after its header: it still looks like a language.</summary>
|
||||
private const string CutShort = "<Language code=\"pl\" name=\"Polski\">";
|
||||
|
||||
private static (LocalizationService Loc, FileDiagnosticsLog Log, string Directory) InFolderOf(
|
||||
params (string File, string Text)[] languages)
|
||||
{
|
||||
string directory = Directory.CreateTempSubdirectory("starterng-lang-").FullName;
|
||||
foreach (var (file, text) in languages)
|
||||
File.WriteAllText(Path.Combine(directory, file), text);
|
||||
var log = TestLog.Over();
|
||||
return (new LocalizationService(log, directory), log, directory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_translation_cut_short_counts_as_missing_and_is_logged()
|
||||
{
|
||||
var (loc, log, directory) = InFolderOf(("en.xml", English), ("pl.xml", CutShort));
|
||||
try
|
||||
{
|
||||
Assert.Equal("English", loc.Load("pl"));
|
||||
|
||||
Assert.Equal("en", loc.CurrentLangCode);
|
||||
Assert.Equal("Hello", loc["Hello"]);
|
||||
Assert.Contains("lang/pl.xml", log.Text);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void With_every_translation_cut_short_the_starter_still_starts()
|
||||
{
|
||||
var (loc, log, directory) = InFolderOf(("pl.xml", CutShort));
|
||||
try
|
||||
{
|
||||
Assert.Equal("English", loc.Load("pl"));
|
||||
|
||||
Assert.Equal("en", loc.CurrentLangCode);
|
||||
Assert.Equal("Hello", loc["Hello"]);
|
||||
Assert.Contains("lang/pl.xml", log.Text);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_language_cut_short_leaves_english_rather_than_the_one_in_use_before()
|
||||
{
|
||||
var (loc, _, directory) = InFolderOf(
|
||||
("cz.xml", "<Language code=\"cz\" name=\"Cestina\"><String key=\"Hello\">Ahoj</String></Language>"),
|
||||
("en.xml", English),
|
||||
("pl.xml", CutShort));
|
||||
try
|
||||
{
|
||||
loc.Load("cz");
|
||||
Assert.Equal("Ahoj", loc["Hello"]);
|
||||
|
||||
loc.Load("pl");
|
||||
|
||||
Assert.Equal("en", loc.CurrentLangCode);
|
||||
Assert.Equal("Hello", loc["Hello"]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A player's folder, the bundled one with English in it, the old starter's and the simulator's.</summary>
|
||||
private sealed class Folders : IDisposable
|
||||
{
|
||||
public readonly string Root = Directory.CreateTempSubdirectory("starterng-langs-").FullName;
|
||||
|
||||
public string Own => Sub("own");
|
||||
public string Bundled => Sub("bundled");
|
||||
public string OldStarter => Sub("starter");
|
||||
public string Simulator => Sub("lang");
|
||||
|
||||
private string Sub(string name) => Directory.CreateDirectory(Path.Combine(Root, name)).FullName;
|
||||
|
||||
public LocalizationService Service() =>
|
||||
new(TestLog.Over(),
|
||||
Bundled, new LanguageFolders(new[] { Own, Bundled }, OldStarter, Simulator));
|
||||
|
||||
public void Dispose() => Directory.Delete(Root, recursive: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_players_own_translation_wins_over_the_bundled_one()
|
||||
{
|
||||
using var folders = new Folders();
|
||||
File.WriteAllText(Path.Combine(folders.Bundled, "en.xml"), English);
|
||||
File.WriteAllText(Path.Combine(folders.Bundled, "pl.xml"),
|
||||
"<Language code=\"pl\" name=\"Polski\"><String key=\"Hello\">Czesc</String></Language>");
|
||||
File.WriteAllText(Path.Combine(folders.Own, "moj.xml"),
|
||||
"<Language code=\"pl\" name=\"Moj polski\"><String key=\"Hello\">Witaj</String></Language>");
|
||||
var loc = folders.Service();
|
||||
|
||||
Assert.Equal("Moj polski", loc.Load("pl"));
|
||||
Assert.Equal("Witaj", loc["Hello"]);
|
||||
Assert.True(loc.HasOwnTexts);
|
||||
Assert.Single(loc.Languages(), lang => lang.Code == "pl");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_old_starters_language_is_offered_in_english_with_its_key_descriptions()
|
||||
{
|
||||
using var folders = new Folders();
|
||||
File.WriteAllText(Path.Combine(folders.Bundled, "en.xml"), English);
|
||||
File.WriteAllText(Path.Combine(folders.OldStarter, "lang-ZH.txt"),
|
||||
"[Main]\ncbLang.Items.Text=PL|EN\n[labels]\nx=y\n<eu07_input-keyboard.ini\nhornhighactivate=gwizd\n\n[frmOther]\nz=q\n");
|
||||
var loc = folders.Service();
|
||||
|
||||
Assert.Contains(loc.Languages(), lang => lang.Code == "zh" && lang.Name == "ZH" && lang.Path.Length == 0);
|
||||
|
||||
loc.Load("zh");
|
||||
|
||||
Assert.Equal("zh", loc.CurrentLangCode);
|
||||
Assert.False(loc.HasOwnTexts);
|
||||
Assert.Equal("Hello", loc["Hello"]);
|
||||
Assert.Equal("gwizd", loc.Opt("Cmd.hornhighactivate"));
|
||||
Assert.Equal(string.Empty, loc.Opt("Cmd.z"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 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<eu07_input-keyboard.ini\r\nhornhighactivate=Houkačka výšková\r\n\r\n"));
|
||||
var loc = folders.Service();
|
||||
|
||||
loc.Load("cz");
|
||||
|
||||
Assert.Equal("Houkačka výšková", loc.Opt("Cmd.hornhighactivate"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Of_the_simulators_files_only_a_language_code_counts()
|
||||
{
|
||||
using var folders = new Folders();
|
||||
File.WriteAllText(Path.Combine(folders.Bundled, "en.xml"), English);
|
||||
foreach (string file in new[] { "hu.po", "editor_pl.po", "template.po" })
|
||||
File.WriteAllText(Path.Combine(folders.Simulator, file), string.Empty);
|
||||
|
||||
var codes = folders.Service().Languages().Select(lang => lang.Code).ToList();
|
||||
|
||||
Assert.Contains("hu", codes);
|
||||
Assert.DoesNotContain("editor_pl", codes);
|
||||
Assert.DoesNotContain("template", codes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_code_eu07_ini_names_stays_on_the_list_once()
|
||||
{
|
||||
using var folders = new Folders();
|
||||
File.WriteAllText(Path.Combine(folders.Bundled, "en.xml"), English);
|
||||
var loc = folders.Service();
|
||||
|
||||
loc.Keep("sk");
|
||||
loc.Keep("SK");
|
||||
loc.Keep("en");
|
||||
|
||||
Assert.Single(loc.Languages(), lang => lang.Code == "sk");
|
||||
Assert.Equal(2, loc.Languages().Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_key_a_translation_lacks_comes_in_english()
|
||||
{
|
||||
using var folders = new Folders();
|
||||
File.WriteAllText(Path.Combine(folders.Bundled, "en.xml"), English);
|
||||
File.WriteAllText(Path.Combine(folders.Own, "sk.xml"),
|
||||
"<Language code=\"sk\" name=\"Slovensky\"><String key=\"Other\">Ine</String></Language>");
|
||||
var loc = folders.Service();
|
||||
|
||||
loc.Load("sk");
|
||||
|
||||
Assert.Equal("Hello", loc["Hello"]);
|
||||
Assert.Equal("Ine", loc["Other"]);
|
||||
}
|
||||
}
|
||||
36
StarterNG.Tests/PhysicalFileSystemTests.cs
Normal file
36
StarterNG.Tests/PhysicalFileSystemTests.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using StarterNG.Infrastructure.Adapters;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public sealed class PhysicalFileSystemTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Directory.CreateTempSubdirectory("starterng-disk-").FullName;
|
||||
private readonly PhysicalFileSystem _files = new();
|
||||
|
||||
public void Dispose() => Directory.Delete(_root, recursive: true);
|
||||
|
||||
[Fact]
|
||||
public void An_atomic_write_makes_or_replaces_the_file_and_leaves_nothing_beside_it()
|
||||
{
|
||||
string path = Path.Combine(_root, "file.json");
|
||||
|
||||
_files.WriteAllTextAtomic(path, "first");
|
||||
_files.WriteAllTextAtomic(path, "zażółć");
|
||||
|
||||
Assert.Equal("zażółć", File.ReadAllText(path));
|
||||
Assert.Equal(new[] { path }, Directory.GetFiles(_root));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_atomic_write_that_cannot_finish_leaves_what_stood_there()
|
||||
{
|
||||
string path = Path.Combine(_root, "file.json");
|
||||
File.WriteAllText(path, "what stood there");
|
||||
// Nowhere to put the new text on its way in.
|
||||
Directory.CreateDirectory(path + PhysicalFileSystem.TempSuffix);
|
||||
|
||||
Assert.ThrowsAny<Exception>(() => _files.WriteAllTextAtomic(path, "half"));
|
||||
|
||||
Assert.Equal("what stood there", File.ReadAllText(path));
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,70 @@ public class PhysicsAndAssetsTests
|
||||
Assert.NotNull(installation.Physics.For("pkp/ep07/", "ep07"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("pkp/21xm_v1/")]
|
||||
[InlineData("pkp/21xm_v1")]
|
||||
[InlineData("PKP\\21XM_V1")]
|
||||
[InlineData("dynamic/pkp/21xm_v1/")]
|
||||
public void Two_fiz_files_of_one_name_are_told_apart_by_their_folder(string folder)
|
||||
{
|
||||
// As in the game: dynamic/**/21xm_v1/214m.fiz and sa134_v1/214m.fiz.
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithFile(Fiz("pkp", "21xm_v1", "214m"), "Param: M=51000\nDimensions: L=24.38")
|
||||
.WithFile(Fiz("pkp", "sa134_v1", "214m"), "Param: M=48000\nDimensions: L=24.5"));
|
||||
|
||||
var physics = installation.Physics.For(folder, "214m.e3d")!;
|
||||
|
||||
Assert.Equal(51000, physics.Mass);
|
||||
Assert.Equal(24.38, physics.Length);
|
||||
Assert.Equal(48000, installation.Physics.For("pkp/sa134_v1/", "214m")!.Mass);
|
||||
// Asked again, the first answer has not stuck to the name.
|
||||
Assert.Equal(51000, installation.Physics.For(folder, "214m")!.Mass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_folder_without_the_fiz_falls_back_to_the_name_alone()
|
||||
{
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithFile(Fiz("pkp", "ep07", "ep07"), "Param: M=80000"));
|
||||
|
||||
Assert.Equal(80000, installation.Physics.For("pkp/other/", "ep07")!.Mass);
|
||||
Assert.Equal(80000, installation.Physics.For(null, "ep07")!.Mass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_fallback_to_a_name_two_folders_share_is_the_same_on_every_machine()
|
||||
{
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithFile(Fiz("zz", "b", "214m"), "Param: M=2")
|
||||
.WithFile(Fiz("aa", "a", "214m"), "Param: M=1"));
|
||||
|
||||
Assert.Equal(1, installation.Physics.For("elsewhere/", "214m")!.Mass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_dumb_variant_in_the_vehicles_own_folder_goes_before_a_namesake_elsewhere()
|
||||
{
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithFile(Fiz("pkp", "ep07", "ep07dumb"), "Param: M=1000")
|
||||
.WithFile(Fiz("pkp", "other", "ep07"), "Param: M=2000"));
|
||||
|
||||
Assert.Equal(1000, installation.Physics.For("pkp/ep07/", "ep07")!.Mass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Two_folders_sharing_a_name_count_once_among_what_is_indexed()
|
||||
{
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithFile(Fiz("pkp", "a", "214m"), "Param: M=1")
|
||||
.WithFile(Fiz("pkp", "b", "214m"), "Param: M=2")
|
||||
.WithFile(Fiz("pkp", "c", "ep07"), "Param: M=3"));
|
||||
|
||||
installation.Physics.Preload();
|
||||
|
||||
Assert.Equal(2, installation.Physics.IndexedCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unknown_model_has_no_physics_and_an_empty_installation_indexes_nothing()
|
||||
{
|
||||
|
||||
160
StarterNG.Tests/PresetFileTests.cs
Normal file
160
StarterNG.Tests/PresetFileTests.cs
Normal file
@@ -0,0 +1,160 @@
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Infrastructure.Adapters;
|
||||
using StarterNG.Tests.Fakes;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
// Over the fake disk throughout: PresetStore itself is the player's real file.
|
||||
public class PresetFileTests
|
||||
{
|
||||
private const string PresetsPath = "/home/user/.config/MaSzyna/starter/userpresets.json";
|
||||
|
||||
private const string ThreePresets = """
|
||||
{ "Presets": [
|
||||
{ "Name": "a", "Entry": "trainset a" },
|
||||
{ "Name": "b", "Entry": "trainset b" },
|
||||
{ "Name": "c", "Entry": "trainset c" }
|
||||
] }
|
||||
""";
|
||||
|
||||
private static (PresetFile Presets, InMemoryFileSystem Files, FileDiagnosticsLog Log) Rig(string? held = null)
|
||||
{
|
||||
var files = new InMemoryFileSystem();
|
||||
if (held is not null)
|
||||
files.WithFile(PresetsPath, held);
|
||||
var log = TestLog.Over(files);
|
||||
return (new PresetFile(files, log, PresetsPath), files, log);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_saved_preset_joins_the_ones_in_the_file()
|
||||
{
|
||||
var (presets, files, _) = Rig(ThreePresets);
|
||||
|
||||
presets.Save("d", "trainset d");
|
||||
|
||||
Assert.Equal(new[] { "a", "b", "c", "d" }, presets.Presets().Select(p => p.Name));
|
||||
Assert.False(files.FileExists(PresetsPath + ".unreadable"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Saving_under_a_name_there_replaces_that_one()
|
||||
{
|
||||
var (presets, _, _) = Rig(ThreePresets);
|
||||
|
||||
presets.Save("B", "trainset other");
|
||||
|
||||
Assert.Equal(new[] { "a", "B", "c" }, presets.Presets().Select(p => p.Name));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_first_preset_needs_no_file_to_be_there()
|
||||
{
|
||||
var (presets, files, log) = Rig();
|
||||
|
||||
presets.Save("a", "trainset a");
|
||||
|
||||
Assert.Equal("a", Assert.Single(presets.Presets()).Name);
|
||||
Assert.False(files.FileExists(PresetsPath + ".unreadable"));
|
||||
Assert.Equal(string.Empty, log.Text);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{ "Presets": [ { "Name": "a", "Entry": "trainset a" }, { "Na""")]
|
||||
[InlineData("")]
|
||||
[InlineData("null")]
|
||||
[InlineData("""{ "Presets": null }""")]
|
||||
public void A_file_cut_short_goes_aside_before_a_save_replaces_it(string held)
|
||||
{
|
||||
var (presets, files, log) = Rig(held);
|
||||
|
||||
presets.Save("new", "trainset new");
|
||||
|
||||
Assert.Equal(held, files.ReadAllText(PresetsPath + ".unreadable"));
|
||||
Assert.Equal("new", Assert.Single(presets.Presets()).Name);
|
||||
Assert.Contains(PresetsPath + ".unreadable", log.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_earlier_copy_set_aside_is_not_overwritten()
|
||||
{
|
||||
var (presets, files, _) = Rig("{ cut");
|
||||
files.WithFile(PresetsPath + ".unreadable", "older");
|
||||
|
||||
presets.Save("new", "trainset new");
|
||||
|
||||
Assert.Equal("older", files.ReadAllText(PresetsPath + ".unreadable"));
|
||||
Assert.Equal("{ cut", files.ReadAllText(PresetsPath + ".unreadable-2"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_locked_file_is_left_alone_when_no_copy_of_it_can_be_made()
|
||||
{
|
||||
var (presets, files, log) = Rig(ThreePresets);
|
||||
files.FailReading(PresetsPath);
|
||||
|
||||
presets.Save("new", "trainset new");
|
||||
|
||||
files.AllowReading(PresetsPath);
|
||||
Assert.Equal(ThreePresets, files.ReadAllText(PresetsPath));
|
||||
Assert.False(files.FileExists(PresetsPath + ".unreadable"));
|
||||
Assert.Contains("saving", log.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_file_unlocked_between_the_read_and_the_write_is_still_kept()
|
||||
{
|
||||
// The list in hand is the empty one from the read that failed: the file has
|
||||
// to be kept whatever it looks like by the time it would be replaced.
|
||||
var (presets, files, _) = Rig(ThreePresets);
|
||||
var unlocking = new UnlockingLog(files, PresetsPath);
|
||||
var racing = new PresetFile(files, unlocking, PresetsPath);
|
||||
files.FailReading(PresetsPath);
|
||||
|
||||
racing.Save("new", "trainset new");
|
||||
|
||||
Assert.Equal(ThreePresets, files.ReadAllText(PresetsPath + ".unreadable"));
|
||||
Assert.Equal("new", Assert.Single(presets.Presets()).Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deleting_from_an_unreadable_file_leaves_it_as_it_is()
|
||||
{
|
||||
var (presets, files, _) = Rig("{ cut");
|
||||
|
||||
presets.Delete("a");
|
||||
|
||||
Assert.Equal("{ cut", files.ReadAllText(PresetsPath));
|
||||
Assert.False(files.FileExists(PresetsPath + ".unreadable"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unreadable_file_shows_as_no_presets_and_is_logged()
|
||||
{
|
||||
var (presets, _, log) = Rig("{ cut");
|
||||
|
||||
Assert.Empty(presets.Presets());
|
||||
Assert.Contains("reading", log.Text);
|
||||
}
|
||||
|
||||
/// <summary>Lets the file go the moment its read has failed and been logged.</summary>
|
||||
private sealed class UnlockingLog : StarterNG.Application.Abstractions.IDiagnosticsLog
|
||||
{
|
||||
private readonly InMemoryFileSystem _files;
|
||||
private readonly string _path;
|
||||
|
||||
public UnlockingLog(InMemoryFileSystem files, string path)
|
||||
{
|
||||
_files = files;
|
||||
_path = path;
|
||||
}
|
||||
|
||||
public string LogPath => "";
|
||||
public string Text => "";
|
||||
public void Log(string message) => _files.AllowReading(_path);
|
||||
public void Log(string context, Exception exception) => _files.AllowReading(_path);
|
||||
public void Log(IEnumerable<string> messages) => _files.AllowReading(_path);
|
||||
public void Clear() { }
|
||||
public void Flush() { }
|
||||
}
|
||||
}
|
||||
103
StarterNG.Tests/ResolutionChoicesTests.cs
Normal file
103
StarterNG.Tests/ResolutionChoicesTests.cs
Normal file
@@ -0,0 +1,103 @@
|
||||
using StarterNG.Domain.Settings;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public class ResolutionChoicesTests
|
||||
{
|
||||
private static List<string> Listed(ScreenSize[] monitors, ScreenSize current) =>
|
||||
ResolutionChoices.For(monitors, current).Select(c => c.Size.ToString()).ToList();
|
||||
|
||||
[Fact]
|
||||
public void The_monitors_come_first_and_are_marked()
|
||||
{
|
||||
var choices = ResolutionChoices.For(new[] { new ScreenSize(1920, 1200), new ScreenSize(2560, 1440) },
|
||||
new ScreenSize(1280, 720));
|
||||
|
||||
Assert.Equal(new ScreenSize(1920, 1200), choices[0].Size);
|
||||
Assert.Equal(new ScreenSize(2560, 1440), choices[1].Size);
|
||||
Assert.True(choices[0].IsMonitor);
|
||||
Assert.True(choices[1].IsMonitor);
|
||||
Assert.All(choices.Skip(2), c => Assert.False(c.IsMonitor));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_size_no_monitor_can_show_is_not_offered()
|
||||
{
|
||||
var listed = Listed(new[] { new ScreenSize(1920, 1080) }, new ScreenSize(1280, 720));
|
||||
|
||||
Assert.Contains("1920x1080", listed);
|
||||
Assert.Contains("1600x900", listed);
|
||||
Assert.DoesNotContain("3840x2160", listed);
|
||||
Assert.DoesNotContain("2560x1440", listed);
|
||||
// Taller than the screen, though no wider.
|
||||
Assert.DoesNotContain("1920x1200", listed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_size_fitting_any_of_the_monitors_is_offered()
|
||||
{
|
||||
var listed = Listed(new[] { new ScreenSize(1920, 1080), new ScreenSize(3440, 1440) }, new ScreenSize(1280, 720));
|
||||
|
||||
Assert.Contains("2560x1080", listed);
|
||||
Assert.DoesNotContain("3840x2160", listed);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1920, 1200)]
|
||||
[InlineData(1280, 800)]
|
||||
[InlineData(2560, 1080)]
|
||||
[InlineData(3440, 1440)]
|
||||
public void The_monitors_size_is_there_even_where_it_is_not_a_usual_one(int width, int height)
|
||||
{
|
||||
var listed = Listed(new[] { new ScreenSize(width, height) }, new ScreenSize(1024, 768));
|
||||
|
||||
Assert.Equal($"{width}x{height}", listed[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_size_asked_for_stays_on_the_list_whatever_it_is()
|
||||
{
|
||||
var listed = Listed(new[] { new ScreenSize(1920, 1080) }, new ScreenSize(5120, 1440));
|
||||
|
||||
Assert.Contains("5120x1440", listed);
|
||||
Assert.Single(listed, "5120x1440");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_rest_goes_from_the_largest_down_without_repeats()
|
||||
{
|
||||
var listed = Listed(new[] { new ScreenSize(2560, 1440) }, new ScreenSize(1920, 1080));
|
||||
|
||||
Assert.Equal(listed.Count, listed.Distinct().Count());
|
||||
var sizes = ResolutionChoices.For(new[] { new ScreenSize(2560, 1440) }, new ScreenSize(1920, 1080))
|
||||
.Skip(1).Select(c => c.Size).ToList();
|
||||
Assert.Equal(sizes.OrderByDescending(s => s.Width).ThenByDescending(s => s.Height), sizes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Two_monitors_of_a_size_are_one_entry()
|
||||
{
|
||||
var choices = ResolutionChoices.For(new[] { new ScreenSize(1920, 1080), new ScreenSize(1920, 1080) },
|
||||
new ScreenSize(1920, 1080));
|
||||
|
||||
Assert.Single(choices, c => c.Size == new ScreenSize(1920, 1080));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void With_no_monitors_known_everything_usual_is_offered()
|
||||
{
|
||||
var listed = Listed(Array.Empty<ScreenSize>(), new ScreenSize(1280, 720));
|
||||
|
||||
Assert.Contains("3840x2160", listed);
|
||||
Assert.Contains("1280x800", listed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_size_of_nothing_is_not_listed()
|
||||
{
|
||||
var listed = Listed(new[] { new ScreenSize(0, 0) }, new ScreenSize(0, 0));
|
||||
|
||||
Assert.DoesNotContain("0x0", listed);
|
||||
Assert.NotEmpty(listed);
|
||||
}
|
||||
}
|
||||
47
StarterNG.Tests/RevisionSpanTests.cs
Normal file
47
StarterNG.Tests/RevisionSpanTests.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
using StarterNG.Domain.Vehicles;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public class RevisionSpanTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("14.01.2005", "2005-01-14", "2005-01-14")]
|
||||
[InlineData("06.12.2016", "2016-12-06", "2016-12-06")]
|
||||
[InlineData("xx.06.2008", "2008-06-01", "2008-06-30")]
|
||||
[InlineData("?.06.2008", "2008-06-01", "2008-06-30")]
|
||||
[InlineData("xx.xx.2011", "2011-01-01", "2011-12-31")]
|
||||
[InlineData("?.?.2011", "2011-01-01", "2011-12-31")]
|
||||
[InlineData("02.2016", "2016-02-01", "2016-02-29")]
|
||||
public void A_credit_line_date_is_as_exact_as_it_says(string text, string from, string to)
|
||||
{
|
||||
var span = RevisionSpan.Parse(text);
|
||||
|
||||
Assert.Equal(DateTime.Parse(from), span?.From);
|
||||
Assert.Equal(DateTime.Parse(to), span?.To);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("2016", "2016-01-01", "2016-12-31")]
|
||||
[InlineData("2016-12", "2016-12-01", "2016-12-31")]
|
||||
[InlineData("2016-12-06", "2016-12-06", "2016-12-06")]
|
||||
[InlineData(" 2016-12- ", "2016-12-01", "2016-12-31")]
|
||||
public void What_is_typed_to_filter_by_reads_the_same_way(string text, string from, string to)
|
||||
{
|
||||
var span = RevisionSpan.Parse(text);
|
||||
|
||||
Assert.Equal(DateTime.Parse(from), span?.From);
|
||||
Assert.Equal(DateTime.Parse(to), span?.To);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("?")]
|
||||
[InlineData("xx.xx.xxxx")]
|
||||
[InlineData("31.02.2015")]
|
||||
[InlineData("01.13.2015")]
|
||||
[InlineData("VRZ-99_Kolowag_Septemwri")]
|
||||
[InlineData("201")]
|
||||
public void Anything_else_is_no_date(string? text) =>
|
||||
Assert.Null(RevisionSpan.Parse(text));
|
||||
}
|
||||
@@ -1,13 +1,9 @@
|
||||
using StarterNG.Infrastructure.Sceneries;
|
||||
using StarterNG.Tests.Fakes;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public class SceneryExportTests
|
||||
{
|
||||
private static SceneryParser Parser() =>
|
||||
new(new FixedClock(new DateTime(2026, 1, 1, 12, 0, 0)), new StubRandom());
|
||||
|
||||
[Fact]
|
||||
public void An_untouched_scenery_exports_its_trainsets_back_verbatim()
|
||||
{
|
||||
@@ -18,7 +14,7 @@ public class SceneryExportTests
|
||||
endtrainset
|
||||
FirstInit
|
||||
""";
|
||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
||||
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||
|
||||
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
|
||||
|
||||
@@ -27,6 +23,45 @@ public class SceneryExportTests
|
||||
Assert.Contains("endtrainset", exported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_consist_emptied_stays_out_of_the_export()
|
||||
{
|
||||
const string scn = """
|
||||
//$n Test
|
||||
trainset pociag tor_1 10 0
|
||||
node -1 -1 ep07-001 dynamic PKP/EP07 ep07 ep07.mmd 0 headdriver 0
|
||||
endtrainset
|
||||
FirstInit
|
||||
""";
|
||||
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||
|
||||
scenery.Trainsets[0].RemoveVehicles();
|
||||
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
|
||||
|
||||
Assert.DoesNotContain("trainset pociag", exported);
|
||||
Assert.DoesNotContain("ep07-001", exported);
|
||||
Assert.Contains("FirstInit", exported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_entry_that_could_not_be_read_goes_when_emptied_too()
|
||||
{
|
||||
const string scn = """
|
||||
//$n Test
|
||||
trainset pociag tor_1 10 0
|
||||
node -1 -1 ep07-001 dynamic PKP/EP07 ep07 ep07.mmd 0 headdriver 0
|
||||
endtrainset
|
||||
FirstInit
|
||||
""";
|
||||
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||
scenery.Trainsets[0].Parsed = false;
|
||||
|
||||
scenery.Trainsets[0].RemoveVehicles();
|
||||
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
|
||||
|
||||
Assert.DoesNotContain("ep07-001", exported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Selected_optional_includes_are_written_back_before_FirstInit()
|
||||
{
|
||||
@@ -36,7 +71,7 @@ public class SceneryExportTests
|
||||
endoptional
|
||||
FirstInit
|
||||
""";
|
||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
||||
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||
|
||||
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
|
||||
|
||||
@@ -54,7 +89,7 @@ public class SceneryExportTests
|
||||
endoptional
|
||||
FirstInit
|
||||
""";
|
||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
||||
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||
scenery.Includes[0].Selected = false;
|
||||
|
||||
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
|
||||
@@ -71,7 +106,7 @@ public class SceneryExportTests
|
||||
endoptional
|
||||
FirstInit
|
||||
""";
|
||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
||||
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||
|
||||
scenery.HasCompanionTimetable = false;
|
||||
Assert.Contains("include rozklad.inc",
|
||||
@@ -95,7 +130,7 @@ public class SceneryExportTests
|
||||
time 8:30 0 0 endtime
|
||||
FirstInit
|
||||
""";
|
||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
||||
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||
|
||||
scenery.Weather.Day = 200;
|
||||
scenery.Weather.Temperature = 25;
|
||||
@@ -120,7 +155,7 @@ public class SceneryExportTests
|
||||
endtrainset
|
||||
FirstInit
|
||||
""";
|
||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
||||
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||
|
||||
Assert.True(scenery.Trainsets[0].Decor);
|
||||
Assert.DoesNotContain("trainset ai_1",
|
||||
@@ -136,11 +171,28 @@ public class SceneryExportTests
|
||||
node -1 -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 enddynamic
|
||||
FirstInit
|
||||
""";
|
||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
||||
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||
|
||||
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
|
||||
|
||||
Assert.Contains("wagon_1", exported);
|
||||
Assert.Contains("enddynamic", exported);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, "3 0 enddynamic")]
|
||||
[InlineData(3, "3 3 pantstate enddynamic")]
|
||||
public void A_load_type_is_written_only_after_a_count_above_zero(int count, string tail)
|
||||
{
|
||||
// After a 0 the simulator would read the type as a destination and take
|
||||
// the next vehicle with it.
|
||||
var car = new StarterNG.Classes.Dynamic
|
||||
{
|
||||
Name = "en57", DataFolder = "pkp/en57", SkinFile = "s", MmdFile = "m",
|
||||
LoadType = StarterNG.Classes.Dynamic.PantState, LoadCount = count
|
||||
};
|
||||
car.Coupling.Flags = 3;
|
||||
|
||||
Assert.EndsWith(tail + "\n", car.ToTrainsetNode());
|
||||
}
|
||||
}
|
||||
|
||||
77
StarterNG.Tests/SceneryFaultTextTests.cs
Normal file
77
StarterNG.Tests/SceneryFaultTextTests.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using StarterNG.Domain.Sceneries;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public class SceneryFaultTextTests
|
||||
{
|
||||
private static string Wording(string key) => key switch
|
||||
{
|
||||
"SyntaxLost" => "lost: {0}",
|
||||
"SyntaxUnmade" => "unmade: {0}",
|
||||
"SyntaxMore" => "and {0} more",
|
||||
_ => key + " {0} {1}"
|
||||
};
|
||||
|
||||
private static string Format(int limit, params SceneryFault[] faults) =>
|
||||
SceneryFaultText.Format(faults, limit, Wording);
|
||||
|
||||
[Fact]
|
||||
public void No_faults_read_as_nothing() =>
|
||||
Assert.Equal("", Format(10));
|
||||
|
||||
[Fact]
|
||||
public void The_place_and_the_fault_stand_in_two_columns_as_wide_as_the_longest_place()
|
||||
{
|
||||
string text = Format(10,
|
||||
new SceneryFault(SceneryFaultKind.VehicleIncomplete, "a.scn", 3, "v1", "x"),
|
||||
new SceneryFault(SceneryFaultKind.NotANumber, "long.inc", 120, "v2", "y"));
|
||||
|
||||
Assert.Equal(
|
||||
"a.scn:3 SyntaxVehicleIncomplete v1 x\n" +
|
||||
"long.inc:120 SyntaxNotANumber v2 y",
|
||||
text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void What_a_fault_has_no_subject_or_token_for_is_filled_in()
|
||||
{
|
||||
string text = Format(10, new SceneryFault(SceneryFaultKind.StrayEndTrainset, "a.scn", 3));
|
||||
|
||||
Assert.Equal("a.scn:3 SyntaxStrayEndTrainset ?", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void What_a_fault_costs_stands_under_it_in_the_second_column()
|
||||
{
|
||||
string text = Format(10,
|
||||
new SceneryFault(SceneryFaultKind.VehicleNotClosed, "a.scn", 3, "v1", "x") { Lost = "v2, v3", Unmade = "b" });
|
||||
|
||||
Assert.Equal(
|
||||
"a.scn:3 SyntaxVehicleNotClosed v1 x\n" +
|
||||
" lost: v2, v3\n" +
|
||||
" unmade: b",
|
||||
text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Past_the_limit_the_faults_are_only_counted_and_do_not_widen_the_column()
|
||||
{
|
||||
string text = Format(1,
|
||||
new SceneryFault(SceneryFaultKind.NotANumber, "a.scn", 3, "v1", "x"),
|
||||
new SceneryFault(SceneryFaultKind.NotANumber, "a-longer-name.scn", 4, "v2", "y"),
|
||||
new SceneryFault(SceneryFaultKind.NotANumber, "a.scn", 5, "v3", "z"));
|
||||
|
||||
Assert.Equal(
|
||||
"a.scn:3 SyntaxNotANumber v1 x\n" +
|
||||
"and 2 more",
|
||||
text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Exactly_as_many_as_the_limit_are_not_counted_as_more()
|
||||
{
|
||||
string text = Format(1, new SceneryFault(SceneryFaultKind.NotANumber, "a.scn", 3, "v1", "x"));
|
||||
|
||||
Assert.DoesNotContain("more", text);
|
||||
}
|
||||
}
|
||||
376
StarterNG.Tests/SceneryFileWatchTests.cs
Normal file
376
StarterNG.Tests/SceneryFileWatchTests.cs
Normal file
@@ -0,0 +1,376 @@
|
||||
using StarterNG.Application;
|
||||
using StarterNG.Domain.Sceneries;
|
||||
using StarterNG.Infrastructure.Adapters;
|
||||
using StarterNG.Tests.Fakes;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public class SceneryFileWatchTests
|
||||
{
|
||||
private static readonly string Scn = TestInstallation.At("scenery", "kato.scn");
|
||||
private static readonly string Other = TestInstallation.At("scenery", "lk4.scn");
|
||||
private static readonly string Inc = TestInstallation.At("scenery", "auta.inc");
|
||||
|
||||
private const string Text =
|
||||
"//$n Katowice\n" +
|
||||
"FirstInit\n" +
|
||||
"trainset a tor 0 0\n" +
|
||||
"node -1 0 v1 dynamic d s m 0 headdriver 3 0 enddynamic\n" +
|
||||
"endtrainset\n" +
|
||||
"trainset b tor 0 0\n" +
|
||||
"node -1 0 v2 dynamic d s m 0 headdriver 3 0 enddynamic\n" +
|
||||
"endtrainset\n";
|
||||
|
||||
private static (TestInstallation Installation, SceneryFileWatch Watch, AppState State) Rig(InMemoryFileSystem files)
|
||||
{
|
||||
var installation = new TestInstallation(files);
|
||||
installation.Library.ReloadSceneries();
|
||||
var state = new AppState();
|
||||
return (installation, new SceneryFileWatch(files, installation.Sceneries, installation.Library, state), state);
|
||||
}
|
||||
|
||||
private static InMemoryFileSystem Folder() => new InMemoryFileSystem().WithFile(Scn, Text);
|
||||
|
||||
[Fact]
|
||||
public void Files_as_they_were_read_are_no_change()
|
||||
{
|
||||
var (installation, watch, _) = Rig(Folder());
|
||||
|
||||
Assert.Empty(watch.Look(installation.Library.Sceneries));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_file_changed_outside_is_read_anew()
|
||||
{
|
||||
var files = Folder();
|
||||
var (installation, watch, _) = Rig(files);
|
||||
var current = installation.Library.Sceneries.Single();
|
||||
|
||||
files.WriteAllText(Scn, Text.Replace("Katowice", "Katowice Główne"));
|
||||
|
||||
var change = Assert.Single(watch.Look(installation.Library.Sceneries));
|
||||
Assert.Same(current, change.Current);
|
||||
Assert.Equal("Katowice Główne", change.Fresh!.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_include_that_changed_or_came_makes_the_scenery_read_anew()
|
||||
{
|
||||
var files = new InMemoryFileSystem().WithFile(Scn, Text + "include auta.inc end\n");
|
||||
var (installation, watch, _) = Rig(files);
|
||||
Assert.Empty(watch.Look(installation.Library.Sceneries));
|
||||
|
||||
files.WriteAllText(Inc, "// auta\n");
|
||||
Assert.Single(watch.Look(installation.Library.Sceneries));
|
||||
|
||||
installation.Library.ReloadSceneries();
|
||||
Assert.Empty(watch.Look(installation.Library.Sceneries));
|
||||
|
||||
files.WriteAllText(Inc, "// inne auta\n");
|
||||
Assert.Single(watch.Look(installation.Library.Sceneries));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_new_file_in_the_folder_comes_as_an_added_scenery()
|
||||
{
|
||||
var files = Folder();
|
||||
var (installation, watch, _) = Rig(files);
|
||||
|
||||
files.WriteAllText(Other, "//$n Lublin\n");
|
||||
|
||||
var change = Assert.Single(watch.Look(installation.Library.Sceneries));
|
||||
Assert.Null(change.Current);
|
||||
Assert.Equal("Lublin", change.Fresh!.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_file_is_gone_only_if_still_away_on_the_next_look()
|
||||
{
|
||||
var files = Folder();
|
||||
var (installation, watch, _) = Rig(files);
|
||||
var current = installation.Library.Sceneries.Single();
|
||||
|
||||
files.DeleteFile(Scn);
|
||||
Assert.Empty(watch.Look(installation.Library.Sceneries));
|
||||
|
||||
var change = Assert.Single(watch.Look(installation.Library.Sceneries));
|
||||
Assert.Same(current, change.Current);
|
||||
Assert.Null(change.Fresh);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_file_replaced_between_two_looks_is_not_gone()
|
||||
{
|
||||
var files = Folder();
|
||||
var (installation, watch, _) = Rig(files);
|
||||
|
||||
files.DeleteFile(Scn);
|
||||
Assert.Empty(watch.Look(installation.Library.Sceneries));
|
||||
|
||||
files.WriteAllText(Scn, Text);
|
||||
Assert.Empty(watch.Look(installation.Library.Sceneries));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_file_that_will_not_load_is_tried_and_logged_once_until_it_can_be()
|
||||
{
|
||||
var files = Folder();
|
||||
var (installation, watch, _) = Rig(files);
|
||||
files.WriteAllText(Other, "//$n Lublin\n");
|
||||
files.FailReading(Other);
|
||||
|
||||
Assert.Empty(watch.Look(installation.Library.Sceneries));
|
||||
string once = installation.Log.Text;
|
||||
Assert.Contains("lk4.scn", once);
|
||||
|
||||
Assert.Empty(watch.Look(installation.Library.Sceneries));
|
||||
Assert.Equal(once, installation.Log.Text);
|
||||
|
||||
files.AllowReading(Other);
|
||||
Assert.Equal("Lublin", Assert.Single(watch.Look(installation.Library.Sceneries)).Fresh!.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Its_own_save_is_no_change_from_outside()
|
||||
{
|
||||
var files = Folder();
|
||||
var (installation, watch, _) = Rig(files);
|
||||
var scenery = installation.Library.Sceneries.Single();
|
||||
var trainset = scenery.Trainsets[0];
|
||||
trainset.ReadyToGo = true;
|
||||
|
||||
var log = new FileDiagnosticsLog(files, installation.Clock, installation.Paths);
|
||||
Assert.Equal(SceneryTrainsetSaveOutcome.Saved, new SaveSceneryTrainset(files, log).Execute(scenery, trainset).Outcome);
|
||||
|
||||
Assert.False(watch.IsStale(scenery));
|
||||
Assert.Empty(watch.Look(installation.Library.Sceneries));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_consist_whose_text_stands_goes_over_with_its_edits()
|
||||
{
|
||||
var files = Folder();
|
||||
var (installation, watch, _) = Rig(files);
|
||||
var current = installation.Library.Sceneries.Single();
|
||||
var edited = current.Trainsets[1];
|
||||
edited.ReadyToGo = true;
|
||||
|
||||
files.WriteAllText(Scn, Text.Replace("Katowice", "Katowice Główne"));
|
||||
var fresh = watch.Look(installation.Library.Sceneries).Single().Fresh!;
|
||||
|
||||
Assert.Empty(SceneryFileWatch.CarryOver(current, fresh));
|
||||
Assert.Same(edited, fresh.Trainsets[1]);
|
||||
Assert.True(SceneryTrainsetText.IsModified(fresh.Trainsets[1]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_edited_consist_whose_text_changed_too_is_the_conflict()
|
||||
{
|
||||
var files = Folder();
|
||||
var (installation, watch, _) = Rig(files);
|
||||
var current = installation.Library.Sceneries.Single();
|
||||
var edited = current.Trainsets[1];
|
||||
edited.ReadyToGo = true;
|
||||
|
||||
files.WriteAllText(Scn, Text.Replace(" v2 ", " v22 "));
|
||||
var fresh = watch.Look(installation.Library.Sceneries).Single().Fresh!;
|
||||
|
||||
Assert.Same(edited, Assert.Single(SceneryFileWatch.CarryOver(current, fresh)));
|
||||
Assert.NotSame(edited, fresh.Trainsets[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_consist_changed_but_not_edited_just_gives_way()
|
||||
{
|
||||
var files = Folder();
|
||||
var (installation, watch, _) = Rig(files);
|
||||
var current = installation.Library.Sceneries.Single();
|
||||
|
||||
files.WriteAllText(Scn, Text.Replace(" v2 ", " v22 "));
|
||||
var fresh = watch.Look(installation.Library.Sceneries).Single().Fresh!;
|
||||
|
||||
Assert.Empty(SceneryFileWatch.CarryOver(current, fresh));
|
||||
Assert.Equal("v22", fresh.Trainsets[1].Vehicles.Single().Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_weather_chosen_by_hand_stays_and_the_authored_one_follows_the_file()
|
||||
{
|
||||
var files = Folder();
|
||||
var (installation, watch, _) = Rig(files);
|
||||
var current = installation.Library.Sceneries.Single();
|
||||
current.Weather.Day = 123;
|
||||
|
||||
files.WriteAllText(Scn, Text.Replace("Katowice", "Katowice Główne"));
|
||||
var fresh = watch.Look(installation.Library.Sceneries).Single().Fresh!;
|
||||
SceneryFileWatch.CarryOver(current, fresh);
|
||||
|
||||
Assert.Equal(123, fresh.Weather.Day);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void What_is_picked_follows_into_the_scenery_read_anew()
|
||||
{
|
||||
var files = Folder();
|
||||
var (installation, watch, state) = Rig(files);
|
||||
var current = installation.Library.Sceneries.Single();
|
||||
var picked = current.Trainsets[1];
|
||||
state.CurrentScenery = current;
|
||||
state.CurrentTrainset = picked;
|
||||
state.StartingVehicleName = "v2";
|
||||
|
||||
files.WriteAllText(Scn, Text.Replace("Katowice", "Katowice Główne"));
|
||||
var change = watch.Look(installation.Library.Sceneries).Single();
|
||||
SceneryFileWatch.CarryOver(change.Current!, change.Fresh!);
|
||||
watch.Apply(change);
|
||||
|
||||
Assert.Same(change.Fresh, installation.Library.Sceneries.Single());
|
||||
Assert.Same(change.Fresh, state.CurrentScenery);
|
||||
Assert.Same(picked, state.CurrentTrainset);
|
||||
Assert.Equal("v2", state.StartingVehicleName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_picked_consist_changed_in_the_file_is_followed_by_its_name()
|
||||
{
|
||||
var files = Folder();
|
||||
var (installation, watch, state) = Rig(files);
|
||||
var current = installation.Library.Sceneries.Single();
|
||||
var picked = current.Trainsets[1];
|
||||
state.CurrentScenery = current;
|
||||
state.CurrentTrainset = picked;
|
||||
state.StartingVehicleName = "v2";
|
||||
|
||||
files.WriteAllText(Scn, Text.Replace(" v2 ", " v22 "));
|
||||
var change = watch.Look(installation.Library.Sceneries).Single();
|
||||
SceneryFileWatch.CarryOver(change.Current!, change.Fresh!);
|
||||
watch.Apply(change);
|
||||
|
||||
Assert.Same(change.Fresh!.Trainsets[1], state.CurrentTrainset);
|
||||
Assert.Null(state.StartingVehicleName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_change_to_a_scenery_reloaded_since_the_look_is_left()
|
||||
{
|
||||
var files = Folder();
|
||||
var (installation, watch, _) = Rig(files);
|
||||
|
||||
files.WriteAllText(Scn, Text.Replace("Katowice", "Katowice Główne"));
|
||||
var change = watch.Look(installation.Library.Sceneries).Single();
|
||||
installation.Library.ReloadSceneries();
|
||||
|
||||
Assert.Equal(SceneryVerdict.Leave, watch.Judge(change).Verdict);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_change_to_a_scenery_saved_from_here_since_the_look_is_left_and_nothing_carried_over()
|
||||
{
|
||||
var files = Folder();
|
||||
var (installation, watch, _) = Rig(files);
|
||||
var current = installation.Library.Sceneries.Single();
|
||||
|
||||
files.WriteAllText(Scn, Text.Replace("Katowice", "Katowice Główne"));
|
||||
var change = watch.Look(installation.Library.Sceneries).Single();
|
||||
current.Sources = change.Fresh!.Sources;
|
||||
|
||||
Assert.Equal(SceneryVerdict.Leave, watch.Judge(change).Verdict);
|
||||
Assert.NotSame(current.Trainsets[0], change.Fresh.Trainsets[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_scenery_new_in_the_folder_is_taken()
|
||||
{
|
||||
var files = Folder();
|
||||
var (installation, watch, _) = Rig(files);
|
||||
|
||||
files.WriteAllText(Other, "//$n Lublin\n");
|
||||
var change = watch.Look(installation.Library.Sceneries).Single();
|
||||
|
||||
Assert.Equal(SceneryVerdict.Take, watch.Judge(change).Verdict);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_scenery_new_in_the_folder_that_got_in_since_the_look_is_left()
|
||||
{
|
||||
var (installation, watch, _) = Rig(Folder());
|
||||
|
||||
var change = new SceneryChange(null, installation.Library.Sceneries.Single());
|
||||
|
||||
Assert.Equal(SceneryVerdict.Leave, watch.Judge(change).Verdict);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_change_that_loses_no_edits_is_taken_with_the_edits_carried_over()
|
||||
{
|
||||
var files = Folder();
|
||||
var (installation, watch, _) = Rig(files);
|
||||
var edited = installation.Library.Sceneries.Single().Trainsets[1];
|
||||
edited.ReadyToGo = true;
|
||||
|
||||
files.WriteAllText(Scn, Text.Replace("Katowice", "Katowice Główne"));
|
||||
var change = watch.Look(installation.Library.Sceneries).Single();
|
||||
|
||||
var judgement = watch.Judge(change);
|
||||
|
||||
Assert.Equal(SceneryVerdict.Take, judgement.Verdict);
|
||||
Assert.Empty(judgement.Lost);
|
||||
Assert.Same(edited, change.Fresh!.Trainsets[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_change_that_loses_edits_is_asked_about_naming_them()
|
||||
{
|
||||
var files = Folder();
|
||||
var (installation, watch, _) = Rig(files);
|
||||
var current = installation.Library.Sceneries.Single();
|
||||
var edited = current.Trainsets[1];
|
||||
edited.ReadyToGo = true;
|
||||
|
||||
files.WriteAllText(Scn, Text.Replace(" v2 ", " v22 "));
|
||||
var change = watch.Look(installation.Library.Sceneries).Single();
|
||||
|
||||
var judgement = watch.Judge(change);
|
||||
|
||||
Assert.Equal(SceneryVerdict.AskFirst, judgement.Verdict);
|
||||
Assert.Same(edited, Assert.Single(judgement.Lost));
|
||||
Assert.Same(current.Trainsets[0], change.Fresh!.Trainsets[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_scenery_gone_with_edits_in_it_stays_for_them_to_be_saved_elsewhere()
|
||||
{
|
||||
var (installation, watch, _) = Rig(Folder());
|
||||
var current = installation.Library.Sceneries.Single();
|
||||
current.Trainsets[1].ReadyToGo = true;
|
||||
|
||||
Assert.Equal(SceneryVerdict.Leave, watch.Judge(new SceneryChange(current, null)).Verdict);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_scenery_gone_with_no_edits_in_it_is_taken_off()
|
||||
{
|
||||
var (installation, watch, _) = Rig(Folder());
|
||||
var current = installation.Library.Sceneries.Single();
|
||||
|
||||
Assert.Equal(SceneryVerdict.Take, watch.Judge(new SceneryChange(current, null)).Verdict);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_scenery_gone_takes_what_was_picked_with_it()
|
||||
{
|
||||
var files = Folder();
|
||||
var (installation, watch, state) = Rig(files);
|
||||
var current = installation.Library.Sceneries.Single();
|
||||
state.CurrentScenery = current;
|
||||
state.CurrentTrainset = current.Trainsets[0];
|
||||
state.StartingVehicleName = "v1";
|
||||
|
||||
watch.Apply(new SceneryChange(current, null));
|
||||
|
||||
Assert.Empty(installation.Library.Sceneries);
|
||||
Assert.Null(state.CurrentScenery);
|
||||
Assert.Null(state.CurrentTrainset);
|
||||
Assert.Null(state.StartingVehicleName);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,10 @@
|
||||
using StarterNG.Infrastructure.Sceneries;
|
||||
using StarterNG.Domain.Sceneries;
|
||||
using StarterNG.Tests.Fakes;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public class SceneryParserTests
|
||||
{
|
||||
private static SceneryParser Parser(DateTime? now = null) =>
|
||||
new(new FixedClock(now ?? new DateTime(2026, 5, 4, 9, 15, 0)), new StubRandom());
|
||||
|
||||
[Fact]
|
||||
public void Directives_become_the_scenery_headline()
|
||||
{
|
||||
@@ -19,7 +16,7 @@ public class SceneryParserTests
|
||||
//$i images/kato.jpg
|
||||
""";
|
||||
|
||||
var scenery = Parser().Parse("scenery/kato.scn", scn);
|
||||
var scenery = TestScenery.Parser().Parse("scenery/kato.scn", scn);
|
||||
|
||||
Assert.Equal("Śląsk", scenery.Group);
|
||||
Assert.Equal("Osobowy do Katowic", scenery.Name);
|
||||
@@ -32,7 +29,7 @@ public class SceneryParserTests
|
||||
[Fact]
|
||||
public void The_a_directive_marks_a_scenery_archival()
|
||||
{
|
||||
var scenery = Parser().Parse("scenery/old.scn", "//$a\n//$n Stary\n");
|
||||
var scenery = TestScenery.Parser().Parse("scenery/old.scn", "//$a\n//$n Stary\n");
|
||||
|
||||
Assert.True(scenery.Archival);
|
||||
}
|
||||
@@ -46,7 +43,7 @@ public class SceneryParserTests
|
||||
//$f doc/notes.txt
|
||||
""";
|
||||
|
||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
||||
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||
|
||||
Assert.Collection(scenery.Attachments,
|
||||
first =>
|
||||
@@ -76,7 +73,7 @@ public class SceneryParserTests
|
||||
FirstInit
|
||||
""";
|
||||
|
||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
||||
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||
|
||||
var include = Assert.Single(scenery.Includes);
|
||||
Assert.Equal("zima.inc", include.FilePath);
|
||||
@@ -95,7 +92,7 @@ public class SceneryParserTests
|
||||
endoptional
|
||||
""";
|
||||
|
||||
var include = Assert.Single(Parser().Parse("scenery/x.scn", scn).Includes);
|
||||
var include = Assert.Single(TestScenery.Parser().Parse("scenery/x.scn", scn).Includes);
|
||||
|
||||
Assert.Equal(2, include.Kind);
|
||||
Assert.False(include.Selected);
|
||||
@@ -112,7 +109,7 @@ public class SceneryParserTests
|
||||
FirstInit
|
||||
""";
|
||||
|
||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
||||
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||
|
||||
var trainset = Assert.Single(scenery.Trainsets);
|
||||
Assert.True(trainset.Parsed);
|
||||
@@ -126,11 +123,11 @@ public class SceneryParserTests
|
||||
public void Vehicles_outside_a_trainset_are_collected_as_loose()
|
||||
{
|
||||
const string scn = """
|
||||
node -1 -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 enddynamic
|
||||
node -1 -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 0 enddynamic
|
||||
FirstInit
|
||||
""";
|
||||
|
||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
||||
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||
|
||||
var vehicle = Assert.Single(scenery.LooseVehicles);
|
||||
Assert.Equal("wagon_1", vehicle.Name);
|
||||
@@ -142,13 +139,43 @@ public class SceneryParserTests
|
||||
[Fact]
|
||||
public void A_malformed_loose_vehicle_is_reported_and_left_in_place()
|
||||
{
|
||||
const string scn = "node x -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 enddynamic\n";
|
||||
const string scn = "node x -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 0 enddynamic\n";
|
||||
|
||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
||||
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||
|
||||
Assert.Empty(scenery.LooseVehicles);
|
||||
Assert.Equal(new[] { "wagon_1" }, scenery.LooseVehicleNames);
|
||||
Assert.Contains(scenery.Faults, fault => fault.Contains("wagon_1"));
|
||||
var fault = Assert.Single(scenery.Faults);
|
||||
Assert.Equal((SceneryFaultKind.NotANumber, "wagon_1", "x", 1), (fault.Kind, fault.Subject, fault.Token, fault.Line));
|
||||
Assert.Contains("enddynamic", scenery.Template);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Directives_in_a_file_put_in_place_are_not_the_scenery_s_own()
|
||||
{
|
||||
const string included = """
|
||||
//$n Auta
|
||||
//$l Inne
|
||||
//$d Opis aut
|
||||
//$f doc/auta.pdf Auta
|
||||
//$i auta.jpg
|
||||
//$a
|
||||
trainset auto road1 0 50
|
||||
endtrainset
|
||||
""";
|
||||
string text = SceneryIncludes.Inline("//$d Opis scenerii\n//$f doc/mapa.png Mapa\nFirstInit\ninclude auta.scm end\n",
|
||||
name => (name, included));
|
||||
|
||||
var scenery = TestScenery.Parser().Parse("scenery/x.scn", text);
|
||||
|
||||
Assert.Equal("Opis scenerii", scenery.Description);
|
||||
Assert.Equal("doc/mapa.png", Assert.Single(scenery.Attachments).FilePath);
|
||||
Assert.Null(scenery.Name);
|
||||
Assert.Null(scenery.Group);
|
||||
Assert.Null(scenery.ImageName);
|
||||
Assert.False(scenery.Archival);
|
||||
// The file's own trainset is still listed, and its text still written for a start.
|
||||
Assert.Equal("auta.scm", Assert.Single(scenery.Trainsets).SourceFile);
|
||||
Assert.Contains("//$d Opis aut", scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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ź")));
|
||||
}
|
||||
}
|
||||
|
||||
382
StarterNG.Tests/ScenerySyntaxTests.cs
Normal file
382
StarterNG.Tests/ScenerySyntaxTests.cs
Normal file
@@ -0,0 +1,382 @@
|
||||
using StarterNG.Domain.Sceneries;
|
||||
using StarterNG.Tests.Fakes;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public class ScenerySyntaxTests
|
||||
{
|
||||
private static (SceneryFaultKind, string, int, string?, string?) Seen(SceneryFault fault) =>
|
||||
(fault.Kind, fault.File, fault.Line, fault.Subject, fault.Token);
|
||||
|
||||
[Fact]
|
||||
public void A_trainset_commented_out_is_no_trainset()
|
||||
{
|
||||
const string scn = """
|
||||
/* trainset stary tor_1 0 0
|
||||
node -1 0 st dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic
|
||||
endtrainset */
|
||||
// trainset dla gracza:
|
||||
trainset pociag tor_1 10 0
|
||||
node -1 0 ep07-001 dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic//koniec
|
||||
endtrainset
|
||||
""";
|
||||
|
||||
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||
|
||||
var trainset = Assert.Single(scenery.Trainsets);
|
||||
Assert.Equal("pociag", trainset.Name);
|
||||
Assert.Single(trainset.Vehicles);
|
||||
Assert.Empty(scenery.Faults);
|
||||
Assert.Contains("trainset stary", scenery.Template);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_trainset_without_its_end_is_reported_and_the_next_one_read_on_its_own()
|
||||
{
|
||||
const string scn = """
|
||||
FirstInit
|
||||
trainset pierwszy tor_1 10 0
|
||||
node -1 0 a dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic
|
||||
trainset drugi tor_2 10 0
|
||||
node -1 0 b dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic
|
||||
endtrainset
|
||||
""";
|
||||
|
||||
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||
|
||||
Assert.Equal((SceneryFaultKind.TrainsetNotClosed, "x.scn", 2, "pierwszy", null),
|
||||
Seen(Assert.Single(scenery.Faults)));
|
||||
var trainset = Assert.Single(scenery.Trainsets);
|
||||
Assert.Equal("drugi", trainset.Name);
|
||||
Assert.Equal("b", Assert.Single(trainset.Vehicles).Name);
|
||||
// The unended one is the simulator's to read as written.
|
||||
Assert.Contains("trainset pierwszy", scenery.Template);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_vehicle_without_its_end_is_reported_yet_its_trainset_listed()
|
||||
{
|
||||
const string scn = """
|
||||
trainset pociag tor_1 10 0
|
||||
node -1 0 a dynamic PKP/EP07 ep07 ep07 0 headdriver 3
|
||||
node -1 0 b dynamic PKP/111A 111a 111a 0 nobody 3 0 enddynamic
|
||||
endtrainset
|
||||
""";
|
||||
|
||||
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||
|
||||
var fault = Assert.Single(scenery.Faults);
|
||||
Assert.Equal((SceneryFaultKind.VehicleNotClosed, "x.scn", 2, "a", null), Seen(fault));
|
||||
// The simulator goes on to the next enddynamic, taking b for a's.
|
||||
Assert.Equal("b", fault.Lost);
|
||||
Assert.Equal(new[] { "a", "b" }, Assert.Single(scenery.Trainsets).Vehicles.Select(v => v.Name));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_vehicle_with_no_load_count_takes_the_next_one_with_it()
|
||||
{
|
||||
const string scn = """
|
||||
trainset pociag tor_1 10 0
|
||||
node -1 0 a dynamic PKP/EP07 ep07 ep07 0 headdriver 3 enddynamic
|
||||
node -1 0 b dynamic PKP/111A 111a 111a 0 nobody 3 0 enddynamic
|
||||
endtrainset
|
||||
""";
|
||||
|
||||
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||
|
||||
// Its enddynamic read for the count, "node" for a destination, b's for its end.
|
||||
var fault = Assert.Single(scenery.Faults);
|
||||
Assert.Equal((SceneryFaultKind.LoadCountMissing, 2, "a", "b"), (fault.Kind, fault.Line, fault.Subject, fault.Lost));
|
||||
Assert.Equal(new[] { "a", "b" }, Assert.Single(scenery.Trainsets).Vehicles.Select(v => v.Name));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_load_with_no_type_runs_on_into_the_next_trainset()
|
||||
{
|
||||
const string scn = """
|
||||
trainset pierwszy tor_1 10 0
|
||||
node -1 0 a dynamic PKP/412Z 412z 412z 0 nobody 3 40 enddynamic
|
||||
endtrainset
|
||||
trainset drugi tor_2 10 0
|
||||
node -1 0 b dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic
|
||||
node -1 0 c dynamic PKP/111A 111a 111a 0 nobody 3 0 enddynamic
|
||||
endtrainset
|
||||
""";
|
||||
|
||||
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||
|
||||
// The second endtrainset is what ends the first trainset then.
|
||||
var fault = Assert.Single(scenery.Faults);
|
||||
Assert.Equal((SceneryFaultKind.LoadTypeMissing, 2, "a", "40", "endtrainset, trainset drugi, b"),
|
||||
(fault.Kind, fault.Line, fault.Subject, fault.Token, fault.Lost));
|
||||
Assert.Equal(new[] { "pierwszy", "drugi" }, scenery.Trainsets.Select(t => t.Name));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("0 none", "none")]
|
||||
[InlineData("0 pantstate", "pantstate")]
|
||||
[InlineData("0 cel Katowice", null)]
|
||||
[InlineData("3 pantstate", null)]
|
||||
[InlineData("12 wegiel", null)]
|
||||
public void One_word_after_the_load_is_taken_for_a_destination_named_enddynamic(string load, string? word)
|
||||
{
|
||||
string scn = $"""
|
||||
trainset pociag tor_1 10 0
|
||||
node -1 0 a dynamic PKP/EP07 ep07 ep07 0 headdriver 3 {load} enddynamic
|
||||
node -1 0 b dynamic PKP/111A 111a 111a 0 nobody 3 0 enddynamic
|
||||
endtrainset
|
||||
""";
|
||||
|
||||
var faults = TestScenery.Parser().Parse("scenery/x.scn", scn).Faults;
|
||||
|
||||
if (word is null)
|
||||
Assert.Empty(faults);
|
||||
else
|
||||
Assert.Equal((SceneryFaultKind.WordBeforeEnd, word, "b"), (faults.Single().Kind, faults.Single().Token, faults.Single().Lost));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_header_written_twice_is_told_as_such()
|
||||
{
|
||||
const string scn = """
|
||||
trainset baltyk\mod\exe45151 chojnice301 40 0.1
|
||||
//$o -Skład ozdobny
|
||||
trainset baltyk\mod\exe45151 chojnice301 40 0.1
|
||||
node -1 0 a dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic
|
||||
endtrainset
|
||||
""";
|
||||
|
||||
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||
|
||||
Assert.Equal((SceneryFaultKind.TrainsetHeaderRepeated, "x.scn", 1, "baltyk\\mod\\exe45151", null),
|
||||
Seen(Assert.Single(scenery.Faults)));
|
||||
Assert.Single(scenery.Trainsets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_trainset_whose_header_is_swallowed_is_told_at_the_fault_that_began_it()
|
||||
{
|
||||
const string scn = """
|
||||
trainset pierwszy tor_1 10 0
|
||||
node -1 0 a dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic
|
||||
endtrainset
|
||||
//trainset bez_naglowka tor_2 0 0
|
||||
node -1 0 b dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic
|
||||
endtrainset
|
||||
trainset trzeci tor_3 0 0
|
||||
node -1 0 c1 dynamic PKP/EU07 eu07 eu07 0 headdriver 3 0 enddynamic
|
||||
node -1 0 c2 dynamic PKP/111A 111a 111a 0 nobody 3 0 enddynamic
|
||||
node -1 0 c3 dynamic PKP/111A 111a 111a 0 nobody 3 0 enddynamic
|
||||
endtrainset
|
||||
trainset czwarty tor_4 0 0
|
||||
node -1 0 d dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic
|
||||
endtrainset
|
||||
""";
|
||||
|
||||
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||
|
||||
// c2 with no trainset takes c3, and the endtrainset is left alone - all of
|
||||
// it from b, where the fault is.
|
||||
var fault = Assert.Single(scenery.Faults);
|
||||
Assert.Equal((SceneryFaultKind.VehicleOutsideTrainset, 5, "endtrainset, trainset trzeci, c1", "trzeci"),
|
||||
(fault.Kind, fault.Line, fault.Lost, fault.Unmade));
|
||||
Assert.Equal(new[] { "pierwszy", "trzeci", "czwarty" }, scenery.Trainsets.Select(t => t.Name));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Text_where_a_number_belongs_is_named()
|
||||
{
|
||||
const string scn = """
|
||||
trainset pociag tor_1 1O 0
|
||||
node -1 0 a dynamic PKP/EP07 ep07 ep07 0,5 headdriver 3 0 enddynamic
|
||||
endtrainset
|
||||
""";
|
||||
|
||||
var faults = TestScenery.Parser().Parse("scenery/x.scn", scn).Faults;
|
||||
|
||||
Assert.Equal(new[]
|
||||
{
|
||||
(SceneryFaultKind.NotANumber, "x.scn", 1, (string?)"pociag", (string?)"1O"),
|
||||
(SceneryFaultKind.NotANumber, "x.scn", 2, "a", "0,5")
|
||||
}, faults.Select(Seen));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_track_name_split_by_a_space_is_named_whole()
|
||||
{
|
||||
// "Pawonków#L" with its "ó" lost to a space.
|
||||
const string scn = """
|
||||
trainset none Pawonk w#L 200.0 0.0
|
||||
node -1 0 a dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic
|
||||
endtrainset
|
||||
""";
|
||||
|
||||
var faults = TestScenery.Parser().Parse("scenery/x.scn", scn).Faults;
|
||||
|
||||
Assert.Equal((SceneryFaultKind.TrackNameSplit, "x.scn", 1, "none", "Pawonk w#L"), Seen(Assert.Single(faults)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vehicles_left_without_their_trainset_header_are_told_so()
|
||||
{
|
||||
const string scn = """
|
||||
//trainset 2003r\64040 Staniszcze_Male_po#2 -50 0.1
|
||||
node -1 0 EN57-1542ra dynamic pkp\en57_v2 EN57-1542ra EN57RA 0 headdriver 255 0 enddynamic
|
||||
endtrainset
|
||||
node -1 0 auto dynamic road\ford transit transit droga-b 0 headdriver 25.0 0 enddynamic
|
||||
""";
|
||||
|
||||
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||
|
||||
// On no track the simulator has, it drops the vehicle and goes on to the
|
||||
// next enddynamic - past the endtrainset, to the car's.
|
||||
var fault = Assert.Single(scenery.Faults);
|
||||
Assert.Equal((SceneryFaultKind.VehicleOutsideTrainset, 2, "endtrainset, auto"), (fault.Kind, fault.Line, fault.Lost));
|
||||
Assert.Equal("auto", Assert.Single(scenery.LooseVehicles).Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Structure_faults_are_found_where_the_simulator_reports_them()
|
||||
{
|
||||
const string scn = """
|
||||
endtrainset
|
||||
trainset pusty tor_1 10 0
|
||||
endtrainset trainset pociag tor_1 10 0
|
||||
node -1 0 a dynamic PKP/EP07 ep07 ep07 0 headdriver enddynamic
|
||||
node -1 0 b dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic
|
||||
reversed
|
||||
endtrainset
|
||||
include dekoracje.scm // bez end
|
||||
include auta.scm end
|
||||
/* bez końca
|
||||
""";
|
||||
|
||||
var faults = TestScenery.Parser().Parse("scenery/x.scn", scn).Faults.Select(f => (f.Kind, f.Line)).ToList();
|
||||
|
||||
Assert.Equal(new[]
|
||||
{
|
||||
(SceneryFaultKind.StrayEndTrainset, 1),
|
||||
(SceneryFaultKind.VehicleIncomplete, 4),
|
||||
(SceneryFaultKind.ReversedMisplaced, 6),
|
||||
(SceneryFaultKind.IncludeNotClosed, 8),
|
||||
(SceneryFaultKind.UnclosedComment, 10)
|
||||
}, faults);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_trainset_with_no_vehicles_is_listed_to_be_filled_in_the_depot()
|
||||
{
|
||||
const string scn = """
|
||||
trainset td/rozklad tdo_n1734 0.0 0.1
|
||||
//$o Skład gotowy na żeberku.
|
||||
endtrainset
|
||||
include koniec.scm
|
||||
""";
|
||||
|
||||
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn, _ => new IncludeLookup(true));
|
||||
|
||||
Assert.Empty(scenery.Faults);
|
||||
Assert.Equal("td/rozklad", Assert.Single(scenery.Trainsets).Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reversed_may_follow_the_assignments_ahead_of_the_vehicles()
|
||||
{
|
||||
const string scn = """
|
||||
Trainset pociag tor_1 10 0
|
||||
assignment pl "Pociąg" endassignment
|
||||
reversed
|
||||
Node -1 0 a dynamic PKP/EP07 ep07 ep07 0 HeadDriver 3 0 EndDynamic
|
||||
EndTrainset
|
||||
""";
|
||||
|
||||
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
|
||||
|
||||
Assert.Empty(scenery.Faults);
|
||||
var trainset = Assert.Single(scenery.Trainsets);
|
||||
Assert.True(trainset.Reversed);
|
||||
Assert.Equal(StarterNG.Classes.eDriverType.Headdriver, Assert.Single(trainset.Vehicles).DriverType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_fault_in_an_included_file_is_placed_in_that_file()
|
||||
{
|
||||
string text = SceneryIncludes.Inline(
|
||||
"FirstInit\ninclude tomaszewo/auta.scm end\ntrainset zly tor 0\nendtrainset\n",
|
||||
name => (name, "// auta\ntrainset auto road1 0 50\nnode -1 0 a1 dynamic car car car x headdriver 3 0 enddynamic\nendtrainset"));
|
||||
|
||||
var faults = TestScenery.Parser().Parse("scenery/x.scn", text).Faults.Select(Seen);
|
||||
|
||||
Assert.Equal(new[]
|
||||
{
|
||||
(SceneryFaultKind.NotANumber, "tomaszewo/auta.scm", 3, (string?)"a1", (string?)"x"),
|
||||
(SceneryFaultKind.TrainsetIncomplete, "x.scn", 3, "zly", null)
|
||||
}, faults);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_includes_are_reported_but_optional_ones_and_templates_are_not()
|
||||
{
|
||||
const string scn = """
|
||||
include tory.scm end
|
||||
include brak.scm end // komentarz
|
||||
include budynek.inc (p1) 0 0 end
|
||||
include $(p1)_dom.inc end
|
||||
include opcja.scm end //$optional 2,Opcja
|
||||
endoptional
|
||||
""";
|
||||
|
||||
var faults = TestScenery.Parser().Parse("scenery/x.scn", scn, name => new IncludeLookup(name is "tory.scm" or "budynek.inc")).Faults;
|
||||
|
||||
Assert.Equal((SceneryFaultKind.MissingInclude, "x.scn", 2, null, "brak.scm"), Seen(Assert.Single(faults)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_include_named_in_another_case_is_found_as_the_simulator_finds_it()
|
||||
{
|
||||
var files = new InMemoryFileSystem(caseSensitive: true)
|
||||
.WithLegacyFile("game/scenery/x.scn", "FirstInit\ninclude Tomaszewo/Auta.SCM end\n")
|
||||
.WithLegacyFile("game/scenery/tomaszewo/auta.scm",
|
||||
"trainset auto road1 0 50\nnode -1 0 a1 dynamic car car car 0 headdriver 3 0 enddynamic\nendtrainset\n");
|
||||
var repository = TestScenery.Repository(files);
|
||||
|
||||
var scenery = repository.Load("game/scenery/x.scn")!;
|
||||
|
||||
Assert.Empty(scenery.Faults);
|
||||
Assert.Equal("tomaszewo/auta.scm", Assert.Single(scenery.Trainsets).SourceFile);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_include_on_disk_only_in_other_letter_case_is_what_linux_misses()
|
||||
{
|
||||
// The simulator folds the name to lower case and opens it so.
|
||||
var files = new InMemoryFileSystem(caseSensitive: true)
|
||||
.WithLegacyFile("game/scenery/braniewo_szeroki.scn",
|
||||
"include braniewo/base.ctr end\ninclude Braniewo/Tory.SCM end\n")
|
||||
.WithFile("game/scenery/Braniewo/Base.ctr", "")
|
||||
.WithFile("game/scenery/braniewo/tory.scm", "");
|
||||
var repository = TestScenery.Repository(files);
|
||||
|
||||
var faults = repository.Load("game/scenery/braniewo_szeroki.scn")!.Faults;
|
||||
|
||||
Assert.Equal((SceneryFaultKind.IncludeLetterCase, "braniewo_szeroki.scn", 1, "Braniewo/Base.ctr", "braniewo/base.ctr"),
|
||||
Seen(Assert.Single(faults)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public void A_missing_terrain_file_is_no_fault_while_the_scenery_has_its_own_sbt(bool sbt)
|
||||
{
|
||||
var files = new InMemoryFileSystem(caseSensitive: true)
|
||||
.WithLegacyFile("game/scenery/Lk2_Sandbox.scn", "include nmt/nmt100_mazowieckie_ter.scm end\nFirstInit\n");
|
||||
if (sbt)
|
||||
files.WithFile("game/scenery/lk2_sandbox.sbt", "EU07");
|
||||
var repository = TestScenery.Repository(files);
|
||||
|
||||
var scenery = repository.Load("game/scenery/Lk2_Sandbox.scn")!;
|
||||
|
||||
Assert.Equal(sbt, scenery.HasCompanionTimetable);
|
||||
Assert.Equal(sbt ? 0 : 1, scenery.Faults.Count);
|
||||
}
|
||||
}
|
||||
580
StarterNG.Tests/SceneryTrainsetSaveTests.cs
Normal file
580
StarterNG.Tests/SceneryTrainsetSaveTests.cs
Normal file
@@ -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<Trainset>? change = null)
|
||||
{
|
||||
var (files, scenery, save) = Setup(scn);
|
||||
change?.Invoke(scenery.Trainsets[0]);
|
||||
|
||||
Assert.Equal(SceneryTrainsetSaveOutcome.Saved, save.Execute(scenery, scenery.Trainsets[0]).Outcome);
|
||||
|
||||
return Cp1250.GetString(files.ReadAllBytes(Path));
|
||||
}
|
||||
|
||||
private static IEnumerable<string> VehicleNames(string scn) =>
|
||||
TestScenery.Parser().Parse(Path, scn).Trainsets.Single().Vehicles.Select(v => v.Name);
|
||||
|
||||
private static void Edit(Trainset trainset) => trainset.Vehicles[0].Coupling.Flags = 5;
|
||||
|
||||
[Fact]
|
||||
public void A_vehicle_commented_out_stays_in_its_comment()
|
||||
{
|
||||
string scn = $"FirstInit\ntrainset a t 0 0\n/*\n{V1}\n*/\n{V2}\nendtrainset\n" +
|
||||
"node -1 0 poza dynamic d s m none 0 nobody 0 0 enddynamic\n";
|
||||
|
||||
Assert.Equal(scn, SavedText(scn));
|
||||
|
||||
string edited = SavedText(scn, Edit);
|
||||
Assert.Contains($"/*\n{V1}\n*/\n", edited);
|
||||
var scenery = TestScenery.Parser().Parse(Path, edited);
|
||||
Assert.Equal(new[] { "v2" }, scenery.Trainsets.Single().Vehicles.Select(v => v.Name));
|
||||
Assert.Equal(new[] { "poza" }, scenery.LooseVehicleNames);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("trainset a t 0 0\nNode -1 0 v1 dynamic d s m 0 nobody 3 0 enddynamic\nendtrainset\n")]
|
||||
[InlineData("trainset a t 0 0\nNODE -1 0 v1 dynamic d s m 0 nobody 3 0 enddynamic\nendtrainset\n")]
|
||||
[InlineData("trainset a t 0 0 node -1 0 v1 dynamic d s m 0 nobody 3 0 enddynamic\nendtrainset\n")]
|
||||
[InlineData("trainset a t 0 0 /*x*/node -1 0 v1 dynamic d s m 0 nobody 3 0 enddynamic\nendtrainset\n")]
|
||||
public void A_vehicle_is_found_however_it_is_written_and_not_written_twice(string scn)
|
||||
{
|
||||
Assert.Equal(scn, SavedText(scn));
|
||||
|
||||
string edited = SavedText(scn, Edit);
|
||||
var vehicle = Assert.Single(TestScenery.Parser().Parse(Path, edited).Trainsets.Single().Vehicles);
|
||||
Assert.Equal(5, vehicle.Coupling.Flags);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_vehicle_named_as_a_keyword_is_no_keyword()
|
||||
{
|
||||
string scn = "trainset node node 0 0\nnode -1 0 node dynamic d s m 0 nobody 3 0 enddynamic\nendtrainset\n";
|
||||
|
||||
Assert.Equal(scn, SavedText(scn));
|
||||
Assert.Contains("trainset node node 0 0.1\n", SavedText(scn, t => t.ReadyToGo = true));
|
||||
}
|
||||
|
||||
private const string Commented =
|
||||
"trainset a t 0 0\n" +
|
||||
V1 + "\n" +
|
||||
"// o v2\n" +
|
||||
V2 + " // przy v2\n" +
|
||||
"/* o v3 */\n" +
|
||||
V3 + "\n" +
|
||||
"// koniec\n" +
|
||||
"EndTrainset\n";
|
||||
|
||||
[Fact]
|
||||
public void A_save_with_no_change_leaves_the_vehicles_and_comments_as_they_were()
|
||||
{
|
||||
Assert.Equal(Commented, SavedText(Commented));
|
||||
|
||||
// Nor is anything but the speed touched when only the speed changes.
|
||||
Assert.Equal(Commented.Replace("t 0 0\n", "t 0 0.1\n"), SavedText(Commented, t => t.ReadyToGo = true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Comments_stay_where_they_are_when_a_vehicle_is_edited()
|
||||
{
|
||||
string edited = SavedText(Commented, t => t.Vehicles[1].Coupling.Flags = 5);
|
||||
|
||||
Assert.Equal(Commented.Replace(V2, V2.Replace("nobody 3", "nobody 5")), edited);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Comments_stay_when_a_vehicle_is_removed()
|
||||
{
|
||||
Assert.Equal(
|
||||
"trainset a t 0 0\n" + V1 + "\n// o v2\n// przy v2\n/* o v3 */\n" + V3 + "\n// koniec\nEndTrainset\n",
|
||||
SavedText(Commented, t => t.Vehicles.RemoveAt(1)));
|
||||
|
||||
Assert.Equal(
|
||||
"trainset a t 0 0\n// o v2\n// przy v2\n/* o v3 */\n// koniec\nEndTrainset\n",
|
||||
SavedText(Commented, t => t.Vehicles.Clear()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Comments_stay_when_a_vehicle_is_added()
|
||||
{
|
||||
var added = new Dynamic { Name = "nowy", DataFolder = "d", SkinFile = "s", MmdFile = "m" };
|
||||
|
||||
Assert.Equal(
|
||||
Commented.Replace(V3, added.ToTrainsetNode() + V3),
|
||||
SavedText(Commented, t => t.Vehicles.Insert(2, added)));
|
||||
|
||||
Assert.Equal(
|
||||
Commented.Replace("EndTrainset", added.ToTrainsetNode() + "EndTrainset"),
|
||||
SavedText(Commented, t => t.Vehicles.Add(added)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Comments_stay_when_vehicles_change_places_and_a_moved_one_stays_as_written()
|
||||
{
|
||||
string moved = "node 500 0 v3 dynamic d s m /*w srodku*/ 0 nobody 3 0 enddynamic";
|
||||
string scn = Commented.Replace(V3, moved);
|
||||
|
||||
string saved = SavedText(scn, t => t.Vehicles.Reverse());
|
||||
|
||||
Assert.Equal(new[] { "v3", "v2", "v1" }, VehicleNames(saved));
|
||||
foreach (string comment in new[] { "// o v2\n", " // przy v2\n", "/* o v3 */\n", "// koniec\n" })
|
||||
Assert.Contains(comment, saved);
|
||||
Assert.Contains(moved, saved);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("trainset a /*tor*/ t 100 0\nendtrainset\n", "trainset a /*tor*/ t 100 0.1\nendtrainset\n")]
|
||||
[InlineData("trainset a t /*x*/ 100/*y*/ 0.0 reversed\nendtrainset\n", "trainset a t /*x*/ 100/*y*/ 0.1 reversed\nendtrainset\n")]
|
||||
[InlineData("Trainset a // tor\n t\n 100\n 0.0\nendtrainset\n", "Trainset a // tor\n t\n 100\n 0.1\nendtrainset\n")]
|
||||
public void The_speed_is_found_past_a_comment_in_the_header(string scn, string expected)
|
||||
{
|
||||
Assert.Equal(expected, SavedText(scn, t => t.ReadyToGo = true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_optional_include_after_first_init_stays_an_option()
|
||||
{
|
||||
const string scn = "FirstInit\ninclude zima.inc end //$optional 1, Zima\nx\nendoptional\n";
|
||||
string content = $"trainset a t 0 0\n{V1}\nendtrainset\n";
|
||||
|
||||
string text = SceneryIncludes.Inline(scn, name => (name, content));
|
||||
|
||||
Assert.Equal(scn, text);
|
||||
var scenery = TestScenery.Parser().Parse(Path, text);
|
||||
Assert.Equal("Zima", Assert.Single(scenery.Includes).Desc);
|
||||
Assert.Empty(scenery.Trainsets);
|
||||
}
|
||||
|
||||
/// <summary>A disk that gives out on whatever is being written; what it does to the file is <see cref="PhysicalFileSystemTests"/>' business.</summary>
|
||||
public class FailingDisk : DispatchProxy
|
||||
{
|
||||
public InMemoryFileSystem Files = null!;
|
||||
|
||||
protected override object? Invoke(MethodInfo? method, object?[]? args) =>
|
||||
method!.Name == nameof(IFileSystem.WriteAllBytesAtomic)
|
||||
? throw new IOException("the disk gave out")
|
||||
: method.Invoke(Files, args);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public void A_write_that_fails_leaves_the_file_and_its_backup_whole(bool backedUp)
|
||||
{
|
||||
var (files, scenery, _) = Setup(Scn);
|
||||
string backup = Path + SaveSceneryTrainset.BackupSuffix;
|
||||
if (backedUp)
|
||||
files.WithLegacyFile(backup, Scn);
|
||||
var disk = DispatchProxy.Create<IFileSystem, FailingDisk>();
|
||||
((FailingDisk)(object)disk).Files = files;
|
||||
var log = TestLog.Over();
|
||||
var trainset = scenery.Trainsets[0];
|
||||
string before = trainset.RawEntry;
|
||||
trainset.Vehicles.RemoveAt(1);
|
||||
|
||||
var (outcome, detail) = new SaveSceneryTrainset(disk, log).Execute(scenery, trainset);
|
||||
|
||||
Assert.Equal((SceneryTrainsetSaveOutcome.Failed, "the disk gave out"), (outcome, detail));
|
||||
Assert.Equal(Scn, Cp1250.GetString(files.ReadAllBytes(Path)));
|
||||
Assert.Equal(backedUp, files.FileExists(backup));
|
||||
Assert.Equal(new[] { Path }.Concat(backedUp ? new[] { backup } : Array.Empty<string>()),
|
||||
files.GetFiles("scenery"));
|
||||
Assert.Equal(before, trainset.RawEntry);
|
||||
}
|
||||
|
||||
private static (string Scn, SaveSceneryTrainset Save, Scenery Scenery, string Folder) OnDisk(string text)
|
||||
{
|
||||
string folder = Directory.CreateTempSubdirectory("starter-save-").FullName;
|
||||
string scn = System.IO.Path.Combine(folder, "test.scn");
|
||||
var files = new PhysicalFileSystem();
|
||||
files.WriteAllText(scn, text, LegacyText.CodePage1250);
|
||||
var log = TestLog.Over();
|
||||
return (scn, new SaveSceneryTrainset(files, log), TestScenery.Parser().Parse(scn, text), folder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void On_disk_the_new_text_is_written_beside_the_file_and_moved_over_it()
|
||||
{
|
||||
var (scn, save, scenery, folder) = OnDisk(Scn);
|
||||
try
|
||||
{
|
||||
var trainset = scenery.Trainsets[0];
|
||||
trainset.Vehicles.RemoveAt(1);
|
||||
|
||||
// Nothing can be written beside it: the file is not touched either.
|
||||
string temp = scn + PhysicalFileSystem.TempSuffix;
|
||||
Directory.CreateDirectory(temp);
|
||||
Assert.Equal(SceneryTrainsetSaveOutcome.Failed, save.Execute(scenery, trainset).Outcome);
|
||||
Assert.Equal(Scn, Cp1250.GetString(File.ReadAllBytes(scn)));
|
||||
Assert.True(File.Exists(scn + SaveSceneryTrainset.BackupSuffix));
|
||||
|
||||
Directory.Delete(temp);
|
||||
Assert.Equal((SceneryTrainsetSaveOutcome.Saved, (string?)null), save.Execute(scenery, trainset));
|
||||
|
||||
Assert.DoesNotContain("wagon-1", Cp1250.GetString(File.ReadAllBytes(scn)));
|
||||
Assert.Equal(Scn, Cp1250.GetString(File.ReadAllBytes(scn + SaveSceneryTrainset.BackupSuffix)));
|
||||
Assert.Equal(
|
||||
new[] { "test.scn", "test.scn" + SaveSceneryTrainset.BackupSuffix },
|
||||
Directory.GetFiles(folder, "test.scn*").Select(file => System.IO.Path.GetFileName(file)).Order());
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(folder, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void On_disk_a_file_that_cannot_be_replaced_stays_and_nothing_is_left_beside_it()
|
||||
{
|
||||
// Only Windows keeps a file in use from being moved over.
|
||||
if (!OperatingSystem.IsWindows())
|
||||
return;
|
||||
|
||||
var (scn, save, scenery, folder) = OnDisk(Scn);
|
||||
try
|
||||
{
|
||||
scenery.Trainsets[0].Vehicles.RemoveAt(1);
|
||||
|
||||
using (new FileStream(scn, FileMode.Open, FileAccess.Read, FileShare.Read))
|
||||
Assert.Equal(SceneryTrainsetSaveOutcome.Failed, save.Execute(scenery, scenery.Trainsets[0]).Outcome);
|
||||
|
||||
Assert.Equal(Scn, Cp1250.GetString(File.ReadAllBytes(scn)));
|
||||
Assert.False(File.Exists(scn + PhysicalFileSystem.TempSuffix));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(folder, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void On_disk_a_link_stays_a_link_and_its_file_gets_the_text()
|
||||
{
|
||||
var (scn, save, scenery, folder) = OnDisk(Scn);
|
||||
try
|
||||
{
|
||||
string target = scn + ".wlasciwy";
|
||||
File.Move(scn, target);
|
||||
try
|
||||
{
|
||||
File.CreateSymbolicLink(scn, target);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Windows lets only some users make links.
|
||||
return;
|
||||
}
|
||||
scenery.Trainsets[0].Vehicles.RemoveAt(1);
|
||||
|
||||
Assert.Equal(SceneryTrainsetSaveOutcome.Saved, save.Execute(scenery, scenery.Trainsets[0]).Outcome);
|
||||
|
||||
Assert.NotNull(new FileInfo(scn).LinkTarget);
|
||||
Assert.DoesNotContain("wagon-1", Cp1250.GetString(File.ReadAllBytes(target)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(folder, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] Bytes(params object[] parts) =>
|
||||
parts.SelectMany(part => part as byte[] ?? Encoding.ASCII.GetBytes((string)part)).ToArray();
|
||||
|
||||
[Fact]
|
||||
public void Bytes_behind_a_mark_that_are_not_utf8_stay_as_they_were()
|
||||
{
|
||||
byte[] mark = { 0xEF, 0xBB, 0xBF };
|
||||
byte[] polish = { 0xB3, 0xF3, 0xBF }; // "łóż" in code page 1250
|
||||
string trainset = $"trainset a t 0 0\n{V1}\nendtrainset\n";
|
||||
var (files, scenery, save) = Setup(Bytes(mark, "// ", polish, "\n", trainset, "// ", polish, "\n"));
|
||||
scenery.Trainsets[0].Vehicles.Clear();
|
||||
|
||||
Assert.Equal(SceneryTrainsetSaveOutcome.Saved, save.Execute(scenery, scenery.Trainsets[0]).Outcome);
|
||||
|
||||
Assert.Equal(Bytes(mark, "// ", polish, "\ntrainset a t 0 0\nendtrainset\n// ", polish, "\n"), files.ReadAllBytes(Path));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void Every_byte_outside_the_vehicles_stays_as_it_was(bool marked)
|
||||
{
|
||||
byte[] mark = marked ? new byte[] { 0xEF, 0xBB, 0xBF } : Array.Empty<byte>();
|
||||
byte[] all = Enumerable.Range(0, 256).Select(b => (byte)b).ToArray();
|
||||
string trainset = $"trainset a t 0 0\n{V1}\nendtrainset\n";
|
||||
var (files, scenery, save) = Setup(Bytes(mark, trainset, all));
|
||||
scenery.Trainsets[0].Vehicles.Clear();
|
||||
|
||||
Assert.Equal(SceneryTrainsetSaveOutcome.Saved, save.Execute(scenery, scenery.Trainsets[0]).Outcome);
|
||||
|
||||
Assert.Equal(Bytes(mark, "trainset a t 0 0\nendtrainset\n", all), files.ReadAllBytes(Path));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// Its own line ends, in a file with the other kind elsewhere.
|
||||
[InlineData("// x\r\ntrainset a t 0 0\n" + V1 + "\nendtrainset\n// y\r\n")]
|
||||
// Both kinds in the trainset.
|
||||
[InlineData("trainset a t 0 0\r\n// x\n" + V1 + "\r\nendtrainset\r\n")]
|
||||
public void Mixed_line_ends_stay_as_they_were(string scn)
|
||||
{
|
||||
Assert.Equal(scn, SavedText(scn));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void What_the_starter_writes_takes_the_line_ends_of_the_trainset()
|
||||
{
|
||||
string scn = "// x\r\ntrainset a t 0 0\n" + V1 + "\nendtrainset\n// y\r\n";
|
||||
|
||||
Assert.Equal(
|
||||
"// x\r\ntrainset a t 0 0\n" + V2 + "\nendtrainset\n// y\r\n",
|
||||
SavedText(scn, t => t.Vehicles[0].Name = "v2"));
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,10 @@
|
||||
using StarterNG.Domain.Sceneries;
|
||||
using StarterNG.Infrastructure.Sceneries;
|
||||
using StarterNG.Tests.Fakes;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public class SceneryWeatherTests
|
||||
{
|
||||
private static SceneryParser Parser(DateTime now) => new(new FixedClock(now), new StubRandom());
|
||||
|
||||
[Fact]
|
||||
public void Authored_weather_is_read_from_the_config_block()
|
||||
{
|
||||
@@ -21,7 +18,7 @@ public class SceneryWeatherTests
|
||||
time 8:30 0 0 endtime
|
||||
""";
|
||||
|
||||
var weather = Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", scn).Weather;
|
||||
var weather = TestScenery.Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", scn).Weather;
|
||||
|
||||
Assert.True(weather.IsAuthored);
|
||||
Assert.Equal(172, weather.Day);
|
||||
@@ -35,7 +32,7 @@ public class SceneryWeatherTests
|
||||
[Fact]
|
||||
public void A_scenery_without_a_time_starts_at_the_players_clock()
|
||||
{
|
||||
var weather = Parser(new DateTime(2026, 5, 4, 17, 42, 0)).Parse("scenery/x.scn", "//$n Test").Weather;
|
||||
var weather = TestScenery.Parser(new DateTime(2026, 5, 4, 17, 42, 0)).Parse("scenery/x.scn", "//$n Test").Weather;
|
||||
|
||||
Assert.Equal("17:42", weather.ScenarioTimeOverride);
|
||||
Assert.False(weather.IsAuthored);
|
||||
@@ -44,7 +41,7 @@ public class SceneryWeatherTests
|
||||
[Fact]
|
||||
public void Freshly_parsed_weather_counts_as_unchanged()
|
||||
{
|
||||
var weather = Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", "movelight 100\n").Weather;
|
||||
var weather = TestScenery.Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", "movelight 100\n").Weather;
|
||||
|
||||
Assert.False(weather.Changed);
|
||||
}
|
||||
@@ -52,7 +49,7 @@ public class SceneryWeatherTests
|
||||
[Fact]
|
||||
public void Editing_then_restoring_returns_to_the_authored_values()
|
||||
{
|
||||
var weather = Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", "movelight 100\n").Weather;
|
||||
var weather = TestScenery.Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", "movelight 100\n").Weather;
|
||||
|
||||
weather.Day = 5;
|
||||
weather.Temperature = -12;
|
||||
@@ -84,4 +81,168 @@ public class SceneryWeatherTests
|
||||
Assert.Equal(-1.5, weather.EffectiveOvercast(new StubRandom()));
|
||||
Assert.Equal(0.42, new SceneryWeather { Overcast = 0.42 }.EffectiveOvercast(new StubRandom()));
|
||||
}
|
||||
|
||||
private static string Rewritten(string scn) =>
|
||||
SceneryWeatherWriter.Rewrite(scn, new SceneryWeather(), new StubRandom());
|
||||
|
||||
private static string Code(string text) => SceneryCode.Mask(text, out _);
|
||||
|
||||
/// <summary>The block the writer puts at the top of the text, and the rest.</summary>
|
||||
private static (string Written, string Others) Split(string rewritten)
|
||||
{
|
||||
const string end = "endtime\r\n";
|
||||
int at = rewritten.IndexOf(end, StringComparison.Ordinal) + end.Length;
|
||||
return (rewritten[..at], rewritten[at..]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_config_commented_out_stays_off_and_the_one_after_it_is_kept()
|
||||
{
|
||||
// As linia61_osobowy2.scn has them.
|
||||
const string commented = "//config pause yes endconfig //yes: zatrzymanie symulacji zaraz po wczytaniu, jeśli start jako jadący";
|
||||
string scn = $"""
|
||||
sky skydome_clouds.t3d endsky
|
||||
atmo 0 0 0 1000 2000 0.341 0.408 0.471 -1.2 endatmo
|
||||
time 6:43 06:07 21:02 endtime //w Lublińcu 7:30
|
||||
|
||||
{commented}
|
||||
config hiddenevents 1 endconfig
|
||||
|
||||
include linia61/czestochowa.scm end
|
||||
|
||||
""";
|
||||
|
||||
var (written, rest) = Split(Rewritten(scn));
|
||||
|
||||
Assert.Contains("hiddenevents 1\r\n", written);
|
||||
Assert.DoesNotContain("pause", written);
|
||||
Assert.Contains(commented, rest);
|
||||
Assert.Contains("//w Lublińcu 7:30", rest);
|
||||
Assert.Contains("sky skydome_clouds.t3d endsky", rest);
|
||||
Assert.Contains("include linia61/czestochowa.scm end", rest);
|
||||
Assert.DoesNotContain("hiddenevents", rest);
|
||||
foreach (string word in new[] { "atmo", "time", "pause" })
|
||||
Assert.DoesNotContain(word, Code(rest));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_keyword_in_a_comment_does_not_cut_up_to_the_next_block()
|
||||
{
|
||||
const string scn = """
|
||||
// atmo: tu byla mgla
|
||||
include a.scm end
|
||||
{{0}}
|
||||
/* config */
|
||||
include b.scm end
|
||||
{{1}}
|
||||
// time 8 tu nie
|
||||
include c.scm end
|
||||
atmo 0 0 0 10 2000 0 0 0 0 endatmo
|
||||
config hiddenevents 1 endconfig
|
||||
time 8:30 0 0 endtime
|
||||
""";
|
||||
|
||||
var (written, rest) = Split(Rewritten(scn));
|
||||
|
||||
Assert.Contains("hiddenevents 1", written);
|
||||
foreach (string kept in new[]
|
||||
{
|
||||
"// atmo: tu byla mgla", "include a.scm end", "{{0}}", "/* config */",
|
||||
"include b.scm end", "{{1}}", "// time 8 tu nie", "include c.scm end"
|
||||
})
|
||||
Assert.Contains(kept, rest);
|
||||
foreach (string gone in new[] { "endatmo", "endconfig", "endtime" })
|
||||
Assert.DoesNotContain(gone, rest);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/* config a 1 endconfig */")]
|
||||
[InlineData("// config a 1 endconfig")]
|
||||
[InlineData("/* atmo 0 0 0 1 2 0 0 0 0 endatmo */")]
|
||||
[InlineData("// atmo 0 0 0 1 2 0 0 0 0 endatmo")]
|
||||
[InlineData("/* time 8:30 0 0 endtime */")]
|
||||
[InlineData("// time 8:30 0 0 endtime")]
|
||||
[InlineData("// movelight 100")]
|
||||
[InlineData("/*\nmovelight 100\n*/")]
|
||||
[InlineData("// scenario.weather.temperature 5")]
|
||||
[InlineData("/* scenario.time.override 6:00 */")]
|
||||
public void What_is_commented_out_stays_as_it_is(string comment)
|
||||
{
|
||||
var (written, rest) = Split(Rewritten($"x\n{comment}\ny\n"));
|
||||
|
||||
Assert.Equal($"x\n{comment}\ny\n", rest);
|
||||
Assert.DoesNotContain("a 1", written);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_comment_in_a_block_stays_when_the_block_goes()
|
||||
{
|
||||
const string scn = "atmo 0 0 0 // mgla\n10 2000 0 0 0 /* zachmurzenie */ 0 endatmo\n" +
|
||||
"config\n// uwaga\nmovelight 5 // lato\nhiddenevents 1\nendconfig\ninclude a.scm end\n";
|
||||
|
||||
var (written, rest) = Split(Rewritten(scn));
|
||||
|
||||
foreach (string comment in new[] { "// mgla\n", "/* zachmurzenie */", "// uwaga\n", "// lato\n" })
|
||||
Assert.Contains(comment, rest);
|
||||
foreach (string word in new[] { "atmo", "2000", "config", "movelight", "hiddenevents" })
|
||||
Assert.DoesNotContain(word, Code(rest));
|
||||
Assert.Contains("hiddenevents 1\r\n", written);
|
||||
Assert.Contains("include a.scm end", rest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Settings_of_every_config_are_carried_and_only_the_starters_own_are_replaced()
|
||||
{
|
||||
const string scn = "config movelight 5 hiddenevents 1 endconfig\nCONFIG\nscenario.weather.temperature 3 pause no\nENDCONFIG\n";
|
||||
|
||||
var (written, rest) = Split(Rewritten(scn));
|
||||
|
||||
Assert.Contains("hiddenevents 1\r\n", written);
|
||||
Assert.Contains("pause no\r\n", written);
|
||||
Assert.DoesNotContain("movelight 5", written);
|
||||
Assert.DoesNotContain("temperature 3", written);
|
||||
Assert.Equal("", rest.Trim());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_word_in_an_include_s_path_is_no_keyword()
|
||||
{
|
||||
const string scn = """
|
||||
include atmo/lasy.scm end
|
||||
include time/8.scm end
|
||||
include config/a.scm end
|
||||
atmo 0 0 0 10 2000 0 0 0 0 endatmo
|
||||
config hiddenevents 1 endconfig
|
||||
""";
|
||||
|
||||
var (_, rest) = Split(Rewritten(scn));
|
||||
|
||||
Assert.Contains("include atmo/lasy.scm end", rest);
|
||||
Assert.Contains("include time/8.scm end", rest);
|
||||
Assert.Contains("include config/a.scm end", rest);
|
||||
Assert.DoesNotContain("endatmo", rest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Weather_in_comments_is_not_read()
|
||||
{
|
||||
const string scn = """
|
||||
// movelight 7
|
||||
// scenario.weather.temperature 40
|
||||
// scenario.time.override 3:33
|
||||
/* time 4:44 0 0 endtime */
|
||||
// atmo 5 mgla w l61
|
||||
include l61/a.scm end
|
||||
movelight 100
|
||||
atmo 0 0 0 50 900 0 0 0 0.7 endatmo
|
||||
""";
|
||||
|
||||
var weather = TestScenery.Parser(new DateTime(2026, 5, 4, 17, 42, 0)).Parse("scenery/x.scn", scn).Weather;
|
||||
|
||||
Assert.Equal(100, weather.Day);
|
||||
Assert.NotEqual(40, weather.Temperature);
|
||||
Assert.Equal("17:42", weather.ScenarioTimeOverride);
|
||||
Assert.Equal(50, weather.FogStart);
|
||||
Assert.Equal(0.7, weather.Overcast);
|
||||
}
|
||||
}
|
||||
|
||||
154
StarterNG.Tests/SettingReachTests.cs
Normal file
154
StarterNG.Tests/SettingReachTests.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,8 +32,39 @@ public class SimulatorAftermathTests
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithFile(TestInstallation.At("log.txt"), "first\r\nsecond\r\n\r\nthird\r\n"));
|
||||
|
||||
var tail = new SimulatorAftermath(installation.Files, installation.Paths, installation.Log).SimulatorLogTail(2);
|
||||
var started = installation.Files.LastWriteTimeUtc.AddMinutes(-1);
|
||||
|
||||
var tail = new SimulatorAftermath(installation.Files, installation.Paths, installation.Log)
|
||||
.SimulatorLogTail(2, started);
|
||||
|
||||
Assert.Equal(new[] { "second", "third" }, tail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_log_not_written_since_the_start_says_nothing_of_this_run()
|
||||
{
|
||||
// Dead before it opened its log: what is there is the session before.
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithFile(TestInstallation.At("log.txt"), "yesterday's error\r\n"));
|
||||
var started = installation.Files.LastWriteTimeUtc.AddMinutes(10);
|
||||
|
||||
var tail = new SimulatorAftermath(installation.Files, installation.Paths, installation.Log)
|
||||
.SimulatorLogTail(2, started);
|
||||
|
||||
Assert.Empty(tail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_log_written_a_moment_before_the_start_was_taken_counts()
|
||||
{
|
||||
// The start is taken just after the launch, as for the crash dumps.
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithFile(TestInstallation.At("log.txt"), "opening\r\n"));
|
||||
var started = installation.Files.LastWriteTimeUtc.AddSeconds(3);
|
||||
|
||||
var tail = new SimulatorAftermath(installation.Files, installation.Paths, installation.Log)
|
||||
.SimulatorLogTail(2, started);
|
||||
|
||||
Assert.Equal(new[] { "opening" }, tail);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,11 +55,28 @@ public class SimulatorExitTests
|
||||
? ("cmd", new[] { "/c", "echo boom 1>&2 & exit 3" })
|
||||
: ("/bin/sh", new[] { "-c", "echo boom >&2; exit 3" });
|
||||
|
||||
var process = launcher.Start(shell, script, null, captureErrors: true, out string? error);
|
||||
var process = launcher.Start(shell, script, null, new Dictionary<string, string>(), captureErrors: true,
|
||||
out string? error);
|
||||
Assert.Null(error);
|
||||
await process!.WaitForExitAsync();
|
||||
|
||||
Assert.Equal(3, process.ExitCode);
|
||||
Assert.Equal("boom", Assert.Single(process.ErrorTail).Trim());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_real_process_gets_the_environment_it_was_given()
|
||||
{
|
||||
var launcher = new SystemProcessLauncher(new TestInstallation().Log);
|
||||
var (shell, script) = OperatingSystem.IsWindows()
|
||||
? ("cmd", new[] { "/c", "echo %__NV_PRIME_RENDER_OFFLOAD% 1>&2" })
|
||||
: ("/bin/sh", new[] { "-c", "echo $__NV_PRIME_RENDER_OFFLOAD >&2" });
|
||||
|
||||
var process = launcher.Start(shell, script, null,
|
||||
new Dictionary<string, string> { ["__NV_PRIME_RENDER_OFFLOAD"] = "1" },
|
||||
captureErrors: true, out _);
|
||||
await process!.WaitForExitAsync();
|
||||
|
||||
Assert.Equal("1", Assert.Single(process.ErrorTail).Trim());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using StarterNG.Application;
|
||||
using StarterNG.Application.Abstractions;
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Infrastructure;
|
||||
using StarterNG.Infrastructure.Sceneries;
|
||||
using StarterNG.Tests.Fakes;
|
||||
|
||||
@@ -23,6 +25,58 @@ public class StartSimulationTests
|
||||
Assert.Equal(installation.Paths.Root, launcher.WorkingDirectory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void With_the_physics_log_on_the_launch_is_noted_for_the_tapes()
|
||||
{
|
||||
var (start, launcher, installation) = Rig(files => files.WithExecutable(Build), sceneryClock: "07:52");
|
||||
installation.SettingsStore.Settings.PhysicsLog = true;
|
||||
|
||||
start.Execute(freeFly: false, saveSettings: false);
|
||||
|
||||
// Under the name the simulator is given, which is the name it logs under.
|
||||
string driven = launcher.Arguments![launcher.Arguments.ToList().IndexOf("-v") + 1];
|
||||
string noted = installation.Files.ReadAllText(TestInstallation.At("physicslog", "starter-launch.txt"));
|
||||
Assert.Contains("clock\t07:52", noted);
|
||||
Assert.Contains($"vehicle\t{driven}\tPKP/EP07\tep07.mmd", noted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_the_physics_log_nothing_is_noted()
|
||||
{
|
||||
var (start, _, installation) = Rig(files => files.WithExecutable(Build));
|
||||
|
||||
start.Execute(freeFly: false, saveSettings: false);
|
||||
|
||||
Assert.False(installation.Files.FileExists(TestInstallation.At("physicslog", "starter-launch.txt")));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("v2")]
|
||||
[InlineData("V2")]
|
||||
[InlineData("gone")]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
public void A_crew_is_there_to_start_with_when_the_pick_has_one_or_names_nothing(string? preferred) =>
|
||||
Assert.True(StartSimulation.HasCrewToStart(WagonAndLocomotive, preferred));
|
||||
|
||||
[Fact]
|
||||
public void A_pick_with_nobody_in_it_says_no_crew() =>
|
||||
Assert.False(StartSimulation.HasCrewToStart(WagonAndLocomotive, "v1"));
|
||||
|
||||
[Fact]
|
||||
public void A_consist_nobody_sits_in_has_no_crew_to_start()
|
||||
{
|
||||
var empty = new Trainset("trainset a tor 0 0\nnode -1 0 v1 dynamic d s m 0 nobody 3 0 enddynamic\nendtrainset");
|
||||
|
||||
Assert.False(StartSimulation.HasCrewToStart(empty, null));
|
||||
}
|
||||
|
||||
private static Trainset WagonAndLocomotive => new(
|
||||
"trainset a tor 0 0\n" +
|
||||
"node -1 0 v1 dynamic d s m 0 nobody 3 0 enddynamic\n" +
|
||||
"node -1 0 v2 dynamic d s m 0 headdriver 3 0 enddynamic\n" +
|
||||
"endtrainset");
|
||||
|
||||
[Fact]
|
||||
public void A_build_for_the_other_system_is_still_launched()
|
||||
{
|
||||
@@ -54,6 +108,49 @@ public class StartSimulationTests
|
||||
Assert.Equal(installation.Paths.Root, launcher.WorkingDirectory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Wine_is_pointed_at_the_settings_the_starter_saves()
|
||||
{
|
||||
string wine = Path.Combine("/usr/bin", "wine");
|
||||
string exe = Path.Combine(Path.GetTempPath(), "maszyna", "eu07.exe");
|
||||
const string roaming = "/home/kolejarz/.wine/drive_c/users/kolejarz/AppData/Roaming";
|
||||
var (start, _, installation) = Rig(files => files
|
||||
.WithExecutable(wine)
|
||||
.WithFile(exe, "MZ\u0090\u0000")
|
||||
.WithDirectory(roaming));
|
||||
installation.Environment.With("PATH", "/usr/bin").With("HOME", "/home/kolejarz").With("USER", "kolejarz");
|
||||
installation.SettingsStore.Settings.ExecutablePath = exe;
|
||||
|
||||
start.Execute(freeFly: true, saveSettings: false);
|
||||
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine("/home/kolejarz", ".config", "MaSzyna")),
|
||||
installation.Files.LinkTarget(Path.Combine(roaming, "MaSzyna")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_native_build_leaves_the_wine_prefix_alone()
|
||||
{
|
||||
const string roaming = "/home/kolejarz/.wine/drive_c/users/kolejarz/AppData/Roaming";
|
||||
var (start, _, installation) = Rig(files => files.WithExecutable(Build).WithDirectory(roaming));
|
||||
installation.Environment.With("HOME", "/home/kolejarz").With("USER", "kolejarz");
|
||||
|
||||
start.Execute(freeFly: true, saveSettings: false);
|
||||
|
||||
Assert.Null(installation.Files.LinkTarget(roaming + "/MaSzyna"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(3)]
|
||||
public void Any_renderer_is_left_for_the_simulator_to_take(int renderer)
|
||||
{
|
||||
var (start, launcher, installation) = Rig(files => files.WithExecutable(Build, "\u007fELF binary"));
|
||||
installation.SettingsStore.Settings.RenderEngine = renderer;
|
||||
|
||||
Assert.Equal(SimulationStartOutcome.Started, start.Execute(freeFly: true, saveSettings: false).Outcome);
|
||||
Assert.NotNull(launcher.Executable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_error_stream_is_kept_only_while_the_starter_stays_to_read_it()
|
||||
{
|
||||
@@ -80,6 +177,28 @@ public class StartSimulationTests
|
||||
Assert.False(installation.Files.FileExists(TestInstallation.At("scenery", "$td.scn")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_new_scenery_opens_at_the_wizard_even_with_a_scenery_selected()
|
||||
{
|
||||
var (start, launcher, _) = Rig(files => files.WithExecutable(Build));
|
||||
|
||||
var result = start.ExecuteNewSceneryEditor();
|
||||
|
||||
Assert.Equal(SimulationStartOutcome.Started, result.Outcome);
|
||||
Assert.Equal(new[] { "-edit" }, launcher.Arguments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void With_no_scenery_selected_the_editor_opens_at_its_wizard()
|
||||
{
|
||||
var (start, launcher, _) = Rig(files => files.WithExecutable(Build), state => state.CurrentScenery = null);
|
||||
|
||||
var result = start.ExecuteEditor();
|
||||
|
||||
Assert.Equal(SimulationStartOutcome.Started, result.Outcome);
|
||||
Assert.Equal(new[] { "-edit" }, launcher.Arguments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_file_that_cannot_be_run_is_not()
|
||||
{
|
||||
@@ -92,8 +211,134 @@ public class StartSimulationTests
|
||||
Assert.Null(launcher.Executable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_scenery_with_no_consist_picked_starts_with_the_free_camera()
|
||||
{
|
||||
var (start, launcher, _) = Rig(files => files.WithExecutable(Build), state => state.CurrentTrainset = null);
|
||||
|
||||
var result = start.Execute(freeFly: false, saveSettings: false);
|
||||
|
||||
Assert.Equal(SimulationStartOutcome.Started, result.Outcome);
|
||||
Assert.Equal(new[] { "-s", "$td.scn" }, launcher.Arguments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_copy_for_a_start_is_named_in_lower_case_as_the_simulator_looks_for_it()
|
||||
{
|
||||
var (start, launcher, _) = Rig(files => files.WithExecutable(Build), sceneryFile: "Glinojeck.scn");
|
||||
|
||||
start.Execute(freeFly: true, saveSettings: false);
|
||||
|
||||
Assert.Equal(new[] { "-s", "$glinojeck.scn" }, launcher.Arguments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_free_camera_start_without_a_consist_notes_no_driven_vehicle_for_the_tapes()
|
||||
{
|
||||
var (start, launcher, installation) = Rig(files => files.WithExecutable(Build),
|
||||
state => state.CurrentTrainset = null);
|
||||
installation.SettingsStore.Settings.PhysicsLog = true;
|
||||
|
||||
start.Execute(freeFly: true, saveSettings: false);
|
||||
|
||||
Assert.NotNull(launcher.Executable);
|
||||
var note = StarterNG.Domain.Tapes.TapeLaunch.Parse(
|
||||
installation.Files.ReadAllText(TestInstallation.At("physicslog", "starter-launch.txt")))!;
|
||||
Assert.Null(note.Driven);
|
||||
Assert.Empty(note.Consist!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_start_in_a_vehicle_notes_it_and_its_consist_for_the_tapes()
|
||||
{
|
||||
var (start, launcher, installation) = Rig(files => files.WithExecutable(Build));
|
||||
installation.SettingsStore.Settings.PhysicsLog = true;
|
||||
|
||||
start.Execute(freeFly: false, saveSettings: false);
|
||||
|
||||
// Under the name the simulator is given, which is the name it logs under.
|
||||
string given = launcher.Arguments![launcher.Arguments.ToList().IndexOf("-v") + 1];
|
||||
var note = StarterNG.Domain.Tapes.TapeLaunch.Parse(
|
||||
installation.Files.ReadAllText(TestInstallation.At("physicslog", "starter-launch.txt")))!;
|
||||
Assert.Equal(given, note.Driven);
|
||||
Assert.Equal(new[] { given }, note.Consist);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_start_notes_the_scenario_and_the_train_of_the_timetable_for_the_tapes()
|
||||
{
|
||||
var (start, _, installation) = Rig(files =>
|
||||
{
|
||||
files.WithExecutable(Build);
|
||||
files.WithFile(TestInstallation.At("scenery", "pociag.txt"),
|
||||
"[ Rodzaj i numer pociagu | MPJ15120 ]\r\n");
|
||||
});
|
||||
installation.SettingsStore.Settings.PhysicsLog = true;
|
||||
|
||||
start.Execute(freeFly: false, saveSettings: false);
|
||||
|
||||
var note = StarterNG.Domain.Tapes.TapeLaunch.Parse(
|
||||
installation.Files.ReadAllText(TestInstallation.At("physicslog", "starter-launch.txt")))!;
|
||||
Assert.Equal("Test", note.SceneryName);
|
||||
Assert.Equal("MPJ15120", note.Train);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_free_camera_start_notes_no_train_for_the_tapes()
|
||||
{
|
||||
var (start, _, installation) = Rig(files => files.WithExecutable(Build));
|
||||
installation.SettingsStore.Settings.PhysicsLog = true;
|
||||
|
||||
start.Execute(freeFly: true, saveSettings: false);
|
||||
|
||||
var note = StarterNG.Domain.Tapes.TapeLaunch.Parse(
|
||||
installation.Files.ReadAllText(TestInstallation.At("physicslog", "starter-launch.txt")))!;
|
||||
Assert.Null(note.Train);
|
||||
}
|
||||
|
||||
private static void HybridLaptop(InMemoryFileSystem files)
|
||||
{
|
||||
files.WithFile("/sys/class/drm/card0/device/uevent",
|
||||
"DRIVER=nvidia\nPCI_ID=10DE:2D19\nPCI_SLOT_NAME=0000:01:00.0\n")
|
||||
.WithFile("/sys/class/drm/card0/device/boot_vga", "0\n")
|
||||
.WithFile("/sys/class/drm/card1/device/uevent",
|
||||
"DRIVER=amdgpu\nPCI_ID=1002:1900\nPCI_SLOT_NAME=0000:65:00.0\n")
|
||||
.WithFile("/sys/class/drm/card1/device/boot_vga", "1\n")
|
||||
.WithFile("/sys/class/drm/card1-eDP-1/status", "connected\n");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void On_a_laptop_with_two_cards_the_simulator_goes_to_the_discrete_one()
|
||||
{
|
||||
var (start, launcher, _) = Rig(files =>
|
||||
{
|
||||
files.WithExecutable(Build);
|
||||
HybridLaptop(files);
|
||||
});
|
||||
|
||||
start.Execute(freeFly: true, saveSettings: false);
|
||||
|
||||
Assert.Equal("1", launcher.Environment!["__NV_PRIME_RENDER_OFFLOAD"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Left_to_the_system_the_simulator_gets_no_card_of_the_starters_choosing()
|
||||
{
|
||||
var (start, launcher, installation) = Rig(files =>
|
||||
{
|
||||
files.WithExecutable(Build);
|
||||
HybridLaptop(files);
|
||||
});
|
||||
installation.SettingsStore.Settings.Gpu = StarterNG.Domain.Settings.GpuPreference.System;
|
||||
|
||||
start.Execute(freeFly: true, saveSettings: false);
|
||||
|
||||
Assert.Empty(launcher.Environment!);
|
||||
}
|
||||
|
||||
private static (StartSimulation Start, RecordingLauncher Launcher, TestInstallation Installation) Rig(
|
||||
Action<InMemoryFileSystem> addSimulator)
|
||||
Action<InMemoryFileSystem> addSimulator, Action<AppState>? arrange = null, string sceneryFile = "td.scn",
|
||||
string sceneryClock = "10:30")
|
||||
{
|
||||
var installation = new TestInstallation();
|
||||
addSimulator(installation.Files);
|
||||
@@ -103,7 +348,7 @@ public class StartSimulationTests
|
||||
settings.ExecutablePath = Build;
|
||||
|
||||
var scenery = new SceneryParser(installation.Clock, installation.Random).Parse(
|
||||
TestInstallation.At("scenery", "td.scn"),
|
||||
TestInstallation.At("scenery", sceneryFile),
|
||||
"""
|
||||
//$n Test
|
||||
trainset pociag tor_1 10 0
|
||||
@@ -111,12 +356,20 @@ public class StartSimulationTests
|
||||
endtrainset
|
||||
FirstInit
|
||||
""");
|
||||
scenery.Weather.ScenarioTimeOverride = sceneryClock;
|
||||
var state = new AppState { CurrentScenery = scenery, CurrentTrainset = scenery.Trainsets[0] };
|
||||
arrange?.Invoke(state);
|
||||
|
||||
var launcher = new RecordingLauncher();
|
||||
var start = new StartSimulation(state, installation.SettingsStore, installation.Library.Vehicles,
|
||||
installation.Executables, installation.Files, installation.Paths, launcher,
|
||||
installation.Random, installation.Log);
|
||||
installation.Executables,
|
||||
installation.Files, installation.Paths, launcher,
|
||||
installation.Random, installation.Log,
|
||||
new LinuxGpus(installation.Files, installation.Environment),
|
||||
installation.Environment,
|
||||
new WineConfigLink(installation.Files, installation.Environment,
|
||||
installation.SettingsPaths, installation.Log),
|
||||
installation.Tapes);
|
||||
return (start, launcher, installation);
|
||||
}
|
||||
|
||||
@@ -130,9 +383,13 @@ public class StartSimulationTests
|
||||
|
||||
public bool? CapturedErrors { get; private set; }
|
||||
|
||||
public IReadOnlyDictionary<string, string>? Environment { get; private set; }
|
||||
|
||||
public IProcessHandle? Start(string executablePath, IReadOnlyList<string> arguments, string? workingDirectory,
|
||||
bool captureErrors, out string? error)
|
||||
IReadOnlyDictionary<string, string> environment, bool captureErrors,
|
||||
out string? error)
|
||||
{
|
||||
Environment = environment;
|
||||
Executable = executablePath;
|
||||
CapturedErrors = captureErrors;
|
||||
Arguments = arguments;
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StarterNG\StarterNG.csproj" />
|
||||
<ProjectReference Include="..\Eu07.BlackBox\Eu07.BlackBox.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
42
StarterNG.Tests/StepsTests.cs
Normal file
42
StarterNG.Tests/StepsTests.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using StarterNG.Domain.Settings;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public class StepsTests
|
||||
{
|
||||
[Fact]
|
||||
public void A_value_among_the_steps_leaves_them_as_they_are()
|
||||
{
|
||||
Assert.Same(Steps.FrameRateLimits, Steps.With(Steps.FrameRateLimits, 144));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_value_between_the_steps_joins_them_in_order()
|
||||
{
|
||||
var steps = Steps.With(Steps.FrameRateLimits, 58);
|
||||
|
||||
Assert.Equal(Steps.FrameRateLimits.Length + 1, steps.Count);
|
||||
Assert.Equal(new double[] { 50, 58, 60 }, steps.Skip(Steps.IndexOf(steps, 50)).Take(3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Values_past_either_end_join_at_that_end()
|
||||
{
|
||||
Assert.Equal(0.5, Steps.With(Steps.Anisotropy, 0.5)[0]);
|
||||
Assert.Equal(500, Steps.With(Steps.FrameRateLimits, 500)[^1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_frame_rate_limits_start_with_none()
|
||||
{
|
||||
Assert.Equal(new double[] { 0, 10, 20 }, Steps.With(Steps.FrameRateLimits, 10).Take(3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_step_is_found_from_its_rounding_in_the_file()
|
||||
{
|
||||
// 1/300 is written as 0.003333.
|
||||
Assert.Equal(0, Steps.IndexOf(Steps.ReflectionUpdates, 0.003333));
|
||||
Assert.Equal(-1, Steps.IndexOf(Steps.ReflectionUpdates, 0.0033));
|
||||
}
|
||||
}
|
||||
485
StarterNG.Tests/TapeLaunchTests.cs
Normal file
485
StarterNG.Tests/TapeLaunchTests.cs
Normal file
@@ -0,0 +1,485 @@
|
||||
using StarterNG.Application;
|
||||
using StarterNG.Domain.Tapes;
|
||||
using StarterNG.Tests.Fakes;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public class TapeLaunchTests
|
||||
{
|
||||
private static readonly DateTime Launched = new(2025, 12, 31, 23, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public void A_launch_is_written_and_read_back_with_names_that_hold_spaces()
|
||||
{
|
||||
var launch = new TapeLaunch(Launched, "td.scn", new TimeSpan(7, 52, 0), new[]
|
||||
{
|
||||
new TapeVehicle("EP07-424-HIST", "pkp/ep07_v1", "ep07-424"),
|
||||
new TapeVehicle("wagon 1", "pkp/111a_v2", "111a")
|
||||
});
|
||||
|
||||
var back = TapeLaunch.Parse(launch.Format())!;
|
||||
|
||||
Assert.Equal(Launched, back.LaunchedUtc);
|
||||
Assert.Equal("td.scn", back.Scenery);
|
||||
Assert.Equal(new TimeSpan(7, 52, 0), back.StartClock);
|
||||
Assert.Equal("pkp/ep07_v1", back.Find("ep07-424-hist")!.DataFolder);
|
||||
Assert.Equal("111a", back.Find("wagon 1")!.TypeName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_driven_vehicle_and_the_consist_are_written_and_read_back()
|
||||
{
|
||||
var launch = new TapeLaunch(Launched, "td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(),
|
||||
"EP07 424", new[] { "EP07 424", "wagon 1" });
|
||||
|
||||
var back = TapeLaunch.Parse(launch.Format())!;
|
||||
|
||||
Assert.Equal(TapeRole.Driven, back.RoleOf("ep07 424"));
|
||||
Assert.Equal(TapeRole.OwnConsist, back.RoleOf("WAGON 1"));
|
||||
Assert.Equal(TapeRole.Other, back.RoleOf("et22-100"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_note_without_the_driven_vehicle_tells_nothing_of_it()
|
||||
{
|
||||
var back = TapeLaunch.Parse("launched\t2025-12-31T23:00:00.0000000Z\nscenery\ttd.scn\nclock\t10:30\n")!;
|
||||
|
||||
Assert.Null(back.Driven);
|
||||
Assert.Equal(TapeRole.Other, back.RoleOf("ep07-424"));
|
||||
Assert.Null(back.SceneryName);
|
||||
Assert.Null(back.Train);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_scenario_and_the_train_are_written_and_read_back()
|
||||
{
|
||||
var launch = new TapeLaunch(Launched, "td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(),
|
||||
SceneryName: "Tor doświadczalny", Train: "EIE 1531");
|
||||
|
||||
var back = TapeLaunch.Parse(launch.Format())!;
|
||||
|
||||
Assert.Equal("Tor doświadczalny", back.SceneryName);
|
||||
Assert.Equal("EIE 1531", back.Train);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_tape_of_the_last_launch_gets_its_scenario_and_train()
|
||||
{
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithFile(TestInstallation.At("physicslog", "EP07-424.dat"), "t"));
|
||||
installation.Clock.Now = Launched;
|
||||
var tapes = installation.Tapes;
|
||||
|
||||
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "EP07-424", new[] { "EP07-424" },
|
||||
"Tor doświadczalny", "EIE1531");
|
||||
var context = tapes.ContextOf(tapes.List().Single());
|
||||
|
||||
Assert.Equal("Tor doświadczalny", context.Scenery);
|
||||
Assert.Equal("EIE1531", context.Train);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_tape_of_someone_elses_vehicle_does_not_get_the_train_of_the_player()
|
||||
{
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithFile(TestInstallation.At("physicslog", "ET22-100.dat"), "t"));
|
||||
installation.Clock.Now = Launched;
|
||||
var tapes = installation.Tapes;
|
||||
|
||||
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "EP07-424", new[] { "EP07-424" },
|
||||
"Tor doświadczalny", "EIE1531");
|
||||
var context = tapes.ContextOf(tapes.List().Single());
|
||||
|
||||
Assert.Equal("Tor doświadczalny", context.Scenery);
|
||||
Assert.Null(context.Train);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_scenario_without_a_name_goes_by_its_file()
|
||||
{
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithFile(TestInstallation.At("physicslog", "EP07-424.dat"), "t"));
|
||||
installation.Clock.Now = Launched;
|
||||
var tapes = installation.Tapes;
|
||||
|
||||
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>());
|
||||
|
||||
Assert.Equal("td.scn", tapes.ContextOf(tapes.List().Single()).Scenery);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("[ Rodzaj i numer pociagu | EIE1531 \t ] \r\n[ Kategoria | IC ]\r\n", "EIE1531")]
|
||||
[InlineData(" ____\r\n[ Rodzaj i numer pociągu | LPE 782 ]\r\n", "LPE 782")]
|
||||
[InlineData("[ Kategoria | IC ]\r\n", null)]
|
||||
[InlineData("", null)]
|
||||
public void The_train_number_comes_from_the_heading_of_its_timetable(string timetable, string? number)
|
||||
{
|
||||
var files = new InMemoryFileSystem().WithFile(TestInstallation.At("scenery", "baltyk", "eie1531.txt"), timetable);
|
||||
|
||||
Assert.Equal(number, new StarterNG.Infrastructure.Sceneries.TimetableLocator(files)
|
||||
.TrainNumber(new StarterNG.Domain.Sceneries.Scenery(TestInstallation.At("scenery", "td.scn"), ""),
|
||||
"baltyk/eie1531"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("none")]
|
||||
[InlineData("NONE")]
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
public void A_trainset_without_a_timetable_has_no_train_number(string? name) =>
|
||||
Assert.Null(new StarterNG.Infrastructure.Sceneries.TimetableLocator(new InMemoryFileSystem())
|
||||
.TrainNumber(new StarterNG.Domain.Sceneries.Scenery(TestInstallation.At("scenery", "td.scn"), ""), name));
|
||||
|
||||
[Fact]
|
||||
public void A_timetable_that_is_not_there_gives_no_train_number() =>
|
||||
Assert.Null(new StarterNG.Infrastructure.Sceneries.TimetableLocator(new InMemoryFileSystem())
|
||||
.TrainNumber(new StarterNG.Domain.Sceneries.Scenery(TestInstallation.At("scenery", "td.scn"), ""),
|
||||
@"baltyk\mod\eie1531"));
|
||||
|
||||
[Fact]
|
||||
public void The_driven_tape_and_the_consist_are_listed_ahead_of_the_rest()
|
||||
{
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithFile(TestInstallation.At("physicslog", "a-other.dat"), "t")
|
||||
.WithFile(TestInstallation.At("physicslog", "b-wagon.dat"), "t")
|
||||
.WithFile(TestInstallation.At("physicslog", "c-lok.dat"), "t"));
|
||||
installation.Clock.Now = Launched;
|
||||
var tapes = installation.Tapes;
|
||||
|
||||
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "c-lok", new[] { "c-lok", "b-wagon" });
|
||||
|
||||
Assert.Equal("c-lok", tapes.DrivenVehicle);
|
||||
Assert.Equal(new[] { "c-lok", "b-wagon", "a-other" }, tapes.List().Select(tape => tape.Vehicle));
|
||||
Assert.Equal(new[] { TapeRole.StartedIn, TapeRole.OwnConsist, TapeRole.Other }, tapes.List().Select(tape => tape.Role));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void With_no_log_the_vehicle_of_the_launch_is_only_the_one_it_started_in()
|
||||
{
|
||||
var launch = new TapeLaunch(Launched, "td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "ra", new[] { "ra", "rb" });
|
||||
|
||||
Assert.Equal(TapeRole.StartedIn, launch.RoleOf("ra", PlayerVehicles.Unknown));
|
||||
Assert.Equal(TapeRole.OwnConsist, launch.RoleOf("rb", PlayerVehicles.Unknown));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Where_the_player_has_been_is_written_and_read_back()
|
||||
{
|
||||
var launch = new TapeLaunch(Launched, "td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(),
|
||||
Player: PlayerVehicles.None.After("a received command: [x]\nb received command: [x]\n"));
|
||||
|
||||
var back = TapeLaunch.Parse(launch.Format())!;
|
||||
|
||||
Assert.Equal("b", back.Player!.Current);
|
||||
Assert.True(back.Player.Used("a"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_note_of_a_single_launch_still_reads()
|
||||
{
|
||||
var all = TapeLaunch.ParseAll("launched\t2025-12-31T23:00:00.0000000Z\nscenery\ttd.scn\nclock\t10:30\n");
|
||||
|
||||
Assert.Equal("td.scn", all.Single().Scenery);
|
||||
Assert.Null(all.Single().Player);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tapes_part_between_the_launches_they_were_written_after()
|
||||
{
|
||||
var files = new InMemoryFileSystem()
|
||||
.WithFile(TestInstallation.At("physicslog", "ra.dat"), "t")
|
||||
.WithFile(TestInstallation.At("physicslog", "rb.dat"), "t");
|
||||
var installation = new TestInstallation(files);
|
||||
var tapes = installation.Tapes;
|
||||
installation.Clock.Now = Launched;
|
||||
tapes.RecordLaunch("a.scn", new TimeSpan(7, 0, 0), Array.Empty<TapeVehicle>(), "ra", new[] { "ra" }, "A", "T1");
|
||||
installation.Clock.Now = Launched.AddHours(1);
|
||||
tapes.RecordLaunch("b.scn", new TimeSpan(9, 0, 0), Array.Empty<TapeVehicle>(), "rb", new[] { "rb" }, "B", "T2");
|
||||
files.WrittenAt(TestInstallation.At("physicslog", "ra.dat"), Launched.AddMinutes(30))
|
||||
.WrittenAt(TestInstallation.At("physicslog", "rb.dat"), Launched.AddMinutes(90));
|
||||
|
||||
var sessions = tapes.Sessions();
|
||||
|
||||
Assert.Equal(new[] { "B", "A" }, sessions.Select(session => session.Launch!.SceneryName));
|
||||
Assert.Equal("rb", sessions[0].Tapes.Single().Vehicle);
|
||||
Assert.Equal("ra", sessions[1].Tapes.Single().Vehicle);
|
||||
Assert.Equal(new TimeSpan(7, 0, 0), tapes.ContextOf(sessions[1].Tapes[0]).StartClock);
|
||||
Assert.Equal("T1", tapes.ContextOf(sessions[1].Tapes[0]).Train);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tapes_with_no_launch_noted_go_together_by_when_they_were_written()
|
||||
{
|
||||
var early = new DateTime(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithFile(TestInstallation.At("physicslog", "a.dat"), "t")
|
||||
.WithFile(TestInstallation.At("physicslog", "b.dat"), "t")
|
||||
.WithFile(TestInstallation.At("physicslog", "c.dat"), "t")
|
||||
.WrittenAt(TestInstallation.At("physicslog", "a.dat"), early)
|
||||
.WrittenAt(TestInstallation.At("physicslog", "b.dat"), early.AddSeconds(30))
|
||||
.WrittenAt(TestInstallation.At("physicslog", "c.dat"), early.AddHours(1)));
|
||||
|
||||
var sessions = installation.Tapes.Sessions();
|
||||
|
||||
Assert.Equal(new[] { 1, 2 }, sessions.Select(session => session.Tapes.Count));
|
||||
Assert.All(sessions, session => Assert.Null(session.Launch));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_running_session_is_listed_with_no_tapes_yet_and_goes_with_the_simulator()
|
||||
{
|
||||
var installation = new TestInstallation(new InMemoryFileSystem());
|
||||
installation.Clock.Now = Launched;
|
||||
var tapes = installation.Tapes;
|
||||
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "ra");
|
||||
|
||||
installation.SimulatorRunning = true;
|
||||
var running = tapes.Sessions().Single();
|
||||
Assert.True(running.Running);
|
||||
Assert.Empty(running.Tapes);
|
||||
|
||||
installation.SimulatorRunning = false;
|
||||
Assert.Empty(tapes.Sessions());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_log_of_its_own_tells_of_the_vehicles_when_the_common_one_is_stale()
|
||||
{
|
||||
string kept = TestInstallation.At("logs", "log (td.scn) 2026.txt");
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithFile(TestInstallation.At("log.txt"), "ra received command: [x]\n")
|
||||
.WithFile(kept, "rb received command: [x]\n")
|
||||
.WrittenAt(TestInstallation.At("log.txt"), Launched.AddHours(-2))
|
||||
.WrittenAt(kept, Launched.AddMinutes(10)));
|
||||
installation.Clock.Now = Launched;
|
||||
var tapes = installation.Tapes;
|
||||
|
||||
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "ra", new[] { "ra", "rb" });
|
||||
|
||||
Assert.Equal("rb", tapes.DrivenVehicle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_vehicles_of_a_run_stay_with_it_when_the_next_begins()
|
||||
{
|
||||
var (tapes, installation) = TapesOfTrain("ra received command: [x]\nrb received command: [x]\n");
|
||||
var written = Launched.AddMinutes(30);
|
||||
installation.Files.WrittenAt(TestInstallation.At("log.txt"), written)
|
||||
.WrittenAt(TestInstallation.At("physicslog", "ra.dat"), written)
|
||||
.WrittenAt(TestInstallation.At("physicslog", "rb.dat"), written)
|
||||
.WrittenAt(TestInstallation.At("physicslog", "obcy.dat"), written);
|
||||
|
||||
installation.Clock.Now = Launched.AddHours(1);
|
||||
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "inny", new[] { "inny" });
|
||||
|
||||
var run = tapes.Sessions().Single();
|
||||
Assert.Equal("Test", run.Launch!.SceneryName);
|
||||
Assert.Equal(TapeRole.Driven, run.Tapes.Single(tape => tape.Vehicle == "rb").Role);
|
||||
Assert.Equal(TapeRole.DrivenEarlier, run.Tapes.Single(tape => tape.Vehicle == "ra").Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Only_the_last_launches_are_remembered()
|
||||
{
|
||||
var installation = new TestInstallation(new InMemoryFileSystem());
|
||||
var tapes = installation.Tapes;
|
||||
for (int i = 0; i < 35; i++)
|
||||
{
|
||||
installation.Clock.Now = Launched.AddMinutes(i);
|
||||
tapes.RecordLaunch($"s{i}.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>());
|
||||
}
|
||||
|
||||
var noted = TapeLaunch.ParseAll(installation.Files.ReadAllText(
|
||||
TestInstallation.At("physicslog", "starter-launch.txt"), System.Text.Encoding.UTF8));
|
||||
|
||||
Assert.Equal(30, noted.Count);
|
||||
Assert.Equal("s5.scn", noted[0].Scenery);
|
||||
Assert.Equal("s34.scn", noted[^1].Scenery);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_vehicle_the_commands_go_to_is_the_one_the_player_is_in()
|
||||
{
|
||||
var player = PlayerVehicles.None.After(
|
||||
"[ 21.764 ]\t\tTrying to enter player train, \"lok-a\"\r\n" +
|
||||
"[ 25.609 ]\t\tlok-a received command: [aidriverdisable]\r\n" +
|
||||
"[ 255.539 ]\t\tKey pressed: [F5]\r\n" +
|
||||
"[ 255.569 ]\t\tlok b received command: [aidriverdisable]\r\n" +
|
||||
"lok-c received command: [cabchangebackward]\n");
|
||||
|
||||
Assert.Equal("lok-c", player.Current);
|
||||
Assert.True(player.Used("LOK-A"));
|
||||
Assert.True(player.Used("lok b"));
|
||||
Assert.False(player.Used("lok-d"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Going_back_to_a_vehicle_makes_it_the_current_one_again()
|
||||
{
|
||||
var player = PlayerVehicles.None
|
||||
.After("a received command: [x]\nb received command: [x]\n")
|
||||
.After("A received command: [x]\n");
|
||||
|
||||
Assert.Equal("A", player.Current);
|
||||
Assert.True(player.Used("b"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void After_a_change_of_vehicle_the_new_one_is_driven_and_the_first_was_driven_earlier()
|
||||
{
|
||||
var launch = new TapeLaunch(Launched, "td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(),
|
||||
"ra", new[] { "ra", "s", "rb" });
|
||||
var player = PlayerVehicles.None.After("ra received command: [x]\nrb received command: [x]\n");
|
||||
|
||||
Assert.Equal(TapeRole.Driven, launch.RoleOf("RB", player));
|
||||
Assert.Equal(TapeRole.DrivenEarlier, launch.RoleOf("ra", player));
|
||||
Assert.Equal(TapeRole.OwnConsist, launch.RoleOf("s", player));
|
||||
Assert.Equal(TapeRole.Other, launch.RoleOf("obcy", player));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Before_the_first_command_the_vehicle_of_the_launch_is_driven()
|
||||
{
|
||||
var launch = new TapeLaunch(Launched, "td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "ra", new[] { "ra", "rb" });
|
||||
|
||||
Assert.Equal(TapeRole.Driven, launch.RoleOf("ra", PlayerVehicles.None));
|
||||
Assert.Equal(TapeRole.OwnConsist, launch.RoleOf("rb", PlayerVehicles.None));
|
||||
}
|
||||
|
||||
private static (HaslerTapes Tapes, TestInstallation Installation) TapesOfTrain(string log)
|
||||
{
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithFile(TestInstallation.At("physicslog", "ra.dat"), "t")
|
||||
.WithFile(TestInstallation.At("physicslog", "rb.dat"), "t")
|
||||
.WithFile(TestInstallation.At("physicslog", "obcy.dat"), "t")
|
||||
.WithFile(TestInstallation.At("log.txt"), log));
|
||||
installation.Clock.Now = Launched;
|
||||
var tapes = installation.Tapes;
|
||||
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "ra", new[] { "ra", "rb" }, "Test", "MPJ1");
|
||||
return (tapes, installation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_tape_of_the_vehicle_changed_to_comes_first_and_keeps_the_train()
|
||||
{
|
||||
var (tapes, _) = TapesOfTrain("ra received command: [x]\nrb received command: [x]\n");
|
||||
|
||||
Assert.Equal(new[] { "rb", "ra", "obcy" }, tapes.List().Select(tape => tape.Vehicle));
|
||||
Assert.Equal(new[] { TapeRole.Driven, TapeRole.DrivenEarlier, TapeRole.Other }, tapes.List().Select(tape => tape.Role));
|
||||
Assert.Equal("rb", tapes.DrivenVehicle);
|
||||
Assert.Equal("MPJ1", tapes.ContextOf(tapes.List().First()).Train);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_vehicle_of_another_train_changed_to_is_driven_without_the_number_of_the_first()
|
||||
{
|
||||
var (tapes, _) = TapesOfTrain("obcy received command: [x]\n");
|
||||
|
||||
var driven = tapes.List().First();
|
||||
|
||||
Assert.Equal("obcy", driven.Vehicle);
|
||||
Assert.Null(tapes.ContextOf(driven).Train);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_change_of_vehicle_during_the_run_is_seen_at_the_next_look()
|
||||
{
|
||||
const string start = "ra received command: [x]\n";
|
||||
var (tapes, installation) = TapesOfTrain(start);
|
||||
Assert.Equal("ra", tapes.DrivenVehicle);
|
||||
|
||||
installation.Files.WithFile(TestInstallation.At("log.txt"), start + "rb received com");
|
||||
Assert.Equal("ra", tapes.DrivenVehicle);
|
||||
|
||||
installation.Files.WithFile(TestInstallation.At("log.txt"), start + "rb received command: [x]\n");
|
||||
Assert.Equal("rb", tapes.DrivenVehicle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_log_begun_anew_forgets_the_vehicles_of_the_old_one()
|
||||
{
|
||||
var (tapes, installation) = TapesOfTrain("ra received command: [x]\nrb received command: [x]\n");
|
||||
Assert.Equal("rb", tapes.DrivenVehicle);
|
||||
|
||||
installation.Files.WithFile(TestInstallation.At("log.txt"), "obcy received command: [x]\n");
|
||||
|
||||
Assert.Equal("obcy", tapes.DrivenVehicle);
|
||||
Assert.Equal(TapeRole.OwnConsist, tapes.List().Single(tape => tape.Vehicle == "rb").Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_log_older_than_the_launch_says_nothing_of_it()
|
||||
{
|
||||
var (tapes, installation) = TapesOfTrain("rb received command: [x]\n");
|
||||
installation.Clock.Now = installation.Files.LastWriteTimeUtc.AddMinutes(10);
|
||||
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "ra", new[] { "ra", "rb" });
|
||||
|
||||
Assert.Equal("ra", tapes.DrivenVehicle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_tape_older_than_the_last_launch_has_no_part_in_it()
|
||||
{
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithFile(TestInstallation.At("physicslog", "c-lok.dat"), "t"));
|
||||
installation.Clock.Now = installation.Files.LastWriteTimeUtc.AddDays(1);
|
||||
var tapes = installation.Tapes;
|
||||
|
||||
tapes.RecordLaunch("td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>(), "c-lok");
|
||||
|
||||
Assert.Equal(TapeRole.Other, tapes.List().Single().Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_log_written_before_the_launch_is_not_taken_for_one_of_it()
|
||||
{
|
||||
var launch = new TapeLaunch(Launched, "td.scn", TimeSpan.Zero, Array.Empty<TapeVehicle>());
|
||||
|
||||
Assert.True(launch.Covers(Launched.AddMinutes(3)));
|
||||
Assert.False(launch.Covers(Launched.AddHours(-1)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("pkp/ep07_v1", "ep07-424", RecorderKind.Hasler)]
|
||||
[InlineData("pkp/en57al_v1", "en57al-1234", RecorderKind.Electronic)]
|
||||
[InlineData("pkp/x4e_v1", "x4e-659", RecorderKind.Electronic)]
|
||||
[InlineData("pkp/et22_v3", "et22-1100", RecorderKind.Hasler)]
|
||||
public void The_recorder_follows_the_vehicle_behind_the_name(string folder, string type, RecorderKind kind) =>
|
||||
Assert.Equal(kind, Recorders.Guess(new TapeVehicle("poc1_lok", folder, type), "poc1_lok"));
|
||||
|
||||
[Fact]
|
||||
public void Without_a_launch_the_tape_name_is_all_there_is_to_go_by()
|
||||
{
|
||||
Assert.Equal(RecorderKind.Electronic, Recorders.Guess(null, "ED250-001"));
|
||||
Assert.Equal(RecorderKind.Hasler, Recorders.Guess(null, "eu07-193"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_tape_of_the_last_launch_gets_its_clock_and_recorder()
|
||||
{
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithFile(TestInstallation.At("physicslog", "ED250-001.dat"), "t"));
|
||||
installation.Clock.Now = Launched;
|
||||
var tapes = installation.Tapes;
|
||||
|
||||
tapes.RecordLaunch("lk4.scn", new TimeSpan(14, 5, 0),
|
||||
new[] { new TapeVehicle("ED250-001", "pkp/ed250_v1", "ed250-001") });
|
||||
var context = tapes.ContextOf(tapes.List().Single());
|
||||
|
||||
Assert.Equal(new TimeSpan(14, 5, 0), context.StartClock);
|
||||
Assert.Equal(RecorderKind.Electronic, context.Recorder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_tape_older_than_the_last_launch_keeps_its_hour_unknown()
|
||||
{
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithFile(TestInstallation.At("physicslog", "EU07-424.dat"), "t"));
|
||||
installation.Clock.Now = installation.Files.LastWriteTimeUtc.AddDays(1);
|
||||
var tapes = installation.Tapes;
|
||||
|
||||
tapes.RecordLaunch("td.scn", new TimeSpan(8, 0, 0), Array.Empty<TapeVehicle>());
|
||||
|
||||
Assert.Null(tapes.ContextOf(tapes.List().Single()).StartClock);
|
||||
}
|
||||
}
|
||||
197
StarterNG.Tests/TexRandomizerTests.cs
Normal file
197
StarterNG.Tests/TexRandomizerTests.cs
Normal file
@@ -0,0 +1,197 @@
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Domain;
|
||||
using StarterNG.Domain.Vehicles;
|
||||
using StarterNG.Tests.Fakes;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public class TexRandomizerTests
|
||||
{
|
||||
private readonly Consist _consist;
|
||||
private readonly VehicleCatalog _catalog;
|
||||
|
||||
public TexRandomizerTests()
|
||||
{
|
||||
var installation = new TestInstallation(new InMemoryFileSystem()
|
||||
.WithFile(TestInstallation.At("dynamic", "pkp", "ep07", "textures.txt"),
|
||||
"!=e\nep07-001.mat=ep07,EP07\nep07-002.mat=ep07,EP07")
|
||||
.WithFile(TestInstallation.At("dynamic", "pkp", "en57", "textures.txt"),
|
||||
"!=z\n^2\nen57-a.mat=en57a,EN57\nen57-b.mat=en57b,EN57\nen57-c.mat=en57a,EN57\nen57-d.mat=en57b,EN57")
|
||||
.WithFile(TestInstallation.At("dynamic", "pkp", "b111", "textures.txt"),
|
||||
"!=b\nb111-01.mat=b111,B111\nb111-02.mat=b111,B111"));
|
||||
installation.Vehicles.Load(installation.Library.Vehicles);
|
||||
|
||||
_catalog = installation.Library.Vehicles;
|
||||
_consist = new Consist(_catalog, new VehicleInfo(_catalog, installation.Physics));
|
||||
}
|
||||
|
||||
private Dynamic Car(string skin) => _consist.MakeDynamic(_catalog.TextureForSkin(skin)!, null);
|
||||
|
||||
private ConsistItem Put(params Dynamic[] cars)
|
||||
{
|
||||
var item = new ConsistItem
|
||||
{
|
||||
Cars = cars.ToList(),
|
||||
Grouped = cars.Length > 1,
|
||||
Driver = Consist.UnitDriver(cars)
|
||||
};
|
||||
_consist.Insert(_consist.Count, item);
|
||||
return item;
|
||||
}
|
||||
|
||||
/// <summary>The randomizer with the next pick in line always the other livery of two.</summary>
|
||||
private int Randomize() => new TexRandomizer(_catalog, new SecondPick()).Apply(_consist, t => _consist.MakeDynamic(t, null));
|
||||
|
||||
private sealed class SecondPick : Random
|
||||
{
|
||||
public override int Next(int maxValue) => 1;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(eDriverType.Headdriver)]
|
||||
[InlineData(eDriverType.Reardriver)]
|
||||
[InlineData(eDriverType.Passenger)]
|
||||
public void The_crew_stays_on_the_vehicle_that_takes_over(eDriverType crew)
|
||||
{
|
||||
var loco = Car("ep07-001");
|
||||
loco.DriverType = crew;
|
||||
Put(loco);
|
||||
|
||||
Assert.Equal(1, Randomize());
|
||||
|
||||
var car = Assert.Single(_consist[0].Cars);
|
||||
Assert.Equal("ep07-002", car.SkinFile);
|
||||
Assert.Equal(crew, car.DriverType);
|
||||
Assert.Equal(crew, _consist[0].Driver);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_driver_kept_is_what_the_export_writes()
|
||||
{
|
||||
var loco = Car("ep07-001");
|
||||
loco.DriverType = eDriverType.Headdriver;
|
||||
Put(loco);
|
||||
|
||||
Randomize();
|
||||
|
||||
Assert.Contains(" headdriver ", _consist[0].Cars[0].ToTrainsetNode());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_empty_locomotive_stays_empty()
|
||||
{
|
||||
// Consist.Replace would staff it; a driver of its own is not this one's to give.
|
||||
Put(Car("ep07-001"));
|
||||
|
||||
Randomize();
|
||||
|
||||
Assert.Equal(eDriverType.Nobody, _consist[0].Cars[0].DriverType);
|
||||
Assert.Equal(eDriverType.Nobody, _consist[0].Driver);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_vehicle_keeps_facing_the_way_it_did()
|
||||
{
|
||||
var loco = Car("ep07-001");
|
||||
loco.Offset = -1f;
|
||||
var item = Put(loco);
|
||||
item.Flipped = true;
|
||||
|
||||
Randomize();
|
||||
|
||||
Assert.True(_consist[0].Flipped);
|
||||
Assert.True(Consist.IsTurned(_consist[0].Cars[0]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_vehicle_keeps_its_load()
|
||||
{
|
||||
var wagon = Car("b111-01");
|
||||
wagon.LoadType = "Passengers";
|
||||
wagon.LoadCount = 40;
|
||||
wagon.MaxLoad = 60;
|
||||
Put(wagon);
|
||||
|
||||
Assert.Equal(1, Randomize());
|
||||
|
||||
var car = _consist[0].Cars[0];
|
||||
Assert.Equal("b111-02", car.SkinFile);
|
||||
Assert.Equal("Passengers", car.LoadType);
|
||||
Assert.Equal(40, car.LoadCount);
|
||||
Assert.Equal(60, car.MaxLoad);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_unit_of_the_same_length_keeps_all_of_it_car_by_car()
|
||||
{
|
||||
var first = Car("en57-a");
|
||||
var second = Car("en57-b");
|
||||
second.DriverType = eDriverType.Reardriver;
|
||||
first.LoadType = "Passengers";
|
||||
first.LoadCount = 10;
|
||||
second.LoadType = "Passengers";
|
||||
second.LoadCount = 20;
|
||||
first.Offset = -1f;
|
||||
second.Offset = -1f;
|
||||
var item = Put(first, second);
|
||||
item.Flipped = true;
|
||||
|
||||
Assert.Equal(1, Randomize());
|
||||
|
||||
var unit = _consist[0];
|
||||
Assert.Equal(new[] { "en57-c", "en57-d" }, unit.Cars.Select(c => c.SkinFile));
|
||||
Assert.True(unit.Grouped);
|
||||
Assert.True(unit.Flipped);
|
||||
Assert.Equal(new[] { eDriverType.Nobody, eDriverType.Reardriver }, unit.Cars.Select(c => c.DriverType));
|
||||
Assert.Equal(eDriverType.Reardriver, unit.Driver);
|
||||
Assert.Equal(new[] { 10, 20 }, unit.Cars.Select(c => c.LoadCount));
|
||||
Assert.All(unit.Cars, c => Assert.True(Consist.IsTurned(c)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(eDriverType.Headdriver, 0)]
|
||||
[InlineData(eDriverType.Reardriver, 1)]
|
||||
public void A_crew_moving_into_a_longer_unit_sits_in_the_car_with_its_cab(eDriverType crew, int car)
|
||||
{
|
||||
var loco = Car("en57-c");
|
||||
loco.DriverType = crew;
|
||||
Put(loco);
|
||||
|
||||
Assert.Equal(1, new TexRandomizer(_catalog, new FirstPick()).Apply(_consist, t => _consist.MakeDynamic(t, null)));
|
||||
|
||||
var unit = _consist[0];
|
||||
Assert.Equal(2, unit.Cars.Count);
|
||||
Assert.Equal(crew, unit.Cars[car].DriverType);
|
||||
Assert.Equal(eDriverType.Nobody, unit.Cars[1 - car].DriverType);
|
||||
Assert.Equal(crew, unit.Driver);
|
||||
}
|
||||
|
||||
private sealed class FirstPick : Random
|
||||
{
|
||||
public override int Next(int maxValue) => 0;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_selected_vehicle_stays_selected_as_the_one_that_took_over()
|
||||
{
|
||||
var item = Put(Car("ep07-001"));
|
||||
_consist.Selected = item;
|
||||
|
||||
Randomize();
|
||||
|
||||
Assert.Same(_consist[0], _consist.Selected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_vehicle_not_selected_does_not_take_the_selection()
|
||||
{
|
||||
// ep07-001's turn picks ep07-002; ep07-002's turn picks itself and passes.
|
||||
Put(Car("ep07-001"));
|
||||
var selected = Put(Car("ep07-002"));
|
||||
_consist.Selected = selected;
|
||||
|
||||
Assert.Equal(1, Randomize());
|
||||
|
||||
Assert.Same(selected, _consist.Selected);
|
||||
}
|
||||
}
|
||||
77
StarterNG.Tests/TrainsetDisplayTests.cs
Normal file
77
StarterNG.Tests/TrainsetDisplayTests.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Domain;
|
||||
using StarterNG.Views;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public class TrainsetDisplayTests
|
||||
{
|
||||
private static Trainset Consist(string driver = "headdriver", string? description = null) =>
|
||||
new(string.Concat(
|
||||
description is null ? "" : $"//$o {description}\n",
|
||||
$"trainset a tor 0 0\nnode -1 0 v1 dynamic d s m 0 {driver} 3 0 enddynamic\nendtrainset"));
|
||||
|
||||
[Theory]
|
||||
[InlineData("-ai", true)]
|
||||
[InlineData(" - ai", true)]
|
||||
[InlineData("a - b", false)]
|
||||
[InlineData("rozkladowy", false)]
|
||||
public void A_dash_opening_the_description_says_the_consist_is_ai(string description, bool ai) =>
|
||||
Assert.Equal(ai, TrainsetDisplay.IsAi(Consist(description: description)));
|
||||
|
||||
[Fact]
|
||||
public void No_description_is_no_ai() =>
|
||||
Assert.False(TrainsetDisplay.IsAi(Consist()));
|
||||
|
||||
[Fact]
|
||||
public void An_ai_consist_is_listed_only_when_they_are_shown()
|
||||
{
|
||||
var ai = Consist(description: "-ai");
|
||||
|
||||
Assert.False(TrainsetDisplay.ListedIn(ai, showAi: false, drivableOnly: false, current: null));
|
||||
Assert.True(TrainsetDisplay.ListedIn(ai, showAi: true, drivableOnly: false, current: null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drivable_only_leaves_out_a_consist_nobody_sits_in()
|
||||
{
|
||||
var empty = Consist(driver: "nobody");
|
||||
|
||||
Assert.False(TrainsetDisplay.ListedIn(empty, showAi: true, drivableOnly: true, current: null));
|
||||
Assert.True(TrainsetDisplay.ListedIn(empty, showAi: true, drivableOnly: false, current: null));
|
||||
Assert.True(TrainsetDisplay.ListedIn(Consist(), showAi: true, drivableOnly: true, current: null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drivable_only_keeps_the_one_picked_whatever_its_crew()
|
||||
{
|
||||
var picked = Consist(driver: "nobody");
|
||||
|
||||
Assert.True(TrainsetDisplay.ListedIn(picked, showAi: true, drivableOnly: true, current: picked));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Being_picked_does_not_show_an_ai_consist_the_filter_hides()
|
||||
{
|
||||
var picked = Consist(description: "-ai");
|
||||
|
||||
Assert.False(TrainsetDisplay.ListedIn(picked, showAi: false, drivableOnly: true, current: picked));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(eDriverType.Headdriver, 0)]
|
||||
[InlineData(eDriverType.Reardriver, 1)]
|
||||
[InlineData(eDriverType.Passenger, 2)]
|
||||
[InlineData(eDriverType.Nobody, 3)]
|
||||
public void A_crew_keeps_its_place_in_the_list(eDriverType crew, int place)
|
||||
{
|
||||
Assert.Equal(place, CrewChoices.IndexOf(crew));
|
||||
Assert.Equal(crew, CrewChoices.At(place));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-1)]
|
||||
[InlineData(4)]
|
||||
public void A_list_with_nothing_selected_means_nobody(int place) =>
|
||||
Assert.Equal(eDriverType.Nobody, CrewChoices.At(place));
|
||||
}
|
||||
162
StarterNG.Tests/VehicleBaseQueryTests.cs
Normal file
162
StarterNG.Tests/VehicleBaseQueryTests.cs
Normal file
@@ -0,0 +1,162 @@
|
||||
using StarterNG.Domain.Vehicles;
|
||||
using StarterNG.Tests.Fakes;
|
||||
using StarterNG.Views;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public class VehicleBaseQueryTests
|
||||
{
|
||||
private static VehicleBaseRow Row(string skin, string? operatorName = null, string? revision = null,
|
||||
string? author = null, string? depot = null, string? photo = null,
|
||||
string directory = "dynamic/pkp/", string? model = "model")
|
||||
{
|
||||
return new VehicleBaseRow(new VehicleTexture
|
||||
{
|
||||
Directory = directory,
|
||||
Skinfile = skin,
|
||||
Model = model,
|
||||
Meta = new VehicleMeta
|
||||
{
|
||||
Operator = operatorName,
|
||||
RevisionDate = revision,
|
||||
TextureAuthor = author,
|
||||
Depot = depot,
|
||||
PhotoAuthor = photo
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static IReadOnlyList<VehicleTexture> Catalogued(params string[] textures)
|
||||
{
|
||||
var files = new InMemoryFileSystem();
|
||||
foreach (string text in textures)
|
||||
files.WithFile(TestInstallation.At("dynamic", "pkp", text[..text.IndexOf('=')], "textures.txt"),
|
||||
text[(text.IndexOf('=') + 1)..]);
|
||||
var installation = new TestInstallation(files);
|
||||
installation.Vehicles.Load(installation.Library.Vehicles);
|
||||
return installation.Library.Vehicles.Textures;
|
||||
}
|
||||
|
||||
private static string[] Skins(IEnumerable<VehicleBaseRow> rows) => rows.Select(r => r.Skin).ToArray();
|
||||
|
||||
private static string[] Selected(VehicleBaseFilters filters, params VehicleBaseRow[] rows) =>
|
||||
Skins(VehicleBaseQuery.Select(rows, filters, r => r.Skin, descending: false));
|
||||
|
||||
[Fact]
|
||||
public void A_question_mark_in_the_credit_line_is_what_its_author_did_not_know()
|
||||
{
|
||||
var row = Row("a", operatorName: "?", revision: "?", author: "Jan");
|
||||
|
||||
Assert.Equal("", row.Operator);
|
||||
Assert.Equal("", row.Revision);
|
||||
Assert.Null(row.Date);
|
||||
Assert.Equal("", row.DateKey);
|
||||
Assert.Equal("Jan", row.Author);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_date_key_sorts_as_the_dates_do() =>
|
||||
Assert.Equal("20081201", Row("a", revision: "xx.12.2008").DateKey);
|
||||
|
||||
[Fact]
|
||||
public void Each_filter_looks_in_its_own_field_ignoring_case_and_spaces_around()
|
||||
{
|
||||
var a = Row("alfa", operatorName: "PKP", author: "Jan", depot: "Lublin", photo: "Ewa", directory: "dynamic/one/");
|
||||
var b = Row("beta", operatorName: "CD", author: "Olaf", depot: "Praha", photo: "Karel", directory: "dynamic/two/");
|
||||
|
||||
Assert.Equal(new[] { "alfa" }, Selected(new VehicleBaseFilters { Name = " ALF " }, a, b));
|
||||
Assert.Equal(new[] { "beta" }, Selected(new VehicleBaseFilters { Operator = "cd" }, a, b));
|
||||
Assert.Equal(new[] { "alfa" }, Selected(new VehicleBaseFilters { Author = "jan" }, a, b));
|
||||
Assert.Equal(new[] { "beta" }, Selected(new VehicleBaseFilters { Station = "prah" }, a, b));
|
||||
Assert.Equal(new[] { "alfa" }, Selected(new VehicleBaseFilters { Photo = "EW" }, a, b));
|
||||
Assert.Equal(new[] { "beta" }, Selected(new VehicleBaseFilters { Model = "TWO/" }, a, b));
|
||||
Assert.Empty(Selected(new VehicleBaseFilters { Name = "alf", Operator = "cd" }, a, b));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void A_filter_with_nothing_typed_lets_everything_through(string? typed) =>
|
||||
Assert.Equal(new[] { "a", "b" }, Selected(new VehicleBaseFilters { Name = typed }, Row("a"), Row("b")));
|
||||
|
||||
[Theory]
|
||||
[InlineData("2016", null, true)]
|
||||
[InlineData("2016-12", null, true)]
|
||||
[InlineData("2017", null, false)]
|
||||
[InlineData(null, "2016", true)]
|
||||
[InlineData(null, "2015", false)]
|
||||
[InlineData("2010", "2020", true)]
|
||||
public void A_revision_counts_while_its_span_overlaps_what_is_asked(string? from, string? to, bool shown)
|
||||
{
|
||||
var row = Row("a", revision: "xx.xx.2016");
|
||||
|
||||
var found = Selected(new VehicleBaseFilters { RevisionFrom = from, RevisionTo = to }, row);
|
||||
|
||||
Assert.Equal(shown, found.Length == 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_month_only_date_overlaps_at_both_of_its_ends()
|
||||
{
|
||||
var row = Row("a", revision: "xx.06.2008");
|
||||
|
||||
Assert.Single(Selected(new VehicleBaseFilters { RevisionFrom = "30.06.2008" }, row));
|
||||
Assert.Single(Selected(new VehicleBaseFilters { RevisionTo = "01.06.2008" }, row));
|
||||
Assert.Empty(Selected(new VehicleBaseFilters { RevisionFrom = "01.07.2008" }, row));
|
||||
Assert.Empty(Selected(new VehicleBaseFilters { RevisionTo = "31.05.2008" }, row));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_revision_asked_for_leaves_out_what_has_no_date_and_what_cannot_be_read_asks_nothing()
|
||||
{
|
||||
var dated = Row("dated", revision: "2016");
|
||||
var undated = Row("undated");
|
||||
|
||||
Assert.Equal(new[] { "dated" }, Selected(new VehicleBaseFilters { RevisionFrom = "2000" }, dated, undated));
|
||||
Assert.Equal(new[] { "dated", "undated" },
|
||||
Selected(new VehicleBaseFilters { RevisionFrom = "soon" }, dated, undated));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, new[] { "b", "c", "a", "d" })]
|
||||
[InlineData(true, new[] { "c", "b", "a", "d" })]
|
||||
public void Blanks_stay_at_the_bottom_whichever_way_a_column_is_sorted_and_the_name_settles_a_tie(
|
||||
bool descending, string[] order)
|
||||
{
|
||||
var rows = new[] { Row("d"), Row("c", operatorName: "Z"), Row("a"), Row("b", operatorName: "M") };
|
||||
|
||||
Assert.Equal(order, Skins(VehicleBaseQuery.Select(rows, new VehicleBaseFilters(), r => r.Operator, descending)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_class_list_counts_a_class_under_one_name_whatever_its_case_in_order_of_the_names()
|
||||
{
|
||||
var textures = Catalogued("b111=!=b\nb111-01.mat=b111,B111\nb111-02.mat=b222,b111",
|
||||
"ep07=!=e\nep07-01.mat=ep07,AP07");
|
||||
|
||||
var counts = VehicleBaseQuery.ClassCounts(textures.Select(t => new VehicleBaseRow(t)));
|
||||
|
||||
Assert.Equal(new[] { ("AP07", 1), ("B111", 2) },
|
||||
counts.Select(c => (c.Name.ToUpperInvariant(), c.Count)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_row_with_no_class_is_not_counted() =>
|
||||
Assert.Empty(VehicleBaseQuery.ClassCounts(new[] { Row("a") }));
|
||||
|
||||
[Fact]
|
||||
public void The_class_picked_has_to_match_whole_while_the_typed_one_only_has_to_appear()
|
||||
{
|
||||
var textures = Catalogued("b111=!=b\nb111-01.mat=b111,B111");
|
||||
var row = new VehicleBaseRow(textures[0]);
|
||||
Assert.NotEqual("", row.Class);
|
||||
|
||||
Assert.Single(VehicleBaseQuery.Select(new[] { row }, new VehicleBaseFilters { PickedClass = row.Class.ToUpperInvariant() },
|
||||
r => r.Skin, descending: false));
|
||||
Assert.Empty(VehicleBaseQuery.Select(new[] { row }, new VehicleBaseFilters { PickedClass = row.Class[..1] + "?" },
|
||||
r => r.Skin, descending: false));
|
||||
Assert.Single(VehicleBaseQuery.Select(new[] { row }, new VehicleBaseFilters { Class = row.Class[..1] },
|
||||
r => r.Skin, descending: false));
|
||||
}
|
||||
}
|
||||
139
StarterNG.Tests/WarehouseEditTests.cs
Normal file
139
StarterNG.Tests/WarehouseEditTests.cs
Normal file
@@ -0,0 +1,139 @@
|
||||
using StarterNG.Application;
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Tests.Fakes;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
// PresetStore keeps the disk of the first test that reaches it, a fake one as every
|
||||
// composition root in the tests is built on fakes. So each test has a name to itself.
|
||||
public class WarehouseEditTests
|
||||
{
|
||||
private const string Text =
|
||||
"trainset a tor 0 0\nnode -1 0 v1 dynamic d s m 0 headdriver 3 0 enddynamic\nendtrainset\n";
|
||||
|
||||
private static readonly string Entry = ConsistText.Serialize(new Trainset(Text));
|
||||
|
||||
private readonly WarehouseEdit _edit = new();
|
||||
|
||||
public WarehouseEditTests()
|
||||
{
|
||||
var installation = new TestInstallation(new InMemoryFileSystem());
|
||||
AppServices.Initialize(installation.Paths, installation.Files, installation.Clock, installation.Environment);
|
||||
}
|
||||
|
||||
private static TrainsetPreset Preset(string name, string? entry = null) => new() { Name = name, Entry = entry ?? Entry };
|
||||
|
||||
private static TrainsetPreset? Stored(string name) => PresetStore.All().FirstOrDefault(p => p.Name == name);
|
||||
|
||||
[Fact]
|
||||
public void Opening_says_what_is_edited_and_closing_that_nothing_is()
|
||||
{
|
||||
var trainset = _edit.Open(Preset("edit-open"));
|
||||
|
||||
Assert.Same(trainset, _edit.Trainset);
|
||||
Assert.Equal("edit-open", _edit.Name);
|
||||
|
||||
_edit.Close();
|
||||
|
||||
Assert.Null(_edit.Trainset);
|
||||
Assert.Null(_edit.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_consist_opened_again_is_the_same_trainset_so_its_undo_history_outlives_a_switch()
|
||||
{
|
||||
var first = _edit.Open(Preset("edit-switch"));
|
||||
_edit.Open(Preset("edit-switch-other"));
|
||||
|
||||
Assert.Same(first, _edit.Open(Preset("edit-switch")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_entry_written_over_from_elsewhere_is_read_afresh()
|
||||
{
|
||||
var first = _edit.Open(Preset("edit-over"));
|
||||
|
||||
var second = _edit.Open(Preset("edit-over", ConsistText.Serialize(new Trainset(Text.Replace(" v1 ", " v9 ")))));
|
||||
|
||||
Assert.NotSame(first, second);
|
||||
Assert.Equal("v9", second.Vehicles.Single().Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_name_of_a_consist_goes_with_any_letter_case()
|
||||
{
|
||||
var first = _edit.Open(Preset("edit-case"));
|
||||
|
||||
Assert.Same(first, _edit.Open(Preset("EDIT-CASE")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nothing_is_written_while_the_consist_stands_as_it_was_read()
|
||||
{
|
||||
PresetStore.Save("edit-unchanged", Entry);
|
||||
_edit.Open(Stored("edit-unchanged")!);
|
||||
PresetStore.Delete("edit-unchanged");
|
||||
|
||||
_edit.Save();
|
||||
|
||||
Assert.Null(Stored("edit-unchanged"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_change_is_written_once()
|
||||
{
|
||||
PresetStore.Save("edit-changed", Entry);
|
||||
var trainset = _edit.Open(Stored("edit-changed")!);
|
||||
trainset.Vehicles.Add(trainset.Vehicles[0].Clone());
|
||||
|
||||
_edit.Save();
|
||||
|
||||
Assert.Equal(ConsistText.Serialize(trainset), Stored("edit-changed")!.Entry);
|
||||
|
||||
PresetStore.Delete("edit-changed");
|
||||
_edit.Save();
|
||||
|
||||
Assert.Null(Stored("edit-changed"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nothing_is_written_with_nothing_open()
|
||||
{
|
||||
var trainset = _edit.Open(Preset("edit-closed"));
|
||||
trainset.Vehicles.Add(trainset.Vehicles[0].Clone());
|
||||
_edit.Close();
|
||||
|
||||
_edit.Save();
|
||||
|
||||
Assert.Null(Stored("edit-closed"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_rename_keeps_the_trainset_and_saves_under_the_new_name()
|
||||
{
|
||||
var trainset = _edit.Open(Preset("edit-old"));
|
||||
|
||||
_edit.Renamed("edit-old", "edit-new");
|
||||
|
||||
Assert.Equal("edit-new", _edit.Name);
|
||||
trainset.Vehicles.Add(trainset.Vehicles[0].Clone());
|
||||
_edit.Save();
|
||||
Assert.Null(Stored("edit-old"));
|
||||
Assert.Equal(ConsistText.Serialize(trainset), Stored("edit-new")!.Entry);
|
||||
|
||||
Assert.Same(trainset, _edit.Open(Preset("edit-new", Stored("edit-new")!.Entry)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Renaming_another_consist_leaves_the_one_open_alone()
|
||||
{
|
||||
var other = _edit.Open(Preset("edit-other"));
|
||||
var open = _edit.Open(Preset("edit-mine"));
|
||||
|
||||
_edit.Renamed("edit-other", "edit-other-new");
|
||||
|
||||
Assert.Same(open, _edit.Trainset);
|
||||
Assert.Equal("edit-mine", _edit.Name);
|
||||
Assert.Same(other, _edit.Open(Preset("edit-other-new")));
|
||||
}
|
||||
}
|
||||
209
StarterNG.Tests/WineConfigLinkTests.cs
Normal file
209
StarterNG.Tests/WineConfigLinkTests.cs
Normal file
@@ -0,0 +1,209 @@
|
||||
using StarterNG.Infrastructure;
|
||||
using StarterNG.Tests.Fakes;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public class WineConfigLinkTests
|
||||
{
|
||||
// Rooted the way the host roots a path: the link is made to the folder's full
|
||||
// path, and on a Windows host "/home" alone is not one. Wine itself only runs off
|
||||
// Windows, but what is decided here is the same wherever these tests run.
|
||||
private static readonly string Home = Path.GetFullPath("/home/kolejarz");
|
||||
private static readonly string Config = Path.GetFullPath(Home + "/.config/MaSzyna");
|
||||
private static readonly string Roaming = Home + "/.wine/drive_c/users/kolejarz/AppData/Roaming";
|
||||
private static readonly string PrefixFolder = Roaming + "/MaSzyna";
|
||||
|
||||
private static (WineConfigLink Link, TestInstallation Installation) Rig(Action<InMemoryFileSystem>? arrange = null)
|
||||
{
|
||||
var files = new InMemoryFileSystem(caseSensitive: true)
|
||||
.WithFile(Config + "/eu07.ini", "fpslimit 30\n");
|
||||
arrange?.Invoke(files);
|
||||
var installation = new TestInstallation(files);
|
||||
installation.Environment.With("HOME", Home).With("USER", "kolejarz");
|
||||
return (new WineConfigLink(files, installation.Environment, installation.SettingsPaths, installation.Log),
|
||||
installation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_prefixs_own_folder_is_kept_aside_and_linked_to_the_starters()
|
||||
{
|
||||
var (link, installation) = Rig(files => files
|
||||
.WithFile(PrefixFolder + "/eu07.ini", "fpslimit 0\n")
|
||||
.WithFile(PrefixFolder + "/eu07_input-uart.ini", "port COM3\n"));
|
||||
|
||||
link.Ensure();
|
||||
|
||||
var files = installation.Files;
|
||||
Assert.Equal(Config, files.LinkTarget(PrefixFolder));
|
||||
Assert.Equal("fpslimit 0\n", files.ReadAllText(PrefixFolder + ".before-starter/eu07.ini"));
|
||||
// What only the prefix had comes along; the starter's own eu07.ini stays.
|
||||
Assert.Equal("port COM3\n", files.ReadAllText(Config + "/eu07_input-uart.ini"));
|
||||
Assert.Equal("fpslimit 30\n", files.ReadAllText(Config + "/eu07.ini"));
|
||||
Assert.Contains("now links to", installation.Log.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_prefix_already_linked_is_left_alone()
|
||||
{
|
||||
var (link, installation) = Rig(files => files.WithDirectory(PrefixFolder));
|
||||
link.Ensure();
|
||||
|
||||
link.Ensure();
|
||||
|
||||
Assert.Equal(Config, installation.Files.LinkTarget(PrefixFolder));
|
||||
Assert.False(installation.Files.DirectoryExists(PrefixFolder + ".before-starter-2"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_link_of_the_players_own_is_not_touched()
|
||||
{
|
||||
var (link, installation) = Rig(files =>
|
||||
{
|
||||
files.WithDirectory(Roaming);
|
||||
files.CreateDirectoryLink(PrefixFolder, "/mnt/shared/MaSzyna");
|
||||
});
|
||||
|
||||
link.Ensure();
|
||||
|
||||
Assert.Equal("/mnt/shared/MaSzyna", installation.Files.LinkTarget(PrefixFolder));
|
||||
Assert.Contains("left as it is", installation.Log.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_a_prefix_nothing_is_made()
|
||||
{
|
||||
var (link, installation) = Rig();
|
||||
|
||||
link.Ensure();
|
||||
|
||||
Assert.False(installation.Files.DirectoryExists(Home + "/.wine"));
|
||||
Assert.Contains("no prefix", installation.Log.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_prefix_named_in_wineprefix_is_the_one_linked()
|
||||
{
|
||||
string prefix = Path.GetFullPath("/games/wine-maszyna");
|
||||
var (link, installation) = Rig(files => files
|
||||
.WithDirectory(prefix + "/drive_c/users/kolejarz/AppData/Roaming")
|
||||
.WithDirectory(Roaming));
|
||||
installation.Environment.With("WINEPREFIX", prefix);
|
||||
|
||||
link.Ensure();
|
||||
|
||||
Assert.Equal(Config, installation.Files.LinkTarget(prefix + "/drive_c/users/kolejarz/AppData/Roaming/MaSzyna"));
|
||||
Assert.Null(installation.Files.LinkTarget(PrefixFolder));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_older_backup_is_not_overwritten()
|
||||
{
|
||||
var (link, installation) = Rig(files => files
|
||||
.WithFile(PrefixFolder + ".before-starter/eu07.ini", "first\n")
|
||||
.WithFile(PrefixFolder + "/eu07.ini", "second\n"));
|
||||
|
||||
link.Ensure();
|
||||
|
||||
Assert.Equal("first\n", installation.Files.ReadAllText(PrefixFolder + ".before-starter/eu07.ini"));
|
||||
Assert.Equal("second\n", installation.Files.ReadAllText(PrefixFolder + ".before-starter-2/eu07.ini"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_older_wine_keeps_application_data_where_xp_did()
|
||||
{
|
||||
string applicationData = Home + "/.wine/drive_c/users/kolejarz/Application Data";
|
||||
var (link, installation) = Rig(files => files.WithDirectory(applicationData));
|
||||
|
||||
link.Ensure();
|
||||
|
||||
Assert.Equal(Config, installation.Files.LinkTarget(applicationData + "/MaSzyna"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_only_profile_is_used_when_the_user_name_differs()
|
||||
{
|
||||
string profile = Home + "/.wine/drive_c/users/steamuser/AppData/Roaming";
|
||||
var (link, installation) = Rig(files => files
|
||||
.WithDirectory(Home + "/.wine/drive_c/users/Public")
|
||||
.WithDirectory(profile));
|
||||
|
||||
link.Ensure();
|
||||
|
||||
Assert.Equal(Config, installation.Files.LinkTarget(profile + "/MaSzyna"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_link_that_cannot_be_made_leaves_the_prefix_as_it_was()
|
||||
{
|
||||
var (link, installation) = Rig(files => files
|
||||
.WithFile(PrefixFolder + "/eu07.ini", "fpslimit 0\n")
|
||||
.WithFile(PrefixFolder + "/eu07_input-uart.ini", "port COM3\n"));
|
||||
var files = installation.Files;
|
||||
files.LinksFail = true;
|
||||
|
||||
link.Ensure();
|
||||
|
||||
Assert.Null(files.LinkTarget(PrefixFolder));
|
||||
Assert.Equal("fpslimit 0\n", files.ReadAllText(PrefixFolder + "/eu07.ini"));
|
||||
Assert.False(files.DirectoryExists(PrefixFolder + ".before-starter"));
|
||||
// Nothing carried over stays behind for a move that did not happen.
|
||||
Assert.False(files.FileExists(Config + "/eu07_input-uart.ini"));
|
||||
Assert.Equal("fpslimit 30\n", files.ReadAllText(Config + "/eu07.ini"));
|
||||
Assert.Contains("linking the settings", installation.Log.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Attempts_that_fail_do_not_pile_up_backups()
|
||||
{
|
||||
var (link, installation) = Rig(files => files.WithFile(PrefixFolder + "/eu07.ini", "fpslimit 0\n"));
|
||||
var files = installation.Files;
|
||||
files.LinksFail = true;
|
||||
|
||||
link.Ensure();
|
||||
link.Ensure();
|
||||
link.Ensure();
|
||||
|
||||
Assert.False(files.DirectoryExists(PrefixFolder + ".before-starter"));
|
||||
Assert.False(files.DirectoryExists(PrefixFolder + ".before-starter-2"));
|
||||
|
||||
files.LinksFail = false;
|
||||
link.Ensure();
|
||||
|
||||
Assert.Equal(Config, files.LinkTarget(PrefixFolder));
|
||||
Assert.Equal("fpslimit 0\n", files.ReadAllText(PrefixFolder + ".before-starter/eu07.ini"));
|
||||
Assert.False(files.DirectoryExists(PrefixFolder + ".before-starter-2"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_copy_that_fails_half_way_takes_back_what_it_had_copied()
|
||||
{
|
||||
var (link, installation) = Rig(files => files
|
||||
.WithFile(PrefixFolder + "/a.ini", "first\n")
|
||||
.WithFile(PrefixFolder + "/b.ini", "second\n")
|
||||
// It is read from where the folder has been moved to by then.
|
||||
.FailReading(PrefixFolder + ".before-starter/b.ini"));
|
||||
var files = installation.Files;
|
||||
|
||||
link.Ensure();
|
||||
|
||||
Assert.Null(files.LinkTarget(PrefixFolder));
|
||||
Assert.Equal("first\n", files.ReadAllText(PrefixFolder + "/a.ini"));
|
||||
Assert.False(files.FileExists(Config + "/a.ini"));
|
||||
Assert.False(files.DirectoryExists(PrefixFolder + ".before-starter"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_folder_that_cannot_be_put_back_is_said_to_be_where_it_is()
|
||||
{
|
||||
var (link, installation) = Rig(files => files.WithFile(PrefixFolder + "/eu07.ini", "fpslimit 0\n"));
|
||||
var files = installation.Files;
|
||||
files.LinksFail = true;
|
||||
// In the way of the move back: made by whatever broke the link, say.
|
||||
files.MoveFails = path => path.EndsWith("/Roaming/MaSzyna");
|
||||
|
||||
link.Ensure();
|
||||
|
||||
Assert.Equal("fpslimit 0\n", files.ReadAllText(PrefixFolder + ".before-starter/eu07.ini"));
|
||||
Assert.Contains("stays at", installation.Log.Text);
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,93 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 18
|
||||
VisualStudioVersion = 18.1.11312.151 d18.0
|
||||
VisualStudioVersion = 18.1.11312.151
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StarterNG", "StarterNG\StarterNG.csproj", "{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StarterNG.Tests", "StarterNG.Tests\StarterNG.Tests.csproj", "{00B068D7-2F49-4CAA-BC21-506503EBDFC8}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Eu07.BlackBox", "Eu07.BlackBox\Eu07.BlackBox.csproj", "{B94F5951-3DC8-4DD8-A694-133B76395A5A}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Eu07.BlackBox.Views", "Eu07.BlackBox.Views\Eu07.BlackBox.Views.csproj", "{7A522978-9E79-4ADC-A3F2-1FEFD196C41E}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Eu07.BlackBox.Viewer", "Eu07.BlackBox.Viewer\Eu07.BlackBox.Viewer.csproj", "{0C131487-66CE-42B9-8BB6-93AAD8C47719}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Debug|x64.Build.0 = Debug|x64
|
||||
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Release|x64.ActiveCfg = Release|x64
|
||||
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Release|x64.Build.0 = Release|x64
|
||||
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Release|x86.Build.0 = Release|Any CPU
|
||||
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Release|x64.Build.0 = Release|Any CPU
|
||||
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Release|x86.Build.0 = Release|Any CPU
|
||||
{B94F5951-3DC8-4DD8-A694-133B76395A5A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B94F5951-3DC8-4DD8-A694-133B76395A5A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B94F5951-3DC8-4DD8-A694-133B76395A5A}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{B94F5951-3DC8-4DD8-A694-133B76395A5A}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{B94F5951-3DC8-4DD8-A694-133B76395A5A}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{B94F5951-3DC8-4DD8-A694-133B76395A5A}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{B94F5951-3DC8-4DD8-A694-133B76395A5A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B94F5951-3DC8-4DD8-A694-133B76395A5A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B94F5951-3DC8-4DD8-A694-133B76395A5A}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{B94F5951-3DC8-4DD8-A694-133B76395A5A}.Release|x64.Build.0 = Release|Any CPU
|
||||
{B94F5951-3DC8-4DD8-A694-133B76395A5A}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{B94F5951-3DC8-4DD8-A694-133B76395A5A}.Release|x86.Build.0 = Release|Any CPU
|
||||
{7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Release|x64.Build.0 = Release|Any CPU
|
||||
{7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{7A522978-9E79-4ADC-A3F2-1FEFD196C41E}.Release|x86.Build.0 = Release|Any CPU
|
||||
{0C131487-66CE-42B9-8BB6-93AAD8C47719}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{0C131487-66CE-42B9-8BB6-93AAD8C47719}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{0C131487-66CE-42B9-8BB6-93AAD8C47719}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{0C131487-66CE-42B9-8BB6-93AAD8C47719}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{0C131487-66CE-42B9-8BB6-93AAD8C47719}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{0C131487-66CE-42B9-8BB6-93AAD8C47719}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{0C131487-66CE-42B9-8BB6-93AAD8C47719}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{0C131487-66CE-42B9-8BB6-93AAD8C47719}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{0C131487-66CE-42B9-8BB6-93AAD8C47719}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{0C131487-66CE-42B9-8BB6-93AAD8C47719}.Release|x64.Build.0 = Release|Any CPU
|
||||
{0C131487-66CE-42B9-8BB6-93AAD8C47719}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{0C131487-66CE-42B9-8BB6-93AAD8C47719}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(RiderSharedRunConfigurations) = postSolution
|
||||
File = linuix.run\StarterNG (linux64).run.xml
|
||||
File = windows.run\StarterNG (win64).run.xml
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -12,6 +12,17 @@
|
||||
<StyleInclude Source="avares://Starter/Styles/Carbon.axaml" />
|
||||
<StyleInclude Source="avares://Starter/Styles/Settings.axaml" />
|
||||
|
||||
<!-- A dropdown whose options are bound to the translation table. The closed
|
||||
box keeps a copy of the text picked, so it would stay in the old
|
||||
language; this shows the option itself, which follows the change. -->
|
||||
<Style Selector="ComboBox.Localized">
|
||||
<Setter Property="SelectionBoxItemTemplate">
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding $parent[ComboBox].((ComboBoxItem)SelectedItem).Content}" />
|
||||
</DataTemplate>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button[(chrome|WindowDecorationProperties.ElementRole)=FullScreenButton]">
|
||||
<Setter Property="IsVisible" Value="False" />
|
||||
</Style>
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Threading;
|
||||
@@ -30,6 +27,7 @@ public partial class App : Avalonia.Application
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
|
||||
AppServices.Current.SettingsStore.Load();
|
||||
AppServices.Current.SettingsStore.StartAtHighWithoutOwnSettings();
|
||||
|
||||
KeyboardConfig.Instance.Load();
|
||||
|
||||
@@ -48,6 +46,10 @@ public partial class App : Avalonia.Application
|
||||
// actually display.
|
||||
string requestedCode = AppServices.Current.Settings.LanguageCode;
|
||||
|
||||
// Subscribed before any view, so the views rebuild in the new culture.
|
||||
Loc.LanguageChanged += () => ApplyCulture(Loc.CurrentLangCode);
|
||||
|
||||
Loc.Keep(requestedCode);
|
||||
ApplyLanguage(langName);
|
||||
|
||||
// The starter ships no translation for this code, so its own interface
|
||||
@@ -60,15 +62,26 @@ public partial class App : Avalonia.Application
|
||||
|
||||
public static void ApplyLanguage(string langNameOrCode)
|
||||
{
|
||||
|
||||
var resolvedName = Loc.Load(langNameOrCode);
|
||||
|
||||
// The service is the authority on which language actually loaded: the code
|
||||
// goes to eu07.ini for the simulator, the name to the picker.
|
||||
AppServices.Current.Settings.Language = resolvedName;
|
||||
AppServices.Current.Settings.LanguageCode = Loc.CurrentLangCode;
|
||||
}
|
||||
|
||||
ApplyCulture(Loc.CurrentLangCode);
|
||||
/// <summary>
|
||||
/// A language picked by the user, written to eu07.ini straight away as the old
|
||||
/// starter did - not left waiting for the Save button of a different tab.
|
||||
/// Only what is already in the settings goes with it, not other unsaved edits.
|
||||
/// </summary>
|
||||
public static void ChangeLanguage(string code)
|
||||
{
|
||||
if (string.Equals(code, Loc.CurrentLangCode, StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
|
||||
ApplyLanguage(code);
|
||||
AppServices.Current.SettingsStore.Save();
|
||||
}
|
||||
|
||||
private static void ApplyCulture(string langCode)
|
||||
@@ -88,10 +101,11 @@ public partial class App : Avalonia.Application
|
||||
culture = CultureInfo.InvariantCulture;
|
||||
}
|
||||
|
||||
// Only the defaults: a culture set on the thread is an async local, so one set
|
||||
// from the picker's handler was undone as soon as the handler returned, and
|
||||
// the UI kept the first language's decimal comma and month names.
|
||||
CultureInfo.DefaultThreadCurrentCulture = culture;
|
||||
CultureInfo.DefaultThreadCurrentUICulture = culture;
|
||||
CultureInfo.CurrentCulture = culture;
|
||||
CultureInfo.CurrentUICulture = culture;
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
@@ -187,6 +201,9 @@ public partial class App : Avalonia.Application
|
||||
|
||||
if (check.ExecutableMissing())
|
||||
await main.OfferExecutablePickAsync();
|
||||
|
||||
if (CpuFeatures.LacksAvx2 && AppServices.Current.Settings.CpuNotice)
|
||||
await main.ShowCpuNoticeAsync();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,6 +25,16 @@ public interface IFileSystem
|
||||
|
||||
void WriteAllText(string path, string contents, Encoding encoding);
|
||||
|
||||
/// <summary>
|
||||
/// Writes the file whole or not at all: a crash or a full disk half way leaves
|
||||
/// what stood there, not the start of what was on its way. A link stays a link,
|
||||
/// its file gets the contents; a file keeps its mode.
|
||||
/// </summary>
|
||||
void WriteAllBytesAtomic(string path, byte[] bytes);
|
||||
|
||||
/// <summary>As UTF-8 without a mark, by <see cref="WriteAllBytesAtomic"/>.</summary>
|
||||
void WriteAllTextAtomic(string path, string contents);
|
||||
|
||||
void WriteAllLines(string path, IEnumerable<string> lines);
|
||||
|
||||
void WriteAllBytes(string path, byte[] bytes);
|
||||
@@ -45,5 +55,36 @@ public interface IFileSystem
|
||||
|
||||
long GetFileSize(string path);
|
||||
|
||||
/// <summary>A file's time and size in one look; null when there is no file.</summary>
|
||||
(DateTime LastWriteUtc, long Size)? LastWriteAndSize(string path);
|
||||
|
||||
bool IsExecutable(string path);
|
||||
|
||||
/// <summary>Where the symbolic link at <paramref name="path"/> points, or null for anything else.</summary>
|
||||
string? LinkTarget(string path);
|
||||
|
||||
/// <summary>Makes <paramref name="path"/> a symbolic link to the directory <paramref name="target"/>.</summary>
|
||||
void CreateDirectoryLink(string path, string target);
|
||||
|
||||
void MoveDirectory(string sourcePath, string destinationPath);
|
||||
}
|
||||
|
||||
public static class FileSystemExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Copies a file that could not be read beside itself, before something replaces
|
||||
/// it: it may hold what was not read. Throws when it cannot; then it must stay.
|
||||
/// </summary>
|
||||
public static void KeepAside(this IFileSystem files, string path, IDiagnosticsLog log)
|
||||
{
|
||||
if (!files.FileExists(path))
|
||||
return;
|
||||
|
||||
string aside = path + ".unreadable";
|
||||
for (int n = 2; files.FileExists(aside); n++)
|
||||
aside = $"{path}.unreadable-{n}";
|
||||
|
||||
files.CopyFile(path, aside, overwrite: false);
|
||||
log.Log($"{path} could not be read; a copy is kept as {aside}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,12 +20,13 @@ public interface IProcessHandle
|
||||
public interface IProcessLauncher
|
||||
{
|
||||
/// <summary>
|
||||
/// Starts a process. With captureErrors its error stream is kept for the
|
||||
/// handle - only for a process the starter outlives: a write to a pipe nobody
|
||||
/// reads any more can kill the writer.
|
||||
/// Starts a process, with <paramref name="environment"/> added to the starter's
|
||||
/// own. With captureErrors its error stream is kept for the handle - only for a
|
||||
/// process the starter outlives: a write to a pipe nobody reads any more can
|
||||
/// kill the writer.
|
||||
/// </summary>
|
||||
IProcessHandle? Start(string executablePath, IReadOnlyList<string> arguments, string? workingDirectory,
|
||||
bool captureErrors, out string? error);
|
||||
IReadOnlyDictionary<string, string> environment, bool captureErrors, out string? error);
|
||||
|
||||
bool OpenInShell(string pathOrUrl);
|
||||
|
||||
|
||||
@@ -11,4 +11,7 @@ public interface ISceneryRepository
|
||||
IReadOnlyList<Scenery> LoadAll(IProgress<SceneryLoadProgress>? progress = null);
|
||||
|
||||
Scenery? Load(string path);
|
||||
|
||||
/// <summary>The scenery files there are now; null while their folder is away.</summary>
|
||||
IReadOnlyList<string>? ListFiles();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using StarterNG.Application.Abstractions;
|
||||
using StarterNG.Domain.Settings;
|
||||
using StarterNG.Infrastructure;
|
||||
using StarterNG.Infrastructure.Adapters;
|
||||
using StarterNG.Infrastructure.Sceneries;
|
||||
using StarterNG.Infrastructure.Settings;
|
||||
@@ -25,37 +27,53 @@ public sealed class AppServices
|
||||
|
||||
var log = new FileDiagnosticsLog(files, clock, paths);
|
||||
Log = log;
|
||||
Random = new SystemRandomSource();
|
||||
var random = new SystemRandomSource();
|
||||
Processes = new SystemProcessLauncher(log);
|
||||
Localization = new LocalizationService(log);
|
||||
Sceneries = new SceneryRepository(files, paths, log, new SceneryParser(clock, Random));
|
||||
SceneryImages = new SceneryImageLocator(files);
|
||||
MiniTextures = new MiniTextureIndex(files, paths);
|
||||
var settingsPaths = new SettingsPaths(environment, paths);
|
||||
SettingsPaths = settingsPaths;
|
||||
// The player's own first: the game folder may need rights to write to, the user folder never.
|
||||
Localization = new LocalizationService(log, folders: new LanguageFolders(
|
||||
new[]
|
||||
{
|
||||
Path.Combine(settingsPaths.UserConfigDirectory(), "starter", "lang"),
|
||||
paths.FromRoot("starter", "lang"),
|
||||
LocalizationService.LangDirectory,
|
||||
paths.FromRoot("startercfg", "lang")
|
||||
},
|
||||
paths.FromRoot("starter"),
|
||||
paths.FromRoot("lang")));
|
||||
Sceneries = new SceneryRepository(files, paths, log, new SceneryParser(clock, random));
|
||||
SceneryImages = new SceneryImageLocator(files);
|
||||
MiniTextures = new MiniTextureIndex(files, paths);
|
||||
|
||||
// textures.txt stays the truth; the starter reads its own JSON copy of it
|
||||
// and rebuilds that copy whenever the installation has moved on.
|
||||
TexturesTxt = new TexturesTxtVehicleRepository(files, paths, log, new TexturesTxtParser());
|
||||
VehicleJson = new VehicleJsonSerializer();
|
||||
VehicleCache = new VehicleCacheRepository(files, paths, log, TexturesTxt, VehicleJson,
|
||||
var texturesTxt = new TexturesTxtVehicleRepository(files, paths, log, new TexturesTxtParser());
|
||||
var vehicleJson = new VehicleJsonSerializer();
|
||||
Vehicles = new VehicleCacheRepository(files, paths, log, texturesTxt, vehicleJson,
|
||||
settingsPaths.VehicleCacheDirectory());
|
||||
Vehicles = VehicleCache;
|
||||
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; }
|
||||
|
||||
/// <summary>The same object as <see cref="Vehicles"/>, typed for the developer tab.</summary>
|
||||
public VehicleCacheRepository VehicleCache { get; }
|
||||
|
||||
/// <summary>The legacy reader behind the cache, for tools that go to the source.</summary>
|
||||
public TexturesTxtVehicleRepository TexturesTxt { get; }
|
||||
|
||||
public VehicleJsonSerializer VehicleJson { get; }
|
||||
|
||||
public IPhysicsRepository Physics { get; }
|
||||
|
||||
public MissingAssetScanner MissingAssets { get; }
|
||||
|
||||
public ISceneryTranslations SceneryTexts { get; }
|
||||
|
||||
public TimetableLocator Timetables { get; }
|
||||
@@ -123,6 +131,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; }
|
||||
}
|
||||
|
||||
@@ -46,5 +46,17 @@ public sealed class AppState
|
||||
}
|
||||
}
|
||||
|
||||
private volatile bool _simulatorRunning;
|
||||
|
||||
/// <summary>
|
||||
/// The simulator the starter watches is still running: the one it started, not
|
||||
/// any of the same name. Read off the UI thread too.
|
||||
/// </summary>
|
||||
public bool SimulatorRunning
|
||||
{
|
||||
get => _simulatorRunning;
|
||||
set => _simulatorRunning = value;
|
||||
}
|
||||
|
||||
public void NotifyChanged() => Changed?.Invoke();
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ public sealed class FavoriteSceneries
|
||||
private readonly string _path;
|
||||
private readonly HashSet<string> _names = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// The file holds more than _names does: it goes aside before anything replaces it.
|
||||
private bool _unread;
|
||||
|
||||
public FavoriteSceneries(IFileSystem files, IDiagnosticsLog log, string path)
|
||||
{
|
||||
_files = files;
|
||||
@@ -52,6 +55,7 @@ public sealed class FavoriteSceneries
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Log($"reading {_path}", ex);
|
||||
_unread = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +66,12 @@ public sealed class FavoriteSceneries
|
||||
if (Path.GetDirectoryName(_path) is { Length: > 0 } directory)
|
||||
_files.CreateDirectory(directory);
|
||||
|
||||
_files.WriteAllText(_path, string.Join('\n', _names.Order(StringComparer.OrdinalIgnoreCase)));
|
||||
if (_unread)
|
||||
{
|
||||
_files.KeepAside(_path, _log);
|
||||
_unread = false;
|
||||
}
|
||||
_files.WriteAllTextAtomic(_path, string.Join('\n', _names.Order(StringComparer.OrdinalIgnoreCase)));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -47,10 +47,19 @@ public sealed class GameLibrary
|
||||
return;
|
||||
|
||||
progress?.Report(new LoadStatus(0, LoadPhase.Vehicles, "textures.txt"));
|
||||
|
||||
// Whatever goes wrong with the rolling stock, the sceneries still get their turn.
|
||||
try
|
||||
{
|
||||
if (_vehicles.Load(Vehicles) == 0)
|
||||
_log.Log("dynamic: brak plikow textures.txt");
|
||||
|
||||
Parallel.Invoke(_minis.Preload, _physics.Preload);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Log("dynamic: loading the rolling stock", ex);
|
||||
}
|
||||
|
||||
ReloadSceneries(progress is null
|
||||
? null
|
||||
|
||||
56
StarterNG/Application/GraphicsGroupLevels.cs
Normal file
56
StarterNG/Application/GraphicsGroupLevels.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StarterNG.Domain.Settings;
|
||||
|
||||
namespace StarterNG.Application;
|
||||
|
||||
/// <summary>Levels of detail part by part: what a level holds for one <see cref="GraphicsGroup"/>, the rest as it is.</summary>
|
||||
public sealed class GraphicsGroupLevels
|
||||
{
|
||||
private readonly SettingsStore _store;
|
||||
|
||||
public GraphicsGroupLevels(SettingsStore store) => _store = store;
|
||||
|
||||
/// <summary>
|
||||
/// The level each group is at in <paramref name="settings"/>, null where it is at none. At
|
||||
/// Ultra the level under it fits too, as it leaves alone what only Ultra sets: the highest counts.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<GraphicsGroup, GraphicsPreset?> LevelsOf(SimulatorSettings settings)
|
||||
{
|
||||
var now = _store.Render(settings);
|
||||
var rendered = GraphicsPreset.All.Select(level => (Level: level, Keys: RenderAt(level, settings))).ToList();
|
||||
return GraphicsGroup.All.ToDictionary(group => group, group =>
|
||||
(GraphicsPreset?)rendered.LastOrDefault(level => Same(group, level.Keys, now)).Level);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A copy of <paramref name="settings"/> with the group at <paramref name="level"/>. What only
|
||||
/// an end of the scale sets goes back to the simulator's own on leaving that end.
|
||||
/// </summary>
|
||||
public SimulatorSettings Apply(GraphicsGroup group, GraphicsPreset level, SimulatorSettings settings)
|
||||
{
|
||||
var current = LevelsOf(settings)[group];
|
||||
var source = settings.Copy();
|
||||
if (level.Extras is null && current?.Extras is not null)
|
||||
GraphicsExtras.None.ApplyTo(source);
|
||||
if (level.Top is null && current?.Top is not null)
|
||||
GraphicsTop.None.ApplyTo(source);
|
||||
level.ApplyTo(source);
|
||||
return _store.WithKeysOf(settings, source, group.Keys);
|
||||
}
|
||||
|
||||
private IReadOnlyDictionary<string, string> RenderAt(GraphicsPreset level, SimulatorSettings settings)
|
||||
{
|
||||
var copy = settings.Copy();
|
||||
level.ApplyTo(copy);
|
||||
return _store.Render(copy);
|
||||
}
|
||||
|
||||
private static bool Same(GraphicsGroup group, IReadOnlyDictionary<string, string> one, IReadOnlyDictionary<string, string> other) =>
|
||||
group.Keys.All(key => string.Equals(ValueIn(one, key), ValueIn(other, key), StringComparison.Ordinal));
|
||||
|
||||
// A key the file does not set is at the simulator's own value.
|
||||
private static string? ValueIn(IReadOnlyDictionary<string, string> keys, string key) =>
|
||||
keys.TryGetValue(key, out string? value) ? value : SimulatorKeys.All.FirstOrDefault(known => known.Key == key)?.Default;
|
||||
}
|
||||
350
StarterNG/Application/HaslerTapes.cs
Normal file
350
StarterNG/Application/HaslerTapes.cs
Normal file
@@ -0,0 +1,350 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using StarterNG.Application.Abstractions;
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Domain.Sceneries;
|
||||
using StarterNG.Domain.Tapes;
|
||||
using StarterNG.Infrastructure.Adapters;
|
||||
using StarterNG.Infrastructure.Sceneries;
|
||||
|
||||
namespace StarterNG.Application;
|
||||
|
||||
public sealed record TapeFile(string Path, string Vehicle, DateTime LastWriteUtc, long Size,
|
||||
TapeRole Role = TapeRole.Other);
|
||||
|
||||
/// <summary>The black box of a run: the whole session in one file, a new one each run.</summary>
|
||||
public sealed record BlackBoxRecording(string Path, DateTime LastWriteUtc, long Size);
|
||||
|
||||
/// <summary>
|
||||
/// One run of the simulator and what is left of its tapes: a later run overwrites those of the
|
||||
/// vehicles it has too. Its black box stays, newest first should there be more.
|
||||
/// </summary>
|
||||
public sealed record TapeSession(TapeLaunch? Launch, DateTime StartedUtc, DateTime LastWrittenUtc, bool Running,
|
||||
IReadOnlyList<TapeFile> Tapes, IReadOnlyList<BlackBoxRecording> BlackBoxes);
|
||||
|
||||
/// <summary>What is known about a tape beyond its samples: the clock it started at, the recorder it is read as, the scenario and the train.</summary>
|
||||
public sealed record TapeContext(TimeSpan? StartClock, RecorderKind Recorder,
|
||||
string? Scenery = null, string? Train = null);
|
||||
|
||||
/// <summary>The tapes the simulator left in physicslog/ - one per driven vehicle, overwritten every run - and the black boxes, one a run.</summary>
|
||||
public sealed class HaslerTapes
|
||||
{
|
||||
private const int RememberedLaunches = 30;
|
||||
private static readonly TimeSpan QuietBetweenRuns = TimeSpan.FromMinutes(2);
|
||||
|
||||
private readonly IFileSystem _files;
|
||||
private readonly IGamePaths _paths;
|
||||
private readonly IDiagnosticsLog _log;
|
||||
private readonly IClock _clock;
|
||||
private readonly TimetableLocator _timetables;
|
||||
private readonly Func<bool> _simulatorRunning;
|
||||
private readonly object _playerLock = new();
|
||||
private IReadOnlyList<TapeLaunch>? _launches;
|
||||
private (DateTime Written, long Size) _launchesAt;
|
||||
private PlayerVehicles _player = PlayerVehicles.None;
|
||||
private DateTime _playerLaunch;
|
||||
private string? _playerLog;
|
||||
private long _playerLogRead;
|
||||
|
||||
public HaslerTapes(IFileSystem files, IGamePaths paths, IDiagnosticsLog log, IClock clock,
|
||||
TimetableLocator timetables, Func<bool> simulatorRunning)
|
||||
{
|
||||
_files = files;
|
||||
_paths = paths;
|
||||
_log = log;
|
||||
_clock = clock;
|
||||
_timetables = timetables;
|
||||
_simulatorRunning = simulatorRunning;
|
||||
}
|
||||
|
||||
public string Folder => _paths.FromRoot("physicslog");
|
||||
|
||||
/// <summary>Not a .dat, so the simulator's own logs are never mistaken for it, nor it for one.</summary>
|
||||
private string LaunchFile => Path.Combine(Folder, "starter-launch.txt");
|
||||
|
||||
/// <summary>
|
||||
/// The simulator logs every driven vehicle under its scenery name, with the
|
||||
/// time counted from the start; the clock it starts at and what each name is
|
||||
/// are only known here. So is whose tape is the player's: a scenery with no
|
||||
/// consist picked starts with the free camera, in no vehicle at all.
|
||||
/// </summary>
|
||||
public void RecordLaunch(Scenery scenery, Trainset? trainset, string? driven)
|
||||
{
|
||||
var clock = TimeSpan.TryParseExact(scenery.Weather.ScenarioTimeOverride, @"h\:mm",
|
||||
CultureInfo.InvariantCulture, out var over)
|
||||
? over
|
||||
: TimeSpan.TryParseExact(scenery.Weather.Time, @"h\:mm", CultureInfo.InvariantCulture, out var time)
|
||||
? time
|
||||
: TimeSpan.Zero;
|
||||
|
||||
var own = trainset?.Vehicles ?? new List<Dynamic>();
|
||||
var vehicles = scenery.Trainsets.SelectMany(t => t.Vehicles).Concat(own)
|
||||
.Select(v => new TapeVehicle(v.Name, v.DataFolder ?? "", v.MmdFile ?? ""));
|
||||
RecordLaunch(Path.GetFileName(scenery.Path), clock, vehicles, driven,
|
||||
driven is null ? null : own.Select(v => v.Name), scenery.Name,
|
||||
driven is null ? null : _timetables.TrainNumber(scenery, trainset?.Name));
|
||||
}
|
||||
|
||||
/// <summary>Notes down, next to the logs the simulator is about to write, what only the starter knows.</summary>
|
||||
public void RecordLaunch(string scenery, TimeSpan startClock, IEnumerable<TapeVehicle> vehicles,
|
||||
string? driven = null, IEnumerable<string>? consist = null,
|
||||
string? sceneryName = null, string? train = null)
|
||||
{
|
||||
var launch = new TapeLaunch(_clock.UtcNow, scenery, startClock,
|
||||
vehicles.Where(v => !string.IsNullOrEmpty(v.Name))
|
||||
.DistinctBy(v => v.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList(),
|
||||
driven, consist?.ToList(), sceneryName, train);
|
||||
try
|
||||
{
|
||||
var noted = Launches().ToList();
|
||||
// The log is about to begin anew: what it says of the run before stays with that run.
|
||||
if (noted.Count > 0)
|
||||
noted[^1] = noted[^1] with { Player = PlayerIn(noted[^1]) };
|
||||
noted.Add(launch);
|
||||
|
||||
_files.CreateDirectory(Folder);
|
||||
_files.WriteAllTextAtomic(LaunchFile,
|
||||
string.Concat(noted.TakeLast(RememberedLaunches).Select(l => l.Format())));
|
||||
lock (_playerLock)
|
||||
_launches = null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Log($"writing {LaunchFile}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public void FollowLastRun()
|
||||
{
|
||||
if (Launches().LastOrDefault() is { } launch)
|
||||
PlayerIn(launch);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The launches noted so far, oldest first. Read again only when the file has
|
||||
/// changed: this runs every few seconds next to a running simulator.
|
||||
/// </summary>
|
||||
private IReadOnlyList<TapeLaunch> Launches()
|
||||
{
|
||||
lock (_playerLock)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_files.FileExists(LaunchFile))
|
||||
return Array.Empty<TapeLaunch>();
|
||||
|
||||
var at = (_files.GetLastWriteTimeUtc(LaunchFile), _files.GetFileSize(LaunchFile));
|
||||
if (_launches is null || at != _launchesAt)
|
||||
{
|
||||
_launches = TapeLaunch.ParseAll(_files.ReadAllText(LaunchFile, Encoding.UTF8));
|
||||
_launchesAt = at;
|
||||
}
|
||||
return _launches;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Log($"reading {LaunchFile}", ex);
|
||||
return Array.Empty<TapeLaunch>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The simulator's log of this launch: log.txt or, with logs kept, one of its own in logs/.</summary>
|
||||
private string? LogOf(TapeLaunch launch)
|
||||
{
|
||||
var logs = new List<string> { _paths.FromRoot("log.txt") };
|
||||
string kept = _paths.FromRoot("logs");
|
||||
if (_files.DirectoryExists(kept))
|
||||
logs.AddRange(_files.GetFiles(kept, "log (*.txt"));
|
||||
|
||||
return logs.Where(_files.FileExists)
|
||||
.Where(log => launch.Covers(_files.GetLastWriteTimeUtc(log)))
|
||||
.OrderByDescending(_files.GetLastWriteTimeUtc)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Where the player has been in this launch. Only what the log has gained since
|
||||
/// the last look is read: this runs every few seconds next to a running simulator.
|
||||
/// </summary>
|
||||
private PlayerVehicles PlayerIn(TapeLaunch launch)
|
||||
{
|
||||
lock (_playerLock)
|
||||
{
|
||||
string? path = null;
|
||||
try
|
||||
{
|
||||
path = LogOf(launch);
|
||||
if (path is null)
|
||||
return PlayerVehicles.Unknown;
|
||||
|
||||
long size = _files.GetFileSize(path);
|
||||
if (launch.LaunchedUtc != _playerLaunch || path != _playerLog || size < _playerLogRead)
|
||||
{
|
||||
_player = PlayerVehicles.None;
|
||||
_playerLaunch = launch.LaunchedUtc;
|
||||
_playerLog = path;
|
||||
_playerLogRead = 0;
|
||||
}
|
||||
if (size == _playerLogRead)
|
||||
return _player;
|
||||
|
||||
using var stream = _files.OpenRead(path);
|
||||
stream.Seek(_playerLogRead, SeekOrigin.Begin);
|
||||
using var added = new MemoryStream();
|
||||
stream.CopyTo(added);
|
||||
byte[] bytes = added.ToArray();
|
||||
|
||||
// A line still being written waits for the next look.
|
||||
int end = Array.LastIndexOf(bytes, (byte)'\n') + 1;
|
||||
if (end > 0)
|
||||
{
|
||||
_player = _player.After(LegacyText.Decode(bytes[..end]));
|
||||
_playerLogRead += end;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Log($"reading {path}", ex);
|
||||
}
|
||||
return _player;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The vehicle the player is in, or went off in while the log has not said otherwise, if the starter launched one.</summary>
|
||||
public string? DrivenVehicle =>
|
||||
Launches().LastOrDefault() is { } launch ? PlayerIn(launch).Current ?? launch.Driven : null;
|
||||
|
||||
public TapeContext ContextOf(TapeFile file)
|
||||
{
|
||||
var launch = Launches().LastOrDefault(l => l.Covers(file.LastWriteUtc));
|
||||
if (launch is null)
|
||||
return new TapeContext(null, Recorders.Guess(null, file.Vehicle));
|
||||
|
||||
var vehicle = launch.Find(file.Vehicle);
|
||||
string scenery = string.IsNullOrWhiteSpace(launch.SceneryName) ? launch.Scenery : launch.SceneryName;
|
||||
return new TapeContext(launch.StartClock, Recorders.Guess(vehicle, file.Vehicle),
|
||||
string.IsNullOrWhiteSpace(scenery) ? null : scenery,
|
||||
launch.RoleOf(file.Vehicle) == TapeRole.Other ? null : launch.Train);
|
||||
}
|
||||
|
||||
/// <summary>Every tape, session by session.</summary>
|
||||
public IReadOnlyList<TapeFile> List() => Sessions().SelectMany(session => session.Tapes).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// The runs the tapes came from, the running or latest one first. A tape belongs
|
||||
/// to the last launch before it was written; one written before any known launch
|
||||
/// goes with those written about the same time, as a run writes them all at its end.
|
||||
/// </summary>
|
||||
public IReadOnlyList<TapeSession> Sessions()
|
||||
{
|
||||
try
|
||||
{
|
||||
var launches = Launches();
|
||||
var tapes = _files.DirectoryExists(Folder)
|
||||
? _files.GetFiles(Folder, "*.dat").Select(ReadTape).ToList()
|
||||
: new List<TapeFile>();
|
||||
var behind = tapes.GroupBy(tape => launches.LastOrDefault(l => l.Covers(tape.LastWriteUtc)))
|
||||
.Where(group => group.Key is not null)
|
||||
.ToDictionary(group => group.Key!, group => group.ToList());
|
||||
var boxes = _files.DirectoryExists(Folder)
|
||||
? _files.GetFiles(Folder, "*.blackbox").Select(ReadBlackBox).ToList()
|
||||
: new List<BlackBoxRecording>();
|
||||
var boxesBehind = boxes.GroupBy(box => launches.LastOrDefault(l => l.Covers(box.LastWriteUtc)))
|
||||
.Where(group => group.Key is not null)
|
||||
.ToDictionary(group => group.Key!, group => group.ToList());
|
||||
|
||||
var sessions = new List<TapeSession>();
|
||||
foreach (var launch in launches)
|
||||
{
|
||||
bool last = ReferenceEquals(launch, launches[^1]);
|
||||
bool running = last && _simulatorRunning();
|
||||
if (!running && !behind.ContainsKey(launch) && !boxesBehind.ContainsKey(launch))
|
||||
continue;
|
||||
|
||||
var player = last ? PlayerIn(launch) : launch.Player ?? PlayerVehicles.Unknown;
|
||||
sessions.Add(Session(launch, running, behind.GetValueOrDefault(launch, new List<TapeFile>())
|
||||
.Select(t => t with { Role = launch.RoleOf(t.Vehicle, player) }),
|
||||
boxesBehind.GetValueOrDefault(launch, new List<BlackBoxRecording>())));
|
||||
}
|
||||
|
||||
// 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)))
|
||||
.Select(b => (Written: b.LastWriteUtc, Tape: (TapeFile?)null, Box: (BlackBoxRecording?)b)))
|
||||
.OrderBy(file => file.Written);
|
||||
var run = new List<TapeFile>();
|
||||
var runBoxes = new List<BlackBoxRecording>();
|
||||
DateTime? lastWritten = null;
|
||||
foreach (var (written, tape, box) in loose)
|
||||
{
|
||||
if (lastWritten is { } before && written - before > QuietBetweenRuns)
|
||||
{
|
||||
sessions.Add(Session(null, running: false, run, runBoxes));
|
||||
run = new List<TapeFile>();
|
||||
runBoxes = new List<BlackBoxRecording>();
|
||||
}
|
||||
if (tape is not null)
|
||||
run.Add(tape);
|
||||
if (box is not null)
|
||||
runBoxes.Add(box);
|
||||
lastWritten = written;
|
||||
}
|
||||
if (lastWritten is not null)
|
||||
sessions.Add(Session(null, running: false, run, runBoxes));
|
||||
|
||||
return sessions.OrderByDescending(session => session.Running)
|
||||
.ThenByDescending(session => session.LastWrittenUtc)
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Log($"listing {Folder}", ex);
|
||||
return Array.Empty<TapeSession>();
|
||||
}
|
||||
}
|
||||
|
||||
private TapeFile ReadTape(string path) =>
|
||||
new(path, Path.GetFileNameWithoutExtension(path), _files.GetLastWriteTimeUtc(path), _files.GetFileSize(path));
|
||||
|
||||
private BlackBoxRecording ReadBlackBox(string path) =>
|
||||
new(path, _files.GetLastWriteTimeUtc(path), _files.GetFileSize(path));
|
||||
|
||||
/// <summary>The player's own tapes first: every manned vehicle leaves one.</summary>
|
||||
private static TapeSession Session(TapeLaunch? launch, bool running, IEnumerable<TapeFile> tapes,
|
||||
IEnumerable<BlackBoxRecording> boxes)
|
||||
{
|
||||
var listed = tapes.OrderByDescending(tape => tape.Role).ThenByDescending(tape => tape.LastWriteUtc).ToList();
|
||||
var recorded = boxes.OrderByDescending(box => box.LastWriteUtc).ToList();
|
||||
var written = listed.Select(tape => tape.LastWriteUtc).Concat(recorded.Select(box => box.LastWriteUtc)).ToList();
|
||||
var started = launch?.LaunchedUtc ?? written.Min();
|
||||
return new TapeSession(launch, started, written.Count > 0 ? written.Max() : started, running, listed, recorded);
|
||||
}
|
||||
|
||||
public HaslerTape? Load(TapeFile file)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Through OpenRead, which does not mind the simulator still writing the file.
|
||||
using var stream = _files.OpenRead(file.Path);
|
||||
using var reader = new StreamReader(stream, Encoding.ASCII);
|
||||
return HaslerTape.Parse(reader.ReadToEnd());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Log($"reading {file.Path}", ex);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
87
StarterNG/Application/SaveSceneryTrainset.cs
Normal file
87
StarterNG/Application/SaveSceneryTrainset.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StarterNG.Application.Abstractions;
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Domain.Sceneries;
|
||||
using StarterNG.Infrastructure.Adapters;
|
||||
|
||||
namespace StarterNG.Application;
|
||||
|
||||
public enum SceneryTrainsetSaveOutcome
|
||||
{
|
||||
Saved,
|
||||
|
||||
/// <summary>The file no longer holds the trainset as it was read - edited outside meanwhile.</summary>
|
||||
NotFound,
|
||||
|
||||
Failed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a consist edited in the depot into the scenery file itself, so it is
|
||||
/// there the next time too - not only in the copy written for one start. The
|
||||
/// file's first version is kept beside it, once - under a name of the starter's
|
||||
/// own, since the simulator's editor keeps its <c>.scn.bak</c> there and rewrites
|
||||
/// it on every save.
|
||||
/// </summary>
|
||||
public sealed class SaveSceneryTrainset
|
||||
{
|
||||
public const string BackupSuffix = ".starter.bak";
|
||||
|
||||
private readonly IFileSystem _files;
|
||||
private readonly IDiagnosticsLog _log;
|
||||
|
||||
public SaveSceneryTrainset(IFileSystem files, IDiagnosticsLog log)
|
||||
{
|
||||
_files = files;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public (SceneryTrainsetSaveOutcome Outcome, string? Detail) Execute(Scenery scenery, Trainset trainset)
|
||||
{
|
||||
string path = FileOf(scenery, trainset);
|
||||
try
|
||||
{
|
||||
byte[] bytes = _files.ReadAllBytes(path);
|
||||
var (encoding, bom) = LegacyText.Detect(bytes);
|
||||
string text = encoding.GetString(bytes, bom.Length, bytes.Length - bom.Length);
|
||||
|
||||
// The trainset's own line ends, if it has any: a file can hold both kinds.
|
||||
string lines = trainset.RawEntry.Contains('\n') ? trainset.RawEntry : text;
|
||||
string newline = lines.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n";
|
||||
string entry = SceneryTrainsetText.Compose(trainset, newline);
|
||||
var sameFile = scenery.Trainsets.Where(t => t.SourceFile == trainset.SourceFile).ToList();
|
||||
if (SceneryTrainsetText.Replace(text, trainset, sameFile, entry) is not { } updated)
|
||||
return (SceneryTrainsetSaveOutcome.NotFound, null);
|
||||
|
||||
string backup = path + BackupSuffix;
|
||||
if (!_files.FileExists(backup))
|
||||
_files.WriteAllBytesAtomic(backup, bytes);
|
||||
|
||||
_files.WriteAllBytesAtomic(path, bom.Concat(encoding.GetBytes(updated)).ToArray());
|
||||
|
||||
// Written here, so no change from outside for the scenery to follow.
|
||||
scenery.Sources = new Dictionary<string, FileStamp?>(scenery.Sources) { [path] = FileStamp.Of(_files, path) };
|
||||
|
||||
// What the file holds now is what the next save looks for.
|
||||
trainset.RawEntry = entry;
|
||||
trainset.OriginalVelocity = trainset.Velocity;
|
||||
return (SceneryTrainsetSaveOutcome.Saved, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Log($"saving trainset {trainset.Name} to {path}", ex);
|
||||
return (SceneryTrainsetSaveOutcome.Failed, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The scenery file, or the included one the trainset came from - named, as
|
||||
/// includes are, from the scenery folder the scenery itself is in.
|
||||
/// </summary>
|
||||
public static string FileOf(Scenery scenery, Trainset trainset) =>
|
||||
trainset.SourceFile is { } file
|
||||
? System.IO.Path.Combine(System.IO.Path.GetDirectoryName(scenery.Path) ?? "", file.Replace('\\', '/'))
|
||||
: scenery.Path;
|
||||
}
|
||||
204
StarterNG/Application/SceneryFileWatch.cs
Normal file
204
StarterNG/Application/SceneryFileWatch.cs
Normal file
@@ -0,0 +1,204 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StarterNG.Application.Abstractions;
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Domain.Sceneries;
|
||||
|
||||
namespace StarterNG.Application;
|
||||
|
||||
/// <summary>What became of a scenery's files since they were read.</summary>
|
||||
/// <param name="Current">The scenery as it is held; null for a file new in the folder.</param>
|
||||
/// <param name="Fresh">As its files have it now; null for one gone from the folder.</param>
|
||||
public sealed record SceneryChange(Scenery? Current, Scenery? Fresh);
|
||||
|
||||
public enum SceneryVerdict
|
||||
{
|
||||
/// <summary>Nothing to do about it, for now or for good.</summary>
|
||||
Leave,
|
||||
|
||||
/// <summary>The library can take it in.</summary>
|
||||
Take,
|
||||
|
||||
/// <summary>Taking it in loses edits, so that is for someone to say.</summary>
|
||||
AskFirst
|
||||
}
|
||||
|
||||
/// <summary>What is to become of a change.</summary>
|
||||
/// <param name="Lost">The consists with unsaved edits that <see cref="SceneryVerdict.AskFirst"/> is about.</param>
|
||||
public sealed record SceneryJudgement(SceneryVerdict Verdict, IReadOnlyList<Trainset> Lost)
|
||||
{
|
||||
public static readonly SceneryJudgement Leave = new(SceneryVerdict.Leave, Array.Empty<Trainset>());
|
||||
public static readonly SceneryJudgement Take = new(SceneryVerdict.Take, Array.Empty<Trainset>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keeps the sceneries in step with their files when those change outside the
|
||||
/// starter - in a text editor, in the simulator's editor - without anyone having
|
||||
/// to ask for a reload.
|
||||
/// </summary>
|
||||
public sealed class SceneryFileWatch
|
||||
{
|
||||
private readonly IFileSystem _files;
|
||||
private readonly ISceneryRepository _sceneries;
|
||||
private readonly GameLibrary _library;
|
||||
private readonly AppState _state;
|
||||
|
||||
// Files that would not load, and how they stood then: not tried, and not logged,
|
||||
// again before something about them changes.
|
||||
private readonly Dictionary<string, string> _unreadable = new();
|
||||
|
||||
private HashSet<string> _away = new();
|
||||
|
||||
public SceneryFileWatch(IFileSystem files, ISceneryRepository sceneries, GameLibrary library, AppState state)
|
||||
{
|
||||
_files = files;
|
||||
_sceneries = sceneries;
|
||||
_library = library;
|
||||
_state = state;
|
||||
}
|
||||
|
||||
/// <summary>What has changed among the files of <paramref name="held"/>. Off the UI thread, one look at a time.</summary>
|
||||
public IReadOnlyList<SceneryChange> Look(IReadOnlyList<Scenery> held)
|
||||
{
|
||||
var changes = new List<SceneryChange>();
|
||||
if (_sceneries.ListFiles() is not { } files)
|
||||
return changes;
|
||||
|
||||
var there = files.ToHashSet();
|
||||
var away = new HashSet<string>();
|
||||
foreach (var scenery in held)
|
||||
{
|
||||
if (!there.Contains(scenery.Path))
|
||||
{
|
||||
// A file being replaced is away for a moment: gone only if still so next time.
|
||||
away.Add(scenery.Path);
|
||||
if (_away.Contains(scenery.Path))
|
||||
changes.Add(new SceneryChange(scenery, null));
|
||||
}
|
||||
else if (IsStale(scenery) && LoadOnce(scenery.Path, scenery.Sources.Keys) is { } fresh)
|
||||
{
|
||||
changes.Add(new SceneryChange(scenery, fresh));
|
||||
}
|
||||
}
|
||||
_away = away;
|
||||
|
||||
var known = held.Select(scenery => scenery.Path).ToHashSet();
|
||||
foreach (string file in files.Where(file => !known.Contains(file)))
|
||||
if (LoadOnce(file, new[] { file }) is { } added)
|
||||
changes.Add(new SceneryChange(null, added));
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
/// <summary>Whether any of the files it was read from is no longer as it was.</summary>
|
||||
public bool IsStale(Scenery scenery) =>
|
||||
scenery.Sources.Any(source => FileStamp.Of(_files, source.Key) != source.Value);
|
||||
|
||||
private Scenery? LoadOnce(string path, IEnumerable<string> sources)
|
||||
{
|
||||
string stood = string.Join('|', sources.Select(source => FileStamp.Of(_files, source)));
|
||||
if (_unreadable.TryGetValue(path, out string? before) && before == stood)
|
||||
return null;
|
||||
|
||||
var loaded = _sceneries.Load(path);
|
||||
if (loaded is null)
|
||||
_unreadable[path] = stood;
|
||||
else
|
||||
_unreadable.Remove(path);
|
||||
return loaded;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Puts what was held into the scenery read anew. A consist whose text is still
|
||||
/// what it was goes over as the object it is, with its edits and its undo steps;
|
||||
/// the choices made by hand go with them.
|
||||
/// </summary>
|
||||
/// <returns>The consists with unsaved edits that have no place there: their text changed too.</returns>
|
||||
public static IReadOnlyList<Trainset> CarryOver(Scenery current, Scenery fresh)
|
||||
{
|
||||
var waiting = current.Trainsets.ToList();
|
||||
for (int i = 0; i < fresh.Trainsets.Count; i++)
|
||||
{
|
||||
var read = fresh.Trainsets[i];
|
||||
int at = waiting.FindIndex(t => t.SourceFile == read.SourceFile && t.RawEntry == read.RawEntry);
|
||||
if (at < 0)
|
||||
continue;
|
||||
|
||||
fresh.Trainsets[i] = waiting[at];
|
||||
waiting.RemoveAt(at);
|
||||
}
|
||||
|
||||
fresh.Weather.KeepChoicesOf(current.Weather);
|
||||
foreach (var include in fresh.Includes)
|
||||
if (current.Includes.FirstOrDefault(i => i.FilePath == include.FilePath) is { } earlier)
|
||||
include.Selected = earlier.Selected;
|
||||
|
||||
return waiting.Where(SceneryTrainsetText.IsModified).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// On the UI thread, once for each change and just before acting on it: what a
|
||||
/// look found may be out of date by then. A scenery read anew has what was held
|
||||
/// put into it here.
|
||||
/// </summary>
|
||||
public SceneryJudgement Judge(SceneryChange change)
|
||||
{
|
||||
var sceneries = _library.Sceneries;
|
||||
if (change.Current is not { } current)
|
||||
return sceneries.Any(s => s.Path == change.Fresh!.Path) ? SceneryJudgement.Leave : SceneryJudgement.Take;
|
||||
|
||||
// Saved from here since that look, or reloaded by hand.
|
||||
if (!sceneries.Contains(current) || (change.Fresh is not null && !IsStale(current)))
|
||||
return SceneryJudgement.Leave;
|
||||
|
||||
// One whose file went stays with what was edited in it, for that to be saved elsewhere.
|
||||
if (change.Fresh is not { } fresh)
|
||||
return current.Trainsets.Any(SceneryTrainsetText.IsModified) ? SceneryJudgement.Leave : SceneryJudgement.Take;
|
||||
|
||||
var lost = CarryOver(current, fresh);
|
||||
return lost.Count > 0 ? new SceneryJudgement(SceneryVerdict.AskFirst, lost) : SceneryJudgement.Take;
|
||||
}
|
||||
|
||||
/// <summary>On the UI thread: the library, and what is picked in it, take the change in.</summary>
|
||||
public void Apply(SceneryChange change)
|
||||
{
|
||||
var sceneries = _library.Sceneries;
|
||||
if (change.Current is not { } current)
|
||||
{
|
||||
sceneries.Add(change.Fresh!);
|
||||
return;
|
||||
}
|
||||
|
||||
if (change.Fresh is not { } fresh)
|
||||
{
|
||||
sceneries.Remove(current);
|
||||
if (ReferenceEquals(_state.CurrentScenery, current))
|
||||
{
|
||||
_state.CurrentScenery = null;
|
||||
_state.CurrentTrainset = null;
|
||||
_state.StartingVehicleName = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
sceneries[sceneries.IndexOf(current)] = fresh;
|
||||
if (ReferenceEquals(_state.CurrentScenery, current))
|
||||
Follow(current, fresh);
|
||||
}
|
||||
|
||||
private void Follow(Scenery current, Scenery fresh)
|
||||
{
|
||||
// Its text changed, the one of its name stands for it, or the one in its place.
|
||||
var picked = _state.CurrentTrainset;
|
||||
var kept = picked is null || fresh.Trainsets.Contains(picked)
|
||||
? picked
|
||||
: fresh.Trainsets.FirstOrDefault(t => t.Name == picked.Name)
|
||||
?? fresh.Trainsets.ElementAtOrDefault(current.Trainsets.IndexOf(picked));
|
||||
|
||||
_state.CurrentScenery = fresh;
|
||||
_state.CurrentTrainset = kept;
|
||||
if (kept is null || kept.Vehicles.All(v => v.Name != _state.StartingVehicleName))
|
||||
_state.StartingVehicleName = null;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using StarterNG.Application.Abstractions;
|
||||
using StarterNG.Classes;
|
||||
@@ -12,8 +14,20 @@ namespace StarterNG.Application;
|
||||
public interface ISettingsCapture
|
||||
{
|
||||
void CaptureInto(SimulatorSettings settings);
|
||||
|
||||
/// <summary>
|
||||
/// Keys the screen shows otherwise than the settings hold them - a value its
|
||||
/// control cannot show - by key, in the file's terms.
|
||||
/// </summary>
|
||||
IReadOnlyDictionary<string, ShownValue> ShownUnlikeSettings => new Dictionary<string, ShownValue>();
|
||||
}
|
||||
|
||||
/// <summary>A key as the screen shows it, and as the settings held it when it was put there; null for absent.</summary>
|
||||
public sealed record ShownValue(string? Shown, string? Held);
|
||||
|
||||
/// <summary>A key a save changes: its value in the file before and after; null where it has none.</summary>
|
||||
public sealed record IniChange(string Key, string? Before, string? After);
|
||||
|
||||
public sealed class SettingsStore
|
||||
{
|
||||
private readonly IFileSystem _files;
|
||||
@@ -29,6 +43,11 @@ public sealed class SettingsStore
|
||||
private DateTime _configAgeUtc = DateTime.MinValue;
|
||||
private ISettingsCapture? _capture;
|
||||
|
||||
// The settings in the file's terms when it was last read or written. A key
|
||||
// they still put the same way was not changed here, so the file keeps it as
|
||||
// it has it: a value outside what the starter offers, its spacing, its comment.
|
||||
private IReadOnlyDictionary<string, string> _synced = new Dictionary<string, string>();
|
||||
|
||||
public SettingsStore(IFileSystem files, IClock clock, IDiagnosticsLog log, SettingsPaths paths,
|
||||
SettingsSerializer serializer, ExecutableLocator executables)
|
||||
{
|
||||
@@ -45,6 +64,15 @@ public sealed class SettingsStore
|
||||
|
||||
public string LoadedFrom { get; private set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The last read failed, so <see cref="Settings"/> holds defaults, not the file's.
|
||||
/// Nothing is saved until a read works: that would put them in its place.
|
||||
/// </summary>
|
||||
public bool LoadFailed { get; private set; }
|
||||
|
||||
/// <summary>The last save did not reach the file; the log says why.</summary>
|
||||
public bool LastSaveFailed { get; private set; }
|
||||
|
||||
public string SavePath => _savePath;
|
||||
|
||||
public void RegisterCapture(ISettingsCapture capture) => _capture = capture;
|
||||
@@ -57,6 +85,16 @@ public sealed class SettingsStore
|
||||
LoadFrom(source);
|
||||
}
|
||||
|
||||
/// <summary>A player with no settings of their own yet starts at High, in the file at once so the simulator goes by it too.</summary>
|
||||
public void StartAtHighWithoutOwnSettings()
|
||||
{
|
||||
if (LoadFailed || _files.FileExists(_savePath))
|
||||
return;
|
||||
|
||||
GraphicsPreset.High.ApplyTo(Settings);
|
||||
Save();
|
||||
}
|
||||
|
||||
public void LoadFrom(string path)
|
||||
{
|
||||
try
|
||||
@@ -64,17 +102,38 @@ public sealed class SettingsStore
|
||||
_config = _files.FileExists(path)
|
||||
? ConfigFile.Parse(_files.ReadAllText(path, _encoding))
|
||||
: new ConfigFile();
|
||||
LoadFailed = false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Log($"reading {path}", ex);
|
||||
_config = new ConfigFile();
|
||||
LoadFailed = true;
|
||||
}
|
||||
|
||||
_serializer.Read(Settings, _config);
|
||||
_synced = Render(Settings);
|
||||
TouchConfigAge();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a profile into the settings; true when it read cleanly. A read that failed
|
||||
/// would leave defaults to be saved over the real settings, so those are read again.
|
||||
/// </summary>
|
||||
public bool TryLoadProfile(string path)
|
||||
{
|
||||
if (_files.FileExists(path))
|
||||
{
|
||||
LoadFrom(path);
|
||||
if (!LoadFailed)
|
||||
return true;
|
||||
}
|
||||
|
||||
if (LoadFailed)
|
||||
Load();
|
||||
return false;
|
||||
}
|
||||
|
||||
public void CaptureAndSave()
|
||||
{
|
||||
_capture?.CaptureInto(Settings);
|
||||
@@ -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<IniChange> 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");
|
||||
}
|
||||
|
||||
/// <summary>Whether the simulator can have DLSS on this platform at all.</summary>
|
||||
public bool DlssPlatform => _serializer.DlssPlatform;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Every key as <paramref name="settings"/> would put it, in the order they are written.</summary>
|
||||
public IReadOnlyDictionary<string, string> Render(SimulatorSettings settings)
|
||||
{
|
||||
var config = new ConfigFile();
|
||||
_serializer.Write(settings, config);
|
||||
var keys = new OrderedDictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (key, value) in config.Entries())
|
||||
keys[key] = value;
|
||||
return keys;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What saving <paramref name="settings"/> would change in the file: the keys
|
||||
/// changed since it was read, those it lacks, and the ones no simulator reads.
|
||||
/// A key the screen shows otherwise than the file has it is left alone until
|
||||
/// moved. One the file leaves bare or past endconfig counts as lacking.
|
||||
/// </summary>
|
||||
public IReadOnlyList<IniChange> Changes(SimulatorSettings settings)
|
||||
{
|
||||
var now = Render(settings);
|
||||
var shown = _capture?.ShownUnlikeSettings ?? new Dictionary<string, ShownValue>();
|
||||
var changes = new List<IniChange>();
|
||||
var listed = new HashSet<string>(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));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The keys <paramref name="shown"/> puts otherwise than the settings hold them,
|
||||
/// with both values, in the file's terms.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, ShownValue> ShownUnlike(SimulatorSettings shown)
|
||||
{
|
||||
var held = Render(Settings);
|
||||
var now = Render(shown);
|
||||
var unlike = new Dictionary<string, ShownValue>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (string key in held.Keys.Union(now.Keys, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
string? before = held.GetValueOrDefault(key), after = now.GetValueOrDefault(key);
|
||||
if (before != after)
|
||||
unlike[key] = new ShownValue(after, before);
|
||||
}
|
||||
return unlike;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A copy of <paramref name="settings"/> with <paramref name="keys"/> back at
|
||||
/// what the simulator goes by when eu07.ini does not set them.
|
||||
/// </summary>
|
||||
public SimulatorSettings WithDefaults(SimulatorSettings settings, IReadOnlyCollection<string> keys)
|
||||
{
|
||||
var defaults = new SimulatorSettings();
|
||||
_serializer.Read(defaults, new ConfigFile());
|
||||
return WithKeysOf(settings, defaults, keys);
|
||||
}
|
||||
|
||||
/// <summary>A copy of <paramref name="settings"/> with <paramref name="keys"/> as <paramref name="source"/> has them.</summary>
|
||||
public SimulatorSettings WithKeysOf(SimulatorSettings settings, SimulatorSettings source, IReadOnlyCollection<string> keys)
|
||||
{
|
||||
var taken = Render(source);
|
||||
|
||||
var config = new ConfigFile();
|
||||
foreach (var (key, value) in Render(settings))
|
||||
if (!keys.Contains(key, StringComparer.OrdinalIgnoreCase))
|
||||
config.Set(key, value);
|
||||
foreach (string key in keys)
|
||||
if (taken.TryGetValue(key, out string? value))
|
||||
config.Set(key, value);
|
||||
|
||||
var copy = settings.Copy();
|
||||
_serializer.Read(copy, config);
|
||||
return copy;
|
||||
}
|
||||
|
||||
public void TouchConfigAge()
|
||||
{
|
||||
string path = string.IsNullOrEmpty(_savePath) ? _paths.UserConfigPath() : _savePath;
|
||||
|
||||
@@ -10,6 +10,9 @@ namespace StarterNG.Application;
|
||||
/// <summary>What a simulator that went down left behind: its log and its crash dumps.</summary>
|
||||
public sealed class SimulatorAftermath
|
||||
{
|
||||
// A little slack: the start is taken just after the launch, not before it.
|
||||
private static readonly TimeSpan StartSlack = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly IFileSystem _files;
|
||||
private readonly IGamePaths _paths;
|
||||
private readonly IDiagnosticsLog _log;
|
||||
@@ -42,8 +45,7 @@ public sealed class SimulatorAftermath
|
||||
if (_files.DirectoryExists(crashpad))
|
||||
dumps.AddRange(_files.GetFilesRecursive(crashpad, "*.dmp"));
|
||||
|
||||
// A little slack: the start is taken just after the launch, not before it.
|
||||
var since = startedUtc - TimeSpan.FromSeconds(5);
|
||||
var since = startedUtc - StartSlack;
|
||||
return dumps.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Where(dump => _files.GetLastWriteTimeUtc(dump) >= since)
|
||||
.OrderByDescending(_files.GetLastWriteTimeUtc)
|
||||
@@ -56,13 +58,17 @@ public sealed class SimulatorAftermath
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The end of the simulator's own log, for when it died without a word on its error stream.</summary>
|
||||
public IReadOnlyList<string> SimulatorLogTail(int count)
|
||||
/// <summary>
|
||||
/// The end of the simulator's own log, for when it died without a word on its
|
||||
/// error stream. A log not written since the start is the session before's, the
|
||||
/// simulator having died before opening its own: it says nothing about this one.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> SimulatorLogTail(int count, DateTime startedUtc)
|
||||
{
|
||||
string path = _paths.FromRoot("log.txt");
|
||||
try
|
||||
{
|
||||
if (!_files.FileExists(path))
|
||||
if (!_files.FileExists(path) || _files.GetLastWriteTimeUtc(path) < startedUtc - StartSlack)
|
||||
return Array.Empty<string>();
|
||||
|
||||
var lines = LegacyText.Decode(_files.ReadAllBytes(path))
|
||||
|
||||
@@ -2,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)
|
||||
/// <summary>
|
||||
/// What the start button says of the consist. The vehicle picked answers for it
|
||||
/// even with nobody in it, where the launch itself goes on to the first crew.
|
||||
/// </summary>
|
||||
public static bool HasCrewToStart(Trainset trainset, string? preferred)
|
||||
{
|
||||
if (trainset is null || trainset.Vehicles.Count == 0)
|
||||
return null;
|
||||
|
||||
static bool CanStart(Dynamic vehicle) =>
|
||||
vehicle.DriverType is eDriverType.Headdriver or eDriverType.Reardriver or eDriverType.Passenger;
|
||||
|
||||
@@ -75,20 +85,28 @@ 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);
|
||||
|
||||
// A scenery with no consist picked starts with the free camera.
|
||||
string? vehicle = null;
|
||||
if (trainset is null)
|
||||
{
|
||||
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)
|
||||
@@ -101,19 +119,20 @@ public sealed class StartSimulation
|
||||
break;
|
||||
}
|
||||
|
||||
string? vehicle = TrainsetDisplay.UniquifyForLaunch(trainset, scenery, _state.StartingVehicleName, _vehicles)
|
||||
?? StartableVehicle(trainset, _state.StartingVehicleName);
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// Opens the selected scenery in the simulator's editor. The scenery file itself,
|
||||
/// not an export: what the editor saves has to land in the scenery.
|
||||
/// not an export: what the editor saves has to land in the scenery. With none
|
||||
/// selected the simulator opens its wizard of a new scenery.
|
||||
/// </summary>
|
||||
public SimulationStartResult ExecuteEditor()
|
||||
{
|
||||
if (_state.CurrentScenery is not { } scenery)
|
||||
return new SimulationStartResult(SimulationStartOutcome.NothingSelected);
|
||||
public SimulationStartResult ExecuteEditor() =>
|
||||
Launch(_state.CurrentScenery is { } scenery
|
||||
? new[] { "-edit", Path.GetFileName(scenery.Path) }
|
||||
: new[] { "-edit" });
|
||||
|
||||
return Launch(new[] { "-edit", Path.GetFileName(scenery.Path) });
|
||||
}
|
||||
/// <summary>The simulator's wizard of a new scenery, whichever one is selected.</summary>
|
||||
public SimulationStartResult ExecuteNewSceneryEditor() => Launch(new[] { "-edit" });
|
||||
|
||||
private SimulationStartResult Launch(IReadOnlyList<string> arguments)
|
||||
{
|
||||
@@ -157,10 +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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What puts the simulator on the card the settings ask for, said in the log
|
||||
/// either way: a slow simulator on a laptop is the first thing to ask about.
|
||||
/// </summary>
|
||||
private IReadOnlyDictionary<string, string> GraphicsCard()
|
||||
{
|
||||
var gpus = _gpus.List();
|
||||
if (gpus.Count < 2)
|
||||
return new Dictionary<string, string>();
|
||||
|
||||
var preference = _settings.Settings.Gpu;
|
||||
string cards = string.Join(", ", gpus);
|
||||
if (GpuOffload.Target(gpus, preference, _environment.GetVariable) is not { } target)
|
||||
{
|
||||
_log.Log($"GPU: {cards}; left to the system ({(preference == GpuPreference.System
|
||||
? "chosen in the settings"
|
||||
: "set by the player, or no card to move to")})");
|
||||
return new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
var environment = GpuOffload.EnvironmentFor(target);
|
||||
_log.Log($"GPU: {cards}; simulator on {target} with " +
|
||||
string.Join(' ', environment.Select(pair => $"{pair.Key}={pair.Value}")));
|
||||
return environment;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
62
StarterNG/Application/WarehouseEdit.cs
Normal file
62
StarterNG/Application/WarehouseEdit.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StarterNG.Classes;
|
||||
|
||||
namespace StarterNG.Application;
|
||||
|
||||
/// <summary>
|
||||
/// The warehouse consist the strip edits while the warehouse tab is open, under
|
||||
/// its name there, and the entry as last written - every change saves itself.
|
||||
/// </summary>
|
||||
public sealed class WarehouseEdit
|
||||
{
|
||||
/// <summary>One trainset per warehouse consist, so its undo history outlives a switch.</summary>
|
||||
private readonly Dictionary<string, Trainset> _trainsets = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private string? _saved;
|
||||
|
||||
public Trainset? Trainset { get; private set; }
|
||||
|
||||
public string? Name { get; private set; }
|
||||
|
||||
public Trainset Open(TrainsetPreset preset)
|
||||
{
|
||||
// Written over from elsewhere - "add to warehouse as" the same name - the
|
||||
// entry is read afresh; the history of what it was no longer applies.
|
||||
if (!_trainsets.TryGetValue(preset.Name, out var trainset) ||
|
||||
ConsistText.Serialize(trainset) != preset.Entry)
|
||||
_trainsets[preset.Name] = trainset = new Trainset(preset.Entry);
|
||||
|
||||
Trainset = trainset;
|
||||
Name = preset.Name;
|
||||
_saved = preset.Entry;
|
||||
return trainset;
|
||||
}
|
||||
|
||||
public void Close()
|
||||
{
|
||||
Trainset = null;
|
||||
Name = null;
|
||||
}
|
||||
|
||||
public void Renamed(string old, string fresh)
|
||||
{
|
||||
if (_trainsets.Remove(old, out var trainset))
|
||||
_trainsets[fresh] = trainset;
|
||||
if (string.Equals(Name, old, StringComparison.OrdinalIgnoreCase))
|
||||
Name = fresh;
|
||||
}
|
||||
|
||||
public void Save()
|
||||
{
|
||||
if (Trainset is null || Name is null)
|
||||
return;
|
||||
|
||||
string entry = ConsistText.Serialize(Trainset);
|
||||
if (entry == _saved)
|
||||
return;
|
||||
|
||||
PresetStore.Save(Name, entry);
|
||||
_saved = entry;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace StarterNG.Classes;
|
||||
@@ -12,6 +13,12 @@ public sealed class ConfigFile
|
||||
private readonly Dictionary<string, int> _index =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// The simulator stops reading at this word (Globals.cpp), whatever its case.
|
||||
private const string EndKey = "endconfig";
|
||||
|
||||
// Its first line, or -1 when the file runs to its end.
|
||||
private int _end = -1;
|
||||
|
||||
public static ConfigFile Parse(string text)
|
||||
{
|
||||
var cfg = new ConfigFile();
|
||||
@@ -24,14 +31,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);
|
||||
|
||||
/// <summary>The line the key is on, or -1. The simulator reads the file top down, so a later line wins.</summary>
|
||||
public int LineOf(string key) => _index.TryGetValue(key, out int i) ? i : -1;
|
||||
|
||||
/// <summary>Whether the line setting the key is one the simulator never gets to.</summary>
|
||||
public bool IsPastEnd(string key) =>
|
||||
_end >= 0 && _index.TryGetValue(key, out int line) && line > _end;
|
||||
|
||||
/// <summary>Every key the file sets, in the order of its lines, with its value.</summary>
|
||||
public IEnumerable<KeyValuePair<string, string>> Entries()
|
||||
{
|
||||
foreach (var (key, _) in _index.OrderBy(pair => pair.Value))
|
||||
yield return new KeyValuePair<string, string>(key, GetRaw(key) ?? string.Empty);
|
||||
}
|
||||
|
||||
public string? GetRaw(string key)
|
||||
{
|
||||
if (!_index.TryGetValue(key, out int i))
|
||||
@@ -83,35 +123,76 @@ public sealed class ConfigFile
|
||||
if (_index.TryGetValue(key, out int i))
|
||||
{
|
||||
SplitLine(_lines[i], out string indent, out _, out _, out string comment);
|
||||
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(Line("", key, value, ""));
|
||||
_index[key] = idx;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Line(string indent, string key, string value, 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();
|
||||
}
|
||||
else
|
||||
{
|
||||
int idx = _lines.Count;
|
||||
_lines.Add(value.Length > 0 ? $"{key} {value}" : key);
|
||||
_index[key] = idx;
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public void Remove(string key)
|
||||
private void InsertBeforeEnd(string line)
|
||||
{
|
||||
if (!_index.TryGetValue(key, out int i))
|
||||
_lines.Insert(_end, line);
|
||||
Reindex();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Puts the line of <paramref name="key"/> right below that of <paramref name="anchor"/> when
|
||||
/// it is above it, for keys of which the simulator, reading top down, heeds the later.
|
||||
/// </summary>
|
||||
public void MoveAfter(string key, string anchor)
|
||||
{
|
||||
if (!_index.TryGetValue(key, out int from) || !_index.TryGetValue(anchor, out int to) || from > to)
|
||||
return;
|
||||
|
||||
SplitLine(_lines[i], out string indent, out _, out string value, out string comment);
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(indent).Append("// ").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 = _lines[from];
|
||||
_lines.RemoveAt(from);
|
||||
_lines.Insert(to, line);
|
||||
Reindex();
|
||||
}
|
||||
|
||||
/// <summary>Comments out every line setting the key: the last one alone would bring the one before it back.</summary>
|
||||
public void Remove(string key)
|
||||
{
|
||||
if (!_index.ContainsKey(key))
|
||||
return;
|
||||
|
||||
for (int i = 0; i < _lines.Count; i++)
|
||||
{
|
||||
SplitLine(_lines[i], out string indent, out string found, out string value, out string comment);
|
||||
if (!found.Equals(key, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
_lines[i] = Line(indent + "// ", found, value, comment);
|
||||
}
|
||||
|
||||
_index.Remove(key);
|
||||
}
|
||||
@@ -133,7 +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]))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Input;
|
||||
|
||||
namespace StarterNG.Classes;
|
||||
@@ -18,8 +19,46 @@ public sealed class KeyCap
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>What the simulator does with a key combination on its own.</summary>
|
||||
/// <param name="TextKey">What that is, as a translation key.</param>
|
||||
/// <param name="Blocks">
|
||||
/// It gets there ahead of the key file, so a binding here would never fire.
|
||||
/// Otherwise it is what is left to the key when no binding takes it.
|
||||
/// </param>
|
||||
public readonly record struct SimulatorKeyUse(string TextKey, bool Blocks);
|
||||
|
||||
public static class KeyMap
|
||||
{
|
||||
public const string NumpadPrefix = "num_";
|
||||
|
||||
/// <summary>
|
||||
/// Read from the simulator's code: ui_layer::on_key and driver_ui::on_key are asked first and only
|
||||
/// without a modifier, driver_mode::on_key takes Shift+F12 itself, then comes the key
|
||||
/// file, and driver_mode::OnKeyDown gets what that leaves.
|
||||
/// </summary>
|
||||
public static SimulatorKeyUse? SimulatorUse(string token, bool shift, bool ctrl)
|
||||
{
|
||||
bool plain = !shift && !ctrl;
|
||||
switch (token.ToLowerInvariant())
|
||||
{
|
||||
case "tab" when plain: return new SimulatorKeyUse("KbUseMap", true);
|
||||
case "f1" when plain: return new SimulatorKeyUse("KbUseConsist", true);
|
||||
case "f2" when plain: return new SimulatorKeyUse("KbUseTimetable", true);
|
||||
case "f3" when plain: return new SimulatorKeyUse("KbUseScenario", true);
|
||||
case "f9" when plain: return new SimulatorKeyUse("KbUseLog", true);
|
||||
case "f10" when plain: return new SimulatorKeyUse("KbUseQuit", true);
|
||||
case "f12" when shift: return new SimulatorKeyUse("KbUseDebugPanel", true);
|
||||
|
||||
case "f4": return new SimulatorKeyUse("KbUseCab", false);
|
||||
case "f5": return new SimulatorKeyUse("KbUseVehicle", false);
|
||||
case "f6": return new SimulatorKeyUse("KbUseDebug", false);
|
||||
case "f7" when plain || ctrl: return new SimulatorKeyUse("KbUseDebug", false);
|
||||
case "f11" when plain: return new SimulatorKeyUse("KbUseEditor", false);
|
||||
case [>= '0' and <= '9'] when shift: return new SimulatorKeyUse("KbUseSceneryEvent", false);
|
||||
case [>= '0' and <= '9'] when ctrl: return new SimulatorKeyUse("KbUseCamera", false);
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static string? FromInput(Key key, PhysicalKey physical)
|
||||
{
|
||||
@@ -75,6 +114,16 @@ public static class KeyMap
|
||||
Key.OemSemicolon => ";",
|
||||
Key.OemQuotes => "'",
|
||||
Key.OemBackslash or Key.OemPipe => "\\",
|
||||
Key.OemOpenBrackets => "[",
|
||||
Key.OemCloseBrackets => "]",
|
||||
Key.Enter => "enter",
|
||||
Key.Tab => "tab",
|
||||
Key.PageUp => "page_up",
|
||||
Key.PageDown => "page_down",
|
||||
Key.Left => "left",
|
||||
Key.Right => "right",
|
||||
Key.Up => "up",
|
||||
Key.Down => "down",
|
||||
Key.F1 => "f1", Key.F2 => "f2", Key.F3 => "f3", Key.F4 => "f4",
|
||||
Key.F5 => "f5", Key.F6 => "f6", Key.F7 => "f7", Key.F8 => "f8",
|
||||
Key.F9 => "f9", Key.F10 => "f10", Key.F11 => "f11", Key.F12 => "f12",
|
||||
@@ -88,15 +137,29 @@ public static class KeyMap
|
||||
Key.LeftAlt or Key.RightAlt or
|
||||
Key.LWin or Key.RWin;
|
||||
|
||||
/// <summary>A key combination as it reads on screen; a dash for no key, whatever the modifiers.</summary>
|
||||
public static string ComboText(string? token, bool shift, bool ctrl)
|
||||
{
|
||||
if (IsNoKey(token))
|
||||
return "—";
|
||||
var parts = new List<string>();
|
||||
if (ctrl) parts.Add("Ctrl");
|
||||
if (shift) parts.Add("Shift");
|
||||
parts.Add(DisplayName(token));
|
||||
return string.Join(" + ", parts);
|
||||
}
|
||||
|
||||
private static bool IsNoKey([NotNullWhen(false)] string? token) =>
|
||||
string.IsNullOrEmpty(token) || token.Equals("none", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public static string DisplayName(string? token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token) ||
|
||||
token.Equals("none", StringComparison.OrdinalIgnoreCase))
|
||||
if (IsNoKey(token))
|
||||
return "—";
|
||||
|
||||
if (token.StartsWith("num_", StringComparison.OrdinalIgnoreCase))
|
||||
if (token.StartsWith(NumpadPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string rest = token.Substring(4);
|
||||
string rest = token.Substring(NumpadPrefix.Length);
|
||||
return rest switch
|
||||
{
|
||||
"enter" => "Num Enter",
|
||||
@@ -116,8 +179,9 @@ public static class KeyMap
|
||||
"end" => "End",
|
||||
"insert" => "Insert",
|
||||
"delete" => "Delete",
|
||||
"pageup" => "Page Up",
|
||||
"pagedown" => "Page Down",
|
||||
"page_up" => "Page Up",
|
||||
"page_down" => "Page Down",
|
||||
"esc" => "Esc",
|
||||
"backspace" => "Backspace",
|
||||
"pause" => "Pause",
|
||||
"\\" => "\\",
|
||||
@@ -131,10 +195,10 @@ public static class KeyMap
|
||||
|
||||
new[]
|
||||
{
|
||||
new KeyCap("Esc", null, 1.4),
|
||||
new KeyCap("F1"), new KeyCap("F2"), new KeyCap("F3"), new KeyCap("F4"),
|
||||
new KeyCap("F5"), new KeyCap("F6"), new KeyCap("F7"), new KeyCap("F8"),
|
||||
new KeyCap("F9"), new KeyCap("F10"), new KeyCap("F11"), new KeyCap("F12"),
|
||||
new KeyCap("Esc", "esc", 1.4),
|
||||
new KeyCap("F1", "f1"), new KeyCap("F2", "f2"), new KeyCap("F3", "f3"), new KeyCap("F4", "f4"),
|
||||
new KeyCap("F5", "f5"), new KeyCap("F6", "f6"), new KeyCap("F7", "f7"), new KeyCap("F8", "f8"),
|
||||
new KeyCap("F9", "f9"), new KeyCap("F10", "f10"), new KeyCap("F11", "f11"), new KeyCap("F12", "f12"),
|
||||
},
|
||||
new[]
|
||||
{
|
||||
@@ -147,11 +211,11 @@ public static class KeyMap
|
||||
},
|
||||
new[]
|
||||
{
|
||||
new KeyCap("Tab", null, 1.5),
|
||||
new KeyCap("Tab", "tab", 1.5),
|
||||
new KeyCap("Q", "q"), new KeyCap("W", "w"), new KeyCap("E", "e"), new KeyCap("R", "r"),
|
||||
new KeyCap("T", "t"), new KeyCap("Y", "y"), new KeyCap("U", "u"), new KeyCap("I", "i"),
|
||||
new KeyCap("O", "o"), new KeyCap("P", "p"),
|
||||
new KeyCap("[", null), new KeyCap("]", null),
|
||||
new KeyCap("[", "["), new KeyCap("]", "]"),
|
||||
new KeyCap("\\", "\\", 1.5),
|
||||
},
|
||||
new[]
|
||||
@@ -161,7 +225,7 @@ public static class KeyMap
|
||||
new KeyCap("G", "g"), new KeyCap("H", "h"), new KeyCap("J", "j"), new KeyCap("K", "k"),
|
||||
new KeyCap("L", "l"),
|
||||
new KeyCap(";", ";"), new KeyCap("'", "'"),
|
||||
new KeyCap("Enter", null, 2.2),
|
||||
new KeyCap("Enter", "enter", 2.2),
|
||||
},
|
||||
new[]
|
||||
{
|
||||
@@ -215,11 +279,19 @@ public static class KeyMap
|
||||
|
||||
new[]
|
||||
{
|
||||
new KeyCap("Ins", "insert"), new KeyCap("Home", "home"), new KeyCap("PgUp"),
|
||||
new KeyCap("Ins", "insert"), new KeyCap("Home", "home"), new KeyCap("PgUp", "page_up"),
|
||||
},
|
||||
new[]
|
||||
{
|
||||
new KeyCap("Del", "delete"), new KeyCap("End", "end"), new KeyCap("PgDn"),
|
||||
new KeyCap("Del", "delete"), new KeyCap("End", "end"), new KeyCap("PgDn", "page_down"),
|
||||
},
|
||||
new[]
|
||||
{
|
||||
new KeyCap("", null), new KeyCap("↑", "up"), new KeyCap("", null),
|
||||
},
|
||||
new[]
|
||||
{
|
||||
new KeyCap("←", "left"), new KeyCap("↓", "down"), new KeyCap("→", "right"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using StarterNG.Infrastructure.Adapters;
|
||||
|
||||
@@ -23,6 +24,20 @@ public sealed class KeyBinding
|
||||
public bool IsAssigned =>
|
||||
!string.IsNullOrEmpty(Key) &&
|
||||
!Key.Equals("none", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>The combination in a form to compare, however the key is spelled.</summary>
|
||||
public string ComboKey => $"{(Ctrl ? 1 : 0)}|{(Shift ? 1 : 0)}|{Key.ToLowerInvariant()}";
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
Shift = Ctrl = false;
|
||||
Key = "none";
|
||||
}
|
||||
|
||||
public bool IsOn(string token, bool shift, bool ctrl) =>
|
||||
IsAssigned &&
|
||||
string.Equals(Key, token, StringComparison.OrdinalIgnoreCase) &&
|
||||
Shift == shift && Ctrl == ctrl;
|
||||
}
|
||||
|
||||
public sealed class KeyboardConfig
|
||||
@@ -119,19 +134,66 @@ public sealed class KeyboardConfig
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The description to show: the bundled one with Polish letters when the file's
|
||||
/// is the same text without them, the file's own when someone wrote their own.
|
||||
/// The description to show: the file's own when someone wrote their own, else
|
||||
/// the <paramref name="translated"/> one for the starter's language, else the
|
||||
/// bundled one with Polish letters.
|
||||
/// </summary>
|
||||
public string DescriptionOf(KeyBinding binding)
|
||||
public string DescriptionOf(KeyBinding binding, Func<string, string>? translated = null)
|
||||
{
|
||||
if (_polishDescriptions.TryGetValue(binding.Command, out string? polish) &&
|
||||
(string.IsNullOrEmpty(binding.Description) ||
|
||||
string.Equals(StripPolish(polish), binding.Description.Trim(), StringComparison.OrdinalIgnoreCase)))
|
||||
return polish;
|
||||
|
||||
bool stock = string.IsNullOrEmpty(binding.Description);
|
||||
if (_polishDescriptions.TryGetValue(binding.Command, out string? polish))
|
||||
stock |= string.Equals(StripPolish(polish), binding.Description.Trim(), StringComparison.OrdinalIgnoreCase);
|
||||
if (!stock)
|
||||
return binding.Description;
|
||||
|
||||
if (translated?.Invoke(binding.Command) is { Length: > 0 } text)
|
||||
return text;
|
||||
|
||||
return polish ?? binding.Description;
|
||||
}
|
||||
|
||||
public KeyBinding? BindingAt(string token, bool shift, bool ctrl) =>
|
||||
Bindings.FirstOrDefault(b => b.IsOn(token, shift, ctrl));
|
||||
|
||||
/// <summary>
|
||||
/// Puts <paramref name="binding"/> on a key combination, or nothing for null, and
|
||||
/// frees it from the rest: the simulator maps a combination to one command - in
|
||||
/// driving and outside the cab alike, the editor has its own fixed keys - so of
|
||||
/// two, one would silently never fire.
|
||||
/// </summary>
|
||||
public void Put(KeyBinding? binding, string token, bool shift, bool ctrl)
|
||||
{
|
||||
foreach (var b in Bindings)
|
||||
if (!ReferenceEquals(b, binding) && b.IsOn(token, shift, ctrl))
|
||||
b.Clear();
|
||||
|
||||
if (binding is null)
|
||||
return;
|
||||
|
||||
binding.Key = token;
|
||||
binding.Shift = shift;
|
||||
binding.Ctrl = ctrl;
|
||||
}
|
||||
|
||||
/// <summary>The <see cref="KeyBinding.ComboKey"/> of each combination more than one binding is on.</summary>
|
||||
public HashSet<string> ConflictingCombos()
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var dup = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var b in Bindings)
|
||||
{
|
||||
if (!b.IsAssigned)
|
||||
continue;
|
||||
string k = b.ComboKey;
|
||||
if (!seen.Add(k))
|
||||
dup.Add(k);
|
||||
}
|
||||
return dup;
|
||||
}
|
||||
|
||||
public int BindingsOnNumpad() =>
|
||||
Bindings.Count(b => b.IsAssigned && b.Key.StartsWith(KeyMap.NumpadPrefix, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public static string StripPolish(string text)
|
||||
{
|
||||
var sb = new StringBuilder(text.Length);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using System.Text.RegularExpressions;
|
||||
using StarterNG.Application;
|
||||
using StarterNG.Application.Abstractions;
|
||||
using StarterNG.Infrastructure.Adapters;
|
||||
|
||||
namespace StarterNG.Classes;
|
||||
@@ -54,18 +54,14 @@ public static class PresetStore
|
||||
|
||||
public static PresetSort SortMode { get; set; } = PresetSort.Name;
|
||||
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
TypeInfoResolver = PresetJsonContext.Default,
|
||||
};
|
||||
private static PresetFile? _disk;
|
||||
|
||||
private static JsonTypeInfo<PresetCollection> TypeInfo =>
|
||||
(JsonTypeInfo<PresetCollection>)Options.GetTypeInfo(typeof(PresetCollection));
|
||||
private static PresetFile Disk =>
|
||||
_disk ??= new PresetFile(AppServices.Current.Files, AppServices.Current.Log, FilePath);
|
||||
|
||||
public static IReadOnlyList<TrainsetPreset> All()
|
||||
{
|
||||
var list = Load().Presets.ToList();
|
||||
var list = Disk.Presets();
|
||||
if (SortMode == PresetSort.Track)
|
||||
list.Sort((a, b) => string.Compare(TrackOf(a), TrackOf(b), StringComparison.OrdinalIgnoreCase));
|
||||
else
|
||||
@@ -73,25 +69,9 @@ public static class PresetStore
|
||||
return list;
|
||||
}
|
||||
|
||||
public static void Save(string? name, string? entry)
|
||||
{
|
||||
name = name?.Trim() ?? "";
|
||||
if (name.Length == 0 || string.IsNullOrWhiteSpace(entry))
|
||||
return;
|
||||
public static void Save(string? name, string? entry) => Disk.Save(name, entry);
|
||||
|
||||
var col = Load();
|
||||
col.Presets.RemoveAll(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||
col.Presets.Add(new TrainsetPreset { Name = name, Entry = entry! });
|
||||
col.Presets.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase));
|
||||
Persist(col);
|
||||
}
|
||||
|
||||
public static void Delete(string name)
|
||||
{
|
||||
var col = Load();
|
||||
if (col.Presets.RemoveAll(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)) > 0)
|
||||
Persist(col);
|
||||
}
|
||||
public static void Delete(string name) => Disk.Delete(name);
|
||||
|
||||
public static int ImportMagazyn(string? path = null)
|
||||
{
|
||||
@@ -162,35 +142,95 @@ public static class PresetStore
|
||||
|
||||
private static string Sanitize(string name) =>
|
||||
string.IsNullOrWhiteSpace(name) ? "preset" : name.Replace(' ', '_');
|
||||
}
|
||||
|
||||
private static PresetCollection Load()
|
||||
/// <summary>The presets as one file holds them; <see cref="PresetStore"/> is the player's own.</summary>
|
||||
public sealed class PresetFile
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
TypeInfoResolver = PresetJsonContext.Default,
|
||||
};
|
||||
|
||||
private static JsonTypeInfo<PresetCollection> TypeInfo =>
|
||||
(JsonTypeInfo<PresetCollection>)Options.GetTypeInfo(typeof(PresetCollection));
|
||||
|
||||
private readonly IFileSystem _files;
|
||||
private readonly IDiagnosticsLog _log;
|
||||
private readonly string _path;
|
||||
|
||||
public PresetFile(IFileSystem files, IDiagnosticsLog log, string path)
|
||||
{
|
||||
_files = files;
|
||||
_log = log;
|
||||
_path = path;
|
||||
}
|
||||
|
||||
public List<TrainsetPreset> Presets()
|
||||
{
|
||||
TryRead(out var col);
|
||||
return col.Presets;
|
||||
}
|
||||
|
||||
public void Save(string? name, string? entry)
|
||||
{
|
||||
name = name?.Trim() ?? "";
|
||||
if (name.Length == 0 || string.IsNullOrWhiteSpace(entry))
|
||||
return;
|
||||
|
||||
bool read = TryRead(out var col);
|
||||
col.Presets.RemoveAll(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||
col.Presets.Add(new TrainsetPreset { Name = name, Entry = entry! });
|
||||
col.Presets.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase));
|
||||
Persist(col, read);
|
||||
}
|
||||
|
||||
public void Delete(string name)
|
||||
{
|
||||
bool read = TryRead(out var col);
|
||||
if (col.Presets.RemoveAll(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)) > 0)
|
||||
Persist(col, read);
|
||||
}
|
||||
|
||||
/// <summary>The file's presets; false, with none, when there is a file but they cannot be had from it.</summary>
|
||||
private bool TryRead(out PresetCollection col)
|
||||
{
|
||||
col = new PresetCollection();
|
||||
try
|
||||
{
|
||||
if (File.Exists(FilePath))
|
||||
if (!_files.FileExists(_path))
|
||||
return true;
|
||||
|
||||
var read = JsonSerializer.Deserialize(_files.ReadAllText(_path), TypeInfo);
|
||||
if (read?.Presets != null)
|
||||
{
|
||||
var col = JsonSerializer.Deserialize(File.ReadAllText(FilePath), TypeInfo);
|
||||
if (col?.Presets != null)
|
||||
return col;
|
||||
col = read;
|
||||
return true;
|
||||
}
|
||||
_log.Log($"reading {_path}: no presets in it");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StarterNG.Infrastructure.Diagnostics.Log($"reading {FilePath}", ex);
|
||||
_log.Log($"reading {_path}", ex);
|
||||
}
|
||||
return new PresetCollection();
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void Persist(PresetCollection col)
|
||||
// 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
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!);
|
||||
File.WriteAllText(FilePath, JsonSerializer.Serialize(col, TypeInfo));
|
||||
_files.CreateDirectory(Path.GetDirectoryName(_path)!);
|
||||
if (!read)
|
||||
_files.KeepAside(_path, _log);
|
||||
_files.WriteAllTextAtomic(_path, JsonSerializer.Serialize(col, TypeInfo));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StarterNG.Infrastructure.Diagnostics.Log($"saving {FilePath}", ex);
|
||||
_log.Log($"saving {_path}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user