Files
LlamaCasty/TASKS/task-47-alert-videos.md
T
gramps 7f14ffb11e TASK 47 take four: alert ticker visible in the preview + 3 display methods
The creator confirmed the clip fix ("the video plays now"), then asked where the
scrolling text was — it was invisible, for a structural reason. AlertTickerFrame
existed only as a frame-pump callback blitted into the OUTPUT; PreviewPane.xaml had
no element for it, because the strip is master-width and global, not a Source, so it
cannot ride a per-element Image. Nothing was wrong in the renderer: there was no
consumer in the preview. Same class of defect as the missing IsAlertBox trigger, one
layer up (MyMistakes RULE 5/6).

- tickerPreviewSink on AlertOverlayLayer, published from RefreshAlertPreviews() so it
  is always the UI thread; MainViewModel.AlertTicker writes it into one reused
  WriteableBitmap bound to a new global AlertTickerElement, mirroring SocialBarElement.
- Source.AlertDisplayMethod + panel "Display" selector: TickerScroll / Flash / Solid.
  Flash pulses 0.5s on / 0.5s off for the whole alert; Solid is centred and still.
- The marquee was also unreadable: a fixed 140px/s took ~17s per pass, so a 10s alert
  showed the text once, entering from the right and never crossing. Paced in reads per
  alert instead (TickerReadsPerAlert = 3 inside the alert's own length, speed derived
  from it) — never px/s. Research (websearch: how do OBS/Streamlabs/StreamElements
  alert boxes present announcement timing?) settled the unit: Streamlabs exposes "Alert
  Duration: choose how long your alert stays on your stream" and "Text Delay", never a
  scroll-speed slider (https://support.streamlabs.com/hc/en-us/articles/52499995174299-Setting-up-Your-Streamlabs-Alerts).
  Run is phase-started half a frame in so the first frame isn't blank.
- Persistence: AlertDisplayMethod INTEGER NOT NULL DEFAULT 0 via the idempotent
  table_info migration, appended LAST in the SELECT because the Source reader is
  positional (GetInt32(32..34)) — a mid-list insert would silently shift a neighbour.

Tests: 15 new facts (suite 339/339). RealApp STA host: the pane draws the strip and
collapses at alert end; the layer publishes a real 1920x48 frame for all three methods
and nothing when the ticker is off; three passes counted in 10s by the pill's leading
edge resetting (a seamless marquee never blanks, so an empty frame cannot count a pass);
Solid byte-identical at every moment; Flash on for half of each second; the panel shows
and writes back the choice; the DB round-trips all three alert fields together.

Incidental finding: a bound ItemsSource ComboBox in LeftPanel.xaml broke
LayerReorderPersistenceTests.RealMouseDrag (that test injects PHYSICAL mouse input, so a
load-time re-measure moves the rows out from under the cursor). Rewritten as inline
ComboBoxItems, the shape the chat Font selector already uses in that panel. Recorded as
MyMistakes RULE (8).
2026-09-26 15:10:26 -07:00

312 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# TASK 47 — Alert box video (TASK 43 follow-up): creator-replaceable alert clip + read-time fade + auto ticker
> Catalog: [`TASKS.md`](../TASKS.md). Status: ✅ **SHIPPED 2026-09-26** — the TASK 43
> alert box grows a real **video** celebration: a built-in mp4 loops on every alert unless
> the creator picks their own clip (referenced from disk, never stored in the DB), the
> clip fades in/out in ~0.3s (alpha envelope on the video + volume on the audio, mixed
> into the live stream at unity — no duck), and an auto-composed **message ticker**
> ("Funder — Super Chat · $10.00") scrolls across the very top of the frame, toggleable.
> The six TASK 43 animations remain the intrinsic fallback when no asset is playable.
## Provenance (recorded per the feature-provenance rule)
- **2026-09-24, the creator asked (right after TASK 43 shipped)** — "make the alerts
animate like a video" / "I want to play my own video for alerts." The TASK 43 unit
closed with this as the explicit next step.
- **Lineage:** Research (websearch: how do OBS/StreamElements/Streamlabs alert boxes show
per-alert videos?) settled the shape, cited in the commit message:
- Alert videos play **per-alert** (a fresh decoder per event), never a resident media
player — the OBS "media source reset on activation" model, not a playlist.
- The clip **must not block**: decode is a background pipe (ffmpeg child, rawvideo
frames + f32le audio), paced real-time, so alerts never stall the 60fps frame pump.
- A **freeze-frame at clip EOF fades out** rather than hard-dropping (Streamlabs'
"end by fade" option) — the tail of a long clip dissolves without a new spawn.
- The custom file is served by **path, not bytes** (OBS media-source semantics; a
read-at-play clip lets the creator re-edit the file without a re-import). Decided
with the creator: "never store the video in the layout." The built-in default is the
one BLOB, stamped into the internal `Asset` table at startup.
- **Creator rulings (asked once, held):** custom video + six-animation fallback
(not either/or); ticker on top of the canvas (very top, full width); **no ducking** —
"don't lower my game audio for a tip" (the TASK 43 planner's auto-duck idea was
rejected on the floor); a per-alert **Volume** slider instead.
## Product model (as shipped)
- **Five new `Source` props** (schema v10, additive guarded ALTERs): `AlertVideoPath`
(custom file), `AlertUseDefaultVideo` (bool, default true — the built-in clip),
`AlertShowTicker` (bool, default true), `AlertVideoVolume` (0–1, default 1).
`AlertVideoAssetId` (internal reference to the stamped default asset row).
- **Built-in clip:** `Assets/alert-default.mp4` (~4.9MB, branded 2.5s loop, included as a
csproj Resource). At startup `MainViewModel.StampDefaultAlertVideo()` upserts its bytes
into the `Asset` table and records the row id in the settings key
`AlertDefaultVideoAssetId` (`AlertDefaultVideoKey`). The **prune** step exempts that
key via a UNION so the default never gets cleaned.
- **Per-alert resolver** (`ResolveAlertClipPath` in `MainViewModel.Chat.cs`): custom path
wins ONLY when `AlertUseDefaultVideo == false` AND the file exists; otherwise the
thumbnailed built-in is materialized once to `%TEMP%\ytLive-alert-{id}.mp4`; failure →
the six `AlertRenderer` animations play (fallback, not an error).
- **`Services/AlertClipDecoder.cs`** — `IAlertClipDecoder` (Start/Stop/Dispose +
`FrameAvailable(Action<VideoFrame>)` / `AudioReady(Action<AudioSample>)` /
`Completed(Action)`) + `AlertClipDecoder` impl: ffmpeg `-loglevel error` child
emitting `rawvideo bgra` (`-vf scale=W:H`) on one pipe and `f32le -ar 48000 -ac 2` on
another; `RawVideoFrameReader` (the TASK 21 media-source reader) on the video pipe,
a **carry-buffer** loop on the audio pipe (float-boundary straddles never drop a
sample — the 3-byte PCM16↔4-byte-f32 mismatch that broke the first draft), both
real-time paced. Per-play lifetime: the layer creates one per alert and disposes it at
drain. Prefer `Start`-synced `TrackFrameRate` — the frame pipe throttles to vfr
timestamps; audio paces itself. ffmpeg bgra is opaque (alpha=255) — the layer owns all
alpha.
- **`Services/AlertOverlayLayer.cs`** — clip branch beside the animation path:
`Enqueue` → `BeginClip` (factory + path + box dims seam), `Advance` advances `_elapsed`
(fade-in frame copies scale alpha 0→255 straight-source over the box), events forward
to the audio sink scaled by `volume × fade`, EOF → **freeze-frame** + fade-out over the
same `FadeDurationSeconds = 0.30` envelope → drain (StopClip + dispose, idle null
again). Ticker: `AlertTickerFrame` composes "Author — Kind · amount" from the live
message and scrolls it.
- **`Services/Compositor/AlertTickerRenderer.cs`** — announcement strip: 1920×48 transparent
BGRA, text-pill rasterized once per text on the UI thread (cached, unpremultiplied),
then pure byte-math per call, so the pump can call it from its own thread every tick
with no locking. Three display methods (`Source.AlertDisplayMethod`, creator-set in the
panel):
- `TickerScroll` — marquee. Paced in **reads per alert**, not px/s: one pass takes
`alertSeconds / TickerReadsPerAlert(3)`, and the speed is derived from that, so the
10s default clip carries the announcement across three times. The old fixed
`140px/s` needed ~17s per pass, i.e. barely one sighting in a 10s alert.
- `Flash` — the static pill blinks `0.5s` on / `0.5s` off (1Hz) for the whole alert;
the off half returns null so the preview collapses too.
- `Solid` — the static pill centred and still for the whole alert.
The run is phase-started half a frame in so the first published frame isn't blank,
and the wrap copies in from the right the instant the pill clears the left (a
seamless loop — the frame never goes empty).
- **Ticker threading** — dynamic overlay like the social bar, but **never baked**
(the social bar IS baked into `BakeStaticBase`; the ticker is a per-frame marquee):
`SceneCompositor.Render` + `CompositeLayers` take a trailing `tickerFrame` param
(blitted at 0,0 after the social bar), threaded by `FramePump._alertTicker`
(`Func<VideoFrame?>` seam, ctor param) through `RenderScene`/`RenderFull`/
`BuildFullRenderSignature` (mixed into the cache signature so a ticker change
invalidates — ticker is distinct from static-cache-eligible content).
- **Audio path** — `AudioMixer.EnqueueAlertAudio` uses a dedicated ring
(`AlertBufferSeconds = 8` at 48k stereo): resample/upmix to stereo 48k, drained
pre-limiter in `FillAndMix` and added at **unity** (never ducked); `StartLive` clears
it. The layer applies `volume × fade` per sample before forwarding.
- **UI — Stream Alerts section** (LeftPanel, visible only on an `AlertBox`):
"Built-in video" pill; custom path box + Browse…; "Reset to built-in"; "Message
ticker" pill; **"Display" selector (Scroll / Flash / Solid)**; "Volume" slider with %
readout. The selector is inline `<ComboBoxItem>`s bound by `SelectedValuePath="Tag"`,
the same shape as the chat Font selector in this panel — NOT a bound `ItemsSource`
(see RULE (8) in `MyMistakes.md`: a data-bound selection re-measures the panel and
breaks the real-mouse layer-drag test). Wiring: `BrowseAlertVideoCommand` +
`ResetAlertVideoCommand` (CanExecute gates on `Source { Type: AlertBox }`); browse =
OpenFileDialog (mp4/mov/webm), result writes `AlertVideoPath` + switches the pill off.
## Test (Good Dog — ONE integration test per change)
`ytLive.Tests/AlertLayerVideoTests.cs` (RealApp STA host, real WPF raster) —
`AlertVideo_PlaysCustomClip_FadesReadTime_TickerScrolls_ForwardsScaledAudio_DrainsIdle`
drives a **fake `IAlertClipDecoder`** (no ffmpeg in tests) through the whole lifecycle
in one pass: the custom path wins per-source (resolution seam asserts box W/H on the
factory + same source to the resolver); the decoder spawns on play (StartCount 1) and
dies at drain (DisposeCount 1); fade-in rides the 0.3s envelope (alpha 0 → 127 at 0.15s
→ 255, straight-alpha copy); pre-fade audio is dropped while ramp audio forwards at
`volume × fade` (0.8 × 0.5 → [0.2, −0.1]) and full-fade at volume only; the ticker
renders non-null with pixels and **scrolls** between two pump ticks while idle returns
null; EOF freeze → fade-out 127 → drain to `IsPlaying false` + null frame + disposed
decoder.
> Catches a REAL bug the animation path never had: the clip branch of `Advance` failed
> to clear `_current` before `AdvanceToNext`, so after the fade-out the layer stayed
> `IsPlaying` and re-rendered the finished alert via the animation fallback. The clean
> frame literally forced the `Assert.False(layer.IsPlaying)` to see it.
## Files (declared scope)
- `Models/Source.cs` (five alert props + `IsAlertBox`), `Services/LayoutStore.Migrations.cs`
(guarded ALTERs), `Services/LayoutStore.Save.cs` (INSERT + prune UNION),
`Services/LayoutStore.Load.cs` (reader indices),
`Services/LayoutStore.Settings.cs` (`AlertDefaultVideoKey` + asset stamps)
- `Assets/alert-default.mp4` + `ytLive.csproj` (Resource include)
- **NEW** `Services/AlertClipDecoder.cs`, **NEW** `Services/Compositor/AlertTickerRenderer.cs`
- `Services/AlertOverlayLayer.cs` (clip branch + fade + ticker + sink), `Services/Audio/AudioMixer.cs`
(alert ring + `EnqueueAlertAudio` + unity drain), `Services/Compositor/SceneCompositor.cs` +
`Services/Encoder/FramePump.cs` (ticker seam)
- `ViewModels/MainViewModel.cs` + `ViewModels/MainViewModel.Chat.cs` (seams, stamp,
resolver, materialize, browse/reset), `Controls/LeftPanel.xaml` (Stream Alerts section)
- **NEW** `ytLive.Tests/AlertLayerVideoTests.cs`
- Docs: `ai.md`, `TASKS.md`, this file, `Services/index.md`, `HANDOFF.md`, `MyMistakes.md`
## Gate
Clean build 0 warnings (Windows dotnet host); alert tests green (`AlertLayer*
FullyQualifiedName` filter = 2/2). Full suite 318/319 — the one failure
`LayerReorderPersistenceTests.RealMouseDrag…` is the HANDOFF-documented environmental
class (physical-mouse drag no-ops when the desktop/session isn't interactive; it passed
in isolation on the third re-run of this unit; the two sibling reorder tests — identical
persistence path — pass). `scripts/scope-check.sh` green.
## Post-ship crash fix (2026-09-26)
First live run found a crash the sims never could: test-tab alerts work (button commands run on
the UI thread — startup.log shows the alert clip's audio in the live mix, `peakMix 0.375 → 0.891`
with mic and loop at 0.000), but the first **real** message through the chat poller killed the app
at `11:22:01.301`:
System.ArgumentException: Must create DependencySource on same Thread as the DependencyObject.
at ...MS.Internal.Data.DataBindEngine.ProcessCrossThreadRequests()
`AlertOverlayLayer.OnMessageReceived` lacked the UI-thread marshal `ChatOverlayLayer.OnMessageReceived`
has: a polled message ran `RefreshAlertPreviews` → `UpdatePreview` on the MTA poller thread and
stamped `alertBox.VideoImageSource` (INPC-raised and WPF-bound) with a `WriteableBitmap` created
there — the binding engine's cross-thread re-bind crashed. Fix: marshal the whole ingest
(`Dispatcher.Invoke`, `Application.Current`-null guard) so enqueue, ticker, and preview writes stay
on the UI thread in lockstep. Good Dog test
`OnMessageReceived_FromPollerThread_MarshalsPreviewWritesToTheUiThread` (red on the old seam: the
WriteableBitmap's `Dispatcher` was the poller's) — committed with the fix.
## Silent-decoder fallback + decode diagnostics (2026-09-26)
Creator repro after the crash fix: On-Air → test → remove and re-add the Stream Alerts
layer → TEST tab event → procs in the chat windows but NOTHING in the alert box.
Startup.log 11:44 (the re-add) shows `Test chat simulation 3 injected` but `peakMix 0.105`
(no alert audio), vs 11:21's 0.375–0.891 (alert audio in the mix) — the re-added box's
alert never reached the mix. No exception anywhere: every failure stage in the clip path
was silent by design. Hunted facts: `LiveScene` is never assigned (the encoder always
renders `StagedScene`, MainViewModel.cs:323) so the re-added box IS in the render graph;
the bake is invalidated per-element on add; the box's DB row (`c85b46ac-…`, X=602 Y=26
W=680 H=200) is healthy; ffmpeg tools incl. the pinned binary decode the default clip to
`scale=680:200` rawvideo correctly to a file. The silent void: a decoder whose `Start()`
succeeds but whose task yields no frames AND no audio leaves `_clip != null` with
`_latestClipFrame == null` → `RenderClipFrame` returns null forever → permanently blank
box, no animation fallback (the fallback only ran on setup-time throws) and no log trail.
Fix (commit `…)`: a no-frame grace in `AlertOverlayLayer.Advance`
(`NoFrameFallbackSeconds = 1.0`) — a started clip that has produced neither a frame nor
an audio chunk inside the grace is torn down (`StopClip`), `_elapsed` resets, and the SAME
alert continues as the six-animation render (never a blank box, never drained early).
Diagnostics stop the lying silence: `BeginClip` logs the box id + resolved path + size and
the setup failure that used to only `Debug.WriteLine`; `AlertClipDecoder.RunAsync` now logs
frame/chunk end-state and the exception it used to swallow. Good Dog test
`SilentDecoder_FallsBackToTheAnimationAfterTheNoFrameGrace` pins the whole contract —
transparent inside the grace, decoder disposed past it, animation rendering, drains to
idle. This lands the corpus at 321, with the one known-env flake (RealMouseDrag reorder,
passes with no fullscreen windows up) — unrelated to this change. Unit was the direct
lineage of bug surface: **a decoder booth production failure surfaces as dead air.**
## Video-frame pacing fix (2026-09-26)
Second creator repro after the fallback commit (12:35 session): decoder HEALTHY —
`Alert clip start: box …` → `Alert clip end: … frames=240 audioChunks=156 failed=False`
twice, alert audio in the live mix (`peakMix 0.277 → 0.733`, mic/loop 0.000) — yet
"no video plays in the web-alert box", "like you lost the video". The hole was never
decode: **the video pipe was unpaced.** `AlertClipDecoderFor` (MainViewModel.Chat.cs:113)
built the decoder with no `frameRateProbe`, so `RunAsync` computed
`frameDuration = TimeSpan.Zero` and `RunVideoAsync`'s pace (`if (frameDuration > 0)`)
was skipped — all 240 frames (a 10s, 680×200 clip, ~130MB) dumped through the pipe in
the first ~1-2s as fast as ffmpeg read them, then the box sat on the LAST frame frozen
for the remaining ~6-8s while the audio chunk-pacer (50ms/chunk × 156) ran out its
real-time cadence. To the eye: a blur then a dead frame = "no video / you lost the
video". The media path already wired exactly this seam (`MediaVideoSource` gets
`frameRateProbe: new FfmpegFrameRateProbe(…)`, MainViewModel.cs:308) — the alert
factory just never supplied one.
Fix (commit `…`): `AlertClipDecoderFor` now passes
`frameRateProbe: new FfmpegFrameRateProbe(new FfmpegLocator(), () => new FfmpegDecodeProcess())`
so the 240 frames pace at the clip's native ~24fps (10s real-time), matching the audio
cadence. Good Dog test `AlertClipDecoderTests.AlertClipDecoder_PacesVideoFramesToTheProbedFrameRate`
drives the REAL `AlertClipDecoder` through the same fake-process/fake-probe seam the
media test uses (3 frames, probe 100fps → one ~10ms pacing delay per frame) — red on
the old factory (no delay), green with a probe. Full suite 321/322, one known-env flake
(RealMouseDrag reorder). **Derivative lesson → `MyMistakes.md`: a decoder that reads
faster than wall-clock needs an explicit pace; "plays but you don't see it" is pacing,
not decode.**
## Open follow-ups (NOT this unit)
- TASK 3 item 20 (RewardEvent SQLite persistence half) and item 16 (Text source) remain
queued, unchanged.
- The alert-store folder (`Apps_Commands`-style per-clip assets) stays out of scope —
the current model is read-at-play from a path, per the creator's "never store the
video" ruling.
## Take three — the video was never DRAWN (2026-09-26, the real one)
Build 89 and build 90 both decoded the clip perfectly and showed nothing. The build-90
diagnostics settled it in one run, and the answer was NOT in the video pipeline at all:
```
Alert clip start: box ce2532a1… path '…ytLive-alert-941785b0….mp4' 680x200
Alert preview: box ce2532a1… frame=680x200 a255 ← opaque, right size, every second
Output resolver: alert box 'ce2532a1…' frame 680x200 (visible=True playing=True)
Alert clip end: … frames=240 audioChunks=156 failed=False
```
The frame reached BOTH consumers, opaque and correctly sized, for the whole 10s. The clip
itself is fine — the shipped asset (`AlertDefaultVideoAssetId` = 941785b0…, md5
`0ee1f4960dd3b23dee5930a2af79d410`) is byte-identical to the creator's original
`Downloads\llamacasty-dancingLlama-thankyou.mp4`, h264 1280x720 10s, and every sampled
frame is full bright content (mean ~470/255, 100% non-zero bytes).
**Root cause: `Controls/PreviewPane.xaml`.** The per-element `Image` is `Collapsed` by
default and only becomes Visible via DataTriggers for `IsImageSource` / `IsChatBox` /
`IsWebSource` / `IsWebcam`. There was no AlertBox trigger, so the alert box's Image stayed
Collapsed forever — the bound frame was never rendered. Chat boxes showed because they
HAVE a trigger; that asymmetry is why "the chat window works but the alert layer doesn't"
looked like a decode problem. Removing/re-adding the layer could never fix it.
Fix: an `IsAlertBox` DataTrigger next to the `IsChatBox` one.
Two Good Dog tests, because the fakes had been hiding this:
- `AlertClipOutputTests.AlertClip_RealDecode_PaintsPixelsInsideTheAlertBoxRect` — the
first test in the repo that runs a REAL codec: generates a clip with the pinned ffmpeg
(`mpeg4`, not libx264 — the pinned BtbN build has no x264), drives the real
`AlertClipDecoder` + real `AlertOverlayLayer` + real `SceneCompositor`, and asserts the
box rect is a COLOURFUL picture that DIFFERS from the idle canvas. It passed even while
the feature was broken in the app, which is exactly why it was needed: it exonerated
decode+layer+compositor and pointed the hunt at the last hop. Note the compositor fills
its base with opaque black, so "any non-zero byte" is NOT a pass — compare against idle.
- `AlertBoxPreviewVisibilityTests.AlertBox_HandedAFrame_IsDrawnInThePreviewPane` — real
`MainWindow` + real `PreviewPane`, an AlertBox handed an opaque `WriteableBitmap`, assert
the bound `Image` is `Visible`. Red with `Expected: Visible / Actual: Collapsed` before
the fix, green after.
Full suite 324/324.
## Take four (2026-09-26) — the ticker was output-only, and unreadably paced
The creator confirmed the clip fix ("the video plays now"), then asked two things:
**"where is the scrolling text? I do not see it"** and whether ~3 reads inside the
10-second default alert would be readable. Answer: yes, three is right — and the text
was invisible for a structural reason, not a rendering fault.
**Bug 1 — the strip had no consumer in the preview.** `AlertTickerFrame` existed only as
a frame-pump callback, blitted into the OUTPUT at (0,0). `PreviewPane.xaml` had no
element for it because the strip is master-width and global, not a `Source`, so it
cannot ride a per-element `Image` (the trap RULE (5) records, one layer up — RULE (6)).
Fix: `tickerPreviewSink` on the ctor + `AlertOverlayLayer` (UI-thread `DispatcherTimer`
only, never the pump thread), the layer publishes from `RefreshAlertPreviews()`, and
`MainViewModel` writes it into one reused `WriteableBitmap`
(`AlertTickerImageSource` / `AlertTickerVisible`) bound to a new global
`AlertTickerElement` in `PreviewPane.xaml`, mirroring `SocialBarElement`.
**Bug 2 — a fixed px/s cannot express "three reads".** See the renderer note above.
Incumbent convention agrees: Streamlabs exposes *Alert Duration* ("choose how long your
alert stays on your stream") and *Text Delay*, never a scroll-speed slider
(https://support.streamlabs.com/hc/en-us/articles/52499995174299-Setting-up-Your-Streamlabs-Alerts),
so the unit is repeats-per-alert, not pixels-per-second.
**Persistence** — `Source.AlertDisplayMethod INTEGER NOT NULL DEFAULT 0` via the
idempotent `PRAGMA table_info` migration (no `user_version` bump: the guard is the
column's presence). The column is appended **last** in the `SELECT` on purpose — the
reader is POSITIONAL (`GetInt32(32..34)`), so inserting anywhere but the end would
silently shift a neighbour rather than fail to load.
**Tests** (`ytLive.Tests/AlertTickerPreviewDisplayTests.cs`, RealApp STA host):
the pane draws the strip and collapses when the alert ends; the layer publishes a real
1920x48 frame for all three methods (and nothing at all when the ticker is off); three
passes are counted in 10s by the pill's leading edge resetting (a seamless marquee never
blanks, so "blank frame" cannot count a pass); Solid is byte-identical at every moment;
Flash is on for half of each second; the panel shows and writes back the chosen method.
`LayoutStorePersistenceTests.AlertDisplayMethod_RoundTrips` guards the positional-reader
risk by asserting all three alert fields together.
Full suite 339/339.