Files
wehub-resource-sync 7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

216 lines
8.0 KiB
Python

"""Unit tests for ``_chat_step_decision`` in research_service.
This helper encodes the live-emit/reload symmetry invariant:
> For chat sessions, what the live socket emits MUST equal what
> ``loadSession`` reconstructs from chat_progress_steps on reload.
The progress_callback inside ``run_research_process`` consults this
helper for every callback event and uses its (persist, suppress_emit)
return to gate both the DB write and the socket emit.
The tests below pin down the contract per scenario.
"""
import pytest
from src.local_deep_research.web.services.research_service import (
_chat_step_decision,
_compose_chat_step_content,
_STEP_PHASES,
)
class TestNonStepPhases:
"""Phases not in _STEP_PHASES (e.g. 'complete') never persist
but never block the emit either — the completion handler needs them."""
def test_complete_phase_no_persist_no_suppress(self):
# "complete" is intentionally excluded from _STEP_PHASES so it
# doesn't get a row ordered after the response message.
assert "complete" not in _STEP_PHASES
persist, suppress = _chat_step_decision(
phase="complete", last_step_phase=None, is_final=True
)
assert persist is False
assert suppress is False
def test_unknown_phase_no_persist_no_suppress(self):
persist, suppress = _chat_step_decision(
phase="some-future-phase",
last_step_phase=None,
is_final=False,
)
assert persist is False
assert suppress is False
def test_none_phase_no_persist_no_suppress(self):
persist, suppress = _chat_step_decision(
phase=None, last_step_phase=None, is_final=False
)
assert persist is False
assert suppress is False
class TestFirstStepEver:
"""The first step of a research always persists; no prior phase."""
@pytest.mark.parametrize("phase", sorted(_STEP_PHASES))
def test_first_step_persists_and_emits(self, phase):
persist, suppress = _chat_step_decision(
phase=phase, last_step_phase=None, is_final=False
)
assert persist is True, f"phase={phase} should persist when no prior"
assert suppress is False, (
f"phase={phase} should also emit on first occurrence"
)
class TestPhaseTransition:
"""Different phase from the last persisted one: persist + emit."""
def test_search_after_init_persists(self):
persist, suppress = _chat_step_decision(
phase="search", last_step_phase="init", is_final=False
)
assert (persist, suppress) == (True, False)
def test_report_generation_after_search_persists(self):
persist, suppress = _chat_step_decision(
phase="report_generation",
last_step_phase="search",
is_final=False,
)
assert (persist, suppress) == (True, False)
class TestRepeatPhaseDedup:
"""Same phase as last persisted: dedup blocks persistence, AND when
the event is non-final the emit is dropped to maintain symmetry.
'observation' is whitelisted to repeat.
"""
def test_repeat_search_non_final_blocks_both(self):
# The symmetry guarantee: if we wouldn't persist this step, we
# don't emit it either — reload would not surface it.
persist, suppress = _chat_step_decision(
phase="search", last_step_phase="search", is_final=False
)
assert persist is False
assert suppress is True
def test_repeat_report_generation_non_final_blocks_both(self):
persist, suppress = _chat_step_decision(
phase="report_generation",
last_step_phase="report_generation",
is_final=False,
)
assert (persist, suppress) == (False, True)
def test_observation_can_repeat(self):
"""'observation' is the documented exception to the dedup —
it represents distinct in-iteration findings and must always
persist + emit."""
persist, suppress = _chat_step_decision(
phase="observation",
last_step_phase="observation",
is_final=False,
)
assert persist is True
assert suppress is False
def test_repeat_phase_but_is_final_still_emits(self):
"""Final phases (error, report_complete) emit even on repeat so
the client completion handler fires. Persist is still blocked by
the dedup so we don't write a duplicate row."""
persist, suppress = _chat_step_decision(
phase="error", last_step_phase="error", is_final=True
)
assert persist is False
# The crucial part: do NOT suppress the emit when is_final, even
# though the DB write was deduped.
assert suppress is False
def test_repeat_report_complete_is_final_still_emits(self):
persist, suppress = _chat_step_decision(
phase="report_complete",
last_step_phase="report_complete",
is_final=True,
)
assert (persist, suppress) == (False, False)
class TestComposeChatStepContent:
"""``_compose_chat_step_content`` appends the observation detail from
``metadata["content"]`` beneath the one-line message — and ONLY for
observation events. Other phases reuse metadata["content"] for large
payloads (full synthesized answers) that must not be duplicated into
step rows. The same composition happens client-side for live steps
(chat.js handleProgressUpdate), preserving the symmetry invariant."""
def test_observation_with_detail_appends_it(self):
out = _compose_chat_step_content(
"📄 From the web (SearXNG): [1] Title…",
"observation",
{"content": "[1] Title (http://a.com)\nFull snippet"},
)
assert out == (
"📄 From the web (SearXNG): [1] Title…"
"\n\n[1] Title (http://a.com)\nFull snippet"
)
def test_observation_without_detail_keeps_message(self):
out = _compose_chat_step_content(
"📄 From PubMed: no results", "observation", {}
)
assert out == "📄 From PubMed: no results"
def test_non_observation_phase_ignores_content(self):
out = _compose_chat_step_content(
"Generating final answer…",
"output_generation",
{"content": "the entire synthesized answer"},
)
assert out == "Generating final answer…"
def test_empty_detail_keeps_message(self):
out = _compose_chat_step_content(
"📄 From arXiv: …", "observation", {"content": ""}
)
assert out == "📄 From arXiv: …"
class TestSymmetryInvariant:
"""Property-style assertion of the invariant: for every event in
_STEP_PHASES, if we persist we also emit; if we don't persist and
it's not final, we must suppress the emit so reload matches live.
"""
@pytest.mark.parametrize(
"phase",
sorted(_STEP_PHASES),
)
@pytest.mark.parametrize("last_phase", sorted(_STEP_PHASES) + [None])
@pytest.mark.parametrize("is_final", [True, False])
def test_invariant_holds(self, phase, last_phase, is_final):
persist, suppress = _chat_step_decision(
phase=phase, last_step_phase=last_phase, is_final=is_final
)
if persist:
# If we persist, we must also emit so the live UI surfaces it.
assert suppress is False, (
f"persist=True + suppress=True breaks symmetry: "
f"phase={phase}, last={last_phase}, is_final={is_final}"
)
elif not is_final:
# Non-final and no persist => emit must be suppressed so
# live UI doesn't surface an event that reload will miss.
assert suppress is True, (
f"non-final + not-persist + not-suppress breaks "
f"symmetry: phase={phase}, last={last_phase}"
)
else:
# Final + not-persist: emit is allowed through so the
# client completion handler fires.
assert suppress is False