diff --git a/SEAMS.md b/SEAMS.md index 0c7d47aa..8182fc0a 100644 --- a/SEAMS.md +++ b/SEAMS.md @@ -44,7 +44,7 @@ > environment- or flag-gated `Add*` branch exists today), so nothing is known to be missing > from this map for that reason. > -> **77 seams across 6 projects.** +> **79 seams across 6 projects.** ## GenWave.Context (2 seams) @@ -89,7 +89,7 @@ | `GenWave.Core.Abstractions.IEnergyAnalyzer` | `GenWave.Loudness.FfmpegEnergyAnalyzer` | Singleton | — | | `GenWave.Core.Abstractions.ILoudnessAnalyzer` | `GenWave.Loudness.FfmpegLoudnessAnalyzer` | Singleton | — | -## GenWave.MediaLibrary (30 seams) +## GenWave.MediaLibrary (32 seams) | Port | Adapter | Lifetime | Notes | |---|---|---|---| @@ -121,6 +121,8 @@ | `GenWave.Core.Abstractions.IRequestCatalogProbe` | `GenWave.MediaLibrary.Catalog.RequestCatalogProbeRepository` | Singleton | — | | `GenWave.Core.Abstractions.IRequestStore` | `GenWave.MediaLibrary.Station.RequestRepository` | Singleton | — | | `GenWave.Core.Abstractions.IScheduleStore` | `GenWave.MediaLibrary.Station.ScheduleRepository` | Singleton | — | +| `GenWave.Core.Abstractions.IShowImagingScope` | `GenWave.MediaLibrary.Catalog.ShowImagingScopeRepository` | Singleton | — | +| `GenWave.Core.Abstractions.IShowStore` | `GenWave.MediaLibrary.Station.ShowRepository` | Singleton | — | | `GenWave.Core.Abstractions.IThemeStore` | `GenWave.MediaLibrary.Station.ThemeRepository` | Singleton | — | | `GenWave.Core.Abstractions.IYearLookup` | `GenWave.MediaLibrary.YearLookup.MusicBrainzYearLookup` | Singleton | — | diff --git a/admin-ui/__specs__/catalog-show-shelf.spec.tsx b/admin-ui/__specs__/catalog-show-shelf.spec.tsx new file mode 100644 index 00000000..f6ab86e0 --- /dev/null +++ b/admin-ui/__specs__/catalog-show-shelf.spec.tsx @@ -0,0 +1,16 @@ +// STORY-315 — Hire a show from the shelf (F118.2, F118.3) — shelf/modal half — PENDING +// scaffold (T255, planned 2026-08-10). The import endpoint half is xUnit +// (Host.Tests/Story315_ShowImport.cs). + +describe("Feature: The show shelf", () => { + describe("Scenario: browsing show cards", () => { + it.todo("show cards render name, tagline, and bestFor chips beside personas/themes/fonts"); + it.todo("the detail modal shows the FULL card including flavor before confirm (F90 trust posture)"); + }); + + describe("Scenario: the soft hire offer", () => { + it.todo("offers 'also hire' only when the suggested persona is on the shelf and not hired"); + it.todo("declining the offer imports the show and hires nothing"); + it.todo("an absent or unknown suggestion renders no offer and no error"); + }); +}); diff --git a/admin-ui/__specs__/imaging-show-scope.spec.tsx b/admin-ui/__specs__/imaging-show-scope.spec.tsx new file mode 100644 index 00000000..3dc1c493 --- /dev/null +++ b/admin-ui/__specs__/imaging-show-scope.spec.tsx @@ -0,0 +1,10 @@ +// STORY-313 — Span-assign & imaging scope (F119.4) — imaging half — PENDING scaffold +// (T246, planned 2026-08-10). Minimal delta on the imaging editor: the scope picker only. + +describe("Feature: Imaging show scope", () => { + describe("Scenario: authoring with a scope", () => { + it.todo("the scope picker defaults to station-wide"); + it.todo("selecting a show sends the scope with the authored insert"); + it.todo("existing authored rows render their scope (station-wide | show name)"); + }); +}); diff --git a/admin-ui/__specs__/schedule-show-picker.spec.tsx b/admin-ui/__specs__/schedule-show-picker.spec.tsx new file mode 100644 index 00000000..37f3e4ae --- /dev/null +++ b/admin-ui/__specs__/schedule-show-picker.spec.tsx @@ -0,0 +1,15 @@ +// STORY-313 — Span-assign & imaging scope (F119.2) — grid picker half — PENDING scaffold +// (T245, planned 2026-08-10). The imaging-scope half is imaging-show-scope.spec.tsx. + +describe("Feature: Grid show picker with span-assign", () => { + describe("Scenario: the run rule", () => { + it.todo("assigning from any block applies to the whole contiguous same-persona run by default"); + it.todo("the narrow checkbox limits assignment to the single clicked block"); + it.todo("the picker lists shows by name with a clear-none option"); + }); + + describe("Scenario: runs end honestly", () => { + it.todo("span-assign stops at a music-only block (the pinned span rule)"); + it.todo("span-assign stops at an other-persona block"); + }); +}); diff --git a/admin-ui/__specs__/schedule-specials-form.spec.tsx b/admin-ui/__specs__/schedule-specials-form.spec.tsx new file mode 100644 index 00000000..72699c1d --- /dev/null +++ b/admin-ui/__specs__/schedule-specials-form.spec.tsx @@ -0,0 +1,14 @@ +// STORY-317 — Dated specials shadow the grid (F120.3) — form half — PENDING scaffold +// (T259, planned 2026-08-10). 🪂 DROPPABLE SLICE: dropping PR 5 removes these todos with it. +// Deliberately a dated-list form, NOT a second paint grid. + +describe("Feature: The specials form", () => { + describe("Scenario: authoring a dated special", () => { + it.todo("creates a special with date, span, persona, show, and envelope"); + it.todo("lists upcoming specials by date with edit/delete"); + }); + + describe("Scenario: rejections surface honestly", () => { + it.todo("an overlapping span on the same date surfaces the EXCLUDE rejection in place"); + }); +}); diff --git a/admin-ui/__specs__/shows-page.spec.tsx b/admin-ui/__specs__/shows-page.spec.tsx new file mode 100644 index 00000000..45ef53c2 --- /dev/null +++ b/admin-ui/__specs__/shows-page.spec.tsx @@ -0,0 +1,22 @@ +// STORY-312 — The Shows page (F119.1, F119.3) — PENDING scaffold (T244, planned 2026-08-10) +// +// it.todo until /build-loop implements the page; UI-flow ACs beyond these are browser +// acceptance per the T92 precedent. + +describe("Feature: The Shows page", () => { + describe("Scenario: authoring in place", () => { + it.todo("renders the show list with the provenance line on imported shows"); + it.todo("creates a show with name/tagline/flavor under budget maxlengths (60/120/400)"); + it.todo("edits an authored show and round-trips every field"); + it.todo("supports several shows referencing the same persona's blocks (one DJ, many shows)"); + }); + + describe("Scenario: guarded delete UX", () => { + it.todo("surfaces the 409 refusal naming the referencing schedule blocks"); + it.todo("deletes an unreferenced show after confirm"); + }); + + describe("Scenario: coverage stays neutral", () => { + it.todo("shows no nudge, badge, or warning anywhere for unnamed blocks (F119.3)"); + }); +}); diff --git a/db/01-library.sh b/db/01-library.sh index f70237ca..2aa1ed57 100755 --- a/db/01-library.sh +++ b/db/01-library.sh @@ -226,6 +226,15 @@ psql -v ON_ERROR_STOP=1 -v pw="$LIBRARY_DB_PASSWORD" \ add column imaging_kind text check (imaging_kind is null or imaging_kind in ('liner', 'station_id', 'jingle', 'promo')); + -- show_id (SPEC F119.4, STORY-305/STORY-310, PLAN T238): scopes an authored imaging row to a + -- station.show. Crosses the db/22 schema-role boundary (station_svc has no grant into library) the + -- same way booth_log.media_id already crosses it in the other direction -- plain int, deliberately + -- NO FK, resolved by the app at its own edge, never a cross-schema join. NULL = station-wide + -- (every row today); set only on an authored imaging row scoped to a show once T246 wires the + -- write path. NO CONSUMER YET (T238): the pool query gains the show filter at T250. + alter table library.media + add column show_id int; + -- Composite partial index: scope-filtered random-ready pick (replaces scalar media_ready). create index media_scope_ready on library.media (library_id, state) where state = 'ready'; create index media_artist on library.media (artist); -- ready for criteria queries diff --git a/db/06-station-settings-migration.sh b/db/06-station-settings-migration.sh index 48327079..18777999 100755 --- a/db/06-station-settings-migration.sh +++ b/db/06-station-settings-migration.sh @@ -184,7 +184,15 @@ psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-' -- would otherwise force this migration to keep pace with. NO CONSUMER YET (T219): T220 wires -- the write path. See db/33-show-and-segment-kind-migration.sh for the in-place upgrade path -- this column also ships as. - segment_kind text + segment_kind text, + -- SPEC F121.1, STORY-310, PLAN T238/T242: the air-time show stamp, written the same + -- synchronous-at-write-time way as persona_id/artist/pick/segment_kind above. NULL for every + -- row aired outside a show or predating this column. Deliberately NO FK — history must outlive + -- the entity; a deleted show must never rewrite or block on past airings (the exact media_id/ + -- segment_kind precedent already on this table). NO CONSUMER YET (T238): T242 wires the write + -- path. See db/35-show-identity-migration.sh for the in-place upgrade path this column also + -- ships as. + show_id int ); -- Keyset paging spine (SPEC F72.2): newest-first (occurred_at DESC, id DESC) with no OFFSET — @@ -244,20 +252,40 @@ psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-' CREATE INDEX IF NOT EXISTS request_pending ON station.request (status, expires_at); - -- Shows (SPEC F114, gh-#383 — the later slice, schema ruled at STORY-304/T219): a first-class - -- entity, singular like every other table in this schema (station.persona precedent) — renaming a - -- show touches one row, and identity is what patter/idents/spectator will reference once the - -- F114 slice lands. Defined here, ahead of station.segment_schedule below, purely so that table's - -- show_id column has something to reference — the two tables carry no other ordering relationship. - -- NO CONSUMER YET (T219): station.show stays dormant by design until F114 wires a writer/reader, - -- the same "seam before consumer" way station.persona_taste (T59), station.theme (T181), and - -- station.font_pack (T198) all shipped. See db/33-show-and-segment-kind-migration.sh for the - -- in-place upgrade path this table also ships as. + -- Shows (SPEC F114/F115, gh-#383 — the later slice, schema ruled at STORY-304/T219 then widened + -- at STORY-305/STORY-310/T238): a first-class entity, singular like every other table in this + -- schema (station.persona precedent) — renaming a show touches one row, and identity is what + -- patter/idents/spectator will reference once the F114/F115 slices land. Defined here, ahead of + -- station.segment_schedule below, purely so that table's show_id column has something to + -- reference — the two tables carry no other ordering relationship. + -- + -- slug is the import identity (a catalog slug for an import, the house Slugify output for an + -- authored show — T239), UNIQUE and NOT NULL — safe with no backfill because this table is still + -- empty on every install (NO CONSUMER YET below). tagline is public (broadcast-shaped); flavor is + -- prompt-only and NEVER public (F115.3 — the persona-soul precedent). imported_from/imported_at + -- mirror station.persona's own db/25 provenance pair exactly. + -- + -- persona_id/envelope are DORMANT bundle columns (ARCHITECTURE.md ruled 2026-08-10): UNREAD until + -- the deferred schedulable-bundle slice. Future semantics recorded there, not enforced here: + -- effective assignment = block ?? show ?? none, block always wins. + -- + -- NO CONSUMER YET (T219, still true after T238's widening): station.show stays dormant by design + -- until F114/F115 wire a writer/reader, the same "seam before consumer" way station.persona_taste + -- (T59), station.theme (T181), and station.font_pack (T198) all shipped. See + -- db/33-show-and-segment-kind-migration.sh and db/35-show-identity-migration.sh for the in-place + -- upgrade paths this table also ships as. CREATE TABLE IF NOT EXISTS station.show ( - id serial PRIMARY KEY, - name text NOT NULL CHECK (length(btrim(name)) > 0), - created_at timestamptz NOT NULL DEFAULT now(), - updated_at timestamptz NOT NULL DEFAULT now() + id serial PRIMARY KEY, + name text NOT NULL CHECK (length(btrim(name)) > 0), + slug text NOT NULL CONSTRAINT show_slug_key UNIQUE, + tagline text, + flavor text, + imported_from text, + imported_at timestamptz, + persona_id int REFERENCES station.persona (id), + envelope jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() ); -- The weekly format-clock grid (SPEC F91.1, F91.2; STORY-240, STORY-242; PLAN T118) that replaces diff --git a/db/35-show-identity-migration.sh b/db/35-show-identity-migration.sh new file mode 100755 index 00000000..e5b7cc54 --- /dev/null +++ b/db/35-show-identity-migration.sh @@ -0,0 +1,75 @@ +#!/bin/bash +# 35-show-identity-migration.sh — idempotent in-place upgrade for existing DBs. +# Widens station.show into the F115 identity package (name/slug/tagline/flavor/provenance) plus the +# DORMANT F115.2/F121 bundle columns, stamps station.booth_log and library.media with an air-time +# show_id (F121.1, F119.4); STORY-305, STORY-310, PLAN T238. ARCHITECTURE.md "Dayparting: named +# shows" -> "Data model" has this DDL verbatim. +# +# station.show shipped DORMANT in db/33 (T219) with no writer anywhere — every install has an empty +# table today, which is what makes `slug text NOT NULL` safe to add here without a backfill or a +# DEFAULT. slug is the import identity (a catalog slug for an imported show, the house Slugify output +# for an authored one — T239) and carries a named UNIQUE constraint so a fresh-init table and an +# upgraded one converge on the identical constraint name. tagline is public (broadcast-shaped, the +# spectator DTO and ceremony read it once F115 wires a reader); flavor is prompt-only and NEVER public +# (F115.3 — the persona-soul precedent). imported_from/imported_at mirror station.persona's own db/25 +# provenance pair exactly: the catalog entry slug for an import, the literal 'file' for an upload, +# NULL for an authored-in-place show. +# +# persona_id/envelope are the DORMANT bundle columns (ARCHITECTURE.md ruled 2026-08-10): UNREAD until +# the deferred schedulable-bundle slice. Future semantics recorded there, not enforced by this schema: +# effective assignment = block ?? show ?? none, block always wins. persona_id is a plain nullable FK +# (no ON DELETE override needed — the column has no consumer to make deletion a live concern yet); +# envelope is jsonb for the same open-ended-shape reason station.theme/font_pack definitions are. +# NO CONSUMER YET for either column, same "seam before consumer" precedent as station.show itself. +# +# booth_log.show_id is the air-time stamp (F121.1, STORY-310) — the same synchronous-at-write-time +# discipline persona_id/artist/pick/segment_kind on this table already use (db/17/18/33). Deliberately +# NO FK: history must outlive the entity (the exact booth_log.persona_id-would-need vs media_id/ +# segment_kind precedent already on this table) — a deleted show must never rewrite or block on past +# airings. +# +# library.media.show_id crosses the db/22 schema-role boundary (station_svc has no grant into +# library) the same way booth_log.media_id already crosses it in the other direction — plain int, NO +# FK, resolved by the app at its own edge, never a cross-schema join. NULL = station-wide (today's +# only meaning); set only on an authored imaging row scoped to a show (T246). +# +# Safe to run multiple times: ADD COLUMN IF NOT EXISTS is a no-op on an already-migrated database. +set -euo pipefail + +: "${POSTGRES_USER:?POSTGRES_USER must be set}" "${POSTGRES_DB:?POSTGRES_DB must be set}" + +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-'SQL' + set role station_svc; + set search_path = station; + + -- The identity package + provenance (station.show shipped dormant in db/33; empty everywhere, + -- so NOT NULL is safe to add with no backfill). Named UNIQUE so a fresh-init table (db/06's own + -- CREATE, already widened) and this upgraded one land on the identical constraint name. + alter table station.show + add column if not exists slug text not null constraint show_slug_key unique, + add column if not exists tagline text, + add column if not exists flavor text, + add column if not exists imported_from text, + add column if not exists imported_at timestamptz, + -- Dormant bundle columns (ruled 2026-08-10): UNREAD until the schedulable-bundle slice. + -- Future semantics recorded: effective = block ?? show ?? none; block always wins. + add column if not exists persona_id int references station.persona (id), + add column if not exists envelope jsonb; + + -- Air-time stamp (F121.1). NO FK — history outlives the entity, same as media_id/segment_kind + -- already on this table. + alter table station.booth_log + add column if not exists show_id int; + SQL + +# library schema (separate connection/grant — the db/22 boundary): its own psql invocation, its own +# role, mirroring how db/34 talks to library.media. +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-'SQL' + set role library_svc; + set search_path = library; + + -- NO FK across the grant boundary (db/22 precedent). NULL = station-wide; set only on + -- authored imaging rows scoped to a show (T246). + alter table library.media + add column if not exists show_id int; + SQL diff --git a/src/GenWave.Abstractions/Abstractions/IActivePersonaAccessor.cs b/src/GenWave.Abstractions/Abstractions/IActivePersonaAccessor.cs index e0cc0587..67ff71bc 100644 --- a/src/GenWave.Abstractions/Abstractions/IActivePersonaAccessor.cs +++ b/src/GenWave.Abstractions/Abstractions/IActivePersonaAccessor.cs @@ -3,12 +3,20 @@ namespace GenWave.Core.Abstractions; /// -/// SEAM (SPEC F35.2, F35.5) — the thin Core-visible accessor between +/// SEAM (SPEC F35.2, F35.5, F121.1) — the thin Core-visible ON-AIR IDENTITY accessor between /// GenWave.Orchestration (which cannot see the Host's IOptionsMonitor<StationOptions> /// or directly) and the Host's live station configuration + persona /// storage. Mirrors 's seam shape one level up: both sides of the /// boundary depend on this one interface instead of inventing separate idioms. /// +/// Answers BOTH halves of "who/what is on air right now" — the persona (, +/// ) and, since PLAN T242, the show () — because +/// a single implementation already owns the one on-air resolve both facts are read off (see +/// OnAirPersonaAccessor's own remarks for how). Show identity rides THIS seam rather than a +/// second one for that reason: a dedicated "active show" seam would just duplicate the same resolver +/// dependency and the same never-throws/degrade discipline for no gain, when the implementation that +/// already exists can name both. +/// /// Implementations MUST re-evaluate the active persona id fresh on every call — never cache it in a /// field — so a live activate/deactivate (the F19 overlay write) is visible to the very next render /// with no process restart. @@ -79,4 +87,24 @@ public interface IActivePersonaAccessor /// /// string? TryGetCachedName(long personaId) => null; + + /// + /// Synchronous, in-memory read of the on-air show's id (SPEC F121.1, STORY-310, PLAN T242) — the + /// SAME resolver-backed "who/what is on air right now" answer + /// already reads, off the SAME cached snapshot source, exposed on this seam rather than a second + /// one so the booth log's hot-path publish (BoothLogWriter.Publish) draws both stamps off + /// ONE dependency, the same "never a store round trip" way already + /// does. NOT literally one read at one instant: each property getter independently re-resolves + /// against the current wall clock (see the concrete resolver's own remarks), so a schedule + /// boundary landing between Publish's two reads can split the pair on at most one narrative + /// row — accepted, not a new risk this member introduces. No store round trip, no awaiting. + /// + /// Same never-throws, same null contract as : + /// for a grid gap, an unnamed block, or any resolver fault — the default "no show" state, not a + /// degradation. Default-implemented as "no show" for the same additive reason + /// is: every pre-F121 implementer (a test double, an older SDK + /// consumer) keeps compiling unchanged and simply reports "no show on air" until it opts in with a + /// real override. + /// + long? ActiveShowId => null; } diff --git a/src/GenWave.Abstractions/Domain/ScheduleSegment.cs b/src/GenWave.Abstractions/Domain/ScheduleSegment.cs index 21a33ddc..2946a308 100644 --- a/src/GenWave.Abstractions/Domain/ScheduleSegment.cs +++ b/src/GenWave.Abstractions/Domain/ScheduleSegment.cs @@ -14,6 +14,17 @@ namespace GenWave.Core.Domain; /// station-default envelope" (F91.4) — each is independently nullable, so a segment may override only /// one of the three while the others fall back to the station default. /// +/// +/// +/// (SPEC F116.1, STORY-306, PLAN T241) is this block's own named show, or +/// for an unnamed (painted-persona-only or music-only) block — +/// GenWave.MediaLibrary.Station.ScheduleRepository resolves it at LOAD time (a join against +/// station.show keyed by segment_schedule.show_id), never a per-tick lookup, so the week +/// snapshot this record is part of already carries every block's show identity in memory before +/// ScheduleResolver ever runs. Defaults to so every pre-T241 +/// construction site (test fixtures included) stays diff-free — the additive-null-member shape SPEC +/// F116.1's own "showless station byte-identical" test leans on. +/// /// public sealed record ScheduleSegment( long? Id, @@ -23,4 +34,5 @@ public sealed record ScheduleSegment( long? PersonaId, IReadOnlyList? Genres, double? EnergyMin, - double? EnergyMax); + double? EnergyMax, + ShowSummary? Show = null); diff --git a/src/GenWave.Abstractions/Domain/ScheduleWeekVersion.cs b/src/GenWave.Abstractions/Domain/ScheduleWeekVersion.cs index ff6b1f76..64771d92 100644 --- a/src/GenWave.Abstractions/Domain/ScheduleWeekVersion.cs +++ b/src/GenWave.Abstractions/Domain/ScheduleWeekVersion.cs @@ -14,11 +14,19 @@ namespace GenWave.Core.Domain; /// /// /// Pure CONTENT hash: SHA-256 over an invariant-culture rendering of every segment's -/// day/start/end/persona/envelope, ordered by day then start minute. Row ids are deliberately -/// EXCLUDED — ReplaceWeekAsync is delete-then-insert, so ids churn on every write even when -/// the content is identical; a version that changed with them would 409 an editor whose grid still -/// matches the stored week exactly. Two weeks with the same content are, for staleness purposes, -/// the same week. +/// day/start-minute/end-minute/persona-id/genres/energy-min/energy-max, ordered by day then start +/// minute. Row ids are deliberately EXCLUDED — ReplaceWeekAsync is delete-then-insert, so ids +/// churn on every write even when the content is identical; a version that changed with them would +/// 409 an editor whose grid still matches the stored week exactly. Two weeks with the same content +/// are, for staleness purposes, the same week. +/// +/// +/// +/// (SPEC F116.1, PLAN T241) is ALSO deliberately excluded — show +/// assignment is read-only through this epic, so this hash has nothing to protect yet. T243's writer +/// (the first caller that lets an editor SET Show) must add it to the fields rendered here, or +/// this staleness guard goes blind to concurrent show-assignment writes — the exact gh-#255 +/// save-loss class this type exists to prevent, just for a field it doesn't yet know about. /// /// public static class ScheduleWeekVersion diff --git a/src/GenWave.Abstractions/Domain/ShowSummary.cs b/src/GenWave.Abstractions/Domain/ShowSummary.cs new file mode 100644 index 00000000..60244017 --- /dev/null +++ b/src/GenWave.Abstractions/Domain/ShowSummary.cs @@ -0,0 +1,35 @@ +namespace GenWave.Core.Domain; + +/// +/// The show identity a schedule block can carry (SPEC F115.1, F116.1; STORY-306, PLAN T241): the +/// narrow, resolver-facing projection of station.show that rides +/// and, from there, +/// . Lives beside +/// in the published GenWave.Abstractions contract surface (same reason that type does — a +/// caller building against the SDK needs to see show identity riding the resolver's own snapshot), +/// NOT beside the full Show CRUD entity (GenWave.Core.Domain.Show, unpublished), which +/// this type is not. +/// +/// +/// Deliberately excludes every station.show column beyond identity: Slug/ +/// ImportedFrom/ImportedAt/CreatedAt/UpdatedAt are Show's own CRUD +/// concern, never needed on the air. Above all, this type has NO member for the DORMANT +/// persona_id/envelope bundle columns (SPEC F115.2 — unread this epic, a law not an +/// oversight): no query that projects a row into this type can even ACCIDENTALLY carry them forward, +/// and no consumer reading can observe them +/// either — the dormant-columns-unread pin is enforced by this type's own SHAPE, not merely by a +/// query that happens not to select them today. +/// +/// +/// Public, broadcast-shaped (SPEC F115.3) — safe for the spectator surface. +/// when the show carries none. +/// Prompt-only, private forever (SPEC F115.3 — the persona-soul precedent): +/// despite riding this same internal snapshot, a consumer building a PUBLIC payload (spectator, T251) +/// must never forward this field. The same law covers log lines (F115.3's "no log line" half): this +/// record's compiler-generated ToString() renders verbatim, so no +/// {Segment}/{Snapshot}-style structured-log placeholder may ever bind an +/// or — or this type +/// directly — on any public-adjacent logging path; log the identity fields +/// (/) by name instead. when the show +/// carries none. +public sealed record ShowSummary(long Id, string Name, string? Tagline, string? Flavor); diff --git a/src/GenWave.Abstractions/Playout/OnAirSnapshot.cs b/src/GenWave.Abstractions/Playout/OnAirSnapshot.cs index 3c857c90..615784bb 100644 --- a/src/GenWave.Abstractions/Playout/OnAirSnapshot.cs +++ b/src/GenWave.Abstractions/Playout/OnAirSnapshot.cs @@ -29,9 +29,19 @@ namespace GenWave.Abstractions.Playout; /// when a gap follows. Same F92.3 ruling as : a /// same-persona successor (e.g. the F91.6 seeded grid's own midnight roll) is still reported here, never /// collapsed away just because it will air no handoff ceremony. +/// SPEC F116.1 (STORY-306, PLAN T241) — the on-air show identity, or +/// on a grid gap or an unnamed block. Resolver-sourced through +/// GenWave.Orchestration.EffectiveAssignment, the ONE identity chokepoint (SPEC F115.2): always +/// exactly 's own , carried +/// here for the same reason already is — a caller never has to null-check +/// first just to learn which show (if any) is on. Every downstream consumer this +/// epic adds (ceremony T248, the flavor line T249, show idents T250, spectator T251, the booth-log +/// stamp T242) reads identity through THIS member, never re-deriving it from or +/// re-querying station.show itself. public sealed record OnAirSnapshot( ScheduleSegment? Segment, long? PersonaId, SegmentEnvelope Envelope, DateTimeOffset? BoundaryAt, - ScheduleSegment? NextSegment); + ScheduleSegment? NextSegment, + ShowSummary? Show = null); diff --git a/src/GenWave.Core/Abstractions/IBoothLogAppender.cs b/src/GenWave.Core/Abstractions/IBoothLogAppender.cs index a8e16bd0..064e180c 100644 --- a/src/GenWave.Core/Abstractions/IBoothLogAppender.cs +++ b/src/GenWave.Core/Abstractions/IBoothLogAppender.cs @@ -1,3 +1,5 @@ +using GenWave.Core.Domain; + namespace GenWave.Core.Abstractions; /// @@ -11,26 +13,9 @@ namespace GenWave.Core.Abstractions; public interface IBoothLogAppender { /// - /// Appends one narrative row (, ), stamped - /// now(). (SPEC F84.6, STORY-215) is the persona active on - /// air at write time for a TRACK-START row — for every other kind, or a - /// persona-less airing. (SPEC F84.1, STORY-215, PLAN T70) is that same - /// track's artist, captured the same way and for the same reason: the accrual write path needs a - /// STRUCTURED artist to build an artist-predicate rule from, never a regex over - /// 's narrative prose. Never surfaced through - /// — read directly by the accrual store only. for every non-track row or a - /// track aired with no known artist. (SPEC F86.1, STORY-217, PLAN T73) is - /// that same track's persona-pick stamp — the caller's already-serialized jsonb text (see - /// GenWave.Core.Domain.BoothLogPickStampSerializer), or for every - /// non-track row, an engine-initiated play, or a persona-off pick. Never backfilled. - /// (gh-#99) is the aired row's numeric catalog id — captured the same - /// way, for every non-track row or a non-catalog id. It exists so the - /// Host can resolve safe-scope membership for the taste-thumb exclusion on the library - /// connection; station.booth_log itself can never join library.media. - /// (SPEC F113.1, STORY-304, PLAN T220) is that same track's - /// air-time SegmentKind token name (e.g. "StationId"), captured the same way — - /// for a music row or a non-track row. This is the demo-hour - /// observability instrument's own column; never inferred from summary. + /// Appends one narrative row, stamped now(). See 's own + /// field docs for what each field carries, and when it is — this method + /// derives nothing from beyond what is already stamped on it. /// - Task AppendAsync(string kind, string summary, long? personaId, string? artist, string? pick, long? mediaId, string? segmentKind, CancellationToken ct); + Task AppendAsync(BoothLogAppendRequest request, CancellationToken ct); } diff --git a/src/GenWave.Core/Abstractions/IScheduleStore.cs b/src/GenWave.Core/Abstractions/IScheduleStore.cs index b4c84a05..ecdcd572 100644 --- a/src/GenWave.Core/Abstractions/IScheduleStore.cs +++ b/src/GenWave.Core/Abstractions/IScheduleStore.cs @@ -55,4 +55,16 @@ Task ReplaceWeekAsync( /// . /// event Action? WeekChanged; + + /// + /// Every station.segment_schedule row naming , ordered by day + /// then start minute — the show delete guard's own detail read (SPEC F115.4, PLAN T240): + /// stays a bare singleton at the store seam (see + /// its own remarks), so ShowsController.Delete calls this directly to NAME the blocking + /// slots in its 409 body, mirroring PersonaRepository.DeleteAsync's own pre-T121 + /// query-for-detail shape but at the endpoint layer instead — IShowStore never pre-queries + /// this table itself (PLAN T239's own deliberate choice). An empty result means nothing in + /// station.segment_schedule currently names this show. + /// + Task> GetSlotsByShowIdAsync(long showId, CancellationToken ct); } diff --git a/src/GenWave.Core/Abstractions/IShowImagingScope.cs b/src/GenWave.Core/Abstractions/IShowImagingScope.cs new file mode 100644 index 00000000..7019b231 --- /dev/null +++ b/src/GenWave.Core/Abstractions/IShowImagingScope.cs @@ -0,0 +1,29 @@ +using GenWave.Core.Domain; + +namespace GenWave.Core.Abstractions; + +/// +/// SPEC F115.4 — the narrow cross-schema answer the show delete guard needs: which +/// library.media rows are scoped (show_id, F117.1 — no FK) to a show that has just been +/// deleted? station_svc deliberately has no grant on library.media (the db/22 boundary — +/// the same reason resolves ITS cross-schema question on the +/// library connection instead of a join), so this seam does too. +/// +/// Deliberately its own narrow interface, mirroring 's own "one +/// question, its own seam" posture: ShowsController's delete guard is the only consumer, and +/// the question — "which rows, and clear them" — never grows read-amplification temptations onto a +/// wider media interface. +/// +public interface IShowImagingScope +{ + /// + /// Best-effort orphan prevention (SPEC F115.4): clears show_id on every library.media + /// row currently scoped to and returns exactly the rows cleared, in + /// id order — the delete guard's response names them in the SAME round trip that unscopes + /// them (an UPDATE ... RETURNING, atomic — no separate SELECT-then-UPDATE). Idempotent: a + /// repeat call against an already-cleared matches nothing and returns an + /// empty list, never an error — library.media.show_id carries no FK to violate, so there is + /// nothing for a second write to conflict with. + /// + Task> UnscopeAsync(long showId, CancellationToken ct); +} diff --git a/src/GenWave.Core/Abstractions/IShowStore.cs b/src/GenWave.Core/Abstractions/IShowStore.cs new file mode 100644 index 00000000..17400d44 --- /dev/null +++ b/src/GenWave.Core/Abstractions/IShowStore.cs @@ -0,0 +1,61 @@ +using GenWave.Core.Domain; + +namespace GenWave.Core.Abstractions; + +/// +/// SEAM (SPEC F115.1, STORY-305, PLAN T239) — CRUD access to station.show: the named-show +/// identity package (name/tagline/flavor/provenance) an hour of airtime can carry. Deliberately never +/// maps, reads, or writes persona_id/envelope (SPEC F115.2 — a law of the epic, not an +/// oversight); a future schedulable-bundle slice adds that seam separately. No DI registration and no +/// consumer land with this seam — /api/shows (PLAN T240) is the first. +/// +public interface IShowStore +{ + /// Returns every show row, ordered by name. + Task> GetAllAsync(CancellationToken ct); + + /// Returns the show identified by , or null if no such row exists. + Task GetByIdAsync(long id, CancellationToken ct); + + /// Returns the show identified by , or null if no such row exists + /// — the primitive a slug-addressed route (/api/shows/{slug}) resolves through. + Task GetBySlugAsync(string slug, CancellationToken ct); + + /// + /// Creates a new authored show from : slug is derived from + /// 's Name via the house Slugify, imported_from/ + /// imported_at stay null. Returns with the new row on + /// success, if Name is blank/whitespace-only or + /// its Slugify output equals the fallback literal "persona" — whether by Slugify's own + /// empty-slug rescue (an emoji-only name) or the ordinary path landing on that same string (e.g. + /// the literal name "Persona" itself; see 's own + /// remarks, PLAN T240 review A1), if a field exceeds its SPEC F115.1 1× + /// budget (checked before the write ever reaches Postgres), or + /// if the derived slug collides with an existing show + /// (enforced by the DB's UNIQUE(slug), not a pre-read). + /// + Task CreateAsync(ShowDraft draft, CancellationToken ct); + + /// + /// Updates the show identified by with 's fields — + /// re-derives slug from the new Name the same way does, and + /// never touches imported_from/imported_at. Returns + /// with the row after the write (updated_at advanced) on success, + /// if no such show exists, + /// / the same + /// way does, or if another show + /// already holds the re-derived slug. + /// + Task UpdateAsync(long id, ShowDraft draft, CancellationToken ct); + + /// + /// Deletes the show identified by . Returns + /// on success, if no + /// such show exists, or if + /// station.segment_schedule.show_id still names it (the FK's own ON DELETE RESTRICT, + /// caught as SQLSTATE 23503) — this seam's own case carries no detail beyond "referenced". Naming + /// the format-clock blocks (and scoped imaging rows) a still-referenced show cannot be deleted + /// through is the endpoint-layer guard PLAN T240 builds on top of that case. + /// + Task DeleteAsync(long id, CancellationToken ct); +} diff --git a/src/GenWave.Core/Domain/BoothLogAppendRequest.cs b/src/GenWave.Core/Domain/BoothLogAppendRequest.cs new file mode 100644 index 00000000..7a2893c2 --- /dev/null +++ b/src/GenWave.Core/Domain/BoothLogAppendRequest.cs @@ -0,0 +1,36 @@ +namespace GenWave.Core.Domain; + +/// +/// One station.booth_log row awaiting persistence — 's +/// parameter object (PLAN T220 review carry-forward: "IBoothLogAppender 8-param positional call wants +/// a Core-side parameter object"; closed here at PLAN T242, where would have +/// pushed that positional call to nine). Every field mirrors a stamp BoothLogWriter.Publish +/// already captured SYNCHRONOUSLY, at AIR time, before the request was ever queued for +/// BoothLogDrainService to drain — this type only regroups them into one argument; it derives +/// nothing of its own and enforces no invariant beyond the shape. +/// +/// The narrative kind (e.g. "track-started", "patter-aired"). +/// The operator-readable narrative line — human language, never a JSON dump. +/// SPEC F84.6, STORY-215 — the persona on air at write time for a +/// TRACK-START row. for every other kind, or a persona-less airing. +/// SPEC F84.1, STORY-215, PLAN T70 — that same track's artist, captured the same +/// way and for the same reason: the accrual write path needs a STRUCTURED artist to build an +/// artist-predicate rule from, never a regex over 's narrative prose. +/// for every non-track row or a track aired with no known artist. +/// SPEC F86.1, STORY-217, PLAN T73 — that same track's persona-pick stamp, the +/// caller's already-serialized jsonb text, or for every non-track row, an +/// engine-initiated play, or a persona-off pick. Never backfilled. +/// gh-#99 — the aired row's numeric catalog id, or for +/// every non-track row or a non-catalog id. +/// SPEC F113.1, STORY-304, PLAN T220 — that same track's air-time +/// SegmentKind token name (e.g. "StationId"), or for a music row +/// or a non-track row. +/// SPEC F121.1, STORY-310, PLAN T242 — the show on air at write time for a +/// TRACK-START row, music and kinded alike, captured the SAME way and for the SAME reason as +/// : the resolver's on-air answer at the exact instant the row aired, +/// never re-derived later. for every non-track row or a showless airing. No +/// FK — history must outlive the entity, so a show deleted later never rewrites or blocks on a past +/// airing. +public sealed record BoothLogAppendRequest( + string Kind, string Summary, long? PersonaId, string? Artist, string? Pick, long? MediaId, + string? SegmentKind, long? ShowId); diff --git a/src/GenWave.Core/Domain/ScheduledSlot.cs b/src/GenWave.Core/Domain/ScheduledSlot.cs index 76cf4c36..95792d46 100644 --- a/src/GenWave.Core/Domain/ScheduledSlot.cs +++ b/src/GenWave.Core/Domain/ScheduledSlot.cs @@ -1,13 +1,18 @@ namespace GenWave.Core.Domain; /// -/// One station.segment_schedule row naming a persona among the offending slots blocking a -/// delete (SPEC F91.9; STORY-247, PLAN T121) — the payload -/// carries so PersonaController.Delete's -/// 409 body can name every slot rather than staying generic (PLAN T120 scaffolding). Deliberately a -/// narrower read projection than — a caller blocked from deleting a -/// persona needs to know WHEN it is still on-air, never the row's id or its genre/energy envelope — -/// so this carries only //, the -/// same day-of-week/minute vocabulary and db/27 both already use. +/// One station.segment_schedule row naming a block among the offenders blocking a delete — +/// shared by two consumers with the identical naming need: the persona guard (SPEC F91.9; STORY-247, +/// PLAN T121), the payload carries so +/// PersonaController.Delete's 409 body can name every slot rather than staying generic (PLAN +/// T120 scaffolding); and the show guard (SPEC F115.4; STORY-305, PLAN T240), where +/// ShowsController.Delete queries IScheduleStore.GetSlotsByShowIdAsync directly for the +/// same day/time naming — stays a bare singleton at the +/// store seam (see its own remarks), so the endpoint re-queries rather than the store pre-fetching +/// detail neither persona nor show writes always need. Deliberately a narrower read projection than +/// — a caller blocked from deleting something needs to know WHEN it is +/// still on-air, never the row's id or its genre/energy envelope — so this carries only +/// //, the same day-of-week/minute +/// vocabulary and db/27 both already use. /// public sealed record ScheduledSlot(DayOfWeek Day, int StartMinute, int EndMinute); diff --git a/src/GenWave.Core/Domain/ScopedImagingRow.cs b/src/GenWave.Core/Domain/ScopedImagingRow.cs new file mode 100644 index 00000000..767ed02f --- /dev/null +++ b/src/GenWave.Core/Domain/ScopedImagingRow.cs @@ -0,0 +1,10 @@ +namespace GenWave.Core.Domain; + +/// +/// One library.media row unscoped from a deleted show (SPEC F115.4, STORY-305, PLAN T240) — +/// what both names and clears. The show +/// delete guard's response names these rows so an operator can see exactly what branding just went +/// station-wide again. is null exactly when the row's own tag is (enrichment may +/// not have run yet) — a caller names the row by in that case. +/// +public sealed record ScopedImagingRow(long MediaId, string? Title); diff --git a/src/GenWave.Core/Domain/Show.cs b/src/GenWave.Core/Domain/Show.cs new file mode 100644 index 00000000..511abfc3 --- /dev/null +++ b/src/GenWave.Core/Domain/Show.cs @@ -0,0 +1,44 @@ +namespace GenWave.Core.Domain; + +/// +/// A named show row (SPEC F115.1, STORY-305, PLAN T239): the identity package — name, tagline, +/// flavor, provenance — an hour of airtime can carry, authored once via IShowStore and +/// referenced across the format clock. Deliberately excludes persona_id/envelope — the +/// DORMANT schedulable-bundle columns db/35 ships alongside the rest of this table (SPEC F115.2, a +/// law of this epic, not an oversight): no type or query in this epic maps, reads, or writes them: the +/// deferred bundle slice adds that seam separately. +/// +/// +/// Public, broadcast-shaped (SPEC F115.3) — joins the F67 disclosure inventory as a pinned field. +/// null when the show carries none. +/// +/// +/// Prompt-only, private forever (SPEC F115.3 — the persona-soul precedent): never appears in a public +/// payload, spectator surface, or log line. null when the show carries none. +/// +/// +/// Provenance stamp (SPEC F115.1, the F90/db-25 pattern): null for a show authored in place via +/// /, +/// "file" for a file-uploaded import, or the catalog entry's slug for a catalog import. No +/// writer for the import path exists yet (PLAN T254) — every row this seam can itself produce carries +/// null here. +/// +/// The moment was last stamped; null exactly +/// when is. +/// +/// The stored station.show.slug column — re-derived from via the house +/// Slugify (LegacyPersonaCardMapper.Slugify, the T68 golden-table contract) on every authored +/// create/edit, mirroring Persona.Slug's own re-derive-on-every-write rule. An imported show +/// instead keeps whatever slug the import route was given (T254), same caveat as +/// Persona.Slug's own remarks. +/// +public sealed record Show( + long Id, + string Name, + string Slug, + string? Tagline, + string? Flavor, + string? ImportedFrom, + DateTime? ImportedAt, + DateTime CreatedAt, + DateTime UpdatedAt); diff --git a/src/GenWave.Core/Domain/ShowBudgetField.cs b/src/GenWave.Core/Domain/ShowBudgetField.cs new file mode 100644 index 00000000..f2987e21 --- /dev/null +++ b/src/GenWave.Core/Domain/ShowBudgetField.cs @@ -0,0 +1,10 @@ +namespace GenWave.Core.Domain; + +/// Which field a outcome +/// names (SPEC F115.1, STORY-305, PLAN T239). +public enum ShowBudgetField +{ + Name, + Tagline, + Flavor, +} diff --git a/src/GenWave.Core/Domain/ShowBudgets.cs b/src/GenWave.Core/Domain/ShowBudgets.cs new file mode 100644 index 00000000..25c141ec --- /dev/null +++ b/src/GenWave.Core/Domain/ShowBudgets.cs @@ -0,0 +1,30 @@ +namespace GenWave.Core.Domain; + +/// +/// SPEC F115.1's field-length budgets for an authored/edited — the app seam's 1× +/// hard line (name ≤60, tagline ≤120, flavor ≤400 chars; reasoned-not-fitted, the F89.5 posture). +/// / +/// enforce these before the write ever reaches Postgres. The manifest parser's own 2× headroom (PLAN +/// T254) and catalog lint's WARN-over-1× posture are separate, later concerns — not this seam's. +/// +public static class ShowBudgets +{ + public const int NameMaxChars = 60; + public const int TaglineMaxChars = 120; + public const int FlavorMaxChars = 400; + + /// + /// The first budget violates, checked in field order (name, tagline, + /// flavor) so a draft violating more than one field always reports the same one first — the rule + /// lives beside its own constants (PLAN T239 review) so later writers (T240/T244/T254) share the + /// identical check order instead of each re-deriving it. null when every field is within + /// budget. + /// + public static ShowBudgetField? FirstViolation(ShowDraft draft) + { + if (draft.Name.Length > NameMaxChars) return ShowBudgetField.Name; + if (draft.Tagline is { Length: > TaglineMaxChars }) return ShowBudgetField.Tagline; + if (draft.Flavor is { Length: > FlavorMaxChars }) return ShowBudgetField.Flavor; + return null; + } +} diff --git a/src/GenWave.Core/Domain/ShowDraft.cs b/src/GenWave.Core/Domain/ShowDraft.cs new file mode 100644 index 00000000..386f62ac --- /dev/null +++ b/src/GenWave.Core/Domain/ShowDraft.cs @@ -0,0 +1,15 @@ +namespace GenWave.Core.Domain; + +/// +/// Caller-supplied fields for authoring or editing a (SPEC F115.1, STORY-305, PLAN +/// T239). Always an AUTHORED write — +/// / +/// never set imported_from/imported_at from this draft, mirroring +/// 's own posture; the import write path is a separate, later seam (PLAN +/// T254). +/// +/// SPEC F115.1's ≤120-char budget () — +/// checked at the write seam, not this record. null/empty means no tagline. +/// SPEC F115.1's ≤400-char budget () — +/// checked at the write seam, not this record. null/empty means no flavor. +public sealed record ShowDraft(string Name, string? Tagline = null, string? Flavor = null); diff --git a/src/GenWave.Core/Domain/ShowWriteResult.cs b/src/GenWave.Core/Domain/ShowWriteResult.cs new file mode 100644 index 00000000..6706220d --- /dev/null +++ b/src/GenWave.Core/Domain/ShowWriteResult.cs @@ -0,0 +1,63 @@ +namespace GenWave.Core.Domain; + +/// +/// Discriminated union expressing every outcome of an write +/// (SPEC F115.1, STORY-305, PLAN T239). Mirrors 's own closed-hierarchy +/// shape: cases that carry data (, , +/// ) are sealed records with a positional payload; singleton cases +/// (, , , , +/// ) carry none. The private constructor on the abstract base closes the +/// hierarchy so callers can write exhaustive pattern-match switches without a discard arm. +/// +public abstract record ShowWriteResult +{ + private ShowWriteResult() { } + + /// The show was created; is the new row. + public sealed record Created(Show Show) : ShowWriteResult; + + /// The show was updated; is the row after the write (updated_at advanced). + public sealed record Updated(Show Show) : ShowWriteResult; + + /// The show was successfully deleted. + public sealed record Deleted : ShowWriteResult; + + /// No show with the requested id exists. + public sealed record NotFound : ShowWriteResult; + + /// Another show already holds the derived slug (UNIQUE(slug), SPEC F115.1) — the unique + /// constraint surfacing as a conflict, never a silent overwrite. + public sealed record SlugConflict : ShowWriteResult; + + /// + /// exceeds its SPEC F115.1 1× budget (name ≤60, tagline ≤120, flavor ≤400 + /// chars, ) — rejected at the app seam before the write ever reaches + /// Postgres. + /// + public sealed record BudgetExceeded(ShowBudgetField Field) : ShowWriteResult; + + /// + /// Name was blank/whitespace-only, or its house-Slugify output equals the fallback literal + /// "persona" (LegacyPersonaCardMapper.FallbackSlug) — rejected regardless of HOW the + /// slug got there: an emoji-only name that hits Slugify's own empty-slug rescue, but also + /// (PLAN T240 review A1) a name that slugifies to "persona" the ordinary way, e.g. the + /// literal name "Persona" itself (lowercases unchanged — the rescue never fires; the + /// ordinary path just lands on the same string). Rejected at the app seam before the write ever + /// reaches Postgres. Mirrors PersonaController's import-slug REJECT-not-autocorrect posture + /// (never silently coerce a bad name into something plausible-looking), enforced here at the + /// store seam so every future caller of inherits the guard + /// rather than each needing its own. + /// + public sealed record InvalidName : ShowWriteResult; + + /// + /// The delete was rejected because station.segment_schedule.show_id still names this show + /// — the FK's own ON DELETE RESTRICT (db/06, SPEC F114), caught here as SQLSTATE 23503. + /// Unlike , this store does not pre-query the + /// schedule for slot detail before deleting: this case stays a bare singleton, "referenced" and + /// nothing more — PLAN T240's endpoint-layer guard (SPEC F115.4) is what names the offending + /// blocks in the 409 body, the same way PLAN T121 once replaced + /// 's own T120 scaffolding. + /// + public sealed record Referenced : ShowWriteResult; +} diff --git a/src/GenWave.Host/Api/PersonaController.cs b/src/GenWave.Host/Api/PersonaController.cs index fea24743..d13eb02c 100644 --- a/src/GenWave.Host/Api/PersonaController.cs +++ b/src/GenWave.Host/Api/PersonaController.cs @@ -1,4 +1,3 @@ -using System.Globalization; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; @@ -658,23 +657,10 @@ static PersonaDraft ToDraft(PersonaRequest request) => Status = StatusCodes.Status409Conflict, Title = "Persona is scheduled.", Detail = slots.Count > 0 - ? $"Persona {id} is still scheduled and cannot be deleted: {string.Join(", ", slots.Select(FormatSlot))}." + ? $"Persona {id} is still scheduled and cannot be deleted: {string.Join(", ", slots.Select(ScheduledSlotText.FormatSlot))}." : $"Persona {id} still appears in the format-clock schedule and cannot be deleted while scheduled.", }; - // Invariant-culture abbreviated day name ("Mon", "Tue", ...) — never a station-configurable - // locale; this is an operator-facing admin message, not station-facing broadcast copy. - static string FormatSlot(ScheduledSlot slot) => - $"{CultureInfo.InvariantCulture.DateTimeFormat.GetAbbreviatedDayName(slot.Day)} " + - $"{FormatMinutes(slot.StartMinute)}–{FormatMinutes(slot.EndMinute)}"; - - // Minutes-since-midnight as HH:mm — plain arithmetic, not TimeSpan's "hh" format specifier: a - // 1440-minute end (midnight, the grid's own maximum) rolls into TimeSpan's Days component, which - // "hh" ignores entirely, silently printing "00:00" for what is actually the end of the day. - static string FormatMinutes(int minutesSinceMidnight) => - $"{(minutesSinceMidnight / 60).ToString("D2", CultureInfo.InvariantCulture)}:" + - $"{(minutesSinceMidnight % 60).ToString("D2", CultureInfo.InvariantCulture)}"; - static ProblemDetails UnknownSlugProblem(string slug) => new() { Status = StatusCodes.Status404NotFound, diff --git a/src/GenWave.Host/Api/ScheduledSlotText.cs b/src/GenWave.Host/Api/ScheduledSlotText.cs new file mode 100644 index 00000000..305e26cb --- /dev/null +++ b/src/GenWave.Host/Api/ScheduledSlotText.cs @@ -0,0 +1,32 @@ +using System.Globalization; +using GenWave.Core.Domain; + +namespace GenWave.Host.Api; + +/// +/// Formats a for an operator-facing 409 detail message — shared by +/// (SPEC F91.9, PLAN T121) and +/// (SPEC F115.4, PLAN T240), the two delete guards that both name every blocking day/time slot the +/// identical "Mon 09:00–12:00" way. Extracted here (PLAN T240 review) rather than left as two +/// hand-copies: the copy on had silently dropped the load-bearing +/// comment below on the minutes-to-HH:mm conversion — a single shared implementation makes that +/// impossible to drop a second time, on either side. +/// +internal static class ScheduledSlotText +{ + /// + /// "Mon 09:00–12:00" — invariant-culture abbreviated day name (never a station-configurable + /// locale; this is an operator-facing admin message, not station-facing broadcast copy) plus the + /// HH:mm span from . + /// + internal static string FormatSlot(ScheduledSlot slot) => + $"{CultureInfo.InvariantCulture.DateTimeFormat.GetAbbreviatedDayName(slot.Day)} " + + $"{FormatMinutes(slot.StartMinute)}–{FormatMinutes(slot.EndMinute)}"; + + // Minutes-since-midnight as HH:mm — plain arithmetic, not TimeSpan's "hh" format specifier: a + // 1440-minute end (midnight, the grid's own maximum) rolls into TimeSpan's Days component, which + // "hh" ignores entirely, silently printing "00:00" for what is actually the end of the day. + internal static string FormatMinutes(int minutesSinceMidnight) => + $"{(minutesSinceMidnight / 60).ToString("D2", CultureInfo.InvariantCulture)}:" + + $"{(minutesSinceMidnight % 60).ToString("D2", CultureInfo.InvariantCulture)}"; +} diff --git a/src/GenWave.Host/Api/ScopedImagingRowDto.cs b/src/GenWave.Host/Api/ScopedImagingRowDto.cs new file mode 100644 index 00000000..545f5faf --- /dev/null +++ b/src/GenWave.Host/Api/ScopedImagingRowDto.cs @@ -0,0 +1,9 @@ +namespace GenWave.Host.Api; + +/// +/// One library.media row the show delete guard named and unscoped (SPEC F115.4). Mirrors +/// field-for-field — kept as its own wire type anyway +/// (house convention: a domain record never serializes directly across the wire, even when its own +/// field names already happen to match the shape the response wants). +/// +public sealed record ScopedImagingRowDto(long MediaId, string? Title); diff --git a/src/GenWave.Host/Api/ShowDeleteResponse.cs b/src/GenWave.Host/Api/ShowDeleteResponse.cs new file mode 100644 index 00000000..4c5d8bc3 --- /dev/null +++ b/src/GenWave.Host/Api/ShowDeleteResponse.cs @@ -0,0 +1,8 @@ +namespace GenWave.Host.Api; + +/// +/// Body for a successful DELETE /api/shows/{slug} that unscoped one or more show-scoped +/// imaging rows (SPEC F115.4) — present only on that path; a delete that unscoped nothing answers +/// plain 204 No Content instead (nothing to name). +/// +public sealed record ShowDeleteResponse(IReadOnlyList UnscopedImaging); diff --git a/src/GenWave.Host/Api/ShowDto.cs b/src/GenWave.Host/Api/ShowDto.cs new file mode 100644 index 00000000..700d5e7b --- /dev/null +++ b/src/GenWave.Host/Api/ShowDto.cs @@ -0,0 +1,13 @@ +namespace GenWave.Host.Api; + +/// +/// Wire shape for a show row (SPEC F115.1, F115.3, F115.4): the whole identity package this ADMIN +/// surface edits — name/slug/tagline/flavor plus provenance (importedFrom/importedAt, +/// the db/25 pattern already carries). is prompt-only +/// and private FOREVER on every OTHER surface (SPEC F115.3, the persona-soul precedent) — but this +/// DTO backs the admin editor that AUTHORS it, so it is the one deliberate exception; the public/ +/// spectator show projection (PLAN T251) is a separate, narrower DTO that never adds this field. +/// +public sealed record ShowDto( + long Id, string Name, string Slug, string? Tagline, string? Flavor, + string? ImportedFrom, DateTime? ImportedAt); diff --git a/src/GenWave.Host/Api/ShowRequest.cs b/src/GenWave.Host/Api/ShowRequest.cs new file mode 100644 index 00000000..6c1bee6d --- /dev/null +++ b/src/GenWave.Host/Api/ShowRequest.cs @@ -0,0 +1,12 @@ +namespace GenWave.Host.Api; + +/// +/// Request body for POST /api/shows (create) and PATCH /api/shows/{slug} (edit) (SPEC +/// F115.1, F115.4). is required, non-blank — 's +/// own rejects a blank/whitespace one, or one +/// whose derived slug equals the reserved fallback literal; / +/// are optional and clear to null when omitted or blank. All fields are nullable here, mirroring +/// 's own all-nullable shape, so the controller produces a typed 400 for a +/// blank/missing name instead of an ASP.NET model-binder 400. +/// +public sealed record ShowRequest(string? Name, string? Tagline, string? Flavor); diff --git a/src/GenWave.Host/Api/ShowsController.cs b/src/GenWave.Host/Api/ShowsController.cs new file mode 100644 index 00000000..1e85e1fe --- /dev/null +++ b/src/GenWave.Host/Api/ShowsController.cs @@ -0,0 +1,353 @@ +using System.Diagnostics; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using GenWave.Core.Abstractions; +using GenWave.Core.Domain; +using GenWave.Core.Logging; +using GenWave.Host.Catalog; + +namespace GenWave.Host.Api; + +/// +/// Show CRUD for the Admin UI (SPEC F115.1, F115.4, F115.5; STORY-305, PLAN T240): +/// GET/POST/PATCH/DELETE /api/shows over . Slug-addressed +/// ({slug}, never {id}) — 's own remarks name it +/// as "the primitive a slug-addressed route resolves through"; every write still resolves through the +/// store's id-keyed / +/// internally, one extra read ahead of each. +/// +/// +/// Delete guard (SPEC F115.4) — two independent reference kinds, two different postures. +/// 's own case fires only +/// for a real FK (station.segment_schedule.show_id, ON DELETE RESTRICT) — +/// re-queries that table () to NAME the blocking +/// slots in the 409 body the store's own bare case can't carry (mirrors PersonaController.Delete's +/// pre-T121 posture — see 's own remarks). +/// library.media.show_id (show-scoped imaging, F117.1) carries NO FK at all, so it can never +/// block the DELETE statement itself — it is handled entirely on the OTHER side of a successful +/// delete instead: only ever runs after +/// comes back, clearing every imaging row that named the +/// now-gone show and naming what it cleared in the response body. +/// +/// +/// +/// Ordering is deliberate, not incidental. A block-referenced show refuses with NOTHING +/// touched — no delete, no imaging unscope — the least-surprising outcome: the show still exists, so +/// an imaging row scoped to it is still correctly scoped, and unscoping it anyway would silently +/// orphan-clear branding off a show an operator just failed to remove. Only once the row is confirmed +/// gone does a stale show_id on an imaging row become the actual problem this guard exists to +/// prevent — so the unscope write happens strictly after, never before or racing the delete. +/// +/// +/// +/// SPEC F115.5 — mirrors ThemeWriteGate's fail-closed posture, deliberately NOT a shared +/// type (PLAN T240, "your call"). An authored targeting a slug whose row +/// already carries a non-null refuses 409 before ever calling +/// — provenance is never even offered a chance to change. +/// ThemeWriteGate (PLAN T207) earns its own type because it guards TWO separate controllers +/// (ThemesImportController/ThemesSaveAsOwnController) through a genuinely multi-phase +/// pipeline (bounded body read, schema-major, deserialize-as-validation, font-law) that a hand-copy +/// between two files had already drifted once. Show writes have neither: ONE controller owns both +/// verbs (no second file to drift against), and the whole authored-vs-imported gate is a single +/// predicate on an already-loaded — extracting a "ShowWriteGate" type for one +/// if would be indirection with nothing behind it (YAGNI). What THIS controller does borrow +/// from that precedent is the shared-refusal-mapping idiom: maps every +/// non-success case and can +/// BOTH produce, so the SAME case always yields the SAME HTTP body regardless of which write route +/// hit it — proven by this controller's own gate-parity table (mirrors PLAN T207's 7-row +/// BadBodyTable precedent, narrowed to this store's five app-seam gates: blank name, +/// fallback-slug name, and the three SPEC F115.1 budgets). +/// +/// +/// Security: deny-by-default cookie auth, the same AdminSurface/Settings pairing as +/// / (this is station configuration, +/// the same admin plane). Writes require Content-Type: application/json (415 otherwise, +/// F18.7). rides this admin DTO deliberately (SPEC F115.3: it MAY appear +/// here, the page that authors it) — the public/spectator show projection (PLAN T251) is its own, +/// narrower DTO that never adds this field. +/// +[ApiController] +[Route("api/shows")] +[AdminSurface] +[Authorize(Policy = AuthorizationPolicies.Settings)] +public sealed class ShowsController( + IShowStore showStore, + IScheduleStore scheduleStore, + IShowImagingScope imagingScope, + ILogger logger) : ControllerBase +{ + /// GET /api/shows — every show row, ordered by name (F115.1). + [HttpGet] + public async Task List(CancellationToken ct) + { + var shows = await showStore.GetAllAsync(ct); + return Ok(shows.Select(ToDto).ToArray()); + } + + /// GET /api/shows/{slug} — a single show. 404 for an unknown slug. + [HttpGet("{slug}")] + public async Task Get(string slug, CancellationToken ct) + { + var show = await showStore.GetBySlugAsync(slug, ct); + return show is null ? NotFound(NotFoundProblem(slug)) : Ok(ToDto(show)); + } + + /// + /// POST /api/shows — create an authored show. 201 with the row on success; 400 for a blank/ + /// invalid name or an over-budget field; 409 for a slug collision (F115.1). + /// + [HttpPost] + [Consumes("application/json")] + public async Task Create([FromBody] ShowRequest request, CancellationToken ct) + { + var draft = ToDraft(request); + var result = await showStore.CreateAsync(draft, ct); + + if (result is ShowWriteResult.Created created) + logger.LogInformation( + "Show created id={ShowId} name={ShowName}", created.Show.Id, LogSanitize.Strip(created.Show.Name)); + + return result switch + { + ShowWriteResult.Created c => StatusCode(StatusCodes.Status201Created, ToDto(c.Show)), + _ => WriteProblem(result, draft.Name), + }; + } + + /// + /// PATCH /api/shows/{slug} — edit an existing authored show. 200 with the row on success; 404 for + /// an unknown slug; 409 when the target is imported (SPEC F115.5 — see this class's own remarks) + /// or when the edit's derived slug collides with another show; 400 for a blank/invalid name or an + /// over-budget field. + /// + [HttpPatch("{slug}")] + [Consumes("application/json")] + public async Task Update(string slug, [FromBody] ShowRequest request, CancellationToken ct) + { + var existing = await showStore.GetBySlugAsync(slug, ct); + if (existing is null) + return NotFound(NotFoundProblem(slug)); + + // SPEC F115.5 — the ThemeWriteGate fail-closed posture, mirrored (see class remarks): an + // authored write never lands on an imported show's slug, so provenance is never even offered + // a chance to change. + // + // Structurally this is the same read-then-write shape gh-#394 flags (GetBySlugAsync's read + // above, then this gate's decision, then UpdateAsync's write — nothing holds a lock across + // the gap): a concurrent import could land on this exact slug between the read and the write. + // Provenance itself survives that race regardless — UpdateAsync never names imported_from/ + // imported_at, so it cannot overwrite whatever an interleaved import just stamped. The + // residual exposure is narrower than #394's own: an authored edit that was ALREADY in flight + // landing on a row that became imported a moment earlier, silently editing what a user would + // now expect to be gate-refused. The fix rides #394, not a second one here. + if (existing.ImportedFrom is not null) + { + logger.LogWarning( + "Show update refused slug={Slug} reason=imported importedFrom={ImportedFrom}", + LogSafeText.Sanitize(slug), LogSanitize.Strip(existing.ImportedFrom)); + return Conflict(ImportedTargetProblem(slug, existing.ImportedFrom)); + } + + var draft = ToDraft(request); + var result = await showStore.UpdateAsync(existing.Id, draft, ct); + + if (result is ShowWriteResult.Updated updated) + logger.LogInformation( + "Show updated id={ShowId} name={ShowName}", updated.Show.Id, LogSanitize.Strip(updated.Show.Name)); + + return result switch + { + ShowWriteResult.Updated u => Ok(ToDto(u.Show)), + ShowWriteResult.NotFound => NotFound(NotFoundProblem(slug)), + _ => WriteProblem(result, draft.Name), + }; + } + + /// + /// DELETE /api/shows/{slug} — remove a show (SPEC F115.4; see this class's own remarks for the + /// full guard). 404 for an unknown slug. 409, naming every referencing schedule block, when + /// station.segment_schedule still names it — nothing deleted, nothing unscoped. Otherwise: + /// 204 when nothing else named it either, or 200 naming every show-scoped imaging row this call + /// unscoped. + /// + [HttpDelete("{slug}")] + public async Task Delete(string slug, CancellationToken ct) + { + var existing = await showStore.GetBySlugAsync(slug, ct); + if (existing is null) + return NotFound(NotFoundProblem(slug)); + + var result = await showStore.DeleteAsync(existing.Id, ct); + + switch (result) + { + case ShowWriteResult.Deleted: + var unscoped = await UnscopeBestEffortAsync(existing.Id, slug); + logger.LogInformation( + "Show deleted id={ShowId} slug={Slug} unscopedImagingCount={Count}", + existing.Id, LogSafeText.Sanitize(slug), unscoped.Count); + return unscoped.Count == 0 + ? NoContent() + : Ok(new ShowDeleteResponse(unscoped.Select(ToImagingDto).ToArray())); + + case ShowWriteResult.Referenced: + var blocks = await scheduleStore.GetSlotsByShowIdAsync(existing.Id, ct); + logger.LogWarning( + "Show delete refused slug={Slug} blockCount={Count}", LogSafeText.Sanitize(slug), blocks.Count); + return Conflict(ReferencedProblem(slug, blocks)); + + case ShowWriteResult.NotFound: + // Race backstop: gone between the GetBySlugAsync read above and this DeleteAsync call. + return NotFound(NotFoundProblem(slug)); + + default: + // Mirrors PersonaController's own unmapped-case posture (e.g. PersonaController.cs:150) + // rather than throwing: an unmapped ShowWriteResult case here is a store/controller + // drift bug, not a client error, but the delete itself already committed by the time + // this runs (unlike Create/Update's WriteProblem, called before any write) — a 500 + // that still completes the response is more honest than crashing the request pipeline + // over a case this switch cannot even reach today (ShowWriteResult's hierarchy is + // closed to exactly the four cases handled above). + return StatusCode(StatusCodes.Status500InternalServerError); + } + } + + /// + /// Best-effort post-commit cleanup (SPEC F115.4) for 's Deleted case — + /// the show row is ALREADY gone by the time this runs (see class remarks, "ordering is + /// deliberate"), so a failure here must never turn an already-successful delete into a 500, and + /// must never be silently skippable either. + /// + /// + /// , deliberately never 's own request + /// ct: the delete already committed, so a client disconnecting between the delete and this + /// call must not skip cleanup — an aborted request is not a reason to leave orphaned + /// library.media.show_id rows behind. + /// + /// + /// + /// Broad catch (Exception), not a specific Npgsql exception type: controllers in this + /// codebase never import Npgsql (PLAN T120 review F4 — that mapping belongs to the store/ + /// repository seam), and this is a single post-commit boundary where "the write failed, for any + /// reason" is the only distinction this action can act on anyway. The failure is logged with the + /// show id (SPEC F115.4) so an operator can hand-recover — a one-off UPDATE library.media SET + /// show_id = NULL WHERE show_id = <id> — rather than retrying (a retry 404s: the show is + /// already gone). The delete itself still reports success either way (mirrors + /// 's own broad-catch-at-a-seam-boundary precedent). + /// + /// + async Task> UnscopeBestEffortAsync(long showId, string slug) + { + try + { + return await imagingScope.UnscopeAsync(showId, CancellationToken.None); + } + catch (Exception ex) + { + logger.LogError( + ex, + "Show imaging unscope failed after delete id={ShowId} slug={Slug} — library.media rows " + + "may still name a deleted show; hand-recover via UPDATE library.media SET show_id = NULL", + showId, LogSafeText.Sanitize(slug)); + return []; + } + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + /// + /// Maps every non-success case BOTH write actions can produce + /// (// + /// ) to the identical ProblemDetails shape — ONE method, + /// called from 's switch AND 's switch, so the two write + /// routes can never drift the way PLAN T207 found the two theme-write controllers had (this + /// controller's own gate-parity table proves it stays that way — see class remarks). + /// is deliberately NOT here: only can + /// produce it, and it needs the target's slug (unavailable to a failure), so + /// it stays inline at that one call site. + /// + IActionResult WriteProblem(ShowWriteResult result, string name) => result switch + { + ShowWriteResult.InvalidName => BadRequest(InvalidNameProblem()), + ShowWriteResult.BudgetExceeded budget => BadRequest(BudgetExceededProblem(budget.Field)), + ShowWriteResult.SlugConflict => Conflict(SlugConflictProblem(name)), + _ => StatusCode(StatusCodes.Status500InternalServerError), + }; + + static ShowDto ToDto(Show show) => + new(show.Id, show.Name, show.Slug, show.Tagline, show.Flavor, show.ImportedFrom, show.ImportedAt); + + static ShowDraft ToDraft(ShowRequest request) => + new(request.Name?.Trim() ?? string.Empty, request.Tagline, request.Flavor); + + static ScopedImagingRowDto ToImagingDto(ScopedImagingRow row) => new(row.MediaId, row.Title); + + static ProblemDetails NotFoundProblem(string slug) => new() + { + Status = StatusCodes.Status404NotFound, + Title = "Not found.", + Detail = $"No show with slug \"{slug}\" exists.", + }; + + static ProblemDetails InvalidNameProblem() => new() + { + Status = StatusCodes.Status400BadRequest, + Title = "Invalid name.", + Detail = "name must not be blank or whitespace, and must not slugify to the reserved value \"persona\".", + }; + + static ProblemDetails BudgetExceededProblem(ShowBudgetField field) => new() + { + Status = StatusCodes.Status400BadRequest, + Title = "Validation error.", + Detail = $"{FieldName(field)} must be at most {BudgetFor(field)} characters.", + }; + + static string FieldName(ShowBudgetField field) => field switch + { + ShowBudgetField.Name => "name", + ShowBudgetField.Tagline => "tagline", + ShowBudgetField.Flavor => "flavor", + _ => throw new UnreachableException($"Unhandled {nameof(ShowBudgetField)} value."), + }; + + static int BudgetFor(ShowBudgetField field) => field switch + { + ShowBudgetField.Name => ShowBudgets.NameMaxChars, + ShowBudgetField.Tagline => ShowBudgets.TaglineMaxChars, + ShowBudgetField.Flavor => ShowBudgets.FlavorMaxChars, + _ => throw new UnreachableException($"Unhandled {nameof(ShowBudgetField)} value."), + }; + + static ProblemDetails SlugConflictProblem(string name) => new() + { + Status = StatusCodes.Status409Conflict, + Title = "Slug conflict.", + Detail = $"Another show already uses the slug derived from \"{name}\".", + }; + + static ProblemDetails ImportedTargetProblem(string slug, string importedFrom) => new() + { + Status = StatusCodes.Status409Conflict, + Title = "Show is imported.", + Detail = + $"\"{slug}\" was imported (from \"{importedFrom}\") and cannot be edited as an authored " + + "show; its provenance is left untouched.", + }; + + // SPEC F115.4 — names every referencing block the same day/time shape PersonaController.Delete's + // own ScheduledPersonaProblem uses. The PROBLEM-BODY builders stay two separate methods + // (mirrored, not shared: each carries its own title/detail wording, and ScheduledPersonaProblem + // is private to PersonaController — see this controller's own class remarks on mirror-vs-share). + // Slot FORMATTING is a different claim — ScheduledSlotText.FormatSlot is the one shared + // implementation both this method and ScheduledPersonaProblem call into (PLAN T240 review). + static ProblemDetails ReferencedProblem(string slug, IReadOnlyList blocks) => new() + { + Status = StatusCodes.Status409Conflict, + Title = "Show is scheduled.", + Detail = blocks.Count > 0 + ? $"\"{slug}\" is still scheduled and cannot be deleted: " + + $"{string.Join(", ", blocks.Select(ScheduledSlotText.FormatSlot))}." + : $"\"{slug}\" still appears in the format-clock schedule and cannot be deleted while scheduled.", + }; +} diff --git a/src/GenWave.Host/Configuration/StationSettingsHostingExtensions.cs b/src/GenWave.Host/Configuration/StationSettingsHostingExtensions.cs index 0e6058df..46d16829 100644 --- a/src/GenWave.Host/Configuration/StationSettingsHostingExtensions.cs +++ b/src/GenWave.Host/Configuration/StationSettingsHostingExtensions.cs @@ -128,6 +128,12 @@ public static WebApplicationBuilder AddGenWaveStationSettings(this WebApplicatio // FontPackController's install route (PLAN T199) is the first Host call site. builder.Services.AddFontPackStore(stationConnStr); + // Show store (SPEC F115.1, STORY-305, PLAN T239) — same station_svc connection string as + // every registration above; station.show lives in the same schema. ShowRepository shipped + // dark at T239 (AddShowStore itself registers no consumer): ShowsController (PLAN T240) is + // the first Host call site. + builder.Services.AddShowStore(stationConnStr); + return builder; } } diff --git a/src/GenWave.MediaLibrary/Catalog/ShowImagingScopeRepository.cs b/src/GenWave.MediaLibrary/Catalog/ShowImagingScopeRepository.cs new file mode 100644 index 00000000..81251657 --- /dev/null +++ b/src/GenWave.MediaLibrary/Catalog/ShowImagingScopeRepository.cs @@ -0,0 +1,36 @@ +using Dapper; +using GenWave.Core.Abstractions; +using GenWave.Core.Domain; +using Npgsql; + +namespace GenWave.MediaLibrary.Catalog; + +/// +/// The in-process implementation of (SPEC F115.4, STORY-305, PLAN +/// T240) over library.media. Connection-per-call against the library's own +/// , mirroring 's wiring — +/// singleton-safe with no captive dependency. +/// +sealed class ShowImagingScopeRepository(NpgsqlDataSource dataSource) : IShowImagingScope +{ + /// + /// One statement: UPDATE ... RETURNING both clears show_id and names what it + /// cleared in the SAME round trip — no separate SELECT-then-UPDATE. There is no FK here for a + /// second writer to race against (F117.1), so the single statement isn't closing a TOCTOU gap so + /// much as it is simply the smallest honest shape for a seam that does one thing. + /// + public async Task> UnscopeAsync(long showId, CancellationToken ct) + { + await using var conn = await dataSource.OpenConnectionAsync(ct); + var rows = await conn.QueryAsync<(long Id, string? Title)>(new CommandDefinition( + """ + update library.media set show_id = null + where show_id = @showId + returning id, title + """, + new { showId }, + cancellationToken: ct)); + + return rows.OrderBy(r => r.Id).Select(r => new ScopedImagingRow(r.Id, r.Title)).ToList(); + } +} diff --git a/src/GenWave.MediaLibrary/MediaLibraryServiceCollectionExtensions.cs b/src/GenWave.MediaLibrary/MediaLibraryServiceCollectionExtensions.cs index 01303cc0..4cfdd853 100644 --- a/src/GenWave.MediaLibrary/MediaLibraryServiceCollectionExtensions.cs +++ b/src/GenWave.MediaLibrary/MediaLibraryServiceCollectionExtensions.cs @@ -86,6 +86,11 @@ public static IServiceCollection AddMediaLibrary(this IServiceCollection service // on library.media. services.AddSingleton(); + // SPEC F115.4, STORY-305, PLAN T240: the show delete guard's own cross-schema answer — which + // library.media rows are scoped to a show, and clearing them — same station_svc-has-no-grant + // rationale as IMediaLibraryMembership immediately above. First consumer: ShowsController. + services.AddSingleton(); + // SPEC F87.5, STORY-226, PLAN T89: the listener-request matcher's catalog probe — same // cross-schema-boundary rationale as IMediaLibraryMembership above (station_svc has no grant // on library.media). First consumer: GenWave.Host.Requests.RequestMatcher. diff --git a/src/GenWave.MediaLibrary/Station/BoothLogDrainService.cs b/src/GenWave.MediaLibrary/Station/BoothLogDrainService.cs index 6c1c53f8..b8e8cf57 100644 --- a/src/GenWave.MediaLibrary/Station/BoothLogDrainService.cs +++ b/src/GenWave.MediaLibrary/Station/BoothLogDrainService.cs @@ -1,5 +1,6 @@ using System.Threading.Channels; using GenWave.Core.Abstractions; +using GenWave.Core.Domain; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -29,6 +30,12 @@ namespace GenWave.MediaLibrary.Station; /// (SPEC F113.1, STORY-304, PLAN T220) arrives the /// same way — already stringified (or ) by /// — and is persisted verbatim. +/// +/// (SPEC F121.1, STORY-310, PLAN T242) arrives the same +/// way — already resolved off IActivePersonaAccessor.ActiveShowId by +/// at air time — and is persisted verbatim; no FK to degrade +/// (F121.1: history outlives the entity), so unlike it +/// has no append-time retry concern for BoothLogRepository to handle either. /// sealed class BoothLogDrainService( ChannelReader queue, @@ -50,7 +57,16 @@ internal async Task ProcessAsync(BoothLogEntryRequest request, CancellationToken { try { - await store.AppendAsync(request.Kind, request.Summary, request.PersonaId, request.Artist, request.Pick, request.MediaId, request.SegmentKind, ct); + // Recorded carry-forward (T220 review, still only half-closed): BoothLogEntryRequest and + // BoothLogAppendRequest duplicate these same 8 fields positionally — this mapping exists + // only because the two types are two records, not one. Not merged now (out of this task's + // scope); merge candidate is passing BoothLogEntryRequest straight onto the channel and + // dropping BoothLogAppendRequest as a separate type. + await store.AppendAsync( + new BoothLogAppendRequest( + request.Kind, request.Summary, request.PersonaId, request.Artist, request.Pick, + request.MediaId, request.SegmentKind, request.ShowId), + ct); } catch (OperationCanceledException) { diff --git a/src/GenWave.MediaLibrary/Station/BoothLogEntryRequest.cs b/src/GenWave.MediaLibrary/Station/BoothLogEntryRequest.cs index ffe45e7c..c7209ba4 100644 --- a/src/GenWave.MediaLibrary/Station/BoothLogEntryRequest.cs +++ b/src/GenWave.MediaLibrary/Station/BoothLogEntryRequest.cs @@ -40,7 +40,15 @@ namespace GenWave.MediaLibrary.Station; /// "StationId") before this request ever reaches the queue. for a music /// row or an engine-initiated advance — the demo-hour instrument's whole point is that this is the /// genuine AIR-time signal, never a render-time guess. +/// +/// (SPEC F121.1, STORY-310, PLAN T242) rides the SAME track-start-only, +/// captured-at-publish-time path as +/// reads it off IActivePersonaAccessor.ActiveShowId, the SAME resolver-backed on-air answer +/// reads ActivePersonaId off, at the SAME instant — never off +/// itself (a show is a schedule-grid fact, not a pushed-item one, so it rides +/// the persona's capture mechanism, not the kind's). for every non-track row or +/// a showless airing. /// sealed record BoothLogEntryRequest( string Kind, string Summary, long? PersonaId, string? Artist = null, string? Pick = null, long? MediaId = null, - string? SegmentKind = null); + string? SegmentKind = null, long? ShowId = null); diff --git a/src/GenWave.MediaLibrary/Station/BoothLogRepository.cs b/src/GenWave.MediaLibrary/Station/BoothLogRepository.cs index 0a3f4106..3bbfc506 100644 --- a/src/GenWave.MediaLibrary/Station/BoothLogRepository.cs +++ b/src/GenWave.MediaLibrary/Station/BoothLogRepository.cs @@ -45,49 +45,54 @@ from station.booth_log """; /// - /// (SPEC F84.6, STORY-215) was captured SYNCHRONOUSLY by - /// at air time, well before this append ever runs — a new - /// edge that drain-time resolution never had: the persona can be DELETED in the gap between air - /// and this call, leaving a dangling reference. That insert fails - /// the persona_id FK (23503) even though booth_log.persona_id's own ON DELETE SET - /// NULL already protects every row persisted BEFORE the delete — SET NULL cannot help a row - /// that has not been inserted yet. Caught here specifically and retried unstamped: the booth-log - /// row itself must never be dropped over a stamp that went stale mid-flight. - /// (SPEC F84.1, STORY-215, PLAN T70) is plain text — no FK, no degrade path of its own. - /// (SPEC F86.1, STORY-217, PLAN T73) is likewise plain text (pre-serialized - /// jsonb, no FK) — the persona-id retry below never touches it either way. - /// (SPEC F113.1, STORY-304, PLAN T220) is likewise plain text (the - /// SegmentKind enum's token name, or null) — no FK, no degrade path; the persona-id retry never - /// touches it either. + /// 's (SPEC F84.6, + /// STORY-215) was captured SYNCHRONOUSLY by at air time, well + /// before this append ever runs — a new edge that drain-time resolution never had: the persona can + /// be DELETED in the gap between air and this call, leaving it a dangling reference. That insert + /// fails the persona_id FK (23503) even though booth_log.persona_id's own + /// ON DELETE SET NULL already protects every row persisted BEFORE the delete — SET NULL + /// cannot help a row that has not been inserted yet. Caught here specifically and retried + /// unstamped: the booth-log row itself must never be dropped over a stamp that went stale + /// mid-flight. (SPEC F84.1, STORY-215, PLAN T70) is + /// plain text — no FK, no degrade path of its own. (SPEC + /// F86.1, STORY-217, PLAN T73) is likewise plain text (pre-serialized jsonb, no FK) — the + /// persona-id retry below never touches it either way. + /// (SPEC F113.1, STORY-304, PLAN T220) is likewise plain text (the SegmentKind enum's token name, + /// or null) — no FK, no degrade path. (SPEC F121.1, + /// STORY-310, PLAN T242) is likewise no-FK-by-design (history outlives the entity) — the + /// persona-id retry never touches any of these three either. /// - public async Task AppendAsync(string kind, string summary, long? personaId, string? artist, string? pick, long? mediaId, string? segmentKind, CancellationToken ct) + public async Task AppendAsync(BoothLogAppendRequest request, CancellationToken ct) { await using var conn = await dataSource.Value.OpenConnectionAsync(ct); try { - await InsertAndEvictAsync(conn, kind, summary, personaId, artist, pick, mediaId, segmentKind, ct); + await InsertAndEvictAsync(conn, request, ct); } - catch (PostgresException ex) when (ex.SqlState == ForeignKeyViolation && personaId is not null) + catch (PostgresException ex) when (ex.SqlState == ForeignKeyViolation && request.PersonaId is not null) { // The failed attempt's `await using var tx` already rolled back (disposal runs as the // exception unwinds InsertAndEvictAsync, before it reaches this catch) — the connection // is clean, so retrying a fresh transaction on the SAME conn is safe. - await InsertAndEvictAsync(conn, kind, summary, personaId: null, artist, pick, mediaId, segmentKind, ct); + await InsertAndEvictAsync(conn, request with { PersonaId = null }, ct); } } - async Task InsertAndEvictAsync( - NpgsqlConnection conn, string kind, string summary, long? personaId, string? artist, string? pick, long? mediaId, string? segmentKind, CancellationToken ct) + async Task InsertAndEvictAsync(NpgsqlConnection conn, BoothLogAppendRequest request, CancellationToken ct) { await using var tx = await conn.BeginTransactionAsync(ct); await conn.ExecuteAsync(new CommandDefinition( """ - insert into station.booth_log (kind, summary, persona_id, artist, pick, media_id, segment_kind) - values (@Kind, @Summary, @PersonaId, @Artist, @Pick::jsonb, @MediaId, @SegmentKind) + insert into station.booth_log (kind, summary, persona_id, artist, pick, media_id, segment_kind, show_id) + values (@Kind, @Summary, @PersonaId, @Artist, @Pick::jsonb, @MediaId, @SegmentKind, @ShowId) """, - new { Kind = kind, Summary = summary, PersonaId = personaId, Artist = artist, Pick = pick, MediaId = mediaId, SegmentKind = segmentKind }, + new + { + request.Kind, request.Summary, request.PersonaId, request.Artist, request.Pick, + request.MediaId, request.SegmentKind, request.ShowId, + }, transaction: tx, cancellationToken: ct)); diff --git a/src/GenWave.MediaLibrary/Station/BoothLogWriter.cs b/src/GenWave.MediaLibrary/Station/BoothLogWriter.cs index ccded3ee..f826aebb 100644 --- a/src/GenWave.MediaLibrary/Station/BoothLogWriter.cs +++ b/src/GenWave.MediaLibrary/Station/BoothLogWriter.cs @@ -45,6 +45,18 @@ public void Publish(StationEvent evt) // track-start with whatever persona is active once the backlog clears, not the one that was // actually on air when the track started. Only a track-start row is ever a stamp candidate; // patter/mode-change rows always publish PersonaId: null. + // + // The show stamp (SPEC F121.1, STORY-310, PLAN T242) rides the IDENTICAL discipline off the + // SAME dependency: personaAccessor.ActiveShowId reads off the same cached snapshot source + // ActivePersonaId already reads (see IActivePersonaAccessor's own remarks), captured here + // rather than at drain time for the exact same backlog-mis-stamp reason. Not one read at one + // instant, though: each property getter independently re-resolves against the current wall + // clock, so a schedule boundary landing between these two reads can split the pair on this + // one row at most — accepted (see IActivePersonaAccessor.ActiveShowId's own remarks). It is + // captured HERE — never carried on TrackAired itself — because a show is a schedule-grid fact + // (who is on air right now), not a pushed-item one: SegmentKind/PersonaPick ride TrackAired + // because PlayoutFeeder already captured them off the pushed MediaItem at push time; a show + // has no such per-item origin, only the on-air answer at the moment this event publishes. var request = evt switch { // Artist (SPEC F84.1, STORY-215, PLAN T70) rides the same capture-at-publish-time @@ -56,10 +68,12 @@ public void Publish(StationEvent evt) // T220) rides the SAME discipline: t.SegmentKind is PlayoutFeeder's own forwarded // MediaItem.SegmentKind, stringified to its enum token name (or null for music/engine- // initiated) — the demo-hour instrument's genuine AIR-time stamp, never patter-aired's - // render-time one. + // render-time one. ShowId rides personaAccessor.ActiveShowId, same capture-at-publish-time, + // never-re-derived discipline — the ONE chokepoint this switch arm already is covers + // music and kinded TrackAired alike (PLAN T242's own "verify one chokepoint" note). TrackAired t => new BoothLogEntryRequest( "track-started", Summarize(t), personaAccessor.ActivePersonaId, t.Artist, BuildPickStamp(t.PersonaPick), - ParseMediaId(t.MediaId), SegmentKind: t.SegmentKind?.ToString()), + ParseMediaId(t.MediaId), SegmentKind: t.SegmentKind?.ToString(), ShowId: personaAccessor.ActiveShowId), SegmentGenerated s => new BoothLogEntryRequest("patter-aired", Summarize(s), PersonaId: null), DegradationModeChanged d => new BoothLogEntryRequest("mode-changed", Summarize(d), PersonaId: null), HandoffPieceDropped h => new BoothLogEntryRequest("handoff-dropped", Summarize(h), PersonaId: null), diff --git a/src/GenWave.MediaLibrary/Station/LegacyPersonaCardMapper.cs b/src/GenWave.MediaLibrary/Station/LegacyPersonaCardMapper.cs index 3079065b..17bca5b6 100644 --- a/src/GenWave.MediaLibrary/Station/LegacyPersonaCardMapper.cs +++ b/src/GenWave.MediaLibrary/Station/LegacyPersonaCardMapper.cs @@ -59,9 +59,22 @@ public static string BuildSoul(string backstory, string style) public static string Slugify(string name) { var slug = NonAlphaNumeric().Replace(name.Trim().ToLowerInvariant(), "-").Trim('-'); - return slug.Length == 0 ? "persona" : slug; + return slug.Length == 0 ? FallbackSlug : slug; } + /// + /// The empty-slug rescue literal (PLAN T240 review A3 — promoted from a hand-copied string + /// constant in ShowRepository). Returned whenever 's regex strips a + /// name down to nothing (an emoji-only name, for instance) — but a caller that REJECTS any + /// resulting slug equal to this literal (rather than only ever reaching it via that empty-input + /// path) also catches the plainer case of a name that slugifies to it directly, e.g. the literal + /// name "Persona" itself (lowercases to "persona" unchanged — no character is + /// non-alphanumeric, so the empty-slug rescue never even fires; the ordinary path just happens to + /// land on the same string). 's own ValidateName is exactly + /// that caller. + /// + public const string FallbackSlug = "persona"; + [GeneratedRegex("[^a-z0-9]+")] private static partial Regex NonAlphaNumeric(); } diff --git a/src/GenWave.MediaLibrary/Station/ScheduleRepository.cs b/src/GenWave.MediaLibrary/Station/ScheduleRepository.cs index 5171bfa6..5875a6e1 100644 --- a/src/GenWave.MediaLibrary/Station/ScheduleRepository.cs +++ b/src/GenWave.MediaLibrary/Station/ScheduleRepository.cs @@ -28,6 +28,14 @@ sealed class ScheduleRepository(Lazy dataSource) : IScheduleSt /// column (genres) as the general CLR type, which Dapper's stricter /// positional-record constructor matching rejects; the property-setter fallback this shape uses /// coerces it instead. + /// + /// + /// ShowId/ShowName/ShowTagline/ShowFlavor (SPEC F116.1, PLAN T241) are + /// 's own LEFT JOIN against station.show — deliberately never + /// show.persona_id/show.envelope (SPEC F115.2's dormant-columns-unread pin: this row + /// shape has no property to even receive them). All four are null together on an unnamed block + /// (no matching join row). + /// /// sealed record ScheduleRow { @@ -39,14 +47,27 @@ sealed record ScheduleRow public string[]? Genres { get; init; } public double? EnergyMin { get; init; } public double? EnergyMax { get; init; } + public long? ShowId { get; init; } + public string? ShowName { get; init; } + public string? ShowTagline { get; init; } + public string? ShowFlavor { get; init; } } + // SPEC F116.1/F91.3 (PLAN T241): the LEFT JOIN resolves every block's show identity at THIS one + // load (LoadWeekAsync/ReplaceWeekAsync's own post-write reload both share this constant) rather + // than a per-tick lookup — ScheduleResolver only ever sees the already-joined ScheduleWeekSnapshot + // (ARCHITECTURE.md "the 3s feeder tick performs no schedule query", now extended to show identity + // too). Selects ONLY show.id/name/tagline/flavor — never show.persona_id/envelope (SPEC F115.2's + // dormant-columns-unread pin), enforced here at the query itself, not merely by ScheduleRow's own + // shape above. const string SelectColumns = """ - select id::bigint as id, day_of_week, start_minute, end_minute, - persona_id::bigint as persona_id, genres, - energy_min::double precision as energy_min, energy_max::double precision as energy_max - from station.segment_schedule + select s.id::bigint as id, s.day_of_week, s.start_minute, s.end_minute, + s.persona_id::bigint as persona_id, s.genres, + s.energy_min::double precision as energy_min, s.energy_max::double precision as energy_max, + sh.id::bigint as show_id, sh.name as show_name, sh.tagline as show_tagline, sh.flavor as show_flavor + from station.segment_schedule s + left join station.show sh on sh.id = s.show_id """; public async Task LoadWeekAsync(CancellationToken ct) @@ -86,6 +107,9 @@ await conn.ExecuteAsync(new CommandDefinition( if (week.Count > 0) { + // ScheduleSegment.Show (SPEC F116.1, PLAN T241) is deliberately absent from this column + // list — it is LOAD-only through SelectColumns' LEFT JOIN, never written here. show_id is + // read-only through this epic; T243's writer owns adding it to this insert. await conn.ExecuteAsync(new CommandDefinition( """ insert into station.segment_schedule @@ -122,7 +146,7 @@ static async Task> LoadSegmentsAsync( NpgsqlConnection conn, NpgsqlTransaction? transaction, CancellationToken ct) { var rows = await conn.QueryAsync(new CommandDefinition( - $"{SelectColumns} order by day_of_week, start_minute", + $"{SelectColumns} order by s.day_of_week, s.start_minute", transaction: transaction, cancellationToken: ct)); return rows.Select(ToSegment).ToList(); @@ -204,5 +228,58 @@ static ScheduleCellError Error(int rowIndex, ScheduleSegment seg, ScheduleCellEr static ScheduleSegment ToSegment(ScheduleRow row) => new( row.Id, (DayOfWeek)row.DayOfWeek, row.StartMinute, row.EndMinute, row.PersonaId, - row.Genres, row.EnergyMin, row.EnergyMax); + row.Genres, row.EnergyMin, row.EnergyMax, ToShowSummary(row)); + + /// SPEC F116.1 (PLAN T241) — when the block names no show (the + /// LEFT JOIN found no matching station.show row); otherwise the four identity columns + /// selected, never persona_id/envelope (SPEC F115.2's + /// dormant-columns-unread pin — this method has no row data to even attempt reading either from). + /// ShowName is checked alongside ShowId purely as belt-and-suspenders null-safety + /// (never the actual guard in practice — station.show.name is NOT NULL, so any row + /// the join finds by id always carries one): this avoids ever needing the null-forgiving operator + /// to construct from a row Dapper types every column of as nullable. + /// + static ShowSummary? ToShowSummary(ScheduleRow row) => + row.ShowId is { } showId && row.ShowName is { } showName + ? new ShowSummary(showId, showName, row.ShowTagline, row.ShowFlavor) + : null; + + /// + /// Ephemeral Dapper projection for — settable properties, not + /// a positional record, mirrors 's own identically-shaped + /// ScheduledSlotRow: kept as a plain here and cast to + /// only when building the public , rather than + /// trusting Dapper's constructor-based binding to coerce an integer column straight into an + /// enum-typed positional-record parameter. + /// + sealed record ScheduledSlotRow + { + public int DayOfWeek { get; init; } + public int StartMinute { get; init; } + public int EndMinute { get; init; } + } + + /// + /// The show delete guard's own detail read (SPEC F115.4, PLAN T240) — mirrors + /// 's own QueryScheduledSlotsAsync shape exactly, just + /// against show_id instead of persona_id and as a public seam member rather than a + /// private helper, since deliberately never + /// pre-queries this table itself (that store's own remarks) — ShowsController calls this + /// directly instead. + /// + public async Task> GetSlotsByShowIdAsync(long showId, CancellationToken ct) + { + await using var conn = await dataSource.Value.OpenConnectionAsync(ct); + var rows = await conn.QueryAsync(new CommandDefinition( + """ + select day_of_week, start_minute, end_minute + from station.segment_schedule + where show_id = @showId + order by day_of_week, start_minute + """, + new { showId }, + cancellationToken: ct)); + + return rows.Select(row => new ScheduledSlot((DayOfWeek)row.DayOfWeek, row.StartMinute, row.EndMinute)).ToList(); + } } diff --git a/src/GenWave.MediaLibrary/Station/ShowRepository.cs b/src/GenWave.MediaLibrary/Station/ShowRepository.cs new file mode 100644 index 00000000..7c609c0c --- /dev/null +++ b/src/GenWave.MediaLibrary/Station/ShowRepository.cs @@ -0,0 +1,206 @@ +using Dapper; +using GenWave.Core.Abstractions; +using GenWave.Core.Domain; +using Npgsql; + +namespace GenWave.MediaLibrary.Station; + +/// +/// The in-process implementation of (SPEC F115.1, STORY-305, PLAN T239) over +/// station.show. Connection-per-query against a station_svc-scoped +/// — mirrors 's own wiring and slug-conflict-via-unique-violation +/// posture exactly (the closest existing station repository: name + house-Slugify'd slug + provenance +/// pair). Never selects/binds persona_id/envelope — SPEC F115.2's "unread this epic" law +/// — so has no way to carry either even by accident. +/// +/// is a , the same "resolving must never be enough +/// to trigger a connection attempt" reason every other station-schema store in this file's directory +/// carries one (see 's own remarks in full). +/// +sealed class ShowRepository(Lazy dataSource) : IShowStore +{ + // Postgres SQLSTATE for unique_violation — mirrors PersonaRepository's NameConflict mapping; here + // the only UNIQUE constraint on station.show is show_slug_key (db/06, db/35), so any 23505 this + // repository can trigger is a slug collision. + const string UniqueViolation = "23505"; + + // Postgres SQLSTATE for foreign_key_violation — station.segment_schedule.show_id's ON DELETE + // RESTRICT (db/06, SPEC F114) fires here when DeleteAsync targets a show a schedule row still + // names. Mirrors PersonaRepository's own ForeignKeyViolation mapping (PLAN T120 review F4: this + // mapping lives in the store, never a controller, so no controller ever imports Npgsql). Unlike + // PersonaRepository.DeleteAsync's query-then-delete idiom, this store does not pre-query the + // schedule for slot detail — PLAN T240's endpoint-layer guard (SPEC F115.4) names the offending + // blocks; ShowWriteResult.Referenced stays a bare singleton here. + const string ForeignKeyViolation = "23503"; + + // id is `serial` (int4) at rest — mirrors PersonaRepository's own SelectColumns comment: every id + // in this codebase is `long` (bigint) in C#, so it is cast on the way out for a consistent, + // single-width C# id type. + const string SelectColumns = + "select id::bigint as id, name, slug, tagline, flavor, imported_from, imported_at, " + + "created_at, updated_at from station.show"; + + public async Task> GetAllAsync(CancellationToken ct) + { + await using var conn = await dataSource.Value.OpenConnectionAsync(ct); + var rows = await conn.QueryAsync(new CommandDefinition( + $"{SelectColumns} order by name", + cancellationToken: ct)); + return rows.ToList(); + } + + public async Task GetByIdAsync(long id, CancellationToken ct) + { + await using var conn = await dataSource.Value.OpenConnectionAsync(ct); + return await conn.QuerySingleOrDefaultAsync(new CommandDefinition( + $"{SelectColumns} where id = @id", + new { id }, + cancellationToken: ct)); + } + + public async Task GetBySlugAsync(string slug, CancellationToken ct) + { + await using var conn = await dataSource.Value.OpenConnectionAsync(ct); + return await conn.QuerySingleOrDefaultAsync(new CommandDefinition( + $"{SelectColumns} where slug = @slug", + new { slug }, + cancellationToken: ct)); + } + + /// + /// Single-statement insert (SPEC F115.1). The insert IS the uniqueness check — a colliding slug + /// raises a 23505 unique_violation on station.show's UNIQUE(slug) constraint, caught + /// here rather than pre-checked with a SELECT (no TOCTOU gap, no wasted round trip on the common + /// path) — mirrors . Deliberately does not name + /// imported_from/imported_at in the column list (SPEC F90.7's pattern, applied here + /// per F115.1): an authored show keeps both NULL by construction; only a future import write path + /// (PLAN T254) ever sets them. tagline/flavor bind through + /// — an empty/whitespace-only value persists as NULL, matching 's own + /// "null when the show carries none" contract instead of a stray ''. + /// + public async Task CreateAsync(ShowDraft draft, CancellationToken ct) + { + var slug = LegacyPersonaCardMapper.Slugify(draft.Name); + if (ValidateName(draft, slug) is { } invalidName) return invalidName; + if (ValidateBudgets(draft) is { } violation) return violation; + + try + { + await using var conn = await dataSource.Value.OpenConnectionAsync(ct); + var show = await conn.QuerySingleAsync(new CommandDefinition( + $""" + insert into station.show (name, slug, tagline, flavor) + values (@Name, @Slug, @Tagline, @Flavor) + returning id::bigint as id, name, slug, tagline, flavor, imported_from, imported_at, + created_at, updated_at + """, + new { draft.Name, Slug = slug, Tagline = NullIfBlank(draft.Tagline), Flavor = NullIfBlank(draft.Flavor) }, + cancellationToken: ct)); + return new ShowWriteResult.Created(show); + } + catch (PostgresException ex) when (ex.SqlState == UniqueViolation) + { + return new ShowWriteResult.SlugConflict(); + } + } + + /// + /// Plain UPDATE (SPEC F115.1) — re-derives slug from the draft's Name the same way + /// does, mirroring 's own + /// "re-derive on every authored edit" rule for Persona.Slug. updated_at advances in + /// SQL (now()), never in C#. Like , never names + /// imported_from/imported_at — an authored edit leaves an imported show's provenance + /// stamp exactly as the last import left it (the endpoint-layer gate that refuses an authored edit + /// to an imported show entirely is PLAN T240's SPEC F115.5, not this seam's) — and, like + /// , binds tagline/flavor through + /// so clearing either field to "" in an edit persists NULL, not an empty string. + /// + public async Task UpdateAsync(long id, ShowDraft draft, CancellationToken ct) + { + var slug = LegacyPersonaCardMapper.Slugify(draft.Name); + if (ValidateName(draft, slug) is { } invalidName) return invalidName; + if (ValidateBudgets(draft) is { } violation) return violation; + + try + { + await using var conn = await dataSource.Value.OpenConnectionAsync(ct); + var show = await conn.QuerySingleOrDefaultAsync(new CommandDefinition( + $""" + update station.show + set name = @Name, slug = @Slug, tagline = @Tagline, flavor = @Flavor, updated_at = now() + where id = @Id + returning id::bigint as id, name, slug, tagline, flavor, imported_from, imported_at, + created_at, updated_at + """, + new { draft.Name, Slug = slug, Tagline = NullIfBlank(draft.Tagline), Flavor = NullIfBlank(draft.Flavor), Id = id }, + cancellationToken: ct)); + return show is null ? new ShowWriteResult.NotFound() : new ShowWriteResult.Updated(show); + } + catch (PostgresException ex) when (ex.SqlState == UniqueViolation) + { + return new ShowWriteResult.SlugConflict(); + } + } + + /// + /// Plain DELETE (SPEC F115.1). station.segment_schedule.show_id's ON DELETE RESTRICT + /// (db/06, SPEC F114) already gives the database its own teeth against deleting a still-referenced + /// show — surfaced here as SQLSTATE 23503, caught and mapped to + /// (PLAN T120 review F4: the mapping lives in the store, + /// not a controller). Unlike , this method does not + /// pre-query the schedule for slot detail — naming which blocks reference it for a 409 body is + /// PLAN T240's endpoint-layer guard (SPEC F115.4); this seam's own case only says "referenced". + /// + public async Task DeleteAsync(long id, CancellationToken ct) + { + try + { + await using var conn = await dataSource.Value.OpenConnectionAsync(ct); + var affected = await conn.ExecuteAsync(new CommandDefinition( + "delete from station.show where id = @id", + new { id }, + cancellationToken: ct)); + return affected == 0 ? new ShowWriteResult.NotFound() : new ShowWriteResult.Deleted(); + } + catch (PostgresException ex) when (ex.SqlState == ForeignKeyViolation) + { + return new ShowWriteResult.Referenced(); + } + } + + /// + /// SPEC F115.1's name-shape guard — pure C#, evaluated before either write method ever opens a + /// connection. Rejects a blank/whitespace-only Name outright (station.show's own check + /// (length(btrim(name)) > 0), db/33, would otherwise surface as an unhandled 23514 + /// check_violation) and a equal to + /// — REJECTED regardless of how it got there: + /// an emoji-only name that hits Slugify's own empty-slug rescue AND a name that slugifies to + /// "persona" the ordinary way (the literal name "Persona", for instance — see + /// 's own remarks, PLAN T240 review A1) both + /// land here. See 's own remarks for the + /// REJECT-not-autocorrect rationale. is passed in rather than re-derived + /// so the two call sites (/) compute + /// LegacyPersonaCardMapper.Slugify exactly once each. + /// + static ShowWriteResult.InvalidName? ValidateName(ShowDraft draft, string slug) => + string.IsNullOrWhiteSpace(draft.Name) || slug == LegacyPersonaCardMapper.FallbackSlug + ? new ShowWriteResult.InvalidName() + : null; + + /// + /// SPEC F115.1's 1× budget check — delegates to (the rule + /// now lives beside its own constants in Core so T240/T244/T254 can reuse the identical check + /// order without re-deriving it). + /// + static ShowWriteResult.BudgetExceeded? ValidateBudgets(ShowDraft draft) => + ShowBudgets.FirstViolation(draft) is { } field ? new ShowWriteResult.BudgetExceeded(field) : null; + + /// + /// Empty/whitespace-only optional text collapses to null at this write seam (SPEC F115.1; + /// /'s own docs promise null "when the show + /// carries none"). An editor that clears a tagline/flavor field sends "", not null — + /// binding that verbatim would persist an empty string forever rather than the documented absent + /// state. + /// + static string? NullIfBlank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value; +} diff --git a/src/GenWave.MediaLibrary/Station/ShowServiceCollectionExtensions.cs b/src/GenWave.MediaLibrary/Station/ShowServiceCollectionExtensions.cs new file mode 100644 index 00000000..a33864db --- /dev/null +++ b/src/GenWave.MediaLibrary/Station/ShowServiceCollectionExtensions.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.DependencyInjection; +using GenWave.Core.Abstractions; +using Npgsql; + +namespace GenWave.MediaLibrary.Station; + +/// +/// DI wiring for (SPEC F115.1, STORY-305, PLAN T239). Deliberately separate +/// from : station.show +/// lives in the station schema/role (station_svc), not library — the same "own +/// connection string, own data source" shape +/// 's and 's +/// registrations use. +/// +/// T239 ships this registration deliberately without a Host call site (mirrors +/// 's own original T181 shape — "no +/// consumer lands with this seam"): /api/shows (PLAN T240) is the first consumer. +/// +public static class ShowServiceCollectionExtensions +{ + /// + /// Registers as a singleton over a dedicated + /// built from . The data source + /// build is wrapped in a — mirrors + /// 's own remarks: merely resolving + /// must never be enough to trigger a connection attempt against an + /// empty/dev-mode connection string. + /// + public static IServiceCollection AddShowStore(this IServiceCollection services, string connectionString) => + services.AddSingleton( + _ => new ShowRepository(new Lazy(() => new NpgsqlDataSourceBuilder(connectionString).Build()))); +} diff --git a/src/GenWave.Orchestration/EffectiveAssignment.cs b/src/GenWave.Orchestration/EffectiveAssignment.cs new file mode 100644 index 00000000..49c1c34f --- /dev/null +++ b/src/GenWave.Orchestration/EffectiveAssignment.cs @@ -0,0 +1,52 @@ +using GenWave.Core.Domain; + +namespace GenWave.Orchestration; + +/// +/// SPEC F115.2 (STORY-306, PLAN T241) — the ONE identity chokepoint every on-air identity concern +/// resolves through, so ARCHITECTURE.md's "📛 Dayparting: named shows" design-for-change spine holds: +/// the deferred schedulable-bundle slice widens exactly this one function's own +/// logic, and every v1 consumer — reading identity off , +/// which builds through this type — is untouched by construction. +/// +/// +/// v1 rule (implemented here): BLOCK-LEVEL PERSONA ONLY. always returns +/// 's own PersonaId is never consulted for it. +/// This is not an oversight: structurally carries no persona_id/ +/// envelope member at all (SPEC F115.2's dormant-columns-unread pin — see that type's own +/// remarks), so there is nothing on this function COULD read even if it tried. +/// +/// +/// +/// Future rule (recorded, NOT implemented here): once the deferred schedulable-bundle slice +/// widens station.show's dormant persona_id/envelope columns into a real reader, +/// the effective persona/envelope becomes block ?? show ?? none — block always wins. That +/// widening touches only this function's own body: the type it takes for would +/// grow the bundle fields, but and every downstream consumer of +/// stay diff-free. +/// +/// +/// +/// is taken as its own parameter rather than reached via +/// 's own property deliberately (Law of +/// Demeter): this function only ever needs the two pieces of already-resolved state its caller hands +/// it, never 's full shape — the same decoupling that lets a future +/// specials rung (PLAN T258, "dated rows shadow the grid") substitute a DIFFERENT resolved show for a +/// given block without this function changing at all. +/// +/// +/// +/// Envelope resolution is deliberately OUT of this function's scope: 's +/// own BuildSegmentEnvelope already implements the unrelated "segment fields ?? station-default" +/// fallback (SPEC F91.4) — that chain never involves a show today, so moving it here would be a diff +/// with no behavior change. The bundle slice's own envelope widening lands in +/// alongside the persona widening described above, not before. +/// +/// +public sealed record EffectiveAssignment(long? PersonaId, ShowSummary? Show) +{ + /// Resolves / into the identity + /// folds into every + /// it builds — see this type's own remarks for the v1-vs-future rule. + public static EffectiveAssignment Resolve(ScheduleSegment block, ShowSummary? show) => new(block.PersonaId, show); +} diff --git a/src/GenWave.Orchestration/OnAirPersonaAccessor.cs b/src/GenWave.Orchestration/OnAirPersonaAccessor.cs index 74b20582..ac46e73e 100644 --- a/src/GenWave.Orchestration/OnAirPersonaAccessor.cs +++ b/src/GenWave.Orchestration/OnAirPersonaAccessor.cs @@ -220,6 +220,14 @@ public sealed class OnAirPersonaAccessor( public string? TryGetCachedName(long personaId) => cachedNames.TryGetValue(personaId, out var name) ? name : null; + /// + /// SPEC F121.1 (STORY-310, PLAN T242) — reads the SAME cached snapshot + /// does, off instead of : no + /// second resolve, no new I/O, the identical "before the first resolve, or an empty grid, answers + /// null" boot-window behavior. + /// + public long? ActiveShowId => scheduleResolver.TryGetCurrent()?.Show?.Id; + /// /// Resolves the on-air snapshot, degrading to on any /// fault (F12.4) — most notably an unconfigured diff --git a/src/GenWave.Orchestration/ScheduleResolver.cs b/src/GenWave.Orchestration/ScheduleResolver.cs index f18da348..5a41518b 100644 --- a/src/GenWave.Orchestration/ScheduleResolver.cs +++ b/src/GenWave.Orchestration/ScheduleResolver.cs @@ -73,20 +73,23 @@ OnAirSnapshot ResolveCurrent( var (boundaryDay, boundaryMinute) = NormalizeMinute(current.Day, current.EndMinute); var boundaryAt = ResolveBoundaryInstant(todayDate, today, nowMinute, boundaryDay, boundaryMinute, zone, now); var next = FindAdjacent(segments, boundaryDay, boundaryMinute); - return new OnAirSnapshot(current, current.PersonaId, envelope, boundaryAt, next); + var assignment = EffectiveAssignment.Resolve(current, current.Show); + return new OnAirSnapshot(current, assignment.PersonaId, envelope, boundaryAt, next, assignment.Show); } OnAirSnapshot ResolveGap( IReadOnlyList segments, TimeZoneInfo zone, DateTime todayDate, DayOfWeek today, int nowMinute, DateTimeOffset now) { + // No block is on air (SPEC F91.4) — nothing for EffectiveAssignment to resolve: persona and + // show are both unconditionally none, the only honest answer for a grid gap. var envelope = defaultEnvelopeSource.Current; var next = FindNextUpcoming(segments, today, nowMinute); if (next is null) - return new OnAirSnapshot(Segment: null, PersonaId: null, envelope, BoundaryAt: null, NextSegment: null); + return new OnAirSnapshot(Segment: null, PersonaId: null, envelope, BoundaryAt: null, NextSegment: null, Show: null); var boundaryAt = ResolveBoundaryInstant(todayDate, today, nowMinute, next.Day, next.StartMinute, zone, now); - return new OnAirSnapshot(Segment: null, PersonaId: null, envelope, boundaryAt, next); + return new OnAirSnapshot(Segment: null, PersonaId: null, envelope, boundaryAt, next, Show: null); } SegmentEnvelope BuildSegmentEnvelope(ScheduleSegment segment) diff --git a/tests/GenWave.Host.Tests/Fakes/FakeScheduleStore.cs b/tests/GenWave.Host.Tests/Fakes/FakeScheduleStore.cs index 89f57118..44e7952c 100644 --- a/tests/GenWave.Host.Tests/Fakes/FakeScheduleStore.cs +++ b/tests/GenWave.Host.Tests/Fakes/FakeScheduleStore.cs @@ -44,6 +44,12 @@ sealed class FakeScheduleStore(ScheduleWeekSnapshot? initial = null) : ISchedule public int ReplaceWeekAsyncCallCount { get; private set; } + /// What answers for a given show id (PLAN T240's + /// show delete guard) — keyed by showId so a scenario can seed the exact referencing blocks a + /// -scripted delete should be named by, without + /// re-deriving ScheduleRepository's own SQL. Unseeded ids answer empty, never an error. + public Dictionary> SlotsByShowId { get; } = []; + /// Counts every call (SPEC F93.4, STORY-244, PLAN T125) — the /// structural proof that /// never reloads: only an explicit @@ -102,4 +108,7 @@ public Task ReplaceWeekAsync( WeekChanged?.Invoke(); return Task.FromResult(new ScheduleReplaceResult.Replaced(current)); } + + public Task> GetSlotsByShowIdAsync(long showId, CancellationToken ct) => + Task.FromResult(SlotsByShowId.TryGetValue(showId, out var slots) ? slots : []); } diff --git a/tests/GenWave.Host.Tests/Fakes/FakeShowImagingScope.cs b/tests/GenWave.Host.Tests/Fakes/FakeShowImagingScope.cs new file mode 100644 index 00000000..c8e8ef42 --- /dev/null +++ b/tests/GenWave.Host.Tests/Fakes/FakeShowImagingScope.cs @@ -0,0 +1,45 @@ +using GenWave.Core.Abstractions; +using GenWave.Core.Domain; + +namespace GenWave.Host.Tests.Fakes; + +/// +/// In-memory double (STORY-305, PLAN T240) for +/// ShowsController's wire-layer specs — mirrors 's own +/// posture: a fixed (show id → scoped rows) map, no re-implementation of +/// GenWave.MediaLibrary.Catalog.ShowImagingScopeRepository's own SQL. The no-args construction +/// knows no rows — every unscope answer is the empty list, the "nothing was ever scoped to this show" +/// default every pre-existing spec would assume. +/// +sealed class FakeShowImagingScope(IReadOnlyDictionary>? scopedByShowId = null) + : IShowImagingScope +{ + readonly IReadOnlyDictionary> scopedByShowId = + scopedByShowId ?? new Dictionary>(); + + /// Every showId this double's was actually called with, in + /// call order — proves the show delete guard calls it AFTER a successful delete, never before or + /// on a refused one (SPEC F115.4's own ordering rule). + public List UnscopeCalls { get; } = []; + + /// Scripts the NEXT call to throw this exception instead of + /// returning (PLAN T240 review — proves ShowsController.Delete's own best-effort posture: + /// a library-connection failure here still reports the delete's own success, logged rather than + /// surfaced as a 500). Cleared after one use; the call is still recorded in + /// before the throw, matching a real repository call that fails mid + /// round-trip after already being dispatched. + public Exception? NextThrow { get; set; } + + public Task> UnscopeAsync(long showId, CancellationToken ct) + { + UnscopeCalls.Add(showId); + + if (NextThrow is { } toThrow) + { + NextThrow = null; + throw toThrow; + } + + return Task.FromResult(scopedByShowId.TryGetValue(showId, out var rows) ? rows : []); + } +} diff --git a/tests/GenWave.Host.Tests/Fakes/FakeShowStore.cs b/tests/GenWave.Host.Tests/Fakes/FakeShowStore.cs new file mode 100644 index 00000000..a4243c93 --- /dev/null +++ b/tests/GenWave.Host.Tests/Fakes/FakeShowStore.cs @@ -0,0 +1,114 @@ +using GenWave.Core.Abstractions; +using GenWave.Core.Domain; + +namespace GenWave.Host.Tests.Fakes; + +/// +/// In-memory double (STORY-305, PLAN T240) for ShowsController's +/// wire-layer specs — mirrors FakeScheduleStore's own posture: this double is deliberately +/// NOT a re-implementation of GenWave.MediaLibrary.Station.ShowRepository's own InvalidName/ +/// budget/slug-conflict validation (that validation is proven for real, against a real Postgres +/// fixture, in GenWave.MediaLibrary.Tests/Specs/Story305_ShowRepository.cs — a +/// re-implementation here would be a lookalike double). The default (unscripted) behavior simply +/// echoes whatever is submitted — a fresh id, a locally-derived slug, blank/whitespace +/// tagline/flavor coerced to null (the one bit of that repository's own contract cheap enough to +/// reproduce faithfully without re-deriving any REJECTION logic) — and every write method can be +/// SCRIPTED via its own Next*Result property to return an exact +/// instead, for the sad-path/gate-parity facts that need one without a real repository behind them. +/// +sealed class FakeShowStore : IShowStore +{ + readonly Dictionary byId; + long nextId; + + /// Seeds the store with pre-existing rows (e.g. an IMPORTED show — no writer through + /// this interface can ever produce one, mirrors how the real repository's own provenance tests + /// insert directly rather than going through ShowRepository.CreateAsync). + public FakeShowStore(IEnumerable? seed = null) + { + byId = (seed ?? []).ToDictionary(s => s.Id); + nextId = byId.Count == 0 ? 1 : byId.Keys.Max() + 1; + } + + /// Scripts the NEXT call's outcome verbatim, bypassing the + /// default echo-and-store behavior. Cleared after one use. + public ShowWriteResult? NextCreateResult { get; set; } + + /// Scripts the NEXT call's outcome verbatim. Cleared after one + /// use. + public ShowWriteResult? NextUpdateResult { get; set; } + + /// Scripts the NEXT call's outcome verbatim — the guard-path + /// facts need without a real + /// station.segment_schedule FK to trigger it. Cleared after one use. + public ShowWriteResult? NextDeleteResult { get; set; } + + public Task> GetAllAsync(CancellationToken ct) => + Task.FromResult>(byId.Values.OrderBy(s => s.Name, StringComparer.Ordinal).ToList()); + + public Task GetByIdAsync(long id, CancellationToken ct) => + Task.FromResult(byId.GetValueOrDefault(id)); + + public Task GetBySlugAsync(string slug, CancellationToken ct) => + Task.FromResult(byId.Values.FirstOrDefault(s => s.Slug == slug)); + + public Task CreateAsync(ShowDraft draft, CancellationToken ct) + { + if (NextCreateResult is { } scripted) + { + NextCreateResult = null; + return Task.FromResult(scripted); + } + + var now = DateTime.UtcNow; + var show = new Show( + nextId++, draft.Name, Slugify(draft.Name), NullIfBlank(draft.Tagline), NullIfBlank(draft.Flavor), + ImportedFrom: null, ImportedAt: null, now, now); + byId[show.Id] = show; + return Task.FromResult(new ShowWriteResult.Created(show)); + } + + public Task UpdateAsync(long id, ShowDraft draft, CancellationToken ct) + { + if (NextUpdateResult is { } scripted) + { + NextUpdateResult = null; + return Task.FromResult(scripted); + } + + if (!byId.TryGetValue(id, out var existing)) + return Task.FromResult(new ShowWriteResult.NotFound()); + + var updated = existing with + { + Name = draft.Name, + Slug = Slugify(draft.Name), + Tagline = NullIfBlank(draft.Tagline), + Flavor = NullIfBlank(draft.Flavor), + UpdatedAt = DateTime.UtcNow, + }; + byId[id] = updated; + return Task.FromResult(new ShowWriteResult.Updated(updated)); + } + + public Task DeleteAsync(long id, CancellationToken ct) + { + if (NextDeleteResult is { } scripted) + { + NextDeleteResult = null; + return Task.FromResult(scripted); + } + + return Task.FromResult( + byId.Remove(id) ? new ShowWriteResult.Deleted() : new ShowWriteResult.NotFound()); + } + + // A deterministic, display-only stand-in for the production house Slugify (never accessible from + // this project — internal to GenWave.MediaLibrary) — good enough for round-trip routing through + // GetBySlugAsync-addressed routes; this double never needs to match LegacyPersonaCardMapper.Slugify + // byte-for-byte, since that algorithm is already proven against Story305_ShowRepository.cs. + static string Slugify(string name) => + string.Join('-', name.Trim().ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries)); + + static string? NullIfBlank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value; +} diff --git a/tests/GenWave.Host.Tests/Specs/Story217_BoothLogPickStamp.cs b/tests/GenWave.Host.Tests/Specs/Story217_BoothLogPickStamp.cs index a3f62bd6..6fa4ec2a 100644 --- a/tests/GenWave.Host.Tests/Specs/Story217_BoothLogPickStamp.cs +++ b/tests/GenWave.Host.Tests/Specs/Story217_BoothLogPickStamp.cs @@ -39,20 +39,20 @@ namespace GenWave.Host.Tests.Specs; /// /// In-memory double: records every call's -/// arguments instead of touching Postgres — the repository seam -/// fakes (mirrors FakeBoothLogReader's idiom on the read side, Story195_BoothLog.cs). Releases -/// a signal per call so a scenario can await exactly as many appends as it published, with no -/// arbitrary sleep. +/// instead of touching Postgres — the repository seam +/// fakes (mirrors FakeBoothLogReader's idiom on the read +/// side, Story195_BoothLog.cs). Releases a signal per call so a scenario can await exactly as many +/// appends as it published, with no arbitrary sleep. /// file sealed class FakeBoothLogAppender : IBoothLogAppender { readonly SemaphoreSlim appended = new(0); - public List<(string Kind, string Summary, long? PersonaId, string? Artist, string? Pick, long? MediaId, string? SegmentKind)> Calls { get; } = []; + public List Calls { get; } = []; - public Task AppendAsync(string kind, string summary, long? personaId, string? artist, string? pick, long? mediaId, string? segmentKind, CancellationToken ct) + public Task AppendAsync(BoothLogAppendRequest request, CancellationToken ct) { - lock (Calls) Calls.Add((kind, summary, personaId, artist, pick, mediaId, segmentKind)); + lock (Calls) Calls.Add(request); appended.Release(); return Task.CompletedTask; } diff --git a/tests/GenWave.Host.Tests/Specs/Story305_ShowsApi.cs b/tests/GenWave.Host.Tests/Specs/Story305_ShowsApi.cs new file mode 100644 index 00000000..be46eefd --- /dev/null +++ b/tests/GenWave.Host.Tests/Specs/Story305_ShowsApi.cs @@ -0,0 +1,454 @@ +// STORY-305 — The show entity & API (F115.1, F115.4, F115.5) — endpoint half +// +// BDD specification — xUnit. WIRED T240 — every Fact below drives the real production +// /api/shows routes (F79/F90 AdminSurface + Settings posture) through WebApplicationFactory +// with real cookie auth (real POST /api/auth/login — mirrors Story240_GridHoldsTheWeek.cs's own +// idiom), against FakeShowStore/FakeScheduleStore/FakeShowImagingScope doubles — no live Postgres, +// this project has none for Host.Tests. The repository half (real Postgres, ShowRepository's own +// InvalidName/budget/slug-conflict validation) lives in +// GenWave.MediaLibrary.Tests/Specs/Story305_ShowRepository.cs — this file never re-derives that +// validation, only the WIRE mapping a scripted GenWave.Core.Domain.ShowWriteResult produces +// (mirrors FakeScheduleStore's own posture, see that Fake's remarks). +// +// ScenarioGateParityAcrossCreateAndUpdate extends PLAN T207's own 7-row BadBodyTable precedent +// (Story287_SaveAsOwn.cs): narrowed to this store's five app-seam gates (blank/fallback-slug name, +// and the three SPEC F115.1 budgets) since Show writes have no multi-phase manifest pipeline to +// drift the way the two theme-write routes once did — see ShowsController's own class remarks for +// why that gate lives as one shared WriteProblem mapping instead of a second ThemeWriteGate-shaped +// type. + +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using GenWave.Core.Abstractions; +using GenWave.Core.Domain; +using GenWave.Host.Api; +using GenWave.Host.Tests.Fakes; + +namespace GenWave.Host.Tests.Specs; + +// ── In-process tests ────────────────────────────────────────────────────────────────────────────── + +public static class FeatureShowsApi +{ + public sealed class ScenarioCrudThroughTheProductionSurface + { + [Fact] + public async Task CrudRoundTripsThroughTheEndpoints() + { + // Given an authenticated admin session + var store = new FakeShowStore(); + await using var factory = new ShowsApiWebFactory(store); + var client = await ShowsApiWebFactory.LoggedInClientAsync(factory); + + // When a show is created, listed, edited, and fetched via /api/shows + var createResponse = await client.PostAsJsonAsync( + "/api/shows", new { name = "Night Moves", tagline = "Late-night deep cuts", flavor = "moody, sparse" }); + var created = await createResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(created); + + var list = await (await client.GetAsync("/api/shows")).Content.ReadFromJsonAsync(); + + var editResponse = await client.PatchAsJsonAsync( + $"/api/shows/{created.Slug}", + new { name = "Night Moves", tagline = "Revisited", flavor = "moodier, sparser" }); + var edited = await editResponse.Content.ReadFromJsonAsync(); + + var fetched = await (await client.GetAsync($"/api/shows/{created.Slug}")).Content.ReadFromJsonAsync(); + + // Then every field round-trips end to end: creation succeeds, the list carries the new + // row, the edit lands, and a fresh read agrees with the edit response — reachable at all + // only because LoggedInClientAsync's real cookie round trip passed the AdminSurface/ + // Settings gate every action above sits behind + Assert.Equal( + (Create: HttpStatusCode.Created, Listed: true, Edit: HttpStatusCode.OK, + EditedFields: (Name: "Night Moves", Tagline: "Revisited", Flavor: "moodier, sparser"), + FetchMatchesEdit: true), + (Create: createResponse.StatusCode, Listed: list!.Any(s => s.Slug == created.Slug), + Edit: editResponse.StatusCode, EditedFields: (edited!.Name, edited.Tagline, edited.Flavor), + FetchMatchesEdit: fetched == edited)); + } + + [Fact] + public async Task UnreferencedShowDeletesClean() + { + // Given a show no block, special, or imaging row references + var store = new FakeShowStore(); + var imagingScope = new FakeShowImagingScope(); + await using var factory = new ShowsApiWebFactory(store, imagingScope: imagingScope); + var client = await ShowsApiWebFactory.LoggedInClientAsync(factory); + var created = await (await client.PostAsJsonAsync("/api/shows", new { name = "To Be Deleted" })) + .Content.ReadFromJsonAsync(); + + // When DELETE /api/shows/{slug} runs + var response = await client.DeleteAsync($"/api/shows/{created!.Slug}"); + + // Then 204, the row is gone, and the unscope seam was called exactly once — proving a + // clean delete still runs the best-effort cleanup step (never skipped just because there + // was nothing scoped to find) + var afterDelete = await client.GetAsync($"/api/shows/{created.Slug}"); + Assert.Equal( + (Delete: HttpStatusCode.NoContent, Gone: HttpStatusCode.NotFound, UnscopeCalls: 1), + (Delete: response.StatusCode, Gone: afterDelete.StatusCode, UnscopeCalls: imagingScope.UnscopeCalls.Count)); + } + } + + public sealed class ScenarioGuardedDelete + { + [Fact] + public async Task DeleteWithReferencesFails409NamingBlocks() + { + // Given a show referenced by schedule blocks — the store's own FK case (Referenced) is + // scripted (this project has no Postgres fixture for Host.Tests to trigger the real + // segment_schedule FK), and the referencing block detail is seeded on FakeScheduleStore + // the same way ShowsController.Delete itself re-queries it (SPEC F115.4) + var show = new Show(1, "Scheduled Show", "scheduled-show", null, null, null, null, DateTime.UtcNow, DateTime.UtcNow); + var store = new FakeShowStore([show]) { NextDeleteResult = new ShowWriteResult.Referenced() }; + var scheduleStore = new FakeScheduleStore(); + scheduleStore.SlotsByShowId[show.Id] = [new ScheduledSlot(DayOfWeek.Monday, 540, 720)]; + var imagingScope = new FakeShowImagingScope(); + await using var factory = new ShowsApiWebFactory(store, scheduleStore, imagingScope); + var client = await ShowsApiWebFactory.LoggedInClientAsync(factory); + + // When DELETE runs + var response = await client.DeleteAsync($"/api/shows/{show.Slug}"); + + // Then 409 whose body names the referencing blocks (the F104 guard precedent) — and + // nothing was touched on the imaging side: a block-refused delete unscopes nothing (this + // action's own "ordering is deliberate" rule) + var detail = await DetailAsync(response); + Assert.Equal( + (Status: HttpStatusCode.Conflict, NamesTheDay: true, NamesTheTime: true, UnscopeCalls: 0), + (Status: response.StatusCode, + NamesTheDay: detail.Contains("Mon", StringComparison.Ordinal), + NamesTheTime: detail.Contains("09:00", StringComparison.Ordinal), + UnscopeCalls: imagingScope.UnscopeCalls.Count)); + } + + [Fact] + public async Task ScopedImagingRowsAreNamedAndUnscopedBestEffort() + { + // Given a show referenced only by a scoped imaging row (no FK — F117.1) + var show = new Show(1, "Imaging-Scoped Show", "imaging-scoped-show", null, null, null, null, DateTime.UtcNow, DateTime.UtcNow); + var store = new FakeShowStore([show]); + var imagingScope = new FakeShowImagingScope( + new Dictionary> { [show.Id] = [new ScopedImagingRow(42, "Sunset Ident")] }); + await using var factory = new ShowsApiWebFactory(store, imagingScope: imagingScope); + var client = await ShowsApiWebFactory.LoggedInClientAsync(factory); + + // When DELETE runs + var response = await client.DeleteAsync($"/api/shows/{show.Slug}"); + + // Then the response names the row and the library-connection unscope write is issued + // (idempotent second write — F115.4): the delete itself succeeded (nothing blocked it — + // library.media.show_id carries no FK). Array/list members don't compare structurally + // inside a bundled tuple (ValueTuple.Equals uses reference equality per component), so + // the "called exactly once, with this show's id" claim gets its own Assert.Equal call. + var body = await response.Content.ReadFromJsonAsync(); + Assert.Equal( + (Status: HttpStatusCode.OK, NamedRow: "Sunset Ident"), + (Status: response.StatusCode, NamedRow: body!.UnscopedImaging.Single().Title)); + Assert.Equal([show.Id], imagingScope.UnscopeCalls); + } + + [Fact] + public async Task UnscopeFailureStillReportsDeleteSuccessAndLogsTheError() + { + // Given a show whose post-delete imaging unscope will fail (e.g. the library connection + // drops mid round-trip) — the failure is scripted on the fake rather than reproduced for + // real, mirroring FakeScheduleStore's own NextThrow idiom + var show = new Show(1, "Fragile Unscope Show", "fragile-unscope-show", null, null, null, null, DateTime.UtcNow, DateTime.UtcNow); + var store = new FakeShowStore([show]); + var imagingScope = new FakeShowImagingScope { NextThrow = new InvalidOperationException("library connection dropped") }; + var logs = new CapturingWarningLoggerProvider(); + await using var factory = new ShowsApiWebFactory(store, imagingScope: imagingScope, logs: logs); + var client = await ShowsApiWebFactory.LoggedInClientAsync(factory); + + // When DELETE runs and the best-effort unscope throws + var response = await client.DeleteAsync($"/api/shows/{show.Slug}"); + + // Then the delete itself still reports success (204 — nothing was actually reported as + // unscoped, since the failed call never got to name what it cleared), the failure is + // logged naming the show so an operator can hand-recover, and the row is genuinely gone + // despite the cleanup failure — a post-commit cleanup fault never surfaces as a 500 + // (this action's own "best-effort" contract, see ShowsController's own class remarks) + var afterDelete = await client.GetAsync($"/api/shows/{show.Slug}"); + Assert.Equal( + (Delete: HttpStatusCode.NoContent, Gone: HttpStatusCode.NotFound, UnscopeAttempted: 1, ErrorLogged: true), + (Delete: response.StatusCode, Gone: afterDelete.StatusCode, UnscopeAttempted: imagingScope.UnscopeCalls.Count, + ErrorLogged: logs.Messages.Any(m => + m.Contains("unscope failed", StringComparison.OrdinalIgnoreCase) && + m.Contains(show.Slug, StringComparison.Ordinal)))); + } + + [Fact] + public async Task DeleteNamesAMidnightEndingBlockAsTwentyFourHundredNotZero() + { + // Given a show referenced by a block running to the grid's own maximum end minute (1440 — + // midnight) — the exact value FormatMinutes's own load-bearing comment (now shared via + // ScheduledSlotText, PLAN T240 review) warns TimeSpan's "hh" format specifier would + // silently misrender as "00:00" + var show = new Show(1, "Overnight Show", "overnight-show", null, null, null, null, DateTime.UtcNow, DateTime.UtcNow); + var store = new FakeShowStore([show]) { NextDeleteResult = new ShowWriteResult.Referenced() }; + var scheduleStore = new FakeScheduleStore(); + scheduleStore.SlotsByShowId[show.Id] = [new ScheduledSlot(DayOfWeek.Sunday, 1380, 1440)]; + await using var factory = new ShowsApiWebFactory(store, scheduleStore); + var client = await ShowsApiWebFactory.LoggedInClientAsync(factory); + + // When DELETE runs + var response = await client.DeleteAsync($"/api/shows/{show.Slug}"); + + // Then the 409 body names the block's end as 24:00, never 00:00 + var detail = await DetailAsync(response); + Assert.Equal( + (Status: HttpStatusCode.Conflict, EndsAtTwentyFourHundred: true, NeverZeroZero: true), + (Status: response.StatusCode, + EndsAtTwentyFourHundred: detail.Contains("23:00–24:00", StringComparison.Ordinal), + NeverZeroZero: !detail.Contains("00:00", StringComparison.Ordinal))); + } + + [Fact] + public async Task DeleteWithEmptyReferencedBlocksStillRefusesWithAGenericDetail() + { + // Given the store reports Referenced but the endpoint's own re-query names nothing — the + // documented rare race in ReferencedProblem's own remarks (the FK fired, but the block + // that caused it is gone by the time this action re-queries station.segment_schedule) + var show = new Show(1, "Racy Show", "racy-show", null, null, null, null, DateTime.UtcNow, DateTime.UtcNow); + var store = new FakeShowStore([show]) { NextDeleteResult = new ShowWriteResult.Referenced() }; + var scheduleStore = new FakeScheduleStore(); // SlotsByShowId left empty for show.Id + var imagingScope = new FakeShowImagingScope(); + await using var factory = new ShowsApiWebFactory(store, scheduleStore, imagingScope); + var client = await ShowsApiWebFactory.LoggedInClientAsync(factory); + + // When DELETE runs + var response = await client.DeleteAsync($"/api/shows/{show.Slug}"); + + // Then it still refuses 409 with the generic fallback wording (never a crash, never a + // silently-empty-looking detail claiming nothing blocks it) — and, as with any Referenced + // outcome, nothing was unscoped + var detail = await DetailAsync(response); + Assert.Equal( + (Status: HttpStatusCode.Conflict, GenericWording: true, UnscopeCalls: 0), + (Status: response.StatusCode, + GenericWording: detail.Contains("still appears in the format-clock schedule", StringComparison.Ordinal), + UnscopeCalls: imagingScope.UnscopeCalls.Count)); + } + } + + public sealed class ScenarioProvenanceProtection + { + [Fact] + public async Task AuthoredSaveNeverErasesImportedProvenance() + { + // Given an imported show + var imported = new Show( + 1, "Retro Nights", "retro-nights", "Old tagline", null, + ImportedFrom: "midnight-drive-catalog-entry", ImportedAt: DateTime.UtcNow, + CreatedAt: DateTime.UtcNow, UpdatedAt: DateTime.UtcNow); + var store = new FakeShowStore([imported]); + await using var factory = new ShowsApiWebFactory(store); + var client = await ShowsApiWebFactory.LoggedInClientAsync(factory); + + // When an authored save targets its slug + var response = await client.PatchAsJsonAsync( + "/api/shows/retro-nights", new { name = "Retro Nights", tagline = "Hijacked tagline" }); + + // Then 409 — the ThemeWriteGate two-phase posture (F115.5); imported_from survives, and so + // does every other field — the write never even reaches IShowStore.UpdateAsync + var detail = await DetailAsync(response); + var stillStored = await store.GetBySlugAsync("retro-nights", CancellationToken.None); + Assert.Equal( + (Status: HttpStatusCode.Conflict, NamesTheSlug: true, + ImportedFromSurvives: "midnight-drive-catalog-entry", TaglineUntouched: "Old tagline"), + (Status: response.StatusCode, NamesTheSlug: detail.Contains("retro-nights", StringComparison.Ordinal), + ImportedFromSurvives: stillStored?.ImportedFrom, TaglineUntouched: stillStored?.Tagline)); + } + } + + public sealed class ScenarioGateParityAcrossCreateAndUpdate + { + public static TheoryData Rows + { + get + { + var data = new TheoryData(); + foreach (var row in GateParityTable.Rows) + data.Add(row); + + return data; + } + } + + [Theory] + [MemberData(nameof(Rows))] + public async Task BothWriteRoutesRefuseWithTheIdenticalStatusAndDetail(GateParityRow row) + { + // Given an existing authored show to PATCH onto (POST always targets a fresh row) and the + // SAME request body submitted to both routes (SlugConflictProblem's own detail embeds the + // submitted name, so byte-identity across routes needs the same name either side) + var existing = new Show(1, "Existing Show", "existing-show", null, null, null, null, DateTime.UtcNow, DateTime.UtcNow); + var store = new FakeShowStore([existing]); + await using var factory = new ShowsApiWebFactory(store); + var client = await ShowsApiWebFactory.LoggedInClientAsync(factory); + var body = new { name = "Night Moves", tagline = (string?)null, flavor = (string?)null }; + + // When the SAME app-seam gate outcome is scripted for both the create and the update write + store.NextCreateResult = row.Result; + var createResponse = await client.PostAsJsonAsync("/api/shows", body); + store.NextUpdateResult = row.Result; + var updateResponse = await client.PatchAsJsonAsync("/api/shows/existing-show", body); + + // Then both refuse with the row's own expected status and byte-identical, content-bearing + // detail text — proving ShowsController.WriteProblem produces the SAME body regardless of + // which write route hit it (mirrors PLAN T207's own byte-identical-copy proof format) + var createDetail = await DetailAsync(createResponse); + var updateDetail = await DetailAsync(updateResponse); + Assert.Equal( + (CreateStatus: row.ExpectedStatus, UpdateStatus: row.ExpectedStatus, + DetailsMatch: true, NamesTheExpectedContent: true), + (CreateStatus: createResponse.StatusCode, UpdateStatus: updateResponse.StatusCode, + DetailsMatch: createDetail == updateDetail, + NamesTheExpectedContent: updateDetail.Contains(row.ExpectedFragment, StringComparison.Ordinal))); + } + } + + static async Task DetailAsync(HttpResponseMessage response) + { + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + return document.RootElement.GetProperty("detail").GetString() ?? ""; + } +} + +// ── Gate-parity table (mirrors Story287_SaveAsOwn.cs's own BadBodyRow/BadBodyTable) ──────────────── + +/// One row of 's own +/// table (PLAN T240, extending the PLAN T207 precedent). is what xUnit's test +/// explorer shows per row, so it names the gate under test, not the row's own field values. +public sealed record GateParityRow(string Label, ShowWriteResult Result, HttpStatusCode ExpectedStatus, string ExpectedFragment) +{ + public override string ToString() => Label; +} + +/// +/// The five app-seam gates ShowRepository.CreateAsync/UpdateAsync share (proven for +/// real, against a real Postgres fixture, in Story305_ShowRepository.cs) — this table proves +/// the SEPARATE claim that ShowsController's own HTTP mapping of each outcome never drifts +/// between the two write routes. Each row's is content +/// the refusal MUST name, never a substring the base refusal's own boilerplate alone would already +/// satisfy (mirrors Story287_SaveAsOwn.cs's own N2 "NamesTheMissingFace pattern", generalized here). +/// +static class GateParityTable +{ + public static readonly IReadOnlyList Rows = + [ + new("blank or fallback-slug name", + new ShowWriteResult.InvalidName(), HttpStatusCode.BadRequest, "blank"), + + new("name over budget", + new ShowWriteResult.BudgetExceeded(ShowBudgetField.Name), HttpStatusCode.BadRequest, "name must be at most 60"), + + new("tagline over budget", + new ShowWriteResult.BudgetExceeded(ShowBudgetField.Tagline), HttpStatusCode.BadRequest, "tagline must be at most 120"), + + new("flavor over budget", + new ShowWriteResult.BudgetExceeded(ShowBudgetField.Flavor), HttpStatusCode.BadRequest, "flavor must be at most 400"), + + new("slug conflict", + new ShowWriteResult.SlugConflict(), HttpStatusCode.Conflict, "Night Moves"), + ]; +} + +// ── Test harness ─────────────────────────────────────────────────────────────────────────────────── + +/// Captures every log entry of Warning or above so a spec can assert on +/// ShowsController's own output — mirrors Story164_FailClosedWithoutPassword's own +/// CapturingWarningLoggerProvider idiom (a file-scoped copy per spec file that needs one, +/// rather than a fifth shared Fakes/CapturingLogger.cs alongside the four already in +/// MediaLibrary.Tests/Tts.Tests/Orchestration.Tests/Context.Tests — this project's own precedent for +/// wire-level log capture is already this per-file shape, not that per-project one). +file sealed class CapturingWarningLoggerProvider : ILoggerProvider +{ + readonly List messages = []; + public IReadOnlyList Messages { get { lock (messages) return messages.ToList(); } } + + public ILogger CreateLogger(string categoryName) => new Logger(this); + public void Dispose() { } + + void Add(string message) { lock (messages) messages.Add(message); } + + sealed class Logger(CapturingWarningLoggerProvider owner) : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Warning; + public void Log( + LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) + { + if (IsEnabled(logLevel)) owner.Add(formatter(state, exception)); + } + } +} + +/// +/// for this file's own Facts — mirrors +/// Story240_GridHoldsTheWeek.cs's own ScheduleApiWebFactory idiom: , +/// , and all replaced by stateful fakes +/// (defaulted to empty ones when a Fact doesn't need to script them). is +/// wired only when a Fact actually needs to assert on logged output (PLAN T240 review) — every other +/// Fact leaves it null and gets the host's ordinary logging pipeline, untouched. +/// +file sealed class ShowsApiWebFactory( + FakeShowStore store, + FakeScheduleStore? scheduleStore = null, + FakeShowImagingScope? imagingScope = null, + bool withAdminPassword = true, + CapturingWarningLoggerProvider? logs = null) + : WebApplicationFactory +{ + internal const string Password = "test-password-story305-showsapi"; + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Development"); + builder.UseSetting("ConnectionStrings:Library", "Host=nowhere;Database=test"); + + if (withAdminPassword) + { + builder.UseSetting("Admin:Password", Password); + } + + if (logs is not null) + builder.ConfigureLogging(logging => logging.AddProvider(logs)); + + builder.ConfigureTestServices(services => + { + // No Liquidsoap/DB connections during this test. + services.RemoveAll(); + + services.RemoveAll(); + services.AddSingleton(store); + + services.RemoveAll(); + services.AddSingleton(scheduleStore ?? new FakeScheduleStore()); + + services.RemoveAll(); + services.AddSingleton(imagingScope ?? new FakeShowImagingScope()); + }); + } + + /// Logs in via the real POST /api/auth/login round trip (mirrors Story240's own helper) and returns the cookie-bearing client. + public static async Task LoggedInClientAsync(WebApplicationFactory factory) + { + var client = factory.CreateClient(); + var login = await client.PostAsJsonAsync("/api/auth/login", new { password = Password }); + Assert.Equal(HttpStatusCode.NoContent, login.StatusCode); + return client; + } +} diff --git a/tests/GenWave.Host.Tests/Specs/Story311_SpectatorShowFields.cs b/tests/GenWave.Host.Tests/Specs/Story311_SpectatorShowFields.cs new file mode 100644 index 00000000..446ed83f --- /dev/null +++ b/tests/GenWave.Host.Tests/Specs/Story311_SpectatorShowFields.cs @@ -0,0 +1,52 @@ +// STORY-311 — The public face names the show (F116.4, F115.3) +// +// BDD specification — xUnit, PENDING scaffold (planned 2026-08-10). Comment-bodied on +// purpose: the spectator DTO fields land at T251. Disclosure follows the F67.6 idiom — +// complete-property-set assertions so an unblessed field fails the build. The one hard +// law: flavor is prompt config and NEVER appears on a public surface. + +namespace GenWave.Host.Tests.Specs; + +using Xunit; + +public static class FeatureSpectatorShowFields +{ + public sealed class ScenarioTheFieldsRide + { + [Fact(Skip = "Pending (T251)")] + public void NowPlayingCarriesShowNameAndTagline() + { + // Given a show on the air + // When /spectator/api/now-playing is read via the public listener + // Then show { name, tagline } is present on the payload + } + + [Fact(Skip = "Pending (T251)")] + public void UpNextCarriesTheShowName() + { + // Given a named next segment + // When now-playing is read + // Then upNext.show carries the name (name only — F116.4) + } + + [Fact(Skip = "Pending (T251)")] + public void UnnamedBlocksReadNull() + { + // Given no show on the air and an unnamed next segment + // When now-playing is read + // Then show and upNext.show are null — the page renders exactly as today + } + } + + public sealed class ScenarioDisclosureHoldsTheLine + { + [Fact(Skip = "Pending (T251)")] + public void FlavorIsStructurallyAbsentFromPublicPayloads() + { + // Given every spectator payload DTO + // When the disclosure-contract suite enumerates complete property sets + // Then flavor appears nowhere (F115.3 — the persona-soul precedent); + // name/tagline are the pinned public additions to the F67 inventory + } + } +} diff --git a/tests/GenWave.Host.Tests/Specs/Story315_ShowImport.cs b/tests/GenWave.Host.Tests/Specs/Story315_ShowImport.cs new file mode 100644 index 00000000..19c12c8a --- /dev/null +++ b/tests/GenWave.Host.Tests/Specs/Story315_ShowImport.cs @@ -0,0 +1,70 @@ +// STORY-315 — Hire a show from the shelf (F118.2, F118.3, F118.5) +// +// BDD specification — xUnit, PENDING scaffold (planned 2026-08-10). Comment-bodied on +// purpose: the import endpoint lands at T254 through the F79 shell (caps, schema-major +// reject, transactional no-partial upsert, provenance). The shelf/modal UI half is jest +// (catalog-show-shelf.spec.tsx). Cross-repo golden parity follows the T107 precedent. + +namespace GenWave.Host.Tests.Specs; + +using Xunit; + +public static class FeatureShowImport +{ + public sealed class ScenarioImportThroughTheShell + { + [Fact(Skip = "Pending (T254)")] + public void ImportUpsertsTransactionallyWithProvenance() + { + // Given a valid show card fetched by catalogSlug + // When POST /api/shows/{slug}/import runs + // Then the show lands whole with imported_from = catalogSlug, imported_at set + } + + [Fact(Skip = "Pending (T254)")] + public void FileUploadStampsFile() + { + // Given a direct file upload of a show manifest + // When the import runs + // Then imported_from = "file" (the F103.6 provenance triple) + } + + [Fact(Skip = "Pending (T254)")] + public void GoldenParityPinsTheCrossRepoContract() + { + // Given fixtures/golden.show.json (the catalog repo pins the same bytes) + // When the manifest parser consumes it + // Then it round-trips — the T107/T193 two-repo drift guard extended to shows + } + } + + public sealed class ScenarioSoftSuggestion + { + [Fact(Skip = "Pending (T254)")] + public void ImportSucceedsWithSuggestionAbsentUnknownOrHired() + { + // Given a card whose suggestedPersona is missing, unknown, or already hired + // When the show imports + // Then it succeeds with no offer and no error (soft means soft — F118.3) + } + } + + public sealed class ScenarioRejectingBadImports + { + [Fact(Skip = "Pending (T254)")] + public void SchemaMajorAndSizeCapRejectAsTheShellDoes() + { + // Given a newer-major manifest (or an over-cap body / >2× flavor) + // When the import runs + // Then it fails closed naming both versions / the cap — no partial write + } + + [Fact(Skip = "Pending (T254)")] + public void SpectatorIsByteIdenticalWithCatalogUnreachable() + { + // Given the catalog disabled or unreachable + // When spectator payloads are read + // Then byte-identical (F103.12 inherited verbatim — F118.5) + } + } +} diff --git a/tests/GenWave.MediaLibrary.Tests/DatabaseFixture.cs b/tests/GenWave.MediaLibrary.Tests/DatabaseFixture.cs index 866c80aa..f3c5325e 100644 --- a/tests/GenWave.MediaLibrary.Tests/DatabaseFixture.cs +++ b/tests/GenWave.MediaLibrary.Tests/DatabaseFixture.cs @@ -1,3 +1,4 @@ +using System.Collections.Frozen; using System.Diagnostics; using Dapper; using Npgsql; @@ -29,6 +30,29 @@ public sealed class DatabaseFixture : IAsyncLifetime public NpgsqlDataSource StationDataSource { get; private set; } = null!; + /// + /// (data_type, is_nullable, column_default) for every column of the station and + /// library schemas, snapshotted in right after + /// — the one instant that is the PURE db/01+db/06 fresh-init + /// world, since db-compose.yaml mounts only those two files as Postgres init scripts. No + /// test class has run yet, so nothing here can have been shaped by db/35 (or any other in-place + /// migration script) rather than by db/06's own CREATE. Story305_ShowRepository.cs's fresh-init + /// facts assert against this snapshot instead of re-running a migration script, which is the only + /// way a dropped db/06 mirror of a db/35 column can actually turn a fact red. + /// + public IReadOnlyDictionary<(string Schema, string Table, string Column), (string DataType, string IsNullable, string? ColumnDefault)> InitialSchema + { get; private set; } = new Dictionary<(string, string, string), (string, string, string?)>(); + + /// + /// The UNIQUE constraint names (pg_constraint.conname, contype = 'u') present on + /// each station-schema table at the same fresh-init instant + /// captures, keyed by bare table name. A table can carry more than one UNIQUE constraint (e.g. + /// station.persona has both name and slug), hence a list per table rather + /// than a single name. + /// + public IReadOnlyDictionary> InitialUniqueConstraints + { get; private set; } = new Dictionary>(); + string composeFile = ""; public async Task InitializeAsync() @@ -43,6 +67,7 @@ public async Task InitializeAsync() DataSource = new NpgsqlDataSourceBuilder(ConnectionString).Build(); StationDataSource = new NpgsqlDataSourceBuilder(StationConnectionString).Build(); await WaitForSchemaAsync(); + await SnapshotInitialSchemaAsync(); } public async Task DisposeAsync() @@ -161,6 +186,25 @@ public async Task ResetFontPackAsync() await cmd.ExecuteNonQueryAsync(); } + /// + /// Truncate station.show and reset its identity (SPEC F115.1, STORY-305, PLAN T239). + /// CASCADE (mirrors 's own remarks): station.segment_schedule's + /// show_id FK (db/06, SPEC F114) — ON DELETE RESTRICT though it is — still makes + /// Postgres refuse a plain TRUNCATE, since TRUNCATE's own FK check is stricter than any single + /// row's ON DELETE action; CASCADE follows it and sweeps that table's rows along with + /// station.show's, same as it already does for the persona-referencing tables + /// truncates. station.booth_log.show_id/ + /// library.media.show_id carry no FK (db/35 — history/imaging must outlive the entity), so + /// neither is touched by this reset. + /// + public async Task ResetShowAsync() + { + await using var conn = await StationDataSource.OpenConnectionAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "truncate table station.show restart identity cascade"; + await cmd.ExecuteNonQueryAsync(); + } + async Task WaitForSchemaAsync() { for (var attempt = 0; attempt < 30; attempt++) @@ -182,6 +226,63 @@ async Task WaitForSchemaAsync() throw new InvalidOperationException("library schema not ready on the test database"); } + /// + /// Populates and — must run + /// only once, immediately after and before any test class gets a + /// chance to run a migration script or otherwise mutate the schema (see those properties' own + /// remarks for why that instant matters). Reads the station schema over + /// and the library schema over — the two + /// roles have no cross-schema grants, the same reason QueryColumnAsync/ + /// QueryLibraryMediaColumnAsync in Story305_ShowRepository.cs split the same way. + /// + async Task SnapshotInitialSchemaAsync() + { + var schema = new Dictionary<(string, string, string), (string, string, string?)>(); + + await AddColumnsAsync(StationDataSource, "station"); + await AddColumnsAsync(DataSource, "library"); + + async Task AddColumnsAsync(NpgsqlDataSource dataSource, string schemaName) + { + await using var conn = await dataSource.OpenConnectionAsync(); + var rows = await conn.QueryAsync<(string TableName, string ColumnName, string DataType, string IsNullable, string? ColumnDefault)>( + """ + select table_name, column_name, data_type, is_nullable, column_default + from information_schema.columns + where table_schema = @schemaName + """, + new { schemaName }); + foreach (var row in rows) + schema[(schemaName, row.TableName, row.ColumnName)] = (row.DataType, row.IsNullable, row.ColumnDefault); + } + + // Frozen so no spec can mutate the shared snapshot via a cast back to a mutable dictionary type. + InitialSchema = schema.ToFrozenDictionary(); + + var uniqueConstraints = new Dictionary>(); + await using (var conn = await StationDataSource.OpenConnectionAsync()) + { + var rows = await conn.QueryAsync<(string TableName, string ConstraintName)>( + """ + select c.relname as table_name, con.conname as constraint_name + from pg_constraint con + join pg_class c on c.oid = con.conrelid + join pg_namespace n on n.oid = c.relnamespace + where con.contype = 'u' and n.nspname = 'station' + """); + foreach (var row in rows) + { + if (!uniqueConstraints.TryGetValue(row.TableName, out var names)) + uniqueConstraints[row.TableName] = names = []; + names.Add(row.ConstraintName); + } + } + + // Each value is a defensive copy (not the live List) so no spec can mutate the shared snapshot. + InitialUniqueConstraints = uniqueConstraints.ToDictionary( + entry => entry.Key, entry => (IReadOnlyList)entry.Value.ToArray()); + } + /// /// Absolute path to the repository root (the directory containing GenWave.sln). /// Populated during ; use to resolve files like db/*.sh. diff --git a/tests/GenWave.MediaLibrary.Tests/Specs/Gh099_SafeContentRatingRepository.cs b/tests/GenWave.MediaLibrary.Tests/Specs/Gh099_SafeContentRatingRepository.cs index f7ab2e88..ec63d5e9 100644 --- a/tests/GenWave.MediaLibrary.Tests/Specs/Gh099_SafeContentRatingRepository.cs +++ b/tests/GenWave.MediaLibrary.Tests/Specs/Gh099_SafeContentRatingRepository.cs @@ -227,8 +227,10 @@ public async Task AnAppendedMediaIdReadsBackThroughBothReadSeams() var repo = Repo(db); await repo.AppendAsync( - "track-started", "Started 'Song' by Someone", personaId: null, artist: "Someone", - pick: null, mediaId: 42, segmentKind: null, CancellationToken.None); + new BoothLogAppendRequest( + "track-started", "Started 'Song' by Someone", PersonaId: null, Artist: "Someone", + Pick: null, MediaId: 42, SegmentKind: null, ShowId: null), + CancellationToken.None); var page = await repo.ReadAsync(before: null, take: 1, CancellationToken.None); var entry = Assert.Single(page.Entries); @@ -243,8 +245,10 @@ public async Task AnUnstampedRowAnswersNullFromBothReadSeams() var repo = Repo(db); await repo.AppendAsync( - "patter-aired", "Patter aired (station-id)", personaId: null, artist: null, - pick: null, mediaId: null, segmentKind: null, CancellationToken.None); + new BoothLogAppendRequest( + "patter-aired", "Patter aired (station-id)", PersonaId: null, Artist: null, + Pick: null, MediaId: null, SegmentKind: null, ShowId: null), + CancellationToken.None); var page = await repo.ReadAsync(before: null, take: 1, CancellationToken.None); var entry = Assert.Single(page.Entries); diff --git a/tests/GenWave.MediaLibrary.Tests/Specs/Gh255_ScheduleVersionGuard.cs b/tests/GenWave.MediaLibrary.Tests/Specs/Gh255_ScheduleVersionGuard.cs index 5861186b..836daa6c 100644 --- a/tests/GenWave.MediaLibrary.Tests/Specs/Gh255_ScheduleVersionGuard.cs +++ b/tests/GenWave.MediaLibrary.Tests/Specs/Gh255_ScheduleVersionGuard.cs @@ -16,8 +16,20 @@ namespace GenWave.MediaLibrary.Tests.Specs; public static class FeatureScheduleVersionGuard { - static ScheduleRepository Repo(DatabaseFixture db) => - new(new Lazy(() => db.StationDataSource)); + /// + /// PLAN T241 review: mirrors Story240_ScheduleStore.cs's own identically-named helper — see its + /// own remarks in full. 's load query now LEFT JOINs + /// station.show keyed on segment_schedule.show_id (SPEC F116.1), so this file also + /// needs BOTH idempotent migration scripts (db/33 then db/35) re-run before every fact's own + /// connection, regardless of xUnit's class scheduling against Story242_UpgradeChangesNothing.cs's + /// and Story305_ShowRepository.cs's own in-place scenarios. + /// + static ScheduleRepository Repo(DatabaseFixture db) + { + db.RunFileInContainer(Path.Combine(db.RepoRoot, "db", "33-show-and-segment-kind-migration.sh")); + db.RunFileInContainer(Path.Combine(db.RepoRoot, "db", "35-show-identity-migration.sh")); + return new(new Lazy(() => db.StationDataSource)); + } static ScheduleSegment MusicOnly(DayOfWeek day, int start, int end) => new(null, day, start, end, PersonaId: null, Genres: null, EnergyMin: null, EnergyMax: null); diff --git a/tests/GenWave.MediaLibrary.Tests/Specs/Story195_BoothLogStore.cs b/tests/GenWave.MediaLibrary.Tests/Specs/Story195_BoothLogStore.cs index 6e7f6699..a190f762 100644 --- a/tests/GenWave.MediaLibrary.Tests/Specs/Story195_BoothLogStore.cs +++ b/tests/GenWave.MediaLibrary.Tests/Specs/Story195_BoothLogStore.cs @@ -140,7 +140,11 @@ public async Task Insert_evicts_rows_older_than_the_retention_window() var store = Store(db, retentionDays: 1); // When a new row is inserted... - await store.AppendAsync("track-started", "Started 'New Song' by New Artist", personaId: null, artist: null, pick: null, mediaId: null, segmentKind: null, ct: CancellationToken.None); + await store.AppendAsync( + new BoothLogAppendRequest( + "track-started", "Started 'New Song' by New Artist", PersonaId: null, Artist: null, + Pick: null, MediaId: null, SegmentKind: null, ShowId: null), + CancellationToken.None); // Then the expired rows are gone and only the new row remains — the table stays bounded. var rows = await AllRowsAsync(db); diff --git a/tests/GenWave.MediaLibrary.Tests/Specs/Story215_TasteAccrualRepository.cs b/tests/GenWave.MediaLibrary.Tests/Specs/Story215_TasteAccrualRepository.cs index a467f13d..64cddf3a 100644 --- a/tests/GenWave.MediaLibrary.Tests/Specs/Story215_TasteAccrualRepository.cs +++ b/tests/GenWave.MediaLibrary.Tests/Specs/Story215_TasteAccrualRepository.cs @@ -51,7 +51,11 @@ static async Task CreatePersonaAsync(DatabaseFixture db, string name) static async Task SeedTrackRowAsync(DatabaseFixture db, long? personaId, string? artist) { var repo = BoothLogRepo(db); - await repo.AppendAsync("track-started", "Started 'Song' by Someone", personaId, artist, pick: null, mediaId: null, segmentKind: null, CancellationToken.None); + await repo.AppendAsync( + new BoothLogAppendRequest( + "track-started", "Started 'Song' by Someone", personaId, artist, Pick: null, MediaId: null, + SegmentKind: null, ShowId: null), + CancellationToken.None); var page = await repo.ReadAsync(before: null, take: 1, CancellationToken.None); return page.Entries.Single().Id; } diff --git a/tests/GenWave.MediaLibrary.Tests/Specs/Story240_ScheduleStore.cs b/tests/GenWave.MediaLibrary.Tests/Specs/Story240_ScheduleStore.cs index 0b15567c..3664955b 100644 --- a/tests/GenWave.MediaLibrary.Tests/Specs/Story240_ScheduleStore.cs +++ b/tests/GenWave.MediaLibrary.Tests/Specs/Story240_ScheduleStore.cs @@ -21,7 +21,29 @@ public static class FeatureScheduleStore // Helpers // --------------------------------------------------------------------- - static ScheduleRepository Repo(DatabaseFixture db) => new(new Lazy(() => db.StationDataSource)); + /// + /// PLAN T241 review: 's own load query now LEFT JOINs + /// station.show keyed on segment_schedule.show_id (SPEC F116.1), so every fact in + /// this file gained an implicit dependency on BOTH columns existing that it never had before. This + /// class carries no ordering guarantee against two sibling files' own in-place scenarios in the + /// same DatabaseCollection: Story242_UpgradeChangesNothing.cs's several scenarios drop + /// station.segment_schedule and rebuild it via db/27 ALONE (predates show_id + /// entirely — see that file's own header, which already documents this exact hazard for + /// Story304_AiredKindStamp.cs and names db/33 as the guard); Story305_ShowRepository.cs's own + /// in-place scenario drops station.show's db/35 columns (no tagline/flavor). + /// Running BOTH idempotent migration scripts here, in order — db/33 first (restores + /// segment_schedule.show_id and a bare station.show if either is missing), db/35 + /// second (widens station.show to its full identity shape) — right before the repository's + /// own connection is even built, makes every fact in this file self-sufficient regardless of + /// xUnit's class scheduling, mirroring Story304's own "(re)running db/33 in its own Arrange before + /// every assertion" guard and Story305_ShowRepository.cs's own db/35 guard, combined. + /// + static ScheduleRepository Repo(DatabaseFixture db) + { + db.RunFileInContainer(Path.Combine(db.RepoRoot, "db", "33-show-and-segment-kind-migration.sh")); + db.RunFileInContainer(Path.Combine(db.RepoRoot, "db", "35-show-identity-migration.sh")); + return new ScheduleRepository(new Lazy(() => db.StationDataSource)); + } static ScheduleSegment MusicOnly(DayOfWeek day, int start, int end) => new(null, day, start, end, PersonaId: null, Genres: null, EnergyMin: null, EnergyMax: null); @@ -143,6 +165,101 @@ await repo.ReplaceWeekAsync( } } + // --------------------------------------------------------------------- + // HAPPY PATH — show identity rides the load, dormant bundle columns unread (SPEC F115.2, F116.1, + // STORY-306, PLAN T241) + // + // ARCHITECTURE.md's own guidance for this pin: "put the live pin where it can be real ... extend + // the schedule repository's spec: populate dormant columns via SQL, reload the week, assert the + // loaded model is identical." ScheduleRepository has no writer for show_id/station.show at all + // (T243/T239 are the write-side seams), so both are populated by direct SQL here, mirroring this + // file's own ScenarioPersonaForeignKeyHasTeeth idiom. + // --------------------------------------------------------------------- + + [Collection(DatabaseCollection.Name)] + [Trait("Category", "Integration")] + public sealed class ScenarioShowIdentityRidesTheLoad(DatabaseFixture db) + { + [Fact] + public async Task HandPopulatingShowPersonaIdAndEnvelopeChangesNothingAboutTheLoadedWeek() + { + // Given a show (tagline + flavor set) referenced by one schedule block naming its OWN + // block-level persona, and a SECOND real persona standing by to hand-populate the show's + // own DORMANT persona_id column with. + await db.ResetShowAsync(); + await db.ResetScheduleAsync(); + var blockPersonaId = await ScheduleTestPersonas.InsertAsync(db, "Block DJ"); + var dormantShowPersonaId = await ScheduleTestPersonas.InsertAsync(db, "Dormant Show DJ"); + + long showId; + await using (var conn = await db.StationDataSource.OpenConnectionAsync()) + { + showId = await conn.ExecuteScalarAsync( + """ + insert into station.show (name, slug, tagline, flavor) + values ('Night Moves', 'night-moves', 'Late-night deep cuts', 'moody, sparse') + returning id + """); + await conn.ExecuteAsync( + """ + insert into station.segment_schedule (day_of_week, start_minute, end_minute, persona_id, show_id) + values (1, 540, 720, @blockPersonaId, @showId) + """, + new { blockPersonaId, showId }); + } + var repo = Repo(db); + + // When the week is loaded BEFORE the dormant columns are ever touched... + var before = await repo.LoadWeekAsync(CancellationToken.None); + + // ...then station.show's own DORMANT persona_id/envelope columns are hand-populated + // directly (SPEC F115.2's pin — ShowRepository has no parameter for either; a raw UPDATE + // is the only way to even attempt setting them)... + await using (var conn = await db.StationDataSource.OpenConnectionAsync()) + await conn.ExecuteAsync( + """ + update station.show + set persona_id = @dormantShowPersonaId, + envelope = '{"genres": ["Jazz"], "energyMin": 0.1, "energyMax": 0.9}'::jsonb + where id = @showId + """, + new { dormantShowPersonaId, showId }); + + // ...and the week is loaded again. + var after = await repo.LoadWeekAsync(CancellationToken.None); + + // Then the loaded model is IDENTICAL — hand-populating the dormant bundle columns changed + // NO v1 behavior (sequence-compared, not whole-snapshot: ScheduleWeekSnapshot's own + // compiler-generated Equals compares its Segments list by reference, the same + // Genres-by-reference gotcha Story241_StationFollowsTheClock.cs's own facts document). + Assert.Equal(before.Segments, after.Segments); + + // And the loaded show identity itself carries only the four public fields (SPEC F115.2's + // pin enforced by ShowSummary's own shape — there is no PersonaId/Envelope member to have + // picked either dormant value up even if the query tried). + var block = Assert.Single(after.Segments); + Assert.Equal(blockPersonaId, block.PersonaId); + Assert.NotNull(block.Show); + Assert.Equal(new ShowSummary(showId, "Night Moves", "Late-night deep cuts", "moody, sparse"), block.Show); + } + + [Fact] + public async Task UnnamedBlockLoadsWithNoShow() + { + // Given a schedule block with no show_id at all + await db.ResetScheduleAsync(); + var repo = Repo(db); + + await repo.ReplaceWeekAsync([MusicOnly(DayOfWeek.Monday, 0, 1440)], expectedVersion: null, CancellationToken.None); + + // When the week is loaded + var snapshot = await repo.LoadWeekAsync(CancellationToken.None); + + // Then the block's own Show is null — the LEFT JOIN finds no matching station.show row + Assert.Null(Assert.Single(snapshot.Segments).Show); + } + } + // --------------------------------------------------------------------- // HAPPY PATH — the whole-week replace is atomic (SPEC F91.8) // --------------------------------------------------------------------- diff --git a/tests/GenWave.MediaLibrary.Tests/Specs/Story247_BenchTransition.cs b/tests/GenWave.MediaLibrary.Tests/Specs/Story247_BenchTransition.cs index 050a98c5..c76fc53e 100644 --- a/tests/GenWave.MediaLibrary.Tests/Specs/Story247_BenchTransition.cs +++ b/tests/GenWave.MediaLibrary.Tests/Specs/Story247_BenchTransition.cs @@ -31,13 +31,29 @@ public sealed class ScenarioBenchingByUnpainting(DatabaseFixture db) // Given a DJ scheduled in one slot, When that slot is removed by replacing the week without // it (ScheduleRepository.ReplaceWeekAsync — the store beneath T122's PUT /api/schedule). + /// + /// PLAN T241 review: mirrors Story240_ScheduleStore.cs's own identically-purposed helper — + /// see its own remarks in full. 's load query now LEFT JOINs + /// station.show keyed on segment_schedule.show_id (SPEC F116.1), so this file + /// also needs BOTH idempotent migration scripts (db/33 then db/35) re-run before every fact's + /// own connection, regardless of xUnit's class scheduling against + /// Story242_UpgradeChangesNothing.cs's and Story305_ShowRepository.cs's own in-place + /// scenarios. + /// + static ScheduleRepository ScheduleRepo(DatabaseFixture db) + { + db.RunFileInContainer(Path.Combine(db.RepoRoot, "db", "33-show-and-segment-kind-migration.sh")); + db.RunFileInContainer(Path.Combine(db.RepoRoot, "db", "35-show-identity-migration.sh")); + return new(new Lazy(() => db.StationDataSource)); + } + [Fact] public async Task PersonaRecordIsUntouched() { await db.ResetStationAsync(); await db.ResetScheduleAsync(); var personaId = await ScheduleTestPersonas.InsertAsync(db, "Bench Transition DJ"); - var scheduleRepo = new ScheduleRepository(new Lazy(() => db.StationDataSource)); + var scheduleRepo = ScheduleRepo(db); var personaRepo = new PersonaRepository(new Lazy(() => db.StationDataSource)); await scheduleRepo.ReplaceWeekAsync( [new ScheduleSegment(null, DayOfWeek.Monday, 0, 600, personaId, Genres: null, EnergyMin: null, EnergyMax: null)], @@ -60,7 +76,7 @@ public async Task PersonaNoLongerAppearsInAnyScheduleRow() await db.ResetStationAsync(); await db.ResetScheduleAsync(); var personaId = await ScheduleTestPersonas.InsertAsync(db, "Bench Transition DJ"); - var scheduleRepo = new ScheduleRepository(new Lazy(() => db.StationDataSource)); + var scheduleRepo = ScheduleRepo(db); await scheduleRepo.ReplaceWeekAsync( [new ScheduleSegment(null, DayOfWeek.Monday, 0, 600, personaId, Genres: null, EnergyMin: null, EnergyMax: null)], expectedVersion: null, CancellationToken.None); diff --git a/tests/GenWave.MediaLibrary.Tests/Specs/Story305_ShowRepository.cs b/tests/GenWave.MediaLibrary.Tests/Specs/Story305_ShowRepository.cs new file mode 100644 index 00000000..89d85f11 --- /dev/null +++ b/tests/GenWave.MediaLibrary.Tests/Specs/Story305_ShowRepository.cs @@ -0,0 +1,1010 @@ +// STORY-305 — The show entity & API (F115.1, F115.4, F115.5) — repository half +// +// PLAN T238 (this file's schema half — the T238 acceptance criterion "fresh init and in-place +// upgrade converge; dormant columns exist") is REAL, live-Postgres coverage (Category=Integration +// via DatabaseCollection): a fresh-init scenario (ScenarioTheIdentityPackageFlowsEndToEnd) and an +// in-place scenario (ScenarioMigrationAddsTheColumnsInPlace, proving db/35's own DDL directly by +// dropping the nine T238 columns and re-running the migration script), mirroring +// Story304_AiredKindStamp.cs's own two-scenario shape. +// +// The fresh-init facts assert against DatabaseFixture.InitialSchema/InitialUniqueConstraints — a +// snapshot the fixture takes once, immediately after Postgres finishes running db/01+db/06 (the only +// two files db-compose.yaml mounts as init scripts) and before any spec class runs. That is the one +// instant a fact can observe db/06's own CREATE in isolation from db/35: an earlier revision of this +// file instead (re-)ran db/35 in each fact's own Arrange, which passed even with the db/06 mirror of +// db/35 deleted entirely — db/35 itself would then silently supply the columns db/06 failed to, +// leaving the acceptance word "converge" untested. Because the snapshot is taken once at fixture +// init, ordering against the in-place scenario below (which drops columns then re-runs db/35) no +// longer matters either way. +// +// ScenarioDormantBundleColumns is likewise real: persona_id/envelope exist with no DEFAULT (read from +// the same snapshot) and read back NULL on an inserted row — the table has no writer anywhere this +// epic (F115.2). +// +// ScenarioAuthoredCrud and ScenarioRejectingInvalidShows are now real too (PLAN T239: the Show domain +// type, IShowStore, and ShowRepository — GenWave.Core.Domain/Abstractions, +// GenWave.MediaLibrary.Station). Both are Category=Integration via DatabaseCollection, mirroring the +// two scenarios above: RunMigrationScript(db) first (the convergence idiom — this file's own live +// database may have had station.show's T238 columns dropped and re-added mid-suite by +// ScenarioMigrationAddsTheColumnsInPlace, with no ordering guarantee against it), then +// DatabaseFixture.ResetShowAsync() for a clean table before every fact. The endpoint half lives in +// GenWave.Host.Tests/Specs/Story305_ShowsApi.cs (PLAN T240, still pending). + +using Dapper; +using GenWave.Core.Domain; +using GenWave.MediaLibrary.Catalog; +using GenWave.MediaLibrary.Station; +using Npgsql; + +namespace GenWave.MediaLibrary.Tests.Specs; + +public static class FeatureShowRepository +{ + // --------------------------------------------------------------------- + // Helpers (T238 schema facts) — mirror Story304_AiredKindStamp's own idioms. + // --------------------------------------------------------------------- + + /// Returns (data_type, is_nullable) for the named column on the given station table, + /// or null when the column does not exist. Mirrors Story304's own QueryColumnAsync helper. + static async Task<(string DataType, string IsNullable)?> QueryColumnAsync( + DatabaseFixture db, string table, string column) + { + await using var conn = await db.StationDataSource.OpenConnectionAsync(); + var row = await conn.QuerySingleOrDefaultAsync<(string data_type, string is_nullable)>( + """ + select data_type, is_nullable from information_schema.columns + where table_schema = 'station' and table_name = @table and column_name = @column + """, + new { table, column }); + + return row == default ? null : (row.data_type, row.is_nullable); + } + + /// Returns (data_type, is_nullable) for the named column on library.media, or null when + /// the column does not exist — the library_svc-rooted counterpart to , + /// needed because library.media.show_id crosses the db/22 schema-role boundary (Story030's own + /// QueryColumnAsync helper is the precedent for reading library schema this way). + static async Task<(string DataType, string IsNullable)?> QueryLibraryMediaColumnAsync( + DatabaseFixture db, string column) + { + await using var conn = await db.DataSource.OpenConnectionAsync(); + var row = await conn.QuerySingleOrDefaultAsync<(string data_type, string is_nullable)>( + """ + select data_type, is_nullable from information_schema.columns + where table_schema = 'library' and table_name = 'media' and column_name = @column + """, + new { column }); + + return row == default ? null : (row.data_type, row.is_nullable); + } + + /// The name(s) of the UNIQUE constraint(s) on the given fully-qualified station table — + /// used by the in-place scenario below to pin that db/35's ALTER lands the show.slug constraint on + /// the same literal name (show_slug_key) the fresh-init snapshot observes from db/06's + /// CREATE. QuerySingleOrDefaultAsync's own single-row contract additionally proves exactly one + /// UNIQUE constraint exists on the table — this says nothing about whether either name is + /// auto-generated, since Postgres's own auto-generated name for a bare UNIQUE here would in fact + /// also be show_slug_key. + static async Task QueryUniqueConstraintNameAsync(DatabaseFixture db, string qualifiedTable) + { + await using var conn = await db.StationDataSource.OpenConnectionAsync(); + return await conn.QuerySingleOrDefaultAsync( + "select conname from pg_constraint where conrelid = @table::regclass and contype = 'u'", + new { table = qualifiedTable }); + } + + /// Runs db/35-show-identity-migration.sh against the test database via the fixture. + /// Mirrors Story304's own RunMigrationScript helper. Safe to call unconditionally — the script is + /// idempotent (ADD COLUMN IF NOT EXISTS). Used only by the in-place scenario below — the fresh-init + /// facts assert against DatabaseFixture.InitialSchema instead (see file header). + static void RunMigrationScript(DatabaseFixture db) => + db.RunFileInContainer(Path.Combine(db.RepoRoot, "db", "35-show-identity-migration.sh")); + + /// (data_type, is_nullable, column_default) for the named station-schema column, read + /// from the fixture's fresh-init snapshot () rather + /// than a live query — see that property's own remarks and this file's header for why that is the + /// only way a dropped db/06 mirror of a db/35 column can actually turn a fresh-init fact red. + /// Fails the calling fact immediately if the column is missing from the snapshot. + static (string DataType, string IsNullable, string? ColumnDefault) InitialStationColumn( + DatabaseFixture db, string table, string column) + { + var found = db.InitialSchema.TryGetValue(("station", table, column), out var value); + Assert.True(found, $"station.{table}.{column} missing from the fresh-init schema snapshot"); + return value; + } + + /// Same as but for library.media, crossing the db/22 + /// schema-role boundary the same way already does for + /// the in-place scenario. + static (string DataType, string IsNullable, string? ColumnDefault) InitialLibraryMediaColumn( + DatabaseFixture db, string column) + { + var found = db.InitialSchema.TryGetValue(("library", "media", column), out var value); + Assert.True(found, $"library.media.{column} missing from the fresh-init schema snapshot"); + return value; + } + + /// The UNIQUE constraint names on the given bare station table name at the same + /// fresh-init instant reads from — empty when the table carries + /// none (mirrors 's own remarks on why this + /// is a list rather than a single name). + static IReadOnlyList InitialUniqueConstraintNames(DatabaseFixture db, string table) => + db.InitialUniqueConstraints.GetValueOrDefault(table, []); + + /// The T239 repository under spec, wired the same "Lazy over the fixture's own + /// StationDataSource" way Story118_PersonaStorage.cs's own Repo helper wires + /// PersonaRepository. + static ShowRepository Repo(DatabaseFixture db) => new(new Lazy(() => db.StationDataSource)); + + /// The show delete guard's own detail-read repository (PLAN T240 review — this method + /// carried zero live-DB coverage before 's two round-trip + /// facts below), wired the same "Lazy over the fixture's own StationDataSource" way as + /// ; mirrors Story240_ScheduleStore.cs's own identically-shaped helper. + static ScheduleRepository ScheduleRepo(DatabaseFixture db) => new(new Lazy(() => db.StationDataSource)); + + // --------------------------------------------------------------------- + // HAPPY PATH — fresh init (db/06's mirror of db/35) + // --------------------------------------------------------------------- + + [Collection(DatabaseCollection.Name)] + [Trait("Category", "Integration")] + public sealed class ScenarioTheIdentityPackageFlowsEndToEnd(DatabaseFixture db) + { + // Every fact below reads DatabaseFixture.InitialSchema/InitialUniqueConstraints — the + // snapshot taken once, at fixture init, of the PURE db/01+db/06 fresh-init world (see file + // header). No fact here ever runs db/35: that is precisely what makes dropping a column from + // db/06's own CREATE turn the matching fact red. + + [Fact] + public void ShowSlugColumnExistsAsTextNotNull() + { + var column = InitialStationColumn(db, "show", "slug"); + + Assert.Equal("text", column.DataType); + Assert.Equal("NO", column.IsNullable); + } + + [Fact] + public void ShowTaglineColumnExistsAsTextNullable() + { + var column = InitialStationColumn(db, "show", "tagline"); + + Assert.Equal("text", column.DataType); + Assert.Equal("YES", column.IsNullable); + } + + [Fact] + public void ShowFlavorColumnExistsAsTextNullable() + { + var column = InitialStationColumn(db, "show", "flavor"); + + Assert.Equal("text", column.DataType); + Assert.Equal("YES", column.IsNullable); + } + + [Fact] + public void ShowImportedFromColumnExistsAsTextNullable() + { + var column = InitialStationColumn(db, "show", "imported_from"); + + Assert.Equal("text", column.DataType); + Assert.Equal("YES", column.IsNullable); + } + + [Fact] + public void ShowImportedAtColumnExistsAsTimestamptzNullable() + { + var column = InitialStationColumn(db, "show", "imported_at"); + + Assert.Equal("timestamp with time zone", column.DataType); + Assert.Equal("YES", column.IsNullable); + } + + [Fact] + public void ShowPersonaIdColumnExistsAsIntegerNullable() + { + var column = InitialStationColumn(db, "show", "persona_id"); + + Assert.Equal("integer", column.DataType); + Assert.Equal("YES", column.IsNullable); + } + + [Fact] + public void ShowEnvelopeColumnExistsAsJsonbNullable() + { + var column = InitialStationColumn(db, "show", "envelope"); + + Assert.Equal("jsonb", column.DataType); + Assert.Equal("YES", column.IsNullable); + } + + [Fact] + public void BoothLogShowIdColumnExistsAsIntegerNullable() + { + // The air-time stamp (F121.1) — deliberately NO FK, history must outlive the entity. + var column = InitialStationColumn(db, "booth_log", "show_id"); + + Assert.Equal("integer", column.DataType); + Assert.Equal("YES", column.IsNullable); + } + + [Fact] + public void LibraryMediaShowIdColumnExistsAsIntegerNullable() + { + // Crosses the db/22 schema-role boundary — read from the library half of the snapshot, + // not the station half (the two roles have no grants into each other's schema). + var column = InitialLibraryMediaColumn(db, "show_id"); + + Assert.Equal("integer", column.DataType); + Assert.Equal("YES", column.IsNullable); + } + + [Fact] + public void ShowSlugUniqueConstraintIsNamedShowSlugKey() + { + // db/06's fresh-init CREATE names this constraint explicitly (CONSTRAINT show_slug_key + // UNIQUE); the list-equality assertion below also proves exactly one UNIQUE constraint + // exists on the table. + var constraintNames = InitialUniqueConstraintNames(db, "show"); + + Assert.Equal(["show_slug_key"], constraintNames); + } + } + + // --------------------------------------------------------------------- + // HAPPY PATH — in-place migration (db/35-show-identity-migration.sh) + // --------------------------------------------------------------------- + + [Collection(DatabaseCollection.Name)] + [Trait("Category", "Integration")] + public sealed class ScenarioMigrationAddsTheColumnsInPlace(DatabaseFixture db) + { + [Fact] + public async Task MigrationAddsTheSevenShowColumnsAndBothShowIdColumnsInPlace() + { + // T239 review: station.show shipped dormant through T238 (never a single row, any test + // order), but PLAN T239's own repository specs (ScenarioAuthoredCrud/ + // ScenarioRejectingInvalidShows below) now insert real rows and leave them behind for + // whichever sibling class runs next — this class carries no ordering guarantee against + // them. Re-adding `slug text not null` below with no DEFAULT fails outright if a leftover + // row exists at that instant (NOT NULL against an unbackfillable new column), so clear the + // table first — this scenario's own DROP COLUMN a few lines down removes any pre-existing + // slug value anyway, so a row surviving this reset would only ever break the re-ADD, never + // help prove anything about it. + await db.ResetShowAsync(); + + // Simulate a pre-T238 database by dropping the seven station.show columns db/35 adds + // (DROP COLUMN also drops the show_slug_key UNIQUE constraint the slug column carries) + // plus the two show_id stamp columns it adds on the other two tables. + await using (var conn = await db.StationDataSource.OpenConnectionAsync()) + { + await conn.ExecuteAsync( + """ + alter table station.show + drop column if exists slug, + drop column if exists tagline, + drop column if exists flavor, + drop column if exists imported_from, + drop column if exists imported_at, + drop column if exists persona_id, + drop column if exists envelope + """); + await conn.ExecuteAsync("alter table station.booth_log drop column if exists show_id"); + } + await using (var conn = await db.DataSource.OpenConnectionAsync()) + await conn.ExecuteAsync("alter table library.media drop column if exists show_id"); + + Assert.Null(await QueryColumnAsync(db, "show", "slug")); + Assert.Null(await QueryColumnAsync(db, "show", "tagline")); + Assert.Null(await QueryColumnAsync(db, "show", "flavor")); + Assert.Null(await QueryColumnAsync(db, "show", "imported_from")); + Assert.Null(await QueryColumnAsync(db, "show", "imported_at")); + Assert.Null(await QueryColumnAsync(db, "show", "persona_id")); + Assert.Null(await QueryColumnAsync(db, "show", "envelope")); + Assert.Null(await QueryColumnAsync(db, "booth_log", "show_id")); + Assert.Null(await QueryLibraryMediaColumnAsync(db, "show_id")); + + RunMigrationScript(db); + + var slug = await QueryColumnAsync(db, "show", "slug"); + Assert.NotNull(slug); + Assert.Equal("text", slug.Value.DataType); + Assert.Equal("NO", slug.Value.IsNullable); + + var tagline = await QueryColumnAsync(db, "show", "tagline"); + Assert.NotNull(tagline); + Assert.Equal("text", tagline.Value.DataType); + Assert.Equal("YES", tagline.Value.IsNullable); + + var flavor = await QueryColumnAsync(db, "show", "flavor"); + Assert.NotNull(flavor); + Assert.Equal("text", flavor.Value.DataType); + Assert.Equal("YES", flavor.Value.IsNullable); + + var importedFrom = await QueryColumnAsync(db, "show", "imported_from"); + Assert.NotNull(importedFrom); + Assert.Equal("text", importedFrom.Value.DataType); + Assert.Equal("YES", importedFrom.Value.IsNullable); + + var importedAt = await QueryColumnAsync(db, "show", "imported_at"); + Assert.NotNull(importedAt); + Assert.Equal("timestamp with time zone", importedAt.Value.DataType); + Assert.Equal("YES", importedAt.Value.IsNullable); + + var personaId = await QueryColumnAsync(db, "show", "persona_id"); + Assert.NotNull(personaId); + Assert.Equal("integer", personaId.Value.DataType); + Assert.Equal("YES", personaId.Value.IsNullable); + + var envelope = await QueryColumnAsync(db, "show", "envelope"); + Assert.NotNull(envelope); + Assert.Equal("jsonb", envelope.Value.DataType); + Assert.Equal("YES", envelope.Value.IsNullable); + + var boothLogShowId = await QueryColumnAsync(db, "booth_log", "show_id"); + Assert.NotNull(boothLogShowId); + Assert.Equal("integer", boothLogShowId.Value.DataType); + Assert.Equal("YES", boothLogShowId.Value.IsNullable); + + var libraryMediaShowId = await QueryLibraryMediaColumnAsync(db, "show_id"); + Assert.NotNull(libraryMediaShowId); + Assert.Equal("integer", libraryMediaShowId.Value.DataType); + Assert.Equal("YES", libraryMediaShowId.Value.IsNullable); + + // The convergence guard (see file header): the ALTER path lands on the identical literal + // constraint name (show_slug_key) the fresh-init snapshot observes from db/06's CREATE + // path. QueryUniqueConstraintNameAsync's own single-row contract additionally proves + // exactly one UNIQUE constraint exists on the table. + var constraintName = await QueryUniqueConstraintNameAsync(db, "station.show"); + Assert.Equal("show_slug_key", constraintName); + } + } + + // --------------------------------------------------------------------- + // T239 — repository CRUD, now real (Integration) + // --------------------------------------------------------------------- + + [Collection(DatabaseCollection.Name)] + [Trait("Category", "Integration")] + public sealed class ScenarioAuthoredCrud(DatabaseFixture db) + { + [Fact] + public async Task RoundTripsEveryField() + { + // Given an authored show "Night Moves" (tagline + flavor within budgets) + RunMigrationScript(db); + await db.ResetShowAsync(); + var repo = Repo(db); + var created = Assert.IsType(await repo.CreateAsync( + new ShowDraft("Night Moves", "Late-night deep cuts", "moody, sparse, past midnight"), + CancellationToken.None)); + + // When it is created, edited, and re-read through the repository + var edited = Assert.IsType(await repo.UpdateAsync( + created.Show.Id, + new ShowDraft("Night Moves", "Late-night deep cuts, revisited", "moodier, sparser, past 1am"), + CancellationToken.None)); + var read = await repo.GetByIdAsync(created.Show.Id, CancellationToken.None); + + // Then name, slug, tagline, and flavor all round-trip; provenance stays NULL — pinned in + // one assertion via full-record equality against the literal expected values (CreatedAt/ + // UpdatedAt are server-set, so those two are taken from the write results themselves). + var expected = new Show( + created.Show.Id, + "Night Moves", + created.Show.Slug, + "Late-night deep cuts, revisited", + "moodier, sparser, past 1am", + ImportedFrom: null, + ImportedAt: null, + created.Show.CreatedAt, + edited.Show.UpdatedAt); + Assert.Equal(expected, read); + } + + [Fact] + public async Task SlugDerivesViaHouseSlugify() + { + // Given an authored create with name "Night Moves" + RunMigrationScript(db); + await db.ResetShowAsync(); + var repo = Repo(db); + + // When the row lands + var created = Assert.IsType( + await repo.CreateAsync(new ShowDraft("Night Moves"), CancellationToken.None)); + + // Then slug is the house Slugify output (the T68 golden-table contract) — compared + // against the real LegacyPersonaCardMapper.Slugify call, not a hand-typed literal, so a + // future Slugify change can never silently disagree with what CreateAsync actually wrote. + Assert.Equal(LegacyPersonaCardMapper.Slugify("Night Moves"), created.Show.Slug); + } + + [Fact] + public async Task OneDjManyShows() + { + // Given one persona — deliberately never created here: persona linkage is block-level + // (later tasks; F115.2's dormant persona_id stays unread this epic), so this fact stays + // honest to its own GWT comment by proving only the show side: three shows authored with + // nothing referencing a persona at all. + RunMigrationScript(db); + await db.ResetShowAsync(); + var repo = Repo(db); + + // When three shows are authored (later assignable across their blocks), deliberately out + // of alphabetical order + await repo.CreateAsync(new ShowDraft("Morning Drive"), CancellationToken.None); + await repo.CreateAsync(new ShowDraft("Afternoon Session"), CancellationToken.None); + await repo.CreateAsync(new ShowDraft("Night Moves"), CancellationToken.None); + + // Then nothing structural objects — shows-per-DJ is unbounded by design (STORY-305 AC3): + // all three land, none rejected, and GetAllAsync's own IShowStore contract ("ordered by + // name") reads back alphabetically regardless of the create order above. + var all = await repo.GetAllAsync(CancellationToken.None); + Assert.Equal(["Afternoon Session", "Morning Drive", "Night Moves"], all.Select(s => s.Name)); + } + + [Fact] + public async Task UpdateAdvancesUpdatedAt() + { + // Given an authored show (mirrors Story118_PersonaStorage's own UpdateAdvancesUpdatedAt) + RunMigrationScript(db); + await db.ResetShowAsync(); + var repo = Repo(db); + var created = Assert.IsType( + await repo.CreateAsync(new ShowDraft("Original Name"), CancellationToken.None)); + + // When it is renamed via an edit + var updated = Assert.IsType(await repo.UpdateAsync( + created.Show.Id, new ShowDraft("Renamed"), CancellationToken.None)); + + // Then updated_at moves forward and the read reflects the rename + Assert.True(updated.Show.UpdatedAt > created.Show.UpdatedAt); + var read = await repo.GetByIdAsync(created.Show.Id, CancellationToken.None); + Assert.NotNull(read); + Assert.Equal("Renamed", read.Name); + } + + [Fact] + public async Task BudgetsAcceptAtExactlyTheLimit() + { + // Given a draft with every field at its exact SPEC F115.1 1× budget — the boundary is + // inclusive (BudgetsRejectAtOneTimes, below, proves budget+1 rejects) + RunMigrationScript(db); + await db.ResetShowAsync(); + var repo = Repo(db); + var draft = new ShowDraft( + new string('n', ShowBudgets.NameMaxChars), + new string('t', ShowBudgets.TaglineMaxChars), + new string('f', ShowBudgets.FlavorMaxChars)); + + // When the repository write is attempted + var outcome = await repo.CreateAsync(draft, CancellationToken.None); + + // Then it is accepted, not rejected + var created = Assert.IsType(outcome); + Assert.Equal(draft.Name, created.Show.Name); + Assert.Equal(draft.Tagline, created.Show.Tagline); + Assert.Equal(draft.Flavor, created.Show.Flavor); + } + + [Fact] + public async Task EmptyOrWhitespaceTaglineAndFlavorPersistAsNull() + { + // Given a show authored with a blank tagline and a whitespace-only flavor (create path) + RunMigrationScript(db); + await db.ResetShowAsync(); + var repo = Repo(db); + var created = Assert.IsType(await repo.CreateAsync( + new ShowDraft("Night Moves", Tagline: "", Flavor: " "), CancellationToken.None)); + + // Then both coerce to null on create — Show/ShowDraft's own docs promise null "when the + // show carries none", never a stray empty/whitespace string + Assert.Null(created.Show.Tagline); + Assert.Null(created.Show.Flavor); + + // When an edit first gives the show a real tagline/flavor, then a later edit clears both + // back to blank/whitespace (update path) + var withValues = Assert.IsType(await repo.UpdateAsync( + created.Show.Id, + new ShowDraft("Night Moves", "Real tagline", "Real flavor"), + CancellationToken.None)); + Assert.Equal("Real tagline", withValues.Show.Tagline); + Assert.Equal("Real flavor", withValues.Show.Flavor); + + var cleared = Assert.IsType(await repo.UpdateAsync( + created.Show.Id, + new ShowDraft("Night Moves", Tagline: "", Flavor: " "), + CancellationToken.None)); + + // Then the cleared fields read back null through UpdateAsync too, never the empty/ + // whitespace string verbatim + Assert.Null(cleared.Show.Tagline); + Assert.Null(cleared.Show.Flavor); + var read = await repo.GetByIdAsync(created.Show.Id, CancellationToken.None); + Assert.NotNull(read); + Assert.Null(read.Tagline); + Assert.Null(read.Flavor); + } + } + + // --------------------------------------------------------------------- + // T239 — GetBySlugAsync (Integration) + // --------------------------------------------------------------------- + + [Collection(DatabaseCollection.Name)] + [Trait("Category", "Integration")] + public sealed class ScenarioGetBySlugAsync(DatabaseFixture db) + { + [Fact] + public async Task ReturnsTheShowWhenTheSlugExists() + { + // Given an authored show + RunMigrationScript(db); + await db.ResetShowAsync(); + var repo = Repo(db); + var created = Assert.IsType( + await repo.CreateAsync(new ShowDraft("Night Moves"), CancellationToken.None)); + + // When it is looked up by its derived slug + var read = await repo.GetBySlugAsync(created.Show.Slug, CancellationToken.None); + + // Then the same row comes back + Assert.Equal(created.Show, read); + } + + [Fact] + public async Task ReturnsNullWhenTheSlugIsUnknown() + { + // Given an empty table + RunMigrationScript(db); + await db.ResetShowAsync(); + var repo = Repo(db); + + // When a slug naming no row is looked up + var read = await repo.GetBySlugAsync("no-such-show", CancellationToken.None); + + // Then it misses cleanly + Assert.Null(read); + } + } + + // --------------------------------------------------------------------- + // T239 — DeleteAsync (Integration) + // --------------------------------------------------------------------- + + [Collection(DatabaseCollection.Name)] + [Trait("Category", "Integration")] + public sealed class ScenarioDeleteAsync(DatabaseFixture db) + { + [Fact] + public async Task DeletesAnExistingShow() + { + // Given an authored show with no references + RunMigrationScript(db); + await db.ResetShowAsync(); + var repo = Repo(db); + var created = Assert.IsType( + await repo.CreateAsync(new ShowDraft("To Be Deleted"), CancellationToken.None)); + + // When it is deleted + var outcome = await repo.DeleteAsync(created.Show.Id, CancellationToken.None); + + // Then it succeeds and the row is gone + Assert.IsType(outcome); + Assert.Null(await repo.GetByIdAsync(created.Show.Id, CancellationToken.None)); + } + + [Fact] + public async Task ReturnsNotFoundForAMissingShow() + { + // Given an empty table (no show with this id has ever existed) + RunMigrationScript(db); + await db.ResetShowAsync(); + var repo = Repo(db); + + // When a delete targets an id that names no row + var outcome = await repo.DeleteAsync(999_999, CancellationToken.None); + + // Then it reports not found, never a silent no-op success + Assert.IsType(outcome); + } + } + + // --------------------------------------------------------------------- + // T239 — DeleteAsync's FK guard (SPEC F115.4): station.segment_schedule.show_id's own ON DELETE + // RESTRICT, surfaced as ShowWriteResult.Referenced (mirrors Story118_PersonaStorage's own + // ScenarioDeleteFkGuard idiom — direct SQL insert into segment_schedule, bypassing ScheduleRepository + // entirely, so this fact exercises the FK itself rather than any other seam). + // --------------------------------------------------------------------- + + [Collection(DatabaseCollection.Name)] + [Trait("Category", "Integration")] + public sealed class ScenarioDeleteFkGuard(DatabaseFixture db) + { + static async Task InsertScheduleRowReferencingShowAsync( + DatabaseFixture db, long showId, int dayOfWeek = 1, int startMinute = 540, int endMinute = 720) + { + await using var conn = await db.StationDataSource.OpenConnectionAsync(); + await conn.ExecuteAsync( + """ + insert into station.segment_schedule (day_of_week, start_minute, end_minute, show_id) + values (@dayOfWeek, @startMinute, @endMinute, @showId) + """, + new { dayOfWeek, startMinute, endMinute, showId }); + } + + [Fact] + public async Task DeletingAReferencedShowIsRejectedThenSucceedsOnceUnreferenced() + { + // Given an authored show referenced by one schedule row + RunMigrationScript(db); + await db.ResetShowAsync(); + var repo = Repo(db); + var created = Assert.IsType( + await repo.CreateAsync(new ShowDraft("Scheduled Show"), CancellationToken.None)); + await InsertScheduleRowReferencingShowAsync(db, created.Show.Id); + + // When delete is attempted while the reference stands + var whileReferenced = await repo.DeleteAsync(created.Show.Id, CancellationToken.None); + + // Then the FK's own RESTRICT rejects it — the store-level case names nothing beyond + // "referenced" (PLAN T240 enriches this with block-naming at the endpoint layer) + Assert.IsType(whileReferenced); + + // When the reference is removed and delete is retried + await using (var conn = await db.StationDataSource.OpenConnectionAsync()) + await conn.ExecuteAsync( + "delete from station.segment_schedule where show_id = @showId", new { showId = created.Show.Id }); + var onceUnreferenced = await repo.DeleteAsync(created.Show.Id, CancellationToken.None); + + // Then it succeeds + Assert.IsType(onceUnreferenced); + } + + // PLAN T240 review — ScheduleRepository.GetSlotsByShowIdAsync itself (the show delete guard's + // own endpoint-layer detail read, ShowsController.Delete's Referenced case) carried zero live + // Postgres coverage before the two facts below: only the FK's own RESTRICT, above, was proven + // for real. Mirrors Story118_PersonaStorage.cs's own ScenarioDeleteFkGuard round-trip facts, + // just against ScheduleRepository directly rather than through a ShowRepository.DeleteAsync + // FK trip (this method is never called from that seam — ShowsController calls it directly). + + [Fact] + public async Task GetSlotsByShowIdAsyncRoundTripsASingleSlot() + { + // Given an authored show referenced by exactly one schedule row + RunMigrationScript(db); + await db.ResetShowAsync(); + var repo = Repo(db); + var created = Assert.IsType( + await repo.CreateAsync(new ShowDraft("Slot Round Trip Show"), CancellationToken.None)); + await InsertScheduleRowReferencingShowAsync( + db, created.Show.Id, dayOfWeek: 1, startMinute: 540, endMinute: 720); + + // When ScheduleRepository.GetSlotsByShowIdAsync reads it back + var slots = await ScheduleRepo(db).GetSlotsByShowIdAsync(created.Show.Id, CancellationToken.None); + + // Then day/start/end round-trip through the real SQL + Dapper mapping exactly — not just + // scripted on a fake, the way ShowsController's own wire specs cover this shape + var slot = Assert.Single(slots); + Assert.Equal(DayOfWeek.Monday, slot.Day); + Assert.Equal(540, slot.StartMinute); + Assert.Equal(720, slot.EndMinute); + } + + [Fact] + public async Task GetSlotsByShowIdAsyncOrdersMultipleSlotsByDayThenStartMinute() + { + // Given an authored show referenced by two schedule rows, inserted deliberately out of + // order (Tuesday before Monday) — proves the query orders by day/start_minute, not + // insert order + RunMigrationScript(db); + await db.ResetShowAsync(); + var repo = Repo(db); + var created = Assert.IsType( + await repo.CreateAsync(new ShowDraft("Busy Show"), CancellationToken.None)); + await InsertScheduleRowReferencingShowAsync( + db, created.Show.Id, dayOfWeek: 2, startMinute: 840, endMinute: 960); + await InsertScheduleRowReferencingShowAsync( + db, created.Show.Id, dayOfWeek: 1, startMinute: 540, endMinute: 720); + + // When ScheduleRepository.GetSlotsByShowIdAsync reads them back + var slots = await ScheduleRepo(db).GetSlotsByShowIdAsync(created.Show.Id, CancellationToken.None); + + // Then the result comes back ordered day-then-start-minute + Assert.Equal(2, slots.Count); + Assert.Equal(DayOfWeek.Monday, slots[0].Day); + Assert.Equal(540, slots[0].StartMinute); + Assert.Equal(DayOfWeek.Tuesday, slots[1].Day); + Assert.Equal(840, slots[1].StartMinute); + } + } + + // --------------------------------------------------------------------- + // PLAN T240 review — ShowImagingScopeRepository.UnscopeAsync (SPEC F115.4): the only writer of + // library.media.show_id, over the library connection (station_svc has no grant on library.media — + // the same db/22 cross-schema boundary IMediaLibraryMembership's own remarks already carry). + // Deliberately no station.show row involved: F117.1's own show_id column carries NO FK, by + // design, so this seam is provably reachable with any long id, real show or not. + // --------------------------------------------------------------------- + + [Collection(DatabaseCollection.Name)] + [Trait("Category", "Integration")] + public sealed class ScenarioShowImagingScopeRepository(DatabaseFixture db) + { + static async Task InsertScopedMediaRowAsync(DatabaseFixture db, long showId, string title) + { + await using var conn = await db.DataSource.OpenConnectionAsync(); + return await conn.ExecuteScalarAsync( + """ + insert into library.media (path, format, size_bytes, mtime, title, show_id) + values (@path, 'flac', 100, now(), @title, @showId) + returning id + """, + new { path = $"/imaging/{Guid.NewGuid()}.flac", title, showId }); + } + + [Fact] + public async Task UnscopeAsyncClearsShowIdAndNamesTheRowItCleared() + { + // Given a library.media row scoped to a show + await db.ResetAsync(); + const long showId = 4242L; + var mediaId = await InsertScopedMediaRowAsync(db, showId, "Sunset Ident"); + var repo = new ShowImagingScopeRepository(db.DataSource); + + // When UnscopeAsync clears every row scoped to that show + var unscoped = await repo.UnscopeAsync(showId, CancellationToken.None); + + // Then the single UPDATE ... RETURNING named exactly the row it cleared, and a fresh read + // confirms show_id genuinely persists as NULL — not just what the RETURNING clause claims + var row = Assert.Single(unscoped); + await using var conn = await db.DataSource.OpenConnectionAsync(); + var showIdAfter = await conn.ExecuteScalarAsync( + "select show_id from library.media where id = @mediaId", new { mediaId }); + Assert.Equal( + (MediaId: mediaId, Title: "Sunset Ident", ShowIdAfter: (long?)null), + (MediaId: row.MediaId, Title: row.Title, ShowIdAfter: showIdAfter)); + } + } + + // --------------------------------------------------------------------- + // T238 dormant bundle columns — now real (Integration) + // --------------------------------------------------------------------- + + [Collection(DatabaseCollection.Name)] + [Trait("Category", "Integration")] + public sealed class ScenarioDormantBundleColumns(DatabaseFixture db) + { + [Fact] + public async Task DormantColumnsExistAndDefaultNull() + { + // Given the fresh-init schema snapshot (see file header)... + var personaId = InitialStationColumn(db, "show", "persona_id"); + var envelope = InitialStationColumn(db, "show", "envelope"); + + // ...persona_id and envelope carry NO DEFAULT — an implicit value here would be as much + // an out-of-band write as a stray non-NULL row below (this column_default assertion was + // previously missing despite the fact's own name). + Assert.Null(personaId.ColumnDefault); + Assert.Null(envelope.ColumnDefault); + + // Converge the live schema before writing: the two assertions above deliberately read the + // frozen fresh-init snapshot (InitialSchema), never the live database, so this call cannot + // affect their falsifiability. But the insert below needs station.show.slug to actually + // exist on the live connection, and this class carries no ordering guarantee against + // Story304_AiredKindStamp's own in-place scenario, which drops and recreates station.show + // via db/33's pre-T238 shape (no slug column) elsewhere in the same run. Running the + // idempotent migration script here — unconditionally, right before the live connection + // opens — makes this fact's live-schema dependency self-sufficient regardless of xUnit's + // class scheduling. + RunMigrationScript(db); + + // When a row is inserted through the bare name/slug columns — deliberately NOT through + // ShowRepository (PLAN T239's real writer of this table as of this task, but it has no + // parameter for persona_id/envelope at all — SPEC F115.2's "unread this epic" law — so a + // raw INSERT is the only way to even attempt setting them, and this one doesn't either) — + // inside a transaction that always rolls back so this fact leaves nothing behind for its + // siblings... + await using var conn = await db.StationDataSource.OpenConnectionAsync(); + await using var tx = await conn.BeginTransactionAsync(); + + var id = await conn.ExecuteScalarAsync( + "insert into station.show (name, slug) values ('Dormant Probe', 'dormant-probe') returning id", + transaction: tx); + + var row = await conn.QuerySingleAsync<(bool PersonaIdSet, bool EnvelopeSet)>( + """ + select persona_id is not null as persona_id_set, envelope is not null as envelope_set + from station.show where id = @id + """, + new { id }, tx); + + await tx.RollbackAsync(); + + // Then persona_id and envelope both read back NULL on it — a falsifiable guard: a column + // DEFAULT or an INSERT trigger/rule that set either column would turn this red, unlike the + // prior `count(*) where … is not null` scan over a table no writer ever touches, which + // was structurally 0 and could never fail. + Assert.False(row.PersonaIdSet); + Assert.False(row.EnvelopeSet); + } + } + + // --------------------------------------------------------------------- + // T239 — validation & conflicts, now real (Integration) + // --------------------------------------------------------------------- + + [Collection(DatabaseCollection.Name)] + [Trait("Category", "Integration")] + public sealed class ScenarioRejectingInvalidShows(DatabaseFixture db) + { + [Fact] + public async Task BudgetsRejectAtOneTimes() + { + // Given a show whose flavor exceeds 400 chars (the same 1x line also covers name > 60, + // tagline > 120 — ShowRepository.ValidateBudgets checks all three; flavor is the + // representative case here, mirroring the GWT comment's own primary example) + RunMigrationScript(db); + await db.ResetShowAsync(); + var repo = Repo(db); + var draft = new ShowDraft("Night Moves", Flavor: new string('a', ShowBudgets.FlavorMaxChars + 1)); + + // When the repository write is attempted + var outcome = await repo.CreateAsync(draft, CancellationToken.None); + + // Then it rejects at the seam — the 1× budget is the app-side hard line (F115.1) + Assert.Equal(new ShowWriteResult.BudgetExceeded(ShowBudgetField.Flavor), outcome); + } + + [Fact] + public async Task NameOverBudgetRejected() + { + // Given a show whose name exceeds 60 chars (PLAN T240 review A2 — a dedicated name-budget + // fact beside the tagline/flavor ones above; ShowBudgets.FirstViolation checks name first, + // so this also pins that field-order precedence rather than trusting it un-Fact-pinned) + RunMigrationScript(db); + await db.ResetShowAsync(); + var repo = Repo(db); + var draft = new ShowDraft(new string('n', ShowBudgets.NameMaxChars + 1)); + + // When the repository write is attempted + var outcome = await repo.CreateAsync(draft, CancellationToken.None); + + // Then it rejects at the seam, naming the name field specifically + Assert.Equal(new ShowWriteResult.BudgetExceeded(ShowBudgetField.Name), outcome); + } + + [Fact] + public async Task DuplicateSlugRejected() + { + // Given an existing show slug + RunMigrationScript(db); + await db.ResetShowAsync(); + var repo = Repo(db); + await repo.CreateAsync(new ShowDraft("Night Moves"), CancellationToken.None); + + // When a second show would land on the same slug + var outcome = await repo.CreateAsync(new ShowDraft("Night Moves"), CancellationToken.None); + + // Then the unique constraint surfaces as a conflict, not a silent overwrite + Assert.IsType(outcome); + } + + [Fact] + public async Task UpdateRejectsABudgetViolation() + { + // Given an existing show + RunMigrationScript(db); + await db.ResetShowAsync(); + var repo = Repo(db); + var created = Assert.IsType( + await repo.CreateAsync(new ShowDraft("Night Moves"), CancellationToken.None)); + + // When it is edited with a tagline over its SPEC F115.1 1× budget + var outcome = await repo.UpdateAsync( + created.Show.Id, + new ShowDraft("Night Moves", Tagline: new string('a', ShowBudgets.TaglineMaxChars + 1)), + CancellationToken.None); + + // Then it rejects at the seam the same way CreateAsync does — checked before the write + // ever reaches Postgres, the row left unchanged + Assert.Equal(new ShowWriteResult.BudgetExceeded(ShowBudgetField.Tagline), outcome); + } + + [Fact] + public async Task UpdateRejectsASlugConflict() + { + // Given two existing shows + RunMigrationScript(db); + await db.ResetShowAsync(); + var repo = Repo(db); + await repo.CreateAsync(new ShowDraft("Night Moves"), CancellationToken.None); + var second = Assert.IsType( + await repo.CreateAsync(new ShowDraft("Day Drift"), CancellationToken.None)); + + // When the second is renamed onto the first's slug + var outcome = await repo.UpdateAsync( + second.Show.Id, new ShowDraft("Night Moves"), CancellationToken.None); + + // Then the unique constraint surfaces as a conflict, not a silent overwrite + Assert.IsType(outcome); + } + + [Fact] + public async Task UpdateReturnsNotFoundForAMissingShow() + { + // Given an empty table (no show with this id has ever existed) + RunMigrationScript(db); + await db.ResetShowAsync(); + var repo = Repo(db); + + // When an edit targets an id that names no row + var outcome = await repo.UpdateAsync(999_999, new ShowDraft("Night Moves"), CancellationToken.None); + + // Then it reports not found, never a silent insert + Assert.IsType(outcome); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task BlankOrWhitespaceNameRejected(string name) + { + // Given a draft whose name is blank or whitespace-only + RunMigrationScript(db); + await db.ResetShowAsync(); + var repo = Repo(db); + + // When the repository write is attempted + var outcome = await repo.CreateAsync(new ShowDraft(name), CancellationToken.None); + + // Then it rejects at the seam, never reaching station.show's own NOT NULL/check + // (length(btrim(name)) > 0) constraint (db/33) + Assert.IsType(outcome); + } + + [Theory] + [InlineData("🎧🎶")] + // PLAN T240 review A1: ValidateName rejects on the RESULTING slug equaling the fallback + // literal, not on "how" it got there — the literal name "Persona" slugifies to "persona" the + // ORDINARY way (lowercases unchanged; Slugify's own empty-slug rescue never fires, since no + // character is non-alphanumeric) and must reject exactly the same as the emoji-only case above. + [InlineData("Persona")] + public async Task NameThatSlugifiesToTheFallbackLiteralRejected(string name) + { + // Given a name whose Slugify output is the bare literal "persona" — either via the + // empty-slug rescue (an emoji-only name) or the ordinary lowercase-unchanged path + RunMigrationScript(db); + await db.ResetShowAsync(); + var repo = Repo(db); + Assert.Equal("persona", LegacyPersonaCardMapper.Slugify(name)); + + // When the repository write is attempted + var outcome = await repo.CreateAsync(new ShowDraft(name), CancellationToken.None); + + // Then it rejects — REJECT, not silently autocorrect into a misleadingly-named show + Assert.IsType(outcome); + } + + [Fact] + public async Task UpdateRejectsABlankName() + { + // Given an existing show + RunMigrationScript(db); + await db.ResetShowAsync(); + var repo = Repo(db); + var created = Assert.IsType( + await repo.CreateAsync(new ShowDraft("Night Moves"), CancellationToken.None)); + + // When it is edited with a blank name + var outcome = await repo.UpdateAsync(created.Show.Id, new ShowDraft(""), CancellationToken.None); + + // Then it rejects at the seam the same way CreateAsync does, the row left unchanged + Assert.IsType(outcome); + var read = await repo.GetByIdAsync(created.Show.Id, CancellationToken.None); + Assert.NotNull(read); + Assert.Equal("Night Moves", read.Name); + } + } +} diff --git a/tests/GenWave.MediaLibrary.Tests/Specs/Story309_ScopedImagingPool.cs b/tests/GenWave.MediaLibrary.Tests/Specs/Story309_ScopedImagingPool.cs new file mode 100644 index 00000000..8a42c28e --- /dev/null +++ b/tests/GenWave.MediaLibrary.Tests/Specs/Story309_ScopedImagingPool.cs @@ -0,0 +1,51 @@ +// STORY-309 — Show-branded idents (F117) — pool-query + authored-scope half +// +// BDD specification — xUnit, PENDING scaffold (planned 2026-08-10). Comment-bodied on +// purpose: library.media.show_id lands at T238, the scoped query at T250, the authored +// insert scope at T246. No FK across the schema/grant boundary (the db/22 precedent). +// The drain-preference half lives in Orchestration.Tests/Story309_ShowIdentDrain.cs. + +namespace GenWave.MediaLibrary.Tests.Specs; + +using Xunit; + +public static class FeatureScopedImagingPool +{ + public sealed class ScenarioScopedQuery + { + [Fact(Skip = "Pending (T250)")] + public void ScopedRowsPreferredWhenAShowIsActive() + { + // Given ready station_id rows both scoped to show 7 and unscoped + // When the pool query runs for show 7 + // Then only scoped rows are candidates in the scoped-first pass + } + + [Fact(Skip = "Pending (T250)")] + public void UnscopedFallbackWhenNoScopedRows() + { + // Given only unscoped ready station_id rows + // When the pool query runs for a show + // Then the unscoped fallback pass serves them (the station-wide pool survives) + } + + [Fact(Skip = "Pending (T250)")] + public void ScopedRowsNeverServeOutsideTheirShow() + { + // Given a ready row scoped to show 7 + // When the pool query runs with no show (or another show) active + // Then the scoped row is not a candidate — scoped means scoped (F117.1) + } + } + + public sealed class ScenarioAuthoringWithScope + { + [Fact(Skip = "Pending (T246)")] + public void InsertAuthoredAcceptsAShowScope() + { + // Given the authoring path with a show scope selected + // When InsertAuthoredAsync runs + // Then the row lands with show_id set; the default remains station-wide NULL + } + } +} diff --git a/tests/GenWave.MediaLibrary.Tests/Specs/Story310_ShowStamp.cs b/tests/GenWave.MediaLibrary.Tests/Specs/Story310_ShowStamp.cs new file mode 100644 index 00000000..b039803a --- /dev/null +++ b/tests/GenWave.MediaLibrary.Tests/Specs/Story310_ShowStamp.cs @@ -0,0 +1,208 @@ +// STORY-310 — Show airings are countable (F121.1) +// +// BDD specification — xUnit, Postgres-backed (Category=Integration, shared DatabaseFixture) — the +// F113.1 pattern exactly, and the SAME production pipeline Story215_BoothLogPersonaStamp.cs/ +// Story304_AiredKindStamp.cs's own DriveThroughAsync drive: real StationEvents through the REAL +// BoothLogWriter/BoothLogDrainService into the real (test) database, because the write-side types +// (BoothLogWriter, BoothLogDrainService, BoothLogEntryRequest) are internal to GenWave.MediaLibrary, +// and a fake store would never prove the real INSERT column-list wiring honestly. `show_id` is +// deliberately read back with a raw query rather than through BoothLogRepository.ReadAsync/ +// BoothLogEntry — F113.3's precedent (segment_kind) keeps the read path untouched this cycle too, so +// the column has no projection to assert against yet. +// +// No FK on booth_log.show_id (SPEC F121.1 — history outlives the entity), so these facts stamp an +// arbitrary show id with no need for a real station.show row to exist. + +using System.Threading.Channels; +using Dapper; +using GenWave.Core.Abstractions; +using GenWave.Core.Domain; +using GenWave.Core.Events; +using GenWave.MediaLibrary.Station; +using Microsoft.Extensions.Logging.Abstractions; +using Npgsql; + +namespace GenWave.MediaLibrary.Tests.Specs; + +public static class FeatureShowStamp +{ + // --------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------- + + /// + /// Scriptable double, scoped to this file's own concern + /// ( — not persona attribution, Story215_BoothLogPersonaStamp.cs's own + /// territory): reports no active persona (the interface's own default). is + /// settable — mirrors Story215's own FakeActivePersonaAccessor — so a scenario can flip the + /// on-air show MID-TEST (the F121.1 "stamped at air time" claim is only provable if the answer + /// can change AFTER already captured it). + /// + sealed class FakeActiveShowAccessor(long? showId) : IActivePersonaAccessor + { + public long? ShowId { get; set; } = showId; + + public Task ResolveAsync(CancellationToken ct) => Task.FromResult(null); + + public long? ActiveShowId => ShowId; + } + + static BoothLogRepository Store(DatabaseFixture db) => + new(new Lazy(() => db.StationDataSource), + Microsoft.Extensions.Options.Options.Create(new BoothLogOptions())); + + /// + /// Publishes every through the real — which + /// captures SYNCHRONOUSLY at publish time (F121.1), off the SAME + /// dependency the persona stamp already reads — and drains + /// each through the real , the same production + /// pipeline this file's sibling specs drive. + /// + static async Task DriveThroughAsync(DatabaseFixture db, long? activeShowId, params StationEvent[] events) + { + var channel = Channel.CreateBounded(16); + var writer = new BoothLogWriter(channel.Writer, new FakeActiveShowAccessor(activeShowId), NullLogger.Instance); + var drain = new BoothLogDrainService(channel.Reader, Store(db), NullLogger.Instance); + + foreach (var evt in events) + writer.Publish(evt); + + for (var i = 0; i < events.Length; i++) + await drain.ProcessAsync(await channel.Reader.ReadAsync(), CancellationToken.None); + } + + /// The persisted `show_id` for every `track-started` row, newest first — a raw query + /// rather than because the column has no projection on + /// yet (F113.3's precedent, carried to F121.1). + static async Task> TrackStartedShowIdsAsync(DatabaseFixture db) + { + await using var conn = await db.StationDataSource.OpenConnectionAsync(); + var rows = await conn.QueryAsync( + """ + select show_id from station.booth_log + where kind = 'track-started' + order by occurred_at desc, id desc + """); + return rows.ToList(); + } + + static TrackAired AKindedAiring() => new( + "tts:abc123", "GenWave", "GenWave", -2.0, DateTimeOffset.UtcNow, 4_000, + SegmentKind: SegmentKind.StationId); + + static TrackAired AMusicAiring() => new("42", "Night Drive", "The Waveforms", -2.5, DateTimeOffset.UtcNow, 214_000); + + // --------------------------------------------------------------------- + // HAPPY PATH — rows during a show carry show_id (F121.1) + // --------------------------------------------------------------------- + + [Collection(DatabaseCollection.Name)] + [Trait("Category", "Integration")] + public sealed class ScenarioRowsDuringAShow(DatabaseFixture db) + { + [Fact] + public async Task KindedRowsCarryTheShowId() + { + // Given a show on the air... + await db.ResetBoothLogAsync(); + const long showId = 501; + + // When a kinded (tts) airing writes its track-started row — SegmentKind.StationId, the + // demo-hour gate's own F121.2(b) evidence... + await DriveThroughAsync(db, showId, AKindedAiring()); + + // Then show_id carries the snapshot's show. + var showIds = await TrackStartedShowIdsAsync(db); + Assert.Equal([showId], showIds); + } + + [Fact] + public async Task MusicRowsCarryItToo() + { + // Given a show on the air, and the SAME BoothLogWriter instance/chokepoint a kinded + // airing stamps through above — not a second writer, not a second accessor... + await db.ResetBoothLogAsync(); + const long showId = 777; + var channel = Channel.CreateBounded(16); + var writer = new BoothLogWriter(channel.Writer, new FakeActiveShowAccessor(showId), NullLogger.Instance); + var drain = new BoothLogDrainService(channel.Reader, Store(db), NullLogger.Instance); + + // When a kinded airing and a plain music airing both flow through it, in order... + writer.Publish(AKindedAiring()); + writer.Publish(AMusicAiring()); + await drain.ProcessAsync(await channel.Reader.ReadAsync(), CancellationToken.None); + await drain.ProcessAsync(await channel.Reader.ReadAsync(), CancellationToken.None); + + // Then show_id is stamped from the same chokepoint on BOTH rows — verifying ONE stamp + // point covers music and kinded alike (the /design TODO made a fact, PLAN T242). + var showIds = await TrackStartedShowIdsAsync(db); + Assert.Equal([showId, showId], showIds); + } + } + + // --------------------------------------------------------------------- + // HAPPY PATH — the stamp reflects AIR time, not DRAIN time, across a channel backlog (F121.1) + // + // Mirrors Story215_BoothLogPersonaStamp.cs's own ScenarioPersonaSwitchesDuringBacklog exactly — + // the T60 review finding this epic's own PLAN task calls out: resolving the on-air show at DRAIN + // time (rather than capturing it synchronously at PUBLISH time) would mis-stamp a row already + // queued behind a bounded-channel backlog once a show change lands before the drain catches up. + // BoothLogWriter.Publish must have already captured the answer before this test ever flips the + // active show. + // --------------------------------------------------------------------- + + [Collection(DatabaseCollection.Name)] + [Trait("Category", "Integration")] + public sealed class ScenarioShowSwitchesDuringBacklog(DatabaseFixture db) + { + [Fact] + public async Task QueuedTrackStartStaysStampedWithTheAirTimeShowDespiteALaterSwitch() + { + // Given show A on the air, and a track-start event published through the real writer — + // captured synchronously, at air time, while A is still on air... + await db.ResetBoothLogAsync(); + const long showAId = 111; + const long showBId = 222; + var accessor = new FakeActiveShowAccessor(showAId); + + var channel = Channel.CreateBounded(16); + var writer = new BoothLogWriter(channel.Writer, accessor, NullLogger.Instance); + var drain = new BoothLogDrainService(channel.Reader, Store(db), NullLogger.Instance); + + writer.Publish(AMusicAiring()); + + // When the on-air show switches to B BEFORE the drain ever processes the entry sitting + // in the queue — the exact bounded-queue-backlog window the finding described... + accessor.ShowId = showBId; + await drain.ProcessAsync(await channel.Reader.ReadAsync(), CancellationToken.None); + + // Then the persisted row is stamped with A — the show on air when the track STARTED, + // never B, which only became active after the row had already queued. + var showIds = await TrackStartedShowIdsAsync(db); + Assert.Equal([showAId], showIds); + } + } + + // --------------------------------------------------------------------- + // HAPPY PATH — a showless airing stays unstamped (F121.1) + // --------------------------------------------------------------------- + + [Collection(DatabaseCollection.Name)] + [Trait("Category", "Integration")] + public sealed class ScenarioShowlessRows(DatabaseFixture db) + { + [Fact] + public async Task NoShowMeansNullStamp() + { + // Given no show on the air (a grid gap, or an unnamed block)... + await db.ResetBoothLogAsync(); + + // When rows are written — kinded and music alike... + await DriveThroughAsync(db, activeShowId: null, AKindedAiring(), AMusicAiring()); + + // Then show_id stays NULL on every row — pre-F121 and showless rows are indistinguishable. + var showIds = await TrackStartedShowIdsAsync(db); + Assert.Equal([null, null], showIds); + } + } +} diff --git a/tests/GenWave.MediaLibrary.Tests/Specs/Story317_SpecialsStore.cs b/tests/GenWave.MediaLibrary.Tests/Specs/Story317_SpecialsStore.cs new file mode 100644 index 00000000..e38205cc --- /dev/null +++ b/tests/GenWave.MediaLibrary.Tests/Specs/Story317_SpecialsStore.cs @@ -0,0 +1,36 @@ +// STORY-317 — Dated specials shadow the grid (F120) — store half · 🪂 DROPPABLE SLICE +// +// BDD specification — xUnit, PENDING scaffold (planned 2026-08-10). Comment-bodied on +// purpose: db/36 + the specials repository land at T258 — and this slice is the epic's +// ONE ruled-droppable tail; dropping it removes these pendings with it. The resolver +// rung half lives in Orchestration.Tests/Story317_SpecialsRung.cs. + +namespace GenWave.MediaLibrary.Tests.Specs; + +using Xunit; + +public static class FeatureSpecialsStore +{ + public sealed class ScenarioDatedRows + { + [Fact(Skip = "Pending (T258)")] + public void ASpecialRoundTripsWithDateSpanPersonaShowEnvelope() + { + // Given a special for 2026-12-24 19:00–21:00 with persona/show/envelope + // When it is written and re-read + // Then every field round-trips; minutes obey the 30-min steps (F91 mirrored) + } + } + + public sealed class ScenarioRejectingOverlap + { + [Fact(Skip = "Pending (T258)")] + public void OverlappingSpecialsOnADateAreRejectedByTheDatabase() + { + // Given a special already covering a span on a date + // When a second special overlaps it + // Then the per-date EXCLUDE guard rejects at the database (F120.1) — + // the weekly table's own invariant is untouched by construction + } + } +} diff --git a/tests/GenWave.Orchestration.Tests/Fakes/FakeScheduleStore.cs b/tests/GenWave.Orchestration.Tests/Fakes/FakeScheduleStore.cs index 87737866..da27d7dd 100644 --- a/tests/GenWave.Orchestration.Tests/Fakes/FakeScheduleStore.cs +++ b/tests/GenWave.Orchestration.Tests/Fakes/FakeScheduleStore.cs @@ -45,6 +45,11 @@ public Task ReplaceWeekAsync( IReadOnlyList week, string? expectedVersion, CancellationToken ct) => throw new NotSupportedException("FakeScheduleStore is a read-only double for T119 specs."); + // PLAN T240's show delete guard read — not exercised by any T119 spec (this double never carries + // show_id-scoped rows), mirrors ReplaceWeekAsync's own NotSupportedException posture above. + public Task> GetSlotsByShowIdAsync(long showId, CancellationToken ct) => + throw new NotSupportedException("FakeScheduleStore is a read-only double for T119 specs."); + public void RaiseWeekChanged() => WeekChanged?.Invoke(); /// Arms a gate so the NEXT call returns an incomplete task, diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story306_IdentityChokepoint.cs b/tests/GenWave.Orchestration.Tests/Specs/Story306_IdentityChokepoint.cs new file mode 100644 index 00000000..72d90264 --- /dev/null +++ b/tests/GenWave.Orchestration.Tests/Specs/Story306_IdentityChokepoint.cs @@ -0,0 +1,163 @@ +// STORY-306 — One identity chokepoint (F115.2, F116.1) +// +// BDD specification — xUnit, real GenWave.Orchestration.ScheduleResolver/EffectiveAssignment (no +// stores needed: every fact here builds a ScheduleWeekSnapshot by hand, mirroring +// Story241_StationFollowsTheClock.cs's own ScenarioResolvingTheCurrentSegment idiom — a pure +// (snapshot, wall clock) function needs no IScheduleStore double at all). Orchestration.Tests carries +// no live Postgres (fakes only — see project header); the live-PG half of the dormant-columns-unread +// pin (SPEC F115.2 — hand-populating station.show.persona_id/envelope through raw SQL and proving the +// loaded ScheduleWeekSnapshot is unaffected) lives in GenWave.MediaLibrary.Tests' +// Story240_ScheduleStore.cs instead, where it can be real. + +using GenWave.Abstractions.Playout; +using GenWave.Core.Domain; +using GenWave.Orchestration.Tests.Fakes; + +namespace GenWave.Orchestration.Tests.Specs; + +public static class FeatureIdentityChokepoint +{ + static readonly ShowSummary NightMoves = new(1, "Night Moves", "Late-night deep cuts", "moody, sparse, past midnight"); + + public sealed class ScenarioSnapshotCarriesTheShow + { + // Given a block assigned show "Night Moves". + + [Fact] + public void ShowRidesTheSnapshotDuringItsBlock() + { + var now = new DateTimeOffset(2026, 3, 2, 10, 30, 0, TimeSpan.Zero); + var time = new FakeTimeProvider(now); + var day = now.DayOfWeek; + var block = new ScheduleSegment( + Id: 1, Day: day, StartMinute: 540, EndMinute: 720, + PersonaId: 7, Genres: null, EnergyMin: null, EnergyMax: null, Show: NightMoves); + var resolver = new ScheduleResolver(time, new FakeStationDefaultEnvelopeSource(SegmentEnvelope.StationDefault)); + + // When the resolver snapshot is read during that block + var result = resolver.Resolve(new ScheduleWeekSnapshot([block])); + + // Then show id/name/tagline/flavor ride OnAirSnapshot — both at the top-level convenience + // member (mirrors PersonaId's own "no null-check Segment first" rationale) and on the + // resolved Segment itself, since ScheduleResolver never rebuilds/clones the block. + Assert.Equal(NightMoves, result.Show); + Assert.Equal(NightMoves, result.Segment!.Show); + } + + [Fact] + public void UnnamedBlocksCarryNullEndToEnd() + { + var now = new DateTimeOffset(2026, 3, 2, 10, 30, 0, TimeSpan.Zero); + var time = new FakeTimeProvider(now); + var day = now.DayOfWeek; + + // Given a block with no show (the default — every pre-T241 construction site's own shape) + var unnamedBlock = new ScheduleSegment( + Id: 1, Day: day, StartMinute: 540, EndMinute: 720, + PersonaId: 7, Genres: null, EnergyMin: null, EnergyMax: null); + var resolver = new ScheduleResolver(time, new FakeStationDefaultEnvelopeSource(SegmentEnvelope.StationDefault)); + + // When the snapshot is read during that block + var staffedResult = resolver.Resolve(new ScheduleWeekSnapshot([unnamedBlock])); + + // Then the show member is null end to end: the snapshot's own member, the resolved + // Segment, and NextSegment (a second unnamed block immediately following) all agree. + var nextUnnamedBlock = new ScheduleSegment( + Id: 2, Day: day, StartMinute: 720, EndMinute: 900, + PersonaId: 9, Genres: null, EnergyMin: null, EnergyMax: null); + var withNext = resolver.Resolve(new ScheduleWeekSnapshot([unnamedBlock, nextUnnamedBlock])); + + Assert.Null(staffedResult.Show); + Assert.Null(staffedResult.Segment!.Show); + Assert.Null(withNext.NextSegment!.Show); + + // ...and a grid gap (no block on air at all) reports the same honest null. + var gapResult = resolver.Resolve(new ScheduleWeekSnapshot([])); + Assert.Null(gapResult.Show); + } + } + + public sealed class ScenarioDormantMeansDormant + { + [Fact] + public void HandPopulatedBundleColumnsChangeNothing() + { + // Given station.show rows with persona_id/envelope hand-populated (F115.2's pin). This + // project carries no live Postgres (see file header) — ShowSummary itself structurally + // enforces the pin (no PersonaId/Envelope member exists to even hand-populate at the C# + // level; the live-PG half proving a REAL station.show row survives this unread lives in + // GenWave.MediaLibrary.Tests/Specs/Story240_ScheduleStore.cs). What THIS fact pins is the + // resolver-side half of the same law: EffectiveAssignment.Resolve/ScheduleResolver never + // let a show's presence — named, tagline'd, flavor'd, whatever it carries — influence which + // persona resolves. Two otherwise-identical blocks, differing ONLY in PersonaId, carry the + // exact SAME show; the show identity is proven fully independent of persona resolution. + var now = new DateTimeOffset(2026, 3, 2, 10, 30, 0, TimeSpan.Zero); + var time = new FakeTimeProvider(now); + var day = now.DayOfWeek; + var staffedBlock = new ScheduleSegment( + Id: 1, Day: day, StartMinute: 540, EndMinute: 720, + PersonaId: 7, Genres: null, EnergyMin: null, EnergyMax: null, Show: NightMoves); + var musicOnlyBlock = staffedBlock with { PersonaId = null }; + var resolver = new ScheduleResolver(time, new FakeStationDefaultEnvelopeSource(SegmentEnvelope.StationDefault)); + + // When any v1 path resolves identity + var staffedResult = resolver.Resolve(new ScheduleWeekSnapshot([staffedBlock])); + var musicOnlyResult = resolver.Resolve(new ScheduleWeekSnapshot([musicOnlyBlock])); + + // Then behavior is UNCHANGED — block-level persona only, and the SAME show identity rides + // both snapshots regardless of which persona (if any) the block itself names. + Assert.Equal(7L, staffedResult.PersonaId); + Assert.Null(musicOnlyResult.PersonaId); + Assert.Equal(NightMoves, staffedResult.Show); + Assert.Equal(NightMoves, musicOnlyResult.Show); + } + } + + public sealed class ScenarioShowlessStationsAreUntouched + { + [Fact] + public void ShowlessSnapshotIsByteIdentical() + { + // Given a station with zero shows — a two-segment week built the exact same way + // Story241_StationFollowsTheClock.cs's own SnapshotCarriesSegmentPersonaEnvelopeBoundaryAndNext + // fact does, with no Show argument passed anywhere (the pre-F116 construction shape, + // verbatim). + var now = new DateTimeOffset(2026, 3, 2, 10, 30, 0, TimeSpan.Zero); + var time = new FakeTimeProvider(now); + var day = now.DayOfWeek; + + var onAir = new ScheduleSegment( + Id: 1, Day: day, StartMinute: 540, EndMinute: 720, + PersonaId: 7, Genres: ["Rock"], EnergyMin: 0.2, EnergyMax: 0.8); + var upNext = new ScheduleSegment( + Id: 2, Day: day, StartMinute: 720, EndMinute: 900, + PersonaId: 9, Genres: null, EnergyMin: null, EnergyMax: null); + var snapshot = new ScheduleWeekSnapshot([onAir, upNext]); + var stationDefault = new FakeStationDefaultEnvelopeSource( + new SegmentEnvelope(TimeOnly.MinValue, TimeOnly.MaxValue, ["Jazz"], new EnergyRange(0.0, 1.0))); + var resolver = new ScheduleResolver(time, stationDefault); + + // When the resolver produces snapshots across a full week (the boundary crossing below + // stands in for "across the week" — the SAME assertions Story241's own pre-F116 fact + // makes, at the SAME instant, the honest way to compare "byte-identical to pre-F116 + // behavior" without duplicating that file's own full DST/boundary coverage here) + var result = resolver.Resolve(snapshot); + + // Then output matches pre-F116 behavior exactly — every field Story241's own fact already + // pins, unchanged (compared field-by-field for Envelope.Genres, same reference-equality + // caveat that file's own fact documents)... + Assert.Equal(onAir, result.Segment); + Assert.Equal(7L, result.PersonaId); + Assert.Equal(new TimeOnly(9, 0), result.Envelope.StartsAt); + Assert.Equal(new TimeOnly(12, 0), result.Envelope.EndsAt); + Assert.Equal(["Rock"], result.Envelope.Genres); + Assert.Equal(new EnergyRange(0.2, 0.8), result.Envelope.EnergyRange); + Assert.Equal(new DateTimeOffset(2026, 3, 2, 12, 0, 0, TimeSpan.Zero), result.BoundaryAt); + Assert.Equal(upNext, result.NextSegment); + + // ...plus the ONE additive member this epic introduces, honestly null (the epic's null + // hypothesis: a showless station gains a field that is always empty, nothing else). + Assert.Null(result.Show); + } + } +} diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story307_CeremonyNamesTheShow.cs b/tests/GenWave.Orchestration.Tests/Specs/Story307_CeremonyNamesTheShow.cs new file mode 100644 index 00000000..76f6f0d5 --- /dev/null +++ b/tests/GenWave.Orchestration.Tests/Specs/Story307_CeremonyNamesTheShow.cs @@ -0,0 +1,46 @@ +// STORY-307 — Ceremony names the show (F116.2) — boundary/dedupe half +// +// BDD specification — xUnit, PENDING scaffold (planned 2026-08-10). Comment-bodied on +// purpose: show-aware ceremony lands at T248. The prompt-content half lives in +// GenWave.Tts.Tests/Specs/Story307_ShowCeremonyCopy.cs (the Story243/Story303 split). + +namespace GenWave.Orchestration.Tests.Specs; + +using Xunit; + +public static class FeatureCeremonyNamesTheShow +{ + public sealed class ScenarioSameDjShowFlip + { + [Fact(Skip = "Pending (T248)")] + public void ExactlyOneTransitionPieceAirs() + { + // Given adjacent blocks: same persona, different shows + // When the boundary drains + // Then exactly ONE ceremony piece airs — the transition-styled sign-on + // (the F92.4 incoming-welcome rung as designed behavior, not degrade) + } + } + + public sealed class ScenarioDjBoundariesUnchanged + { + [Fact(Skip = "Pending (T248)")] + public void DifferentPersonaBoundariesKeepTheTwoPieceCeremony() + { + // Given adjacent blocks with different personas (shows named or not) + // When the boundary drains + // Then the F92 two-piece ceremony behaves exactly as shipped + } + } + + public sealed class ScenarioAmendedDedupe + { + [Fact(Skip = "Pending (T248)")] + public void SamePersonaSameShowStaysSilent() + { + // Given adjacent blocks with the same persona AND the same show + // When the boundary passes + // Then no ceremony airs — F92.3 dedupes on persona AND show (F114.3 as ruled) + } + } +} diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story309_ShowIdentDrain.cs b/tests/GenWave.Orchestration.Tests/Specs/Story309_ShowIdentDrain.cs new file mode 100644 index 00000000..a06de525 --- /dev/null +++ b/tests/GenWave.Orchestration.Tests/Specs/Story309_ShowIdentDrain.cs @@ -0,0 +1,63 @@ +// STORY-309 — Show-branded idents (F117) — drain-preference half +// +// BDD specification — xUnit, PENDING scaffold (planned 2026-08-10). Comment-bodied on +// purpose: the StationId drain's show preference lands at T250. Ladder under spec: +// scoped authored row → templated show line → F110.2 exactly. ⚠️ Drain-region caution +// recorded in PLAN: serialize behind T248 if both touch the drain switch. + +namespace GenWave.Orchestration.Tests.Specs; + +using Xunit; + +public static class FeatureShowIdentDrain +{ + public sealed class ScenarioScopedPoolFirst + { + [Fact(Skip = "Pending (T250)")] + public void ScopedAuthoredRowAirsDuringItsShow() + { + // Given a ready authored station_id row scoped to the current show + // When the StationId drain fires during that show + // Then the scoped row airs (authored voice preserved — it is rendered audio) + } + } + + public sealed class ScenarioTemplatedFloor + { + [Fact(Skip = "Pending (T250)")] + public void TemplatedShowLineAirsWhenNoScopedRows() + { + // Given a show with zero scoped authored rows + // When the drain fires + // Then "You're listening to {show} on {station}." renders — station-voiced, + // zero LLM (the gate-countable floor, F117.2) + } + + [Fact(Skip = "Pending (T250)")] + public void SecondAiringIsACacheHit() + { + // Given the templated show line rendered once + // When the drain fires again for the same show name + // Then the render is a forever-cache hit (keyed on the rendered text) + } + + [Fact(Skip = "Pending (T250)")] + public void RenameRekeysTheCache() + { + // Given the show is renamed + // When the next drain fires + // Then a fresh render occurs by construction — the key IS the text + } + } + + public sealed class ScenarioOutsideShowsUntouched + { + [Fact(Skip = "Pending (T250)")] + public void NoShowMeansF110Exactly() + { + // Given no show on the air + // When the StationId drain fires + // Then behavior is byte-identical to F110.2 (station pool → template) + } + } +} diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story317_SpecialsRung.cs b/tests/GenWave.Orchestration.Tests/Specs/Story317_SpecialsRung.cs new file mode 100644 index 00000000..b2f005e1 --- /dev/null +++ b/tests/GenWave.Orchestration.Tests/Specs/Story317_SpecialsRung.cs @@ -0,0 +1,43 @@ +// STORY-317 — Dated specials shadow the grid (F120) — resolver-rung half · 🪂 DROPPABLE SLICE +// +// BDD specification — xUnit, PENDING scaffold (planned 2026-08-10). Comment-bodied on +// purpose: the specials-first rung lands at T258. Downstream consumers (ceremony, idents, +// stamps, spectator) are unchanged by construction — they read the resolver, which is +// exactly why the rung is the ONLY orchestration change this slice makes. + +namespace GenWave.Orchestration.Tests.Specs; + +using Xunit; + +public static class FeatureSpecialsRung +{ + public sealed class ScenarioTheShadow + { + [Fact(Skip = "Pending (T258)")] + public void TheResolverServesTheSpecialForItsSpan() + { + // Given a special covering 19:00–21:00 today over a differently-staffed weekly block + // When the resolver snapshot is read inside the span + // Then persona/show/envelope come from the special (specials-first rung, F120.2) + } + + [Fact(Skip = "Pending (T258)")] + public void DownstreamConsumersFollowWithZeroSpecialCasing() + { + // Given the special on the air + // When ceremony context, ident preference, and the booth stamp read identity + // Then all read the same snapshot — no consumer knows specials exist + } + } + + public sealed class ScenarioTheDayAfter + { + [Fact(Skip = "Pending (T258)")] + public void TheWeeklyGridServesExactlyAsBefore() + { + // Given the special's date has passed + // When the same wall-clock span arrives next day/week + // Then the weekly grid resolves byte-identically to pre-special behavior + } + } +} diff --git a/tests/GenWave.Tts.Tests/Specs/Story307_ShowCeremonyCopy.cs b/tests/GenWave.Tts.Tests/Specs/Story307_ShowCeremonyCopy.cs new file mode 100644 index 00000000..83667321 --- /dev/null +++ b/tests/GenWave.Tts.Tests/Specs/Story307_ShowCeremonyCopy.cs @@ -0,0 +1,43 @@ +// STORY-307 — Ceremony names the show (F116.2) — prompt-content half +// +// BDD specification — xUnit, PENDING scaffold (planned 2026-08-10). Comment-bodied on +// purpose: the show fields in ceremony prompts land at T248. Golden-string idiom follows +// Story243/Story303. The boundary/dedupe half lives in Orchestration.Tests. + +namespace GenWave.Tts.Tests.Specs; + +using Xunit; + +public static class FeatureShowCeremonyCopy +{ + public sealed class ScenarioSignOnCarriesTheShow + { + [Fact(Skip = "Pending (T248)")] + public void SignOnPromptCarriesIncomingShowNameAndFlavor() + { + // Given a boundary into a block with show "The Breakfast Show" (tagline + flavor set) + // When the sign-on prompt is built + // Then it carries the incoming show's name and flavor (flavor reaches the + // prompt ONLY — never any public payload; F115.3) + } + + [Fact(Skip = "Pending (T248)")] + public void SignOffMayNameTheEndingAndNextShows() + { + // Given a boundary between two named shows + // When the sign-off prompt is built + // Then both show names are available to the copywriter (F114.3's "may name") + } + } + + public sealed class ScenarioShowlessCeremonyUntouched + { + [Fact(Skip = "Pending (T248)")] + public void ShowlessCeremonyPromptIsByteIdentical() + { + // Given a boundary between blocks with no shows + // When sign-on/sign-off prompts are built + // Then output matches the pre-F116 golden byte-for-byte + } + } +} diff --git a/tests/GenWave.Tts.Tests/Specs/Story308_FlavorLineSharedSlot.cs b/tests/GenWave.Tts.Tests/Specs/Story308_FlavorLineSharedSlot.cs new file mode 100644 index 00000000..f2807b20 --- /dev/null +++ b/tests/GenWave.Tts.Tests/Specs/Story308_FlavorLineSharedSlot.cs @@ -0,0 +1,54 @@ +// STORY-308 — The flavor line shares the slot (F116.3, amends F107.5) +// +// BDD specification — xUnit, PENDING scaffold (planned 2026-08-10). Comment-bodied on +// purpose: the shared-slot arbitration lands at T249 (serialized behind T248 — both +// touch prompt assembly; the T224→T225 precedent). The one law this file exists to hold: +// a break's prompt carries AT MOST ONE extra line, and the ceiling never grows past F107. + +namespace GenWave.Tts.Tests.Specs; + +using Xunit; + +public static class FeatureFlavorLineSharedSlot +{ + public sealed class ScenarioTheShowLineAirs + { + [Fact(Skip = "Pending (T249)")] + public void ShowLineAppearsWhenDueAndNoContextFact() + { + // Given Station:Shows:PatterCadenceMinutes elapsed, a show on the air, no due fact + // When a lead-in prompt is built + // Then exactly one show-flavor line is present + } + } + + public sealed class ScenarioContextWinsTheSlot + { + [Fact(Skip = "Pending (T249)")] + public void ContextLineAppearsAndShowLineDoesNot() + { + // Given a due context fact AND a due show line + // When the prompt is built + // Then the context line appears and the show line does not (facts beat identity) + } + + [Fact(Skip = "Pending (T249)")] + public void ShowGateStaysOpenAfterLosingTheSlot() + { + // Given the show line lost the slot to a context fact + // When the next eligible break's prompt is built (no fact due) + // Then the show line appears — losing the slot never consumed the cadence + } + } + + public sealed class ScenarioClosedGateIsByteIdentical + { + [Fact(Skip = "Pending (T249)")] + public void ClosedGateMatchesTheF107Golden() + { + // Given cadence not elapsed, or the setting at its 0 default, or no show on air + // When the prompt is built + // Then output matches the F107 golden byte-for-byte (the Story298 pin extended) + } + } +} diff --git a/tools/demo_hour_gate.sql b/tools/demo_hour_gate.sql index 80e1dc4c..3b2329a9 100644 --- a/tools/demo_hour_gate.sql +++ b/tools/demo_hour_gate.sql @@ -55,3 +55,134 @@ having and bool_or(segment_kind = 'ContextSegment') and bool_or(segment_kind is not null and segment_kind not in ('StationId', 'ContextSegment')) order by broadcast_hour; + +-- ============================================================================ +-- F121.2 extension (SPEC F121, STORY-310, PLAN T242) — show observability. +-- +-- The query above is BYTE-IDENTICAL to the F113.2 shape (F121.2c: zero regression to the existing +-- counts) — both halves below are ADDITIONAL statements in the same batch, reading +-- `station.booth_log.show_id` (F121.1's air-time stamp) ALONE, never `station.segment_schedule`: a +-- grid repaint after the window closes can never change what already aired. +-- +-- MEASURED (EXPLAIN ANALYZE, 60k track-started rows, ~120 shows) — the two halves do NOT share one +-- access pattern: +-- * Half (a): the planner DOES walk `booth_log_paging` — an Index Scan Backward feeds the +-- `track_rows`/`show_transitions` window functions (they need `occurred_at`-ordered input, and +-- the index already provides it), and the correlated `exists` subquery's own +-- `occurred_at <= transition_at` bound is itself an index condition (Bitmap Index/Heap Scan on the +-- same index) run once per transition. Measured ~590ms at 60k rows / ~120 transitions — dominated +-- by that once-per-transition subquery cost (bounded by transition COUNT, not row count) and JIT +-- compilation, not by an unindexed scan. +-- * Half (b): `hourly_show_occupancy` groups by `date_trunc('hour', occurred_at)`, an expression the +-- index cannot serve — the planner does a Seq Scan on `station.booth_log` feeding an explicit +-- Sort. Measured ~34ms at 60k rows. +-- Both bounded by SPEC F72.3's 14-day retention (this table never grows past a hobby-station's few +-- weeks of rows) — neither number, nor a new index, is a concern for a manual close-out query at that +-- scale. +-- +-- Narrowing to a specific window: half (a)'s `where` predicate MUST go on the FINAL select (filtering +-- `transition_at`), never inside `track_rows`/`show_transitions` — those two CTEs must always see the +-- table's FULL history so `prev_show_id`/`prev_transition_at` stay correct at the window's own edges; +-- narrowing the source rows would make the first transition inside the window look like it has no +-- predecessor (exactly the unbounded-below shape this rewrite exists to close). Half (b) has no such +-- constraint — its CTE groups each hour independently with no cross-row window function — so its +-- predicate goes inside `hourly_show_occupancy`'s `where`, the same place `hourly_kind_counts` above +-- takes one. +-- +-- Run the same way as above (piped over stdin); psql prints one result table per statement in the +-- batch, in order — this file now prints three: the F113.2 hourly mix, half (a), then half (b). +-- ============================================================================ + +-- Half (a): every SHOW TRANSITION in the window has a show-stamped `SignOn` row. +-- +-- A transition is the first `track-started` row of a run of consecutive same-show rows — the moment +-- a new show's own airings actually start (`show_id` distinct from the immediately preceding +-- track-started row's `show_id`, and this row itself names a show). That show is COVERED when a +-- `SignOn` row (kind='track-started', segment_kind='SignOn') stamped with the SAME `show_id` aired +-- STRICTLY AFTER the immediately PRECEDING transition (any show) and at or before THIS transition's +-- own instant — ceremony fires before the incoming show's first track, never after (SPEC F116.2), so +-- in the ordinary case the SignOn row IS this transition's own row (occurred_at equal). +-- +-- The lower bound is load-bearing, not cosmetic: an `exists` with only the upper bound +-- (`sign_on.occurred_at <= transition_at`) is satisfied by ANY historical SignOn ever stamped for that +-- show_id, no matter how long ago — a RECURRING show's second (and every later) airing then +-- false-passes against a SignOn from an earlier, unrelated day, since that show never re-signs-on and +-- its own genuinely-missing-today SignOn is masked by the stale match. Proven on a seeded probe (show +-- airs day 1 with a SignOn, then airs again day 2 with none): the old query rendered day 2 `true`; this +-- one renders `f`. `show_transitions_windowed.prev_transition_at` — a `lag()` over the TRANSITIONS +-- themselves, not over raw booth_log rows — carries that lower edge, bounding the match to the +-- transition's own run. It is NULL only for the very first transition this database has ever recorded, +-- where there is by definition no earlier data to false-match against, so the bound is correctly open +-- there alone. `has_show_stamped_sign_on = false` on any row here is the gate failing this half. +with track_rows as ( + select + occurred_at, + segment_kind, + show_id, + lag(show_id) over (order by occurred_at, id) as prev_show_id + from station.booth_log + where kind = 'track-started' +), +show_transitions as ( + select occurred_at, show_id + from track_rows + where show_id is not null + and show_id is distinct from prev_show_id +), +show_transitions_windowed as ( + select + occurred_at as transition_at, + show_id, + lag(occurred_at) over (order by occurred_at) as prev_transition_at + from show_transitions +) +select + transition_at, + show_id, + exists ( + select 1 + from station.booth_log sign_on + where sign_on.kind = 'track-started' + and sign_on.segment_kind = 'SignOn' + and sign_on.show_id = show_transitions_windowed.show_id + and sign_on.occurred_at <= show_transitions_windowed.transition_at + and ( + show_transitions_windowed.prev_transition_at is null + or sign_on.occurred_at > show_transitions_windowed.prev_transition_at + ) + ) as has_show_stamped_sign_on +from show_transitions_windowed +order by transition_at; + +-- Half (b): every FULL HOUR inside a show has >= 1 show-stamped `StationId` row. +-- +-- "Full hour inside a show" is read from booth_log's own air-time stamp alone (never +-- segment_schedule): an hour whose `track-started` rows are ALL stamped with the SAME `show_id` — no +-- showless row, no show change — within that hour. An hour with no track-started rows at all is +-- structurally absent from this result (nothing to group), the same "silence isn't this gate's +-- concern" posture the F113.2 header above already states. +-- +-- `bool_or` returns NULL, not `false`, when every input row's own predicate evaluates to NULL rather +-- than false — exactly what an all-music show hour does, since `segment_kind = 'StationId'` is itself +-- NULL (SQL three-valued logic, not false) on every row where `segment_kind` is NULL. That is the EXACT +-- failure case this half exists to catch, so a bare `bool_or` renders it as a blank cell instead of the +-- `f` a human running this manually needs to see — indistinguishable from "not applicable" rather than +-- "gate failed here". `coalesce(..., false)` closes that gap. `has_show_stamped_station_id = false` on +-- any row here is the gate failing this half. +with hourly_show_occupancy as ( + select + date_trunc('hour', occurred_at) as broadcast_hour, + count(*) filter (where show_id is null) as showless_row_count, + count(distinct show_id) filter (where show_id is not null) as distinct_show_count, + min(show_id) as show_id, + coalesce(bool_or(segment_kind = 'StationId' and show_id is not null), false) + as has_show_stamped_station_id + from station.booth_log + where kind = 'track-started' + group by date_trunc('hour', occurred_at) +) +select broadcast_hour, show_id, has_show_stamped_station_id +from hourly_show_occupancy +where showless_row_count = 0 + and distinct_show_count = 1 +order by broadcast_hour;