Initial commit: TeslaSuite monorepo (TeslaConsole + TeslaLauncher)
Co-locate the two cockpit-pod projects into a single repository:
- Console/ : TeslaConsole, the net48 WinForms operator console (decompiled
reconstruction) plus its differential + catalog test suite.
- Launcher/ : TeslaLauncher, the net6 pod-side Service + Agent rewrite.
Adds a combined TeslaSuite.sln, root README documenting the shared wire
contract (and its current duplication, the main follow-up), and a root
.gitignore. Histories were not preserved per request; this is a fresh start
from the current working state of both projects.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace TeslaConsole.DiffTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Locates the two assemblies under comparison:
|
||||
/// * Original - original/TeslaConsole.exe (the lost-source reference baseline)
|
||||
/// * Recovered - bin/Release/net48/TeslaConsole.exe (freshly built reconstruction)
|
||||
/// </summary>
|
||||
public static class AssemblyPaths
|
||||
{
|
||||
public static string RepoRoot { get; } = FindRepoRoot();
|
||||
|
||||
public static string OriginalExe { get; } =
|
||||
Path.Combine(RepoRoot, "original", "TeslaConsole.exe");
|
||||
|
||||
// The reconstruction's build output. Release is what README documents; fall
|
||||
// back to Debug so the suite still runs from either configuration.
|
||||
public static string RecoveredExe { get; } = FindRecoveredExe();
|
||||
|
||||
// The product catalog copied next to the recovered exe.
|
||||
public static string RecoveredCatalog { get; } =
|
||||
Path.Combine(Path.GetDirectoryName(RecoveredExe), "RedPlanet", "Apps.xml");
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppDomain.CurrentDomain.BaseDirectory);
|
||||
while (dir != null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(dir.FullName, "TeslaConsole.csproj")))
|
||||
return dir.FullName;
|
||||
dir = dir.Parent;
|
||||
}
|
||||
throw new InvalidOperationException(
|
||||
"Could not locate repo root (TeslaConsole.csproj) above " +
|
||||
AppDomain.CurrentDomain.BaseDirectory);
|
||||
}
|
||||
|
||||
private static string FindRecoveredExe()
|
||||
{
|
||||
string release = Path.Combine(RepoRoot, "bin", "Release", "net48", "TeslaConsole.exe");
|
||||
string debug = Path.Combine(RepoRoot, "bin", "Debug", "net48", "TeslaConsole.exe");
|
||||
|
||||
// Test whichever build is freshest, so a stale config never silently wins.
|
||||
string best = null;
|
||||
DateTime bestTime = DateTime.MinValue;
|
||||
foreach (var candidate in new[] { release, debug })
|
||||
{
|
||||
if (!File.Exists(candidate)) continue;
|
||||
var t = File.GetLastWriteTimeUtc(candidate);
|
||||
if (t >= bestTime) { bestTime = t; best = candidate; }
|
||||
}
|
||||
// Fall back to the Release path; callers assert existence with a clear message.
|
||||
return best ?? release;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using System;
|
||||
using Xunit;
|
||||
|
||||
namespace TeslaConsole.DiffTests
|
||||
{
|
||||
/// <summary>
|
||||
/// For every input below, the original and the recovered build must return the
|
||||
/// exact same result. Each [Theory] case runs the same method in both assemblies
|
||||
/// (in their own AppDomains) and asserts byte-for-byte string equality.
|
||||
/// </summary>
|
||||
public class BehavioralEquivalenceTests : IClassFixture<DifferentialFixture>
|
||||
{
|
||||
private readonly DifferentialFixture _fx;
|
||||
|
||||
public BehavioralEquivalenceTests(DifferentialFixture fx) => _fx = fx;
|
||||
|
||||
private void AssertSame(string caseName, params string[] args)
|
||||
{
|
||||
var (original, recovered) = _fx.RunBoth(caseName, args);
|
||||
Assert.Equal(original, recovered);
|
||||
// Guard against both silently throwing the same way for a case that should succeed.
|
||||
Assert.False(original == null,
|
||||
$"{caseName}({string.Join(",", args)}) returned null from both assemblies");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Harness_Distinguishes_Different_Outputs()
|
||||
{
|
||||
// Negative control: a passing suite must be capable of seeing a difference.
|
||||
// Same method, different inputs, must yield different results in each assembly.
|
||||
string ninetySec = (90L * TimeSpan.TicksPerSecond).ToString();
|
||||
var zero = _fx.Original.Run("GetTimeString", new[] { "0" });
|
||||
var ninety = _fx.Original.Run("GetTimeString", new[] { ninetySec });
|
||||
Assert.NotEqual(zero, ninety);
|
||||
|
||||
var rZero = _fx.Recovered.Run("GetTimeString", new[] { "0" });
|
||||
var rNinety = _fx.Recovered.Run("GetTimeString", new[] { ninetySec });
|
||||
Assert.NotEqual(rZero, rNinety);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Loaded_Assemblies_Share_Identity()
|
||||
{
|
||||
// Both must really be TeslaConsole 4.11.3.37076 — guards against testing the wrong files.
|
||||
Assert.Equal(_fx.Original.AssemblyFullName, _fx.Recovered.AssemblyFullName);
|
||||
Assert.Contains("TeslaConsole", _fx.Original.AssemblyFullName);
|
||||
}
|
||||
|
||||
// ---- RPStrings.GetTimeString: mm:ss formatting with 0.5s rounding ----
|
||||
[Theory]
|
||||
[InlineData(0L)] // 0s -> 00:00
|
||||
[InlineData(4_000_000L)] // 0.4s -> rounds down
|
||||
[InlineData(5_000_000L)] // 0.5s -> rounds up to 1s
|
||||
[InlineData(6_000_000L)] // 0.6s
|
||||
[InlineData(595_000_000L)] // 59.5s -> rolls to 01:00
|
||||
[InlineData(600_000_000L)] // 60s -> 01:00
|
||||
[InlineData(900_000_000L)] // 90s -> 01:30
|
||||
[InlineData(5_990_000_000L)] // 599s
|
||||
[InlineData(6_000_000_000L)] // 600s -> 10:00
|
||||
[InlineData(36_000_000_000L)] // 3600s -> 60:00
|
||||
[InlineData(-50_000_000L)] // negative
|
||||
[InlineData(864_000_000_000L)] // 1 day
|
||||
public void GetTimeString_Matches(long ticks)
|
||||
=> AssertSame("GetTimeString", ticks.ToString());
|
||||
|
||||
// ---- HostTypeHelper.Parse(...).ToString() including invalid input ----
|
||||
[Theory]
|
||||
[InlineData("Game Machine")]
|
||||
[InlineData("Mission Review")]
|
||||
[InlineData("Console")]
|
||||
[InlineData("GameMachineHostType")]
|
||||
[InlineData("MissionReviewHostType")]
|
||||
[InlineData("ConsoleHostType")]
|
||||
[InlineData("console")] // case-sensitive: should throw in both
|
||||
[InlineData("Nonsense")] // invalid: ArgumentException in both
|
||||
[InlineData("")] // empty
|
||||
public void HostTypeParse_Matches(string input)
|
||||
=> AssertSame("HostTypeParse", input);
|
||||
|
||||
// ---- PlasmaBitmaps.ConvertBitmap: 1bpp packing of a known pixel pattern ----
|
||||
[Theory]
|
||||
[InlineData(8, 8, 0)]
|
||||
[InlineData(8, 8, 1)]
|
||||
[InlineData(8, 8, 2)]
|
||||
[InlineData(16, 16, 0)]
|
||||
[InlineData(16, 16, 3)]
|
||||
[InlineData(32, 8, 5)]
|
||||
[InlineData(7, 8, 0)] // width not multiple of 8 -> throws in both
|
||||
[InlineData(8, 7, 0)] // height not multiple of 8 -> throws in both
|
||||
public void ConvertBitmap_Matches(int width, int height, int seed)
|
||||
=> AssertSame("ConvertBitmap", width.ToString(), height.ToString(), seed.ToString());
|
||||
|
||||
// ---- PlasmaBitmaps.GenerateString: full GDI text -> 1bpp pipeline ----
|
||||
[Theory]
|
||||
[InlineData(128, 32, "Microsoft Sans Serif", "RED")]
|
||||
[InlineData(64, 16, "Microsoft Sans Serif", "RED")]
|
||||
[InlineData(128, 32, "Microsoft Sans Serif", "1")]
|
||||
[InlineData(128, 32, "Microsoft Sans Serif", "ABCDEFGH")]
|
||||
[InlineData(128, 32, "Microsoft Sans Serif", "")]
|
||||
[InlineData(128, 32, "Arial", "GO")]
|
||||
public void GenerateString_Matches(int width, int height, string font, string text)
|
||||
=> AssertSame("GenerateString", width.ToString(), height.ToString(), font, text);
|
||||
|
||||
// ---- RedPlanet RPMap / RPVehicle XML parsing ----
|
||||
[Theory]
|
||||
[InlineData("<map key=\"m1\" name=\"Blade's Edge\" image=\"images/x.bmp\" />")]
|
||||
[InlineData("<map key=\"m2\" name=\"Lyz's Lane\" />")] // no image attr
|
||||
[InlineData("<map key=\"\" name=\"\" />")] // empty values
|
||||
public void RPMapParse_Matches(string xml)
|
||||
=> AssertSame("RPMapParse", xml);
|
||||
|
||||
[Theory]
|
||||
[InlineData("<vehicle key=\"v1\" name=\"Armadillo\" image=\"images/a.bmp\" />")]
|
||||
[InlineData("<vehicle key=\"v2\" name=\"Skeeter\" />")]
|
||||
public void RPVehicleParse_Matches(string xml)
|
||||
=> AssertSame("RPVehicleParse", xml);
|
||||
|
||||
// ---- SiteManagement well-known application GUIDs (constants) ----
|
||||
[Theory]
|
||||
[InlineData("RPAppGuid")]
|
||||
[InlineData("RPMRAppGuid")]
|
||||
[InlineData("RPLCAppGuid")]
|
||||
[InlineData("MW4AppGuid")]
|
||||
[InlineData("MW4LCAppGuid")]
|
||||
public void SiteManagementGuid_Matches(string fieldName)
|
||||
=> AssertSame("SiteManagementGuid", fieldName);
|
||||
|
||||
// ---- Tuple.Create<int,string> generic factory ----
|
||||
[Theory]
|
||||
[InlineData("1", "alpha")]
|
||||
[InlineData("-7", "")]
|
||||
public void TupleCreate_Matches(string a, string b)
|
||||
=> AssertSame("TupleCreate", a, b);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.IO;
|
||||
using Xunit;
|
||||
|
||||
namespace TeslaConsole.DiffTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Characterization tests for the data-driven product catalog (AppRegistry + RedPlanet\Apps.xml).
|
||||
/// These assert the catalog reproduces the EXACT LaunchData the previously-hardcoded
|
||||
/// SiteManagement code produced, so the refactor is behavior-preserving. Catalog logic is new
|
||||
/// (absent from the original exe), so these run against the recovered build only.
|
||||
/// </summary>
|
||||
public class CatalogTests : IClassFixture<DifferentialFixture>
|
||||
{
|
||||
private readonly DifferentialFixture _fx;
|
||||
private readonly string _catalog;
|
||||
|
||||
public CatalogTests(DifferentialFixture fx)
|
||||
{
|
||||
_fx = fx;
|
||||
_catalog = AssemblyPaths.RecoveredCatalog;
|
||||
Assert.True(File.Exists(_catalog), "Catalog not found next to the build: " + _catalog);
|
||||
}
|
||||
|
||||
private string Entry(string launchKey, string w = "", string h = "")
|
||||
=> _fx.Recovered.Run("CatalogEntry", new[] { _catalog, launchKey, w, h });
|
||||
|
||||
[Fact]
|
||||
public void Catalog_Has_Three_Products_And_Five_Entries()
|
||||
=> Assert.Equal("products=3;entries=5",
|
||||
_fx.Recovered.Run("CatalogSummary", new[] { _catalog }));
|
||||
|
||||
// Each expected string is "DisplayName|LaunchKey|Exe|Args|WorkingDirectory|AutoRestart"
|
||||
// and matches exactly what the old hardcoded PodInfo_InstallProductCompleted emitted.
|
||||
|
||||
[Fact]
|
||||
public void RedPlanet_GameClient_Matches_Original()
|
||||
=> Assert.Equal(
|
||||
@"Red Planet 4.11|7d241b1f-ab6d-4e08-9c20-12294e743d94|C:\Games\RP411\rpl4opt.exe|-net 1501|C:\Games\RP411|True",
|
||||
Entry("7D241B1F-AB6D-4e08-9C20-12294E743D94"));
|
||||
|
||||
[Fact]
|
||||
public void RedPlanet_GameClient_With_Resolution_Matches_Original()
|
||||
=> Assert.Equal(
|
||||
@"Red Planet 4.11|7d241b1f-ab6d-4e08-9c20-12294e743d94|C:\Games\RP411\rpl4opt.exe|-net 1501 -res 1024 768|C:\Games\RP411|True",
|
||||
Entry("7D241B1F-AB6D-4e08-9C20-12294E743D94", "1024", "768"));
|
||||
|
||||
[Fact]
|
||||
public void RedPlanet_LiveCamera_Matches_Original()
|
||||
=> Assert.Equal(
|
||||
@"Red Planet 4.11 LC|57a0b3c2-d5cf-46d6-abed-a8f4a26ab086|C:\Games\RP411\rpl4opt.exe|-net 1501 -lc|C:\Games\RP411|True",
|
||||
Entry("57A0B3C2-D5CF-46d6-ABED-A8F4A26AB086"));
|
||||
|
||||
[Fact]
|
||||
public void RedPlanet_LiveCamera_With_Resolution_Matches_Original()
|
||||
=> Assert.Equal(
|
||||
@"Red Planet 4.11 LC|57a0b3c2-d5cf-46d6-abed-a8f4a26ab086|C:\Games\RP411\rpl4opt.exe|-net 1501 -res 1024 768 -lc|C:\Games\RP411|True",
|
||||
Entry("57A0B3C2-D5CF-46d6-ABED-A8F4A26AB086", "1024", "768"));
|
||||
|
||||
[Fact]
|
||||
public void RedPlanet_MissionReview_Matches_Original()
|
||||
=> Assert.Equal(
|
||||
@"Red Planet 4.11 MR|8f71d5c2-38e4-413c-8e22-88cad08774d2|C:\Games\RP411\rpl4opt.exe|-net 1501 -mr|C:\Games\RP411|True",
|
||||
Entry("8F71D5C2-38E4-413c-8E22-88CAD08774D2"));
|
||||
|
||||
[Fact]
|
||||
public void Firestorm_Matches_Original()
|
||||
=> Assert.Equal(
|
||||
@"BattleTech Firestorm|cc8500ed-a653-45a7-bef8-c332d30371a6|C:\Games\MW4\launcher.exe||C:\Games\MW4|True",
|
||||
Entry("CC8500ED-A653-45a7-BEF8-C332D30371A6"));
|
||||
|
||||
[Fact]
|
||||
public void FirestormLC_Matches_Original()
|
||||
=> Assert.Equal(
|
||||
@"BattleTech Firestorm LC|8ee93a6c-f16a-49be-b867-37fae9087fff|C:\Games\MW4\launcher.exe||C:\Games\MW4|True",
|
||||
Entry("8EE93A6C-F16A-49be-B867-37FAE9087FFF"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using Xunit;
|
||||
|
||||
namespace TeslaConsole.DiffTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Shared per-test-class fixture that spins up one child AppDomain per assembly
|
||||
/// (original + recovered), each rooted at that assembly's own directory so the CLR
|
||||
/// probes for any dependencies next to it. The Invoker proxy in each domain loads
|
||||
/// and exercises that assembly.
|
||||
/// </summary>
|
||||
public sealed class DifferentialFixture : IDisposable
|
||||
{
|
||||
private readonly AppDomain _originalDomain;
|
||||
private readonly AppDomain _recoveredDomain;
|
||||
|
||||
public Invoker Original { get; }
|
||||
public Invoker Recovered { get; }
|
||||
|
||||
public DifferentialFixture()
|
||||
{
|
||||
Assert.True(File.Exists(AssemblyPaths.OriginalExe),
|
||||
"Original baseline not found: " + AssemblyPaths.OriginalExe);
|
||||
Assert.True(File.Exists(AssemblyPaths.RecoveredExe),
|
||||
"Recovered build not found (build TeslaConsole.csproj -c Release first): " +
|
||||
AssemblyPaths.RecoveredExe);
|
||||
|
||||
_originalDomain = CreateDomain("Original", AssemblyPaths.OriginalExe);
|
||||
_recoveredDomain = CreateDomain("Recovered", AssemblyPaths.RecoveredExe);
|
||||
|
||||
Original = CreateInvoker(_originalDomain, AssemblyPaths.OriginalExe);
|
||||
Recovered = CreateInvoker(_recoveredDomain, AssemblyPaths.RecoveredExe);
|
||||
}
|
||||
|
||||
private static AppDomain CreateDomain(string name, string exePath)
|
||||
{
|
||||
var setup = new AppDomainSetup
|
||||
{
|
||||
ApplicationBase = Path.GetDirectoryName(exePath),
|
||||
};
|
||||
return AppDomain.CreateDomain("TeslaDiff_" + name, null, setup);
|
||||
}
|
||||
|
||||
private static Invoker CreateInvoker(AppDomain domain, string exePath)
|
||||
{
|
||||
// Probe directory = recovered output, which ships every dependency DLL.
|
||||
string probeDir = Path.GetDirectoryName(AssemblyPaths.RecoveredExe);
|
||||
return (Invoker)domain.CreateInstanceFromAndUnwrap(
|
||||
typeof(Invoker).Assembly.Location,
|
||||
typeof(Invoker).FullName,
|
||||
false,
|
||||
BindingFlags.Default,
|
||||
null,
|
||||
new object[] { exePath, probeDir },
|
||||
null,
|
||||
null);
|
||||
}
|
||||
|
||||
/// <summary>Run a case in both domains and return (original, recovered).</summary>
|
||||
public (string Original, string Recovered) RunBoth(string caseName, params string[] args)
|
||||
{
|
||||
return (Original.Run(caseName, args), Recovered.Run(caseName, args));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_originalDomain != null) AppDomain.Unload(_originalDomain);
|
||||
if (_recoveredDomain != null) AppDomain.Unload(_recoveredDomain);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Xml;
|
||||
|
||||
namespace TeslaConsole.DiffTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Lives inside a child AppDomain whose base directory is the directory of one
|
||||
/// TeslaConsole.exe. It loads that exe and invokes deterministic, dependency-free
|
||||
/// methods by reflection, returning a normalized string so results marshal cleanly
|
||||
/// across the AppDomain boundary.
|
||||
///
|
||||
/// Only methods whose code path touches nothing but the framework + the exe itself
|
||||
/// are exercised, so the original (which ships without its proprietary dependency
|
||||
/// DLLs) loads and runs them fine.
|
||||
/// </summary>
|
||||
public sealed class Invoker : MarshalByRefObject
|
||||
{
|
||||
private readonly Assembly _asm;
|
||||
private readonly string _probeDir;
|
||||
|
||||
public Invoker(string exePath, string probeDir)
|
||||
{
|
||||
_probeDir = probeDir;
|
||||
// The original ships without its proprietary dependency DLLs; resolve any
|
||||
// referenced assembly out of the recovered build's output directory so both
|
||||
// assemblies expose the same metadata.
|
||||
AppDomain.CurrentDomain.AssemblyResolve += ResolveFromProbeDir;
|
||||
_asm = Assembly.LoadFrom(exePath);
|
||||
}
|
||||
|
||||
private Assembly ResolveFromProbeDir(object sender, ResolveEventArgs args)
|
||||
{
|
||||
var name = new AssemblyName(args.Name);
|
||||
if (string.Equals(name.Name, "TeslaConsole", StringComparison.OrdinalIgnoreCase))
|
||||
return _asm; // self-references bind to the assembly under test
|
||||
if (string.IsNullOrEmpty(_probeDir)) return null;
|
||||
foreach (var ext in new[] { ".dll", ".exe" })
|
||||
{
|
||||
string candidate = Path.Combine(_probeDir, name.Name + ext);
|
||||
if (File.Exists(candidate))
|
||||
return Assembly.LoadFrom(candidate);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Full identity of the loaded assembly (for sanity assertions).</summary>
|
||||
public string AssemblyFullName => _asm.FullName;
|
||||
|
||||
// ---- Structural (public API surface) enumeration, run inside this domain ----
|
||||
|
||||
public string[] GetPublicTypeNames()
|
||||
{
|
||||
return SafeGetTypes()
|
||||
.Where(t => (t.IsPublic || t.IsNestedPublic) && !IsCompilerGenerated(t))
|
||||
.Select(t => t.FullName)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public string[] GetPublicMemberSignatures()
|
||||
{
|
||||
var set = new HashSet<string>();
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.Instance |
|
||||
BindingFlags.Static | BindingFlags.DeclaredOnly;
|
||||
foreach (var t in SafeGetTypes())
|
||||
{
|
||||
if (!(t.IsPublic || t.IsNestedPublic) || IsCompilerGenerated(t))
|
||||
continue;
|
||||
foreach (var m in t.GetMembers(flags))
|
||||
{
|
||||
if (m.Name.IndexOf('<') >= 0 || IsCompilerGenerated(m))
|
||||
continue;
|
||||
// Skip property/event accessor methods: the property/event itself is
|
||||
// compared via its own entry, and Roslyn vs. the original compiler mark
|
||||
// these accessors' [CompilerGenerated] differently. Real operators
|
||||
// (op_*) are kept.
|
||||
if (IsAccessorMethod(m))
|
||||
continue;
|
||||
try { set.Add(t.FullName + " :: " + DescribeMember(m)); }
|
||||
catch { /* unresolved member metadata; skip symmetrically */ }
|
||||
}
|
||||
}
|
||||
return set.ToArray();
|
||||
}
|
||||
|
||||
private IEnumerable<Type> SafeGetTypes()
|
||||
{
|
||||
try { return _asm.GetTypes(); }
|
||||
catch (ReflectionTypeLoadException ex) { return ex.Types.Where(t => t != null); }
|
||||
}
|
||||
|
||||
private static string DescribeMember(MemberInfo m)
|
||||
{
|
||||
switch (m)
|
||||
{
|
||||
case MethodInfo mi:
|
||||
return "M " + mi.ReturnType.FullName + " " + mi.Name +
|
||||
"(" + string.Join(",", mi.GetParameters().Select(p => p.ParameterType.FullName)) + ")";
|
||||
case ConstructorInfo ci:
|
||||
return "C .ctor(" +
|
||||
string.Join(",", ci.GetParameters().Select(p => p.ParameterType.FullName)) + ")";
|
||||
case PropertyInfo pi:
|
||||
return "P " + pi.PropertyType.FullName + " " + pi.Name;
|
||||
case FieldInfo fi:
|
||||
return "F " + fi.FieldType.FullName + " " + fi.Name;
|
||||
case EventInfo ei:
|
||||
return "E " + ei.EventHandlerType.FullName + " " + ei.Name;
|
||||
case Type nt:
|
||||
return "T " + nt.FullName;
|
||||
default:
|
||||
return m.MemberType + " " + m.Name;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsAccessorMethod(MemberInfo m)
|
||||
{
|
||||
if (!(m is MethodInfo mi) || !mi.IsSpecialName) return false;
|
||||
string n = mi.Name;
|
||||
return n.StartsWith("get_") || n.StartsWith("set_") ||
|
||||
n.StartsWith("add_") || n.StartsWith("remove_");
|
||||
}
|
||||
|
||||
private static bool IsCompilerGenerated(MemberInfo m)
|
||||
{
|
||||
try { return m.IsDefined(typeof(CompilerGeneratedAttribute), false); }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispatch a named case. Returns the method's normalized result, or
|
||||
/// "EXC:<ExceptionTypeName>" when the underlying call throws — so exception
|
||||
/// behavior is compared too.
|
||||
/// </summary>
|
||||
public string Run(string caseName, string[] args)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Dispatch(caseName, args);
|
||||
}
|
||||
catch (TargetInvocationException tie)
|
||||
{
|
||||
return "EXC:" + (tie.InnerException ?? tie).GetType().Name;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return "EXC:" + ex.GetType().Name;
|
||||
}
|
||||
}
|
||||
|
||||
private string Dispatch(string caseName, string[] args)
|
||||
{
|
||||
switch (caseName)
|
||||
{
|
||||
case "GetTimeString":
|
||||
return RPStringsGetTimeString(long.Parse(args[0], CultureInfo.InvariantCulture));
|
||||
|
||||
case "HostTypeParse":
|
||||
return HostTypeParse(args[0]);
|
||||
|
||||
case "ConvertBitmap":
|
||||
return ConvertBitmap(
|
||||
int.Parse(args[0], CultureInfo.InvariantCulture),
|
||||
int.Parse(args[1], CultureInfo.InvariantCulture),
|
||||
int.Parse(args[2], CultureInfo.InvariantCulture));
|
||||
|
||||
case "GenerateString":
|
||||
return GenerateString(
|
||||
int.Parse(args[0], CultureInfo.InvariantCulture),
|
||||
int.Parse(args[1], CultureInfo.InvariantCulture),
|
||||
args[2], args[3]);
|
||||
|
||||
case "RPMapParse":
|
||||
return ParseRedPlanetNode("TeslaConsole.RedPlanet.RPMap", args[0]);
|
||||
|
||||
case "RPVehicleParse":
|
||||
return ParseRedPlanetNode("TeslaConsole.RedPlanet.RPVehicle", args[0]);
|
||||
|
||||
case "SiteManagementGuid":
|
||||
return SiteManagementGuid(args[0]);
|
||||
|
||||
case "TupleCreate":
|
||||
return TupleCreate(args[0], args[1]);
|
||||
|
||||
case "CatalogSummary":
|
||||
return CatalogSummary(args[0]);
|
||||
|
||||
case "CatalogEntry":
|
||||
return CatalogEntry(args[0], args[1], args[2], args[3]);
|
||||
|
||||
default:
|
||||
throw new ArgumentException("Unknown case: " + caseName);
|
||||
}
|
||||
}
|
||||
|
||||
private Type T(string fullName)
|
||||
{
|
||||
return _asm.GetType(fullName, throwOnError: true);
|
||||
}
|
||||
|
||||
private string RPStringsGetTimeString(long ticks)
|
||||
{
|
||||
var m = T("TeslaConsole.RPStrings").GetMethod(
|
||||
"GetTimeString", BindingFlags.Public | BindingFlags.Static);
|
||||
return (string)m.Invoke(null, new object[] { TimeSpan.FromTicks(ticks) });
|
||||
}
|
||||
|
||||
private string HostTypeParse(string input)
|
||||
{
|
||||
var helperType = T("TeslaConsole.HostTypeHelper");
|
||||
var parse = helperType.GetMethod("Parse", BindingFlags.Public | BindingFlags.Static);
|
||||
object helper = parse.Invoke(null, new object[] { input });
|
||||
return helper.ToString();
|
||||
}
|
||||
|
||||
private string ConvertBitmap(int width, int height, int seed)
|
||||
{
|
||||
using (var bmp = MakePatternBitmap(width, height, seed))
|
||||
{
|
||||
var m = T("TeslaConsole.PlasmaBitmaps").GetMethod(
|
||||
"ConvertBitmap", BindingFlags.Public | BindingFlags.Static);
|
||||
return (string)m.Invoke(null, new object[] { bmp });
|
||||
}
|
||||
}
|
||||
|
||||
private string GenerateString(int width, int height, string font, string text)
|
||||
{
|
||||
var m = T("TeslaConsole.PlasmaBitmaps").GetMethod(
|
||||
"GenerateString",
|
||||
BindingFlags.Public | BindingFlags.Static,
|
||||
null,
|
||||
new[] { typeof(int), typeof(int), typeof(string), typeof(string) },
|
||||
null);
|
||||
return (string)m.Invoke(null, new object[] { width, height, font, text });
|
||||
}
|
||||
|
||||
private string ParseRedPlanetNode(string typeName, string xml)
|
||||
{
|
||||
var doc = new XmlDocument();
|
||||
doc.LoadXml(xml);
|
||||
XmlNode node = doc.DocumentElement;
|
||||
|
||||
var type = T(typeName);
|
||||
var ctor = type.GetConstructor(
|
||||
BindingFlags.NonPublic | BindingFlags.Instance,
|
||||
null, new[] { typeof(XmlNode) }, null);
|
||||
object instance = ctor.Invoke(new object[] { node });
|
||||
|
||||
string key = (string)type.GetProperty("Key").GetValue(instance, null);
|
||||
string name = (string)type.GetProperty("Name").GetValue(instance, null);
|
||||
string toStr = instance.ToString();
|
||||
return key + "|" + name + "|" + toStr;
|
||||
}
|
||||
|
||||
private string SiteManagementGuid(string fieldName)
|
||||
{
|
||||
var f = T("TeslaConsole.SiteManagement").GetField(
|
||||
fieldName, BindingFlags.Public | BindingFlags.Static);
|
||||
return ((Guid)f.GetValue(null)).ToString("D");
|
||||
}
|
||||
|
||||
private string TupleCreate(string a, string b)
|
||||
{
|
||||
// Tuple.Create<int, string>(int.Parse(a), b) -> "A|B"
|
||||
var tupleType = T("TeslaConsole.Tuple");
|
||||
MethodInfo create = null;
|
||||
foreach (var m in tupleType.GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||
{
|
||||
if (m.Name == "Create" && m.IsGenericMethodDefinition &&
|
||||
m.GetGenericArguments().Length == 2 && m.GetParameters().Length == 2)
|
||||
{
|
||||
create = m;
|
||||
break;
|
||||
}
|
||||
}
|
||||
var closed = create.MakeGenericMethod(typeof(int), typeof(string));
|
||||
object tuple = closed.Invoke(null, new object[] { int.Parse(a, CultureInfo.InvariantCulture), b });
|
||||
|
||||
Type tt = tuple.GetType();
|
||||
object av = tt.GetProperty("A").GetValue(tuple, null);
|
||||
object bv = tt.GetProperty("B").GetValue(tuple, null);
|
||||
return Convert.ToString(av, CultureInfo.InvariantCulture) + "|" +
|
||||
Convert.ToString(bv, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private System.Collections.IEnumerable LoadCatalog(string path)
|
||||
{
|
||||
var reg = T("TeslaConsole.AppRegistry");
|
||||
reg.GetMethod("LoadFromFile", BindingFlags.Public | BindingFlags.Static)
|
||||
.Invoke(null, new object[] { path });
|
||||
return (System.Collections.IEnumerable)reg.GetProperty("Products").GetValue(null);
|
||||
}
|
||||
|
||||
private string CatalogSummary(string path)
|
||||
{
|
||||
int products = 0, entries = 0;
|
||||
foreach (var product in LoadCatalog(path))
|
||||
{
|
||||
products++;
|
||||
var list = (System.Collections.IEnumerable)product.GetType().GetField("Entries").GetValue(product);
|
||||
foreach (var _ in list) entries++;
|
||||
}
|
||||
return $"products={products};entries={entries}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the launch entry with the given key, builds its LaunchData with the optional
|
||||
/// resolution, and returns "DisplayName|LaunchKey|Exe|Args|WorkingDirectory|AutoRestart".
|
||||
/// </summary>
|
||||
private string CatalogEntry(string path, string launchKey, string resW, string resH)
|
||||
{
|
||||
Guid key = Guid.Parse(launchKey);
|
||||
object entry = null;
|
||||
foreach (var product in LoadCatalog(path))
|
||||
{
|
||||
var entries = (System.Collections.IEnumerable)product.GetType().GetField("Entries").GetValue(product);
|
||||
foreach (var en in entries)
|
||||
{
|
||||
if ((Guid)en.GetType().GetField("LaunchKey").GetValue(en) == key) { entry = en; break; }
|
||||
}
|
||||
if (entry != null) break;
|
||||
}
|
||||
if (entry == null) return "NOTFOUND";
|
||||
|
||||
object resolution = null;
|
||||
if (resW.Length > 0 && resH.Length > 0)
|
||||
resolution = new Size(int.Parse(resW, CultureInfo.InvariantCulture),
|
||||
int.Parse(resH, CultureInfo.InvariantCulture));
|
||||
|
||||
object ld = entry.GetType().GetMethod("ToLaunchData").Invoke(entry, new object[] { resolution });
|
||||
Type t = ld.GetType();
|
||||
object pair = t.GetField("LaunchPair").GetValue(ld);
|
||||
string disp = (string)pair.GetType().GetField("DisplayName").GetValue(pair);
|
||||
Guid lkey = (Guid)pair.GetType().GetField("LaunchKey").GetValue(pair);
|
||||
string exe = (string)t.GetField("ExeFile").GetValue(ld);
|
||||
string margs = (string)t.GetField("Arguments").GetValue(ld);
|
||||
string wd = (string)t.GetField("WorkingDirectory").GetValue(ld);
|
||||
bool ar = (bool)t.GetField("AutoRestart").GetValue(ld);
|
||||
return $"{disp}|{lkey:D}|{exe}|{margs}|{wd}|{ar}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic black/white bitmap whose pixels depend only on (x, y, seed),
|
||||
/// so both assemblies receive identical input pixels to pack.
|
||||
/// </summary>
|
||||
private static Bitmap MakePatternBitmap(int width, int height, int seed)
|
||||
{
|
||||
var bmp = new Bitmap(width, height);
|
||||
for (int y = 0; y < height; y++)
|
||||
{
|
||||
for (int x = 0; x < width; x++)
|
||||
{
|
||||
bool on = ((x * 31 + y * 17 + seed * 7) & 3) == 0;
|
||||
bmp.SetPixel(x, y, on ? Color.White : Color.Black);
|
||||
}
|
||||
}
|
||||
return bmp;
|
||||
}
|
||||
|
||||
// Keep the proxy alive for the life of the AppDomain.
|
||||
public override object InitializeLifetimeService() => null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace TeslaConsole.DiffTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Structural equivalence: every public type and public member exposed by the
|
||||
/// original TeslaConsole.exe must also be exposed, with the same signature, by the
|
||||
/// recovered build. This is a strong "same program" contract that catches any
|
||||
/// dropped, renamed, or re-signatured member introduced during reconstruction.
|
||||
///
|
||||
/// The enumeration runs inside each assembly's own AppDomain (see DifferentialFixture)
|
||||
/// because the two files share identical assembly identity and cannot coexist in one
|
||||
/// domain. Compiler-generated members are excluded — the README notes those legitimately
|
||||
/// differ between a decompilation and the lost original sources.
|
||||
/// </summary>
|
||||
public class PublicApiSurfaceTests : IClassFixture<DifferentialFixture>
|
||||
{
|
||||
private readonly DifferentialFixture _fx;
|
||||
|
||||
public PublicApiSurfaceTests(DifferentialFixture fx) => _fx = fx;
|
||||
|
||||
[Fact]
|
||||
public void Recovered_Exposes_All_Original_Public_Types()
|
||||
{
|
||||
var original = _fx.Original.GetPublicTypeNames();
|
||||
var recovered = _fx.Recovered.GetPublicTypeNames();
|
||||
|
||||
// Sanity: the original actually yielded a non-trivial surface to compare.
|
||||
Assert.True(original.Length > 20,
|
||||
"Only " + original.Length + " public types enumerated from the original; " +
|
||||
"dependency resolution likely failed.");
|
||||
|
||||
var missing = original.Except(recovered).OrderBy(s => s).ToArray();
|
||||
Assert.True(missing.Length == 0,
|
||||
"Public types present in the original but missing from the recovered build:\n " +
|
||||
string.Join("\n ", missing));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Recovered_Exposes_All_Original_Public_Members()
|
||||
{
|
||||
var original = _fx.Original.GetPublicMemberSignatures();
|
||||
var recovered = _fx.Recovered.GetPublicMemberSignatures();
|
||||
|
||||
Assert.True(original.Length > 100,
|
||||
"Only " + original.Length + " public members enumerated from the original; " +
|
||||
"dependency resolution likely failed.");
|
||||
|
||||
var missing = original.Except(recovered).OrderBy(s => s).ToArray();
|
||||
Assert.True(missing.Length == 0,
|
||||
missing.Length + " public member(s) present in the original but missing from the recovered build:\n " +
|
||||
string.Join("\n ", missing.Take(80)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
# TeslaConsole.DiffTests — differential equivalence suite
|
||||
|
||||
Verifies that the **reconstructed** `TeslaConsole.exe` (built from the decompiled
|
||||
source in this repo) behaves identically to the **original** reference binary in
|
||||
[`original/TeslaConsole.exe`](../../original/TeslaConsole.exe).
|
||||
|
||||
## How it works
|
||||
|
||||
Both files carry the *exact same* assembly identity
|
||||
(`TeslaConsole, Version=4.11.3.37076`), so the .NET loader will not hold both in
|
||||
one AppDomain. The suite therefore loads each assembly into its **own child
|
||||
AppDomain** (`DifferentialFixture`) and drives it through a `MarshalByRefObject`
|
||||
proxy (`Invoker`). This is why the project targets **net48** — AppDomains are a
|
||||
.NET Framework feature.
|
||||
|
||||
Each child domain is given a probe directory (the recovered build's output, which
|
||||
ships every dependency DLL) so the original — which is distributed without its
|
||||
proprietary dependencies — still resolves its references for metadata inspection.
|
||||
|
||||
### What is compared
|
||||
|
||||
1. **Public API surface** (`PublicApiSurfaceTests`)
|
||||
Every public type and public member (signature-for-signature) exposed by the
|
||||
original must also be exposed by the recovered build. Compiler-generated members
|
||||
and property/event accessor methods are excluded — the README at the repo root
|
||||
notes those legitimately differ between a decompilation and the lost sources.
|
||||
|
||||
2. **Behavioral output** (`BehavioralEquivalenceTests`)
|
||||
The same deterministic, dependency-free methods are invoked in *both* assemblies
|
||||
over a battery of inputs and the results must match byte-for-byte:
|
||||
- `RPStrings.GetTimeString` (mm:ss formatting + 0.5 s rounding)
|
||||
- `HostTypeHelper.Parse(...).ToString()` (incl. invalid-input exceptions)
|
||||
- `PlasmaBitmaps.ConvertBitmap` (1-bpp packing of a known pixel pattern)
|
||||
- `PlasmaBitmaps.GenerateString` (full GDI text → 1-bpp plasma pipeline)
|
||||
- `RPMap` / `RPVehicle` XML parsing
|
||||
- `SiteManagement` well-known application GUID constants
|
||||
- `Tuple.Create<,>` generic factory
|
||||
|
||||
A negative-control test (`Harness_Distinguishes_Different_Outputs`) proves the
|
||||
harness can actually see a difference, so a green run is never vacuous.
|
||||
|
||||
## Running
|
||||
|
||||
```
|
||||
dotnet test tests/TeslaConsole.DiffTests/TeslaConsole.DiffTests.csproj
|
||||
```
|
||||
|
||||
A project reference builds the reconstruction first, and the suite always tests
|
||||
the most recently built `bin/{Debug,Release}/net48/TeslaConsole.exe`.
|
||||
|
||||
## Scope / limitations
|
||||
|
||||
This compares **deterministic logic**. It deliberately does not drive the WinForms
|
||||
UI, the pod networking, secure-configuration, or hardware-facing code — those
|
||||
require the live console, its pods, and the proprietary services, and are not
|
||||
reproducible in a unit test. The API-surface test still asserts those types exist
|
||||
with matching signatures even though their behavior isn't exercised.
|
||||
@@ -0,0 +1,46 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
Differential test suite: verifies the reconstructed TeslaConsole.exe behaves
|
||||
identically to the original (reference baseline in ../../original).
|
||||
|
||||
Targets net48 on purpose: the comparison loads BOTH assemblies (which share
|
||||
the exact same assembly identity, TeslaConsole 4.11.3.37076) into separate
|
||||
AppDomains, which only the .NET Framework runtime supports.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>disable</Nullable>
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<AssemblyName>TeslaConsole.DiffTests</AssemblyName>
|
||||
<RootNamespace>TeslaConsole.DiffTests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- net48 reference assemblies so this builds without a full targeting pack installed -->
|
||||
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||
<PackageReference Include="xunit" Version="2.6.6" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.6" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="System.Drawing" />
|
||||
<Reference Include="System.Xml" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!--
|
||||
Build the reconstruction before the tests so we always compare against a fresh
|
||||
build. ReferenceOutputAssembly=false: we load it by path via reflection, and
|
||||
copying it next to the tests would collide with the original's identical identity.
|
||||
-->
|
||||
<ProjectReference Include="..\..\TeslaConsole.csproj">
|
||||
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
|
||||
<Private>false</Private>
|
||||
</ProjectReference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user