Skip to content

Commit 7221d8b

Browse files
dmealingclaude
andcommitted
Merge FR-010 C# port — Phase 1: native recover engine + dirty-input conformance
Brings FR-010's tolerant recover engine to the C# port (MetaObjects.Render/Recover/), a native idiomatic reimplementation of the Java reference. 8-stage pipeline (strip → locate → forgiving-read → malformed-vs-absent → coerce → classify), XML + JSON, never throws. Per-field FieldRecovery classification + canonical value pinned by the shared fixtures/recover-conformance/ corpus — all 10 cases green with zero fixture edits, so the C# engine classifies dirty LLM output identically to Java/Kotlin. Pre-merge gates (code-review + code-simplifier) applied: RecoverMap aligned to Java `instanceof Number` / `String.valueOf` semantics (never-throw, invariant-culture); KNOWN_GAPS.md records the nested-object deferral + blessed cross-port divergences. 202 render tests green. Phases 2 (OutputFormatRenderer) + 3 (codegen + attrs) follow. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 parents 26e9dd0 + fab423d commit 7221d8b

29 files changed

Lines changed: 2373 additions & 0 deletions
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
using MetaObjects.Render.Recover;
2+
using Xunit;
3+
4+
namespace MetaObjects.Render.Tests.Recover;
5+
6+
/// <summary>
7+
/// Unit tests for <see cref="Coerce"/> — FR-010 stage 7 (scalar canonicalization).
8+
/// Mirrors CoerceTest.java exactly.
9+
/// </summary>
10+
public class CoerceTests
11+
{
12+
private RecoveryReport _rep = new();
13+
14+
private static RecoverOptions Normal() => RecoverOptions.Defaults();
15+
16+
// ---- enum ----
17+
18+
[Fact]
19+
public void EnumExactMatch()
20+
{
21+
var f = FieldSpec.EnumField("tone", required: true,
22+
values: new[] { "FRIENDLY", "HOSTILE" },
23+
aliases: new Dictionary<string, string>());
24+
25+
var result = Coerce.Value("FRIENDLY", f, Normal(), "tone", _rep);
26+
Assert.Equal("FRIENDLY", result);
27+
}
28+
29+
[Fact]
30+
public void EnumCaseFoldedInNormal()
31+
{
32+
var f = FieldSpec.EnumField("tone", required: true,
33+
values: new[] { "FRIENDLY" },
34+
aliases: new Dictionary<string, string>());
35+
36+
var result = Coerce.Value("friendly", f, Normal(), "tone", _rep);
37+
Assert.Equal("FRIENDLY", result);
38+
}
39+
40+
[Fact]
41+
public void EnumStrictDoesNotCaseFold()
42+
{
43+
var f = FieldSpec.EnumField("tone", required: true,
44+
values: new[] { "FRIENDLY" },
45+
aliases: new Dictionary<string, string>());
46+
47+
var result = Coerce.Value("friendly", f, Normal().WithTolerance(Tolerance.Strict), "tone", _rep);
48+
Assert.Same(Coerce.Malformed, result);
49+
}
50+
51+
[Fact]
52+
public void EnumSchemaAliasFolds()
53+
{
54+
var f = FieldSpec.EnumField("tone", required: true,
55+
values: new[] { "FRIENDLY" },
56+
aliases: new Dictionary<string, string> { ["warm"] = "FRIENDLY" });
57+
58+
var result = Coerce.Value("warm", f, Normal(), "tone", _rep);
59+
Assert.Equal("FRIENDLY", result);
60+
Assert.Contains(_rep.Coercions(), c => c.Kind == "alias");
61+
}
62+
63+
[Fact]
64+
public void RuntimeAliasWinsOverSchema()
65+
{
66+
var f = FieldSpec.EnumField("tone", required: true,
67+
values: new[] { "FRIENDLY", "HOSTILE" },
68+
aliases: new Dictionary<string, string> { ["x"] = "FRIENDLY" });
69+
70+
var opts = new RecoverOptions(
71+
Tolerance.Normal,
72+
new Dictionary<string, string> { ["x"] = "HOSTILE" },
73+
new Dictionary<string, Func<string, object?>>(),
74+
null);
75+
76+
var result = Coerce.Value("x", f, opts, "tone", _rep);
77+
Assert.Equal("HOSTILE", result);
78+
Assert.Contains(_rep.Coercions(), c => c.Kind == "runtime-alias-override");
79+
}
80+
81+
[Fact]
82+
public void EnumOffVocabIsMalformed()
83+
{
84+
var f = FieldSpec.EnumField("tone", required: true,
85+
values: new[] { "FRIENDLY" },
86+
aliases: new Dictionary<string, string>());
87+
88+
var result = Coerce.Value("banana", f, Normal(), "tone", _rep);
89+
Assert.Same(Coerce.Malformed, result);
90+
}
91+
92+
// ---- int / range ----
93+
94+
[Fact]
95+
public void IntClampToRange()
96+
{
97+
var f = FieldSpec.Range("score", FieldKind.Int, required: true, min: 0.0, max: 10.0);
98+
99+
var result = Coerce.Value("42", f, Normal(), "score", _rep);
100+
Assert.Equal(10L, result);
101+
Assert.Contains(_rep.Coercions(), c => c.Kind == "clamp");
102+
}
103+
104+
[Fact]
105+
public void IntUnparseableMalformed()
106+
{
107+
var f = FieldSpec.Scalar("score", FieldKind.Int, required: true);
108+
109+
var result = Coerce.Value("abc", f, Normal(), "score", _rep);
110+
Assert.Same(Coerce.Malformed, result);
111+
}
112+
113+
// ---- boolean ----
114+
115+
[Fact]
116+
public void BooleanForms()
117+
{
118+
var f = FieldSpec.Scalar("ok", FieldKind.Boolean, required: true);
119+
120+
Assert.Equal(true, Coerce.Value("yes", f, Normal(), "ok", _rep));
121+
Assert.Equal(false, Coerce.Value("0", f, Normal(), "ok", _rep));
122+
}
123+
124+
// ---- onField hook ----
125+
126+
[Fact]
127+
public void OnFieldHookWins()
128+
{
129+
var f = FieldSpec.Scalar("x", FieldKind.String, required: true);
130+
var opts = new RecoverOptions(
131+
Tolerance.Normal,
132+
new Dictionary<string, string>(),
133+
new Dictionary<string, Func<string, object?>>(),
134+
(path, raw, spec) => "HOOKED");
135+
136+
var result = Coerce.Value("anything", f, opts, "x", _rep);
137+
Assert.Equal("HOOKED", result);
138+
}
139+
140+
// ---- non-finite guard ----
141+
142+
[Fact]
143+
public void NanIsMalformedForDouble()
144+
{
145+
var f = FieldSpec.Scalar("n", FieldKind.Double, required: true);
146+
147+
var result = Coerce.Value("NaN", f, Normal(), "n", _rep);
148+
Assert.Same(Coerce.Malformed, result);
149+
}
150+
151+
[Fact]
152+
public void InfinityIsMalformedForInt()
153+
{
154+
var f = FieldSpec.Scalar("k", FieldKind.Int, required: true);
155+
156+
Assert.Same(Coerce.Malformed, Coerce.Value("Infinity", f, Normal(), "k", _rep));
157+
Assert.Same(Coerce.Malformed, Coerce.Value("-Infinity", f, Normal(), "k", _rep));
158+
}
159+
160+
// ---- normalizer ----
161+
162+
[Fact]
163+
public void NormalizerByFieldNameApplied()
164+
{
165+
var f = FieldSpec.Scalar("x", FieldKind.String, required: true);
166+
var opts = new RecoverOptions(
167+
Tolerance.Normal,
168+
new Dictionary<string, string>(),
169+
new Dictionary<string, Func<string, object?>>
170+
{
171+
["x"] = raw => raw.ToUpperInvariant()
172+
},
173+
null);
174+
175+
var result = Coerce.Value("hello", f, opts, "x", _rep);
176+
Assert.Equal("HELLO", result);
177+
Assert.Contains(_rep.Coercions(), c => c.Kind == "normalizer");
178+
}
179+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
using MetaObjects.Render.Recover;
2+
using Xunit;
3+
4+
namespace MetaObjects.Render.Tests.Recover;
5+
6+
/// <summary>
7+
/// Unit tests for <see cref="JsonForgivingReader"/> (FR-010 stage 4).
8+
/// Ported from Java JsonForgivingReaderTest — all cases preserved exactly,
9+
/// including TRUNCATED sentinel and no-hang assertions.
10+
/// </summary>
11+
public class JsonForgivingReaderTests
12+
{
13+
private static Dictionary<string, object?> Read(string? s) =>
14+
new JsonForgivingReader().Read(s);
15+
16+
[Fact]
17+
public void CleanObject()
18+
{
19+
var m = Read("{\"a\":\"1\",\"b\":\"two\"}");
20+
Assert.Equal("1", m["a"]);
21+
Assert.Equal("two", m["b"]);
22+
}
23+
24+
[Fact]
25+
public void TrailingComma()
26+
{
27+
var m = Read("{\"a\":\"1\",}");
28+
Assert.Equal("1", m["a"]);
29+
Assert.Single(m);
30+
}
31+
32+
[Fact]
33+
public void SingleQuotes()
34+
{
35+
var m = Read("{'a':'1'}");
36+
Assert.Equal("1", m["a"]);
37+
}
38+
39+
[Fact]
40+
public void UnquotedKeys()
41+
{
42+
var m = Read("{a:\"1\",b:\"2\"}");
43+
Assert.Equal("1", m["a"]);
44+
Assert.Equal("2", m["b"]);
45+
}
46+
47+
[Fact]
48+
public void NestedObject()
49+
{
50+
var m = Read("{\"a\":{\"b\":\"1\"}}");
51+
var inner = Assert.IsType<Dictionary<string, object?>>(m["a"]);
52+
Assert.Equal("1", inner["b"]);
53+
}
54+
55+
[Fact]
56+
public void ArrayValues()
57+
{
58+
var m = Read("{\"xs\":[\"a\",\"b\"]}");
59+
var xs = Assert.IsType<List<object?>>(m["xs"]);
60+
Assert.Equal(new List<object?> { "a", "b" }, xs);
61+
}
62+
63+
[Fact]
64+
public void TruncatedRecoversCompletePrefixKeys()
65+
{
66+
var m = Read("{\"a\":\"1\",\"b\":\"2\",\"c\":");
67+
Assert.Equal("1", m["a"]);
68+
Assert.Equal("2", m["b"]);
69+
Assert.Same(JsonForgivingReader.Truncated, m["c"]);
70+
}
71+
72+
[Fact]
73+
public void UnrecoverableReturnsEmpty()
74+
{
75+
Assert.Empty(Read("@@@@"));
76+
}
77+
78+
[Fact(Timeout = 5000)]
79+
public async Task MalformedArrayBraceCloseDoesNotHang()
80+
{
81+
var m = await Task.Run(() => Read("{\"xs\":[}"));
82+
// xs present (empty/partial list), no hang
83+
Assert.True(m.ContainsKey("xs"));
84+
}
85+
86+
[Fact(Timeout = 5000)]
87+
public async Task MalformedArrayBraceCloseAfterCommaDoesNotHang()
88+
{
89+
var m = await Task.Run(() => Read("{\"xs\":[1,}"));
90+
// does not hang; xs recovered as a list with the prefix element
91+
Assert.IsType<List<object?>>(m["xs"]);
92+
}
93+
94+
[Fact]
95+
public void EmptyValueMarksTruncated()
96+
{
97+
var m = Read("{\"a\":\"1\",\"c\":}");
98+
Assert.Equal("1", m["a"]);
99+
Assert.Same(JsonForgivingReader.Truncated, m["c"]);
100+
}
101+
102+
[Fact]
103+
public void EmptyValueWhitespaceMarksTruncated()
104+
{
105+
var m = Read("{\"a\": }");
106+
Assert.Same(JsonForgivingReader.Truncated, m["a"]);
107+
}
108+
109+
[Fact]
110+
public void EmptyValueThenMoreKeysContinues()
111+
{
112+
var m = Read("{\"a\":,\"b\":\"2\"}");
113+
Assert.Same(JsonForgivingReader.Truncated, m["a"]);
114+
Assert.Equal("2", m["b"]);
115+
}
116+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
using MetaObjects.Render.Recover;
2+
using Xunit;
3+
4+
namespace MetaObjects.Render.Tests.Recover;
5+
6+
/// <summary>
7+
/// Unit tests for <see cref="Locate"/> (FR-010 stages 2–3).
8+
/// Ported from Java LocateTest, with the additional no-throw guard case.
9+
/// </summary>
10+
public class LocateTests
11+
{
12+
// ---- Json ----
13+
14+
[Fact]
15+
public void Json_IsolatesBalancedObjectFromProse()
16+
{
17+
var input = "Here is the result: {\"a\":1,\"b\":{\"c\":2}} — done.";
18+
Assert.Equal("{\"a\":1,\"b\":{\"c\":2}}", Locate.Json(input));
19+
}
20+
21+
[Fact]
22+
public void Json_IgnoresBracesInsideStrings()
23+
{
24+
var input = "{\"text\":\"a } not a close\",\"n\":1}";
25+
Assert.Equal(input, Locate.Json(input));
26+
}
27+
28+
[Fact]
29+
public void Json_TruncatedReturnsPrefixToEnd()
30+
{
31+
var input = "prefix {\"a\":1,\"b\":";
32+
Assert.Equal("{\"a\":1,\"b\":", Locate.Json(input));
33+
}
34+
35+
[Fact]
36+
public void Json_NoBraceReturnsNull()
37+
{
38+
Assert.Null(Locate.Json("no object here"));
39+
}
40+
41+
[Fact]
42+
public void Json_FirstClosedCandidateWins()
43+
{
44+
var input = "noise {\"a\":1} tail {\"b\":2}";
45+
Assert.Equal("{\"a\":1}", Locate.Json(input));
46+
}
47+
48+
// ---- Xml ----
49+
50+
[Fact]
51+
public void Xml_SpansRoot()
52+
{
53+
var input = "blah <answer><t>hi</t></answer> blah";
54+
Assert.Equal("<answer><t>hi</t></answer>", Locate.Xml(input, "answer", false));
55+
}
56+
57+
[Fact]
58+
public void Xml_UnclosedRootReturnsToEnd()
59+
{
60+
var input = "x <answer><t>hi</t>";
61+
Assert.Equal("<answer><t>hi</t>", Locate.Xml(input, "answer", false));
62+
}
63+
64+
[Fact]
65+
public void Xml_CaseInsensitiveMatch()
66+
{
67+
var input = "<Answer><t>hi</t></Answer>";
68+
Assert.Equal("<Answer><t>hi</t></Answer>", Locate.Xml(input, "answer", true));
69+
}
70+
71+
[Fact]
72+
public void Xml_NoOpenReturnsNull()
73+
{
74+
Assert.Null(Locate.Xml("nothing", "answer", false));
75+
}
76+
77+
[Fact]
78+
public void Xml_BareCloseTagDoesNotThrow()
79+
{
80+
// Guard: a string that starts with a close tag for the root should not throw
81+
// and should return null (no opener found).
82+
var result = Locate.Xml("</x>", "x", false);
83+
Assert.Null(result);
84+
}
85+
}

0 commit comments

Comments
 (0)