Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 7s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 13s
PR Gates / Docs update reminder (pull_request) Successful in 13s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 20s
PR Gates / decisions lifecycle (pull_request) Successful in 18s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 12s
Review verdict / Set review-verdict status (pull_request_target) Successful in 26s
review-verdict/h10 Review-verdict: MERGEABLE @ fcdc381 (base: main)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m23s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m23s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 17m33s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m7s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
api-conventions.md now says which half of an id-list validator a sibling field may gate (existence, never the raw-count cap) and that a lost-race recovery re-asks the whole validator set rather than the fields whoever wrote the catch remembered. Those, with the bound and the field-named 422, are one convention with residuals, so they get a record -- api.top-level-id-list-validation -- and a task-signal row. The record states what #568 does NOT settle: three validators on two DTOs is a per-field constant, not the repo-wide rule #917 owns, and it says to expect #917 to replace the mechanism. graphics-elements.md: rows 35-44 re-measured against the whole ErsatzTV.Tests project on this tree, because the fix moved five of their red sets -- Validate is now also what the recovery path re-runs, so removing a validator from it reddens that handler's race test too. Rows 45-47 are new and measured the same way. The "redden more than one test" figure is recounted from the table (21 -> 24); the cross-fixture set is unchanged at five. The negative discriminator rows now carry a stated seeding rule: vary one half of the identity and hold the other at the seeded value. Varying both leaves the row rejected by the pre-#568 predicate as well, so a composite revert to it would pass every test at that site. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
284 lines
28 KiB
Markdown
284 lines
28 KiB
Markdown
# Graphics elements — schema reference
|
||
|
||
Graphics elements are YAML-authored overlays composited onto a channel's **transcoded** frames by the
|
||
SkiaSharp graphics engine (`ErsatzTV.Infrastructure/Streaming/Graphics/`). They appear in the FFmpeg
|
||
filter graph as the `[vge]` stage.
|
||
|
||
`[vge]` is **not** proof that a graphics element is bound. Watermarks are a separate, older system that
|
||
falls back onto the graphics engine whenever it cannot use ffmpeg's native `overlay` shortcut — an
|
||
`Intermittent`/`OpacityExpression` mode, more than one active watermark, or an external-URL image all
|
||
route a *watermark* through `[vge]` with zero graphics-element rows. The native shortcut is only taken
|
||
for a single `Permanent` local-file watermark
|
||
(`ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs`, `CanUseFFmpegNativeWatermark`).
|
||
|
||
`StreamingMode.HttpLiveStreamingDirect` renders nothing: ErsatzTV is not transcoding, so there is no
|
||
frame pipeline to draw into and `GraphicsElementSelector` returns empty.
|
||
|
||
## Discovery and attachment
|
||
|
||
- Files live under `FileSystemLayout.GraphicsElements*TemplatesFolder` (`text/`, `image/`, `motion/`,
|
||
`subtitle/`, `script/`). `RefreshGraphicsElementsHandler` scans them and maintains one
|
||
`GraphicsElement` row per file, removing rows whose file vanished.
|
||
- Five join tables attach an element: `ChannelGraphicsElement`, `PlayoutItemGraphicsElement`,
|
||
`ProgramScheduleItemGraphicsElement`, `BlockItemGraphicsElement`, `DecoGraphicsElement`.
|
||
Resolution order and the deco `Merge`/`Override`/`Disable`/`Inherit` semantics are in
|
||
`graphics.channel-level-attachment`; the channel level is the **base layer**.
|
||
|
||
## Parsing rules that bite
|
||
|
||
- Deserialization is YamlDotNet **without** `IgnoreUnmatchedProperties`, so **an unknown key throws**
|
||
and the whole element is silently disabled for that content with only a logged warning
|
||
(`GraphicsElementLoader.FromYaml<T>`). A typo is not a partial render — it is no render.
|
||
- There is no schema validation (`GraphicsElementLoader` carries an explicit `TODO: validate schema`).
|
||
- Colour strings are parsed with SkiaSharp's `SKColor.TryParse` (`#RRGGBB`, `#AARRGGBB`, named
|
||
colours). Failure handling differs by field and by whether the style is the base one. An unparseable
|
||
**background/border** colour is warned about and that part of the box is skipped. Everywhere else it
|
||
is silent: on the **base** style an unparseable `text_color` falls back to white and an unparseable
|
||
`halo_color` leaves the halo unset; on a **non-base** style an unparseable `text_color` OR
|
||
`halo_color` is simply not applied, so the value inherited from the base style stands.
|
||
- **`font_family` is required on the base style.** Non-base styles inherit it; a null one reaching
|
||
the mapper makes `CustomFontMapper` throw on a null
|
||
dictionary key, which the renderer swallows into "disable for this content" (#570).
|
||
- `{{ … }}` template expressions are rendered by Scriban before the YAML is parsed.
|
||
|
||
## Text element fields
|
||
|
||
Element level (`TextGraphicsElement`):
|
||
|
||
| Field | Type | Notes |
|
||
| --- | --- | --- |
|
||
| `name` | string | Display name; **never** used for identity. |
|
||
| `text` | string | Scriban-templated. `[styleName]…[/styleName]` selects a style for a span. |
|
||
| `base_style` | string | Must name an entry in `styles`, or initialization throws. |
|
||
| `styles` | list | See below. |
|
||
| `epg_entries` | int | How many EPG entries to expose as `Epg[…]`. |
|
||
| `location` | enum | `TopLeft`, `BottomLeft`, … |
|
||
| `horizontal_margin_percent` / `vertical_margin_percent` | double | Percent of frame width/height. |
|
||
| `width_percent` | double | Percent of frame width. A wrapping/scaling **budget** for the whole element, box included — not a hard clamp; see the geometry notes. |
|
||
| `text_fit` | `None` \| `Wrap` \| `Scale` | Only meaningful with `width_percent`. |
|
||
| `text_align` | `Left` \| `Center` \| `Right` | |
|
||
| `z_index` | int | |
|
||
| `opacity_percent` | int | Ignored when `opacity_expression` is set. |
|
||
| `opacity_expression` | string | NCalc expression, e.g. `LinearFadeDuration(content_seconds, 4, 1, 6)`. |
|
||
| `include_fonts_from` | string | Directory of `.ttf`/`.otf` to load. |
|
||
|
||
Background box (#732) — element level, because the engine lays the whole element out as one text block
|
||
and rasterises it into one bitmap, so there is exactly one box per element:
|
||
|
||
| Field | Type | Default | Notes |
|
||
| --- | --- | --- | --- |
|
||
| `background_color` | string | unset | Unset means no **fill**. A `border_color` alone still draws an outlined box; with neither, there is no box and no insets. |
|
||
| `background_opacity_percent` | int | 100 | Clamped to 0–100; multiplies the colour's own alpha, so `#AARRGGBB` and this field compose. |
|
||
| `background_padding` | double px | 0 | Space between the text and the box edge, on all four sides. |
|
||
| `background_corner_radius` | double px | 0 | Clamped to half the shorter side. |
|
||
| `border_color` | string | unset | |
|
||
| `border_width` | double px | 1 when `border_color` is set | A border colour with no width draws a hairline rather than nothing. |
|
||
|
||
Geometry notes:
|
||
|
||
- Padding and border grow the element's bitmap and are subtracted from the text's wrap/scale budget, so
|
||
`width_percent` applies to the visible box rather than to the text inside it. The inset is rounded up
|
||
to a whole pixel once and the same integer is used on both sides, so the box cannot exceed the budget
|
||
by a rounding remainder.
|
||
- **`width_percent` is a budget, not a guarantee.** Wrapping cannot break below a single glyph, so a
|
||
narrow `width_percent` overflows with or without a box — that is pre-existing behaviour, not
|
||
something the box introduced. `text_fit: Scale` shrinks the font instead, down to a 5px floor.
|
||
- If the inset does not fit inside the budget at all, the **inset is clamped** (with a logged warning)
|
||
rather than the text being squeezed to nothing, so an oversized `background_padding` stops growing
|
||
its box at the budget instead of producing one several times the requested width.
|
||
- The inset is **also capped at `min(frameWidth, frameHeight) / 2`**, with a logged warning, whether or
|
||
not `width_percent` is set. Each field is bounded on its own, but padding and border add up, so
|
||
without this a two-field mistake could allocate a bitmap far larger than the frame it is drawn onto.
|
||
- With no box fields set the wrap/scale budget is passed through untouched, so existing overlays keep
|
||
their exact pre-#732 geometry.
|
||
- The box is drawn behind the text and fades with the element, so `opacity_expression` dims box and
|
||
text together.
|
||
- Sizes are in **pixels at frame scale**, matching `font_size`. They do not scale with resolution.
|
||
|
||
Per-style (`StyleDefinition`): `name`, `font_family` (required), `font_size`, `font_weight`,
|
||
`font_italic`, `text_color`, `letter_spacing`, `line_height`, `halo_color`, `halo_width`, `halo_blur`.
|
||
Every one of these overrides the `base_style`'s value when the style declares it — the halo fields
|
||
did not until #732, so before that a non-base style silently rendered the base style's halo. The
|
||
seeded `on-now-next.yml` is unaffected because all three of its styles declare the same halo: its
|
||
rendered bitmap is byte-identical either way. That invariant holds on any host; a specific hash would
|
||
not, because the render depends on the host's fonts (see `docs/testing.md`). Re-derive it by rendering
|
||
`OnNowNextYaml` through `TextElement` at 1920x1080 with and without the per-style halo merge and
|
||
comparing the encoded PNGs — they must be equal, whatever their hash is.
|
||
|
||
**This is an operator-visible change and belongs in the release notes.** A custom template whose
|
||
non-base style declares its own `halo_color` / `halo_width` / `halo_blur` renders differently after
|
||
this version — correctly, but differently. So does one using `line_height` with `text_fit: Scale`:
|
||
the Scale path now carries line height through, which changes measured height and therefore where the
|
||
element anchors.
|
||
Halo is drawn by RichTextKit as part of the text, not by the box code — a wide halo **on top of** a
|
||
background box over-darkens the glyphs, which is why the seeded template cuts it to 1px.
|
||
|
||
## The built-in On Now / Next element
|
||
|
||
`GraphicsElementSeeder` writes `on-now-next.yml` once per database (`graphics.on_now_next_seeded`) and
|
||
never overwrites an operator's file. Two rules govern it after that:
|
||
|
||
- Updating the shipped default → `graphics.seeded-template-upgrade-by-fingerprint`.
|
||
- It is attached to channels by default → `graphics.on-now-next-on-by-default`.
|
||
|
||
Identity is the **full seeded path plus `Kind == Text`**, never the editable `Name` and never the
|
||
bare filename (`OnNowNextFileName`) alone — a filename-only, folder-agnostic comparison let a user
|
||
element named exactly `on-now-next.yml` in a different template folder also report `builtIn:true`
|
||
(#568). Both halves live in one predicate, `GraphicsElementDefaults.IsOnNowNext(path, kind)`, and
|
||
both `GraphicsElementResponseModel.BuiltIn` and `GraphicsElementSeeder.GetBuiltInElementId` resolve
|
||
through it whole, so the SPA never name-matches and the two sites cannot answer differently about
|
||
the same row. `Kind` was a `Where` clause on the seeder's lookup alone until #568, and that split is
|
||
exactly what let the API report an Image row at the seeded path as `builtIn:true` while the lookup
|
||
refused to treat it as the built-in element.
|
||
|
||
`Kind` is load-bearing rather than decorative, which is why it is inside the identity rather than
|
||
a filter any one caller may add: the seeder's own "does this row exist yet?" test —
|
||
`EnsureBuiltInElementRow`, which decides whether to create the row at startup — calls
|
||
`GetBuiltInElementId`, so a wrong-kind row at the seeded path answering yes would suppress the Text
|
||
row every consumer resolves. Asking that question a second way, as its own SQL `Path == target`
|
||
comparison, could answer differently in two ways, each leaving the built-in element undiscoverable
|
||
for the life of the install (#568): the collation one below, and `Kind`, which the SQL check ignored
|
||
(rows 18 and 34). Creating the row stays idempotent because the path it writes is the path the
|
||
lookup matches — held by `Repeated_Seeding_Does_Not_Accumulate_Element_Rows` (row 21), which reddens
|
||
if the two drift.
|
||
|
||
Each half of the identity rejects a row on its own, so a row that is BOTH of another kind AND
|
||
outside the seeded folder is still refused when either half is dropped — the survivor rejects it. A
|
||
test asserting such a row is ignored therefore cannot appear in row 18's red set; it is a test that
|
||
cannot fail, and the combination is deliberately not shipped. Row 10 drops the identity call
|
||
entirely, which is a different mutation from dropping either half.
|
||
|
||
The same arithmetic constrains how each negative case is SEEDED, at both discriminator sites: a
|
||
row varies exactly one half of the identity and holds the other at the seeded value. The
|
||
outside-folder rows carry `Kind == Text`, the wrong-kind rows sit at the seeded path. A negative row
|
||
that varies both is refused by the surviving half of the *pre-#568* predicate too — filename plus
|
||
`Kind == Text` — so a composite revert to it would pass every discriminator test at that site while
|
||
restoring the folder-agnostic bug the issue reports (#568).
|
||
|
||
`IsOnNowNext` is **ordinal**, and every caller applies it **in memory** rather than in a `Where`
|
||
clause. That is not incidental: `GraphicsElement.Path` takes no explicit collation (`TvContext`
|
||
pins one only on the listed name/title columns), so a SQL `Path ==` comparison is case-sensitive
|
||
under SQLite and normally case-INsensitive under MySQL. Evaluating one discriminator site in SQL and
|
||
another in memory is what would let them disagree, on MySQL only. The SQLite test suite cannot
|
||
distinguish the two — BINARY collation and an ordinal comparison agree on every input —
|
||
so this is held by keeping the comparison out of SQL, not by a test; what the case-variant tests in
|
||
the table below pin is the ordinal rule itself, against a loosening to `OrdinalIgnoreCase`.
|
||
|
||
## Tests
|
||
|
||
`ErsatzTV.Tests/Infrastructure/Graphics/TextElementBackgroundBoxTests.cs` renders real bitmaps and
|
||
asserts pixels — the box's failure mode is a silent no-op, which a model-level test cannot see.
|
||
Geometry assertions are relative to a no-box baseline so they do not depend on the host's typeface,
|
||
and `Baseline_Renders_A_Non_Empty_Bitmap` turns a fontless host into a loud red instead of a suite
|
||
that passes vacuously on a 0x0 bitmap (see `docs/testing.md` → font dependency).
|
||
|
||
Attachment behaviour is covered by `GraphicsElementDefaultAttachTests`,
|
||
`GraphicsElementSeederUpgradeTests` and `CreateChannelDefaultGraphicsElementTests`.
|
||
|
||
### Mutation record (#732)
|
||
|
||
Every clause below was mutated in turn against this tree and **the whole `ErsatzTV.Tests` project**
|
||
re-run; the "Reddens" column is the measured failure set. The working tree was confirmed clean after
|
||
each run. Reproduce one by making the edit and running
|
||
`dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj` — **not** a `--filter`ed run.
|
||
|
||
Three traps this table is built to avoid:
|
||
|
||
- **Measure against the whole project, never a per-fixture filter.** A filtered run structurally
|
||
cannot observe a red in another fixture, so it under-reports the failure set while looking precise.
|
||
Twenty-four rows below redden more than one test, and five of them (13, 18, 22, 33, 41) span two
|
||
fixture classes — those five are exactly what a per-fixture filter cannot see. Both figures are
|
||
counted from the table itself, so a row added without recounting them makes this bullet quietly
|
||
false — recount both whenever a row is added or its red set changes.
|
||
- **A mutation that fails to COMPILE is not a result.** Warnings-as-error turn the obvious mutation
|
||
shapes into build failures — `CS0162` for `if (true) { return; }`, `CS1717` for self-assignment,
|
||
Sonar `S3981` for a constant-folded condition — and a build failure emits no test summary at all,
|
||
which reads exactly like a pass in a scrolled log. Require a summary line, and detect build failure
|
||
with `: error `, not `error CS`.
|
||
- **Re-measure the red set; never carry it over.** Test renames and added cases both change which
|
||
tests a clause reddens, so a row written from anything but a fresh run describes a tree that no
|
||
longer exists.
|
||
|
||
**This table is not an enumeration of the change.** It lists the clauses that have a measured proof;
|
||
it makes no claim about the ones that do not appear. A clause absent from it has no proof — which is a
|
||
reason to go and check, never evidence that none is possible. A "the only uncovered clause is X"
|
||
sentence was tried here and abandoned: it was falsified three times, each time by a different clause,
|
||
because it silently becomes false the moment a guard is added without its row.
|
||
|
||
Known clauses with no red, as examples rather than a closed set. Each was measured, and each is
|
||
recorded with why a test could not reach it — an unreachable clause is a reason to say so, not to ship
|
||
a test that cannot fail:
|
||
|
||
- the `float.IsFinite` guard on the `width_percent` budget — .NET saturates float→int conversion, so
|
||
removing it changes nothing observable;
|
||
- the `insetPixels == 0 ? maxWidth : …` compatibility branch — a `width_percent` that rounds to zero
|
||
renders identically whether RichTextKit is given a `MaxWidth` of 0 or 1;
|
||
- the absent-target early return and the temp-file existence check in `UpgradeUnmodifiedTemplate` —
|
||
behaviourally redundant; the first re-raises as a caught `IOException`, the second guards a delete
|
||
that is already a no-op;
|
||
- the inner `catch` around that cleanup delete — it stops a failing delete from replacing the
|
||
exception being unwound (which C# otherwise does, and which would downgrade a real cancellation to a
|
||
swallowed warning). `MockFileSystem` does not propagate an interceptor throw raised during the
|
||
delete, so the clause is correct by language semantics but not reachable from a test here.
|
||
|
||
Rows are written per clause, not per block: a row naming a whole `if` or a whole style-merge block
|
||
hides which individual fields inside it are actually reachable from a test.
|
||
|
||
A row number is an identity, not a position: prose elsewhere cites rows by number (the built-in
|
||
element section above cites rows 18 and 21), so a new row takes the next unused number and the rest
|
||
are never renumbered. A clause that loses its red keeps its number vacant rather than freeing it for
|
||
some other clause — reusing it would silently re-point every existing citation. Reinstating the SAME
|
||
clause under its OWN number is the sole exception, because that is the same identity and not a new
|
||
one: row 18 is the `Kind == Text` filter, and stays that clause whether or not it currently has a
|
||
red.
|
||
|
||
| # | Clause mutated | Reddens (measured) |
|
||
|---|---|---|
|
||
| 1 | DrawBackgroundBox returns immediately | `An_Oversized_Border_Is_Clamped_And_Does_Not_Flood_The_Element`<br>`Background_Color_Fills_The_Box`<br>`Background_Opacity_Percent_Is_Clamped_To_Its_Documented_Range`<br>`Background_Opacity_Percent_Scales_The_Alpha`<br>`Background_Padding_Actually_Insets_The_Text`<br>`Border_Color_Draws_A_Border_Distinct_From_The_Fill`<br>`Border_Color_Without_An_Explicit_Width_Draws_A_Hairline`<br>`Corner_Radius_Rounds_The_Corner_Away` |
|
||
| 2 | inset not subtracted from the wrap budget | `Fractional_Padding_Still_Respects_Width_Percent`<br>`Width_Percent_Bounds_The_Whole_Box_Including_Padding` |
|
||
| 3 | text painted at (0,0) instead of the inset | `Background_Padding_Actually_Insets_The_Text` |
|
||
| 4 | ApplyOpacityPercent returns the colour unchanged | `Background_Opacity_Percent_Is_Clamped_To_Its_Documented_Range`<br>`Background_Opacity_Percent_Scales_The_Alpha` |
|
||
| 5 | UpgradeUnmodifiedTemplate call removed | `A_Failed_Replace_After_A_Complete_Temp_Write_Leaves_The_Original_Intact`<br>`A_Failed_Write_Leaves_The_Original_Template_Intact`<br>`An_Unwritable_Template_Does_Not_Fail_Startup`<br>`The_Upgraded_Template_Still_Deserializes_With_A_Resolvable_Base_Style`<br>`Upgrades_An_Untouched_Previous_Default`<br>`Upgrades_An_Untouched_Previous_Default_With_Windows_Line_Endings` |
|
||
| 6 | fingerprint check ignored (upgrade unconditionally) | `Leaves_An_Operator_Modified_File_Alone` |
|
||
| 7 | line-ending normalisation dropped from Normalize | `Upgrades_An_Untouched_Previous_Default_With_Windows_Line_Endings` |
|
||
| 8 | HLS-Direct exclusion removed from the backfill | `Skips_Hls_Direct_Channels_Where_The_Overlay_Cannot_Render` |
|
||
| 9 | already-attached filter removed | `Does_Not_Duplicate_An_Existing_Attachment`<br>`While_Armed_A_Restored_Element_Is_Attached_To_Every_Eligible_Channel` |
|
||
| 10 | the whole `IsOnNowNext(c.Path, c.Kind)` filter removed from the built-in lookup (#568) — both halves of the identity at once, so every candidate row matches | `A_Row_Of_Another_Kind_At_The_Seeded_Path_Does_Not_Suppress_The_Built_In_Row`<br>`Ignores_A_Case_Variant_Of_The_Seeded_Path`<br>`Ignores_A_Non_Built_In_Element_With_A_Different_Filename`<br>`Ignores_A_Same_Named_Same_Kind_Element_Outside_The_Seeded_Folder` |
|
||
| 11 | graphics.on_now_next_default_attached guard never fires | `Does_Not_Re_Attach_After_An_Operator_Clears_It` |
|
||
| 12 | create-time `ChannelGraphicsDefaults.Attach` call removed from `CreateChannelHandler` | `Attaches_The_Built_In_Element_To_A_New_Channel` |
|
||
| 13 | HLS-Direct skip removed from the create path | `Leaves_An_Hls_Direct_Channel_Alone_Because_Nothing_Can_Render_There`<br>`Should_Not_Attach_The_Overlay_To_An_Hls_Direct_Channel` |
|
||
| 14 | upgrade no longer catches filesystem exceptions | `A_Failed_Replace_After_A_Complete_Temp_Write_Leaves_The_Original_Intact`<br>`A_Failed_Write_Leaves_The_Original_Template_Intact`<br>`A_Read_Failure_On_The_Template_Does_Not_Fail_Startup`<br>`An_Unwritable_Template_Does_Not_Fail_Startup` |
|
||
| 15 | FitTextBlock drops HaloColor again | `The_Scale_Path_Preserves_Halo_Blur`<br>`The_Scale_Path_Preserves_The_Halo` |
|
||
| 16 | unrounded inset subtracted from the budget | `Fractional_Padding_Still_Respects_Width_Percent` |
|
||
| 17 | inset clamp removed | `An_Oversized_Padding_Is_Clamped_To_The_Largest_That_Fits` |
|
||
| 18 | `kind == GraphicsElementKind.Text` conjunct dropped from `GraphicsElementDefaults.IsOnNowNext` (#568) — one row for both discriminator sites since they share the predicate; the lookup is what `EnsureBuiltInElementRow` asks, so a wrong-kind row at the seeded path would answer for the Text row that never then gets created, and the API would report it as `builtIn:true` | `A_Row_Of_Another_Kind_At_The_Seeded_Path_Does_Not_Suppress_The_Built_In_Row`<br>`GetAllGraphicsElementsForApi_Should_Not_Mark_A_Wrong_Kind_Row_At_The_Seeded_Path_As_BuiltIn` |
|
||
| 19 | Sanitize passes values through unchecked | `Non_Finite_And_Absurd_Box_Values_Do_Not_Corrupt_The_Geometry` |
|
||
| 20 | the BOX itself is no longer clamped (only insetPixels) | `An_Oversized_Border_Is_Clamped_And_Does_Not_Flood_The_Element` |
|
||
| 21 | duplicate guard removed from EnsureBuiltInElementRow | `Repeated_Seeding_Does_Not_Accumulate_Element_Rows` |
|
||
| 22 | EnsureBuiltInElementRow removed from the ALREADY-SEEDED branch | `A_Row_Of_Another_Kind_At_The_Seeded_Path_Does_Not_Suppress_The_Built_In_Row`<br>`An_Already_Seeded_Install_Missing_Its_Element_Row_Gets_One`<br>`Repeated_Seeding_Does_Not_Accumulate_Element_Rows` |
|
||
| 23 | non-atomic in-place write restored | `A_Failed_Replace_After_A_Complete_Temp_Write_Leaves_The_Original_Intact`<br>`A_Failed_Write_Leaves_The_Original_Template_Intact` |
|
||
| 24 | marker written even with nothing resolved | `Stays_Armed_When_There_Is_No_Built_In_Element_To_Attach`<br>`While_Armed_A_Restored_Element_Is_Attached_To_Every_Eligible_Channel` |
|
||
| 25 | the atomic replace becomes a non-atomic copy | `A_Failed_Replace_After_A_Complete_Temp_Write_Leaves_The_Original_Intact` |
|
||
| 26 | the frame cap on the inset is disarmed | `Non_Finite_And_Absurd_Box_Values_Do_Not_Corrupt_The_Geometry`<br>`The_Inset_Is_Capped_Against_The_Frame_Even_With_No_Width_Percent` |
|
||
| 27 | per-style halo overrides dropped from the style merge | `A_Non_Base_Style_Uses_Its_Own_Halo_Blur`<br>`A_Non_Base_Style_Uses_Its_Own_Halo_Not_The_Base_Styles` |
|
||
| 28 | FitTextBlock drops HaloBlur from the rebuilt style | `The_Scale_Path_Preserves_Halo_Blur` |
|
||
| 29 | FitTextBlock drops LineHeight from the rebuilt style | `The_Scale_Path_Preserves_Line_Height` |
|
||
| 30 | per-style halo_blur override dropped from the style merge | `A_Non_Base_Style_Uses_Its_Own_Halo_Blur` |
|
||
| 31 | the 0-100 opacity clamp is removed | `Background_Opacity_Percent_Is_Clamped_To_Its_Documented_Range` |
|
||
| 32 | the lineup create path stops attaching the built-in element | `Should_Attach_The_Built_In_On_Now_Next_Element` |
|
||
| 33 | `IsOnNowNext` loosened from `Ordinal` to `OrdinalIgnoreCase` (#568) — one row for both discriminator sites, since they share the predicate | `GetAllGraphicsElementsForApi_Should_Not_Mark_A_Case_Variant_Of_The_Seeded_Path_As_BuiltIn`<br>`Ignores_A_Case_Variant_Of_The_Seeded_Path` |
|
||
| 34 | `EnsureBuiltInElementRow`'s existence check re-derived as a SQL `AnyAsync(e => e.Path == target)` instead of asking `GetBuiltInElementId` (#568) | `A_Row_Of_Another_Kind_At_The_Seeded_Path_Does_Not_Suppress_The_Built_In_Row` |
|
||
| 35 | `GraphicsElementIdsMustExist` removed from `UpdateChannelHandler.Validate` (#568) — the unknown-id 422 the issue asks for, and with it the count cap and the diagnostic cap that validator carries, and the lost-race recheck, which re-runs `Validate` whole | `Should_Cap_The_Ids_Echoed_Back_In_The_Unknown_Id_422`<br>`Should_Reject_A_Duplicate_Heavy_List_On_Its_Raw_Count`<br>`Should_Reject_One_More_Than_The_Maximum_Number_Of_GraphicsElementIds`<br>`Should_Reject_Unknown_GraphicsElementId_With_422_Not_500`<br>`Should_Reject_When_One_Of_Several_GraphicsElementIds_Is_Unknown`<br>`Should_Translate_An_Element_Deleted_Between_Validation_And_Save_Into_The_Same_422` |
|
||
| 36 | `GraphicsElementIdsMustExist` removed from `UpdateDecoHandler.Validate` (#568) — and so from the lost-race recheck too, which re-runs `Validate` whole | `Should_Reject_More_Than_The_Maximum_Number_Of_GraphicsElementIds`<br>`Should_Reject_Too_Many_GraphicsElementIds_Even_Under_A_Mode_That_Does_Not_Consume_Them`<br>`Should_Reject_Unknown_GraphicsElementId_With_A_Validation_Error_Not_A_Throw`<br>`Should_Translate_A_Deco_Element_Deleted_Between_Validation_And_Save_Into_The_Same_422` |
|
||
| 37 | `WatermarkIdsMustExist` removed from `UpdateDecoHandler.Validate` (#568) — and so from the lost-race recheck too | `Should_Reject_More_Than_The_Maximum_Number_Of_WatermarkIds`<br>`Should_Reject_Too_Many_WatermarkIds_Even_Under_A_Mode_That_Does_Not_Consume_Them`<br>`Should_Reject_Unknown_WatermarkId_With_A_Validation_Error_Not_A_Throw`<br>`Should_Translate_A_Deco_Watermark_Deleted_Between_Validation_And_Save_Into_The_Same_422` |
|
||
| 38 | `idsAreConsumed: ConsumesGraphicsElementIds(request)` replaced by `idsAreConsumed: true` at `UpdateDecoHandler.GraphicsElementIdsMustExist`'s call site (#568) — a validator may only reject ids the apply path would consume | `Should_Ignore_An_Unknown_GraphicsElementId_When_The_Mode_Does_Not_Consume_It` |
|
||
| 39 | the same replacement at `UpdateDecoHandler.WatermarkIdsMustExist`'s call site (#568) | `Should_Ignore_An_Unknown_WatermarkId_When_The_Mode_Does_Not_Consume_It` |
|
||
| 40 | `GetAllGraphicsElementsForApiHandler`'s `BuiltIn` reverted from `GraphicsElementDefaults.IsOnNowNext(e.Path, e.Kind)` to `Path.GetFileName(e.Path) == GraphicsElementDefaults.OnNowNextFileName` (#568) — the API-side call, the folder-agnostic and kind-agnostic shape the issue reports | `GetAllGraphicsElementsForApi_Should_Not_Mark_A_Wrong_Kind_Row_At_The_Seeded_Path_As_BuiltIn`<br>`GetAllGraphicsElementsForApi_Should_Not_Mark_Same_Filename_Outside_Seeded_Folder_As_BuiltIn` |
|
||
| 41 | the raw-count cap removed from `Validators.IdsMustExist` (#568) — one clause, three call sites, so its red set is what shows the cap is shared rather than restated | `Should_Reject_A_Duplicate_Heavy_List_On_Its_Raw_Count`<br>`Should_Reject_More_Than_The_Maximum_Number_Of_GraphicsElementIds`<br>`Should_Reject_More_Than_The_Maximum_Number_Of_WatermarkIds`<br>`Should_Reject_One_More_Than_The_Maximum_Number_Of_GraphicsElementIds`<br>`Should_Reject_Too_Many_GraphicsElementIds_Even_Under_A_Mode_That_Does_Not_Consume_Them`<br>`Should_Reject_Too_Many_WatermarkIds_Even_Under_A_Mode_That_Does_Not_Consume_Them` |
|
||
| 42 | `Validators.DescribeIds` stops truncating and joins every missing id (#568) | `Should_Cap_The_Ids_Echoed_Back_In_The_Unknown_Id_422` |
|
||
| 43 | the `DbUpdateException` catch disarmed in `UpdateChannelHandler.ApplyUpdateRequestTranslatingLostRace` (#568) — retyped to an exception the save cannot raise, so the FK failure propagates as it did before the fix | `Should_Translate_A_Watermark_Deleted_Between_Validation_And_Save_Into_The_Same_422`<br>`Should_Translate_An_Element_Deleted_Between_Validation_And_Save_Into_The_Same_422` |
|
||
| 44 | the same catch disarmed in `UpdateDecoHandler.ApplyUpdateRequestTranslatingLostRace` (#568) | `Should_Translate_A_Deco_Element_Deleted_Between_Validation_And_Save_Into_The_Same_422`<br>`Should_Translate_A_Deco_Watermark_Deleted_Between_Validation_And_Save_Into_The_Same_422` |
|
||
| 45 | the lost-race recheck in `UpdateChannelHandler.ApplyUpdateRequestTranslatingLostRace` narrowed from `Validate` back to `GraphicsElementIdsMustExist` alone (#568) — the recovery enumerating its own fields instead of re-asking the validator set, which omits every other FK the same save writes | `Should_Translate_A_Watermark_Deleted_Between_Validation_And_Save_Into_The_Same_422` |
|
||
| 46 | the same narrowing in `UpdateDecoHandler.ApplyUpdateRequestTranslatingLostRace` (#568) | `Should_Translate_A_Deco_Watermark_Deleted_Between_Validation_And_Save_Into_The_Same_422` |
|
||
| 47 | the `idsAreConsumed` gate hoisted ABOVE the raw-count cap in `Validators.IdsMustExist` (#568) — the shape the deco mode gate had while it short-circuited the whole call, which took the cap with it | `Should_Reject_Too_Many_GraphicsElementIds_Even_Under_A_Mode_That_Does_Not_Consume_Them`<br>`Should_Reject_Too_Many_WatermarkIds_Even_Under_A_Mode_That_Does_Not_Consume_Them` |
|