271 lines
11 KiB
Python
271 lines
11 KiB
Python
import json
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import textwrap
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8")
|
|
SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
|
|
|
|
# Sibling harness scaffolding lives beside this file and is not on sys.path by
|
|
# default; import it the same way the other node-harness tests do.
|
|
sys.path.insert(0, str(ROOT / "tests"))
|
|
import _unread_store_helpers as unread_store_helpers # noqa: E402
|
|
|
|
|
|
def _function_body(src: str, name: str) -> str:
|
|
marker = f"function {name}"
|
|
start = src.index(marker)
|
|
brace = src.index("{", start)
|
|
depth = 0
|
|
for idx in range(brace, len(src)):
|
|
ch = src[idx]
|
|
if ch == "{":
|
|
depth += 1
|
|
elif ch == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return src[brace + 1 : idx]
|
|
raise AssertionError(f"function {name} body not found")
|
|
|
|
|
|
def _extract_function(src: str, name: str) -> str:
|
|
marker = f"function {name}"
|
|
start = src.index(marker)
|
|
body = _function_body(src, name)
|
|
sig = src[start : src.index("{", start)]
|
|
return f"{sig}{{{body}}}"
|
|
|
|
|
|
def _done_handler_body() -> str:
|
|
marker = "source.addEventListener('done',e=>{"
|
|
start = MESSAGES_JS.index(marker)
|
|
brace = MESSAGES_JS.index("{", start)
|
|
depth = 0
|
|
for idx in range(brace, len(MESSAGES_JS)):
|
|
ch = MESSAGES_JS[idx]
|
|
if ch == "{":
|
|
depth += 1
|
|
elif ch == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return MESSAGES_JS[brace + 1 : idx]
|
|
raise AssertionError("done handler body not found")
|
|
|
|
|
|
def _has_pre_list_view_sync() -> bool:
|
|
body = _done_handler_body()
|
|
list_call = "_markSessionCompletedInList(completedSession, activeSid);"
|
|
viewed_call = "_markSessionViewed(completedSid,"
|
|
assert list_call in body, "done handler must update the cached sidebar row"
|
|
assert viewed_call in body, "done handler must sync viewed state for active sessions"
|
|
return body.index(viewed_call) < body.index(list_call)
|
|
|
|
|
|
def _completed_message_count_assignment() -> str:
|
|
body = _done_handler_body()
|
|
marker = "const completedMessageCount="
|
|
start = body.index(marker)
|
|
end = body.index(";\n", start) + 1
|
|
return body[start:end]
|
|
|
|
|
|
def _run_done_compaction_harness(
|
|
*,
|
|
is_session_viewed: bool,
|
|
include_message_count: bool = True,
|
|
include_completed_messages: bool = True,
|
|
session_sid_matches_completed: bool = True,
|
|
session_message_count: int | None = 4,
|
|
visible_message_count: int = 4,
|
|
) -> dict:
|
|
helpers = "\n".join(
|
|
[
|
|
_extract_function(MESSAGES_JS, "_markSessionViewed"),
|
|
_extract_function(SESSIONS_JS, "_getSessionViewedCounts"),
|
|
_extract_function(SESSIONS_JS, "_saveSessionViewedCounts"),
|
|
_extract_function(SESSIONS_JS, "_setSessionViewedCount"),
|
|
_extract_function(SESSIONS_JS, "_getSessionCompletionUnread"),
|
|
_extract_function(SESSIONS_JS, "_saveSessionCompletionUnread"),
|
|
_extract_function(SESSIONS_JS, "_markSessionCompletionUnread"),
|
|
_extract_function(SESSIONS_JS, "_clearSessionCompletionUnread"),
|
|
_extract_function(SESSIONS_JS, "_hasSessionCompletionUnread"),
|
|
_extract_function(SESSIONS_JS, "_hasUnreadForSession"),
|
|
_extract_function(SESSIONS_JS, "_markSessionCompletedInList"),
|
|
# Merge/tombstone helpers the unread persistence paths call.
|
|
unread_store_helpers.BLOCK,
|
|
]
|
|
)
|
|
has_pre_list_sync = _has_pre_list_view_sync()
|
|
completed_message_count_assignment = textwrap.indent(
|
|
_completed_message_count_assignment(),
|
|
" ",
|
|
).rstrip()
|
|
harness = textwrap.dedent(
|
|
f"""
|
|
const SESSION_VIEWED_COUNTS_KEY='session-viewed-counts';
|
|
const SESSION_COMPLETION_UNREAD_KEY='session-completion-unread';
|
|
let _sessionViewedCounts=null;
|
|
let _sessionCompletionUnread=null;
|
|
const visibleMessages=Array.from({{length:{visible_message_count}}}, (_, idx)=>({{
|
|
role:'assistant',
|
|
content:`message-${{idx + 1}}`,
|
|
}}));
|
|
let _allSessions=[{{
|
|
session_id:'active-before-compact',
|
|
message_count:3,
|
|
last_message_at:10,
|
|
updated_at:10,
|
|
is_streaming:true,
|
|
}}];
|
|
const _sessionStreamingById=new Map();
|
|
const _sessionListSnapshotById=new Map();
|
|
const _sessionListSourceById=new Map();
|
|
const storage=new Map();
|
|
const localStorage={{
|
|
getItem(key){{ return storage.has(key) ? storage.get(key) : null; }},
|
|
setItem(key, value){{ storage.set(key, String(value)); }},
|
|
removeItem(key){{ storage.delete(key); }},
|
|
}};
|
|
function renderSessionListFromCache(){{}}
|
|
function _forgetObservedStreamingSession(){{}}
|
|
function _rememberSessionListSource(){{}}
|
|
{helpers}
|
|
_setSessionViewedCount('active-after-compact', 3);
|
|
const activeSid='active-before-compact';
|
|
const completedSid='active-after-compact';
|
|
const S={{
|
|
session:{{session_id:{json.dumps("active-after-compact" if session_sid_matches_completed else "different-session")}}},
|
|
messages:visibleMessages,
|
|
}};
|
|
if ({json.dumps(session_message_count is not None)}) S.session.message_count = {json.dumps(session_message_count)};
|
|
const completedSession={{
|
|
session_id:completedSid,
|
|
updated_at:20,
|
|
last_message_at:20,
|
|
}};
|
|
if ({str(include_completed_messages).lower()}) completedSession.messages = visibleMessages.slice();
|
|
if ({str(include_message_count).lower()}) completedSession.message_count = 4;
|
|
{completed_message_count_assignment}
|
|
if ({str(is_session_viewed).lower()} && {json.dumps(has_pre_list_sync)}) {{
|
|
_markSessionViewed(completedSid, completedMessageCount);
|
|
}}
|
|
if (!{str(is_session_viewed).lower()}) {{
|
|
_markSessionCompletionUnread(completedSid, completedMessageCount);
|
|
}}
|
|
_markSessionCompletedInList(completedSession, activeSid);
|
|
const cacheRow=_allSessions.find(s=>s&&s.session_id===completedSid);
|
|
const unreadAfterCacheUpdate=_hasUnreadForSession(cacheRow);
|
|
const viewedCountAfterCacheUpdate=_sessionViewedCountValue(_getSessionViewedCounts()[completedSid]);
|
|
if ({str(is_session_viewed).lower()}) {{
|
|
_markSessionViewed(completedSid, completedMessageCount);
|
|
}}
|
|
console.log(JSON.stringify({{
|
|
hasPreListSync:{json.dumps(has_pre_list_sync)},
|
|
unreadAfterCacheUpdate,
|
|
viewedCountAfterCacheUpdate,
|
|
unreadAfterActiveBranchSync:_hasUnreadForSession(cacheRow),
|
|
hasCompletionUnread:_hasSessionCompletionUnread(completedSid),
|
|
completionUnreadMessageCount:_getSessionCompletionUnread()[completedSid]?.message_count ?? null,
|
|
viewedCount:_sessionViewedCountValue(_getSessionViewedCounts()[completedSid]),
|
|
cacheRow,
|
|
}}));
|
|
"""
|
|
)
|
|
result = subprocess.run(
|
|
["node", "-e", harness],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
check=False,
|
|
)
|
|
assert result.returncode == 0, result.stderr
|
|
return json.loads(result.stdout.strip())
|
|
|
|
|
|
@pytest.mark.skipif(shutil.which("node") is None, reason="node required for behavioral test")
|
|
def test_actively_viewed_done_completion_stays_read_after_sidebar_cache_update():
|
|
result = _run_done_compaction_harness(is_session_viewed=True)
|
|
|
|
assert result["cacheRow"]["session_id"] == "active-after-compact"
|
|
assert result["cacheRow"]["message_count"] == 4
|
|
assert result["unreadAfterCacheUpdate"] is False, (
|
|
"actively viewed done settle must sync viewed_count before the sidebar cache "
|
|
"re-render sees the higher compacted message_count"
|
|
)
|
|
assert result["viewedCountAfterCacheUpdate"] == 4
|
|
assert result["unreadAfterActiveBranchSync"] is False
|
|
assert result["hasCompletionUnread"] is False
|
|
assert result["viewedCount"] == 4
|
|
|
|
|
|
@pytest.mark.skipif(shutil.which("node") is None, reason="node required for behavioral test")
|
|
def test_actively_viewed_done_completion_uses_messages_fallback_when_message_count_missing():
|
|
result = _run_done_compaction_harness(
|
|
is_session_viewed=True,
|
|
include_message_count=False,
|
|
)
|
|
|
|
assert result["cacheRow"]["session_id"] == "active-after-compact"
|
|
assert result["cacheRow"]["message_count"] == 4
|
|
assert result["unreadAfterCacheUpdate"] is False
|
|
assert result["viewedCountAfterCacheUpdate"] == 4
|
|
assert result["unreadAfterActiveBranchSync"] is False
|
|
assert result["hasCompletionUnread"] is False
|
|
assert result["viewedCount"] == 4
|
|
|
|
|
|
@pytest.mark.skipif(shutil.which("node") is None, reason="node required for behavioral test")
|
|
def test_actively_viewed_done_completion_uses_session_fallback_when_payload_counts_are_missing():
|
|
result = _run_done_compaction_harness(
|
|
is_session_viewed=True,
|
|
include_message_count=False,
|
|
include_completed_messages=False,
|
|
)
|
|
|
|
assert result["cacheRow"]["session_id"] == "active-after-compact"
|
|
assert result["cacheRow"]["message_count"] == 3
|
|
assert result["unreadAfterCacheUpdate"] is False
|
|
assert result["viewedCountAfterCacheUpdate"] == 4
|
|
assert result["unreadAfterActiveBranchSync"] is False
|
|
assert result["hasCompletionUnread"] is False
|
|
assert result["viewedCount"] == 4
|
|
|
|
|
|
@pytest.mark.skipif(shutil.which("node") is None, reason="node required for behavioral test")
|
|
def test_actively_viewed_done_completion_uses_visible_messages_fallback_when_session_count_is_missing():
|
|
result = _run_done_compaction_harness(
|
|
is_session_viewed=True,
|
|
include_message_count=False,
|
|
include_completed_messages=False,
|
|
session_sid_matches_completed=False,
|
|
session_message_count=None,
|
|
)
|
|
|
|
assert result["cacheRow"]["session_id"] == "active-after-compact"
|
|
assert result["cacheRow"]["message_count"] == 3
|
|
assert result["unreadAfterCacheUpdate"] is False
|
|
assert result["viewedCountAfterCacheUpdate"] == 4
|
|
assert result["unreadAfterActiveBranchSync"] is False
|
|
assert result["hasCompletionUnread"] is False
|
|
assert result["viewedCount"] == 4
|
|
|
|
|
|
@pytest.mark.skipif(shutil.which("node") is None, reason="node required for behavioral test")
|
|
def test_background_done_completion_stays_unread_after_sidebar_cache_update():
|
|
result = _run_done_compaction_harness(is_session_viewed=False)
|
|
|
|
assert result["cacheRow"]["session_id"] == "active-after-compact"
|
|
assert result["cacheRow"]["message_count"] == 4
|
|
assert result["unreadAfterCacheUpdate"] is True
|
|
assert result["viewedCountAfterCacheUpdate"] == 3
|
|
assert result["unreadAfterActiveBranchSync"] is True
|
|
assert result["hasCompletionUnread"] is True
|
|
assert result["completionUnreadMessageCount"] == 4
|
|
assert result["viewedCount"] == 3
|