«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>
235 lines
7.2 KiB
Python
235 lines
7.2 KiB
Python
"""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
|