Commit 04fc8c7c by PLN (Algolia)

feat(gig): the backlog IS the setlist — one parser, and the set was 15 not 13 (#123)

PLN's ruling yesterday: "set SSOT is the backlog entry, always. if in the backlog
i add a track or reorder them, its the truth." Then today, the part that decided
the design: "if i added new ones, id not need to tell you to update, it should
flow downstream, ditto if remove one."

So this is not a resolution, it is a pipe.

WHAT WAS ACTUALLY WRONG. Applying the ruling for the first time found the backlog
listing 15 tracks while every tool in the repo believed 13:

    missing everywhere:  ouais_je_funk (120)   lime_tacos (110)
    wrong order:         backlog has you_my_sunshine (166) BEFORE mafia_sans_serif

ouais_je_funk was not a stale wishlist entry — PLN committed a kick rework to it
in 23f60e76, yesterday. And the drift had already cost him something audible:

    preload warmed   50 banks   (13 tracks)
    the set needs    53 banks   (15 tracks)
    would cold-load  dr  gfunk_bass  h2ogmcy

Three banks read from disk on first play. That is the same mechanism PLN heard as
crackle on the take_5_drops transition (#120) — a bug we thought was fixed, still
live, just smaller. check-preload.sh reported "ok" the whole time, because it asked
set-coherence, which was one of the wrong answers. The freshness check had
inherited the staleness it was built to catch.

ROOT CAUSE: eight tools each hardcoding `SETLIST = armada/setlist_opal2026.txt`,
and one human keeping that file in sync by hand.

THE SHAPE, and why it is not "make all eight import a module". The .txt stays
exactly where it is, same format, so all eight consumers are untouched — this is
J-7 to a gig and a refactor that breaks a tool costs more than it buys. What
changes is its STATUS: generated, never hand-edited, with a HARD gig-up check
that it still matches the backlog. One parser, one artifact, one answer at a time,
and a gate that fails loudly the moment they drift.

  tools/setlist.py         the parser. Resolves PLN's freeform codenames
                           ("Ceci n'est pas Une Bombe" -> bombe_dj, "Le shifteur
                           marteau ? to finish!" -> electric_hammer) via aliases +
                           token containment, and raises rather than guessing.
  gig-up --converge        regenerates the setlist BEFORE preload. That order is
                           load-bearing: preload reads the setlist, so doing it the
                           other way warms the old set.
  gig-up "setlist vs backlog"   HARD, and placed before every check that reads the
                           setlist — a stale list makes "setlist compiles",
                           "preload covers set" and "transition ghosts" all answer
                           about a set PLN is not playing.

WHAT THE END-OF-SECTION DETECTION TAUGHT ME. First attempt whitelisted the
headings that count as part of the set ({FINALE, Encore, ...}) and died on line
one against "## Livecoding Techno DNB Nu-jazz". PLN's headings are prose; any
whitelist is a list of the ones he happened to write that day. The working rule
ignores heading semantics entirely: run to the next top-level heading, take the
list items. Non-track content does not survive that filter — "> Sunset Forest",
"1h30 - 20h30-22h", "??TRANSITION???" and the trailing url: block are none of
them list items.

VALIDATION, and specifically not "it returns the 15 tracks currently in the
backlog" — a hardcoded list passes that too. tools/tests/test_setlist.py MUTATES
a synthetic backlog and asserts the output moved: add a track (appears, in PLN's
position, not appended), remove one (disappears), swap two lines (output swaps),
unresolvable name / duplicate / missing section (all raise). 18/18.
Then the gate itself, mutated: dropped a line from the .txt -> caught; swapped
two lines, same membership -> caught as "same tracks, DIFFERENT ORDER".

AND IT PAID OFF IMMEDIATELY. With the real 15 in the setlist, pvlint went red on
two tracks that had been invisible to every tool until today: an unmutable orbit
in ouais_je_funk (gF with no gM3 — nothing on the surface can drop it) and button
^44 driving both d3 and d6 in lime_tacos (one press, two orbits). Neither is new
breakage; both were simply never looked at. Left for PLN — they are musical calls.

The 53 banks are warm on the rig now: 54/54 banks OK in 7.0 s.
parent fbe427ea
# OPAL 2026 — the set, in play order.
#
# One track per line: <repo-relative .tidal path> # <codename> [<bpm>]
# Blank lines and #-comments ignored. Order matters: tools/orphan-orbits.py walks
# it pairwise to find which orbits ghost across each transition (#68), so
# reordering this file re-answers that question.
# GENERATED by tools/setlist.py from backlog.md's "## OPAL 2026" section.
# DO NOT EDIT THIS FILE — edit the backlog, then run:
# tools/gig-up.sh --converge (or: tools/setlist.py --emit)
#
# Resolved from backlog.md's "## OPAL 2026" section (the canonical informal
# source), which lists codenames only. The mapping was verified by locating each
# file, not guessed: "Mafia" is mafia_sans_serif, "Sunshine" is you_my_sunshine,
# "Le shifteur marteau" is electric_hammer, "Take five Drops" is take_5_drops.
# Section headings from the backlog are kept as comments so the arc stays visible.
# PLN's ruling 2026-08-01: the backlog entry is the SSOT for membership AND
# order. Hand-editing this file would recreate the exact drift it was
# generated to end — see tools/setlist.py's header and #123.
#
# Order matters beyond bookkeeping: tools/orphan-orbits.py walks it pairwise
# to find which orbits ghost across each transition (#68).
# --- Ouverture ---
live/midi/nova/techno/bombe_dj.tidal # Ceci n'est pas Une Bombe [124]
live/midi/nova/dnb/wap.tidal # WAP [133]
live/techno/do_it_right.tidal # Am i Doing it Right? [89]
live/techno/do_it_right.tidal # Am i _Doing it Right_ [89]
# --- We call it NuJazz ---
live/midi/nova/lounge/take_5_drops.tidal # Take five Drops [124]
live/collab/raph/piment_bresilien.tidal # Piment Bresilien [124]
live/midi/nova/lounge/ouais_je_funk.tidal # Eh ouais je Funk [120]
live/midi/nova/dnb/lime_tacos.tidal # Lime Tacos molotov acid [110]
live/midi/nova/remix/perfect.tidal # Perfect <3 [128]
live/midi/nova/acid/gimme_acid.tidal # Gimme Acid??!? [160]
live/midi/nova/acid/gimme_acid.tidal # Gimme Acid [160]
live/techno/vague_de_crime.tidal # Vague de CRIME [120]
live/collab/raph/mafia_sans_serif.tidal # Mafia [160]
live/midi/nova/dnb/liquid/you_my_sunshine.tidal # Sunshine [166]
live/collab/raph/mafia_sans_serif.tidal # Mafia [160]
# --- Finale ---
# --- FINALE ---
live/collab/raph/desire.tidal # Desire [129]
# --- Encore ---
live/midi/nova/jazz/the_revolution_will_be_sampled.tidal # REVOLUTION
live/midi/nova/techno/electric_hammer.tidal # Le shifteur marteau
live/midi/nova/techno/electric_hammer.tidal # Le shifteur marteau ? to finish
......@@ -140,7 +140,25 @@ if (( CONVERGE )); then
(( QUIET )) || { echo " ${D}converge — starting what is missing${Z}"; }
did=0
# Preload FIRST: fixing it after SuperDirt has booted would warm nothing until the
# THE SETLIST BEFORE EVERYTHING, because everything downstream reads it — including
# the preload plan immediately below. PLN's ruling 2026-08-01: backlog.md is the SSOT
# for membership AND order, and "if i added new ones, id not need to tell you to
# update, it should flow downstream". This is where that flow starts (#123).
#
# ORDER OF THESE TWO BLOCKS IS LOAD-BEARING. Regenerating preload first would warm
# the OLD set: that is exactly the 2026-08-01 bug, where the backlog had 15 tracks,
# every tool believed 13, and three banks (dr, gfunk_bass, h2ogmcy) were left to
# lazy-load from disk mid-transition.
if ! python3 tools/setlist.py --check >/dev/null 2>&1; then
if python3 tools/setlist.py --emit >>"$LOG" 2>&1; then
echo " ${G}+${Z} setlist regenerated from backlog.md"; did=1
else
# A backlog we cannot parse must never fall back to the stale file in silence.
echo " ${R}!${Z} backlog.md's OPAL entry does not parse — see: tools/setlist.py"
fi
fi
# Preload SECOND: fixing it after SuperDirt has booted would warm nothing until the
# next restart, and the whole point is that the boot reads the set's banks off disk.
if ! tools/check-preload.sh >/dev/null 2>&1; then
tools/check-preload.sh --fix >>"$LOG" 2>&1 && { echo " ${G}+${Z} preload plan regenerated"; did=1; }
......@@ -207,7 +225,7 @@ run "tools executable" \
bash -c '
bad=0
for f in tools/gig-up.sh tools/check-boot.sh tools/check-tracks.sh \
tools/check-preload.sh tools/sc-watchdog.sh; do
tools/check-preload.sh tools/sc-watchdog.sh tools/setlist.py; do
[ -e "$f" ] || continue
[ -x "$f" ] || { echo "not executable on disk: $f"; bad=1; }
m=$(git ls-files -s "$f" 2>/dev/null | awk "{print \$1}")
......@@ -215,6 +233,20 @@ run "tools executable" \
done
exit $bad'
# --- 1b. the setlist still matches the backlog -------------------------------
# HARD, and placed before every check that reads the setlist, because if this is stale
# then "every setlist track compiles", "the preload plan covers the set" and "transition
# ghosts" are all answering about a set PLN is not playing. That is not a hypothetical:
# on 2026-08-01 the backlog said 15 tracks and all eight setlist consumers said 13, so
# three sample banks went unwarmed and PLN heard it as crackle mid-transition (#120).
#
# The remedy is `--emit`, never a hand-edit: armada/setlist_opal2026.txt is a GENERATED
# artifact now, and hand-editing it is how a generated file drifts from its source
# ([[feedback_parsers_over_copy]]).
run "setlist vs backlog" \
"tools/setlist.py --emit (edit backlog.md, never the .txt)" \
python3 tools/setlist.py --check
# --- 2. the saved Ardour faders ----------------------------------------------
run "ardour faders" \
"raise it on the desk, then Ctrl+S in Ardour, then re-run. Still -inf after a save = genuinely down." \
......
#!/usr/bin/env python3
"""setlist — THE parser for what a set IS, and in what order.
tools/setlist.py # the ordered track paths
tools/setlist.py --long # + codename, bpm, section
tools/setlist.py --check # is armada/setlist_opal2026.txt in sync? exit 1 if not
tools/setlist.py --emit # regenerate it from backlog.md
WHY THIS EXISTS (PLN's ruling, 2026-08-01):
"set SSOT is the backlog entry, always. if in the backlog i add a track or
reorder them, its the truth."
"if i added new ones, id not need to tell you to update, it should flow
downstream, ditto if remove one"
So `backlog.md`'s "## OPAL 2026" section is canonical for BOTH membership and ORDER,
and every other representation of the set is a derived artifact that must follow it
without anyone being told.
WHAT WENT WRONG WITHOUT IT. On 2026-08-01 the backlog listed 15 tracks and every tool
in the repo believed 13 — `ouais_je_funk` and `lime_tacos` were missing, and
you_my_sunshine/mafia_sans_serif were in the wrong order. Consequence: preload warmed
50 banks where the set needs 53, so `dr`, `gfunk_bass` and `h2ogmcy` would have been
read from DISK on first play. That is the same mechanism PLN heard as crackle on the
take_5_drops transition the day before (#120). `check-preload.sh` reported "ok" the
whole time, because it asked one of the wrong answers.
The root cause was EIGHT tools each hardcoding `SETLIST = armada/setlist_opal2026.txt`
and one human keeping that file in sync by hand. See [[feedback_one_parser_per_concept]]
and [[feedback_parsers_over_copy]]: a catalog is a GENERATED ARTIFACT, and a parser miss
must never be papered over by hand-editing the artifact.
THE SHAPE, and why it is not "make all eight import this module":
`armada/setlist_opal2026.txt` stays exactly where it is and keeps its format, so all
eight consumers are untouched (this is J-7 to a gig; a refactor that breaks a tool costs
more than it buys). What changes is its STATUS: it is now generated, never hand-edited,
and `gig-up.sh` carries a HARD check that it still matches backlog.md. One parser, one
artifact, one answer at any moment — with a gate that fails loudly the moment they drift.
DO NOT MECHANIZE backlog.md. It is PLN's informal working document
([[reference_gig_tracklist_sources]]) — freeform, full of "??TRANSITION???" and "<3".
This reads it; it must never write it.
"""
from __future__ import annotations
import argparse
import dataclasses
import pathlib
import re
import sys
import unicodedata
ROOT = pathlib.Path(__file__).resolve().parent.parent
BACKLOG = ROOT / "backlog.md"
SETLIST = ROOT / "armada" / "setlist_opal2026.txt"
GIG = "OPAL 2026"
# Codename -> file stem, for the names normalisation cannot reach on its own. Each of
# these was verified by locating the actual file, not guessed; the mapping is inherited
# from the hand-resolved setlist_opal2026.txt, where it had already been checked.
# Matched as a SUBSTRING of the normalised codename (longest key first), so PLN's
# trailing chatter -- "Le shifteur marteau ? to finish!" -- does not break resolution.
ALIASES = {
"ceci n est pas une bombe": "bombe_dj",
"le shifteur marteau": "electric_hammer",
"am i doing it right": "do_it_right",
"take five drops": "take_5_drops",
"eh ouais je funk": "ouais_je_funk",
"lime tacos": "lime_tacos",
"revolution": "the_revolution_will_be_sampled",
"sunshine": "you_my_sunshine",
"mafia": "mafia_sans_serif",
}
# A backlog line is a track if it is a list item. PLN uses both "-" and "--" and the
# distinction is not consistent enough across the file to carry meaning, so both count.
ITEM = re.compile(r"^\s*-{1,2}\s+(.*)$")
HEADING = re.compile(r"^(#{1,6})\s+(.*)$")
BRACKET = re.compile(r"\[([^\]]*)\]")
BPM = re.compile(r"^(\d{2,3})\*?$")
@dataclasses.dataclass(frozen=True)
class Entry:
path: pathlib.Path # repo-relative
codename: str # as PLN wrote it, cleaned of tags
bpm: int | None
tags: tuple[str, ...] # e.g. ("lightT",)
section: str # "Ouverture", "We call it NuJazz", "FINALE", "Encore"
class SetlistError(RuntimeError):
"""Raised loudly. Never guess a setlist -- a silent fallback to a stale list is
exactly the failure mode this module retires."""
def _norm(s: str) -> str:
"""Fold to plain lowercase words: accents, markdown emphasis and punctuation out."""
s = unicodedata.normalize("NFKD", s)
s = "".join(c for c in s if not unicodedata.combining(c))
s = s.lower().replace("_", " ")
s = re.sub(r"[^a-z0-9]+", " ", s)
return " ".join(s.split())
def _index() -> dict[str, list[pathlib.Path]]:
"""stem -> the live/ files carrying it. Built fresh every call: a new track file
must be visible the moment PLN adds it to the backlog."""
out: dict[str, list[pathlib.Path]] = {}
for p in (ROOT / "live").rglob("*.tidal"):
out.setdefault(p.stem, []).append(p)
return out
def _resolve(codename: str, index: dict[str, list[pathlib.Path]]) -> pathlib.Path:
n = _norm(codename)
if not n:
raise SetlistError(f"empty track name in {BACKLOG.name}")
stem = None
for key in sorted(ALIASES, key=len, reverse=True):
if key in n:
stem = ALIASES[key]
break
if stem is None:
direct = n.replace(" ", "_")
if direct in index:
stem = direct
else:
# Token containment, both directions: "Piment Bresilien" -> piment_bresilien,
# and a codename with extra words still finds a shorter stem.
want = set(n.split())
scored = []
for cand in index:
toks = set(_norm(cand).split())
if toks <= want or want <= toks:
scored.append((len(toks & want), -len(toks ^ want), cand))
if not scored:
raise SetlistError(
f'cannot resolve "{codename}" to a .tidal file under live/.\n'
f" Add it to ALIASES in tools/setlist.py, or rename the file.")
scored.sort(reverse=True)
best = scored[0]
ties = [c for s, d, c in scored if (s, d) == (best[0], best[1])]
if len(ties) > 1:
raise SetlistError(
f'"{codename}" is ambiguous: {", ".join(sorted(ties))}.\n'
f" Pin it in ALIASES in tools/setlist.py.")
stem = best[2]
paths = index.get(stem)
if not paths:
raise SetlistError(f'"{codename}" resolves to "{stem}" but no such file exists')
if len(paths) > 1:
rel = sorted(str(p.relative_to(ROOT)) for p in paths)
raise SetlistError(f'"{codename}" -> "{stem}" matches {len(paths)} files: '
f'{", ".join(rel)}')
return paths[0].relative_to(ROOT)
def entries(gig: str = GIG, backlog: pathlib.Path | None = None) -> list[Entry]:
"""Ordered entries for a gig's backlog section. Raises SetlistError, never guesses."""
src = backlog or BACKLOG
if not src.exists():
raise SetlistError(f"no backlog at {src}")
lines = src.read_text().splitlines()
start = None
for i, ln in enumerate(lines):
m = HEADING.match(ln)
if m and _norm(m.group(2)) == _norm(gig):
start, depth = i + 1, len(m.group(1))
break
if start is None:
raise SetlistError(f'no "{gig}" section in {src.name}')
index = _index()
out: list[Entry] = []
section = ""
for ln in lines[start:]:
m = HEADING.match(ln)
if m:
# Do NOT try to tell "part of the set" from "a new topic" by heading text.
# The first attempt whitelisted {FINALE, Encore, ...} and died instantly on
# "## Livecoding Techno DNB Nu-jazz" — PLN's headings are prose, and any
# whitelist is a list of the ones he happened to have written that day.
#
# The set's own structure is all at this depth or deeper ("## FINALE",
# "### _Ouverture_"), so we run to the next TOP-level heading or EOF and
# simply take the list items. Non-track content in between does not survive
# that filter: "> Sunset Forest", "1h30 - 20h30-22h", "??TRANSITION???" and
# the trailing `url: "..."` image block are none of them list items.
if len(m.group(1)) < depth:
break
section = m.group(2).strip("_# ").split(":")[0].strip()
continue
im = ITEM.match(ln)
if not im:
continue
raw = im.group(1).strip()
# A markdown link is a reference, not a track ("- [MathMusic](https://...)").
if not raw or re.match(r"^\[[^\]]*\]\(", raw) or raw.startswith(("http", "url:")):
continue
tags = tuple(t.strip() for t in BRACKET.findall(raw))
name = BRACKET.sub(" ", raw)
bpm = next((int(BPM.match(t).group(1)) for t in tags if BPM.match(t)), None)
out.append(Entry(path=_resolve(name, index),
codename=" ".join(name.split()).strip(" ?!.-"),
bpm=bpm,
tags=tuple(t for t in tags if not BPM.match(t)),
section=section))
if not out:
raise SetlistError(f'"{gig}" section in {src.name} lists no resolvable tracks')
dupes = {p for p in (e.path for e in out) if [e.path for e in out].count(p) > 1}
if dupes:
raise SetlistError("track listed twice: "
+ ", ".join(sorted(str(d) for d in dupes)))
return out
def tracks(gig: str = GIG) -> list[pathlib.Path]:
"""Ordered repo-relative paths. The one function every consumer should want."""
return [e.path for e in entries(gig)]
def render(es: list[Entry]) -> str:
"""The generated setlist_opal2026.txt. Same format the eight consumers already parse:
one repo-relative path per line, #-comments and blanks ignored, order = play order."""
w = max(len(str(e.path)) for e in es) + 2
L = [f"# {GIG} — the set, in play order.",
"#",
"# GENERATED by tools/setlist.py from backlog.md's \"## OPAL 2026\" section.",
"# DO NOT EDIT THIS FILE — edit the backlog, then run:",
"# tools/gig-up.sh --converge (or: tools/setlist.py --emit)",
"#",
"# PLN's ruling 2026-08-01: the backlog entry is the SSOT for membership AND",
"# order. Hand-editing this file would recreate the exact drift it was",
"# generated to end — see tools/setlist.py's header and #123.",
"#",
"# Order matters beyond bookkeeping: tools/orphan-orbits.py walks it pairwise",
"# to find which orbits ghost across each transition (#68).",
""]
sec = None
for e in es:
if e.section != sec:
sec = e.section
L += ["", f"# --- {sec} ---"] if sec else [""]
bpm = f" [{e.bpm}]" if e.bpm else ""
L.append(f"{str(e.path):<{w}}# {e.codename}{bpm}")
return "\n".join(L).strip() + "\n"
def main() -> int:
ap = argparse.ArgumentParser(prog="setlist", description=__doc__.split("\n")[0])
ap.add_argument("--gig", default=GIG)
ap.add_argument("--long", action="store_true", help="codename, bpm, section too")
ap.add_argument("--check", action="store_true", help="exit 1 if the .txt is stale")
ap.add_argument("--emit", action="store_true", help="regenerate the .txt")
a = ap.parse_args()
try:
es = entries(a.gig)
except SetlistError as exc:
print(f"setlist: {exc}", file=sys.stderr)
return 2
if a.emit:
text = render(es)
stale = not SETLIST.exists() or SETLIST.read_text() != text
SETLIST.write_text(text)
print(f"setlist: {'regenerated' if stale else 'already current'} "
f"{SETLIST.relative_to(ROOT)} — {len(es)} tracks")
return 0
if a.check:
if not SETLIST.exists():
print(f"setlist: {SETLIST.relative_to(ROOT)} does not exist", file=sys.stderr)
return 1
want = [e.path for e in es]
have = [pathlib.Path(ln.split("#")[0].strip())
for ln in SETLIST.read_text().splitlines() if ln.split("#")[0].strip()]
if have == want:
print(f"setlist: ok — {len(want)} tracks, matching backlog.md")
return 0
print(f"setlist: STALE — {SETLIST.relative_to(ROOT)} disagrees with backlog.md")
for p in want:
if p not in have:
print(f" backlog has, file missing: {p}")
for p in have:
if p not in want:
print(f" file has, backlog dropped: {p}")
if sorted(have) == sorted(want):
print(" same tracks, DIFFERENT ORDER — order is truth too (#123).")
print(" fix: tools/setlist.py --emit (never hand-edit the .txt)")
return 1
for e in es:
if a.long:
bpm = f"{e.bpm:>3}" if e.bpm else " ?"
print(f"{bpm} {e.section:<20} {str(e.path):<52} {e.codename}")
else:
print(e.path)
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""Does tools/setlist.py actually FOLLOW backlog.md, or does it merely agree with it today?
PLN's requirement (2026-08-01) is a live one, not a one-off resolution:
"if i added new ones, id not need to tell you to update, it should flow
downstream, ditto if remove one"
A test that only asserts "the parser returns the 15 tracks currently in the backlog"
would pass just as happily against a hardcoded list — which is precisely the artifact
being retired here. So every test below MUTATES a synthetic backlog and asserts the
output moved with it. Add / remove / reorder / break: the parser must track all four.
python3 tools/tests/test_setlist.py
"""
from __future__ import annotations
import importlib.util
import pathlib
import sys
import tempfile
ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
spec = importlib.util.spec_from_file_location("setlist", ROOT / "tools" / "setlist.py")
S = importlib.util.module_from_spec(spec)
# Register BEFORE exec: on Python 3.14 @dataclass resolves its own module via
# sys.modules[cls.__module__], which is None for a spec-loaded module that was never
# registered — it raises AttributeError inside dataclasses, nowhere near the real cause.
sys.modules["setlist"] = S
spec.loader.exec_module(S)
FAILED: list[str] = []
def check(name: str, cond: bool, detail: str = "") -> None:
print(f" {'ok ' if cond else 'FAIL'} {name}" + (f" {detail}" if detail else ""))
if not cond:
FAILED.append(name)
def backlog(body: str) -> pathlib.Path:
"""A throwaway backlog whose OPAL section is `body`. Deliberately reproduces the
awkward shape of the real file: a prose h2 right after the gig heading (the thing
that broke the first implementation), a blockquote, a bare duration line, PLN's
??TRANSITION??? markers, and a trailing image block of non-list `url:` lines."""
f = tempfile.NamedTemporaryFile("w", suffix=".md", delete=False)
f.write("# Some earlier section\n- ignore me\n\n"
"## OPAL 2026\n\n"
"## Livecoding Techno DNB Nu-jazz\n"
"> Sunset Forest\n1h30 - 20h30-22h\n"
f"### _Ouverture_\n{body}\n"
"## Images\n"
' url: "/home/pln/Work/Art/GLITCHWAVE/outputs/digital_flow.gif"\n')
f.close()
return pathlib.Path(f.name)
def paths(body: str) -> list[str]:
return [str(p) for p in (e.path for e in S.entries("OPAL 2026", backlog(body)))]
BASE = ("-- [lightT][124] Ceci n'est pas Une Bombe\n"
"-- [lightT][133] WAP\n"
"??TRANSITION???\n"
"-- [129] Desire [TODO BASS ETC!]\n")
BOMBE = "live/midi/nova/techno/bombe_dj.tidal"
WAP = "live/midi/nova/dnb/wap.tidal"
DESIRE = "live/collab/raph/desire.tidal"
FUNK = "live/midi/nova/lounge/ouais_je_funk.tidal"
print(__doc__.strip().splitlines()[0])
print("\nbaseline")
base = paths(BASE)
check("resolves PLN's freeform codenames", base == [BOMBE, WAP, DESIRE], f"{base}")
check("ignores ??TRANSITION???, blockquote, duration, url: block", len(base) == 3)
print("\nmutation: ADD a track (the #123 requirement)")
added = paths(BASE.replace("-- [lightT][133] WAP\n",
"-- [lightT][133] WAP\n-- [highTech][120] Eh ouais je Funk?\n"))
check("a new backlog line appears downstream", added == [BOMBE, WAP, FUNK, DESIRE],
f"{added}")
check("it lands in PLN's position, not appended", added.index(FUNK) == 2)
print("\nmutation: REMOVE a track")
removed = paths(BASE.replace("-- [lightT][133] WAP\n", ""))
check("a deleted backlog line disappears downstream", removed == [BOMBE, DESIRE],
f"{removed}")
print("\nmutation: REORDER (order is truth too)")
reordered = paths("-- [lightT][133] WAP\n-- [lightT][124] Ceci n'est pas Une Bombe\n")
check("order follows the backlog", reordered == [WAP, BOMBE], f"{reordered}")
# Swapping the two backlog lines must swap the output. (Do NOT assert "not sorted()"
# here: dnb/wap genuinely sorts before techno/bombe_dj, so that assertion passes or
# fails on path spelling, not on ordering behaviour. It cost a red herring once.)
check("...and swapping the lines swapped the output", reordered == base[1::-1] != base[:2])
print("\nloudness: it must never guess")
try:
paths("-- [124] Un Morceau Qui N'Existe Pas\n")
check("unresolvable name raises", False, "it returned silently")
except S.SetlistError as e:
check("unresolvable name raises SetlistError", True)
check("...and names the offender", "Un Morceau" in str(e), str(e).split("\n")[0])
try:
paths("-- [124] WAP\n-- [133] WAP\n")
check("duplicate track raises", False, "it returned silently")
except S.SetlistError as e:
check("duplicate track raises SetlistError", True, str(e).split("\n")[0])
try:
S.entries("NO SUCH GIG 1999", backlog(BASE))
check("missing gig section raises", False, "it returned silently")
except S.SetlistError:
check("missing gig section raises SetlistError", True)
print("\nthe real backlog")
real = S.entries()
check("parses backlog.md", len(real) >= 13, f"{len(real)} tracks")
check("every track file exists", all((ROOT / e.path).exists() for e in real))
check("ouais_je_funk is in the set (was missing from all 8 tools)",
any(e.path.name == "ouais_je_funk.tidal" for e in real))
check("lime_tacos is in the set", any(e.path.name == "lime_tacos.tidal" for e in real))
names = [e.path.stem for e in real]
check("you_my_sunshine precedes mafia_sans_serif (PLN 2026-08-01)",
names.index("you_my_sunshine") < names.index("mafia_sans_serif"))
check("the generated .txt round-trips through the parser",
[pathlib.Path(ln.split("#")[0].strip())
for ln in S.render(real).splitlines() if ln.split("#")[0].strip()]
== [e.path for e in real])
print(f"\n{len(FAILED)} failed" if FAILED else "\nall passed")
sys.exit(1 if FAILED else 0)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment