assistant/tests/test_reply_preflight_d44.py

267 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Read the shipped DOM observers without navigation, persistence or proofs."""
from copy import deepcopy
import json
from pathlib import Path
import subprocess
import pytest
from backend.browser import tools
URL = 'https://www.xiaohongshu.com/explore/' + 'a' * 24
FIXTURE = Path(__file__).with_name('reply_dom_fixture_d41.cjs')
def node(identity='', classes='', text='', *children, tag='div', **attrs):
return {'tag': tag, 'attrs': {'id': identity, 'class': classes, **attrs}, 'text': text, 'children': list(children)}
def content(identity, text='PRIVATE-QUOTE', *children):
return node(identity+'-content', 'content', '', node(identity+'-text', 'note-text', text, *children))
def speaker(identity, person, own=None, *, kind='reply', extra=(), **attrs):
author = node(identity+'-author', 'name', 'PRIVATE-SAME-NAME', tag='a',
href='https://www.xiaohongshu.com/user/profile/' + person*24)
return node(identity, 'comment-item' if kind == 'comment' else 'comment-item-sub', '',
node('', 'author-wrapper', '', author), own or content(identity), *extra,
**{'data-comment-id': identity+'-key', **attrs})
def target(*, href=True, identity='c', user_attribute=False):
attrs = {'href': 'https://www.xiaohongshu.com/user/profile/'+identity*24} if href else {}
if user_attribute:
attrs['data-target-user-id'] = identity*24
return content('child', '回复 ', node('target', 'user-name', 'PRIVATE-SAME-NAME',
tag='a' if href else 'span', **attrs), node('', '', ': PRIVATE-CHILD-QUOTE'))
def thread(*replies, identity='thread'):
return node(identity, 'parent-comment', '', speaker(identity+'-root', 'b', kind='comment'),
node(identity+'-replies', 'reply-container', '', *replies))
def tree(*groups):
return node('noteContainer', '', '', node('detail-desc', '', 'PRIVATE-NOTE'), *groups)
def find(value, identity):
if value.get('attrs', {}).get('id') == identity:
return value
for child in value.get('children', []):
found = find(child, identity)
if found:
return found
return None
class Page:
def __init__(self, value, mutate=None):
self.tree, self.mutate = deepcopy(value), mutate
self.url = URL
self.calls = []
self.source_calls = {}
async def get_url(self):
return self.url
async def evaluate(self, code, arg):
assert code in {tools._INSPECT_SOURCE_CANDIDATES, tools._OBSERVE_SOURCE, tools._OBSERVE_RELATION_RECHECK}
self.calls.append((code, arg))
if code == tools._OBSERVE_SOURCE:
assert isinstance(arg, str), 'preflight must never supply a native proof identity'
self.source_calls[arg] = self.source_calls.get(arg, 0)+1
if self.mutate:
self.mutate(self, code, arg)
response = subprocess.run(['node', str(FIXTURE)], text=True, capture_output=True, check=True, timeout=5,
input=json.dumps({'code': code, 'arg': arg, 'tree': self.tree}, ensure_ascii=False))
return response.stdout
class Session:
def __init__(self, page):
self.page = page
async def get_current_page(self):
return self.page
@pytest.fixture
def isolated(monkeypatch, tmp_path):
task_id = 'reply_preflight_d44'
tools.set_prospecting_context(task_id, artifacts={
'messages': [{'role': 'user', 'text': '只预检第一组讨论,不保存、不点击'}],
'sources': [{'source_id': 'old-note', 'source_type': 'note', 'note_id': 'a'*24,
'source_selector': '#detail-desc', 'content': 'PRIVATE-NOTE'},
{'source_id': 'old-child', 'source_type': 'reply', 'note_id': 'a'*24,
'source_selector': '#child-content', 'content': 'PRIVATE-CHILD-QUOTE', 'parent_source_id': None}],
'source_observations': [{'operation': 'old-observation'}], 'operation_evidence': [{'id': 'old-fact'}],
'knowledge_event_ids': ['old-knowledge'],
})
tools._reply_target_proofs[task_id] = {'child-key': {'run_id': 'old-run', 'signature': 'old', 'profile_id': 'c'*24}}
tools._prospecting_run_context[task_id] = {'run_id': 'new-run', 'goal_version': 2}
def forbidden(*_args, **_kwargs):
raise AssertionError('read-only preflight must not write facts, navigate or use proofs')
for name in ('_persist_evidence', '_operation_evidence', '_save_source', '_write_json_atomic', '_apply_reply_target_proof', 'task_doc_dir'):
monkeypatch.setattr(tools, name, forbidden)
before = deepcopy((tools._task_artifacts, tools._reply_target_proofs, tools._prospecting_run_context,
tools._pending_blocker, tools._pending_question))
yield task_id
assert before == (tools._task_artifacts, tools._reply_target_proofs, tools._prospecting_run_context,
tools._pending_blocker, tools._pending_question)
assert list(tmp_path.iterdir()) == []
tools.clear_task_context(task_id)
async def inspect(value, task_id, mutate=None, maximum=5):
page = Page(value, mutate)
result = await tools._inspect_reply_relations(Session(page), maximum, task_id)
serialized = json.dumps(result)
assert 'PRIVATE' not in serialized and 'https://' not in serialized and 'profile_id' not in serialized
assert result['note_id'] == 'a'*24
for identity in ('a'*24, 'b'*24, 'c'*24, 'd'*24, 'e'*24):
assert identity not in json.dumps({key: value for key, value in result.items() if key != 'note_id'})
return result, page
def child_row(result):
return next(row for row in result['candidates'] if row['source_selector'] == '#child-content')
@pytest.mark.parametrize('mode', ['href', 'user_attribute', 'comment_id'])
async def test_real_unique_identity_or_explicit_comment_ref_qualifies_without_saving(mode, isolated):
own = target(href=mode == 'href', user_attribute=mode == 'user_attribute')
child = speaker('child', 'd', own, **({'data-parent-comment-id': 'parent-key'} if mode == 'comment_id' else {}))
result, _ = await inspect(tree(thread(speaker('parent', 'c'), child)), isolated)
row = child_row(result)
assert row['classification'] == 'unique_dom_deep_reply' and row['eligible'] is True
assert row['parent_is_reply'] is True and row['parent_relation_observed'] is True
assert row['parent_source_selector'] == '#parent-content' and row['root_source_selector'] == '#thread-root-content'
assert row['native_navigation_confirmed'] is False
assert row['parent_resolution'] == ('explicit_comment_id' if mode == 'comment_id' else 'unique_target_profile')
async def test_actual_nested_parent_reply_has_structural_evidence(isolated):
child = speaker('child', 'd')
parent = speaker('parent', 'c', extra=[child])
root = speaker('root', 'b', kind='comment', extra=[parent])
result, _ = await inspect(tree(root), isolated)
row = child_row(result)
assert row['classification'] == 'unique_dom_deep_reply'
assert row['parent_resolution'] == 'structural_parent' and row['parent_is_reply'] is True
async def test_identical_names_and_two_actual_comments_by_target_identity_remain_ambiguous(isolated):
result, _ = await inspect(tree(thread(speaker('parent', 'c'), speaker('another', 'c'), speaker('child', 'd', target()))), isolated)
row = child_row(result)
assert row['classification'] == 'ambiguous' and not row['eligible']
assert row['relation_diagnostics']['matching_parent_comments'] == 2
assert row['parent_source_selector'] == ''
async def test_plain_span_is_unproved_even_with_an_existing_native_proof_cache(isolated):
result, _ = await inspect(tree(thread(speaker('parent', 'c'), speaker('child', 'd', target(href=False)))), isolated)
row = child_row(result)
assert row['classification'] == 'needs_native_identity' and row['observation_stable'] is True
assert row['relation_diagnostics']['target_identities'] == 0
assert row['relation_diagnostics']['target_profile_links'] == 0
assert not row['eligible'] and not row['native_navigation_confirmed']
@pytest.mark.parametrize('change', ['source_key', 'quote', 'rendering', 'identity', 'url'])
async def test_source_key_quote_rendering_identity_or_url_change_is_unknown(change, isolated):
def mutate(page, code, selector):
if code != tools._OBSERVE_SOURCE or selector != '#child-content' or page.source_calls[selector] != 2:
return
if change == 'source_key':
find(page.tree, 'child')['attrs']['data-comment-id'] = 'changed-key'
elif change == 'quote':
find(page.tree, 'child-text')['text'] = 'CHANGED-QUOTE'
elif change == 'rendering':
find(page.tree, 'child-content')['rect'] = {'left': 0, 'right': 300, 'top': 700, 'bottom': 735, 'width': 300, 'height': 35}
elif change == 'identity':
# Same nickname, target counts, parent key and selector; the actual
# target data-* identity changes together with its observed author.
find(page.tree, 'target')['attrs']['data-target-user-id'] = 'e'*24
find(page.tree, 'parent-author')['attrs']['href'] = 'https://www.xiaohongshu.com/user/profile/'+'e'*24
else:
page.url = URL.replace('a'*24, 'f'*24)
result, _ = await inspect(tree(thread(speaker('parent', 'c'), speaker('child', 'd', target(href=False, user_attribute=True)))), isolated, mutate)
row = child_row(result)
assert row['classification'] == 'unknown' and row['observation_stable'] is False and not row['eligible']
async def test_changed_candidate_excerpt_before_first_observation_is_unknown(isolated):
def mutate(page, code, selector):
if code == tools._OBSERVE_SOURCE and selector == '#child-content' and page.source_calls[selector] == 1:
find(page.tree, 'child-text')['text'] = 'DIFFERENT-QUOTE'
result, _ = await inspect(tree(thread(speaker('parent', 'c'), speaker('child', 'd', target()))), isolated, mutate)
assert child_row(result)['classification'] == 'unknown'
async def test_parent_moved_to_another_thread_does_not_qualify_old_child_observation(isolated):
def mutate(page, code, selector):
if (code == tools._OBSERVE_SOURCE and selector == '#parent-content'
and page.source_calls.get('#child-content') == 1):
old = find(page.tree, 'thread-replies')['children']
parent = old.pop(next(index for index, row in enumerate(old) if row['attrs']['id'] == 'parent'))
find(page.tree, 'other-replies')['children'].append(parent)
value = tree(thread(speaker('parent', 'c'), speaker('child', 'd', target())), thread(identity='other'))
result, _ = await inspect(value, isolated, mutate)
assert child_row(result)['classification'] == 'unknown' and not child_row(result)['eligible']
async def test_direct_root_reply_is_not_a_qualified_reply_to_reply_sample(isolated):
result, _ = await inspect(tree(thread(speaker('child', 'd'))), isolated)
row = child_row(result)
assert row['parent_is_reply'] is False and row['parent_resolution'] == 'thread_root'
assert not row['eligible'] and row['classification'] == 'unknown'
async def test_later_candidate_mutation_invalidates_an_earlier_qualified_row(isolated):
def mutate(page, code, selector):
if code == tools._OBSERVE_SOURCE and selector == '#later-content' and page.source_calls[selector] == 1:
find(page.tree, 'child')['attrs']['data-comment-id'] = 'new-child-key'
value = tree(thread(speaker('parent', 'c'), speaker('child', 'd', target()), speaker('later', 'e')))
result, _ = await inspect(value, isolated, mutate)
assert child_row(result)['classification'] == 'unknown' and not child_row(result)['eligible']
async def test_explicit_first_group_does_not_preflight_another_group(isolated):
value = tree(thread(speaker('first', 'c')), thread(speaker('parent', 'c'), speaker('child', 'd', target()), identity='other'))
result, _ = await inspect(value, isolated)
assert all(row['source_selector'] != '#child-content' for row in result['candidates'])
assert result['eligible_count'] == 0
@pytest.mark.parametrize('maximum', [0, 6, True, '2'])
async def test_batch_bound_is_strict_before_reading(maximum):
with pytest.raises(ValueError, match='1–5'):
await tools._inspect_reply_relations(object(), maximum)
async def test_ordinary_inspector_still_records_by_default_but_internal_no_record_does_not(monkeypatch):
tid = 'preflight_inspector_compatibility'
tools.set_prospecting_context(tid)
writes = []
monkeypatch.setattr(tools, '_persist_evidence', lambda task: writes.append(task))
page = Page(tree(thread(speaker('child', 'd'))))
try:
pure = await tools._inspect_source_candidates(Session(page), task_id=tid, record_observation=False)
assert 'source_observations' not in tools._task_artifacts[tid] and not writes
normal = await tools._inspect_source_candidates(Session(page), task_id=tid)
assert normal == pure
assert writes == [tid] and tools._task_artifacts[tid]['source_observations'][0]['operation'] == 'inspect_source_candidates'
finally:
tools.clear_task_context(tid)
def test_registered_preflight_accepts_only_a_bounded_count_not_selectors_or_identity(monkeypatch):
monkeypatch.setattr(tools.settings, 'reply_relation_preflight_enabled', True)
registry = tools.build_prospecting_tools('preflight_registration').registry.registry.actions
action = registry['inspect_reply_relations']
assert set(action.param_model.model_fields) == {'max_replies'}
assert '不自动调用resolver' in action.description
assert 'unique_dom_deep_reply' in action.description