243 lines
12 KiB
Python
243 lines
12 KiB
Python
"""Execute real read-only expansion observers on separate discussion groups."""
|
|
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from backend.browser import tools
|
|
|
|
|
|
FIXTURE = Path(__file__).with_name('reply_dom_fixture_d41.cjs')
|
|
|
|
|
|
def el(identity, classes='', text='', *children):
|
|
return {'attrs': {'id': identity, 'class': classes}, 'text': text, 'children': list(children)}
|
|
|
|
|
|
def record(identity, reply=False):
|
|
return el(identity, 'comment-item-sub' if reply else 'comment-item', '',
|
|
el(identity + '-content', 'content', '', el(identity + '-text', 'note-text', '独立原话')))
|
|
|
|
|
|
def group(identity, replies=1):
|
|
return el(identity, 'parent-comment', '', record(identity + '-root'),
|
|
el(identity + '-list', 'reply-container', '',
|
|
*(record(identity + '-reply-' + str(i), True) for i in range(replies)),
|
|
el(identity + '-expand', 'show-more', '展开3条回复')))
|
|
|
|
|
|
def observe(code, arg, *groups):
|
|
tree = el('noteContainer', '', '', el('detail-desc', '', '正文'), *groups)
|
|
result = subprocess.run(['node', str(FIXTURE)], text=True, capture_output=True, check=True, timeout=5,
|
|
input=json.dumps({'code': code, 'arg': arg, 'tree': tree}, ensure_ascii=False))
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
def test_div_control_in_sibling_reply_list_is_discovered_without_text_click_guess():
|
|
result = observe(tools._INSPECT_SOURCE_CANDIDATES, {'max_comments': 5}, group('one'))
|
|
assert result['reply_controls'] == [{'source_selector': '#one-expand', 'text': '展开3条回复', 'in_view': True}]
|
|
|
|
|
|
def test_expansion_observes_the_whole_unique_thread_not_only_its_root_record():
|
|
before = observe(tools._OBSERVE_REPLY_CONTROL, {'selector': '#one-expand'}, group('one'), group('two', 4))
|
|
after = observe(tools._OBSERVE_REPLY_OWNER, {'selector': before['owner_selector']}, group('one', 2), group('two', 7))
|
|
assert before['owner_selector'] == '#one'
|
|
assert before['owner_identity'] == after['owner_identity'] == 'one-root'
|
|
assert before['reply_count'] == 1 and after['reply_count'] == 2
|
|
|
|
|
|
def test_candidate_delta_is_scoped_to_observed_discussion_even_with_other_new_replies():
|
|
result = observe(tools._INSPECT_SOURCE_CANDIDATES,
|
|
{'max_comments': 5, 'discussion_selector': '#one'}, group('one'), group('two', 4))
|
|
assert {row['source_selector'] for row in result['candidates']} == {'#one-root-content', '#one-reply-0-content'}
|
|
assert len(result['reply_controls']) == 1
|
|
|
|
|
|
def test_ambiguous_thread_does_not_choose_a_root_by_position():
|
|
ambiguous = el('bad', 'parent-comment', '', record('first'), record('second'),
|
|
el('expand', 'show-more', '展开3条回复'))
|
|
result = observe(tools._OBSERVE_REPLY_CONTROL, {'selector': '#expand'}, ambiguous)
|
|
assert result['error'] == 'discussion_owner_unresolved'
|
|
|
|
|
|
def test_reply_scope_disappeared_is_rejected_instead_of_reading_all_discussions():
|
|
result = observe(tools._INSPECT_SOURCE_CANDIDATES,
|
|
{'max_comments': 5, 'discussion_selector': '#absent'}, group('two'))
|
|
assert result['error'] == 'discussion_scope_not_unique'
|
|
|
|
|
|
def test_collapse_label_cannot_be_used_as_a_new_expansion():
|
|
expanded = group('one')
|
|
expanded['children'][1]['children'][-1]['text'] = '收起回复'
|
|
result = observe(tools._OBSERVE_REPLY_CONTROL, {'selector': '#one-expand'}, expanded)
|
|
assert result['error'] == 'not_expand_reply_control'
|
|
|
|
|
|
def test_rendered_offscreen_control_is_only_a_navigation_target_not_a_clickable_claim():
|
|
tree = group('one')
|
|
tree['children'][1]['children'][-1]['rect'] = {'left': 0, 'right': 300, 'top': 800,
|
|
'bottom': 835, 'width': 300, 'height': 35}
|
|
result = observe(tools._INSPECT_SOURCE_CANDIDATES, {'max_comments': 5}, tree)
|
|
assert result['reply_controls'] == [{'source_selector': '#one-expand', 'text': '展开3条回复', 'in_view': False, 'position': 'below'}]
|
|
assert result['reply_control_diagnostics']['rendered_matches'] > 0
|
|
assert result['reply_control_diagnostics']['viewport_matches'] == 0
|
|
click_guard = observe(tools._OBSERVE_REPLY_CONTROL, {'selector': '#one-expand'}, tree)
|
|
assert click_guard['error'] == 'reply_control_not_visible'
|
|
|
|
|
|
def test_full_before_snapshot_contains_more_replies_than_one_five_source_batch():
|
|
result = observe(tools._OBSERVE_REPLY_CONTROL, {'selector': '#one-expand'}, group('one', 8))
|
|
assert result['reply_snapshot_complete'] is True
|
|
assert result['reply_count'] == 8
|
|
assert set(result['visible_reply_selectors']) == {'#one-reply-' + str(i) + '-content' for i in range(8)}
|
|
|
|
|
|
def test_before_snapshot_over_hundred_records_is_explicitly_incomplete():
|
|
result = observe(tools._OBSERVE_REPLY_CONTROL, {'selector': '#one-expand'}, group('one', 101))
|
|
assert result['reply_count'] == 101 and len(result['visible_reply_selectors']) == 100
|
|
assert result['reply_snapshot_complete'] is False
|
|
|
|
|
|
def test_duplicate_root_text_cannot_stand_in_for_stable_discussion_identity():
|
|
first, second = group('one'), group('two')
|
|
first['children'][0]['attrs']['id'] = ''
|
|
result = observe(tools._OBSERVE_REPLY_CONTROL, {'selector': '#one-expand'}, first, second)
|
|
assert result['error'] == 'discussion_owner_unresolved'
|
|
|
|
|
|
def test_unique_root_text_can_identify_a_thread_without_a_root_record_id():
|
|
first, second = group('one'), group('two')
|
|
first['children'][0]['attrs']['id'] = ''
|
|
second['children'][0]['children'][0]['children'][0]['text'] = '其他根评论'
|
|
result = observe(tools._OBSERVE_REPLY_CONTROL, {'selector': '#one-expand'}, first, second)
|
|
assert result['owner_identity'] == 'unique-root-text:独立原话'
|
|
assert result['owner_selector'] == '#one'
|
|
|
|
|
|
class BatchedExpansionPage:
|
|
"""A reading batch can omit old visible replies that the full snapshot saw."""
|
|
url = 'https://www.xiaohongshu.com/explore/' + 'a' * 24
|
|
|
|
def __init__(self, before_complete=True, after_complete=True):
|
|
self.clicked = False
|
|
self.before_complete, self.after_complete = before_complete, after_complete
|
|
|
|
async def get_url(self):
|
|
return self.url
|
|
|
|
async def evaluate(self, code, options):
|
|
if code in {tools._OBSERVE_REPLY_CONTROL, tools._OBSERVE_REPLY_OWNER}:
|
|
return json.dumps({'url': self.url, 'text': '展开1条回复', 'owner_selector': '#thread',
|
|
'click_point': {'x':100,'y':80,'viewport_width':400,'viewport_height':600},
|
|
'owner_identity': 'stable-root', 'fingerprint': str(self.clicked),
|
|
'reply_count': 6 + int(self.clicked),
|
|
'visible_reply_selectors': ['#old-' + str(i) for i in range(6)] + (['#new'] if self.clicked else []),
|
|
'reply_snapshot_complete': self.after_complete if self.clicked else self.before_complete})
|
|
assert code == tools._INSPECT_SOURCE_CANDIDATES
|
|
assert options['discussion_selector'] == '#thread'
|
|
candidates = [{'source_type': 'reply', 'source_selector': '#old-' + str(i), 'content': '旧回复' + str(i)} for i in range(5)]
|
|
if self.clicked:
|
|
candidates = [{'source_type': 'reply', 'source_selector': '#old-5', 'content': '旧回复5'},
|
|
{'source_type': 'reply', 'source_selector': '#new', 'content': '实际新增'}]
|
|
return json.dumps({'url': self.url, 'candidates': candidates})
|
|
|
|
async def get_elements_by_css_selector(self, selector):
|
|
assert selector == '#expand'
|
|
async def click():
|
|
raise AssertionError('Native current-point click must not implicitly scroll')
|
|
return [SimpleNamespace(click=click)]
|
|
|
|
@property
|
|
async def mouse(self):
|
|
async def click(**point):
|
|
assert point == {'x':100,'y':80}
|
|
self.clicked = True
|
|
return SimpleNamespace(click=click)
|
|
|
|
|
|
@pytest.fixture
|
|
def expansion_context(tmp_path, monkeypatch):
|
|
task_id = 'd41-expansion-snapshot-fixture'
|
|
monkeypatch.setattr(tools, 'task_doc_dir', lambda _: tmp_path)
|
|
tools.clear_task_context(task_id)
|
|
tools.set_prospecting_context(task_id)
|
|
yield task_id
|
|
tools.clear_task_context(task_id)
|
|
|
|
|
|
async def test_old_reply_outside_reading_batch_is_not_new_expansion_evidence(expansion_context):
|
|
page = BatchedExpansionPage()
|
|
async def current():
|
|
return page
|
|
result = await tools._expand_replies(expansion_context, SimpleNamespace(get_current_page=current), '#expand')
|
|
assert result['new_visible_replies'] == 1
|
|
fact = tools.get_task_artifacts(expansion_context)['operation_evidence'][-1]
|
|
assert fact['outcome'] == 'success'
|
|
assert fact['details']['pre_snapshot']['reply_snapshot_complete'] is True
|
|
import hashlib
|
|
assert fact['details']['post_snapshot']['new_reply_fingerprints'] == [hashlib.sha256('#new实际新增'.encode()).hexdigest()]
|
|
|
|
|
|
@pytest.mark.parametrize('before_complete,after_complete', [(False, True), (True, False), (False, False)])
|
|
async def test_incomplete_discussion_snapshot_never_credits_expansion_success(expansion_context, before_complete, after_complete):
|
|
page = BatchedExpansionPage(before_complete, after_complete)
|
|
async def current():
|
|
return page
|
|
await tools._expand_replies(expansion_context, SimpleNamespace(get_current_page=current), '#expand')
|
|
fact = tools.get_task_artifacts(expansion_context)['operation_evidence'][-1]
|
|
assert fact['outcome'] == 'unknown'
|
|
|
|
|
|
class ContainerObservedExpansionPage(BatchedExpansionPage):
|
|
def __init__(self, failure=None):
|
|
super().__init__()
|
|
self.failure = failure
|
|
self.container_observations = 0
|
|
|
|
async def evaluate(self, code, options):
|
|
if code != tools._OBSERVE_SOURCE:
|
|
return await super().evaluate(code, options)
|
|
assert options == '#new'
|
|
self.container_observations += 1
|
|
if self.failure == 'exception':
|
|
raise RuntimeError('isolated observer unavailable')
|
|
return json.dumps({'url': self.url if self.failure != 'note' else 'https://www.xiaohongshu.com/explore/' + 'b' * 24,
|
|
'text': '不相关原话' if self.failure == 'quote' else '实际新增,\n还有完整上下文。',
|
|
'source_type': 'comment' if self.failure == 'kind' else 'reply'})
|
|
|
|
|
|
async def test_expansion_program_records_full_normalized_container_fingerprint(expansion_context):
|
|
page = ContainerObservedExpansionPage()
|
|
async def current():
|
|
return page
|
|
await tools._expand_replies(expansion_context, SimpleNamespace(get_current_page=current), '#expand')
|
|
fact = tools.get_task_artifacts(expansion_context)['operation_evidence'][-1]
|
|
import hashlib
|
|
expected = hashlib.sha256(('a' * 24 + '\n#new\n实际新增,还有完整上下文。').encode()).hexdigest()
|
|
assert fact['details']['post_snapshot']['new_reply_container_fingerprints'] == [expected]
|
|
assert page.container_observations == 1 and page.clicked
|
|
|
|
|
|
@pytest.mark.parametrize('failure', ['exception', 'note', 'quote', 'kind'])
|
|
async def test_failed_extra_container_observation_does_not_retry_or_abort_native_expansion(expansion_context, failure):
|
|
page = ContainerObservedExpansionPage(failure)
|
|
async def current():
|
|
return page
|
|
result = await tools._expand_replies(expansion_context, SimpleNamespace(get_current_page=current), '#expand')
|
|
fact = tools.get_task_artifacts(expansion_context)['operation_evidence'][-1]
|
|
assert result['status'] == 'expanded' and fact['outcome'] == 'success'
|
|
assert fact['details']['post_snapshot']['new_reply_container_fingerprints'] == []
|
|
assert page.container_observations == 1 and page.clicked
|
|
|
|
|
|
def test_visible_control_has_priority_over_ten_earlier_offscreen_controls():
|
|
groups = [group('thread-' + str(i)) for i in range(11)]
|
|
for tree in groups[:10]:
|
|
tree['children'][1]['children'][-1]['rect'] = {'left':0,'right':300,'top':800,'bottom':835,'width':300,'height':35}
|
|
result = observe(tools._INSPECT_SOURCE_CANDIDATES, {'max_comments': 5}, *groups)
|
|
assert len(result['reply_controls']) == 10
|
|
assert result['reply_controls'][0]['source_selector'] == '#thread-10-expand'
|
|
assert result['reply_controls'][0]['in_view'] is True
|