Files
lingua-engine/tests/Engine.Kernel.Tests/PluginHostTests.cs
T
EmilandClaude Sonnet 5 246b969744 Close M0: headless CLI, JSON world dump, project-level plugin loading
The last piece of the agent loop from docs/kernel-contract.md §7:

- WorldDumper serializes a World to JSON — every GameObject, its
  transform, its components (arbitrary plugin-defined classes, so this
  leans on System.Text.Json's own reflection with IncludeFields=true,
  since components are public fields, not properties). Components are
  a list of {type, data}, not a dictionary keyed by type name:
  AddComponent<T>() doesn't enforce uniqueness, so a GameObject can
  carry two components of the same type, and a dictionary would throw
  on exactly that case — tested directly.
- PluginHost.LoadProject reads a project.json and resolves each
  referenced plugin id against engine + project-local search paths,
  finally putting ProjectManifest/PluginReference to use — they'd sat
  unused since the very first scaffold commit.
- Engine.Host is a real CLI now: `engine run --headless --plugins <dir>
  --project <project.json> --frames <n> [--dump <path>]`. --scene and
  --assert are explicitly rejected with a message pointing at why
  (no scene format yet — that's M2; no query DSL was ever actually
  specified for --assert, and jq over a plain JSON dump already covers
  that need), rather than silently ignored or generically rejected.
  Same for `engine diag why-pinned`: nothing has ever failed to unload
  in testing, so there's nothing to build that against yet.
- EchoPlugin now seeds one Ping-bearing GameObject in Configure() —
  sandbox.echo is documented as a test fixture, not a real subsystem,
  and there's no scene format yet to seed content any other way.

Verified by hand, not just by unit tests, since Program.cs itself
isn't covered by any: assembled a real flat plugin directory
(plugin.json + both built DLLs) and actually ran the CLI against
sandbox.echo end to end — 3 frames in, Ping.Count came back 3 in the
dump. Also checked every "not implemented" path (--scene, `diag`,
missing required args) prints its intended message rather than a
generic error.

32 tests total now (26 in Engine.Kernel.Tests, 6 in
Engine.ConformanceHarness), all green on a clean build.

README's Status section updated — M0 was the whole reason this
project exists (fast iteration without Unity's domain reload), and
that claim is no longer just architecture on paper.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
2026-09-02 01:07:36 +03:00

64 lines
1.8 KiB
C#

using Engine.Kernel.Events;
using Engine.Kernel.Plugins;
using Engine.Kernel.Scheduling;
using Engine.Kernel.Services;
using Engine.Kernel.World;
namespace Engine.Kernel.Tests;
/// <summary>
/// Edge cases that don't need a real, compiled plugin — see
/// Engine.ConformanceHarness/AlcUnloadTests.cs for the load/unload/reload
/// path exercised against a real one (Sandbox.Echo).
/// </summary>
public class PluginHostTests
{
private static PluginHost NewHost() =>
new(new GameWorld(), new ServiceRegistry(), new Schedule(), new NullEventBus());
[Fact]
public void Load_Throws_When_The_Directory_Has_No_Manifest()
{
var host = NewHost();
var emptyDir = Directory.CreateTempSubdirectory();
try
{
Assert.Throws<FileNotFoundException>(() => host.Load(emptyDir.FullName));
}
finally
{
emptyDir.Delete(recursive: true);
}
}
[Fact]
public void LoadProject_Throws_When_The_Project_Manifest_Is_Missing()
{
var host = NewHost();
var missingPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}-project.json");
Assert.Throws<FileNotFoundException>(() => host.LoadProject(missingPath, []));
}
[Fact]
public void LoadProject_Throws_When_A_Referenced_Plugin_Is_Not_Found_In_Any_Search_Path()
{
var host = NewHost();
var dir = Directory.CreateTempSubdirectory();
try
{
var projectPath = Path.Combine(dir.FullName, "project.json");
File.WriteAllText(projectPath,
"""{ "engineVersion": "^0.1", "plugins": [ { "id": "nonexistent.plugin" } ] }""");
Assert.Throws<InvalidOperationException>(() => host.LoadProject(projectPath, [dir.FullName]));
}
finally
{
dir.Delete(recursive: true);
}
}
}