diff --git a/tools/probe/corpus/claims.json b/tools/probe/corpus/claims.json index 7c4ce6dd..b0bfe1f6 100644 --- a/tools/probe/corpus/claims.json +++ b/tools/probe/corpus/claims.json @@ -13725,7 +13725,34 @@ "obligations": [ { "id": "the-underlying-types-json", - "sentence": "Its JSON is the underlying type's under your options" + "sentence": "Its JSON is the underlying type's under your options", + "inapplicable": [ + { + "axes": { + "json.Converters": [ + "own-for-value-object" + ] + }, + "cite": "docs:src/CodoMetis.TypeKit.AspNetCore/README.md#what-the-document-says" + }, + { + "axes": { + "shape.kind": [ + "record-struct", + "sealed-record-class", + "validated-struct", + "validated-class" + ], + "shape.wrapped": [ + "byte" + ], + "shape.position": [ + "array-element" + ] + }, + "cite": "not-promised:a-byte-array-is-base64" + } + ] }, { "id": "as-a-dictionary-key-too", @@ -13734,11 +13761,74 @@ "shape.position": [ "dictionary-key" ] - } + }, + "inapplicable": [ + { + "axes": { + "json.Converters": [ + "own-for-value-object" + ] + }, + "cite": "docs:src/CodoMetis.TypeKit.AspNetCore/README.md#what-the-document-says" + }, + { + "axes": { + "shape.kind": [ + "closed-set" + ], + "shape.position": [ + "dictionary-key" + ], + "json.DictionaryKeyPolicy": [ + "CamelCase", + "SnakeCaseLower", + "KebabCaseUpper", + "own-upper-case" + ] + }, + "cite": "not-promised:a-closed-set-key-is-its-name" + } + ] }, { "id": "read-as-the-underlying-type-reads", - "sentence": "**Swapping a primitive for a value object keeps the JSON, the column and the schema.**" + "sentence": "**Swapping a primitive for a value object keeps the JSON, the column and the schema.**", + "inapplicable": [ + { + "axes": { + "shape.kind": [ + "record-struct", + "sealed-record-class", + "validated-struct", + "validated-class" + ], + "shape.wrapped": [ + "byte" + ], + "shape.position": [ + "array-element" + ] + }, + "cite": "not-promised:a-byte-array-is-base64" + }, + { + "axes": { + "shape.kind": [ + "closed-set" + ], + "shape.position": [ + "dictionary-key" + ], + "json.DictionaryKeyPolicy": [ + "CamelCase", + "SnakeCaseLower", + "KebabCaseUpper", + "own-upper-case" + ] + }, + "cite": "not-promised:a-closed-set-key-is-its-name" + } + ] } ], "not-promised": [ @@ -13860,11 +13950,76 @@ "ISpanParsable", "type-converter" ] - } + }, + "inapplicable": [ + { + "axes": { + "shape.kind": [ + "record-struct", + "sealed-record-class", + "validated-struct", + "validated-class" + ], + "shape.wrapped": [ + "Uri", + "enum", + "flags-enum", + "NodaTime.Instant" + ], + "text.entry": [ + "ISpanParsable" + ] + }, + "cite": "docs:src/CodoMetis.TypeKit.Generators/README.md#what-is-generated" + }, + { + "axes": { + "shape.kind": [ + "closed-set" + ], + "text.entry": [ + "ISpanParsable" + ] + }, + "cite": "docs:src/CodoMetis.TypeKit.Generators/README.md#closed-sets" + }, + { + "axes": { + "shape.kind": [ + "record-struct", + "sealed-record-class", + "validated-struct", + "validated-class" + ], + "shape.wrapped": [ + "own-record-class" + ], + "text.entry": [ + "Parse", + "TryParse", + "IParsable", + "ISpanParsable", + "type-converter" + ] + }, + "cite": "docs:src/CodoMetis.TypeKit.Generators/README.md#what-is-generated" + } + ] }, { "id": "json-never-repeats", - "sentence": "A JSON or parsing rejection names the value object and the rule, or the underlying type it could not read." + "sentence": "A JSON or parsing rejection names the value object and the rule, or the underlying type it could not read.", + "inapplicable": [ + { + "axes": { + "json.Converters": [ + "own-for-wrapped", + "own-for-value-object" + ] + }, + "cite": "not-promised:the-applications-own-converter" + } + ] }, { "id": "no-inner-that-quotes", @@ -13877,7 +14032,61 @@ "ISpanParsable", "type-converter" ] - } + }, + "inapplicable": [ + { + "axes": { + "shape.kind": [ + "record-struct", + "sealed-record-class", + "validated-struct", + "validated-class" + ], + "shape.wrapped": [ + "Uri", + "enum", + "flags-enum", + "NodaTime.Instant" + ], + "text.entry": [ + "ISpanParsable" + ] + }, + "cite": "docs:src/CodoMetis.TypeKit.Generators/README.md#what-is-generated" + }, + { + "axes": { + "shape.kind": [ + "closed-set" + ], + "text.entry": [ + "ISpanParsable" + ] + }, + "cite": "docs:src/CodoMetis.TypeKit.Generators/README.md#closed-sets" + }, + { + "axes": { + "shape.kind": [ + "record-struct", + "sealed-record-class", + "validated-struct", + "validated-class" + ], + "shape.wrapped": [ + "own-record-class" + ], + "text.entry": [ + "Parse", + "TryParse", + "IParsable", + "ISpanParsable", + "type-converter" + ] + }, + "cite": "docs:src/CodoMetis.TypeKit.Generators/README.md#what-is-generated" + } + ] } ], "not-promised": [ @@ -13922,7 +14131,20 @@ "text.entry": [ "TryFrom" ] - } + }, + "inapplicable": [ + { + "axes": { + "shape.kind": [ + "closed-set" + ], + "text.entry": [ + "TryFrom" + ] + }, + "cite": "docs:src/CodoMetis.TypeKit.Generators/README.md#closed-sets" + } + ] }, { "id": "parsing-runs-create", @@ -13935,7 +14157,61 @@ "ISpanParsable", "type-converter" ] - } + }, + "inapplicable": [ + { + "axes": { + "shape.kind": [ + "record-struct", + "sealed-record-class", + "validated-struct", + "validated-class" + ], + "shape.wrapped": [ + "Uri", + "enum", + "flags-enum", + "NodaTime.Instant" + ], + "text.entry": [ + "ISpanParsable" + ] + }, + "cite": "docs:src/CodoMetis.TypeKit.Generators/README.md#what-is-generated" + }, + { + "axes": { + "shape.kind": [ + "closed-set" + ], + "text.entry": [ + "ISpanParsable" + ] + }, + "cite": "docs:src/CodoMetis.TypeKit.Generators/README.md#closed-sets" + }, + { + "axes": { + "shape.kind": [ + "record-struct", + "sealed-record-class", + "validated-struct", + "validated-class" + ], + "shape.wrapped": [ + "own-record-class" + ], + "text.entry": [ + "Parse", + "TryParse", + "IParsable", + "ISpanParsable", + "type-converter" + ] + }, + "cite": "docs:src/CodoMetis.TypeKit.Generators/README.md#what-is-generated" + } + ] }, { "id": "json-runs-create", @@ -13980,11 +14256,120 @@ "TryFormat-short-span", "type-converter" ] - } + }, + "inapplicable": [ + { + "axes": { + "shape.kind": [ + "closed-set" + ], + "text.entry": [ + "ToString-format", + "TryFormat-short-span" + ] + }, + "cite": "docs:src/CodoMetis.TypeKit.Generators/README.md#closed-sets" + }, + { + "axes": { + "shape.kind": [ + "record-struct", + "sealed-record-class", + "validated-struct", + "validated-class" + ], + "shape.wrapped": [ + "own-record-class" + ], + "text.entry": [ + "type-converter" + ] + }, + "cite": "docs:src/CodoMetis.TypeKit.Generators/README.md#what-is-generated" + } + ] }, { "id": "text-reads-back", - "sentence": "That text reads back as the same value wherever the underlying type's own does" + "sentence": "That text reads back as the same value wherever the underlying type's own does", + "inapplicable": [ + { + "axes": { + "shape.kind": [ + "closed-set" + ], + "text.entry": [ + "TryFrom", + "ISpanParsable", + "ToString-format", + "TryFormat-short-span" + ] + }, + "cite": "docs:src/CodoMetis.TypeKit.Generators/README.md#closed-sets" + }, + { + "axes": { + "shape.kind": [ + "record-struct", + "sealed-record-class", + "validated-struct", + "validated-class" + ], + "shape.wrapped": [ + "own-record-class" + ], + "text.entry": [ + "Parse", + "TryParse", + "IParsable", + "ISpanParsable", + "type-converter", + "ToString-format", + "TryFormat-short-span" + ] + }, + "cite": "docs:src/CodoMetis.TypeKit.Generators/README.md#what-is-generated" + }, + { + "axes": { + "shape.kind": [ + "record-struct", + "sealed-record-class", + "validated-struct", + "validated-class" + ], + "shape.wrapped": [ + "Uri", + "enum", + "flags-enum", + "NodaTime.Instant" + ], + "text.entry": [ + "ISpanParsable" + ] + }, + "cite": "docs:src/CodoMetis.TypeKit.Generators/README.md#what-is-generated" + }, + { + "axes": { + "shape.kind": [ + "record-struct", + "sealed-record-class", + "validated-struct", + "validated-class" + ], + "shape.wrapped": [ + "TimeOnly", + "DateTime", + "DateTimeOffset" + ], + "text.entry": [ + "type-converter" + ] + }, + "cite": "not-promised:dates-and-times-lose-detail-as-text" + } + ] }, { "id": "json-refusal-is-a-json-exception", diff --git a/tools/probe/corpus/harness/DecimalSpelling.cs b/tools/probe/corpus/harness/DecimalSpelling.cs index 2f09a3fa..d8147c2d 100644 --- a/tools/probe/corpus/harness/DecimalSpelling.cs +++ b/tools/probe/corpus/harness/DecimalSpelling.cs @@ -44,6 +44,17 @@ static List Held(object? v, Type declared, decimal d) static bool Same(decimal a, decimal b) => decimal.GetBits(a).SequenceEqual(decimal.GetBits(b)); static string Show(List l) => string.Join(",", l.Select(x => x.ToString(Inv))); + /// What one form of the document read as: the decimal's and the value object's refusals and the decimals each holds. + sealed record Form(string Name, string Json, Exception? Ep, Exception? Ev, List Cp, List Cv, bool BothRefused); + + /// + /// C01's checks, one per obligation, over one write and one read of the row's shape: the converter's spelling of each value + /// (smallest-scale; number-is-a-number or string-is-a-string, by the row's converter) against the docs' minimal spelling worked out + /// here; every decimal of the document, a key's included, and the value object's document against the plain decimal's + /// (covers-every-decimal, the twin); each form of the input read (reads-a-number-or-a-string) with the scale it carries + /// (keeps-the-scale-it-reads). The options' own leaving-out is shown by a baseline without the converter in the same row: a + /// direction it leaves out has nothing to check. Every check runs; returns the first failure. + /// public static string? Check(Row r) { var kind = r["shape.kind"]; @@ -63,87 +74,149 @@ static List Held(object? v, Type declared, decimal d) foreach (var c in ob.Converters.Where(c => c.CanConvert(typeof(decimal))).ToList()) ob.Converters.Remove(c); o.Converters.Insert(0, conv); // registered first: an application converter for decimals (own-for-wrapped) comes after it var ownVo = r.Is("json.Converters", "own-for-value-object"); + var docs = Baseline.Docs("docs/json.md#decimals-in-one-spelling"); + const string Smallest = "smallest-scale", Covers = "covers-every-decimal", Reads = "reads-a-number-or-a-string", Keeps = "keeps-the-scale-it-reads"; + var kindOf = number ? "number-is-a-number" : "string-is-a-string"; // Writing: one spelling, the smallest scale, as a number or a string; the value object's document is the decimal's. var (jp, xp) = Jx.Ser(hp, o); var (jv, xv) = Jx.Ser(hv, o); var (jb, xb) = Jx.Ser(hp, ob); - var wrote = false; if (xb is not null && xp is not null && (xv is not null || ownVo) && xp.GetType() == xb.GetType()) - { r.Note(NoteKeys.BothThrew, "write: " + Jx.Ex(xb)); return null; } // the options refuse the shape for the serializer's own decimal too - if (xp is not null) return $"write: the decimal with {conv.GetType().Name} threw {Jx.Ex(xp)}; without it the serializer wrote {Jx.Cut(jb)}"; - if (xv is not null && !ownVo) return $"write: the value object threw {Jx.Ex(xv)} where the decimal wrote {Jx.Cut(jp)}"; - var tp = Jx.Tokens(jp!).Where(t => IsDecimal(t, d)).ToList(); - var tb = xb is null ? Jx.Tokens(jb!).Where(t => IsDecimal(t, d)).ToList() : null; - if (tb is { Count: 0 }) { - if (tp.Count != 0) return $"write: the options leave the member out without the converter ({Jx.Cut(jb)}) but it wrote {Jx.Cut(jp)}"; - r.Note(NoteKeys.JsonMemberLeftOutByOptions, "write: " + Jx.Cut(jb)); + // the options refuse the shape for the serializer's own decimal too: nothing is written or read to compare + var why = "the options refuse the shape for the serializer's own decimal too: " + Jx.Ex(xb); + string? None(Check c) => c.Inconclusive(why); + foreach (var ob2 in new[] { Smallest, kindOf }) r.Check(ob2, docs, None); + r.Check(Covers, Baseline.Twin, None); r.Check(Reads, docs, None); r.Check(Keeps, docs, None); + return null; } - else + var tp = xp is null ? Jx.Tokens(jp!).Where(t => IsDecimal(t, d)).ToList() : []; + var tb = xb is null ? Jx.Tokens(jb!).Where(t => IsDecimal(t, d)).ToList() : null; + var writeLeftOut = tb is { Count: 0 } && xp is null && tp.Count == 0; + string? WriteLeftOut(Check c) { c.Note(NoteKeys.JsonMemberLeftOutByOptions, "write: " + Jx.Cut(jb)); return c.Inconclusive("write: the options leave the member out, for the serializer's own decimal too: " + Jx.Cut(jb)); } + var want = number ? JsonTokenType.Number : JsonTokenType.String; + var f1 = r.Check(Smallest, docs, c => { + if (xp is not null) return $"write: the decimal with {conv.GetType().Name} threw {Jx.Ex(xp)}; without it the serializer wrote {Jx.Cut(jb)}"; + if (tb is { Count: 0 } && tp.Count != 0) return $"write: the options leave the member out without the converter ({Jx.Cut(jb)}) but it wrote {Jx.Cut(jp)}"; + if (writeLeftOut) return WriteLeftOut(c); if (tp.Count == 0) return $"write: no decimal in {Jx.Cut(jp)}; the serializer alone wrote {Jx.Cut(jb)}"; - if (tb is not null && tp.Count != tb.Count) return $"write: {tp.Count} decimals in {Jx.Cut(jp)}, {tb.Count} in {Jx.Cut(jb)}"; - foreach (var t in tp) - { - if (t.Type == JsonTokenType.PropertyName) { if (t.Raw != canon) return $"write: key {t.Raw}, expected {canon}, in {Jx.Cut(jp)}"; continue; } - var want = number ? JsonTokenType.Number : JsonTokenType.String; - if (t.Type != want || t.Raw != canon) return $"write: {t.Type} {t.Raw}, expected the {want} {canon}, in {Jx.Cut(jp)}"; - } - wrote = true; - } - if (ownVo) + foreach (var t in tp.Where(t => t.Type != JsonTokenType.PropertyName)) + if (t.Raw != canon) return $"write: {t.Type} {t.Raw}, expected the smallest scale {canon}, in {Jx.Cut(jp)}"; + c.Note(NoteKeys.Compared, $"write {text} -> {canon}"); + return null; + }); + var f2 = r.Check(kindOf, docs, c => { - r.Note(NoteKeys.OwnVo); // the application's converter for the value object writes its own document; it reads it back - var (_, xr) = Jx.De(jv!, hv.Declared, o); - if (xr is not null) return "own converter for the value object: its document does not read back: " + Jx.Ex(xr); - } - else if (jv != jp) return $"write: the value object wrote {Jx.Cut(jv)}, the decimal {Jx.Cut(jp)}"; + if (xp is not null) return c.Inconclusive("write: the decimal with the converter threw, judged by smallest-scale"); + if (writeLeftOut) return WriteLeftOut(c); + var values = tp.Where(t => t.Type != JsonTokenType.PropertyName).ToList(); + if (values.Count == 0) return c.Inconclusive($"write: no decimal value in {Jx.Cut(jp)}, only keys, which are names"); + foreach (var t in values) + if (t.Type != want) return $"write: {t.Type} {t.Raw}, expected the {want} {canon}, in {Jx.Cut(jp)}"; + c.Note(NoteKeys.Compared, $"write as a {want}"); + return null; + }); // Reading: a number, a string holding one, and an escaped string all read, the scale they carry kept, on both sides alike. var full = Jx.Full(o); var (jf, xf) = Jx.Ser(hp, full); - if (xf is not null) { r.Outcome("harness-error", "the document with every member written threw " + Jx.Ex(xf)); return null; } + if (xf is not null) { r.Outcome("harness-error", "the document with every member written threw " + Jx.Ex(xf)); return f1 ?? f2; } var at = Jx.Tokens(jf!).Where(t => IsDecimal(t, d)).ToList(); + string? readNothing = null; + var forms = new List
(); + var leftOut = false; if (at.Count == 0) { // Nothing to read either: the options leave the member out of every document (a field without IncludeFields). var (jfb, _) = Jx.Ser(hp, Jx.Full(ob)); - if (jfb is null || Jx.Tokens(jfb).Any(t => IsDecimal(t, d))) { r.Outcome("harness-error", "no decimal in the document with every member written: " + jf); return null; } - if (!wrote) r.Note(NoteKeys.JsonBothDirectionsLeftOutByOptions, jf); - return null; + if (jfb is null || Jx.Tokens(jfb).Any(t => IsDecimal(t, d))) { r.Outcome("harness-error", "no decimal in the document with every member written: " + jf); return f1 ?? f2; } + readNothing = "read: the options leave the member out of every document, for the serializer's own decimal too: " + Jx.Cut(jf); } - var key = at.Any(t => t.Type == JsonTokenType.PropertyName); - var forms = new List<(string Name, string Json)>(); - if (!key) forms.Add(("number", Jx.Rewrite(jf!, at, _ => text))); - forms.Add(("string", Jx.Rewrite(jf!, at, _ => "\"" + text + "\""))); - forms.Add(("escaped string", Jx.Rewrite(jf!, at, _ => "\"" + Bs + "u00" + ((int)text[0]).ToString("x2") + text[1..] + "\""))); - // The baseline reads the first form with the serializer's own decimal: it shows whether these options read the member at all. - var (vb, eb) = Jx.De(forms[0].Json, hp.Declared, ob); - var leftOut = eb is null && Held(vb, hp.Declared, d).Count == 0; - var read = false; - foreach (var (name, json) in forms) + else { - var (vp, ep) = Jx.De(json, hp.Declared, o); - var (vv, ev) = ownVo ? (null, null) : Jx.De(json, hv.Declared, o); - if (ep is not null) + var key = at.Any(t => t.Type == JsonTokenType.PropertyName); + var texts = new List<(string Name, string Json)>(); + if (!key) texts.Add(("number", Jx.Rewrite(jf!, at, _ => text))); + texts.Add(("string", Jx.Rewrite(jf!, at, _ => "\"" + text + "\""))); + texts.Add(("escaped string", Jx.Rewrite(jf!, at, _ => "\"" + Bs + "u00" + ((int)text[0]).ToString("x2") + text[1..] + "\""))); + // The baseline reads the first form with the serializer's own decimal: it shows whether these options read the member at all. + var (vb, eb) = Jx.De(texts[0].Json, hp.Declared, ob); + leftOut = eb is null && Held(vb, hp.Declared, d).Count == 0; + foreach (var (name, json) in texts) { - if (eb is not null && (ownVo || ev is not null) && ep.GetType() == eb.GetType()) { r.Note(NoteKeys.ReadBothThrew, name + ": " + Jx.Ex(eb)); continue; } - return $"read {name}: the decimal with {conv.GetType().Name} threw {Jx.Ex(ep)}\n text={Jx.Cut(json)}"; + var (vp, ep) = Jx.De(json, hp.Declared, o); + var (vv, ev) = ownVo ? (null, null) : Jx.De(json, hv.Declared, o); + var both = ep is not null && eb is not null && (ownVo || ev is not null) && ep.GetType() == eb.GetType(); + var cp = ep is null ? Held(vp, hp.Declared, d) : []; var cv = ownVo ? cp : ev is null ? Held(vv, hv.Declared, d) : []; + forms.Add(new(name, json, ep, ev, cp, cv, both)); } - if (ev is not null) return $"read {name}: the value object threw {Jx.Ex(ev)} where the decimal read it\n text={Jx.Cut(json)}"; - var cp = Held(vp, hp.Declared, d); var cv = ownVo ? cp : Held(vv, hv.Declared, d); - if (leftOut) + if (leftOut && forms.All(f => f.Ep is null && f.Ev is null && f.Cp.Count == 0 && f.Cv.Count == 0)) + readNothing = "read: the options read no member, for the serializer's own decimal too"; + } + string? ReadLeftOut(Check c) { c.Note(NoteKeys.JsonMemberLeftOutByOptions, "read"); return c.Inconclusive(readNothing!); } + // The forms both sides refuse alike under these options (the serializer's own decimal too) have nothing to compare. + var compared = forms.Where(f => !f.BothRefused).ToList(); + string? AllRefused(Check c) + { + foreach (var f in forms.Where(f => f.BothRefused)) c.Log.AppendLine($"read {f.Name}: both refuse: {Jx.Ex(f.Ep!)}"); + return c.Inconclusive("read: every form is refused for the serializer's own decimal too: " + Jx.Ex(forms[0].Ep!)); + } + var f4 = r.Check(Reads, docs, c => + { + if (readNothing is not null) return ReadLeftOut(c); + foreach (var f in forms) { - if (cp.Count != 0 || cv.Count != 0) return $"read {name}: the options read no member for the serializer's own decimal, yet it read {Show(cp)} / {Show(cv)}"; - r.Note(NoteKeys.JsonMemberLeftOutByOptions, "read: " + name); - continue; + if (f.BothRefused) continue; + if (f.Ep is not null) return $"read {f.Name}: the decimal with {conv.GetType().Name} threw {Jx.Ex(f.Ep)}\n text={Jx.Cut(f.Json)}"; + if (leftOut) { if (f.Cp.Count != 0) return $"read {f.Name}: the options read no member for the serializer's own decimal, yet it read {Show(f.Cp)}"; continue; } + if (f.Cp.Count == 0) return $"read {f.Name}: the decimal read nothing\n text={Jx.Cut(f.Json)}"; } - if (cp.Count == 0) return $"read {name}: the decimal read nothing\n text={Jx.Cut(json)}"; - if (cp.Any(x => !Same(x, d))) return $"read {name}: read {Show(cp)}, expected {d.ToString(Inv)} with its scale\n text={Jx.Cut(json)}"; - if (cp.Count != cv.Count || cp.Zip(cv).Any(z => !Same(z.First, z.Second))) return $"read {name}: the value object read {Show(cv)}, the decimal {Show(cp)}\n text={Jx.Cut(json)}"; - read = true; - } - if (!wrote && !read) r.Note(NoteKeys.JsonBothDirectionsLeftOutByOptions); - r.Note(NoteKeys.Compared, $"{(number ? "number" : "string")} {text} -> {canon}"); - return null; + if (compared.Count == 0) return AllRefused(c); + c.Note(NoteKeys.Compared, "read " + string.Join(", ", compared.Select(f => f.Name))); + return null; + }); + var f5 = r.Check(Keeps, docs, c => + { + if (readNothing is not null) return ReadLeftOut(c); + if (compared.Count == 0) return AllRefused(c); + var kept = 0; + foreach (var f in compared) + { + if (f.Cp.Count == 0) continue; // read nothing, or refused: reads-a-number-or-a-string judges it + if (f.Cp.Any(x => !Same(x, d))) return $"read {f.Name}: read {Show(f.Cp)}, expected {d.ToString(Inv)} with its scale\n text={Jx.Cut(f.Json)}"; + kept++; + } + if (kept == 0) return c.Inconclusive("read: no form read a decimal to hold its scale to"); + c.Note(NoteKeys.Compared, $"read with the scale of {text}"); + return null; + }); + var f3 = r.Check(Covers, ownVo ? Baseline.RoundTrip : Baseline.Twin, c => + { + if (xv is not null && !ownVo) return $"write: the value object threw {Jx.Ex(xv)} where the decimal wrote {Jx.Cut(jp)}"; + if (xp is not null) return c.Inconclusive("write: the decimal with the converter threw, judged by smallest-scale"); + if (tb is not null && tb.Count != 0 && tp.Count != tb.Count) return $"write: {tp.Count} decimals in {Jx.Cut(jp)}, {tb.Count} in {Jx.Cut(jb)}"; + foreach (var t in tp.Where(t => t.Type == JsonTokenType.PropertyName)) + if (t.Raw != canon) return $"write: key {t.Raw}, expected {canon}, in {Jx.Cut(jp)}"; + if (ownVo) + { + // "Your own converter wins" (AspNetCore README#what-the-document-says): the application's converter writes the value object, + // so the decimal's document is none of its; its own document reads back under the same options + c.Note(NoteKeys.OwnVo); + var (_, xr) = Jx.De(jv!, hv.Declared, o); + if (xr is not null) return "own converter for the value object: its document does not read back: " + Jx.Ex(xr); + return null; + } + if (jv != jp) return $"write: the value object wrote {Jx.Cut(jv)}, the decimal {Jx.Cut(jp)}"; + foreach (var f in compared) + { + if (f.Ep is not null) continue; // the decimal refused it: reads-a-number-or-a-string judges it + if (f.Ev is not null) return $"read {f.Name}: the value object threw {Jx.Ex(f.Ev)} where the decimal read it\n text={Jx.Cut(f.Json)}"; + if (f.Cp.Count != f.Cv.Count || f.Cp.Zip(f.Cv).Any(z => !Same(z.First, z.Second))) return $"read {f.Name}: the value object read {Show(f.Cv)}, the decimal {Show(f.Cp)}\n text={Jx.Cut(f.Json)}"; + } + if (writeLeftOut && (readNothing is not null || compared.Count == 0)) return WriteLeftOut(c); + c.Note(NoteKeys.Compared, $"{(number ? "number" : "string")} {text} -> {canon}, the value object's document the decimal's"); + return null; + }); + return f1 ?? f2 ?? f3 ?? f4 ?? f5; } } diff --git a/tools/probe/corpus/harness/Equality.cs b/tools/probe/corpus/harness/Equality.cs index ee30e6ef..7cabd460 100644 --- a/tools/probe/corpus/harness/Equality.cs +++ b/tools/probe/corpus/harness/Equality.cs @@ -70,7 +70,9 @@ static object Make(Type v, Type w, object value) x1 = Make(v, w, a1); x2 = Make(v, w, a2); y = Make(v, w, b); if (!cw.Equals(v.GetProperty("Value")!.GetValue(x1), a1)) return $"Value of {v.Name} is not the value it was made from"; } - var fails = new List(); + // each failure goes to the obligation it breaks: the value object's own equality and wherever it is held, its ordering, its bases + List eqF = [], ordF = [], baseF = []; + var fails = eqF; void Expect(bool got, bool want, string what) { if (got != want) fails.Add($"{what}: {got}, expected {want}"); } // the value object's own equality @@ -86,6 +88,7 @@ static object Make(Type v, Type w, object value) if (Op(v, "op_Inequality", x1, x2) is { } n12) Expect((bool)n12, !eq12, "!= of equal values"); // ordering: generated where the wrapped type is comparable, ordinal for a string, zero exactly when equal + fails = ordF; var icv = typeof(IComparable<>).MakeGenericType(v); if (!closed) { @@ -113,8 +116,13 @@ static object Make(Type v, Type w, object value) } // through every base, and every IEquatable it implements + var bases = 0; for (var t = v.BaseType; t is not null; t = t.BaseType) { + // object and ValueType are every value object's, a base of none: held as one, it is its own equality + var own = t == typeof(object) || t == typeof(ValueType); + if (!own) bases++; + fails = own ? eqF : baseF; var c = Comparer(t); Expect(c.Equals(x1, x2), eq12, $"EqualityComparer<{t.Name}> of equal values"); Expect(c.Equals(x1, y), false, $"EqualityComparer<{t.Name}> of unequal values"); @@ -127,12 +135,14 @@ static object Make(Type v, Type w, object value) } foreach (var i in v.GetInterfaces().Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEquatable<>))) { + fails = i.GetGenericArguments()[0] == v ? eqF : baseF; // IEquatable is its own equality, IEquatable a base's var m = i.GetMethod("Equals")!; Expect((bool)m.Invoke(x1, [x2])!, eq12, $"{Jx.Name(i)}.Equals of equal values"); Expect((bool)m.Invoke(x1, [y])!, false, $"{Jx.Name(i)}.Equals of unequal values"); } // wherever it is held + fails = eqF; var position = r["shape.position"]; var containing = position == "root" ? "class" : r["shape.containing"]; var e = new Entry(kind, wrapped, v, v, () => x1, () => null, typeof(L05PolyBase), typeof(L05PolyDerived<>).MakeGenericType(v), typeof(L05PolyBase), typeof(L05PolyDerived<>).MakeGenericType(v)); @@ -175,8 +185,27 @@ static object Make(Type v, Type w, object value) break; } } - if (fails.Count > 0) return $"{Jx.Name(v)} ({kind}, {wrapped}, {inh}, {site}, {position} in {containing}): " + string.Join("; ", fails); - r.Note(NoteKeys.Compared, $"{v.Name} {position}"); - return null; + string What(List f) => $"{Jx.Name(v)} ({kind}, {wrapped}, {inh}, {site}, {position} in {containing}): " + string.Join("; ", f); + var f1 = r.Check("equality-is-the-underlying-values", Baseline.Twin, c => + { + if (eqF.Count > 0) return What(eqF); + c.Note(NoteKeys.Compared, $"{v.Name} {position}: equality, against EqualityComparer<{(w is null ? v.Name : w.Name)}>"); + return null; + }); + var f2 = r.Check("ordering-follows-the-underlying-type", Baseline.Twin, c => + { + if (ordF.Count > 0) return What(ordF); + c.Note(NoteKeys.Compared, $"{v.Name}: ordering{(icv.IsAssignableFrom(v) ? "" : ", none generated where the wrapped type has none")}"); + return null; + }); + // a value object with a base record: each base compares it as it does (the chain is shape.inheritance's) + var f3 = bases == 0 ? null : r.Check("through-every-base", Baseline.Twin, c => + { + _ = c["shape.inheritance"]; + if (baseF.Count > 0) return What(baseF); + c.Note(NoteKeys.Compared, $"{v.Name}: {bases} base type(s)"); + return null; + }); + return f1 ?? f2 ?? f3; } } diff --git a/tools/probe/corpus/harness/JsonFamily.cs b/tools/probe/corpus/harness/JsonFamily.cs index 29b322ec..515d8b11 100644 --- a/tools/probe/corpus/harness/JsonFamily.cs +++ b/tools/probe/corpus/harness/JsonFamily.cs @@ -56,6 +56,8 @@ public static string Rewrite(string json, IEnumerable at, Func s is null ? "(null)" : s.Length > 220 ? s[..220] + "..." : s; public static string Name(Type t) => t.IsGenericType ? t.Name[..t.Name.IndexOf('`')] + "<" + string.Join(", ", t.GetGenericArguments().Select(Name)) + ">" : t.Name; + // Text-matching site, kept in this step (WS2 step 4): the depth refusal is told by the message's words. Baseline gap: no twin in the + // row shows System.Text.Json charging a converter a level, and no record or fact cites it (the three-sided differential). /// The serializer's depth limit under json.MaxDepth=3, which any converter is charged a level against (design note D1). public static bool Depth(Row r, Exception? x) => x is JsonException && x.Message.Contains("maximum allowed depth") && r.Is("json.MaxDepth", "3"); /// A refusal's message with the type the other side holds named instead: the serializer names the shape it refused. diff --git a/tools/probe/corpus/harness/OffTheWire.cs b/tools/probe/corpus/harness/OffTheWire.cs index 35357b2c..75d25c6b 100644 --- a/tools/probe/corpus/harness/OffTheWire.cs +++ b/tools/probe/corpus/harness/OffTheWire.cs @@ -38,6 +38,7 @@ public static class OffTheWire var position = r["shape.position"]; var containing = position == "root" ? "class" : r["shape.containing"]; var shape = r["or.shape"]; var op = r["or.operation"]; + _ = r["input.json"]; // the row's input, which every check of the row runs under; the read applies it if (shape == "Result-E" && op is "query-syntax" or "FirstOrNone") { r.Substituted("or.operation", "Match", "Result has no " + op + " (claims.json excludes the pair)"); op = "Match"; } var voValue = Make.Vo(e, e.Valid()); @@ -61,6 +62,8 @@ public static class OffTheWire if (nullableUnderContext) return x is InvalidOperationException ? null : $"{dir}: a source-generated context refuses an {name}? property with the serializer's InvalidOperationException; got {(x is null ? "no exception" : Jx.Ex(x))}"; if (x is not NotSupportedException) return $"{dir}: expected NotSupportedException naming {name}, got {(x is null ? "no exception" : Jx.Ex(x))}"; + // Text-matching site, kept in this step (WS2 step 4): the refusal is told TypeKit's by its words ("not a wire type"). Baseline + // gap: a NotSupportedException of the serializer's own is the same type, and no twin in the row refuses as the serializer alone does. if (!x.Message.Contains(name) || !x.Message.Contains("not a wire type")) return $"{dir}: the NotSupportedException does not name {name} as no wire type: {Jx.Cut(x.Message)}"; return null; } @@ -68,63 +71,75 @@ public static class OffTheWire // the baseline: the application's own converter for the type wins, in both directions var (jOwn, xOwn) = Jx.Ser(ho, oOwn); var (jv, xv) = Jx.Ser(hv, o); - if (xOwn is not null) + var refused = Baseline.Refusal; + var fw = r.Check("never-written", refused, c => { - if (xv is not null && xv.GetType() == xOwn.GetType()) { r.Note(NoteKeys.BothThrew, "the options refuse the shape itself: " + Jx.Ex(xv)); return null; } - return $"write: a converter registered on the options for {name} does not take precedence: {Jx.Ex(xOwn)}"; - } - var ownWritten = Jx.Tokens(jOwn!).Any(t => t.Text == "own-or"); - - // writing - var (jo, xo) = Jx.Ser(ho, o); - var wrote = false; - if (xo is null) - { - if (ownWritten || jo != jOwn) return $"write: {name} was written: {Jx.Cut(jo)}"; - r.Note(NoteKeys.JsonMemberLeftOutByOptions, "write: " + Jx.Cut(jo)); - } - else { if (Refused(xo, "write") is { } f) return f; wrote = true; } - - // reading: the value object's document with the row's input, where the Option or Result stands - var full = Jx.Full(o); - var (jf, xf) = Jx.Ser(hv, full); - // does the application's converter get called there? Its own document, read under the same options, says so - var (jOwnFull, _) = Jx.Ser(ho, Jx.Full(oOwn)); - string input, text; - if (xf is null) { input = r["input.json"]; text = JsonParity.InputFor(input, e, full, jf!, position); } - else - { - // the value object's own shape cannot be written here (a value object over a class under Preserve, a class as a key): the - // document with the application's converter has a token where the Option or Result stands, and is read as it is - if (jOwnFull is null) { r.Note(NoteKeys.BothThrew, "read: no document has the member: " + Jx.Ex(xf)); return null; } - _ = r["input.json"]; r.Substituted("input.json", "valid", "the value object's document cannot be written: " + xf.GetType().Name); - input = "valid"; text = jOwnFull; - } - var (_, xOwnRead) = Jx.De(jOwnFull ?? "null", ho.Declared, oOwn); - if (xOwnRead is not null) return $"read: a converter registered on the options for {name} does not take precedence: {Jx.Ex(xOwnRead)}"; - var memberRead = OwnReads() > 0; - var (vr, xr) = Jx.De(text, ho.Declared, o); - if (xr is null && input == "null" && position == "in-patch" && Jx.Member(ho.Declared, vr) is { } patch && patch.GetType().GetProperty("State")!.GetValue(patch)!.ToString() == "Null") - { - // a JSON null is the patch's own null (docs/merge-patches.md): it holds no Option or Result, and none was read - r.Note(NoteKeys.Compared, $"read null: the Patch<{name}> is null, nothing of {name} read"); + if (xOwn is not null) + { + if (xv is not null && xv.GetType() == xOwn.GetType()) return c.Inconclusive("write: the options refuse the shape itself, with the application's converter alike: " + Jx.Ex(xv)); + return $"write: a converter registered on the options for {name} does not take precedence: {Jx.Ex(xOwn)}"; + } + var ownWritten = Jx.Tokens(jOwn!).Any(t => t.Text == "own-or"); + var (jo, xo) = Jx.Ser(ho, o); + if (xo is null) + { + if (ownWritten || jo != jOwn) return $"write: {name} was written: {Jx.Cut(jo)}"; + // the options leave the member out, the application's converter's document alike: no converter ran + c.Note(NoteKeys.JsonMemberLeftOutByOptions, "write: " + Jx.Cut(jo)); + return c.Inconclusive("write: the options leave the member out, so no converter runs: " + Jx.Cut(jo)); + } + if (Refused(xo, "write") is { } f) return f; + c.Note(NoteKeys.Compared, $"{name} by {op}, {position}: write refused"); return null; - } - if (xr is null) + }); + var fr = await r.Check("never-read", refused, async c => { - if (memberRead) return $"read {input}: {name} was read without an exception\n text={Jx.Cut(text)}"; - r.Note(NoteKeys.JsonMemberLeftOutByOptions, "read " + input); // not-promised a-missing-property-reaches-no-converter - if (!wrote) r.Note(NoteKeys.JsonBothDirectionsLeftOutByOptions); - } - else if (Refused(xr, "read " + input) is { } f) - { - // the serializer may refuse the document before the member: the value object's shape refuses it with the very same exception - var (_, xvr) = Jx.De(text, hv.Declared, o); - if (xvr is not null && xvr.GetType() == xr.GetType() && xvr.Message == Jx.Swap(Jx.Swap(xr.Message, ho.Declared, hv.Declared), orType, v)) r.Note(NoteKeys.JsonReadRefusedByStjAlone, input + ": " + Jx.Ex(xr)); - else return f + $"\n text={Jx.Cut(text)}"; - } - r.Note(NoteKeys.Compared, $"{name} by {op}, {position}"); - return null; + await Task.CompletedTask; + if (xOwn is not null) return c.Inconclusive("read: the options refuse the shape itself, so no document has the member: " + Jx.Ex(xOwn)); + // reading: the value object's document with the row's input, where the Option or Result stands + var full = Jx.Full(o); + var (jf, xf) = Jx.Ser(hv, full); + // does the application's converter get called there? Its own document, read under the same options, says so + var (jOwnFull, _) = Jx.Ser(ho, Jx.Full(oOwn)); + string input, text; + if (xf is null) { input = c["input.json"]; text = JsonParity.InputFor(input, e, full, jf!, position); } + else + { + // the value object's own shape cannot be written here (a value object over a class under Preserve, a class as a key): the + // document with the application's converter has a token where the Option or Result stands, and is read as it is + if (jOwnFull is null) return c.Inconclusive("read: no document has the member: " + Jx.Ex(xf)); + _ = c["input.json"]; c.Substituted("input.json", "valid", "the value object's document cannot be written: " + xf.GetType().Name); + input = "valid"; text = jOwnFull; + } + var (_, xOwnRead) = Jx.De(jOwnFull ?? "null", ho.Declared, oOwn); + if (xOwnRead is not null) return $"read: a converter registered on the options for {name} does not take precedence: {Jx.Ex(xOwnRead)}"; + var memberRead = OwnReads() > 0; + var (vr, xr) = Jx.De(text, ho.Declared, o); + if (xr is null && input == "null" && position == "in-patch" && Jx.Member(ho.Declared, vr) is { } patch && patch.GetType().GetProperty("State")!.GetValue(patch)!.ToString() == "Null") + // a JSON null is the patch's own null (docs/merge-patches.md): it holds no Option or Result, and none was read + return c.Inapplicable(Cite.Docs("docs/merge-patches.md#tell-absent-null-and-a-value-apart"), $"read null: the Patch<{name}> is null, nothing of {name} read"); + if (xr is null) + { + if (memberRead) return $"read {input}: {name} was read without an exception\n text={Jx.Cut(text)}"; + c.Note(NoteKeys.JsonMemberLeftOutByOptions, "read " + input); + return c.Inapplicable(Cite.NotPromised("a-missing-property-reaches-no-converter"), "read " + input); + } + if (Refused(xr, "read " + input) is { } f) + { + // the serializer may refuse the document before the member: the value object's shape refuses it with the very same exception + var (_, xvr) = Jx.De(text, hv.Declared, o); + if (xvr is not null && xvr.GetType() == xr.GetType() && xvr.Message == Jx.Swap(Jx.Swap(xr.Message, ho.Declared, hv.Declared), orType, v)) + { + // the framework's refusal, shown by the value object's own shape in the same row: a pass, as decision 99's document is + c.Note(NoteKeys.JsonReadRefusedByStjAlone, input + ": " + Jx.Ex(xr)); + return null; + } + return f + $"\n text={Jx.Cut(text)}"; + } + c.Note(NoteKeys.Compared, $"{name} by {op}, {position}: read {input} refused"); + return null; + }); + return fw ?? fr; } static async Task<(Type, object)> Produce(string shape, string op, V v) where V : notnull diff --git a/tools/probe/corpus/harness/Positions.cs b/tools/probe/corpus/harness/Positions.cs index 989e0d85..d3a94d63 100644 --- a/tools/probe/corpus/harness/Positions.cs +++ b/tools/probe/corpus/harness/Positions.cs @@ -188,8 +188,13 @@ public static bool ReadMayDiffer(Row r, Entry e, string input, Exception? xv) } static bool Validated(Entry e) => e.Kind.StartsWith("validated"); - /// The oracle "JSON parity" (differential): a shape with the value object and the same shape with the wrapped type give the same - /// bytes under the same options and read each other's JSON. Returns null for a pass, otherwise the failure. + /// + /// The oracle "JSON parity" (differential) as L01's checks: the value object's write against the wrapped twin in the same shape + /// under the same options (the-underlying-types-json, or as-a-dictionary-key-too where the value object is a dictionary key), and + /// the read of the twin's document with the row's input.json against the twin's read (read-as-the-underlying-type-reads). Under an + /// application's converter for the value object, which writes it instead of the generated one, the read is that converter's own + /// document read back. Every check runs; returns the first failure. + /// public static string? Check(Row r, Entry e, JsonSerializerOptions o, string position, string containing, string input, JsonSerializerOptions? oPlain = null) { oPlain ??= o; @@ -201,74 +206,112 @@ public static bool ReadMayDiffer(Row r, Entry e, string input, Exception? xv) if (Equivalence.Shapes(hp.Declared, hv.Declared, e) is { } whyShape) { r.Outcome("harness-error", "inputs not equivalent: " + whyShape); return null; } if (Equivalence.Options(oPlain, o, allowConverters: e.Kind == "closed-set") is { } whyOptions) { r.Outcome("harness-error", "inputs not equivalent: " + whyOptions); return null; } var (jp, ep) = Ser(hp, oPlain); var (jv, ev) = Ser(hv, o); - var ownVo = r.Get("json.Converters") == "own-for-value-object"; - var preserveOwn = r.Get("json.ReferenceHandler") == "Preserve"; - if (ownVo) + var write = position == "dictionary-key" ? "as-a-dictionary-key-too" : "the-underlying-types-json"; + const string read = "read-as-the-underlying-type-reads"; + string? fw, fr; + if (r.Get("json.Converters") == "own-for-value-object") { - if (ev is not null && ep is null) - { - // The application's converter sits on the value object's side only. Its baseline is a converter the application - // registers for the wrapped type at the same position: where that throws alike, System.Text.Json refuses any - // custom converter there (MaxDepth counts its value one level deeper, design note D1), not the value object. - var oOwn = new JsonSerializerOptions(oPlain); - oOwn.Converters.Insert(0, (System.Text.Json.Serialization.JsonConverter)Activator.CreateInstance(typeof(OwnWrappedConverter<>).MakeGenericType(Make.Held(e.Plain)), JsonRow.Inner(e))!); - var (_, eo) = Ser(hp, oOwn); - if (eo is not null && eo.GetType() == ev.GetType()) { r.Note(NoteKeys.OwnConverterRefusedForWrappedToo, eo.GetType().Name); return null; } - return $"own VO converter row: VO serialization threw {ev.GetType().Name}: {ev.Message}"; - } - r.Note(NoteKeys.OwnVo); - if (jv is not null) - { - var (back, eb) = De(jv, hv, o); - if (eb is not null) { if (r.Get("json.UnmappedMemberHandling") == "Disallow" || input != "valid") { r.Note(NoteKeys.OwnVoReadBackRefused); return null; } return "own VO converter: read-back threw " + eb.GetType().Name + ": " + eb.Message; } - if (back != jv) return $"own VO converter round trip differs: {jv} vs {back}"; - } - return null; + // "Your own converter wins" (AspNetCore README#what-the-document-says): the application's converter writes the value object + // instead of the generated one, so the twin's bytes are none of its. claims.json declares the write inapplicable there. + fw = r.Check(write, Baseline.Twin, c => c.Inapplicable(Cite.Docs("src/CodoMetis.TypeKit.AspNetCore/README.md#what-the-document-says"), "write")); + fr = r.Check(read, Baseline.RoundTrip, c => OwnVoRead(c, e, hp, hv, oPlain, o, jv, ev, ep)); + return fw ?? fr; } - if (e.Wrapped == "byte" && position == "array-element" && jp != jv) { r.Note(NoteKeys.ByteArrayIsBase64, "write"); return null; } // STJ writes byte[] as base64; an array of byte value objects is a JSON array + fw = r.Check(write, Baseline.Twin, c => Write(c, e, position, jp, ep, jv, ev)); + fr = r.Check(read, Baseline.Twin, c => Read(c, e, position, input, hp, hv, oPlain, o, jp)); + return fw ?? fr; + } + + /// The value object's document is the wrapped twin's, byte for byte, in the same shape under the same options. + static string? Write(Check c, Entry e, string position, string? jp, Exception? ep, string? jv, Exception? ev) + { + // System.Text.Json writes byte[] as base64 and an array of value objects over byte as a JSON array (KB-038); claims.json declares it + if (e.Wrapped == "byte" && position == "array-element" && jp != jv) return c.Inapplicable(Cite.NotPromised("a-byte-array-is-base64"), "write"); if (ep is not null && ev is not null) - { - // Both refused: evidence about refusing, not about serializing. Refusing differently is no agreement at all. - if (ep.GetType() != ev.GetType()) { r.Note(NoteKeys.BothThrewDifferently, ep.GetType().Name + "/" + ev.GetType().Name); return null; } - r.Note(NoteKeys.BothThrew, ep.GetType().Name + ": " + ev.Message[..Math.Min(70, ev.Message.Length)]); return null; - } + // Both refused: evidence about refusing, not about serializing, and nothing to compare. + return c.Inconclusive(ep.GetType() != ev.GetType() + ? $"write: both refuse, differently: the wrapped type {ep.GetType().Name}, the value object {ev.GetType().Name}" + : $"write: the options refuse the shape for the wrapped type too: {ep.GetType().Name}: {Cut(ep.Message)}"); if (ep is null && ev is not null) { - // Text-matching site, kept in this step (WS2 step 4, the http family): the depth refusal is told by the message's words. Baseline - // gap: no twin in the row shows System.Text.Json charging a converter a level, and no record or fact cites it (the three-sided differential). - if (r.Is("json.MaxDepth", "3") && ev is JsonException && ev.Message.Contains("maximum allowed depth")) { r.Note(NoteKeys.StjMaxdepth, "charges custom converters"); return null; } // inherent to STJ: any custom converter costs a level (design note D1) - if (preserveOwn && e.Wrapped == "own-record-class" && ev is NotSupportedException) { r.Note(NoteKeys.PreserveDocumented); return null; } // documented (Generators README#json; not-promised preserved-references-over-a-walked-class) + // Text-matching site, kept in this step (WS2 step 4): the depth refusal is told by the message's words. Baseline gap: no twin in + // the row shows System.Text.Json charging a converter a level, and no record or fact cites it (the three-sided differential). + if (c.Is("json.MaxDepth", "3") && ev is JsonException && ev.Message.Contains("maximum allowed depth")) { c.Note(NoteKeys.StjMaxdepth, "charges custom converters"); return null; } // inherent to STJ: any custom converter costs a level (design note D1) + if (c.Is("json.ReferenceHandler", "Preserve") && e.Wrapped == "own-record-class" && ev is NotSupportedException) return c.Refused(Cite.NotPromised("preserved-references-over-a-walked-class"), "write"); return $"wrapped serialized ({jp}) but the value object threw {ev.GetType().Name}: {ev.Message}"; } if (ep is not null && ev is null) return $"value object serialized ({jv}) where the wrapped type threw {ep.GetType().Name}: {ep.Message}"; - if (jp != jv && e.Kind == "closed-set" && position == "dictionary-key" && r.Get("json.DictionaryKeyPolicy") != "default") { r.Note(NoteKeys.ClosedSetKeyUnpoliced); return null; } // a closed set travels as its name (not-promised a-closed-set-key-is-its-name) - if (jp != jv) - { - // intended: a struct VO over... none. DateTime is written as UTC; sample is UTC so equal. - return $"JSON differs: wrapped {Cut(jp)} vs value object {Cut(jv)}"; - } - // reading - var text = Input(input, e, oPlain, jp!, position); + // a closed set travels as its name, which no key policy renames; claims.json declares it + if (jp != jv && e.Kind == "closed-set" && position == "dictionary-key" && c.Get("json.DictionaryKeyPolicy") is not (null or "default")) return c.Inapplicable(Cite.NotPromised("a-closed-set-key-is-its-name"), "write"); + if (jp != jv) return $"JSON differs: wrapped {Cut(jp)} vs value object {Cut(jv)}"; + c.Note(NoteKeys.Compared, "write"); + return null; + } + + /// The wrapped twin's document, with the row's input.json, read into both shapes: each reads it as the other does, or refuses as a document says. + static string? Read(Check c, Entry e, string position, string input, Held hp, Held hv, JsonSerializerOptions oPlain, JsonSerializerOptions o, string? jp) + { + if (e.Wrapped == "byte" && position == "array-element") return c.Inapplicable(Cite.NotPromised("a-byte-array-is-base64"), "read"); + if (e.Kind == "closed-set" && position == "dictionary-key" && c.Get("json.DictionaryKeyPolicy") is not (null or "default")) return c.Inapplicable(Cite.NotPromised("a-closed-set-key-is-its-name"), "read"); + if (jp is null) return c.Inconclusive("read: the wrapped type is not written under these options, so there is no document to read"); + var text = Input(input, e, oPlain, jp, position); var (dp, xp) = De(text, hp, oPlain); var (dv, xv) = De(text, hv, o); + var stored = c.Get("json.Converters") == "StoredJsonConverterFactory"; if (xp is not null && xv is not null) - { - if (xp.GetType() != xv.GetType() && !(xp is JsonException && xv is JsonException)) { r.Note(NoteKeys.BothThrewDifferently, "read: " + xp.GetType().Name + "/" + xv.GetType().Name); return null; } - r.Note(NoteKeys.ReadBothThrew, xp.GetType().Name); return null; - } + return c.Inconclusive(xp.GetType() != xv.GetType() && !(xp is JsonException && xv is JsonException) + ? $"read {input}: both refuse, differently: the wrapped type {xp.GetType().Name}, the value object {xv.GetType().Name}" + : $"read {input}: both refuse: {xp.GetType().Name}"); if (xp is null && xv is not null) { - if (preserveOwn && e.Wrapped == "own-record-class" && xv is NotSupportedException) return null; - if (Validated(e) && input == "refused-by-create" && r.Get("json.Converters") != "StoredJsonConverterFactory") return xv is JsonException ? null : $"refusal is {xv.GetType().Name}, not JsonException: {xv.Message}"; - if (input == "null" && e.Vo.IsValueType) return null; // intended: a struct value object refuses JSON null (not-promised a-struct-refuses-a-json-null) - if (e.Kind == "closed-set" && input != "valid") return xv is JsonException ? null : $"closed set refusal is {xv.GetType().Name}"; // a closed set has the members it has + if (c.Is("json.ReferenceHandler", "Preserve") && e.Wrapped == "own-record-class" && xv is NotSupportedException) return c.Refused(Cite.NotPromised("preserved-references-over-a-walked-class"), "read"); + // The text-matching site of the write, at the read: the depth refusal told by its words, no twin in the row showing it. + if (c.Is("json.MaxDepth", "3") && xv is JsonException && xv.Message.Contains("maximum allowed depth")) { c.Note(NoteKeys.StjMaxdepth, "read: charges custom converters"); return null; } + // The documented refusals, each the JsonException the generated converter throws. + if (!(xv is JsonException) && ((Validated(e) && input == "refused-by-create" && !stored) || (input == "null" && e.Vo.IsValueType) || (e.Kind == "closed-set" && input != "valid"))) + return $"input {input}: the refusal is {xv.GetType().Name}, not JsonException: {Cut(xv.Message)}"; + if (Validated(e) && input == "refused-by-create" && !stored) return c.Refused(Cite.NotPromised("a-validated-value-object-refuses-what-create-refuses"), $"read {input}"); + if (input == "null" && e.Vo.IsValueType) return c.Refused(Cite.NotPromised("a-struct-refuses-a-json-null"), $"read {input}"); + if (e.Kind == "closed-set" && input != "valid") return c.Refused(Cite.Docs("src/CodoMetis.TypeKit.Generators/README.md#matching-parsing-and-binding"), $"read {input}"); // "Unknown names are rejected." return $"input {input}: wrapped read it, value object threw {xv.GetType().Name}: {Cut(xv.Message)}\n text={Cut(text)}"; } - if (xp is not null && xv is null && input == "null" && !e.Vo.IsValueType && e.Plain.IsValueType) return null; // a reference-type value object takes JSON null as null, as any class does (README: "a JSON null for a struct value object") + // a reference-type value object takes JSON null as null, as any class does + if (xp is not null && xv is null && input == "null" && !e.Vo.IsValueType && e.Plain.IsValueType) return c.Inapplicable(Cite.Docs("docs/json.md#null-and-missing"), "read null"); if (xp is not null && xv is null) return $"input {input}: value object read what the wrapped type refused ({xp.GetType().Name}: {Cut(xp.Message)})\n text={Cut(text)}"; - if (Validated(e) && input == "refused-by-create" && text != jp && r.Get("json.Converters") != "StoredJsonConverterFactory" && position != "dictionary-key") - return $"validated value object accepted the refused value: {Cut(text)}"; + if (Validated(e) && input == "refused-by-create" && text != jp && !stored) + return $"validated value object accepted the refused value{(position == "dictionary-key" ? " as a dictionary key" : "")}: {Cut(text)}"; if (dp != dv) return $"input {input}: read-back differs: wrapped {Cut(dp)} vs value object {Cut(dv)}"; - r.Note(NoteKeys.Compared); return null; + c.Note(NoteKeys.Compared, "read " + input); + return null; + } + + /// + /// Under an application's converter for the value object, the generated converter does not run: the value object's document is that + /// converter's, and it reads back as written under the row's options. Where the value object's document cannot be written, a converter + /// the application registers for the wrapped type at the same position is the baseline. + /// + static string? OwnVoRead(Check c, Entry e, Held hp, Held hv, JsonSerializerOptions oPlain, JsonSerializerOptions o, string? jv, Exception? ev, Exception? ep) + { + var oOwn = new JsonSerializerOptions(oPlain); + oOwn.Converters.Insert(0, (System.Text.Json.Serialization.JsonConverter)Activator.CreateInstance(typeof(OwnWrappedConverter<>).MakeGenericType(Make.Held(e.Plain)), JsonRow.Inner(e))!); + var (jo, eo) = Ser(hp, oOwn); + if (ev is not null) + { + // System.Text.Json refuses any custom converter there (MaxDepth counts its value one level deeper, design note D1), the wrapped + // type's under a converter of the application's alike + if (ep is not null || (eo is not null && eo.GetType() == ev.GetType())) return c.Inconclusive($"the application's converter cannot write here, for the wrapped type too: {ev.GetType().Name}"); + return $"own VO converter row: VO serialization threw {ev.GetType().Name}: {ev.Message}"; + } + c.Note(NoteKeys.OwnVo); + var (back, eb) = De(jv!, hv, o); + if (eb is not null) + { + // the same converter for the wrapped type, at the same position: its own document does not read back under these options either + var ebOwn = jo is null ? eo : De(jo, hp, oOwn).Ex; + if (ebOwn is not null && ebOwn.GetType() == eb.GetType()) return c.Inconclusive($"the application's converter's document does not read back under these options, for the wrapped type too: {eb.GetType().Name}"); + return "own VO converter: read-back threw " + eb.GetType().Name + ": " + eb.Message; + } + if (back != jv) return $"own VO converter round trip differs: {jv} vs {back}"; + return null; } static string Cut(string? s) => s is null ? "(null)" : s.Length > 200 ? s[..200] + "..." : s; } diff --git a/tools/probe/corpus/harness/PreserveGuard.cs b/tools/probe/corpus/harness/PreserveGuard.cs index 2b7afd5e..43ec6af6 100644 --- a/tools/probe/corpus/harness/PreserveGuard.cs +++ b/tools/probe/corpus/harness/PreserveGuard.cs @@ -27,6 +27,7 @@ public static class PreserveGuard var e = kind == "closed-set" ? Registry.Get(kind, "-") : Registry.Get(kind, r["shape.wrapped"]); var position = r["shape.position"]; var containing = position == "root" ? "class" : r["shape.containing"]; + _ = r["input.json"]; // the row's input, which every check of the row runs under; the reads apply it var plain = e.Valid(); var voValue = Make.Vo(e, plain); var hp = Pos.Build(position, containing, e, false, plain); var hv = Pos.Build(position, containing, e, true, voValue); @@ -38,10 +39,15 @@ public static class PreserveGuard var ownWrapped = row.Is("json.Converters", "own-for-wrapped"); var ownVo = row.Is("json.Converters", "own-for-value-object"); var guarded = want == "Preserve" && e.Wrapped == "own-record-class" && !ownWrapped && !ownVo; - return guarded ? Guarded(r, e, hp, hv, o) : Unaffected(r, e, hp, hv, op, o, ownVo); + // The obligation the row's handler and converters select: a converter of the application's, the guarded class under Preserve, + // IgnoreCycles, and otherwise a value the serializer writes with a value converter, under Preserve or with no handler at all. + var obligation = ownWrapped || ownVo ? "own-converters-unaffected" : guarded ? "refused-where-references-could-be-written" + : want == "IgnoreCycles" ? "ignore-cycles-unaffected" : "value-converters-unaffected"; + return guarded ? Guarded(r, obligation, e, hp, hv, o) : Unaffected(r, obligation, e, hp, hv, op, o, ownVo); } - static string? Guarded(Row r, Entry e, Held hp, Held hv, JsonSerializerOptions o) + /// The guarded rows: NotSupportedException naming the wrapped type and Preserve, in both directions, never a document. + static string? Guarded(Row r, string obligation, Entry e, Held hp, Held hv, JsonSerializerOptions o) { var wrapped = Make.Held(e.Plain); string? Refusal(Exception? x, string what) @@ -52,73 +58,104 @@ public static class PreserveGuard } var (jv, xv) = Jx.Ser(hv, o); var (jp, xp) = Jx.Ser(hp, o); - if (jv is not null) + var fw = r.Check(obligation, Baseline.Refusal, c => { - // the options may leave the member out of the document, for the wrapped type too: then no converter ran - if (jp is not null && !Jx.Tokens(jp).Any(t => t.Text == "x") && !Jx.Tokens(jv).Any(t => t.Text == "x")) r.Note(NoteKeys.JsonMemberLeftOutByOptions, "write: " + Jx.Cut(jv)); - else return Refusal(null, "write") + $"; it wrote {Jx.Cut(jv)}" + (DuplicateId(jv) is { } dup ? $" with {dup} twice" : ""); - } - else if (Refusal(xv, "write") is { } f) return f; - if (xp is not null) { r.Note(NoteKeys.BothThrew, "the wrapped type's document under these options: " + Jx.Ex(xp)); return null; } - // reading: the wrapped type's own document, with the row's input applied - var input = r["input.json"]; - var text = JsonParity.InputFor(input, e, Jx.Full(o), Jx.Ser(hp, Jx.Full(o)).Json ?? jp!, r["shape.position"]); - var (vp, ep) = Jx.De(text, hp.Declared, o); - var (vv, ev) = Jx.De(text, hv.Declared, o); - if (ev is null) - { - // no converter ran: the read holds none of the wrapped value (a JSON null for a class, a member the options do not read) - if (vv is not null && Jx.Tokens(JsonSerializer.Serialize(vv, hv.Declared, Jx.Plain)).Any(t => t.Text == "x")) - return $"read {input}: the value object over {Make.Held(e.Plain).Name} read a document under Preserve: {Jx.Cut(text)}"; - r.Note(NoteKeys.JsonMemberLeftOutByOptions, "read " + input); + if (jv is not null) + { + // the options may leave the member out of the document, for the wrapped type too: then no converter ran, and nothing was refused + if (jp is not null && !Jx.Tokens(jp).Any(t => t.Text == "x") && !Jx.Tokens(jv).Any(t => t.Text == "x")) + { c.Note(NoteKeys.JsonMemberLeftOutByOptions, "write: " + Jx.Cut(jv)); return c.Inconclusive("write: the options leave the member out, for the wrapped type too: " + Jx.Cut(jv)); } + return Refusal(null, "write") + $"; it wrote {Jx.Cut(jv)}" + (DuplicateId(jv) is { } dup ? $" with {dup} twice" : ""); + } + if (Refusal(xv, "write") is { } f) return f; + c.Note(NoteKeys.Compared, "write refused"); return null; - } - if (ev is NotSupportedException) return Refusal(ev, "read " + input); - if (ep is not null && ev.GetType() == ep.GetType() && ev.Message == ep.Message) { r.Note(NoteKeys.JsonReadRefusedByStjAlone, input + ": " + Jx.Ex(ev)); return null; } - if (ev is JsonException && ev.Message.Contains(Make.Held(e.Vo).Name) && ev.InnerException is null) { r.Note(NoteKeys.Compared, "read " + input + ": refused naming the value object before the guard"); return null; } - return Refusal(ev, "read " + input) + $"\n text={Jx.Cut(text)}"; + }); + var fr = r.Check(obligation, Baseline.Refusal, c => + { + if (xp is not null) return c.Inconclusive("read: the wrapped type's document is refused under these options, so there is none to read: " + Jx.Ex(xp)); + // reading: the wrapped type's own document, with the row's input applied + var input = c["input.json"]; + var text = JsonParity.InputFor(input, e, Jx.Full(o), Jx.Ser(hp, Jx.Full(o)).Json ?? jp!, c["shape.position"]); + var (vp, ep) = Jx.De(text, hp.Declared, o); + var (vv, ev) = Jx.De(text, hv.Declared, o); + if (ev is null) + { + // no converter ran: the read holds none of the wrapped value (a JSON null for a class, a member the options do not read) + if (vv is not null && Jx.Tokens(JsonSerializer.Serialize(vv, hv.Declared, Jx.Plain)).Any(t => t.Text == "x")) + return $"read {input}: the value object over {Make.Held(e.Plain).Name} read a document under Preserve: {Jx.Cut(text)}"; + c.Note(NoteKeys.JsonMemberLeftOutByOptions, "read " + input); + return c.Inconclusive($"read {input}: no converter ran, the read holds none of the wrapped value"); + } + if (ev is NotSupportedException) { if (Refusal(ev, "read " + input) is { } f) return f; c.Note(NoteKeys.Compared, "read " + input + " refused"); return null; } + // the serializer refused the document before any converter ran, the wrapped type's alike + if (ep is not null && ev.GetType() == ep.GetType() && ev.Message == ep.Message) + { c.Note(NoteKeys.JsonReadRefusedByStjAlone, input + ": " + Jx.Ex(ev)); return null; } // the framework's refusal, the twin's alike: a pass, as decision 99's document is + if (ev is JsonException && ev.Message.Contains(Make.Held(e.Vo).Name) && ev.InnerException is null) { c.Note(NoteKeys.Compared, "read " + input + ": refused naming the value object before the guard"); return null; } + return Refusal(ev, "read " + input) + $"\n text={Jx.Cut(text)}"; + }); + return fw ?? fr; } - static string? Unaffected(Row r, Entry e, Held hp, Held hv, JsonSerializerOptions op, JsonSerializerOptions o, bool ownVo) + /// + /// The unaffected rows: the value object's document is the wrapped type's under the same options, never an $id twice, and its read + /// agrees with the wrapped type's (ReadAlike); under a converter of the application's for the value object, that converter's document + /// reads back and reads the row's input alike with and without the reference handler. + /// + static string? Unaffected(Row r, string obligation, Entry e, Held hp, Held hv, JsonSerializerOptions op, JsonSerializerOptions o, bool ownVo) { var (jp, xp) = Jx.Ser(hp, op); var (jv, xv) = Jx.Ser(hv, o); - // MaxDepth=3: a converter costs a level of depth, the serializer's or the generated one (design note D1, the catalogue's stj-maxdepth) - if (Jx.Depth(r, xv) || Jx.Depth(r, xp)) { r.Note(NoteKeys.StjMaxdepth, "write: " + Jx.Ex((xv ?? xp)!)); return null; } - if (xp is not null && xv is not null) - { - if (xp.GetType() == xv.GetType() && xp.Message == xv.Message) { r.Note(NoteKeys.BothThrew, "write: " + Jx.Ex(xp)); return null; } - return $"write: both threw, differently: the wrapped type {Jx.Ex(xp)}, the value object {Jx.Ex(xv)}"; - } - if (xv is not null) return $"write: the value object threw {Jx.Ex(xv)} where the wrapped type wrote {Jx.Cut(jp)}"; - if (xp is not null) return $"write: the value object wrote {Jx.Cut(jv)} where the wrapped type threw {Jx.Ex(xp)}"; - if (DuplicateId(jv!) is { } dup) return $"write: $id {dup} twice in {Jx.Cut(jv)}"; - if (ownVo) - { - r.Note(NoteKeys.OwnVo); // the application's converter for the value object writes its own document; it reads it back, unaffected - var (jf, _) = Jx.Ser(hv, Jx.Full(o)); - var (_, xr) = Jx.De(jf ?? jv!, hv.Declared, o); - if (xr is not null) return "own converter for the value object: its document does not read back: " + Jx.Ex(xr); - return OwnReadsInput(r, e, hv, o); - } - if (jv != jp) + // byte[] is base64 by the serializer's own rule (KB-038), an array of value objects a JSON array: unaffected means the value + // object's document reads back as written + var byteArray = e.Wrapped == "byte" && r.Is("shape.position", "array-element") && jv is not null && jp is not null && jv != jp; + var fw = r.Check(obligation, ownVo || byteArray ? Baseline.RoundTrip : Baseline.Twin, c => { - if (e.Wrapped == "byte" && r.Is("shape.position", "array-element")) + // MaxDepth=3: a converter costs a level of depth, the serializer's or the generated one (design note D1, the catalogue's stj-maxdepth); + // Jx.Depth is a text-matching site (JsonFamily.cs) + if (Jx.Depth(r, xv) || Jx.Depth(r, xp)) { c.Note(NoteKeys.StjMaxdepth, "write: " + Jx.Ex((xv ?? xp)!)); return null; } + if (xp is not null && xv is not null) + { + if (xp.GetType() == xv.GetType() && xp.Message == xv.Message) return c.Inconclusive("write: the options refuse the shape, for the wrapped type alike: " + Jx.Ex(xp)); + return $"write: both threw, differently: the wrapped type {Jx.Ex(xp)}, the value object {Jx.Ex(xv)}"; + } + if (xv is not null) return $"write: the value object threw {Jx.Ex(xv)} where the wrapped type wrote {Jx.Cut(jp)}"; + if (xp is not null) return $"write: the value object wrote {Jx.Cut(jv)} where the wrapped type threw {Jx.Ex(xp)}"; + if (DuplicateId(jv!) is { } dup) return $"write: $id {dup} twice in {Jx.Cut(jv)}"; + if (ownVo) + { + c.Note(NoteKeys.OwnVo); // the application's converter for the value object writes its own document; it reads it back, unaffected + var (jf, _) = Jx.Ser(hv, Jx.Full(o)); + var (_, xr) = Jx.De(jf ?? jv!, hv.Declared, o); + if (xr is not null) return "own converter for the value object: its document does not read back: " + Jx.Ex(xr); + return null; + } + if (byteArray) { - // byte[] is base64 by the serializer's own rule, an array of value objects a JSON array: unaffected means the value - // object's document reads back as written var (back, xb) = Jx.De(jv!, hv.Declared, o); if (xb is not null) return "write: the array of byte value objects does not read back: " + Jx.Ex(xb); var again = JsonSerializer.Serialize(back, hv.Declared, o); if (again != jv) return $"round trip: {Jx.Cut(jv)} read back as {Jx.Cut(again)}"; - r.Note(NoteKeys.Compared, "an array of byte value objects round-trips as a JSON array"); + c.Note(NoteKeys.Compared, "an array of byte value objects round-trips as a JSON array"); return null; } - return $"write: the value object wrote {Jx.Cut(jv)}, the wrapped type {Jx.Cut(jp)}"; - } - var input = r["input.json"]; - var full = Jx.Ser(hp, Jx.Full(op)).Json ?? jp!; - var text = JsonParity.InputFor(input, e, Jx.Full(op), full, r["shape.position"]); - return ReadAlike.Check(r, e, input, text, hp, hv, op, ownVo ? null : o); + if (jv != jp) return $"write: the value object wrote {Jx.Cut(jv)}, the wrapped type {Jx.Cut(jp)}"; + c.Note(NoteKeys.Compared, "write"); + return null; + }); + var fr = r.Check(obligation, ownVo ? Baseline.RoundTrip : Baseline.Twin, c => + { + if (ownVo) return OwnReadsInput(r, c, e, hv, o); + // the wrapped type's base64 document is no array of value objects (KB-038): the round trip above holds the value object to its own + if (byteArray) return c.Inapplicable(Cite.Fact("KB-038"), "read: the wrapped type's document is a base64 string"); + // the document is refused by depth (Jx.Depth, the text-matching site), so its read is too: the write's refusal, as it is + if (Jx.Depth(r, xv) || Jx.Depth(r, xp)) { c.Note(NoteKeys.StjMaxdepth, "read: " + Jx.Ex((xv ?? xp)!)); return null; } + var full = Jx.Ser(hp, Jx.Full(op)).Json ?? jp; + if (full is null) return c.Inconclusive("read: the wrapped type is not written under these options, so there is no document to read"); + var input = c["input.json"]; + var text = JsonParity.InputFor(input, e, Jx.Full(op), full, c["shape.position"]); + return ReadAlike.Check(r, c, e, input, text, hp, hv, op, o); + }); + return fw ?? fr; } /// @@ -126,9 +163,9 @@ public static class PreserveGuard /// type's JSON) alike under the row's reference handler and under none: "anything a converter of your own writes" is unaffected /// (Generators README#json). Each side writes its own document, so Preserve's metadata is its own, and only the value is rewritten. /// - static string? OwnReadsInput(Row r, Entry e, Held hv, JsonSerializerOptions o) + static string? OwnReadsInput(Row r, Check c, Entry e, Held hv, JsonSerializerOptions o) { - var input = r["input.json"]; + var input = c["input.json"]; var inner = JsonRow.Inner(e); var wrapped = JsonSerializer.Serialize(Make.Unwrap(e, Make.Vo(e, e.Valid())), Make.Held(e.Plain), inner); var value = "vo:" + JsonParity.InputFor(input, e, inner, wrapped, "root"); @@ -144,12 +181,12 @@ public static class PreserveGuard return (JsonSerializer.Serialize(v, hv.Declared, bare), null, null); } var handled = Through(o); var unhandled = Through(bare); - if ((handled.Depth ?? unhandled.Depth) is { } depth) { r.Note(NoteKeys.StjMaxdepth, "own converter, read " + input + ": " + Jx.Ex(depth)); return null; } + if ((handled.Depth ?? unhandled.Depth) is { } depth) { c.Note(NoteKeys.StjMaxdepth, "own converter, read " + input + ": " + Jx.Ex(depth)); return null; } // Jx.Depth: a text-matching site if (handled.Read is null && handled.Refused is null && unhandled.Read is null && unhandled.Refused is null) - { r.Note(NoteKeys.JsonMemberLeftOutByOptions, "own converter: the options write no member"); return null; } + { c.Note(NoteKeys.JsonMemberLeftOutByOptions, "own converter: the options write no member"); return c.Inconclusive("own converter: the options write no member to read"); } if (handled.Read != unhandled.Read || handled.Refused != unhandled.Refused) - return $"read {input} through the application's converter: {handled.Read ?? handled.Refused ?? "no member"} under ReferenceHandler {r["handler"]}, {unhandled.Read ?? unhandled.Refused ?? "no member"} without one"; - r.Note(NoteKeys.Compared, $"read {input} through the application's converter, alike with and without the reference handler: {Jx.Cut(handled.Read ?? handled.Refused)}"); + return $"read {input} through the application's converter: {handled.Read ?? handled.Refused ?? "no member"} under ReferenceHandler {c["handler"]}, {unhandled.Read ?? unhandled.Refused ?? "no member"} without one"; + c.Note(NoteKeys.Compared, $"read {input} through the application's converter, alike with and without the reference handler: {Jx.Cut(handled.Read ?? handled.Refused)}"); return null; } @@ -170,40 +207,40 @@ public static class PreserveGuard /// public static class ReadAlike { - public static string? Check(Row r, Entry e, string input, string text, Held hp, Held hv, JsonSerializerOptions op, JsonSerializerOptions? ov) + public static string? Check(Row r, Check c, Entry e, string input, string text, Held hp, Held hv, JsonSerializerOptions op, JsonSerializerOptions ov) { var (vp, ep) = Jx.De(text, hp.Declared, op); - if (ov is null) { if (ep is null) r.Note(NoteKeys.Compared, "read " + input + " (the wrapped type; the value object's converter is the application's)"); else r.Note(NoteKeys.ReadBothThrew, Jx.Ex(ep)); return null; } var (vv, ev) = Jx.De(text, hv.Declared, ov); var voName = Make.Held(e.Vo).Name; string? Named(Exception x) => x is JsonException && x.Message.Contains(voName) && x.InnerException is null ? null : $"read {input}: the value object's refusal is {Jx.Ex(x)}{(x.InnerException is null ? "" : " with an inner exception")}, not a JsonException naming {voName}\n text={Jx.Cut(text)}"; - var stored = r.Is("json.Converters", "StoredJsonConverterFactory"); - if (Jx.Depth(r, ev) || Jx.Depth(r, ep)) { r.Note(NoteKeys.StjMaxdepth, "read " + input + ": " + Jx.Ex((ev ?? ep)!)); return null; } + var stored = c.Is("json.Converters", "StoredJsonConverterFactory"); + if (Jx.Depth(r, ev) || Jx.Depth(r, ep)) { c.Note(NoteKeys.StjMaxdepth, "read " + input + ": " + Jx.Ex((ev ?? ep)!)); return null; } // Jx.Depth: a text-matching site if (ep is null && ev is null) { - if (e.Kind.StartsWith("validated") && input == "refused-by-create" && !stored && e.Refused() is not null && text != Jx.Ser(hp, op).Json && !r.Is("shape.position", "dictionary-key")) + if (e.Kind.StartsWith("validated") && input == "refused-by-create" && !stored && e.Refused() is not null && text != Jx.Ser(hp, op).Json && !c.Is("shape.position", "dictionary-key")) return $"read {input}: the validated value object read what Create refuses\n text={Jx.Cut(text)}"; var sp = JsonSerializer.Serialize(vp, hp.Declared, op); var sv = JsonSerializer.Serialize(vv, hv.Declared, ov); if (sp != sv) return $"read {input}: read back differs: wrapped {Jx.Cut(sp)} vs value object {Jx.Cut(sv)}\n text={Jx.Cut(text)}"; - r.Note(NoteKeys.Compared, "read " + input); return null; + c.Note(NoteKeys.Compared, "read " + input); return null; } if (ep is not null && ev is not null) { - if (ep.GetType() == ev.GetType() && Jx.Swap(ep.Message, Make.Held(e.Plain), Make.Held(e.Vo)) == ev.Message) { r.Note(NoteKeys.JsonReadRefusedByStjAlone, input + ": " + Jx.Ex(ev)); return null; } + // refused alike, message for message: the value object is unaffected, as the twin + if (ep.GetType() == ev.GetType() && Jx.Swap(ep.Message, Make.Held(e.Plain), Make.Held(e.Vo)) == ev.Message) { c.Note(NoteKeys.JsonReadRefusedByStjAlone, input + ": " + Jx.Ex(ev)); return null; } if (Named(ev) is { } f) return f + $"; the wrapped type: {Jx.Ex(ep)}"; - r.Note(NoteKeys.Compared, "read " + input + ": both refuse, the value object naming itself"); return null; + c.Note(NoteKeys.Compared, "read " + input + ": both refuse, the value object naming itself"); return null; } if (ev is not null) { var intended = (input == "null" && e.Vo.IsValueType) || (e.Kind.StartsWith("validated") && input == "refused-by-create" && !stored) || (e.Kind == "closed-set" && input != "valid"); if (!intended) return $"read {input}: the wrapped type read it, the value object threw {Jx.Ex(ev)}\n text={Jx.Cut(text)}"; if (Named(ev) is { } f) return f; - r.Note(NoteKeys.Compared, "read " + input + ": refused as documented"); return null; + c.Note(NoteKeys.Compared, "read " + input + ": refused as documented"); return null; } // the wrapped type refused and the value object read: only a JSON null for a sealed record, which reads it as any class does if (input == "null" && !e.Vo.IsValueType && e.Plain.IsValueType && Jx.Tokens(JsonSerializer.Serialize(vv, hv.Declared, Jx.Plain)).Any(t => t.Type == JsonTokenType.Null)) - { r.Note(NoteKeys.Compared, "read null: the sealed record reads null, as any class does"); return null; } + { c.Note(NoteKeys.Compared, "read null: the sealed record reads null, as any class does"); return null; } return $"read {input}: the value object read what the wrapped type refused ({Jx.Ex(ep!)})\n text={Jx.Cut(text)}"; } } diff --git a/tools/probe/corpus/harness/TextRow.cs b/tools/probe/corpus/harness/TextRow.cs index df9c261d..a7fdb059 100644 --- a/tools/probe/corpus/harness/TextRow.cs +++ b/tools/probe/corpus/harness/TextRow.cs @@ -46,7 +46,10 @@ static Out Unwrap(Func f) /// Plain text of a wrapped value the way the value object formats it (invariant), used as the sample's text. public static string PlainText(Entry e, object plain) => plain is IFormattable f ? f.ToString(Fmt(e), CultureInfo.InvariantCulture) : plain.ToString()!; - public static bool HasText(Entry e) => e.Vo.GetMethod("Parse", [typeof(string), typeof(IFormatProvider)]) is not null; + /// The value object reads text: a generated Parse(string, IFormatProvider), or a closed set's Parse(string) by name. + public static bool HasText(Entry e) => e.Vo.GetMethod("Parse", [typeof(string), typeof(IFormatProvider)]) is not null || ByName(e, "Parse", [typeof(string)]) is not null; + /// A closed set's own Parse and TryParse, by name and ordinal (Generators README#matching-parsing-and-binding): the provider means nothing to a name. + static MethodInfo? ByName(Entry e, string name, Type[] parameters) => e.Kind == "closed-set" ? e.Vo.GetMethod(name, parameters) : null; public static Out Read(Entry e, string entry, string text) => Unwrap(() => { @@ -54,14 +57,17 @@ public static Out Read(Entry e, string entry, string text) => Unwrap(() => { case "Parse": { - var m = e.Vo.GetMethod("Parse", [typeof(string), typeof(IFormatProvider)]); if (m is null) return Out.NA; - return new(false, true, m.Invoke(null, [text, null]), null, null); + var m = e.Vo.GetMethod("Parse", [typeof(string), typeof(IFormatProvider)]); + if (m is not null) return new(false, true, m.Invoke(null, [text, null]), null, null); + return ByName(e, "Parse", [typeof(string)]) is { } n ? new(false, true, n.Invoke(null, [text]), null, null) : Out.NA; } case "TryParse": { - var m = e.Vo.GetMethod("TryParse", [typeof(string), typeof(IFormatProvider), e.Vo.MakeByRefType()]); if (m is null) return Out.NA; - var a = new object?[] { text, null, null }; var ok = (bool)m.Invoke(null, a)!; - return new(false, ok, a[2], null, null); + var m = e.Vo.GetMethod("TryParse", [typeof(string), typeof(IFormatProvider), e.Vo.MakeByRefType()]); + if (m is not null) { var a = new object?[] { text, null, null }; var ok = (bool)m.Invoke(null, a)!; return new(false, ok, a[2], null, null); } + if (ByName(e, "TryParse", [typeof(string), e.Vo.MakeByRefType()]) is not { } n) return Out.NA; + var b = new object?[] { text, null }; var found = (bool)n.Invoke(null, b)!; + return new(false, found, b[1], null, null); } case "IParsable": { @@ -123,6 +129,7 @@ public static Out TryFrom(Entry e, object plain) => Unwrap(() => public static class TextClaims { static readonly string[] Entries = ["Parse", "TryParse", "TryFrom", "type-converter", "IParsable", "ISpanParsable", "ToString-format", "TryFormat-short-span"]; + static bool Reads(string entry) => entry is "Parse" or "TryParse" or "IParsable" or "ISpanParsable" or "type-converter"; static bool WrappedLosesItToo(Entry e, object plain) { @@ -134,102 +141,196 @@ static bool WrappedLosesItToo(Entry e, object plain) static bool Same(Entry e, object? a, object? b) => a is not null && b is not null && (a.Equals(b) || (e.Wrapped == "DateTime" && ((DateTime)Make.Unwrap(e, a)).ToUniversalTime() == ((DateTime)Make.Unwrap(e, b)).ToUniversalTime())); + /// + /// Why a generated text entry is missing, where a document says it is not generated: a closed set's surface is its own (Generators + /// README#closed-sets), no parsing is generated over a type that cannot be parsed, and no span parsing over a type that has none + /// (#what-is-generated). Each is declared in claims.json too; null where nothing says it is missing. + /// + static string? Missing(Entry e, string entry) + { + if (e.Kind == "closed-set") return entry is "TryFrom" or "ISpanParsable" or "ToString-format" or "TryFormat-short-span" ? "closed-set" : null; + if (!TextOps.HasText(e) && entry is not ("ToString-format" or "TryFormat-short-span" or "TryFrom")) return "not-parsable"; + if (entry == "ISpanParsable" && e.Wrapped is "Uri" or "enum" or "flags-enum" or "NodaTime.Instant") return "no-span-parsing"; + return null; + } + /// The check does not apply: the entry names is not generated, cited by the sentence that says so. + static string? NotGenerated(Check c, Entry e, string entry) => Missing(e, entry) switch + { + "closed-set" => c.Inapplicable(Cite.Docs("src/CodoMetis.TypeKit.Generators/README.md#closed-sets"), entry), + _ => c.Inapplicable(Cite.Docs("src/CodoMetis.TypeKit.Generators/README.md#what-is-generated"), entry), + }; + // ---- L04: write then read gives back the value; text half and JSON half + /// + /// L04's checks: the generated ToString() is the wrapped type's invariant text whatever the culture (invariant-whatever-the-culture); + /// the row's writing entry writes the same (the-type-converter-writes-the-same); that text, or TryFrom's value, reads back as the same + /// value wherever the wrapped type's own does (text-reads-back); and the JSON half, the value object's own document read back and the + /// row's changed input refused with a JsonException naming the value object (json-refusal-is-a-json-exception). Every check runs; + /// returns the first failure. + /// public static string? RoundTrip(Row r, Entry e) { TextOps.Prepare(r, e); var entry = r["text.entry"]; var plain = e.Valid(); var vo = Make.Vo(e, plain); + var fails = new List(); if (entry == "TryFrom") - { - var t = TextOps.TryFrom(e, plain); - if (t.NotApplicable) r.Note(NoteKeys.EntryNotApplicable, "TryFrom"); else if (!t.Ok || !Same(e, t.Value, vo)) return $"TryFrom(valid) gave ok={t.Ok} value={t.Value}, expected {vo}"; - } - else if (!TextOps.HasText(e)) r.Note(NoteKeys.NoText, e.Kind); + fails.Add(r.Check("text-reads-back", Baseline.RoundTrip, c => + { + var t = TextOps.TryFrom(e, plain); + if (t.NotApplicable) return Missing(e, entry) is not null ? NotGenerated(c, e, entry) : "TryFrom is not generated for the value object"; + if (!t.Ok || !Same(e, t.Value, vo)) return $"TryFrom(valid) gave ok={t.Ok} value={t.Value}, expected {vo}"; + c.Note(NoteKeys.Compared, "TryFrom(valid)"); + return null; + })); else { - var w = TextOps.Write(e, entry is "Parse" or "TryParse" or "IParsable" or "ISpanParsable" ? "ToString" : entry, vo); - if (w.NotApplicable) r.Note(NoteKeys.EntryNotApplicable, entry); - else if (w.Ex is not null) return $"{entry}: writing text threw {w.Ex.GetType().Name}: {w.Ex.Message}"; - else + var invariant = TextOps.PlainText(e, plain); + var w0 = TextOps.Write(e, "ToString", vo); + fails.Add(r.Check("invariant-whatever-the-culture", Baseline.Twin, c => { - var text = w.Text!; - var wantText = entry is "TryFormat-short-span" or "type-converter" ? (plain is IFormattable pf ? pf.ToString(null, CultureInfo.InvariantCulture) : plain.ToString()!) : TextOps.PlainText(e, plain); - if (e.Kind != "closed-set" && entry is "ToString-format" or "TryFormat-short-span" or "type-converter" && text != wantText) return $"{entry}: text '{text}' differs from the wrapped type's invariant text '{wantText}' under culture {r["text.culture"]}"; - if (entry == "TryFormat-short-span") + if (w0.Ex is not null) return $"ToString(): threw {w0.Ex.GetType().Name}: {w0.Ex.Message}"; + if (w0.Text != invariant) return $"ToString(): text '{w0.Text}' differs from the wrapped type's invariant text '{invariant}' under culture {c["text.culture"]}"; + c.Note(NoteKeys.Compared, $"ToString() under {c["text.culture"]}"); + return null; + })); + var writes = entry is "ToString-format" or "TryFormat-short-span" or "type-converter"; + var w = writes ? TextOps.Write(e, entry, vo) : w0; + if (writes) + fails.Add(r.Check("the-type-converter-writes-the-same", Baseline.Twin, c => { - var args = new object?[] { vo, 1, null, null }; - try + if (w.NotApplicable) { - typeof(TextOps).GetMethod(nameof(TextOps.FS))!.MakeGenericMethod(e.Vo).Invoke(null, args); - if (text.Length > 1 && (bool)args[2]!) return $"TryFormat into a 1-char span said it fit text of {text.Length} chars"; - if (text.Length > 1 && (int)args[3]! != 0) return "TryFormat wrote chars into a too-short span and still returned false"; + if (Missing(e, entry) is not null) return NotGenerated(c, e, entry); + // TryFormat is generated where the wrapped type has it (ValueObjectFormattableAspect); the wrapped type has none either + if (entry == "TryFormat-short-span" && !typeof(ISpanFormattable).IsAssignableFrom(Make.Held(e.Plain))) + return c.Inconclusive($"no TryFormat: the wrapped {Make.Held(e.Plain).Name} has no ISpanFormattable either"); + return $"{entry}: the value object writes no text where the wrapped {Make.Held(e.Plain).Name} does"; } - catch (TargetInvocationException t) { return $"TryFormat into a short span threw {t.InnerException!.GetType().Name}"; } - } - var readEntry = entry is "ToString-format" or "TryFormat-short-span" ? "Parse" : entry; - if (!(entry == "type-converter" && TextOps.Lossy(e))) // not-promised dates-and-times-lose-detail-as-text - { - var back = TextOps.Read(e, readEntry, text); - if (!back.NotApplicable) + if (w.Ex is not null) return $"{entry}: writing text threw {w.Ex.GetType().Name}: {w.Ex.Message}"; + var text = w.Text!; + var wantText = entry is "TryFormat-short-span" or "type-converter" ? (plain is IFormattable pf ? pf.ToString(null, CultureInfo.InvariantCulture) : plain.ToString()!) : invariant; + if (text != wantText) return $"{entry}: text '{text}' differs from the wrapped type's invariant text '{wantText}' under culture {c["text.culture"]}"; + if (entry == "TryFormat-short-span") { - if (back.Ex is not null) return $"{readEntry}: reading back '{text}' threw {back.Ex.GetType().Name}: {back.Ex.Message}"; - if (!back.Ok) return $"{readEntry}: could not read back its own text '{text}'"; - if (!Same(e, back.Value, vo)) + var args = new object?[] { vo, 1, null, null }; + try { - // Differential: the value object only has to round-trip where its wrapped type does - // (TimeOnly.ToString() under the invariant culture prints HH:mm and drops the seconds). - if (!WrappedLosesItToo(e, plain)) return $"{readEntry}: '{text}' read back as {back.Value}, expected {vo}"; - r.Note(NoteKeys.TextWrappedRoundTripLossyToo); + typeof(TextOps).GetMethod(nameof(TextOps.FS))!.MakeGenericMethod(e.Vo).Invoke(null, args); + if (text.Length > 1 && (bool)args[2]!) return $"TryFormat into a 1-char span said it fit text of {text.Length} chars"; + if (text.Length > 1 && (int)args[3]! != 0) return "TryFormat wrote chars into a too-short span and still returned false"; } + catch (TargetInvocationException t) { return $"TryFormat into a short span threw {t.InnerException!.GetType().Name}"; } } + c.Note(NoteKeys.Compared, $"{entry} under {c["text.culture"]}"); + return null; + })); + fails.Add(r.Check("text-reads-back", Baseline.RoundTrip, c => + { + var readEntry = entry is "ToString-format" or "TryFormat-short-span" ? "Parse" : entry; + if (Missing(e, entry) is not null) return NotGenerated(c, e, entry); + if (Missing(e, readEntry) is not null) return NotGenerated(c, e, readEntry); + if (w.NotApplicable || w.Ex is not null || w.Text is null) return c.Inconclusive($"{entry}: no text written to read back, judged by the-type-converter-writes-the-same"); + // claims.json declares it: not-promised dates-and-times-lose-detail-as-text + if (entry == "type-converter" && TextOps.Lossy(e)) return c.Inapplicable(Cite.NotPromised("dates-and-times-lose-detail-as-text"), entry); + var text = w.Text; + var back = TextOps.Read(e, readEntry, text); + if (back.NotApplicable) return $"{readEntry}: the value object has no {readEntry} to read its own text '{text}' back"; + if (back.Ex is not null) return $"{readEntry}: reading back '{text}' threw {back.Ex.GetType().Name}: {back.Ex.Message}"; + if (!back.Ok) return $"{readEntry}: could not read back its own text '{text}'"; + if (!Same(e, back.Value, vo)) + { + // Differential: the value object only has to round-trip where its wrapped type does + // (TimeOnly.ToString() under the invariant culture prints HH:mm and drops the seconds). + if (!WrappedLosesItToo(e, plain)) return $"{readEntry}: '{text}' read back as {back.Value}, expected {vo}"; + if (TextOps.Lossy(e)) return c.Inapplicable(Cite.NotPromised("dates-and-times-lose-detail-as-text"), $"{readEntry}: the wrapped type's own text does not read back either"); + return c.Inapplicable(Cite.Docs("src/CodoMetis.TypeKit.Generators/README.md#parsing-and-formatting"), $"{readEntry}: the wrapped type's own text does not read back either"); // "wherever the underlying type's own does" } - } + c.Note(NoteKeys.Compared, $"{readEntry} read back '{text}'"); + return null; + })); } - return JsonRoundTrip(r, e); + fails.AddRange(JsonRoundTrip(r, e)); + return fails.FirstOrDefault(f => f is not null); } - static string? JsonRoundTrip(Row r, Entry e) + /// + /// L04's JSON half, json-refusal-is-a-json-exception: the value object's own document reads back as written (the round trip the + /// refusal is measured from), and the row's changed input is read or refused with a JsonException, at the root naming the value object + /// and carrying no inner exception ("Input it can't read throws a JsonException naming the value object and the underlying type"). + /// + static List JsonRoundTrip(Row r, Entry e) { + const string Obligation = "json-refusal-is-a-json-exception"; var o = JsonRow.Apply(r, e).Options!; var pos = r["shape.position"]; var con = r.Get("shape.containing") ?? "class"; var plain = e.Valid(); var vo = Make.Vo(e, plain); var hv = Pos.Build(pos, con, e, true, vo); var hp = Pos.Build(pos, con, e, false, plain); - string jv; - try { jv = JsonSerializer.Serialize(hv.Value, hv.Declared, o); } - catch (Exception ex) - { - try { JsonSerializer.Serialize(hp.Value, hp.Declared, o); } catch { r.Note(NoteKeys.SerBothThrew); return null; } - if (r.Is("json.MaxDepth", "3") && ex.Message.Contains("maximum allowed depth")) return null; - if (r.Is("json.ReferenceHandler", "Preserve") && e.Wrapped == "own-record-class" && ex is NotSupportedException) return null; // documented - return $"serializing threw {ex.GetType().Name} where the wrapped type did not: {ex.Message}"; - } - string? Back(string text, out Exception? ex) + string? jv = null; Exception? wx = null; + try { jv = JsonSerializer.Serialize(hv.Value, hv.Declared, o); } catch (Exception ex) { wx = ex; } + string? Back(Held h, string text, out Exception? ex) { ex = null; - try { var v = JsonSerializer.Deserialize(text, hv.Declared, o); return v is null ? "null" : JsonSerializer.Serialize(v, hv.Declared, o); } catch (Exception x) { ex = x; return null; } + try { var v = JsonSerializer.Deserialize(text, h.Declared, o); return v is null ? "null" : JsonSerializer.Serialize(v, h.Declared, o); } catch (Exception x) { ex = x; return null; } } - var b = Back(jv, out var ex1); - if (ex1 is not null) + bool Guarded(Exception x, Check c) => c.Is("json.ReferenceHandler", "Preserve") && e.Wrapped == "own-record-class" && x is NotSupportedException; + var fails = new List(); + fails.Add(r.Check(Obligation, Baseline.RoundTrip, c => { - if (JsonParity.ReadMayDiffer(r, e, "valid", ex1) || r.Get("json.UnmappedMemberHandling") == "Disallow" || (r.Is("json.MaxDepth", "3") && ex1.Message.Contains("maximum allowed depth")) || r.Is("json.ReferenceHandler", "Preserve")) { r.Note(NoteKeys.ReadBackRefused, ex1.GetType().Name); } - else if (PlainFailsToo(r, hp, o, pos, e)) r.Note(NoteKeys.PlainReadBackFailsToo); - else return $"own JSON {Cut(jv)} did not read back: {ex1.GetType().Name}: {Cut(ex1.Message)}"; - } - else if (b != jv && !r.Is("json.ReferenceHandler", "Preserve")) return $"JSON round trip differs: wrote {Cut(jv)}, read and wrote {Cut(b)}"; + if (wx is not null) + { + try { JsonSerializer.Serialize(hp.Value, hp.Declared, o); } + catch (Exception px) { return c.Inconclusive("write: the options refuse the shape for the wrapped type too: " + px.GetType().Name); } + // Text-matching site, kept in this step (WS2 step 4): the depth refusal is told by the message's words. Baseline gap: no twin in + // the row shows System.Text.Json charging a converter a level, and no record or fact cites it (the three-sided differential). + if (c.Is("json.MaxDepth", "3") && wx.Message.Contains("maximum allowed depth")) { c.Note(NoteKeys.StjMaxdepth, "write"); return null; } + if (Guarded(wx, c)) return c.Refused(Cite.Docs("src/CodoMetis.TypeKit.Generators/README.md#json"), "write"); + return $"serializing threw {wx.GetType().Name} where the wrapped type did not: {wx.Message}"; + } + var b = Back(hv, jv!, out var ex1); + if (ex1 is not null) + { + if (Guarded(ex1, c)) return c.Refused(Cite.Docs("src/CodoMetis.TypeKit.Generators/README.md#json"), "read back"); + // Text-matching site, kept in this step (WS2 step 4), as above: the depth refusal told by its words, no twin showing it. + if (c.Is("json.MaxDepth", "3") && ex1.Message.Contains("maximum allowed depth")) { c.Note(NoteKeys.StjMaxdepth, "read back"); return null; } + // the wrapped twin does not read its own document back under these options either: nothing to compare + if (PlainFails(hp, o) is { } px) { return c.Inconclusive($"read back: the wrapped type does not read its own document back under these options either: {px.GetType().Name}: {Cut(px.Message)}"); } + return $"own JSON {Cut(jv)} did not read back: {ex1.GetType().Name}: {Cut(ex1.Message)}"; + } + if (b != jv) + { + // under Preserve the serializer numbers references per call: the twin's document read back and written again, alike + if (c.Is("json.ReferenceHandler", "Preserve") && TwinRoundTrip(hp, o) is { } tw && tw.Back != tw.Json) + return c.Inconclusive($"JSON round trip: written again it differs, for the wrapped type alike ({Cut(tw.Json)} / {Cut(tw.Back)})"); + return $"JSON round trip differs: wrote {Cut(jv)}, read and wrote {Cut(b)}"; + } + c.Note(NoteKeys.Compared, "JSON round trip"); + return null; + })); // a changed input must be read or refused with a JsonException (never another type) var input = r["input.json"]; - if (input != "valid") - { - var text = JsonParity.InputFor(input, e, o, jv, pos); - Back(text, out var ex2); - // The application's own converter is in charge under own-for-value-object: what it throws is its own. - if (ex2 is not null && r.Is("json.Converters", "own-for-value-object")) r.Note(NoteKeys.OwnVoConverterThrew, ex2.GetType().Name); - else if (ex2 is not null && ex2 is not (JsonException or NotSupportedException or InvalidOperationException { })) - return $"input {input}: {ex2.GetType().Name} instead of JsonException: {Cut(ex2.Message)}" - + (ex2.InnerException is { } inner ? $" / inner {inner.GetType().FullName}: {Cut(inner.Message)} @ {Cut(inner.StackTrace?.Split('\n').FirstOrDefault()?.Trim() ?? "")}" : ""); - if (!r.Is("json.Converters", "own-for-value-object") && RootRefusal(e, o, input) is { } refusal) return refusal; - } - return null; + if (input != "valid" && jv is not null) + fails.Add(r.Check(Obligation, Baseline.Refusal, c => + { + // "Your own converter wins": the application's converter reads the value object, so what it throws is its own. It shares the + // obligation with the round trip above, which runs under it, so it is decided here, not declared in claims.json. + if (c.Is("json.Converters", "own-for-value-object")) return c.Inapplicable(Cite.Docs("src/CodoMetis.TypeKit.AspNetCore/README.md#what-the-document-says"), "input " + input); + var text = JsonParity.InputFor(input, e, o, jv, pos); + if (text == jv) return c.Inconclusive($"input {input}: no place for it in {Cut(jv)}"); + Back(hv, text, out var ex2); + if (ex2 is not null and not JsonException) + { + if (Guarded(ex2, c)) return c.Refused(Cite.Docs("src/CodoMetis.TypeKit.Generators/README.md#json"), "input " + input); + // the serializer's own refusal of the document, for the wrapped twin alike + Back(hp, text, out var px); + if (px is not null && px.GetType() == ex2.GetType()) return c.Inconclusive($"input {input}: {ex2.GetType().Name} for the wrapped type alike: {Cut(px.Message)}"); + return $"input {input}: {ex2.GetType().Name} instead of JsonException: {Cut(ex2.Message)}" + + (ex2.InnerException is { } inner ? $" / inner {inner.GetType().FullName}: {Cut(inner.Message)} @ {Cut(inner.StackTrace?.Split('\n').FirstOrDefault()?.Trim() ?? "")}" : ""); + } + if (RootRefusal(e, o, input) is { } refusal) return refusal; + c.Note(NoteKeys.Compared, $"input {input}: {(ex2 is null ? "read" : "a JsonException")}"); + return null; + })); + return fails; } /// @@ -252,114 +353,204 @@ static bool WrappedLosesItToo(Entry e, object plain) } catch (Exception) { return null; } // another exception type is judged above, at the row's position } - static bool PlainFailsToo(Row r, Held hp, JsonSerializerOptions o, string pos, Entry e) + /// The wrapped twin's refusal to read back its own document under the options, or null where it reads it back. + static Exception? PlainFails(Held hp, JsonSerializerOptions o) + { + try { var jp = JsonSerializer.Serialize(hp.Value, hp.Declared, o); JsonSerializer.Deserialize(jp, hp.Declared, o); return null; } catch (Exception x) { return x; } + } + /// The wrapped twin's document and that document read back and written again, or null where either throws. + static (string Json, string Back)? TwinRoundTrip(Held hp, JsonSerializerOptions o) { - try { var jp = JsonSerializer.Serialize(hp.Value, hp.Declared, o); JsonSerializer.Deserialize(jp, hp.Declared, o); return false; } catch { return true; } + try { var jp = JsonSerializer.Serialize(hp.Value, hp.Declared, o); var v = JsonSerializer.Deserialize(jp, hp.Declared, o); return (jp, v is null ? "null" : JsonSerializer.Serialize(v, hp.Declared, o)); } + catch { return null; } } static bool PlainHasInner(string text, Held hp, JsonSerializerOptions o) { try { JsonSerializer.Deserialize(text, hp.Declared, o); return false; } catch (Exception x) { return x.InnerException is not null; } } // STJ's own inner (the container, a dictionary value) is not the value object's internal static string Cut(string? s) => s is null ? "(null)" : s.Length > 160 ? s[..160] + "..." : s; // ---- L02: a rejection never repeats the input - static string? Quotes(Exception ex, string needle, string how, bool stripPath) - { - string Msg(Exception x) => stripPath && x.Message.IndexOf(" Path:", StringComparison.Ordinal) is var i and >= 0 ? x.Message[..i] : x.Message; - if (needle.Length > 0 && Msg(ex).Contains(needle)) return $"{how}: {ex.GetType().Name} quotes the input: {Cut(ex.Message)}"; - if (ex.InnerException is { } inner && needle.Length > 0 && inner.Message.Contains(needle)) return $"{how}: inner {inner.GetType().Name} quotes the input"; - return null; - } + // Text-matching site, kept in this step (WS2 step 4): System.Text.Json's Path, which holds a rejected dictionary key (not-promised + // a-rejected-dictionary-key-in-the-path), is cut off by its words. Baseline gap: no twin in the row shows the wrapped type's key in the Path too. + static string StripPath(Exception x) => x.Message.IndexOf(" Path:", StringComparison.Ordinal) is var i and >= 0 ? x.Message[..i] : x.Message; + /// + /// L02's checks: a parsing entry's refusal of a secret, or of the text Create refuses, neither quotes it (parsing-never-repeats, a + /// FormatException where it is not the type converter's) nor carries an inner exception that does (no-inner-that-quotes); and a JSON + /// read of the secret, or of the row's changed input, where the wrapped value stands in the document refuses without quoting it, but + /// for the dictionary key System.Text.Json puts in the Path (json-never-repeats). Every check runs; returns the first failure. + /// public static string? NoQuote(Row r, Entry e) { TextOps.Prepare(r, e); var entry = r["text.entry"]; var plain = e.Valid(); var texts = new List { TextOps.Secret }; if (TextOps.ValidatedKind(e) && e.Refused() is { } rf) texts.Add(TextOps.PlainText(e, rf)); - if (TextOps.HasText(e) && entry is "Parse" or "TryParse" or "IParsable" or "ISpanParsable" or "type-converter") + var fails = new List(); + if (Reads(entry)) { - foreach (var t in texts) + var outs = texts.Select(t => (Text: t, Out: TextOps.Read(e, entry, t))).ToList(); + string? Absent(Check c) => Missing(e, entry) is not null ? NotGenerated(c, e, entry) : $"{entry}: the value object does not read text through it"; + fails.Add(r.Check("parsing-never-repeats", Baseline.Refusal, c => { - var o = TextOps.Read(e, entry, t); - if (o.NotApplicable) { r.Note(NoteKeys.EntryNotApplicable, entry); continue; } - if (o.Ex is not null) + if (outs.Any(x => x.Out.NotApplicable)) return Absent(c); + var refused = 0; + foreach (var (t, o) in outs) { + if (o.Ex is null) { if (!o.Ok) refused++; continue; } // TryParse answers false: a refusal with nothing to quote + refused++; if (entry == "TryParse") return $"TryParse threw {o.Ex.GetType().Name} on '{t}': {Cut(o.Ex.Message)}"; - if (Quotes(o.Ex, t, entry, false) is { } q) return q; - if (o.Ex.InnerException is not null && !(entry == "type-converter")) return $"{entry}: refusal of '{t}' carries an inner {o.Ex.InnerException.GetType().Name}"; + if (o.Ex.Message.Contains(t)) return $"{entry}: {o.Ex.GetType().Name} quotes the input: {Cut(o.Ex.Message)}"; if (entry != "type-converter" && o.Ex is not FormatException) return $"{entry}: refusal of '{t}' is {o.Ex.GetType().Name}, not FormatException: {Cut(o.Ex.Message)}"; - if (entry == "type-converter" && o.Ex.InnerException is not null) r.Note(NoteKeys.ConverterWrapsInner); } - } - } - else if (entry == "TryFrom" && TextOps.ValidatedKind(e) && e.Refused() is { } rf2) - { - var o = TextOps.TryFrom(e, rf2); if (o.Ex is not null) return $"TryFrom(refused) threw {o.Ex.GetType().Name}"; if (o.Ok) return "TryFrom accepted the refused value"; + if (refused == 0) return c.Inconclusive($"{entry}: every text read, none refused: nothing to quote"); + c.Note(NoteKeys.Compared, $"{entry}: {refused} refused without the text"); + return null; + })); + fails.Add(r.Check("no-inner-that-quotes", Baseline.Refusal, c => + { + if (outs.Any(x => x.Out.NotApplicable)) return Absent(c); + var thrown = 0; + foreach (var (t, o) in outs) + { + if (o.Ex is null) continue; + thrown++; + if (o.Ex.InnerException is { } inner && inner.Message.Contains(t)) return $"{entry}: inner {inner.GetType().Name} quotes the input"; + if (o.Ex.InnerException is not null && entry != "type-converter") return $"{entry}: refusal of '{t}' carries an inner {o.Ex.InnerException.GetType().Name}"; + if (entry == "type-converter" && o.Ex.InnerException is not null) c.Note(NoteKeys.ConverterWrapsInner); + } + if (thrown == 0) return c.Inconclusive($"{entry}: no text refused with an exception: no inner to look at"); + c.Note(NoteKeys.Compared, $"{entry}: {thrown} refusals without a quoting inner"); + return null; + })); } // JSON half var opts = JsonRow.Apply(r, e).Options!; var pos = r["shape.position"]; var con = r.Get("shape.containing") ?? "class"; var hp = Pos.Build(pos, con, e, false, plain); var hv = Pos.Build(pos, con, e, true, Make.Vo(e, plain)); - string jp; string sample; - try { jp = JsonSerializer.Serialize(hv.Value, hv.Declared, opts); sample = JsonSerializer.Serialize(Make.Unwrap(e, Make.Vo(e, plain)), e.Plain, opts); } catch { r.Note(NoteKeys.SerRefused); return null; } - if (r.Get("json.Converters") is "own-for-wrapped" or "own-for-value-object") return null; // the application's own converter owns the message (not-promised the-applications-own-converter) - var at = jp.IndexOf(sample, StringComparison.Ordinal); - var variants = new List<(string Text, string Needle)>(); - if (at >= 0) variants.Add((jp[..at] + "\"" + TextOps.Secret + "\"" + jp[(at + sample.Length)..], TextOps.Secret)); var input = r["input.json"]; - if (input != "valid" && at >= 0) - { - var text = JsonParity.InputFor(input, e, opts, jp, pos); - var x = text.Length - jp.Length + sample.Length; - if (x > 0 && text != jp) { var lit = text.Substring(at, x).Trim('"'); if (lit.Length >= 6 && lit.Length < 60 && lit.All(c => !char.IsPunctuation(c) || c is '-' or ':' or '.')) variants.Add((text, lit)); } - } - foreach (var (text, needle) in variants) + fails.Add(r.Check("json-never-repeats", Baseline.Refusal, c => { - try { JsonSerializer.Deserialize(text, hv.Declared, opts); } - catch (Exception ex) + // the application's own converter owns the message; claims.json declares it + if (c.Get("json.Converters") is "own-for-wrapped" or "own-for-value-object") return c.Inapplicable(Cite.NotPromised("the-applications-own-converter")); + string? doc = null, sample; + try { doc = JsonSerializer.Serialize(hv.Value, hv.Declared, opts); } + catch (Exception vx) { - if (ex is JsonException je && Quotes(je, needle, "JSON read", true) is { } q) // the Path is System.Text.Json's (not-promised a-rejected-dictionary-key-in-the-path) - return q + (pos == "dictionary-key" ? " [dictionary-key]" : ""); - if (ex.InnerException is not null && ex is JsonException && !(ex.InnerException is JsonException) && !PlainHasInner(text, hp, opts)) return $"JSON read: refusal carries inner {ex.InnerException.GetType().Name}: {Cut(ex.InnerException.Message)}"; + // the value object's document is the wrapped type's (L01): where it cannot be written, the twin's places the input + try { doc = JsonSerializer.Serialize(hp.Value, hp.Declared, opts); } + catch (Exception px) { return c.Inconclusive($"the options refuse the shape, for the wrapped type too: {vx.GetType().Name} / {px.GetType().Name}"); } } - } - return null; + try { sample = JsonSerializer.Serialize(Make.Unwrap(e, Make.Vo(e, plain)), e.Plain, opts); } + catch (Exception sx) { return c.Inconclusive("the wrapped value alone is not written under these options: " + sx.GetType().Name); } + var at = doc.IndexOf(sample, StringComparison.Ordinal); + var variants = new List<(string Text, string Needle)>(); + if (at >= 0) variants.Add((doc[..at] + "\"" + TextOps.Secret + "\"" + doc[(at + sample.Length)..], TextOps.Secret)); + else if (pos == "dictionary-key" && Probes.Json.Jx.Tokens(doc).FirstOrDefault(t => t.Type == JsonTokenType.PropertyName && t.Text == sample.Trim('"')) is { } key) + // a key is a property name, the wrapped value's text without its quotes: the secret goes there + variants.Add((Probes.Json.Jx.Rewrite(doc, [key], _ => "\"" + TextOps.Secret + "\""), TextOps.Secret)); + if (input != "valid" && at >= 0) + { + var text = JsonParity.InputFor(input, e, opts, doc, pos); + var x = text.Length - doc.Length + sample.Length; + if (x > 0 && text != doc) { var lit = text.Substring(at, x).Trim('"'); if (lit.Length >= 6 && lit.Length < 60 && lit.All(ch => !char.IsPunctuation(ch) || ch is '-' or ':' or '.')) variants.Add((text, lit)); } + } + if (variants.Count == 0) return c.Inconclusive($"the wrapped value has no place in {Cut(doc)}: the options leave it out"); + var rejected = 0; + foreach (var (text, needle) in variants) + { + try { JsonSerializer.Deserialize(text, hv.Declared, opts); } + catch (Exception ex) + { + rejected++; + // the Path is System.Text.Json's (not-promised a-rejected-dictionary-key-in-the-path): a JsonException's message is read without it + var msg = ex is JsonException ? StripPath(ex) : ex.Message; + if (msg.Contains(needle)) return $"JSON read: {ex.GetType().Name} quotes the input: {Cut(ex.Message)}" + (pos == "dictionary-key" ? " [dictionary-key]" : ""); + if (ex.InnerException is { } inner && inner.Message.Contains(needle)) return $"JSON read: inner {inner.GetType().Name} quotes the input"; + if (ex.InnerException is not null && ex is JsonException && !(ex.InnerException is JsonException) && !PlainHasInner(text, hp, opts)) return $"JSON read: refusal carries inner {ex.InnerException.GetType().Name}: {Cut(ex.InnerException.Message)}"; + } + } + if (rejected == 0) return c.Inconclusive("every JSON input read, none refused: nothing to quote"); + c.Note(NoteKeys.Compared, $"JSON read: {rejected} refused without the input"); + return null; + })); + return fails.FirstOrDefault(f => f is not null); } // ---- L03: one set of rules: every entry runs Create and reports its fault + /// + /// L03's checks: TryFrom (tryfrom-runs-create) and the parsing entries (parsing-runs-create) refuse the value Create refuses and accept + /// a valid one, the JSON converter reads back what it writes and refuses what Create refuses (json-runs-create), and every refusal, + /// FromKnownGood's, the FormatException and the JsonException, names the fault (refusals-name-the-fault). A value object without Create + /// is held to accepting the valid value. Every check runs; returns the first failure. + /// public static string? OneRule(Row r, Entry e) { TextOps.Prepare(r, e); var entry = r["text.entry"]; var plain = e.Valid(); - if (!TextOps.ValidatedKind(e) || e.Refused() is not { } refused) + var fails = new List(); + var validated = TextOps.ValidatedKind(e) && e.Refused() is not null; + if (!validated) { // no Create: the valid value passes every entry - if (entry == "TryFrom") { var t = TextOps.TryFrom(e, plain); if (!t.NotApplicable && (!t.Ok || t.Ex is not null)) return "TryFrom(valid) refused on a value object with no rule"; } - else if (TextOps.HasText(e) && entry is "Parse" or "TryParse" or "IParsable" or "ISpanParsable" or "type-converter") - { var o = TextOps.Read(e, entry, TextOps.PlainText(e, plain)); if (!o.NotApplicable && (o.Ex is not null || !o.Ok)) return $"{entry}: valid text refused: {o.Ex?.GetType().Name} {Cut(o.Ex?.Message)}"; } - return JsonOneRule(r, e, false); + if (entry == "TryFrom") + fails.Add(r.Check("tryfrom-runs-create", Baseline.RoundTrip, c => + { + var t = TextOps.TryFrom(e, plain); + if (t.NotApplicable) return Missing(e, entry) is not null ? NotGenerated(c, e, entry) : "TryFrom is not generated for the value object"; + if (!t.Ok || t.Ex is not null) return "TryFrom(valid) refused on a value object with no rule"; + c.Note(NoteKeys.Compared, "TryFrom(valid), no rule"); return null; + })); + else if (Reads(entry)) + fails.Add(r.Check("parsing-runs-create", Baseline.RoundTrip, c => + { + if (Missing(e, entry) is not null) return NotGenerated(c, e, entry); + var o = TextOps.Read(e, entry, TextOps.PlainText(e, plain)); + if (o.NotApplicable) return $"{entry}: the value object does not read text through it"; + if (o.Ex is not null || !o.Ok) return $"{entry}: valid text refused: {o.Ex?.GetType().Name} {Cut(o.Ex?.Message)}"; + c.Note(NoteKeys.Compared, $"{entry}(valid), no rule"); return null; + })); + fails.AddRange(JsonOneRule(r, e, false)); + return fails.FirstOrDefault(f => f is not null); } - var rtext = TextOps.PlainText(e, refused); + var refusedValue = e.Refused()!; + var rtext = TextOps.PlainText(e, refusedValue); if (entry == "TryFrom") { - var t = TextOps.TryFrom(e, refused); if (t.Ex is not null) return "TryFrom threw " + t.Ex.GetType().Name; if (t.Ok) return "TryFrom accepted the value Create refuses"; - var kg = Unwrap(() => e.Vo.GetMethod("FromKnownGood")!.Invoke(null, [refused, null])); - if (kg is not { } kex) return "FromKnownGood accepted the value Create refuses"; if (!kex.Message.Contains("Refused")) return "FromKnownGood does not name the fault: " + Cut(kex.Message); + fails.Add(r.Check("tryfrom-runs-create", Baseline.Refusal, c => + { + var t = TextOps.TryFrom(e, refusedValue); if (t.Ex is not null) return "TryFrom threw " + t.Ex.GetType().Name; if (t.Ok) return "TryFrom accepted the value Create refuses"; + c.Note(NoteKeys.Compared, "TryFrom refused"); return null; + })); + fails.Add(r.Check("refusals-name-the-fault", Baseline.Refusal, c => + { + var kg = Unwrap(() => e.Vo.GetMethod("FromKnownGood")!.Invoke(null, [refusedValue, null])); + if (kg is not { } kex) return "FromKnownGood accepted the value Create refuses"; if (!kex.Message.Contains("Refused")) return "FromKnownGood does not name the fault: " + Cut(kex.Message); + c.Note(NoteKeys.Compared, "FromKnownGood names the fault"); return null; + })); } - else if (TextOps.HasText(e) && entry is "Parse" or "TryParse" or "IParsable" or "ISpanParsable" or "type-converter") + else if (Reads(entry)) { - var o = TextOps.Read(e, entry, rtext); - if (!o.NotApplicable) + var o = Missing(e, entry) is null ? TextOps.Read(e, entry, rtext) : Out.NA; + fails.Add(r.Check("parsing-runs-create", Baseline.Refusal, c => { + if (Missing(e, entry) is not null) return NotGenerated(c, e, entry); + if (o.NotApplicable) return $"{entry}: the value object does not read text through it"; if (entry == "TryParse") { if (o.Ex is not null) return "TryParse threw " + o.Ex.GetType().Name; if (o.Ok) return "TryParse accepted text Create refuses"; } - else + else if (o.Ex is null) return $"{entry} accepted '{rtext}' that Create refuses"; + c.Note(NoteKeys.Compared, $"{entry} refused"); return null; + })); + if (entry != "TryParse") + fails.Add(r.Check("refusals-name-the-fault", Baseline.Refusal, c => { - if (o.Ex is null) return $"{entry} accepted '{rtext}' that Create refuses"; + if (Missing(e, entry) is not null) return NotGenerated(c, e, entry); + if (o.NotApplicable || o.Ex is null) return c.Inconclusive($"{entry}: no refusal to name the fault, judged by parsing-runs-create"); if (!o.Ex.Message.Contains("Refused")) return $"{entry}: refusal does not name the fault ({o.Ex.GetType().Name}): {Cut(o.Ex.Message)}"; if (entry != "type-converter" && o.Ex is not FormatException) return $"{entry}: refusal is {o.Ex.GetType().Name}, not FormatException"; - } - } + c.Note(NoteKeys.Compared, $"{entry}: the {o.Ex.GetType().Name} names the fault"); return null; + })); } - return JsonOneRule(r, e, true); + fails.AddRange(JsonOneRule(r, e, true)); + return fails.FirstOrDefault(f => f is not null); } static Exception? Unwrap(Func f) { try { f(); return null; } catch (TargetInvocationException t) { return t.InnerException; } catch (Exception ex) { return ex; } } @@ -371,8 +562,9 @@ static bool PlainHasInner(string text, Held hp, JsonSerializerOptions o) /// validation"). A depth limit is the framework's only where a converter the application registers for the wrapped type, at the /// same position, is refused alike (design note D1: System.Text.Json counts a custom converter's value one level deeper). /// - static string? JsonOneRule(Row r, Entry e, bool validated) + static List JsonOneRule(Row r, Entry e, bool validated) { + const string Obligation = "json-runs-create"; var o = JsonRow.Apply(r, e).Options!; var pos = r["shape.position"]; var con = r.Get("shape.containing") ?? "class"; var plain = e.Valid(); var hp = Pos.Build(pos, con, e, false, plain); var hv = Pos.Build(pos, con, e, true, Make.Vo(e, plain)); @@ -386,49 +578,82 @@ bool OwnConverterRefusedToo(Exception voEx) var ex = wex ?? Read(text!, hp, own).Ex; return ex is not null && ex.GetType() == voEx.GetType(); } - var (jp, xp) = Write(hp, o); var (jv, xv) = Write(hv, o); // Where the wrapped type alone refuses under the row's options, checked in the same row with the value object refusing alike, // the JSON half has nothing to compare and the row is judged by its text entries (as decision 99 judges a document ASP.NET alone refuses). - if (xp is not null && xv is not null) { r.Note(NoteKeys.JsonHalfWrappedAloneRefuses, "write"); return null; } - if (xp is not null) { r.Note(NoteKeys.PlainRefused, "the wrapped type is not written under these options"); return null; } - if (xv is not null) - { - if (OwnConverterRefusedToo(xv)) { r.Note(NoteKeys.JsonHalfWrappedAloneRefuses, "write, with a converter of the application's for the wrapped type"); return null; } - r.Note(NoteKeys.SerRefused, "the value object is not written where the wrapped type is: JSON parity (L01) judges it"); return null; - } + // A pass, as decision 99's document refused by ASP.NET alone is: the refusal is the framework's, shown by the twin in the same row. + string? Alone(Check c, string what) { c.Note(NoteKeys.JsonHalfWrappedAloneRefuses, what); return null; } + bool Guarded(Exception x) => r.Is("json.ReferenceHandler", "Preserve") && e.Wrapped == "own-record-class" && x is NotSupportedException; + var (jp, xp) = Write(hp, o); var (jv, xv) = Write(hv, o); + var fails = new List(); + // Why the value object's own document has nothing to compare under the row's options, or null where it reads back: the refused + // value is then not told apart from a document the options do not let it read, and step 2 has nothing to check either. + string? nothing = null; // 1. the valid value reads back as written - var (back, xb) = Read(jv!, hv, o); - if (xb is not null) + fails.Add(r.Check(Obligation, Baseline.RoundTrip, c => { - if (OwnConverterRefusedToo(xb)) { r.Note(NoteKeys.JsonHalfWrappedAloneRefuses, "read back, with a converter of the application's for the wrapped type"); return null; } - if (Read(jp!, hp, o).Ex is not null) { r.Note(NoteKeys.JsonHalfWrappedAloneRefuses, "read back"); return null; } - return $"JSON: the value object does not read back what it writes ({xb.GetType().Name}: {Cut(xb.Message)}): {Cut(jv)}"; - } - var again = Write(new Held(hv.Declared, back!), o).Json; - if (again != jv) return $"JSON: read back as {Cut(again)}, written as {Cut(jv)}"; - if (r.Is("json.Converters", "own-for-value-object")) { r.Note(NoteKeys.OwnVo, "the application's converter takes precedence and reads its own spelling"); return null; } - if (!validated) return null; - // 2. the value Create refuses - var text = JsonParity.InputFor("refused-by-create", e, o, jp!, pos); - if (text == jp) { r.Note(NoteKeys.RefusedInputNotPlaceable, pos); return null; } + string? None(string why) { nothing = why; return Alone(c, why); } + if (xp is not null && xv is not null) return None("write"); + if (xp is not null) { nothing = "the wrapped type is not written"; return c.Inconclusive("JSON write: the wrapped type is not written under these options, the value object is: JSON parity (L01) judges it"); } + if (xv is not null) + { + if (Guarded(xv)) { nothing = "the Preserve guard refuses the value object"; return c.Refused(Cite.Docs("src/CodoMetis.TypeKit.Generators/README.md#json"), "write"); } + if (OwnConverterRefusedToo(xv)) return None("write, with a converter of the application's for the wrapped type"); + return $"JSON: the value object is not written where the wrapped type is ({xv.GetType().Name}: {Cut(xv.Message)})"; + } + var (back, xb) = Read(jv!, hv, o); + if (xb is not null) + { + if (OwnConverterRefusedToo(xb)) return None("read back, with a converter of the application's for the wrapped type"); + if (Read(jp!, hp, o).Ex is not null) return None("read back"); + return $"JSON: the value object does not read back what it writes ({xb.GetType().Name}: {Cut(xb.Message)}): {Cut(jv)}"; + } + var again = Write(new Held(hv.Declared, back!), o).Json; + if (again != jv) return $"JSON: read back as {Cut(again)}, written as {Cut(jv)}"; + if (c.Is("json.Converters", "own-for-value-object")) c.Note(NoteKeys.OwnVo, "the application's converter takes precedence and reads its own spelling"); + else c.Note(NoteKeys.Compared, "JSON read back"); + return null; + })); + if (!validated || jp is null) return fails; + // 2. the value Create refuses, placed where the wrapped value stands in the wrapped type's document + var text = JsonParity.InputFor("refused-by-create", e, o, jp, pos); var (accepted, xr) = Read(text, hv, o); - if (Read(text, hp, o).Ex is not null) - { - if (xr is null) return $"JSON read accepted text the wrapped type refuses: {Cut(text)}"; - r.Note(NoteKeys.JsonHalfWrappedAloneRefuses, "the refused value's JSON"); return null; - } - if (r.Is("json.Converters", "StoredJsonConverterFactory")) + var twinReads = Read(text, hp, o).Ex is null; // the wrapped type reads the refused value's document: only then is a refusal Create's + var stored = r.Is("json.Converters", "StoredJsonConverterFactory"); + fails.Add(r.Check(Obligation, stored ? Baseline.Docs("README.md#what-typekit-guarantees") : Baseline.Refusal, c => { - if (xr is not null) return $"StoredJsonConverterFactory refused a value Create refuses ({xr.GetType().Name}): it reads stored JSON without validation"; - r.Note(NoteKeys.StoredFactoryReadUnvalidated); return null; // not-promised stored-json-skips-validation - } - if (xr is null) return $"JSON read accepted the value Create refuses: {Cut(text)}"; - if (xr is not JsonException) return $"JSON refusal is {xr.GetType().Name}, not JsonException: {Cut(xr.Message)}"; - if (!xr.Message.Contains("Refused")) - { - if (OwnConverterRefusedToo(xr)) { r.Note(NoteKeys.JsonHalfWrappedAloneRefuses, "refused read, with a converter of the application's for the wrapped type"); return null; } - return $"JsonException does not name the fault: {Cut(xr.Message)}"; - } - return null; + // "Your own converter wins": the generated converter does not run, so no Create; the round trip above runs under it, which + // shares the obligation, so it is decided here, not declared in claims.json + if (c.Is("json.Converters", "own-for-value-object")) return c.Inapplicable(Cite.Docs("src/CodoMetis.TypeKit.AspNetCore/README.md#what-the-document-says"), "the refused value"); + if (xr is not null && Guarded(xr)) return c.Refused(Cite.Docs("src/CodoMetis.TypeKit.Generators/README.md#json"), "the refused value"); + if (nothing is not null) return c.Inconclusive($"the refused value: the value object's own document has nothing to compare under these options ({nothing})"); + if (text == jp) return c.Inconclusive($"the refused value has no place in {Cut(jp)} at {pos}"); + if (!twinReads) + { + if (xr is null) return $"JSON read accepted text the wrapped type refuses: {Cut(text)}"; + return Alone(c, "the refused value's JSON"); + } + if (stored) + { + // "Only two paths skip validation, and both are named": StoredJsonConverterFactory reads the stored value without Create + if (xr is not null) return $"StoredJsonConverterFactory refused a value Create refuses ({xr.GetType().Name}): it reads stored JSON without validation"; + c.Note(NoteKeys.StoredFactoryReadUnvalidated); return null; // not-promised stored-json-skips-validation + } + if (xr is null) return $"JSON read accepted the value Create refuses: {Cut(text)}"; + if (xr is not JsonException) + { + if (Guarded(xr)) return c.Refused(Cite.Docs("src/CodoMetis.TypeKit.Generators/README.md#json"), "the refused value"); + return $"JSON refusal is {xr.GetType().Name}, not JsonException: {Cut(xr.Message)}"; + } + c.Note(NoteKeys.Compared, "the refused value refused"); + return null; + })); + if (xr is JsonException && twinReads && !stored && nothing is null && !r.Is("json.Converters", "own-for-value-object") && text != jp) + fails.Add(r.Check("refusals-name-the-fault", Baseline.Refusal, c => + { + if (xr.Message.Contains("Refused")) { c.Note(NoteKeys.Compared, "the JsonException names the fault"); return null; } + if (OwnConverterRefusedToo(xr)) return Alone(c, "refused read, with a converter of the application's for the wrapped type"); + return $"JsonException does not name the fault: {Cut(xr.Message)}\n text={Cut(text)} (the wrapped type reads it; its own document {Cut(jp)})"; + })); + return fails; } } diff --git a/tools/probe/corpus/probes/C02OptionalTextProbe.cs b/tools/probe/corpus/probes/C02OptionalTextProbe.cs index e1dcb8bf..30419505 100644 --- a/tools/probe/corpus/probes/C02OptionalTextProbe.cs +++ b/tools/probe/corpus/probes/C02OptionalTextProbe.cs @@ -42,24 +42,34 @@ public sealed class C02OptionalTextProbe } var optional = entry.StartsWith("AsOptional"); var text = entry.EndsWith("Text"); - var invariantMode = gi; + string got, want; + if (optional) { - string got; - if (optional) - { - var res = text ? s.AsOptionalText() : s.AsOptionalIdentifier(); - got = res.Match(o => o.Match(v => "Some:" + v, () => "None"), f => "Error:" + f); - var want = expected switch { "ok" => "Some:word", "Blank" => "None", _ => "Error:Hidden" }; - if (got != want) return $"{entry}({kind}) = {got}; expected {want}"; - } - else + var res = text ? s.AsOptionalText() : s.AsOptionalIdentifier(); + got = res.Match(o => o.Match(v => "Some:" + v, () => "None"), f => "Error:" + f); + want = expected switch { "ok" => "Some:word", "Blank" => "None", _ => "Error:Hidden" }; + } + else + { + var res = text ? s.AsText() : s.AsIdentifier(); + got = res.Match(v => "ok:" + v, f => "Error:" + f); + want = expected switch { "ok" => "ok:word", _ => "Error:" + expected }; + } + // The obligation the input selects: a hidden character is an error in both; a blank is an error for a required field and no value + // for an optional one, and a null is a blank too. A word is checked as the blank obligations' control: it is no blank, in either. + var obligations = new List { expected == "Hidden" ? "hidden-is-an-error-in-both" : optional ? "optional-blank-is-no-value" : "required-blank-is-an-error" }; + if (kind == "null") obligations.Add("null-is-blank"); + string? fail = null; + foreach (var obligation in obligations) + { + var f = r.Check(obligation, Baseline.Docs("docs/text.md#required-and-optional"), c => { - var res = text ? s.AsText() : s.AsIdentifier(); - got = res.Match(v => "ok:" + v, f => "Error:" + f); - var want = expected switch { "ok" => "ok:word", _ => "Error:" + expected }; if (got != want) return $"{entry}({kind}) = {got}; expected {want}"; - } + c.Note(NoteKeys.Compared, $"{entry}({kind}) = {got}"); + return null; + }); + fail ??= f; } - return null; + return fail; } } diff --git a/tools/probe/corpus/probes/C09BaseRecordEqualityProbe.cs b/tools/probe/corpus/probes/C09BaseRecordEqualityProbe.cs index 19a904b6..b1e977c2 100644 --- a/tools/probe/corpus/probes/C09BaseRecordEqualityProbe.cs +++ b/tools/probe/corpus/probes/C09BaseRecordEqualityProbe.cs @@ -58,24 +58,37 @@ public sealed class C09BaseRecordEqualityProbe if (!(bool)eq.Invoke(comparer, [a, same])! || (bool)eq.Invoke(comparer, [a, other])!) { r.Outcome("harness-error", "the samples do not compare as intended under the wrapped type's equality"); return null; } var va = c.Make(a); var vb = c.Make(same); var vc = c.Make(other); - var fails = new List(); - if (!va.Equals(vb)) fails.Add("Equals(object) is false for equal wrapped values"); - if (va.Equals(vc)) fails.Add("Equals(object) is true for different wrapped values"); - if (va.GetHashCode() != vb.GetHashCode()) fails.Add("equal value objects have different hash codes"); - var op = c.Vo.GetMethod("op_Equality", BindingFlags.Public | BindingFlags.Static, [c.Vo, c.Vo]); - if (op is null) fails.Add("no operator =="); - else if (!(bool)op.Invoke(null, [va, vb])!) fails.Add("== is false for equal wrapped values"); - - foreach (var t in c.Bases.Prepend(c.Vo)) + var baseline = Probes.Shapes.Baseline.Twin; // the wrapped type's EqualityComparer, which the samples were checked against + var f1 = r.Check("equal-by-the-wrapped-value", baseline, k => { - var label = t == c.Vo ? "the value object" : $"the base {t.Name}"; - var f = (string?)typeof(C09BaseRecordEqualityProbe).GetMethod(nameof(Collect), BindingFlags.NonPublic | BindingFlags.Static)!.MakeGenericMethod(t).Invoke(null, [collection, va, vb, vc]); - if (f is not null) fails.Add($"{label}: {f}"); - } - r.Note(NoteKeys.Compared, $"{c.Bases.Length + 1} types, against EqualityComparer<{a.GetType().Name}>"); - return fails.Count == 0 ? null : string.Join("; ", fails); + var fails = new List(); + if (!va.Equals(vb)) fails.Add("Equals(object) is false for equal wrapped values"); + if (va.Equals(vc)) fails.Add("Equals(object) is true for different wrapped values"); + if (va.GetHashCode() != vb.GetHashCode()) fails.Add("equal value objects have different hash codes"); + var op = c.Vo.GetMethod("op_Equality", BindingFlags.Public | BindingFlags.Static, [c.Vo, c.Vo]); + if (op is null) fails.Add("no operator =="); + else if (!(bool)op.Invoke(null, [va, vb])!) fails.Add("== is false for equal wrapped values"); + if (Held(c.Vo, collection, va, vb, vc) is { } f) fails.Add($"the value object: {f}"); + if (fails.Count > 0) return string.Join("; ", fails); + k.Note(NoteKeys.Compared, $"the value object, against EqualityComparer<{a.GetType().Name}>"); + return null; + }); + var f2 = r.Check("in-a-collection-of-the-base", baseline, k => + { + if (c.Bases.Length == 0) return k.Inconclusive("no base record in the chain: claims.json excludes the row's pairs"); + var fails = new List(); + foreach (var t in c.Bases) + if (Held(t, collection, va, vb, vc) is { } f) fails.Add($"the base {t.Name}: {f}"); + if (fails.Count > 0) return string.Join("; ", fails); + k.Note(NoteKeys.Compared, $"{c.Bases.Length} base type(s), against EqualityComparer<{a.GetType().Name}>"); + return null; + }); + return f1 ?? f2; } + static string? Held(Type t, string collection, object a, object b, object c) => + (string?)typeof(C09BaseRecordEqualityProbe).GetMethod(nameof(Collect), BindingFlags.NonPublic | BindingFlags.Static)!.MakeGenericMethod(t).Invoke(null, [collection, a, b, c]); + /// Three values held as , two of them equal: the collection holds two, and IEquatable agrees. static string? Collect(string collection, object a, object b, object c) where T : notnull { diff --git a/tools/probe/corpus/probes/C20ShouldHoldProbe.cs b/tools/probe/corpus/probes/C20ShouldHoldProbe.cs index 4d323979..f84da364 100644 --- a/tools/probe/corpus/probes/C20ShouldHoldProbe.cs +++ b/tools/probe/corpus/probes/C20ShouldHoldProbe.cs @@ -50,37 +50,73 @@ public sealed class C20ShouldHoldProbe var wrong = r["wrong-reason"] == "yes"; // the held fault: with wrong-reason=yes the carrier holds a fault of ANOTHER type (foreign) and the test asks for TW's reason a TC held = wrong ? foreign : make(a); - string? fail = null; var op = r["or.operation"]; var shape = r["or.shape"]; TC? got = await Carry(r, held, op, shape); - if (got is null) return null; // inapplicable, noted + if (got is null) { r.Outcome("harness-error", $"the carrier gave no fault back ({shape}, {op})"); return null; } var composed = got.Value; Fault? hand = null; Exception? thrown = null; try { hand = holdA(composed); } catch (Exception ex) { thrown = ex; } + var docs = Baseline.Docs("src/CodoMetis.TypeKit.Testing/README.md"); if (op == "default-instance") + return r.Check("uninitialized-fails", docs, c => + { + if (thrown is not TypeKitAssertionException u || !u.Message.Contains("uninitialized")) return $"default composed fault: ShouldHold gave {thrown?.GetType().Name ?? "no exception"} {thrown?.Message}; expected TypeKitAssertionException 'uninitialized'"; + c.Note(NoteKeys.Compared, "uninitialized: TypeKitAssertionException"); + return null; + }); + if (wrong) { - if (thrown is not TypeKitAssertionException u || !u.Message.Contains("uninitialized")) return $"default composed fault: ShouldHold gave {thrown?.GetType().Name ?? "no exception"} {thrown?.Message}; expected TypeKitAssertionException 'uninitialized'"; - return null; + // the carrier holds a fault of another type, and the test asks for TW's reason a + var w1 = r.Check("fails-unless-it-holds-that-reason", docs, c => + { + if (thrown is not TypeKitAssertionException) return $"ShouldHold({a}) on a fault of another type gave {(thrown is null ? "a result: " + hand : thrown.GetType().Name)}; expected TypeKitAssertionException"; + // same type other value + try { holdB(composed); return "ShouldHold(other value) passed on a fault of another type"; } catch (TypeKitAssertionException) { } + c.Note(NoteKeys.Compared, "fails for a reason it does not hold"); + return null; + }); + var w2 = r.Check("a-failure-says-the-outcome", docs, c => + { + if (thrown is not TypeKitAssertionException e) return c.Inconclusive("no assertion failed to say anything: judged by fails-unless-it-holds-that-reason"); + if (!e.Message.Contains("Expected the fault") || e.Message.Contains("UNEXPECTED")) return "message: " + e.Message; + // the outcome: the fault the composed fault holds, as ShouldHold of its own reason hands it back + string outcome; + try { outcome = holdForeignSet(composed).ToString()!; } catch (Exception ex) { return c.Inconclusive("the held fault is not handed back by its own reason, judged by whichever-of-its-types: " + ex.GetType().Name); } + if (!e.Message.Contains(outcome)) return $"the failure does not say what the outcome was ({outcome}): {e.Message}"; + c.Note(NoteKeys.Compared, "the failure names " + outcome); + return null; + }); + var w3 = r.Check("whichever-of-its-types", docs, c => + { + // the held reason itself is still reachable by its own type + try { holdForeignSet(composed); } catch (Exception ex) { return "ShouldHold(own reason) threw " + ex.GetType().Name + ": " + ex.Message; } + c.Note(NoteKeys.Compared, "the held fault's own reason, whichever of its types"); + return null; + }); + return w1 ?? w2 ?? w3; } - if (wrong) + var f1 = r.Check("whichever-of-its-types", docs, c => { - if (thrown is not TypeKitAssertionException e) return $"ShouldHold({a}) on a fault of another type gave {(thrown is null ? "a result: " + hand : thrown.GetType().Name)}; expected TypeKitAssertionException"; - if (!e.Message.Contains("Expected the fault") || e.Message.Contains("UNEXPECTED")) return "message: " + e.Message; - // the outcome: the fault the composed fault holds, as ShouldHold of its own reason hands it back - var outcome = holdForeignSet(composed).ToString()!; - if (!e.Message.Contains(outcome)) return $"the failure does not say what the outcome was ({outcome}): {e.Message}"; - // same type other value - try { holdB(composed); return "ShouldHold(other value) passed on a fault of another type"; } catch (TypeKitAssertionException) { } - // and the held reason itself is still reachable by its own type - try { holdForeignSet(composed); } catch (Exception ex) { return "ShouldHold(own reason) threw " + ex.GetType().Name + ": " + ex.Message; } + if (thrown is not null) return $"ShouldHold({a}) on a fault holding it threw {thrown.GetType().Name}: {thrown.Message}"; + if (!EqualityComparer.Default.Equals(hand!.Value.Reason, a)) return $"returned fault has reason {hand.Value.Reason}, expected {a}"; + c.Note(NoteKeys.Compared, $"the reason of the {r["set"]} of {r["arity"]} types"); return null; - } - if (thrown is not null) return $"ShouldHold({a}) on a fault holding it threw {thrown.GetType().Name}: {thrown.Message}"; - if (!EqualityComparer.Default.Equals(hand!.Value.Reason, a)) return $"returned fault has reason {hand.Value.Reason}, expected {a}"; - var want = Held(a); - if (hand.Value.Detail != want.Detail || hand.Value.Rule != want.Rule) return $"returned fault has detail '{hand.Value.Detail}' and rule '{hand.Value.Rule}', expected '{want.Detail}' and '{want.Rule}'"; - try { holdB(composed); fail = "ShouldHold(other value of the same type) passed"; } catch (TypeKitAssertionException) { } - return fail; + }); + var f2 = r.Check("hands-back-detail-and-rule", docs, c => + { + if (thrown is not null || hand is null) return c.Inconclusive("nothing handed back: judged by whichever-of-its-types"); + var want = Held(a); + if (hand.Value.Detail != want.Detail || hand.Value.Rule != want.Rule) return $"returned fault has detail '{hand.Value.Detail}' and rule '{hand.Value.Rule}', expected '{want.Detail}' and '{want.Rule}'"; + c.Note(NoteKeys.Compared, "detail and rule intact"); + return null; + }); + var f3 = r.Check("fails-unless-it-holds-that-reason", docs, c => + { + try { holdB(composed); return "ShouldHold(other value of the same type) passed"; } catch (TypeKitAssertionException) { } + c.Note(NoteKeys.Compared, "fails for another value of the reason's type"); + return null; + }); + return f1 ?? f2 ?? f3; } // Carries the fault through the carrier and the operation, then extracts it with the Testing assertions. null = the combination has no such operation (noted inapplicable). @@ -122,7 +158,7 @@ public sealed class C20ShouldHoldProbe case "Sequence-Transpose": re = new[] { re }.Sequence(); break; case "Result.Catch": re = Result.Catch((Action)(() => throw new InvalidOperationException())).MapError(_ => fault); break; case "after-await-elsewhere": await Task.Yield(); re = await Task.Run(() => re).ConfigureAwait(false); break; - default: r.Note(NoteKeys.EntryNotApplicable, op + " on Result-E"); return null; + default: throw new ArgumentException(op + " on Result-E: the row's operation is substituted above where the carrier has none"); } return re.ShouldBeError(); } @@ -137,7 +173,7 @@ public sealed class C20ShouldHoldProbe case "FirstOrNone": opt = new[] { fault }.FirstOrNone(); break; case "Sequence-Transpose": { var back = Option.Some>(Result.Error(fault)).Transpose().ShouldBeError(); opt = Option.Some(back); break; } case "after-await-elsewhere": await Task.Yield(); opt = await Task.Run(() => opt).ConfigureAwait(false); break; - default: r.Note(NoteKeys.EntryNotApplicable, op + " on Option"); return null; + default: throw new ArgumentException(op + " on Option: the row's operation is substituted above where the carrier has none"); } return opt.ShouldBeSome(); }