250 lines
18 KiB
Python
250 lines
18 KiB
Python
"""Single immutable selection handoff; fake browser and isolated SQLite/PG stores."""
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import sqlite3
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
from fastapi.testclient import TestClient
|
|
|
|
from apps.server.db import Store, StoreConflict
|
|
from apps.server.reading import detail_contract
|
|
from apps.server.tests.test_discovery import BASE, item, search_candidate
|
|
from apps.server.tests.test_learning import client, initialize
|
|
from apps.server.tests.test_postgres import pg_client
|
|
|
|
NOTE_ID = "a" * 24
|
|
TITLE = "准备装修求推荐靠谱公司"
|
|
NOTE_URL = BASE + "/explore/" + NOTE_ID
|
|
DETAIL = {"kind": "extract", "selector": "#note", "output_name": "note_detail", "limit": 1,
|
|
"fields": [{"name": name, "selector": "." + name, "source": "text", "required": True} for name in ("title", "body", "author")]}
|
|
COMMENTS = {"kind": "extract", "selector": "#comments", "row_selector": ".comment", "output_name": "comments", "limit": 5, "complete_only": True,
|
|
"fields": [{"name": "comment_id", "source": "attribute", "attribute": "id", "required": True}, {"name": "author", "selector": ".author", "source": "text", "required": True}, {"name": "body", "selector": ".body", "source": "text", "required": True}],
|
|
"batch": {"scroll_selector": "#comments", "pixels": 320, "max_batches": 3, "dedupe_field": "comment_id"}}
|
|
|
|
|
|
def detail_output():
|
|
return {"items": [{"title": TITLE, "body": "准备装修,希望了解材料和施工流程。🦁", "author": "合成作者"}], "count": 1, "truncated": True, "coverage": "limit_reached", "source_url": NOTE_URL}
|
|
|
|
|
|
def comment_output():
|
|
return {"items": [{"comment_id": "comment-a", "author": "合成评论者", "body": "也在了解装修方案"}], "count": 1, "truncated": True, "coverage": "bounded", "source_url": NOTE_URL,
|
|
"skipped_incomplete_count": 0, "batch_summary": {"batches_read": 1, "duplicates_removed": 0, "stop_reason": "boundary"}}
|
|
|
|
|
|
def learned_detail(client, *, seek=False, content=False):
|
|
response = client.post("/api/learning/tasks", json={"name": "单篇正文和首层评论", "platform": "demo", "entry_url": BASE + "/api/learning/demo-page", "goal": "单篇阅读", "account_key": "detail-account", "parameters": {"keyword": "装修", "note_title": TITLE}})
|
|
assert response.status_code == 201, response.text
|
|
task = response.json()
|
|
prefix = "/api/learning/tasks/" + task["id"]
|
|
worker = client.worker_reference
|
|
previous_capture, previous_call = worker.capture, worker.call
|
|
state = {"mode": "normal"}
|
|
|
|
def capture(task_id):
|
|
value = previous_capture(task_id)
|
|
value["elements"].extend({"selector": selector, "tag": "section", "kind": "read", "label": "合成内容", "unique": True, "sensitive": False, "in_viewport": True,
|
|
"scroll_y": {"can_scroll": False, "scroll_top": 0, "scroll_height": 500, "client_height": 500}} for selector in ("#note", "#comments"))
|
|
if seek:
|
|
value["elements"].append({"selector": "#seek", "tag": "div", "kind": "read", "label": "结果滚动区", "unique": True, "sensitive": False, "scroll_y": {"can_scroll": True}})
|
|
next(element for element in value["elements"] if element["selector"] == '[data-testid="detail-link"]')["collection"] = True
|
|
if content:
|
|
next(element for element in value["elements"] if element["selector"] == "#comments")["read_hints"] = [{"selector": ".picture", "tag": "img", "label": "图片元素(未解释)", "source": "media", "media_kind": "image", "loaded": True, "unique": True, "count": 1, "in_viewport": True, "link": False}]
|
|
return value
|
|
|
|
worker.capture = capture
|
|
|
|
async def call(method, params):
|
|
value = await previous_call(method, params)
|
|
if method == "action" and params["kind"] == "extract":
|
|
value["result"] = detail_output() if params["output_name"] == "note_detail" else comment_output()
|
|
if params["output_name"] == "comments":
|
|
value["batch_evidence"] = [{"batch_index": 1, "added_count": 1, "duplicate_count": 0, "capture": worker.capture(params["task_id"])}]
|
|
if content:
|
|
value["result"]["items"][0]["body"] = {"kind": "text", "text": "也在了解装修方案", "media": [], "media_truncated": False}
|
|
if method == "action" and params.get("until_target"):
|
|
value["result"] = {"scrolls": 1, "movements": [{"before": 0, "after": 320, "scroll_height": 1000, "client_height": 500}], "target_reached": True, "stop_reason": "target_reached"}
|
|
if method == "verify" and params.get("note_binding"):
|
|
detail, comments = detail_output(), comment_output()
|
|
proof = {"note_id": NOTE_ID, "href_checked": True, "url_checked": True, "source_url": NOTE_URL, "title": TITLE}
|
|
if state["mode"] == "missing_comments":
|
|
comments["items"][0].pop("body")
|
|
elif state["mode"] == "empty_comments":
|
|
comments.update(items=[], count=0, coverage="empty")
|
|
elif state["mode"] == "wrong_id":
|
|
proof["source_url"] = BASE + "/explore/" + "b" * 24
|
|
elif state["mode"] == "wrong_title":
|
|
detail["items"][0]["title"] = proof["title"] = "同一笔记出现新的标题"
|
|
elif state["mode"] == "wrong_comments_source":
|
|
comments["source_url"] = BASE + "/explore/" + "b" * 24
|
|
elif state["mode"] == "no_proof":
|
|
proof = None
|
|
value.update(outputs={"note_detail": detail, "comments": comments}, note_binding_result=proof, error_codes=[])
|
|
if seek:
|
|
value["verify_budget_ms"] = 95000
|
|
value["seek_results"] = [{"step_position": 4, "scrolls": 1, "movements": [{"before": 0, "after": 320, "scroll_height": 1000, "client_height": 500}], "target_reached": True, "stop_reason": "target_reached"}]
|
|
if state["mode"] == "seek_boundary":
|
|
value.update(passed=False, outputs={}, errors=["fixture seek stopped"], error_codes=["SCROLL_TARGET_BOUNDARY"], note_binding_result={"note_id": NOTE_ID, "href_checked": False, "url_checked": False, "source_url": None, "title": None})
|
|
value["seek_results"][0].update(target_reached=False, stop_reason="boundary")
|
|
elif state["mode"] == "bad_seek_summary":
|
|
value["seek_results"][0]["scrolls"] = 11
|
|
elif state["mode"] == "bad_budget":
|
|
value["verify_budget_ms"] = 90000
|
|
if content:
|
|
mode = state["mode"]
|
|
text = None if mode == "media" else "也在了解装修方案"
|
|
media = [{"kind": "image", "selector": ".picture"}] if mode in {"media", "mixed"} else []
|
|
value["outputs"]["comments"]["items"][0]["body"] = {"kind": "media" if mode == "media" else "mixed" if mode == "mixed" else "text", "text": text, "media": media, "media_truncated": False}
|
|
value["step_timings"] = [{"step_position": index, "kind": step["kind"], "status": "passed", "duration_ms": 10, "phase_ms": {"execute": 4, "capture_after": 6}} for index, step in enumerate(params["steps"], 1)]
|
|
value["verify_timing"] = {"duration_ms": 120, "entry_ms": 20, "close_ms": None}
|
|
if mode == "bad_timing":
|
|
value["step_timings"][0]["phase_ms"]["private-fixture"] = 1
|
|
return value
|
|
|
|
worker.call = call
|
|
assert client.post(prefix + "/browser/open").status_code == 200
|
|
assert client.post(prefix + "/browser/ready", json={"identity_label": "合成账号"}).status_code == 200
|
|
assert client.post(prefix + "/browser/capture", json={}).status_code == 200
|
|
configured_comments = copy.deepcopy(COMMENTS)
|
|
if content:
|
|
configured_comments["fields"][-1] = {"name": "body", "source": "content", "text_selector": ".body", "media_selector": ".picture", "max_media": 4, "required": True}
|
|
actions = [{"kind": "fill", "selector": '[data-testid="search-input"]', "parameter": "keyword"}, {"kind": "press", "selector": '[data-testid="search-input"]', "key": "Enter"},
|
|
{"kind": "wait", "selector": '[data-testid="result"]', "state": "visible", "min_count": 1}, {"kind": "click", "selector": '[data-testid="detail-link"]', "target": {"field": "text", "parameter": "note_title"}},
|
|
{"kind": "wait", "selector": "#note", "state": "visible"}, DETAIL, configured_comments]
|
|
if seek:
|
|
actions.insert(3, {"kind": "scroll", "selector": "#seek", "direction": "down", "pixels": 320, "max_scrolls": 3, "timeout_ms": 5000, "until_target": {"selector": '[data-testid="detail-link"]', "field": "text", "parameter": "note_title"}})
|
|
for action in actions:
|
|
result = client.post(prefix + "/browser/action", json=action)
|
|
assert result.status_code == 200, result.text
|
|
candidate = client.post(prefix + "/candidates", json={}).json()
|
|
assert client.post("/api/learning/candidates/" + candidate["id"] + "/check").json()["status"] == "passed"
|
|
return task, candidate, state
|
|
|
|
|
|
@pytest.fixture(params=["client", "pg_client"])
|
|
def reading_client(request):
|
|
return request.getfixturevalue(request.param)
|
|
|
|
|
|
def prepare(client):
|
|
initialize(client)
|
|
_, search = search_candidate(client, [item(TITLE)])
|
|
result = client.post("/api/discovery/runs", json={"search_candidate_id": search["id"], "keyword": "准备装修"})
|
|
assert result.status_code == 201 and result.json()["status"] == "completed", result.text
|
|
run = result.json()
|
|
task, candidate, state = learned_detail(client)
|
|
return run, task, candidate, state
|
|
|
|
|
|
def test_reading_immutable_handoff_binding_and_owner_history(reading_client):
|
|
client = reading_client
|
|
run, task, candidate, _ = prepare(client)
|
|
endpoint = "/api/discovery/runs/" + run["id"] + "/readings"
|
|
store = client.app_reference.state.store
|
|
snapshot = store.one("SELECT data_json FROM discovery_runs WHERE id=$1", (run["id"],))
|
|
frozen = store.one("SELECT digest,manifest_json FROM candidates WHERE id=$1", (candidate["id"],))
|
|
result = client.post(endpoint, json={"detail_candidate_id": candidate["id"], "note_id": NOTE_ID})
|
|
assert result.status_code == 201, result.text
|
|
reading = result.json()
|
|
assert reading["status"] == "completed" and reading["model_connected"] is False
|
|
assert reading["note_title"] == TITLE and reading["note_url"] == NOTE_URL and reading["keyword"] == run["keyword"]
|
|
assert reading["detail_task_id"] == task["id"] and reading["candidate_digest"] == candidate["digest"]
|
|
assert reading["detail"] == detail_output() and reading["comments"] == comment_output()
|
|
assert reading["errors"] == reading["error_codes"] == []
|
|
assert reading["note_binding_result"]["href_checked"] and reading["note_binding_result"]["url_checked"]
|
|
assert client.get("/api/discovery/readings/" + reading["id"]).json() == reading
|
|
assert client.get(endpoint).json() == {"items": [reading], "total": 1}
|
|
validation = next(value for value in client.get("/api/learning/candidates/" + candidate["id"]).json()["validations"] if value["id"] == reading["validation_id"])
|
|
assert validation["mode"] == "exploration" and validation["note_binding"] == {"note_id": NOTE_ID}
|
|
assert validation["note_binding_result"] == reading["note_binding_result"]
|
|
assert next(value for value in validation["assertion_checks"] if value["name"] == "note_source_binding")["passed"]
|
|
verify_call = [params for method, params in client.worker_reference.calls if method == "verify"][-1]
|
|
assert verify_call["parameters"] == {"keyword": run["keyword"], "note_title": TITLE}
|
|
assert verify_call["note_binding"] == {"note_id": NOTE_ID}
|
|
assert verify_call["expected_outputs"] == {"note_detail": {"state": "nonempty"}, "comments": {"state": "nonempty"}}
|
|
assert all(client.get(value["screenshot_url"]).status_code == 200 for value in reading["evidence"])
|
|
service = client.app_reference.state.reading
|
|
owner = store.one("SELECT owner_id FROM reading_runs WHERE id=$1", (reading["id"],))["owner_id"]
|
|
with pytest.raises(HTTPException) as error:
|
|
service.get(reading["id"], owner + "_other")
|
|
assert error.value.status_code == 404
|
|
with pytest.raises(HTTPException) as error:
|
|
service.list(run["id"], owner + "_other")
|
|
assert error.value.status_code == 404
|
|
for query in ("UPDATE reading_runs SET data_json='{}' WHERE id=$1", "DELETE FROM reading_runs WHERE id=$1"):
|
|
with pytest.raises((sqlite3.IntegrityError, StoreConflict)):
|
|
store.execute(query, (reading["id"],))
|
|
if store.backend == "postgresql":
|
|
with pytest.raises(StoreConflict):
|
|
# Newer append-only analyses reference readings. CASCADE reaches
|
|
# the database's immutable guards instead of its FK precondition.
|
|
store.execute("TRUNCATE reading_runs CASCADE")
|
|
else:
|
|
assert Store(store.home).one("SELECT id FROM reading_runs WHERE id=$1", (reading["id"],))["id"] == reading["id"]
|
|
assert store.one("SELECT data_json FROM discovery_runs WHERE id=$1", (run["id"],)) == snapshot
|
|
assert store.one("SELECT digest,manifest_json FROM candidates WHERE id=$1", (candidate["id"],)) == frozen
|
|
assert store.one("SELECT version FROM schema_versions WHERE version=6")["version"] == 6
|
|
with TestClient(client.app_reference, base_url=BASE) as anonymous:
|
|
assert anonymous.get(endpoint).status_code == anonymous.get("/api/discovery/readings/" + reading["id"]).status_code == 401
|
|
assert anonymous.post(endpoint, json={"detail_candidate_id": candidate["id"], "note_id": NOTE_ID}).status_code in {401, 403}
|
|
|
|
|
|
def test_reading_partial_mismatch_not_ready_and_input_do_not_fake_success(reading_client):
|
|
client = reading_client
|
|
run, task, candidate, state = prepare(client)
|
|
endpoint = "/api/discovery/runs/" + run["id"] + "/readings"
|
|
payload = {"detail_candidate_id": candidate["id"], "note_id": NOTE_ID}
|
|
upper = client.post("/api/learning/candidates/" + candidate["id"] + "/verify", json={"expected_outputs": {"note_detail": {"state": "nonempty"}, "comments": {"state": "nonempty"}}, "note_binding": {"note_id": NOTE_ID.upper()}})
|
|
assert upper.status_code == 200 and upper.json()["status"] == "passed"
|
|
assert upper.json()["note_binding"] == {"note_id": NOTE_ID}
|
|
for mode in ("missing_comments", "empty_comments"):
|
|
state["mode"] = mode
|
|
result = client.post(endpoint, json=payload)
|
|
assert result.status_code == 201, result.text
|
|
partial = result.json()
|
|
assert partial["status"] == "partial" and partial["detail"] == detail_output() and partial["evidence"]
|
|
assert partial["comments"] is None if mode == "missing_comments" else partial["comments"]["count"] == 0
|
|
assert partial["validation_id"] and partial["errors"] and partial["error_codes"]
|
|
for mode in ("wrong_id", "wrong_title", "wrong_comments_source", "no_proof"):
|
|
state["mode"] = mode
|
|
failed = client.post(endpoint, json=payload).json()
|
|
assert failed["status"] == "failed" and failed["detail"] is failed["comments"] is None
|
|
assert "NOTE_BINDING_MISMATCH" in failed["error_codes"] and failed["validation_id"] and failed["evidence"]
|
|
calls = len([call for call in client.worker_reference.calls if call[0] == "verify"])
|
|
for changes in ({"note_id": "b" * 24}, {"note_id": "bad"}, {"note_title": "不信任标题"}, {"url": "https://evil.test"}, {"note_id": None}):
|
|
assert client.post(endpoint, json={**payload, **changes}).status_code == 422
|
|
assert len([call for call in client.worker_reference.calls if call[0] == "verify"]) == calls
|
|
wrong_contract = client.post(endpoint, json={**payload, "detail_candidate_id": run["search_candidate_id"]}).json()
|
|
assert wrong_contract["status"] == "failed" and wrong_contract["error_codes"] == ["READING_CONTRACT_INVALID"]
|
|
assert wrong_contract["validation_id"] is None
|
|
# The generic Verify DTO also enforces the strict closed binding shape, and
|
|
# binding cannot be used to turn a search candidate into a detail workflow.
|
|
verify_endpoint = "/api/learning/candidates/" + candidate["id"] + "/verify"
|
|
for binding in ({}, {"note_id": "bad"}, {"note_id": NOTE_ID, "url": NOTE_URL}):
|
|
assert client.post(verify_endpoint, json={"expected_outputs": {"note_detail": {"state": "nonempty"}}, "note_binding": binding}).status_code == 422
|
|
assert client.post("/api/learning/candidates/" + run["search_candidate_id"] + "/verify", json={"expected_outputs": {"search_results": {"state": "nonempty"}}, "note_binding": {"note_id": NOTE_ID}}).status_code == 422
|
|
assert len([call for call in client.worker_reference.calls if call[0] == "verify"]) == calls
|
|
client.post("/api/learning/tasks/" + task["id"] + "/browser/close")
|
|
not_ready = client.post(endpoint, json=payload).json()
|
|
assert not_ready["status"] == "failed" and not_ready["validation_id"] is None and not_ready["detail"] is None
|
|
assert not_ready["error_codes"] == ["browser_session_required"]
|
|
assert len([call for call in client.worker_reference.calls if call[0] == "verify"]) == calls
|
|
assert client.get("/api/discovery/readings/" + not_ready["id"]).json() == not_ready
|
|
|
|
|
|
def test_reading_business_contract_is_exact_without_hardcoded_css():
|
|
steps = [{"kind": "fill", "parameter": "keyword"}, {"kind": "press", "key": "Enter"}, {"kind": "wait", "state": "visible", "min_count": 1},
|
|
{"kind": "click", "target": {"field": "text", "parameter": "note_title"}}, {"kind": "wait", "state": "visible"}, DETAIL, COMMENTS]
|
|
manifest = {"platform": "demo", "entry_url": BASE, "parameters": {"keyword": "装修", "note_title": TITLE}, "steps": copy.deepcopy(steps)}
|
|
assert detail_contract(manifest)
|
|
changed = copy.deepcopy(manifest)
|
|
changed["steps"][5]["selector"] = "#different-observed-container"
|
|
assert detail_contract(changed)
|
|
for key, value in (("platform", "other"), ("entry_url", "https://example.test")):
|
|
assert not detail_contract({**manifest, key: value})
|
|
for mutate in (lambda m: m["steps"][5].update(limit=2), lambda m: m["steps"][6].update(allow_empty=True), lambda m: m["steps"][6]["fields"][0].update(required=False), lambda m: m["steps"][3].update(target={"field": "href", "parameter": "note_title"})):
|
|
changed = copy.deepcopy(manifest)
|
|
mutate(changed)
|
|
assert not detail_contract(changed)
|