"""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)