"""Scoped batch/empty contracts, using fake DOM captures and isolated stores.""" from __future__ import annotations import copy import sqlite3 import pytest from apps.server.db import StoreConflict, digest from apps.server.learning_checks import output_assertion_checks from apps.server.tests.test_learning import client, initialize, working_candidate from apps.server.tests.test_postgres import pg_client from apps.server.worker import WorkerError FIELDS = [{"name": "id", "source": "attribute", "attribute": "id", "required": True}, {"name": "body", "selector": ".content", "source": "text", "required": True}] SCOPED = {"kind": "extract", "selector": "#scope", "row_selector": ".row", "fields": FIELDS, "limit": 3, "complete_only": True, "output_name": "scoped"} BATCH = {**SCOPED, "batch": {"scroll_selector": "#scope", "pixels": 320, "max_batches": 2, "dedupe_field": "id"}} EMPTY = {**SCOPED, "allow_empty": True, "empty_state": {"selector": ".empty", "text": "暂无评论"}} @pytest.fixture(params=["client", "pg_client"]) def scoped_client(request): return request.getfixturevalue(request.param) def prepare(client): initialize(client) task, legacy = working_candidate(client) worker = client.worker_reference capture = worker.capture def scoped_capture(task_id): value = capture(task_id) value["elements"].append({"selector": "#scope", "tag": "main", "kind": "read", "label": "内容区域", "unique": True, "sensitive": False, "in_viewport": True, "scroll_y": {"can_scroll": True, "scroll_top": 0, "scroll_height": 1200, "client_height": 500}}) return value worker.capture = scoped_capture prefix = "/api/learning/tasks/" + task["id"] assert client.post(prefix + "/browser/capture", json={}).status_code == 200 return task, legacy, prefix, worker def batch_output(): return {"items": [{"id": "row-a", "body": "建材采购甲"}, {"id": "row-b", "body": "建材采购乙"}], "count": 2, "truncated": True, "coverage": "bounded", "source_url": "http://127.0.0.1/demo", "skipped_incomplete_count": 0, "batch_summary": {"batches_read": 2, "duplicates_removed": 1, "stop_reason": "batch_budget"}} def empty_output(): return {"items": [], "count": 0, "truncated": False, "coverage": "empty", "source_url": "http://127.0.0.1/demo", "skipped_incomplete_count": 0, "empty_state": {"confirmed": True, "selector": ".empty", "text": "暂无评论"}} def batches(worker, task_id): return [{"batch_index": index, "added_count": 1, "duplicate_count": index - 1, "capture": worker.capture(task_id)} for index in (1, 2)] def test_scoped_batch_evidence_freezes_and_replays_with_immutable_history(scoped_client): client = scoped_client task, legacy, prefix, worker = prepare(client) original = worker.call async def batch_call(method, params): value = await original(method, params) if method == "action" and params["kind"] == "extract": value.update(result=batch_output(), batch_evidence=batches(worker, task["id"])) if method == "verify": value["outputs"]["scoped"] = batch_output() value["evidence"].extend(item["capture"] for item in batches(worker, task["id"])) value["error_codes"] = [] return value worker.call = batch_call response = client.post(prefix + "/browser/action", json=BATCH) assert response.status_code == 200, response.text ids = response.json()["step"]["batch_evidence_ids"] assert len(ids) == 2 observations = client.get(prefix).json()["observations"] assert [item["id"] for item in observations[-3:-1]] == ids assert observations[-1]["id"] == response.json()["after"]["id"] for index, item in enumerate(observations[-3:-1], 1): assert item["source"] == "action_batch" and item["batch"]["batch_index"] == index assert client.get(item["screenshot_url"]).status_code == 200 candidate = client.post(prefix + "/candidates", json={}).json() assert set(ids) <= {item["observation_id"] for item in candidate["manifest"]["evidence_bindings"]} assert client.post("/api/learning/candidates/" + candidate["id"] + "/check").json()["status"] == "passed" record = client.post("/api/learning/candidates/" + candidate["id"] + "/verify", json={"expected_outputs": {"scoped": "建材采购乙"}}).json() assert record["status"] == "passed" and len(record["evidence"]) == 3 and record["error_codes"] == [] old = client.get("/api/learning/candidates/" + legacy["id"]).json() assert old["digest"] == legacy["digest"] and old["manifest"] == legacy["manifest"] service = client.app_reference.state.learning for query in ("UPDATE observations SET data_json='{}' WHERE id=$1", "DELETE FROM observations WHERE id=$1"): with pytest.raises((StoreConflict, sqlite3.IntegrityError)): service.store.execute(query, (ids[0],)) broken = copy.deepcopy(candidate) broken["manifest"]["steps"][-1]["batch_evidence_ids"] = ["observation_other_task"] broken["digest"] = digest(broken["manifest"]) assert service.static_errors(broken) def test_scoped_batch_failure_keeps_partial_evidence_without_success_step(scoped_client): client = scoped_client task, _, prefix, worker = prepare(client) original = worker.call async def fail(method, params): if method == "action": raise WorkerError("fixture error", "WAIT_TIMEOUT", before=worker.capture(task["id"]), after=worker.capture(task["id"]), batch_evidence=batches(worker, task["id"])[:1], error_codes=["WAIT_TIMEOUT", "bad code private-fixture"]) return await original(method, params) worker.call = fail result = client.post(prefix + "/browser/action", json=BATCH) assert result.status_code == 409 and result.json()["detail"]["code"] == "WAIT_TIMEOUT" detail = result.json()["detail"] assert len(detail["batch_evidence_ids"]) == 1 and detail["error_codes"] == ["WAIT_TIMEOUT"] current = client.get(prefix).json() assert len(current["steps"]) == 3 assert current["observations"][-2]["source"] == "action_failure_batch" assert current["observations"][-1]["action_failure"]["moment"] == "after" assert "private-fixture" not in result.text def test_scoped_empty_requires_exact_evidence_and_explicit_expectation(scoped_client): client = scoped_client task, _, prefix, worker = prepare(client) original = worker.call error_mode = False async def empty_call(method, params): value = await original(method, params) if method == "action" and params["kind"] == "extract": value["result"] = empty_output() if method == "verify": value["outputs"]["scoped"] = empty_output() if error_mode: value.update(passed=False, outputs={}, errors=["需要重新登录"], error_codes=["LOGIN_REQUIRED", "bad code private-fixture"]) return value worker.call = empty_call raw_empty = {**EMPTY, "empty_state": {**EMPTY["empty_state"], "text": " 暂无评论 \n"}} result = client.post(prefix + "/browser/action", json=raw_empty) assert result.status_code == 200, result.text assert result.json()["step"]["empty_state"]["text"] == "暂无评论" candidate = client.post(prefix + "/candidates", json={}).json() assert candidate["manifest"]["steps"][-1]["empty_state"]["text"] == "暂无评论" endpoint = "/api/learning/candidates/" + candidate["id"] assert client.post(endpoint + "/check").json()["status"] == "passed" expected = {"expected_outputs": {"scoped": {"state": "empty"}}} verification = client.post(endpoint + "/verify", json=expected) assert verification.status_code == 200 and verification.json()["status"] == "passed", verification.text assert all(item["passed"] for item in verification.json()["assertion_checks"]) content = client.post(endpoint + "/verify", json={"expected_outputs": {"scoped": "暂无评论"}}) assert content.status_code == 200 and content.json()["status"] == "failed" for invalid in ({"state": "empty", "extra": True}, {"state": "anything"}, []): assert client.post(endpoint + "/verify", json={"expected_outputs": {"scoped": invalid}}).status_code == 422 error_mode = True failure = client.post(endpoint + "/verify", json=expected) assert failure.status_code == 200 and failure.json()["status"] == "failed" assert failure.json()["error_codes"] == ["LOGIN_REQUIRED"] assert "private-fixture" not in failure.text def test_scoped_contract_rejects_unsafe_inputs_before_worker(client): _, _, prefix, worker = prepare(client) invalid = [ {**SCOPED, "row_selector": "body .row"}, {**SCOPED, "row_selector": ""}, {**SCOPED, "target": {"field": "text", "value": "x"}}, {**BATCH, "complete_only": False}, {**BATCH, "allow_empty": True, "empty_state": EMPTY["empty_state"]}, {**BATCH, "batch": {**BATCH["batch"], "max_batches": 6}}, {**BATCH, "batch": {**BATCH["batch"], "pixels": True}}, {**BATCH, "batch": {**BATCH["batch"], "dedupe_field": "body"}}, {**BATCH, "batch": {**BATCH["batch"], "scroll_selector": "#not-observed"}}, {**EMPTY, "empty_state": {"selector": "body .empty", "text": "暂无评论"}}, {**EMPTY, "empty_state": {"selector": ".empty", "text": " "}}, {**EMPTY, "allow_empty": "true"}, {**EMPTY, "allow_empty": None}, {**SCOPED, "empty_state": EMPTY["empty_state"]}, {"kind": "wait", "selector": '[data-testid="result"]', "state": "hidden", "min_count": 1}, {"kind": "wait", "selector": '[data-testid="result"]', "state": "visible", "min_count": True}, ] for action in invalid: calls = len([item for item in worker.calls if item[0] == "action"]) result = client.post(prefix + "/browser/action", json=action) assert result.status_code == 422, result.text assert len([item for item in worker.calls if item[0] == "action"]) == calls result = client.post(prefix + "/browser/action", json={"kind": "wait", "selector": '[data-testid="result"]', "state": "visible", "min_count": 1}) 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" calls = len([item for item in worker.calls if item[0] == "verify"]) assert client.post("/api/learning/candidates/" + candidate["id"] + "/verify", json={"expected_outputs": {"results": {"state": "empty"}}}).status_code == 422 assert len([item for item in worker.calls if item[0] == "verify"]) == calls def test_scoped_shape_rejects_pseudo_empty_and_bad_deduplication(): def checks(value, contract=EMPTY, expected={"state": "empty"}): return output_assertion_checks({"scoped": value}, {"scoped": expected}, {"scoped": contract}) assert all(item["passed"] for item in checks(empty_output())) for changed in ( {**empty_output(), "truncated": True}, {**empty_output(), "skipped_incomplete_count": 1}, {**empty_output(), "empty_state": {"confirmed": False, "selector": ".empty", "text": "暂无评论"}}, {**empty_output(), "empty_state": {"confirmed": True, "selector": ".other", "text": "暂无评论"}}, {**empty_output(), "empty_state": {"confirmed": True, "selector": ".empty", "text": "加载中"}}, {key: value for key, value in empty_output().items() if key != "empty_state"}, ): assert not all(item["passed"] for item in checks(changed)) assert not all(item["passed"] for item in checks(empty_output(), {**SCOPED, "allow_empty": False})) assert not all(item["passed"] for item in checks(empty_output(), expected="暂无评论")) good = batch_output() assert all(item["passed"] for item in checks(good, BATCH, "采购")) for changed in ( {**good, "truncated": False}, {**good, "items": [good["items"][0], good["items"][0]]}, {**good, "batch_summary": {**good["batch_summary"], "batches_read": 3}}, {**good, "batch_summary": {**good["batch_summary"], "duplicates_removed": True}}, {**good, "batch_summary": {**good["batch_summary"], "stop_reason": "unknown"}}, ): assert not checks(changed, BATCH, "采购")[0]["passed"] def test_scoped_boundary_container_can_read_first_batch(client): task, _, prefix, worker = prepare(client) old_capture, old_call = worker.capture, worker.call def capture(task_id): value = old_capture(task_id) value["elements"][-1]["scroll_y"]["can_scroll"] = False return value worker.capture = capture assert client.post(prefix + "/browser/capture", json={}).status_code == 200 async def first_batch(method, params): value = await old_call(method, params) if method == "action": output = batch_output() output.update(items=output["items"][:1], count=1, batch_summary={"batches_read": 1, "duplicates_removed": 0, "stop_reason": "boundary"}) value.update(result=output, batch_evidence=[{"batch_index": 1, "added_count": 1, "duplicate_count": 0, "capture": capture(task["id"])}]) return value worker.call = first_batch result = client.post(prefix + "/browser/action", json=BATCH) assert result.status_code == 200, result.text assert result.json()["result"]["batch_summary"]["stop_reason"] == "boundary" candidate = client.post(prefix + "/candidates", json={}).json() assert client.post("/api/learning/candidates/" + candidate["id"] + "/check").json()["status"] == "passed"