Planlagte sendinger

«Send denne kl. 18:00.» Engangs, ikke gjentakende.

Dette er den første delen av appen som må ligge på disk. Kø og historikk har
alltid bare levd i minnet, og det har gått bra fordi en melding spilles med en
gang. En melding planlagt til 18:00 må overleve at containeren startes om
17:45, så schedule.py lagrer i data/scheduled.json – skrevet til side og
flyttet på plass, så fila aldri er halvskrevet.

Ingen database, med vilje: dette er en håndfull meldinger på et arrangement,
og en fil man kan lese med cat er verdt mer her enn indekser. En ødelagt fil
hindrer ikke oppstart; da er det bedre å miste planene og si det i loggen.

Modulen vet ingenting om Flask eller avspilling. Den lagrer, og den svarer på
«hva skal sendes nå?». _scheduler() i announcer.py eier tråden som spør hvert
20. sekund og legger forfalte meldinger i den vanlige køen – derfra oppfører de
seg nøyaktig som en melding sendt for hånd.

Gikk den glipp av, spilles den ikke. due() skiller på grace_seconds (120):
innenfor den sendes meldingen, utenfor markeres den missed og vises som det.
Nådefristen er slingringsmonn for at tråden våkner med mellomrom og for en
kjapp omstart, ikke en policy om å ta igjen etterslep – «turneringen starter om
ti minutter» skal ikke ut på anlegget en time etter at maskina kom tilbake.
due() endrer ingenting selv; markeringen skjer først etter at meldingen
faktisk er i kø, så ingenting kan stå som «sendt» uten å ha blitt det.

Klienten sender absolutt tidspunkt (epoch), så tidssoner spiller ingen rolle
for selve planleggingen. TZ settes likevel fra .env, ellers står loggen i UTC
mens man selv står i CEST.

Planlegging er låst til samme forhåndslytt-nøkkel som «Send ut»: man planlegger
bare det man har hørt. Det gir en bonus – forhåndslytt varmer cache/, så WAV-en
ligger ferdig på disk, og en planlagt edge-tts-melding spiller kl. 18:00 selv
om nettet er nede da.

Sine egne kan man alltid avbryte. Andres krever clear, samme rett som å tømme
køen, siden det er samme slags inngrep i noe andre har satt i gang.

23 nye tester. Verifisert i container: sending fyrer og spiller, overlever en
omstart av containeren, og en melding som forfalt mens maskina var av blir
markert missed uten å bli spilt.

Merk ved oppgradering: schedule er en ny handling, og et oppsett med eksplisitt
[roles] i config.toml får den ikke automatisk. Den må legges inn selv.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kent 2026-10-08 16:29:16 +02:00
parent 6c1253f54c
commit 04b3712ef5
15 changed files with 746 additions and 15 deletions

235
tests/test_schedule.py Normal file
View file

@ -0,0 +1,235 @@
"""Tester for planlagte sendinger.
Det som er verdt å prøve her er ikke at en melding kan legges i en liste, men
de to tingene som faktisk kan svikte: at lista overlever en omstart, og at en
melding som skulle gått for en time siden *ikke* spilles når maskina kommer
tilbake.
"""
from __future__ import annotations
import json
import sys
import time
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import schedule as sched # noqa: E402
def mk(tmp_path: Path, **kw) -> sched.Schedule:
return sched.Schedule(tmp_path / "scheduled.json", **kw)
def add(s: sched.Schedule, *, om: float = 3600, text: str = "melding", **kw):
"""Legger til en melding «om» så mange sekunder."""
return s.add(at=time.time() + om, text=text, voice="finn", speed=1.0,
chime="none", **kw)
# --------------------------------------------------------------------------
# Lagring – grunnen til at modulen finnes
# --------------------------------------------------------------------------
def test_planen_overlever_omstart(tmp_path):
s = mk(tmp_path)
item = add(s, om=1800, text="Pizzaen kommer", user="Kari", subject="jwt:7")
# Ny instans, som etter en restart av containeren
s2 = mk(tmp_path)
assert len(s2.items) == 1
got = s2.items[0]
assert (got.id, got.text, got.user, got.subject) == (item.id, "Pizzaen kommer", "Kari", "jwt:7")
assert got.status == "pending"
def test_fila_er_lesbar_json(tmp_path):
"""Poenget med fil framfor database: man skal kunne cat'e den."""
s = mk(tmp_path)
add(s, text="Hallo")
raw = json.loads((tmp_path / "scheduled.json").read_text())
assert raw["items"][0]["text"] == "Hallo"
def test_ingen_tmp_fil_ligger_igjen(tmp_path):
s = mk(tmp_path)
add(s)
assert not list(tmp_path.glob("*.tmp")), "atomisk skriving skal rydde etter seg"
def test_odelagt_fil_hindrer_ikke_oppstart(tmp_path):
(tmp_path / "scheduled.json").write_text("{dette er ikke json")
s = mk(tmp_path) # skal ikke kaste
assert s.items == []
add(s, text="virker likevel")
assert len(mk(tmp_path).items) == 1
def test_ukjente_felt_i_fila_ignoreres(tmp_path):
"""Så en nyere versjon av fila ikke velter en eldre app."""
(tmp_path / "scheduled.json").write_text(json.dumps({"items": [{
"id": "abc", "at": time.time() + 60, "text": "x", "voice": "finn",
"speed": 1.0, "chime": "none", "noe_nytt": 42,
}]}))
s = mk(tmp_path)
assert len(s.items) == 1 and s.items[0].id == "abc"
def test_tom_mappe_er_greit(tmp_path):
s = mk(tmp_path / "finnes" / "ikke" / "enda")
add(s)
assert len(s.items) == 1
# --------------------------------------------------------------------------
# Validering
# --------------------------------------------------------------------------
def test_tidspunkt_i_fortiden_avvises(tmp_path):
s = mk(tmp_path)
with pytest.raises(ValueError, match="passert"):
add(s, om=-3600)
def test_tidspunkt_rett_bak_oss_godtas(tmp_path):
"""Innenfor nådefristen; ellers ville «send nå» vært en kappløpssituasjon."""
s = mk(tmp_path, grace=120)
assert add(s, om=-30) is not None
def test_for_langt_fram_avvises(tmp_path):
s = mk(tmp_path, max_ahead=30 * 86400)
with pytest.raises(ValueError, match="dager"):
add(s, om=400 * 86400) # typisk tastefeil i årstall
# --------------------------------------------------------------------------
# due(): skal den sendes, eller gikk den glipp av?
# --------------------------------------------------------------------------
def test_ikke_forfalt_blir_liggende(tmp_path):
s = mk(tmp_path)
add(s, om=600)
send, missed = s.due()
assert (send, missed) == ([], [])
def test_forfalt_innenfor_nadefrist_sendes(tmp_path):
s = mk(tmp_path, grace=120)
item = add(s, om=600)
# Som om klokka har gått fram til litt etter planlagt tid.
send, missed = s.due(now=item.at + 30)
assert [i.id for i in send] == [item.id]
assert missed == []
def test_for_sent_gar_glipp_av_og_spilles_ikke(tmp_path):
"""Kjernen i det Kent valgte: hopp over, men vis det.
Forløpet er: meldingen ble lagt inn i god tid, maskina var av da den
skulle gått, og nå er den tilbake en time for sent. «Turneringen starter
om ti minutter» skal ikke ut på anlegget da."""
s = mk(tmp_path, grace=120)
item = add(s, om=600)
send, missed = s.due(now=item.at + 3600)
assert send == []
assert [i.id for i in missed] == [item.id]
def test_due_endrer_ingenting_selv(tmp_path):
"""announcer.py markerer først etter at meldingen faktisk er i kø, så en
melding ikke kan stå som «sendt» uten å ha blitt det."""
s = mk(tmp_path)
item = add(s, om=600)
s.due(now=item.at + 10)
assert mk(tmp_path).items[0].status == "pending"
def test_avbrutt_forfaller_ikke(tmp_path):
s = mk(tmp_path)
item = add(s, om=600)
s.cancel(item.id)
assert s.due(now=item.at + 10) == ([], [])
def test_sendt_forfaller_ikke_igjen(tmp_path):
s = mk(tmp_path)
item = add(s, om=600)
s.mark(item.id, "sent", "kø #4")
assert s.due(now=item.at + 10) == ([], [])
# --------------------------------------------------------------------------
# Avbryting og markering
# --------------------------------------------------------------------------
def test_avbryting_lagres(tmp_path):
s = mk(tmp_path)
item = add(s)
assert s.cancel(item.id) is not None
assert mk(tmp_path).items[0].status == "cancelled"
def test_avbryting_to_ganger_gir_none(tmp_path):
s = mk(tmp_path)
item = add(s)
s.cancel(item.id)
assert s.cancel(item.id) is None
def test_avbryting_av_ukjent_id_gir_none(tmp_path):
assert mk(tmp_path).cancel("finnes-ikke") is None
def test_mark_lagres(tmp_path):
s = mk(tmp_path)
item = add(s)
s.mark(item.id, "missed", "maskina var av")
got = mk(tmp_path).items[0]
assert (got.status, got.note) == ("missed", "maskina var av")
# --------------------------------------------------------------------------
# Visning
# --------------------------------------------------------------------------
def test_snapshot_sorterer_ventende_kronologisk(tmp_path):
s = mk(tmp_path)
add(s, om=7200, text="sist")
add(s, om=600, text="først")
add(s, om=3600, text="midt")
assert [i["text"] for i in s.snapshot()["pending"]] == ["først", "midt", "sist"]
def test_snapshot_skiller_ventende_fra_ferdige(tmp_path):
s = mk(tmp_path)
a = add(s, om=600, text="venter")
add(s, om=1200, text="venter også")
s.mark(a.id, "sent")
snap = s.snapshot()
assert [i["text"] for i in snap["pending"]] == ["venter også"]
assert [i["text"] for i in snap["done"]] == ["venter"]
def test_subject_lekker_ikke_ut_til_klienten(tmp_path):
"""Navnet skal vises, bruker-id-en er intern."""
s = mk(tmp_path)
add(s, user="Kari", subject="jwt:7")
pub = s.snapshot()["pending"][0]
assert pub["user"] == "Kari"
assert "subject" not in pub
def test_ferdige_hoper_seg_ikke_opp(tmp_path):
s = mk(tmp_path)
for i in range(sched.KEEP_DONE + 20):
item = add(s, om=600 + i, text=f"nr {i}")
s.mark(item.id, "sent")
assert len([i for i in s.items if i.status != "pending"]) == sched.KEEP_DONE