forked from retoor/devplacepy
Continuous Delivery could never be turned off once a crop was planted. Add a one-shot per-plot "stop after harvest" flag (POST /game/plot/stop, Devii tool game_plot_stop, game_plots.stop_after_harvest), cleared by the next plant, autoreplant claim or Refactor. Autoreplant now also respects crop locks through the shared _crop_lock predicate. Plan and measurements in happyplanting.md. Also includes the pending community sync, node network and related work in the tree; the full suite passed (4999) with all of it. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
227 lines
11 KiB
Python
227 lines
11 KiB
Python
# retoor <retoor@molodetz.nl>
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from tests.conftest import run_async
|
|
|
|
from devplacepy.database import db, get_setting, get_table, init_db, set_setting
|
|
from devplacepy.services.community.ledger import (
|
|
LEDGER,
|
|
Ledger,
|
|
OUTCOME_KEYS,
|
|
REASON_TEXT,
|
|
explain,
|
|
label_for,
|
|
tally_of,
|
|
verdict,
|
|
)
|
|
from devplacepy.services.community.ledger_store import TABLE, ledger_store
|
|
from devplacepy.services.community.pusher import SyncPusher
|
|
from devplacepy.services.community.runner import CommunityRunner
|
|
from devplacepy.services.community.store import store
|
|
from devplacepy.services.sync.options import SyncOptions
|
|
from devplacepy.services.sync.selection import Selector
|
|
from devplacepy.services.sync.spec import REASONS, SKIP_REASONS
|
|
from tests.unit.services.community.fakes import FakeFetcher, json_community, no_sleep
|
|
|
|
ORIGIN = "ledger-unit.test"
|
|
|
|
|
|
@pytest.fixture
|
|
def world():
|
|
init_db()
|
|
previous = {key: get_setting(key, "") for key in ("sync_enabled", "sync_origins")}
|
|
set_setting("sync_enabled", "1")
|
|
set_setting("sync_origins", "")
|
|
get_table("communities").delete()
|
|
get_table("community_runs").delete()
|
|
get_table(TABLE).delete()
|
|
get_table("sync_links").delete(origin=ORIGIN)
|
|
get_table("sync_links").delete(origin="forum.test")
|
|
yield
|
|
for key, value in previous.items():
|
|
set_setting(key, value)
|
|
|
|
|
|
def records() -> dict:
|
|
return {
|
|
"users": [{"origin_uid": "u1", "username": "ledgerone", "created_at": "2026-10-01T10:00:00Z"}],
|
|
"posts": [
|
|
{"origin_uid": "p1", "author_uid": "u1", "title": "A good post", "content": "Body of a good post, long enough.", "created_at": "2026-10-01T10:00:00Z"},
|
|
{"origin_uid": "p2", "author_uid": "ghost", "title": "Orphan post", "content": "Written by someone who was never sent.", "created_at": "2026-10-01T10:00:00Z"},
|
|
{"origin_uid": "p3", "title": "Gone", "deleted": True, "author_uid": "u1", "content": "Deleted at the origin before it ever arrived.", "created_at": "2026-10-01T10:00:00Z"},
|
|
],
|
|
"comments": [],
|
|
}
|
|
|
|
|
|
def test_pusher_records_created_rejected_and_held_records_with_reasons(world):
|
|
ledger = Ledger()
|
|
report = SyncPusher(ORIGIN, ledger=ledger).push(records())
|
|
by_origin = {item.origin_uid: item for item in ledger.items}
|
|
assert by_origin["u1"].outcome == "created" and by_origin["u1"].label == "ledgerone" and by_origin["u1"].local_uid
|
|
assert by_origin["p1"].outcome == "created" and by_origin["p1"].label == "A good post"
|
|
assert by_origin["p2"].outcome == "deferred" and by_origin["p2"].reason == "unknown_author" and by_origin["p2"].label == "Orphan post"
|
|
assert by_origin["p3"].outcome == "unchanged" and by_origin["p3"].reason == "tombstone_for_unknown"
|
|
assert tally_of(report.counts, ledger) == {"created": 2, "unchanged": 1, "deferred": 1}
|
|
|
|
|
|
def test_repeating_the_same_batch_lists_nothing_that_is_merely_unchanged(world):
|
|
SyncPusher(ORIGIN).push(records())
|
|
ledger = Ledger()
|
|
report = SyncPusher(ORIGIN, ledger=ledger).push(records())
|
|
assert {item.origin_uid for item in ledger.items} == {"p2", "p3"}
|
|
assert report.counts["users"] == {"unchanged": 1}
|
|
|
|
|
|
def test_a_dry_run_lists_what_it_would_create(world):
|
|
ledger = Ledger()
|
|
SyncPusher(ORIGIN, dry_run=True, ledger=ledger).push(records())
|
|
created = [item for item in ledger.items if item.outcome == "created"]
|
|
assert {item.origin_uid for item in created} == {"u1", "p1"}
|
|
assert get_table("sync_links").count(origin=ORIGIN) == 0
|
|
|
|
|
|
def test_caps_never_let_routine_rows_push_out_problems_and_counts_stay_exact():
|
|
ledger = Ledger(routine_limit=3, problem_limit=2)
|
|
for number in range(10):
|
|
ledger.add("posts", "created", origin_uid=f"c{number}")
|
|
for number in range(5):
|
|
ledger.add("posts", "rejected", reason="invalid", origin_uid=f"r{number}")
|
|
kept = [item.outcome for item in ledger.items]
|
|
assert kept.count("created") == 3 and kept.count("rejected") == 2
|
|
assert ledger.dropped == 7 + 3
|
|
assert ledger.counts["created"] == 10 and ledger.counts["rejected"] == 5
|
|
|
|
|
|
def test_every_reason_the_system_can_emit_has_a_plain_explanation():
|
|
for reason in (*REASONS, *SKIP_REASONS, "article_unreachable", "truncated", "collect_error"):
|
|
assert reason in REASON_TEXT, reason
|
|
assert explain(reason)["title"] and explain(reason)["text"]
|
|
assert explain("never_heard_of_it")["title"] == "Never heard of it"
|
|
assert explain("identity_failed")["retried"] and not explain("sampled_out")["retried"]
|
|
assert explain("") == {"title": "", "text": "", "retried": False}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"status,dry,tally,level",
|
|
[
|
|
("ok", False, {"created": 5}, "ok"),
|
|
("ok", False, {"unchanged": 40}, "idle"),
|
|
("ok", False, {}, "idle"),
|
|
("partial", False, {"created": 5, "rejected": 1}, "attention"),
|
|
("partial", False, {"created": 5, "skipped": 3}, "attention"),
|
|
("partial", False, {"created": 5, "warning": 1}, "attention"),
|
|
("partial", False, {"created": 5, "failed": 1}, "bad"),
|
|
("partial", False, {"error": 2}, "bad"),
|
|
("failed", False, {}, "bad"),
|
|
("skipped", False, {}, "attention"),
|
|
("running", False, {}, "running"),
|
|
("dry_run", True, {"created": 5}, "ok"),
|
|
],
|
|
)
|
|
def test_verdict_levels(status, dry, tally, level):
|
|
outcome = verdict(status, dry, tally)
|
|
assert outcome["level"] == level and outcome["headline"]
|
|
assert outcome["headline"].endswith("(dry run).") == (dry and status != "running")
|
|
|
|
|
|
def test_tally_merges_sync_statuses_skips_and_collector_findings():
|
|
ledger = Ledger()
|
|
ledger.add("posts", "warning", reason="truncated")
|
|
ledger.add("feed", "error", reason="collect_error")
|
|
pushed = {"users": {"created": 2}, "posts": {"created": 1, "updated": 1, "rejected": 1}, "skipped": {"sampled_out": 4, "topic_not_allowed": 1}}
|
|
assert tally_of(pushed, ledger) == {"created": 3, "updated": 1, "rejected": 1, "skipped": 5, "warning": 1, "error": 1}
|
|
assert set(tally_of(pushed, ledger)) <= set(OUTCOME_KEYS)
|
|
|
|
|
|
def test_labels_are_clipped_single_lines():
|
|
assert label_for("users", {"username": "bob"}) == "bob"
|
|
assert label_for("posts", {"title": "", "content": "line one\n\nline two"}) == "line one line two"
|
|
assert len(label_for("comments", {"content": "x" * 900})) == LEDGER.label_chars
|
|
|
|
|
|
def test_selector_reports_every_record_it_drops():
|
|
selector = Selector(ORIGIN, SyncOptions.build(sample_percent=1))
|
|
batch = {
|
|
"posts": [{"origin_uid": f"s{number}", "title": "t", "content": "c" * 20} for number in range(60)],
|
|
"comments": [{"origin_uid": f"c{number}", "post_uid": f"s{number}", "content": "hey"} for number in range(60)],
|
|
}
|
|
kept, skipped = selector.select(batch)
|
|
assert len(selector.dropped) == sum(skipped.values()) > 0
|
|
assert {reason for _, _, reason in selector.dropped} <= {"sampled_out", "parent_skipped"}
|
|
ledger = Ledger()
|
|
for kind, record, reason in selector.dropped:
|
|
ledger.add_dropped_record(kind, record, reason)
|
|
assert ledger.counts["skipped"] == len(selector.dropped) and all(item.reason for item in ledger.items)
|
|
|
|
|
|
def test_a_run_stores_its_ledger_and_a_repeat_stores_nothing(world):
|
|
routes = json_community()
|
|
runner = CommunityRunner(fetcher_factory=lambda: FakeFetcher(routes), sleep=no_sleep)
|
|
store.create("forum-test", "https://forum.test/api/posts", "tester", name="forum")
|
|
run_async(runner.discover("https://forum.test/api/posts", "tester", relearn_slug="forum-test"))
|
|
first = run_async(runner.run(store.get("forum-test"), dry_run=False, trigger="test"))
|
|
saved = store.run(first.run_uid)
|
|
assert saved["items_stored"] == get_table(TABLE).count(run_uid=first.run_uid) > 0
|
|
assert saved["tally"]["created"] == ledger_store.counts(run_uid=first.run_uid)["created"]
|
|
assert saved["verdict"] == "ok" and saved["items_dropped"] == 0
|
|
second = run_async(runner.run(store.get("forum-test"), dry_run=False, trigger="test"))
|
|
again = store.run(second.run_uid)
|
|
assert again["items_stored"] == 0 and again["verdict"] == "idle" and again["tally"]["unchanged"] > 0
|
|
|
|
|
|
def test_query_plans_use_an_index_and_never_sort_or_scan(world):
|
|
ledger = Ledger(routine_limit=4000, problem_limit=4000)
|
|
for number in range(3000):
|
|
ledger.add("posts", ("created", "rejected", "skipped", "updated")[number % 4], reason="invalid", origin_uid=f"o{number}", label=f"title {number}")
|
|
ledger_store.save("plan-run", "plan-community", ledger)
|
|
db.query("ANALYZE")
|
|
cases = [
|
|
({}, {"run_uid": "plan-run"}),
|
|
({"outcome": "rejected"}, {"run_uid": "plan-run"}),
|
|
({"outcome": "rejected", "kind": "posts"}, {"run_uid": "plan-run"}),
|
|
({}, {"community_uid": "plan-community"}),
|
|
({"outcome": "skipped"}, {"community_uid": "plan-community"}),
|
|
]
|
|
for filters, scope in cases:
|
|
where, params, order = ledger_store.compile(filters, **scope)
|
|
for sql in (
|
|
f"SELECT * FROM {TABLE} WHERE {where} ORDER BY {order} LIMIT 50 OFFSET 0",
|
|
f"SELECT COUNT(*) FROM (SELECT 1 FROM {TABLE} WHERE {where} LIMIT 10001)",
|
|
):
|
|
plan = " | ".join(row["detail"] for row in db.query("EXPLAIN QUERY PLAN " + sql, **params))
|
|
assert "USING INDEX" in plan or "USING COVERING INDEX" in plan, (filters, scope, plan)
|
|
assert "SCAN community_run_items" not in plan, plan
|
|
if not filters.get("outcome") or not scope.get("run_uid"):
|
|
assert "TEMP B-TREE" not in plan, (filters, scope, plan)
|
|
assert ledger_store.counts(run_uid="plan-run") == {"created": 750, "rejected": 750, "skipped": 750, "updated": 750}
|
|
rows, pagination = ledger_store.page({"q": "title 7%"}, 1, run_uid="plan-run")
|
|
assert rows == [] and pagination["total"] == 0
|
|
rows, pagination = ledger_store.page({"q": "title 77"}, 1, run_uid="plan-run")
|
|
assert pagination["total"] == 11 and len(rows) == 11
|
|
rows, pagination = ledger_store.page({}, 10**6, run_uid="plan-run")
|
|
assert pagination["page"] == pagination["total_pages"] and len(rows) == 50 and pagination["capped"] is False
|
|
json.dumps(rows)
|
|
|
|
|
|
def test_record_detail_is_kept_for_the_newest_runs_only_but_their_counts_stay(world):
|
|
from devplacepy.services.community.spec import LIMITS
|
|
|
|
community = store.create("keep-test", "q", "tester", name="keep")
|
|
uids = []
|
|
for number in range(LIMITS.ledger_runs_kept + 3):
|
|
run_uid = store.start_run(community["uid"], "test", False)
|
|
ledger = Ledger()
|
|
ledger.add("posts", "created", origin_uid=f"k{number}")
|
|
ledger_store.save(run_uid, community["uid"], ledger)
|
|
store.finish_run(run_uid, "ok", tally={"created": 1}, items_stored=1)
|
|
uids.append(run_uid)
|
|
store.prune_runs(community["uid"])
|
|
kept = [get_table(TABLE).count(run_uid=uid) for uid in uids]
|
|
assert sum(kept) == LIMITS.ledger_runs_kept and kept[0] == 0 and kept[-1] == 1
|
|
assert len(store.run_summaries(community["uid"])) == LIMITS.ledger_runs_kept + 3
|
|
assert store.run(uids[0])["tally"] == {"created": 1}
|