"""The viewer's judgement, tested without going near the markup.
What is worth asserting here is the part that could be wrong without anybody
noticing: whether a proposal was scored the way the bot scored it, whether the
page can tell "the lance chose badly" from "nobody offered anything", whether a
half-written log still reads, and whether a stance change is spotted. The HTML
is not tested - a page that renders the wrong number correctly is the failure
these tests exist to catch, and asserting on tags would not see it.
"""
import json
import tempfile
import unittest
from pathlib import Path
from sds.viewer import (
INTENTS,
ViewerError,
build_view,
discover,
latch_events,
phase_counters,
proposal_value,
read_decisions,
render,
stance_weights,
unit_label,
unit_names,
)
def stance(**parts):
"""A stance from intent names, normalised the way sds-core normalises one."""
weights = [float(parts.get(name, 0.0)) for name in INTENTS]
total = sum(weights)
if total <= 0:
return {"weights": [1.0 / len(INTENTS)] * len(INTENTS)}
return {"weights": [w / total for w in weights]}
# What the fixtures score with. Two features are enough to pin a dot product,
# and one of each sign is enough to show that a sign is respected.
WEIGHTS = {"expected_damage": 4.0, "heat_incurred": -2.0}
def phi(**parts):
"""A feature vector in the shape sds-core serialises one."""
return {
name: {"value": float(value), "norm": "bounded", "description": f"{name}."}
for name, value in parts.items()
}
def proposal(label, serves=None, dealt=0.0, taken=0.0, risk=0.0, notes=None, features=None):
"""One proposal, as the bot writes it.
`dealt` doubles as the feature the fixtures rank by: a proposal that deals
more measures a higher `expected_damage`, which is what the weights price.
`serves` is gone from the record and is accepted and ignored so the older
fixtures below still read.
"""
del serves
return {
"unit": 12,
"label": label,
"action": {"kind": "move", "steps": []},
"features": features if features is not None else phi(expected_damage=dealt / 20.0),
"damage_dealt": dealt,
"damage_taken": taken,
"risk": risk,
"notes": notes or [],
}
def decision(force="1st Lance", held=3, because=None, dominant="hold", proposals=None, chose=0):
held_stance = stance(hold=1.0) if dominant == "hold" else stance(**{dominant: 1.0})
proposals = proposals or [proposal("stand and shoot", stance(hold=1.0), dealt=10.0)]
return {
"force": force,
"stance": held_stance,
"dominant": dominant,
"held_rounds": held,
"changed_because": because,
"weights": WEIGHTS,
"chosen": [
{
"unit": 12,
"proposal": chose,
"label": proposals[chose]["label"],
"value": proposal_value(proposals[chose], WEIGHTS),
}
],
"considered": [
{
"unit": 12,
"role": "SNIPER",
"appraisal": {"values": [0.0] * len(INTENTS)},
"proposals": proposals,
}
],
}
def record(
seq,
round_no,
phase="MOVEMENT",
outcome="answered",
rationale=None,
seat="Davion",
name=None,
enemy=None,
):
line = {
"seat": seat,
"seq": seq,
"round": round_no,
"phase": phase,
"actor": 12,
"outcome": outcome,
"taken": "stand still",
"action": {"kind": "move", "steps": []},
}
if rationale is not None:
line["rationale"] = rationale
positions = []
if name is not None:
positions.append({"id": 12, "name": name, "friendly": True, "x": 3, "y": 4})
if enemy is not None:
positions.append({"id": 7, "name": enemy, "friendly": False, "x": 9, "y": 9})
if positions:
line["positions"] = positions
return line
class TestNames(unittest.TestCase):
"""A log recorded before `positions` named units must still render."""
def test_a_named_log_labels_the_unit_and_keeps_the_id(self):
records = [record(1, 1, name="Turkina C", rationale=[decision()])]
self.assertEqual(unit_names(records), {12: "Turkina C"})
view = build_view(records)
unit = view["seats"][0]["rounds"][0]["forces"][0]["units"][0]
self.assertEqual(unit["name"], "Turkina C")
self.assertIn("Turkina C (#12)", render(view))
def test_an_old_log_with_no_names_shows_the_bare_id(self):
records = [record(1, 1, rationale=[decision()])]
self.assertEqual(unit_names(records), {})
view = build_view(records)
unit = view["seats"][0]["rounds"][0]["forces"][0]["units"][0]
self.assertIsNone(unit["name"])
page = render(view)
self.assertIn("unit 12", page)
self.assertNotIn("None", page)
def test_a_name_seen_once_labels_every_round(self):
# A unit is only in `positions` once the seat has spotted it, so a
# rationale from a round before that has to borrow the later sighting.
records = [
record(1, 1, rationale=[decision()]),
record(2, 2, name="Turkina C", rationale=[decision()]),
]
view = build_view(records)
first = view["seats"][0]["rounds"][0]["forces"][0]["units"][0]
self.assertEqual(first["name"], "Turkina C")
def test_the_roster_lists_only_what_this_seat_fielded(self):
# `positions` carries the enemy as well; the roster must not.
view = build_view([record(1, 1, name="Turkina C", enemy="Nova Cat E")])
self.assertEqual(view["seats"][0]["roster"], [{"unit": 12, "name": "Turkina C"}])
page = render(view)
self.assertIn("fielded: Turkina C (#12)", page)
self.assertNotIn("Nova Cat E", page)
def test_a_label_falls_back_to_the_id(self):
self.assertEqual(unit_label(4, "Mad Cat"), "Mad Cat (#4)")
self.assertEqual(unit_label(4, None), "unit 4")
class TestReading(unittest.TestCase):
def test_a_truncated_last_line_does_not_stop_the_read(self):
# The live case: the file is being appended to while it is read, so the
# final record is routinely half a line. Everything before it must still
# arrive.
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "m-Davion.decisions.jsonl"
path.write_text(
json.dumps(record(1, 1)) + "\n" + json.dumps(record(2, 1)) + '\n{"seat": "Dav'
)
self.assertEqual(len(read_decisions(path)), 2)
def test_blank_lines_and_non_objects_are_skipped(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "m-Davion.decisions.jsonl"
path.write_text("\n\n[1,2,3]\n" + json.dumps(record(1, 1)) + "\n")
self.assertEqual(len(read_decisions(path)), 1)
class TestDiscover(unittest.TestCase):
def test_a_tag_keeps_its_own_dashes(self):
# The tag the harness generates is full of dashes and the seat name is
# not, which is the only reason a rightmost split is safe.
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "mirror-lance-seed1-a1b2c3d4-Free_Worlds.decisions.jsonl").write_text("")
inputs = discover(root)
self.assertEqual(inputs.tag, "mirror-lance-seed1-a1b2c3d4")
def test_both_seats_of_one_match_are_found_together(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
for seat in ("Davion", "Kurita"):
(root / f"m-seed1-abcd-{seat}.decisions.jsonl").write_text("")
self.assertEqual(len(discover(root).logs), 2)
def test_a_result_document_finds_its_own_logs(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "m-seed1-abcd.json").write_text("{}")
(root / "m-seed1-abcd-Davion.decisions.jsonl").write_text("")
inputs = discover(root / "m-seed1-abcd.json")
self.assertEqual(inputs.tag, "m-seed1-abcd")
self.assertEqual(len(inputs.logs), 1)
self.assertIsNotNone(inputs.result)
def test_a_directory_with_no_logs_says_so(self):
with tempfile.TemporaryDirectory() as tmp, self.assertRaises(ViewerError):
discover(Path(tmp))
class TestValue(unittest.TestCase):
"""`Proposal::value` is reimplemented here; these pin it to the Rust one."""
def test_value_is_the_dot_product_and_nothing_else(self):
scored = proposal("shoot", features=phi(expected_damage=0.5, heat_incurred=0.25))
self.assertAlmostEqual(proposal_value(scored, WEIGHTS), 4.0 * 0.5 - 2.0 * 0.25, places=4)
def test_reported_damage_does_not_reach_the_value(self):
# The property the training corpus depends on: a reader can recompute
# `value` from `phi` and the weights, with nothing else in the sum.
quiet = proposal("shoot", dealt=0.0, features=phi(expected_damage=0.5))
loud = proposal(
"shoot", dealt=99.0, taken=40.0, risk=9.0, features=phi(expected_damage=0.5)
)
self.assertAlmostEqual(proposal_value(quiet, WEIGHTS), proposal_value(loud, WEIGHTS))
def test_an_unweighted_feature_contributes_nothing(self):
plain = proposal("shoot", features=phi(expected_damage=0.5))
extra = proposal("shoot", features=phi(expected_damage=0.5, p_kill=1.0))
self.assertAlmostEqual(proposal_value(plain, WEIGHTS), proposal_value(extra, WEIGHTS))
def test_a_missing_stance_reads_as_no_opinion(self):
self.assertAlmostEqual(sum(stance_weights(None)), 1.0, places=5)
self.assertEqual(len(stance_weights({"weights": [1.0]})), len(INTENTS))
class TestRegret(unittest.TestCase):
"""The distinction the whole epic exists for."""
def test_a_force_that_passed_on_a_better_proposal_shows_regret(self):
offered = [
proposal("shuffle", stance(hold=1.0), dealt=1.0),
proposal("hold the ridge", stance(hold=1.0), dealt=12.0),
]
view = build_view([record(1, 1, rationale=[decision(proposals=offered, chose=0)])])
unit = view["seats"][0]["rounds"][0]["forces"][0]["units"][0]
self.assertGreater(unit["regret"], 0.0)
self.assertGreater(unit["best_value"], unit["chosen_value"])
def test_a_force_that_took_the_best_shows_none(self):
offered = [
proposal("shuffle", stance(hold=1.0), dealt=1.0),
proposal("hold the ridge", stance(hold=1.0), dealt=12.0),
]
view = build_view([record(1, 1, rationale=[decision(proposals=offered, chose=1)])])
unit = view["seats"][0]["rounds"][0]["forces"][0]["units"][0]
self.assertAlmostEqual(unit["regret"], 0.0, places=5)
def test_a_bad_menu_is_not_a_bad_choice(self):
# Every option is worth nothing. Zero regret and a best value at the
# floor: the lance did not choose badly, it was offered nothing.
offered = [
proposal("stand still", features=phi(expected_damage=0.0, heat_incurred=0.5)),
proposal(
"stand still, facing left", features=phi(expected_damage=0.0, heat_incurred=0.9)
),
]
view = build_view([record(1, 1, rationale=[decision(proposals=offered, chose=0)])])
unit = view["seats"][0]["rounds"][0]["forces"][0]["units"][0]
self.assertAlmostEqual(unit["regret"], 0.0, places=5)
self.assertLess(unit["best_value"], 0.0)
class TestCounters(unittest.TestCase):
def test_outcomes_are_tallied_per_phase(self):
records = [
record(1, 1, "MOVEMENT", "answered"),
record(2, 1, "MOVEMENT", "defaulted"),
record(3, 1, "FIRING", "illegal"),
record(4, 1, "FIRING", "failed"),
]
counters = phase_counters(records)
self.assertEqual(counters["MOVEMENT"]["decisions"], 2)
self.assertEqual(counters["MOVEMENT"]["defaulted"], 1)
self.assertEqual(counters["FIRING"]["illegal"], 1)
self.assertEqual(counters["FIRING"]["failed"], 1)
def test_totals_add_up_across_phases(self):
view = build_view(
[record(1, 1, "MOVEMENT", "answered"), record(2, 1, "FIRING", "defaulted")]
)
self.assertEqual(view["seats"][0]["totals"]["decisions"], 2)
self.assertEqual(view["seats"][0]["totals"]["defaulted"], 1)
def test_the_host_counters_are_carried_through_when_the_match_finished(self):
result = {
"outcome": "victory",
"round": 9,
"players": [
{"name": "Davion", "sds": {"decisions": 2, "answered": 1, "defaulted": 1}},
],
}
view = build_view([record(1, 1), record(2, 1, outcome="defaulted")], result)
self.assertEqual(view["seats"][0]["reported"]["decisions"], 2)
class TestRounds(unittest.TestCase):
def test_one_plan_per_force_per_round(self):
# The bot repeats the same rationale on every reply for the rest of the
# round. A viewer that took all of them would draw the same lance four
# times and read as if it re-decided per unit.
rationale = [decision()]
view = build_view([record(seq, 3, rationale=rationale) for seq in range(1, 5)])
rounds = view["seats"][0]["rounds"]
self.assertEqual(len(rounds), 1)
self.assertEqual(len(rounds[0]["forces"]), 1)
def test_seats_are_kept_apart(self):
view = build_view(
[
record(1, 1, rationale=[decision()], seat="Davion"),
record(1, 1, rationale=[decision()], seat="Kurita"),
]
)
self.assertEqual({s["seat"] for s in view["seats"]}, {"Davion", "Kurita"})
def test_a_record_with_no_rationale_still_counts_as_a_decision(self):
view = build_view([record(1, 1, outcome="defaulted")])
self.assertEqual(view["seats"][0]["totals"]["decisions"], 1)
self.assertEqual(view["seats"][0]["rounds"], [])
class TestLatches(unittest.TestCase):
def test_the_first_sighting_of_a_force_opens_rather_than_latches(self):
rounds = [{"round": 1, "forces": [{"force": "1st", "dominant": "hold", "held_rounds": 4}]}]
self.assertEqual([e["kind"] for e in latch_events(rounds)], ["opened"])
def test_a_changed_dominant_intent_is_a_latch(self):
rounds = [
{"round": 1, "forces": [{"force": "1st", "dominant": "hold", "held_rounds": 4}]},
{"round": 2, "forces": [{"force": "1st", "dominant": "hold", "held_rounds": 5}]},
{
"round": 7,
"forces": [
{
"force": "1st",
"dominant": "withdraw",
"held_rounds": 0,
"changed_because": "half the lance is crippled",
}
],
},
]
events = latch_events(rounds)
self.assertEqual([e["kind"] for e in events], ["opened", "latch"])
self.assertEqual(events[1]["round"], 7)
self.assertEqual(events[1]["from"], "hold")
self.assertEqual(events[1]["to"], "withdraw")
self.assertIn("crippled", events[1]["because"])
def test_a_reset_hold_counter_latches_even_within_one_intent(self):
# The stance swung hard but landed on the same dominant intent. Only
# held_rounds can see that, which is why it is checked as well.
rounds = [
{"round": 1, "forces": [{"force": "1st", "dominant": "hold", "held_rounds": 4}]},
{"round": 2, "forces": [{"force": "1st", "dominant": "hold", "held_rounds": 0}]},
]
self.assertEqual([e["kind"] for e in latch_events(rounds)], ["opened", "latch"])
def test_forces_do_not_latch_against_each_other(self):
rounds = [
{
"round": 1,
"forces": [
{"force": "1st", "dominant": "hold", "held_rounds": 3},
{"force": "2nd", "dominant": "flank", "held_rounds": 3},
],
},
{
"round": 2,
"forces": [
{"force": "1st", "dominant": "hold", "held_rounds": 4},
{"force": "2nd", "dominant": "flank", "held_rounds": 4},
],
},
]
self.assertEqual([e["kind"] for e in latch_events(rounds)], ["opened", "opened"])
class TestRender(unittest.TestCase):
"""Not the markup - only that the page is self-contained and complete."""
def _page(self, **kwargs):
offered = [
proposal("shuffle", stance(hold=1.0), dealt=1.0),
proposal("hold the ridge", stance(hold=1.0), dealt=12.0, notes=["in woods"]),
]
view = build_view(
[record(1, 1, rationale=[decision(proposals=offered, chose=0, because="shot at")])],
tag="mirror-lance-seed1-abcd",
)
return render(view, **kwargs)
def test_nothing_is_loaded_from_anywhere(self):
page = self._page()
for forbidden in ("http://", "https://", "
fast & low", stance(close=1.0), dealt=5.0)]
view = build_view([record(1, 1, rationale=[decision(proposals=offered)])])
page = render(view)
self.assertIn("<b>", page)
self.assertNotIn("fast", page)
def test_an_empty_log_still_renders(self):
self.assertIn("no decisions logged yet", render(build_view([])))
if __name__ == "__main__":
unittest.main()