Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b41340abe |
@@ -4,13 +4,6 @@
|
||||
# body-diff, CI). This shim runs the structural validator over the working tree; the body-diff/no-
|
||||
# vanish checks run in CI where a base/head is available. Fail-open on any tooling trouble.
|
||||
set -uo pipefail
|
||||
|
||||
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
|
||||
# git hook: decides by exit code, and its stdout is live progress text.
|
||||
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
|
||||
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
|
||||
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
|
||||
etv_hook_fire_begin decisions-guard "" stream || true
|
||||
cd "$(git rev-parse --show-toplevel)" || exit 0
|
||||
command -v python3 >/dev/null 2>&1 || exit 0 # no python -> fail-open
|
||||
PYTHONPATH=. python3 scripts/decisions_validate.py
|
||||
|
||||
@@ -17,13 +17,6 @@
|
||||
# This is a reminder, never a hard gate — `start` only injects context; `finish` is a one-shot Stop nudge.
|
||||
set -euo pipefail
|
||||
|
||||
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
|
||||
# Claude hook: decides by printed JSON, so stdout is captured.
|
||||
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
|
||||
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
|
||||
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
|
||||
etv_hook_fire_begin design-sync-reminder "${1:-}" capture || true
|
||||
|
||||
UI_RE='(^|/)web/src/.*\.(tsx|css)$'
|
||||
TEST_RE='\.test\.(tsx|ts)$'
|
||||
|
||||
|
||||
@@ -4,13 +4,6 @@
|
||||
# a sibling worktree another session created apart from this session's own.
|
||||
# Fail-safe: any parse trouble → do nothing (the guard stays fail-open without a marker).
|
||||
set -euo pipefail
|
||||
|
||||
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
|
||||
# Claude hook: decides by printed JSON, so stdout is captured.
|
||||
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
|
||||
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
|
||||
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
|
||||
etv_hook_fire_begin posttooluse-worktree-marker "" capture || true
|
||||
input=$(cat)
|
||||
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null || true)
|
||||
cwd=$(printf '%s' "$input" | jq -r '.cwd // ""' 2>/dev/null || true)
|
||||
|
||||
@@ -15,13 +15,6 @@
|
||||
# no origin/main, HEAD unresolved -> allow. Deliberate escape: ETV_ALLOW_DIRTY_PUSH=1.
|
||||
set -uo pipefail
|
||||
|
||||
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
|
||||
# git hook: decides by exit code, and its stdout is live progress text.
|
||||
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
|
||||
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
|
||||
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
|
||||
etv_hook_fire_begin prepush-clean-worktree-check "" stream || true
|
||||
|
||||
[ "${ETV_ALLOW_DIRTY_PUSH:-}" = "1" ] && exit 0
|
||||
git rev-parse --git-dir >/dev/null 2>&1 || exit 0
|
||||
|
||||
|
||||
@@ -12,13 +12,6 @@
|
||||
# Auth (never committed): ETV_GITEA_TOKEN or ETV_GITEA_BASICAUTH; ETV_GITEA_URL overrides the base.
|
||||
set -euo pipefail
|
||||
|
||||
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
|
||||
# git hook: decides by exit code, and its stdout is live progress text.
|
||||
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
|
||||
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
|
||||
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
|
||||
etv_hook_fire_begin prepush-donewhen "" stream || true
|
||||
|
||||
# git passes "<localref> <localsha> <remoteref> <remotesha>" lines on stdin.
|
||||
refs=$(cat || true)
|
||||
printf '%s\n' "$refs" | grep -q 'refs/heads/main' || exit 0 # only gate pushes to main
|
||||
|
||||
@@ -9,48 +9,9 @@
|
||||
# a positively-proven "behind origin/main". Deliberate exception: ETV_SKIP_REBASE_CHECK=1.
|
||||
set -uo pipefail
|
||||
|
||||
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
|
||||
# git hook: decides by exit code, and its stdout is live progress text.
|
||||
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
|
||||
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
|
||||
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
|
||||
etv_hook_fire_begin prepush-rebase-check "" stream || true
|
||||
|
||||
[ "${ETV_SKIP_REBASE_CHECK:-}" = "1" ] && exit 0
|
||||
git rev-parse --git-dir >/dev/null 2>&1 || exit 0
|
||||
|
||||
# Tag-only push exemption (ersatztv#719): the release cut tags a commit on main while the local
|
||||
# branch sits 1 commit behind origin/main, so H11 blocked EVERY release -- and its "rebase first"
|
||||
# advice did not even apply, since no branch was being pushed. A tag push cannot revert anyone's
|
||||
# merged work, which is the failure mode H11 exists to prevent, so skip the freshness check when
|
||||
# EVERY ref being pushed is under refs/tags/. (See #719 for the observed flow.)
|
||||
#
|
||||
# Read pushed refs from stdin: git feeds pre-push hooks one line per ref, "<local ref> <local sha>
|
||||
# <remote ref> <remote sha>" (.husky/pre-push forwards the lines it already captured). Ignore blank
|
||||
# lines. VACUOUS-TRUTH GUARD: "all refs are tags" is trivially true when there are zero ref lines
|
||||
# (hook run manually, stdin not forwarded, etc.) -- that would silently disable H11 for every push.
|
||||
# Require at least one parsed ref line before granting the exemption; with zero lines, fall through
|
||||
# to the existing branch-freshness check below (current behavior preserved).
|
||||
#
|
||||
# `[ -t 0 ] ||` so an interactive run does not hang waiting on a terminal: this script had no stdin
|
||||
# reader before #719, and its own docs call "run by hand" a supported case. A TTY yields no ref
|
||||
# lines, which is exactly the zero-line fall-through.
|
||||
_h11_refs_seen=0
|
||||
_h11_all_tags=1
|
||||
[ -t 0 ] || while IFS=' ' read -r _h11_local_ref _h11_local_sha _h11_remote_ref _h11_remote_sha \
|
||||
|| [ -n "${_h11_local_ref:-}" ]; do # `|| [ -n ... ]` also processes a final line with no trailing newline
|
||||
[ -z "${_h11_local_ref:-}" ] && continue
|
||||
_h11_refs_seen=1
|
||||
case "${_h11_remote_ref:-}" in
|
||||
refs/tags/*) ;;
|
||||
*) _h11_all_tags=0 ;;
|
||||
esac
|
||||
_h11_local_ref=''
|
||||
done
|
||||
if [ "$_h11_refs_seen" = "1" ] && [ "$_h11_all_tags" = "1" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Best-effort fetch of the latest main; offline / no network -> don't block.
|
||||
git fetch origin main --quiet 2>/dev/null || exit 0
|
||||
git rev-parse --verify --quiet origin/main >/dev/null 2>&1 || exit 0
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
# no forcing function was the one that got defaulted. A check that runs beats a rule you must remember
|
||||
# (the same reasoning as pretooluse-bom-guard.sh).
|
||||
#
|
||||
# SCOPE — gate EVERY dispatch that names no model, not just implementer-looking ones. A NARROWER
|
||||
# cut was TRIED AND REJECTED: it fired only when the prompt text matched implementer signals (`git
|
||||
# commit`, `worktree`, `fixes #`…). Measured (#583), the heuristic both over- and
|
||||
# SCOPE — gate EVERY dispatch that names no model, not just implementer-looking ones. The first cut
|
||||
# tried to be clever: it fired only when the prompt text matched implementer signals (`git commit`,
|
||||
# `worktree`, `fixes #`…). Review of that version (#583) confirmed the heuristic both over- and
|
||||
# under-fired — a read-only recon brief mentioning "worktree" nagged, while "author the change and
|
||||
# open a PR", "land this on the branch" and "make the changes and commit them" all sailed through
|
||||
# silently, i.e. it missed the exact case it existed to catch. Prompt prose is not a reliable signal
|
||||
@@ -40,13 +40,6 @@
|
||||
# Fail-open by design: any parse trouble -> allow (exit 0, no output).
|
||||
set -uo pipefail
|
||||
|
||||
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
|
||||
# Claude hook: decides by printed JSON, so stdout is captured.
|
||||
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
|
||||
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
|
||||
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
|
||||
etv_hook_fire_begin pretooluse-agent-model "" capture || true
|
||||
|
||||
input=$(cat)
|
||||
|
||||
tool=$(printf '%s' "$input" | jq -r '.tool_name // ""' 2>/dev/null || true)
|
||||
|
||||
@@ -3,13 +3,6 @@
|
||||
# The historic 8-9-way crash was RAM starvation, not CPU load; gate on FREE RAM.
|
||||
# Fail-open: if memory_pressure is unavailable/unparsable → allow.
|
||||
set -euo pipefail
|
||||
|
||||
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
|
||||
# Claude hook: decides by printed JSON, so stdout is captured.
|
||||
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
|
||||
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
|
||||
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
|
||||
etv_hook_fire_begin pretooluse-agent-ram "" capture || true
|
||||
free=$(memory_pressure -Q 2>/dev/null | grep -oE 'free percentage: [0-9]+' | grep -oE '[0-9]+' || true)
|
||||
[ -z "${free:-}" ] && exit 0
|
||||
|
||||
|
||||
@@ -2,13 +2,6 @@
|
||||
# PreToolUse / Bash — deny commands that violate a HARD RULE.
|
||||
# Fail-open: any parse trouble → allow (exit 0 with no output).
|
||||
set -euo pipefail
|
||||
|
||||
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
|
||||
# Claude hook: decides by printed JSON, so stdout is captured.
|
||||
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
|
||||
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
|
||||
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
|
||||
etv_hook_fire_begin pretooluse-bash-guard "" capture || true
|
||||
input=$(cat)
|
||||
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null || true)
|
||||
|
||||
|
||||
@@ -18,13 +18,6 @@
|
||||
# the reason a commit can't happen; CI is still the backstop.
|
||||
set -uo pipefail
|
||||
|
||||
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
|
||||
# Claude hook: decides by printed JSON, so stdout is captured.
|
||||
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
|
||||
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
|
||||
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
|
||||
etv_hook_fire_begin pretooluse-bom-guard "" capture || true
|
||||
|
||||
input=$(cat)
|
||||
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null || true)
|
||||
[ -n "$cmd" ] || exit 0
|
||||
@@ -73,11 +66,7 @@ while IFS= read -r f; do
|
||||
esac
|
||||
p="$root/$f"
|
||||
[ -f "$p" ] || continue
|
||||
# `od`, NOT `xxd`. `xxd` ships with vim and is absent on plain Linux hosts including this repo's
|
||||
# CI runner, where the command substitution yielded empty, never equalled `efbbbf`, and this guard
|
||||
# therefore passed every BOM in silence. It has been fail-open on any host without vim since it
|
||||
# was written. `od -A n -t x1 -N 3` is POSIX and produces byte-identical output on macOS and Linux.
|
||||
if [ "$(od -A n -t x1 -N 3 < "$p" 2>/dev/null | tr -d ' \n')" = "efbbbf" ]; then
|
||||
if [ "$(head -c3 "$p" 2>/dev/null | xxd -p 2>/dev/null)" = "efbbbf" ]; then
|
||||
bad="${bad} ${f}"$'\n'
|
||||
fi
|
||||
done < /tmp/.bom-guard-files.$$
|
||||
|
||||
@@ -8,12 +8,8 @@
|
||||
# LATEST commit was reviewed, not a stale earlier diff (the ersatztv#242 failure mode:
|
||||
# "re-review the fix commit, not just the initial PR diff").
|
||||
#
|
||||
# EVERY ONE OF THOSE IS A SNAPSHOT, taken when the merge tool is called. The window is SMALL for an
|
||||
# immediate merge and UNBOUNDED for a scheduled one. Small is not zero, and calling this gate
|
||||
# "sound" is the overclaim ersatztv#778 removed: this hook returns `allow` and a SEPARATE call
|
||||
# performs the merge, so a push can still land in between. The merge API accepts an optional
|
||||
# `head_commit_id` that would make that call a true compare-and-set; a PreToolUse hook cannot add an
|
||||
# argument, only refuse without one. With merge_when_checks_succeed, Gitea merges
|
||||
# EVERY ONE OF THOSE IS A SNAPSHOT, taken when the merge tool is called. That is sound for an
|
||||
# immediate merge and UNSOUND for a scheduled one: with merge_when_checks_succeed, Gitea merges
|
||||
# later, against whatever head is green then (ersatztv#622). So the sha-bound half of H10 is
|
||||
# enforced by the SERVER, not here — `review-verdict/h10` is a required status check on `main`,
|
||||
# written per-sha by scripts/post-review-verdict.sh, and a new commit cannot inherit it. This hook
|
||||
@@ -22,7 +18,7 @@
|
||||
# The "## Done-when" issue-body checklist is the convention (docs/decisions.md, CLAUDE.md Task
|
||||
# Completion Protocol). One box is "adversarial review passed"; the others are per-issue.
|
||||
# The H10 review-verdict convention: after reviewing a PR (or its latest fix commit), post a PR
|
||||
# comment carrying a line `Review-verdict: <MERGEABLE|APPROVED|LGTM|BLOCKED|NOT-MERGEABLE> @ <head-sha>`.
|
||||
# comment carrying a line `Review-verdict: <MERGEABLE|APPROVED|BLOCKED|NOT-MERGEABLE> @ <head-sha>`.
|
||||
#
|
||||
# Decision policy — a CONSENT gate, so it does NOT fail silently open:
|
||||
# - state derivable and satisfied -> grant (auto-approve: permissionDecision "allow",
|
||||
@@ -44,25 +40,6 @@
|
||||
# Gitea auth from env (never committed): ETV_GITEA_TOKEN (a token) OR ETV_GITEA_BASICAUTH (user:pass).
|
||||
# ETV_GITEA_URL overrides the base (default: the LAN instance; a LAN address, not a secret).
|
||||
set -euo pipefail
|
||||
|
||||
# THE FIRE-LOG PATH BELOW IS SELF-LOCATED, not `${CLAUDE_PROJECT_DIR:-...}` — as is every other
|
||||
# tracked hook's since ersatztv#891, byte-identically (`process.hook-resolves-inputs-from-repo-root`).
|
||||
# Written here rather than beside the assignment because the instrumentation preamble that follows is
|
||||
# machine-compared: `test_hook_fire_log.py::test_the_stripper_removes_EXACTLY_the_preamble_and_nothing_else`
|
||||
# permits only its own recognised lines in that block, so a comment inside it fails the suite.
|
||||
#
|
||||
# That line is `. `-SOURCED, so whatever it names runs AS CODE inside this hook, before stdin is read
|
||||
# and before `decide` exists. It is therefore not "telemetry" in any sense a gate can rely on.
|
||||
# MEASURED 2026-08-30: with the env-var-first form, a `hook-fire-log.sh` in an env-var-named tree
|
||||
# that prints an `allow` decision and exits 0 GRANTS THE MERGE outright, having bypassed every check
|
||||
# below. Self-locating binds it to the tree this hook was loaded from and closes that.
|
||||
|
||||
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
|
||||
# Claude hook: decides by printed JSON, so stdout is captured.
|
||||
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
|
||||
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
|
||||
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
|
||||
etv_hook_fire_begin pretooluse-merge-consent "" capture || true
|
||||
input=$(cat)
|
||||
|
||||
decide() { # $1=grant|allow|deny|ask $2=reason
|
||||
@@ -108,9 +85,8 @@ sha=$(printf '%s' "$prjson" | jq -r '.head.sha // ""' 2>/dev/null || true)
|
||||
body=$(printf '%s' "$prjson" | jq -r '.body // ""' 2>/dev/null || true)
|
||||
|
||||
# --- Docs-only exemption: if every changed file is docs/process, skip the gate. ---
|
||||
# The file list must be enumerated EXHAUSTIVELY, validated row by row, and checked for head/base
|
||||
# movement across the paging round trips, or the exemption is unsafe. (That check detects ONE-WAY
|
||||
# movement only — this said "bound to ONE head" until 2026-08-28, ersatztv#803.) ALL of that now lives in scripts/pr-changed-files.sh — the single shared
|
||||
# The file list must be enumerated EXHAUSTIVELY, validated row by row, and bound to ONE head, or the
|
||||
# exemption is unsafe. ALL of that now lives in scripts/pr-changed-files.sh — the single shared
|
||||
# implementation, also called by .gitea/workflows/review-verdict.yml (ersatztv#649).
|
||||
#
|
||||
# Why it moved: this logic was written twice. This copy is ADVISORY (a failure produces a human
|
||||
@@ -186,87 +162,16 @@ fi
|
||||
# posted before ersatztv#632 and gets NO opinion, rather than denying every in-flight PR the day
|
||||
# this lands. The window closes on its own — verdicts are per-head and short-lived, so every verdict
|
||||
# posted after this carries the field.
|
||||
# "Could not check" is a THIRD outcome, distinct from both "matches" and "no base recorded".
|
||||
# Collapsing it into the latter is a false-open: an unreadable status response yields
|
||||
# an empty `recorded_base`, which takes the graceful-adoption path and skips validation silently —
|
||||
# "Could not check" is a THIRD outcome, distinct from both "matches" and "no base recorded". Cold
|
||||
# review found the first draft collapsing it into the latter: an unreadable status response yielded
|
||||
# an empty `recorded_base`, which took the graceful-adoption path and skipped validation silently —
|
||||
# after which a later, successful status read could still auto-grant. A transient failure would then
|
||||
# produce a "merge gate: satisfied" message for a comparison that never happened. Every
|
||||
# have produced a "merge gate: satisfied" message for a comparison that never happened. Every
|
||||
# unreadable input here therefore falls through to a human (`ask`), never to silence.
|
||||
# RE-READ THE BASE HERE, ONCE, FOR EVERY PATH BELOW (ersatztv#778).
|
||||
#
|
||||
# "Below" is literal, and the one consumer ABOVE is disclosed rather than implied: the docs-only
|
||||
# enumeration still runs against the snapshot `$base_ref` and can `decide allow` before reaching
|
||||
# this point. That is bounded and deliberate — a docs-only match is a PASSTHROUGH to the ordinary
|
||||
# human prompt, never an auto-grant, so a stale base there costs a prompt someone was going to see
|
||||
# anyway. Every path that can GRANT passes through the check below.
|
||||
#
|
||||
# `$base_ref` above comes from the PR snapshot taken at the top of this hook, and the docs-only
|
||||
# enumeration between there and here is up to forty round trips. A PERSISTENT retarget in that gap
|
||||
# needs no ABA and no force-push: every base-dependent decision below would be formed against a
|
||||
# branch the PR no longer targets. Checking a stale identifier is not checking — which is the whole
|
||||
# of `process.check-and-use-pins-a-version`, so the guard enforcing that rule must not break it.
|
||||
#
|
||||
# This re-read first landed inside the scheduled-auto-merge branch only, which fixed the branch-
|
||||
# protection lookup and left the #632 retarget DETECTION below still reading the stale snapshot.
|
||||
# Measured on this repo's own fixture: scheduled+retarget denied, while
|
||||
# immediate+retarget auto-GRANTED. That is the twin-missed shape — a fix applied to the path where it
|
||||
# was noticed — so the re-read is hoisted above every consumer rather than duplicated into each.
|
||||
prjson_now=$(gq "repos/$owner/$repo/pulls/$pr")
|
||||
if [ -z "${prjson_now//[[:space:]]/}" ] || ! printf '%s' "$prjson_now" | jq -e 'type == "object"' >/dev/null 2>&1; then
|
||||
decide ask "H10 merge gate: could not re-read PR #$pr to confirm it still targets '$base_ref' before checking the verdict against it. Confirm the target branch, then merge."
|
||||
fi
|
||||
base_now=$(printf '%s' "$prjson_now" | jq -r '.base.ref // ""' 2>/dev/null || true)
|
||||
if [ -z "$base_now" ]; then
|
||||
live_base=$(printf '%s' "$prjson" | jq -r '.base.ref // ""' 2>/dev/null || true)
|
||||
if [ -z "$live_base" ]; then
|
||||
decide ask "H10 merge gate: PR #$pr reports no base branch (.base.ref), so the verdict cannot be checked against the branch it was formed for (ersatztv#632). Confirm the PR still targets the branch it was reviewed against before merging."
|
||||
fi
|
||||
if [ -n "$base_ref" ] && [ "$base_now" != "$base_ref" ]; then
|
||||
decide deny "H6/H10 merge gate: BLOCKED — PR #$pr was retargeted from '$base_ref' to '$base_now' while this gate was evaluating. Every check formed against '$base_ref', including the changed-file enumeration and the review verdict, describes a merge that is no longer the one being requested (ersatztv#632). Re-review against '$base_now' and run: scripts/post-review-verdict.sh $pr MERGEABLE"
|
||||
fi
|
||||
# From here on both names are the freshly-confirmed base; they are equal by the check above.
|
||||
base_ref=$base_now
|
||||
live_base=$base_now
|
||||
|
||||
# THE HEAD IS RE-READ AT THE SAME HOIST, FROM THE SAME RESPONSE (ersatztv#803).
|
||||
#
|
||||
# `$sha` comes from the PR snapshot at the top of this hook, and until 2026-08-28 every later check
|
||||
# consumed that captured value: the CI combined status, the `review-verdict/h10` status, and the
|
||||
# verdict-comment classification were all evaluated against `/commits/$sha/status` and `--head $sha`.
|
||||
# A push landing in the gap — which includes the docs-only enumeration's up-to-forty round trips —
|
||||
# was therefore checked against the commit it had just replaced, and the hook would report "a
|
||||
# positive Review-verdict references the current head" about a head that was no longer current.
|
||||
#
|
||||
# This is the SAME defect the base had until #778 hoisted the re-read above, and it is fixed the same
|
||||
# way rather than a different way. Reading `.head.sha` off `$prjson_now` — the response the base
|
||||
# check already fetched — costs NO extra round trip, and it keeps the two axes on ONE snapshot, so
|
||||
# they cannot disagree about which moment they describe. Two separate reads would answer about two
|
||||
# different instants while reading as one check.
|
||||
#
|
||||
# DENY, not ask, and for the same reason the `stale` verdict class denies: a head that moved means
|
||||
# the verdict this hook is about to accept covers an OLDER commit, which is a state we have
|
||||
# positively established rather than failed to establish. An UNREADABLE `.head.sha` is the different
|
||||
# case and asks.
|
||||
#
|
||||
# WHAT THIS DOES NOT CLOSE, said here rather than left to be inferred. A push landing after this
|
||||
# check still passes, exactly as a retarget does — the file's rule against a second re-read applies
|
||||
# unchanged (see the branch-protection block below), because two reads only move the window rather
|
||||
# than closing it. That residual is bounded server-side and this hook is not what bounds it: the new
|
||||
# head has no `review-verdict/h10` status, and that context is REQUIRED on `main`, so Gitea refuses
|
||||
# the merge (#622). The hook's job here is to stop CLAIMING a head is reviewed when it can see that
|
||||
# it is not — an advisory gate that states something false is worse than one that asks.
|
||||
if [ -n "$sha" ]; then
|
||||
sha_now=$(printf '%s' "$prjson_now" | jq -r '.head.sha // ""' 2>/dev/null || true)
|
||||
if [ -z "$sha_now" ]; then
|
||||
decide ask "H10 merge gate: PR #$pr reports no head commit (.head.sha) on re-read, so whether the review verdict still covers the current head could not be confirmed. Check the PR, then merge."
|
||||
fi
|
||||
if [ "$sha_now" != "$sha" ]; then
|
||||
decide deny "H6/H10 merge gate: BLOCKED — PR #$pr's head moved from ${sha:0:7} to ${sha_now:0:7} while this gate was evaluating. Every check formed against ${sha:0:7} — the changed-file enumeration, the CI status and the review verdict — describes a commit that is no longer the one being merged (ersatztv#803). Re-review the current head and run: scripts/post-review-verdict.sh $pr MERGEABLE"
|
||||
fi
|
||||
# From here on `$sha` is the freshly-confirmed head; the two are equal by the check above. Mirrors
|
||||
# `base_ref=$base_now` a few lines up, and is written for the same reason that one is: it makes the
|
||||
# value every later check consumes the one that was just re-read, so a future edit moving a
|
||||
# consumer above this point fails visibly rather than silently reading the stale capture.
|
||||
sha=$sha_now
|
||||
fi
|
||||
if [ -n "$sha" ]; then
|
||||
# This is the THIRD read of this endpoint in a worst-case hook run (the ordinary-CI branch and the
|
||||
# scheduled-auto-merge branch each do their own). Sharing one snapshot would close a narrow
|
||||
@@ -333,50 +238,6 @@ for n in $issues; do
|
||||
fi
|
||||
done
|
||||
|
||||
# ONE branch-protection READ per run (ersatztv#859). Two arms consume this endpoint — the scheduled
|
||||
# path's `review-verdict/h10` required-check test, and the guard-scope freshness check at the bottom
|
||||
# — and they used to issue independent GETs, so a scheduled auto-merge hit it twice (measured: the
|
||||
# test stub recorded 2 URLs).
|
||||
#
|
||||
# THE ROUND TRIP IS THE SMALLER HALF. What matters is that branch protection is MUTABLE config: two
|
||||
# reads can return two different answers, and the gap between them is a gap in which the two arms
|
||||
# decide about different repo states — one concluding `review-verdict/h10` is required on the base
|
||||
# while the other classifies a rule list that no longer says so. Neither arm can detect that; both
|
||||
# would report confidently. Caching makes a single run internally consistent BY CONSTRUCTION, which
|
||||
# is a property no retry or ordering change can supply.
|
||||
#
|
||||
# WHY #787 DID NOT ALREADY SHARE IT, since the obvious question is why two reads existed at all: the
|
||||
# arms ask genuinely different QUESTIONS — one about `$base_ref` and its required contexts, one about
|
||||
# `main` and snapshot freshness — so their classifications must stay separate. But they ask those
|
||||
# questions of the same URL with the same credentials, so the RESPONSE is shareable even though the
|
||||
# verdicts are not. Cache the bytes; never cache a verdict.
|
||||
#
|
||||
# This does NOT pin anything: protection can still change after the read, and the honest ceiling is
|
||||
# unchanged (`process.check-and-use-pins-a-version`). It removes a second window, it does not remove
|
||||
# the first.
|
||||
bp_fetched=no
|
||||
bp_cache=""
|
||||
bp_cache_code=""
|
||||
fetch_branch_protections() {
|
||||
# Idempotent by design: every caller invokes it unconditionally and the FIRST one pays. A caller
|
||||
# that had to know whether it was first would be a second place for the two arms to disagree.
|
||||
if [ "$bp_fetched" = yes ]; then return 0; fi
|
||||
bp_fetched=yes
|
||||
local f
|
||||
# A temp-file failure gets its own sentinel rather than an HTTP-shaped one, so each caller can
|
||||
# keep the distinct message it had before this was shared. Reporting a mktemp failure as HTTP
|
||||
# '000 — Gitea unreachable' would state a cause that did not happen, which is the defect class
|
||||
# this whole file is organised around.
|
||||
f=$(mktemp) || { bp_cache=""; bp_cache_code=mktemp-failed; return 0; }
|
||||
if [ -n "${ETV_GITEA_TOKEN:-}" ]; then
|
||||
bp_cache_code=$(curl -s -o "$f" -w '%{http_code}' -H "Authorization: token $ETV_GITEA_TOKEN" "$base_url/repos/$owner/$repo/branch_protections" 2>/dev/null || true)
|
||||
else
|
||||
bp_cache_code=$(curl -s -o "$f" -w '%{http_code}' -u "$ETV_GITEA_BASICAUTH" "$base_url/repos/$owner/$repo/branch_protections" 2>/dev/null || true)
|
||||
fi
|
||||
bp_cache=$(cat "$f" 2>/dev/null || true)
|
||||
rm -f "$f"
|
||||
}
|
||||
|
||||
# --- (a) CI combined status must be green (unless deferring to Gitea's own check-gate). ---
|
||||
if [ "$mwcs" != "true" ]; then
|
||||
[ -n "$sha" ] || decide ask "H6 merge gate: could not resolve PR #$pr head sha to check CI. Verify CI is green before merging."
|
||||
@@ -440,19 +301,8 @@ else
|
||||
# status decide this. Here the fallthrough happens to land on `vstate=""` -> deny (fail-CLOSED,
|
||||
# so this was never a hole), but it would have surfaced the wrong message — a "BLOCKED, no
|
||||
# verdict" deny instead of the "could not read the status" ask this branch exists to give.
|
||||
# Validate the MEMBERS, not just the array. `.statuses | type == "array"` passes for
|
||||
# `{"statuses":[1]}`, and the extraction below then errors with "Cannot index number with string"
|
||||
# and exits 5 — which, under `set -e`, aborts this hook with NO JSON on stdout at all. A consent
|
||||
# hook that emits nothing has violated its own contract: it neither grants, denies nor asks. Same
|
||||
# one-level-down swallow as the #632 base-change guard and the branch-protection shape check
|
||||
# below; the validation domain must match the CONSUMPTION domain (ersatztv#778).
|
||||
if [ -z "${vjson//[[:space:]]/}" ] \
|
||||
|| ! printf '%s' "$vjson" \
|
||||
| jq -e '(.statuses | type == "array")
|
||||
and all(.statuses[]; type == "object"
|
||||
and ((.context | type) == "string")
|
||||
and ((.status | type) == "string"))' >/dev/null 2>&1; then
|
||||
decide ask "H6/H10 merge gate: could not read the 'review-verdict/h10' status for PR #$pr head ${sha:0:7} (Gitea unreachable, or a response whose status rows are not the expected shape). Confirm the current head is reviewed before scheduling an auto-merge."
|
||||
if [ -z "${vjson//[[:space:]]/}" ] || ! printf '%s' "$vjson" | jq -e '.statuses | type == "array"' >/dev/null 2>&1; then
|
||||
decide ask "H6/H10 merge gate: could not read the 'review-verdict/h10' status for PR #$pr head ${sha:0:7} (Gitea unreachable or an unexpected response). Confirm the current head is reviewed before scheduling an auto-merge."
|
||||
fi
|
||||
vstate=$(printf '%s' "$vjson" | jq -r '[.statuses[] | select(.context == "review-verdict/h10")] | first | .status // ""')
|
||||
case "$vstate" in
|
||||
@@ -461,226 +311,6 @@ else
|
||||
pending) decide deny "H6/H10 merge gate: BLOCKED — 'review-verdict/h10' is still pending on PR #$pr head ${sha:0:7} (no verdict posted for this commit yet). Review the current head and run: scripts/post-review-verdict.sh $pr MERGEABLE" ;;
|
||||
*) decide deny "H6/H10 merge gate: BLOCKED — 'review-verdict/h10' is '$vstate' on PR #$pr head ${sha:0:7}. Resolve the findings, then run: scripts/post-review-verdict.sh $pr MERGEABLE" ;;
|
||||
esac
|
||||
|
||||
# --- The mitigation this path RESTS on, verified instead of asserted (ersatztv#778). -----------
|
||||
# Everything above proves a property of the head that exists NOW. What makes that safe under
|
||||
# merge_when_checks_succeed is stated in the paragraph opening this branch: `review-verdict/h10`
|
||||
# is a REQUIRED status check on the base, a commit status belongs to exactly ONE sha, so a commit
|
||||
# pushed after scheduling cannot inherit it and Gitea's own gate refuses the merge.
|
||||
#
|
||||
# That guarantee is branch-protection CONFIG. It lives outside this repo, no code here owned it,
|
||||
# and until #778 nothing compared the two — so the grant reason handed to a human cited a
|
||||
# protection that could have been switched off with no signal anywhere. The comment above and the
|
||||
# grant string below are claims about the past; a dated claim is not a check.
|
||||
#
|
||||
# This is the hook's OWN defect class (#778 / `process.check-and-use-pins-a-version`): a check
|
||||
# ("a later push clears the status") authorizes an action ("arm an auto-merge that Gitea completes
|
||||
# later") over state that can change in between, with nothing pinning it. The read here does not
|
||||
# pin anything either — branch protection can still be edited after this call — but it converts an
|
||||
# ASSUMPTION that was never observed into a precondition that is, which is the honest ceiling for
|
||||
# a config whose API offers no version, ETag or conditional read.
|
||||
#
|
||||
# Tri-state, matching this file's idiom throughout: unreadable -> ask (a human adjudicates),
|
||||
# present -> proceed, ABSENT -> deny. Absence is not a degraded read; it is #622's hole reopened,
|
||||
# and the whole point of that issue is that the failure is silent from the merge caller's side.
|
||||
# Belt-and-braces: `$base_ref` was proven non-empty and re-confirmed at the hoisted check above,
|
||||
# so this cannot fire today. Kept because it is the precondition this block's URL depends on, and
|
||||
# a future edit that moves either piece should fail loudly here rather than request a URL with an
|
||||
# empty path segment.
|
||||
[ -n "$base_ref" ] || decide ask "H6/H10 merge gate: could not resolve PR #$pr's base branch, so the 'review-verdict/h10' required-check protection that makes a scheduled auto-merge safe (ersatztv#622) can't be confirmed. Verify branch protection on the base, or merge immediately instead of scheduling."
|
||||
# The base was re-read and confirmed unchanged above, for every path — see the hoist comment
|
||||
# there. It is deliberately NOT re-read a second time here: two reads would create a window
|
||||
# between them for no gain, and the hoisted check already covers the enumeration gap that made
|
||||
# this necessary.
|
||||
# A read failure here is NOT evidence about the branch. The deleted by-name endpoint answered 404
|
||||
# for "no rule with this name", which was a finding; the LIST endpoint's 404 means the repo was not
|
||||
# found or is invisible to this credential, which is a read failure. Absence is now established by
|
||||
# the classifier returning `nomatch` over a list that WAS read, never by an HTTP status.
|
||||
# ALWAYS enumerate the rule LIST; never look a rule up by name. The by-name endpoint
|
||||
# (`branch_protections/{name}`) is an exact DB lookup — `GetProtectedBranchRuleByName` — which
|
||||
# performs no matching and knows nothing about precedence, so a 200 from it means only "a rule
|
||||
# with this NAME exists and lists this context", never "this context is required on this branch".
|
||||
#
|
||||
# It was used first, with the list consulted only on a 404, and that design left a false-open
|
||||
# behind: the precedence argument below guarded the 404 path while the 200 path — the one this
|
||||
# repo actually takes — granted without it. Given a rule `main` requiring `review-verdict/h10` and
|
||||
# a rule `m*` with better Priority that does not, Gitea applies `m*`, and the by-name hit on
|
||||
# `main` granted anyway. The hardened path was dead code and the unhardened one was live. Deleting
|
||||
# the twin rather than documenting it is the point: one fetch, one classifier, one argument, and
|
||||
# no second path to keep in step. The ref no longer reaches a URL segment, so it needs no
|
||||
# encoding either.
|
||||
fetch_branch_protections
|
||||
if [ "$bp_cache_code" = "mktemp-failed" ]; then
|
||||
decide ask "H6/H10 merge gate: could not allocate a temp file to read branch protection for '$base_ref'. Confirm the 'review-verdict/h10' required check manually before scheduling an auto-merge."
|
||||
fi
|
||||
bp_code=$bp_cache_code
|
||||
bp_list=$bp_cache
|
||||
bp=""
|
||||
if [ "$bp_code" = "200" ] && printf '%s' "$bp_list" | jq -e 'type == "array"' >/dev/null 2>&1; then
|
||||
# DO NOT claim parity with Gitea's matcher — this code cannot have it, and asserting it would
|
||||
# be the exact defect this PR records (a mitigation outside the code, asserted rather than
|
||||
# verified). Gitea compiles a rule name with gobwas/glob and a `/` separator, so its `*` does
|
||||
# NOT cross a slash, `?`/`[…]`/`{a,b}` are wildcards, and a plain name is folded case-
|
||||
# insensitively. Reimplementing that here would be a second copy of somebody else's parser.
|
||||
#
|
||||
# So the classification is deliberately THREE-way, and each arm is safe without knowing the
|
||||
# dialect:
|
||||
# exact — no glob rule could apply, AND some rule name has no glob metacharacter and
|
||||
# equals the base case-insensitively. Only then is a single rule decidable.
|
||||
#
|
||||
# UNDECIDABLE IS EVALUATED FIRST, and the order is the point. Gitea picks the
|
||||
# governing rule with `GetFirstMatched` over a list sorted by Priority, THEN
|
||||
# by plain-name-ness — so a glob rule with a better Priority outranks an
|
||||
# exactly-named one. Preferring `exact` would therefore inspect a rule Gitea
|
||||
# might not be applying: if the exact rule requires `review-verdict/h10` and a
|
||||
# higher-priority glob rule does not, the gate auto-grants on a base where the
|
||||
# check is not enforced. Asking whenever ANY glob rule could apply is sound
|
||||
# without knowing the precedence rules at all, which is the only claim this
|
||||
# code is entitled to make about somebody else's resolver.
|
||||
#
|
||||
# Case folding is ASCII-only here, while Gitea's `EqualFold` is
|
||||
# Unicode-aware — so a rule `ünstable` and a base `Ünstable` fold equal there
|
||||
# and not here. ASCII-fold equality implies EqualFold equality, so the gap can
|
||||
# only MISS a match, never invent one; but a miss lands on `none`, which
|
||||
# DENIES with the stated cause that no rule can govern the base. The backslash
|
||||
# paragraph below rejects "nearly unreachable" as a standard for that arm, and
|
||||
# the same standard has to apply here, so a rule name carrying any non-ASCII
|
||||
# byte is `undecidable` rather than fold-compared. Two fold-equal plain names
|
||||
# are undecidable too: this code picks by list order while Gitea picks by
|
||||
# Priority, and guessing which one is enforced is the defect the arm order
|
||||
# above exists to avoid.
|
||||
# undecidable — some glob rule COULD govern this base. Tested with a provable SUPERSET of any
|
||||
# glob dialect: literal prefix before the first metacharacter, `.*`, literal
|
||||
# suffix after the last. If even that does not match, no dialect can, because
|
||||
# every dialect requires the literal head and tail to match literally.
|
||||
#
|
||||
# BACKSLASH counts as a metacharacter for that purpose, and it is the one case that breaks the
|
||||
# superset proof if it does not. gobwas/glob reads `\{` as a LITERAL brace, so a rule `a\{b`
|
||||
# governs the base `a{b` — while a superset that treated `\` as literal would build `a\.*b`,
|
||||
# fail to match, and answer `none`, i.e. deny a base that IS protected. Git ref rules make this
|
||||
# nearly unreachable (a branch name may not contain `*`, `?`, `[` or `\`, though it MAY contain
|
||||
# `{`), but `none` is the arm that authorises a DENY on the stated grounds "nothing can govern
|
||||
# this base", so its premise has to hold unconditionally rather than usually.
|
||||
# none — nothing can possibly govern the base, so it is genuinely unprotected.
|
||||
#
|
||||
# `undecidable` asks rather than granting or denying. Over-matching would auto-grant on a base
|
||||
# whose protection we never established (#622's hole, reached through the block written to
|
||||
# close it); under-matching would deny with a stated cause that is false, which this block's
|
||||
# own comment calls the worse outcome. Asking is the only answer that is honest in both
|
||||
# directions, and it is rare in practice: as of 2026-08-19 this repo's only rule is the plain
|
||||
# name `main`, which the classifier resolves to `exact` on every run. That is a dated
|
||||
# observation about mutable remote config, not a property to rely on.
|
||||
# The classifier is a FILE now (ersatztv#787), so its absence is a new failure mode: `jq -f` on a
|
||||
# missing program exits 2 with empty stdout, which reaches the `*)` arm below and asks that "this
|
||||
# repo's branch-protection rules came back in a shape this hook could not parse" — blaming the
|
||||
# payload for a missing local file. That is precisely the states-a-cause-that-did-not-happen defect
|
||||
# the two comments beside that arm were written to fix, so it is checked here rather than inherited.
|
||||
classifier="$repo_root/scripts/lib/branch-rule-classifier.jq"
|
||||
if [ ! -r "$classifier" ]; then
|
||||
decide ask "H6/H10 merge gate: the shared branch-protection rule classifier is missing or unreadable at $classifier, so which rule governs '$base_ref' — and therefore whether 'review-verdict/h10' is required on it — could not be derived (ersatztv#787). Restore the file, or confirm the required checks manually."
|
||||
fi
|
||||
bp_verdict=$(printf '%s' "$bp_list" | jq --arg b "$base_ref" -c -f "$classifier" 2>/dev/null || true)
|
||||
case $(printf '%s' "$bp_verdict" | jq -r '.verdict // ""' 2>/dev/null || true) in
|
||||
exact) bp=$(printf '%s' "$bp_verdict" | jq -c '.rule' 2>/dev/null || true); bp_code=200 ;;
|
||||
undecidable) decide ask "H6/H10 merge gate: no branch-protection rule on this repo governs '$base_ref' decidably — a GLOB rule could govern it, or two rule names fold-equal, or a name is non-ASCII. This hook deliberately does not reimplement Gitea's glob matcher, so whether 'review-verdict/h10' is required on this base cannot be derived here (ersatztv#778). Confirm it in the repo's branch-protection settings, or merge immediately instead of scheduling." ;;
|
||||
none) bp_code=nomatch; bp="" ;;
|
||||
# A DECLARED class of the classifier's contract (ersatztv#859), with its OWN sentinel — not
|
||||
# merely its own arm. Giving it an arm that set `unreadable-rules`, the same value
|
||||
# the catch-all sets, was measured to be a no-op: deleting that arm left the WHOLE suite
|
||||
# green, because nothing downstream could tell the two apart. An arm no observation can
|
||||
# distinguish is not a fix, it is a comment with syntax. (The invariant is "no test reddens",
|
||||
# not a test count — a count goes stale the next time anyone adds one.)
|
||||
#
|
||||
# They are different findings and now say so. `unnamed-rule` means the list was READ and a rule
|
||||
# in it carries no usable name; `unreadable-rules` means jq died or answered a word this hook
|
||||
# does not know. Same decision (ask), different cause — and naming the cause accurately is the
|
||||
# entire subject of this issue, so collapsing them here would have reproduced the defect being
|
||||
# fixed, one arm over.
|
||||
unreadable) bp_code=unnamed-rule; bp="" ;;
|
||||
*) bp_code=unreadable-rules; bp="" ;;
|
||||
esac
|
||||
else
|
||||
# A 200 whose body is NOT an array never reaches the classifier — it is diverted by the array
|
||||
# gate above — so it needs the same sentinel, or the generic ask below reports
|
||||
# "HTTP '200' — Gitea unreachable" about a read that plainly succeeded. Same defect as the
|
||||
# throw-inside-the-classifier arm, one branch earlier; fixing only the arm where it was noticed
|
||||
# is the twin-missed shape this PR is largely about.
|
||||
if [ "$bp_code" = "200" ]; then
|
||||
bp_code=unreadable-rules
|
||||
else
|
||||
bp_code=${bp_code:-000} # a real transport/HTTP failure -> the ask arm below
|
||||
fi
|
||||
bp=""
|
||||
fi
|
||||
# `nomatch` is the CLASSIFIER's verdict, deliberately not an HTTP code. Reusing 404 for it made
|
||||
# this deny reachable from an HTTP 404 on the list read too — repo not found, or invisible to the
|
||||
# credential, which Gitea also answers 404 — and then the reason claimed "the full rule list was
|
||||
# read and none matches" about a read that never happened. A transport failure must reach the ask
|
||||
# below, not a deny stating a finding.
|
||||
if [ "$bp_code" = "nomatch" ]; then
|
||||
decide deny "H6/H10 merge gate: BLOCKED — no branch-protection rule on this repo can govern '$base_ref' (the full rule list was read and none matches), so 'review-verdict/h10' is not a required check on it. A scheduled auto-merge is safe ONLY because that per-sha required check stops a commit pushed after scheduling from merging unreviewed (ersatztv#622). Restore branch protection on '$base_ref', or merge immediately (without merge_when_checks_succeed) once CI is green."
|
||||
fi
|
||||
# `unnamed-rule` is the classifier reporting a rule whose NAME it could not use. Two distinct
|
||||
# shapes, and the reason string must cover both or it states a cause that did not happen: EITHER
|
||||
# both fields supply no name (absent, null, or empty), OR one of them is present holding a
|
||||
# non-string, which poisons the rule however good its sibling is. It is deliberately NOT reported as
|
||||
# "no rule matches": a rule that cannot be read might be the rule Gitea is applying, so a list
|
||||
# containing one supports no finding about which rule governs the base. That was the #859 defect —
|
||||
# `""` is a valid name that matches nothing, so an unreadable rule DENIED with a stated cause that
|
||||
# had not happened.
|
||||
if [ "$bp_code" = "unnamed-rule" ]; then
|
||||
decide ask "H6/H10 merge gate: a branch-protection rule on this repo carries no name this hook can use — either both 'branch_name' and 'rule_name' are absent/null/empty, or one of them is present holding something that is not a string. Which rule governs '$base_ref', and whether 'review-verdict/h10' is required on it, therefore could not be derived. A rule that cannot be read might be the one Gitea applies, so this is deliberately NOT reported as 'no rule matches' (ersatztv#859). Inspect the branch-protection rules, or merge immediately instead of scheduling."
|
||||
fi
|
||||
# `unreadable-rules` is the CLASSIFIER failing on a 200 this hook could not turn into a verdict —
|
||||
# jq died, or answered a word this contract does not define. It gets its own sentinel for the same
|
||||
# reason `nomatch` does: reporting "HTTP '000' — Gitea unreachable" about a successful 200 read
|
||||
# states a cause that did not happen, which is the defect fixed one arm over for the deny.
|
||||
#
|
||||
# A numeric `branch_name` was the worked example here until ersatztv#859 and no longer reaches this
|
||||
# arm: it is not a usable NAME, so the classifier now classifies it rather than throwing on it, and
|
||||
# it lands on `unnamed-rule` above with the cause that actually applies. The example is corrected
|
||||
# rather than dropped, because it is the one shape a reader is likely to reach for when testing.
|
||||
if [ "$bp_code" = "unreadable-rules" ]; then
|
||||
decide ask "H6/H10 merge gate: this repo's branch-protection rules came back in a shape this hook could not parse, so whether 'review-verdict/h10' is required on '$base_ref' is unknown. Check the rules manually, or merge immediately instead of scheduling."
|
||||
fi
|
||||
if [ "$bp_code" != "200" ] || [ -z "${bp//[[:space:]]/}" ] || ! printf '%s' "$bp" | jq -e 'type == "object"' >/dev/null 2>&1; then
|
||||
decide ask "H6/H10 merge gate: could not read this repo's branch-protection rules (HTTP '${bp_code:-none}' — Gitea unreachable, or these credentials lack the repo-admin scope that endpoint needs), so whether 'review-verdict/h10' is required on '$base_ref' is unknown. Scheduling an auto-merge is only safe while 'review-verdict/h10' is a REQUIRED check there (ersatztv#622) — confirm that manually, or merge immediately instead of scheduling."
|
||||
fi
|
||||
# The membership test is `any(.[]; . == …)` over a value FIRST PROVEN to be an array of strings —
|
||||
# never `index()`. `index` on a STRING is substring search, so a `status_check_contexts` that
|
||||
# arrived as the string "prefix-review-verdict/h10-suffix" would answer "yes" and auto-grant a
|
||||
# merge on a base where no such context is required. That is a FALSE-OPEN in the gate, reachable
|
||||
# from any payload shape drift, and it is the direction that matters: a false-closed costs a
|
||||
# prompt, a false-open costs an unreviewed merge.
|
||||
#
|
||||
# Validating `$bp` as an object does not make its MEMBERS well-formed, which is the same
|
||||
# one-level-down swallow that survived the first fix in the #632 base-change guard — the
|
||||
# validation domain has to match the CONSUMPTION domain, not stop at the top-level type. So the
|
||||
# shape is checked explicitly and anything else becomes "unknown" rather than a decision.
|
||||
#
|
||||
# `null` and `[]` are legitimate (an unprotected-in-practice branch) and answer "no", not
|
||||
# "unknown": absent IS the finding here, not a read failure. The word is then matched
|
||||
# exhaustively, because "" is not a third synonym for "no".
|
||||
# `// []` defaults on FALSE as well as on null, because jq's alternative operator fires for both.
|
||||
# So `"status_check_contexts": false` — a malformed shape — became `[]` and answered "no", i.e. a
|
||||
# confident DENY derived from a payload that was never understood. Absent and null are defaulted
|
||||
# explicitly; every other non-array is "unknown".
|
||||
# `enable_status_check` is validated as a BOOLEAN before it is trusted, for the same reason the
|
||||
# contexts list is: `"true"` (the string) is not `true`, and comparing it to `true` yields a
|
||||
# confident "no" -> deny derived from a payload never understood. Every malformed shape on this
|
||||
# endpoint has to reach the same "unknown" -> ask arm, or the tri-state is only two states.
|
||||
guarded=$(printf '%s' "$bp" \
|
||||
| jq -r 'def ctxs: if (has("status_check_contexts") | not) or .status_check_contexts == null
|
||||
then [] else .status_check_contexts end;
|
||||
if (.enable_status_check | type) != "boolean" then "unknown"
|
||||
elif (ctxs | type) != "array" or any(ctxs[]; type != "string") then "unknown"
|
||||
elif (.enable_status_check == true) and any(ctxs[]; . == "review-verdict/h10") then "yes"
|
||||
else "no" end' 2>/dev/null || true)
|
||||
case "$guarded" in
|
||||
yes) : ;;
|
||||
no) decide deny "H6/H10 merge gate: BLOCKED — 'review-verdict/h10' is NOT a required status check on '$base_ref' (branch protection reports enable_status_check/status_check_contexts without it). A scheduled auto-merge is safe ONLY because that per-sha required check stops a commit pushed after scheduling from merging unreviewed (ersatztv#622); without it, arming merge_when_checks_succeed freezes consent at a head Gitea may not be the one to merge. Restore it in branch protection, or merge immediately (without merge_when_checks_succeed) once CI is green." ;;
|
||||
*) decide ask "H6/H10 merge gate: branch protection for '$base_ref' came back in an unexpected shape, so the 'review-verdict/h10' required check that makes a scheduled auto-merge safe (ersatztv#622) could not be confirmed either way. Check it manually, or merge immediately instead of scheduling." ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# --- (c) Review-verdict freshness (ersatztv#303 H10): a review-verdict comment must reference the
|
||||
@@ -700,19 +330,7 @@ fi
|
||||
# inside a fenced code block (documentation showing the convention counted as a real verdict), and a
|
||||
# sha taken from the first `@<hex>` anywhere on the line (a markdown link could supply it). Every
|
||||
# decision the classifier makes is documented there; this file only maps a class onto a hook decision.
|
||||
# RESOLVED FROM `$repo_root`, never `$CLAUDE_PROJECT_DIR` — the rule, the threat model and the
|
||||
# boundary are in `process.hook-resolves-inputs-from-repo-root` (ersatztv#858, #891). Written once there
|
||||
# rather than twice here: this file carried two resolutions of the same question, and the guard-scope
|
||||
# arm below is the other one. Two answers in one file is the state most likely to be "tidied" toward
|
||||
# the weaker side, so neither site restates the argument now.
|
||||
#
|
||||
# Site-specific consequence only: a `$CLAUDE_PROJECT_DIR` naming a sibling worktree — routine here —
|
||||
# would classify THIS PR's comments with THAT tree's copy of the H10 grammar.
|
||||
#
|
||||
# `ETV_HOOK_FIRE_LIB` at the top of this file is bound the same way, and for a STRONGER reason — it
|
||||
# is sourced, so it is code. See the block above it. Since #891 every tracked hook binds it
|
||||
# identically, and `test_hook_fire_log.py` fails any that stops doing so.
|
||||
verdict_script="$repo_root/scripts/check-review-verdict.sh"
|
||||
verdict_script="${CLAUDE_PROJECT_DIR:-.}/scripts/check-review-verdict.sh"
|
||||
if [ ! -x "$verdict_script" ]; then
|
||||
decide ask "H10 merge gate: verdict classifier not found at $verdict_script, so the review state can't be derived. Confirm the review covered the latest commit before merging."
|
||||
fi
|
||||
@@ -741,133 +359,13 @@ case "$class" in
|
||||
decide ask "H10 merge gate: unrecognized verdict classification '$class' for PR #$pr. Confirm the review covered the latest commit ($short) before merging." ;;
|
||||
esac
|
||||
|
||||
# --- (d) Guard-scope freshness (ersatztv#787): the committed mirror of `main`'s required status
|
||||
# checks must still match the server. ------------------------------------------------------
|
||||
# ORDERED LAST, and that is a severity argument rather than a stylistic one. Every check above
|
||||
# can DENY; this one can only ever downgrade an otherwise-satisfied auto-grant to a prompt. Run
|
||||
# earlier it would preempt those verdicts and report a stale guard scope at a reader whose merge
|
||||
# is blocked for a completely different and more serious reason, and it would ask on payloads the
|
||||
# checks above are about to reject anyway. Placed here it is also PAST the point where the two
|
||||
# merge paths converge, so it covers both without duplicating anything.
|
||||
# `scripts/tests/test_ci_dropped_step_guard.py` DERIVES which jobs must carry per-step execution
|
||||
# markers from `.gitea/required-status-contexts.json`, because its CI job checks out with
|
||||
# `persist-credentials: false` and cannot ask Gitea. That makes the snapshot the single
|
||||
# hand-maintained input in the chain: a fourth required context added on the server leaves the
|
||||
# snapshot — and therefore the guard's scope — silently behind, which is the whole of #787.
|
||||
#
|
||||
# THIS RUNS ON BOTH MERGE PATHS, deliberately, and it is placed here rather than beside the
|
||||
# branch-protection read in the scheduled-auto-merge branch for that reason.
|
||||
#
|
||||
# WHAT IT DOES NOT COVER, said here rather than left to be discovered: a PR whose changed files are
|
||||
# all docs/process — `.gitea/` included — exits at the docs-only passthrough far above, so this arm
|
||||
# never runs for it. A PR that edits ONLY `.gitea/required-status-contexts.json` is docs-only BY
|
||||
# CONSTRUCTION, and that is exactly the snapshot-NARROWING direction the decision record names as
|
||||
# this design's residual. Excluding that path from the allow-list would not buy the protection it
|
||||
# looks like it would: this arm compares the live server against the snapshot in the LOCAL CHECKOUT,
|
||||
# not against the version the PR proposes, so it cannot see a narrowing that has not landed yet.
|
||||
# What does hold is that the passthrough is a passthrough — a human prompt, never an auto-grant —
|
||||
# which is the `.gitea/` treatment ersatztv#317 asked for. That read is inside
|
||||
# `else` (mwcs = true) and never executes on an immediate merge, which is the common case; hanging
|
||||
# the freshness check off it would fire it only when an auto-merge is armed. This file already
|
||||
# records that exact defect one section up — the base re-read "first landed inside the
|
||||
# scheduled-auto-merge branch only", with scheduled+retarget denied while
|
||||
# immediate+retarget auto-GRANTED. Same shape, so it is not repeated here.
|
||||
#
|
||||
# It reads `main` (the branch the snapshot names), NOT `$base_ref`. That is a DIFFERENT question
|
||||
# from the one the scheduled branch asks — "is review-verdict/h10 required on the base I am merging
|
||||
# into" — so this is not a second copy of that classifier and the two cannot drift into disagreeing:
|
||||
# they consume different fields of different rules for different decisions.
|
||||
#
|
||||
# ASK, NEVER DENY. Drift does not make THIS merge unsafe: Gitea enforces the live required set
|
||||
# server-side, so a newly required context with no status blocks the merge on its own. What has gone
|
||||
# stale is a guard's scope — a different artifact, on a different clock. Denying would state
|
||||
# something false about the change in front of the reader. Every non-`match` class asks, so a
|
||||
# comparison that could not be made is surfaced rather than skipped (`unknown` is not `fine`).
|
||||
# ONE base for both the checker and the snapshot, and it is `$repo_root` — see
|
||||
# `process.hook-resolves-inputs-from-repo-root` for why an env var may not select either
|
||||
# (ersatztv#787, #858). The reason specific to THIS arm is that both halves of a comparison are
|
||||
# resolved here: from two different roots the hook would classify one checkout's snapshot with
|
||||
# another checkout's script — mismatched halves of a comparison whose entire job is to detect a
|
||||
# mismatch — and answer `match` about a tree nobody asked about.
|
||||
ctx_base="$repo_root"
|
||||
ctx_snapshot="$ctx_base/.gitea/required-status-contexts.json"
|
||||
ctx_script="$ctx_base/scripts/check-required-contexts.sh"
|
||||
|
||||
# THIS ARM IS ABOUT ONE REPO, and the merge tool is not. Every other check here reads
|
||||
# `$owner/$repo` from the tool input and is repo-agnostic; this one compares a HARDCODED branch
|
||||
# against a snapshot committed in THIS checkout. Merging a PR in another repo from a session opened
|
||||
# here would otherwise weigh that repo's live contexts against this repo's mirror and report a
|
||||
# confident, flatly false finding about it — measured: server-management returns `[]`, which
|
||||
# classifies as `nomatch`. So the snapshot names the repo it describes and the arm runs only for it.
|
||||
# An unreadable snapshot cannot answer "is this my repo?" either, so it asks rather than skipping.
|
||||
ctx_repo=$(jq -r 'if (.repo | type) == "string" then .repo else "" end' "$ctx_snapshot" 2>/dev/null || true)
|
||||
if [ -z "$ctx_repo" ]; then
|
||||
decide ask "H6 merge gate: $ctx_snapshot is missing, unreadable, or names no \`repo\`, so the dropped-step guard's scope could not be checked against branch protection — nor could it be established whether this snapshot even describes $owner/$repo (ersatztv#787). Restore the file, or check the required checks manually."
|
||||
fi
|
||||
# CASE-FOLDED, because Gitea resolves owner/repo case-insensitively: verified live, both
|
||||
# `/repos/timothy/ersatztv` and `/repos/TIMOTHY/ErsatzTV` answer 200. A byte-exact compare would let
|
||||
# any case variant sail through every other arm and SKIP this one, so drift would go unreported with
|
||||
# no ask — the gate failing open on a spelling. The hook already treats case folding as
|
||||
# decision-relevant one section up, where `MAIN` vs `main` makes the governing rule undecidable.
|
||||
ctx_repo_fold=$(printf '%s' "$ctx_repo" | tr '[:upper:]' '[:lower:]')
|
||||
target_repo_fold=$(printf '%s' "$owner/$repo" | tr '[:upper:]' '[:lower:]')
|
||||
if [ "$ctx_repo_fold" = "$target_repo_fold" ]; then
|
||||
if [ ! -x "$ctx_script" ]; then
|
||||
decide ask "H6 merge gate: the required-contexts checker is missing or not executable at $ctx_script, so whether the dropped-step guard's scope still matches branch protection on 'main' could not be derived (ersatztv#787). Check it manually, or restore the script."
|
||||
fi
|
||||
# THE SHARED READ (ersatztv#859). On a scheduled merge the arm above already fetched this; here that
|
||||
# call is a cache hit, so the endpoint is read once per run instead of twice. On the IMMEDIATE path
|
||||
# this is the only consumer and it performs the fetch itself, which is why the call sits AFTER the
|
||||
# `[ ! -x "$ctx_script" ]` check above: a missing checker must ask without having touched the
|
||||
# network, and a test pins exactly that by asserting no branch-protection URL was recorded.
|
||||
fetch_branch_protections
|
||||
if [ "$bp_cache_code" = "mktemp-failed" ]; then
|
||||
decide ask "H6 merge gate: could not allocate a temp file to read branch protection for the guard-scope freshness check (ersatztv#787)."
|
||||
fi
|
||||
ctx_code=$bp_cache_code
|
||||
# ONE temp file, and it holds the checker's STDERR. Until ersatztv#859 this was `mktemp` for the
|
||||
# payload plus an unmanaged `$bpf.err` beside it — a second path mktemp never created and therefore
|
||||
# never made unpredictable. The payload now comes from the shared cache over a pipe, so the only
|
||||
# thing still needing a file is the diagnostic, and it gets the mktemp'd one.
|
||||
ctx_err=$(mktemp) || decide ask "H6 merge gate: could not allocate a temp file for the guard-scope freshness check's diagnostics (ersatztv#787)."
|
||||
if [ "$ctx_code" = "200" ]; then
|
||||
# stderr is KEPT, not sent to /dev/null. The checker exits 2 with a diagnostic on a usage error —
|
||||
# an unreadable snapshot, a branch mismatch, a missing classifier — and discarding it made all of
|
||||
# those arrive at the operator as the catch-all's "returned 'nothing'", which names no cause. That
|
||||
# is the same states-a-cause-that-did-not-happen shape this arm was careful about elsewhere.
|
||||
ctx_class=$(printf '%s' "$bp_cache" | "$ctx_script" --branch main --snapshot "$ctx_snapshot" 2>"$ctx_err" || true)
|
||||
ctx_diag=$(tr '\n' ' ' < "$ctx_err" 2>/dev/null | cut -c1-300 || true)
|
||||
else
|
||||
ctx_class=readfail
|
||||
ctx_diag=""
|
||||
fi
|
||||
rm -f "$ctx_err"
|
||||
case "$ctx_class" in
|
||||
match) : ;;
|
||||
drift)
|
||||
decide ask "H6 merge gate: the required status checks on 'main' no longer match .gitea/required-status-contexts.json (ersatztv#787). scripts/tests/test_ci_dropped_step_guard.py derives its marked-job scope from that snapshot, so until it is reconciled a required context may have NO dropped-step guard — a step the runner drops would conclude success and take that check green having done no work (ersatztv#756). Re-read the live list and update the snapshot in a PR (the guard will then demand markers for any newly required job, or an ACCOUNTED_ELSEWHERE entry naming what covers it). This does not make the merge in front of you unsafe — Gitea enforces the live required set server-side — so approve if you have judged it unrelated." ;;
|
||||
nomatch)
|
||||
decide ask "H6 merge gate: no branch-protection rule governs 'main' at all, so the required status checks the dropped-step guard scopes itself to could not be confirmed (ersatztv#787). Branch protection on 'main' is what makes 'review-verdict/h10' load-bearing (ersatztv#743) — check it before merging." ;;
|
||||
undecidable)
|
||||
decide ask "H6 merge gate: a glob branch-protection rule could govern 'main', so which rule's required contexts to compare against .gitea/required-status-contexts.json is not derivable without reimplementing Gitea's matcher (ersatztv#787). Confirm the required checks manually." ;;
|
||||
unreadable)
|
||||
decide ask "H6 merge gate: branch protection for 'main', or .gitea/required-status-contexts.json itself, came back in a shape the required-contexts checker could not consume, so whether the dropped-step guard's scope is still current is unknown (ersatztv#787). Check the rules and the snapshot manually." ;;
|
||||
readfail)
|
||||
decide ask "H6 merge gate: could not read branch protection for the guard-scope freshness check (HTTP '${ctx_code:-none}' — Gitea unreachable, or these credentials lack the repo-admin scope that endpoint needs), so whether .gitea/required-status-contexts.json is still current is unknown (ersatztv#787). Confirm the required checks on 'main' manually." ;;
|
||||
*)
|
||||
decide ask "H6 merge gate: the required-contexts checker returned '${ctx_class:-nothing}', which is not a class this hook understands, so the dropped-step guard's scope could not be confirmed against branch protection (ersatztv#787).${ctx_diag:+ It said: ${ctx_diag}}Check scripts/check-required-contexts.sh." ;;
|
||||
esac
|
||||
fi # end of the guard-scope freshness arm (opened at `if [ "$ctx_repo_fold" = ... ]` above). The
|
||||
# body is left unindented to match the rest of this file, which is flat throughout; the marker
|
||||
# is here because the block is long enough that its extent is otherwise easy to misread.
|
||||
|
||||
if [ "$class" = "positive" ]; then
|
||||
# (a) CI + (b) all Done-when ticked + (c) positive verdict @ current head -> SATISFIED. Auto-grant.
|
||||
# The reason string must not claim more than was actually checked: on the merge_when_checks_succeed
|
||||
# path this hook never read the CI status at all (it is delegated to Gitea), so saying "CI green"
|
||||
# there was a plain falsehood in the one message a human reads to decide whether to trust the gate.
|
||||
if [ "$mwcs" = "true" ]; then
|
||||
decide grant "H6/H10 merge gate: satisfied — all Done-when boxes ticked, and both a positive Review-verdict comment and the 'review-verdict/h10' status cover the current head ($short). CI is gated by Gitea (merge_when_checks_succeed). A commit pushed before Gitea merges clears the sha-bound verdict status and is blocked by the 'review-verdict/h10' required check (ersatztv#622) — which this hook has just CONFIRMED is still required on '$base_ref' — read from the repo's full rule list and matched with Gitea's own plain-vs-glob split, refusing rather than guessing wherever precedence or folding is not derivable. That guarantee holds while that branch protection stands; if it is weakened after this check, nothing here would see it (ersatztv#778). Auto-granted."
|
||||
decide grant "H6/H10 merge gate: satisfied — all Done-when boxes ticked, and both a positive Review-verdict comment and the 'review-verdict/h10' status cover the current head ($short). CI is gated by Gitea (merge_when_checks_succeed), and because the verdict status is bound to this sha, a commit pushed before Gitea merges will clear it and block the merge (ersatztv#622). Auto-granted."
|
||||
fi
|
||||
decide grant "H6/H10 merge gate: satisfied — CI green, all Done-when boxes ticked, and a positive Review-verdict references the current head ($short). Auto-granted (no separate confirmation needed)."
|
||||
fi
|
||||
|
||||
@@ -2,13 +2,6 @@
|
||||
# PreToolUse / browser-navigate — deny opening download/stream endpoints in a tab
|
||||
# (they hang the MCP session; curl them instead). Fail-open on parse trouble.
|
||||
set -euo pipefail
|
||||
|
||||
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
|
||||
# Claude hook: decides by printed JSON, so stdout is captured.
|
||||
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
|
||||
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
|
||||
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
|
||||
etv_hook_fire_begin pretooluse-nav-guard "" capture || true
|
||||
input=$(cat)
|
||||
url=$(printf '%s' "$input" | jq -r '.tool_input.url // ""' 2>/dev/null || true)
|
||||
|
||||
|
||||
@@ -8,13 +8,6 @@
|
||||
# So the main tree (never marked) and pre-convention worktrees (no marker) are unaffected;
|
||||
# only a commit/merge into another session's marked worktree is blocked.
|
||||
set -euo pipefail
|
||||
|
||||
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
|
||||
# Claude hook: decides by printed JSON, so stdout is captured.
|
||||
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
|
||||
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
|
||||
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
|
||||
etv_hook_fire_begin pretooluse-worktree-guard "" capture || true
|
||||
input=$(cat)
|
||||
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null || true)
|
||||
cwd=$(printf '%s' "$input" | jq -r '.cwd // ""' 2>/dev/null || true)
|
||||
|
||||
@@ -4,15 +4,8 @@ description: "ErsatzTV custom IPTV channel management — REST API, SQLite DB, J
|
||||
---
|
||||
|
||||
> **Canonical copy: `~/ersatztv/.claude/skills/ersatztv/SKILL.md`** (ersatztv owns this skill per that
|
||||
> repo's `CLAUDE.md` → Project Boundaries and `process.ersatztv-owns-code-not-operations`). Both
|
||||
> `~/server-management/.claude/skills/ersatztv` **and** `~/media-management/.claude/skills/ersatztv`
|
||||
> are symlinks to it. Edit it in the ersatztv repo; never fork a second copy (ersatztv#617, #755) —
|
||||
> media-management's copy had silently become a divergent fork still describing a Blazor UI that no
|
||||
> longer exists, which is what made this the rule rather than a preference.
|
||||
>
|
||||
> **Channel OPERATIONS (create/edit a live channel, lineup, collection, schedule, playout, logo,
|
||||
> overlay) are `media-management`'s job**; ersatztv owns the fork code, `/api/v1`, CI and releases.
|
||||
> This skill serves both — it is the operator's reference *and* the developer's map.
|
||||
> repo's `CLAUDE.md` → Project Boundaries). `~/server-management/.claude/skills/ersatztv` is a symlink
|
||||
> to it. Edit it in the ersatztv repo; never fork a second copy (ersatztv#617).
|
||||
|
||||
# ErsatzTV Channel Management
|
||||
|
||||
@@ -187,99 +180,6 @@ POST /api/v1/libraries/{id}/scan-show \
|
||||
POST /api/v1/channels/{channelId}/playout/reset
|
||||
```
|
||||
|
||||
### Scripted Schedule API — `/api/v1/scripted/…`
|
||||
|
||||
For **programmatic playout building**: each call mutates one build session, addressed by `buildId`.
|
||||
Documented by its own OpenAPI spec, **separate from `v1.json`** — which is why
|
||||
`docs/endpoint-index.md` does not list any of it. It ships as **two** files, both served at
|
||||
`/openapi/` (measured 2026-08-26 on prod: `scripted-schedule.json`, `scripted-schedule-tagged.json`
|
||||
and `v1.json` all return 200). They carry the same 28 paths, so either answers "what operations
|
||||
exist"; they differ only in grouping — the plain file puts everything under one `ScriptedSchedule`
|
||||
tag, the `-tagged` one splits it into Scripted Content / Control / Metadata / Scheduling. Scalar's
|
||||
`/docs` page renders the `-tagged` file (`Startup.cs` registers `openapi/scripted-schedule-tagged.json`),
|
||||
which is why the browsable docs are grouped and a raw fetch of the plain file is not.
|
||||
|
||||
The base path is **`/api/v1/scripted/playout/build/{buildId}/`**, and `buildId` is routed as a GUID
|
||||
(`ScriptedScheduleController.cs`). An older archived copy of this skill gave it as `/api/scripted/…`,
|
||||
without the `v1`; no such route is registered.
|
||||
|
||||
**You cannot tell a wrong base path from a stale `buildId` by probing** — measured on prod
|
||||
2026-08-26, `GET …/context` with a non-existent build id:
|
||||
|
||||
| | `/api/v1/scripted/…` | `/api/scripted/…` (no route) |
|
||||
|---|---|---|
|
||||
| no key | 401 | 401 |
|
||||
| valid key | 404 | 404 |
|
||||
|
||||
Unauthenticated everything is 401, because the api-key filter runs before routing. Authenticated, the
|
||||
correct path 404s too — the build session does not exist — so the 404 that a wrong path earns is
|
||||
indistinguishable from the one a correct path earns. The bound: this holds **while the build id is
|
||||
not live**. Against a real, open build session the correct path would answer 200 and the difference
|
||||
would show — but that is not the situation you are in when you are probing to find out why nothing
|
||||
works. Confirm the route in `ErsatzTV/Controllers/Api/ScriptedScheduleController.cs`; do not infer it
|
||||
from a status code.
|
||||
|
||||
```
|
||||
# 28 operations, derived from scripted-schedule.json on 2026-08-26 (ersatztv#755)
|
||||
POST add_all {content, fillerKind, customTitle, disableWatermarks}
|
||||
POST add_collection {key, collection, order}
|
||||
POST add_count {content, count, fillerKind, customTitle, disableWatermarks}
|
||||
POST add_duration {content, duration, fallback, trim, discardAttempts, stopBeforeEnd, offlineTail, fillerKind, customTitle, disableWatermarks}
|
||||
POST add_marathon {key, groupBy, itemOrder, guids, searches, playAllItems, shuffleGroups}
|
||||
POST add_multi_collection {key, multiCollection, order}
|
||||
POST add_playlist {key, playlist, playlistGroup}
|
||||
POST add_search {key, query, order}
|
||||
POST add_show {key, guids, order}
|
||||
POST add_smart_collection {key, smartCollection, order}
|
||||
POST create_playlist {key, items}
|
||||
POST graphics_off {graphics}
|
||||
POST graphics_on {graphics, variables}
|
||||
POST pad_to_next {content, minutes, fallback, trim, discardAttempts, stopBeforeEnd, offlineTail, fillerKind, customTitle, disableWatermarks}
|
||||
POST pad_until {content, when, tomorrow, fallback, trim, discardAttempts, stopBeforeEnd, offlineTail, fillerKind, customTitle, disableWatermarks}
|
||||
POST pad_until_exact {content, when, fallback, trim, discardAttempts, stopBeforeEnd, offlineTail, fillerKind, customTitle, disableWatermarks}
|
||||
POST pre_roll_off (no body)
|
||||
POST pre_roll_on {playlist}
|
||||
POST skip_items {content, count}
|
||||
POST skip_to_item {content, season, episode}
|
||||
POST start_epg_group {advance, customTitle}
|
||||
POST stop_epg_group (no body)
|
||||
POST wait_until {when, tomorrow, rewindOnReset}
|
||||
POST wait_until_exact {when, rewindOnReset}
|
||||
POST watermark_off {watermark}
|
||||
POST watermark_on {watermark}
|
||||
GET context (no body)
|
||||
GET peek_next/{content} (no body)
|
||||
```
|
||||
|
||||
Re-derive rather than trusting this table (it is prose and will drift):
|
||||
|
||||
```bash
|
||||
# Absolute path on purpose: this skill is symlinked into ~/server-management and
|
||||
# ~/media-management, where a repo-relative path would not resolve. ~/ersatztv is the
|
||||
# shared checkout and can lag origin/main — use the live-instance form below to see
|
||||
# what is actually deployed.
|
||||
python3 -c "import json;d=json.load(open('$HOME/ersatztv/ErsatzTV/wwwroot/openapi/scripted-schedule.json'));\
|
||||
print('\n'.join(f'{m.upper()} {p}' for p,i in d['paths'].items() for m in i if m in('get','post')))"
|
||||
```
|
||||
|
||||
Without a checkout — straight off the running instance (prod; test is port 8410):
|
||||
|
||||
```bash
|
||||
ssh timothy@192.168.1.29 'curl -s http://localhost:8409/openapi/scripted-schedule.json' \
|
||||
| python3 -c "import json,sys;d=json.load(sys.stdin);\
|
||||
print('\n'.join(f'{m.upper()} {p}' for p,i in d['paths'].items() for m in i if m in('get','post')))"
|
||||
```
|
||||
|
||||
Field lists above are the request-body property names only; consult the spec for types,
|
||||
required-ness and defaults. That omission matters for the three on/off pairs: `graphics_on`/
|
||||
`graphics_off`, `watermark_on`/`watermark_off` and `pre_roll_on`/`pre_roll_off` are **separate
|
||||
operations, not one toggle**, and the difference is not always visible as differing property names.
|
||||
`graphics_*` and `pre_roll_*` differ outright. `watermark_on` and `watermark_off` both list
|
||||
`{watermark}`, but only `on` marks it **required** — `watermark_off` with an **empty** list turns
|
||||
*every* scripted watermark off (`SchedulingEngine.WatermarkOff`: `watermarks.Count == 0` →
|
||||
`ClearChannelWatermarkIds()`; `GraphicsOff` is the same shape). Read the schema, not this table,
|
||||
before sending an `_off`.
|
||||
|
||||
## SQLite DB Operations
|
||||
|
||||
```bash
|
||||
@@ -463,7 +363,7 @@ docker start ersatztv
|
||||
```
|
||||
- **Dispatcharr caches ErsatzTV's XMLTV.** Repointing its DB rows is not enough — it keeps serving a stale EPG full of dead `ersatztv:8409` artwork URLs (breaks Kodi artwork). Force a refresh (EPG source 9):
|
||||
```bash
|
||||
ssh timothy@192.168.1.29 'docker exec dispatcharr python manage.py shell -c \
|
||||
ssh timothy@192.168.1.99 'docker exec dispatcharr python manage.py shell -c \
|
||||
"from apps.epg.tasks import refresh_epg_data; refresh_epg_data(9)"'
|
||||
```
|
||||
- **`/api/health` returns 401** (needs an API key). The Telegraf probe has no `response_string_match`, so ErsatzTV reads as **unhealthy in Grafana** — a false alarm, and **pre-existing**, not caused by the move. The container healthcheck uses the unauthenticated internal `/health` and is unaffected.
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
export const meta = {
|
||||
name: 'ersatztv-issue-build',
|
||||
description: 'Close one ersatztv issue or bundle in its own worktree via PR: claim, recon, implement, local gate, adversarial review before the push, fix loop, single push, PR, closing record',
|
||||
phases: [{ title: 'Recon' }, { title: 'Implement' }, { title: 'Review' }, { title: 'Fix' }, { title: 'Land' }],
|
||||
}
|
||||
|
||||
// args: { issues: [n,...], slug, title, body_summary, done_condition, files_likely, area, size, risk: 'routine'|'rubric',
|
||||
// needs_e2e, port: the slot's ETV_UI_PORT, avoid: [{issues, files}], trailer: 'Co-Authored-By: ...\nClaude-Session: ...',
|
||||
// effort?: 'xhigh' for lock/threading/migration work, model?: override for the implementer/fixer }
|
||||
if (!args || !Array.isArray(args.issues) || !args.issues.length || !args.trailer || !/Claude-Session: \S+/.test(args.trailer) || !(Number.isInteger(args.port) && args.port > 1024 && args.port < 65000)) {
|
||||
return { error: 'args.issues (non-empty), args.trailer (with a Claude-Session: line) and an integer args.port in (1024, 65000) are required' }
|
||||
}
|
||||
const issues = args.issues
|
||||
const ISSUE = issues[0]
|
||||
const REF = issues.map(n => '#' + n).join(', ')
|
||||
const BRANCH = `${issues.join('-')}-${(args.slug || 'work')}`
|
||||
const WT = `/Users/timothy/orca/workspaces/ersatztv/wt-${issues.join('-')}`
|
||||
const SHARED = '/Users/timothy/ersatztv'
|
||||
const API = 'http://192.168.1.95:3000/api/v1/repos/timothy/ersatztv'
|
||||
const big = args.size === 'large'
|
||||
const rubric = args.risk === 'rubric'
|
||||
const implModel = args.model || (args.size === 'small' ? 'sonnet' : 'opus')
|
||||
const implEffort = args.effort || (args.size === 'small' ? 'medium' : 'high')
|
||||
const TRAILER = args.trailer
|
||||
const SESSION_URL = TRAILER.split('\n').filter(l => l.startsWith('Claude-Session:')).map(l => l.replace('Claude-Session: ', '')).join('\n')
|
||||
|
||||
const COMMON = `Project: ersatztv, a fork of the ErsatzTV IPTV channel server (C#/.NET + a React SPA under web/). Shared checkout ${SHARED} is READ-ONLY for you: never commit there and never read its git log or HEAD as truth about main (process.shared-tree-readonly) — origin/main after a fetch is the only truth.
|
||||
Issue(s) ${REF}: "${args.title}".
|
||||
Issue body (condensed by a picker; read the real thing): ${args.body_summary}
|
||||
DONE CONDITION: ${args.done_condition}
|
||||
Read every issue in the bundle and all its comments yourself: curl -s -u "$ETV_GITEA_BASICAUTH" ${API}/issues/${ISSUE} and ${API}/issues/${ISSUE}/comments (the env var is set; never write the credential into a file or a commit).
|
||||
|
||||
Other slots of this session are working IN PARALLEL and will edit these files; do not touch them, and if your fix genuinely needs one of them, stop and report it instead of editing:
|
||||
${JSON.stringify(args.avoid || [], null, 1)}
|
||||
|
||||
Working rules, non-negotiable:
|
||||
- Docs-first is a HARD RULE: read CLAUDE.md, then docs/README.md's task-signal map and ONLY the sections it points to for this task, then docs/contributing.md for the code you touch. Decisions resolve through docs/decisions/README.md by key, never by chasing a file path named in an old comment. Do not reverse-engineer conventions from source before reading these.
|
||||
- Docs-update is part of done, same PR: an endpoint change updates docs/api-conventions.md's checklist and regenerates v1.json + endpoint-index.md via ./scripts/update-openapi.sh (build the app project first, then the script, then npm run generate:api under web/); a screen or route change updates docs/blazor-route-parity.md + docs/domain-model.md; a new or reversed convention gets a record under docs/decisions/records/<area>/ and a regenerated catalog (PYTHONPATH=. python3 scripts/build_decisions_catalog.py — the catalog docs/decisions/README.md is generated and shared with other slots: never hand-edit it, regenerate it, and resolve a rebase conflict in it by regenerating); a new or retitled doc updates docs/README.md.
|
||||
- A TvContext model change needs a migration in BOTH providers: scripts/add-migration.sh <Name>.
|
||||
- Tests are NUnit + Shouldly + NSubstitute in the existing *.Tests projects; vitest under web/. Pin the behaviour with a test that reddens when the fix alone is removed; never set ETV_UPDATE_GOLDENS or ETV_UPDATE_PLAYOUT_GOLDENS.
|
||||
- Dependencies use Central Package Management: versions live only in Directory.Packages.props.
|
||||
- Docs record the end state, never the investigation (docs.no-session-narrative): the path goes in the commit message and the issue comment. Date any measurement you write into a doc.
|
||||
- Gitea labels take their own endpoint: POST ${API}/issues/{n}/labels {"labels":[100]} adds in-progress, DELETE ${API}/issues/{n}/labels/100 removes it; PATCH silently ignores labels.
|
||||
- Never use bare git stash (the stash stack is shared across worktrees; commit WIP instead). Never push to main (it is refused server-side anyway). Never amend or force-push a pushed branch; a fix after the push is a new commit. Never cd out of your worktree except to read the shared checkout read-only.
|
||||
- Kill only PIDs you started; never pkill by name — other sessions run dotnet and Playwright on this machine.`
|
||||
|
||||
const WORKTREE = `Worktree: ${WT} on branch ${BRANCH}. Check git -C ${SHARED} worktree list; if absent: git -C ${SHARED} fetch origin && git -C ${SHARED} worktree add ${WT} -b ${BRANCH} origin/main (absolute path, as written). Then give it its own web/node_modules: if cmp -s ${SHARED}/web/package-lock.json ${WT}/web/package-lock.json then cp -Rc ${SHARED}/web/node_modules ${WT}/web/node_modules, else (cd ${WT}/web && npm ci). Do ALL work inside ${WT}. If git commit is denied by the worktree-owner guard, the worktree belongs to ANOTHER session (orchestrated worktrees carry no marker): never overwrite the marker — STOP and report done=false with the guard's message. Commit as you go; every commit message ends with these trailer lines exactly:
|
||||
${TRAILER}`
|
||||
|
||||
const CLAIM = `CLAIM FIRST, the four-way check from the kickoff (process.parallel-session-claim), for EVERY issue in the bundle: git -C ${SHARED} fetch origin; curl the open PRs (${API}/pulls?state=open&limit=50, page until empty) for a body saying fixes/refs ${REF}; ${issues.map(n => `git -C ${SHARED} ls-remote --heads origin '*${n}*'`).join('; ')}; read each issue's comments for a claim that predates the label. If a PR, branch or comment shows another session already on ${REF} (other than this orchestrator's note, if any), STOP and report done=false with the evidence. Otherwise add the in-progress label and post a claiming comment naming branch ${BRANCH} and worktree ${WT}, on every issue in the bundle. If an issue body has no "## Done-when" section, append one (PATCH ${API}/issues/{n} with the full body): one unticked box per concrete completion criterion drawn from the issue, plus "- [ ] Adversarial review passed". The merge gate derives consent from those boxes; the orchestrator ticks them from your evidence, so write criteria that can be evidenced.`
|
||||
|
||||
const gateFor = (port, where) => `LOCAL GATE (process.local-gate-before-push) — run it inside ${where} and read the real output; a skipped test is not a passing one:
|
||||
- .NET: dotnet build the solution, then dotnet test on every test project that covers what you touched (ErsatzTV.Tests, ErsatzTV.Core.Tests, ErsatzTV.Scanner.Tests, ErsatzTV.FFmpeg.Tests, ErsatzTV.Architecture.Tests — all of them for anything under ErsatzTV.Core). Before any push touching .cs: BOM-check the touched set with od -A n -t x1 -N 3 <file> (efbbbf = BOM) and run bash -c 'dotnet format whitespace . --folder --verify-no-changes --include <files>' (process.bom-format-detection-recipe).
|
||||
- SPA: cd web && npm run check:api && npm run lint && npm run typecheck && npm run build && npm test.
|
||||
- scripts/, .claude/, .husky/, .gitea/: PYTHONPATH=. python3 -m pytest scripts/tests -q, plus ruff check and ruff format --check on any Python you touched. A new executable under scripts/ or .claude/hooks/ needs its row in docs/remote-state-inventory.md and, if it is a guard, in docs/guard-inventory.md — the suites say so.
|
||||
- Docs: python3 scripts/check-doc-narrative.py --diff origin/main and answer what it flags (it is advisory, the rule is not).
|
||||
- Live-E2E${args.needs_e2e ? ' IS REQUIRED for this change (write path or UI)' : ' only if you changed a write path or a screen'}: ETV_UI_PORT=${port} scripts/e2e-local.sh <fresh CONFIG_DIR> — port ${port} is yours; one run at a time in that worktree; curl the endpoints, never a browser tab; when done, kill the PID the launcher printed and nothing else. The launcher's pre-flight refuses a busy port and names the holder: report that, do not pick another port and never kill the holder.
|
||||
- Builds on this Mac are capped at 3–4 concurrent and other slots are building too: run the .NET and web gates sequentially, not in parallel with each other.`
|
||||
const GATE = gateFor(args.port, WT)
|
||||
|
||||
const REPORT_SCHEMA = {
|
||||
type: 'object',
|
||||
required: ['done', 'summary', 'verified', 'left', 'commits', 'head_sha'],
|
||||
properties: {
|
||||
done: { type: 'boolean' },
|
||||
summary: { type: 'string', description: 'what was built, file by file' },
|
||||
verified: { type: 'string', description: 'exact gate commands run and their real output summary (test counts, E2E result)' },
|
||||
left: { type: 'string', description: 'what is not done and why; what the next agent must know' },
|
||||
commits: { type: 'string', description: 'git log --oneline origin/main..HEAD' },
|
||||
pr_url: { type: 'string' },
|
||||
head_sha: { type: 'string', description: 'git rev-parse HEAD of YOUR WORKTREE after your last commit (not a PR head) — the finisher derives fix commits from these' },
|
||||
patch_changed: { type: 'boolean', description: 'finisher only: true if the pre-push rebase changed the patch-id (a conflict resolved or an artifact regenerated)' },
|
||||
},
|
||||
}
|
||||
const FINDINGS_SCHEMA = {
|
||||
type: 'object', required: ['findings', 'verdict'],
|
||||
properties: {
|
||||
verdict: { type: 'string', enum: ['merge', 'send-back'] },
|
||||
findings: { type: 'array', items: { type: 'object', required: ['severity', 'file', 'summary', 'evidence'], properties: {
|
||||
severity: { type: 'string', enum: ['blocking', 'should-fix', 'nit'] }, file: { type: 'string' }, summary: { type: 'string' }, evidence: { type: 'string' } } } },
|
||||
},
|
||||
}
|
||||
const RUNNER_SCHEMA = {
|
||||
type: 'object', required: ['findings', 'verdict', 'ran'],
|
||||
properties: {
|
||||
ran: { type: 'boolean', description: 'false if codex produced no VERDICT line — required, because the fallback branches on it' },
|
||||
verdict: FINDINGS_SCHEMA.properties.verdict, findings: FINDINGS_SCHEMA.properties.findings,
|
||||
},
|
||||
}
|
||||
const LAND_SCHEMA = {
|
||||
type: 'object', required: REPORT_SCHEMA.required.concat(['patch_changed']),
|
||||
properties: REPORT_SCHEMA.properties,
|
||||
}
|
||||
const RECON_SCHEMA = {
|
||||
type: 'object', required: ['plan', 'facts', 'risks', 'test_plan'],
|
||||
properties: {
|
||||
plan: { type: 'string', description: 'files, handlers, components, signatures, exact edits' },
|
||||
facts: { type: 'string', description: 'what the docs the task-signal map names and the existing code say, with paths and decision keys' },
|
||||
risks: { type: 'string' }, test_plan: { type: 'string', description: 'tests to add and the gate or E2E route that proves the done condition' },
|
||||
},
|
||||
}
|
||||
|
||||
let recon = null
|
||||
if (big) {
|
||||
phase('Recon')
|
||||
recon = await agent(`${COMMON}
|
||||
|
||||
You are the recon agent. Read-only, in ${SHARED}. Read the docs the task-signal map names for this task, then find every fact an implementer needs to close ${REF} without re-deriving it: the exact handlers, components, signatures, call sites and guards, the existing tests, and which gate or E2E route proves the done condition. For a multi-site sweep use the csharp-lsp MCP tools, not the LSP tool (docs/local-lsp-tooling.md). Produce a concrete plan.`,
|
||||
{ label: 'recon', model: 'opus', effort: 'high', schema: RECON_SCHEMA })
|
||||
}
|
||||
|
||||
phase('Implement')
|
||||
const impl = await agent(`${COMMON}
|
||||
|
||||
${WORKTREE}
|
||||
|
||||
${CLAIM}
|
||||
|
||||
${recon ? `Recon (verify what you rely on):\nPLAN: ${recon.plan}\nFACTS: ${recon.facts}\nRISKS: ${recon.risks}\nTEST PLAN: ${recon.test_plan}\n` : ''}
|
||||
You are the implementer. Close ${REF} completely: pin the behaviour with tests named for the branch they protect, update the docs the change obligates, commit. Then git fetch origin and rebase onto origin/main if it moved (never merge main in; regenerate generated artifacts), run the LOCAL GATE and STOP — do not push; reviewers read your worktree first, and a finisher pushes once after the review loop is clean. ${GATE}
|
||||
Report done=true with the gate output when the worktree is ready for review, with pr_url empty and head_sha = git rev-parse HEAD of the worktree after your last commit.`,
|
||||
{ label: `impl:${REF}`, model: implModel, effort: implEffort, schema: REPORT_SCHEMA })
|
||||
if (!impl) return { issues, error: 'implementer returned nothing' }
|
||||
if (!impl.done) return { issues, error: 'implementer stopped', impl }
|
||||
|
||||
const reviewCommon = (e2ePort) => `${COMMON}
|
||||
|
||||
${gateFor(e2ePort, 'your own isolated worktree (never ' + WT + ')')}
|
||||
|
||||
Worktree ${WT}, branch ${BRANCH}, not yet pushed; diff: git -C ${WT} diff origin/main...HEAD. Read-only except scratch you create under /private/tmp; do not commit or push. NEVER run rm -rf, git worktree remove, git branch -D or any delete outside a directory you created under /private/tmp this session, and never build a path with .. segments. If you must build or run tests, do it in your own isolated worktree, never in ${WT}: git fetch ${WT} ${BRANCH} && git checkout --detach FETCH_HEAD puts the unpushed branch there; run the .NET and web gates sequentially — other slots are building; E2E there on port ${e2ePort} (the GATE above is written for your worktree and that port).`
|
||||
|
||||
const LENSES = [
|
||||
{ key: 'correctness', model: 'opus', isolation: 'worktree', prompt: 'correctness against the done condition: run the gate and, for a write path or screen, the live-E2E route yourself, and read the output; try to break the change with the edge cases the issue and the docs name; check the pinning test actually reddens when the fix alone is reverted (mutate the clause, not the file).' },
|
||||
{ key: 'conformance', model: 'sonnet', prompt: 'repo conformance: docs-update obligations met in this diff (endpoint → api-conventions + regenerated v1.json/endpoint-index; screen/route → blazor-route-parity + domain-model; convention → decision record + regenerated catalog; new doc → README index); no narrative in docs; every new script or hook has its inventory row; CPM respected; both-provider migration if the model changed; tests are NUnit/vitest in the existing projects; no BOM in touched .cs; no edit to a file another slot owns (listed above); commit trailers present; branch rebased on current origin/main; nothing pushed yet.' },
|
||||
]
|
||||
let xfamilyFailedRound = null
|
||||
let xfamily = rubric ? 'codex' : 'not required (routine risk class under process.independent-review-rubric)'
|
||||
async function codexRunner(round) {
|
||||
const r = await agent(`${reviewCommon(Number(args.port) + 3)}
|
||||
|
||||
You run the cross-family review — the diff touches a class where process.independent-review-rubric requires a reviewer from another model family, and you are only the runner. Write a prompt file under a directory you create in /private/tmp asking for an adversarial correctness and security review of the diff of branch ${BRANCH} against origin/main in ${WT} for issue(s) ${REF} with done condition "${args.done_condition}", listing findings as blocking / should-fix / nit with file and evidence, ending with a line VERDICT: merge or VERDICT: send-back. Run it EXACTLY like this, in the background, output to a file, stdin from /dev/null (it hangs otherwise): codex exec -C ${WT} -s read-only "$(cat <prompt>)" < /dev/null > <out> 2>&1 — then wait for the process to exit (poll pgrep on its PID with Monitor; measured 2026-07-28 in the #672 session, a real review took ~35 minutes for a 7-file diff) and read the file. Return its findings faithfully in the schema with ran=true; if the file has no VERDICT line the run failed (quota, tool error) — return ran=false, verdict merge, no findings, and put the file's tail in a single nit finding so the failure is visible; never invent a verdict.`,
|
||||
{ label: `review:codex:r${round}`, phase: 'Review', model: 'sonnet', effort: 'low', schema: RUNNER_SCHEMA })
|
||||
return r
|
||||
}
|
||||
async function codexFallback(round, r) {
|
||||
xfamily = `codex could not run in round ${round} (${r ? 'no VERDICT line' : 'runner returned nothing'}); substituted a cold same-family review-only agent per process.independent-review-rubric — retry cross-family next window`
|
||||
log(`${REF}: ${xfamily}`)
|
||||
return agent(`${reviewCommon(Number(args.port) + 2)}
|
||||
|
||||
You are a COLD, review-only substitute for a cross-family reviewer that could not run. You have seen none of this branch before. Lens: adversarial correctness AND security of the diff against the done condition — the classes process.independent-review-rubric names (locks/concurrency, auth/security, API write paths, migrations, large C# diffs). Run the gate in your own worktree and read the output; report only what you verified, with evidence. blocking = done condition or a repo rule violated; should-fix = real defect; nit = style. Verdict send-back if any blocking.`,
|
||||
{ label: `review:fallback:r${round}`, phase: 'Review', model: 'opus', effort: 'high', isolation: 'worktree', schema: FINDINGS_SCHEMA })
|
||||
}
|
||||
async function review(round) {
|
||||
// Per round, like blocking/sendBack: a substitute that failed in round 1 says nothing about the tree
|
||||
// that lands after round 2, and a stale xfamily string must never reach the PR body.
|
||||
xfamilyFailedRound = null
|
||||
xfamily = rubric ? 'codex' : 'not required (routine risk class under process.independent-review-rubric)'
|
||||
// The Codex runner builds nothing, so it may run beside the lenses; the FALLBACK is a second
|
||||
// worktree-isolated .NET reviewer and starts only after both lenses have returned.
|
||||
const runnerPromise = rubric ? codexRunner(round).catch(() => null) : Promise.resolve(null)
|
||||
const lenses = (await parallel(LENSES.map(l => () => agent(`${reviewCommon(Number(args.port) + 1)}
|
||||
|
||||
Review round ${round} of the branch for ${REF}. Lens: ${l.prompt}
|
||||
Be adversarial; report only what you verified, with evidence. blocking = done condition or a repo rule violated, or a test that passes for the wrong reason; should-fix = real defect; nit = style. Verdict send-back if any blocking.`,
|
||||
{ label: `review:${l.key}:r${round}`, phase: 'Review', model: l.model, effort: 'high', isolation: l.isolation, schema: FINDINGS_SCHEMA })))).filter(Boolean)
|
||||
if (!rubric) return lenses
|
||||
const r = await runnerPromise
|
||||
if (r && r.ran === true) return lenses.concat([r])
|
||||
let fb = null
|
||||
try { fb = await codexFallback(round, r) } catch (e) { log(`${REF}: fallback reviewer threw: ${e && e.message}`) }
|
||||
if (!fb) { xfamily += ` — the substitute ALSO failed in round ${round}; no cross-family-equivalent review ran`; xfamilyFailedRound = round }
|
||||
return fb ? lenses.concat([fb]) : lenses
|
||||
}
|
||||
|
||||
let round = 1
|
||||
const actionable = rs => rs.flatMap(r => r.findings.filter(f => f.severity === 'blocking' || f.severity === 'should-fix'))
|
||||
const countBy = (rs, sev) => rs.flatMap(r => r.findings).filter(f => f.severity === sev).length
|
||||
let knownHead = impl.head_sha
|
||||
let reviews = (await review(round)).filter(Boolean)
|
||||
if (!reviews.length) return { issues, error: `review round ${round} produced no reviews (every lens failed); not pushed`, history: [] }
|
||||
let blocking = reviews.flatMap(r => r.findings.filter(f => f.severity === 'blocking'))
|
||||
let sendBack = actionable(reviews)
|
||||
const history = [{ round, reviews, fix: null, fix_range: null }]
|
||||
while (sendBack.length && round < 3) {
|
||||
log(`${REF} round ${round}: ${blocking.length} blocking, ${sendBack.length - blocking.length} should-fix — sending back`)
|
||||
const fix = await agent(`${COMMON}
|
||||
|
||||
${WORKTREE}
|
||||
|
||||
You are the fixer. Reviewers found these problems in the unpushed branch; fix every blocking and should-fix one as new commits, or show with evidence why a finding is wrong:
|
||||
${JSON.stringify(reviews.flatMap(r => r.findings.filter(f => f.severity !== 'nit')), null, 1)}
|
||||
Then re-run the LOCAL GATE and STOP without pushing; the reviewers read the worktree again. ${GATE}
|
||||
Report, with head_sha = git rev-parse HEAD of the worktree after your last commit.`,
|
||||
{ label: `fix:r${round}`, phase: 'Fix', model: implModel, effort: implEffort, schema: REPORT_SCHEMA })
|
||||
if (!fix || !fix.done) return { issues, error: `fixer for round ${round} ${fix ? 'stopped' : 'returned nothing'}; not pushed`, fix, history }
|
||||
history[history.length - 1].fix = fix
|
||||
history[history.length - 1].fix_range = fix.head_sha && fix.head_sha !== knownHead ? `${knownHead}..${fix.head_sha}` : null
|
||||
knownHead = fix.head_sha || knownHead
|
||||
round++
|
||||
reviews = (await review(round)).filter(Boolean)
|
||||
if (!reviews.length) return { issues, error: `review round ${round} produced no reviews (every lens failed); not pushed`, history }
|
||||
blocking = reviews.flatMap(r => r.findings.filter(f => f.severity === 'blocking'))
|
||||
sendBack = actionable(reviews)
|
||||
history.push({ round, reviews, fix: null, fix_range: null })
|
||||
}
|
||||
if (blocking.length) return { issues, error: 'blocking findings after two fix rounds; not pushed', blocking_remaining: blocking, history }
|
||||
if (sendBack.length) return { issues, error: 'should-fix findings still open after two fix rounds; not pushed — the orchestrator decides', should_fix_remaining: sendBack, history }
|
||||
if (xfamilyFailedRound) return { issues, error: `the cross-family runner and its substitute both failed in round ${xfamilyFailedRound}; not pushed`, cross_family: xfamily, history }
|
||||
const FIX_RANGES = history.map(h => h.fix_range).filter(Boolean)
|
||||
const REVIEW_HISTORY = history.map(h => `round ${h.round}: ${h.reviews.length} lens(es); ${countBy(h.reviews, 'blocking')} blocking, ${countBy(h.reviews, 'should-fix')} should-fix, ${countBy(h.reviews, 'nit')} nit` + (h.fix ? (h.fix_range ? `; answered by the fix commit(s) in git log --oneline ${h.fix_range}` : '; answered without a new commit (findings refuted with evidence in the fixer report)') : '; clean — loop ended')).join('\n')
|
||||
|
||||
phase('Land')
|
||||
const FINISH = `FINISH, in this order. Record the patch-id first: git diff $(git merge-base origin/main HEAD)..HEAD | git patch-id --stable. Then git fetch origin; if origin/main moved, rebase onto it (never merge main in; regenerate, never hand-resolve, generated artifacts — the decisions catalog by its generator), re-run the LOCAL GATE, and recompute the patch-id: report patch_changed=true if it differs. ${GATE}
|
||||
Then ONE push: git push -u origin ${BRANCH}. Open the PR with the Gitea API (POST ${API}/pulls; head=${BRANCH}, base=main, title, body). The body must contain "fixes #N" for every issue in the bundle so the merge closes them, the root cause for a bug fix, the measured numbers, the review history VERBATIM as recorded by the workflow, one line per round, between the markers <<REVIEW HISTORY and REVIEW HISTORY>>:
|
||||
<<REVIEW HISTORY
|
||||
${REVIEW_HISTORY}
|
||||
REVIEW HISTORY>>
|
||||
${FIX_RANGES.length ? `followed by what each fix commit changed, read from git show and not from memory, for exactly the commits git log --oneline lists in these ranges: ${FIX_RANGES.join('; ')}` : (history.some(h => h.fix) ? 'and a sentence saying every finding was answered without a new commit, as the history block records' : 'and a sentence saying no fix commit exists because round one was clean')}, then the cross-family review status verbatim — "${xfamily}" — and every deliberately-left item with an issue number (file follow-up issues where needed). End the body with:
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
|
||||
${SESSION_URL}
|
||||
Arm the CI monitor: note the head sha and read ${API}/commits/<sha>/status once. Then the closing-an-issue skill (invoke it through the Skill tool if you have it, otherwise read .claude/skills/closing-an-issue/SKILL.md) with two modifications: do NOT close the issue — the merge closes it — and do NOT tick any "## Done-when" box; instead the "## Closing record" comment you post on each issue, linking the PR, ends with a "Done-when evidence" list giving, for every box, the command or artifact that evidences it — the orchestrator ticks from that. Remove nothing; the orchestrator removes the worktree after the merge. Report the PR URL, the head sha and patch_changed.`
|
||||
const land = await agent(`${COMMON}
|
||||
|
||||
${WORKTREE}
|
||||
|
||||
You are the finisher. The branch has passed its review loop (${round} round(s)); nothing is pushed yet. ${FINISH}`,
|
||||
{ label: `land:${REF}`, model: 'sonnet', effort: 'medium', schema: LAND_SCHEMA })
|
||||
if (!land || !land.done) return { issues, error: 'finisher stopped', land, history }
|
||||
if (!land.pr_url || !land.head_sha) return { issues, error: 'finisher reported done without a PR URL or head sha — the branch may already be pushed; read its report before re-running', land, history }
|
||||
log(`${REF} PR: ${land.pr_url || 'none'} @ ${land.head_sha || '?'}${land.patch_changed ? ' (patch changed by the pre-push rebase)' : ''}`)
|
||||
let post_rebase_reviews = null
|
||||
if (land.patch_changed) {
|
||||
log(`${REF}: patch changed on rebase — one more review round on the pushed head before any verdict`)
|
||||
round++
|
||||
post_rebase_reviews = (await review(round)).filter(Boolean)
|
||||
if (!post_rebase_reviews.length) return { issues, error: 'the post-rebase review round produced no reviews (every lens failed); pushed, no verdict may be posted', pr_url: land.pr_url, head_sha: land.head_sha, cross_family: xfamily, history }
|
||||
const late = actionable(post_rebase_reviews)
|
||||
if (late.length) return { issues, error: 'blocking or should-fix findings on the pushed head after the pre-push rebase; no verdict may be posted', pr_url: land.pr_url, head_sha: land.head_sha, findings_remaining: late, cross_family: xfamily, history, post_rebase_reviews }
|
||||
}
|
||||
return { issues, pr_url: land.pr_url, head_sha: land.head_sha, patch_changed: !!land.patch_changed, cross_family: xfamily, impl, land, history, post_rebase_reviews }
|
||||
@@ -1,52 +0,0 @@
|
||||
export const meta = {
|
||||
name: 'ersatztv-pick-next',
|
||||
description: 'Pick the next N ersatztv issues by the kickoff queue rules from scripts/select-queue.sh and live Gitea state, mutually non-colliding and avoiding what other slots hold, then adversarially verify the set',
|
||||
phases: [{ title: 'Pick' }, { title: 'Refute' }],
|
||||
}
|
||||
// args: { taken: [{issues:[n], files:[...]}], closed: [n...], notes: 'free text', count: how many picks to return (default 3) }
|
||||
const taken = (args && args.taken) || []
|
||||
const closed = (args && args.closed) || []
|
||||
const notes = (args && args.notes) || ''
|
||||
const count = (args && args.count) || 3
|
||||
const RULES = `Work read-only in /Users/timothy/ersatztv (the shared checkout; do not modify files, push, label or comment). Never read its git log or HEAD as truth about main: run git -C /Users/timothy/ersatztv fetch origin first, then read origin/main.
|
||||
Read docs/handoffs/chicorytv-issue-queue.md fully — "Current phase", "Two concurrent tracks", the Selection and Bundles rules, and step 3's four-way claim check — and docs/handoffs/orchestration.md.
|
||||
Ranking is NOT yours to derive: run ETV_GITEA_BASICAUTH="$ETV_GITEA_BASICAUTH" scripts/select-queue.sh 40 (the env var is already set) and take its order as given. It already excludes in-progress, parked, PRs, bot-authored issues and anything with an open blocker. Resolve only its CLAIM? and UMBRELLA? flags, by reading the flagged issue's body and comments.
|
||||
Gitea REST: base http://192.168.1.95:3000/api/v1/repos/timothy/ersatztv, auth -u "$ETV_GITEA_BASICAUTH", curl only. Issue: GET /issues/{n}; comments: GET /issues/{n}/comments; open PRs: GET /pulls?state=open&limit=50 (page until a page comes back empty — the endpoint caps limit at 50). Remote branches naming an issue: git -C /Users/timothy/ersatztv ls-remote --heads origin '*<n>*'.
|
||||
A pick is claimable only if the four-way check is clean: no open PR whose body says fixes/refs #n, no remote branch naming n, no claiming comment on the issue (a claim can precede the label), and the issue is still open after the fetch.
|
||||
Bundles: after choosing an issue, scan its milestone, its cross-references and its labels for small independent siblings that are cheap to sweep in the same worktree; a bundle is one pick with several issue numbers. Never bundle issues that a taken slot already holds.
|
||||
ALREADY TAKEN by this orchestrator (in flight, with the files each edits): ${JSON.stringify(taken)}
|
||||
Closed this session: ${JSON.stringify(closed)}
|
||||
Orchestrator notes: ${notes}`
|
||||
const PICK = { type: 'object', required: ['issues', 'title', 'slug', 'rationale', 'body_summary', 'done_condition', 'files_likely', 'area', 'size', 'risk', 'needs_e2e', 'skipped'], properties: {
|
||||
issues: { type: 'array', items: { type: 'integer' } }, title: { type: 'string' },
|
||||
slug: { type: 'string', description: 'short kebab-case branch slug, e.g. null-font-family' },
|
||||
rationale: { type: 'string' },
|
||||
body_summary: { type: 'string', description: 'body plus all comments, condensed but complete; include the Done-when section verbatim if the issue has one' },
|
||||
done_condition: { type: 'string' },
|
||||
files_likely: { type: 'array', items: { type: 'string' } },
|
||||
area: { type: 'string', enum: ['spa', 'api', 'core', 'scanner', 'ffmpeg', 'ci', 'scripts', 'docs', 'mixed'] },
|
||||
size: { type: 'string', enum: ['small', 'medium', 'large'] },
|
||||
risk: { type: 'string', enum: ['routine', 'rubric'], description: 'rubric = touches locks/concurrency, auth/security, an API write-path handler, a DB migration, or will exceed ~150 changed C# lines (process.independent-review-rubric); needs a cross-family review' },
|
||||
needs_e2e: { type: 'boolean', description: 'true for a write path or UI change (testing.live-e2e-prepush-timing)' },
|
||||
skipped: { type: 'string', description: 'each higher-ranked issue skipped and the reason' } } }
|
||||
const SCHEMA = { type: 'object', required: ['picks'], properties: { picks: { type: 'array', items: PICK, description: 'in queue order; each later pick avoids the files of every earlier one' } } }
|
||||
const VERDICT = { type: 'object', required: ['refuted', 'reason'], properties: { refuted: { type: 'boolean' }, reason: { type: 'string' }, bad_picks: { type: 'array', items: { type: 'integer' }, description: 'issue numbers of the picks that fail, if not all' }, better: { type: 'array', items: { type: 'integer' } } } }
|
||||
phase('Pick')
|
||||
const res = await agent(`${RULES}
|
||||
|
||||
Walk the selector's order and return up to ${count} issues or natural bundles, in that order, each of which (a) passes the four-way claim check, (b) edits no file a taken slot OR AN EARLIER PICK edits, (c) does not depend on another open issue (an earlier pick counts as open; a blocked-by dependency the selector already dropped), (d) is not a screen, handler or script an earlier pick is already on, (e) is not needs-hands or needs-the-user in disguise (a live-prod measurement nobody can take from here, a design question the body leaves open). Read each candidate's body and comments before accepting or rejecting it. Size is not a reason to skip: a large issue at the top of the queue is a pick, say size=large. Classify risk honestly — a write-path handler is rubric even when the diff is small. Stop early if the eligible queue runs out and say so in the last pick's skipped field; fewer than ${count} is fine, a colliding pair is not.`, { label: 'picker', model: 'sonnet', effort: 'medium', schema: SCHEMA })
|
||||
const picks = (res && res.picks) || []
|
||||
if (!picks.length) return { picks: [], refutations: [], note: 'the picker returned no eligible pick', raw: res }
|
||||
log('picks: ' + picks.map(p => '#' + p.issues.join('+#')).join(', '))
|
||||
phase('Refute')
|
||||
const desc = picks.map(p => `- #${p.issues.join(', #')} "${p.title}" (size ${p.size}, risk ${p.risk}, area ${p.area}, e2e ${p.needs_e2e}). Rationale: ${p.rationale}. Files: ${p.files_likely.join(', ')}. Skipped: ${p.skipped}`).join('\n')
|
||||
const votes = await parallel([
|
||||
'ordering and claims: re-run scripts/select-queue.sh and the four-way claim check on every pick; refute if a higher-ranked eligible issue was skipped without a valid reason, the picks are out of selector order, or a pick is already claimed by a PR, branch or comment',
|
||||
'collisions and classification: read the code each pick will touch; refute if any pick edits a file a taken slot or another pick edits, or the same docs section, or depends on an open issue; also refute a risk=routine pick that touches a lock, auth, an API write-path handler or a migration, and a needs_e2e=false pick that changes a write path or a screen',
|
||||
].map((lens, i) => () =>
|
||||
agent(`${RULES}
|
||||
|
||||
Picks, in order:
|
||||
${desc}
|
||||
Lens: ${lens}. Try to refute; name the failing picks in bad_picks and a better ordering in better.`, { label: `refute:${i}`, model: 'sonnet', effort: 'medium', schema: VERDICT })))
|
||||
return { picks, refutations: votes.filter(Boolean).filter(v => v.refuted) }
|
||||
@@ -1,204 +0,0 @@
|
||||
export const meta = {
|
||||
name: 'ersatztv-resume-branch',
|
||||
description: 'Resume a paused ersatztv branch: finish or fix, rebase onto origin/main, local gate, adversarial review, fix loop, push, PR body and closing record refreshed',
|
||||
phases: [{ title: 'Work' }, { title: 'Review' }, { title: 'Fix' }, { title: 'Land' }],
|
||||
}
|
||||
|
||||
// args: { issues, branch, wt, pr (number or ''), mode: 'fix'|'implement', title, risk: 'routine'|'rubric', needs_e2e,
|
||||
// port: the slot's ETV_UI_PORT, trailer, brief: path to a JSON file holding done_condition, findings, recon, context }
|
||||
if (!args || !Array.isArray(args.issues) || !args.issues.length || !args.trailer || !/Claude-Session: \S+/.test(args.trailer) || !(Number.isInteger(args.port) && args.port > 1024 && args.port < 65000) || !args.wt || !args.branch || !args.brief) {
|
||||
return { error: 'args.issues (non-empty), trailer (with a Claude-Session: line), an integer port in (1024, 65000), wt, branch and brief are required' }
|
||||
}
|
||||
const issues = args.issues
|
||||
const REF = issues.map(n => '#' + n).join(', ')
|
||||
const WT = args.wt
|
||||
const BRANCH = args.branch
|
||||
const SHARED = '/Users/timothy/ersatztv'
|
||||
const API = 'http://192.168.1.95:3000/api/v1/repos/timothy/ersatztv'
|
||||
const rubric = args.risk === 'rubric'
|
||||
const TRAILER = args.trailer
|
||||
const SESSION_URL = TRAILER.split('\n').filter(l => l.startsWith('Claude-Session:')).map(l => l.replace('Claude-Session: ', '')).join('\n')
|
||||
|
||||
const COMMON = `Project: ersatztv, a fork of the ErsatzTV IPTV channel server (C#/.NET + a React SPA under web/). Shared checkout ${SHARED} is READ-ONLY for you: never commit there and never read its git log or HEAD as truth about main (process.shared-tree-readonly) — origin/main after a fetch is the only truth.
|
||||
Issue(s) ${REF}: "${args.title}".
|
||||
YOUR BRIEF is the JSON file ${args.brief}: read it first with cat. It holds done_condition, context from the orchestrator, findings (the last review round) and recon where they apply.
|
||||
Read every issue in the bundle and all its comments: curl -s -u "$ETV_GITEA_BASICAUTH" ${API}/issues/N and ${API}/issues/N/comments (the env var is set; never write the credential into a file or a commit).
|
||||
|
||||
Working rules, non-negotiable:
|
||||
- Docs-first is a HARD RULE: read CLAUDE.md, then docs/README.md's task-signal map and ONLY the sections it points to for this task, then docs/contributing.md for the code you touch. Decisions resolve through docs/decisions/README.md by key.
|
||||
- Docs-update is part of done, same PR: an endpoint change updates docs/api-conventions.md's checklist and regenerates v1.json + endpoint-index.md via ./scripts/update-openapi.sh (build the app project first, then the script, then npm run generate:api under web/); a screen or route change updates docs/blazor-route-parity.md + docs/domain-model.md; a convention gets a record under docs/decisions/records/<area>/ and a regenerated catalog (PYTHONPATH=. python3 scripts/build_decisions_catalog.py — the catalog is generated and shared with other slots: never hand-edit it, regenerate it, and resolve a rebase conflict in it by regenerating); a new doc updates docs/README.md. A TvContext change needs both providers' migrations via scripts/add-migration.sh.
|
||||
- Tests are NUnit + Shouldly + NSubstitute; vitest under web/. Never set ETV_UPDATE_GOLDENS or ETV_UPDATE_PLAYOUT_GOLDENS. Dependencies only in Directory.Packages.props.
|
||||
- Docs record the end state, never the investigation; the path goes in the commit message.
|
||||
- Gitea labels: POST ${API}/issues/{n}/labels {"labels":[100]} / DELETE ${API}/issues/{n}/labels/100; PATCH ignores labels.
|
||||
- Never use bare git stash. Never push to main. The ONLY sanctioned rewrite of a pushed branch is a rebase onto origin/main pushed with --force-with-lease (process.orchestrated-session); a fix is a new commit, never an amend. Never cd out of the worktree except to read the shared checkout read-only. Kill only PIDs you started.
|
||||
Worktree: ${WT} on branch ${BRANCH}; it exists, do ALL work inside it. Give it its own web/node_modules if missing (cp -Rc from ${SHARED}/web when the lockfiles match, else npm ci). If git commit is denied by the worktree-owner guard, the worktree belongs to ANOTHER session (orchestrated worktrees carry no marker): never overwrite the marker — STOP and report done=false with the guard's message. Every commit message ends with these trailer lines exactly:
|
||||
${TRAILER}`
|
||||
|
||||
const gateFor = (port, where) => `LOCAL GATE (process.local-gate-before-push) — inside ${where}, real output, a skipped test is not a pass:
|
||||
- .NET: dotnet build, then dotnet test on every test project covering what the branch touches (all of them for anything under ErsatzTV.Core); BOM-check touched .cs with od -A n -t x1 -N 3 and bash -c 'dotnet format whitespace . --folder --verify-no-changes --include <files>'.
|
||||
- SPA: cd web && npm run check:api && npm run lint && npm run typecheck && npm run build && npm test.
|
||||
- scripts/, .claude/, .husky/, .gitea/: PYTHONPATH=. python3 -m pytest scripts/tests -q, plus ruff on touched Python.
|
||||
- Docs: python3 scripts/check-doc-narrative.py --diff origin/main.
|
||||
- Live-E2E${args.needs_e2e ? ' IS REQUIRED (write path or UI)' : ' only for a write path or screen change'}: ETV_UI_PORT=${port} scripts/e2e-local.sh <fresh CONFIG_DIR> — port ${port} is yours; one run at a time in that worktree; curl, never a browser tab; kill the PID the launcher printed when done and nothing else; a busy port is reported, never taken over.
|
||||
- Run the .NET and web gates sequentially; other slots are building.`
|
||||
const GATE = gateFor(args.port, WT)
|
||||
|
||||
const REBASE = `Rebase onto origin/main FIRST: git fetch origin; git rebase origin/main; resolve conflicts faithfully, keeping both sides' intent; regenerate generated artifacts rather than hand-resolving them. A commit titled "WIP: orchestrator checkpoint" holds uncommitted work from the paused session and must be folded into the commit it belongs to, never left in history — if it sits directly on that commit: git reset --soft HEAD~1 && git commit --amend --no-edit; otherwise: git commit --fixup=<target> is already its shape, so GIT_SEQUENCE_EDITOR=true git rebase --autosquash <target>~1 folds it non-interactively.`
|
||||
|
||||
const REPORT_SCHEMA = {
|
||||
type: 'object', required: ['done', 'summary', 'verified', 'left', 'commits', 'head_sha'],
|
||||
properties: {
|
||||
done: { type: 'boolean' }, summary: { type: 'string' },
|
||||
verified: { type: 'string', description: 'exact gate commands run and their real output summary' },
|
||||
left: { type: 'string' }, commits: { type: 'string', description: 'git log --oneline origin/main..HEAD' }, pr_url: { type: 'string' }, head_sha: { type: 'string', description: 'git rev-parse HEAD of YOUR WORKTREE after your last commit (not a PR head) — the finisher derives fix commits from these' },
|
||||
patch_changed: { type: 'boolean', description: 'finisher only: true if a second rebase before the push changed the patch-id' },
|
||||
},
|
||||
}
|
||||
const FINDINGS_SCHEMA = {
|
||||
type: 'object', required: ['findings', 'verdict'],
|
||||
properties: {
|
||||
verdict: { type: 'string', enum: ['merge', 'send-back'] },
|
||||
findings: { type: 'array', items: { type: 'object', required: ['severity', 'file', 'summary', 'evidence'], properties: {
|
||||
severity: { type: 'string', enum: ['blocking', 'should-fix', 'nit'] }, file: { type: 'string' }, summary: { type: 'string' }, evidence: { type: 'string' } } } },
|
||||
},
|
||||
}
|
||||
|
||||
const RUNNER_SCHEMA = {
|
||||
type: 'object', required: ['findings', 'verdict', 'ran'],
|
||||
properties: {
|
||||
ran: { type: 'boolean', description: 'false if codex produced no VERDICT line — required, because the fallback branches on it' },
|
||||
verdict: FINDINGS_SCHEMA.properties.verdict, findings: FINDINGS_SCHEMA.properties.findings,
|
||||
},
|
||||
}
|
||||
const LAND_SCHEMA = {
|
||||
type: 'object', required: REPORT_SCHEMA.required.concat(['patch_changed']),
|
||||
properties: REPORT_SCHEMA.properties,
|
||||
}
|
||||
|
||||
phase('Work')
|
||||
let work
|
||||
if (args.mode === 'implement') {
|
||||
work = await agent(`${COMMON}
|
||||
|
||||
You are the implementer, continuing a paused session. Read git log and git show for the branch's commits first; a WIP checkpoint commit is the paused implementer's partial edit. ${REBASE} The brief's recon is a plan; verify what you rely on. Finish the done condition completely, with a regression test that reddens against the unfixed code. Run the LOCAL GATE and STOP without pushing; reviewers read the worktree first; report head_sha = git rev-parse HEAD of the worktree after your last commit. ${GATE}`,
|
||||
{ label: `impl:${REF}`, model: 'opus', effort: 'high', schema: REPORT_SCHEMA })
|
||||
} else {
|
||||
work = await agent(`${COMMON}
|
||||
|
||||
You are the fixer, continuing a paused session. The PR is #${args.pr}. ${REBASE} Then the brief's findings are the last review round's: fix every blocking and should-fix one as new commits, or show with evidence why a finding is wrong. Run the LOCAL GATE and STOP without pushing; reviewers read the worktree first; report head_sha = git rev-parse HEAD of the worktree after your last commit. ${GATE}`,
|
||||
{ label: `fix:${REF}`, model: 'opus', effort: 'high', schema: REPORT_SCHEMA })
|
||||
}
|
||||
if (!work) return { issues, error: 'work agent returned nothing' }
|
||||
if (!work.done) return { issues, error: 'work agent stopped', work }
|
||||
|
||||
const reviewCommon = (e2ePort) => `${COMMON}
|
||||
|
||||
${gateFor(e2ePort, 'your own isolated worktree (never ' + WT + ')')}
|
||||
|
||||
Diff: git -C ${WT} diff origin/main...HEAD (rebased, not yet pushed). ${args.pr ? `PR #${args.pr} exists: its pushed head, its body and any earlier closing record are INTENTIONALLY behind this worktree until the finisher pushes after this review loop and resyncs them — a stale PR head or body is not a finding, and neither is "not pushed".` : 'No PR exists yet; the finisher opens it after this loop.'} Read-only except scratch you create under /private/tmp; do not commit or push. NEVER run rm -rf, git worktree remove, git branch -D or any delete outside a directory you created under /private/tmp this session, and never build a path with .. segments. If you must build or test, do it in your own isolated worktree, never in ${WT}: git fetch ${WT} ${BRANCH} && git checkout --detach FETCH_HEAD puts the branch there; gates sequentially; E2E there on port ${e2ePort} (the GATE above is written for your worktree and that port).`
|
||||
|
||||
const LENSES = [
|
||||
{ key: 'correctness', model: 'opus', isolation: 'worktree', prompt: 'correctness against the done condition: run the gate and, for a write path or screen, the live-E2E route yourself, and read the output; try to break the change with the edge cases the issue and the docs name; check the pinning test reddens when the fix alone is reverted.' },
|
||||
{ key: 'conformance', model: 'sonnet', prompt: 'repo conformance: docs-update obligations met; no narrative in docs; inventory rows for new scripts/hooks; CPM respected; both-provider migration if the model changed; no BOM in touched .cs; no WIP commit left in history; branch rebased on current origin/main; commit trailers present; PR body will carry fixes #N for each issue.' },
|
||||
]
|
||||
let xfamilyFailedRound = null
|
||||
let xfamily = rubric ? 'codex' : 'not required (routine risk class under process.independent-review-rubric)'
|
||||
async function codexRunner(round) {
|
||||
const r = await agent(`${reviewCommon(Number(args.port) + 3)}
|
||||
|
||||
You run the cross-family review required by process.independent-review-rubric; you are only the runner. Write a prompt file under a directory you create in /private/tmp asking for an adversarial correctness and security review of branch ${BRANCH} against origin/main in ${WT} for ${REF} with done condition from the brief, findings as blocking / should-fix / nit with file and evidence, ending with VERDICT: merge or VERDICT: send-back. Run EXACTLY: codex exec -C ${WT} -s read-only "$(cat <prompt>)" < /dev/null > <out> 2>&1 in the background, wait for the PID to exit (Monitor; measured 2026-07-28 in the #672 session, ~35 minutes for a 7-file diff), read the file, return its findings faithfully with ran=true; no VERDICT line means the run failed — return ran=false, verdict merge, no findings, and the file's tail in one nit finding; never invent a verdict.`,
|
||||
{ label: `review:codex:r${round}`, phase: 'Review', model: 'sonnet', effort: 'low', schema: RUNNER_SCHEMA })
|
||||
return r
|
||||
}
|
||||
async function codexFallback(round, r) {
|
||||
xfamily = `codex could not run in round ${round} (${r ? 'no VERDICT line' : 'runner returned nothing'}); substituted a cold same-family review-only agent per process.independent-review-rubric — retry cross-family next window`
|
||||
log(`${REF}: ${xfamily}`)
|
||||
return agent(`${reviewCommon(Number(args.port) + 2)}
|
||||
|
||||
You are a COLD, review-only substitute for a cross-family reviewer that could not run. Lens: adversarial correctness AND security of the diff against the done condition in the brief. Run the gate in your own worktree; report only what you verified, with evidence. blocking = done condition or a repo rule violated; should-fix = real defect; nit = style. Verdict send-back if any blocking.`,
|
||||
{ label: `review:fallback:r${round}`, phase: 'Review', model: 'opus', effort: 'high', isolation: 'worktree', schema: FINDINGS_SCHEMA })
|
||||
}
|
||||
async function review(round) {
|
||||
// Per round, like blocking/sendBack: a substitute that failed in round 1 says nothing about the tree
|
||||
// that lands after round 2, and a stale xfamily string must never reach the PR body.
|
||||
xfamilyFailedRound = null
|
||||
xfamily = rubric ? 'codex' : 'not required (routine risk class under process.independent-review-rubric)'
|
||||
// The Codex runner builds nothing, so it may run beside the lenses; the FALLBACK is a second
|
||||
// worktree-isolated .NET reviewer and starts only after both lenses have returned.
|
||||
const runnerPromise = rubric ? codexRunner(round).catch(() => null) : Promise.resolve(null)
|
||||
const lenses = (await parallel(LENSES.map(l => () => agent(`${reviewCommon(Number(args.port) + 1)}
|
||||
|
||||
Review round ${round} of the branch for ${REF}. Lens: ${l.prompt}
|
||||
Be adversarial; report only what you verified, with evidence. blocking = done condition or a repo rule violated, or a test that passes for the wrong reason; should-fix = real defect; nit = style. Verdict send-back if any blocking.`,
|
||||
{ label: `review:${l.key}:r${round}`, phase: 'Review', model: l.model, effort: 'high', isolation: l.isolation, schema: FINDINGS_SCHEMA })))).filter(Boolean)
|
||||
if (!rubric) return lenses
|
||||
const r = await runnerPromise
|
||||
if (r && r.ran === true) return lenses.concat([r])
|
||||
let fb = null
|
||||
try { fb = await codexFallback(round, r) } catch (e) { log(`${REF}: fallback reviewer threw: ${e && e.message}`) }
|
||||
if (!fb) { xfamily += ` — the substitute ALSO failed in round ${round}; no cross-family-equivalent review ran`; xfamilyFailedRound = round }
|
||||
return fb ? lenses.concat([fb]) : lenses
|
||||
}
|
||||
|
||||
let round = 1
|
||||
const actionable = rs => rs.flatMap(r => r.findings.filter(f => f.severity === 'blocking' || f.severity === 'should-fix'))
|
||||
const countBy = (rs, sev) => rs.flatMap(r => r.findings).filter(f => f.severity === sev).length
|
||||
let knownHead = work.head_sha
|
||||
let reviews = (await review(round)).filter(Boolean)
|
||||
if (!reviews.length) return { issues, error: `review round ${round} produced no reviews (every lens failed); not pushed`, history: [] }
|
||||
let blocking = reviews.flatMap(r => r.findings.filter(f => f.severity === 'blocking'))
|
||||
let sendBack = actionable(reviews)
|
||||
const history = [{ round, reviews, fix: null, fix_range: null }]
|
||||
while (sendBack.length && round < 3) {
|
||||
log(`${REF} round ${round}: ${blocking.length} blocking, ${sendBack.length - blocking.length} should-fix — sending back`)
|
||||
const fix = await agent(`${COMMON}
|
||||
|
||||
You are the fixer. Reviewers found these problems in the unpushed, rebased branch; fix every blocking and should-fix one as new commits, or show with evidence why a finding is wrong:
|
||||
${JSON.stringify(reviews.flatMap(r => r.findings.filter(f => f.severity !== 'nit')), null, 1)}
|
||||
Re-run the LOCAL GATE and STOP without pushing; report head_sha = git rev-parse HEAD of the worktree after your last commit. ${GATE}`,
|
||||
{ label: `fix:r${round}`, phase: 'Fix', model: 'opus', effort: 'high', schema: REPORT_SCHEMA })
|
||||
if (!fix || !fix.done) return { issues, error: `fixer for round ${round} ${fix ? 'stopped' : 'returned nothing'}; not pushed`, fix, history }
|
||||
history[history.length - 1].fix = fix
|
||||
history[history.length - 1].fix_range = fix.head_sha && fix.head_sha !== knownHead ? `${knownHead}..${fix.head_sha}` : null
|
||||
knownHead = fix.head_sha || knownHead
|
||||
round++
|
||||
reviews = (await review(round)).filter(Boolean)
|
||||
if (!reviews.length) return { issues, error: `review round ${round} produced no reviews (every lens failed); not pushed`, history }
|
||||
blocking = reviews.flatMap(r => r.findings.filter(f => f.severity === 'blocking'))
|
||||
sendBack = actionable(reviews)
|
||||
history.push({ round, reviews, fix: null, fix_range: null })
|
||||
}
|
||||
if (blocking.length) return { issues, error: 'blocking findings after two fix rounds; not pushed', blocking_remaining: blocking, history }
|
||||
if (sendBack.length) return { issues, error: 'should-fix findings still open after two fix rounds; not pushed — the orchestrator decides', should_fix_remaining: sendBack, history }
|
||||
if (xfamilyFailedRound) return { issues, error: `the cross-family runner and its substitute both failed in round ${xfamilyFailedRound}; not pushed`, cross_family: xfamily, history }
|
||||
const FIX_RANGES = history.map(h => h.fix_range).filter(Boolean)
|
||||
const REVIEW_HISTORY = history.map(h => `round ${h.round}: ${h.reviews.length} lens(es); ${countBy(h.reviews, 'blocking')} blocking, ${countBy(h.reviews, 'should-fix')} should-fix, ${countBy(h.reviews, 'nit')} nit` + (h.fix ? (h.fix_range ? `; answered by the fix commit(s) in git log --oneline ${h.fix_range}` : '; answered without a new commit (findings refuted with evidence in the fixer report)') : '; clean — loop ended')).join('\n')
|
||||
|
||||
phase('Land')
|
||||
const FINISH = `FINISH: record the patch-id (git diff $(git merge-base origin/main HEAD)..HEAD | git patch-id --stable); git fetch origin; if origin/main moved again, rebase onto it, re-run the LOCAL GATE, and recompute the patch-id — report patch_changed=true if it differs. ${GATE}
|
||||
Then git push --force-with-lease origin ${BRANCH}. ${args.pr ? `Update PR #${args.pr}'s body (PATCH ${API}/pulls/${args.pr}) so it describes the branch as it now is` : `Open a PR (POST ${API}/pulls; head=${BRANCH}, base=main)`}: the body must contain "fixes #N" for every issue in the bundle, the root cause for a bug fix, the measured numbers, the review history VERBATIM as recorded by the workflow, one line per round, between the markers <<REVIEW HISTORY and REVIEW HISTORY>>:
|
||||
<<REVIEW HISTORY
|
||||
${REVIEW_HISTORY}
|
||||
REVIEW HISTORY>>
|
||||
${FIX_RANGES.length ? `followed by what each fix commit changed, read from git show and not from memory, for exactly the commits git log --oneline lists in these ranges: ${FIX_RANGES.join('; ')}` : (history.some(h => h.fix) ? 'and a sentence saying every finding was answered without a new commit, as the history block records' : 'and a sentence saying no fix commit exists because round one was clean')}, then the cross-family review status verbatim — "${xfamily}" — every deliberately-left item with an issue number, and end with:
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
|
||||
${SESSION_URL}
|
||||
Read ${API}/commits/<head-sha>/status once to arm the CI monitor. Post or update the "## Closing record" comment on each issue (the closing-an-issue skill's template) linking the PR, without closing the issue and without ticking any "## Done-when" box; end it with a "Done-when evidence" list naming, for every box, the command or artifact that evidences it — the orchestrator ticks from that. Report the PR URL, the head sha and patch_changed.`
|
||||
const land = await agent(`${COMMON}
|
||||
|
||||
You are the finisher. The rebased branch has passed its review loop (${round} round(s)); nothing is pushed yet. ${FINISH}`,
|
||||
{ label: `land:${REF}`, model: 'sonnet', effort: 'medium', schema: LAND_SCHEMA })
|
||||
if (!land || !land.done) return { issues, error: 'finisher stopped', land, history }
|
||||
if (!(land.pr_url || args.pr) || !land.head_sha) return { issues, error: 'finisher reported done without a PR or head sha — the branch may already be pushed; read its report before re-running', land, history }
|
||||
log(`${REF} PR: ${land.pr_url || args.pr} @ ${land.head_sha || '?'}${land.patch_changed ? ' (patch changed by the pre-push rebase)' : ''}`)
|
||||
let post_rebase_reviews = null
|
||||
if (land.patch_changed) {
|
||||
log(`${REF}: patch changed on rebase — one more review round on the pushed head before any verdict`)
|
||||
round++
|
||||
post_rebase_reviews = (await review(round)).filter(Boolean)
|
||||
if (!post_rebase_reviews.length) return { issues, error: 'the post-rebase review round produced no reviews (every lens failed); pushed, no verdict may be posted', pr_url: land.pr_url || args.pr, head_sha: land.head_sha, cross_family: xfamily, history }
|
||||
const late = actionable(post_rebase_reviews)
|
||||
if (late.length) return { issues, error: 'blocking or should-fix findings on the pushed head after the pre-push rebase; no verdict may be posted', pr_url: land.pr_url || args.pr, head_sha: land.head_sha, findings_remaining: late, cross_family: xfamily, history, post_rebase_reviews }
|
||||
}
|
||||
return { issues, pr_url: land.pr_url || args.pr, head_sha: land.head_sha, patch_changed: !!land.patch_changed, cross_family: xfamily, work, land, history, post_rebase_reviews }
|
||||
@@ -3,7 +3,7 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"jetbrains.resharper.globaltools": {
|
||||
"version": "2025.3.5",
|
||||
"version": "2025.3.4.1",
|
||||
"commands": [
|
||||
"jb"
|
||||
],
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"repo": "timothy/ersatztv",
|
||||
"branch": "main",
|
||||
"read_on": "2026-08-27",
|
||||
"source": "GET /repos/timothy/ersatztv/branch_protections -> the rule governing `main` -> status_check_contexts",
|
||||
"why": "ersatztv#787. The committed mirror of the required status checks on `main`. It exists because the guards that make a required context trustworthy run in `pr-checks.yml::script-tests`, which checks out with persist-credentials:false and holds no Gitea credential, so it cannot ask the server. scripts/tests/test_ci_dropped_step_guard.py DERIVES its marked-job scope from `contexts` rather than repeating it as a literal, and scripts/check-required-contexts.sh compares this list against the live one wherever a credential does exist. Editing `contexts` by hand without re-reading the server is the one move that defeats both. The `repo` field exists because the merge-consent hook fires for whatever owner/repo the merge tool was called with: without it, merging a PR in another repo from an ersatztv session compares that repo's live contexts against THIS repo's mirror and reports a confident, flatly false finding about it.",
|
||||
"contexts": [
|
||||
"Build ErsatzTV Image / Build & test (.NET) (pull_request)",
|
||||
"Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request)",
|
||||
"review-verdict/h10"
|
||||
]
|
||||
}
|
||||
@@ -4,74 +4,29 @@ name: Build CI Toolchain Image
|
||||
# pushes it to the Gitea container registry (ersatztv#390). The toolchain jobs in
|
||||
# docker-build.yml consume it via `container:`, pinned to an immutable :<sha>.
|
||||
#
|
||||
# push to MAIN touching docker/ci/** -> :<short-sha> + :latest
|
||||
# workflow_dispatch on main -> :<short-sha> of main's HEAD + :latest
|
||||
# workflow_dispatch on a branch -> :<short-sha> of that branch's HEAD ONLY (never :latest)
|
||||
# schedule (weekly) -> picks up base-image security updates
|
||||
# push touching docker/ci/** -> :<short-sha> (+ :latest only from main)
|
||||
# workflow_dispatch -> manual rebuild
|
||||
# schedule (weekly) -> picks up base-image security updates
|
||||
#
|
||||
# Deliberately separate from docker-build.yml: this image changes rarely (a Dockerfile edit or
|
||||
# the weekly cron), while docker-build.yml runs on every push/PR. Coupling them would rebuild a
|
||||
# ~2GB toolchain image on every commit.
|
||||
#
|
||||
# ROLLOUT NOTE: the jobs pin an immutable :<sha>, never :latest — a broken toolchain image would
|
||||
# otherwise block every converted job the moment it was pushed. Bumping the toolchain is a deliberate
|
||||
# two-step, and BOTH steps land in the SAME PR: publish (push the docker/ci commit as branch HEAD,
|
||||
# dispatch this workflow on that branch), then commit the pin bump in docker-build.yml. Merging first
|
||||
# is not available: a PR that changes docker/ci/** without moving the pin turns `ci-image-pin` red,
|
||||
# and the merge-consent hook reads the COMBINED commit status, so it will not auto-grant. That much
|
||||
# predates ersatztv#744 — what #744 changed is how the publish half is performed.
|
||||
# See docs/ci-cd.md -> "Publishing from a branch is a dispatch, not a push".
|
||||
# otherwise block every converted job the moment it was pushed. Bumping the toolchain is therefore
|
||||
# a deliberate two-step: merge a docker/ci/Dockerfile change (this workflow publishes a new :<sha>),
|
||||
# then update the pin in docker-build.yml in a follow-up PR whose CI proves the new image works.
|
||||
# See docs/ci-cd.md -> "CI toolchain image".
|
||||
#
|
||||
# Like docker-build.yml: the Gitea registry is HTTP-only, so BuildKit needs the inline
|
||||
# `http = true` config (it does not inherit the host daemon's insecure-registries setting).
|
||||
|
||||
on:
|
||||
# Publishing from a branch is a DELIBERATE act, not a side effect of pushing (ersatztv#744).
|
||||
# Gitea resolves a `push` workflow's definition from the pushed branch, so an unfiltered `push`
|
||||
# trigger ran this file's own YAML — attacker-supplied, unreviewed, with no status check in the
|
||||
# loop — on a docker-capable runner holding the credential that writes `ersatztv:prod` and the
|
||||
# `ersatztv-ci:<sha>` five `container:` jobs execute.
|
||||
#
|
||||
# BE PRECISE ABOUT WHAT THIS BUYS, because the mechanism cuts both ways: the filter below is read
|
||||
# from the pushed ref like everything else in this file, so a branch that DELETES it re-enables
|
||||
# the route. What closes is the DRIVE-BY case — an ordinary push of a legitimate `docker/ci`
|
||||
# change publishing an image nobody asked for, with no deliberate act anywhere. This is NOT a
|
||||
# boundary against a malicious or compromised writer and must not be cited as one. That class was
|
||||
# probed and ACCEPTED in ersatztv#853 (`ci.workflow-dispatch-ref-unrestricted`): Gitea 1.27.1 cannot
|
||||
# restrict `workflow_dispatch` by ref, and restricting it would close nothing anyway:
|
||||
# docker-build.yml's head-resolved `pull_request:` runs attacker-authored YAML, which reaches every
|
||||
# secret in the store — so it covers renovate.yml's RENOVATE_TOKEN too, without dispatching
|
||||
# renovate.yml at all. Only the DISPATCH third is settled; the `v*` tag push and the PR route
|
||||
# itself remain open in ersatztv#885. `workflow_dispatch` is loaded from the ref it is dispatched
|
||||
# on, exactly as the `branches:` filter below is loaded from the pushed ref, and is the deliberate
|
||||
# publish path (docs/ci-cd.md -> "CI toolchain image").
|
||||
#
|
||||
# A `v*` tag push does not match this trigger either: there is no `tags:` key, and a `branches:`
|
||||
# filter is compared against a branch ref. The exact matcher semantics are not probed here; the
|
||||
# observable claim is the one that matters — a release cut no longer republishes the toolchain
|
||||
# image as a side effect.
|
||||
#
|
||||
# `.gitea/workflows/ci-image.yml` is NOT in `paths:`, and it left `ci-image-pin`'s `expected` in
|
||||
# the same change. That pairing is a DECIDED TRADEOFF, not a necessity: keeping it works, because
|
||||
# the dispatch above can publish the ci-image.yml commit itself and the pin then matches. The
|
||||
# price is what decided it — that route charges a full ~2GB publish plus a five-pin bump for
|
||||
# EVERY edit to this file, comments included, and a rebase charges it again. The cost of the side
|
||||
# taken is stated here and in ci-cd.md: a change to HOW the image is built that lives only in
|
||||
# this file no longer republishes on its own, so pair it with a `docker/ci/**` edit.
|
||||
#
|
||||
# `paths:` here and `ci-image-pin`'s `expected` pathspec in pr-checks.yml MUST name the same
|
||||
# sources. Since the shared self-reference went, `scripts/tests/test_ci_image_paths_pin_agreement.py`
|
||||
# is what holds them together: it derives BOTH lists from these two workflows and compares them for
|
||||
# set equality (ersatztv#855). The two are written in different glob dialects, so it models exactly
|
||||
# one pair of spellings — `<dir>/**` here against the pathspec `<dir>` — and REFUSES anything else
|
||||
# rather than canonicalising a pattern space whose spellings the two consumers treat differently.
|
||||
# Change this list and that guard goes red until the pathspec follows; write it any other way and
|
||||
# it goes red asking for the new shape to be modelled.
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'docker/ci/**'
|
||||
- '.gitea/workflows/ci-image.yml'
|
||||
schedule:
|
||||
# Mondays 05:00 UTC. Gitea registers `schedule` only from the default branch (main).
|
||||
#
|
||||
@@ -95,21 +50,6 @@ env:
|
||||
REGISTRY: 192.168.1.95:3000
|
||||
CI_IMAGE: 192.168.1.95:3000/timothy/ersatztv-ci
|
||||
|
||||
# Explicit token scope (ersatztv#748) so the owner-level Actions default can move to Restricted
|
||||
# (server-management#714). Declaring `permissions:` is EXHAUSTIVE, not additive: a unit omitted here
|
||||
# is NOT granted, and that holds at any owner default — it is not conditional on Restricted being on.
|
||||
# Only `review-verdict.yml` needs write; it declares that at the job and says why there. Full
|
||||
# rationale and the per-workflow credential audit: docs/ci-cd.md -> "Workflow token scope".
|
||||
# This workflow's registry pushes authenticate with the scoped REGISTRY_* PAT
|
||||
# (`ci.actions-credential-scoping`), so the injected GITEA_TOKEN serves only its single
|
||||
# `actions/checkout`. This file was the one workflow #748 could not originally reach: editing it
|
||||
# re-pointed `ci-image-pin`'s `expected` at the editing commit and reddened a BLOCKING job, and its
|
||||
# own `paths:` made the edit publish an image. ersatztv#744 took this path out of both
|
||||
# (`ci.toolchain-image-publish-is-a-dispatch`), so the exemption that briefly existed here is DELETED
|
||||
# rather than documented — which is what ersatztv#835 asked for.
|
||||
permissions:
|
||||
code: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build & push CI image
|
||||
@@ -118,23 +58,13 @@ jobs:
|
||||
# toolchain image — the heaviest thing that ran in that lane. `small` is now
|
||||
# git-only and capped at 1g per job, which would OOM this build.
|
||||
#
|
||||
# Rare trigger (main pushes touching docker/ci, a weekly cron, and the occasional
|
||||
# branch dispatch), so it costs the ubuntu-latest lane almost nothing, and
|
||||
# ci-runner (.127) runs no prod workload.
|
||||
# Rare trigger (pushes touching docker/ci + a weekly cron), so it costs the
|
||||
# ubuntu-latest lane almost nothing, and ci-runner (.127) runs no prod workload.
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CI_JOB_ROLE: none
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# ersatztv#746's convention, applied here once #744 removed the reason it was skipped:
|
||||
# without it the action leaves a write-capable Authorization header in .git/config for
|
||||
# every later step. Nothing here pushes with git — the only git call is the
|
||||
# `rev-parse --short HEAD` below — and the repo is public, so the clone needs no
|
||||
# credential of its own. Guarded for every workflow by
|
||||
# scripts/tests/test_workflow_persist_credentials.py (ersatztv#835).
|
||||
persist-credentials: false
|
||||
# only docker/ci/Dockerfile is needed; no git describe/log here
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -146,11 +76,7 @@ jobs:
|
||||
# Always publish the immutable :<sha> — that is what docker-build.yml pins.
|
||||
TAGS=("${CI_IMAGE}:${SHORT}")
|
||||
# :latest is a convenience/floating pointer for humans and the weekly rebuild; jobs must
|
||||
# never consume it. Only main may move it — and since #744 the `push` trigger is
|
||||
# main-only, so on that path the branch check is satisfied by construction. It is now the
|
||||
# SOLE protection on the one event that never exercised it before: a `workflow_dispatch`
|
||||
# selects any ref, and the branch-side publish path documented in ci-cd.md runs exactly
|
||||
# that. Do not simplify this away on the reasoning that the trigger is already main-only.
|
||||
# never consume it. Only main may move it.
|
||||
if [ "${GITHUB_REF}" = "refs/heads/main" ]; then
|
||||
TAGS+=("${CI_IMAGE}:latest")
|
||||
fi
|
||||
|
||||
@@ -33,27 +33,13 @@ env:
|
||||
DOTNET_CLI_USE_MSBUILD_SERVER: "0"
|
||||
MSBUILDDISABLENODEREUSE: "1"
|
||||
|
||||
# Explicit token scope (ersatztv#748) so the owner-level Actions default can move to Restricted
|
||||
# (server-management#714). Declaring `permissions:` is EXHAUSTIVE, not additive: a unit omitted here
|
||||
# is NOT granted, and that holds at any owner default — it is not conditional on Restricted being on.
|
||||
# Only `review-verdict.yml` needs write; it declares that at the job and says why there. Full
|
||||
# rationale and the per-workflow credential audit: docs/ci-cd.md -> "Workflow token scope".
|
||||
# Holds no registry credential and reads nothing from the Gitea API; the injected GITEA_TOKEN serves
|
||||
# only its one `actions/checkout`.
|
||||
permissions:
|
||||
code: read
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
name: NuGet vulnerable packages
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CI_JOB_ROLE: guard
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
|
||||
@@ -38,22 +38,9 @@ name: Build ErsatzTV Image
|
||||
# report `success` in seconds — the two REQUIRED contexts (`Build & test (.NET)`, `EF migration
|
||||
# integrity (SQLite + MySql)`) must keep reporting or a docs-only PR could never merge. We do NOT
|
||||
# `if:`-skip a required job: on Gitea 1.25.4 a skipped job reports commit-status state `skipped`
|
||||
# (verified, throwaway PR #418; re-confirmed on 1.27.1, 2026-08-28, ersatztv#747 — `Build & push
|
||||
# image (amd64)` is `if:`-skipped on every PR and reported `skipped` on the two heads sampled,
|
||||
# PRs #829 and #828) and we don't rely on how branch protection treats a skipped
|
||||
# (verified, throwaway PR #418) and we don't rely on how branch protection treats a skipped
|
||||
# REQUIRED context. See docs/ci-cd.md -> "Docs-only skip".
|
||||
#
|
||||
# RELEASE-PATH DELIMITER GATE (ersatztv#767): the `scan` job runs the PyYAML-based delimiter-ban
|
||||
# test and is a `needs:` of `build`, so a `${{` opener in a banned job's `run:` body means `build`
|
||||
# never runs. It is deliberately NOT gated by either skip below: the gate's coverage must not depend
|
||||
# on a detector the gate is not allowed to trust, and it is cheap enough that gating it buys nothing.
|
||||
# (Do NOT justify that with "the docs-only path still builds an image" — it does not. `Build and
|
||||
# push` carries the docs_only gate too; a tag build is unaffected only because the script forces
|
||||
# docs_only=false there.) Note it installs from PyPI (setup-python + pip), putting a NEW network
|
||||
# dependency between a `v*` tag and its image. Not the only one on this path — `test` runs
|
||||
# `dotnet restore` and `npm ci` behind actions/cache, and a cache miss reaches nuget.org/npm — but
|
||||
# newly added here. Fail-closed and loud, and still a real availability dependency.
|
||||
#
|
||||
# ALREADY-VALIDATED SKIP (ersatztv#420): a second, sibling gate in `test`, `migrations` and
|
||||
# `functional-e2e` only (NOT `build`). On a push-to-main merge commit, `id: revalidate` runs
|
||||
# `scripts/ci-detect-already-validated.sh`, which emits `skip=true` only when the merged tree is
|
||||
@@ -115,78 +102,19 @@ env:
|
||||
DOTNET_CLI_USE_MSBUILD_SERVER: "0" # no persistent MSBuild server process
|
||||
MSBUILDDISABLENODEREUSE: "1" # MSBuild worker nodes exit with the build instead of lingering
|
||||
|
||||
# Explicit token scope (ersatztv#748) so the owner-level Actions default can move to Restricted
|
||||
# (server-management#714). Declaring `permissions:` is EXHAUSTIVE, not additive: a unit omitted here
|
||||
# is NOT granted, and that holds at any owner default — it is not conditional on Restricted being on.
|
||||
# Only `review-verdict.yml` needs write; it declares that at the job and says why there. Full
|
||||
# rationale and the per-workflow credential audit: docs/ci-cd.md -> "Workflow token scope".
|
||||
# NO JOB ON THE `pull_request` ROUTE NAMES A STORED SECRET (ersatztv#885,
|
||||
# `ci.pr-route-carries-no-stored-credential`). Gitea resolves a `pull_request` run from the PR HEAD,
|
||||
# so this file is attacker-authored on that route and every `secrets.*` it names is materialised
|
||||
# into the run. The scoped REGISTRY_* PAT is therefore held by `build` alone, which is gated
|
||||
# `if: github.event_name != 'pull_request'`. The jobs that used to hold it now work without it:
|
||||
# the five `container:` pulls and `toolchain-preflight`'s registry tag READ go through the registry's
|
||||
# anonymous bearer-token flow, and the three commit-status GETs
|
||||
# (scripts/ci-detect-already-validated.sh in `test`, `migrations` and `functional-e2e`) read the
|
||||
# combined-status API unauthenticated. Those are TWO dependencies, on two different objects, with
|
||||
# opposite failure directions — do not collapse them into one "keep it public or CI breaks loudly".
|
||||
# The `ersatztv-ci` package is linked to no repository (measured 2026-09-05: every version of it
|
||||
# reports `"repository": null`), so THIS repo's visibility is not what gates the anonymous pull
|
||||
# token. (1) The five pulls and the preflight need that PACKAGE to stay anonymously pullable, and
|
||||
# losing it IS loud: every `container:` job dies at image pull, before it runs a step, both
|
||||
# required contexts among them, and `toolchain-preflight` names the cause in its own 401/403
|
||||
# message. (2) The three commit-status GETs need `timothy/ersatztv` itself to stay publicly
|
||||
# readable, and losing that is SILENT: `curl -sf` fails, ci-detect-already-validated.sh falls
|
||||
# through to `skip=false`, and the jobs stay GREEN — only the ersatztv#420 cross-run skip quietly
|
||||
# stops firing, which costs a redundant re-validation and never a skip that was not earned. The
|
||||
# invariant is held by
|
||||
# scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret.
|
||||
# The injected token serves only this file's eight `actions/checkout` steps. Note it needs no
|
||||
# `packages:` unit: the container pulls are anonymous, not token-authenticated.
|
||||
# (Sites above are named by JOB, not by line number: this file is ~1150 lines, so any edit above a
|
||||
# citation silently invalidates it — a line-number citation here has gone stale within two lines
|
||||
# of being written.)
|
||||
permissions:
|
||||
code: read
|
||||
|
||||
jobs:
|
||||
# Answers "is the toolchain image still there?" in ONE place, so a deleted pin does not read as
|
||||
# five broken jobs and a broken diff (ersatztv#772). Deliberately container-free and deliberately
|
||||
# NOT a `needs:` of the jobs it diagnoses — see scripts/ci-toolchain-image-resolves.sh for both
|
||||
# decisions and for the cleanup-rule root cause it cannot fix from this repo.
|
||||
toolchain-preflight:
|
||||
name: CI toolchain image resolves
|
||||
runs-on: small
|
||||
env:
|
||||
CI_EXECUTION_CLASS: bare-runner
|
||||
CI_JOB_ROLE: guard
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Resolve the pinned toolchain tag in the registry
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark resolve
|
||||
scripts/ci-toolchain-image-resolves.sh
|
||||
- name: Assert every expected step executed (ersatztv#756)
|
||||
run: >-
|
||||
scripts/ci-step-ran.sh assert
|
||||
--always resolve
|
||||
|
||||
test:
|
||||
name: Build & test (.NET)
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
|
||||
env:
|
||||
CI_EXECUTION_CLASS: toolchain
|
||||
CI_JOB_ROLE: guard
|
||||
credentials:
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
# git history/tags are needed by the `build` job's `git describe` (ersatztv#190) and,
|
||||
# here, by the #420 revalidate step's `HEAD^2` tree comparison on a main merge commit.
|
||||
fetch-depth: 2
|
||||
@@ -194,21 +122,14 @@ jobs:
|
||||
# ersatztv#416: is this a docs-only change? If so, every heavy step below is skipped and this
|
||||
# REQUIRED job reports success in seconds. It still RUNS (never `if:`-skipped) so the required
|
||||
# context keeps reporting — see the workflow header and docs/ci-cd.md -> "Docs-only skip".
|
||||
# EVERY consequential `run:` step in this job marks itself as its FIRST act (ersatztv#756),
|
||||
# and the trailing `Assert every expected step executed` guard fails the job when one is
|
||||
# missing. This is a REQUIRED context on `main`, and a step the runner drops takes the job
|
||||
# GREEN having done no work — see scripts/ci-step-ran.sh for why that is fail-OPEN here while
|
||||
# the same drop in review-verdict.yml is fail-CLOSED.
|
||||
- name: Detect docs-only changes
|
||||
id: detect
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark detect
|
||||
scripts/ci-detect-docs-only.sh
|
||||
run: scripts/ci-detect-docs-only.sh
|
||||
- name: Detect already-validated tree (#420)
|
||||
id: revalidate
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark revalidate
|
||||
scripts/ci-detect-already-validated.sh
|
||||
env:
|
||||
ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: scripts/ci-detect-already-validated.sh
|
||||
|
||||
- name: Cache NuGet packages
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
@@ -220,9 +141,7 @@ jobs:
|
||||
|
||||
- name: Restore
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark restore
|
||||
dotnet restore
|
||||
run: dotnet restore
|
||||
|
||||
# Replaces setup-node's built-in `cache: npm`. The toolchain image supplies node/npm, but
|
||||
# the SPA's package downloads are project deps, so they stay cached per lockfile.
|
||||
@@ -237,50 +156,36 @@ jobs:
|
||||
- name: Install SPA dependencies
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
working-directory: web
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark npm-ci
|
||||
npm ci
|
||||
run: npm ci
|
||||
|
||||
- name: Check generated SPA API client
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
working-directory: web
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark check-api
|
||||
npm run check:api
|
||||
run: npm run check:api
|
||||
|
||||
- name: Lint SPA
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
working-directory: web
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark lint
|
||||
npm run lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Typecheck SPA
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
working-directory: web
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark typecheck
|
||||
npm run typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Test SPA
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
working-directory: web
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark web-test
|
||||
npm test -- --run
|
||||
run: npm test -- --run
|
||||
|
||||
- name: Build SPA
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
working-directory: web
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark web-build
|
||||
npm run build
|
||||
run: npm run build
|
||||
|
||||
- name: Strip Scanner project ref (matches Docker build)
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark strip-scanner
|
||||
sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
|
||||
# Start the true peak-anon sampler just before the memory-heavy dotnet Build/Test/Coverage so
|
||||
# its high-water mark spans them (SPA build/test above are comparatively light). Paired with the
|
||||
@@ -294,16 +199,13 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark build
|
||||
dotnet build --configuration Release --no-restore
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
|
||||
- name: Test
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark dotnet-test
|
||||
dotnet test --configuration Release --no-build --blame-hang-timeout "2m" --verbosity normal \
|
||||
--collect:"XPlat Code Coverage" --settings coverlet.runsettings --results-directory ./coverage
|
||||
run: >-
|
||||
dotnet test --configuration Release --no-build --blame-hang-timeout "2m" --verbosity normal
|
||||
--collect:"XPlat Code Coverage" --settings coverlet.runsettings --results-directory ./coverage
|
||||
|
||||
# Coverage reporting (ersatztv#15 scope item 4): coverlet.collector emits a Cobertura report
|
||||
# per test project (via --collect above); ReportGenerator merges them into a human-readable
|
||||
@@ -356,48 +258,14 @@ jobs:
|
||||
continue-on-error: true
|
||||
run: scripts/ci-peak-anon.sh report
|
||||
|
||||
# THE DROPPED-STEP GUARD (ersatztv#756). Every `run:` step above records that it began; this
|
||||
# asserts the whole expected SET was recorded. A step the runner declines to interpolate is
|
||||
# DROPPED and still concludes `success` (ersatztv#751), so without this a REQUIRED context
|
||||
# reports green having done no work — fail-OPEN, and strictly worse than the fail-CLOSED
|
||||
# version of the same bug that #751 fixed in review-verdict.yml.
|
||||
#
|
||||
# NO `if:` HERE, WHICH IS A DELIBERATE DEPARTURE FROM THE #751 GUARD and the one decision in
|
||||
# this block that is easy to "fix" wrongly. That guard uses `if: always()` because its job has
|
||||
# exactly one real step, so there is no ordinary red for it to talk over. Here there are
|
||||
# twelve, and a genuine failure in an early one (a lint error, a failing test) SKIPS every
|
||||
# later step — an `always()` guard would then announce "these steps never executed: typecheck
|
||||
# web-test build dotnet-test" on top of every normal red build. That is not a dropped step, it
|
||||
# is the runner doing what it is told, and a guard that cries wolf on every red build is a
|
||||
# guard that gets deleted.
|
||||
#
|
||||
# The default `if:` is `success()`, which is exactly the condition wanted, and the invariant it
|
||||
# rests on is worth stating because it is what makes the omission safe rather than lucky: this
|
||||
# step is skipped ONLY when an earlier step failed, and an earlier step failing already fails
|
||||
# the job. So `guard skipped => job red`, and the only path to a green job runs the guard. A
|
||||
# dropped step is invisible precisely because it concludes `success`, which keeps the job green
|
||||
# and therefore reaches here.
|
||||
#
|
||||
# ITS OWN BODY CANNOT BE DROPPED BY THE MECHANISM IT GUARDS AGAINST: it is a single command
|
||||
# with no expression delimiter anywhere in the scalar, so the runner has nothing to rewrite.
|
||||
# The two gate values come in through `env:`, which is interpolated PER VALUE — a bad payload
|
||||
# there cannot take the body with it (`ci.workflow-run-body-no-expressions`), and both paths
|
||||
# are held to naming a real context by
|
||||
# test_every_workflow_expression_names_a_REAL_context_or_function.
|
||||
- name: Assert every expected step executed (ersatztv#756)
|
||||
env:
|
||||
ETV_DOCS_ONLY: ${{ steps.detect.outputs.docs_only }}
|
||||
ETV_REVALIDATE_SKIP: ${{ steps.revalidate.outputs.skip }}
|
||||
run: >-
|
||||
scripts/ci-step-ran.sh assert
|
||||
--always detect revalidate
|
||||
--gated restore npm-ci check-api lint typecheck web-test web-build strip-scanner build dotnet-test
|
||||
|
||||
migrations:
|
||||
name: EF migration integrity (SQLite + MySql)
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
|
||||
credentials:
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
# Independent gate (not a 'needs' of build yet) so the new MySql-service dependency
|
||||
# can't block image builds until it's proven reliable on the runner. Promote to a
|
||||
# required check / build dependency once green. (ersatztv#13)
|
||||
@@ -450,33 +318,24 @@ jobs:
|
||||
--health-interval=5s
|
||||
--health-timeout=5s
|
||||
--health-retries=30
|
||||
env:
|
||||
CI_EXECUTION_CLASS: toolchain
|
||||
CI_JOB_ROLE: guard
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
# was the default fetch-depth: 1 (ersatztv#190); bumped to 2 so the #420 revalidate
|
||||
# step's `HEAD^2` tree comparison can resolve on a main merge commit.
|
||||
fetch-depth: 2
|
||||
|
||||
# ersatztv#416: docs-only? Skip the build + migration replay; the job still reports success in
|
||||
# seconds. REQUIRED context, so it always RUNS (never `if:`-skipped). See the workflow header.
|
||||
# Same per-step marker contract as the `test` job above (ersatztv#756) — this is the other
|
||||
# REQUIRED context, so a dropped migration-replay step would report EF integrity green having
|
||||
# replayed nothing.
|
||||
- name: Detect docs-only changes
|
||||
id: detect
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark detect
|
||||
scripts/ci-detect-docs-only.sh
|
||||
run: scripts/ci-detect-docs-only.sh
|
||||
- name: Detect already-validated tree (#420)
|
||||
id: revalidate
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark revalidate
|
||||
scripts/ci-detect-already-validated.sh
|
||||
env:
|
||||
ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: scripts/ci-detect-already-validated.sh
|
||||
|
||||
- name: Cache NuGet packages
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
@@ -488,15 +347,11 @@ jobs:
|
||||
|
||||
- name: Restore
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark restore
|
||||
dotnet restore
|
||||
run: dotnet restore
|
||||
|
||||
- name: Build
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark build
|
||||
dotnet build --configuration Release --no-restore
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
|
||||
# dotnet-ef is baked into the CI toolchain image (docker/ci/Dockerfile) and already on PATH
|
||||
# — no per-run `dotnet tool install`. Bump its version there (ersatztv#390).
|
||||
@@ -506,7 +361,6 @@ jobs:
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark sqlite
|
||||
echo "::group::SQLite model drift (has-pending-model-changes)"
|
||||
dotnet ef migrations has-pending-model-changes --no-build --configuration Release \
|
||||
--context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.Sqlite -- --provider Sqlite
|
||||
@@ -530,7 +384,6 @@ jobs:
|
||||
MySql__ConnectionString: "Server=mysql;Port=3306;Database=ersatztv_migrations;Uid=root;Pwd=ersatztv;DefaultCommandTimeout=300;"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark mysql
|
||||
echo "::group::MySql model drift (has-pending-model-changes)"
|
||||
dotnet ef migrations has-pending-model-changes --no-build --configuration Release \
|
||||
--context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.MySql -- --provider MySql
|
||||
@@ -565,42 +418,6 @@ jobs:
|
||||
# how the original defects escaped. The fixture itself is retained and is opt-in via
|
||||
# ETV_TEST_MYSQL_CONNECTION (skipped, visibly, without it). Re-arming it here is tracked by #627.
|
||||
|
||||
# THE DROPPED-STEP GUARD (ersatztv#756). Every `run:` step above records that it began; this
|
||||
# asserts the whole expected SET was recorded. A step the runner declines to interpolate is
|
||||
# DROPPED and still concludes `success` (ersatztv#751), so without this a REQUIRED context
|
||||
# reports green having done no work — fail-OPEN, and strictly worse than the fail-CLOSED
|
||||
# version of the same bug that #751 fixed in review-verdict.yml.
|
||||
#
|
||||
# NO `if:` HERE, WHICH IS A DELIBERATE DEPARTURE FROM THE #751 GUARD and the one decision in
|
||||
# this block that is easy to "fix" wrongly. That guard uses `if: always()` because its job has
|
||||
# exactly one real step, so there is no ordinary red for it to talk over. Here a genuine
|
||||
# failure in an early step (a failing `dotnet build`, a MySql replay error) SKIPS every later
|
||||
# step — an `always()` guard would then announce "these steps never executed: sqlite mysql" on
|
||||
# top of every normal red build. That is not a dropped step, it is the runner doing what it is
|
||||
# told, and a guard that cries wolf on every red build is a guard that gets deleted.
|
||||
#
|
||||
# The default `if:` is `success()`, which is exactly the condition wanted, and the invariant it
|
||||
# rests on is worth stating because it is what makes the omission safe rather than lucky: this
|
||||
# step is skipped ONLY when an earlier step failed, and an earlier step failing already fails
|
||||
# the job. So `guard skipped => job red`, and the only path to a green job runs the guard. A
|
||||
# dropped step is invisible precisely because it concludes `success`, which keeps the job green
|
||||
# and therefore reaches here.
|
||||
#
|
||||
# ITS OWN BODY CANNOT BE DROPPED BY THE MECHANISM IT GUARDS AGAINST: it is a single command
|
||||
# with no expression delimiter anywhere in the scalar, so the runner has nothing to rewrite.
|
||||
# The two gate values come in through `env:`, which is interpolated PER VALUE — a bad payload
|
||||
# there cannot take the body with it (`ci.workflow-run-body-no-expressions`), and both paths
|
||||
# are held to naming a real context by
|
||||
# test_every_workflow_expression_names_a_REAL_context_or_function.
|
||||
- name: Assert every expected step executed (ersatztv#756)
|
||||
env:
|
||||
ETV_DOCS_ONLY: ${{ steps.detect.outputs.docs_only }}
|
||||
ETV_REVALIDATE_SKIP: ${{ steps.revalidate.outputs.skip }}
|
||||
run: >-
|
||||
scripts/ci-step-ran.sh assert
|
||||
--always detect revalidate
|
||||
--gated restore build sqlite mysql
|
||||
|
||||
functional-e2e:
|
||||
name: Functional E2E (curl + UI contracts)
|
||||
runs-on: ubuntu-latest
|
||||
@@ -615,14 +432,13 @@ jobs:
|
||||
if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main'
|
||||
container:
|
||||
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
|
||||
env:
|
||||
CI_EXECUTION_CLASS: toolchain
|
||||
CI_JOB_ROLE: guard
|
||||
credentials:
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
# bumped from 1 (ersatztv#190 default) so the #420 revalidate step's `HEAD^2` tree
|
||||
# comparison can resolve on a main merge commit.
|
||||
fetch-depth: 2
|
||||
@@ -633,6 +449,8 @@ jobs:
|
||||
run: scripts/ci-detect-docs-only.sh
|
||||
- name: Detect already-validated tree (#420)
|
||||
id: revalidate
|
||||
env:
|
||||
ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: scripts/ci-detect-already-validated.sh
|
||||
|
||||
- name: Cache NuGet packages
|
||||
@@ -711,111 +529,6 @@ jobs:
|
||||
# server. Its exit status is Playwright's.
|
||||
scripts/e2e-ui.sh
|
||||
|
||||
# THE DELIMITER BAN, RE-CHECKED ON THE RELEASE PATH ITSELF (ersatztv#767).
|
||||
#
|
||||
# The ban that keeps `build`'s `Smoke + IPTV E2E` from being silently dropped was enforced only by
|
||||
# `test_the_delimiter_banned_jobs_have_NO_expression_delimiter_in_any_run_body` in the
|
||||
# `script-tests` job of pr-checks.yml — `on: pull_request`, and NOT a required context. So the ban
|
||||
# was REVIEW-TIME only: nothing re-checked it on a `v*` tag push, which is precisely when the
|
||||
# candidate image is published and `DeployStack jazz-media` promotes it.
|
||||
#
|
||||
# WHY A JOB AND NOT A STEP INSIDE `build`. A step cannot protect the thing it shares a job with:
|
||||
# `build` is what publishes, so a guard step there fails OPEN if the runner drops it, and "my body
|
||||
# has no opener so I cannot be dropped" is circular when the only thing enforcing that property is
|
||||
# the same PR-only test being backstopped. As a `needs:` of `build`, a red here means `build` never
|
||||
# runs at all — the image is not built, let alone pushed. Fail-closed by dependency, not by
|
||||
# assertion.
|
||||
#
|
||||
# WHY IT RUNS THE REAL PYTEST rather than a bespoke scanner. A stdlib hand-parser of the workflow
|
||||
# YAML was TRIED AND REJECTED in #767 (its appeal: no PyYAML to provision on `build`'s bare
|
||||
# runner). That parser had ~10 false NEGATIVES, all found at once (flow mappings
|
||||
# `{run: …}`, a quoted `"run":` key, aliases, multiline quoted scalars) — i.e. it was strictly
|
||||
# WEAKER than the check it was meant to backstop, in the one direction that matters for a security
|
||||
# gate. Running the existing PyYAML-based test needs no second implementation of "what is a `run:`
|
||||
# body" and therefore has no drift surface. `small` is git-only, so Python is provisioned here the
|
||||
# same way `script-tests` does it.
|
||||
#
|
||||
# This job's OWN steps carry #756 markers and a trailing assert, so a drop inside THIS job is
|
||||
# caught too. That terminates the regress at the same axiom the sibling guards already rest on —
|
||||
# to fail open you must now drop the pytest step AND the assert step, rather than either one.
|
||||
#
|
||||
# THIS PUTS A `small`-LANE JOB BACK ON THE TAG PATH, which ersatztv#535 deliberately moved away
|
||||
# from — say so rather than letting it look accidental. #535 split the git-only gates into
|
||||
# pr-checks.yml because on the v26.12.0 tag they wedged in act's setup phase, were killed, and
|
||||
# reported `failure` with no logs. The blast radius here is WORSE than it was then: as a `needs:`
|
||||
# of `build`, that flake would not merely redden a status, it would skip the build and produce no
|
||||
# release image at all.
|
||||
#
|
||||
# It is acceptable now for a stated reason rather than an assumed one, and the evidence is weaker
|
||||
# than it first looks — so read the limits. Per `ci.small-lane-git-only`, the lane's per-job cap was
|
||||
# forced to 10g by its two HEAVIEST members (this file's `build` AND ci-image.yml's toolchain
|
||||
# buildx), not by `build` alone, and that cap is what pinned the lane to one slot on a 25 GiB host;
|
||||
# both were moved off in server-management#639, after which the lane is git-only and runs wide and
|
||||
# tiny. What has NOT been demonstrated is this lane on a TAG PUSH: `script-tests` runs there happily
|
||||
# but lives in pr-checks.yml (`on: pull_request`), so it has never exercised the condition #535
|
||||
# measured, and #767's own runs (1928/1929) were `workflow_dispatch` on a scratch branch. The
|
||||
# lane-width argument is what carries this, not a like-for-like observation. If the wedging returns,
|
||||
# move this job to `ubuntu-latest` rather than weakening the `needs:` edge — a slower gate is fine,
|
||||
# an optional one is not.
|
||||
scan:
|
||||
name: Delimiter ban (release path)
|
||||
runs-on: small
|
||||
env:
|
||||
CI_EXECUTION_CLASS: bare-runner
|
||||
CI_JOB_ROLE: guard
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Install test dependencies
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark deps
|
||||
python3 -m pip install --disable-pip-version-check --quiet pytest pyyaml
|
||||
# The ban test plus the structural tests that hold this job's own shape. NOT the whole
|
||||
# scripts/tests suite: that is `script-tests`'s job, it needs jq/git preflights, and an
|
||||
# unrelated pytest regression must not be able to block a release.
|
||||
- name: Run the delimiter-ban tests
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark ban
|
||||
PYTHONPATH=. python3 -m pytest scripts/tests/test_ci_dropped_step_guard.py scripts/tests/test_ci_release_path_scan_job.py -q
|
||||
# THE POSITIVE CONTROL, and it is deliberately NOT a test (ersatztv#767). The step above proves
|
||||
# the ban HOLDS; it cannot prove the ban would NOTICE. DEMONSTRATED: ONE repo-root `pytest.ini`
|
||||
# (`addopts = -k "not delimiter_banned"`) or `conftest.py` (`pytest_collection_modifyitems`)
|
||||
# disarms the entire gate, deselecting the ban test and every test guarding it,
|
||||
# leaving all jobs green with a delimiter sitting in `Smoke`. Nothing inside pytest can be
|
||||
# trusted to catch that, because pytest's own configuration outranks it.
|
||||
#
|
||||
# So this poisons the checked-out workflow, re-runs the SAME command, and fails the job if it
|
||||
# PASSES. It runs in the real checkout — an isolated copy does not inherit the repo-root config
|
||||
# a disarm would live in, so a check run in a copy reports healthy while the
|
||||
# job's real invocation is deselected. The workflow file is restored by an EXIT trap.
|
||||
- name: Prove the ban would DETECT a delimiter (ersatztv#767)
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark selfcheck
|
||||
scripts/ci-prove-ban-detects.sh
|
||||
# No `if:` — see the sibling guards in `test`/`migrations` for why the default `success()` is
|
||||
# the wanted condition. Both keys are `--always`: every step in this job is unconditional.
|
||||
#
|
||||
# THE MARKER-PATH RATIONALE DOES NOT TRANSFER HERE, and assuming it did would be the mistake
|
||||
# `ci.required-job-step-execution-markers` itself warns about. That record says the run-id and
|
||||
# attempt keying is "defence in depth" because "these jobs get a fresh container, which is the
|
||||
# primary protection". This job has NO `container:` — it is on `small`, where RUNNER_TEMP is
|
||||
# the shared host /tmp. So here the keying is the ONLY protection, and the residual is a
|
||||
# single-job re-run that does not increment GITHUB_RUN_ATTEMPT: it would find the previous
|
||||
# attempt's marker file and the assert would pass even had the pytest step been dropped.
|
||||
# Identity was read off a real run rather than assumed — run 1929 printed
|
||||
# `Marker identity: job=scan run=1929 attempt=1 (from the runner)`, so all three variables are
|
||||
# populated on this lane.
|
||||
- name: Assert every expected step executed (ersatztv#756)
|
||||
run: >-
|
||||
scripts/ci-step-ran.sh assert
|
||||
--always deps ban selfcheck
|
||||
|
||||
build:
|
||||
name: Build & push image (amd64)
|
||||
# Moved back off `small` (server-management#639). This is the one HEAVY job that
|
||||
@@ -827,24 +540,17 @@ jobs:
|
||||
#
|
||||
# The `ubuntu-latest` queueing that sent it to `small` in the first place
|
||||
# (server-management#574: a PR-run skip stuck 31 min behind long builds) does not
|
||||
# come back, because `needs: [test, migrations, scan]` means this job cannot be
|
||||
# dispatched until those three have already finished — by which point the lane it
|
||||
# come back, because `needs: [test, migrations]` means this job cannot be
|
||||
# dispatched until those two have already finished — by which point the lane it
|
||||
# was queueing behind has drained. Real builds (main/tags) get the full
|
||||
# ubuntu-latest allotment: 4 CPUs / 10g on ci-runner (.127).
|
||||
runs-on: ubuntu-latest
|
||||
# `scan` (ersatztv#767) re-checks the delimiter ban on the release path. As a `needs:` its red
|
||||
# SKIPS this job outright, so a delimiter in `Smoke + IPTV E2E` can no longer reach the point
|
||||
# where an image is published and never booted.
|
||||
needs: [test, migrations, scan]
|
||||
needs: [test, migrations]
|
||||
if: github.event_name != 'pull_request'
|
||||
env:
|
||||
CI_EXECUTION_CLASS: bare-runner
|
||||
CI_JOB_ROLE: none
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
# ersatztv#416: a docs-only push to main has nothing to rebuild (docs are not in the image),
|
||||
@@ -864,27 +570,7 @@ jobs:
|
||||
INFO_VERSION="${VERSION}"
|
||||
TAGS=("${IMAGE}:prod" "${IMAGE}:${VERSION}" "${IMAGE}:${SHORT}")
|
||||
else
|
||||
# `git describe` MUST resolve here, and a failure is fatal rather than defaulted
|
||||
# (ersatztv#836). This job checks out `fetch-depth: 0`, so the tags are present; the
|
||||
# only thing that ever stopped `describe` from seeing them was the detector step above
|
||||
# grafting this complete clone shallow. The old `|| echo v0.0.0` was a fallback that
|
||||
# cannot fail, so from 2026-07-17 (when #416 introduced the depth) until #836 every
|
||||
# `:latest` image was published carrying
|
||||
# `InformationalVersion 0.0.0-<sha>` and nothing anywhere went red — the defect was
|
||||
# found by reading the string out of a running container, which is not a detector.
|
||||
# Failing the job instead means no `:latest` is published at all: visible, recoverable,
|
||||
# and never a mislabelled image promoted downstream. The tag path above never calls
|
||||
# `describe`, so a release cut is unaffected by this.
|
||||
# stderr is discarded on the CAPTURE and re-run for the diagnostic, rather than folded
|
||||
# in with `2>&1`: a git warning on the SUCCESS path would otherwise land inside DESC and
|
||||
# become part of the version string — the same shape of silent corruption this whole
|
||||
# step is being hardened against.
|
||||
if ! DESC=$(git describe --tags --abbrev=0 2>/dev/null); then
|
||||
echo "is-shallow-repository=$(git rev-parse --is-shallow-repository)"
|
||||
git describe --tags --abbrev=0 || true
|
||||
echo "::error::git describe --tags --abbrev=0 failed, so this image would ship InformationalVersion 0.0.0-${SHORT} instead of a real version (ersatztv#836). The usual cause is a --depth fetch grafting this complete clone shallow; the two lines above say which."
|
||||
exit 1
|
||||
fi
|
||||
DESC=$(git describe --tags --abbrev=0 2>/dev/null || echo v0.0.0)
|
||||
INFO_VERSION="${DESC#v}-${SHORT}"
|
||||
TAGS=("${IMAGE}:latest" "${IMAGE}:${SHORT}")
|
||||
fi
|
||||
@@ -930,33 +616,11 @@ jobs:
|
||||
cache-from: type=registry,ref=192.168.1.95:3000/timothy/ersatztv:buildcache
|
||||
cache-to: type=registry,ref=192.168.1.95:3000/timothy/ersatztv:buildcache,mode=max,ignore-error=true
|
||||
|
||||
# THE TWO VALUES COME IN THROUGH `env:`, NOT INLINE (ersatztv#756). This step runs AFTER
|
||||
# `Build and push`, so on a `v*` tag the image is already in the registry as the release
|
||||
# candidate — and it is this smoke run that decides whether the candidate was ever booted at
|
||||
# all. A stray expression delimiter anywhere in this body (a comment is not inert — #751) would
|
||||
# DROP the step and conclude the job `success`: a candidate published, never smoke-tested, and
|
||||
# `DeployStack jazz-media` promotes exactly that image. `env:` is interpolated PER VALUE, so a
|
||||
# bad payload there fails that value instead of taking the whole body with it, and with the
|
||||
# body delimiter-free the class is unreachable here — held by
|
||||
# test_the_delimiter_banned_jobs_have_NO_expression_delimiter_in_any_run_body.
|
||||
#
|
||||
# The ban IS re-checked on the release path now (ersatztv#767): the `scan` job above runs the
|
||||
# PyYAML-based ban test and is a `needs:` of this job, so a delimiter here means `build` never
|
||||
# runs and no image is published. Do not re-add the note that once stood here saying the ban is
|
||||
# "review-time only, tracked as #767" — that was true before the `scan` job existed.
|
||||
#
|
||||
# This step still carries no per-step markers, and that is a genuine (smaller) residual rather
|
||||
# than a dismissal: markers would additionally catch a drop caused by something OTHER than a
|
||||
# delimiter. Adding them needs a bucket modelling this step's publish-ref `if:`, which the
|
||||
# guard's always/gated buckets do not express. The delimiter class itself is covered.
|
||||
- name: Smoke + IPTV E2E (assert key endpoints)
|
||||
if: ${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) && steps.detect.outputs.docs_only != 'true' }}
|
||||
env:
|
||||
SMOKE_SHORT_SHA: ${{ steps.meta.outputs.short }}
|
||||
SMOKE_RUN_ID: ${{ github.run_id }}
|
||||
run: |
|
||||
IMG="${IMAGE}:${SMOKE_SHORT_SHA}"
|
||||
NAME="etv-smoke-${SMOKE_RUN_ID}"
|
||||
IMG="${IMAGE}:${{ steps.meta.outputs.short }}"
|
||||
NAME="etv-smoke-${{ github.run_id }}"
|
||||
trap 'docker rm -f "$NAME" >/dev/null 2>&1 || true' EXIT
|
||||
echo "Pulling ${IMG}"
|
||||
docker pull "$IMG"
|
||||
@@ -1043,29 +707,22 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
|
||||
credentials:
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
CI_EXECUTION_CLASS: toolchain
|
||||
CI_JOB_ROLE: guard
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Detect API-surface changes
|
||||
id: detect
|
||||
run: |
|
||||
base_ref="${{ github.base_ref }}"
|
||||
if ! git fetch --no-tags origin "$base_ref"; then
|
||||
echo "::error::git fetch of origin/${base_ref} failed, so this job cannot compute the changed-file set it derives its work from. That is a broken job, not an empty change set (ersatztv#746). Check the base branch still exists and that the runner can reach the repository."
|
||||
exit 1
|
||||
fi
|
||||
if ! changed="$(git diff --name-only "origin/${base_ref}...HEAD")"; then
|
||||
echo "::error::git diff against origin/${base_ref} failed, so the changed-file set could not be computed — do not read this as 'nothing changed' (ersatztv#746). If it reports no merge base, rebase this branch onto ${base_ref}."
|
||||
exit 1
|
||||
fi
|
||||
git fetch --no-tags --depth=100 origin "$base_ref" || true
|
||||
changed="$(git diff --name-only "origin/${base_ref}...HEAD" 2>/dev/null || true)"
|
||||
echo "Changed files in this PR:"; printf '%s\n' "$changed"
|
||||
if printf '%s\n' "$changed" | grep -Eq '^ErsatzTV/Controllers/Api/|^ErsatzTV\.Core/Api/'; then
|
||||
echo "api_changed=true" >> "$GITHUB_OUTPUT"
|
||||
@@ -1145,29 +802,22 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
|
||||
credentials:
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
CI_EXECUTION_CLASS: toolchain
|
||||
CI_JOB_ROLE: guard
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Detect changed C# files
|
||||
id: detect
|
||||
run: |
|
||||
base_ref="${{ github.base_ref }}"
|
||||
if ! git fetch --no-tags origin "$base_ref"; then
|
||||
echo "::error::git fetch of origin/${base_ref} failed, so this job cannot compute the changed-file set it derives its work from. That is a broken job, not an empty change set (ersatztv#746). Check the base branch still exists and that the runner can reach the repository."
|
||||
exit 1
|
||||
fi
|
||||
if ! changed="$(git diff --name-only --diff-filter=ACM "origin/${base_ref}...HEAD" -- '*.cs')"; then
|
||||
echo "::error::git diff against origin/${base_ref} failed, so the changed-file set could not be computed — do not read this as 'nothing changed' (ersatztv#746). If it reports no merge base, rebase this branch onto ${base_ref}."
|
||||
exit 1
|
||||
fi
|
||||
git fetch --no-tags --depth=100 origin "$base_ref" || true
|
||||
changed="$(git diff --name-only --diff-filter=ACM "origin/${base_ref}...HEAD" -- '*.cs' 2>/dev/null || true)"
|
||||
echo "Changed .cs files in this PR:"; printf '%s\n' "$changed"
|
||||
if [ -n "$changed" ]; then
|
||||
printf '%s\n' "$changed" > /tmp/changed-cs.txt
|
||||
|
||||
+42
-341
@@ -42,84 +42,40 @@ concurrency:
|
||||
group: ersatztv-pr-gates-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Explicit token scope (ersatztv#748) so the owner-level Actions default can move to Restricted
|
||||
# (server-management#714). Declaring `permissions:` is EXHAUSTIVE, not additive: a unit omitted here
|
||||
# is NOT granted, and that holds at any owner default — it is not conditional on Restricted being on.
|
||||
# Only `review-verdict.yml` needs write; it declares that at the job and says why there. Full
|
||||
# rationale and the per-workflow credential audit: docs/ci-cd.md -> "Workflow token scope".
|
||||
# Holds no secrets at all and reads nothing from the Gitea API; the injected GITEA_TOKEN serves only
|
||||
# its five `actions/checkout` steps.
|
||||
permissions:
|
||||
code: read
|
||||
|
||||
jobs:
|
||||
# BLOCKING (ersatztv#390): the CI toolchain image pin in docker-build.yml must name the short sha of
|
||||
# the last commit to touch the image's SOURCES (`docker/ci/**`). Read that as "the image ci-image.yml
|
||||
# last published" only under the convention that every such commit is published — this job compares
|
||||
# git shas and never queries the registry, so it cannot see a pin whose tag was never built or has
|
||||
# been evicted. Existence is `toolchain-preflight`'s job, and the container jobs' pull is the backstop.
|
||||
# Since ersatztv#744 publishing from a branch is a `workflow_dispatch`, so "was it published" is a
|
||||
# human step this job does not observe.
|
||||
#
|
||||
# Without this detector, a PR that edits docker/ci/** ships a new image RECIPE while running its own
|
||||
# BLOCKING (ersatztv#390): the CI toolchain image pin in docker-build.yml must name the image that
|
||||
# ci-image.yml actually last published — i.e. the short sha of the last commit to touch the image's
|
||||
# sources. Without this detector, a PR that edits docker/ci/** publishes a NEW image but runs its own
|
||||
# jobs against the OLD pin: CI green-lights a toolchain it never executed, and once merged, main's
|
||||
# Dockerfile silently disagrees with what CI runs. **Renovate actively generates exactly that PR** —
|
||||
# it manages docker/ci/Dockerfile's base pins (dockerfile manager) but cannot bump an opaque
|
||||
# `:<sha>` in `container.image`, so it would leave the pin behind every time.
|
||||
#
|
||||
# Failing here forces the documented two-step (docs/ci-cd.md -> "CI toolchain image"): get the
|
||||
# Dockerfile change published as `:<sha>`, then update the pin to that sha. Since ersatztv#744 the
|
||||
# publish half of that two-step is a `workflow_dispatch` on the branch rather than a side effect of
|
||||
# the push — ci-image.yml's `push` trigger is now `branches: [main]`. Seconds-long git+grep -> keep
|
||||
# it off the build runners.
|
||||
# Failing here forces the documented two-step (docs/ci-cd.md -> "CI toolchain image"): push the
|
||||
# Dockerfile change, let ci-image.yml publish `:<sha>`, then update the pin to that sha. Seconds-long
|
||||
# git+grep -> keep it off the build runners.
|
||||
ci-image-pin:
|
||||
name: CI image pin matches docker/ci
|
||||
runs-on: small
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
CI_JOB_ROLE: guard
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
# need real history: `git log -- <path>` on a shallow clone can't find the last
|
||||
# commit that touched the image sources
|
||||
fetch-depth: 0
|
||||
- name: Verify the pin matches the image-source commit
|
||||
- name: Verify the pin matches the last-published image
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# ci-image.yml tags the image `git rev-parse --short HEAD` of the run that built it. Only
|
||||
# its filtered `push` clause requires a `docker/ci/**` change; the weekly `schedule` and a
|
||||
# `workflow_dispatch` both build the selected ref's HEAD whatever it touched. So `expected`
|
||||
# is not a model of every tag in the registry — it is the one tag a PR is REQUIRED to be
|
||||
# pinned to: the last commit to change the image's sources.
|
||||
#
|
||||
# `.gitea/workflows/ci-image.yml` is deliberately NOT part of `expected` (ersatztv#744),
|
||||
# and that is a DECIDED TRADEOFF, not a necessity. Keeping it is workable — dispatch the
|
||||
# branch at the ci-image.yml commit, then pin it — but it prices every edit to that file,
|
||||
# comments included, at a full ~2GB publish plus a five-pin bump, redone after every
|
||||
# rebase. Dropping it prices the opposite risk: a change to HOW the image is built living
|
||||
# ONLY in ci-image.yml (build-args, Dockerfile path, platforms) neither republishes nor
|
||||
# invalidates the pin, so CI keeps running an image built by the previous recipe. The
|
||||
# second was chosen because that file is edited far more often for triggers, comments and
|
||||
# runner placement than for build recipe. Make a recipe change alongside a `docker/ci/**`
|
||||
# edit — a comment bump suffices, and it is the ONLY remedy: pinning the workflow-only
|
||||
# commit is rejected here, because `expected` is the last `docker/ci` commit.
|
||||
# This pathspec and `ci-image.yml`'s `on.push.paths` MUST name the same sources; before
|
||||
# #744 the shared self-reference kept them in step. Divergence is silent and green in the
|
||||
# dangerous direction, so it is enforced rather than asserted:
|
||||
# `scripts/tests/test_ci_image_paths_pin_agreement.py` derives BOTH lists from the two
|
||||
# workflows and compares them for set equality (ersatztv#855). It takes this pathspec from
|
||||
# the ASSIGNMENT below rather than from any `git log` in the job, and models only a plain
|
||||
# `<dir>` against `<dir>/**` there — any other spelling is refused rather than compared.
|
||||
# Change this pathspec and that guard goes red until `on.push.paths` follows.
|
||||
# See docs/ci-cd.md -> "Publishing from a branch is a dispatch, not a push".
|
||||
# ci-image.yml tags the image `git rev-parse --short HEAD` of the push that built it, and it
|
||||
# only builds on pushes touching these paths — so the published image is named by the last
|
||||
# commit to touch them.
|
||||
#
|
||||
# Compare RESOLVED FULL shas, never the abbreviations: git auto-scales abbreviation length
|
||||
# with the repo's object count, so the tag built in CI from a `fetch-depth: 1` shallow clone
|
||||
# is 7 chars while `%h` here (full clone) is 8. Comparing those strings would fail always.
|
||||
expected="$(git log -1 --format=%H -- docker/ci)"
|
||||
expected="$(git log -1 --format=%H -- docker/ci .gitea/workflows/ci-image.yml)"
|
||||
mapfile -t pins < <(grep -oE 'ersatztv-ci:[0-9a-f]+' .gitea/workflows/docker-build.yml | cut -d: -f2 | sort -u)
|
||||
echo "Image sources last changed in: ${expected}"
|
||||
echo "Pins found in docker-build.yml: ${pins[*]} (${#pins[@]} distinct)"
|
||||
@@ -151,10 +107,10 @@ jobs:
|
||||
# in-repo remedy in that state: relax this length check in the same PR and say why. Note
|
||||
# that ci-image.yml still tags with a plain `--short` (auto-scaled), so "always 7" is an
|
||||
# empirical property of today's shallow clone, not an enforced invariant. Making the
|
||||
# publisher emit `--short=7` is tracked as ersatztv#597. That is no longer blocked by this
|
||||
# job at all: since ersatztv#744, editing ci-image.yml does NOT re-point `expected`, so a
|
||||
# `--short=7` change lands like any other PR. It does need a deliberate republish to take
|
||||
# effect — see the note on `expected` above.
|
||||
# publisher emit `--short=7` is tracked as ersatztv#597. It is not blocked, just out of
|
||||
# scope here: editing ci-image.yml re-points `expected` (above) at that commit, so it needs
|
||||
# the branch's own publish-then-pin two-step (docs/ci-cd.md -> 'CI toolchain image') —
|
||||
# ci-image.yml's push trigger has no branches: filter, so a feature branch does publish.
|
||||
if [ "${#pins[0]}" -ne 7 ]; then
|
||||
echo "::error::CI toolchain image pin ersatztv-ci:${pins[0]} is ${#pins[0]} chars, but ci-image.yml publishes 7-char tags (it tags with 'git rev-parse --short HEAD' from a fetch-depth:1 clone). A differently-sized abbreviation still resolves to the right commit, so this would pass every other check here — but NO such tag exists in the registry, and all five container: jobs would fail at image-pull time with 'manifest unknown'. Pin exactly: ersatztv-ci:${expected:0:7} (locally: git rev-parse --short=7 HEAD). See docs/ci-cd.md -> 'CI toolchain image'."
|
||||
exit 1
|
||||
@@ -165,7 +121,7 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
if [ "$pin_full" != "$expected" ]; then
|
||||
echo "::error::CI toolchain image pin is stale: docker-build.yml pins ersatztv-ci:${pins[0]} ($pin_full), but docker/ci was last changed in $expected. Your jobs are testing an image that is NOT built from this PR's docker/ci. Publish the new :<sha> — push this commit as branch HEAD and dispatch ci-image.yml on the branch (a branch PUSH no longer publishes, ersatztv#744) — then update the pin in ALL jobs to it (docs/ci-cd.md -> 'CI toolchain image')."
|
||||
echo "::error::CI toolchain image pin is stale: docker-build.yml pins ersatztv-ci:${pins[0]} ($pin_full), but docker/ci was last changed in $expected. Your jobs are testing an image that is NOT built from this PR's docker/ci. Let ci-image.yml publish the new :<sha>, then update the pin in ALL jobs to it (docs/ci-cd.md -> 'CI toolchain image')."
|
||||
exit 1
|
||||
fi
|
||||
echo "Pin is current: ersatztv-ci:${pins[0]} resolves to $pin_full = docker/ci's last change."
|
||||
@@ -178,32 +134,16 @@ jobs:
|
||||
name: Docs update reminder
|
||||
runs-on: small # seconds-long git diff; keep it off the build runners
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
CI_JOB_ROLE: report-only
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
# `continue-on-error` for the same reason the two steps below carry it: this whole job
|
||||
# is a non-blocking nudge, and an advisory red still joins the combined status the merge gate
|
||||
# reads. Unmasking the fetch (ersatztv#746) makes a broken base LOUD in the log; it must not
|
||||
# also make a warn-only job merge-blocking. The three jobs that genuinely gate on this diff —
|
||||
# api-docs, format, decisions lifecycle — do redden on a failed fetch, which is where that
|
||||
# belongs.
|
||||
- name: Warn when a screen/route change skips the parity doc
|
||||
continue-on-error: true
|
||||
run: |
|
||||
base_ref="${{ github.base_ref }}"
|
||||
if ! git fetch --no-tags origin "$base_ref"; then
|
||||
echo "::error::git fetch of origin/${base_ref} failed, so this job cannot compute the changed-file set it derives its work from. That is a broken job, not an empty change set (ersatztv#746). Check the base branch still exists and that the runner can reach the repository."
|
||||
exit 1
|
||||
fi
|
||||
if ! changed="$(git diff --name-only "origin/${base_ref}...HEAD")"; then
|
||||
echo "::error::git diff against origin/${base_ref} failed, so the changed-file set could not be computed — do not read this as 'nothing changed' (ersatztv#746). If it reports no merge base, rebase this branch onto ${base_ref}."
|
||||
exit 1
|
||||
fi
|
||||
git fetch --no-tags --depth=100 origin "$base_ref" || true
|
||||
changed="$(git diff --name-only "origin/${base_ref}...HEAD" 2>/dev/null || true)"
|
||||
echo "Changed files in this PR:"; printf '%s\n' "$changed"
|
||||
screen_or_route=no
|
||||
if printf '%s\n' "$changed" | grep -Eq '^web/src/screens/.+\.tsx$|^ErsatzTV/LegacyUiRedirects\.cs$'; then
|
||||
@@ -219,40 +159,6 @@ jobs:
|
||||
echo "Parity-doc reminder: nothing to flag."
|
||||
fi
|
||||
|
||||
# ersatztv#784 — ADVISORY nudge for `docs.no-session-narrative`. Deliberately NON-BLOCKING and
|
||||
# deliberately in this job rather than a gate of its own: it is a string predicate over prose,
|
||||
# and `docs/defect-shapes-773.md` §4 argues that class must not be load-bearing. The script
|
||||
# exits 0 on every path (asserted per argument shape in scripts/tests/test_check_doc_narrative.py,
|
||||
# not only in prose), so this step cannot redden the run even on a hit; if you find yourself
|
||||
# wanting it to fail, read the decision record first — it says no in as many words.
|
||||
# `python3` is not guaranteed on the bare `small` lane (docs/ci-cd.md), and every other
|
||||
# python-using job on it declares this. Without it a missing interpreter is exit 127 — a RED
|
||||
# advisory job joining the combined status, which is the one thing this step must never be.
|
||||
#
|
||||
# Both steps OF THIS CHECK (setup-python + the narrative step; the parity nudge above has its
|
||||
# own) carry `continue-on-error` because the SCRIPT exiting 0 is not the whole invariant:
|
||||
# a setup-python download failure reddens the job just as effectively as a hit would, and an
|
||||
# advisory red still joins the combined status the merge gate reads (ersatztv#598). Scope,
|
||||
# stated rather than implied: this covers the two steps that exist to run the check. A failed
|
||||
# `Checkout` is NOT covered and deliberately so — with no tree there is nothing to check, and
|
||||
# a job that cannot run is a different failure from an advisory one that ran and disagreed.
|
||||
# Measured on this runner (PR#811, run 2179): the job reports `success` and the commit status
|
||||
# context is `success` with both steps green under `continue-on-error`.
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
continue-on-error: true
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Warn when a doc narrates its own revision history
|
||||
continue-on-error: true
|
||||
run: |
|
||||
base_ref="${{ github.base_ref }}"
|
||||
if ! git fetch --no-tags origin "$base_ref"; then
|
||||
echo "::error::git fetch of origin/${base_ref} failed, so this job cannot compute the changed-file set it derives its work from. That is a broken job, not an empty change set (ersatztv#746). Check the base branch still exists and that the runner can reach the repository."
|
||||
exit 1
|
||||
fi
|
||||
python3 scripts/check-doc-narrative.py --diff "origin/${base_ref}"
|
||||
|
||||
# BLOCKING (ersatztv#521, supersedes the ersatztv#303 H9 append-only mechanic): validates decision-
|
||||
# record lifecycle invariants (metadata schema, one active record per key, reciprocal
|
||||
# supersedes/superseded-by links, no rationale-prose rewrite without a Decisions-Edit: yes git
|
||||
@@ -264,13 +170,10 @@ jobs:
|
||||
name: decisions lifecycle
|
||||
runs-on: small
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
CI_JOB_ROLE: guard
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
@@ -279,10 +182,7 @@ jobs:
|
||||
- name: Validate decision lifecycle
|
||||
run: |
|
||||
base_ref="${{ github.base_ref }}"
|
||||
if ! git fetch --no-tags origin "$base_ref"; then
|
||||
echo "::error::git fetch of origin/${base_ref} failed, so this job cannot compute the changed-file set it derives its work from. That is a broken job, not an empty change set (ersatztv#746). Check the base branch still exists and that the runner can reach the repository."
|
||||
exit 1
|
||||
fi
|
||||
git fetch --no-tags --depth=200 origin "$base_ref" || true
|
||||
PYTHONPATH=. python3 scripts/decisions_validate.py --base "origin/${base_ref}" --head HEAD
|
||||
- name: Active catalog in sync
|
||||
run: PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check
|
||||
@@ -306,245 +206,46 @@ jobs:
|
||||
# close. A distinct job name keeps a real failure unambiguous.
|
||||
#
|
||||
# Runs UNCONDITIONALLY on every PR rather than behind a `scripts/**` path filter. The suite's
|
||||
# corpus tests are fixture/tmp-repo based, but several execute REAL artifacts from other top-level
|
||||
# directories: test_post_review_verdict.py runs `scripts/post-review-verdict.sh`,
|
||||
# test_merge_consent_exemption.py runs `.claude/hooks/pretooluse-merge-consent.sh`, and since
|
||||
# ersatztv#845 test_post_review_verdict.py ALSO reads `.gitea/workflows/review-verdict.yml` —
|
||||
# the writer derives the H10 allow-list from it, so editing that literal changes the suite's
|
||||
# outcome. Its true input set therefore spans at least three top-level directories, and this
|
||||
# enumeration is the kind that goes stale: a `scripts/**` filter would silently miss a
|
||||
# `.claude/hooks/**` or `.gitea/workflows/**` edit. The reason is the INPUT SET, not the cost —
|
||||
# the suite was ~10s when that was decided and is minutes now, and filtering on `scripts/**`
|
||||
# would still be wrong.
|
||||
prove-fix:
|
||||
name: "Fix proofs (Proves trailers)"
|
||||
runs-on: small
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
CI_JOB_ROLE: guard
|
||||
steps:
|
||||
- name: Checkout
|
||||
# Full history: prove-fix.sh reverts each commit against its PARENT, so a shallow
|
||||
# clone would leave it unable to resolve `<sha>^` and it would refuse every commit.
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Install test dependencies
|
||||
run: python3 -m pip install --disable-pip-version-check --quiet pytest pyyaml
|
||||
# OPT-IN BY TRAILER, deliberately. Requiring `Proves:` on every commit would block
|
||||
# docs, CI and refactor commits that have no code side to revert, and a gate that
|
||||
# blocks ordinary work gets disabled — which is how a check ends up running nowhere
|
||||
# (#631). So the trailer is the AUTHOR'S CLAIM, and this job checks claims: write
|
||||
# one and it must hold. Coverage is therefore honest rather than assumed, and
|
||||
# `docs/decisions/records/testing/fix-ships-a-witnessed-red-test.md` says so.
|
||||
- name: Prove every commit that claims a proof
|
||||
run: |
|
||||
set -uo pipefail
|
||||
base="${{ github.event.pull_request.base.sha }}"
|
||||
head="${{ github.event.pull_request.head.sha }}"
|
||||
echo "range: $base..$head"
|
||||
|
||||
# Capture and VALIDATE the enumeration before looping. `for sha in $(git ...)`
|
||||
# swallows a git failure: the command substitution yields nothing, the loop body
|
||||
# never runs, and the job reports "0 claims" green. Fail-open enumeration in the
|
||||
# thing that decides what gets checked is the defect this job exists to catch.
|
||||
if ! shas="$(git rev-list "$base".."$head")"; then
|
||||
echo "::error::git rev-list failed for $base..$head — cannot enumerate commits," \
|
||||
"so this job cannot assert anything. Refusing to pass."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
claimed=0; proven=0; failed=0
|
||||
while IFS= read -r sha; do
|
||||
[ -n "$sha" ] || continue
|
||||
# Trim whitespace only — NOT `xargs`, which applies quote parsing and turns a
|
||||
# legitimate parametrised node id like test_x[can't] into an empty selector,
|
||||
# silently dropping a real claim.
|
||||
# Extract with a CHECKED status. `sel="$(git show ... )"` under `set -uo
|
||||
# pipefail` but no `-e` yields an empty selector when git fails, the commit is
|
||||
# skipped, and the job exits 0 having been unable to inspect a possible claim —
|
||||
# fail-open in the step that decides what gets checked.
|
||||
if ! raw="$(git show -s --format='%(trailers:key=Proves,valueonly)' "$sha")"; then
|
||||
echo "::error::git show failed for $sha — cannot read its trailers, so this" \
|
||||
"job cannot assert anything about it. Refusing to pass."
|
||||
exit 1
|
||||
fi
|
||||
# Refuse MORE THAN ONE `Proves:` here too. prove-fix.sh has this guard, but it
|
||||
# only fires when it reads the trailer itself — and this job passes the selector
|
||||
# explicitly, so the guard was bypassed on the one path that actually enforces.
|
||||
# Measured: a commit with two trailers reported PROVEN while the second was never
|
||||
# run. Fixing the script and not its twin is how a guard reads as coverage.
|
||||
# Count trailer PRESENCE, not non-empty values: `%(...valueonly)` renders a bare
|
||||
# `Proves:` as an empty line, so counting non-empty lines misses a commit whose
|
||||
# FIRST trailer is empty — `sel` then comes out empty and the commit is skipped
|
||||
# in silence, with a real second selector never checked. Fail-open in CI while
|
||||
# the script is fail-closed is the same asymmetry this guard exists to remove.
|
||||
present="$(git show -s --format='%(trailers:key=Proves)' "$sha")"
|
||||
if [ "$(printf '%s\n' "$present" | grep -c .)" -gt 1 ]; then
|
||||
claimed=$((claimed + 1)); failed=$((failed + 1))
|
||||
echo "::error::commit $sha carries more than one 'Proves:' trailer; only the" \
|
||||
"first would be checked, so the rest would read as proven without ever" \
|
||||
"running. Use a single selector."
|
||||
continue
|
||||
fi
|
||||
sel="$(printf '%s\n' "$raw" | head -1 | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
|
||||
# A trailer that is PRESENT but empty is a claim with no selector. Refuse it
|
||||
# loudly; skipping it silently would let the job report "no claims" for a PR that
|
||||
# made one.
|
||||
if [ -n "$present" ] && [ -z "$sel" ]; then
|
||||
claimed=$((claimed + 1)); failed=$((failed + 1))
|
||||
echo "::error::commit $sha carries a 'Proves:' trailer with no selector."
|
||||
continue
|
||||
fi
|
||||
[ -n "$sel" ] || continue
|
||||
claimed=$((claimed + 1))
|
||||
|
||||
# A merge commit has several parents, so "before this change" is ambiguous.
|
||||
# prove-fix.sh refuses them; catch it here with a clearer message rather than
|
||||
# letting the trailer be silently skipped (which --no-merges used to do).
|
||||
if [ "$(git rev-list --parents -n 1 "$sha" | wc -w)" -gt 2 ]; then
|
||||
failed=$((failed + 1))
|
||||
echo "::error::commit $sha is a MERGE carrying 'Proves: $sel'. Put the trailer" \
|
||||
"on the commit that carries the fix — a merge has no single 'before'."
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "::group::prove $sha -> $sel"
|
||||
if bash ./scripts/prove-fix.sh "$sha" "$sel"; then
|
||||
proven=$((proven + 1)); echo "PROVEN $sha"
|
||||
else
|
||||
rc=$?
|
||||
failed=$((failed + 1))
|
||||
echo "::error::commit $sha claims 'Proves: $sel' but prove-fix.sh exited $rc." \
|
||||
"A claimed proof that does not hold is worse than none — it reads as" \
|
||||
"coverage. Strengthen the test until reverting the fix reddens it, or" \
|
||||
"drop the trailer."
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
done <<< "$shas"
|
||||
|
||||
echo "commits claiming a proof: $claimed (proven $proven, failed $failed)"
|
||||
if [ "$claimed" -eq 0 ]; then
|
||||
echo "::notice::No commit in this PR carries a 'Proves:' trailer, so nothing was" \
|
||||
"verified here. That is allowed — the trailer is opt-in — but it means this" \
|
||||
"job asserts NOTHING about this PR. Do not read its green as fix coverage."
|
||||
fi
|
||||
[ "$failed" -eq 0 ]
|
||||
|
||||
# corpus tests are fixture/tmp-repo based, but test_post_review_verdict.py and
|
||||
# test_merge_consent_exemption.py execute the REAL `scripts/post-review-verdict.sh` and
|
||||
# `.claude/hooks/pretooluse-merge-consent.sh`, so its true input set spans at least two top-level
|
||||
# directories. A `scripts/**` filter would silently miss a `.claude/hooks/**` edit — and at ~10s a
|
||||
# filter buys nothing but drift.
|
||||
script-tests:
|
||||
name: Script lint and tests (ruff + pytest)
|
||||
name: Script tests (pytest)
|
||||
runs-on: small
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
CI_JOB_ROLE: guard
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
# Preflight, not an install (ersatztv#390 removed run-time `apt-get` from CI on purpose).
|
||||
# Two consumers need `git`: the lint steps below derive their population from `git ls-files`,
|
||||
# and test_post_review_verdict.py / test_merge_consent_exemption.py exec the REAL
|
||||
# post-review-verdict.sh / pretooluse-merge-consent.sh. `curl` those tests shim on PATH; `jq`
|
||||
# and `git` they do NOT. It stays AHEAD of the lint steps, not merely ahead of pytest: without
|
||||
# it, a missing git reaches the lint steps as an empty population, which they report as a
|
||||
# population problem. One actionable line beats a misdirected one, and beats the wall of
|
||||
# unattributable assertion failures the suite produces without git.
|
||||
- name: Preflight external tools
|
||||
run: |
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
echo "::error::script-tests needs git on PATH but it is absent. The lint steps derive" \
|
||||
"their population from it and the suite execs real shell scripts that use it." \
|
||||
"Bake it into the runner image rather than apt-get installing here (ersatztv#390)."
|
||||
exit 1
|
||||
fi
|
||||
echo "Preflight OK: $(git --version)"
|
||||
# ersatztv#780. Lint runs EARLY — after the git preflight it depends on, but before the test
|
||||
# dependencies, the jq preflight and the ~4-minute pytest run. A style red therefore arrives in
|
||||
# seconds, and, more importantly, the lint does not sit behind `Preflight jq version`: that is
|
||||
# an `--expect` tripwire, so a runner jq bump would take the lint dark for as long as the jq
|
||||
# contract is broken, under a red that says "jq".
|
||||
#
|
||||
# The version is PINNED: an unpinned ruff makes the verdict a function of whenever the job ran
|
||||
# — the same environment-divergence the committed ruff.toml exists to close. Bumping it is a
|
||||
# deliberate PR (new rules may fire), exactly like the jq pin below. `pytest`/`pyyaml` are
|
||||
# deliberately NOT pinned: a pytest release does not add assertions to your suite, a ruff
|
||||
# release adds rules to your lint.
|
||||
- name: Install ruff
|
||||
run: python3 -m pip install --disable-pip-version-check --quiet 'ruff==0.12.11'
|
||||
# POPULATION. Both steps lint an EXPLICIT list from `git ls-files`, never `ruff check .`, and
|
||||
# pass `--no-force-exclude`. Measured with ruff 0.12.11 and `exclude = ["scripts/**"]` — a
|
||||
# per-FILE pattern, because `exclude` matches per file: a bare `["scripts"]` still works at the
|
||||
# top level but matches nothing under `[lint]`/`[format]`. The subject is a planted tracked file
|
||||
# holding an unused import, a hardcoded credential and a formatting error. GREEN means the gate
|
||||
# was silently off:
|
||||
#
|
||||
# DISCOVERY FORM EXPLICIT FORM (what ships)
|
||||
# exclude scope check . format --check . check format --check
|
||||
# top-level GREEN GREEN red red
|
||||
# [lint] GREEN red red red
|
||||
# [format] red GREEN red red
|
||||
# top + force-exclude GREEN GREEN red red <- with the flag
|
||||
# GREEN GREEN <- without it
|
||||
#
|
||||
# Only the top-level scope empties BOTH discovery commands; `[lint]` empties `check` and
|
||||
# `[format]` empties `format --check`, so in those two the job would still redden on the other
|
||||
# step. `[format]` is where a line appended to ruff.toml lands, by TOML rules. `include = []`,
|
||||
# `extend-exclude` and a nested `scripts/ruff.toml` behave the same way and are equally inert
|
||||
# against the explicit form. The last row is the whole reason for `--no-force-exclude`:
|
||||
# `force-exclude = true` re-applies excludes to explicitly-passed paths, and is the one setting
|
||||
# that reaches explicitly-passed paths at all.
|
||||
#
|
||||
# `ruff check .` over an empty tree exits **0** with only a stderr warning, so every GREEN above
|
||||
# is a gate that was switched off without a red.
|
||||
#
|
||||
# This also derives the population from source rather than from the filesystem
|
||||
# (docs/decisions/records/testing/guard-derives-population-from-source.md) and covers
|
||||
# tracked-but-gitignored files, which `ruff check .` skips. The empty-population arm is the
|
||||
# anti-vacuity check: a completeness check whose population is empty reports that it proved
|
||||
# everything. What it does NOT cover: an emptied RULE set. `select = []` silences every selected
|
||||
# rule, so the `ruff check` step goes green over any lint violation (a syntax error still reds)
|
||||
# while printing a reassuring file count.
|
||||
# `ruff format --check` is unaffected, because formatting is not rule-selected. So half the
|
||||
# gate is killable by a config edit, and only a human reading that edit catches it.
|
||||
- name: Lint scripts (ruff check)
|
||||
run: |
|
||||
mapfile -d '' -t PYFILES < <(git ls-files -z '*.py' '*.pyi' '*.ipynb')
|
||||
if [ "${#PYFILES[@]}" -eq 0 ]; then
|
||||
echo "::error::the lint population is EMPTY — git tracks no Python files. Either the" \
|
||||
"checkout is wrong or the glob is. A lint over nothing passes; see ersatztv#780."
|
||||
exit 1
|
||||
fi
|
||||
echo "Linting ${#PYFILES[@]} tracked Python files"
|
||||
python3 -m ruff check --no-force-exclude -- "${PYFILES[@]}"
|
||||
- name: Lint scripts (ruff format --check)
|
||||
run: |
|
||||
mapfile -d '' -t PYFILES < <(git ls-files -z '*.py' '*.pyi' '*.ipynb')
|
||||
if [ "${#PYFILES[@]}" -eq 0 ]; then
|
||||
echo "::error::the format population is EMPTY — git tracks no Python files. See ersatztv#780."
|
||||
exit 1
|
||||
fi
|
||||
echo "Format-checking ${#PYFILES[@]} tracked Python files"
|
||||
python3 -m ruff format --check --no-force-exclude -- "${PYFILES[@]}"
|
||||
# pytest + PyYAML. PyYAML is NOT a contradiction of the dependency-free decisions READ path:
|
||||
# `decisions_lib._read_frontmatter` is hand-written precisely so validation runs where nothing
|
||||
# is installed, but the one-shot WRITE path `migrate_decisions_split.py` uses PyYAML by
|
||||
# design — and `test_migration_equivalence.py` imports that module, so the suite needs it.
|
||||
# `pytest` and `yaml` are the complete third-party set, established by an AST import scan over
|
||||
# all of scripts/ rather than by reading the files that seemed relevant — reading only those
|
||||
# yields "pure stdlib", a claim that passes locally on a machine that happens to have PyYAML
|
||||
# and goes red in CI on a collection error.
|
||||
# all of scripts/ rather than by reading the files that seemed relevant: the first cut of this
|
||||
# job claimed "pure stdlib", passed locally on a machine that happened to have PyYAML, and
|
||||
# went red in CI on a collection error.
|
||||
- name: Install test dependencies
|
||||
run: python3 -m pip install --disable-pip-version-check --quiet pytest pyyaml
|
||||
# Preflight, not an install (ersatztv#390 removed run-time `apt-get` from CI on purpose).
|
||||
# test_post_review_verdict.py and test_merge_consent_exemption.py exec the REAL
|
||||
# post-review-verdict.sh / pretooluse-merge-consent.sh, which shell out to `jq` ~26 times.
|
||||
# `curl` those tests shim on PATH; `jq` they do NOT. If it were missing, the suite would fail
|
||||
# as ~20 opaque assertion errors — this turns that into one actionable line.
|
||||
- name: Preflight external tools
|
||||
run: |
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
echo "::error::script-tests needs git on PATH but it is absent. The suite execs real" \
|
||||
"shell scripts that use it. Bake it into the runner image rather than apt-get" \
|
||||
"installing here (see ersatztv#390)."
|
||||
exit 1
|
||||
fi
|
||||
echo "Preflight OK: $(git --version)"
|
||||
# jq gets its OWN step because its VERSION, not merely its presence, is load-bearing
|
||||
# (ersatztv#648). `--expect` makes this a TRIPWIRE: scripts/tests exercises the jq 1.6 code path
|
||||
# only because this runner ships 1.6, so an upgrade would silently delete that coverage — and
|
||||
|
||||
@@ -45,26 +45,12 @@ concurrency:
|
||||
group: ersatztv-renovate
|
||||
cancel-in-progress: false
|
||||
|
||||
# Explicit token scope (ersatztv#748) so the owner-level Actions default can move to Restricted
|
||||
# (server-management#714). Declaring `permissions:` is EXHAUSTIVE, not additive: a unit omitted here
|
||||
# is NOT granted, and that holds at any owner default — it is not conditional on Restricted being on.
|
||||
# Only `review-verdict.yml` needs write; it declares that at the job and says why there. Full
|
||||
# rationale and the per-workflow credential audit: docs/ci-cd.md -> "Workflow token scope".
|
||||
# This workflow has no checkout step and never uses the injected GITEA_TOKEN for anything. Renovate's
|
||||
# own branch/PR writes go through RENOVATE_TOKEN, a dedicated bot PAT the Actions default does not
|
||||
# govern, and its container image comes from Docker Hub. Read-only is declared to STATE that the
|
||||
# injected token is unused, not because any step needs it.
|
||||
permissions:
|
||||
code: read
|
||||
|
||||
jobs:
|
||||
renovate:
|
||||
name: Renovate
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: renovate/renovate:43
|
||||
env:
|
||||
CI_JOB_ROLE: none
|
||||
steps:
|
||||
- name: Run Renovate
|
||||
env:
|
||||
|
||||
+49
-2137
File diff suppressed because it is too large
Load Diff
-20
@@ -10,10 +10,6 @@ project.lock.json
|
||||
# Claude Code
|
||||
.mcp/
|
||||
.mcp.json
|
||||
# Machine-local settings (DOTNET_ROOT and friends — see docs/local-lsp-tooling.md).
|
||||
# Ignored here rather than relying on a personal ~/.config/git/ignore, so a second
|
||||
# contributor following that doc cannot accidentally commit their own Homebrew paths.
|
||||
/.claude/settings.local.json
|
||||
.agents/
|
||||
plugins/
|
||||
nupkg/
|
||||
@@ -74,11 +70,6 @@ ErsatzTV/wwwroot/app/
|
||||
web/dist/
|
||||
web/node_modules
|
||||
|
||||
# Root-level link that makes `typescript` resolvable from the repo root, which is
|
||||
# the LSP workspace root — without it typescript-language-server refuses to start
|
||||
# (ersatztv#777). See docs/local-lsp-tooling.md.
|
||||
/node_modules/
|
||||
|
||||
# E2E / screenshot scratch (from Playwright/live-E2E runs) — never committed
|
||||
/*.png
|
||||
.playwright-mcp/
|
||||
@@ -95,14 +86,3 @@ web/playwright-report/
|
||||
# plaintext Gitea credential and absolute /Users paths, so it is neither portable nor safe to
|
||||
# commit. See ersatztv#711 for the related merge-gate gap.
|
||||
.codex/
|
||||
|
||||
# serena's per-project state, written by `activate_project` (ersatztv#799): project.yml,
|
||||
# project.local.yml, a language-server cache, and memories/.
|
||||
#
|
||||
# This deliberately rejects serena's own versioning model. Its nested .serena/.gitignore excludes
|
||||
# only `cache` and `project.local.yml`, and project.local.yml says project.yml "is intended to be
|
||||
# versioned" — but activation here is per DIRECTORY, and every worktree generates a project.yml
|
||||
# whose project_name is that worktree's folder (e.g. `781-tooling`). A committed copy would name
|
||||
# the wrong project in every checkout but the one that produced it. memories/ is ignored with it:
|
||||
# it is serena's own written notes, and this repo's durable knowledge lives in docs/ instead.
|
||||
.serena/
|
||||
|
||||
+2
-3
@@ -12,9 +12,8 @@ unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
|
||||
|
||||
# H11 (ersatztv#311): refuse to push a branch that is BEHIND origin/main — rebase, don't merge
|
||||
# main in (a merge drags in files you never touched, e.g. legacy-BOM .cs, and trips the format
|
||||
# hook on code that isn't yours). Fail-open; escape with ETV_SKIP_REBASE_CHECK=1. Exempts a
|
||||
# tag-only push (ersatztv#719) — forward the ref lines captured above so it can tell.
|
||||
printf '%s\n' "$_prepush_refs" | ./.claude/hooks/prepush-rebase-check.sh || exit 1
|
||||
# hook on code that isn't yours). Fail-open; escape with ETV_SKIP_REBASE_CHECK=1.
|
||||
./.claude/hooks/prepush-rebase-check.sh || exit 1
|
||||
|
||||
# H13 (ersatztv#416 session): refuse to push when a file in the pushed diff still has uncommitted
|
||||
# working-tree/index changes — the pushed commit wouldn't match what you built/reviewed (the #416
|
||||
|
||||
@@ -52,7 +52,7 @@ docker build -f docker/Dockerfile -t ersatztv:dev .
|
||||
- Test with **NUnit** + Shouldly + NSubstitute (the existing `*.Tests` projects); xUnit is **not** used here
|
||||
- **Dependencies use Central Package Management**: versions live in the repo-root `Directory.Packages.props`; csproj reference packages by name only. Add/upgrade by editing the central `<PackageVersion>` — never put `Version=` back on a `<PackageReference>` (trips `NU1008`). See `docs/ci-cd.md` → Dependency management.
|
||||
- **DB migrations target BOTH providers**: a `TvContext` model change needs a migration in `ErsatzTV.Infrastructure.Sqlite` **and** `ErsatzTV.Infrastructure.MySql` — run `scripts/add-migration.sh <Name>` (does both). CI's `migrations` job enforces model-drift + apply-to-fresh-DB per provider. See `docs/ci-cd.md` → Migration integrity.
|
||||
- **Renovate** is live (`.gitea/workflows/renovate.yml`, weekly + `workflow_dispatch`): opens dependency-update + OSV vuln-fix PRs and a Dependency Dashboard issue; patch bumps to test/dev-only packages auto-merge once `Build & test` passes, the rest are manual. Their `review-verdict/h10` required check is auto-passed **only when BOTH hold**: the PR touches none of `.claude/`/`.codex/`/`.gitea/`/`.husky/`/`scripts/`/`docker/ci/`, **and** every changed path is a dependency manifest (`Directory.Packages.props`, `.config/dotnet-tools.json`) — ersatztv#698. A bot ACCOUNT does not attribute the CODE at a head, so identity alone is no longer sufficient; a Renovate PR touching a `.csproj` or a source file is not blocked, it just needs a real verdict. Cross-repo rollout: server-management#484. See `docs/ci-cd.md` → Dependency management.
|
||||
- **Renovate** is live (`.gitea/workflows/renovate.yml`, weekly + `workflow_dispatch`): opens dependency-update + OSV vuln-fix PRs and a Dependency Dashboard issue; patch bumps to test/dev-only packages auto-merge once `Build & test` passes, the rest are manual. Their `review-verdict/h10` required check is auto-passed **only when BOTH hold**: the PR touches none of `.claude/`/`.gitea/`/`.husky/`/`scripts/`/`docker/ci/`, **and** every changed path is a dependency manifest (`Directory.Packages.props`, `.config/dotnet-tools.json`) — ersatztv#698. A bot ACCOUNT does not attribute the CODE at a head, so identity alone is no longer sufficient; a Renovate PR touching a `.csproj` or a source file is not blocked, it just needs a real verdict. Cross-repo rollout: server-management#484. See `docs/ci-cd.md` → Dependency management.
|
||||
- **Versioning**: release tags are `vYY.<release-seq>.<patch>` (year · sequential release-within-year · patch) — inherited from upstream, **not** year.month. `v26.3.1` = our infra rebuild of upstream 26.3.0 (no app changes); `v26.4.0` is reserved for the first release with app changes. Never `[skip ci]` a commit you'll tag (it suppresses the release build). Full policy: `docs/ci-cd.md` → Versioning & releases.
|
||||
- Backlog tracked via [Gitea Issues](http://192.168.1.95:3000/timothy/ersatztv/issues)
|
||||
|
||||
@@ -83,13 +83,11 @@ main in) and re-run the local gate whenever the fetch shows movement.
|
||||
Every task that closes a Gitea issue MUST complete ALL of these before it is considered done. Use `/done <issue>` to run through this automatically.
|
||||
|
||||
**Merge-consent is derived from state, not asserted (`## Done-when` convention — ersatztv#303 H6 + H10).** Any issue whose PR will merge to `main` should carry a `## Done-when` section in its **issue body** — a checklist of completion criteria (always include an "adversarial review passed" box; add per-issue criteria like tests-green, docs-updated, live-E2E). Two hooks derive merge-consent from it so a premature merge is blocked *by construction*, not by memory:
|
||||
- `pretooluse-merge-consent.sh` (Claude PreToolUse on the Gitea merge tool) — **auto-grants** a merge (emits `permissionDecision: allow`, so **no** redundant mechanical prompt fires) only when the PR's CI is green **and** every `## Done-when` box on the linked issue (`fixes #N`) is ticked **and** a `Review-verdict:` comment references the PR's *current head sha* (**H10**); **denies** on an unticked box, red CI, or a stale/negative review verdict; **asks** (falls back to a human prompt) when it can't derive state (no linked issue, no `## Done-when` section, no `Review-verdict:` comment yet, no creds, Gitea down). On the auto-grant (satisfied) path the derived state **is** the consent — do not also ask conversationally to merge; a separate human confirmation is warranted only when the gate **asks** (ersatztv#314). **The H10 review-verdict convention**: after an adversarial/Codex review of a PR (or its latest fix commit), run **`scripts/post-review-verdict.sh <pr> <MERGEABLE|APPROVED|LGTM|BLOCKED|NOT-MERGEABLE> [note]`** — it posts both the `Review-verdict: … @ <head-sha>` comment and the sha-bound `review-verdict/h10` commit status, proving the *latest* commit was reviewed rather than a stale earlier diff (ersatztv#242). Do not hand-write the comment: the **status** is the required check branch protection enforces, and a comment alone leaves it absent. **The credential you post with must be an account on `H10_REVIEWERS` in `.gitea/workflows/review-verdict.yml`** (`timothy` today) — since ersatztv#742 the gate inherits an existing `success` only from an allow-listed creator (an existing `failure` is left alone on a weaker attributability test, so an attributable rejection VISIBLE AT THE FIRST READ is not re-derived into a green — a rejection landing later, inside a run's own write window, was a separate route and is NARROWED since ersatztv#849 — every path that cannot establish what the head carries now replaces that unknown state with a sticky sentinel instead of leaving it standing; see `ci.verdict-unverified-write-sentinel` for the residuals it names), and since ersatztv#845 the script ENFORCES that coupling rather than assuming it: it reads its own status back and refuses, before writing the verdict comment, unless the recorded `.creator.login` is on that allow-list — so a POSITIVE verdict posted with any other account fails loudly at your terminal instead of being reported as success. The gate still re-derives such a status on the next PR event — that part is unchanged; what the check removes is the tool telling you it worked. **The membership requirement is `success`-only**, mirroring the gate: a `BLOCKED` verdict is honoured from ANY attributable account, so an off-list reviewer can still record a rejection. **The status is still written** — the check runs after the POST, because it measures the creator Gitea recorded rather than what the credential claims — and what is withheld is the verdict COMMENT, which leaves the merge hook at condition (c) with nothing to classify, i.e. an `ask`. So a refused positive verdict leaves a green `review-verdict/h10` standing on that head that the gate itself will not inherit; branch protection binds the context NAME and not its issuer, so do not read that green as consent. The allow-list is derived from the workflow by `scripts/lib/h10-reviewers.sh`; it is never restated.
|
||||
- **The gate is enforced server-side, per sha (ersatztv#622).** `review-verdict/h10` is a required status check on `main`. Because a commit status belongs to one sha, a commit pushed *after* an auto-merge is scheduled clears it and blocks the merge — closing the hole where `merge_when_checks_succeed` froze consent at scheduling time and Gitea later merged an unreviewed head. Renovate-authored and docs-only PRs are auto-passed by `.gitea/workflows/review-verdict.yml`, **except** when they touch `.claude/`, `.codex/`, `.gitea/`, `.husky/`, `scripts/` or `docker/ci/`. See `docs/ci-cd.md` → Review-verdict gate.
|
||||
- `.husky/pre-push` → `prepush-donewhen.sh` — a fail-open backstop that blocks a direct `git push origin main` whose commits `fix #N` an issue with unticked boxes. **Since ersatztv#743 that push can no longer happen at all** (see below), so this hook is now belt-and-braces for a path the server refuses.
|
||||
- `pretooluse-merge-consent.sh` (Claude PreToolUse on the Gitea merge tool) — **auto-grants** a merge (emits `permissionDecision: allow`, so **no** redundant mechanical prompt fires) only when the PR's CI is green **and** every `## Done-when` box on the linked issue (`fixes #N`) is ticked **and** a `Review-verdict:` comment references the PR's *current head sha* (**H10**); **denies** on an unticked box, red CI, or a stale/negative review verdict; **asks** (falls back to a human prompt) when it can't derive state (no linked issue, no `## Done-when` section, no `Review-verdict:` comment yet, no creds, Gitea down). On the auto-grant (satisfied) path the derived state **is** the consent — do not also ask conversationally to merge; a separate human confirmation is warranted only when the gate **asks** (ersatztv#314). **The H10 review-verdict convention**: after an adversarial/Codex review of a PR (or its latest fix commit), run **`scripts/post-review-verdict.sh <pr> <MERGEABLE|APPROVED|BLOCKED|NOT-MERGEABLE> [note]`** — it posts both the `Review-verdict: … @ <head-sha>` comment and the sha-bound `review-verdict/h10` commit status, proving the *latest* commit was reviewed rather than a stale earlier diff (ersatztv#242). Do not hand-write the comment: the **status** is the required check branch protection enforces, and a comment alone leaves it absent.
|
||||
- **The gate is enforced server-side, per sha (ersatztv#622).** `review-verdict/h10` is a required status check on `main`. Because a commit status belongs to one sha, a commit pushed *after* an auto-merge is scheduled clears it and blocks the merge — closing the hole where `merge_when_checks_succeed` froze consent at scheduling time and Gitea later merged an unreviewed head. Renovate-authored and docs-only PRs are auto-passed by `.gitea/workflows/review-verdict.yml`, **except** when they touch `.claude/`, `.gitea/`, `.husky/`, `scripts/` or `docker/ci/`. See `docs/ci-cd.md` → Review-verdict gate.
|
||||
- `.husky/pre-push` → `prepush-donewhen.sh` — a fail-open backstop that blocks a direct `git push origin main` whose commits `fix #N` an issue with unticked boxes.
|
||||
|
||||
**`main` is PR-only — there is no direct-push path any more (ersatztv#743, `release.main-direct-push-disabled`).** Branch protection carries `enable_push: false` **and** `block_admin_merge_override: true`: a direct `git push origin HEAD:main` is refused server-side at pre-receive for every account including a site admin, the contents API is refused too, and an admin cannot `force_merge` past a missing or red required context. This is what makes `review-verdict/h10` load-bearing rather than conventional — Gitea only evaluates `status_check_contexts` on the PR merge path, so before this the whole gate was skippable with no forgery. Practically: **every** change to `main` goes through a PR, including a one-line docs fix. Tag pushes are unaffected (separate mechanism), so the release cut is unchanged.
|
||||
|
||||
Both need Gitea read creds in the env to enforce (**`ETV_GITEA_BASICAUTH=user:pass`** or `ETV_GITEA_TOKEN`; `ETV_GITEA_URL` overrides the base). Without them the merge hook asks and the push backstop is a no-op — the gate degrades to today's manual confirmation, never a silent pass. Docs-only PRs are exempt from the *review-verdict* gate; the direct-push exemption is moot now that direct pushes are refused outright.
|
||||
Both need Gitea read creds in the env to enforce (**`ETV_GITEA_BASICAUTH=user:pass`** or `ETV_GITEA_TOKEN`; `ETV_GITEA_URL` overrides the base). Without them the merge hook asks and the push backstop is a no-op — the gate degrades to today's manual confirmation, never a silent pass. Docs-only PRs/pushes are exempt.
|
||||
|
||||
**The 7 mandatory completion steps and the `## Closing record` comment template** live in the
|
||||
`closing-an-issue` skill (`.claude/skills/closing-an-issue/SKILL.md`) — invoke it (or `/done`)
|
||||
@@ -97,30 +95,17 @@ when finishing a task that closes an issue.
|
||||
|
||||
## Project Boundaries
|
||||
|
||||
**ersatztv OWNS** — *developing the fork*: the ErsatzTV fork code (C#/.NET), the `/api/v1` REST
|
||||
surface, M3U/XMLTV generation, the `ErsatzTV.Mcp` server, CI and releases, and the **`ersatztv`
|
||||
skill** — whose canonical copy is `.claude/skills/ersatztv/SKILL.md` **here**. Both
|
||||
`~/server-management/.claude/skills/ersatztv` and `~/media-management/.claude/skills/ersatztv` are
|
||||
symlinks to it (ersatztv#617, #755). Edit it in this repo; never fork a second copy.
|
||||
|
||||
**The split that is easy to get wrong** (ersatztv#755, `process.ersatztv-owns-code-not-operations`):
|
||||
channel/collection/schedule *code* is owned here; **channel OPERATIONS against the running instance
|
||||
are not**. Creating and editing channels, lineups, collections, schedules, playouts, logos and
|
||||
overlays on the live ErsatzTV belong to `media-management`. Driving prod from here is in scope only
|
||||
as *verification of a change this repo is shipping* (live-E2E, a release smoke test) — not as
|
||||
day-to-day channel work.
|
||||
**ersatztv OWNS**: ErsatzTV fork code (C#/.NET), channel/collection/schedule management, M3U/XMLTV generation, and the **`ersatztv` skill** — whose canonical copy is `.claude/skills/ersatztv/SKILL.md` **here**; `~/server-management/.claude/skills/ersatztv` is a symlink to it (ersatztv#617). Edit it in this repo; never fork a second copy.
|
||||
|
||||
**ersatztv does NOT own**:
|
||||
- Channel/collection/schedule/playout **operations** against a live instance → media-management
|
||||
- Docker compose configs → server-management (`~/downloadswarm/stacks/ersatztv/`)
|
||||
- NFS mounts, Ansible, DNS, networking → server-management
|
||||
- Content sourcing (yt-dlp downloads, Sonarr/Radarr libraries) → media-management
|
||||
- Content sourcing (yt-dlp downloads, Sonarr/Radarr libraries) → media-management (planned)
|
||||
- Jellyfin skill → server-management. `.claude/skills/jellyfin` here is a **relative symlink** to `~/server-management/.claude/skills/jellyfin` (ersatztv#617 — it had silently become a stale divergent copy). It therefore resolves only in a checkout at `~/ersatztv`, not inside a git worktree; that is inherent to the cross-repo symlink pattern server-management already uses (`beets`, `radarr`, `sonarr`, …).
|
||||
|
||||
**For infrastructure changes** (Docker, NFS, ports, Authelia): open an issue in `timothy/server-management`.
|
||||
|
||||
**For content/media sourcing questions and channel operations** (what goes into channels, yt-dlp
|
||||
pipelines, editing a live channel): open an issue in `timothy/media-management`.
|
||||
**For content/media sourcing questions** (what goes into channels, yt-dlp pipelines): open an issue in `timothy/media-management` once it exists; for now, `timothy/server-management`.
|
||||
|
||||
**For plan/audit reviews**: open `~/adversarial-reviewer` before significant architecture changes.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="AsyncFixer" Version="2.1.0" />
|
||||
<PackageVersion Include="Blurhash.SkiaSharp" Version="2.0.0" />
|
||||
<PackageVersion Include="CliWrap" Version="3.10.4" />
|
||||
<PackageVersion Include="CliWrap" Version="3.10.3" />
|
||||
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageVersion Include="Dapper" Version="2.1.79" />
|
||||
<PackageVersion Include="Destructurama.Attributed" Version="5.2.0" />
|
||||
@@ -29,7 +29,7 @@
|
||||
<PackageVersion Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00017" />
|
||||
<PackageVersion Include="Lucene.Net.QueryParser" Version="4.8.0-beta00017" />
|
||||
<PackageVersion Include="MediatR" Version="[12.5.0]" />
|
||||
<PackageVersion Include="Meziantou.Analyzer" Version="3.0.257" />
|
||||
<PackageVersion Include="Meziantou.Analyzer" Version="3.0.129" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Identity.Core" Version="10.0.2" />
|
||||
@@ -75,7 +75,7 @@
|
||||
<PackageVersion Include="RichTextKit.Stbear" Version="0.4.167.3" />
|
||||
<PackageVersion Include="Roslynator.Analyzers" Version="4.15.0" />
|
||||
<PackageVersion Include="Scalar.AspNetCore" Version="2.12.32" />
|
||||
<PackageVersion Include="Scriban.Signed" Version="7.2.6" />
|
||||
<PackageVersion Include="Scriban.Signed" Version="7.2.5" />
|
||||
<PackageVersion Include="Serilog" Version="4.3.0" />
|
||||
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageVersion Include="Serilog.Extensions.Hosting" Version="10.0.0" />
|
||||
@@ -94,7 +94,7 @@
|
||||
<!-- Direct pin to override EF Core 9's transitive SQLitePCLRaw 2.1.10 (vulnerable
|
||||
bundled SQLite, GHSA-2m69-gcr7-jv3q). The 3.x line ships the patched native
|
||||
(lib.e_sqlite3 3.50.3); core 3.0.4 satisfies Microsoft.Data.Sqlite's `>= 2.1.10`. (#8) -->
|
||||
<PackageVersion Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.4" />
|
||||
<PackageVersion Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.5" />
|
||||
<PackageVersion Include="System.CommandLine" Version="2.0.2" />
|
||||
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
|
||||
<PackageVersion Include="Testably.Abstractions" Version="10.0.0" />
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.FFmpeg.State;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Streaming.Graphics;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// #732: the On Now / Next overlay is a default rather than an opt-in, so every newly created channel
|
||||
/// gets the built-in element attached.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This lives in one place because there is more than one channel-creation path and they diverged
|
||||
/// once already: <c>CreateChannelHandler</c> had it and <c>CreateChannelFromLineupHandler</c> -- the
|
||||
/// SPA's primary "Add Channel" flow, and the one Auto-Tune bulk-creates through -- did not. Any new
|
||||
/// site that persists a <c>Channel</c> must call this. The third site, <c>DbInitializer</c>'s default
|
||||
/// channel, needs no call: it runs before <c>AttachOnNowNextByDefault</c> in the same startup, so the
|
||||
/// backfill covers it.
|
||||
/// </remarks>
|
||||
public static class ChannelGraphicsDefaults
|
||||
{
|
||||
public static async Task Attach(TvContext dbContext, Channel channel, CancellationToken cancellationToken)
|
||||
{
|
||||
// HLS Direct is skipped because ErsatzTV is not transcoding there -- there is no frame
|
||||
// pipeline to draw into, and the editor disables the toggle for the same reason. Identity is
|
||||
// the element's full seeded path (`GraphicsElementDefaults.OnNowNextSeededPath`), never its
|
||||
// user-editable Name (the #67 lesson, sharpened from filename to full path by #568).
|
||||
if (channel.StreamingMode is StreamingMode.HttpLiveStreamingDirect)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Option<int> maybeElementId =
|
||||
await GraphicsElementSeeder.GetBuiltInElementId(dbContext, cancellationToken);
|
||||
|
||||
foreach (int elementId in maybeElementId)
|
||||
{
|
||||
// Add rather than assign: a future create path that carries graphics ids would otherwise
|
||||
// be silently discarded here.
|
||||
channel.ChannelGraphicsElements ??= [];
|
||||
channel.ChannelGraphicsElements.Add(new ChannelGraphicsElement { GraphicsElementId = elementId });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,6 @@ public class CreateChannelFromLineupHandler(
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
await ChannelGraphicsDefaults.Attach(dbContext, prepared.Channel, cancellationToken);
|
||||
dbContext.Channels.Add(prepared.Channel);
|
||||
if (prepared.Playlist is not null)
|
||||
{
|
||||
|
||||
@@ -7,7 +7,6 @@ using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Streaming.Graphics;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.Channels.ChannelValidations;
|
||||
@@ -36,8 +35,7 @@ public class CreateChannelHandler(
|
||||
Right: async logoPath =>
|
||||
{
|
||||
ApplyResolvedLogo(request, channel, logoPath);
|
||||
return Right<BaseError, CreateChannelResult>(
|
||||
await PersistChannel(dbContext, channel, cancellationToken));
|
||||
return Right<BaseError, CreateChannelResult>(await PersistChannel(dbContext, channel));
|
||||
},
|
||||
Left: e => Task.FromResult(Left<BaseError, CreateChannelResult>(e)));
|
||||
},
|
||||
@@ -77,12 +75,8 @@ public class CreateChannelHandler(
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<CreateChannelResult> PersistChannel(
|
||||
TvContext dbContext,
|
||||
Channel channel,
|
||||
CancellationToken cancellationToken)
|
||||
private async Task<CreateChannelResult> PersistChannel(TvContext dbContext, Channel channel)
|
||||
{
|
||||
await ChannelGraphicsDefaults.Attach(dbContext, channel, cancellationToken);
|
||||
await dbContext.Channels.AddAsync(channel);
|
||||
await dbContext.SaveChangesAsync();
|
||||
searchTargets.SearchTargetsChanged();
|
||||
|
||||
@@ -595,13 +595,6 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
metadata.Genres ??= [];
|
||||
metadata.Studios ??= [];
|
||||
|
||||
// Artists/AlbumArtists are NULLABLE primitive collections, so they are guarded at the read site
|
||||
// rather than assigned back onto `metadata` like the navigations above (ersatztv#701/#691): they
|
||||
// are scalar JSON-array columns, so `??= []` on a tracked entity would persist `[]` over NULL.
|
||||
// The shipped `_song.sbntxt` only does `array.join`, but a user template is free to do anything.
|
||||
List<string> songArtists = Optional(metadata.Artists).Flatten().ToList();
|
||||
List<string> songAlbumArtists = Optional(metadata.AlbumArtists).Flatten().ToList();
|
||||
|
||||
string artworkPath = GetPrioritizedArtworkPath(metadata);
|
||||
|
||||
var data = new
|
||||
@@ -614,8 +607,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
HasCustomTitle = hasCustomTitle,
|
||||
displayItem.CustomTitle,
|
||||
SongTitle = subtitle,
|
||||
SongArtists = songArtists,
|
||||
SongAlbumArtists = songAlbumArtists,
|
||||
SongArtists = metadata.Artists,
|
||||
SongAlbumArtists = metadata.AlbumArtists,
|
||||
SongHasYear = metadata.Year.HasValue,
|
||||
SongYear = metadata.Year,
|
||||
SongGenres = metadata.Genres.Map(g => g.Name).OrderBy(n => n),
|
||||
|
||||
@@ -47,13 +47,8 @@ public class UpdateChannelHandler(
|
||||
{
|
||||
Either<BaseError, string> resolvedLogo = await ResolveLogoPath(request, cancellationToken);
|
||||
return await resolvedLogo.Match(
|
||||
Right: logoPath =>
|
||||
ApplyUpdateRequestTranslatingLostRace(
|
||||
dbContext,
|
||||
c,
|
||||
request,
|
||||
logoPath,
|
||||
cancellationToken),
|
||||
Right: async logoPath => Right<BaseError, ChannelViewModel>(
|
||||
await ApplyUpdateRequest(dbContext, c, request, logoPath, cancellationToken)),
|
||||
Left: e => Task.FromResult(Left<BaseError, ChannelViewModel>(e)));
|
||||
},
|
||||
Fail: errors => Task.FromResult(Left<BaseError, ChannelViewModel>(errors.Join())));
|
||||
@@ -81,56 +76,6 @@ public class UpdateChannelHandler(
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Validation and the write are two statements, not one atomic step: RefreshGraphicsElements
|
||||
// deletes elements whose template file is gone, and a delete landing between the two turns the
|
||||
// join insert back into the FK violation the validator exists to prevent -- the unhandled 500
|
||||
// again (#568). A transaction does not close that window either: neither provider locks the rows
|
||||
// the validator merely READ, so the concurrent delete still commits. Ask the existence question
|
||||
// again on the failure path instead, and return the same 422 the validator would have returned;
|
||||
// a DbUpdateException from any other cause keeps its own exception rather than being reported as
|
||||
// a client error.
|
||||
//
|
||||
// What is re-asked is the WHOLE of Validate, not the graphics-element half: every FK on this
|
||||
// full-replace DTO -- FFmpegProfileId, WatermarkId, FallbackFillerId, MirrorSourceChannelId and
|
||||
// the graphics element ids -- is written by ApplyUpdateRequest and can lose the same race, and a
|
||||
// recovery path that names its fields one by one silently omits the next FK the DTO gains.
|
||||
// Re-running the validator set is what keeps the two paths from drifting: a check added to
|
||||
// Validate is covered here by construction.
|
||||
private async Task<Either<BaseError, ChannelViewModel>> ApplyUpdateRequestTranslatingLostRace(
|
||||
TvContext dbContext,
|
||||
Channel channel,
|
||||
UpdateChannel request,
|
||||
string logoPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Right<BaseError, ChannelViewModel>(
|
||||
await ApplyUpdateRequest(dbContext, channel, request, logoPath, cancellationToken));
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
// a fresh context: the failed save left the original one tracking the changes that
|
||||
// could not be written, so the same query there could be answered from those. The
|
||||
// channel entity is still the tracked one from the failed context, which Validate reads
|
||||
// only in memory (MirrorSourceMustBeValid's own-playout count) and never re-queries.
|
||||
await using TvContext recheckContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Channel> recheck =
|
||||
await Validate(recheckContext, request, channel, cancellationToken);
|
||||
|
||||
Option<BaseError> maybeError = recheck.Match(
|
||||
Succ: _ => Option<BaseError>.None,
|
||||
Fail: errors => Some(errors.Join()));
|
||||
|
||||
foreach (BaseError error in maybeError)
|
||||
{
|
||||
return Left<BaseError, ChannelViewModel>(error);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ChannelViewModel> ApplyUpdateRequest(
|
||||
TvContext dbContext,
|
||||
Channel c,
|
||||
@@ -284,15 +229,14 @@ public class UpdateChannelHandler(
|
||||
.Apply((_, _, _, _, _) => channel);
|
||||
|
||||
// combine the page-only Group rule plus the FK existence checks (FFmpeg profile / watermark /
|
||||
// fallback filler / graphics elements) with the channel validation; splitting keeps tuple
|
||||
// arity within LanguageExt's supported applicative range while still accumulating all errors
|
||||
// fallback filler) with the channel validation; splitting keeps tuple arity within
|
||||
// LanguageExt's supported applicative range while still accumulating all errors
|
||||
return (ValidateGroup(request.Group),
|
||||
await FFmpegProfileMustExist(dbContext, request, cancellationToken),
|
||||
await WatermarkMustExist(dbContext, request, cancellationToken),
|
||||
await FillerPresetMustExist(dbContext, request, cancellationToken),
|
||||
await GraphicsElementIdsMustExist(dbContext, request, cancellationToken),
|
||||
channelValidation)
|
||||
.Apply((_, _, _, _, _, c) => c);
|
||||
.Apply((_, _, _, _, c) => c);
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, int>> FFmpegProfileMustExist(
|
||||
@@ -351,28 +295,6 @@ public class UpdateChannelHandler(
|
||||
return BaseError.New($"Fallback filler {request.FallbackFillerId} does not exist.");
|
||||
}
|
||||
|
||||
// The reconcile in ApplyUpdateRequest blindly Adds a ChannelGraphicsElement for every incoming
|
||||
// id; an id with no matching GraphicsElement row would otherwise hit
|
||||
// FK_ChannelGraphicsElement_GraphicsElement_GraphicsElementId at SaveChangesAsync and surface as
|
||||
// an unhandled 500 (there is no global exception filter). Reject it here instead, for parity
|
||||
// with every other FK field on this full-replace DTO (#568). The count cap, the request field
|
||||
// named in the message and the cap on echoed ids all live in Validators.IdsMustExist, shared
|
||||
// with the two UpdateDecoHandler twins so the three cannot drift apart.
|
||||
private static Task<Validation<BaseError, Unit>> GraphicsElementIdsMustExist(
|
||||
TvContext dbContext,
|
||||
UpdateChannel request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Validators.IdsMustExist(
|
||||
request,
|
||||
r => r.GraphicsElementIds,
|
||||
"Graphics element",
|
||||
idsAreConsumed: true,
|
||||
(ids, token) => dbContext.GraphicsElements
|
||||
.Where(e => ids.Contains(e.Id))
|
||||
.Select(e => e.Id)
|
||||
.ToListAsync(token),
|
||||
cancellationToken);
|
||||
|
||||
private static async Task<Validation<BaseError, Unit>> MirrorSourceMustBeValid(
|
||||
TvContext dbContext,
|
||||
UpdateChannel request,
|
||||
|
||||
@@ -35,6 +35,4 @@ public record CreateFFmpegProfile(
|
||||
bool NormalizeFramerate,
|
||||
bool NormalizeColors,
|
||||
bool DeinterlaceVideo,
|
||||
bool QsvPreferNativeDecoder,
|
||||
double? ReadRate,
|
||||
double? ReadRateCatchup) : IRequest<Either<BaseError, CreateFFmpegProfileResult>>;
|
||||
bool QsvPreferNativeDecoder) : IRequest<Either<BaseError, CreateFFmpegProfileResult>>;
|
||||
|
||||
@@ -50,12 +50,8 @@ public class CreateFFmpegProfileHandler :
|
||||
private static Validation<BaseError, FFmpegProfile> Validate(
|
||||
CreateFFmpegProfile request,
|
||||
int resolutionId) =>
|
||||
(ValidateName(request),
|
||||
ValidateThreadCount(request),
|
||||
FFmpegProfileBounds.ValidateQsvExtraHardwareFrames(request.QsvExtraHardwareFrames, stored: null),
|
||||
FFmpegProfileBounds.ValidateReadRate(request.ReadRate),
|
||||
FFmpegProfileBounds.ValidateReadRateCatchup(request.ReadRateCatchup, request.ReadRate))
|
||||
.Apply((name, threadCount, _, _, _) =>
|
||||
(ValidateName(request), ValidateThreadCount(request))
|
||||
.Apply((name, threadCount) =>
|
||||
{
|
||||
var hwAccel = request.NormalizeVideo
|
||||
? request.HardwareAcceleration
|
||||
@@ -72,9 +68,11 @@ public class CreateFFmpegProfileHandler :
|
||||
HardwareAcceleration = hwAccel,
|
||||
VaapiDriver = request.VaapiDriver,
|
||||
VaapiDevice = request.VaapiDevice,
|
||||
// stored exactly as submitted: an out-of-range value was already rejected with a
|
||||
// 422 naming the bound, so there is nothing left to silently rewrite (ersatztv#735)
|
||||
QsvExtraHardwareFrames = request.QsvExtraHardwareFrames,
|
||||
// store what the pipeline will actually use, never a pool size FFmpegState would
|
||||
// floor away at render time (ersatztv#529)
|
||||
QsvExtraHardwareFrames = request.QsvExtraHardwareFrames is { } frames
|
||||
? Math.Max(frames, FFmpegState.MinimumQsvExtraHardwareFrames)
|
||||
: null,
|
||||
ResolutionId = resolutionId,
|
||||
ScalingBehavior = request.ScalingBehavior,
|
||||
|
||||
@@ -113,9 +111,7 @@ public class CreateFFmpegProfileHandler :
|
||||
NormalizeFramerate = request.NormalizeFramerate,
|
||||
NormalizeColors = request.NormalizeColors,
|
||||
DeinterlaceVideo = request.DeinterlaceVideo,
|
||||
QsvPreferNativeDecoder = request.QsvPreferNativeDecoder,
|
||||
ReadRate = request.ReadRate,
|
||||
ReadRateCatchup = request.ReadRateCatchup
|
||||
QsvPreferNativeDecoder = request.QsvPreferNativeDecoder
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -36,6 +36,4 @@ public record UpdateFFmpegProfile(
|
||||
bool NormalizeFramerate,
|
||||
bool NormalizeColors,
|
||||
bool DeinterlaceVideo,
|
||||
bool QsvPreferNativeDecoder,
|
||||
double? ReadRate,
|
||||
double? ReadRateCatchup) : IRequest<Either<BaseError, UpdateFFmpegProfileResult>>;
|
||||
bool QsvPreferNativeDecoder) : IRequest<Either<BaseError, UpdateFFmpegProfileResult>>;
|
||||
|
||||
@@ -55,10 +55,11 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
|
||||
p.VaapiDisplay = update.VaapiDisplay;
|
||||
p.VaapiDriver = update.VaapiDriver;
|
||||
p.VaapiDevice = update.VaapiDevice;
|
||||
// stored exactly as submitted: an out-of-range NEW value was already rejected with a 422
|
||||
// naming the bound. an unchanged value that predates that validation is written back as-is
|
||||
// rather than rewritten, and FFmpegState floors it at render time (ersatztv#735)
|
||||
p.QsvExtraHardwareFrames = update.QsvExtraHardwareFrames;
|
||||
// store what the pipeline will actually use, so a profile doesn't keep displaying a pool
|
||||
// size that FFmpegState floors away at render time (ersatztv#529)
|
||||
p.QsvExtraHardwareFrames = update.QsvExtraHardwareFrames is { } frames
|
||||
? Math.Max(frames, FFmpegState.MinimumQsvExtraHardwareFrames)
|
||||
: null;
|
||||
p.ResolutionId = update.ResolutionId;
|
||||
p.ScalingBehavior = update.ScalingBehavior;
|
||||
p.PadMode = update.PadMode;
|
||||
@@ -107,8 +108,6 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
|
||||
p.NormalizeColors = update.NormalizeColors;
|
||||
p.DeinterlaceVideo = update.DeinterlaceVideo;
|
||||
p.QsvPreferNativeDecoder = update.QsvPreferNativeDecoder;
|
||||
p.ReadRate = update.ReadRate;
|
||||
p.ReadRateCatchup = update.ReadRateCatchup;
|
||||
|
||||
// don't save invalid preset
|
||||
ICollection<string> presets = FFmpegLibraryHelper.PresetsForFFmpegProfile(
|
||||
@@ -132,14 +131,8 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
|
||||
TvContext dbContext,
|
||||
UpdateFFmpegProfile request,
|
||||
FFmpegProfile profile) =>
|
||||
(await ValidateName(dbContext, request),
|
||||
ValidateThreadCount(request),
|
||||
FFmpegProfileBounds.ValidateQsvExtraHardwareFrames(
|
||||
request.QsvExtraHardwareFrames,
|
||||
profile.QsvExtraHardwareFrames),
|
||||
FFmpegProfileBounds.ValidateReadRate(request.ReadRate),
|
||||
FFmpegProfileBounds.ValidateReadRateCatchup(request.ReadRateCatchup, request.ReadRate))
|
||||
.Apply((_, _, _, _, _) => profile);
|
||||
(await ValidateName(dbContext, request), ValidateThreadCount(request))
|
||||
.Apply((_, _) => profile);
|
||||
|
||||
private static Task<Option<FFmpegProfile>> FFmpegProfileMustExist(
|
||||
TvContext dbContext,
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.FFmpeg;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
|
||||
/// <summary>
|
||||
/// Write-path bounds for the consequential numeric FFmpeg profile fields.
|
||||
/// A submitted value outside its documented range is REJECTED, naming the bound, rather than
|
||||
/// accepted and silently rewritten to something the caller never sent (ersatztv#735). The
|
||||
/// render-time clamps in <see cref="FFmpegState" /> stay as they are: they cover rows that
|
||||
/// predate this validation or were written out of band, which is what keeps the fix
|
||||
/// migration-free.
|
||||
/// </summary>
|
||||
internal static class FFmpegProfileBounds
|
||||
{
|
||||
internal static Validation<BaseError, Unit> ValidateQsvExtraHardwareFrames(int? requested, int? stored)
|
||||
{
|
||||
// a row stored before this validation existed may hold anything, and the SPA sends the whole
|
||||
// profile back on every edit — so rejecting an UNCHANGED legacy value would make an old
|
||||
// profile uneditable over a field the operator never touched (and cannot even see unless
|
||||
// hardware acceleration is QSV). only a NEWLY submitted out-of-range value is rejected;
|
||||
// FFmpegState.QsvExtraHardwareFrames still floors the legacy one at render time
|
||||
if (requested is null || requested == stored)
|
||||
{
|
||||
return Success<BaseError, Unit>(Unit.Default);
|
||||
}
|
||||
|
||||
return requested < FFmpegState.MinimumQsvExtraHardwareFrames
|
||||
? BaseError.New(
|
||||
$"QSV extra hardware frames must be at least {FFmpegState.MinimumQsvExtraHardwareFrames}; " +
|
||||
$"{requested} leaves the QSV upload pool with too little headroom and the transcode writes nothing at all")
|
||||
: Success<BaseError, Unit>(Unit.Default);
|
||||
}
|
||||
|
||||
internal static Validation<BaseError, Unit> ValidateReadRate(double? requested)
|
||||
{
|
||||
if (requested is null)
|
||||
{
|
||||
return Success<BaseError, Unit>(Unit.Default);
|
||||
}
|
||||
|
||||
return requested is < FFmpegState.MinimumReadRate or > FFmpegState.MaximumReadRate
|
||||
? BaseError.New(
|
||||
$"Read rate must be between {Format(FFmpegState.MinimumReadRate)} and {Format(FFmpegState.MaximumReadRate)}; " +
|
||||
"below realtime the channel stalls, and above this the input is no longer meaningfully paced")
|
||||
: Success<BaseError, Unit>(Unit.Default);
|
||||
}
|
||||
|
||||
internal static Validation<BaseError, Unit> ValidateReadRateCatchup(double? requested, double? requestedReadRate)
|
||||
{
|
||||
if (requested is null)
|
||||
{
|
||||
return Success<BaseError, Unit>(Unit.Default);
|
||||
}
|
||||
|
||||
if (requested is < FFmpegState.MinimumReadRateCatchup or > FFmpegState.MaximumReadRateCatchup)
|
||||
{
|
||||
return BaseError.New(
|
||||
$"Read rate catchup must be between {Format(FFmpegState.MinimumReadRateCatchup)} and " +
|
||||
$"{Format(FFmpegState.MaximumReadRateCatchup)}");
|
||||
}
|
||||
|
||||
// catchup is the rate a LAGGING input may read at until it is level again, so a value at or
|
||||
// below the base rate cannot let it recover: EQUAL is rejected too, because a catchup with
|
||||
// zero headroom is functionally no catchup while still reading as configured. compared
|
||||
// against the transcode default rather than the stream-copy one because that is the higher
|
||||
// of the two: a value that clears it clears both, without this check having to know the
|
||||
// profile's video format
|
||||
double effectiveReadRate = requestedReadRate ?? FFmpegState.DefaultReadRate;
|
||||
return requested <= effectiveReadRate
|
||||
? BaseError.New(
|
||||
$"Read rate catchup ({Format(requested.Value)}) must be greater than the read rate " +
|
||||
$"({Format(effectiveReadRate)}); a lagging input cannot catch up at a rate it is already paced at")
|
||||
: Success<BaseError, Unit>(Unit.Default);
|
||||
}
|
||||
|
||||
private static string Format(double value) =>
|
||||
value.ToString("0.0####", System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
@@ -36,6 +36,4 @@ public record FFmpegProfileViewModel(
|
||||
bool NormalizeFramerate,
|
||||
bool NormalizeColors,
|
||||
bool DeinterlaceVideo,
|
||||
bool QsvPreferNativeDecoder,
|
||||
double? ReadRate,
|
||||
double? ReadRateCatchup);
|
||||
bool QsvPreferNativeDecoder);
|
||||
|
||||
@@ -38,9 +38,7 @@ internal static class Mapper
|
||||
profile.NormalizeFramerate,
|
||||
profile.NormalizeColors,
|
||||
profile.DeinterlaceVideo == true,
|
||||
profile.QsvPreferNativeDecoder != false,
|
||||
profile.ReadRate,
|
||||
profile.ReadRateCatchup);
|
||||
profile.QsvPreferNativeDecoder != false);
|
||||
|
||||
internal static FFmpegProfileResponseModel ProjectToResponseModel(FFmpegProfile ffmpegProfile) =>
|
||||
new(
|
||||
@@ -84,7 +82,5 @@ internal static class Mapper
|
||||
ffmpegProfile.NormalizeFramerate,
|
||||
ffmpegProfile.NormalizeColors,
|
||||
ffmpegProfile.DeinterlaceVideo == true,
|
||||
ffmpegProfile.QsvPreferNativeDecoder != false,
|
||||
ffmpegProfile.ReadRate,
|
||||
ffmpegProfile.ReadRateCatchup);
|
||||
ffmpegProfile.QsvPreferNativeDecoder != false);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.Filler.Mapper;
|
||||
|
||||
@@ -13,13 +12,9 @@ public class GetPagedFillerPresetsHandler(IDbContextFactory<TvContext> dbContext
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
// no filter today, but count and page are still derived from ONE query so that adding one
|
||||
// cannot leave the count behind (api.paged-count-matches-page-query)
|
||||
IQueryable<FillerPreset> query = dbContext.FillerPresets.AsNoTracking();
|
||||
|
||||
int count = await query.CountAsync(cancellationToken);
|
||||
|
||||
List<FillerPresetViewModel> page = await query
|
||||
int count = await dbContext.FillerPresets.CountAsync(cancellationToken);
|
||||
List<FillerPresetViewModel> page = await dbContext.FillerPresets
|
||||
.AsNoTracking()
|
||||
.OrderBy(f => f.Name)
|
||||
.Skip(request.PageNum * request.PageSize)
|
||||
.Take(request.PageSize)
|
||||
|
||||
@@ -22,7 +22,7 @@ public class GetAllGraphicsElementsForApiHandler(IDbContextFactory<TvContext> db
|
||||
.Select(e => new
|
||||
{
|
||||
Vm = ProjectToViewModel(e),
|
||||
BuiltIn = GraphicsElementDefaults.IsOnNowNext(e.Path, e.Kind)
|
||||
BuiltIn = Path.GetFileName(e.Path) == GraphicsElementDefaults.OnNowNextFileName
|
||||
})
|
||||
.OrderBy(x => x.Vm.Name == x.Vm.FileName)
|
||||
.ThenBy(x => x.Vm.Name)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.MediaCollections.Mapper;
|
||||
@@ -13,6 +13,8 @@ public class GetPagedCollectionsHandler(IDbContextFactory<TvContext> dbContextFa
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
int count = await dbContext.Collections.CountAsync(cancellationToken);
|
||||
|
||||
IQueryable<Collection> query = dbContext.Collections.AsNoTracking();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Query))
|
||||
@@ -20,9 +22,6 @@ public class GetPagedCollectionsHandler(IDbContextFactory<TvContext> dbContextFa
|
||||
query = query.Where(c => EF.Functions.Like(c.Name, $"%{request.Query}%"));
|
||||
}
|
||||
|
||||
// count the SAME query the page is taken from, so the two cannot drift (issues #690, #758)
|
||||
int count = await query.CountAsync(cancellationToken);
|
||||
|
||||
List<MediaCollectionViewModel> page = await query
|
||||
.OrderBy(c => c.Name)
|
||||
.Skip(request.PageNum * request.PageSize)
|
||||
|
||||
@@ -13,6 +13,9 @@ public class GetPagedMultiCollectionsHandler(IDbContextFactory<TvContext> dbCont
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
int count = await dbContext.MultiCollections
|
||||
.CountAsync(mc => mc.OwnedByChannelId == null, cancellationToken);
|
||||
|
||||
IQueryable<MultiCollection> query = dbContext.MultiCollections
|
||||
.AsNoTracking()
|
||||
.Where(mc => mc.OwnedByChannelId == null);
|
||||
@@ -22,9 +25,6 @@ public class GetPagedMultiCollectionsHandler(IDbContextFactory<TvContext> dbCont
|
||||
query = query.Where(mc => EF.Functions.Like(mc.Name, $"%{request.Query}%"));
|
||||
}
|
||||
|
||||
// count the SAME query the page is taken from, so the two cannot drift (issues #690, #758)
|
||||
int count = await query.CountAsync(cancellationToken);
|
||||
|
||||
List<MultiCollectionViewModel> page = await query
|
||||
.OrderBy(mc => mc.Name)
|
||||
.Skip(request.PageNum * request.PageSize)
|
||||
|
||||
@@ -13,21 +13,18 @@ public class GetPagedRerunCollectionsHandler(IDbContextFactory<TvContext> dbCont
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
IQueryable<RerunCollection> query = dbContext.RerunCollections.AsNoTracking();
|
||||
int count = await dbContext.RerunCollections.CountAsync(cancellationToken);
|
||||
|
||||
IQueryable<RerunCollection> query = dbContext.RerunCollections.AsNoTracking().IncludeSelectionDetails();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Query))
|
||||
{
|
||||
query = query.Where(rc => EF.Functions.Like(rc.Name, $"%{request.Query}%"));
|
||||
}
|
||||
|
||||
// count the SAME query the page is taken from, so the two cannot drift (issues #690, #758).
|
||||
// The includes belong to the page chain only — a COUNT does not materialize the graph.
|
||||
int count = await query.CountAsync(cancellationToken);
|
||||
|
||||
// EF applies the includes to the paged subquery, so the selection graph is loaded for at most
|
||||
// PageSize rows — the per-request cost is bounded by the page, not by the table (issue #671).
|
||||
List<RerunCollectionViewModel> page = await query
|
||||
.IncludeSelectionDetails()
|
||||
.OrderBy(rc => rc.Name)
|
||||
.Skip(request.PageNum * request.PageSize)
|
||||
.Take(request.PageSize)
|
||||
|
||||
@@ -13,6 +13,9 @@ public class GetPagedSmartCollectionsHandler(IDbContextFactory<TvContext> dbCont
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
int count = await dbContext.SmartCollections
|
||||
.CountAsync(sc => sc.OwnedByChannelId == null, cancellationToken);
|
||||
|
||||
IQueryable<SmartCollection> query = dbContext.SmartCollections
|
||||
.AsNoTracking()
|
||||
.Where(sc => sc.OwnedByChannelId == null);
|
||||
@@ -22,9 +25,6 @@ public class GetPagedSmartCollectionsHandler(IDbContextFactory<TvContext> dbCont
|
||||
query = query.Where(sc => EF.Functions.Like(sc.Name, $"%{request.Query}%"));
|
||||
}
|
||||
|
||||
// count the SAME query the page is taken from, so the two cannot drift (issues #690, #758)
|
||||
int count = await query.CountAsync(cancellationToken);
|
||||
|
||||
List<SmartCollectionViewModel> page = await query
|
||||
.OrderBy(s => s.Name)
|
||||
.Skip(request.PageNum * request.PageSize)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.MediaCollections.Mapper;
|
||||
|
||||
@@ -13,13 +12,9 @@ public class GetPagedTraktListsHandler(IDbContextFactory<TvContext> dbContextFac
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
// no filter today, but count and page are still derived from ONE query so that adding one
|
||||
// cannot leave the count behind (api.paged-count-matches-page-query)
|
||||
IQueryable<TraktList> query = dbContext.TraktLists.AsNoTracking();
|
||||
|
||||
int count = await query.CountAsync(cancellationToken);
|
||||
|
||||
List<TraktListViewModel> page = await query
|
||||
int count = await dbContext.TraktLists.CountAsync(cancellationToken);
|
||||
List<TraktListViewModel> page = await dbContext.TraktLists
|
||||
.AsNoTracking()
|
||||
.OrderBy(l => l.Name)
|
||||
.Skip(request.PageNum * request.PageSize)
|
||||
.Take(request.PageSize)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Scheduling;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
@@ -87,29 +86,6 @@ public class ReplacePlayoutAlternateScheduleItemsHandler(
|
||||
|
||||
var incoming = request.Items.Except([highest]).ToList();
|
||||
|
||||
// Reject an EXPLICITLY empty recurrence set before any mutation (#880). The checked set is
|
||||
// `incoming` -- the exact list whose DaysOfWeek/DaysOfMonth/MonthsOfYear the loops below
|
||||
// write -- so the check and its subject cannot drift apart. That EXCLUDES the highest-Index
|
||||
// catch-all by construction: its recurrence is discarded along with its date range (only its
|
||||
// ProgramScheduleId is read, further down), so an empty set there cannot make anything "never
|
||||
// apply" and rejecting it would state a reason that is false for that item.
|
||||
foreach (ReplacePlayoutAlternateSchedule item in incoming)
|
||||
{
|
||||
ProgramScheduleAlternate stored = existing.FirstOrDefault(e => e.Id == item.Id);
|
||||
Option<BaseError> recurrenceError = RecurrenceSetBounds.Validate(
|
||||
item.DaysOfWeek,
|
||||
item.DaysOfMonth,
|
||||
item.MonthsOfYear,
|
||||
stored?.DaysOfWeek,
|
||||
stored?.DaysOfMonth,
|
||||
stored?.MonthsOfYear);
|
||||
|
||||
foreach (BaseError error in recurrenceError)
|
||||
{
|
||||
return error;
|
||||
}
|
||||
}
|
||||
|
||||
var toAdd = incoming.Filter(x => existing.All(e => e.Id != x.Id)).ToList();
|
||||
var toRemove = existing.Filter(e => incoming.All(m => m.Id != e.Id)).ToList();
|
||||
var toUpdate = incoming.Except(toAdd).ToList();
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Scheduling;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
@@ -41,15 +40,9 @@ internal static class Mapper
|
||||
programScheduleAlternate.Id,
|
||||
programScheduleAlternate.Index,
|
||||
programScheduleAlternate.ProgramScheduleId,
|
||||
// ersatztv#823: these three are NULLABLE columns and a legacy row can hold NULL. Substitute the
|
||||
// SAME unrestricted defaults AlternateScheduleSelector.GetScheduleForDate reads, so the DTO the
|
||||
// SPA renders agrees with what actually gets scheduled -- web/src/screens/playoutTemplateCalendar.ts
|
||||
// `appliesToDate` is an exact port of that method, and it would otherwise both mispreview and
|
||||
// throw (`[...template.daysOfMonth]` on a null is a TypeError). Never assigned back onto the
|
||||
// entity (`media.nullable-primitive-collection-mutation`).
|
||||
programScheduleAlternate.DaysOfWeek ?? AlternateScheduleSelector.AllDaysOfWeek(),
|
||||
programScheduleAlternate.DaysOfMonth ?? AlternateScheduleSelector.AllDaysOfMonth(),
|
||||
programScheduleAlternate.MonthsOfYear ?? AlternateScheduleSelector.AllMonthsOfYear(),
|
||||
programScheduleAlternate.DaysOfWeek,
|
||||
programScheduleAlternate.DaysOfMonth,
|
||||
programScheduleAlternate.MonthsOfYear,
|
||||
programScheduleAlternate.LimitToDateRange,
|
||||
programScheduleAlternate.StartMonth,
|
||||
programScheduleAlternate.StartDay,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.Playouts.Mapper;
|
||||
@@ -13,8 +13,13 @@ public class GetPagedPlayoutsHandler(IDbContextFactory<TvContext> dbContextFacto
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
int count = await dbContext.Playouts.CountAsync(cancellationToken);
|
||||
|
||||
IQueryable<Playout> query = dbContext.Playouts
|
||||
.AsNoTracking()
|
||||
.Include(p => p.Channel)
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.Include(p => p.BuildStatus)
|
||||
.Filter(p => p.Channel != null);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Query))
|
||||
@@ -22,15 +27,7 @@ public class GetPagedPlayoutsHandler(IDbContextFactory<TvContext> dbContextFacto
|
||||
query = query.Where(p => EF.Functions.Like(p.Channel.Name, $"%{request.Query}%"));
|
||||
}
|
||||
|
||||
// count the SAME query the page is taken from, so the two cannot drift (issues #690, #758).
|
||||
// This is also what makes the `Channel != null` filter count, which the old unfiltered
|
||||
// CountAsync over the whole DbSet did not.
|
||||
int count = await query.CountAsync(cancellationToken);
|
||||
|
||||
List<PlayoutNameViewModel> page = await query
|
||||
.Include(p => p.Channel)
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.Include(p => p.BuildStatus)
|
||||
.OrderBy(p => p.Channel.SortNumber)
|
||||
.Skip(request.PageNum * request.PageSize)
|
||||
.Take(request.PageSize)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.ProgramSchedules.Mapper;
|
||||
@@ -13,6 +13,8 @@ public class GetPagedProgramSchedulesHandler(IDbContextFactory<TvContext> dbCont
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
int count = await dbContext.ProgramSchedules.CountAsync(cancellationToken);
|
||||
|
||||
IQueryable<ProgramSchedule> query = dbContext.ProgramSchedules.AsNoTracking();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Query))
|
||||
@@ -20,9 +22,6 @@ public class GetPagedProgramSchedulesHandler(IDbContextFactory<TvContext> dbCont
|
||||
query = query.Where(ps => EF.Functions.Like(ps.Name, $"%{request.Query}%"));
|
||||
}
|
||||
|
||||
// count the SAME query the page is taken from, so the two cannot drift (issues #690, #758)
|
||||
int count = await query.CountAsync(cancellationToken);
|
||||
|
||||
List<ProgramScheduleViewModel> page = await query
|
||||
.OrderBy(ps => ps.Name)
|
||||
.Skip(request.PageNum * request.PageSize)
|
||||
|
||||
@@ -44,25 +44,6 @@ public class ReplacePlayoutTemplateItemsHandler(
|
||||
|
||||
List<ReplacePlayoutTemplate> incoming = request.Items;
|
||||
|
||||
// Same rule as the alternate-schedule path (#880), over ALL items: unlike that one, every
|
||||
// template item's recurrence IS stored, so there is no catch-all to exclude here.
|
||||
foreach (ReplacePlayoutTemplate item in incoming)
|
||||
{
|
||||
PlayoutTemplate stored = existing.FirstOrDefault(e => e.Id == item.Id);
|
||||
Option<BaseError> recurrenceError = RecurrenceSetBounds.Validate(
|
||||
item.DaysOfWeek,
|
||||
item.DaysOfMonth,
|
||||
item.MonthsOfYear,
|
||||
stored?.DaysOfWeek,
|
||||
stored?.DaysOfMonth,
|
||||
stored?.MonthsOfYear);
|
||||
|
||||
if (recurrenceError.IsSome)
|
||||
{
|
||||
return recurrenceError;
|
||||
}
|
||||
}
|
||||
|
||||
var toAdd = incoming.Filter(x => existing.All(e => e.Id != x.Id)).ToList();
|
||||
var toRemove = existing.Filter(e => incoming.All(m => m.Id != e.Id)).ToList();
|
||||
var toUpdate = incoming.Except(toAdd).ToList();
|
||||
|
||||
@@ -19,50 +19,7 @@ public class UpdateDecoHandler(
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Deco> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Match(
|
||||
Succ: deco => ApplyUpdateRequestTranslatingLostRace(dbContext, deco, request, cancellationToken),
|
||||
Fail: errors => Task.FromResult(Left<BaseError, Unit>(errors.Join())));
|
||||
}
|
||||
|
||||
// Mirrors UpdateChannelHandler.ApplyUpdateRequestTranslatingLostRace (#568): validation and the
|
||||
// write are two statements, so a concurrent delete of a validated watermark or graphics element
|
||||
// -- RefreshGraphicsElements deletes elements whose template file is gone -- lands the join
|
||||
// insert on the FK violation the validators exist to prevent, as an unhandled 500. A transaction
|
||||
// does not close that window either (neither provider locks the rows the validator merely READ),
|
||||
// so ask the existence questions again on the failure path and return the same 422; a
|
||||
// DbUpdateException from any other cause keeps its own exception.
|
||||
//
|
||||
// The whole of Validate is re-asked rather than a named pair of fields, for the same reason as
|
||||
// the channel twin: a recovery path that enumerates its own fields omits the next one the DTO
|
||||
// gains, while re-running the validator set covers a check added to Validate by construction.
|
||||
private async Task<Either<BaseError, Unit>> ApplyUpdateRequestTranslatingLostRace(
|
||||
TvContext dbContext,
|
||||
Deco existing,
|
||||
UpdateDeco request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await ApplyUpdateRequest(dbContext, existing, request, cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
// a fresh context: the failed save left the original one tracking the changes that
|
||||
// could not be written, so the same query there could be answered out of those.
|
||||
await using TvContext recheckContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Deco> recheck = await Validate(recheckContext, request, cancellationToken);
|
||||
|
||||
Option<BaseError> maybeError = recheck.Match(
|
||||
Succ: _ => Option<BaseError>.None,
|
||||
Fail: errors => Some(errors.Join()));
|
||||
|
||||
foreach (BaseError error in maybeError)
|
||||
{
|
||||
return Left<BaseError, Unit>(error);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
return await validation.Apply(ps => ApplyUpdateRequest(dbContext, ps, request, cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyUpdateRequest(
|
||||
@@ -74,7 +31,7 @@ public class UpdateDecoHandler(
|
||||
existing.Name = request.Name;
|
||||
|
||||
// watermark
|
||||
bool hasWatermark = ConsumesWatermarkIds(request);
|
||||
bool hasWatermark = request.WatermarkMode is (DecoMode.Override or DecoMode.Merge);
|
||||
existing.WatermarkMode = request.WatermarkMode;
|
||||
existing.UseWatermarkDuringFiller = hasWatermark && request.UseWatermarkDuringFiller;
|
||||
|
||||
@@ -102,7 +59,7 @@ public class UpdateDecoHandler(
|
||||
}
|
||||
|
||||
// graphics elements
|
||||
bool hasGraphicsElements = ConsumesGraphicsElementIds(request);
|
||||
bool hasGraphicsElements = request.GraphicsElementsMode is (DecoMode.Override or DecoMode.Merge);
|
||||
existing.GraphicsElementsMode = request.GraphicsElementsMode;
|
||||
existing.UseGraphicsElementsDuringFiller = hasGraphicsElements && request.UseGraphicsElementsDuringFiller;
|
||||
|
||||
@@ -261,64 +218,8 @@ public class UpdateDecoHandler(
|
||||
UpdateDeco request,
|
||||
CancellationToken cancellationToken) =>
|
||||
(await DecoMustExist(dbContext, request, cancellationToken), await ValidateDecoName(dbContext, request),
|
||||
ValidateBreakContent(request),
|
||||
await WatermarkIdsMustExist(dbContext, request, cancellationToken),
|
||||
await GraphicsElementIdsMustExist(dbContext, request, cancellationToken))
|
||||
.Apply((deco, _, _, _, _) => deco);
|
||||
|
||||
// The mode decides whether an id list is data or dead weight: ApplyUpdateRequest reconciles the
|
||||
// join table only under Override/Merge and Clear()s it otherwise, ignoring the ids entirely. The
|
||||
// validators below read these same two predicates rather than restating the mode test, so a
|
||||
// validator can never reject an id the apply path was going to discard (#568). The SPA sends both
|
||||
// id lists regardless of the mode selector, so that shape arrives from the real editor: a draft
|
||||
// holding an element that has since been deleted must still be able to save the deco back to
|
||||
// Inherit. The predicate is handed to Validators.IdsMustExist rather than short-circuiting the
|
||||
// call, because only the EXISTENCE half belongs to the apply path: a discarded list was still
|
||||
// parsed and materialized out of the request body, so the raw-count cap has to apply under
|
||||
// every mode.
|
||||
private static bool ConsumesWatermarkIds(UpdateDeco request) =>
|
||||
request.WatermarkMode is (DecoMode.Override or DecoMode.Merge);
|
||||
|
||||
private static bool ConsumesGraphicsElementIds(UpdateDeco request) =>
|
||||
request.GraphicsElementsMode is (DecoMode.Override or DecoMode.Merge);
|
||||
|
||||
// Mirrors UpdateChannelHandler.GraphicsElementIdsMustExist (#568): the reconcile in
|
||||
// ApplyUpdateRequest blindly Adds a DecoWatermark/DecoGraphicsElement for every incoming id, and
|
||||
// an id with no matching row hits the FK constraint at SaveChangesAsync and surfaces as an
|
||||
// unhandled 500 (there is no global exception filter). These are top-level fields on
|
||||
// ReplaceDecoRequest, the same position as graphicsElementIds on UpdateChannelRequest -- not the
|
||||
// "deep FK ids nested inside item-list request bodies" carve-out in api-conventions.md. Both go
|
||||
// through Validators.IdsMustExist, the one place the count cap, the request field named in the
|
||||
// message and the cap on echoed ids are written.
|
||||
private static Task<Validation<BaseError, Unit>> WatermarkIdsMustExist(
|
||||
TvContext dbContext,
|
||||
UpdateDeco request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Validators.IdsMustExist(
|
||||
request,
|
||||
r => r.WatermarkIds,
|
||||
"Watermark",
|
||||
idsAreConsumed: ConsumesWatermarkIds(request),
|
||||
(ids, token) => dbContext.ChannelWatermarks
|
||||
.Where(w => ids.Contains(w.Id))
|
||||
.Select(w => w.Id)
|
||||
.ToListAsync(token),
|
||||
cancellationToken);
|
||||
|
||||
private static Task<Validation<BaseError, Unit>> GraphicsElementIdsMustExist(
|
||||
TvContext dbContext,
|
||||
UpdateDeco request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Validators.IdsMustExist(
|
||||
request,
|
||||
r => r.GraphicsElementIds,
|
||||
"Graphics element",
|
||||
idsAreConsumed: ConsumesGraphicsElementIds(request),
|
||||
(ids, token) => dbContext.GraphicsElements
|
||||
.Where(e => ids.Contains(e.Id))
|
||||
.Select(e => e.Id)
|
||||
.ToListAsync(token),
|
||||
cancellationToken);
|
||||
ValidateBreakContent(request))
|
||||
.Apply((deco, _, _) => deco);
|
||||
|
||||
private static Task<Validation<BaseError, Deco>> DecoMustExist(
|
||||
TvContext dbContext,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using ErsatzTV.Application.Tree;
|
||||
using ErsatzTV.Application.Tree;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Scheduling;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Application.Scheduling;
|
||||
|
||||
@@ -191,15 +190,9 @@ internal static class Mapper
|
||||
ProjectToViewModel(playoutTemplate.Template),
|
||||
ProjectToViewModel(playoutTemplate.DecoTemplate),
|
||||
playoutTemplate.Index,
|
||||
// ersatztv#823: these three are NULLABLE columns and a legacy row can hold NULL. Substitute the
|
||||
// SAME unrestricted defaults AlternateScheduleSelector.GetScheduleForDate reads, so the DTO the
|
||||
// SPA renders agrees with what actually gets scheduled -- web/src/screens/playoutTemplateCalendar.ts
|
||||
// `appliesToDate` is an exact port of that method, and it would otherwise both mispreview and
|
||||
// throw (`[...template.daysOfMonth]` on a null is a TypeError). Never assigned back onto the
|
||||
// entity (`media.nullable-primitive-collection-mutation`).
|
||||
playoutTemplate.DaysOfWeek ?? AlternateScheduleSelector.AllDaysOfWeek(),
|
||||
playoutTemplate.DaysOfMonth ?? AlternateScheduleSelector.AllDaysOfMonth(),
|
||||
playoutTemplate.MonthsOfYear ?? AlternateScheduleSelector.AllMonthsOfYear(),
|
||||
playoutTemplate.DaysOfWeek,
|
||||
playoutTemplate.DaysOfMonth,
|
||||
playoutTemplate.MonthsOfYear,
|
||||
playoutTemplate.LimitToDateRange,
|
||||
playoutTemplate.StartMonth,
|
||||
playoutTemplate.StartDay,
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// Validates the three recurrence sets shared by <c>ProgramScheduleAlternate</c> and
|
||||
/// <c>PlayoutTemplate</c> (ersatztv#880). One validator called from BOTH replace handlers, mirroring
|
||||
/// <c>FFmpegProfileBounds</c> — the exemplar for `api.ffmpeg-profile-numeric-bounds`, whose shape this
|
||||
/// follows deliberately.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// An EMPTY set is rejected because the three are read CONJUNCTIVELY by
|
||||
/// <c>AlternateScheduleSelector.GetScheduleForDate</c> — a miss on any one continues to the next
|
||||
/// item — so an empty one matches NO date and stores an item that can never apply. Rejecting
|
||||
/// rather than substituting is the point: accept-then-rewrite would make an explicit `[]`
|
||||
/// indistinguishable from an omitted field, which is the very collapse this issue removed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// An UNCHANGED empty set that the row ALREADY holds is let through. Both PUT paths are
|
||||
/// whole-list replaces, so a hard rejection would make every OTHER item in the playout
|
||||
/// uneditable over a row the operator never touched — the same reason
|
||||
/// `api.ffmpeg-profile-numeric-bounds` rejects only a NEWLY submitted out-of-range value. A row
|
||||
/// whose stored set is NULL is NOT exempt: null means unrestricted, so submitting `[]` for it is
|
||||
/// a new emptying, not an unchanged legacy value.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This runs on the COMMAND, after the request records have normalized an ABSENT array to the
|
||||
/// All*() sets, so an empty set reaching here is one a caller sent EXPLICITLY. That also means a
|
||||
/// direct (non-HTTP) caller is held to the same rule rather than being able to write a dead row.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class RecurrenceSetBounds
|
||||
{
|
||||
public static Option<BaseError> Validate(
|
||||
ICollection<DayOfWeek> daysOfWeek,
|
||||
ICollection<int> daysOfMonth,
|
||||
ICollection<int> monthsOfYear,
|
||||
ICollection<DayOfWeek> storedDaysOfWeek,
|
||||
ICollection<int> storedDaysOfMonth,
|
||||
ICollection<int> storedMonthsOfYear)
|
||||
{
|
||||
if (IsNewlyEmpty(daysOfWeek, storedDaysOfWeek))
|
||||
{
|
||||
return Some(BaseError.New(Message("DaysOfWeek", "no day of the week")));
|
||||
}
|
||||
|
||||
if (IsNewlyEmpty(daysOfMonth, storedDaysOfMonth))
|
||||
{
|
||||
return Some(BaseError.New(Message("DaysOfMonth", "no day of the month")));
|
||||
}
|
||||
|
||||
if (IsNewlyEmpty(monthsOfYear, storedMonthsOfYear))
|
||||
{
|
||||
return Some(BaseError.New(Message("MonthsOfYear", "no month")));
|
||||
}
|
||||
|
||||
return Option<BaseError>.None;
|
||||
}
|
||||
|
||||
// "send null" rather than "omit the property": all three are listed in the schema's `required` array
|
||||
// in v1.json (they are nullable, not optional), so a client generated from the published contract
|
||||
// cannot omit them. Omitting also works at runtime -- Newtonsoft maps a missing property and an
|
||||
// explicit null to the same thing -- but naming only that would tell a conforming client to send
|
||||
// something its own schema forbids.
|
||||
private static string Message(string field, string consequence) =>
|
||||
$"[{field}] must not be empty; an empty set matches {consequence}, so the item would never apply. " +
|
||||
"Send null to leave it unrestricted";
|
||||
|
||||
// A new item (no stored row) has `stored` null, so an empty set is newly empty and is rejected.
|
||||
// Only a stored set that is ITSELF already empty exempts an empty submission.
|
||||
private static bool IsNewlyEmpty<T>(ICollection<T> submitted, ICollection<T> stored) =>
|
||||
submitted is { Count: 0 } && stored is not { Count: 0 };
|
||||
}
|
||||
@@ -18,7 +18,8 @@ public class GetSearchFieldValuesHandler(IDbContextFactory<TvContext> dbContextF
|
||||
/// Rows read per round trip when walking the list-valued (JSON-array) columns on
|
||||
/// <c>SongMetadata</c>, and the ceiling on rows read per request.
|
||||
/// <para>
|
||||
/// These count ACTUAL ROWS. A fixed <c>LIMIT</c> budget bounded the RESULT, and
|
||||
/// These count ACTUAL ROWS, and arriving at that took four tries — each earlier attempt bounded a
|
||||
/// quantity that sounded like rows and was not. A fixed <c>LIMIT</c> budget bounded the RESULT, and
|
||||
/// the pre-filter (allowed to over-match) starved it with rows that could not match. Keyset paging
|
||||
/// with a <c>LIMIT</c> bounded CANDIDATES RETURNED — but a query matching nothing must evaluate
|
||||
/// every eligible row before it can return an empty page, so rows inspected stayed unbounded. A
|
||||
@@ -35,7 +36,8 @@ public class GetSearchFieldValuesHandler(IDbContextFactory<TvContext> dbContextF
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Be precise about what is bounded: LOGICAL ROWS RETURNED AND MATERIALIZED, and the number of
|
||||
/// round trips. Not physical work, and not bytes.</b> Two things break the stronger reading:
|
||||
/// round trips. Not physical work, and not bytes.</b> Two things break the stronger reading, and an
|
||||
/// earlier version of this comment asserted it anyway:
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// MySQL purge lag. Deleted clustered-index records survive until purge runs, and a range
|
||||
@@ -140,7 +142,7 @@ public class GetSearchFieldValuesHandler(IDbContextFactory<TvContext> dbContextF
|
||||
// over-match, even though the column collation (utf8mb4_0900_ai_ci) is accent-insensitive: the driver
|
||||
// binds the LIKE pattern with a BINARY collation, so the comparison is accent-sensitive in practice.
|
||||
// A hand-typed probe using a LITERAL pattern DOES over-match; that is a different query from the one
|
||||
// this code runs, and mistaking the two gives a false read on whether this predicate over-matches.
|
||||
// this code runs, and mistaking the two is how an earlier revision of the decision record got it wrong.
|
||||
if (source is not null && ContainsNonAscii(query) && IsSqlite(dbContext))
|
||||
{
|
||||
values.AddRange(
|
||||
@@ -470,7 +472,7 @@ public class GetSearchFieldValuesHandler(IDbContextFactory<TvContext> dbContextF
|
||||
/// ordering key</i>, which positions the scan and never discards a row, whereas a residual
|
||||
/// predicate throws away rows the engine already produced. <c>LIMIT</c> only truncates what
|
||||
/// survives a residual predicate, so with one present it bounds the output rather than the row
|
||||
/// count — a gap wide enough to scan straight past a nominal row-count bound. With none, <c>LIMIT n</c>
|
||||
/// count — which is how every earlier revision scanned past its own bound. With none, <c>LIMIT n</c>
|
||||
/// yields <c>n</c> logical rows. Null payloads are dropped in memory by
|
||||
/// <see cref="ParseElements" />.
|
||||
/// </para>
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
using System.Linq.Expressions;
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application;
|
||||
|
||||
public static partial class Validators
|
||||
{
|
||||
/// <summary>
|
||||
/// The largest id list a full-replace write path accepts in one of its top-level id fields.
|
||||
/// Deliberately far above any real payload -- the lists it bounds select from tables an
|
||||
/// operator curates by hand (graphics elements, watermarks), where a few dozen rows is a
|
||||
/// large install -- so the bound is a ceiling on abuse, not a product limit anyone can reach
|
||||
/// by using the editor (#568).
|
||||
/// </summary>
|
||||
public const int MaximumIdListCount = 512;
|
||||
|
||||
// A 422 that echoes every rejected id turns an oversized request into an oversized response.
|
||||
// Enough ids to fix the payload by hand, then a count.
|
||||
private const int MaximumReportedMissingIds = 10;
|
||||
|
||||
/// <summary>
|
||||
/// The shared existence check for a top-level list of FK ids on a full-replace request:
|
||||
/// bound the list, resolve which of its ids exist through <paramref name="findExisting" />,
|
||||
/// and reject the rest with a 422 that names the request field it came from.
|
||||
/// </summary>
|
||||
/// <param name="idsAreConsumed">
|
||||
/// Whether the apply path will actually read this list — false where another field of the
|
||||
/// same request (a deco's <c>DecoMode</c>) makes the reconcile discard it. It gates the
|
||||
/// EXISTENCE half only, never the count: a validator may not reject an id the apply path
|
||||
/// was going to throw away, but the raw list was still parsed and materialized out of the
|
||||
/// request body whatever is done with it afterwards, so the cap is the request's bound and
|
||||
/// not the apply path's (#568).
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// The count is taken from the RAW list, before <c>Distinct</c> and before any database
|
||||
/// work: deduplication is not what the request costs. A million-entry list of one repeated
|
||||
/// id parses, allocates and materializes in full whatever the distinct count turns out to
|
||||
/// be, so a cap applied after <c>Distinct</c> would bound the query and leave the request
|
||||
/// itself unbounded.
|
||||
/// </remarks>
|
||||
public static async Task<Validation<BaseError, Unit>> IdsMustExist<T>(
|
||||
T input,
|
||||
Expression<Func<T, List<int>>> expression,
|
||||
string noun,
|
||||
bool idsAreConsumed,
|
||||
Func<List<int>, CancellationToken, Task<List<int>>> findExisting,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string field = GetMemberName(expression);
|
||||
List<int> submitted = expression.Compile()(input) ?? [];
|
||||
|
||||
if (submitted.Count > MaximumIdListCount)
|
||||
{
|
||||
return BaseError.New(
|
||||
$"[{field}] contains {submitted.Count} ids; at most {MaximumIdListCount} are accepted. " +
|
||||
"The whole list is materialized into one existence query and then reconciled against every " +
|
||||
"row already attached, so a longer list turns a single request into unbounded work.");
|
||||
}
|
||||
|
||||
if (!idsAreConsumed)
|
||||
{
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
List<int> requested = submitted.Distinct().ToList();
|
||||
if (requested.Count == 0)
|
||||
{
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
List<int> existingIds = await findExisting(requested, cancellationToken);
|
||||
|
||||
List<int> missingIds = requested.Except(existingIds).OrderBy(id => id).ToList();
|
||||
if (missingIds.Count == 0)
|
||||
{
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
return BaseError.New($"[{field}] {noun}(s) do not exist: {DescribeIds(missingIds)}");
|
||||
}
|
||||
|
||||
private static string DescribeIds(IReadOnlyList<int> ids) =>
|
||||
ids.Count <= MaximumReportedMissingIds
|
||||
? string.Join(", ", ids)
|
||||
: $"{string.Join(", ", ids.Take(MaximumReportedMissingIds))} (and " +
|
||||
$"{ids.Count - MaximumReportedMissingIds} more)";
|
||||
}
|
||||
@@ -359,9 +359,9 @@ public class WatermarkSelectorDecoResolutionTests
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The channel-level fallback is deliberately an INDEPENDENTLY RESOLVABLE `ChannelLogo` watermark whose
|
||||
/// cached file exists. Giving the fallback the same missing custom path as the playout-item watermark
|
||||
/// would make the test unfalsifiable: a wrongly-widened guard would fall through to a fallback that also
|
||||
/// resolves to None, so the assertion would hold either way.
|
||||
/// cached file exists. An earlier version of this test gave the fallback the same missing custom path as
|
||||
/// the playout-item watermark, which made it unfalsifiable: a wrongly-widened guard would have fallen
|
||||
/// through to a fallback that also resolved to None, so the assertion held either way.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public void Missing_But_Named_Custom_Playout_Item_Watermark_Should_Not_Fall_Through()
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Scheduling;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using NUnit.Framework;
|
||||
@@ -865,241 +864,4 @@ public static class AlternateScheduleSelectorTests
|
||||
result.IsNone.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ersatztv#823. <c>DaysOfWeek</c>, <c>DaysOfMonth</c> and <c>MonthsOfYear</c> on
|
||||
/// <see cref="PlayoutTemplate" /> and <see cref="ProgramScheduleAlternate" /> are six
|
||||
/// single-column primitive collections whose columns are <c>nullable: true</c> on both providers.
|
||||
/// A NULL column materializes as CLR <c>null</c> — EF does not invoke the value converter for a
|
||||
/// NULL at all — so unguarded, each <c>.Contains</c> in
|
||||
/// <see cref="AlternateScheduleSelector.GetScheduleForDate{T}" /> throws
|
||||
/// <see cref="NullReferenceException" />. These tests are RED without the read-site guard.
|
||||
/// <para>
|
||||
/// A null reads as UNRESTRICTED (the <c>All*()</c> sets), not as empty. The deciding case is
|
||||
/// SQLite's <c>20240113140741_Add_PlayoutTemplate_DaysOfMonth</c>, which adds the column
|
||||
/// <c>nullable: true</c> with NO default: a row inserted before it had no day-of-month
|
||||
/// restriction, so reading its NULL as empty would INVERT its meaning and silently stop the
|
||||
/// template applying. That is the one NULL reachable without any code writing one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Reachability itself is pinned by
|
||||
/// <c>ErsatzTV.Tests.Integration.SchedulingCollectionColumnNullTests</c> against a real
|
||||
/// <c>TvContext</c>; these tests pin what the selector does once the null is there.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class GetScheduleForDate_NullCollections
|
||||
{
|
||||
private static readonly TimeSpan Offset = TimeSpan.FromHours(-5);
|
||||
|
||||
// A Wednesday in March, so no All*() member is coincidentally excluded — and deliberately the
|
||||
// 20th rather than the 6th. With a day <= 12 a CROSS-WIRED substitution survives the whole
|
||||
// fixture: `DaysOfMonth ?? AllMonthsOfYear()` hands back 1..12, which still contains day 6, so
|
||||
// every assertion here passes while the guard substitutes the wrong set. Day 20 is outside 1..12
|
||||
// and kills it.
|
||||
private static readonly DateTimeOffset AnyDate = new(2024, 3, 20, 0, 0, 0, Offset);
|
||||
|
||||
private static PlayoutTemplate Unrestricted() =>
|
||||
new()
|
||||
{
|
||||
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
|
||||
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
|
||||
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear()
|
||||
};
|
||||
|
||||
private static Option<PlayoutTemplate> Select(params PlayoutTemplate[] templates) =>
|
||||
AlternateScheduleSelector.GetScheduleForDate(templates.ToList(), AnyDate);
|
||||
|
||||
[Test]
|
||||
public void Null_DaysOfWeek_Reads_As_Unrestricted()
|
||||
{
|
||||
PlayoutTemplate template = Unrestricted();
|
||||
template.DaysOfWeek = null!;
|
||||
|
||||
Select(template).IsSome.ShouldBeTrue(
|
||||
"a NULL DaysOfWeek means no weekday restriction was recorded, so the template still applies");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Null_DaysOfMonth_Reads_As_Unrestricted()
|
||||
{
|
||||
PlayoutTemplate template = Unrestricted();
|
||||
template.DaysOfMonth = null!;
|
||||
|
||||
Select(template).IsSome.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Null_MonthsOfYear_Reads_As_Unrestricted()
|
||||
{
|
||||
PlayoutTemplate template = Unrestricted();
|
||||
template.MonthsOfYear = null!;
|
||||
|
||||
Select(template).IsSome.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void All_Three_Null_On_ProgramScheduleAlternate_Reads_As_Unrestricted()
|
||||
{
|
||||
var alternate = new ProgramScheduleAlternate
|
||||
{
|
||||
DaysOfWeek = null!,
|
||||
DaysOfMonth = null!,
|
||||
MonthsOfYear = null!
|
||||
};
|
||||
|
||||
AlternateScheduleSelector.GetScheduleForDate(
|
||||
new List<ProgramScheduleAlternate> { alternate },
|
||||
AnyDate)
|
||||
.IsSome.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// THE DISCRIMINATING CONTROL. Every test above sets a NULL and expects the item to be selected,
|
||||
/// so all of them pass equally under "NULL means unrestricted" and under the much broader
|
||||
/// "any NULL makes this item match unconditionally" — a refactor that short-circuits the whole
|
||||
/// date check when any dimension is null keeps them green. Here the nulled dimension is paired
|
||||
/// with a RESTRICTIVE non-null one that the date fails, so only the narrow reading passes.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void A_Null_Dimension_Does_Not_Relax_The_Other_Dimensions()
|
||||
{
|
||||
PlayoutTemplate template = Unrestricted();
|
||||
template.DaysOfWeek = null!;
|
||||
|
||||
// AnyDate is in MARCH; restrict to January only.
|
||||
template.MonthsOfYear = [1];
|
||||
|
||||
Select(template).IsNone.ShouldBeTrue(
|
||||
"a NULL DaysOfWeek relaxes ONLY the weekday dimension — the January restriction still "
|
||||
+ "excludes a March date");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One arrangement is not enough: with only the <c>DaysOfWeek</c> case above, a PER-DIMENSION
|
||||
/// mutant survives the whole fixture — e.g. <c>if (item.MonthsOfYear is null) return item;</c>
|
||||
/// placed ahead of the checks is never reached by that test, because its <c>MonthsOfYear</c> is
|
||||
/// non-null. So each of the three dimensions is nulled in turn against a restriction on a
|
||||
/// DIFFERENT dimension.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void A_Null_MonthsOfYear_Does_Not_Relax_The_Other_Dimensions()
|
||||
{
|
||||
PlayoutTemplate template = Unrestricted();
|
||||
template.MonthsOfYear = null!;
|
||||
|
||||
// AnyDate is a WEDNESDAY; restrict to Monday only.
|
||||
template.DaysOfWeek = [DayOfWeek.Monday];
|
||||
|
||||
Select(template).IsNone.ShouldBeTrue(
|
||||
"a NULL MonthsOfYear relaxes ONLY the month dimension — the Monday restriction still "
|
||||
+ "excludes a Wednesday");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The third of the per-dimension controls — see
|
||||
/// <see cref="A_Null_MonthsOfYear_Does_Not_Relax_The_Other_Dimensions" /> for why one
|
||||
/// arrangement is not enough. Here the nulled dimension is <c>DaysOfMonth</c> and the
|
||||
/// restriction that must still bite is on <c>MonthsOfYear</c>.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void A_Null_DaysOfMonth_Does_Not_Relax_The_Other_Dimensions()
|
||||
{
|
||||
PlayoutTemplate template = Unrestricted();
|
||||
template.DaysOfMonth = null!;
|
||||
|
||||
// AnyDate is in MARCH; restrict to January only.
|
||||
template.MonthsOfYear = [1];
|
||||
|
||||
Select(template).IsNone.ShouldBeTrue(
|
||||
"a NULL DaysOfMonth relaxes ONLY the day-of-month dimension — the January restriction "
|
||||
+ "still excludes a March date");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A null must not be confused with an explicitly EMPTY collection. Empty is a legal, reachable
|
||||
/// state meaning "matches no day", and it keeps that meaning — which is exactly why a NULL
|
||||
/// cannot be normalized to it.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void An_Explicitly_Empty_Collection_Still_Matches_Nothing()
|
||||
{
|
||||
PlayoutTemplate template = Unrestricted();
|
||||
template.DaysOfWeek = [];
|
||||
|
||||
Select(template).IsNone.ShouldBeTrue(
|
||||
"an empty DaysOfWeek is a recorded restriction of NO days, unlike a NULL");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The guard resolves PER ITEM: a null on the first item must not decide the second. Making the
|
||||
/// nulled item genuinely non-matching is what measures that — with an unrestricted nulled item
|
||||
/// at index 0 it simply wins on ordering and the second item is never evaluated, so the
|
||||
/// invariant would go unmeasured while the test passed.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void A_Null_On_One_Item_Does_Not_Decide_A_Later_Item()
|
||||
{
|
||||
PlayoutTemplate nulled = Unrestricted();
|
||||
nulled.DaysOfWeek = null!;
|
||||
nulled.MonthsOfYear = [1]; // AnyDate is in March, so this item must NOT match
|
||||
nulled.Index = 0;
|
||||
|
||||
PlayoutTemplate second = Unrestricted();
|
||||
second.Index = 1;
|
||||
|
||||
foreach (PlayoutTemplate selected in Select(nulled, second))
|
||||
{
|
||||
selected.ShouldBeSameAs(second);
|
||||
return;
|
||||
}
|
||||
|
||||
Assert.Fail("the loop stopped at the null-collection item instead of continuing to the next");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ...and when the nulled item IS unrestricted it legitimately wins on ordering. Paired with the
|
||||
/// test above so "index 0 wins" and "the loop continues past a non-matching null item" are
|
||||
/// separately pinned.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void An_Unrestricted_Null_Item_Wins_On_Index_Order()
|
||||
{
|
||||
PlayoutTemplate nulled = Unrestricted();
|
||||
nulled.DaysOfWeek = null!;
|
||||
nulled.Index = 0;
|
||||
|
||||
PlayoutTemplate second = Unrestricted();
|
||||
second.Index = 1;
|
||||
|
||||
foreach (PlayoutTemplate selected in Select(nulled, second))
|
||||
{
|
||||
selected.ShouldBeSameAs(nulled);
|
||||
return;
|
||||
}
|
||||
|
||||
Assert.Fail("the null-collection item was skipped instead of read as unrestricted");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The read-site guard must not be written BACK onto the item. These are single-column
|
||||
/// primitive collections, so assigning the guard would flip a tracked entity to
|
||||
/// <c>Modified</c> and the next <c>SaveChanges</c> would persist the substituted collection
|
||||
/// over the NULL — the mechanism recorded as <c>media.nullable-primitive-collection-mutation</c>.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Guard_Must_Not_Be_Written_Back_Onto_The_Item()
|
||||
{
|
||||
PlayoutTemplate template = Unrestricted();
|
||||
template.DaysOfWeek = null!;
|
||||
template.DaysOfMonth = null!;
|
||||
template.MonthsOfYear = null!;
|
||||
|
||||
Select(template);
|
||||
|
||||
template.DaysOfWeek.ShouldBeNull();
|
||||
template.DaysOfMonth.ShouldBeNull();
|
||||
template.MonthsOfYear.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,10 +14,8 @@ namespace ErsatzTV.Core.Tests.Scheduling;
|
||||
// different algorithm keyed on the same PlaybackOrder), and
|
||||
// - every order the two engines don't support returns None, so each caller logs its own #70 warning
|
||||
// instead of silently scheduling nothing.
|
||||
// The Scripted engine has no golden: its external-process/HTTP transport is permanently outside the
|
||||
// automated suite, and the engine behind it is covered in-process instead (decision
|
||||
// testing.scripted-engine-in-process-net, docs/testing.md -> "Scripted playout coverage"). This direct
|
||||
// helper test is the regression net for the shared construction that engine drives.
|
||||
// The Scripted engine has no golden (its external-process/HTTP transport is integration-only, #563), so
|
||||
// this direct helper test is the in-process regression net for the shared construction it drives.
|
||||
[TestFixture]
|
||||
public class ContentEnumeratorBuilderTests
|
||||
{
|
||||
|
||||
@@ -1,50 +1,23 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Domain.Scheduling;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Core.Scheduling.Engine;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Scheduling.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// Characterization of the <see cref="SchedulingEngine" /> build API — the surface a scripted schedule
|
||||
/// drives, one method per <c>ScriptedScheduleController</c> action. Covers content registration
|
||||
/// (<c>AddCollection</c>), the scheduling instructions (<c>AddCount</c>, <c>AddAll</c>,
|
||||
/// <c>AddDuration</c>, <c>PadUntilExact</c>), EPG grouping, per-item history, the no-progress halt, and
|
||||
/// the anchor round-trip that a Continue build restores from.
|
||||
/// <para>
|
||||
/// Deliberately out of scope here: the <c>Cli.Wrap</c> launch of the user-authored script process
|
||||
/// (exit code, timeout, stdout capture) and the Kestrel/HTTP/auth transport it calls back over. Those
|
||||
/// are permanently outside the automated suite; the controller adapter that sits between them and this
|
||||
/// engine is pinned by <c>ErsatzTV.Tests/Controllers/ScriptedScheduleControllerTests</c>. Decision:
|
||||
/// <c>testing.scripted-engine-in-process-net</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Every fixture here is timezone-independent by construction: it uses only Chronological order plus
|
||||
/// <c>AddCount</c>/<c>AddAll</c>/<c>AddDuration</c>/<c>PadUntilExact</c>, all of which preserve the
|
||||
/// instant. <c>WaitUntil(TimeOnly)</c> and <c>PadUntil(string)</c> read the LOCAL day and time-of-day
|
||||
/// and are therefore excluded. See docs/testing.md → Timezone independence.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class SchedulingEngineTests
|
||||
{
|
||||
private const string ContentKey = "content";
|
||||
private const string CollectionName = "Test Collection";
|
||||
|
||||
// Pinned build window, offset zero: the engine writes PlayoutItem.Start/Finish as UtcDateTime, so every
|
||||
// assertion below is on an instant rather than a wall-clock reading.
|
||||
private static readonly DateTimeOffset Start = new(2026, 1, 15, 6, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Test]
|
||||
public void Continue_Across_Time_Change()
|
||||
{
|
||||
SchedulingEngine engine = NewEngine(Substitute.For<IMediaCollectionRepository>());
|
||||
var engine = new SchedulingEngine(
|
||||
Substitute.For<IMediaCollectionRepository>(),
|
||||
Substitute.For<IGraphicsElementRepository>(),
|
||||
Substitute.For<IChannelRepository>(),
|
||||
Substitute.For<ILogger<SchedulingEngine>>());
|
||||
|
||||
var anchor = new PlayoutAnchor
|
||||
{
|
||||
@@ -52,468 +25,11 @@ public class SchedulingEngineTests
|
||||
};
|
||||
|
||||
var start = new DateTimeOffset(new DateTime(2025, 11, 20), TimeSpan.FromHours(-6));
|
||||
DateTimeOffset finish = start.AddDays(1);
|
||||
var finish = start.AddDays(1);
|
||||
|
||||
engine.BuildBetween(start, finish);
|
||||
|
||||
// should not throw
|
||||
engine.RestoreOrReset(anchor);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AddCollection_Then_AddCount_Lays_Items_Back_To_Back()
|
||||
{
|
||||
SchedulingEngine engine = await ResetEngineWithCollection();
|
||||
|
||||
engine.AddCount(ContentKey, 4, Option<FillerKind>.None, null, false).ShouldBeTrue();
|
||||
|
||||
List<PlayoutItem> items = engine.GetState().AddedItems;
|
||||
items.Count.ShouldBe(4);
|
||||
|
||||
// chronological order is the collection's release-date order, which is item id order here
|
||||
items.Select(i => i.MediaItemId).ShouldBe([1, 2, 3, 4]);
|
||||
|
||||
items[0].Start.ShouldBe(Start.UtcDateTime);
|
||||
for (var i = 1; i < items.Count; i++)
|
||||
{
|
||||
items[i].Start.ShouldBe(items[i - 1].Finish);
|
||||
}
|
||||
|
||||
foreach (PlayoutItem item in items)
|
||||
{
|
||||
item.FillerKind.ShouldBe(FillerKind.None);
|
||||
item.InPoint.ShouldBe(TimeSpan.Zero);
|
||||
item.OutPoint.ShouldBe(item.Finish - item.Start);
|
||||
item.PlayoutId.ShouldBe(1);
|
||||
}
|
||||
|
||||
// outside an EPG group every item opens its own guide group
|
||||
items.Select(i => i.GuideGroup).ShouldBe([1, 2, 3, 4]);
|
||||
|
||||
// 30 + 45 + 60 + 20 minutes of content
|
||||
TimeSpan scheduled = TimeSpan.FromMinutes(155);
|
||||
items[^1].Finish.ShouldBe(Start.UtcDateTime + scheduled);
|
||||
engine.GetState().CurrentTime.ToUniversalTime().ShouldBe(Start + scheduled);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AddAll_Schedules_Every_Item_Once()
|
||||
{
|
||||
SchedulingEngine engine = await ResetEngineWithCollection();
|
||||
|
||||
engine.AddAll(ContentKey, Option<FillerKind>.None, null, false).ShouldBeTrue();
|
||||
|
||||
List<PlayoutItem> items = engine.GetState().AddedItems;
|
||||
items.Select(i => i.MediaItemId).ShouldBe([1, 2, 3, 4, 5, 6]);
|
||||
|
||||
// 30 + 45 + 60 + 20 + 90 + 15 minutes
|
||||
engine.GetState().CurrentTime.ToUniversalTime().ShouldBe(Start + TimeSpan.FromMinutes(260));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AddDuration_Stops_Before_Overrunning_The_Target()
|
||||
{
|
||||
SchedulingEngine engine = await ResetEngineWithCollection();
|
||||
|
||||
engine.AddDuration(
|
||||
ContentKey,
|
||||
"2:00:00",
|
||||
fallback: null,
|
||||
trim: false,
|
||||
discardAttempts: 0,
|
||||
stopBeforeEnd: true,
|
||||
offlineTail: false,
|
||||
Option<FillerKind>.None,
|
||||
customTitle: null,
|
||||
disableWatermarks: false)
|
||||
.ShouldBeTrue();
|
||||
|
||||
DateTimeOffset target = Start.AddHours(2);
|
||||
List<PlayoutItem> items = engine.GetState().AddedItems;
|
||||
|
||||
// 30 + 45 fits; the third item (60) does not, and nothing is trimmed
|
||||
items.Select(i => i.MediaItemId).ShouldBe([1, 2]);
|
||||
items[^1].Finish.ShouldBe(Start.UtcDateTime + TimeSpan.FromMinutes(75));
|
||||
items[^1].Finish.ShouldBeLessThan(target.UtcDateTime);
|
||||
engine.GetState().CurrentTime.ToUniversalTime().ShouldBe(Start + TimeSpan.FromMinutes(75));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AddDuration_Trims_The_Last_Item_When_Trim_Is_Set()
|
||||
{
|
||||
SchedulingEngine engine = await ResetEngineWithCollection();
|
||||
|
||||
engine.AddDuration(
|
||||
ContentKey,
|
||||
"2:00:00",
|
||||
fallback: null,
|
||||
trim: true,
|
||||
discardAttempts: 0,
|
||||
stopBeforeEnd: true,
|
||||
offlineTail: false,
|
||||
Option<FillerKind>.None,
|
||||
customTitle: null,
|
||||
disableWatermarks: false)
|
||||
.ShouldBeTrue();
|
||||
|
||||
DateTimeOffset target = Start.AddHours(2);
|
||||
List<PlayoutItem> items = engine.GetState().AddedItems;
|
||||
|
||||
items.Select(i => i.MediaItemId).ShouldBe([1, 2, 3]);
|
||||
items[^1].Finish.ShouldBe(target.UtcDateTime);
|
||||
items[^1].OutPoint.ShouldBe(items[^1].Finish - items[^1].Start);
|
||||
items[^1].OutPoint.ShouldBe(TimeSpan.FromMinutes(45));
|
||||
engine.GetState().CurrentTime.ToUniversalTime().ShouldBe(target);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task PadUntilExact_Fills_To_The_Target_Instant()
|
||||
{
|
||||
SchedulingEngine engine = await ResetEngineWithCollection();
|
||||
|
||||
DateTimeOffset target = Start.AddHours(2);
|
||||
|
||||
engine.PadUntilExact(
|
||||
ContentKey,
|
||||
target,
|
||||
fallback: null,
|
||||
trim: true,
|
||||
discardAttempts: 0,
|
||||
stopBeforeEnd: true,
|
||||
offlineTail: false,
|
||||
Option<FillerKind>.None,
|
||||
customTitle: null,
|
||||
disableWatermarks: false)
|
||||
.ShouldBeTrue();
|
||||
|
||||
List<PlayoutItem> items = engine.GetState().AddedItems;
|
||||
items.Count.ShouldBeGreaterThan(0);
|
||||
items[0].Start.ShouldBe(Start.UtcDateTime);
|
||||
items[^1].Finish.ShouldBe(target.UtcDateTime);
|
||||
|
||||
// the target is an instant, so a machine-local offset must not move it
|
||||
engine.GetState().CurrentTime.ToUniversalTime().ShouldBe(target);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AddDuration_Rejects_An_Unparseable_Duration()
|
||||
{
|
||||
SchedulingEngine engine = await ResetEngineWithCollection();
|
||||
|
||||
engine.AddDuration(
|
||||
ContentKey,
|
||||
"not-a-duration",
|
||||
fallback: null,
|
||||
trim: false,
|
||||
discardAttempts: 0,
|
||||
stopBeforeEnd: true,
|
||||
offlineTail: false,
|
||||
Option<FillerKind>.None,
|
||||
customTitle: null,
|
||||
disableWatermarks: false)
|
||||
.ShouldBeFalse();
|
||||
|
||||
engine.GetState().AddedItems.ShouldBeEmpty();
|
||||
engine.GetState().CurrentTime.ToUniversalTime().ShouldBe(Start);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AddDuration_Rejects_Offline_Tail_Without_Stop_Before_End()
|
||||
{
|
||||
SchedulingEngine engine = await ResetEngineWithCollection();
|
||||
|
||||
engine.AddDuration(
|
||||
ContentKey,
|
||||
"2:00:00",
|
||||
fallback: null,
|
||||
trim: false,
|
||||
discardAttempts: 0,
|
||||
stopBeforeEnd: false,
|
||||
offlineTail: true,
|
||||
Option<FillerKind>.None,
|
||||
customTitle: null,
|
||||
disableWatermarks: false)
|
||||
.ShouldBeFalse();
|
||||
|
||||
engine.GetState().AddedItems.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[TestCase("add_all")]
|
||||
[TestCase("add_count")]
|
||||
[TestCase("add_duration")]
|
||||
[TestCase("pad_to_next")]
|
||||
[TestCase("pad_until")]
|
||||
[TestCase("pad_until_exact")]
|
||||
public async Task Unknown_Content_Key_Returns_False_And_Schedules_Nothing(string instruction)
|
||||
{
|
||||
SchedulingEngine engine = await ResetEngineWithCollection();
|
||||
|
||||
const string Unknown = "no-such-key";
|
||||
|
||||
bool result = instruction switch
|
||||
{
|
||||
"add_all" => engine.AddAll(Unknown, Option<FillerKind>.None, null, false),
|
||||
"add_count" => engine.AddCount(Unknown, 1, Option<FillerKind>.None, null, false),
|
||||
"add_duration" => engine.AddDuration(
|
||||
Unknown,
|
||||
"1:00:00",
|
||||
null,
|
||||
false,
|
||||
0,
|
||||
true,
|
||||
false,
|
||||
Option<FillerKind>.None,
|
||||
null,
|
||||
false),
|
||||
"pad_to_next" => engine.PadToNext(
|
||||
Unknown,
|
||||
15,
|
||||
null,
|
||||
false,
|
||||
0,
|
||||
true,
|
||||
false,
|
||||
Option<FillerKind>.None,
|
||||
null,
|
||||
false),
|
||||
"pad_until" => engine.PadUntil(
|
||||
Unknown,
|
||||
"07:00",
|
||||
false,
|
||||
null,
|
||||
false,
|
||||
0,
|
||||
true,
|
||||
false,
|
||||
Option<FillerKind>.None,
|
||||
null,
|
||||
false),
|
||||
"pad_until_exact" => engine.PadUntilExact(
|
||||
Unknown,
|
||||
Start.AddHours(1),
|
||||
null,
|
||||
false,
|
||||
0,
|
||||
true,
|
||||
false,
|
||||
Option<FillerKind>.None,
|
||||
null,
|
||||
false),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(instruction))
|
||||
};
|
||||
|
||||
result.ShouldBeFalse();
|
||||
|
||||
// the false is only meaningful if nothing was scheduled behind it
|
||||
engine.GetState().AddedItems.ShouldBeEmpty();
|
||||
engine.GetState().CurrentTime.ToUniversalTime().ShouldBe(Start);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Empty_Collection_Is_Skipped()
|
||||
{
|
||||
var repository = Substitute.For<IMediaCollectionRepository>();
|
||||
repository.GetCollectionItemsByName(CollectionName, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<MediaItem>());
|
||||
|
||||
SchedulingEngine engine = ResetEngine(repository);
|
||||
await engine.AddCollection(ContentKey, CollectionName, PlaybackOrder.Chronological, CancellationToken.None);
|
||||
|
||||
engine.AddCount(ContentKey, 1, Option<FillerKind>.None, null, false).ShouldBeFalse();
|
||||
engine.GetState().AddedItems.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Filler_Kind_And_Custom_Title_Reach_The_Item()
|
||||
{
|
||||
SchedulingEngine engine = await ResetEngineWithCollection();
|
||||
|
||||
engine.AddCount(ContentKey, 1, FillerKind.PreRoll, "Bumper", true).ShouldBeTrue();
|
||||
|
||||
PlayoutItem item = engine.GetState().AddedItems.Single();
|
||||
item.FillerKind.ShouldBe(FillerKind.PreRoll);
|
||||
item.CustomTitle.ShouldBe("Bumper");
|
||||
item.DisableWatermarks.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Guide_Group_Is_Locked_Across_An_Epg_Group()
|
||||
{
|
||||
SchedulingEngine engine = await ResetEngineWithCollection();
|
||||
|
||||
engine.LockGuideGroup(advance: true, customTitle: "Block");
|
||||
engine.AddCount(ContentKey, 3, Option<FillerKind>.None, null, false).ShouldBeTrue();
|
||||
engine.UnlockGuideGroup();
|
||||
engine.AddCount(ContentKey, 1, Option<FillerKind>.None, null, false).ShouldBeTrue();
|
||||
|
||||
List<PlayoutItem> items = engine.GetState().AddedItems;
|
||||
items.Count.ShouldBe(4);
|
||||
|
||||
List<PlayoutItem> grouped = items.Take(3).ToList();
|
||||
grouped.Select(i => i.GuideGroup).Distinct().Count().ShouldBe(1);
|
||||
grouped.ShouldAllBe(i => i.CustomTitle == "Block");
|
||||
|
||||
// unlocking resumes per-item advancement from the group's number
|
||||
items[3].GuideGroup.ShouldBe(grouped[0].GuideGroup + 1);
|
||||
items[3].CustomTitle.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task History_Is_Recorded_Per_Item()
|
||||
{
|
||||
SchedulingEngine engine = await ResetEngineWithCollection();
|
||||
|
||||
engine.AddCount(ContentKey, 3, Option<FillerKind>.None, null, false).ShouldBeTrue();
|
||||
|
||||
List<PlayoutItem> items = engine.GetState().AddedItems;
|
||||
List<PlayoutHistory> history = engine.GetState().AddedHistory;
|
||||
|
||||
history.Count.ShouldBe(items.Count);
|
||||
|
||||
string expectedKey = HistoryDetails.KeyForSchedulingContent(ContentKey, PlaybackOrder.Chronological);
|
||||
for (var i = 0; i < history.Count; i++)
|
||||
{
|
||||
history[i].Key.ShouldBe(expectedKey);
|
||||
history[i].PlayoutId.ShouldBe(1);
|
||||
history[i].PlaybackOrder.ShouldBe(PlaybackOrder.Chronological);
|
||||
history[i].Index.ShouldBe(i);
|
||||
history[i].When.ShouldBe(items[i].Start);
|
||||
history[i].Finish.ShouldBe(items[i].Finish);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Is_Done_Throws_After_Twenty_Consecutive_Calls_Without_Progress()
|
||||
{
|
||||
SchedulingEngine engine = ResetEngine(Substitute.For<IMediaCollectionRepository>());
|
||||
ISchedulingEngineState state = engine.GetState();
|
||||
|
||||
// the first read establishes the baseline; each of the next 19 increments the no-progress counter
|
||||
for (var i = 0; i < 20; i++)
|
||||
{
|
||||
state.IsDone.ShouldBeFalse();
|
||||
}
|
||||
|
||||
Should.Throw<InvalidOperationException>(() => _ = state.IsDone);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Is_Done_Counter_Resets_When_Time_Advances()
|
||||
{
|
||||
SchedulingEngine engine = await ResetEngineWithCollection();
|
||||
ISchedulingEngineState state = engine.GetState();
|
||||
|
||||
for (var i = 0; i < 20; i++)
|
||||
{
|
||||
state.IsDone.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// one instruction that advances CurrentTime clears the counter, so the budget starts over
|
||||
engine.AddCount(ContentKey, 1, Option<FillerKind>.None, null, false).ShouldBeTrue();
|
||||
|
||||
for (var i = 0; i < 20; i++)
|
||||
{
|
||||
state.IsDone.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Anchor_Round_Trips_Through_Restore()
|
||||
{
|
||||
SchedulingEngine first = await ResetEngineWithCollection();
|
||||
first.AddCount(ContentKey, 2, Option<FillerKind>.None, null, false).ShouldBeTrue();
|
||||
|
||||
List<PlayoutItem> firstItems = first.GetState().AddedItems;
|
||||
int lastGuideGroup = firstItems[^1].GuideGroup;
|
||||
|
||||
PlayoutAnchor anchor = first.GetAnchor();
|
||||
anchor.NextStart.ShouldBe(firstItems[^1].Finish);
|
||||
|
||||
SchedulingEngine second = NewEngine(CollectionRepository());
|
||||
second.WithPlayoutId(1)
|
||||
.WithMode(PlayoutBuildMode.Continue)
|
||||
.WithSeed(0)
|
||||
.BuildBetween(Start, Start.AddDays(1))
|
||||
.WithReferenceData(EmptyReferenceData())
|
||||
.RestoreOrReset(anchor);
|
||||
|
||||
// the anchor carries an instant, not a wall-clock reading
|
||||
second.GetState().CurrentTime.ToUniversalTime().ShouldBe(new DateTimeOffset(anchor.NextStart, TimeSpan.Zero));
|
||||
|
||||
await second.AddCollection(ContentKey, CollectionName, PlaybackOrder.Chronological, CancellationToken.None);
|
||||
second.AddCount(ContentKey, 1, Option<FillerKind>.None, null, false).ShouldBeTrue();
|
||||
|
||||
PlayoutItem resumed = second.GetState().AddedItems.Single();
|
||||
resumed.Start.ShouldBe(anchor.NextStart);
|
||||
|
||||
// the guide group continues from the serialized context instead of restarting at 1
|
||||
resumed.GuideGroup.ShouldBe(lastGuideGroup + 1);
|
||||
}
|
||||
|
||||
private static SchedulingEngine NewEngine(IMediaCollectionRepository repository) =>
|
||||
new(
|
||||
repository,
|
||||
Substitute.For<IGraphicsElementRepository>(),
|
||||
Substitute.For<IChannelRepository>(),
|
||||
Substitute.For<ILogger<SchedulingEngine>>());
|
||||
|
||||
// WithReferenceData must precede RestoreOrReset and AddCollection: both dereference
|
||||
// PlayoutReferenceData.PlayoutHistory, so a different order fails with a null reference that reads
|
||||
// like an engine bug. This is the same order ScriptedPlayoutBuilder uses.
|
||||
private static SchedulingEngine ResetEngine(IMediaCollectionRepository repository)
|
||||
{
|
||||
SchedulingEngine engine = NewEngine(repository);
|
||||
engine.WithPlayoutId(1)
|
||||
.WithMode(PlayoutBuildMode.Reset)
|
||||
.WithSeed(0)
|
||||
.BuildBetween(Start, Start.AddDays(1))
|
||||
.WithReferenceData(EmptyReferenceData())
|
||||
.RestoreOrReset(Option<PlayoutAnchor>.None);
|
||||
return engine;
|
||||
}
|
||||
|
||||
private static async Task<SchedulingEngine> ResetEngineWithCollection()
|
||||
{
|
||||
SchedulingEngine engine = ResetEngine(CollectionRepository());
|
||||
await engine.AddCollection(ContentKey, CollectionName, PlaybackOrder.Chronological, CancellationToken.None);
|
||||
return engine;
|
||||
}
|
||||
|
||||
private static IMediaCollectionRepository CollectionRepository()
|
||||
{
|
||||
var repository = Substitute.For<IMediaCollectionRepository>();
|
||||
repository.GetCollectionItemsByName(CollectionName, Arg.Any<CancellationToken>())
|
||||
.Returns(_ => TestCollection());
|
||||
return repository;
|
||||
}
|
||||
|
||||
// Distinct release dates make chronological order deterministic (id order); distinct durations make
|
||||
// every boundary in an assertion unambiguous.
|
||||
private static List<MediaItem> TestCollection() =>
|
||||
[
|
||||
FakeMovie(1, 30),
|
||||
FakeMovie(2, 45),
|
||||
FakeMovie(3, 60),
|
||||
FakeMovie(4, 20),
|
||||
FakeMovie(5, 90),
|
||||
FakeMovie(6, 15)
|
||||
];
|
||||
|
||||
private static Movie FakeMovie(int id, int minutes) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
MediaVersions = [new MediaVersion { Duration = TimeSpan.FromMinutes(minutes) }],
|
||||
MovieMetadata =
|
||||
[
|
||||
new MovieMetadata
|
||||
{
|
||||
Title = $"Movie {id:D2}",
|
||||
ReleaseDate = new DateTime(2005, 1, 1).AddDays(id)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
private static PlayoutReferenceData EmptyReferenceData() =>
|
||||
new(null, Option<Deco>.None, [], [], null, [], [], TimeSpan.Zero);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,4 @@ public record FFmpegFullProfileResponseModel(
|
||||
bool NormalizeFramerate,
|
||||
bool NormalizeColors,
|
||||
bool DeinterlaceVideo,
|
||||
bool QsvPreferNativeDecoder,
|
||||
double? ReadRate,
|
||||
double? ReadRateCatchup);
|
||||
bool QsvPreferNativeDecoder);
|
||||
|
||||
@@ -26,9 +26,6 @@ public class ConfigElementKey
|
||||
public static ConfigElementKey ChannelTemplatesDefaultTemplateId => new("channel_templates.default_template_id");
|
||||
public static ConfigElementKey WatermarkChannelBugSeeded => new("watermark.channel_bug_seeded");
|
||||
public static ConfigElementKey GraphicsOnNowNextSeeded => new("graphics.on_now_next_seeded");
|
||||
|
||||
public static ConfigElementKey GraphicsOnNowNextDefaultAttached =>
|
||||
new("graphics.on_now_next_default_attached");
|
||||
public static ConfigElementKey FFmpegSegmenterTimeout => new("ffmpeg.segmenter.timeout_seconds");
|
||||
public static ConfigElementKey FFmpegWorkAheadSegmenters => new("ffmpeg.segmenter.work_ahead_limit");
|
||||
public static ConfigElementKey FFmpegInitialSegmentCount => new("ffmpeg.segmenter.initial_segment_count");
|
||||
|
||||
@@ -14,8 +14,6 @@ public record FFmpegProfile
|
||||
public VaapiDriver VaapiDriver { get; set; }
|
||||
public string VaapiDevice { get; set; }
|
||||
public int? QsvExtraHardwareFrames { get; set; }
|
||||
public double? ReadRate { get; set; }
|
||||
public double? ReadRateCatchup { get; set; }
|
||||
public bool? QsvPreferNativeDecoder { get; set; }
|
||||
public int ResolutionId { get; set; }
|
||||
public Resolution Resolution { get; set; }
|
||||
|
||||
@@ -610,9 +610,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
false,
|
||||
GetTonemapAlgorithm(playbackSettings),
|
||||
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel,
|
||||
channel.FFmpegProfile.QsvPreferNativeDecoder != false,
|
||||
Optional(channel.FFmpegProfile.ReadRate),
|
||||
Optional(channel.FFmpegProfile.ReadRateCatchup));
|
||||
channel.FFmpegProfile.QsvPreferNativeDecoder != false);
|
||||
|
||||
_logger.LogDebug("FFmpeg desired state {FrameState}", desiredState);
|
||||
|
||||
@@ -829,9 +827,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
false,
|
||||
false,
|
||||
GetTonemapAlgorithm(playbackSettings),
|
||||
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel,
|
||||
MaybeReadRate: Optional(channel.FFmpegProfile.ReadRate),
|
||||
MaybeReadRateCatchup: Optional(channel.FFmpegProfile.ReadRateCatchup));
|
||||
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel);
|
||||
|
||||
var ffmpegSubtitleStream = new ErsatzTV.FFmpeg.MediaStream(0, "ass", StreamKind.Video);
|
||||
|
||||
@@ -972,9 +968,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
false,
|
||||
false,
|
||||
GetTonemapAlgorithm(playbackSettings),
|
||||
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel,
|
||||
MaybeReadRate: Optional(channel.FFmpegProfile.ReadRate),
|
||||
MaybeReadRateCatchup: Optional(channel.FFmpegProfile.ReadRateCatchup));
|
||||
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel);
|
||||
|
||||
var audioInputFile = new NullAudioInputFile(audioState);
|
||||
|
||||
|
||||
@@ -1,52 +1,7 @@
|
||||
using System.IO;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Graphics;
|
||||
|
||||
public static class GraphicsElementDefaults
|
||||
{
|
||||
// Built-in "On Now / Next" text element filename -- a component of OnNowNextSeededPath below,
|
||||
// never itself an identity check (#568: a filename-only comparison is folder-agnostic).
|
||||
// Built-in "On Now / Next" text element; identity is by filename, never by user-editable Name.
|
||||
public const string OnNowNextFileName = "on-now-next.yml";
|
||||
|
||||
// Display name only. Never use it for identity -- that is IsOnNowNext below (#67 / #74 / #568).
|
||||
public const string OnNowNextName = "On Now / Next";
|
||||
|
||||
// The full path the seeder writes the built-in template to (GraphicsElementSeeder.SeedOnNowNext
|
||||
// builds `target` the same way). A `builtIn` discriminator must match THIS, not
|
||||
// `Path.GetFileName(...) == OnNowNextFileName` -- a filename-only comparison is folder-agnostic:
|
||||
// a user element named exactly `on-now-next.yml` in any of the other four template folders
|
||||
// (image/motion/subtitle/script) would also report `builtIn:true` (#568). `Kind == Text` alone
|
||||
// does not close this either, since a second text template could share the filename in principle.
|
||||
public static string OnNowNextSeededPath =>
|
||||
Path.Combine(FileSystemLayout.GraphicsElementsTextTemplatesFolder, OnNowNextFileName);
|
||||
|
||||
/// <summary>
|
||||
/// The one identity test for the built-in On Now / Next element: the exact file the seeder
|
||||
/// wrote, at the exact path it wrote it to, of the kind it wrote it as. Ordinal on purpose,
|
||||
/// and so case-sensitive on purpose.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <c>Kind</c> is part of the identity rather than a second test any caller may add or
|
||||
/// skip: the seeder's own "does the built-in row exist yet?" check resolves through this
|
||||
/// predicate, so a row of another kind at the seeded path answering yes would suppress
|
||||
/// the Text row every consumer resolves. A caller applying only the path half would
|
||||
/// report that wrong-kind row as the built-in element while the seeder refused to treat
|
||||
/// it as one -- the two sites disagreeing about the same row, which is the defect this
|
||||
/// predicate exists to make impossible (#568).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Callers compare in memory rather than in a <c>Where</c> clause, because in SQL the
|
||||
/// answer would be the PROVIDER's to give: <c>GraphicsElement.Path</c> takes no explicit
|
||||
/// collation (<c>TvContext.OnModelCreating</c> pins one only on the listed name/title
|
||||
/// columns), so SQLite compares it case-sensitively while MySQL uses the server default,
|
||||
/// which is normally case-INsensitive. Evaluating one discriminator site in SQL and the
|
||||
/// other in memory would let the two disagree on MySQL alone. The SQLite test suite
|
||||
/// cannot tell the two apart -- BINARY collation and an ordinal comparison agree on
|
||||
/// every input -- so this is held by keeping the comparison out of SQL, not by a test.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static bool IsOnNowNext(string path, GraphicsElementKind kind) =>
|
||||
kind == GraphicsElementKind.Text && string.Equals(path, OnNowNextSeededPath, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
@@ -31,28 +31,6 @@ public class TextGraphicsElement : BaseGraphicsElement
|
||||
[YamlMember(Alias = "z_index", ApplyNamingConventions = false)]
|
||||
public int? ZIndex { get; set; }
|
||||
|
||||
// Background box (ersatztv#732). Element-level, not per-style: the graphics engine renders one
|
||||
// TextBlock into one bitmap, so a single box behind the whole element is the only shape the
|
||||
// renderer can express. Unset background_color means no FILL; a border_color alone still draws
|
||||
// an outlined box. With neither there is no box and no insets -- the pre-#732 geometry.
|
||||
[YamlMember(Alias = "background_color", ApplyNamingConventions = false)]
|
||||
public string BackgroundColor { get; set; }
|
||||
|
||||
[YamlMember(Alias = "background_opacity_percent", ApplyNamingConventions = false)]
|
||||
public int? BackgroundOpacityPercent { get; set; }
|
||||
|
||||
[YamlMember(Alias = "background_padding", ApplyNamingConventions = false)]
|
||||
public double? BackgroundPadding { get; set; }
|
||||
|
||||
[YamlMember(Alias = "background_corner_radius", ApplyNamingConventions = false)]
|
||||
public double? BackgroundCornerRadius { get; set; }
|
||||
|
||||
[YamlMember(Alias = "border_color", ApplyNamingConventions = false)]
|
||||
public string BorderColor { get; set; }
|
||||
|
||||
[YamlMember(Alias = "border_width", ApplyNamingConventions = false)]
|
||||
public double? BorderWidth { get; set; }
|
||||
|
||||
public List<StyleDefinition> Styles { get; set; } = [];
|
||||
|
||||
[YamlMember(Alias = "base_style", ApplyNamingConventions = false)]
|
||||
|
||||
@@ -85,46 +85,19 @@ public static class AlternateScheduleSelector
|
||||
}
|
||||
}
|
||||
|
||||
// These three are NULLABLE single-column primitive collections, and a runtime null IS
|
||||
// reachable (ersatztv#823, measured against a real TvContext on SQLite and MySQL 8.4): EF does
|
||||
// NOT invoke the value converter for a NULL column, so it materializes as CLR null rather than
|
||||
// through IntCollectionValueConverter's null-to-empty branch, which never runs on this path.
|
||||
// Unguarded, each .Contains below throws NullReferenceException.
|
||||
//
|
||||
// A NULL reads as UNRESTRICTED -- the All*() sets -- NOT as empty. This is the whole semantic
|
||||
// question and it is decided by the one NULL that is reachable WITHOUT any code writing one:
|
||||
// Sqlite's 20240113140741_Add_PlayoutTemplate_DaysOfMonth adds DaysOfMonth with
|
||||
// `nullable: true` and NO defaultValue, so a PlayoutTemplate row inserted before it holds NULL
|
||||
// and, by construction, had NO day-of-month restriction. Reading that as empty would INVERT
|
||||
// the row's meaning and silently stop the template applying at all. All*() preserves it, and
|
||||
// it is how "no restriction recorded" is already represented elsewhere in this domain
|
||||
// (GetPlayoutAlternateSchedulesHandler, PreviewBlockPlayoutHandler). Note what does NOT decide
|
||||
// it: the API request records normalize an omitted field with `?? []`, but that is a client
|
||||
// omitting a field on a WRITE and says nothing about what a legacy database NULL meant.
|
||||
//
|
||||
// Guarded at the READ SITE, into locals, and NEVER assigned back onto `item`: the property IS
|
||||
// the column value, so writing the guard back would flip a tracked entry to Modified and
|
||||
// persist the substituted collection over the NULL
|
||||
// (`media.nullable-primitive-collection-mutation`). The matching substitution happens at the
|
||||
// entity->DTO boundary in the two Mapper.ProjectToViewModel overloads, so the SPA's
|
||||
// appliesToDate -- an exact port of this method -- previews what this actually schedules.
|
||||
ICollection<DayOfWeek> itemDaysOfWeek = item.DaysOfWeek ?? AllDaysOfWeek();
|
||||
ICollection<int> itemDaysOfMonth = item.DaysOfMonth ?? AllDaysOfMonth();
|
||||
ICollection<int> itemMonthsOfYear = item.MonthsOfYear ?? AllMonthsOfYear();
|
||||
|
||||
bool daysOfWeek = itemDaysOfWeek.Contains(date.DayOfWeek);
|
||||
bool daysOfWeek = item.DaysOfWeek.Contains(date.DayOfWeek);
|
||||
if (!daysOfWeek)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bool daysOfMonth = itemDaysOfMonth.Contains(date.Day);
|
||||
bool daysOfMonth = item.DaysOfMonth.Contains(date.Day);
|
||||
if (!daysOfMonth)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bool monthOfYear = itemMonthsOfYear.Contains(date.Month);
|
||||
bool monthOfYear = item.MonthsOfYear.Contains(date.Month);
|
||||
if (monthOfYear)
|
||||
{
|
||||
return item;
|
||||
|
||||
@@ -593,143 +593,7 @@ public class PipelineBuilderBaseTests
|
||||
command.ShouldNotContain("-readrate_initial_burst");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Realtime_Input_Should_Catch_Up_When_Option_Is_Supported()
|
||||
{
|
||||
string command = BuildRealtimeCommand(new CatchupCapableFFmpegCapabilities());
|
||||
|
||||
// -readrate paces an input off its furthest-behind stream, so a sparse stream sharing the
|
||||
// input pins throughput below realtime; catchup lets it recover (ersatztv#726). anchor on
|
||||
// the input path so this can't be satisfied by some other input carrying the option
|
||||
// this overlaps Bitmap_Subtitle_Burn_In_... by design: that one pins the #726 MECHANISM on a
|
||||
// bitmap pipeline, this one pins the plain no-subtitle shape plus the uniqueness guard below
|
||||
command.ShouldContain("-readrate 1.05 -readrate_initial_burst 8 -readrate_catchup 6.0 -i /tmp/whatever.mkv");
|
||||
Regex.Matches(command, Regex.Escape("-readrate_catchup 6.0")).Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Realtime_Input_Should_Not_Catch_Up_A_Still_Image()
|
||||
{
|
||||
// mirrors the burst's still-image exclusion (ersatztv#350): the video input takes no readrate
|
||||
// at all, so catchup would only reach the separate audio input and run it ahead of a graph
|
||||
// that the realtime filter is already pacing. pinned so the divergence can't reappear silently
|
||||
string command = BuildRealtimeCommand(new CatchupCapableFFmpegCapabilities(), stillImage: true);
|
||||
|
||||
// the positive anchor keeps this from passing vacuously if the helper ever stops
|
||||
// producing a realtime audio input at all
|
||||
command.ShouldContain("-readrate 1.05");
|
||||
command.ShouldNotContain("-readrate_catchup");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Realtime_Input_Should_Not_Catch_Up_When_Option_Is_Unsupported()
|
||||
{
|
||||
// an older binary silently keeps today's behavior rather than failing to start
|
||||
string command = BuildRealtimeCommand(new BurstCapableFFmpegCapabilities());
|
||||
|
||||
// the positive anchor keeps this from passing vacuously if the helper ever stops
|
||||
// producing a realtime input at all
|
||||
command.ShouldContain("-readrate 1.05");
|
||||
command.ShouldNotContain("-readrate_catchup");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Concat_Should_Never_Catch_Up()
|
||||
{
|
||||
// concat reads already-written segments from the running segmenter at a flat 1.0; it has no
|
||||
// sparse stream to lag on, and letting it catch up would gallop through the segments
|
||||
var concatInputFile = new ConcatInputFile("http://localhost:8080/ffmpeg/concat/1", new FrameSize(1920, 1080));
|
||||
|
||||
var builder = new SoftwarePipelineBuilder(
|
||||
new CatchupCapableFFmpegCapabilities(),
|
||||
HardwareAccelerationMode.None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
concatInputFile,
|
||||
Option<GraphicsEngineInput>.None,
|
||||
"",
|
||||
"",
|
||||
_logger);
|
||||
|
||||
FFmpegPipeline result = builder.Concat(concatInputFile, FFmpegState.Concat(false, "Some Channel"));
|
||||
|
||||
string command = PrintCommand(None, None, None, concatInputFile, None, result);
|
||||
|
||||
command.ShouldContain("-readrate 1.0");
|
||||
command.ShouldNotContain("-readrate_catchup");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Bitmap_Subtitle_Burn_In_Should_Catch_Up_On_The_Shared_Video_Input()
|
||||
{
|
||||
// THE #726 regression test. an embedded bitmap subtitle is read through the SAME -i as the
|
||||
// video (SubtitleInputFile carries the video's path and resolves to a stream specifier on
|
||||
// that input), and being sparse it drags that input's pacing down to ~0.53x realtime.
|
||||
// this must be built on a BITMAP subtitle: a text subtitle is fetched by the libass filter
|
||||
// outside the demuxer, so the same assertions would pass vacuously while the bug is present.
|
||||
string command = BuildRealtimeCommand(new CatchupCapableFFmpegCapabilities(), imageSubtitle: true);
|
||||
|
||||
// the mechanism itself: subtitle stream 2 resolves onto input 0 -- the VIDEO's input -- so it
|
||||
// is read through the throttled demuxer that catchup is being applied to. if the subtitle
|
||||
// ever moves to an input of its own this label changes and the test fails, which is the point
|
||||
command.ShouldContain("[0:0][0:2]overlay");
|
||||
|
||||
// ...so the catchup has to be on that input
|
||||
command.ShouldContain("-readrate 1.05 -readrate_initial_burst 8 -readrate_catchup 6.0 -i /tmp/whatever.mkv");
|
||||
}
|
||||
|
||||
// ersatztv#735: the pacing values became operator-tunable profile fields. these pin that a
|
||||
// configured value actually reaches the command line -- the defaults above are the OTHER half
|
||||
// of the same guard, and they are what an unset profile still gets
|
||||
[Test]
|
||||
public void Realtime_Input_Should_Use_A_Configured_ReadRate_And_Catchup()
|
||||
{
|
||||
string command = BuildRealtimeCommand(
|
||||
new CatchupCapableFFmpegCapabilities(),
|
||||
readRate: 1.5,
|
||||
readRateCatchup: 4.0);
|
||||
|
||||
command.ShouldContain("-readrate 1.5 -readrate_initial_burst 8 -readrate_catchup 4.0 -i /tmp/whatever.mkv");
|
||||
command.ShouldNotContain("-readrate 1.05");
|
||||
command.ShouldNotContain("-readrate_catchup 6.0");
|
||||
}
|
||||
|
||||
// the write path rejects an out-of-range value with a 422, so this only fires for a row written
|
||||
// out of band -- but FFmpeg must never see the unbounded value either way
|
||||
[Test]
|
||||
public void Realtime_Input_Should_Clamp_An_Out_Of_Range_ReadRate()
|
||||
{
|
||||
string command = BuildRealtimeCommand(
|
||||
new CatchupCapableFFmpegCapabilities(),
|
||||
readRate: 9.0,
|
||||
readRateCatchup: 0.1);
|
||||
|
||||
// 9.0 clamps to the 2.0 ceiling, and 0.1 is raised to the resolved base rate, because a
|
||||
// catchup below it could never let a lagging input recover
|
||||
command.ShouldContain("-readrate 2.0 -readrate_initial_burst 8 -readrate_catchup 2.0 -i /tmp/whatever.mkv");
|
||||
}
|
||||
|
||||
// ...and the catchup CEILING isolated from the base rate, which the case above cannot show:
|
||||
// there both clamps land on the same 2.0, so either one alone would satisfy it
|
||||
[Test]
|
||||
public void Realtime_Input_Should_Clamp_An_Out_Of_Range_ReadRateCatchup()
|
||||
{
|
||||
string command = BuildRealtimeCommand(
|
||||
new CatchupCapableFFmpegCapabilities(),
|
||||
readRate: 1.2,
|
||||
readRateCatchup: 15.0);
|
||||
|
||||
command.ShouldContain("-readrate 1.2 -readrate_initial_burst 8 -readrate_catchup 10.0 -i /tmp/whatever.mkv");
|
||||
}
|
||||
|
||||
private string BuildRealtimeCommand(
|
||||
IFFmpegCapabilities capabilities,
|
||||
bool stillImage = false,
|
||||
bool imageSubtitle = false,
|
||||
Option<double> readRate = default,
|
||||
Option<double> readRateCatchup = default)
|
||||
private string BuildRealtimeCommand(IFFmpegCapabilities capabilities, bool stillImage = false)
|
||||
{
|
||||
var videoInputFile = new VideoInputFile(
|
||||
"/tmp/whatever.mkv",
|
||||
@@ -794,9 +658,7 @@ public class PipelineBuilderBaseTests
|
||||
false,
|
||||
false,
|
||||
"clip",
|
||||
false,
|
||||
MaybeReadRate: readRate,
|
||||
MaybeReadRateCatchup: readRateCatchup);
|
||||
false);
|
||||
|
||||
// a *separate* audio input matters here: for a still image the video input takes no readrate
|
||||
// at all, so only a distinct audio input can prove the burst was suppressed (this is the
|
||||
@@ -814,22 +676,13 @@ public class PipelineBuilderBaseTests
|
||||
AudioFilter.None,
|
||||
Option<double>.None));
|
||||
|
||||
// an embedded bitmap subtitle carries the VIDEO's path, which is how it ends up sharing the
|
||||
// video's single throttled -i rather than getting one of its own (ersatztv#726)
|
||||
Option<SubtitleInputFile> subtitleInputFile = imageSubtitle
|
||||
? new SubtitleInputFile(
|
||||
"/tmp/whatever.mkv",
|
||||
new List<MediaStream> { new(2, "dvdsub", StreamKind.Subtitle) },
|
||||
SubtitleMethod.Burn)
|
||||
: Option<SubtitleInputFile>.None;
|
||||
|
||||
var builder = new SoftwarePipelineBuilder(
|
||||
capabilities,
|
||||
HardwareAccelerationMode.None,
|
||||
videoInputFile,
|
||||
audioInputFile,
|
||||
None,
|
||||
subtitleInputFile,
|
||||
None,
|
||||
None,
|
||||
Option<GraphicsEngineInput>.None,
|
||||
"",
|
||||
@@ -882,19 +735,4 @@ public class PipelineBuilderBaseTests
|
||||
new System.Collections.Generic.HashSet<string>(),
|
||||
new System.Collections.Generic.HashSet<string> { FFmpegKnownOption.ReadrateInitialBurst.Name },
|
||||
new System.Collections.Generic.HashSet<string>());
|
||||
|
||||
// a binary new enough for -readrate_catchup also has -readrate_initial_burst, so this models a
|
||||
// real ffmpeg rather than an impossible catchup-without-burst one
|
||||
public class CatchupCapableFFmpegCapabilities() : FFmpegCapabilities(
|
||||
string.Empty,
|
||||
new System.Collections.Generic.HashSet<string>(),
|
||||
new System.Collections.Generic.HashSet<string>(),
|
||||
new System.Collections.Generic.HashSet<string>(),
|
||||
new System.Collections.Generic.HashSet<string>(),
|
||||
new System.Collections.Generic.HashSet<string>
|
||||
{
|
||||
FFmpegKnownOption.ReadrateInitialBurst.Name,
|
||||
FFmpegKnownOption.ReadrateCatchup.Name
|
||||
},
|
||||
new System.Collections.Generic.HashSet<string>());
|
||||
}
|
||||
|
||||
@@ -13,15 +13,8 @@ public record FFmpegKnownOption
|
||||
// ffmpeg 6.1+; lets a readrate-throttled input read flat out for an initial window
|
||||
public static FFmpegKnownOption ReadrateInitialBurst => new("readrate_initial_burst");
|
||||
|
||||
// ffmpeg 8.0+ (added 2025-02-15 in 6232f416b, first released in 8.0); lets a readrate-throttled
|
||||
// input read faster than its readrate *while it is behind*, so a sparse stream sharing that
|
||||
// input cannot pin throughput below realtime (ersatztv#726). verified present in 8.1.2, the
|
||||
// pinned base image — note this is NEWER than 7.1, so it is detected at runtime, never assumed
|
||||
public static FFmpegKnownOption ReadrateCatchup => new("readrate_catchup");
|
||||
|
||||
public static IList<string> AllOptions =>
|
||||
[
|
||||
ReadrateInitialBurst.Name,
|
||||
ReadrateCatchup.Name
|
||||
ReadrateInitialBurst.Name
|
||||
];
|
||||
}
|
||||
|
||||
@@ -28,9 +28,7 @@ public record FFmpegState(
|
||||
bool IsHdrTonemap,
|
||||
string TonemapAlgorithm,
|
||||
bool IsTroubleshooting,
|
||||
bool QsvPreferNativeDecoder = false,
|
||||
Option<double> MaybeReadRate = default,
|
||||
Option<double> MaybeReadRateCatchup = default)
|
||||
bool QsvPreferNativeDecoder = false)
|
||||
{
|
||||
// the QSV upload pool needs headroom for the frames in flight through the filter graph.
|
||||
// extra_hw_frames=0 leaves none, so any input that is not throttled exhausts it: the graph
|
||||
@@ -44,49 +42,6 @@ public record FFmpegState(
|
||||
public int QsvExtraHardwareFrames =>
|
||||
Math.Max(MaybeQsvExtraHardwareFrames.IfNone(MinimumQsvExtraHardwareFrames), MinimumQsvExtraHardwareFrames);
|
||||
|
||||
// realtime pacing. an unset profile keeps the values these constants name, which are the ones
|
||||
// the pipeline hardcoded before they became configurable (ersatztv#735)
|
||||
public const double DefaultReadRate = 1.05;
|
||||
public const double DefaultStreamCopyReadRate = 1.0;
|
||||
|
||||
// how fast a LAGGING realtime input may read until it is level again. measured on the #726
|
||||
// repro (embedded dvd_subtitle -> overlay, QSV encode): 1.05 alone sustains 0.53x, catchup 2.0
|
||||
// reaches 0.711x, and 6.0 restores the full 1.067x that the same pipeline achieves with no
|
||||
// subtitle at all. 20.0 also measures 1.067x — i.e. the value is not a throughput dial above
|
||||
// the point where the input catches up, so 6.0 is chosen as the smallest measured-sufficient
|
||||
// ceiling rather than the largest that works (ersatztv#726)
|
||||
public const double DefaultReadRateCatchup = 6.0;
|
||||
|
||||
// below realtime the process reads slower than a live client consumes and the channel stalls;
|
||||
// ersatztv#726 is that failure, measured at an effective 0.53x. the ceiling is a CHOSEN bound,
|
||||
// not a measured cliff: it exists so the field cannot be used to effectively disable pacing,
|
||||
// which is the configuration ersatztv#529 measured to produce zero segments on a QSV pipeline
|
||||
public const double MinimumReadRate = 1.0;
|
||||
public const double MaximumReadRate = 2.0;
|
||||
|
||||
// catchup is a ceiling that applies only WHILE an input is behind, so it is bounded more
|
||||
// loosely than the base rate; the same chosen-not-measured caveat applies to the ceiling.
|
||||
// the FLOOR is only a write-path bound: at render time the resolved base rate is always at
|
||||
// least MinimumReadRate, so Math.Max below already dominates it
|
||||
public const double MinimumReadRateCatchup = 1.0;
|
||||
public const double MaximumReadRateCatchup = 10.0;
|
||||
|
||||
// clamped for the same reason QsvExtraHardwareFrames is: a row written out of band (or before
|
||||
// the write path validated the field) must not reach FFmpeg unbounded. the write path rejects
|
||||
// an out-of-range value with a 422 naming the bound, so this is belt-and-braces, not the
|
||||
// primary guard (ersatztv#735)
|
||||
public double ReadRateFor(bool isStreamCopy) =>
|
||||
MaybeReadRate.Match(
|
||||
configured => Math.Clamp(configured, MinimumReadRate, MaximumReadRate),
|
||||
() => isStreamCopy ? DefaultStreamCopyReadRate : DefaultReadRate);
|
||||
|
||||
// a catchup rate below the base rate cannot let a lagging input recover, so the resolved base
|
||||
// rate is its real floor — no separate lower clamp, which would be unreachable behind this Max
|
||||
public double ReadRateCatchupFor(bool isStreamCopy) =>
|
||||
Math.Max(
|
||||
Math.Min(MaybeReadRateCatchup.IfNone(DefaultReadRateCatchup), MaximumReadRateCatchup),
|
||||
ReadRateFor(isStreamCopy));
|
||||
|
||||
public static FFmpegState Concat(bool saveReport, string channelName) =>
|
||||
new(
|
||||
saveReport,
|
||||
|
||||
@@ -3,11 +3,10 @@ using ErsatzTV.FFmpeg.Environment;
|
||||
|
||||
namespace ErsatzTV.FFmpeg.InputOption;
|
||||
|
||||
public class ReadrateInputOption(double readRate, Option<int> initialBurstSeconds, Option<double> catchupReadRate)
|
||||
: IInputOption
|
||||
public class ReadrateInputOption(double readRate, Option<int> initialBurstSeconds) : IInputOption
|
||||
{
|
||||
public ReadrateInputOption(double readRate)
|
||||
: this(readRate, Option<int>.None, Option<double>.None)
|
||||
: this(readRate, Option<int>.None)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -31,17 +30,6 @@ public class ReadrateInputOption(double readRate, Option<int> initialBurstSecond
|
||||
result.Add(burst.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
// -readrate paces the WHOLE input off its furthest-behind stream, so one sparse stream
|
||||
// (an embedded PGS/DVD bitmap subtitle feeding the overlay) drags the video down with it
|
||||
// and output collapses to ~0.53x realtime. catchup lets a lagging input read faster until
|
||||
// it is level again; it is a ceiling that only applies WHILE behind, never a target, so
|
||||
// caught-up input still paces at readRate and cannot race ahead (ersatztv#726)
|
||||
foreach (double catchup in catchupReadRate)
|
||||
{
|
||||
result.Add("-readrate_catchup");
|
||||
result.Add(catchup.ToString("0.0####", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
|
||||
@@ -652,7 +652,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
|
||||
}
|
||||
|
||||
//SetStillImageInfiniteLoop(videoInputFile, videoStream, ffmpegState);
|
||||
SetRealtimeInput(videoInputFile, ffmpegState, desiredState);
|
||||
SetRealtimeInput(videoInputFile, desiredState);
|
||||
SetInfiniteLoop(videoInputFile, videoStream, ffmpegState, desiredState);
|
||||
SetFrameRateOutput(desiredState, pipelineSteps);
|
||||
SetVideoTrackTimescaleOutput(desiredState, pipelineSteps);
|
||||
@@ -847,17 +847,14 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
|
||||
}
|
||||
}
|
||||
|
||||
private void SetRealtimeInput(VideoInputFile videoInputFile, FFmpegState ffmpegState, FrameState desiredState)
|
||||
private void SetRealtimeInput(VideoInputFile videoInputFile, FrameState desiredState)
|
||||
{
|
||||
if (videoInputFile.StreamInputKind is StreamInputKind.Live || !desiredState.Realtime)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// both defaults and both bounds live on FFmpegState, beside the profile fields that
|
||||
// override them, so the pacing contract is readable in one place (ersatztv#735)
|
||||
bool isStreamCopy = desiredState.VideoFormat == VideoFormat.Copy;
|
||||
double readRate = ffmpegState.ReadRateFor(isStreamCopy);
|
||||
double readRate = desiredState.VideoFormat == VideoFormat.Copy ? 1.0 : 1.05;
|
||||
|
||||
// without a burst, the readrate throttle applies from the very first read, so the first
|
||||
// segment cannot be written faster than ~realtime and every start pays a multi-second wait.
|
||||
@@ -874,26 +871,8 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
|
||||
? InitialBurstSeconds
|
||||
: Option<int>.None;
|
||||
|
||||
// -readrate paces an input off its furthest-behind stream. an embedded bitmap subtitle is
|
||||
// read through the SAME -i as the video (its SubtitleInputFile carries the video's path and
|
||||
// resolves to a stream specifier on that input), and being sparse it falls further behind
|
||||
// every second, dragging video throughput to ~0.53x — well under the 1.0x a live client
|
||||
// consumes at. catchup lets the lagging input recover instead of pinning the whole process.
|
||||
// applied to every realtime input, not just subtitle pipelines: it is inert unless an input
|
||||
// is actually behind, and any sparse stream can cause this (ersatztv#726).
|
||||
//
|
||||
// a still image is excluded for the SAME reason the burst above excludes it: its video input
|
||||
// takes no readrate at all, so this would reach only the separate audio input and let it run
|
||||
// ahead of the video, which is exactly what #350 declined. for a non-still-image item both
|
||||
// inputs carry identical options, so the symmetry is preserved there. and an image-based
|
||||
// subtitle always rides the video path, so this shape cannot suffer the starvation anyway
|
||||
Option<double> catchupReadRate =
|
||||
!isStillImage && _ffmpegCapabilities.HasOption(FFmpegKnownOption.ReadrateCatchup)
|
||||
? ffmpegState.ReadRateCatchupFor(isStreamCopy)
|
||||
: Option<double>.None;
|
||||
|
||||
_audioInputFile.Iter(a => a.AddOption(new ReadrateInputOption(readRate, initialBurstSeconds, catchupReadRate)));
|
||||
videoInputFile.AddOption(new ReadrateInputOption(readRate, initialBurstSeconds, catchupReadRate));
|
||||
_audioInputFile.Iter(a => a.AddOption(new ReadrateInputOption(readRate, initialBurstSeconds)));
|
||||
videoInputFile.AddOption(new ReadrateInputOption(readRate, initialBurstSeconds));
|
||||
}
|
||||
|
||||
protected static void SetStillImageLoop(
|
||||
|
||||
ErsatzTV.Infrastructure.MySql/Migrations/20260826191057_Add_FFmpegProfile_ReadRatePacing.Designer.cs
Generated
-7348
File diff suppressed because it is too large
Load Diff
-38
@@ -1,38 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Add_FFmpegProfile_ReadRatePacing : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<double>(
|
||||
name: "ReadRate",
|
||||
table: "FFmpegProfile",
|
||||
type: "double",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<double>(
|
||||
name: "ReadRateCatchup",
|
||||
table: "FFmpegProfile",
|
||||
type: "double",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ReadRate",
|
||||
table: "FFmpegProfile");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ReadRateCatchup",
|
||||
table: "FFmpegProfile");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -929,12 +929,6 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
.HasColumnType("tinyint(1)")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<double?>("ReadRate")
|
||||
.HasColumnType("double");
|
||||
|
||||
b.Property<double?>("ReadRateCatchup")
|
||||
.HasColumnType("double");
|
||||
|
||||
b.Property<int>("ResolutionId")
|
||||
.HasColumnType("int");
|
||||
|
||||
|
||||
-7173
File diff suppressed because it is too large
Load Diff
-38
@@ -1,38 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Add_FFmpegProfile_ReadRatePacing : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<double>(
|
||||
name: "ReadRate",
|
||||
table: "FFmpegProfile",
|
||||
type: "REAL",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<double>(
|
||||
name: "ReadRateCatchup",
|
||||
table: "FFmpegProfile",
|
||||
type: "REAL",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ReadRate",
|
||||
table: "FFmpegProfile");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ReadRateCatchup",
|
||||
table: "FFmpegProfile");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -896,12 +896,6 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<double?>("ReadRate")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<double?>("ReadRateCatchup")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<int>("ResolutionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Dapper;
|
||||
using Dapper;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
@@ -171,15 +171,8 @@ public class MusicVideoRepository : IMusicVideoRepository
|
||||
public async Task<int> GetMusicVideoCount(int artistId)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
// count the same population GetPagedMusicVideos pages — MusicVideoMetadata, not MusicVideo.
|
||||
// A music video whose metadata row is missing (a scanner failure; FindOrphanPaths models
|
||||
// exactly that state) is not pageable, so counting the item table over-reports
|
||||
// (api.paged-count-matches-page-query, #832).
|
||||
return await dbContext.Connection.QuerySingleAsync<int>(
|
||||
@"SELECT COUNT(*)
|
||||
FROM MusicVideoMetadata MVM
|
||||
INNER JOIN MusicVideo M on MVM.MusicVideoId = M.Id
|
||||
WHERE M.ArtistId = @ArtistId",
|
||||
@"SELECT COUNT(*) FROM MusicVideo WHERE ArtistId = @ArtistId",
|
||||
new { ArtistId = artistId });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Dapper;
|
||||
using Dapper;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
@@ -134,26 +134,9 @@ public class TelevisionRepository : ITelevisionRepository
|
||||
public async Task<int> GetSeasonCount(int showId)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
// GetPagedSeasons expands the requested show to EVERY show sharing its Title+Year (the same
|
||||
// show present in two libraries) and pages the union, so the count must expand identically
|
||||
// or it under-reports (api.paged-count-matches-page-query, #832).
|
||||
Option<ShowMetadata> maybeShowMetadata = await dbContext.ShowMetadata
|
||||
.SelectOneAsync(sm => sm.Id, sm => sm.ShowId == showId, CancellationToken.None);
|
||||
|
||||
foreach (ShowMetadata showMetadata in maybeShowMetadata)
|
||||
{
|
||||
List<int> showIds = await dbContext.ShowMetadata
|
||||
.Filter(sm => sm.Title == showMetadata.Title && sm.Year == showMetadata.Year)
|
||||
.Map(sm => sm.ShowId)
|
||||
.ToListAsync();
|
||||
|
||||
return await dbContext.Seasons
|
||||
.AsNoTracking()
|
||||
.CountAsync(s => showIds.Contains(s.ShowId));
|
||||
}
|
||||
|
||||
// no metadata for the requested show: GetPagedSeasons returns nothing, so neither does this
|
||||
return 0;
|
||||
return await dbContext.Seasons
|
||||
.AsNoTracking()
|
||||
.CountAsync(s => s.ShowId == showId);
|
||||
}
|
||||
|
||||
public async Task<List<Season>> GetPagedSeasons(
|
||||
@@ -196,12 +179,9 @@ public class TelevisionRepository : ITelevisionRepository
|
||||
public async Task<int> GetEpisodeCount(int seasonId)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
// count the same population GetPagedEpisodes pages — EpisodeMetadata, not Episode. An
|
||||
// episode whose metadata row is missing is not pageable, so counting the item table
|
||||
// over-reports (api.paged-count-matches-page-query, #832).
|
||||
return await dbContext.EpisodeMetadata
|
||||
return await dbContext.Episodes
|
||||
.AsNoTracking()
|
||||
.CountAsync(em => em.Episode.SeasonId == seasonId);
|
||||
.CountAsync(e => e.SeasonId == seasonId);
|
||||
}
|
||||
|
||||
public async Task<List<EpisodeMetadata>> GetPagedEpisodes(int seasonId, int pageNumber, int pageSize)
|
||||
|
||||
@@ -440,64 +440,64 @@ public class ElasticSearchIndex : ISearchIndex
|
||||
Season season)
|
||||
{
|
||||
foreach (SeasonMetadata metadata in season.SeasonMetadata.HeadOrNone())
|
||||
foreach (ShowMetadata showMetadata in season.Show.ShowMetadata.HeadOrNone())
|
||||
foreach (ShowMetadata showMetadata in season.Show.ShowMetadata.HeadOrNone())
|
||||
{
|
||||
try
|
||||
{
|
||||
try
|
||||
var seasonTitle = $"{showMetadata.Title} - S{season.SeasonNumber}";
|
||||
string sortTitle = $"{showMetadata.SortTitle}_{season.SeasonNumber:0000}"
|
||||
.ToLowerInvariant();
|
||||
string titleAndYear = $"{showMetadata.Title}_{showMetadata.Year}_{season.SeasonNumber}"
|
||||
.ToLowerInvariant();
|
||||
|
||||
var doc = new ElasticSearchItem
|
||||
{
|
||||
var seasonTitle = $"{showMetadata.Title} - S{season.SeasonNumber}";
|
||||
string sortTitle = $"{showMetadata.SortTitle}_{season.SeasonNumber:0000}"
|
||||
.ToLowerInvariant();
|
||||
string titleAndYear = $"{showMetadata.Title}_{showMetadata.Year}_{season.SeasonNumber}"
|
||||
.ToLowerInvariant();
|
||||
Id = season.Id,
|
||||
Type = LuceneSearchIndex.SeasonType,
|
||||
Title = seasonTitle,
|
||||
SortTitle = sortTitle,
|
||||
LibraryName = season.LibraryPath.Library.Name,
|
||||
LibraryId = season.LibraryPath.Library.Id,
|
||||
TitleAndYear = titleAndYear,
|
||||
TitleAndYearSearch = LuceneSearchIndex.GetTitleAndYearSearch(metadata),
|
||||
JumpLetter = LuceneSearchIndex.GetJumpLetter(showMetadata),
|
||||
State = season.State.ToString(),
|
||||
SeasonNumber = season.SeasonNumber,
|
||||
ShowTitle = showMetadata.Title,
|
||||
ShowGenre = showMetadata.Genres.Map(g => g.Name).ToList(),
|
||||
ShowTag = showMetadata.Tags.Map(t => t.Name).ToList(),
|
||||
ShowStudio = showMetadata.Studios.Map(s => s.Name).ToList(),
|
||||
ShowContentRating = GetContentRatings(showMetadata.ContentRating),
|
||||
Language = GetLanguages(
|
||||
languageCodeService,
|
||||
await searchRepository.GetLanguagesForSeason(season)),
|
||||
LanguageTag = await searchRepository.GetLanguagesForSeason(season),
|
||||
SubLanguage = GetLanguages(
|
||||
languageCodeService,
|
||||
await searchRepository.GetSubLanguagesForSeason(season)),
|
||||
SubLanguageTag = await searchRepository.GetSubLanguagesForSeason(season),
|
||||
ContentRating = GetContentRatings(showMetadata.ContentRating),
|
||||
ReleaseDate = GetReleaseDate(metadata.ReleaseDate),
|
||||
AddedDate = GetAddedDate(metadata.DateAdded),
|
||||
TraktList = season.TraktListItems
|
||||
.Map(t => t.TraktList.TraktId.ToString(CultureInfo.InvariantCulture)).ToList(),
|
||||
Tag = metadata.Tags.Map(a => a.Name).ToList(),
|
||||
TagFull = metadata.Tags.Map(t => t.Name).ToList()
|
||||
};
|
||||
|
||||
var doc = new ElasticSearchItem
|
||||
{
|
||||
Id = season.Id,
|
||||
Type = LuceneSearchIndex.SeasonType,
|
||||
Title = seasonTitle,
|
||||
SortTitle = sortTitle,
|
||||
LibraryName = season.LibraryPath.Library.Name,
|
||||
LibraryId = season.LibraryPath.Library.Id,
|
||||
TitleAndYear = titleAndYear,
|
||||
TitleAndYearSearch = LuceneSearchIndex.GetTitleAndYearSearch(metadata),
|
||||
JumpLetter = LuceneSearchIndex.GetJumpLetter(showMetadata),
|
||||
State = season.State.ToString(),
|
||||
SeasonNumber = season.SeasonNumber,
|
||||
ShowTitle = showMetadata.Title,
|
||||
ShowGenre = showMetadata.Genres.Map(g => g.Name).ToList(),
|
||||
ShowTag = showMetadata.Tags.Map(t => t.Name).ToList(),
|
||||
ShowStudio = showMetadata.Studios.Map(s => s.Name).ToList(),
|
||||
ShowContentRating = GetContentRatings(showMetadata.ContentRating),
|
||||
Language = GetLanguages(
|
||||
languageCodeService,
|
||||
await searchRepository.GetLanguagesForSeason(season)),
|
||||
LanguageTag = await searchRepository.GetLanguagesForSeason(season),
|
||||
SubLanguage = GetLanguages(
|
||||
languageCodeService,
|
||||
await searchRepository.GetSubLanguagesForSeason(season)),
|
||||
SubLanguageTag = await searchRepository.GetSubLanguagesForSeason(season),
|
||||
ContentRating = GetContentRatings(showMetadata.ContentRating),
|
||||
ReleaseDate = GetReleaseDate(metadata.ReleaseDate),
|
||||
AddedDate = GetAddedDate(metadata.DateAdded),
|
||||
TraktList = season.TraktListItems
|
||||
.Map(t => t.TraktList.TraktId.ToString(CultureInfo.InvariantCulture)).ToList(),
|
||||
Tag = metadata.Tags.Map(a => a.Name).ToList(),
|
||||
TagFull = metadata.Tags.Map(t => t.Name).ToList()
|
||||
};
|
||||
|
||||
foreach ((string key, List<string> value) in GetMetadataGuids(metadata))
|
||||
{
|
||||
doc.AdditionalProperties.Add(key, value);
|
||||
}
|
||||
|
||||
await _client.IndexAsync(doc, IndexName, ES.Id.From(doc));
|
||||
}
|
||||
catch (Exception ex)
|
||||
foreach ((string key, List<string> value) in GetMetadataGuids(metadata))
|
||||
{
|
||||
metadata.Season = null;
|
||||
_logger.LogWarning(ex, "Error indexing season with metadata {@Metadata}", metadata);
|
||||
doc.AdditionalProperties.Add(key, value);
|
||||
}
|
||||
|
||||
await _client.IndexAsync(doc, IndexName, ES.Id.From(doc));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
metadata.Season = null;
|
||||
_logger.LogWarning(ex, "Error indexing season with metadata {@Metadata}", metadata);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateArtist(
|
||||
@@ -763,10 +763,8 @@ public class ElasticSearchIndex : ISearchIndex
|
||||
{
|
||||
try
|
||||
{
|
||||
// Guard the two NULLABLE primitive collections at the READ SITE, never by assigning back onto
|
||||
// `metadata` (ersatztv#701) -- see the matching comment in LuceneSearchIndex.UpdateSong.
|
||||
List<string> artists = Optional(metadata.Artists).Flatten().ToList();
|
||||
List<string> albumArtists = Optional(metadata.AlbumArtists).Flatten().ToList();
|
||||
metadata.AlbumArtists ??= [];
|
||||
metadata.Artists ??= [];
|
||||
|
||||
var doc = new ElasticSearchItem
|
||||
{
|
||||
@@ -787,8 +785,8 @@ public class ElasticSearchIndex : ISearchIndex
|
||||
SubLanguageTag = GetSubLanguageTags(song.MediaVersions),
|
||||
AddedDate = GetAddedDate(metadata.DateAdded),
|
||||
Album = metadata.Album ?? string.Empty,
|
||||
Artist = artists,
|
||||
AlbumArtist = albumArtists,
|
||||
Artist = metadata.Artists.ToList(),
|
||||
AlbumArtist = metadata.AlbumArtists.ToList(),
|
||||
Genre = metadata.Genres.Map(g => g.Name).ToList(),
|
||||
Tag = metadata.Tags.Map(t => t.Name).ToList(),
|
||||
TagFull = metadata.Tags.Map(t => t.Name).ToList()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Globalization;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
@@ -145,7 +145,7 @@ public sealed class LuceneSearchIndex : ISearchIndex
|
||||
_directory = FSDirectory.Open(FileSystemLayout.SearchIndexFolder);
|
||||
Analyzer analyzer = SearchQueryParser.AnalyzerWrapper();
|
||||
var indexConfig = new IndexWriterConfig(AppLuceneVersion, analyzer)
|
||||
{ OpenMode = OpenMode.CREATE_OR_APPEND };
|
||||
{ OpenMode = OpenMode.CREATE_OR_APPEND };
|
||||
_writer = new IndexWriter(_directory, indexConfig);
|
||||
_initialized = true;
|
||||
}
|
||||
@@ -328,7 +328,7 @@ public sealed class LuceneSearchIndex : ISearchIndex
|
||||
using (Analyzer analyzer = SearchQueryParser.AnalyzerWrapper())
|
||||
{
|
||||
var indexConfig = new IndexWriterConfig(AppLuceneVersion, analyzer)
|
||||
{ OpenMode = OpenMode.CREATE_OR_APPEND };
|
||||
{ OpenMode = OpenMode.CREATE_OR_APPEND };
|
||||
using (var w = new IndexWriter(d, indexConfig))
|
||||
{
|
||||
using (DirectoryReader _ = w.GetReader(true))
|
||||
@@ -1318,13 +1318,8 @@ public sealed class LuceneSearchIndex : ISearchIndex
|
||||
{
|
||||
try
|
||||
{
|
||||
// Guard the two NULLABLE primitive collections at the READ SITE, never by assigning back onto
|
||||
// `metadata` (ersatztv#701). The entity reaching here may be TRACKED, and Artists/AlbumArtists
|
||||
// are scalar JSON-array columns rather than navigations -- so `??= []` flips the entity to
|
||||
// Modified and the next SaveChanges writes `[]` over a NULL column. Same convention as
|
||||
// SongVideoGenerator and MediaCollectionRepository (ersatztv#691).
|
||||
List<string> artists = Optional(metadata.Artists).Flatten().ToList();
|
||||
List<string> albumArtists = Optional(metadata.AlbumArtists).Flatten().ToList();
|
||||
metadata.AlbumArtists ??= [];
|
||||
metadata.Artists ??= [];
|
||||
|
||||
var doc = new Document
|
||||
{
|
||||
@@ -1360,12 +1355,12 @@ public sealed class LuceneSearchIndex : ISearchIndex
|
||||
doc.Add(new TextField(AlbumField, metadata.Album, Field.Store.NO));
|
||||
}
|
||||
|
||||
foreach (string artist in artists)
|
||||
foreach (string artist in metadata.Artists)
|
||||
{
|
||||
doc.Add(new TextField(ArtistField, artist, Field.Store.NO));
|
||||
}
|
||||
|
||||
foreach (string albumArtist in albumArtists)
|
||||
foreach (string albumArtist in metadata.AlbumArtists)
|
||||
{
|
||||
doc.Add(new TextField(AlbumArtistField, albumArtist, Field.Store.NO));
|
||||
}
|
||||
|
||||
@@ -4,57 +4,11 @@ using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Graphics;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Streaming.Graphics;
|
||||
|
||||
public static class GraphicsElementSeeder
|
||||
{
|
||||
// The pre-#732 default, kept verbatim so an installation still carrying it byte-for-byte can
|
||||
// be recognised as unmodified and upgraded. Never edit an entry here -- it is a fingerprint of
|
||||
// what we shipped, not a template. Add a new entry when the current default changes again.
|
||||
private const string OnNowNextYamlV1 =
|
||||
"""
|
||||
name: On Now / Next
|
||||
epg_entries: 2
|
||||
location: BottomLeft
|
||||
horizontal_margin_percent: 4
|
||||
vertical_margin_percent: 8
|
||||
width_percent: 42
|
||||
text_fit: Wrap
|
||||
text_align: Left
|
||||
z_index: 100
|
||||
# transparent until 4s in, fade in 1s, hold 6s, fade out 1s
|
||||
opacity_expression: "LinearFadeDuration(content_seconds, 4, 1, 6)"
|
||||
base_style: now
|
||||
styles:
|
||||
- name: now
|
||||
font_family: "Noto Sans"
|
||||
font_size: 30
|
||||
font_weight: 700
|
||||
text_color: "#FFFFFF"
|
||||
halo_color: "#000000"
|
||||
halo_width: 2
|
||||
- name: sub
|
||||
font_family: "Noto Sans"
|
||||
font_size: 22
|
||||
font_weight: 400
|
||||
text_color: "#DDDDDD"
|
||||
halo_color: "#000000"
|
||||
halo_width: 2
|
||||
- name: next
|
||||
font_family: "Noto Sans"
|
||||
font_size: 22
|
||||
font_weight: 400
|
||||
text_color: "#DDDDDD"
|
||||
halo_color: "#000000"
|
||||
halo_width: 2
|
||||
text: |
|
||||
[now]NOW {{ Epg[0].Title }}[/now]
|
||||
{{ if Epg[0].SubTitle }}[sub]{{ Epg[0].SubTitle }}[/sub]{{ end }}
|
||||
{{ if (array.size Epg) > 1 }}[next]NEXT {{ Epg[1].Title }}[/next]{{ end }}
|
||||
""";
|
||||
|
||||
private const string OnNowNextYaml =
|
||||
"""
|
||||
name: On Now / Next
|
||||
@@ -68,17 +22,6 @@ public static class GraphicsElementSeeder
|
||||
z_index: 100
|
||||
# transparent until 4s in, fade in 1s, hold 6s, fade out 1s
|
||||
opacity_expression: "LinearFadeDuration(content_seconds, 4, 1, 6)"
|
||||
# #732: a translucent box carries legibility over both bright and dark content. The halo is
|
||||
# cut from 2 to 1 rather than dropped -- the box is translucent, so bright content still
|
||||
# shows through behind the glyphs, but 2px of halo ON TOP of a box over-darkens the text.
|
||||
background_color: "#000000"
|
||||
background_opacity_percent: 65
|
||||
background_padding: 14
|
||||
background_corner_radius: 8
|
||||
# A translucent black box vanishes into dark content, so the box needs an edge of its own.
|
||||
# Low-alpha white reads as a hairline on dark frames without becoming a hard line on bright ones.
|
||||
border_color: "#59FFFFFF"
|
||||
border_width: 1
|
||||
base_style: now
|
||||
styles:
|
||||
- name: now
|
||||
@@ -87,56 +30,39 @@ public static class GraphicsElementSeeder
|
||||
font_weight: 700
|
||||
text_color: "#FFFFFF"
|
||||
halo_color: "#000000"
|
||||
halo_width: 1
|
||||
halo_width: 2
|
||||
- name: sub
|
||||
font_family: "Noto Sans"
|
||||
font_size: 22
|
||||
font_weight: 400
|
||||
text_color: "#DDDDDD"
|
||||
halo_color: "#000000"
|
||||
halo_width: 1
|
||||
halo_width: 2
|
||||
- name: next
|
||||
font_family: "Noto Sans"
|
||||
font_size: 22
|
||||
font_weight: 400
|
||||
text_color: "#DDDDDD"
|
||||
halo_color: "#000000"
|
||||
halo_width: 1
|
||||
halo_width: 2
|
||||
text: |
|
||||
[now]NOW {{ Epg[0].Title }}[/now]
|
||||
{{ if Epg[0].SubTitle }}[sub]{{ Epg[0].SubTitle }}[/sub]{{ end }}
|
||||
{{ if (array.size Epg) > 1 }}[next]NEXT {{ Epg[1].Title }}[/next]{{ end }}
|
||||
""";
|
||||
|
||||
// Every default we have ever shipped, most recent first. A file matching one of these was
|
||||
// written by us and never touched, so replacing it is an upgrade rather than a clobber.
|
||||
private static readonly string[] SupersededDefaults = [OnNowNextYamlV1];
|
||||
|
||||
public static async Task SeedOnNowNext(
|
||||
TvContext context,
|
||||
IFileSystem fileSystem,
|
||||
ILogger logger,
|
||||
CancellationToken cancellationToken)
|
||||
public static async Task SeedOnNowNext(TvContext context, IFileSystem fileSystem, CancellationToken cancellationToken)
|
||||
{
|
||||
string folder = FileSystemLayout.GraphicsElementsTextTemplatesFolder;
|
||||
string target = fileSystem.Path.Combine(folder, GraphicsElementDefaults.OnNowNextFileName);
|
||||
|
||||
string seededKey = ConfigElementKey.GraphicsOnNowNextSeeded.Key;
|
||||
bool alreadySeeded = await context.ConfigElements.AnyAsync(c => c.Key == seededKey, cancellationToken);
|
||||
|
||||
if (alreadySeeded)
|
||||
{
|
||||
// Already-seeded installations never revisit the file, so a change to the default would
|
||||
// otherwise reach new databases only. Upgrade the ones still carrying an untouched
|
||||
// earlier default; anything an operator edited no longer matches and is left alone.
|
||||
//
|
||||
// Deliberately no CreateDirectory on this branch: before #732 it touched the filesystem
|
||||
// not at all, so an already-seeded install stays bootable on a read-only /config.
|
||||
await UpgradeUnmodifiedTemplate(fileSystem, target, logger, cancellationToken);
|
||||
await EnsureBuiltInElementRow(context, fileSystem, target, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
string folder = FileSystemLayout.GraphicsElementsTextTemplatesFolder;
|
||||
string target = fileSystem.Path.Combine(folder, GraphicsElementDefaults.OnNowNextFileName);
|
||||
|
||||
if (!fileSystem.Directory.Exists(folder))
|
||||
{
|
||||
fileSystem.Directory.CreateDirectory(folder);
|
||||
@@ -152,242 +78,5 @@ public static class GraphicsElementSeeder
|
||||
new ConfigElement { Key = seededKey, Value = "true" },
|
||||
cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await EnsureBuiltInElementRow(context, fileSystem, target, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// `RefreshGraphicsElements` is what normally turns a template file into a `GraphicsElement` row,
|
||||
/// but it runs on the scheduler/stream-start path -- long after startup. Creating the row here
|
||||
/// removes that ordering dependency, so `AttachOnNowNextByDefault` below can never mark itself
|
||||
/// done against an element that simply had not been discovered yet.
|
||||
/// </summary>
|
||||
private static async Task EnsureBuiltInElementRow(
|
||||
TvContext context,
|
||||
IFileSystem fileSystem,
|
||||
string target,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!fileSystem.File.Exists(target))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// "Does the built-in row already exist?" is the same question every consumer asks later, so
|
||||
// ask it with the same code instead of re-deriving it here. As its own SQL comparison
|
||||
// (`AnyAsync(e => e.Path == target)`) it could answer differently in two ways, and either
|
||||
// one leaves the built-in element undiscoverable after startup (#568):
|
||||
// * string equality in SQL is the PROVIDER's collation to decide, so on MySQL's normally
|
||||
// case-INsensitive default a case-variant row satisfied the check, the canonical row was
|
||||
// never created, and the ordinal lookup below then matched nothing;
|
||||
// * it ignored `Kind`, so a row of another kind sitting at the seeded path suppressed the
|
||||
// Text row the lookup actually resolves.
|
||||
// Creating the row stays idempotent because `target` IS the path the lookup matches -- held
|
||||
// by `Repeated_Seeding_Does_Not_Accumulate_Element_Rows`, which reddens if the two drift.
|
||||
if ((await GetBuiltInElementId(context, cancellationToken)).IsSome)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Name is display-only (identity is the full seeded path, `target` above -- #568), but
|
||||
// leaving it null sorts the built-in element into the unnamed bucket at the bottom of the
|
||||
// SPA list until the first refresh.
|
||||
await context.GraphicsElements.AddAsync(
|
||||
new Core.Domain.GraphicsElement
|
||||
{
|
||||
Path = target,
|
||||
Kind = GraphicsElementKind.Text,
|
||||
Name = GraphicsElementDefaults.OnNowNextName
|
||||
},
|
||||
cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #732: the On Now / Next overlay is a default, not an opt-in. Existing channels predate that
|
||||
/// decision, so attach the built-in element to them once.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The marker is written only once the built-in element RESOLVES, so an install whose row does
|
||||
/// not exist yet is retried on the next startup rather than stranded permanently. Once written,
|
||||
/// no channel is ever re-attached. While still armed the backfill cannot tell a deliberately
|
||||
/// cleared channel from an untouched one -- a single global flag cannot express both
|
||||
/// properties; see <c>graphics.on-now-next-on-by-default</c> for why that trade is made this
|
||||
/// way. Every channel created after the marker gets the element from
|
||||
/// <c>ChannelGraphicsDefaults.Attach</c> instead, which BOTH create paths call.
|
||||
/// </remarks>
|
||||
public static async Task AttachOnNowNextByDefault(TvContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
string key = ConfigElementKey.GraphicsOnNowNextDefaultAttached.Key;
|
||||
if (await context.ConfigElements.AnyAsync(c => c.Key == key, cancellationToken))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Option<int> maybeElementId = await GetBuiltInElementId(context, cancellationToken);
|
||||
if (maybeElementId.IsNone)
|
||||
{
|
||||
// Nothing to attach TO. Writing the marker here would strand every channel permanently
|
||||
// on the one population this exists for, so stay armed and try again next startup.
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (int elementId in maybeElementId)
|
||||
{
|
||||
// HLS Direct has no frame pipeline to draw into, so an attachment there would be inert
|
||||
// while still reading as "on" in the editor.
|
||||
List<int> channelIds = await context.Channels
|
||||
.Where(c => c.StreamingMode != StreamingMode.HttpLiveStreamingDirect)
|
||||
.Where(c => c.ChannelGraphicsElements.All(cge => cge.GraphicsElementId != elementId))
|
||||
.Select(c => c.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (int channelId in channelIds)
|
||||
{
|
||||
await context.AddAsync(
|
||||
new ChannelGraphicsElement { ChannelId = channelId, GraphicsElementId = elementId },
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
await context.ConfigElements.AddAsync(
|
||||
new ConfigElement { Key = key, Value = "true" },
|
||||
cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Identity is the full seeded path, never the user-editable Name (the #67 lesson carried into
|
||||
/// #74) and never the bare filename (#568: filename-only matching is folder-agnostic, so a user
|
||||
/// element named exactly `on-now-next.yml` in a different template folder would also match).
|
||||
/// The <c>Kind</c> half of that identity is load-bearing rather than decorative:
|
||||
/// <c>EnsureBuiltInElementRow</c> asks this method whether the row it is about to create already
|
||||
/// exists, so a row of another kind at the seeded path must NOT answer yes -- it would suppress
|
||||
/// the Text row every consumer resolves.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Both halves are <see cref="GraphicsElementDefaults.IsOnNowNext(string,GraphicsElementKind)"/>
|
||||
/// in memory rather than a <c>Where</c> clause. The path half must be, or the match would be the
|
||||
/// provider's collation to decide and this site would disagree with the API's `builtIn` (which
|
||||
/// compares in memory) on MySQL. The <c>Kind</c> half could be a SQL filter -- it is an enum,
|
||||
/// not a string -- but then this site would hold half the identity and the predicate the other
|
||||
/// half, and the API site could apply the predicate alone and quietly answer for rows this one
|
||||
/// rejects. That is exactly the disagreement #568 found, so identity is one predicate applied
|
||||
/// whole, at every site.
|
||||
/// </remarks>
|
||||
public static async Task<Option<int>> GetBuiltInElementId(
|
||||
TvContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<(int Id, string Path, GraphicsElementKind Kind)> candidates = await context.GraphicsElements
|
||||
.Select(e => new { e.Id, e.Path, e.Kind })
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(rows => rows.Select(r => (r.Id, r.Path, r.Kind)).ToList());
|
||||
|
||||
List<int> matches = candidates
|
||||
.Where(c => GraphicsElementDefaults.IsOnNowNext(c.Path, c.Kind))
|
||||
.Select(c => c.Id)
|
||||
.OrderBy(id => id)
|
||||
.ToList();
|
||||
|
||||
// Lowest id wins if two rows somehow share the seeded path, so the choice is stable across
|
||||
// restarts rather than dependent on query order.
|
||||
return matches.Count == 0 ? Option<int>.None : matches[0];
|
||||
}
|
||||
|
||||
private static async Task UpgradeUnmodifiedTemplate(
|
||||
IFileSystem fileSystem,
|
||||
string target,
|
||||
ILogger logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// This runs inside the blocking database-startup path, ahead of DatabaseIsReady(). Before
|
||||
// #732 the already-seeded branch never touched the filesystem at all, so an unreadable or
|
||||
// read-only template is a state that used to boot fine -- it must not become a failure to
|
||||
// start. Cosmetic upgrade, best effort.
|
||||
try
|
||||
{
|
||||
if (!fileSystem.File.Exists(target))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string existing = await fileSystem.File.ReadAllTextAsync(target, cancellationToken);
|
||||
if (!SupersededDefaults.Any(d => IsSameTemplate(existing, d)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Write-then-move, never write in place. WriteAllTextAsync truncates first, so an
|
||||
// interrupted write (disk full, IO fault, cancellation) would leave a partial file that
|
||||
// matches no fingerprint and is therefore never repaired on a later boot -- the overlay
|
||||
// would just be gone, permanently, on every channel carrying it.
|
||||
//
|
||||
// The temp name is random per call. A fixed one is shared by two containers on the same
|
||||
// config volume, where one can truncate it while the other is mid-write and then rename
|
||||
// the partial file over the live template. The process id is NOT good enough here: the
|
||||
// image uses an exec-form ENTRYPOINT, so every container's PID namespace makes this
|
||||
// process 1 and every container computes the same name. A random name also means a temp
|
||||
// left by a crashed earlier boot is never reused. Only ever delete the path this call
|
||||
// created.
|
||||
string temp = $"{target}.{fileSystem.Path.GetRandomFileName()}.upgrade.tmp";
|
||||
try
|
||||
{
|
||||
await fileSystem.File.WriteAllTextAsync(temp, OnNowNextYaml, cancellationToken);
|
||||
|
||||
// Deliberately NO in-place fallback when this throws. rename(2) onto a mountpoint
|
||||
// is EBUSY, so a single-file bind mount of this template will not be upgraded --
|
||||
// accepted, because reaching that case needs a pinned file that is ALSO byte-identical
|
||||
// to a shipped default, and the alternative is reintroducing the truncation this
|
||||
// whole dance exists to prevent, on every IO fault rather than just that one.
|
||||
fileSystem.File.Move(temp, target, true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Cleanup must never REPLACE the exception that brought us here. Without this inner
|
||||
// catch, a delete that throws while unwinding a cancellation swaps the
|
||||
// OperationCanceledException for an IOException, which the outer filter then
|
||||
// swallows -- so a real shutdown would be silently downgraded to a warning.
|
||||
try
|
||||
{
|
||||
if (fileSystem.File.Exists(temp))
|
||||
{
|
||||
fileSystem.File.Delete(temp);
|
||||
}
|
||||
}
|
||||
catch (Exception cleanupEx)
|
||||
{
|
||||
logger.LogDebug(cleanupEx, "Could not remove the temporary upgrade file {Path}", temp);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Recoverable filesystem faults only. Catching everything would swallow genuinely fatal
|
||||
// runtime failures (OutOfMemory and friends) and continue booting a compromised process;
|
||||
// letting IO escape would turn a file permission into a restart loop. Cancellation
|
||||
// propagates so shutdown is not swallowed.
|
||||
// OperationCanceledException is deliberately absent from this list so a real shutdown
|
||||
// propagates -- but only a real one: an OCE raised while the token is NOT cancelled is just
|
||||
// another faulty read, and letting it escape is the restart loop this catch exists to stop.
|
||||
catch (Exception ex) when ((ex is IOException
|
||||
or UnauthorizedAccessException
|
||||
or NotSupportedException
|
||||
or System.Security.SecurityException)
|
||||
|| (ex is OperationCanceledException
|
||||
&& !cancellationToken.IsCancellationRequested))
|
||||
{
|
||||
logger.LogWarning(
|
||||
ex,
|
||||
"Could not upgrade the built-in graphics template at {Path}; leaving it as-is",
|
||||
target);
|
||||
}
|
||||
}
|
||||
|
||||
// Compare on content, ignoring the line endings and trailing whitespace an editor or a volume
|
||||
// mount may rewrite. This is a fingerprint check, not a parse: anything that is not one of our
|
||||
// own shipped defaults must fall through untouched.
|
||||
private static bool IsSameTemplate(string left, string right) =>
|
||||
string.Equals(Normalize(left), Normalize(right), StringComparison.Ordinal);
|
||||
|
||||
private static string Normalize(string value) =>
|
||||
value.Replace("\r\n", "\n", StringComparison.Ordinal).TrimEnd();
|
||||
}
|
||||
|
||||
@@ -14,10 +14,6 @@ public partial class TextElement(
|
||||
ILogger logger)
|
||||
: GraphicsElement, IDisposable
|
||||
{
|
||||
// Far larger than any sane overlay on an 8K frame, and small enough that every downstream
|
||||
// int cast stays well inside range.
|
||||
private const float MaxBoxDimension = 10_000f;
|
||||
|
||||
private static readonly Regex StylePattern = StyleRegex();
|
||||
private SKBitmap _image;
|
||||
private SKPointI _location;
|
||||
@@ -66,96 +62,30 @@ public partial class TextElement(
|
||||
}
|
||||
}
|
||||
|
||||
BackgroundBox box = BuildBackgroundBox();
|
||||
|
||||
|
||||
RichTextKit.TextBlock textBlock = BuildTextBlock(textElement.Text);
|
||||
|
||||
// Padding and border sit OUTSIDE the laid-out text on every side, so they shrink the
|
||||
// space the text may occupy and grow the bitmap that holds it. Zero when there is no
|
||||
// box, which reproduces the pre-#732 geometry exactly.
|
||||
//
|
||||
// Round ONCE, here, and use the same integer on both sides: the bitmap grows by
|
||||
// 2 * insetPixels, so subtracting the unrounded inset from the wrap budget would let a
|
||||
// fractional padding push the finished box a pixel past width_percent.
|
||||
var insetPixels = (int)Math.Ceiling(box?.Inset ?? 0f);
|
||||
|
||||
// Bound the inset against the FRAME even when there is no width_percent. Sanitize caps
|
||||
// each field individually, but padding and border add up, and without a budget nothing
|
||||
// else clamps them -- a two-field fat-finger would otherwise allocate a bitmap far
|
||||
// larger than the frame it is drawn onto. Pre-#732 no config value could inflate the
|
||||
// bitmap independently of the measured text.
|
||||
int frameInsetCap = Math.Max(0, Math.Min(context.FrameSize.Width, context.FrameSize.Height) / 2);
|
||||
if (insetPixels > frameInsetCap)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Background padding/border of {Inset}px exceeds the frame; clamping to {Clamped}px",
|
||||
insetPixels,
|
||||
frameInsetCap);
|
||||
|
||||
insetPixels = frameInsetCap;
|
||||
box = box?.ClampedTo(frameInsetCap);
|
||||
}
|
||||
|
||||
// A width_percent of 1e300 makes maxWidth Infinity, and every int cast below it is then
|
||||
// unspecified. Treat a non-finite budget as "no budget", which is what an absent
|
||||
// width_percent already means.
|
||||
if (textElement.WidthPercent.HasValue
|
||||
&& float.IsFinite((float)(textElement.WidthPercent.Value / 100.0 * context.FrameSize.Width)))
|
||||
if (textElement.WidthPercent.HasValue)
|
||||
{
|
||||
var maxWidth = (float)Math.Round(textElement.WidthPercent.Value / 100.0 * context.FrameSize.Width);
|
||||
|
||||
// A padding wider than the budget itself cannot be honoured AND stay inside it.
|
||||
// Clamp the inset rather than squeezing the text to 1px: an unclamped floor turns a
|
||||
// fat-fingered background_padding into a box several times the requested width.
|
||||
int maxInset = Math.Max(0, (int)Math.Floor((maxWidth - 1) / 2));
|
||||
if (insetPixels > maxInset)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Background padding/border of {Inset}px does not fit within width_percent "
|
||||
+ "({MaxWidth}px); clamping to {Clamped}px",
|
||||
insetPixels,
|
||||
maxWidth,
|
||||
maxInset);
|
||||
|
||||
// Clamp the BOX, not just the bitmap's inset. Shrinking insetPixels alone leaves
|
||||
// DrawBackgroundBox stroking at the original border width, which is centred on a
|
||||
// rect that no longer has room for it -- the stroke then floods the element.
|
||||
insetPixels = maxInset;
|
||||
box = box?.ClampedTo(maxInset);
|
||||
}
|
||||
|
||||
// width_percent bounds the ELEMENT, so the text gets what is left after the insets.
|
||||
// With no box the budget is passed through untouched -- not through Math.Max -- so a
|
||||
// width_percent that rounds to 0 keeps its exact pre-#732 behavior.
|
||||
float textMaxWidth = insetPixels == 0
|
||||
? maxWidth
|
||||
: Math.Max(1f, maxWidth - (2 * insetPixels));
|
||||
|
||||
switch (textElement.Fit)
|
||||
{
|
||||
case TextFit.Wrap:
|
||||
textBlock.MaxWidth = textMaxWidth;
|
||||
textBlock.MaxWidth = maxWidth;
|
||||
break;
|
||||
case TextFit.Scale:
|
||||
FitTextBlock(textBlock, textMaxWidth);
|
||||
FitTextBlock(textBlock, maxWidth);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_image = new SKBitmap(
|
||||
(int)Math.Ceiling(textBlock.MeasuredWidth) + (2 * insetPixels),
|
||||
(int)Math.Ceiling(textBlock.MeasuredHeight) + (2 * insetPixels));
|
||||
(int)Math.Ceiling(textBlock.MeasuredWidth),
|
||||
(int)Math.Ceiling(textBlock.MeasuredHeight));
|
||||
using (var canvas = new SKCanvas(_image))
|
||||
{
|
||||
canvas.Clear(SKColors.Transparent);
|
||||
|
||||
if (box is not null)
|
||||
{
|
||||
DrawBackgroundBox(canvas, box, _image.Width, _image.Height);
|
||||
}
|
||||
|
||||
textBlock.Paint(canvas, new SKPoint(insetPixels, insetPixels));
|
||||
textBlock.Paint(canvas, new SKPoint(0, 0));
|
||||
}
|
||||
|
||||
var horizontalMargin =
|
||||
@@ -204,158 +134,6 @@ public partial class TextElement(
|
||||
: new ValueTask<Option<PreparedElementImage>>(new PreparedElementImage(_image, _location, opacity, ZIndex, false));
|
||||
}
|
||||
|
||||
// A background box is drawn only when a colour actually parses. An unparseable colour is
|
||||
// warned about and skipped rather than substituted, so a typo never silently changes the
|
||||
// look into something that appears deliberate.
|
||||
private BackgroundBox BuildBackgroundBox()
|
||||
{
|
||||
SKColor? fill = ParseOptionalColor(textElement.BackgroundColor, "background_color");
|
||||
if (fill.HasValue)
|
||||
{
|
||||
fill = ApplyOpacityPercent(fill.Value, textElement.BackgroundOpacityPercent);
|
||||
}
|
||||
|
||||
SKColor? border = ParseOptionalColor(textElement.BorderColor, "border_color");
|
||||
|
||||
// A border colour with no explicit width means a hairline border, not an invisible one:
|
||||
// "border_color set, nothing drawn" is the more confusing of the two readings.
|
||||
float borderWidth = Sanitize(textElement.BorderWidth ?? 1, "border_width");
|
||||
if (!border.HasValue)
|
||||
{
|
||||
borderWidth = 0;
|
||||
}
|
||||
|
||||
if (!fill.HasValue && borderWidth <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new BackgroundBox(
|
||||
fill,
|
||||
border,
|
||||
borderWidth,
|
||||
Sanitize(textElement.BackgroundCornerRadius, "background_corner_radius"),
|
||||
Sanitize(textElement.BackgroundPadding, "background_padding"));
|
||||
}
|
||||
|
||||
// YAML happily yields 1e100 or NaN. Cast to float those become Infinity/NaN, and
|
||||
// (int)Math.Ceiling(Infinity) is an unspecified value -- in practice int.MinValue, which sails
|
||||
// straight past every `> maxInset` clamp and can wrap 2 * inset back to zero. Sanitize at the
|
||||
// boundary so no downstream arithmetic ever sees a non-finite value.
|
||||
private float Sanitize(double? value, string fieldName)
|
||||
{
|
||||
if (value is not { } raw)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
if (double.IsNaN(raw) || raw < 0)
|
||||
{
|
||||
logger.LogWarning("Ignoring out-of-range {Field} value {Value}", fieldName, raw);
|
||||
return 0f;
|
||||
}
|
||||
|
||||
if (raw > MaxBoxDimension)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Clamping {Field} value {Value} to {Max}",
|
||||
fieldName,
|
||||
raw,
|
||||
MaxBoxDimension);
|
||||
|
||||
return MaxBoxDimension;
|
||||
}
|
||||
|
||||
return (float)raw;
|
||||
}
|
||||
|
||||
private SKColor? ParseOptionalColor(string value, string fieldName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (SKColor.TryParse(value, out SKColor parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
logger.LogWarning(
|
||||
"Unable to parse {Field} value {Value}; that part of the background box will not be drawn",
|
||||
fieldName,
|
||||
value);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static SKColor ApplyOpacityPercent(SKColor color, int? opacityPercent)
|
||||
{
|
||||
if (opacityPercent is not { } percent)
|
||||
{
|
||||
return color;
|
||||
}
|
||||
|
||||
int clamped = Math.Clamp(percent, 0, 100);
|
||||
return color.WithAlpha((byte)Math.Round(color.Alpha * clamped / 100.0));
|
||||
}
|
||||
|
||||
private static void DrawBackgroundBox(SKCanvas canvas, BackgroundBox box, int width, int height)
|
||||
{
|
||||
// Skia strokes centred on the path, so half the border would fall outside the bitmap and
|
||||
// be clipped. Inset the rect by half the width to keep the whole border visible.
|
||||
float half = box.BorderWidth / 2f;
|
||||
var rect = new SKRect(half, half, width - half, height - half);
|
||||
|
||||
// A radius larger than half the shorter side is not expressible as a rounded rect.
|
||||
float radius = Math.Min(box.CornerRadius, Math.Min(rect.Width, rect.Height) / 2f);
|
||||
radius = Math.Max(0, radius);
|
||||
|
||||
if (box.Fill is { } fill)
|
||||
{
|
||||
using var fillPaint = new SKPaint
|
||||
{
|
||||
Color = fill,
|
||||
Style = SKPaintStyle.Fill,
|
||||
IsAntialias = true
|
||||
};
|
||||
|
||||
canvas.DrawRoundRect(rect, radius, radius, fillPaint);
|
||||
}
|
||||
|
||||
if (box.Border is { } border && box.BorderWidth > 0)
|
||||
{
|
||||
using var borderPaint = new SKPaint
|
||||
{
|
||||
Color = border,
|
||||
Style = SKPaintStyle.Stroke,
|
||||
StrokeWidth = box.BorderWidth,
|
||||
IsAntialias = true
|
||||
};
|
||||
|
||||
canvas.DrawRoundRect(rect, radius, radius, borderPaint);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record BackgroundBox(
|
||||
SKColor? Fill,
|
||||
SKColor? Border,
|
||||
float BorderWidth,
|
||||
float CornerRadius,
|
||||
float Padding)
|
||||
{
|
||||
public float Inset => Padding + BorderWidth;
|
||||
|
||||
// Border first, then whatever is left goes to padding: a border that cannot be drawn inside
|
||||
// the bitmap is worse than a thin one, and padding degrades gracefully to zero.
|
||||
public BackgroundBox ClampedTo(float maxInset)
|
||||
{
|
||||
float borderWidth = Math.Min(BorderWidth, maxInset);
|
||||
float padding = Math.Max(0, maxInset - borderWidth);
|
||||
return this with { BorderWidth = borderWidth, Padding = padding };
|
||||
}
|
||||
}
|
||||
|
||||
private RichTextKit.TextBlock BuildTextBlock(string textToRender)
|
||||
{
|
||||
var textBlock = new RichTextKit.TextBlock
|
||||
@@ -433,17 +211,6 @@ public partial class TextElement(
|
||||
finalStyle.TextColor = parsedColor;
|
||||
}
|
||||
|
||||
// Halo is per-style in the schema and was being dropped here, so a non-base style's
|
||||
// halo_* silently inherited the base style's. The seeded template only looked correct
|
||||
// because all three of its styles declare the same halo.
|
||||
finalStyle.HaloWidth = s.HaloWidth ?? finalStyle.HaloWidth;
|
||||
finalStyle.HaloBlur = s.HaloBlur ?? finalStyle.HaloBlur;
|
||||
|
||||
if (s.HaloColor != null && SKColor.TryParse(s.HaloColor, out SKColor parsedHalo))
|
||||
{
|
||||
finalStyle.HaloColor = parsedHalo;
|
||||
}
|
||||
|
||||
styles[s.Name] = finalStyle;
|
||||
}
|
||||
|
||||
@@ -519,11 +286,6 @@ public partial class TextElement(
|
||||
|
||||
foreach ((string text, RichTextKit.IStyle style) in originalContent)
|
||||
{
|
||||
// Carry across every property the YAML schema can set, not just the ones the scale
|
||||
// needs (the rest are RichTextKit defaults we never touch). Halo and
|
||||
// line height were being dropped here, which only mattered once #732 gave the box
|
||||
// insets that can push a previously-fitting element into the Scale path: adding a
|
||||
// background would then silently remove the halo it sits behind.
|
||||
var newStyle = new RichTextKit.Style
|
||||
{
|
||||
FontFamily = style.FontFamily,
|
||||
@@ -532,11 +294,7 @@ public partial class TextElement(
|
||||
FontWidth = style.FontWidth,
|
||||
FontWeight = style.FontWeight,
|
||||
LetterSpacing = style.LetterSpacing,
|
||||
LineHeight = style.LineHeight,
|
||||
TextColor = style.TextColor,
|
||||
HaloColor = style.HaloColor,
|
||||
HaloWidth = style.HaloWidth,
|
||||
HaloBlur = style.HaloBlur
|
||||
TextColor = style.TextColor
|
||||
};
|
||||
|
||||
float newSize = newStyle.FontSize * scale;
|
||||
|
||||
@@ -21,16 +21,4 @@
|
||||
<ProjectReference Include="..\ErsatzTV.Mcp\ErsatzTV.Mcp.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
The generated OpenAPI document is the wire contract the MCP catalog wraps. Copying it into the
|
||||
test output lets ToolCatalogTests assert that every write tool declares exactly the request-body
|
||||
fields its endpoint accepts, so a new DTO property cannot drift out of a tool schema unnoticed
|
||||
(issue #754). Regenerated by scripts/update-openapi.sh.
|
||||
-->
|
||||
<ItemGroup>
|
||||
<Content Include="..\ErsatzTV\wwwroot\openapi\v1.json"
|
||||
Link="openapi\v1.json"
|
||||
CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -77,22 +77,9 @@ public class ToolCatalogTests
|
||||
// Enums must NOT be forced required (they have server-side defaults).
|
||||
createRequired.ShouldNotContain("streamingMode");
|
||||
|
||||
// Update carries the create body fields plus the route id...
|
||||
JsonElement updateProps = update.InputSchema.RootElement.GetProperty("properties");
|
||||
updateProps.TryGetProperty("id", out _).ShouldBeTrue();
|
||||
updateProps.TryGetProperty("showInEpg", out _).ShouldBeTrue();
|
||||
|
||||
// ...plus graphicsElementIds, which is on UpdateChannelRequest only. PUT is a full replace, so
|
||||
// while the tool could not express this field an agent following the tool's own "send the full
|
||||
// desired state" instruction silently detached every graphics element (issue #754).
|
||||
updateProps.TryGetProperty("graphicsElementIds", out JsonElement graphicsElementIds).ShouldBeTrue();
|
||||
graphicsElementIds.GetProperty("type").GetString().ShouldBe("array");
|
||||
graphicsElementIds.GetProperty("items").GetProperty("type").GetString().ShouldBe("integer");
|
||||
|
||||
// Create must NOT send it: CreateChannelRequest has no such property, and the tool schema is
|
||||
// additionalProperties:false. This is why it is declared on the update tool rather than in the
|
||||
// shared ChannelFields().
|
||||
createProps.TryGetProperty("graphicsElementIds", out _).ShouldBeFalse();
|
||||
// Update carries the same body fields plus the route id.
|
||||
update.InputSchema.RootElement.GetProperty("properties").TryGetProperty("id", out _).ShouldBeTrue();
|
||||
update.InputSchema.RootElement.GetProperty("properties").TryGetProperty("showInEpg", out _).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -269,248 +256,4 @@ public class ToolCatalogTests
|
||||
tool.QueryParameters.ShouldNotBeNull();
|
||||
tool.QueryParameters!.ShouldContain("deep");
|
||||
}
|
||||
|
||||
// #754: ToolCatalog declared 27 of UpdateChannelRequest's 28 properties. The missing one was
|
||||
// graphicsElementIds, and because PUT /api/v1/channels/{id} is a FULL REPLACE the omission was not
|
||||
// merely "one field you cannot set" — an agent that GET-edit-PUT the channel, exactly as the tool's
|
||||
// description tells it to, detached every graphics element (including the On Now/Next overlay) with
|
||||
// a 200 and no error. The same shape was live on ersatztv_update_schedule, which omitted
|
||||
// padToNearestMinute and silently cleared a configured pad.
|
||||
//
|
||||
// Neither is fixable by counting fields once: the defect is that nothing tied the tool schema to the
|
||||
// contract it wraps. So this test asserts the tie for EVERY write tool against the generated OpenAPI
|
||||
// document (the actual wire contract, linked into the test output by the csproj). A new property on
|
||||
// any request DTO now fails here until the catalog declares it.
|
||||
[Test]
|
||||
public void Every_Write_Tool_Should_Declare_Exactly_Its_OpenApi_Request_Body_Fields()
|
||||
{
|
||||
using JsonDocument spec = LoadOpenApiDocument();
|
||||
JsonElement paths = spec.RootElement.GetProperty("paths");
|
||||
|
||||
ToolDefinition[] writeTools = ToolCatalog.All
|
||||
.Where(t => t.HttpMethod == HttpMethod.Post
|
||||
|| t.HttpMethod == HttpMethod.Put
|
||||
|| t.HttpMethod == HttpMethod.Patch)
|
||||
.ToArray();
|
||||
|
||||
// Pin the covered set rather than trusting the filter. A tool that stopped being a write verb,
|
||||
// or a new write tool, must show up as a change here — a bare loop over a filtered set passes
|
||||
// just as happily when the set silently shrinks to nothing.
|
||||
string[] expectedWriteTools =
|
||||
[
|
||||
"ersatztv_add_collection_items",
|
||||
"ersatztv_create_channel",
|
||||
"ersatztv_create_collection",
|
||||
"ersatztv_create_playout",
|
||||
"ersatztv_create_schedule",
|
||||
"ersatztv_create_smart_collection",
|
||||
"ersatztv_enable_jellyfin_library_sync",
|
||||
"ersatztv_refresh_jellyfin_libraries",
|
||||
"ersatztv_reset_channel_playout",
|
||||
"ersatztv_scan_jellyfin_collections",
|
||||
"ersatztv_scan_library",
|
||||
"ersatztv_update_channel",
|
||||
"ersatztv_update_collection",
|
||||
"ersatztv_update_collection_custom_order",
|
||||
"ersatztv_update_playout",
|
||||
"ersatztv_update_schedule",
|
||||
"ersatztv_update_smart_collection"
|
||||
];
|
||||
|
||||
writeTools.Select(t => t.Name).OrderBy(n => n, StringComparer.Ordinal)
|
||||
.ShouldBe(expectedWriteTools.OrderBy(n => n, StringComparer.Ordinal));
|
||||
|
||||
foreach (ToolDefinition tool in writeTools)
|
||||
{
|
||||
paths.TryGetProperty(tool.PathTemplate, out JsonElement pathItem)
|
||||
.ShouldBeTrue($"{tool.Name}: {tool.PathTemplate} is not in the OpenAPI document");
|
||||
|
||||
string verb = tool.HttpMethod.Method.ToLowerInvariant();
|
||||
pathItem.TryGetProperty(verb, out JsonElement operation)
|
||||
.ShouldBeTrue($"{tool.Name}: {verb.ToUpperInvariant()} {tool.PathTemplate} is not in the OpenAPI document");
|
||||
|
||||
Dictionary<string, string> declared = DeclaredBodyArguments(tool);
|
||||
Dictionary<string, string> accepted = RequestBodyProperties(spec, operation, tool.Name);
|
||||
|
||||
// Compare name AND type. Names alone would let a field drift to the wrong JSON type: the
|
||||
// tool would advertise "string" for an int?, the agent would send "30", and the API would
|
||||
// 400 — green test, broken tool.
|
||||
declared.Select(p => $"{p.Key}: {p.Value}").OrderBy(s => s, StringComparer.Ordinal)
|
||||
.ShouldBe(
|
||||
accepted.Select(p => $"{p.Key}: {p.Value}").OrderBy(s => s, StringComparer.Ordinal),
|
||||
customMessage:
|
||||
$"{tool.Name} declares body fields that do not match {verb.ToUpperInvariant()} {tool.PathTemplate}. "
|
||||
+ "A field the endpoint accepts but the tool omits is silently dropped on a full-replace "
|
||||
+ "write (#754); a field the tool sends but the endpoint does not accept is rejected; "
|
||||
+ "a field declared with the wrong type is rejected at the API.");
|
||||
}
|
||||
}
|
||||
|
||||
// #757, the sibling of the body guard above. Query parameters drift the same way and are WORSE for
|
||||
// reads: ToolArgumentValidator rejects undeclared arguments, so a parameter the tool omits is not
|
||||
// merely undocumented, it is unreachable — the caller cannot pass it at all. That is how #616's
|
||||
// paging omission hard-capped two tools at the first page. This covers EVERY tool, not just the
|
||||
// write verbs, because the drift that existed when this was written was entirely on reads.
|
||||
[Test]
|
||||
public void Every_Tool_Should_Declare_Exactly_Its_OpenApi_Query_Parameters()
|
||||
{
|
||||
using JsonDocument spec = LoadOpenApiDocument();
|
||||
JsonElement paths = spec.RootElement.GetProperty("paths");
|
||||
|
||||
// Every tool is covered, so an emptiness guard is enough here — there is no filter to escape.
|
||||
ToolCatalog.All.Count.ShouldBeGreaterThan(30);
|
||||
|
||||
// Accumulate rather than throwing on the first mismatch, so one run reports the WHOLE drift set.
|
||||
// Failing fast here would hand back one tool at a time and invite fixing them one at a time,
|
||||
// which is how the #754 twin stayed hidden in the first place.
|
||||
List<string> drift = [];
|
||||
|
||||
foreach (ToolDefinition tool in ToolCatalog.All)
|
||||
{
|
||||
paths.TryGetProperty(tool.PathTemplate, out JsonElement pathItem)
|
||||
.ShouldBeTrue($"{tool.Name}: {tool.PathTemplate} is not in the OpenAPI document");
|
||||
|
||||
string verb = tool.HttpMethod.Method.ToLowerInvariant();
|
||||
pathItem.TryGetProperty(verb, out JsonElement operation)
|
||||
.ShouldBeTrue($"{tool.Name}: {verb.ToUpperInvariant()} {tool.PathTemplate} is not in the OpenAPI document");
|
||||
|
||||
IReadOnlySet<string> declared = tool.QueryParameters ?? new HashSet<string>(StringComparer.Ordinal);
|
||||
HashSet<string> accepted = QueryParameterNames(operation);
|
||||
|
||||
string[] missing = accepted.Except(declared, StringComparer.Ordinal).OrderBy(n => n, StringComparer.Ordinal).ToArray();
|
||||
string[] phantom = declared.Except(accepted, StringComparer.Ordinal).OrderBy(n => n, StringComparer.Ordinal).ToArray();
|
||||
|
||||
if (missing.Length > 0 || phantom.Length > 0)
|
||||
{
|
||||
drift.Add(
|
||||
$"{tool.Name} ({verb.ToUpperInvariant()} {tool.PathTemplate}): "
|
||||
+ $"unreachable={string.Join(",", missing)} phantom={string.Join(",", phantom)}");
|
||||
}
|
||||
}
|
||||
|
||||
// A parameter the endpoint accepts but the tool omits is UNREACHABLE, not merely undocumented:
|
||||
// ToolArgumentValidator rejects undeclared arguments, so the caller cannot pass it at all
|
||||
// (#616 hard-capped two paged tools exactly this way). A phantom is the reverse — the tool
|
||||
// advertises something the endpoint ignores.
|
||||
drift.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
private static HashSet<string> QueryParameterNames(JsonElement operation)
|
||||
{
|
||||
if (!operation.TryGetProperty("parameters", out JsonElement parameters))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return parameters.EnumerateArray()
|
||||
.Where(p => p.TryGetProperty("in", out JsonElement location)
|
||||
&& string.Equals(location.GetString(), "query", StringComparison.Ordinal))
|
||||
.Select(p => p.GetProperty("name").GetString())
|
||||
.OfType<string>()
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
// The body is every declared argument that is not routed elsewhere — mirroring exactly how
|
||||
// ErsatzTvApiClient builds the request, so this test cannot disagree with the code it guards.
|
||||
// DELETE is not compared: ErsatzTvApiClient sets hasBody for POST/PUT/PATCH only, so a body
|
||||
// argument on a DELETE tool would be silently dropped. No DELETE tool has one today.
|
||||
private static Dictionary<string, string> DeclaredBodyArguments(ToolDefinition tool)
|
||||
{
|
||||
var pathParameters = Regex.Matches(tool.PathTemplate, @"\{([^}]+)\}")
|
||||
.Select(m => m.Groups[1].Value)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
IReadOnlySet<string> queryParameters = tool.QueryParameters ?? new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
if (!tool.InputSchema.RootElement.TryGetProperty("properties", out JsonElement properties))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return properties.EnumerateObject()
|
||||
.Where(p => !pathParameters.Contains(p.Name)
|
||||
&& !queryParameters.Contains(p.Name)
|
||||
&& !string.Equals(p.Name, "ifMatch", StringComparison.Ordinal))
|
||||
.ToDictionary(p => p.Name, p => DeclaredType(p.Value), StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
// The tool schema's own shape: a plain "type", plus the array element type where there is one.
|
||||
private static string DeclaredType(JsonElement property)
|
||||
{
|
||||
string type = property.GetProperty("type").GetString().ShouldNotBeNull();
|
||||
|
||||
return type == "array" && property.TryGetProperty("items", out JsonElement items)
|
||||
? $"array<{items.GetProperty("type").GetString()}>"
|
||||
: type;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> RequestBodyProperties(JsonDocument spec, JsonElement operation, string toolName)
|
||||
{
|
||||
// No request body at all (queue/scan POSTs) — the tool must send none either.
|
||||
if (!operation.TryGetProperty("requestBody", out JsonElement requestBody))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
JsonElement schema = requestBody
|
||||
.GetProperty("content")
|
||||
.GetProperty("application/json")
|
||||
.GetProperty("schema");
|
||||
|
||||
// Every request body in this document is a plain $ref to a component schema. Anything else
|
||||
// (allOf/inline/oneOf) is a contract shape this guard has not been taught to read, so fail
|
||||
// loudly rather than comparing against an empty set and reporting a false pass.
|
||||
schema.TryGetProperty("$ref", out JsonElement reference)
|
||||
.ShouldBeTrue($"{toolName}: request body schema is not a $ref; teach this test the new shape");
|
||||
|
||||
JsonElement schemas = spec.RootElement.GetProperty("components").GetProperty("schemas");
|
||||
string componentName = reference.GetString().ShouldNotBeNull().Split('/')[^1];
|
||||
|
||||
return schemas
|
||||
.GetProperty(componentName)
|
||||
.GetProperty("properties")
|
||||
.EnumerateObject()
|
||||
.ToDictionary(p => p.Name, p => SpecType(schemas, p.Value, toolName, p.Name), StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
// Normalize the generator's shapes onto the catalog's vocabulary. Two forms appear in this
|
||||
// document: a nullable type as ["null", T] (the catalog has no nullable notion — optionality is
|
||||
// carried by `required`), and a $ref to a component, which for the enum fields is a string enum
|
||||
// and for `logo` is an object.
|
||||
private static string SpecType(JsonElement schemas, JsonElement property, string toolName, string fieldName)
|
||||
{
|
||||
if (property.TryGetProperty("$ref", out JsonElement reference))
|
||||
{
|
||||
string componentName = reference.GetString().ShouldNotBeNull().Split('/')[^1];
|
||||
return SpecType(schemas, schemas.GetProperty(componentName), toolName, fieldName);
|
||||
}
|
||||
|
||||
JsonElement type = property.GetProperty("type");
|
||||
|
||||
string[] types = type.ValueKind == JsonValueKind.Array
|
||||
? type.EnumerateArray().Select(t => t.GetString()).OfType<string>().Where(t => t != "null").ToArray()
|
||||
: [type.GetString().ShouldNotBeNull()];
|
||||
|
||||
// More than one non-null type is a shape this guard has not been taught to read; fail rather
|
||||
// than picking one and reporting a comparison that means nothing.
|
||||
types.Length.ShouldBe(1, $"{toolName}.{fieldName}: unexpected OpenAPI type union [{string.Join(", ", types)}]");
|
||||
|
||||
// The element schema is resolved through the same normalization: an array's items can itself be
|
||||
// a $ref to a component (ReplaceRemoteLibraryPreferencesRequest.libraries), which the catalog
|
||||
// declares as an object array.
|
||||
return types[0] == "array" && property.TryGetProperty("items", out JsonElement items)
|
||||
? $"array<{SpecType(schemas, items, toolName, fieldName)}>"
|
||||
: types[0];
|
||||
}
|
||||
|
||||
private static JsonDocument LoadOpenApiDocument()
|
||||
{
|
||||
string path = Path.Combine(AppContext.BaseDirectory, "openapi", "v1.json");
|
||||
|
||||
// A missing spec would make every assertion above vacuous, so it is an explicit failure.
|
||||
File.Exists(path).ShouldBeTrue(
|
||||
$"OpenAPI document not found at {path}; the test project links it from ErsatzTV/wwwroot/openapi/v1.json");
|
||||
|
||||
return JsonDocument.Parse(File.ReadAllText(path));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,32 +29,14 @@ public static class ToolCatalog
|
||||
Get("ersatztv_list_schedules", "List schedules.", "/api/v1/schedules"),
|
||||
Get("ersatztv_get_schedule", "Get a schedule by id.", "/api/v1/schedules/{id}", IdPath("Schedule id.")),
|
||||
Get("ersatztv_get_schedule_items", "Get a schedule's items. Emits the schedule ETag.", "/api/v1/schedules/{id}/items", IdPath("Schedule id.")),
|
||||
Get(
|
||||
"ersatztv_list_playouts",
|
||||
"List playouts (paged), optionally filtered by channel name.",
|
||||
"/api/v1/playouts",
|
||||
[],
|
||||
[
|
||||
Str(
|
||||
"query",
|
||||
"Optional case-insensitive substring match on the CHANNEL name (not the playout or schedule name); omit for all playouts.",
|
||||
arg: In.Query),
|
||||
.. Page()
|
||||
]),
|
||||
Get("ersatztv_list_playouts", "List playouts (paged).", "/api/v1/playouts", [], Page()),
|
||||
Get("ersatztv_get_playout", "Get a playout by id.", "/api/v1/playouts/{id}", IdPath("Playout id.")),
|
||||
Get(
|
||||
"ersatztv_get_playout_items",
|
||||
"Get upcoming items (and unscheduled gaps) for a playout (paged).",
|
||||
"/api/v1/playouts/{id}/items",
|
||||
[IdPath("Playout id.")],
|
||||
[
|
||||
Bool(
|
||||
"showFiller",
|
||||
"Include items whose filler kind is not None (pre/mid/post-roll, tail, fallback, guide-mode, deco); "
|
||||
+ "default false returns only non-filler items.",
|
||||
arg: In.Query),
|
||||
.. Page()
|
||||
]),
|
||||
Page()),
|
||||
Get("ersatztv_list_ffmpeg_profiles", "List FFmpeg profiles.", "/api/v1/ffmpeg/profiles"),
|
||||
Get("ersatztv_get_ffmpeg_profile", "Get an FFmpeg profile by id.", "/api/v1/ffmpeg/profiles/{id}", IdPath("FFmpeg profile id.")),
|
||||
Get(
|
||||
@@ -150,8 +132,7 @@ public static class ToolCatalog
|
||||
[Str("name", "Schedule name.", required: true), .. ScheduleFlags()]),
|
||||
Put(
|
||||
"ersatztv_update_schedule",
|
||||
"Update a program schedule. Send the full desired state: every field is applied, so omitting "
|
||||
+ "padToNearestMinute CLEARS a configured pad (GET the schedule first to copy current values).",
|
||||
"Update a program schedule's settings.",
|
||||
"/api/v1/schedules/{id}",
|
||||
[IdPath("Schedule id."), Str("name", "Schedule name.", required: true), .. ScheduleFlags()]),
|
||||
Delete("ersatztv_delete_schedule", "Delete a program schedule.", "/api/v1/schedules/{id}", IdPath("Schedule id.")),
|
||||
@@ -178,22 +159,9 @@ public static class ToolCatalog
|
||||
ChannelFields()),
|
||||
Put(
|
||||
"ersatztv_update_channel",
|
||||
"Update a channel. Send the full desired state; enum fields take the enum name (GET the channel first to copy current values). "
|
||||
+ "graphicsElementIds is part of that state: omitting it DETACHES every graphics element (e.g. the On Now/Next overlay), "
|
||||
+ "so copy it from ersatztv_get_channel unless you mean to clear it.",
|
||||
"Update a channel. Send the full desired state; enum fields take the enum name (GET the channel first to copy current values).",
|
||||
"/api/v1/channels/{id}",
|
||||
[
|
||||
IdPath("Channel id."),
|
||||
.. ChannelFields(),
|
||||
|
||||
// Update-only: UpdateChannelRequest carries GraphicsElementIds, CreateChannelRequest does
|
||||
// not, so this cannot move into the shared ChannelFields() without making create send an
|
||||
// unknown property. PUT is a full replace, so omitting it detaches every attached element
|
||||
// with no error — issue #754.
|
||||
IntArray(
|
||||
"graphicsElementIds",
|
||||
"Ids of the graphics elements attached to the channel. Full replace: omit or send [] to detach all.")
|
||||
]),
|
||||
[IdPath("Channel id."), .. ChannelFields()]),
|
||||
Post(
|
||||
"ersatztv_reset_channel_playout",
|
||||
"Queue a rebuild of a channel's playout (202 Accepted; 409 if a build is already running).",
|
||||
@@ -329,12 +297,7 @@ public static class ToolCatalog
|
||||
Bool("treatCollectionsAsShows", "Treat collections as shows."),
|
||||
Bool("shuffleScheduleItems", "Shuffle schedule items."),
|
||||
Bool("randomStartPoint", "Use a random start point."),
|
||||
Str("fixedStartTimeBehavior", "Fixed start-time behavior (enum name; GET a schedule to see valid values)."),
|
||||
|
||||
// Both Create- and UpdateScheduleRequest carry this, so it belongs in the shared helper. The
|
||||
// update PUT is a full replace that writes the value unconditionally, so omitting it used to
|
||||
// clear a configured pad silently — the same #754 shape as channel graphicsElementIds.
|
||||
Int("padToNearestMinute", "Pad each item to the nearest N minutes; omit or send null for no padding.")
|
||||
Str("fixedStartTimeBehavior", "Fixed start-time behavior (enum name; GET a schedule to see valid values).")
|
||||
];
|
||||
|
||||
// ---- Tool factories ----
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Graphics;
|
||||
using ErsatzTV.FFmpeg.State;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// #732: the On Now / Next overlay is a default rather than an opt-in, so a channel created after
|
||||
/// that decision gets the built-in element without the operator toggling anything.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class CreateChannelDefaultGraphicsElementTests : ChannelHandlerTestBase
|
||||
{
|
||||
private CreateChannelHandler MakeHandler() => new(Worker, Db.Factory, SearchTargets, RemoteLogoCacher);
|
||||
|
||||
private async Task<int> SeedBuiltInElement()
|
||||
{
|
||||
await using TvContext context = Db.CreateContext();
|
||||
var element = new GraphicsElement
|
||||
{
|
||||
Path = GraphicsElementDefaults.OnNowNextSeededPath,
|
||||
Kind = GraphicsElementKind.Text
|
||||
};
|
||||
|
||||
context.GraphicsElements.Add(element);
|
||||
await context.SaveChangesAsync();
|
||||
return element.Id;
|
||||
}
|
||||
|
||||
private async Task<List<int>> AttachedElementIds(int channelId)
|
||||
{
|
||||
await using TvContext context = Db.CreateContext();
|
||||
Channel reloaded = await context.Channels
|
||||
.Include(c => c.ChannelGraphicsElements)
|
||||
.SingleAsync(c => c.Id == channelId);
|
||||
|
||||
return reloaded.ChannelGraphicsElements.Select(x => x.GraphicsElementId).ToList();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Attaches_The_Built_In_Element_To_A_New_Channel()
|
||||
{
|
||||
await SeedFFmpegProfile();
|
||||
int elementId = await SeedBuiltInElement();
|
||||
|
||||
Either<BaseError, CreateChannelResult> result =
|
||||
await MakeHandler().Handle(MakeCreate(), CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
int channelId = result.RightToSeq().Head().ChannelId;
|
||||
|
||||
(await AttachedElementIds(channelId)).ShouldBe([elementId]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Leaves_An_Hls_Direct_Channel_Alone_Because_Nothing_Can_Render_There()
|
||||
{
|
||||
await SeedFFmpegProfile();
|
||||
await SeedBuiltInElement();
|
||||
|
||||
Either<BaseError, CreateChannelResult> result = await MakeHandler().Handle(
|
||||
MakeCreate(streamingMode: StreamingMode.HttpLiveStreamingDirect),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
int channelId = result.RightToSeq().Head().ChannelId;
|
||||
|
||||
(await AttachedElementIds(channelId)).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Creates_The_Channel_Even_When_The_Built_In_Element_Does_Not_Exist()
|
||||
{
|
||||
await SeedFFmpegProfile();
|
||||
|
||||
Either<BaseError, CreateChannelResult> result =
|
||||
await MakeHandler().Handle(MakeCreate(), CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
int channelId = result.RightToSeq().Head().ChannelId;
|
||||
|
||||
(await AttachedElementIds(channelId)).ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,9 @@ using ErsatzTV.Core.Api.LibraryBrowse;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Graphics;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.FFmpeg.State;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
@@ -464,7 +462,7 @@ public class CreateChannelFromLineupHandlerTests
|
||||
[Test]
|
||||
public async Task Should_Reject_WeightedShuffle_For_A_Multi_Item_Lineup()
|
||||
{
|
||||
// regression (#70, PR #402): a 2+ entry lineup is persisted as a
|
||||
// regression (#70, found by adversarial review of PR #402): a 2+ entry lineup is persisted as a
|
||||
// Playlist, and PlaylistEnumerator has no default arm -- an order it doesn't know leaves the
|
||||
// enumerator null and the items vanish from the playlist with nothing reported. This handler is the
|
||||
// THIRD writer of PlaylistItem.PlaybackOrder and was missed when the other two were gated.
|
||||
@@ -966,66 +964,4 @@ public class CreateChannelFromLineupHandlerTests
|
||||
|
||||
private static TR RightOf<TR>(Either<BaseError, TR> either) =>
|
||||
either.Match(Left: e => throw new AssertionException($"Expected a Right result, got {e.Value}"), Right: r => r);
|
||||
|
||||
// #732: this is the SPA's primary "Add Channel" flow and the one Auto-Tune bulk-creates through.
|
||||
// It was the channel-creation site the default attach originally missed, so a channel made here
|
||||
// would silently never get the overlay once the one-time backfill marker had landed.
|
||||
[Test]
|
||||
public async Task Should_Attach_The_Built_In_On_Now_Next_Element()
|
||||
{
|
||||
await SeedTemplateDependencies();
|
||||
await SeedTemplate();
|
||||
await SeedMovie(42);
|
||||
int elementId = await SeedBuiltInGraphicsElement();
|
||||
|
||||
Either<BaseError, CreateChannelFromLineupResponseModel> result =
|
||||
await MakeHandler().Handle(MakeRequest(), CancellationToken.None);
|
||||
|
||||
CreateChannelFromLineupResponseModel response = RightOf(result);
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
DomainChannel channel = await context.Channels
|
||||
.Include(c => c.ChannelGraphicsElements)
|
||||
.SingleAsync(c => c.Id == response.ChannelId);
|
||||
|
||||
channel.ChannelGraphicsElements.Select(x => x.GraphicsElementId).ShouldBe([elementId]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Not_Attach_The_Overlay_To_An_Hls_Direct_Channel()
|
||||
{
|
||||
await SeedTemplateDependencies();
|
||||
await SeedTemplate();
|
||||
await SeedMovie(42);
|
||||
await SeedBuiltInGraphicsElement();
|
||||
|
||||
Either<BaseError, CreateChannelFromLineupResponseModel> result = await MakeHandler().Handle(
|
||||
MakeRequest(advanced: new CreateChannelFromLineupAdvancedOptions(
|
||||
PlaybackOrder.Shuffle,
|
||||
StreamingMode: StreamingMode.HttpLiveStreamingDirect)),
|
||||
CancellationToken.None);
|
||||
|
||||
CreateChannelFromLineupResponseModel response = RightOf(result);
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
DomainChannel channel = await context.Channels
|
||||
.Include(c => c.ChannelGraphicsElements)
|
||||
.SingleAsync(c => c.Id == response.ChannelId);
|
||||
|
||||
channel.ChannelGraphicsElements.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
private async Task<int> SeedBuiltInGraphicsElement()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
var element = new GraphicsElement
|
||||
{
|
||||
Path = GraphicsElementDefaults.OnNowNextSeededPath,
|
||||
Kind = GraphicsElementKind.Text
|
||||
};
|
||||
|
||||
context.GraphicsElements.Add(element);
|
||||
await context.SaveChangesAsync();
|
||||
return element.Id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
using System.Globalization;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
@@ -18,9 +15,6 @@ public class UpdateChannelGraphicsElementsTests : ChannelHandlerTestBase
|
||||
{
|
||||
private UpdateChannelHandler MakeHandler() => new(Worker, Db.Factory, SearchTargets, RemoteLogoCacher);
|
||||
|
||||
private static BaseError LeftOf(Either<BaseError, ChannelViewModel> either) =>
|
||||
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
|
||||
|
||||
private async Task<(int ElementAId, int ElementBId)> SeedGraphicsElements()
|
||||
{
|
||||
await using TvContext context = Db.CreateContext();
|
||||
@@ -31,37 +25,6 @@ public class UpdateChannelGraphicsElementsTests : ChannelHandlerTestBase
|
||||
return (elementA.Id, elementB.Id);
|
||||
}
|
||||
|
||||
private async Task<int> SeedWatermark()
|
||||
{
|
||||
await using TvContext context = Db.CreateContext();
|
||||
var watermark = new ChannelWatermark { Name = "W" };
|
||||
context.ChannelWatermarks.Add(watermark);
|
||||
await context.SaveChangesAsync();
|
||||
return watermark.Id;
|
||||
}
|
||||
|
||||
private async Task<List<int>> SeedGraphicsElements(int count)
|
||||
{
|
||||
await using TvContext context = Db.CreateContext();
|
||||
List<GraphicsElement> elements = Enumerable.Range(0, count)
|
||||
.Select(i => new GraphicsElement { Path = $"element-{i}.yml" })
|
||||
.ToList();
|
||||
context.GraphicsElements.AddRange(elements);
|
||||
await context.SaveChangesAsync();
|
||||
return elements.Select(e => e.Id).ToList();
|
||||
}
|
||||
|
||||
// Replaces the harness the base class built with one whose SaveChangesAsync can be made to fail
|
||||
// on demand. Foreign keys are off in InMemoryTvContext, so the FK violation a concurrent delete
|
||||
// really produces cannot be raised by seeding alone.
|
||||
private async Task<ArmedSaveFailureInterceptor> UseFailingSaveHarness()
|
||||
{
|
||||
await Db.DisposeAsync();
|
||||
var interceptor = new ArmedSaveFailureInterceptor();
|
||||
Db = await InMemoryTvContext.CreateAsync(interceptor);
|
||||
return interceptor;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Reconcile_GraphicsElement_Join_Add_Then_Remove()
|
||||
{
|
||||
@@ -110,199 +73,4 @@ public class UpdateChannelGraphicsElementsTests : ChannelHandlerTestBase
|
||||
reloaded.ChannelGraphicsElements.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
// #568: an unknown graphicsElementIds entry used to reach ApplyUpdateRequest unchecked, which
|
||||
// blindly Adds a ChannelGraphicsElement and lets SaveChangesAsync hit
|
||||
// FK_ChannelGraphicsElement_GraphicsElement_GraphicsElementId -> unhandled 500. Removing the
|
||||
// GraphicsElementIdsMustExist validator alone from UpdateChannelHandler.Validate is row 35 of the
|
||||
// mutation table in docs/graphics-elements.md, measured against the whole ErsatzTV.Tests project.
|
||||
[Test]
|
||||
public async Task Should_Reject_Unknown_GraphicsElementId_With_422_Not_500()
|
||||
{
|
||||
await SeedFFmpegProfile();
|
||||
Channel channel = await SeedChannel(1, "5");
|
||||
|
||||
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
|
||||
MakeUpdate(channel.Id, number: "5", graphicsElementIds: [999]),
|
||||
CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.ShouldNotBeOfType<NotFoundError>();
|
||||
error.Value.ShouldContain("[GraphicsElementIds]");
|
||||
error.Value.ShouldContain("999");
|
||||
|
||||
// no partial write: the channel keeps no graphics element association
|
||||
await using TvContext context = Db.CreateContext();
|
||||
Channel reloaded = await context.Channels.Include(c => c.ChannelGraphicsElements)
|
||||
.SingleAsync(c => c.Id == channel.Id);
|
||||
reloaded.ChannelGraphicsElements.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Reject_When_One_Of_Several_GraphicsElementIds_Is_Unknown()
|
||||
{
|
||||
await SeedFFmpegProfile();
|
||||
Channel channel = await SeedChannel(1, "5");
|
||||
(int elementAId, _) = await SeedGraphicsElements();
|
||||
|
||||
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
|
||||
MakeUpdate(channel.Id, number: "5", graphicsElementIds: [elementAId, 12345]),
|
||||
CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.ShouldNotBeOfType<NotFoundError>();
|
||||
error.Value.ShouldContain("[GraphicsElementIds]");
|
||||
error.Value.ShouldContain("12345");
|
||||
}
|
||||
|
||||
// #568: the id list is client-supplied and was bounded only by the Kestrel body cap, which is a
|
||||
// transport limit and not a collection limit. The cap is Validators.MaximumIdListCount, shared
|
||||
// by all three id-list validators; these three tests pin its two edges and the shape that makes
|
||||
// its placement matter. Removing the cap from Validators.IdsMustExist is row 41 of the mutation
|
||||
// table in docs/graphics-elements.md.
|
||||
[Test]
|
||||
public async Task Should_Accept_Exactly_The_Maximum_Number_Of_GraphicsElementIds()
|
||||
{
|
||||
await SeedFFmpegProfile();
|
||||
Channel channel = await SeedChannel(1, "5");
|
||||
List<int> ids = await SeedGraphicsElements(Validators.MaximumIdListCount);
|
||||
|
||||
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
|
||||
MakeUpdate(channel.Id, number: "5", graphicsElementIds: ids),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
Channel reloaded = await context.Channels.Include(c => c.ChannelGraphicsElements)
|
||||
.SingleAsync(c => c.Id == channel.Id);
|
||||
reloaded.ChannelGraphicsElements.Count.ShouldBe(Validators.MaximumIdListCount);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Reject_One_More_Than_The_Maximum_Number_Of_GraphicsElementIds()
|
||||
{
|
||||
await SeedFFmpegProfile();
|
||||
Channel channel = await SeedChannel(1, "5");
|
||||
List<int> ids = Enumerable.Range(1, Validators.MaximumIdListCount + 1).ToList();
|
||||
|
||||
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
|
||||
MakeUpdate(channel.Id, number: "5", graphicsElementIds: ids),
|
||||
CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.Value.ShouldContain("[GraphicsElementIds]");
|
||||
error.Value.ShouldContain((Validators.MaximumIdListCount + 1).ToString(CultureInfo.InvariantCulture));
|
||||
error.Value.ShouldContain(Validators.MaximumIdListCount.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
// The cap counts the RAW list, before Distinct: every one of these ids exists and they collapse
|
||||
// to a single distinct id, so a cap applied after deduplication would accept this request and
|
||||
// leave the parse and materialization it costs unbounded.
|
||||
[Test]
|
||||
public async Task Should_Reject_A_Duplicate_Heavy_List_On_Its_Raw_Count()
|
||||
{
|
||||
await SeedFFmpegProfile();
|
||||
Channel channel = await SeedChannel(1, "5");
|
||||
(int elementAId, _) = await SeedGraphicsElements();
|
||||
List<int> ids = Enumerable.Repeat(elementAId, Validators.MaximumIdListCount + 1).ToList();
|
||||
|
||||
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
|
||||
MakeUpdate(channel.Id, number: "5", graphicsElementIds: ids),
|
||||
CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.Value.ShouldContain("[GraphicsElementIds]");
|
||||
error.Value.ShouldContain((Validators.MaximumIdListCount + 1).ToString(CultureInfo.InvariantCulture));
|
||||
error.Value.ShouldNotContain("do not exist");
|
||||
}
|
||||
|
||||
// A 422 that echoes every rejected id turns an oversized request into an oversized response.
|
||||
// Removing the truncation from Validators.DescribeIds is row 42 of the mutation table.
|
||||
[Test]
|
||||
public async Task Should_Cap_The_Ids_Echoed_Back_In_The_Unknown_Id_422()
|
||||
{
|
||||
await SeedFFmpegProfile();
|
||||
Channel channel = await SeedChannel(1, "5");
|
||||
List<int> ids = Enumerable.Range(1001, 30).ToList();
|
||||
|
||||
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
|
||||
MakeUpdate(channel.Id, number: "5", graphicsElementIds: ids),
|
||||
CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.Value.ShouldContain("1001");
|
||||
error.Value.ShouldContain("(and 20 more)");
|
||||
error.Value.ShouldNotContain("1030");
|
||||
}
|
||||
|
||||
// #568: validation and the write are two statements, so RefreshGraphicsElements can delete a
|
||||
// validated element in between and hand the join insert the FK violation the validator exists to
|
||||
// prevent -- the unhandled 500 again. Removing the DbUpdateException catch from
|
||||
// ApplyUpdateRequestTranslatingLostRace is row 43 of the mutation table.
|
||||
[Test]
|
||||
public async Task Should_Translate_An_Element_Deleted_Between_Validation_And_Save_Into_The_Same_422()
|
||||
{
|
||||
ArmedSaveFailureInterceptor interceptor = await UseFailingSaveHarness();
|
||||
await SeedFFmpegProfile();
|
||||
Channel channel = await SeedChannel(1, "5");
|
||||
(int elementAId, _) = await SeedGraphicsElements();
|
||||
|
||||
interceptor.SqlBeforeFailing =
|
||||
$"DELETE FROM GraphicsElement WHERE Id = {elementAId.ToString(CultureInfo.InvariantCulture)}";
|
||||
interceptor.Armed = true;
|
||||
|
||||
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
|
||||
MakeUpdate(channel.Id, number: "5", graphicsElementIds: [elementAId]),
|
||||
CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.Value.ShouldContain("[GraphicsElementIds]");
|
||||
error.Value.ShouldContain(elementAId.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
// graphicsElementIds is not the only FK this DTO writes, and the recovery path re-asks the whole
|
||||
// of Validate rather than the graphics-element half precisely so the other FKs are covered:
|
||||
// WatermarkId is written by the same SaveChangesAsync and loses the same race. Reddens if the
|
||||
// recheck is narrowed back to GraphicsElementIdsMustExist -- row 45 of the mutation table in
|
||||
// docs/graphics-elements.md.
|
||||
[Test]
|
||||
public async Task Should_Translate_A_Watermark_Deleted_Between_Validation_And_Save_Into_The_Same_422()
|
||||
{
|
||||
ArmedSaveFailureInterceptor interceptor = await UseFailingSaveHarness();
|
||||
await SeedFFmpegProfile();
|
||||
Channel channel = await SeedChannel(1, "5");
|
||||
int watermarkId = await SeedWatermark();
|
||||
|
||||
interceptor.SqlBeforeFailing =
|
||||
$"DELETE FROM ChannelWatermark WHERE Id = {watermarkId.ToString(CultureInfo.InvariantCulture)}";
|
||||
interceptor.Armed = true;
|
||||
|
||||
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
|
||||
MakeUpdate(channel.Id, number: "5", watermarkId: watermarkId),
|
||||
CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.Value.ShouldContain("Watermark");
|
||||
error.Value.ShouldContain(watermarkId.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
// The other half of that catch: a DbUpdateException whose cause is NOT a missing graphics
|
||||
// element is a real fault and must keep its own exception rather than be reported to the client
|
||||
// as a validation error about ids that are all still present.
|
||||
[Test]
|
||||
public async Task Should_Not_Report_An_Unrelated_DbUpdateException_As_A_Graphics_Element_422()
|
||||
{
|
||||
ArmedSaveFailureInterceptor interceptor = await UseFailingSaveHarness();
|
||||
await SeedFFmpegProfile();
|
||||
Channel channel = await SeedChannel(1, "5");
|
||||
(int elementAId, _) = await SeedGraphicsElements();
|
||||
|
||||
interceptor.Armed = true;
|
||||
|
||||
await Should.ThrowAsync<DbUpdateException>(
|
||||
async () => await MakeHandler().Handle(
|
||||
MakeUpdate(channel.Id, number: "5", graphicsElementIds: [elementAId]),
|
||||
CancellationToken.None));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,6 @@ using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.FFmpeg;
|
||||
using ErsatzTV.FFmpeg.OutputFormat;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
@@ -137,30 +134,15 @@ public class FFmpegProfileHandlerTests
|
||||
persisted.QsvPreferNativeDecoder.ShouldBe(false);
|
||||
}
|
||||
|
||||
// ersatztv#735: the write path used to accept an out-of-range pool size and store the floored
|
||||
// value instead, so a client that PUT 0 got a 200 and read back 64. it is now rejected, naming
|
||||
// the bound; FFmpegState still floors at render time for rows that predate this.
|
||||
[TestCase(0)]
|
||||
[TestCase(-8)]
|
||||
[TestCase(63)]
|
||||
public async Task Create_Should_Reject_QsvExtraHardwareFrames_Below_Minimum(int configured)
|
||||
{
|
||||
await SeedResolution(1);
|
||||
var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets);
|
||||
|
||||
Either<BaseError, CreateFFmpegProfileResult> result = await handler.Handle(
|
||||
MakeCreate(1, qsvExtraHardwareFrames: configured),
|
||||
CancellationToken.None);
|
||||
|
||||
LeftOf(result).Value.ShouldContain("at least 64");
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
(await context.FFmpegProfiles.CountAsync()).ShouldBe(0);
|
||||
}
|
||||
|
||||
[TestCase(64)]
|
||||
[TestCase(128)]
|
||||
public async Task Create_Should_Store_QsvExtraHardwareFrames_Exactly_As_Submitted(int configured)
|
||||
// ersatztv#529: a stored 0 reached ffmpeg as hwupload=extra_hw_frames=0, leaving the QSV pool no
|
||||
// headroom; FFmpegState floors it at render time, and these pin that the stored row converges too
|
||||
// so the profile never keeps displaying a value the pipeline would override.
|
||||
[TestCase(0, 64)]
|
||||
[TestCase(-8, 64)]
|
||||
[TestCase(63, 64)]
|
||||
[TestCase(64, 64)]
|
||||
[TestCase(128, 128)]
|
||||
public async Task Create_Should_Floor_QsvExtraHardwareFrames(int configured, int expected)
|
||||
{
|
||||
await SeedResolution(1);
|
||||
var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets);
|
||||
@@ -173,15 +155,15 @@ public class FFmpegProfileHandlerTests
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(created.FFmpegProfileId);
|
||||
persisted.QsvExtraHardwareFrames.ShouldBe(configured);
|
||||
persisted.QsvExtraHardwareFrames.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[TestCase(0)]
|
||||
[TestCase(-8)]
|
||||
[TestCase(63)]
|
||||
public async Task Update_Should_Reject_A_Newly_Submitted_QsvExtraHardwareFrames_Below_Minimum(int configured)
|
||||
[TestCase(0, 64)]
|
||||
[TestCase(-8, 64)]
|
||||
[TestCase(128, 128)]
|
||||
public async Task Update_Should_Floor_QsvExtraHardwareFrames(int configured, int expected)
|
||||
{
|
||||
await SeedProfile(1, qsvExtraHardwareFrames: 128);
|
||||
await SeedProfile(1);
|
||||
await SeedResolution(1);
|
||||
var handler = new UpdateFFmpegProfileHandler(_db.Factory, _searchTargets);
|
||||
|
||||
@@ -189,59 +171,11 @@ public class FFmpegProfileHandlerTests
|
||||
MakeUpdate(1, qsvExtraHardwareFrames: configured),
|
||||
CancellationToken.None);
|
||||
|
||||
LeftOf(result).Value.ShouldContain("at least 64");
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(1);
|
||||
persisted.QsvExtraHardwareFrames.ShouldBe(128);
|
||||
}
|
||||
|
||||
// the other half of the same rule: the SPA sends the whole profile back on every edit, so a row
|
||||
// stored before this validation existed must stay editable over fields the operator did touch.
|
||||
// an UNCHANGED out-of-range value is written back as-is and floored at render time instead
|
||||
[Test]
|
||||
public async Task Update_Should_Accept_An_Unchanged_Legacy_QsvExtraHardwareFrames()
|
||||
{
|
||||
await SeedProfile(1, qsvExtraHardwareFrames: 0);
|
||||
await SeedResolution(1);
|
||||
var handler = new UpdateFFmpegProfileHandler(_db.Factory, _searchTargets);
|
||||
|
||||
Either<BaseError, UpdateFFmpegProfileResult> result = await handler.Handle(
|
||||
MakeUpdate(1, qsvExtraHardwareFrames: 0),
|
||||
CancellationToken.None);
|
||||
|
||||
RightOf(result);
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(1);
|
||||
persisted.QsvExtraHardwareFrames.ShouldBe(0);
|
||||
new FFmpegState(
|
||||
false,
|
||||
HardwareAccelerationMode.None,
|
||||
HardwareAccelerationMode.None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
OutputFormatKind.MpegTs,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
TimeSpan.Zero,
|
||||
None,
|
||||
Optional(persisted.QsvExtraHardwareFrames),
|
||||
false,
|
||||
false,
|
||||
"linear",
|
||||
false)
|
||||
.QsvExtraHardwareFrames.ShouldBe(FFmpegState.MinimumQsvExtraHardwareFrames);
|
||||
persisted.QsvExtraHardwareFrames.ShouldBe(expected);
|
||||
}
|
||||
|
||||
// null means "unconfigured" and FFmpegState already resolves it to the same 64; it must stay
|
||||
@@ -262,133 +196,6 @@ public class FFmpegProfileHandlerTests
|
||||
persisted.QsvExtraHardwareFrames.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ersatztv#735: readrate pacing is an operator-tunable bounded field. out of band it is a dead
|
||||
// channel either way — below realtime the client starves, above the ceiling the input is no
|
||||
// longer meaningfully paced (which is the unthrottled read #529 measured to write no segments)
|
||||
[TestCase(0.9)]
|
||||
[TestCase(0.0)]
|
||||
[TestCase(2.5)]
|
||||
public async Task Create_Should_Reject_ReadRate_Outside_Bounds(double configured)
|
||||
{
|
||||
await SeedResolution(1);
|
||||
var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets);
|
||||
|
||||
Either<BaseError, CreateFFmpegProfileResult> result = await handler.Handle(
|
||||
MakeCreate(1, readRate: configured),
|
||||
CancellationToken.None);
|
||||
|
||||
LeftOf(result).Value.ShouldContain("Read rate must be between 1.0 and 2.0");
|
||||
}
|
||||
|
||||
[TestCase(0.9)]
|
||||
[TestCase(10.5)]
|
||||
public async Task Create_Should_Reject_ReadRateCatchup_Outside_Bounds(double configured)
|
||||
{
|
||||
await SeedResolution(1);
|
||||
var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets);
|
||||
|
||||
Either<BaseError, CreateFFmpegProfileResult> result = await handler.Handle(
|
||||
MakeCreate(1, readRateCatchup: configured),
|
||||
CancellationToken.None);
|
||||
|
||||
LeftOf(result).Value.ShouldContain("Read rate catchup must be between 1.0 and 10.0");
|
||||
}
|
||||
|
||||
// a catchup rate inside its own band can still be at or below the base rate, where it cannot
|
||||
// let a lagging input recover — the cross-field bound is the one a per-field check cannot see.
|
||||
// the EQUAL cases matter: zero headroom is functionally no catchup, while still reading as a
|
||||
// configured one
|
||||
[TestCase(null, 1.0)]
|
||||
[TestCase(null, 1.05)]
|
||||
[TestCase(1.5, 1.2)]
|
||||
[TestCase(1.5, 1.5)]
|
||||
[TestCase(2.0, 2.0)]
|
||||
public async Task Create_Should_Reject_ReadRateCatchup_At_Or_Below_The_ReadRate(double? readRate, double catchup)
|
||||
{
|
||||
await SeedResolution(1);
|
||||
var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets);
|
||||
|
||||
Either<BaseError, CreateFFmpegProfileResult> result = await handler.Handle(
|
||||
MakeCreate(1, readRate: readRate, readRateCatchup: catchup),
|
||||
CancellationToken.None);
|
||||
|
||||
LeftOf(result).Value.ShouldContain("must be greater than the read rate");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Persist_ReadRate_Pacing()
|
||||
{
|
||||
await SeedResolution(1);
|
||||
var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets);
|
||||
|
||||
Either<BaseError, CreateFFmpegProfileResult> result = await handler.Handle(
|
||||
MakeCreate(1, readRate: 1.2, readRateCatchup: 4.0),
|
||||
CancellationToken.None);
|
||||
|
||||
CreateFFmpegProfileResult created = RightOf(result);
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(created.FFmpegProfileId);
|
||||
persisted.ReadRate.ShouldBe(1.2);
|
||||
persisted.ReadRateCatchup.ShouldBe(4.0);
|
||||
}
|
||||
|
||||
// unset is the default posture and must stay null: FFmpegState resolves null to the values the
|
||||
// pipeline used before the fields existed, so an untouched profile paces exactly as it did
|
||||
[Test]
|
||||
public async Task Create_Should_Leave_Unset_ReadRate_Pacing_Null()
|
||||
{
|
||||
await SeedResolution(1);
|
||||
var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets);
|
||||
|
||||
Either<BaseError, CreateFFmpegProfileResult> result =
|
||||
await handler.Handle(MakeCreate(1), CancellationToken.None);
|
||||
|
||||
CreateFFmpegProfileResult created = RightOf(result);
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(created.FFmpegProfileId);
|
||||
persisted.ReadRate.ShouldBeNull();
|
||||
persisted.ReadRateCatchup.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Reject_ReadRate_Outside_Bounds()
|
||||
{
|
||||
await SeedProfile(1);
|
||||
await SeedResolution(1);
|
||||
var handler = new UpdateFFmpegProfileHandler(_db.Factory, _searchTargets);
|
||||
|
||||
Either<BaseError, UpdateFFmpegProfileResult> result = await handler.Handle(
|
||||
MakeUpdate(1, readRate: 3.0),
|
||||
CancellationToken.None);
|
||||
|
||||
LeftOf(result).Value.ShouldContain("Read rate must be between 1.0 and 2.0");
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(1);
|
||||
persisted.ReadRate.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Persist_ReadRate_Pacing()
|
||||
{
|
||||
await SeedProfile(1);
|
||||
await SeedResolution(1);
|
||||
var handler = new UpdateFFmpegProfileHandler(_db.Factory, _searchTargets);
|
||||
|
||||
Either<BaseError, UpdateFFmpegProfileResult> result = await handler.Handle(
|
||||
MakeUpdate(1, readRate: 1.5, readRateCatchup: 8.0),
|
||||
CancellationToken.None);
|
||||
|
||||
RightOf(result);
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(1);
|
||||
persisted.ReadRate.ShouldBe(1.5);
|
||||
persisted.ReadRateCatchup.ShouldBe(8.0);
|
||||
}
|
||||
|
||||
private static TR RightOf<TR>(Either<BaseError, TR> either) =>
|
||||
either.Match(Left: e => throw new AssertionException($"Expected a Right result, got {e}"), Right: r => r);
|
||||
|
||||
@@ -402,13 +209,12 @@ public class FFmpegProfileHandlerTests
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task SeedProfile(int id, int? qsvExtraHardwareFrames = null)
|
||||
private async Task SeedProfile(int id)
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
context.FFmpegProfiles.Add(new FFmpegProfile
|
||||
{
|
||||
Id = id,
|
||||
QsvExtraHardwareFrames = qsvExtraHardwareFrames,
|
||||
Name = "Default",
|
||||
ThreadCount = 1,
|
||||
NormalizeAudio = true,
|
||||
@@ -443,9 +249,7 @@ public class FFmpegProfileHandlerTests
|
||||
private static CreateFFmpegProfile MakeCreate(
|
||||
int resolutionId,
|
||||
bool qsvPreferNativeDecoder = true,
|
||||
int? qsvExtraHardwareFrames = null,
|
||||
double? readRate = null,
|
||||
double? readRateCatchup = null) =>
|
||||
int? qsvExtraHardwareFrames = null) =>
|
||||
new(
|
||||
"Default",
|
||||
1,
|
||||
@@ -477,17 +281,13 @@ public class FFmpegProfileHandlerTests
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
qsvPreferNativeDecoder,
|
||||
readRate,
|
||||
readRateCatchup);
|
||||
qsvPreferNativeDecoder);
|
||||
|
||||
private static UpdateFFmpegProfile MakeUpdate(
|
||||
int id,
|
||||
int resolutionId = 1,
|
||||
bool qsvPreferNativeDecoder = true,
|
||||
int? qsvExtraHardwareFrames = null,
|
||||
double? readRate = null,
|
||||
double? readRateCatchup = null) =>
|
||||
int? qsvExtraHardwareFrames = null) =>
|
||||
new(
|
||||
id,
|
||||
"Default",
|
||||
@@ -520,7 +320,5 @@ public class FFmpegProfileHandlerTests
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
qsvPreferNativeDecoder,
|
||||
readRate,
|
||||
readRateCatchup);
|
||||
qsvPreferNativeDecoder);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
using ErsatzTV.Application.Graphics;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Graphics;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Graphics;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using NUnit.Framework;
|
||||
@@ -64,97 +62,6 @@ public class GraphicsElementHandlerTests
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// #568: the discriminator used to be Path.GetFileName(e.Path) == OnNowNextFileName, which is
|
||||
// folder-agnostic -- a user element named exactly "on-now-next.yml" outside the seeded text
|
||||
// template folder would also report builtIn:true. The row carries the SAME Kind as the real
|
||||
// seeded element, deliberately: a wrong-kind row here would be rejected by the Kind conjunct
|
||||
// whatever the path comparison is, so the composite revert (filename AND Kind == Text) would
|
||||
// pass. Only the PATH half can reject a Text row in another folder, which is what this pins --
|
||||
// it mirrors the seeder-site Ignores_A_Same_Named_Same_Kind_Element_Outside_The_Seeded_Folder.
|
||||
[Test]
|
||||
public async Task GetAllGraphicsElementsForApi_Should_Not_Mark_Same_Filename_Outside_Seeded_Folder_As_BuiltIn()
|
||||
{
|
||||
string userElementPath = System.IO.Path.Combine(
|
||||
"/config/graphics-elements/text/some-subfolder",
|
||||
GraphicsElementDefaults.OnNowNextFileName);
|
||||
await SeedElement(1, userElementPath, GraphicsElementKind.Text, string.Empty);
|
||||
|
||||
var handler = new GetAllGraphicsElementsForApiHandler(_db.Factory);
|
||||
|
||||
List<GraphicsElementResponseModel> result =
|
||||
await handler.Handle(new GetAllGraphicsElementsForApi(), CancellationToken.None);
|
||||
|
||||
result.Count.ShouldBe(1);
|
||||
result[0].BuiltIn.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAllGraphicsElementsForApi_Should_Mark_The_Seeded_Path_As_BuiltIn()
|
||||
{
|
||||
await SeedElement(
|
||||
1,
|
||||
GraphicsElementDefaults.OnNowNextSeededPath,
|
||||
GraphicsElementKind.Text,
|
||||
GraphicsElementDefaults.OnNowNextName);
|
||||
|
||||
var handler = new GetAllGraphicsElementsForApiHandler(_db.Factory);
|
||||
|
||||
List<GraphicsElementResponseModel> result =
|
||||
await handler.Handle(new GetAllGraphicsElementsForApi(), CancellationToken.None);
|
||||
|
||||
result.Count.ShouldBe(1);
|
||||
result[0].BuiltIn.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// #568: identity is GraphicsElementDefaults.IsOnNowNext, an ORDINAL comparison, so a row whose
|
||||
// path differs from the seeded one only in case is a different element. Reddens if that
|
||||
// comparison is loosened to OrdinalIgnoreCase. It does NOT pin provider independence: under
|
||||
// SQLite's BINARY collation a `Where(e => e.Path == ...)` in SQL answers identically, which is
|
||||
// why the comparison is kept in memory rather than pinned here (see IsOnNowNext's remarks).
|
||||
[Test]
|
||||
public async Task GetAllGraphicsElementsForApi_Should_Not_Mark_A_Case_Variant_Of_The_Seeded_Path_As_BuiltIn()
|
||||
{
|
||||
await SeedElement(1, CaseVariantOfSeededPath(), GraphicsElementKind.Text, string.Empty);
|
||||
|
||||
var handler = new GetAllGraphicsElementsForApiHandler(_db.Factory);
|
||||
|
||||
List<GraphicsElementResponseModel> result =
|
||||
await handler.Handle(new GetAllGraphicsElementsForApi(), CancellationToken.None);
|
||||
|
||||
result.Count.ShouldBe(1);
|
||||
result[0].BuiltIn.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// #568: Kind is part of the built-in element's identity, not a second test the seeder applies
|
||||
// and the API skips. GetBuiltInElementId refuses a wrong-kind row at the seeded path -- it has
|
||||
// to, since EnsureBuiltInElementRow asks it whether the Text row it is about to create already
|
||||
// exists -- so an API that reported the same row as builtIn:true would have the two sites
|
||||
// disagreeing about one row. Reddens if the Kind conjunct is dropped from
|
||||
// GraphicsElementDefaults.IsOnNowNext (row 18 of the mutation table in docs/graphics-elements.md).
|
||||
[Test]
|
||||
public async Task GetAllGraphicsElementsForApi_Should_Not_Mark_A_Wrong_Kind_Row_At_The_Seeded_Path_As_BuiltIn()
|
||||
{
|
||||
await SeedElement(
|
||||
1,
|
||||
GraphicsElementDefaults.OnNowNextSeededPath,
|
||||
GraphicsElementKind.Image,
|
||||
string.Empty);
|
||||
|
||||
var handler = new GetAllGraphicsElementsForApiHandler(_db.Factory);
|
||||
|
||||
List<GraphicsElementResponseModel> result =
|
||||
await handler.Handle(new GetAllGraphicsElementsForApi(), CancellationToken.None);
|
||||
|
||||
result.Count.ShouldBe(1);
|
||||
result[0].BuiltIn.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// The seeded path with only the FILENAME's case changed -- same folder, same spelling.
|
||||
private static string CaseVariantOfSeededPath() =>
|
||||
System.IO.Path.Combine(
|
||||
System.IO.Path.GetDirectoryName(GraphicsElementDefaults.OnNowNextSeededPath)!,
|
||||
GraphicsElementDefaults.OnNowNextFileName.ToUpperInvariant());
|
||||
|
||||
private async Task SeedElement(int id, string path, GraphicsElementKind kind, string name)
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Data.Repositories;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Paging;
|
||||
|
||||
/// <summary>
|
||||
/// The MediaCards count/page pairs live in two separate repository methods rather than in one
|
||||
/// handler, so `api.paged-count-matches-page-query` cannot be satisfied structurally there — the
|
||||
/// two must be kept in agreement and pinned by a test instead. Each case below constructs the
|
||||
/// divergence the count used to miss and asserts count == pageable rows.
|
||||
/// Expected values are pinned literals, never re-derived from the method's own predicate.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class MediaCardsCountMatchesPageTests
|
||||
{
|
||||
private InMemoryTvContext _db = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
private TelevisionRepository TelevisionRepo =>
|
||||
new(_db.Factory, NullLogger<TelevisionRepository>.Instance);
|
||||
|
||||
[Test]
|
||||
public async Task GetSeasonCount_Should_Expand_To_The_Same_Shows_GetPagedSeasons_Pages()
|
||||
{
|
||||
// the same show present in two libraries: same Title+Year, different Show rows.
|
||||
// GetPagedSeasons pages the union (2 + 3), so the count must be 5, not 2.
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
context.Shows.Add(new Show { Id = 1 });
|
||||
context.Shows.Add(new Show { Id = 2 });
|
||||
context.ShowMetadata.Add(new ShowMetadata { Id = 201, ShowId = 1, Title = "Star Trek", Year = 1966 });
|
||||
context.ShowMetadata.Add(new ShowMetadata { Id = 202, ShowId = 2, Title = "Star Trek", Year = 1966 });
|
||||
|
||||
// an unrelated show that must NOT be swept in
|
||||
context.Shows.Add(new Show { Id = 3 });
|
||||
context.ShowMetadata.Add(new ShowMetadata { Id = 203, ShowId = 3, Title = "Star Trek", Year = 1987 });
|
||||
context.Seasons.Add(new Season { Id = 19, ShowId = 3, SeasonNumber = 1 });
|
||||
|
||||
context.Seasons.Add(new Season { Id = 11, ShowId = 1, SeasonNumber = 1 });
|
||||
context.Seasons.Add(new Season { Id = 12, ShowId = 1, SeasonNumber = 2 });
|
||||
context.Seasons.Add(new Season { Id = 13, ShowId = 2, SeasonNumber = 1 });
|
||||
context.Seasons.Add(new Season { Id = 14, ShowId = 2, SeasonNumber = 2 });
|
||||
context.Seasons.Add(new Season { Id = 15, ShowId = 2, SeasonNumber = 3 });
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
int count = await TelevisionRepo.GetSeasonCount(1);
|
||||
List<Season> page = await TelevisionRepo.GetPagedSeasons(1, 1, 50, CancellationToken.None);
|
||||
|
||||
count.ShouldBe(5);
|
||||
page.Count.ShouldBe(5);
|
||||
count.ShouldBe(page.Count);
|
||||
|
||||
// pin WHICH rows, not just how many — a count and a page can agree on the wrong set
|
||||
page.Select(s => s.Id).OrderBy(id => id).ShouldBe([11, 12, 13, 14, 15]);
|
||||
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetSeasonCount_Should_Be_Zero_When_The_Show_Has_No_Metadata()
|
||||
{
|
||||
// GetPagedSeasons returns nothing without a ShowMetadata row to expand from, so the count
|
||||
// must agree rather than reporting the show's seasons
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
context.Shows.Add(new Show { Id = 1 });
|
||||
context.Seasons.Add(new Season { Id = 11, ShowId = 1, SeasonNumber = 1 });
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
int count = await TelevisionRepo.GetSeasonCount(1);
|
||||
List<Season> page = await TelevisionRepo.GetPagedSeasons(1, 1, 50, CancellationToken.None);
|
||||
|
||||
count.ShouldBe(0);
|
||||
page.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetEpisodeCount_Should_Count_Episodes_That_Have_Metadata()
|
||||
{
|
||||
// 3 episodes, one of which lost its metadata row to a scanner failure. GetPagedEpisodes
|
||||
// pages EpisodeMetadata, so only 2 are reachable and the count must say 2.
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
// GetPagedEpisodes's include chain reaches Episode -> Season -> Show through REQUIRED
|
||||
// reference navs, which EF emits as INNER JOINs, so a missing Season or Show row drops
|
||||
// every row and would make the page 0 for a reason unrelated to the count under test.
|
||||
// The ShowMetadata leg is a COLLECTION nav (LEFT JOIN) and drops nothing — the row below
|
||||
// is incidental, seeded only to keep the graph realistic.
|
||||
context.Shows.Add(new Show { Id = 1 });
|
||||
context.ShowMetadata.Add(new ShowMetadata { Id = 201, ShowId = 1, Title = "Show", Year = 2000 });
|
||||
context.Seasons.Add(new Season { Id = 11, ShowId = 1, SeasonNumber = 1 });
|
||||
for (var i = 21; i <= 23; i++)
|
||||
{
|
||||
context.Episodes.Add(new Episode { Id = i, SeasonId = 11 });
|
||||
}
|
||||
|
||||
context.EpisodeMetadata.Add(new EpisodeMetadata { Id = 221, EpisodeId = 21, EpisodeNumber = 1 });
|
||||
context.EpisodeMetadata.Add(new EpisodeMetadata { Id = 222, EpisodeId = 22, EpisodeNumber = 2 });
|
||||
|
||||
// an episode in a different season must not be swept in
|
||||
context.Seasons.Add(new Season { Id = 12, ShowId = 1, SeasonNumber = 2 });
|
||||
context.Episodes.Add(new Episode { Id = 29, SeasonId = 12 });
|
||||
context.EpisodeMetadata.Add(new EpisodeMetadata { Id = 229, EpisodeId = 29, EpisodeNumber = 1 });
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
int count = await TelevisionRepo.GetEpisodeCount(11);
|
||||
List<EpisodeMetadata> page = await TelevisionRepo.GetPagedEpisodes(11, 1, 50);
|
||||
|
||||
count.ShouldBe(2);
|
||||
page.Count.ShouldBe(2);
|
||||
count.ShouldBe(page.Count);
|
||||
|
||||
// the two episodes WITH metadata, and not the other season's
|
||||
page.Select(em => em.EpisodeId).OrderBy(id => id).ShouldBe([21, 22]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetMusicVideoCount_Should_Count_Music_Videos_That_Have_Metadata()
|
||||
{
|
||||
// 3 music videos for the artist, one without a metadata row; GetPagedMusicVideos pages
|
||||
// MusicVideoMetadata, so the count must be 2
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
context.Artists.Add(new Artist { Id = 41 });
|
||||
for (var i = 31; i <= 33; i++)
|
||||
{
|
||||
context.MusicVideos.Add(new MusicVideo { Id = i, ArtistId = 41 });
|
||||
}
|
||||
|
||||
context.MusicVideoMetadata.Add(new MusicVideoMetadata { Id = 231, MusicVideoId = 31, Title = "A" });
|
||||
context.MusicVideoMetadata.Add(new MusicVideoMetadata { Id = 232, MusicVideoId = 32, Title = "B" });
|
||||
|
||||
// another artist's video must not be swept in
|
||||
context.Artists.Add(new Artist { Id = 42 });
|
||||
context.MusicVideos.Add(new MusicVideo { Id = 39, ArtistId = 42 });
|
||||
context.MusicVideoMetadata.Add(new MusicVideoMetadata { Id = 239, MusicVideoId = 39, Title = "C" });
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var repo = new MusicVideoRepository(_db.Factory);
|
||||
|
||||
int count = await repo.GetMusicVideoCount(41);
|
||||
List<MusicVideoMetadata> page = await repo.GetPagedMusicVideos(41, 1, 50);
|
||||
|
||||
count.ShouldBe(2);
|
||||
page.Count.ShouldBe(2);
|
||||
count.ShouldBe(page.Count);
|
||||
|
||||
// this artist's two videos with metadata, and not the other artist's
|
||||
page.Select(m => m.Title).OrderBy(x => x).ShouldBe(["A", "B"]);
|
||||
}
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Application.ProgramSchedules;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using DomainChannel = ErsatzTV.Core.Domain.Channel;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Paging;
|
||||
|
||||
/// <summary>
|
||||
/// Every paged handler whose page query applies a filter must compute its TotalCount from the SAME
|
||||
/// query, or a filtered page reports the unfiltered total and the SPA paginates to pages that can
|
||||
/// never contain anything (issues #690, #758).
|
||||
/// Expected counts here are PINNED LITERALS derived from the seeded set by hand — never recomputed
|
||||
/// by re-applying the handler's own predicate, which would pass whatever the handler happens to do.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class PagedQueryTotalCountTests
|
||||
{
|
||||
// 5 seeded rows, of which exactly these 2 contain "Alpha"
|
||||
private const int SeededRows = 5;
|
||||
private const int MatchingAlpha = 2;
|
||||
|
||||
private InMemoryTvContext _db = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
private static readonly string[] Names =
|
||||
["Alpha One", "Beta", "Alpha Two", "Gamma", "Delta"];
|
||||
|
||||
[Test]
|
||||
public async Task GetPagedCollections_Filtered_Count_Should_Match_Filter()
|
||||
{
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
for (var i = 0; i < Names.Length; i++)
|
||||
{
|
||||
context.Collections.Add(new Collection { Id = i + 1, Name = Names[i], MediaItems = [] });
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var handler = new GetPagedCollectionsHandler(_db.Factory);
|
||||
|
||||
PagedMediaCollectionsViewModel unfiltered =
|
||||
await handler.Handle(new GetPagedCollections(string.Empty, 0, 10), CancellationToken.None);
|
||||
unfiltered.TotalCount.ShouldBe(SeededRows);
|
||||
|
||||
PagedMediaCollectionsViewModel filtered =
|
||||
await handler.Handle(new GetPagedCollections("Alpha", 0, 10), CancellationToken.None);
|
||||
|
||||
filtered.TotalCount.ShouldBe(MatchingAlpha);
|
||||
filtered.Page.Select(c => c.Name).ShouldBe(["Alpha One", "Alpha Two"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetPagedMultiCollections_Filtered_Count_Should_Match_Filter()
|
||||
{
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
for (var i = 0; i < Names.Length; i++)
|
||||
{
|
||||
context.MultiCollections.Add(new MultiCollection { Id = i + 1, Name = Names[i] });
|
||||
}
|
||||
|
||||
// channel-owned rows are excluded from BOTH the page and the count, filter or no filter
|
||||
context.MultiCollections.Add(
|
||||
new MultiCollection { Id = 99, Name = "Alpha Owned", OwnedByChannelId = 7 });
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var handler = new GetPagedMultiCollectionsHandler(_db.Factory);
|
||||
|
||||
PagedMultiCollectionsViewModel unfiltered =
|
||||
await handler.Handle(new GetPagedMultiCollections(string.Empty, 0, 10), CancellationToken.None);
|
||||
unfiltered.TotalCount.ShouldBe(SeededRows);
|
||||
|
||||
PagedMultiCollectionsViewModel filtered =
|
||||
await handler.Handle(new GetPagedMultiCollections("Alpha", 0, 10), CancellationToken.None);
|
||||
|
||||
filtered.TotalCount.ShouldBe(MatchingAlpha);
|
||||
filtered.Page.Select(mc => mc.Name).ShouldBe(["Alpha One", "Alpha Two"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetPagedSmartCollections_Filtered_Count_Should_Match_Filter()
|
||||
{
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
for (var i = 0; i < Names.Length; i++)
|
||||
{
|
||||
context.SmartCollections.Add(
|
||||
new SmartCollection { Id = i + 1, Name = Names[i], Query = "tag:family" });
|
||||
}
|
||||
|
||||
context.SmartCollections.Add(
|
||||
new SmartCollection
|
||||
{
|
||||
Id = 99,
|
||||
Name = "Alpha Owned",
|
||||
Query = "tag:family",
|
||||
OwnedByChannelId = 7
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var handler = new GetPagedSmartCollectionsHandler(_db.Factory);
|
||||
|
||||
PagedSmartCollectionsViewModel unfiltered =
|
||||
await handler.Handle(new GetPagedSmartCollections(string.Empty, 0, 10), CancellationToken.None);
|
||||
unfiltered.TotalCount.ShouldBe(SeededRows);
|
||||
|
||||
PagedSmartCollectionsViewModel filtered =
|
||||
await handler.Handle(new GetPagedSmartCollections("Alpha", 0, 10), CancellationToken.None);
|
||||
|
||||
filtered.TotalCount.ShouldBe(MatchingAlpha);
|
||||
filtered.Page.Select(sc => sc.Name).ShouldBe(["Alpha One", "Alpha Two"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetPagedRerunCollections_Filtered_Count_Should_Match_Filter()
|
||||
{
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
for (var i = 0; i < Names.Length; i++)
|
||||
{
|
||||
context.RerunCollections.Add(
|
||||
new RerunCollection
|
||||
{
|
||||
Id = i + 1,
|
||||
Name = Names[i],
|
||||
CollectionType = CollectionType.Collection,
|
||||
CollectionId = i + 1
|
||||
});
|
||||
context.Collections.Add(new Collection { Id = i + 1, Name = Names[i], MediaItems = [] });
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var handler = new GetPagedRerunCollectionsHandler(_db.Factory);
|
||||
|
||||
PagedRerunCollectionsViewModel unfiltered =
|
||||
await handler.Handle(new GetPagedRerunCollections(string.Empty, 0, 10), CancellationToken.None);
|
||||
unfiltered.TotalCount.ShouldBe(SeededRows);
|
||||
|
||||
PagedRerunCollectionsViewModel filtered =
|
||||
await handler.Handle(new GetPagedRerunCollections("Alpha", 0, 10), CancellationToken.None);
|
||||
|
||||
filtered.TotalCount.ShouldBe(MatchingAlpha);
|
||||
filtered.Page.Select(rc => rc.Name).ShouldBe(["Alpha One", "Alpha Two"]);
|
||||
|
||||
// the selection graph still loads for the page — moving IncludeSelectionDetails off the
|
||||
// counted query must not stop the page from projecting it (issue #671)
|
||||
filtered.Page.Select(rc => rc.Collection?.Name).ShouldBe(["Alpha One", "Alpha Two"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetPagedProgramSchedules_Filtered_Count_Should_Match_Filter()
|
||||
{
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
for (var i = 0; i < Names.Length; i++)
|
||||
{
|
||||
context.ProgramSchedules.Add(new ProgramSchedule { Id = i + 1, Name = Names[i] });
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var handler = new GetPagedProgramSchedulesHandler(_db.Factory);
|
||||
|
||||
PagedProgramSchedulesViewModel unfiltered =
|
||||
await handler.Handle(new GetPagedProgramSchedules(string.Empty, 0, 10), CancellationToken.None);
|
||||
unfiltered.TotalCount.ShouldBe(SeededRows);
|
||||
|
||||
PagedProgramSchedulesViewModel filtered =
|
||||
await handler.Handle(new GetPagedProgramSchedules("Alpha", 0, 10), CancellationToken.None);
|
||||
|
||||
filtered.TotalCount.ShouldBe(MatchingAlpha);
|
||||
filtered.Page.Select(ps => ps.Name).ShouldBe(["Alpha One", "Alpha Two"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetPagedPlayouts_Filtered_Count_Should_Match_Filter()
|
||||
{
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
for (var i = 0; i < Names.Length; i++)
|
||||
{
|
||||
context.Channels.Add(NewChannel(i + 1, $"{i + 1}", Names[i]));
|
||||
context.Playouts.Add(
|
||||
new Playout
|
||||
{
|
||||
Id = i + 1,
|
||||
ChannelId = i + 1,
|
||||
ScheduleKind = PlayoutScheduleKind.Classic
|
||||
});
|
||||
}
|
||||
|
||||
// a playout whose channel row does not exist: excluded from the page by the
|
||||
// `Channel != null` filter, so it must be excluded from the count too
|
||||
context.Playouts.Add(
|
||||
new Playout { Id = 99, ChannelId = 4242, ScheduleKind = PlayoutScheduleKind.Classic });
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var handler = new GetPagedPlayoutsHandler(_db.Factory);
|
||||
|
||||
PagedPlayoutsViewModel unfiltered =
|
||||
await handler.Handle(new GetPagedPlayouts(string.Empty, 0, 10), CancellationToken.None);
|
||||
|
||||
// 5, NOT 6 — the orphaned playout is filtered out of the page, so it is not part of the total
|
||||
unfiltered.TotalCount.ShouldBe(SeededRows);
|
||||
unfiltered.Page.Count.ShouldBe(SeededRows);
|
||||
|
||||
PagedPlayoutsViewModel filtered =
|
||||
await handler.Handle(new GetPagedPlayouts("Alpha", 0, 10), CancellationToken.None);
|
||||
|
||||
filtered.TotalCount.ShouldBe(MatchingAlpha);
|
||||
filtered.Page.Select(p => p.ChannelName).ShouldBe(["Alpha One", "Alpha Two"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Filtered_Count_Should_Drive_A_Second_Page()
|
||||
{
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
for (var i = 0; i < Names.Length; i++)
|
||||
{
|
||||
context.Collections.Add(new Collection { Id = i + 1, Name = Names[i], MediaItems = [] });
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var handler = new GetPagedCollectionsHandler(_db.Factory);
|
||||
|
||||
// pageSize 1 over the 2 matching rows: this is the property the issues are about — the count
|
||||
// is what tells the client a SECOND page exists, and each page holds exactly its own row
|
||||
PagedMediaCollectionsViewModel first =
|
||||
await handler.Handle(new GetPagedCollections("Alpha", 0, 1), CancellationToken.None);
|
||||
first.TotalCount.ShouldBe(MatchingAlpha);
|
||||
first.Page.Select(c => c.Name).ShouldBe(["Alpha One"]);
|
||||
|
||||
PagedMediaCollectionsViewModel second =
|
||||
await handler.Handle(new GetPagedCollections("Alpha", 1, 1), CancellationToken.None);
|
||||
second.TotalCount.ShouldBe(MatchingAlpha);
|
||||
second.Page.Select(c => c.Name).ShouldBe(["Alpha Two"]);
|
||||
|
||||
// and the page AFTER the last matching row is empty — with the pre-fix count of 5 the client
|
||||
// would have been told to fetch three more pages that can never contain anything
|
||||
PagedMediaCollectionsViewModel past =
|
||||
await handler.Handle(new GetPagedCollections("Alpha", 2, 1), CancellationToken.None);
|
||||
past.TotalCount.ShouldBe(MatchingAlpha);
|
||||
past.Page.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
private static DomainChannel NewChannel(int id, string number, string name) =>
|
||||
new(Guid.NewGuid())
|
||||
{
|
||||
Id = id,
|
||||
Number = number,
|
||||
SortNumber = id,
|
||||
Name = name,
|
||||
Group = "ErsatzTV",
|
||||
Categories = string.Empty,
|
||||
FFmpegProfileId = 1,
|
||||
StreamSelector = string.Empty,
|
||||
PreferredAudioLanguageCode = string.Empty,
|
||||
PreferredAudioTitle = string.Empty,
|
||||
PreferredSubtitleLanguageCode = string.Empty,
|
||||
MusicVideoCreditsTemplate = string.Empty,
|
||||
StreamingMode = StreamingMode.TransportStreamHybrid,
|
||||
PlayoutSource = ChannelPlayoutSource.Generated,
|
||||
PlayoutMode = ChannelPlayoutMode.Continuous
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,6 @@ using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Application.Scheduling;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Scheduling;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -122,200 +121,13 @@ public class PlayoutHandlerTests
|
||||
LeftOf(result).Value.ShouldContain("must not be empty");
|
||||
}
|
||||
|
||||
// ---- #880 empty recurrence sets ----
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceAlternateSchedules_Should_Reject_A_Newly_Empty_Recurrence_On_A_Stored_Item()
|
||||
{
|
||||
await SeedPlayout(1, version: 1);
|
||||
var handler = new ReplacePlayoutAlternateScheduleItemsHandler(
|
||||
_db.Factory,
|
||||
_worker,
|
||||
NullLogger<ReplacePlayoutAlternateScheduleItemsHandler>.Instance);
|
||||
|
||||
// TWO items: index 1 is the catch-all (highest index), so index 0 is a real alternate whose
|
||||
// recurrence IS stored. The empty set goes on THAT one.
|
||||
ReplacePlayoutAlternateSchedule empty = AltItem(index: 0) with { DaysOfWeek = [] };
|
||||
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
new ReplacePlayoutAlternateScheduleItems(1, [empty, AltItem(index: 1)]),
|
||||
CancellationToken.None);
|
||||
|
||||
LeftOf(result).Value.ShouldContain("[DaysOfWeek]");
|
||||
LeftOf(result).Value.ShouldContain("no day of the week");
|
||||
|
||||
// rejected BEFORE any mutation -- the version bump is the observable proof nothing was written
|
||||
(await ReadPlayoutVersion(1)).ShouldBe(1);
|
||||
}
|
||||
|
||||
// The catch-all's recurrence is discarded by the handler (only its ProgramScheduleId is used), so an
|
||||
// empty set there cannot make anything "never apply". Rejecting it would state a reason that is FALSE
|
||||
// for that item, which is why the check walks `incoming` rather than every submitted item.
|
||||
[Test]
|
||||
public async Task ReplaceAlternateSchedules_Should_Allow_An_Empty_Recurrence_On_The_CatchAll_Item()
|
||||
{
|
||||
await SeedPlayout(1, version: 1);
|
||||
var handler = new ReplacePlayoutAlternateScheduleItemsHandler(
|
||||
_db.Factory,
|
||||
_worker,
|
||||
NullLogger<ReplacePlayoutAlternateScheduleItemsHandler>.Instance);
|
||||
|
||||
// a single item IS the catch-all
|
||||
ReplacePlayoutAlternateSchedule catchAll = AltItem(index: 0) with { DaysOfWeek = [], MonthsOfYear = [] };
|
||||
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
new ReplacePlayoutAlternateScheduleItems(1, [catchAll]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
(await ReadPlayoutVersion(1)).ShouldBe(2);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceTemplates_Should_Reject_A_Newly_Empty_Recurrence()
|
||||
{
|
||||
await SeedPlayout(1, version: 1);
|
||||
var handler = new ReplacePlayoutTemplateItemsHandler(
|
||||
_db.Factory,
|
||||
NullLogger<ReplacePlayoutTemplateItemsHandler>.Instance);
|
||||
|
||||
ReplacePlayoutTemplate empty = TemplateItem() with { DaysOfMonth = [] };
|
||||
|
||||
Option<BaseError> result = await handler.Handle(
|
||||
new ReplacePlayoutTemplateItems(1, [empty]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IfNone(() => throw new AssertionException("Expected a Some(error)"))
|
||||
.Value.ShouldContain("[DaysOfMonth]");
|
||||
(await ReadPlayoutVersion(1)).ShouldBe(1);
|
||||
}
|
||||
|
||||
// `api.ffmpeg-profile-numeric-bounds`: reject a NEWLY submitted bad value, not an UNCHANGED one the row
|
||||
// already holds. Both PUT paths are whole-list replaces, so without this a single pre-existing empty row
|
||||
// would make every OTHER item in the playout uneditable.
|
||||
[Test]
|
||||
public async Task ReplaceTemplates_Should_Allow_An_UNCHANGED_Empty_Recurrence_That_Is_Already_Stored()
|
||||
{
|
||||
int templateItemId = await SeedPlayoutWithEmptyTemplateRecurrence();
|
||||
var handler = new ReplacePlayoutTemplateItemsHandler(
|
||||
_db.Factory,
|
||||
NullLogger<ReplacePlayoutTemplateItemsHandler>.Instance);
|
||||
|
||||
// same row, same empty DaysOfWeek -- an edit to some OTHER field on the same list
|
||||
ReplacePlayoutTemplate unchanged = TemplateItem() with { Id = templateItemId, DaysOfWeek = [] };
|
||||
|
||||
Option<BaseError> result = await handler.Handle(
|
||||
new ReplacePlayoutTemplateItems(1, [unchanged]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsNone.ShouldBeTrue();
|
||||
(await ReadPlayoutVersion(1)).ShouldBe(2);
|
||||
}
|
||||
|
||||
// ... and the complement: the SAME stored row rejects a DIFFERENT field being newly emptied, so the
|
||||
// exemption is per-field rather than "this row is grandfathered".
|
||||
[Test]
|
||||
public async Task ReplaceTemplates_Should_Still_Reject_A_Different_Field_Newly_Emptied_On_A_Stored_Row()
|
||||
{
|
||||
int templateItemId = await SeedPlayoutWithEmptyTemplateRecurrence();
|
||||
var handler = new ReplacePlayoutTemplateItemsHandler(
|
||||
_db.Factory,
|
||||
NullLogger<ReplacePlayoutTemplateItemsHandler>.Instance);
|
||||
|
||||
// DaysOfWeek is the stored-empty one; MonthsOfYear is stored FULL, so emptying it is new
|
||||
ReplacePlayoutTemplate item = TemplateItem() with
|
||||
{
|
||||
Id = templateItemId,
|
||||
DaysOfWeek = [],
|
||||
MonthsOfYear = []
|
||||
};
|
||||
|
||||
Option<BaseError> result = await handler.Handle(
|
||||
new ReplacePlayoutTemplateItems(1, [item]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IfNone(() => throw new AssertionException("Expected a Some(error)"))
|
||||
.Value.ShouldContain("[MonthsOfYear]");
|
||||
(await ReadPlayoutVersion(1)).ShouldBe(1);
|
||||
}
|
||||
|
||||
private async Task<int> SeedPlayoutWithEmptyTemplateRecurrence()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
|
||||
// The handler loads templates with `.Include(p => p.Templates).ThenInclude(t => t.Template)`, and
|
||||
// that navigation is required -- so a PlayoutTemplate whose Template row does not exist is joined
|
||||
// OUT and never reaches `existing`. Without seeding this, the stored row is invisible, the
|
||||
// exemption cannot match, and the test fails for a reason that has nothing to do with the rule.
|
||||
context.TemplateGroups.Add(new TemplateGroup { Id = 5, Name = "Group", Templates = [] });
|
||||
context.Templates.Add(new Template { Id = 20, TemplateGroupId = 5, Name = "Template 20", Items = [] });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var template = new PlayoutTemplate
|
||||
{
|
||||
Index = 0,
|
||||
TemplateId = 20,
|
||||
DaysOfWeek = [],
|
||||
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
|
||||
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
|
||||
LimitToDateRange = false,
|
||||
StartMonth = 1,
|
||||
StartDay = 1,
|
||||
EndMonth = 12,
|
||||
EndDay = 31
|
||||
};
|
||||
context.Playouts.Add(
|
||||
new Playout
|
||||
{
|
||||
Id = 1,
|
||||
ChannelId = 1,
|
||||
ProgramScheduleId = 10,
|
||||
Version = 1,
|
||||
Items = [],
|
||||
ProgramScheduleAlternates = [],
|
||||
Templates = [template]
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
return template.Id;
|
||||
}
|
||||
|
||||
// ---- #253 optimistic concurrency (alternate schedules #7 + templates #8, shared Playout.Version) ----
|
||||
|
||||
// Recurrence sets are UNRESTRICTED here, not empty (#880): an empty set now means "matches no date"
|
||||
// and is rejected on any item whose recurrence is stored, so an empty fixture would make these
|
||||
// concurrency tests measure the recurrence guard instead of the version check.
|
||||
private static ReplacePlayoutAlternateSchedule AltItem(int programScheduleId = 10, int index = 0) =>
|
||||
new(
|
||||
0,
|
||||
index,
|
||||
programScheduleId,
|
||||
AlternateScheduleSelector.AllDaysOfWeek(),
|
||||
AlternateScheduleSelector.AllDaysOfMonth(),
|
||||
AlternateScheduleSelector.AllMonthsOfYear(),
|
||||
false,
|
||||
1,
|
||||
1,
|
||||
null,
|
||||
12,
|
||||
31,
|
||||
null);
|
||||
private static ReplacePlayoutAlternateSchedule AltItem(int programScheduleId = 10) =>
|
||||
new(0, 0, programScheduleId, [], [], [], false, 1, 1, null, 12, 31, null);
|
||||
|
||||
private static ReplacePlayoutTemplate TemplateItem(int templateId = 20) =>
|
||||
new(
|
||||
0,
|
||||
0,
|
||||
templateId,
|
||||
null,
|
||||
AlternateScheduleSelector.AllDaysOfWeek(),
|
||||
AlternateScheduleSelector.AllDaysOfMonth(),
|
||||
AlternateScheduleSelector.AllMonthsOfYear(),
|
||||
false,
|
||||
1,
|
||||
1,
|
||||
null,
|
||||
12,
|
||||
31,
|
||||
null);
|
||||
new(0, 0, templateId, null, [], [], [], false, 1, 1, null, 12, 31, null);
|
||||
|
||||
private async Task SeedPlayout(int id, int version, int? programScheduleId = 10)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using System.Collections;
|
||||
using System.Reflection;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.ProgramSchedules;
|
||||
@@ -67,8 +65,7 @@ public class ScheduleItemResponseRoundTripTests
|
||||
await replaceHandler.Handle(new ReplaceProgramScheduleItems(scheduleId, reconstructed), CancellationToken.None);
|
||||
replaced.IsRight.ShouldBeTrue(replaced.LeftToSeq().HeadOrNone().Match(e => e.Value, () => "unknown"));
|
||||
|
||||
// GET again → envelope B; A and B must be semantically identical INCLUDING row ids —
|
||||
// the handler reconciles by id and updates in place, it does not regenerate rows.
|
||||
// GET again → envelope B; A and B must be semantically identical (ignoring regenerated row ids).
|
||||
ScheduleItemsResponseModel envelopeB = await GetItemsEnvelope(scheduleId);
|
||||
|
||||
envelopeB.Items.Count.ShouldBe(envelopeA.Items.Count);
|
||||
@@ -343,97 +340,62 @@ public class ScheduleItemResponseRoundTripTests
|
||||
r.PreferredSubtitleLanguageCode,
|
||||
r.SubtitleMode);
|
||||
|
||||
// ersatztv#779 (detector G): the compared field list is DERIVED from the DTO by reflection,
|
||||
// never hand-copied. The previous version was a hand-written run of `b.X.ShouldBe(a.X)` lines.
|
||||
// It was COMPLETE on the day it was written — every property but Id — and had no way
|
||||
// to report the day it stopped being: a field added to ScheduleItemResponseModel simply went
|
||||
// uncompared, and this "lossless round-trip" test kept passing while the round trip silently
|
||||
// dropped it. That is #754's mechanism exactly (a hand-maintained mirror drifting from a
|
||||
// 28-property DTO by one field, HTTP 200, no error), one altitude up — in the very test whose
|
||||
// job is to catch losses.
|
||||
//
|
||||
// Properties deliberately NOT compared. The set is EMPTY, and that is a finding rather than an
|
||||
// oversight. The first version exempted Id on the reasoning that "the PUT replaces the item
|
||||
// set, so B's rows are new rows with new ids". ReplaceProgramScheduleItemsHandler does not do
|
||||
// that for this fixture's payload: it forwards every Id, takes the id-based reconcile, and
|
||||
// updates rows in place. So Id compares equal, and the exemption was unnecessary.
|
||||
//
|
||||
// Two mutations of this fixture, both EXECUTED — recorded as results, with no account of why:
|
||||
// a confident mechanism for this observation is easy to get wrong, and two independent ones
|
||||
// were each contradicted by the code:
|
||||
//
|
||||
// ToReplaceCommand passes `null` for EVERY id -> test stays GREEN
|
||||
// ToReplaceCommand passes `null` for index 0 only -> test goes RED, "Id differs"
|
||||
//
|
||||
// So the Id comparison does discriminate; it is not decorative. What it is NOT is a substitute
|
||||
// for ReplaceProgramScheduleItemsReconcileTests, whose
|
||||
// Reorder_ById_Should_Move_State_With_The_Logical_Item_Not_The_Slot and
|
||||
// Insert_ById_In_Middle_Should_Keep_Existing_Ids_And_State pass real ids and pin that state
|
||||
// moves with the logical item rather than the slot. Those are the #252 tests; this is a
|
||||
// round-trip check that happens to also notice a lost row.
|
||||
//
|
||||
// Any name added here must still exist on ScheduleItemResponseModel (asserted below), so
|
||||
// renaming a field cannot leave a stale exemption silently exempting nothing.
|
||||
private static readonly System.Collections.Generic.HashSet<string> RoundTripExemptProperties =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
private static void AssertSemanticallyEqual(ScheduleItemResponseModel a, ScheduleItemResponseModel b)
|
||||
{
|
||||
PropertyInfo[] properties = typeof(ScheduleItemResponseModel)
|
||||
.GetProperties(BindingFlags.Public | BindingFlags.Instance);
|
||||
|
||||
// A stale exemption is a silent hole: it would exempt nothing while reading as a reviewed
|
||||
// decision, and the property it once named would be compared or not by accident.
|
||||
foreach (string exempt in RoundTripExemptProperties)
|
||||
{
|
||||
properties.Any(p => p.Name == exempt).ShouldBeTrue(
|
||||
$"'{exempt}' is exempted from the round-trip comparison but is not a property of "
|
||||
+ $"{nameof(ScheduleItemResponseModel)}; remove the stale exemption or fix the name.");
|
||||
}
|
||||
|
||||
var compared = 0;
|
||||
foreach (PropertyInfo property in properties)
|
||||
{
|
||||
if (RoundTripExemptProperties.Contains(property.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
object? expected = property.GetValue(a);
|
||||
object? actual = property.GetValue(b);
|
||||
|
||||
if (expected is IEnumerable expectedSequence and not string)
|
||||
{
|
||||
// Collection-valued members (WatermarkIds, Watermarks, GraphicsElementIds,
|
||||
// GraphicsElements). The elementwise walk still delegates to each element's Equals,
|
||||
// so it is value equality only because those elements are records
|
||||
// (NamedIdResponseModel) or value types (the int id lists); a future element type that
|
||||
// is neither would silently be compared by REFERENCE inside this loop. It is also order-sensitive, which is
|
||||
// correct for these ordered lists but would be wrong for an unordered type such as
|
||||
// a dictionary-valued property.
|
||||
actual.ShouldNotBeNull($"{property.Name} was null on the round-tripped item");
|
||||
var actualSequence = (IEnumerable)actual;
|
||||
actualSequence.Cast<object?>().ToList()
|
||||
.ShouldBe(expectedSequence.Cast<object?>().ToList(), $"{property.Name} differs");
|
||||
}
|
||||
else
|
||||
{
|
||||
actual.ShouldBe(expected, $"{property.Name} differs");
|
||||
}
|
||||
|
||||
compared++;
|
||||
}
|
||||
|
||||
// Anti-vacuity, as a PIN rather than a floor. A `>=` floor lets properties vanish silently,
|
||||
// which is the one-sided version of the both-directions rule this test is meant to embody.
|
||||
// Comparing against the reflected count minus exemptions would be tautological — both sides
|
||||
// come from the same reflection — so the expected number is written down and must be
|
||||
// bumped deliberately in the same change that adds or removes a DTO field.
|
||||
const int expectedComparedProperties = 55;
|
||||
compared.ShouldBe(
|
||||
expectedComparedProperties,
|
||||
$"{compared} properties were compared, expected {expectedComparedProperties}; update "
|
||||
+ "this pin in the same change that alters ScheduleItemResponseModel's field list");
|
||||
b.Index.ShouldBe(a.Index);
|
||||
b.StartType.ShouldBe(a.StartType);
|
||||
b.StartTime.ShouldBe(a.StartTime);
|
||||
b.FixedStartTimeBehavior.ShouldBe(a.FixedStartTimeBehavior);
|
||||
b.PlayoutMode.ShouldBe(a.PlayoutMode);
|
||||
b.CollectionType.ShouldBe(a.CollectionType);
|
||||
b.CollectionId.ShouldBe(a.CollectionId);
|
||||
b.MultiCollectionId.ShouldBe(a.MultiCollectionId);
|
||||
b.SmartCollectionId.ShouldBe(a.SmartCollectionId);
|
||||
b.RerunCollectionId.ShouldBe(a.RerunCollectionId);
|
||||
b.MediaItemId.ShouldBe(a.MediaItemId);
|
||||
b.PlaylistId.ShouldBe(a.PlaylistId);
|
||||
b.SearchTitle.ShouldBe(a.SearchTitle);
|
||||
b.SearchQuery.ShouldBe(a.SearchQuery);
|
||||
b.PlaybackOrder.ShouldBe(a.PlaybackOrder);
|
||||
b.MarathonGroupBy.ShouldBe(a.MarathonGroupBy);
|
||||
b.MarathonShuffleGroups.ShouldBe(a.MarathonShuffleGroups);
|
||||
b.MarathonShuffleItems.ShouldBe(a.MarathonShuffleItems);
|
||||
b.MarathonBatchSize.ShouldBe(a.MarathonBatchSize);
|
||||
b.FillWithGroupMode.ShouldBe(a.FillWithGroupMode);
|
||||
b.MultipleMode.ShouldBe(a.MultipleMode);
|
||||
b.MultipleCount.ShouldBe(a.MultipleCount);
|
||||
b.PlayoutDuration.ShouldBe(a.PlayoutDuration);
|
||||
b.TailMode.ShouldBe(a.TailMode);
|
||||
b.DiscardToFillAttempts.ShouldBe(a.DiscardToFillAttempts);
|
||||
b.CustomTitle.ShouldBe(a.CustomTitle);
|
||||
b.GuideMode.ShouldBe(a.GuideMode);
|
||||
b.PreRollFillerId.ShouldBe(a.PreRollFillerId);
|
||||
b.MidRollFillerId.ShouldBe(a.MidRollFillerId);
|
||||
b.PostRollFillerId.ShouldBe(a.PostRollFillerId);
|
||||
b.TailFillerId.ShouldBe(a.TailFillerId);
|
||||
b.FallbackFillerId.ShouldBe(a.FallbackFillerId);
|
||||
b.WatermarkIds.ShouldBe(a.WatermarkIds);
|
||||
b.GraphicsElementIds.ShouldBe(a.GraphicsElementIds);
|
||||
b.PreferredAudioLanguageCode.ShouldBe(a.PreferredAudioLanguageCode);
|
||||
b.PreferredAudioTitle.ShouldBe(a.PreferredAudioTitle);
|
||||
b.PreferredSubtitleLanguageCode.ShouldBe(a.PreferredSubtitleLanguageCode);
|
||||
b.SubtitleMode.ShouldBe(a.SubtitleMode);
|
||||
b.CollectionName.ShouldBe(a.CollectionName);
|
||||
b.MultiCollectionName.ShouldBe(a.MultiCollectionName);
|
||||
b.SmartCollectionName.ShouldBe(a.SmartCollectionName);
|
||||
b.RerunCollectionName.ShouldBe(a.RerunCollectionName);
|
||||
b.PlaylistName.ShouldBe(a.PlaylistName);
|
||||
b.PlaylistGroupId.ShouldBe(a.PlaylistGroupId);
|
||||
b.MediaItemName.ShouldBe(a.MediaItemName);
|
||||
b.PreRollFillerName.ShouldBe(a.PreRollFillerName);
|
||||
b.MidRollFillerName.ShouldBe(a.MidRollFillerName);
|
||||
b.PostRollFillerName.ShouldBe(a.PostRollFillerName);
|
||||
b.TailFillerName.ShouldBe(a.TailFillerName);
|
||||
b.FallbackFillerName.ShouldBe(a.FallbackFillerName);
|
||||
b.Watermarks.Select(w => (w.Id, w.Name)).ShouldBe(a.Watermarks.Select(w => (w.Id, w.Name)));
|
||||
b.GraphicsElements.Select(g => (g.Id, g.Name)).ShouldBe(a.GraphicsElements.Select(g => (g.Id, g.Name)));
|
||||
b.Name.ShouldBe(a.Name);
|
||||
b.DurationEstimate.ShouldBe(a.DurationEstimate);
|
||||
}
|
||||
|
||||
private async Task<int> SeedScheduleAndReferences(bool shuffleScheduleItems)
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Application.Scheduling;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Scheduling;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using PlayoutsMapper = ErsatzTV.Application.Playouts.Mapper;
|
||||
using SchedulingMapper = ErsatzTV.Application.Scheduling.Mapper;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// ersatztv#823. Guarding <see cref="AlternateScheduleSelector" /> alone would have left the OTHER read
|
||||
/// of the same six columns unguarded — the entity→view-model mappers, which feed
|
||||
/// <c>PlayoutController</c>'s response models and therefore the SPA.
|
||||
/// <para>
|
||||
/// Two things break without the guard, and neither is a C# exception, which is why the selector
|
||||
/// tests cannot see them. <c>web/src/screens/PlayoutScheduleEditors.tsx</c> spreads the collection
|
||||
/// (<c>daysOfMonth: [...template.daysOfMonth]</c>) and throws <c>TypeError: not iterable</c> on a
|
||||
/// JSON <c>null</c>; and <c>web/src/screens/playoutTemplateCalendar.ts</c>'s <c>appliesToDate</c> —
|
||||
/// an exact TypeScript port of <see cref="AlternateScheduleSelector.GetScheduleForDate{T}" /> —
|
||||
/// calls <c>.includes</c> on it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// So the mappers substitute the SAME unrestricted defaults the selector reads. That agreement is
|
||||
/// the point: a DTO that said "empty" while the selector scheduled "unrestricted" would make the
|
||||
/// preview calendar disagree with the playout it is previewing.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class RecurrenceLimitsMapperNullTests
|
||||
{
|
||||
private static Template MinimalTemplate() =>
|
||||
new()
|
||||
{
|
||||
Id = 7,
|
||||
Name = "T",
|
||||
TemplateGroupId = 1,
|
||||
TemplateGroup = new TemplateGroup { Name = "G" },
|
||||
Items = []
|
||||
};
|
||||
|
||||
[Test]
|
||||
public void ProgramScheduleAlternate_Null_Collections_Map_To_Unrestricted()
|
||||
{
|
||||
var alternate = new ProgramScheduleAlternate
|
||||
{
|
||||
Id = 1,
|
||||
Index = 0,
|
||||
ProgramScheduleId = 2,
|
||||
DaysOfWeek = null!,
|
||||
DaysOfMonth = null!,
|
||||
MonthsOfYear = null!
|
||||
};
|
||||
|
||||
PlayoutAlternateScheduleViewModel vm = PlayoutsMapper.ProjectToViewModel(alternate);
|
||||
|
||||
vm.DaysOfWeek.ShouldBe(AlternateScheduleSelector.AllDaysOfWeek());
|
||||
vm.DaysOfMonth.ShouldBe(AlternateScheduleSelector.AllDaysOfMonth());
|
||||
vm.MonthsOfYear.ShouldBe(AlternateScheduleSelector.AllMonthsOfYear());
|
||||
|
||||
// Never assigned back: these are single-column primitive collections, so writing the guard onto a
|
||||
// tracked entity would persist the substituted set over the NULL
|
||||
// (media.nullable-primitive-collection-mutation).
|
||||
alternate.DaysOfWeek.ShouldBeNull();
|
||||
alternate.DaysOfMonth.ShouldBeNull();
|
||||
alternate.MonthsOfYear.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PlayoutTemplate_Null_Collections_Map_To_Unrestricted()
|
||||
{
|
||||
var template = new PlayoutTemplate
|
||||
{
|
||||
Id = 1,
|
||||
Index = 0,
|
||||
Template = MinimalTemplate(),
|
||||
DecoTemplate = null,
|
||||
DaysOfWeek = null!,
|
||||
DaysOfMonth = null!,
|
||||
MonthsOfYear = null!
|
||||
};
|
||||
|
||||
PlayoutTemplateViewModel vm = SchedulingMapper.ProjectToViewModel(template);
|
||||
|
||||
vm.DaysOfWeek.ShouldBe(AlternateScheduleSelector.AllDaysOfWeek());
|
||||
vm.DaysOfMonth.ShouldBe(AlternateScheduleSelector.AllDaysOfMonth());
|
||||
vm.MonthsOfYear.ShouldBe(AlternateScheduleSelector.AllMonthsOfYear());
|
||||
|
||||
template.DaysOfWeek.ShouldBeNull();
|
||||
template.DaysOfMonth.ShouldBeNull();
|
||||
template.MonthsOfYear.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An explicitly EMPTY collection is a recorded restriction of no days and must survive the mapper
|
||||
/// unchanged. Without this, a guard written as "empty or null becomes All*" would pass the two tests
|
||||
/// above while silently rewriting real user data on the way out.
|
||||
/// <para>
|
||||
/// There is one of these per MAPPER, not one in total. The two overloads are byte-identical
|
||||
/// triples in different files, so a defensive edit to one alone is exactly the "one helper, two
|
||||
/// callers" shape this repo has been bitten by: covering only the Playouts mapper would leave
|
||||
/// the PlayoutTemplate one free to acquire an `empty-or-null` guard with the suite still green.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void An_Explicitly_Empty_Collection_Is_Not_Rewritten_By_The_PlayoutTemplate_Mapper()
|
||||
{
|
||||
var template = new PlayoutTemplate
|
||||
{
|
||||
Id = 1,
|
||||
Index = 0,
|
||||
Template = MinimalTemplate(),
|
||||
DecoTemplate = null,
|
||||
DaysOfWeek = [],
|
||||
DaysOfMonth = [],
|
||||
MonthsOfYear = []
|
||||
};
|
||||
|
||||
PlayoutTemplateViewModel vm = SchedulingMapper.ProjectToViewModel(template);
|
||||
|
||||
vm.DaysOfWeek.ShouldBeEmpty();
|
||||
vm.DaysOfMonth.ShouldBeEmpty();
|
||||
vm.MonthsOfYear.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <c>ProgramScheduleAlternate</c> half of the same pair — see the PlayoutTemplate one above
|
||||
/// for why there is one per MAPPER rather than one in total.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void An_Explicitly_Empty_Collection_Is_Not_Rewritten()
|
||||
{
|
||||
var alternate = new ProgramScheduleAlternate
|
||||
{
|
||||
Id = 1,
|
||||
Index = 0,
|
||||
ProgramScheduleId = 2,
|
||||
DaysOfWeek = [],
|
||||
DaysOfMonth = [],
|
||||
MonthsOfYear = []
|
||||
};
|
||||
|
||||
PlayoutAlternateScheduleViewModel vm = PlayoutsMapper.ProjectToViewModel(alternate);
|
||||
|
||||
vm.DaysOfWeek.ShouldBeEmpty();
|
||||
vm.DaysOfMonth.ShouldBeEmpty();
|
||||
vm.MonthsOfYear.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -1,416 +0,0 @@
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Scheduling;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// #568: the same full-replace-DTO FK hardening applied to UpdateChannelHandler's
|
||||
/// graphicsElementIds also closes the identical twin defect in UpdateDecoHandler -- both
|
||||
/// graphicsElementIds and watermarkIds are top-level ReplaceDecoRequest fields (not the "deep FK
|
||||
/// ids nested inside item-list request bodies" carve-out in api-conventions.md), and the
|
||||
/// reconcile in ApplyUpdateRequest blindly Adds a join row for every incoming id, so an unknown
|
||||
/// id used to hit the FK constraint at SaveChangesAsync and surface as an unhandled 500.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class UpdateDecoGraphicsElementsTests
|
||||
{
|
||||
private InMemoryTvContext _db = null!;
|
||||
private ChannelWriter<IBackgroundServiceRequest> _channel = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp()
|
||||
{
|
||||
_db = await InMemoryTvContext.CreateAsync();
|
||||
_channel = Substitute.For<ChannelWriter<IBackgroundServiceRequest>>();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
private static bool IsLeft<T>(Either<BaseError, T> result) => result.Match(Right: _ => false, Left: _ => true);
|
||||
|
||||
private async Task SeedDeco()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
context.Decos.Add(
|
||||
new Deco
|
||||
{
|
||||
Id = 1,
|
||||
DecoGroupId = 1,
|
||||
Name = "D",
|
||||
BreakContent = [],
|
||||
DecoWatermarks = [],
|
||||
DecoGraphicsElements = []
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static UpdateDeco MakeUpdate(
|
||||
List<int> graphicsElementIds = null,
|
||||
List<int> watermarkIds = null,
|
||||
DecoMode? graphicsElementsMode = null,
|
||||
DecoMode? watermarkMode = null) =>
|
||||
new(
|
||||
1,
|
||||
1,
|
||||
"D",
|
||||
watermarkMode ?? DecoMode.Inherit,
|
||||
watermarkIds ?? [],
|
||||
false,
|
||||
graphicsElementsMode ?? (graphicsElementIds is null ? DecoMode.Inherit : DecoMode.Override),
|
||||
graphicsElementIds ?? [],
|
||||
false,
|
||||
DecoMode.Inherit,
|
||||
[],
|
||||
DecoMode.Inherit,
|
||||
CollectionType.Collection,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
DecoMode.Inherit,
|
||||
CollectionType.Collection,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
|
||||
private async Task<int> SeedGraphicsElement()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
var element = new GraphicsElement { Path = "element-a.yml" };
|
||||
context.GraphicsElements.Add(element);
|
||||
await context.SaveChangesAsync();
|
||||
return element.Id;
|
||||
}
|
||||
|
||||
private async Task<List<int>> SeedGraphicsElements(int count)
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
List<GraphicsElement> elements = Enumerable.Range(0, count)
|
||||
.Select(i => new GraphicsElement { Path = $"element-{i}.yml" })
|
||||
.ToList();
|
||||
context.GraphicsElements.AddRange(elements);
|
||||
await context.SaveChangesAsync();
|
||||
return elements.Select(e => e.Id).ToList();
|
||||
}
|
||||
|
||||
private async Task<List<int>> SeedWatermarks(int count)
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
List<ChannelWatermark> watermarks = Enumerable.Range(0, count)
|
||||
.Select(i => new ChannelWatermark { Name = $"W{i}" })
|
||||
.ToList();
|
||||
context.ChannelWatermarks.AddRange(watermarks);
|
||||
await context.SaveChangesAsync();
|
||||
return watermarks.Select(w => w.Id).ToList();
|
||||
}
|
||||
|
||||
private async Task<int> SeedWatermark()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
var watermark = new ChannelWatermark { Name = "W" };
|
||||
context.ChannelWatermarks.Add(watermark);
|
||||
await context.SaveChangesAsync();
|
||||
return watermark.Id;
|
||||
}
|
||||
|
||||
private async Task AttachWatermark(int watermarkId)
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
Deco deco = await context.Decos.Include(d => d.DecoWatermarks).SingleAsync(d => d.Id == 1);
|
||||
deco.WatermarkMode = DecoMode.Override;
|
||||
deco.DecoWatermarks.Add(new DecoWatermark { DecoId = 1, WatermarkId = watermarkId });
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task AttachGraphicsElement(int elementId)
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
Deco deco = await context.Decos.Include(d => d.DecoGraphicsElements).SingleAsync(d => d.Id == 1);
|
||||
deco.GraphicsElementsMode = DecoMode.Override;
|
||||
deco.DecoGraphicsElements.Add(new DecoGraphicsElement { DecoId = 1, GraphicsElementId = elementId });
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Removing UpdateDecoHandler.GraphicsElementIdsMustExist alone from Validate is row 36 of the
|
||||
// mutation table in docs/graphics-elements.md, measured against the whole ErsatzTV.Tests project.
|
||||
[Test]
|
||||
public async Task Should_Reject_Unknown_GraphicsElementId_With_A_Validation_Error_Not_A_Throw()
|
||||
{
|
||||
await SeedDeco();
|
||||
|
||||
var handler = new UpdateDecoHandler(_db.Factory, _channel);
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
MakeUpdate(graphicsElementIds: [999]),
|
||||
CancellationToken.None);
|
||||
|
||||
IsLeft(result).ShouldBeTrue();
|
||||
BaseError error = result.Match(Left: e => e, Right: _ => throw new AssertionException("expected Left"));
|
||||
error.Value.ShouldContain("[GraphicsElementIds]");
|
||||
error.Value.ShouldContain("999");
|
||||
|
||||
// no partial write: the deco keeps no graphics element association
|
||||
await using TvContext context = _db.CreateContext();
|
||||
Deco reloaded = await context.Decos.Include(d => d.DecoGraphicsElements).SingleAsync(d => d.Id == 1);
|
||||
reloaded.DecoGraphicsElements.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// Removing UpdateDecoHandler.WatermarkIdsMustExist alone from Validate is row 37 of the
|
||||
// mutation table in docs/graphics-elements.md.
|
||||
[Test]
|
||||
public async Task Should_Reject_Unknown_WatermarkId_With_A_Validation_Error_Not_A_Throw()
|
||||
{
|
||||
await SeedDeco();
|
||||
|
||||
var handler = new UpdateDecoHandler(_db.Factory, _channel);
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
new UpdateDeco(
|
||||
1,
|
||||
1,
|
||||
"D",
|
||||
DecoMode.Override,
|
||||
[999],
|
||||
false,
|
||||
DecoMode.Inherit,
|
||||
[],
|
||||
false,
|
||||
DecoMode.Inherit,
|
||||
[],
|
||||
DecoMode.Inherit,
|
||||
CollectionType.Collection,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
DecoMode.Inherit,
|
||||
CollectionType.Collection,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null),
|
||||
CancellationToken.None);
|
||||
|
||||
IsLeft(result).ShouldBeTrue();
|
||||
BaseError error = result.Match(Left: e => e, Right: _ => throw new AssertionException("expected Left"));
|
||||
error.Value.ShouldContain("[WatermarkIds]");
|
||||
error.Value.ShouldContain("999");
|
||||
}
|
||||
|
||||
// The mode, not the id list, decides whether an id is data. ApplyUpdateRequest reconciles the
|
||||
// join table only under Override/Merge and Clear()s it otherwise, so validating unconditionally
|
||||
// would reject a save the apply path was going to discard. Removing the ConsumesGraphicsElementIds
|
||||
// guard alone from UpdateDecoHandler.GraphicsElementIdsMustExist is row 38 of the mutation table
|
||||
// in docs/graphics-elements.md.
|
||||
[Test]
|
||||
public async Task Should_Ignore_An_Unknown_GraphicsElementId_When_The_Mode_Does_Not_Consume_It()
|
||||
{
|
||||
await SeedDeco();
|
||||
int elementId = await SeedGraphicsElement();
|
||||
await AttachGraphicsElement(elementId);
|
||||
|
||||
var handler = new UpdateDecoHandler(_db.Factory, _channel);
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
MakeUpdate(graphicsElementsMode: DecoMode.Inherit, graphicsElementIds: [999]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
|
||||
// the apply path discards the ids under Inherit, and the existing attachment with them
|
||||
await using TvContext reload = _db.CreateContext();
|
||||
Deco reloaded = await reload.Decos.Include(d => d.DecoGraphicsElements).SingleAsync(d => d.Id == 1);
|
||||
reloaded.GraphicsElementsMode.ShouldBe(DecoMode.Inherit);
|
||||
reloaded.DecoGraphicsElements.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// Twin of the above for the watermark half; removing the ConsumesWatermarkIds guard alone from
|
||||
// UpdateDecoHandler.WatermarkIdsMustExist is row 39 of the mutation table in
|
||||
// docs/graphics-elements.md.
|
||||
[Test]
|
||||
public async Task Should_Ignore_An_Unknown_WatermarkId_When_The_Mode_Does_Not_Consume_It()
|
||||
{
|
||||
await SeedDeco();
|
||||
int watermarkId = await SeedWatermark();
|
||||
await AttachWatermark(watermarkId);
|
||||
|
||||
var handler = new UpdateDecoHandler(_db.Factory, _channel);
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
MakeUpdate(watermarkMode: DecoMode.Disable, watermarkIds: [999]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
|
||||
await using TvContext reload = _db.CreateContext();
|
||||
Deco reloaded = await reload.Decos.Include(d => d.DecoWatermarks).SingleAsync(d => d.Id == 1);
|
||||
reloaded.WatermarkMode.ShouldBe(DecoMode.Disable);
|
||||
reloaded.DecoWatermarks.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Accept_A_Known_GraphicsElementId()
|
||||
{
|
||||
await SeedDeco();
|
||||
|
||||
int elementId = await SeedGraphicsElement();
|
||||
|
||||
var handler = new UpdateDecoHandler(_db.Factory, _channel);
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
MakeUpdate(graphicsElementIds: [elementId]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
|
||||
await using TvContext reload = _db.CreateContext();
|
||||
Deco reloaded = await reload.Decos.Include(d => d.DecoGraphicsElements).SingleAsync(d => d.Id == 1);
|
||||
reloaded.DecoGraphicsElements.Select(x => x.GraphicsElementId).ShouldBe(new[] { elementId });
|
||||
}
|
||||
|
||||
// Both deco id lists go through the same Validators.IdsMustExist as the channel's, so both
|
||||
// inherit the same raw-count cap; the channel fixture pins its edges, these two pin that each
|
||||
// deco field is actually behind it and names itself when it rejects.
|
||||
[Test]
|
||||
public async Task Should_Reject_More_Than_The_Maximum_Number_Of_GraphicsElementIds()
|
||||
{
|
||||
await SeedDeco();
|
||||
|
||||
var handler = new UpdateDecoHandler(_db.Factory, _channel);
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
MakeUpdate(graphicsElementIds: Enumerable.Range(1, Validators.MaximumIdListCount + 1).ToList()),
|
||||
CancellationToken.None);
|
||||
|
||||
IsLeft(result).ShouldBeTrue();
|
||||
BaseError error = result.Match(Left: e => e, Right: _ => throw new AssertionException("expected Left"));
|
||||
error.Value.ShouldContain("[GraphicsElementIds]");
|
||||
error.Value.ShouldContain(Validators.MaximumIdListCount.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Reject_More_Than_The_Maximum_Number_Of_WatermarkIds()
|
||||
{
|
||||
await SeedDeco();
|
||||
|
||||
var handler = new UpdateDecoHandler(_db.Factory, _channel);
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
MakeUpdate(
|
||||
watermarkMode: DecoMode.Override,
|
||||
watermarkIds: Enumerable.Range(1, Validators.MaximumIdListCount + 1).ToList()),
|
||||
CancellationToken.None);
|
||||
|
||||
IsLeft(result).ShouldBeTrue();
|
||||
BaseError error = result.Match(Left: e => e, Right: _ => throw new AssertionException("expected Left"));
|
||||
error.Value.ShouldContain("[WatermarkIds]");
|
||||
error.Value.ShouldContain(Validators.MaximumIdListCount.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
// The mode gate is handed to Validators.IdsMustExist rather than short-circuiting the call,
|
||||
// because only the EXISTENCE half is the apply path's business: a list the reconcile discards
|
||||
// was still parsed and materialized out of the request body. These two pin that the cap holds
|
||||
// under a mode that consumes nothing -- row 47 of the mutation table in
|
||||
// docs/graphics-elements.md. Note the ids all EXIST here, so nothing but the cap can reject
|
||||
// them: a rejection is the cap's, not a smuggled existence check.
|
||||
[Test]
|
||||
public async Task Should_Reject_Too_Many_GraphicsElementIds_Even_Under_A_Mode_That_Does_Not_Consume_Them()
|
||||
{
|
||||
await SeedDeco();
|
||||
List<int> ids = await SeedGraphicsElements(Validators.MaximumIdListCount + 1);
|
||||
|
||||
var handler = new UpdateDecoHandler(_db.Factory, _channel);
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
MakeUpdate(graphicsElementsMode: DecoMode.Inherit, graphicsElementIds: ids),
|
||||
CancellationToken.None);
|
||||
|
||||
IsLeft(result).ShouldBeTrue();
|
||||
BaseError error = result.Match(Left: e => e, Right: _ => throw new AssertionException("expected Left"));
|
||||
error.Value.ShouldContain("[GraphicsElementIds]");
|
||||
error.Value.ShouldContain(Validators.MaximumIdListCount.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Reject_Too_Many_WatermarkIds_Even_Under_A_Mode_That_Does_Not_Consume_Them()
|
||||
{
|
||||
await SeedDeco();
|
||||
List<int> ids = await SeedWatermarks(Validators.MaximumIdListCount + 1);
|
||||
|
||||
var handler = new UpdateDecoHandler(_db.Factory, _channel);
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
MakeUpdate(watermarkMode: DecoMode.Disable, watermarkIds: ids),
|
||||
CancellationToken.None);
|
||||
|
||||
IsLeft(result).ShouldBeTrue();
|
||||
BaseError error = result.Match(Left: e => e, Right: _ => throw new AssertionException("expected Left"));
|
||||
error.Value.ShouldContain("[WatermarkIds]");
|
||||
error.Value.ShouldContain(Validators.MaximumIdListCount.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
// The deco twin of the channel handler's lost-race translation: an element deleted between
|
||||
// Validate and SaveChangesAsync must come back as the validator's own 422, not the FK
|
||||
// exception. Removing the DbUpdateException catch from
|
||||
// UpdateDecoHandler.ApplyUpdateRequestTranslatingLostRace is row 44 of the mutation table in
|
||||
// docs/graphics-elements.md.
|
||||
[Test]
|
||||
public async Task Should_Translate_A_Deco_Element_Deleted_Between_Validation_And_Save_Into_The_Same_422()
|
||||
{
|
||||
var interceptor = new ArmedSaveFailureInterceptor();
|
||||
await _db.DisposeAsync();
|
||||
_db = await InMemoryTvContext.CreateAsync(interceptor);
|
||||
|
||||
await SeedDeco();
|
||||
int elementId = await SeedGraphicsElement();
|
||||
|
||||
interceptor.SqlBeforeFailing =
|
||||
$"DELETE FROM GraphicsElement WHERE Id = {elementId.ToString(CultureInfo.InvariantCulture)}";
|
||||
interceptor.Armed = true;
|
||||
|
||||
var handler = new UpdateDecoHandler(_db.Factory, _channel);
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
MakeUpdate(graphicsElementIds: [elementId]),
|
||||
CancellationToken.None);
|
||||
|
||||
IsLeft(result).ShouldBeTrue();
|
||||
BaseError error = result.Match(Left: e => e, Right: _ => throw new AssertionException("expected Left"));
|
||||
error.Value.ShouldContain("[GraphicsElementIds]");
|
||||
error.Value.ShouldContain(elementId.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
// The watermark half of the same recovery. Without it, removing the watermark question from the
|
||||
// recheck would redden nothing -- and the recheck re-asks the whole of Validate exactly so that
|
||||
// neither id list is the only one covered. Row 46 of the mutation table.
|
||||
[Test]
|
||||
public async Task Should_Translate_A_Deco_Watermark_Deleted_Between_Validation_And_Save_Into_The_Same_422()
|
||||
{
|
||||
var interceptor = new ArmedSaveFailureInterceptor();
|
||||
await _db.DisposeAsync();
|
||||
_db = await InMemoryTvContext.CreateAsync(interceptor);
|
||||
|
||||
await SeedDeco();
|
||||
int watermarkId = await SeedWatermark();
|
||||
|
||||
interceptor.SqlBeforeFailing =
|
||||
$"DELETE FROM ChannelWatermark WHERE Id = {watermarkId.ToString(CultureInfo.InvariantCulture)}";
|
||||
interceptor.Armed = true;
|
||||
|
||||
var handler = new UpdateDecoHandler(_db.Factory, _channel);
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
MakeUpdate(watermarkMode: DecoMode.Override, watermarkIds: [watermarkId]),
|
||||
CancellationToken.None);
|
||||
|
||||
IsLeft(result).ShouldBeTrue();
|
||||
BaseError error = result.Match(Left: e => e, Right: _ => throw new AssertionException("expected Left"));
|
||||
error.Value.ShouldContain("[WatermarkIds]");
|
||||
error.Value.ShouldContain(watermarkId.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user