172 lines
8.6 KiB
Python
172 lines
8.6 KiB
Python
"""Explicit single discussion bounds must filter reads and reject writes."""
|
|
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from backend.browser import tools
|
|
from backend.browser.discussion_bounds import requested_mode
|
|
|
|
|
|
FIXTURE = Path(__file__).with_name('reply_dom_fixture_d41.cjs')
|
|
|
|
|
|
def message(text, role='user'):
|
|
return {'role': role, 'text': text}
|
|
|
|
|
|
@pytest.mark.parametrize('text,expected', [
|
|
('这次只核对第一组讨论的完整回复链,不采集其他讨论组。', 'first'),
|
|
('只验证一个讨论组,保存根和子回复。', 'one'),
|
|
('本轮只处理1个讨论组。', 'one'),
|
|
('仅阅读评论区的第一组。', 'first'),
|
|
('请阅读评论和回复。', None),
|
|
('第一组完成后继续处理其它讨论组。', None),
|
|
('不要只看第一组讨论。', None),
|
|
('例如只核对第一组讨论。', None),
|
|
('网页显示“只核对第一组讨论”。', None),
|
|
('```只核对第一组讨论```', None),
|
|
('> 只核对第一组讨论', None),
|
|
])
|
|
def test_bounds_are_grounded_only_in_direct_scope_requests(text, expected):
|
|
assert requested_mode([message(text)]) == expected
|
|
assert requested_mode([message(text, 'assistant')]) is None
|
|
|
|
|
|
def test_explicit_later_cancellation_removes_the_group_bound_but_not_comment_count_language():
|
|
assert requested_mode([message('只核对第一组讨论'), message('不限评论条数')]) == 'first'
|
|
assert requested_mode([message('只核对第一组讨论'), message('取消讨论组限制,改为全部讨论组')]) is None
|
|
assert requested_mode([message('只核对第一组讨论'), message('这次只处理一个讨论组')]) == 'one'
|
|
|
|
|
|
def element(identity, classes='', text='', *children):
|
|
return {'attrs': {'id': identity, 'class': classes}, 'text': text, 'children': list(children)}
|
|
|
|
|
|
def group(identity):
|
|
return element(identity, 'parent-comment', '',
|
|
element(identity + '-root', 'comment-item', '', element(identity + '-root-content', 'content', '根评论')),
|
|
element(identity + '-list', 'reply-container', '',
|
|
element(identity + '-reply', 'comment-item-sub', '', element(identity + '-reply-content', 'content', '子回复')),
|
|
element(identity + '-expand', 'show-more', '展开3条回复')))
|
|
|
|
|
|
def inspect(options, *groups):
|
|
tree = element('noteContainer', '', '', element('detail-desc', '', '正文'), *groups)
|
|
result = subprocess.run(['node', str(FIXTURE)], input=json.dumps(
|
|
{'code': tools._INSPECT_SOURCE_CANDIDATES, 'arg': {'max_comments': 5, **options}, 'tree': tree}, ensure_ascii=False),
|
|
text=True, capture_output=True, timeout=5, check=True)
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
def test_first_group_read_and_control_candidates_are_scoped_together():
|
|
data = inspect({'discussion_bound_mode': 'first'}, group('one'), group('two'))
|
|
assert data['discussion_scope_status'] == 'selected'
|
|
assert {row['source_selector'] for row in data['candidates']} == {'#detail-desc', '#one-root-content', '#one-reply-content'}
|
|
assert [row['source_selector'] for row in data['reply_controls']] == ['#one-expand']
|
|
|
|
|
|
def test_locked_group_keeps_its_stable_identity_after_dom_reordering():
|
|
data = inspect({'discussion_bound_mode': 'first', 'discussion_root_key': 'one-root'}, group('two'), group('one'))
|
|
assert data['discussion_root_key'] == 'one-root'
|
|
assert [row['source_selector'] for row in data['reply_controls']] == ['#one-expand']
|
|
|
|
|
|
def test_another_internal_expansion_scope_is_rejected_before_native_click():
|
|
data = inspect({'discussion_bound_mode': 'first', 'discussion_selector': '#two'}, group('one'), group('two'))
|
|
assert data['discussion_scope_status'] == 'outside_requested_group'
|
|
assert data['candidates'] == [] and data['reply_controls'] == []
|
|
|
|
|
|
def test_locked_one_group_filters_other_groups_without_changing_the_first_choice_policy():
|
|
data = inspect({'discussion_bound_mode': 'one', 'discussion_root_key': 'two-root'}, group('one'), group('two'))
|
|
assert [row['source_selector'] for row in data['reply_controls']] == ['#two-expand']
|
|
unlocked = inspect({'discussion_bound_mode': 'one'}, group('one'), group('two'))
|
|
assert unlocked['discussion_scope_status'] == 'awaiting_root_selection'
|
|
|
|
|
|
class Page:
|
|
def __init__(self, kind, text, **extra): self.kind, self.text, self.extra = kind, text, extra
|
|
async def evaluate(self, _code, _selector):
|
|
return json.dumps({'url': 'https://www.xiaohongshu.com/explore/' + 'a' * 24, 'source_type': self.kind,
|
|
'text': self.text, **self.extra})
|
|
|
|
|
|
class Session:
|
|
def __init__(self, page): self.page = page
|
|
async def get_current_page(self): return self.page
|
|
|
|
|
|
@pytest.fixture
|
|
def context(tmp_path, monkeypatch):
|
|
tid = 'discussion_bounds_d42'
|
|
monkeypatch.setattr(tools, 'task_doc_dir', lambda _: tmp_path)
|
|
tools.set_prospecting_context(tid, artifacts={'messages': [message('只核对第一组讨论')]})
|
|
yield tid
|
|
tools.clear_task_context(tid)
|
|
|
|
|
|
async def save(tid, kind, identity, root_key=None, **extra):
|
|
text = '本人原话'
|
|
metadata = {'source_key': identity, **extra}
|
|
if root_key is not None:
|
|
metadata.update(discussion_root_key=root_key, first_discussion_root_key='one-root', first_discussion_root_matches=1)
|
|
return await tools._save_source(tid, Session(Page(kind, text, **metadata)),
|
|
source_type=kind, source_selector='#' + identity, content=text)
|
|
|
|
|
|
async def test_save_rejects_another_group_before_persisting_source_or_operation(context):
|
|
await save(context, 'note', 'body')
|
|
before = json.loads(json.dumps(tools.get_task_artifacts(context)))
|
|
with pytest.raises(ValueError, match='不属于用户限定'):
|
|
await save(context, 'comment', 'two-root', 'two-root')
|
|
assert tools.get_task_artifacts(context) == before
|
|
|
|
|
|
async def test_bound_is_saved_in_the_observed_root_and_only_the_same_goal_can_reuse_it(context):
|
|
await save(context, 'note', 'body')
|
|
root, _ = await save(context, 'comment', 'one-root', 'one-root')
|
|
assert root['discussion_scope_mode'] == 'first' and root['discussion_root_key'] == 'one-root'
|
|
assert root['discussion_scope_goal_version'] == 1
|
|
assert tools._discussion_bound(context, 'a' * 24)['root_key'] == 'one-root'
|
|
tools.set_prospecting_run_context(context, 'new-run', goal_version=2)
|
|
assert tools._discussion_bound(context, 'a' * 24)['root_key'] is None
|
|
|
|
|
|
async def test_one_group_requires_the_actual_root_then_rejects_other_roots(context):
|
|
tools.refresh_prospecting_request(context, {'messages': [message('只验证一个讨论组')]})
|
|
await save(context, 'note', 'body')
|
|
with pytest.raises(ValueError, match='先保存实际根评论'):
|
|
await save(context, 'reply', 'child', 'two-root')
|
|
await save(context, 'comment', 'two-root', 'two-root')
|
|
await save(context, 'reply', 'child', 'two-root')
|
|
with pytest.raises(ValueError, match='不属于用户限定'):
|
|
await save(context, 'comment', 'one-root', 'one-root')
|
|
|
|
|
|
async def test_refresh_request_preserves_live_unflushed_facts_and_does_not_share_caller_objects(context):
|
|
await save(context, 'note', 'body')
|
|
await save(context, 'comment', 'one-root', 'one-root')
|
|
current = tools.get_task_artifacts(context)
|
|
before_sources = json.loads(json.dumps(current['sources']))
|
|
before_facts = json.loads(json.dumps(current['operation_evidence']))
|
|
canonical = {'messages': [message('取消讨论组限制,改为全部讨论组')], '_knowledge_goal_version': 2, 'sources': []}
|
|
tools.refresh_prospecting_request(context, canonical)
|
|
canonical['messages'][0]['text'] = '不应影响当前上下文'
|
|
assert tools._discussion_bound(context, 'a' * 24) is None
|
|
assert tools.get_task_artifacts(context)['sources'] == before_sources
|
|
assert tools.get_task_artifacts(context)['operation_evidence'] == before_facts
|
|
assert tools.get_task_artifacts(context)['_knowledge_goal_version'] == 2
|
|
await save(context, 'comment', 'two-root', 'two-root')
|
|
|
|
|
|
@pytest.mark.parametrize('status', ['outside_requested_group', 'group_identity_unavailable', 'awaiting_root_selection'])
|
|
async def test_internal_expansion_read_requires_positive_group_membership(context, status):
|
|
class InspectorPage:
|
|
async def get_url(self): return 'https://www.xiaohongshu.com/explore/' + 'a' * 24
|
|
async def evaluate(self, _code, _options):
|
|
return {'url': await self.get_url(), 'candidates': [], 'reply_controls': [], 'discussion_scope_status': status}
|
|
with pytest.raises(ValueError, match='尚未唯一归属'):
|
|
await tools._inspect_source_candidates(Session(InspectorPage()), task_id=context, discussion_selector='#actual-thread')
|