3-tier split (step 1) + engine-bridge v3 scaffold + HmegDirectStateProvider

Lays down the Generic / HmEG-aware / App-specific separation that lets us
target other HmEG-hosting WPF applications later, and lands the v3 engine
state provider on top of it.

Architecture rule (CLAUDE.md §8.1, new): every module belongs to exactly one
of three tiers — Generic / HmEG-aware / App-specific (e.g. EgBim). Dependency
direction is strictly App-specific → HmEG-aware → Generic. Generic must not
reference HmEG.dll; HmEG-aware must not reference any per-app assembly.

This commit is the first incremental step:

  + src/Recordingtest.Bridge.Abstractions/  (Generic, new csproj)
      IEngineStateProvider, CameraSnapshot, SceneSnapshot,
      NullEngineStateProvider — extracted from EgPlugin so the generic core
      owns the contract. Zero SUT references.

  + src/Hmeg/Recordingtest.Hmeg.Bridge/      (HmEG-aware, new csproj)
      HmegDirectStateProvider — IEngineStateProvider implemented against
      the HmEG public API (Space, HmEGViewport, ISelectable, ModelBase.Uid).
      Decoupled from any specific host app via Func<Space?>/Func<HmEGViewport?>
      lambdas; the EgBim plugin host supplies them. Reusable for any other
      WPF application that hosts HmEG.

      Selection traversal walks Space.Children and collects ModelBase.Uid
      for nodes whose ISelectable.IsSelected is true. We deliberately type
      nodes as object + late-bound Uid lookup to avoid pulling MemoryPack
      into the dependency graph.

  + tests/Hmeg/Recordingtest.Hmeg.Bridge.Tests/
      5 unit tests covering null lambdas, throwing lambdas, document path
      provider, and constructor null arg validation.

  + src/Recordingtest.EgPlugin/ChainedEngineStateProvider.cs
      Wraps two providers; falls back from Hmeg.Direct to the existing
      Reflection accessor when the primary returns empty/default. Lets us
      land the new wire-up before the EgBim adapter Q1~Q7 lookups are
      filled in. 7 new tests.

  + src/Recordingtest.EgPlugin/IAppManagerAccessor.cs
      Reflection accessor abstraction (preserved as the v3 fallback). Looks
      up Editor.AppManager.AppManager via well-known Instance/Current
      property names. Unit-testable through a fake.

  ~ src/Recordingtest.EgPlugin/IEngineStateProvider.cs
      Type definitions removed (now in Bridge.Abstractions); only the
      reflection-based provider remains. ReflectionEngineStateProvider
      delegates everything to IAppManagerAccessor.

  ~ src/Recordingtest.EgPlugin/HmEgBridgePlugin.cs
      BuildProvider() picks ChainedEngineStateProvider(Hmeg.Direct,
      Reflection). The HmEG-aware lambdas are stubs (return null) until the
      next step wires the EgBim adapter; the chain falls through to the
      reflection path so behaviour matches v2 for now.

  + docs/contracts/engine-bridge-v3.md       — Sprint Contract
  + docs/contracts/generic-sut-split.md      — Sprint Contract for the
      remaining mass-rename / folder move (step 2, deferred).
  + docs/hmeg-api-survey.md                  — Read-only survey of the HmEG
      public API (Space, ModelBase, HmEGViewport, IHmCamera, IPlugin) used
      to design HmegDirectStateProvider. Open Q1~Q7 listed.

Tests: 94 → 115 passing, 0 failing. The new HmEG-aware test project copies
HmEG.dll next to its output (Private=true) since it runs out-of-process.

Step 2 (deferred to next session): mass-rename
  src/Recordingtest.EgPlugin → src/Sut/EgBim/Recordingtest.Sut.EgBim.PluginHost + .Adapter
  src/Recordingtest.EngineBridge → src/Hmeg/Recordingtest.Hmeg.Catalog
  src/Recordingtest.EngineBridge.Client → split (Generic + Hmeg)
plus Recordingtest.Architecture.Tests to enforce the §8.1 dependency rule.

Ref: #10 follow-up, #14 follow-up.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
minsung
2026-04-09 09:53:27 +09:00
parent a771352bcb
commit f6b6e7449e
24 changed files with 1743 additions and 32 deletions

View File

@@ -0,0 +1,73 @@
using Recordingtest.Bridge;
using Recordingtest.Hmeg.Bridge;
using Xunit;
namespace Recordingtest.Hmeg.Bridge.Tests;
public class HmegDirectStateProviderTests
{
[Fact]
public void NullLambdas_Return_SafeDefaults_NoThrow()
{
var p = new HmegDirectStateProvider(
spaceProvider: () => null,
viewportProvider: () => null);
Assert.Empty(p.GetSelectedIds());
var c = p.GetCamera();
Assert.Equal(45.0, c.Fov);
Assert.Equal(new double[] { 0, 0, 1 }, c.Up);
var s = p.GetScene();
Assert.Equal(0, s.ObjectCount);
Assert.Null(s.DocumentPath);
Assert.True(p.GetRenderComplete());
}
[Fact]
public void Throwing_Lambdas_Are_Swallowed_Returns_SafeDefaults()
{
var p = new HmegDirectStateProvider(
spaceProvider: () => throw new InvalidOperationException("boom"),
viewportProvider: () => throw new InvalidOperationException("boom"));
Assert.Empty(p.GetSelectedIds());
var c = p.GetCamera();
Assert.Equal(45.0, c.Fov);
var s = p.GetScene();
Assert.Equal(0, s.ObjectCount);
}
[Fact]
public void DocumentPathProvider_Is_Used_For_Scene()
{
var p = new HmegDirectStateProvider(
spaceProvider: () => null,
viewportProvider: () => null,
documentPathProvider: () => "C:/sample.hmeg");
var s = p.GetScene();
Assert.Equal("C:/sample.hmeg", s.DocumentPath);
}
[Fact]
public void DocumentPathProvider_Throwing_Is_Swallowed()
{
var p = new HmegDirectStateProvider(
spaceProvider: () => null,
viewportProvider: () => null,
documentPathProvider: () => throw new InvalidOperationException());
var s = p.GetScene();
Assert.Null(s.DocumentPath);
}
[Fact]
public void Constructor_Throws_OnNullProviders()
{
Assert.Throws<ArgumentNullException>(() =>
new HmegDirectStateProvider(null!, () => null));
Assert.Throws<ArgumentNullException>(() =>
new HmegDirectStateProvider(() => null, null!));
}
}

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<RootNamespace>Recordingtest.Hmeg.Bridge.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Hmeg\Recordingtest.Hmeg.Bridge\Recordingtest.Hmeg.Bridge.csproj" />
<ProjectReference Include="..\..\..\src\Recordingtest.Bridge.Abstractions\Recordingtest.Bridge.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<Reference Include="HmEG">
<HintPath>..\..\..\EG-BIM Modeler\HmEG.dll</HintPath>
<!-- Test process is standalone (not loaded into the SUT), so the
assembly must be copied next to the test dll at runtime. -->
<Private>true</Private>
</Reference>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,93 @@
using Recordingtest.Bridge;
using Recordingtest.EgPlugin;
using Xunit;
namespace Recordingtest.EgPlugin.Tests;
public class ChainedEngineStateProviderTests
{
private sealed class ScriptedProvider : IEngineStateProvider
{
public IReadOnlyList<string> SelectedIds = Array.Empty<string>();
public CameraSnapshot Camera = new(
new double[] { 0, 0, 0 },
new double[] { 0, 0, 0 },
new double[] { 0, 0, 1 },
45.0);
public SceneSnapshot Scene = new(0, null);
public bool Render = true;
public IReadOnlyList<string> GetSelectedIds() => SelectedIds;
public CameraSnapshot GetCamera() => Camera;
public SceneSnapshot GetScene() => Scene;
public bool GetRenderComplete() => Render;
}
[Fact]
public void Selection_Primary_NonEmpty_Wins()
{
var p = new ScriptedProvider { SelectedIds = new[] { "a" } };
var f = new ScriptedProvider { SelectedIds = new[] { "fallback" } };
var c = new ChainedEngineStateProvider(p, f);
Assert.Equal(new[] { "a" }, c.GetSelectedIds());
}
[Fact]
public void Selection_Primary_Empty_Falls_Through()
{
var p = new ScriptedProvider();
var f = new ScriptedProvider { SelectedIds = new[] { "fallback" } };
var c = new ChainedEngineStateProvider(p, f);
Assert.Equal(new[] { "fallback" }, c.GetSelectedIds());
}
[Fact]
public void Camera_Primary_Default_Falls_Through()
{
var p = new ScriptedProvider();
var fc = new CameraSnapshot(new double[] { 1, 2, 3 }, new double[] { 4, 5, 6 }, new double[] { 0, 0, 1 }, 60);
var f = new ScriptedProvider { Camera = fc };
var c = new ChainedEngineStateProvider(p, f);
Assert.Equal(60.0, c.GetCamera().Fov);
}
[Fact]
public void Camera_Primary_NonDefault_Wins()
{
var pc = new CameraSnapshot(new double[] { 1, 0, 0 }, new double[] { 0, 0, 0 }, new double[] { 0, 0, 1 }, 30);
var p = new ScriptedProvider { Camera = pc };
var f = new ScriptedProvider();
var c = new ChainedEngineStateProvider(p, f);
Assert.Equal(30.0, c.GetCamera().Fov);
}
[Fact]
public void Scene_Primary_Empty_Falls_Through()
{
var p = new ScriptedProvider();
var f = new ScriptedProvider { Scene = new(7, "x.hmeg") };
var c = new ChainedEngineStateProvider(p, f);
var s = c.GetScene();
Assert.Equal(7, s.ObjectCount);
Assert.Equal("x.hmeg", s.DocumentPath);
}
[Fact]
public void Scene_Primary_Has_Path_Wins_Even_With_Zero_Objects()
{
var p = new ScriptedProvider { Scene = new(0, "primary.hmeg") };
var f = new ScriptedProvider { Scene = new(99, "fallback.hmeg") };
var c = new ChainedEngineStateProvider(p, f);
var s = c.GetScene();
Assert.Equal("primary.hmeg", s.DocumentPath);
}
[Fact]
public void Render_Primary_Always_Wins()
{
var p = new ScriptedProvider { Render = false };
var f = new ScriptedProvider { Render = true };
var c = new ChainedEngineStateProvider(p, f);
Assert.False(c.GetRenderComplete());
}
}

View File

@@ -0,0 +1,149 @@
using Recordingtest.Bridge;
using Recordingtest.EgPlugin;
using Xunit;
namespace Recordingtest.EgPlugin.Tests;
public class ReflectionEngineStateProviderTests
{
private sealed class FakeAccessor : IAppManagerAccessor
{
public object? AppManager { get; set; } = new object();
public object? ActiveDocument { get; set; }
public object? ActiveViewport { get; set; }
public IReadOnlyList<string> SelectedIds { get; set; } = Array.Empty<string>();
public int ObjectCount { get; set; }
public string? DocumentPath { get; set; }
public (double[] Eye, double[] Target, double[] Up, double Fov)? Camera { get; set; }
public bool ThrowOnSelection { get; set; }
public bool ThrowOnCamera { get; set; }
public bool ThrowOnScene { get; set; }
public object? GetAppManager() => AppManager;
public object? GetActiveDocument() => ActiveDocument;
public object? GetActiveViewport() => ActiveViewport;
public IReadOnlyList<string> GetSelectedIds()
{
if (ThrowOnSelection) throw new InvalidOperationException("boom");
return SelectedIds;
}
public int GetObjectCount()
{
if (ThrowOnScene) throw new InvalidOperationException("boom");
return ObjectCount;
}
public string? GetDocumentPath()
{
if (ThrowOnScene) throw new InvalidOperationException("boom");
return DocumentPath;
}
public (double[] Eye, double[] Target, double[] Up, double Fov)? GetCameraTuple()
{
if (ThrowOnCamera) throw new InvalidOperationException("boom");
return Camera;
}
}
[Fact]
public void GetSelectedIds_Returns_Accessor_Values()
{
var fa = new FakeAccessor { SelectedIds = new[] { "a", "b" } };
var p = new ReflectionEngineStateProvider(fa);
var ids = p.GetSelectedIds();
Assert.Equal(new[] { "a", "b" }, ids);
}
[Fact]
public void GetSelectedIds_Swallows_Exception_Returns_Empty()
{
var fa = new FakeAccessor { ThrowOnSelection = true };
var p = new ReflectionEngineStateProvider(fa);
Assert.Empty(p.GetSelectedIds());
}
[Fact]
public void GetCamera_Returns_Accessor_Tuple()
{
var fa = new FakeAccessor
{
Camera = (
Eye: new double[] { 1, 2, 3 },
Target: new double[] { 4, 5, 6 },
Up: new double[] { 0, 0, 1 },
Fov: 60.0)
};
var p = new ReflectionEngineStateProvider(fa);
var c = p.GetCamera();
Assert.Equal(new double[] { 1, 2, 3 }, c.Eye);
Assert.Equal(new double[] { 4, 5, 6 }, c.Target);
Assert.Equal(60.0, c.Fov);
}
[Fact]
public void GetCamera_Null_Tuple_Returns_Default()
{
var fa = new FakeAccessor { Camera = null };
var p = new ReflectionEngineStateProvider(fa);
var c = p.GetCamera();
Assert.Equal(45.0, c.Fov);
Assert.Equal(new double[] { 0, 0, 1 }, c.Up);
}
[Fact]
public void GetCamera_Throwing_Accessor_Returns_Default_Not_Throw()
{
var fa = new FakeAccessor { ThrowOnCamera = true };
var p = new ReflectionEngineStateProvider(fa);
var c = p.GetCamera();
Assert.Equal(45.0, c.Fov);
}
[Fact]
public void GetScene_Returns_Accessor_Values()
{
var fa = new FakeAccessor { ObjectCount = 42, DocumentPath = "C:/x.hmeg" };
var p = new ReflectionEngineStateProvider(fa);
var s = p.GetScene();
Assert.Equal(42, s.ObjectCount);
Assert.Equal("C:/x.hmeg", s.DocumentPath);
}
[Fact]
public void GetScene_Throwing_Accessor_Returns_Empty_Default()
{
var fa = new FakeAccessor { ThrowOnScene = true };
var p = new ReflectionEngineStateProvider(fa);
var s = p.GetScene();
Assert.Equal(0, s.ObjectCount);
Assert.Null(s.DocumentPath);
}
[Fact]
public void Default_Reflection_Accessor_Without_Hmeg_Returns_Null_Safely()
{
// CI process has no Editor.AppManager.AppManager type loaded; the
// accessor must return null/empty/default without throwing.
var acc = new ReflectionAppManagerAccessor(seedRoot: new object());
Assert.Null(acc.GetAppManager());
Assert.Null(acc.GetActiveDocument());
Assert.Null(acc.GetActiveViewport());
Assert.Empty(acc.GetSelectedIds());
Assert.Equal(0, acc.GetObjectCount());
Assert.Null(acc.GetDocumentPath());
Assert.Null(acc.GetCameraTuple());
}
[Fact]
public void Provider_With_Default_Accessor_Without_Hmeg_Returns_Defaults()
{
// End-to-end fallback: provider built from a non-HmEG seed must
// produce the v2 safe defaults so /state continues to respond.
var p = new ReflectionEngineStateProvider(seedRoot: new object());
Assert.Empty(p.GetSelectedIds());
var c = p.GetCamera();
Assert.Equal(45.0, c.Fov);
var s = p.GetScene();
Assert.Equal(0, s.ObjectCount);
Assert.True(p.GetRenderComplete());
}
}

View File

@@ -1,4 +1,5 @@
using System.Net;
using Recordingtest.Bridge;
using Recordingtest.EgPlugin;
using Xunit;