Editor/Projection/Tests/GoldenRunner.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using Grains.RazorDesigner.Serialization;
using Sandbox;
namespace Grains.RazorDesigner.Projection.Tests;
public static class GoldenRunner
{
private const string LogPrefix = "[Grains.RazorDesigner]";
private const int SupportedFormatVersion = 1;
private static string GoldensDir()
{
var root = Project.Current?.RootDirectory?.FullName
?? throw new InvalidOperationException( "GoldenRunner: Project.Current is null — cannot locate Goldens directory." );
return Path.Combine( root, "Editor", "Projection", "Tests", "Goldens" );
}
public readonly struct GoldenResult
{
public string Kind { get; }
public bool Pass { get; }
public string Message { get; }
public GoldenResult( string kind, bool pass, string message )
{
Kind = kind;
Pass = pass;
Message = message;
}
}
// Run all *.golden.json fixtures + PanelOpExhaustivenessTest.
public static List<GoldenResult> RunAll()
{
var results = new List<GoldenResult>();
var dir = GoldensDir();
if ( !Directory.Exists( dir ) )
{
Log.Warning( $"{LogPrefix} GoldenRunner.RunAll: Goldens directory not found at '{dir}'." );
results.Add( new GoldenResult( "<dir-missing>", false, $"Goldens directory missing: {dir}" ) );
return results;
}
foreach ( var path in Directory.GetFiles( dir, "*.golden.json" ) )
{
var result = RunFile( path );
results.Add( result );
if ( result.Pass )
Log.Info( $"{LogPrefix} PASS [{result.Kind}]" );
else
Log.Warning( $"{LogPrefix} FAIL [{result.Kind}]: {result.Message}" );
}
// --- Exhaustiveness test ---
var (exPass, exMsg) = PanelOpExhaustivenessTest.Run();
results.Add( new GoldenResult( "PanelOpExhaustivenessTest", exPass, exMsg ) );
if ( exPass )
Log.Info( $"{LogPrefix} PASS [PanelOpExhaustivenessTest]: {exMsg}" );
else
Log.Warning( $"{LogPrefix} FAIL [PanelOpExhaustivenessTest]: {exMsg}" );
return results;
}
// Run a single kind by name (matches filename stem <kind>.golden.json).
public static GoldenResult Run( string kind )
{
var path = Path.Combine( GoldensDir(), $"{kind.ToLowerInvariant()}.golden.json" );
if ( !File.Exists( path ) )
return new GoldenResult( kind, false, $"No golden file at '{path}'" );
return RunFile( path );
}
public static void WriteGolden( string kind, bool overwrite = false )
{
var dir = GoldensDir();
var path = Path.Combine( dir, $"{kind.ToLowerInvariant()}.golden.json" );
if ( !File.Exists( path ) )
throw new FileNotFoundException( $"GoldenRunner.WriteGolden: fixture file not found at '{path}'. Create the fixture node first." );
var json = File.ReadAllText( path );
var fixture = JsonSerializer.Deserialize<GoldenFixture>( json, GoldenJson.Options )
?? throw new InvalidOperationException( $"GoldenRunner.WriteGolden: failed to deserialize '{path}'." );
if ( fixture.Expected != null && !overwrite )
throw new InvalidOperationException(
$"GoldenRunner.WriteGolden: fixture '{kind}' already has an expected block. Pass overwrite:true to replace it." );
var result = RunProjection( fixture );
fixture.Expected = ResultToExpected( result );
var updated = JsonSerializer.Serialize( fixture, GoldenJson.Options );
File.WriteAllText( path, updated );
Log.Info( $"{LogPrefix} GoldenRunner.WriteGolden: wrote expected block to '{path}'." );
}
// Write (or overwrite) expected blocks for ALL fixtures.
public static void WriteAllGoldens( bool overwrite = false )
{
var dir = GoldensDir();
if ( !Directory.Exists( dir ) )
{
Log.Warning( $"{LogPrefix} GoldenRunner.WriteAllGoldens: directory missing at '{dir}'." );
return;
}
foreach ( var path in Directory.GetFiles( dir, "*.golden.json" ) )
{
var json = File.ReadAllText( path );
var fixture = JsonSerializer.Deserialize<GoldenFixture>( json, GoldenJson.Options );
if ( fixture == null )
{
Log.Warning( $"{LogPrefix} GoldenRunner.WriteAllGoldens: failed to parse '{path}', skipping." );
continue;
}
if ( fixture.Expected != null && !overwrite )
{
Log.Info( $"{LogPrefix} GoldenRunner.WriteAllGoldens: skipping '{fixture.Kind}' (expected present, overwrite=false)." );
continue;
}
var result = RunProjection( fixture );
fixture.Expected = ResultToExpected( result );
var updated = JsonSerializer.Serialize( fixture, GoldenJson.Options );
File.WriteAllText( path, updated );
Log.Info( $"{LogPrefix} GoldenRunner.WriteAllGoldens: wrote '{fixture.Kind}'." );
}
}
private static GoldenResult RunFile( string path )
{
var kind = "<unknown>";
try
{
var json = File.ReadAllText( path );
var fixture = JsonSerializer.Deserialize<GoldenFixture>( json, GoldenJson.Options );
if ( fixture == null )
return new GoldenResult( kind, false, $"Deserialization returned null for '{path}'" );
kind = fixture.Kind ?? "<unknown>";
// Version gate
if ( fixture.GoldenFormatVersion != SupportedFormatVersion )
return new GoldenResult( kind, false,
$"goldenFormatVersion={fixture.GoldenFormatVersion}, expected {SupportedFormatVersion}" );
if ( fixture.Expected == null )
return new GoldenResult( kind, false, "expected block is null — run WriteGolden to author it first" );
var actual = RunProjection( fixture );
// --- Compare PanelOps (ordered structural equality) ---
var expectedOps = ToOps( fixture.Expected.PanelOps );
if ( expectedOps.Count != actual.PanelOps.Count )
return new GoldenResult( kind, false,
$"PanelOps count: expected {expectedOps.Count}, got {actual.PanelOps.Count}" );
for ( int i = 0; i < expectedOps.Count; i++ )
{
if ( !Equals( expectedOps[i], actual.PanelOps[i] ) )
return new GoldenResult( kind, false,
$"PanelOps[{i}]: expected {expectedOps[i]}, got {actual.PanelOps[i]}" );
}
// --- Compare ScssLines (normalize each line, keep order) ---
IReadOnlyList<string> expScss;
IReadOnlyList<string> actScss;
try
{
expScss = ScssNormalizer.Normalize( fixture.Expected.ScssLines ?? new List<string>() );
actScss = ScssNormalizer.Normalize( actual.ScssLines );
}
catch ( Exception ex )
{
return new GoldenResult( kind, false, $"ScssNormalizer: {ex.Message}" );
}
if ( expScss.Count != actScss.Count )
return new GoldenResult( kind, false,
$"ScssLines count: expected {expScss.Count}, got {actScss.Count}" );
for ( int i = 0; i < expScss.Count; i++ )
{
if ( !string.Equals( expScss[i], actScss[i], StringComparison.Ordinal ) )
return new GoldenResult( kind, false,
$"ScssLines[{i}]: expected \"{expScss[i]}\", got \"{actScss[i]}\"" );
}
// --- Compare RazorAttributes (ordered exact) ---
var expAttrs = fixture.Expected.RazorAttributes ?? new List<string>();
var actAttrs = actual.RazorAttributes;
if ( expAttrs.Count != actAttrs.Count )
return new GoldenResult( kind, false,
$"RazorAttributes count: expected {expAttrs.Count}, got {actAttrs.Count}" );
for ( int i = 0; i < expAttrs.Count; i++ )
{
if ( !string.Equals( expAttrs[i], actAttrs[i], StringComparison.Ordinal ) )
return new GoldenResult( kind, false,
$"RazorAttributes[{i}]: expected \"{expAttrs[i]}\", got \"{actAttrs[i]}\"" );
}
// --- Compare RazorInnerText (exact) ---
var expText = fixture.Expected.RazorInnerText;
var actText = actual.RazorInnerText;
if ( !string.Equals( expText, actText, StringComparison.Ordinal ) )
return new GoldenResult( kind, false,
$"RazorInnerText: expected \"{expText}\", got \"{actText}\"" );
return new GoldenResult( kind, true, $"PASS" );
}
catch ( Exception ex )
{
return new GoldenResult( kind, false, $"Exception: {ex.GetType().Name}: {ex.Message}" );
}
}
private static ProjectionResult RunProjection( GoldenFixture fixture )
{
if ( !ProjectorRegistry.Has( fixture.Kind ) )
throw new KeyNotFoundException( $"GoldenRunner: no projector registered for kind '{fixture.Kind}'" );
var node = new FixtureNode( fixture.Node );
var ctx = new ProjectionContext(
Theme: Grains.RazorDesigner.Serialization.PreviewTheme.Default,
ForPreview: fixture.Context?.ForPreview ?? false );
// Base projection (PanelOps / RazorAttributes / RazorInnerText come from the projector).
var proj = ProjectorRegistry.For( fixture.Kind ).Project( node, node.Appearance, node.Payload, ctx );
// ScssLines comes from the Applier (projector lines + state blocks).
var scssLines = Applier.GetNodeScssLines( node, ctx );
return new ProjectionResult(
PanelOps: proj.PanelOps,
ScssLines: scssLines,
RazorAttributes: proj.RazorAttributes,
RazorInnerText: proj.RazorInnerText );
}
private static List<PanelOp> ToOps( List<OpDto> dtos )
{
var result = new List<PanelOp>( dtos.Count );
foreach ( var dto in dtos )
result.Add( OpMapping.ToOp( dto ) );
return result;
}
private static ExpectedDto ResultToExpected( ProjectionResult r )
{
var opDtos = new List<OpDto>( r.PanelOps.Count );
foreach ( var op in r.PanelOps )
opDtos.Add( OpMapping.FromOp( op ) );
return new ExpectedDto
{
PanelOps = opDtos,
ScssLines = new List<string>( r.ScssLines ),
RazorAttributes = new List<string>( r.RazorAttributes ),
RazorInnerText = r.RazorInnerText,
};
}
}