assistant/tests/test_reply_target_navigatio...

281 lines
14 KiB
Python

"""Native reply-target verification must return to and recheck the same source."""
import json
import subprocess
from pathlib import Path
from types import SimpleNamespace
import pytest
from backend.browser import tools
NOTE = 'a' * 24
NOTE_URL = 'https://www.xiaohongshu.com/explore/' + NOTE
PARENT = 'c' * 24
PROFILE = 'https://www.xiaohongshu.com/user/profile/' + PARENT
def observed_point(*, occluded=False, source_key='child-key', outside=False, label='目标用户'):
target = {'tag':'span','attrs':{'id':'target'},'text':label}
child = {'attrs':{'id':'child','class':'comment-item-sub','data-comment-id':'child-key'},
'children':[{'attrs':{'id':'content','class':'content'},'text':'回复 ',
'children':[] if outside else [target]}]}
tree = {'attrs':{'id':'noteContainer'}, 'children':[child], 'hit_target_id':'target', 'occluded':occluded}
if outside:
tree['children'].append({'attrs':{'class':'comment-item'},'children':[target]})
request = {'code':tools._OBSERVE_REPLY_TARGET_POINT,
'arg':{'source_selector':'#content','target_selector':'#target','source_key':source_key},'tree':tree}
result = subprocess.run(['node', str(Path(__file__).with_name('reply_dom_fixture_d41.cjs'))],
input=json.dumps(request, ensure_ascii=False), text=True, capture_output=True, check=True, timeout=5)
return json.loads(result.stdout)
def test_target_point_observer_requires_actual_painted_same_reply_element():
result = observed_point()
assert result['source_key'] == 'child-key'
assert 0 <= result['click_point']['x'] < result['click_point']['viewport_width']
assert 0 <= result['click_point']['y'] < result['click_point']['viewport_height']
@pytest.mark.parametrize('options,error', [
({'occluded':True}, 'reply_target_not_visible'),
({'outside':True}, 'target_outside_reply'),
({'source_key':'a-different-key'}, 'source_key_changed'),
({'label':'关注'}, 'not_reply_target'),
])
def test_target_point_observer_rejects_occlusion_other_reply_changed_key_and_action_buttons(options, error):
result = observed_point(**options)
assert result['error'] == error and 'click_point' not in result
class Page:
def __init__(self):
self.url = NOTE_URL
self.clicks = []
self.pointer_moves = []
self.backs = 0
self.target_visible = True
self.moves = True
self.header = True
self.hover = False
self.back_fails = False
self.return_changed = False
self.parent_changed = False
self.ambiguous = False
self.profile_id = PARENT
self.child_quote = '回复 目标用户:新的独立需求'
self.after_back = None
self.after_move = None
self.point_shift = False
async def get_url(self): return self.url
async def evaluate(self, code, arg=None):
if code == tools._OBSERVE_PROFILE_DESTINATION:
return {'url': self.url, 'profile_header_visible': self.header and '/user/profile/' in self.url,
'hover_card_visible': self.hover}
if code == tools._OBSERVE_REPLY_TARGET_POINT:
assert arg == {'source_selector': '#child', 'target_selector': '#target', 'source_key': 'child-key'}
if not self.target_visible:
return {'error': 'reply_target_not_visible'}
return {'url': self.url, 'source_key': 'child-key', 'target_text': '目标用户',
'click_point': {'x': 141 if self.point_shift else 140, 'y': 240, 'viewport_width': 400, 'viewport_height': 600}}
assert code == tools._OBSERVE_SOURCE
selector = arg['selector'] if isinstance(arg, dict) else arg
verified = arg.get('verified_target_identity') if isinstance(arg, dict) else None
values = {
'#body': {'source_type': 'note', 'text': '正文'},
'#root': {'source_type': 'comment', 'text': '根评论', 'source_key': 'root-key', 'author_profile_url': 'https://www.xiaohongshu.com/user/profile/' + 'b' * 24},
'#parent': {'source_type': 'reply', 'text': '父回复', 'source_key': 'parent-key',
'parent_source_selector': '#root', 'root_source_selector': '#root',
'parent_source_key': 'root-key', 'root_source_key': 'root-key', 'parent_resolution': 'thread_root'},
'#child': {'source_type': 'reply', 'text': self.child_quote, 'source_key': 'child-key',
'root_source_selector': '#root', 'root_source_key': 'root-key',
'reply_to': '目标用户', 'reply_to_selector': '#target', 'parent_resolution': 'target_identity_missing'},
}
value = dict(values[selector])
if selector == '#parent' and self.parent_changed and self.backs:
value['source_key'] = 'a-different-parent'
if selector == '#child' and self.return_changed and self.backs:
value['source_key'] = 'a-different-child'
if selector == '#child' and verified:
if verified == PARENT and not self.ambiguous:
value.update(parent_source_selector='#parent', parent_source_key='a-different-parent' if self.parent_changed and self.backs else 'parent-key', parent_resolution='native_target_profile')
else:
value['parent_resolution'] = 'ambiguous_or_missing_target_comment'
return {'url': self.url, **value}
@property
async def mouse(self):
async def move(**point):
self.pointer_moves.append(point)
if self.after_move:
self.after_move()
async def click(**point):
self.clicks.append(point)
if self.moves:
self.url = 'https://www.xiaohongshu.com/user/profile/' + self.profile_id
return SimpleNamespace(move=move, click=click)
async def go_back(self):
self.backs += 1
if self.back_fails:
raise RuntimeError('native history unavailable')
self.url = NOTE_URL
if self.after_back:
self.after_back()
async def get_elements_by_css_selector(self, _selector):
raise AssertionError('Reply targets must use fresh painted Mouse.click, never actor pre-scroll coordinates')
class Session:
def __init__(self, page): self.page = page
async def get_current_page(self): return self.page
@pytest.fixture
async def context(tmp_path, monkeypatch):
task_id = 'native_reply_target_d42'
monkeypatch.setattr(tools, 'task_doc_dir', lambda _: tmp_path)
async def no_delay(_): pass
monkeypatch.setattr(tools.asyncio, 'sleep', no_delay)
tools.set_prospecting_context(task_id)
tools.set_prospecting_run_context(task_id, 'first-run', goal_version=1)
page, session = Page(), Session(None)
session.page = page
saved = {}
for kind, selector, quote in [('note', '#body', '正文'), ('comment', '#root', '根评论'),
('reply', '#parent', '父回复'), ('reply', '#child', '新的独立需求')]:
saved[selector], _ = await tools._save_source(task_id, session, source_type=kind, source_selector=selector, content=quote)
yield task_id, page, session, saved
tools.clear_task_context(task_id)
async def test_native_target_round_trip_refreshes_parent_and_child_for_the_current_goal(context):
task_id, page, session, saved = context
old_saved = tools.get_task_artifacts(task_id)['operation_evidence'][-1]
tools.set_prospecting_run_context(task_id, 'second-run', goal_version=2)
result = await tools._resolve_reply_target(task_id, session, saved['#child']['source_id'])
assert result['status'] == 'resolved' and result['native_target_rechecked']
assert page.clicks == [{'x':140,'y':240}] and page.backs == 1 and page.url == NOTE_URL
assert page.pointer_moves == [{'x':140,'y':240}]
assert saved['#child']['parent_source_id'] == saved['#parent']['source_id']
assert saved['#child']['parent_resolution'] == 'native_target_profile'
assert old_saved['outcome'] == 'success' and old_saved['details']['post_snapshot']['parent_source_id'] is None
assert old_saved['details']['post_snapshot']['relations_checked'] is False
current = tools.get_task_artifacts(task_id)['operation_evidence']
current_ids = {row['details']['post_snapshot']['source_id'] for row in current
if row['outcome'] == 'success' and row['details'].get('trace', {}).get('goal_version') == 2}
assert {saved['#parent']['source_id'], saved['#child']['source_id']} <= current_ids
before_count = len(current)
again = await tools._resolve_reply_target(task_id, session, saved['#child']['source_id'])
assert again['status'] == 'resolved' and len(page.clicks) == 1
assert len(page.pointer_moves) == 1
assert len(tools.get_task_artifacts(task_id)['operation_evidence']) == before_count
assert len(tools.get_task_artifacts(task_id)['sources']) == 4
@pytest.mark.parametrize('option,status', [
('ambiguous', 'parent_source_unresolved'),
('wrong_profile', 'parent_source_unresolved'),
('back_fails', 'return_failed'),
('return_changed', 'returned_source_unconfirmed'),
('parent_changed', 'parent_source_unresolved'),
])
async def test_uncertain_profile_parent_or_return_never_creates_a_successful_child_relation(context, option, status):
task_id, page, session, saved = context
if option == 'wrong_profile': page.profile_id = 'e' * 24
else: setattr(page, option, True)
result = await tools._resolve_reply_target(task_id, session, saved['#child']['source_id'])
assert result['status'] == status
successes = [row for row in tools.get_task_artifacts(task_id)['operation_evidence']
if row['outcome'] == 'success' and row['details']['post_snapshot']['relations_checked']
and row['details']['post_snapshot']['source_id'] == saved['#child']['source_id']]
if option != 'parent_changed':
assert successes == []
else:
assert result['parent_refresh']['status'] == 'missing_parent_refresh'
async def test_offscreen_target_is_not_clicked_or_treated_as_a_profile(context):
task_id, page, session, saved = context
page.target_visible = False
result = await tools._resolve_reply_target(task_id, session, saved['#child']['source_id'])
assert result['status'] == 'reply_target_not_visible' and page.clicks == [] and page.backs == 0
assert saved['#child']['parent_source_id'] is None
async def test_hover_or_no_navigation_does_not_go_back_or_repeat_the_same_target(context):
task_id, page, session, saved = context
page.moves, page.hover = False, True
result = await tools._resolve_reply_target(task_id, session, saved['#child']['source_id'])
assert result['status'] == 'hover_only' and len(page.clicks) == 1 and page.backs == 0
assert result['target_verified'] is False
with pytest.raises(ValueError, match='不重复点击'):
await tools._resolve_reply_target(task_id, session, saved['#child']['source_id'])
assert len(page.clicks) == 1
@pytest.mark.parametrize('changed', ['point', 'occluded', 'source'])
async def test_hover_changes_require_new_observation_instead_of_clicking_stale_coordinates(context, changed):
task_id, page, session, saved = context
def change():
if changed == 'point': page.point_shift = True
elif changed == 'occluded': page.target_visible = False
else: page.child_quote += ' 页面已经变化'
page.after_move = change
result = await tools._resolve_reply_target(task_id, session, saved['#child']['source_id'])
assert result['status'] == 'target_changed_after_move' and result['target_verified'] is False
assert page.pointer_moves == [{'x':140,'y':240}] and page.clicks == [] and page.backs == 0
assert saved['#child']['parent_source_id'] is None
async def test_run_change_after_native_move_prevents_the_click(context):
task_id, page, session, saved = context
page.after_move = lambda: tools.set_prospecting_run_context(task_id, 'changed-run', goal_version=2)
result = await tools._resolve_reply_target(task_id, session, saved['#child']['source_id'])
assert result['status'] == 'run_changed' and result['target_verified'] is False
assert page.clicks == [] and len(page.pointer_moves) == 1
async def test_profile_url_without_real_profile_header_does_not_create_proof_or_go_back(context):
task_id, page, session, saved = context
page.header = False
result = await tools._resolve_reply_target(task_id, session, saved['#child']['source_id'])
assert result['status'] == 'not_moved' and page.backs == 0
assert tools._reply_target_proofs.get(task_id, {}) == {}
async def test_a_new_run_cannot_reuse_previous_native_target_proof(context):
task_id, page, session, saved = context
await tools._resolve_reply_target(task_id, session, saved['#child']['source_id'])
assert tools._reply_target_proofs[task_id]
tools.set_prospecting_run_context(task_id, 'new-run', goal_version=2)
assert tools._reply_target_proofs.get(task_id, {}) == {}
async def test_execution_change_during_native_trip_cannot_bind_the_old_observation_to_the_new_run(context):
task_id, page, session, saved = context
page.after_back = lambda: tools.set_prospecting_run_context(task_id, 'another-run', goal_version=2)
result = await tools._resolve_reply_target(task_id, session, saved['#child']['source_id'])
assert result['status'] == 'run_changed'
assert saved['#child']['parent_source_id'] is None
assert tools._reply_target_proofs.get(task_id, {}) == {}
async def test_changed_container_invalidates_cached_proof_instead_of_reusing_the_nickname(context):
task_id, page, session, saved = context
await tools._resolve_reply_target(task_id, session, saved['#child']['source_id'])
page.child_quote += ' 已有新变化'
raw = await tools._observe_source(session, '#child')
observed = await tools._apply_reply_target_proof(task_id, session, '#child', raw)
assert observed['parent_resolution'] == 'target_identity_missing'
assert tools._reply_target_proofs.get(task_id, {}) == {}
def test_registered_target_tool_exposes_only_the_saved_source_id_not_identity_or_coordinates(monkeypatch):
monkeypatch.setattr(tools.settings, 'reply_relation_preflight_enabled', True)
action = tools.build_prospecting_tools('schema_only').registry.registry.actions['resolve_reply_target']
assert set(action.param_model.model_fields) == {'source_id'}