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>
101 lines
5.2 KiB
Python
101 lines
5.2 KiB
Python
# retoor <retoor@molodetz.nl>
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from devplacepy.services.community.discoverer import CommunityDiscoverer
|
|
from devplacepy.services.community.extract import extract_json_ld_posts, parse_html
|
|
from devplacepy.services.community.generator import CodeGenerator
|
|
from devplacepy.services.community.runtime import Collector
|
|
from devplacepy.services.community.schemacheck import validate_schema
|
|
from devplacepy.services.sync.records import RECORD_MODELS
|
|
from tests.conftest import run_async
|
|
from tests.unit.services.community.fakes import FakeFetcher
|
|
from tests.unit.services.community.generator import load, no_sleep
|
|
|
|
|
|
def thread(number: int, comments: int = 2) -> dict:
|
|
return {
|
|
"@type": "DiscussionForumPosting",
|
|
"@id": f"https://ld.test/t/{number}",
|
|
"headline": f"Thread number {number} about parsing",
|
|
"text": f"This is the body of thread {number}, written long enough to sync as a post.",
|
|
"author": {"@type": "Person", "name": f"writer{number % 2}", "url": f"https://ld.test/u/{number % 2}"},
|
|
"datePublished": f"2026-10-{number:02d}T10:00:00Z",
|
|
"dateModified": f"2026-10-{number:02d}T12:00:00Z",
|
|
"url": f"https://ld.test/t/{number}",
|
|
"comment": [
|
|
{
|
|
"@type": "Comment",
|
|
"@id": f"https://ld.test/t/{number}#c{index}",
|
|
"text": f"Comment {index} on thread {number}",
|
|
"author": {"@type": "Person", "name": f"replier{index}"},
|
|
"dateCreated": f"2026-10-{number:02d}T11:0{index}:00Z",
|
|
}
|
|
for index in range(comments)
|
|
],
|
|
}
|
|
|
|
|
|
def page(blobs: list) -> str:
|
|
scripts = "".join(f'<script type="application/ld+json">{json.dumps(blob)}</script>' for blob in blobs)
|
|
return f"<html><head><title>LD Forum</title>{scripts}</head><body><div id=app>Loading</div></body></html>"
|
|
|
|
|
|
def ld_site(blobs=None) -> dict:
|
|
blobs = blobs or [{"@context": "https://schema.org", "@graph": [thread(number) for number in range(1, 7)]}]
|
|
return {"https://ld.test/": page(blobs), "https://ld.test/about": "<html><body><p>About: 100 members.</p></body></html>"}
|
|
|
|
|
|
def test_the_extractor_normalizes_graphs_lists_and_nested_comments():
|
|
root = parse_html(page([{"@graph": [thread(1), {"@type": "WebSite", "name": "x"}]}, thread(2), [thread(3)], {"@type": "ItemList", "itemListElement": [{"item": thread(4)}]}]))
|
|
posts = extract_json_ld_posts(root)
|
|
assert [item["id"] for item in posts] == [f"https://ld.test/t/{n}" for n in (1, 2, 3, 4)]
|
|
first = posts[0]
|
|
assert first["title"] == "Thread number 1 about parsing" and first["author"] == {"username": "writer1", "id": "https://ld.test/u/1"}
|
|
assert first["created_at"].startswith("2026-10-01") and len(first["comments"]) == 2
|
|
assert first["comments"][0]["content"] == "Comment 0 on thread 1" and first["comments"][0]["author"] == {"username": "replier0"}
|
|
|
|
|
|
def test_garbage_and_non_posts_are_ignored():
|
|
root = parse_html('<script type="application/ld+json">{not json</script><script type="application/ld+json">{"@type":"Organization","name":"x"}</script><script>var x=1</script>')
|
|
assert extract_json_ld_posts(root) == []
|
|
nameless = parse_html(page([{"@type": "Article", "headline": "No identity at all"}]))
|
|
assert extract_json_ld_posts(nameless) == []
|
|
|
|
|
|
def test_a_site_with_only_structured_data_is_learned_end_to_end():
|
|
result = run_async(CommunityDiscoverer(FakeFetcher(ld_site())).discover("https://ld.test/"))
|
|
assert result.status == "ready", result.error
|
|
feed = result.schema["feeds"][0]
|
|
assert feed["type"] == "json_ld" and feed["post"]["fields"]["id"] and feed["comments"]["mode"] == "inline"
|
|
assert validate_schema(result.schema) == []
|
|
records = result.sample["records"]
|
|
assert len(records["posts"]) == 6 and len(records["comments"]) == 6 and records["users"]
|
|
full = run_async(Collector(FakeFetcher(ld_site()), result.schema, sleep=no_sleep).run())
|
|
assert len(full["records"]["comments"]) == 12
|
|
for kind, rows in records.items():
|
|
for row in rows:
|
|
RECORD_MODELS[kind].model_validate(row)
|
|
assert {user["username"] for user in records["users"]} >= {"writer0", "writer1", "replier0"}
|
|
|
|
|
|
def test_the_collector_and_the_generated_file_agree_on_structured_data(tmp_path):
|
|
schema = run_async(CommunityDiscoverer(FakeFetcher(ld_site())).discover("https://ld.test/")).schema
|
|
direct = run_async(Collector(FakeFetcher(ld_site()), schema, sleep=no_sleep).run())
|
|
module = load(CodeGenerator(schema).write(tmp_path))
|
|
generated = run_async(module.collect_all(FakeFetcher(ld_site()), sleep=no_sleep))
|
|
assert {kind: len(rows) for kind, rows in direct["records"].items()} == {kind: len(rows) for kind, rows in generated["records"].items()}
|
|
|
|
|
|
def test_a_single_structured_post_is_not_a_feed():
|
|
result = run_async(CommunityDiscoverer(FakeFetcher(ld_site([thread(1)]))).discover("https://ld.test/"))
|
|
assert result.status != "ready"
|
|
|
|
|
|
@pytest.mark.parametrize("count", [2, 5])
|
|
def test_few_posts_still_make_a_feed(count):
|
|
result = run_async(CommunityDiscoverer(FakeFetcher(ld_site([{"@graph": [thread(number, 0) for number in range(1, count + 1)]}]))).discover("https://ld.test/"))
|
|
assert result.status == "ready", result.error
|