| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424 |
- #!/usr/bin/env python3
- """Static + logic tests for the task event-log page's client JS.
- The app binary cannot be built in every container (statum-mkpstm /
- libstatum are build-time dependencies), so this suite verifies
- src/pages/task_log.html two ways:
- 1. Statically - the <script> body must be ASCII-only (the page
- compiler HTML-escapes script bodies, so any non-ASCII literal is
- re-served as literal entity text like "·" after the
- browser's one decode pass), must wire the SSE stream through
- `onmessage` (the server sends id:/data: frames with no `event:`
- name, so named listeners can never fire), must not listen for an
- 'append' event name, and its delimiter structure must balance
- after strings and comments are stripped.
- 2. Behaviourally - the ingest / render-order / scroll-stick /
- load-more logic is ported verbatim below as pure python functions
- and unit-tested: oldest-first chronology, id dedupe, the 48px
- bottom-stick rule, prepend-mode scroll anchoring, and the
- newest-first-page reversal with the `more` -> exhausted flag.
- Runnable standalone: python3 test/test_task_log_js.py
- """
- import os
- import re
- import unittest
- PAGE = os.path.join(os.path.dirname(os.path.abspath(__file__)),
- os.pardir, "src", "pages", "task_log.html")
- def script_body():
- with open(PAGE, encoding="utf-8") as handle:
- source = handle.read()
- head, rest = source.split("<script>", 1)
- body, _ = rest.split("</script>", 1)
- assert head, "no content before the script block"
- return body
- # ---------------------------------------------------------------------------
- # Static checks
- # ---------------------------------------------------------------------------
- class ScriptSourceTests(unittest.TestCase):
- """What the served bytes must look like after compilation."""
- def setUp(self):
- self.body = script_body()
- def test_script_is_ascii_only(self):
- """Non-ASCII in a script body comes back as literal entity
- text ("·") because the compiler escapes script bodies
- and the browser decodes exactly once. ASCII (with \\uXXXX
- escapes for the characters we want) is immune."""
- offenders = [(index, hex(ord(char)))
- for index, char in enumerate(self.body) if ord(char) > 127]
- self.assertEqual(offenders, [])
- def test_unicode_escapes_used_for_separators(self):
- """The separator characters must still render - as escapes,
- not as raw bytes and not as HTML entities."""
- for escape in ("\\u00b7", "\\u2026", "\\u2014"):
- self.assertIn(escape, self.body)
- self.assertNotIn("·", self.body)
- self.assertNotIn("—", self.body)
- def test_stream_wired_through_onmessage(self):
- self.assertRegex(self.body, r"stream\.onmessage\s*=")
- self.assertNotIn("addEventListener('append'", self.body)
- self.assertNotIn('addEventListener("append"', self.body)
- def test_heartbeat_ping_is_skipped(self):
- self.assertRegex(self.body, r"message\.data\s*===\s*'ping'")
- def test_load_more_pages_before_oldest(self):
- self.assertRegex(self.body, r"/events\?before='\s*\+\s*\(state\.oldest")
- def test_page_is_reversed_before_ingest(self):
- self.assertIn("slice().reverse().forEach(ingest)", self.body)
- def test_exhausted_set_when_no_more(self):
- self.assertIn("state.exhausted = true", self.body)
- def test_more_button_sits_above_the_feed(self):
- # Search the page markup only - the script's comments also
- # mention [data-log-feed], before the elements do.
- with open(PAGE, encoding="utf-8") as handle:
- markup = handle.read().split("</script>", 1)[1]
- more = markup.index("data-log-more")
- feed = markup.index("data-log-feed")
- self.assertLess(more, feed,
- "the load-older control must precede the feed markup")
- def test_delimiters_balance(self):
- """A cheap structural stand-in for a JS parser (none is
- installed here): after stripping comments and string literals
- every bracket must close."""
- body = self.body
- out = []
- i = 0
- mode = None # None | 'line' | 'block' | "'" | '"'
- while i < len(body):
- char = body[i]
- nxt = body[i + 1] if i + 1 < len(body) else ""
- if mode is None:
- if char == "/" and nxt == "/":
- mode = "line"
- i += 2
- continue
- if char == "/" and nxt == "*":
- mode = "block"
- i += 2
- continue
- if char in "'\"":
- mode = char
- i += 1
- continue
- out.append(char)
- elif mode == "line":
- if char == "\n":
- mode = None
- out.append("\n")
- elif mode == "block":
- if char == "*" and nxt == "/":
- mode = None
- i += 2
- continue
- else: # inside a string literal
- if char == "\\":
- i += 2
- continue
- if char == mode:
- mode = None
- i += 1
- self.assertEqual(mode, None, "unterminated comment or string literal")
- pairs = {")": "(", "]": "[", "}": "{"}
- stack = []
- for char in out:
- if char in "([{":
- stack.append(char)
- elif char in pairs:
- self.assertTrue(stack and stack[-1] == pairs[char],
- "unbalanced %r at depth %s" % (char, stack))
- stack.pop()
- self.assertEqual(stack, [], "unclosed delimiters %s" % stack)
- # ---------------------------------------------------------------------------
- # Port of the client logic (keep in step with task_log.html)
- # ---------------------------------------------------------------------------
- STICK_THRESHOLD = 48 # px that still counts as "reading the tail"
- PAGE_SIZE = 30
- class Feed(object):
- """Scroll-state stand-in for [data-log-feed]."""
- def __init__(self, scroll_height=0, client_height=600, scroll_top=0):
- self.scroll_height = scroll_height
- self.client_height = client_height
- self.scroll_top = scroll_top
- def at_bottom(feed):
- """PORT: a feed shorter than its viewport counts as at-bottom."""
- return feed.scroll_height - feed.scroll_top - feed.client_height \
- <= STICK_THRESHOLD
- class State(object):
- """PORT: the module-level `state` object."""
- def __init__(self):
- self.events = []
- self.oldest = 0
- self.exhausted = False
- self.loading = False
- self.seeded = False
- def ingest(state, evt):
- """PORT: keep state.events oldest-first (ascending id); skip ids
- already held (SSE replay after a reconnect resends a frame)."""
- i = 0
- while i < len(state.events) and state.events[i]["id"] < evt["id"]:
- i += 1
- if i < len(state.events) and state.events[i]["id"] == evt["id"]:
- return
- state.events.insert(i, evt)
- if evt["id"] < state.oldest or state.oldest == 0:
- state.oldest = evt["id"]
- def render(state, feed, mode=None, height_after=None):
- """PORT of render(mode): decide the post-render scroll position.
- height_after stands in for the DOM rebuild's new scrollHeight."""
- was_at_bottom = at_bottom(feed)
- height_before = feed.scroll_height
- scroll_top_before = feed.scroll_top
- if height_after is not None:
- feed.scroll_height = height_after
- if not state.seeded and len(state.events) > 0:
- state.seeded = True
- feed.scroll_top = feed.scroll_height # scrollToBottom()
- elif mode == "prepend":
- if was_at_bottom:
- feed.scroll_top = feed.scroll_height
- else:
- feed.scroll_top = scroll_top_before + (feed.scroll_height
- - height_before)
- elif was_at_bottom:
- feed.scroll_top = feed.scroll_height
- else:
- feed.scroll_top = scroll_top_before
- return feed.scroll_top
- def server_page(newest, oldest, more):
- """The endpoint's wire shape: newest-first within the page."""
- return {"events": [{"id": i} for i in range(newest, oldest - 1, -1)],
- "more": more}
- def load_more(state, feed, page, height_after):
- """PORT of loadMore's success path: reverse the newest-first page
- into chronological order, ingest it, set exhausted, render."""
- rows = page["events"]
- for evt in reversed(rows):
- ingest(state, evt)
- if not page["more"] or len(rows) == 0:
- state.exhausted = True
- return render(state, feed, mode="prepend", height_after=height_after)
- def on_stream_message(state, feed, data, height_after):
- """PORT of onStreamMessage: skip heartbeats, ingest one row."""
- if data == "ping":
- return feed.scroll_top
- ingest(state, {"id": int(data)})
- return render(state, feed, mode="append", height_after=height_after)
- # ---------------------------------------------------------------------------
- # Behaviour tests
- # ---------------------------------------------------------------------------
- def ids(state):
- return [evt["id"] for evt in state.events]
- class ChronologyTests(unittest.TestCase):
- def test_initial_window_renders_oldest_first(self):
- """The SSE replay / first JSON page arrives newest-first
- ([30..1]); the feed must render [1..30] - oldest at the top."""
- state = State()
- for evt in server_page(30, 1, True)["events"]:
- ingest(state, evt)
- self.assertEqual(ids(state), list(range(1, 31)))
- self.assertEqual(state.oldest, 1)
- def test_live_appends_land_in_order(self):
- state = State()
- for i in range(30, 0, -1):
- ingest(state, {"id": i})
- ingest(state, {"id": 31})
- ingest(state, {"id": 33})
- ingest(state, {"id": 32}) # out of order arrival
- self.assertEqual(ids(state)[-3:], [31, 32, 33])
- def test_duplicate_ids_are_dropped(self):
- state = State()
- ingest(state, {"id": 5})
- ingest(state, {"id": 5}) # EventSource reconnect replay
- self.assertEqual(ids(state), [5])
- class ScrollStickTests(unittest.TestCase):
- def test_first_render_with_rows_seeds_at_the_bottom(self):
- state = State()
- feed = Feed(scroll_height=0, client_height=600)
- for evt in server_page(30, 1, True)["events"]:
- ingest(state, evt)
- render(state, feed, height_after=3000)
- self.assertTrue(state.seeded)
- self.assertEqual(feed.scroll_top, 3000)
- def test_append_sticks_when_reading_the_tail(self):
- state = State()
- feed = Feed(scroll_height=3000, client_height=600)
- state.seeded = True
- feed.scroll_top = 2400 # exactly at the bottom
- self.assertTrue(at_bottom(feed))
- ingest(state, {"id": 31})
- render(state, feed, mode="append", height_after=3100)
- self.assertEqual(feed.scroll_top, 3100)
- def test_append_within_threshold_still_sticks(self):
- feed = Feed(scroll_height=3000, client_height=600, scroll_top=2360)
- self.assertTrue(at_bottom(feed)) # 40px shy: close enough
- def test_just_past_threshold_is_not_the_tail(self):
- feed = Feed(scroll_height=3000, client_height=600, scroll_top=2351)
- self.assertFalse(at_bottom(feed)) # 49px shy: reading history
- def test_scrolled_up_reader_is_never_yanked(self):
- state = State()
- feed = Feed(scroll_height=3000, client_height=600, scroll_top=500)
- state.seeded = True
- self.assertFalse(at_bottom(feed))
- ingest(state, {"id": 31})
- render(state, feed, mode="append", height_after=3100)
- self.assertEqual(feed.scroll_top, 500)
- def test_short_feed_counts_as_at_bottom(self):
- """A feed shorter than its viewport cannot be scrolled away
- from the newest row."""
- feed = Feed(scroll_height=100, client_height=600, scroll_top=0)
- self.assertTrue(at_bottom(feed))
- def test_heartbeat_does_not_touch_anything(self):
- state = State()
- feed = Feed(scroll_height=3000, client_height=600, scroll_top=2400)
- state.seeded = True
- before = (list(ids(state)), feed.scroll_top)
- on_stream_message(state, feed, "ping", height_after=3100)
- self.assertEqual((list(ids(state)), feed.scroll_top), before)
- class LoadMoreTests(unittest.TestCase):
- def test_older_page_is_reversed_and_prepended(self):
- """before=<oldest>=1 returns [60..31] newest-first on the
- wire; the display must gain [31..60] above [1..30]."""
- state = State()
- feed = Feed(scroll_height=3000, client_height=600, scroll_top=500)
- for evt in server_page(30, 1, True)["events"]:
- ingest(state, evt)
- state.seeded = True
- render(state, feed, height_after=3000)
- feed.scroll_top = 500 # the reader scrolled up into history
- load_more(state, feed, server_page(60, 31, True), height_after=6000)
- self.assertEqual(ids(state), list(range(1, 61)))
- self.assertEqual(state.oldest, 1)
- self.assertFalse(state.exhausted)
- def test_prepend_keeps_the_viewport_anchored(self):
- """Content added above must not move the rows under the
- reader's cursor: scrollTop grows by the added height."""
- state = State()
- feed = Feed(scroll_height=3000, client_height=600, scroll_top=500)
- state.seeded = True
- self.assertFalse(at_bottom(feed))
- delta = 3000
- load_more(state, feed, server_page(60, 31, True),
- height_after=3000 + delta)
- self.assertEqual(feed.scroll_top, 500 + delta)
- def test_prepend_while_at_bottom_stays_at_bottom(self):
- state = State()
- feed = Feed(scroll_height=3000, client_height=600, scroll_top=2400)
- state.seeded = True
- self.assertTrue(at_bottom(feed))
- load_more(state, feed, server_page(60, 31, True), height_after=6000)
- self.assertEqual(feed.scroll_top, 6000)
- def test_exhausted_when_more_is_false(self):
- state = State()
- feed = Feed(scroll_height=3000, client_height=600, scroll_top=2400)
- state.seeded = True
- load_more(state, feed, server_page(60, 31, False), height_after=6000)
- self.assertTrue(state.exhausted)
- def test_empty_page_exhausts_too(self):
- """A defensive guard: a page with no rows means no older
- history, whatever the flag says."""
- state = State()
- feed = Feed(scroll_height=3000, client_height=600, scroll_top=2400)
- state.seeded = True
- ingest(state, {"id": 1})
- load_more(state, feed, {"events": [], "more": True}, height_after=3000)
- self.assertTrue(state.exhausted)
- class EndToEndSequenceTests(unittest.TestCase):
- """The whole first-minute story: replay, live tail, history."""
- def test_replay_then_append_then_history(self):
- state = State()
- feed = Feed(scroll_height=0, client_height=600)
- # 1. SSE replay: most-recent-30 oldest-first on the wire.
- for evt in server_page(30, 1, True)["events"]:
- ingest(state, evt)
- render(state, feed, height_after=3000)
- self.assertEqual(ids(state), list(range(1, 31)))
- self.assertEqual(feed.scroll_top, 3000) # seeded at the tail
- # 2. Two live rows arrive; the reader is at the bottom.
- on_stream_message(state, feed, "31", height_after=3100)
- on_stream_message(state, feed, "32", height_after=3200)
- self.assertEqual(ids(state)[-2:], [31, 32])
- self.assertEqual(feed.scroll_top, 3200)
- # 3. A heartbeat is ignored, then the reader scrolls up.
- on_stream_message(state, feed, "ping", height_after=3200)
- feed.scroll_top = 100
- on_stream_message(state, feed, "33", height_after=3300)
- self.assertEqual(feed.scroll_top, 100) # never yanked
- # 4. Load older: before=1 -> [60..31] newest-first wire page.
- self.assertEqual(state.oldest, 1)
- load_more(state, feed, server_page(60, 31, False), height_after=6300)
- self.assertEqual(ids(state), list(range(1, 61)))
- self.assertTrue(state.exhausted)
- self.assertEqual(feed.scroll_top, 100 + 3000) # anchored
- if __name__ == "__main__":
- unittest.main(verbosity=2)
|