mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-26 17:29:57 +00:00
Merge pull request #1878 from ouroboros-agent/contrib/header-consumer-followup-20261015
test: qualify header status, readability and real history return
This commit is contained in:
commit
1b425bfd06
3 changed files with 464 additions and 9 deletions
|
|
@ -71,6 +71,92 @@ def test_a_deep_place_with_an_open_review_and_line_reopens_at_the_newest_message
|
|||
browser.close()
|
||||
|
||||
|
||||
# Main's history note is header chrome on the header's own reading backing (DESIGN "History
|
||||
# edges", §5 "Floating chrome stays readable"): page ink under the whole header, so the messages
|
||||
# moving under the note never show through its words. Judged by what is painted, not by a CSS
|
||||
# spelling: with the note's words hidden, its box must paint the same while the messages beneath it
|
||||
# move by half a row; the words themselves keep the contrast floor on that backing.
|
||||
# Every layer the header paints itself with: its reading backing, and the light theme's own fill.
|
||||
_NO_HEADER_BACKING = """#page-chat .chat-page-header, #page-chat .chat-page-header::before {
|
||||
background: transparent !important; backdrop-filter: none !important; -webkit-backdrop-filter: none !important; }"""
|
||||
_NOTE_GEOMETRY = """note => {
|
||||
const feed = document.querySelector('#chat-messages'), header = note.closest('.chat-page-header');
|
||||
const box = r => ({x: r.x, y: r.y, w: r.width, h: r.height, right: r.right, bottom: r.bottom});
|
||||
const n = note.getBoundingClientRect(), h = header.getBoundingClientRect();
|
||||
const middle = n.top + n.height / 2;
|
||||
// A message's middle is moved under the note's middle; the feed scrolls, nothing else moves.
|
||||
const bubbles = [...feed.querySelectorAll('[data-history-id]')].filter(m => m.getClientRects().length);
|
||||
const target = bubbles.map(m => ({m, r: m.getBoundingClientRect()}))
|
||||
.sort((a, b) => Math.abs(a.r.top + a.r.height / 2 - middle) - Math.abs(b.r.top + b.r.height / 2 - middle))[0];
|
||||
if (target) feed.scrollTop += target.r.top + target.r.height / 2 - middle;
|
||||
const before = getComputedStyle(header, '::before');
|
||||
return {note: box(n), header: box(h), color: getComputedStyle(note).color, backing: before.backgroundColor,
|
||||
headerFill: getComputedStyle(header).backgroundColor, backingContent: before.content,
|
||||
shift: Math.round((target?.r.height || 80) / 2)};
|
||||
}"""
|
||||
# How many messages lie under the note right now.
|
||||
_UNDER_NOTE = """note => { const n = note.getBoundingClientRect();
|
||||
return [...document.querySelectorAll('#chat-messages [data-history-id]')].filter(m => { const r = m.getBoundingClientRect();
|
||||
return m.getClientRects().length && r.bottom > n.top && r.top < n.bottom && r.right > n.left && r.left < n.right; }).length; }"""
|
||||
|
||||
|
||||
def _rgb(color):
|
||||
import re
|
||||
parts = [float(v) for v in re.findall(r'[\d.]+', color)]
|
||||
if color.startswith('color(srgb'):
|
||||
return [v * 255 for v in parts[:3]], (parts[3] if len(parts) > 3 else 1.0)
|
||||
assert color.startswith('rgb') and len(parts) in (3, 4), ('an sRGB computed colour', color)
|
||||
return parts[:3], (parts[3] if len(parts) > 3 else 1.0)
|
||||
|
||||
|
||||
def _contrast(a, b):
|
||||
def luminance(rgb):
|
||||
lin = [(c / 255 / 12.92) if c / 255 <= 0.03928 else ((c / 255 + 0.055) / 1.055) ** 2.4 for c in rgb]
|
||||
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]
|
||||
hi, lo = sorted((luminance(a), luminance(b)), reverse=True)
|
||||
return (hi + 0.05) / (lo + 0.05)
|
||||
|
||||
|
||||
def _note_backing_facts(page, note):
|
||||
import io
|
||||
|
||||
from PIL import Image, ImageChops
|
||||
|
||||
facts = note.evaluate(_NOTE_GEOMETRY)
|
||||
clip = {'x': facts['note']['x'], 'y': facts['note']['y'], 'width': facts['note']['w'], 'height': facts['note']['h']}
|
||||
feed = page.locator('#chat-messages')
|
||||
note.evaluate("n => { n.dataset.testWords = n.style.visibility; n.style.visibility = 'hidden'; }")
|
||||
shots, under = [], []
|
||||
try:
|
||||
for move in (0, facts['shift']):
|
||||
feed.evaluate('(n, d) => { n.scrollTop += d; }', move)
|
||||
page.evaluate(_FRAMES)
|
||||
under.append(note.evaluate(_UNDER_NOTE))
|
||||
shots.append(Image.open(io.BytesIO(page.screenshot(clip=clip, animations='disabled'))).convert('RGB'))
|
||||
feed.evaluate('(n, d) => { n.scrollTop -= d; }', facts['shift'])
|
||||
finally:
|
||||
note.evaluate("n => { n.style.visibility = n.dataset.testWords; delete n.dataset.testWords; }")
|
||||
raw = ImageChops.difference(*shots).tobytes()
|
||||
changed = sum(1 for i in range(0, len(raw), 3) if raw[i] + raw[i + 1] + raw[i + 2] > 24)
|
||||
backing, alpha = _rgb(facts['backing'])
|
||||
ink, ink_alpha = _rgb(facts['color']) # meta ink may be translucent: judge it as painted on the backing
|
||||
ink = [ink_alpha * c + (1 - ink_alpha) * b for c, b in zip(ink, backing)]
|
||||
facts.update(under=under, backingAlpha=alpha, pixels=len(raw) // 3, showThrough=changed / max(1, len(raw) // 3),
|
||||
contrast=round(_contrast(ink, backing), 2))
|
||||
return facts
|
||||
|
||||
|
||||
def _assert_note_readable(facts):
|
||||
note, header = facts['note'], facts['header']
|
||||
assert min(facts['under']) >= 1, ('messages move under the note, or nothing is proved', facts)
|
||||
assert facts['backingContent'] not in ('none', 'normal') and facts['backingAlpha'] == 1, (
|
||||
'history note reading backing: the header paints opaque page ink', facts)
|
||||
assert header['y'] - 0.5 <= note['y'] and note['bottom'] <= header['bottom'] + 0.5, (
|
||||
'history note reading backing: the note lies inside the backed header', facts)
|
||||
assert facts['showThrough'] <= 0.002, ('history note reading backing: moving messages show through the note', facts)
|
||||
assert facts['contrast'] >= 4.5, ('history note words keep the contrast floor on their backing', facts)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('browser_engine', ['chromium', 'webkit'])
|
||||
@pytest.mark.parametrize('theme', ['light', 'dark'])
|
||||
def test_main_coverage_status_remains_readable_away_from_history_control(direct_server_with_data, browser_engine, theme, tmp_path):
|
||||
|
|
@ -98,9 +184,22 @@ def test_main_coverage_status_remains_readable_away_from_history_control(direct_
|
|||
assert box['x'] >= 0 and box['x'] + box['width'] <= width + 1
|
||||
assert box['y'] >= 0 and box['y'] + box['height'] < 850
|
||||
assert page.locator('#page-chat .chat-load-older-note').count() == 1
|
||||
style = note.evaluate('e => { const s = getComputedStyle(e); return {bg:s.backgroundColor, radius:s.borderRadius, padding:s.paddingLeft}; }')
|
||||
assert style['bg'] != 'rgba(0, 0, 0, 0)' and style['radius'] != '0px' and style['padding'] != '0px', style
|
||||
facts = _note_backing_facts(page, note)
|
||||
_assert_note_readable(facts)
|
||||
_screenshot(page, tmp_path, f'main-history-status-{theme}-{width}-{browser_engine}')
|
||||
# Falsify the same consumer oracle: without the header's backing, the messages
|
||||
# under the note show through it, and the oracle must say so.
|
||||
bare_style = page.add_style_tag(content=_NO_HEADER_BACKING)
|
||||
try:
|
||||
bare = _note_backing_facts(page, note)
|
||||
with pytest.raises(AssertionError, match='history note reading backing'):
|
||||
_assert_note_readable(bare)
|
||||
assert bare['showThrough'] > 0.002 >= facts['showThrough'], ('the painted oracle alone tells them apart', facts, bare)
|
||||
finally:
|
||||
bare_style.evaluate('n => n.remove()')
|
||||
_assert_note_readable(_note_backing_facts(page, note))
|
||||
(tmp_path / f'main-history-status-{theme}-{width}-{browser_engine}.json').write_text(
|
||||
json.dumps({'backed': facts, 'bare': bare}, indent=2))
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
|
|
|
|||
347
tests/test_room_work_pager_return_browser.py
Normal file
347
tests/test_room_work_pager_return_browser.py
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
"""View question and Back to reading through the PRODUCTION history pager (DESIGN "Room work status",
|
||||
"History edges").
|
||||
|
||||
The real server, history endpoint, census, owner wait and SPA of ``direct_server_with_data``; only
|
||||
model judgment is scripted (``test_room_work_backend_browser.ScriptedModel``). A room holds several
|
||||
real history pages (150 conversation rows each); every page is read by the room's own
|
||||
``Load more history`` and every release is the pager's own (``chat_history.createChatHistoryPager``
|
||||
keeps three pages and lets the farthest go; ``↓`` re-anchors on the newest page and lets the old
|
||||
chain go). Nothing here removes a node or filters a response.
|
||||
|
||||
The room's newest window stays mounted while the reader walks older (``chat.js``
|
||||
``retainedHistoryIds``), so the waited question is made older than it: the turn waits, then 300
|
||||
newer saved lines follow it in the room's log, and the question lives in an older page.
|
||||
|
||||
``test_view_question_and_back_through_real_history_pages`` (Main and a Project, Chromium and WebKit),
|
||||
by keyboard, and View question never posts a decision:
|
||||
loaded the question's older page and a newer one are loaded, the reader in the newer one:
|
||||
Back returns exactly to the passage left.
|
||||
released reading on releases the question's page: View question reads the task detail and
|
||||
shows the question; the passage left is still loaded, and Back returns exactly.
|
||||
origin gone after View question, ``↓`` re-anchors on the present and the pager releases the
|
||||
passage left: Back says ``Earlier place is no longer loaded`` and reads nothing; the
|
||||
current Back loads no lost place (no bookmark, pager or reload of its own).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from tests import test_ui_smoke_playwright as smoke
|
||||
from tests.test_chat_history_paging_browser import _OBSERVE_HISTORY, _idle, _step
|
||||
from tests.test_room_work_backend_browser import (
|
||||
ASK,
|
||||
Evidence,
|
||||
ScriptedModel,
|
||||
_decision_posts,
|
||||
_facts,
|
||||
_http,
|
||||
_launch,
|
||||
_open_chat,
|
||||
_send_from_composer,
|
||||
_wait,
|
||||
_wait_facts,
|
||||
_waiting_turn,
|
||||
)
|
||||
|
||||
direct_server_with_data = smoke.direct_server_with_data
|
||||
|
||||
pytestmark = [pytest.mark.ui_browser, pytest.mark.serial]
|
||||
|
||||
FILLER = 700 # older saved lines; pages hold 150 conversation rows (gateway/history.py _DEFAULT_N_HUMAN)
|
||||
LATER = 300 # newer saved lines written after the question, so it lives two pages back
|
||||
_FRAMES = "() => new Promise(done => requestAnimationFrame(() => requestAnimationFrame(done)))"
|
||||
|
||||
# The reader's place as the feed's anchor owner sees it: the first visible top-level node.
|
||||
_PLACE = """feed => {
|
||||
const root = document.querySelector(feed), box = root.getBoundingClientRect();
|
||||
const node = [...root.children].find(n => !n.classList.contains('typing-bubble') && !n.classList.contains('chat-load-older')
|
||||
&& n.getBoundingClientRect().bottom > box.top && n.getBoundingClientRect().top < box.bottom);
|
||||
return {scrollTop: root.scrollTop, history: node?.dataset.historyId || '', text: (node?.textContent || '').slice(0, 40),
|
||||
offset: node ? Math.round(node.getBoundingClientRect().top - box.top) : null,
|
||||
mounted: root.querySelectorAll('[data-history-id]').length};
|
||||
}"""
|
||||
# Put one row the reader chose at a reading position below the floating header.
|
||||
_READ_AT = """([feed, id]) => {
|
||||
const root = document.querySelector(feed), node = root.querySelector(`[data-history-id="${CSS.escape(id)}"]`);
|
||||
if (!node) return false;
|
||||
root.scrollTop += node.getBoundingClientRect().top - root.getBoundingClientRect().top - 140;
|
||||
root.dispatchEvent(new Event('scroll'));
|
||||
return true;
|
||||
}"""
|
||||
_ACTIVE = """() => { const a = document.activeElement;
|
||||
return !a || a === document.body ? 'body' : `${a.tagName}#${a.id}.${String(a.className || '')}`; }"""
|
||||
_LIVE = """root => document.querySelector(`${root} .rw-sr[role=status]`)?.textContent || ''"""
|
||||
|
||||
|
||||
def _seed_filler(data_dir, chat_id, tag, count, later=False):
|
||||
"""Saved exchanges in the room's own log: older than the run, or (``later``) just after now."""
|
||||
now = _dt.datetime.now(_dt.timezone.utc)
|
||||
rows = []
|
||||
for index in range(count):
|
||||
ts = (now + _dt.timedelta(milliseconds=index + 1) if later
|
||||
else now - _dt.timedelta(minutes=2 * count - index)).isoformat()
|
||||
name = f"{tag}{'L' if later else ''}-{index:04d}"
|
||||
row = {"ts": ts, "chat_id": chat_id, "direction": "in" if index % 2 == 0 else "out",
|
||||
"text": f"PG-{name} saved reading line."}
|
||||
if index % 2 == 0:
|
||||
row["client_message_id"] = f"pg-{name}"
|
||||
rows.append(row)
|
||||
path = data_dir / "logs" / "chat.jsonl"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("a", encoding="utf-8") as stream:
|
||||
stream.write("".join(json.dumps(row) + "\n" for row in rows))
|
||||
|
||||
|
||||
def _reads(page, chat_id):
|
||||
return page.evaluate("id => window.__historyReads.filter(read => read.chatId === id)", chat_id)
|
||||
|
||||
|
||||
def _ids(read):
|
||||
return [row["history_id"] for row in (read.get("body") or {}).get("messages") or [] if row.get("history_id")]
|
||||
|
||||
|
||||
def _filler_id(read, tag):
|
||||
"""A saved line from the middle of one real page: far from either edge of the reading window."""
|
||||
rows = [row for row in read["body"]["messages"] if str(row.get("text", "")).startswith(f"PG-{tag}")]
|
||||
assert len(rows) > 40, ("the page carries the room's saved lines", len(rows))
|
||||
return rows[len(rows) // 2]["history_id"]
|
||||
|
||||
|
||||
def _mounted(page, feed, ids):
|
||||
return page.evaluate("([feed, ids]) => { const root = document.querySelector(feed);"
|
||||
" return ids.filter(id => root.querySelector(`[data-history-id=\"${CSS.escape(id)}\"]`)).length; }",
|
||||
[feed, list(ids)])
|
||||
|
||||
|
||||
def _read_at(page, feed, history_id):
|
||||
assert page.evaluate(_READ_AT, [feed, history_id]), ("the chosen passage is mounted", history_id)
|
||||
page.evaluate(_FRAMES)
|
||||
page.wait_for_timeout(350)
|
||||
return page.evaluate(_PLACE, feed)
|
||||
|
||||
|
||||
_ON_PILL = """id => document.activeElement?.closest('.rw-row')?.dataset.id === id
|
||||
&& document.activeElement.classList.contains('rw-pill-question')"""
|
||||
|
||||
|
||||
def _view_question_by_keyboard(page, room, task_id, engine):
|
||||
"""The status control by keyboard (ArrowDown opens into the list), then the row's View question
|
||||
by Enter. Chromium reaches the button by Tab. macOS WebKit's Tab visits only text fields: it would
|
||||
leave the list for the question's answer field and scroll the feed there, so on WebKit the button
|
||||
is focused directly and still pressed by its key."""
|
||||
page.locator(f"{room['root']} .rw-trigger").focus()
|
||||
page.keyboard.press("ArrowDown")
|
||||
_wait_facts(page, room["scope"], lambda f: (f["active"] or {}).get("inSurface"), "the keyboard in the list")
|
||||
if engine == "chromium":
|
||||
for _ in range(4):
|
||||
if page.evaluate(_ON_PILL, task_id):
|
||||
break
|
||||
page.keyboard.press("Tab")
|
||||
else:
|
||||
page.locator(f"{room['root']} .rw-row[data-id='{task_id}'] .rw-pill-question").focus()
|
||||
assert page.evaluate(_ON_PILL, task_id), page.evaluate(_ACTIVE)
|
||||
page.keyboard.press("Enter")
|
||||
|
||||
|
||||
def _landed(page, room, card):
|
||||
card.wait_for(state="visible", timeout=30_000)
|
||||
page.wait_for_function("() => document.activeElement?.classList.contains('chat-quiz-question')", timeout=30_000)
|
||||
facts = card.evaluate("""card => { const feed = card.closest('#chat-messages, .chat-messages');
|
||||
const box = feed.getBoundingClientRect(), r = card.getBoundingClientRect();
|
||||
const top = [...feed.children].find(n => n.contains(card));
|
||||
return { focus: document.activeElement.className, onScreen: r.bottom > box.top && r.top < box.bottom,
|
||||
comment: card.querySelector('.chat-quiz-comment')?.value ?? null,
|
||||
enabled: [...card.querySelectorAll('.chat-quiz-option')].filter(b => !b.disabled).length,
|
||||
position: [...feed.children].indexOf(top), of: feed.children.length,
|
||||
history: card.closest('[data-history-id]')?.dataset.historyId || '' }; }""")
|
||||
assert facts["onScreen"] and facts["comment"] in ("", None) and facts["enabled"] == 2, facts
|
||||
assert _facts(page, room["scope"])["back"] == "Back to reading"
|
||||
return facts
|
||||
|
||||
|
||||
def _back_by_keyboard(page, room):
|
||||
page.locator(f"{room['root']} .rw-back").focus()
|
||||
page.keyboard.press("Enter")
|
||||
page.evaluate(_FRAMES)
|
||||
page.wait_for_timeout(400)
|
||||
return {"place": page.evaluate(_PLACE, room["feed"]), "back": _facts(page, room["scope"])["back"],
|
||||
"live": page.evaluate(_LIVE, room["root"]), "focus": page.evaluate(_ACTIVE)}
|
||||
|
||||
|
||||
def _assert_exact_return(before, after, label):
|
||||
assert after["live"] == "Returned to where you were reading.", (label, after)
|
||||
assert after["back"] == "", (label, "Back leaves once it has returned", after)
|
||||
assert after["place"]["history"] == before["history"] and abs(after["place"]["offset"] - before["offset"]) <= 2, \
|
||||
(label, before, after)
|
||||
assert abs(after["place"]["scrollTop"] - before["scrollTop"]) <= 2, (label, before, after)
|
||||
assert after["focus"] != "body", (label, "Back by keyboard dropped focus to the document", after)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("engine", ["chromium", "webkit"])
|
||||
@pytest.mark.parametrize("scope", ["main", "project"])
|
||||
def test_view_question_and_back_through_real_history_pages(direct_server_with_data, monkeypatch, tmp_path, scope, engine):
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
server = direct_server_with_data
|
||||
url, data_dir = server["url"], server["data_dir"]
|
||||
evidence = Evidence(data_dir, f"pager-{scope}-{engine}")
|
||||
model = ScriptedModel(monkeypatch, ask=[ASK])
|
||||
project = None
|
||||
if scope == "project":
|
||||
folder = tmp_path / "pager-room"
|
||||
folder.mkdir()
|
||||
subprocess.run(["git", "init", "-q", str(folder)], check=True, capture_output=True)
|
||||
project = _http(url, "POST", "/api/projects", {"name": "Pager reading room", "path": str(folder)})["project"]
|
||||
chat_id = int(project["chat_id"]) if project else 1
|
||||
room = ({"scope": "main", "root": "#page-chat", "feed": "#chat-messages"} if not project else
|
||||
{"scope": "project", "root": "#project-panel", "feed": f"#pchat-{project['id']}-messages"})
|
||||
tag = "P" if project else "M"
|
||||
server["stop_server"]()
|
||||
_seed_filler(data_dir, chat_id, tag, FILLER)
|
||||
server["start_server"]()
|
||||
|
||||
def open_room(page):
|
||||
_open_chat(page, url)
|
||||
if project:
|
||||
page.evaluate("p => window.dispatchEvent(new CustomEvent('ouro:open-project', {detail: {project: p}}))", project)
|
||||
page.wait_for_selector(f"{room['root']} .project-panel-bar .rw-trigger", state="attached", timeout=30_000)
|
||||
_idle(page, room["feed"])
|
||||
|
||||
try:
|
||||
with sync_playwright() as pw:
|
||||
browser, page, log = _launch(pw, engine)
|
||||
page.add_init_script(f"({_OBSERVE_HISTORY})()")
|
||||
try:
|
||||
open_room(page)
|
||||
_send_from_composer(page, f"{ASK}: check which evidence the reading notes should cite, and ask me first.",
|
||||
room["scope"], project["id"] if project else "")
|
||||
turn = _wait(lambda: _waiting_turn(url, chat_id), 90, "the room's turn waiting on its own question")
|
||||
task_id, quiz_id = turn["activity_id"], turn["owner_wait"]["quiz_id"]
|
||||
evidence.record("turn", {"task_id": task_id, "quiz_id": quiz_id, "chat_id": chat_id})
|
||||
card = page.locator(f'{room["feed"]} .chat-quiz-card[data-task-id="{task_id}"][data-quiz-id="{quiz_id}"]')
|
||||
card.wait_for(state="attached", timeout=30_000)
|
||||
# The turn waits for its answer while the room's log goes on: the question is now older
|
||||
# than the newest window, which stays mounted while the reader walks older.
|
||||
_seed_filler(data_dir, chat_id, tag, LATER, later=True)
|
||||
|
||||
def holds_question(read):
|
||||
return quiz_id in json.dumps((read.get("body") or {}).get("messages") or [])
|
||||
|
||||
# A fresh session: every row on screen comes from the room's real history pages.
|
||||
open_room(page)
|
||||
reads = _reads(page, chat_id)
|
||||
newest = reads[-1]
|
||||
assert not newest["cursor"] and newest["body"]["has_more"], ("one newest page, older ones exist", newest["cursor"])
|
||||
assert not holds_question(newest) and card.count() == 0, "the question is older than the newest page"
|
||||
newest_ids = _ids(newest)
|
||||
base = len(reads)
|
||||
|
||||
# ---- loaded: read older until the question's page lands; the reader in the newer page before it.
|
||||
for _ in range(4):
|
||||
_step(page, room["feed"])
|
||||
if holds_question(_reads(page, chat_id)[-1]):
|
||||
break
|
||||
walk = _reads(page, chat_id)[base:]
|
||||
assert all(read["cursor"] for read in walk) and holds_question(walk[-1]), [read["cursor"] for read in walk]
|
||||
assert len(walk) >= 2, ("the question is at least two pages back", len(walk))
|
||||
question_ids = _ids(walk[-1])
|
||||
card.wait_for(state="attached", timeout=30_000)
|
||||
evidence.record("question_page", {"older_reads_to_it": len(walk), "rows": len(question_ids)})
|
||||
origin = _read_at(page, room["feed"], _filler_id(walk[-2], tag))
|
||||
assert _mounted(page, room["feed"], newest_ids) == len(newest_ids), "every page read so far is loaded"
|
||||
evidence.record("loaded_origin", origin)
|
||||
_view_question_by_keyboard(page, room, task_id, engine)
|
||||
evidence.record("loaded_landed", _landed(page, room, card))
|
||||
evidence.shot(page, "01-loaded-question")
|
||||
reads_before = len(_reads(page, chat_id))
|
||||
after = _back_by_keyboard(page, room)
|
||||
evidence.record("loaded_back", after)
|
||||
_assert_exact_return(origin, after, "loaded")
|
||||
assert len(_reads(page, chat_id)) == reads_before, "Back reads no history"
|
||||
evidence.shot(page, "02-loaded-back")
|
||||
|
||||
# ---- released: reading on lets the question's page go (the pager keeps three pages).
|
||||
for _ in range(6):
|
||||
if card.count() == 0 and _mounted(page, room["feed"], question_ids) == 0:
|
||||
break
|
||||
_step(page, room["feed"])
|
||||
pages = _reads(page, chat_id)[base:]
|
||||
evidence.record("walk", {"older_reads": len(pages), "rows": [len(_ids(read)) for read in pages],
|
||||
"question_page_mounted": _mounted(page, room["feed"], question_ids),
|
||||
"newest_page_mounted": _mounted(page, room["feed"], newest_ids),
|
||||
"newest_page_rows": len(newest_ids), "place": page.evaluate(_PLACE, room["feed"])})
|
||||
assert all(read["cursor"] for read in pages), "each older page read by its cursor"
|
||||
assert card.count() == 0, "reading on released the question's page"
|
||||
assert _mounted(page, room["feed"], question_ids) == 0, "the question's page left the feed"
|
||||
assert page.locator(f"{room['feed']} .chat-load-older button").evaluate("n => !n.hidden"), \
|
||||
"the room's beginning is still ahead"
|
||||
origin = _read_at(page, room["feed"], _filler_id(pages[-1], tag))
|
||||
evidence.record("released_origin", origin)
|
||||
detail_reads = sum(1 for method, u in log["requests"] if method == "GET" and u.endswith(f"/api/tasks/{task_id}"))
|
||||
_view_question_by_keyboard(page, room, task_id, engine)
|
||||
landed = _landed(page, room, card)
|
||||
evidence.record("released_landed", landed)
|
||||
assert sum(1 for method, u in log["requests"] if method == "GET"
|
||||
and u.endswith(f"/api/tasks/{task_id}")) > detail_reads, "the reveal read the real task detail"
|
||||
evidence.shot(page, "03-released-question")
|
||||
after = _back_by_keyboard(page, room)
|
||||
evidence.record("released_back", after)
|
||||
_assert_exact_return(origin, after, "released question, loaded passage")
|
||||
evidence.shot(page, "04-released-back")
|
||||
|
||||
# ---- origin gone: from the question, ↓ re-anchors on the present and the pager lets the passage go.
|
||||
origin_id = origin["history"]
|
||||
_view_question_by_keyboard(page, room, task_id, engine)
|
||||
evidence.record("gone_landed", _landed(page, room, card))
|
||||
cursor_reads = len([read for read in _reads(page, chat_id) if read["cursor"]])
|
||||
latest_reads = len(_reads(page, chat_id))
|
||||
page.locator(f"{room['root']} .chat-scroll-bottom-btn").focus()
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_function("([n, id]) => window.__historyReads.filter(r => r.chatId === id).length > n"
|
||||
" && window.__historyReads.every(r => r.done)", arg=[latest_reads, chat_id],
|
||||
timeout=30_000)
|
||||
page.evaluate(_FRAMES)
|
||||
page.wait_for_timeout(600)
|
||||
present = _reads(page, chat_id)[latest_reads:]
|
||||
evidence.record("present", {"cursors": [read["cursor"] for read in present], "place": page.evaluate(_PLACE, room["feed"]),
|
||||
"origin_mounted": _mounted(page, room["feed"], [origin_id])})
|
||||
assert present and not present[0]["cursor"], "↓ read the room's newest page"
|
||||
assert _mounted(page, room["feed"], [origin_id]) == 0, "the pager released the passage left"
|
||||
assert _facts(page, room["scope"])["back"] == "Back to reading", "Back stays offered"
|
||||
evidence.shot(page, "05-present-origin-released")
|
||||
place_before = page.evaluate(_PLACE, room["feed"])
|
||||
after = _back_by_keyboard(page, room)
|
||||
evidence.record("gone_back", after)
|
||||
assert after["back"] == "Earlier place is no longer loaded", after
|
||||
assert after["live"] == "The earlier reading place is no longer loaded.", after
|
||||
assert len([read for read in _reads(page, chat_id) if read["cursor"]]) == cursor_reads, \
|
||||
"Back loads no lost place: no older page is read again"
|
||||
assert _mounted(page, room["feed"], [origin_id]) == 0, "the lost passage is not rebuilt"
|
||||
assert abs(after["place"]["scrollTop"] - place_before["scrollTop"]) <= 2, ("the reader stays", place_before, after)
|
||||
assert after["focus"] != "body", after
|
||||
evidence.shot(page, "06-gone-back")
|
||||
page.wait_for_timeout(4300)
|
||||
later = {"back": _facts(page, room["scope"])["back"], "focus": page.evaluate(_ACTIVE)}
|
||||
evidence.record("gone_after_4s", later)
|
||||
assert later["back"] == "" and later["focus"] != "body", later
|
||||
|
||||
assert _decision_posts(log) == [], "View question answers nothing"
|
||||
assert (_waiting_turn(url, chat_id) or {}).get("activity_id") == task_id, "the turn still waits for the owner"
|
||||
assert log["pageerrors"] == [], log["pageerrors"]
|
||||
except Exception:
|
||||
evidence.shot(page, "failure")
|
||||
raise
|
||||
finally:
|
||||
evidence.record("history_reads", [{k: read.get(k) for k in ("cursor", "chatId", "status", "error")}
|
||||
| {"rows": len((read.get("body") or {}).get("messages") or []),
|
||||
"has_more": (read.get("body") or {}).get("has_more")}
|
||||
for read in page.evaluate("() => window.__historyReads || []")])
|
||||
evidence.record("log", log)
|
||||
browser.close()
|
||||
finally:
|
||||
model.finish()
|
||||
evidence.record("model_calls", list(model.calls))
|
||||
|
|
@ -207,6 +207,15 @@ def test_task_status_stays_factual_in_main_and_project_chat(
|
|||
assert card["phaseScroll"] <= card["phaseClient"] + 1, geometry
|
||||
assert card["titleScroll"] <= card["titleClient"] + 1, geometry
|
||||
|
||||
def room_status(page, status_selector):
|
||||
# A room says its status once, in its header's one status control (DESIGN
|
||||
# "Room work status"): Main's floating group, a Project's panel bar. The
|
||||
# cards stay in the chat column; the word is never read from two places.
|
||||
status = page.locator(status_selector)
|
||||
assert status.count() == 1, status_selector
|
||||
assert status.evaluate("n => n.closest('.rw-trigger') !== null"), status_selector
|
||||
return status
|
||||
|
||||
def run_thread_flow(
|
||||
page,
|
||||
scope,
|
||||
|
|
@ -284,10 +293,10 @@ def test_task_status_stays_factual_in_main_and_project_chat(
|
|||
|
||||
page.wait_for_function(
|
||||
"selector => document.querySelector(selector)?.textContent === 'Working...'",
|
||||
arg=f"{scope_selector} {status_selector}",
|
||||
arg=status_selector,
|
||||
timeout=30_000,
|
||||
)
|
||||
assert "Attention" not in scope.locator(status_selector).inner_text()
|
||||
assert "Attention" not in room_status(page, status_selector).inner_text()
|
||||
assert page.locator("#toast-stack .toast").count() == 0
|
||||
assert page.locator('[data-nav-page="chat"] .unread-badge').count() == 0
|
||||
|
||||
|
|
@ -320,7 +329,7 @@ def test_task_status_stays_factual_in_main_and_project_chat(
|
|||
assert_phase_accessibility(child, "Subagent", "Failed")
|
||||
page.wait_for_function(
|
||||
"selector => document.querySelector(selector)?.textContent === 'Online'",
|
||||
arg=f"{scope_selector} {status_selector}",
|
||||
arg=status_selector,
|
||||
timeout=30_000,
|
||||
)
|
||||
|
||||
|
|
@ -343,10 +352,10 @@ def test_task_status_stays_factual_in_main_and_project_chat(
|
|||
":scope > [data-live-summary-button] [data-live-title]"
|
||||
).inner_text()
|
||||
|
||||
status = scope.locator(status_selector)
|
||||
status = room_status(page, status_selector)
|
||||
page.wait_for_function(
|
||||
"selector => document.querySelector(selector)?.textContent === 'Online'",
|
||||
arg=f"{scope_selector} {status_selector}",
|
||||
arg=status_selector,
|
||||
timeout=30_000,
|
||||
)
|
||||
assert status.inner_text() == "Online"
|
||||
|
|
@ -405,7 +414,7 @@ def test_task_status_stays_factual_in_main_and_project_chat(
|
|||
page,
|
||||
page.locator("#page-chat"),
|
||||
"#page-chat",
|
||||
"#chat-status",
|
||||
"#page-chat .chat-page-header .rw-trigger #chat-status",
|
||||
1,
|
||||
"main",
|
||||
)
|
||||
|
|
@ -446,7 +455,7 @@ def test_task_status_stays_factual_in_main_and_project_chat(
|
|||
page,
|
||||
project_scope,
|
||||
"#project-panel .chat-instance-panel",
|
||||
f"#pchat-{project['id']}-status",
|
||||
f"#project-panel .project-panel-bar .rw-trigger #pchat-{project['id']}-status",
|
||||
project_chat_id,
|
||||
"project",
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue