Ctrl K
ComunidadGuías…

tests/test_tarot_openings.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Pre-generated /tarot opening turns — the pure store logic.

The clips are served by id as a PATH COMPONENT (`/api/tarot/opening/<id>.wav`),
so the id shape is the whole traversal defence: these pin that `clip_id_ok`
rejects anything that is not `h<HH>-<8 hex>`, the same structural guarantee
gamesave_store makes with its sha256 path component.

The rest is the pick and the top-up arithmetic the nightly check runs on.
"""
import os
import sys

sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "api"))

from tarot.openings import (clip_id_ok, drop_oldest, empty_index, hour_key,  # noqa: E402
                            pick_clip, shortfall, total_clips)
from tarot.openings_loop import _verdict  # noqa: E402


def _index(**hours):
    idx = empty_index()
    for k, clips in hours.items():
        idx["hours"][k] = clips
    return idx


def _clip(i, created="2026-09-19T00:00:00"):
    return {"id": f"h03-{i:08x}", "text": f"opening {i}", "created": created}


# ── the id is the traversal defence ──────────────────────────────────────────
def test_well_formed_id_accepted():
    assert clip_id_ok("h03-9b499eeb")
    assert clip_id_ok("h00-00000000")
    assert clip_id_ok("h23-ffffffff")


def test_traversal_rejected():
    assert not clip_id_ok("../../etc/passwd")
    assert not clip_id_ok("h03-9b499eeb/../../rd")
    assert not clip_id_ok("h03-9b499eeb.json")


def test_off_shape_ids_rejected():
    assert not clip_id_ok("")
    assert not clip_id_ok(None)
    assert not clip_id_ok("h24-9b499eeb")      # not an hour
    assert not clip_id_ok("h3-9b499eeb")       # hour not zero-padded
    assert not clip_id_ok("h03-9B499EEB")      # hex is lower-case by construction
    assert not clip_id_ok("h03-9b499ee")       # too short
    assert not clip_id_ok("h03-9b499eebb")     # too long


# ── the pick ─────────────────────────────────────────────────────────────────
def test_empty_hour_picks_nothing():
    """None is the signal the page falls back to generating the opening live."""
    assert pick_clip(empty_index(), 3) is None


def test_pick_comes_from_the_asked_hour():
    idx = _index(**{"03": [_clip(1), _clip(2)]})
    for _ in range(20):
        assert pick_clip(idx, 3)["id"] in {_clip(1)["id"], _clip(2)["id"]}
    assert pick_clip(idx, 4) is None


def test_hour_wraps_rather_than_raising():
    """The hour arrives from a client clock; a wrapped hour beats a 500."""
    idx = _index(**{"03": [_clip(1)]})
    assert hour_key(27) == "03"
    assert pick_clip(idx, 27)["id"] == _clip(1)["id"]
    assert hour_key(-1) == "23"


# ── the top-up arithmetic ────────────────────────────────────────────────────
def test_shortfall_counts_every_hour_under_target():
    idx = _index(**{"03": [_clip(i) for i in range(10)], "04": [_clip(99)]})
    short = dict(shortfall(idx, target=10))
    assert "03" not in short          # full
    assert short["04"] == 9
    assert short["00"] == 10
    assert len(short) == 23


def test_total_counts_all_hours():
    idx = _index(**{"03": [_clip(1), _clip(2)], "04": [_clip(3)]})
    assert total_clips(idx) == 3


def test_drop_oldest_drops_by_creation_order():
    idx = _index(**{"03": [
        _clip(1, "2026-01-01T00:00:00"),
        _clip(2, "2026-06-01T00:00:00"),
        _clip(3, "2026-09-01T00:00:00"),
    ]})
    dropped = drop_oldest(idx, 3, 2)
    assert dropped == [_clip(1)["id"], _clip(2)["id"]]
    assert [c["id"] for c in idx["hours"]["03"]] == [_clip(3)["id"]]


# ── the nightly verdict: three ways to be quiet, not one ─────────────────────
def _probe(ok=False, error="tts upstream unreachable", first_ms=None):
    return {"ok": ok, "error": error, "first_ms": first_ms, "total_ms": 500, "audio_s": 2.2}


def test_a_working_voice_reads_ok():
    assert _verdict(_probe(ok=True, error=None, first_ms=362), "idle").startswith("OK  ")


def test_slow_first_audio_is_flagged_but_still_ok():
    """A synth that worked is not a failure — but 4s to first audio on a box
    that should be warm is the cold load this whole feature exists to avoid."""
    assert _verdict(_probe(ok=True, error=None, first_ms=4000), "homo").startswith("OK  SLOW")


def test_a_deliberately_stopped_server_is_not_a_fault():
    assert _verdict(_probe(), "idle").startswith("voice down (mode=idle)")
    assert _verdict(_probe(), "emo").startswith("voice down (mode=emo)")


def test_an_unreachable_box_is_reported_as_unreachable_not_as_stopped():
    """`gone` means the box never answered — asleep, off, or its tunnel down.
    Calling that a deliberate stop sends the reader looking in the wrong place."""
    line = _verdict(_probe(), "gone")
    assert line.startswith("voice unreachable (mode=gone)")
    assert "tunnel" in line


def test_homo_serving_nothing_is_the_loud_one():
    assert _verdict(_probe(), "homo").startswith("FAIL (mode=homo)")