BREAKING: 3-tier split step 2 + engine-bridge v3 EgBim lambdas wired

Completes the Generic / HmEG-aware / App-specific separation started in
f6b6e74. The legacy EgPlugin / EngineBridge / EngineBridge.Client /
EngineBridge.Probe modules are moved into their proper tiers, namespaces
and csproj/sln entries are renamed, and the HmegDirectStateProvider
lambdas are finally populated with real handles from the EgBim plugin
host. A new Recordingtest.Architecture.Tests project enforces the tier
rule at build time.

Moves (git mv + csproj/RootNamespace/AssemblyName rename + sln):

  src/Recordingtest.EgPlugin
    -> src/Sut/EgBim/Recordingtest.Sut.EgBim.PluginHost
  src/Recordingtest.EngineBridge
    -> src/Hmeg/Recordingtest.Hmeg.Catalog
  src/Recordingtest.EngineBridge.Client
    -> src/Hmeg/Recordingtest.Hmeg.Bridge.Client
  src/Recordingtest.EngineBridge.Probe
    -> src/Hmeg/Recordingtest.Hmeg.Catalog.Probe

  tests/Recordingtest.EgPlugin.Tests
    -> tests/Sut/EgBim/Recordingtest.Sut.EgBim.PluginHost.Tests
  tests/Recordingtest.EngineBridge.Tests
    -> tests/Hmeg/Recordingtest.Hmeg.Catalog.Tests
  tests/Recordingtest.EngineBridge.IntegrationTests
    -> tests/Hmeg/Recordingtest.Hmeg.Catalog.IntegrationTests

Namespace rename applied across all .cs files and csproj RootNamespace:

  Recordingtest.EgPlugin           -> Recordingtest.Sut.EgBim.PluginHost
  Recordingtest.EngineBridge       -> Recordingtest.Hmeg.Catalog
  Recordingtest.EngineBridge.Client -> Recordingtest.Hmeg.Bridge.Client
  Recordingtest.EngineBridge.Probe -> Recordingtest.Hmeg.Catalog.Probe

New: tests/Recordingtest.Architecture.Tests/

  DependencyGraphTests walks Assembly.GetReferencedAssemblies() for each
  tier and fails if a forbidden reference leaks in:
    - Generic modules must not reference HmEG or any app-specific DLL
    - HmEG-aware modules must not reference app-specific DLLs
    - Recordingtest.Hmeg.Bridge must reference HmEG (positive check)
  11 tests, all passing. Prevents future drift from CLAUDE.md §8.1.

Engine-bridge v3 wire-up (HmEgBridgePlugin.BuildProvider):

  Previously the HmegDirectStateProvider lambdas returned null and the
  chain fell through to reflection. They now call directly into the
  EditorPlugin base class that HmEgBridgePlugin inherits:

    spaceProvider    = () => RootSpace
                           // AppManager.ViewportManager.RootSpace
    viewportProvider = () => View
                           // EGViewport : Control, HmEGViewport
    documentPathProvider = () => AppManager?.FileManager?.CurrentFile

  Every lambda is wrapped in try/catch so plugin construction still
  cannot throw back into the SUT. Editor02.HmEGAppManager.dll added as
  a reference on Recordingtest.Sut.EgBim.PluginHost.csproj — app-
  specific tier, which is allowed by the architecture tests.

Entry points were confirmed from read-only review of the SUT sources at
  D:\GiteaAll\EG-BIM_Modeler\EditorPluginInterface\EditorPlugin.cs
  D:\GiteaAll\EG-BIM_Modeler\HmEGApplicationManagementLibrary\HmEGAppManager.cs
  D:\GiteaAll\EG-BIM_Modeler\HmEGApplicationManagementLibrary\SubManager\FileManager.cs

closing out Q1/Q2/Q6/Q7 from docs/hmeg-api-survey.md.

Tests: 115 -> 126 (+11 Architecture), 0 failures.

Next step: live verification of /scene /camera /selection with a real
SUT session; any discrepancy in HmegDirectStateProvider reflection will
be tightened after observing real HmEG camera field names.

Ref: #10 follow-up, #14 follow-up, docs/contracts/generic-sut-split.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
minsung
2026-04-09 10:39:13 +09:00
parent f6b6e7449e
commit 03fb504eea
36 changed files with 542 additions and 206 deletions

View File

@@ -0,0 +1,72 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
namespace Recordingtest.Hmeg.Catalog.IntegrationTests;
public sealed class FakeBridgeServer : IDisposable
{
public Dictionary<string, string> Responses { get; } = new();
public TimeSpan ResponseDelay { get; set; } = TimeSpan.Zero;
private readonly HttpListener _listener;
private readonly Thread _thread;
private volatile bool _stop;
public int Port { get; }
public string BaseUrl => $"http://localhost:{Port}";
public FakeBridgeServer()
{
Port = FindFreePort();
_listener = new HttpListener();
_listener.Prefixes.Add($"http://localhost:{Port}/");
_listener.Start();
_thread = new Thread(Loop) { IsBackground = true };
_thread.Start();
}
private static int FindFreePort()
{
var l = new TcpListener(IPAddress.Loopback, 0);
l.Start();
var p = ((IPEndPoint)l.LocalEndpoint).Port;
l.Stop();
return p;
}
private void Loop()
{
while (!_stop && _listener.IsListening)
{
HttpListenerContext ctx;
try { ctx = _listener.GetContext(); }
catch { return; }
try
{
if (ResponseDelay > TimeSpan.Zero) Thread.Sleep(ResponseDelay);
var path = ctx.Request.Url?.AbsolutePath ?? "/";
if (Responses.TryGetValue(path, out var body))
{
var bytes = Encoding.UTF8.GetBytes(body);
ctx.Response.StatusCode = 200;
ctx.Response.ContentType = "application/json";
ctx.Response.OutputStream.Write(bytes, 0, bytes.Length);
}
else
{
ctx.Response.StatusCode = 404;
}
ctx.Response.OutputStream.Close();
}
catch { try { ctx.Response.Abort(); } catch { } }
}
}
public void Dispose()
{
_stop = true;
try { _listener.Stop(); } catch { }
try { _listener.Close(); } catch { }
}
}

View File

@@ -0,0 +1,66 @@
using Recordingtest.Hmeg.Bridge.Client;
using Xunit;
namespace Recordingtest.Hmeg.Catalog.IntegrationTests;
public class HmEgHttpSnapshotTests
{
[Fact]
public void Client_SelectionEndpoint_ReturnsIds()
{
using var srv = new FakeBridgeServer();
srv.Responses["/selection"] = "{\"selected_ids\":[\"a\",\"b\"]}";
using var c = new HmEgHttpSnapshot(srv.BaseUrl);
Assert.Equal(new[] { "a", "b" }, c.SelectedObjectIds);
}
[Fact]
public void Client_CameraEndpoint_ReturnsCameraState()
{
using var srv = new FakeBridgeServer();
srv.Responses["/camera"] = "{\"eye\":[1,2,3],\"target\":[4,5,6],\"up\":[0,0,1],\"fov\":50}";
using var c = new HmEgHttpSnapshot(srv.BaseUrl);
var cam = c.Camera;
Assert.Equal(new double[] { 1, 2, 3 }, cam.EyePoint);
Assert.Equal(new double[] { 4, 5, 6 }, cam.Target);
Assert.Equal(50, cam.Fov);
}
[Fact]
public void Client_SceneEndpoint_ReturnsSceneSummary()
{
using var srv = new FakeBridgeServer();
srv.Responses["/scene"] = "{\"object_count\":42,\"document_path\":\"C:/m.hmeg\"}";
using var c = new HmEgHttpSnapshot(srv.BaseUrl);
var s = c.Scene;
Assert.Equal(42, s.ObjectCount);
Assert.Equal("C:/m.hmeg", s.DocumentPath);
}
[Fact]
public void Client_RenderEndpoint_ReturnsIsComplete()
{
using var srv = new FakeBridgeServer();
srv.Responses["/render"] = "{\"complete\":true}";
using var c = new HmEgHttpSnapshot(srv.BaseUrl);
Assert.True(c.IsRenderComplete);
}
[Fact]
public void Client_HealthEndpoint_ReturnsOk()
{
using var srv = new FakeBridgeServer();
srv.Responses["/health"] = "{\"status\":\"ok\",\"port\":1}";
using var c = new HmEgHttpSnapshot(srv.BaseUrl);
Assert.True(c.IsHealthy);
}
[Fact]
public void Client_Timeout_ThrowsEngineBridgeException()
{
using var srv = new FakeBridgeServer { ResponseDelay = TimeSpan.FromSeconds(5) };
srv.Responses["/selection"] = "{\"selected_ids\":[]}";
using var c = new HmEgHttpSnapshot(srv.BaseUrl, timeout: TimeSpan.FromMilliseconds(500));
Assert.Throws<EngineBridgeException>(() => _ = c.SelectedObjectIds);
}
}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<RootNamespace>Recordingtest.Hmeg.Catalog.IntegrationTests</RootNamespace>
<AssemblyName>Recordingtest.Hmeg.Catalog.IntegrationTests</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Hmeg\Recordingtest.Hmeg.Bridge.Client\Recordingtest.Hmeg.Bridge.Client.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,135 @@
using System.Reflection;
using System.Text.Json;
using Recordingtest.Hmeg.Catalog;
using Xunit;
namespace Recordingtest.Hmeg.Catalog.Tests;
public sealed class EngineBridgeTests
{
private static readonly Lazy<string?> SutRootLazy = new(FindSutRoot);
private static string? SutRoot => SutRootLazy.Value;
private static bool SutAvailable => SutRoot != null;
private static readonly string[] TargetAssemblies =
{
"HmEG.dll",
"HmGeometry.dll",
"HmGeometry.V2.dll",
"HmTriangle.dll",
"EditorCore.dll",
"Editor02.HmEGAppManager.dll",
"Editor03.PluginInterface.dll",
};
private static string? FindSutRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
for (int i = 0; i < 10 && dir != null; i++)
{
var candidate = Path.Combine(dir.FullName, "EG-BIM Modeler");
if (Directory.Exists(candidate) && File.Exists(Path.Combine(candidate, "HmEG.dll")))
return candidate;
dir = dir.Parent;
}
return null;
}
[Fact]
public void MetadataLoader_LoadsHmegAssembly_WithoutExecution()
{
if (!SutAvailable) { Assert.True(true, "SUT not available — skipped"); return; }
// MetadataLoadContext is pure metadata: it never runs type initializers
// or any user code from the loaded assembly. The fact that we can load
// HmEG.dll (which would otherwise need SharpDX/WPF at runtime) and
// enumerate DefinedTypes without a TypeInitializationException is the
// observable evidence of that guarantee.
using var loader = new MetadataLoader(SutRoot!);
var types = loader.LoadTypes(new[] { "HmEG.dll" }).ToList();
Assert.True(types.Count > 0);
}
[Fact]
public void CandidateFinder_FindsSelectionRelatedTypes()
{
if (!SutAvailable) { Assert.True(true, "SUT not available — skipped"); return; }
using var loader = new MetadataLoader(SutRoot!);
var types = loader.LoadTypes(TargetAssemblies).ToList();
var candidates = CandidateFinder.Find(types);
Assert.Contains(candidates, c => c.Category == "select");
}
[Fact]
public void CatalogSerializer_OutputsSorted_Idempotent()
{
if (!SutAvailable) { Assert.True(true, "SUT not available — skipped"); return; }
using var loader = new MetadataLoader(SutRoot!);
var types = loader.LoadTypes(new[] { "HmEG.dll" }).ToList();
var candidates = CandidateFinder.Find(types);
var tmp1 = Path.Combine(Path.GetTempPath(), "eb-cand-1-" + Guid.NewGuid().ToString("N") + ".json");
var tmp2 = Path.Combine(Path.GetTempPath(), "eb-cand-2-" + Guid.NewGuid().ToString("N") + ".json");
try
{
CatalogWriter.WriteCandidates(tmp1, candidates);
CatalogWriter.WriteCandidates(tmp2, candidates);
Assert.Equal(File.ReadAllBytes(tmp1), File.ReadAllBytes(tmp2));
}
finally
{
File.Delete(tmp1);
File.Delete(tmp2);
}
}
[Fact]
public void HmEgSnapshot_DefaultInstance_ThrowsNotImplemented()
{
var s = new HmEgSnapshot();
Assert.Throws<NotImplementedException>(() => _ = s.SelectedObjectIds);
Assert.Throws<NotImplementedException>(() => _ = s.Camera);
Assert.Throws<NotImplementedException>(() => _ = s.Scene);
Assert.Throws<NotImplementedException>(() => _ = s.IsRenderComplete);
}
[Fact]
public void CandidateCategories_AllFourPresent()
{
if (!SutAvailable) { Assert.True(true, "SUT not available — skipped"); return; }
using var loader = new MetadataLoader(SutRoot!);
var types = loader.LoadTypes(TargetAssemblies).ToList();
var candidates = CandidateFinder.Find(types);
foreach (var cat in new[] { "select", "camera", "scene", "render" })
{
Assert.True(candidates.Any(c => c.Category == cat), $"category '{cat}' missing");
}
}
[Fact]
public void HmEgSnapshot_Constants_MatchCatalog()
{
if (!SutAvailable) { Assert.True(true, "SUT not available — skipped"); return; }
using var loader = new MetadataLoader(SutRoot!);
var types = loader.LoadTypes(TargetAssemblies).ToList();
var candidates = CandidateFinder.Find(types);
// HmEgAssemblyHint ("HmEG") should be a prefix of at least one loaded assembly.
var asmNames = types.Select(t => t.Assembly.GetName().Name ?? "").Distinct().ToList();
Assert.Contains(asmNames, a => a.StartsWith(HmEgSnapshot.HmEgAssemblyHint, StringComparison.Ordinal));
// EditorManagerTypeHint ("HmEGAppManager") should appear in at least one candidate TypeName OR type entry.
var appearsInCandidates = candidates.Any(c =>
c.TypeName.Contains(HmEgSnapshot.EditorManagerTypeHint, StringComparison.Ordinal));
var appearsInTypes = types.Any(t =>
(t.FullName ?? t.Name).Contains(HmEgSnapshot.EditorManagerTypeHint, StringComparison.Ordinal) ||
(t.Assembly.GetName().Name ?? "").Contains(HmEgSnapshot.EditorManagerTypeHint, StringComparison.Ordinal));
Assert.True(appearsInCandidates || appearsInTypes,
$"EditorManagerTypeHint '{HmEgSnapshot.EditorManagerTypeHint}' not found in catalog");
}
}

View File

@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
<RootNamespace>Recordingtest.Hmeg.Catalog.Tests</RootNamespace>
<AssemblyName>Recordingtest.Hmeg.Catalog.Tests</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Hmeg\Recordingtest.Hmeg.Catalog\Recordingtest.Hmeg.Catalog.csproj" />
</ItemGroup>
</Project>