t10views.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. """The T10 views bundle (goal 41 task 10): the two phase-D structural
  2. bets — saved views / URL state on /tasks (ux-notes bet 4) and the
  3. cross-entity work stream on the dashboard (bet 5).
  4. Bet 4 is a CLIENT-side engine (the statum entrypoint fetch carries no
  5. query string, so filter state lives in location.hash): this bundle
  6. verifies the engine's round-trip as pure logic (a python port of the
  7. serialise/parse pair driving the exact page functions) plus the
  8. markup hooks the page ships. The prefill namespace (#new?project=)
  9. must keep coexisting with the view namespace (#view?...).
  10. Bet 5 is server data + markup: the dashboard slot's work_stream rows
  11. (joined, time-ordered, capped, linked) are cross-checked against
  12. seeded ledger events, and the page renders the stream feed with the
  13. old activity table gone.
  14. """
  15. from harness import * # noqa: F401,F403 — the shared wire helpers
  16. # The open-work status set (window.TASK_OPEN_STATUSES on the page).
  17. OPEN = ("queued", "assigned", "working", "blocked", "suspended")
  18. def view_hash(filters):
  19. """window.taskViewHash ported verbatim: only non-default axes
  20. serialise ('open' status, all projects, all goals stay clean)."""
  21. parts = []
  22. for key in ("status", "project", "goal"):
  23. value = filters.get(key)
  24. if key == "status":
  25. value = value or "open"
  26. if value == "open":
  27. continue
  28. elif value in (None, "", 0, "0", "all"):
  29. continue
  30. from urllib.parse import quote
  31. parts.append("%s=%s" % (key, quote(str(value), safe="")))
  32. return "#view?" + "&".join(parts) if parts else ""
  33. def filters_from_hash(hash_text):
  34. """window.taskFiltersFromHash ported verbatim (None = not a view
  35. hash: the #new prefill namespace and bare hashes fall through)."""
  36. if not hash_text or not hash_text.startswith("#view?"):
  37. return None
  38. filters = {}
  39. for part in hash_text[len("#view?"):].split("&"):
  40. if "=" not in part:
  41. continue
  42. key, value = part.split("=", 1)
  43. from urllib.parse import unquote
  44. key, value = unquote(key), unquote(value)
  45. if key == "status" and value:
  46. filters["status"] = value
  47. elif key == "project" and value and value != "0":
  48. filters["project"] = value
  49. elif key == "goal" and value and value != "all":
  50. filters["goal"] = value
  51. return filters
  52. def run(ctx):
  53. auth, fake = ctx.auth, ctx.fake
  54. now = "strftime('%s','now')"
  55. # ------------------------------------------------------------------
  56. print("== bet 4: the hash engine (round-trip as pure logic) ==")
  57. cases = [
  58. ({}, ""), # the default view keeps the URL clean
  59. ({"status": "open"}, ""),
  60. ({"status": "working"}, "#view?status=working"),
  61. ({"status": "blocked"}, "#view?status=blocked"),
  62. ({"status": "all"}, "#view?status=all"),
  63. ({"project": 3}, "#view?project=3"),
  64. ({"project": "3"}, "#view?project=3"),
  65. ({"project": 0}, ""), # "all projects" is the default
  66. ({"goal": "standalone"}, "#view?goal=standalone"),
  67. ({"goal": "all"}, ""),
  68. ({"goal": 12}, "#view?goal=12"),
  69. ({"status": "working", "project": 3, "goal": "standalone"},
  70. "#view?status=working&project=3&goal=standalone"),
  71. # A title with spaces/ampersands survives the round-trip
  72. # encoded (the goal axis carries ids in practice, but the
  73. # engine must not corrupt any value).
  74. ({"goal": "a & b=c"}, "#view?goal=a%20%26%20b%3Dc"),
  75. ]
  76. for filters, expected in cases:
  77. got = view_hash(filters)
  78. check(got == expected,
  79. "serialise %r -> %r" % (filters, expected))
  80. if expected:
  81. back = filters_from_hash(expected)
  82. wanted = {}
  83. for key, value in filters.items():
  84. text = str(value)
  85. if key == "status":
  86. if text not in ("open", ""):
  87. wanted[key] = text
  88. elif text not in ("0", "all", ""):
  89. wanted[key] = text
  90. check(back == wanted,
  91. "parse %r restores %r (got %r)" % (expected, wanted, back))
  92. # The prefill namespace never parses as a view.
  93. check(filters_from_hash("#new?project=7") is None,
  94. "the #new prefill hash is not a view hash")
  95. check(filters_from_hash("") is None and filters_from_hash("#") is None,
  96. "bare hashes are not view hashes")
  97. print("== bet 4: the page ships the engine + the view row ==")
  98. tasks_page = http_get_page(auth, "/tasks")
  99. for needle, label in (
  100. ("window.taskViewHash", "the serialiser"),
  101. ("window.taskFiltersFromHash", "the parser"),
  102. ("window.applyTaskViewHash", "the load-time seed"),
  103. ("window.copyTaskViewLink", "the copy-link affordance"),
  104. ("syncTaskHash", "the filter-change hash write"),
  105. ):
  106. check(needle in tasks_page, "the tasks page ships %s" % label)
  107. check("syncTaskHash();" in tasks_page,
  108. "every filter change lands in the hash (setTaskFilter)")
  109. check("window.history.replaceState" in tasks_page,
  110. "hash updates use replaceState (no Back-button spam)")
  111. check("hashchange" in tasks_page,
  112. "a pasted #view hash re-seeds the filters in-place")
  113. check("window.applyTaskViewHash();" in tasks_page,
  114. "the hash seeds taskFilters before the first render")
  115. check("#view?" in tasks_page,
  116. "the view namespace is distinct from the #new prefill")
  117. # The view row: presets + copy button.
  118. check('data-task-view-row' in tasks_page,
  119. "the view row is marked for the bundle/harness")
  120. check("Copy view link" in tasks_page
  121. and "copyTaskViewLink" in tasks_page,
  122. "the one-click copy-link button ships")
  123. for preset in ("Open work", "All", "Blocked"):
  124. check(preset in tasks_page,
  125. "the %r preset chip ships in the view row" % preset)
  126. check("navigator.clipboard" in tasks_page,
  127. "the copy uses the async clipboard API")
  128. # The presets ARE the chips' statuses (no second filter state).
  129. check(tasks_page.count("window.taskChipClass('open')") >= 2,
  130. "the presets reuse the chip engine's status vocabulary")
  131. print("== bet 4: prefill coexistence ==")
  132. # T9's #new?project= deep-link still parses + drops itself, and
  133. # the view engine ignores it (namespace check above; markup).
  134. check("indexOf('#new')" in tasks_page,
  135. "the #new prefill contract still ships (T9)")
  136. check("indexOf('#view?')" in tasks_page,
  137. "the view parser gates on its own namespace")
  138. # A prefill hash and a view hash in one URL: the prefill wins on
  139. # load (it consumes and drops the whole hash); the view engine
  140. # then reads nothing - the ported parser mirrors that by gating.
  141. check(filters_from_hash("#new?project=7") is None,
  142. "a load-time prefill never double-applies as a view")
  143. # ------------------------------------------------------------------
  144. print("== bet 5: the work stream (joined, ordered, capped, linked) ==")
  145. # Seed the three row grammars through the ledger the timeline
  146. # reads: a task progress line, a goal-scoped narrative event and
  147. # a plain task comment (log highlight).
  148. conn = db()
  149. conn.execute(
  150. "insert into tasks (project_id, title, status, priority, created_at)"
  151. " values (1, 'T10 stream task', 'working', 5, %s)" % now)
  152. stream_task = conn.execute(
  153. "select last_insert_rowid()").fetchone()[0]
  154. conn.execute(
  155. "insert into goals (project_id, title, brief, branch_name, status,"
  156. " created_at) values (1, 'T10 stream goal', 'The stream probe.',"
  157. " 'goal/t10', 'running', %s)" % now)
  158. stream_goal = conn.execute(
  159. "select last_insert_rowid()").fetchone()[0]
  160. # Goal runs are internal - their task rows never stream.
  161. conn.execute(
  162. "insert into tasks (project_id, goal_id, kind, title, status,"
  163. " priority, created_at) values (1, ?, 'goal_run',"
  164. " 'T10 internal run', 'working', 5, %s)" % now, (stream_goal,))
  165. run_task = conn.execute(
  166. "select last_insert_rowid()").fetchone()[0]
  167. def event(kind, scope_type, scope_id, payload, offset):
  168. conn.execute(
  169. "insert into events (kind, actor, scope_type, scope_id,"
  170. " payload, created_at) values (?, 'model', ?, ?, ?,"
  171. " strftime('%s','now') - ?)",
  172. (kind, scope_type, scope_id, payload, offset))
  173. event("progress", "task", stream_task,
  174. '{"text": "T10 progress line"}', 30)
  175. event("state_change", "goal", stream_goal,
  176. '{"to": "running", "text": "T10 goal narrative"}', 20)
  177. event("comment", "task", stream_task,
  178. '{"author": "human", "body": "T10 comment highlight"}', 10)
  179. event("progress", "task", run_task,
  180. '{"text": "T10 internal run progress"}', 5)
  181. conn.commit()
  182. conn.close()
  183. dash = slot_public(auth, "/", "dashboard")
  184. stream = dash.get("work_stream", [])
  185. check(isinstance(stream, list),
  186. "the dashboard slot carries the work_stream rows")
  187. check(0 < len(stream) <= 15,
  188. "the stream is capped at 15 rows (got %d)" % len(stream))
  189. epochs = [int(row["at_epoch"]) for row in stream]
  190. check(epochs == sorted(epochs, reverse=True),
  191. "the stream is newest-first")
  192. progress_rows = [row for row in stream
  193. if row["kind"] == "task_progress"]
  194. goal_rows = [row for row in stream if row["kind"] == "goal_event"]
  195. highlight_rows = [row for row in stream
  196. if row["kind"] == "log_highlight"]
  197. check(any("T10 progress line" in row["text"]
  198. and int(row["task_id"]) == stream_task
  199. and row["href"] == "/tasks/%d" % stream_task
  200. for row in progress_rows),
  201. "task progress rows join with the task link")
  202. check(any("T10 goal narrative" in row["text"]
  203. and int(row["goal_id"]) == stream_goal
  204. and row["href"] == "/goals/%d" % stream_goal
  205. for row in goal_rows),
  206. "goal narrative rows join with the goal link")
  207. check(any("T10 comment highlight" in row["text"]
  208. and int(row["task_id"]) == stream_task
  209. for row in highlight_rows),
  210. "comment rows stream as log highlights")
  211. check(all(not ("internal run" in row["text"]
  212. or int(row.get("task_id", 0)) == run_task)
  213. for row in stream),
  214. "goal-run internals never stream (the goal carries them)")
  215. # The three grammars agree with the activity projection they
  216. # derive from (one window, two projections - same order).
  217. activity = dash.get("activity", [])
  218. check(len(stream) <= len(activity),
  219. "the stream is the window's newest slice (activity keeps the full window)")
  220. check(len(activity) > 15 or len(stream) == len(activity),
  221. "under the cap the stream covers the whole window")
  222. # Every row links somewhere or is a bare highlight.
  223. for row in stream:
  224. if row["href"]:
  225. check(row["href"].startswith(("/tasks/", "/goals/")),
  226. "row hrefs stay in-app (%s)" % row["href"])
  227. break
  228. print("== bet 5: the dashboard renders the stream as its feed ==")
  229. home_page = http_get_page(auth, "/")
  230. check('data-work-stream' in home_page,
  231. "the dashboard ships the work-stream feed")
  232. check("work-stream" in home_page and "dashboard.work_stream" in home_page,
  233. "the feed renders the slot's work_stream rows")
  234. check("Work stream" in home_page,
  235. "the feed is titled as the work stream")
  236. check("badge-stream-" in home_page,
  237. "the rows carry the stream's badge vocabulary")
  238. # The old three-grammar surfaces it replaces on THIS page: the
  239. # activity table is gone from the dashboard.
  240. check("dash-activity-table" not in home_page,
  241. "the old activity table is gone from the dashboard")
  242. check("data-activity-toggle" not in home_page,
  243. "the activity Show-all toggle is gone (the server caps)")
  244. check("Show all" not in home_page,
  245. "no stale Show-all control remains")
  246. # The new-since badge keeps working off the stream rows.
  247. check("state.work_stream" in home_page,
  248. "the new-since badge counts stream rows")
  249. # The underlying surfaces live elsewhere (not deleted): the goal
  250. # page narrative + the task log stay their pages' own.
  251. check("data-activity-new" in home_page,
  252. "the ambient expander frame (with the new badge) keeps the feed quiet")
  253. print("== bet 5: the stream survives the pulse refresh ==")
  254. refresh = dash.get("refresh")
  255. check(refresh is not None, "the dashboard refresh action is authored")
  256. act(auth, refresh, {})
  257. dash2 = slot_public(auth, "/", "dashboard")
  258. check(len(dash2.get("work_stream", [])) > 0,
  259. "the sealed refresh rebuilds the stream too")