test(563): witness the Newtonsoft half of the binder claim, and narrow the prose to what reddens
The branch asserted that binding fixture bodies through ApiJsonSettings makes "a swap to a
lookalike serializer" redden. Measured, only half of that was true: replacing
ScriptedScheduleControllerTests' BodyBinderSettings with a plain `new JsonSerializerSettings()`
-- a Newtonsoft lookalike that has lost the production configuration -- left all 8 tests green.
Only the System.Text.Json swap reddened. So the production edits the branch makes for that
coupling (ErsatzTV/Serialization/ApiJsonSettings.cs and the Startup rewrite) were justified in
four places by a hazard no test could see.
Both halves are now real. Production_Body_Binder_Keeps_Declared_Defaults_Over_An_Explicit_Null
binds `{"order": null}` and posts it to AddCollection: NullValueHandling.Ignore keeps
ContentCollection.Order at its declared "shuffle" and the call is a 200, where Newtonsoft's own
Include default writes the null through and AddCollection's Enum.TryParse returns a 400. That is
a behaviour difference a script would see, not a settings-shape assertion, so it is not a second
copy of the settings list.
Mutants, run 2026-09-05 over the 9-test fixture:
Bind -> System.Text.Json web defaults 2 red
BodyBinderSettings -> new() 1 red (was 0 before this commit)
OpenApi RuntimeSettings -> new() 4 red (write side, naming strategy)
What still nothing observes is Startup.ConfigureServices itself: re-inlining the
AddNewtonsoftJson lambda as a hand-copy of Apply reddens no test, because a byte-equal mirror is
behaviourally indistinguishable. ApiJsonSettings removes the duplicate rather than detecting its
drift, and docs/testing.md, the decision record and all four docstrings now say that instead of
claiming a detector. Drift confined to ReferenceLoopHandling or the StringEnumConverter is
witnessed by neither suite; that is stated rather than left implied.
Also files the 401 blind spot the record had described as "tracked separately" while nothing
tracked it. ersatztv#913 records the chain, verified from source: the filter is registered
globally, EndpointRequiresKey fail-closes every mutating verb, ScriptedScheduleController carries
no [SkipApiAuthorization], and neither ScriptedPlayoutBuilder nor entrypoint.py supplies a
credential.
Refs ersatztv#563 and ersatztv#913.
Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
This commit is contained in:
@@ -25,7 +25,11 @@ namespace ErsatzTV.Tests.Controllers;
|
||||
public class OpenApiSerializerContractTests
|
||||
{
|
||||
// The configuration Startup.ConfigureServices -> AddNewtonsoftJson applies, from the same function
|
||||
// rather than a mirror of it: a mirror drifts silently and this test would keep passing.
|
||||
// rather than a hand-copy of it. This fixture is the WRITE-side witness for the naming strategy: with
|
||||
// a plain JsonSerializerSettings here, all four cases go red on PascalCase keys. It says nothing about
|
||||
// NullValueHandling (every DTO member below is populated, so nothing is dropped either way) or the
|
||||
// StringEnumConverter (it compares key NAMES, not values). docs/testing.md -> "Scripted playout
|
||||
// coverage" tabulates which suite witnesses which half.
|
||||
private static readonly JsonSerializerSettings RuntimeSettings = ApiJsonSettings.Create();
|
||||
|
||||
private static IEnumerable<TestCaseData> Cases()
|
||||
|
||||
@@ -38,11 +38,15 @@ namespace ErsatzTV.Tests.Controllers;
|
||||
/// produces before an action runs, since every test here hands an action an already-bound object.
|
||||
/// The serializer <i>inside</i> that wrapper is not residue: request bodies are deserialized with
|
||||
/// <see cref="ApiJsonSettings" />, the same configuration <c>Startup</c> hands to
|
||||
/// <c>AddNewtonsoftJson</c>, so the fixture is bound the way production binds it. That is not a
|
||||
/// formality — Newtonsoft and System.Text.Json disagree on the DTOs' <c>required</c> members,
|
||||
/// which <see cref="Production_Body_Binder_Ignores_Required_Members" /> pins. The reasoning for
|
||||
/// the scope-out is in docs/testing.md → "Scripted playout coverage" and the decision record
|
||||
/// <c>testing.scripted-engine-in-process-net</c>.
|
||||
/// <c>AddNewtonsoftJson</c>, so the fixture is bound the way production binds it. Two tests hold
|
||||
/// the two ways that binder can be replaced, since the fixture's own bodies parse identically
|
||||
/// under all of them: <see cref="Production_Body_Binder_Ignores_Required_Members" /> against a
|
||||
/// System.Text.Json swap, and
|
||||
/// <see cref="Production_Body_Binder_Keeps_Declared_Defaults_Over_An_Explicit_Null" /> against a
|
||||
/// plain Newtonsoft settings object. What no test here observes is <c>Startup</c>'s own
|
||||
/// registration — <see cref="ApiJsonSettings" /> removes the duplicate rather than detecting
|
||||
/// drift in one. The reasoning for the scope-out is in docs/testing.md → "Scripted playout
|
||||
/// coverage" and the decision record <c>testing.scripted-engine-in-process-net</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Deviation from the three golden-file nets (docs/contributing.md §10): the expected output is a
|
||||
@@ -60,12 +64,18 @@ public class ScriptedScheduleControllerTests
|
||||
private static readonly Guid BuildId = Guid.Parse("00000000-0000-0000-0000-000000000563");
|
||||
private static readonly DateTimeOffset Start = new(2026, 1, 15, 6, 0, 0, TimeSpan.Zero);
|
||||
|
||||
// What Startup hands to AddNewtonsoftJson, from the same function rather than a mirror of it.
|
||||
// What Startup hands to AddNewtonsoftJson, from the same function rather than a hand-copy of it.
|
||||
// Replacing this with a plain `new()` reddens Production_Body_Binder_Keeps_Declared_Defaults_...
|
||||
private static readonly JsonSerializerSettings BodyBinderSettings = ApiJsonSettings.Create();
|
||||
|
||||
// A body that omits the `required` member "collection"; the two serializers disagree about it.
|
||||
// A body that omits the `required` member "collection"; Newtonsoft and System.Text.Json disagree.
|
||||
private const string BodyMissingRequiredMember = """{"key":"content","order":"chronological"}""";
|
||||
|
||||
// A body sending "order" as an explicit null. NullValueHandling.Ignore keeps the DTO's declared
|
||||
// default; Newtonsoft's own default (Include) overwrites it with null.
|
||||
private const string BodyWithExplicitNullOrder =
|
||||
"""{"key":"content","collection":"Test Collection","order":null}""";
|
||||
|
||||
// Every action the fixture is expected to exercise. A fixture edit that drops one must fail loudly
|
||||
// rather than quietly shrinking what the snapshot covers.
|
||||
private static readonly string[] ExpectedActions =
|
||||
@@ -138,7 +148,9 @@ public class ScriptedScheduleControllerTests
|
||||
// The claim is about the SERIALIZER only — what MVC's validation layer then does with such a
|
||||
// body (non-nullable reference types pick up an implicit required check) is the uncovered
|
||||
// wrapper, not this. Binding through Bind itself is what gives this teeth: the fixture's own
|
||||
// bodies parse identically under either serializer, so only a body like this one witnesses a swap.
|
||||
// bodies parse identically under either serializer, so only a body like this one witnesses the
|
||||
// swap. It witnesses THAT swap only — a Newtonsoft settings object that has merely lost the
|
||||
// production configuration still binds this body correctly, which is the sibling test's job.
|
||||
using JsonDocument document = JsonDocument.Parse(BodyMissingRequiredMember);
|
||||
|
||||
ContentCollection bound = Bind<ContentCollection>(document.RootElement).ShouldNotBeNull();
|
||||
@@ -152,6 +164,28 @@ public class ScriptedScheduleControllerTests
|
||||
new JsonSerializerOptions(JsonSerializerDefaults.Web)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Production_Body_Binder_Keeps_Declared_Defaults_Over_An_Explicit_Null()
|
||||
{
|
||||
// The other half of the binder-fidelity claim, and the half that a NEWTONSOFT lookalike witnesses:
|
||||
// production sets NullValueHandling.Ignore, so an explicit `"order": null` leaves ContentCollection
|
||||
// at its declared "shuffle" rather than overwriting it. Newtonsoft's own default is Include, which
|
||||
// writes the null through — and AddCollection's Enum.TryParse then rejects it as a 400. So this is
|
||||
// a behaviour difference a script would see, not a settings-shape assertion: it reddens if the bind
|
||||
// settings are replaced by a plain JsonSerializerSettings that merely looks like the production one.
|
||||
(ScriptedScheduleController controller, SchedulingEngine engine) = NewSession();
|
||||
|
||||
using JsonDocument document = JsonDocument.Parse(BodyWithExplicitNullOrder);
|
||||
ContentCollection bound = Bind<ContentCollection>(document.RootElement).ShouldNotBeNull();
|
||||
bound.Order.ShouldBe("shuffle");
|
||||
|
||||
IActionResult result = await controller.AddCollection(BuildId, bound, CancellationToken.None);
|
||||
result.ShouldBeOfType<OkResult>();
|
||||
|
||||
// and the content is usable, i.e. the surviving default really was a valid playback order
|
||||
engine.AddCount(ContentKey, 1, Option<FillerKind>.None, null, false).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Unknown_Build_Id_Returns_404()
|
||||
{
|
||||
@@ -337,7 +371,8 @@ public class ScriptedScheduleControllerTests
|
||||
|
||||
// Deserializes a fixture body with the SAME configuration the production body binder uses
|
||||
// (Startup -> AddNewtonsoftJson -> ApiJsonSettings), so the fixture's field names and casing are
|
||||
// asserted against the serializer MVC actually runs rather than a lookalike that agrees by luck.
|
||||
// asserted against the serializer MVC actually runs. The two Production_Body_Binder_* tests, not
|
||||
// this helper, are what make a replacement of that configuration visible.
|
||||
private static T Bind<T>(JsonElement body)
|
||||
{
|
||||
body.ValueKind.ShouldBe(JsonValueKind.Object);
|
||||
|
||||
@@ -6,9 +6,15 @@ namespace ErsatzTV.Serialization;
|
||||
/// <summary>
|
||||
/// The Newtonsoft settings the MVC JSON formatters read and write `/api/*` bodies with. Defined once,
|
||||
/// here, so a test can deserialize a request body exactly the way the production body binder does
|
||||
/// instead of mirroring <see cref="Startup" />'s registration — a mirror drifts silently, and the
|
||||
/// divergence is not cosmetic: Newtonsoft has no notion of the C# <c>required</c> keyword, so a body
|
||||
/// omitting a required member deserializes to a default here where System.Text.Json would throw.
|
||||
/// instead of mirroring <see cref="Startup" />'s registration. This REMOVES the duplicate rather than
|
||||
/// detecting drift in one: no test observes <c>Startup.ConfigureServices</c>, so re-inlining its
|
||||
/// <c>AddNewtonsoftJson</c> lambda as a hand-copy of <see cref="Apply" /> would redden nothing. What
|
||||
/// the tests do catch is a caller that binds with something else — a body omitting a C#
|
||||
/// <c>required</c> member deserializes to a default here where System.Text.Json throws, and
|
||||
/// <see cref="NullValueHandling" />.<c>Ignore</c> keeps a DTO's declared default over an explicit
|
||||
/// null where plain Newtonsoft writes the null through. Both are pinned by
|
||||
/// <c>ScriptedScheduleControllerTests</c>; the naming strategy is pinned on the write side by
|
||||
/// <c>OpenApiSerializerContractTests</c>. See docs/testing.md → "Scripted playout coverage".
|
||||
/// </summary>
|
||||
public static class ApiJsonSettings
|
||||
{
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -5,7 +5,7 @@ status: active
|
||||
since: '2026-09-05'
|
||||
supersedes: testing.scripted-playout-golden-deferred@2026-07-22
|
||||
superseded-by: none
|
||||
rule: 'Scripted playout is characterized at two in-process levels and no further. (1) `SchedulingEngineTests` drives the engine build API directly with substituted repositories — the same setup order `ScriptedPlayoutBuilder` uses (`WithPlayoutId`/`WithMode`/`WithSeed`/`BuildBetween`/`WithReferenceData`/`RestoreOrReset`, and `WithReferenceData` MUST precede the last two or `PlayoutReferenceData.PlayoutHistory` NREs). (2) `ScriptedScheduleControllerTests` replays a committed script fixture (`ErsatzTV.Tests/Controllers/Fixtures/scripted-build.json`) through the REAL `ScriptedScheduleController` + `ScriptedPlayoutBuilderService.MockSession` + `SchedulingEngine` and pins the resulting `PlayoutItem`s as a snapshot in the golden line format — so exactly ONE action-to-engine mapping exists in the repo, the production one. Fixture bodies in that replay are deserialized with `ApiJsonSettings`, the configuration `Startup` applies to `AddNewtonsoftJson`, invoked from the same function rather than mirrored: Newtonsoft and System.Text.Json disagree about the DTO `required` members, so a lookalike binder would pass a fixture the production binder would bind differently. The `Cli.Wrap` launch of the user-authored program (exit code, timeout, stdout capture), the Kestrel/middleware/`ApiAuthorizationFilter` transport it calls back over, and MVC model binding as a WRAPPER (the input formatter, model validation and the `[ApiController]` automatic 400 either produces before an action runs — the serializer inside that wrapper is covered, the wrapper around it is not) are NOT tested and are not scheduled to be; that residue is named below rather than argued away. The controller is a thin adapter, NOT a 1:1 pass-through: an unparseable playback order is a 400, an unparseable filler kind SILENTLY degrades to `FillerKind.None`, an unknown build id is a 404, and the engine no-progress `InvalidOperationException` is translated to a 400 — each pinned by a test, because a prose claim of equivalence decays.'
|
||||
rule: 'Scripted playout is characterized at two in-process levels and no further. (1) `SchedulingEngineTests` drives the engine build API directly with substituted repositories — the same setup order `ScriptedPlayoutBuilder` uses (`WithPlayoutId`/`WithMode`/`WithSeed`/`BuildBetween`/`WithReferenceData`/`RestoreOrReset`, and `WithReferenceData` MUST precede the last two or `PlayoutReferenceData.PlayoutHistory` NREs). (2) `ScriptedScheduleControllerTests` replays a committed script fixture (`ErsatzTV.Tests/Controllers/Fixtures/scripted-build.json`) through the REAL `ScriptedScheduleController` + `ScriptedPlayoutBuilderService.MockSession` + `SchedulingEngine` and pins the resulting `PlayoutItem`s as a snapshot in the golden line format — so exactly ONE action-to-engine mapping exists in the repo, the production one. Fixture bodies in that replay are deserialized with `ApiJsonSettings`, the configuration `Startup` applies to `AddNewtonsoftJson`, invoked from the same function rather than mirrored — and two tests WITNESS that choice instead of asserting it: `Production_Body_Binder_Ignores_Required_Members` reddens under a System.Text.Json binder, `Production_Body_Binder_Keeps_Declared_Defaults_Over_An_Explicit_Null` reddens under a bare `JsonSerializerSettings`. What no test observes is the `Startup` registration itself, so the extraction REMOVES the duplicate rather than detecting its drift. The `Cli.Wrap` launch of the user-authored program (exit code, timeout, stdout capture), the Kestrel/middleware/`ApiAuthorizationFilter` transport it calls back over, and MVC model binding as a WRAPPER (the input formatter, model validation and the `[ApiController]` automatic 400 either produces before an action runs — the serializer inside that wrapper is covered, the wrapper around it is not) are NOT tested and are not scheduled to be; that residue is named below rather than argued away. The controller is a thin adapter, NOT a 1:1 pass-through: an unparseable playback order is a 400, an unparseable filler kind SILENTLY degrades to `FillerKind.None`, an unknown build id is a 404, and the engine no-progress `InvalidOperationException` is translated to a 400 — each pinned by a test, because a prose claim of equivalence decays.'
|
||||
signals: 'why is there no scripted golden; scripted integration harness; scripted transport vs engine; SchedulingEngine is in-process testable; Cli.Wrap external process is transport only; shell-out scoped out; MockSession seam; in-process stand-in for a scripted script; engine build API characterization; committed script fixture replay; ScriptedScheduleController is an adapter not a pass-through; scripted filler kind silently falls back to None; scripted playout no-progress halt maps to 400; scripted build 401 blind spot; EnumeratorForContent · paths: `ErsatzTV.Core.Tests/Scheduling/Engine/SchedulingEngineTests.cs`, `ErsatzTV.Tests/Controllers/ScriptedScheduleControllerTests.cs`, `ErsatzTV.Tests/Controllers/Fixtures/scripted-build.json`, `ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs`, `ErsatzTV/Controllers/Api/ScriptedScheduleController.cs`, `ErsatzTV.Core/Scheduling/ScriptedScheduling/ScriptedPlayoutBuilder.cs`, `ErsatzTV.Core.Tests/Scheduling/Goldens/PlayoutBuildGoldenTests.cs` · issues: #563, #381, #395, #163'
|
||||
mechanics: docs/testing.md → "Scripted playout coverage" and "Golden-file nets"
|
||||
---
|
||||
@@ -45,19 +45,34 @@ formatter (content-type selection, malformed-body handling), model validation (a
|
||||
type picks up an implicit required check there) and the `[ApiController]` automatic 400 either produces
|
||||
before an action runs, since every test hands an action an already-bound object; and
|
||||
`ApiAuthorizationFilter`. The *serializer* inside that wrapper is deliberately not on this list:
|
||||
`ScriptedScheduleControllerTests` binds with `ApiJsonSettings` itself, and
|
||||
`Production_Body_Binder_Ignores_Required_Members` pins the Newtonsoft semantics that choice buys — a
|
||||
body omitting a `required` member deserializes to a default rather than failing, which a System.Text.Json
|
||||
stand-in would silently turn into a rejection. That is a claim about the SERIALIZER and is scoped to it:
|
||||
what the wrapper then does with such a body — non-nullable reference types pick up an implicit required
|
||||
check in MVC validation — is on the uncovered list above, not measured here.
|
||||
`ScriptedScheduleControllerTests` binds with `ApiJsonSettings` itself, and two tests hold the two ways
|
||||
that binder can be replaced (mutants run 2026-09-05, 9 tests in the fixture):
|
||||
|
||||
| Replace the replay binder with | Result |
|
||||
| --- | --- |
|
||||
| `System.Text.Json` with web defaults | 2 red — Newtonsoft has no notion of the C# `required` keyword, so a body omitting one deserializes to a default rather than being rejected |
|
||||
| a bare `new JsonSerializerSettings()` — Newtonsoft, but without the production configuration | 1 red — `NullValueHandling.Ignore` keeps `ContentCollection.Order` at its declared `"shuffle"` over an explicit `"order": null`, where Newtonsoft's own `Include` default writes the null through and `AddCollection`'s `Enum.TryParse` then returns a 400 |
|
||||
|
||||
Both claims are about the SERIALIZER and are scoped to it: what the wrapper then does with such a body —
|
||||
non-nullable reference types pick up an implicit required check in MVC validation — is on the uncovered
|
||||
list above, not measured here.
|
||||
|
||||
**What the extraction does and does not buy.** `ApiJsonSettings` exists so the binder is defined once;
|
||||
it is not a drift *detector*, and the difference matters because the branch cannot claim one. Nothing in
|
||||
the suite observes `Startup.ConfigureServices` — re-inlining the `AddNewtonsoftJson` lambda as a hand-copy
|
||||
of `Apply` reddens no test, because a byte-equal mirror is behaviourally indistinguishable by
|
||||
construction. What the two suites *do* catch is a mirror that has lost something: the table above for the
|
||||
read path, and `OpenApiSerializerContractTests` for the write path, where dropping `CustomContractResolver`
|
||||
turns all 4 cases red on PascalCase keys (measured 2026-09-05). Drift confined to `ReferenceLoopHandling`
|
||||
or the `StringEnumConverter` is witnessed by neither, since neither suite serializes a cycle and
|
||||
`OpenApiSerializerContractTests` compares key names rather than values.
|
||||
|
||||
`ApiAuthorizationFilter` on that list is not hypothetical — `ScriptedScheduleController` carries no
|
||||
`[SkipApiAuthorization]` while
|
||||
`ScriptedPlayoutBuilder` passes the script only host, build id and mode, with no `X-Api-Key`, and
|
||||
`entrypoint.py` builds its client with no credential. Whether a scripted build therefore 401s on its first
|
||||
POST in this fork is unverified against a live instance and is tracked separately; it is recorded here as
|
||||
the concrete shape of what an engine-level net cannot see, so the scope-out is not read as a claim of
|
||||
POST in this fork is unverified against a live instance and is tracked in ersatztv#913; it is recorded here
|
||||
as the concrete shape of what an engine-level net cannot see, so the scope-out is not read as a claim of
|
||||
equivalence.
|
||||
|
||||
**Determinism constraints on the fixtures.** Both use Chronological order and only instant-preserving
|
||||
|
||||
+22
-11
@@ -88,18 +88,29 @@ automatic 400 either produces before the action runs, since the replay hands eac
|
||||
already-bound object. Hosting the real `Startup` would drag in the whole DI graph, a
|
||||
hand-rolled minimal host would test a transport the product does not have, and the script is
|
||||
user-authored by definition — so any committed script is a stand-in either way. The decision record
|
||||
names the concrete blind spot this leaves.
|
||||
names the concrete blind spot this leaves, and it is tracked as ersatztv#913.
|
||||
|
||||
The *serializer* inside that binding wrapper is covered rather than scoped out, and the distinction is
|
||||
load-bearing: production binds `/api/*` bodies with Newtonsoft (`Startup` → `AddNewtonsoftJson`), which
|
||||
has no notion of the C# `required` keyword, so a body omitting a required member deserializes to a
|
||||
default where System.Text.Json rejects it outright. That is a statement about the serializer and stops
|
||||
there — what MVC validation then does with such a body belongs to the wrapper named above. The replay
|
||||
deserializes fixture bodies through `ErsatzTV.Serialization.ApiJsonSettings`, the same function
|
||||
`Startup` calls rather than a mirror of it, and `Production_Body_Binder_Ignores_Required_Members` pins
|
||||
both halves of the divergence through the replay's own bind helper — so a swap to a lookalike serializer
|
||||
reddens instead of passing on the fixture bodies the two happen to agree about.
|
||||
`OpenApiSerializerContractTests` reads the same settings from the same place.
|
||||
The *serializer* inside that binding wrapper is covered rather than scoped out. The replay deserializes
|
||||
fixture bodies through `ErsatzTV.Serialization.ApiJsonSettings`, the same function `Startup` hands to
|
||||
`AddNewtonsoftJson`, and two tests witness that choice through the replay's own bind helper — the
|
||||
fixture's own bodies parse the same way under any of these serializers, so each needs a body built to
|
||||
separate them (mutants run 2026-09-05):
|
||||
|
||||
| Replace the replay binder with | Result |
|
||||
| --- | --- |
|
||||
| `System.Text.Json` with web defaults | 2 of 9 red. Newtonsoft has no notion of the C# `required` keyword, so a body omitting one deserializes to a default where System.Text.Json rejects it outright. |
|
||||
| a bare `new JsonSerializerSettings()` — still Newtonsoft, but without the production configuration | 1 of 9 red. `NullValueHandling.Ignore` keeps `ContentCollection.Order` at its declared `"shuffle"` over an explicit `"order": null`; Newtonsoft's own `Include` default writes the null through, and `AddCollection`'s `Enum.TryParse` then returns a 400. |
|
||||
|
||||
Both are statements about the serializer and stop there — what MVC validation does with such a body
|
||||
belongs to the wrapper named above.
|
||||
|
||||
`ApiJsonSettings` exists so that binder is *defined* once; it is not a drift detector, and the difference
|
||||
is worth stating because it bounds what the suite can promise. Nothing here observes
|
||||
`Startup.ConfigureServices`, so re-inlining its `AddNewtonsoftJson` lambda as a hand-copy reddens no test
|
||||
— a byte-equal mirror is behaviourally indistinguishable by construction. A mirror that has *lost*
|
||||
something is caught: the table above on the read path, and `OpenApiSerializerContractTests` on the write
|
||||
path, where dropping `CustomContractResolver` turns all 4 cases red on PascalCase keys. Drift confined to
|
||||
`ReferenceLoopHandling` or the `StringEnumConverter` is witnessed by neither.
|
||||
|
||||
## Timezone independence
|
||||
|
||||
|
||||
Reference in New Issue
Block a user