|
@@ -0,0 +1,238 @@
|
|
|
|
|
+"""The T9 library bundle (goal 41 task 9): the project detail, rules,
|
|
|
|
|
+discoveries and chats pages on the Spry conversion — the project page's
|
|
|
|
|
+Details/Edit twin cards collapsed into ONE in-place editor
|
|
|
|
|
+(data-long-form) with the freed structure carrying the Goals reverse
|
|
|
|
|
+link and a create-task deep-link into the /tasks dialog, the rules
|
|
|
|
|
+page's Edit expander turned in-place with Delete demoted behind a
|
|
|
|
|
+ghost button and the new blast-radius attachments list, the discoveries
|
|
|
|
|
+rows' edit expander turned in-place, and breadcrumbs on the project and
|
|
|
|
|
+chat detail pages. Markup-level via http_get_page; the new slot members
|
|
|
|
|
+(goals vector, rule attachments, crumbs vectors) cross-checked against
|
|
|
|
|
+seeded database rows."""
|
|
|
|
|
+from harness import * # noqa: F401,F403 — the shared wire helpers
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def run(ctx):
|
|
|
|
|
+ auth, fake = ctx.auth, ctx.fake
|
|
|
|
|
+
|
|
|
|
|
+ now = "strftime('%s','now')"
|
|
|
|
|
+
|
|
|
|
|
+ print("== project detail: the in-place Details editor ==")
|
|
|
|
|
+ # A dedicated project so the bundle owns every row it asserts on.
|
|
|
|
|
+ state = slot_public(auth, "/projects", "projects")
|
|
|
|
|
+ act(auth, state["create"], {"name": "t9-library",
|
|
|
|
|
+ "git_url": "https://example.com/t9.git",
|
|
|
|
|
+ "default_branch": "main",
|
|
|
|
|
+ "summary": "the T9 probe project"})
|
|
|
|
|
+ rows = slot_public(auth, "/projects", "projects")["projects"]
|
|
|
|
|
+ project_id = int([p["id"] for p in rows if p["name"] == "t9-library"][0])
|
|
|
|
|
+
|
|
|
|
|
+ detail = slot_public(auth, "/projects/%d" % project_id, "project")
|
|
|
|
|
+ check(detail["update"] is not None,
|
|
|
|
|
+ "the project slot authors the update action")
|
|
|
|
|
+
|
|
|
|
|
+ page = http_get_page(auth, "/projects/%d" % project_id)
|
|
|
|
|
+ check('data-long-form data-field="project"' in page,
|
|
|
|
|
+ "the Details card is one data-long-form card")
|
|
|
|
|
+ check(page.count('data-lf-edit') >= 1 and page.count('data-lf-view') >= 1,
|
|
|
|
|
+ "the card carries the read/edit halves")
|
|
|
|
|
+ check('data-lf-src="project.git_url"' in page
|
|
|
|
|
+ and 'data-lf-src="project.summary"' in page,
|
|
|
|
|
+ "the edit half prefills from the slot's current values")
|
|
|
|
|
+ check("<h3>Edit</h3>" not in page,
|
|
|
|
|
+ "the side-by-side Edit card is gone")
|
|
|
|
|
+ check('stm-action="project.update"' in page,
|
|
|
|
|
+ "the editor form posts the project update action")
|
|
|
|
|
+
|
|
|
|
|
+ print("== project detail: breadcrumbs replace the back anchor ==")
|
|
|
|
|
+ check("<p><a href=\"/projects\">All projects</a></p>" not in page,
|
|
|
|
|
+ "the hand-drawn All-projects anchor is gone")
|
|
|
|
|
+ check('stm-def-crumbs-as="project.crumbs"' in page,
|
|
|
|
|
+ "the breadcrumb partial rides the project page")
|
|
|
|
|
+ crumbs = detail["crumbs"]
|
|
|
|
|
+ check([c["label"] for c in crumbs]
|
|
|
|
|
+ == ["Dashboard", "Projects", "t9-library"]
|
|
|
|
|
+ and crumbs[0]["href"] == "/" and crumbs[1]["href"] == "/projects"
|
|
|
|
|
+ and crumbs[2].get("href") is None,
|
|
|
|
|
+ "the crumbs vector trails Dashboard / Projects / <name> (%s)"
|
|
|
|
|
+ % [c["label"] for c in crumbs])
|
|
|
|
|
+
|
|
|
|
|
+ print("== project detail: the create-task deep-link ==")
|
|
|
|
|
+ check("'#new?project=' + project.id" in page
|
|
|
|
|
+ or "'/tasks#new?project=' + project.id" in page,
|
|
|
|
|
+ "the create affordance deep-links the /tasks dialog prefill")
|
|
|
|
|
+ check("New task in this project" in page,
|
|
|
|
|
+ "the affordance names the project flow")
|
|
|
|
|
+ tasks_page = http_get_page(auth, "/tasks")
|
|
|
|
|
+ check("applyNewTaskPrefill" in tasks_page,
|
|
|
|
|
+ "the tasks page ships the prefill pass")
|
|
|
|
|
+ check("task-create-dialog" in tasks_page
|
|
|
|
|
+ and "openAppDialog" in tasks_page,
|
|
|
|
|
+ "the prefill opens T4's create dialog through the helper")
|
|
|
|
|
+
|
|
|
|
|
+ print("== project detail: the goals reverse link ==")
|
|
|
|
|
+ # Seed one primary goal and one co-developed link; a reference link
|
|
|
|
|
+ # must NOT appear (context, not work happening here).
|
|
|
|
|
+ conn = db()
|
|
|
|
|
+ now = "strftime('%s','now')"
|
|
|
|
|
+ conn.execute(
|
|
|
|
|
+ "insert into goals (project_id, title, brief, branch_name, status,"
|
|
|
|
|
+ " priority, creator, created_at) values (?, 'T9 primary goal',"
|
|
|
|
|
+ " 'built here', 'goal/t9-primary', 'active', 3, 'human', %s)"
|
|
|
|
|
+ % now, (project_id,))
|
|
|
|
|
+ primary_goal = int(conn.execute("select max(id) from goals").fetchone()[0])
|
|
|
|
|
+ conn.execute(
|
|
|
|
|
+ "insert into projects (name, git_url, default_branch, created_at)"
|
|
|
|
|
+ " values ('t9-codev', 'https://example.com/t9c.git', 'main', %s)"
|
|
|
|
|
+ % now)
|
|
|
|
|
+ codev_project = int(conn.execute("select max(id) from projects")
|
|
|
|
|
+ .fetchone()[0])
|
|
|
|
|
+ conn.execute(
|
|
|
|
|
+ "insert into goals (project_id, title, brief, branch_name, status,"
|
|
|
|
|
+ " priority, creator, created_at) values (?, 'T9 co-dev goal',"
|
|
|
|
|
+ " 'built alongside', 'goal/t9-codedev', 'planning', 3, 'human', %s)"
|
|
|
|
|
+ % now, (codev_project,))
|
|
|
|
|
+ codev_goal = int(conn.execute("select max(id) from goals").fetchone()[0])
|
|
|
|
|
+ conn.execute(
|
|
|
|
|
+ "insert into goal_links (goal_id, project_id, relationship)"
|
|
|
|
|
+ " values (?, ?, 'co-developed')", (codev_goal, project_id))
|
|
|
|
|
+ conn.execute(
|
|
|
|
|
+ "insert into goals (project_id, title, brief, branch_name, status,"
|
|
|
|
|
+ " priority, creator, created_at) values (?, 'T9 ref goal',"
|
|
|
|
|
+ " 'read only', 'goal/t9-ref', 'draft', 3, 'human', %s)"
|
|
|
|
|
+ % now, (codev_project,))
|
|
|
|
|
+ ref_goal = int(conn.execute("select max(id) from goals").fetchone()[0])
|
|
|
|
|
+ conn.execute(
|
|
|
|
|
+ "insert into goal_links (goal_id, project_id, relationship)"
|
|
|
|
|
+ " values (?, ?, 'reference')", (ref_goal, project_id))
|
|
|
|
|
+ conn.commit()
|
|
|
|
|
+ conn.close()
|
|
|
|
|
+
|
|
|
|
|
+ detail = slot_public(auth, "/projects/%d" % project_id, "project")
|
|
|
|
|
+ goals = {str(g["id"]): g for g in detail["goals"]}
|
|
|
|
|
+ check(set(goals) == {str(primary_goal), str(codev_goal)},
|
|
|
|
|
+ "the goals vector carries the primary and co-developed goals only"
|
|
|
|
|
+ " (got %s)" % sorted(goals))
|
|
|
|
|
+ check(goals[str(primary_goal)]["relationship"] == "primary"
|
|
|
|
|
+ and goals[str(codev_goal)]["relationship"] == "co-developed",
|
|
|
|
|
+ "each goal row names its relationship")
|
|
|
|
|
+ check(goals[str(primary_goal)]["display"] == "active"
|
|
|
|
|
+ and goals[str(codev_goal)]["display"] == "planning",
|
|
|
|
|
+ "the rows carry the human display status")
|
|
|
|
|
+
|
|
|
|
|
+ page = http_get_page(auth, "/projects/%d" % project_id)
|
|
|
|
|
+ check("Goals in this project" in page
|
|
|
|
|
+ and "'/goals/' + goal.id" in page,
|
|
|
|
|
+ "the page renders the goals reverse-link table")
|
|
|
|
|
+ check("'badge badge-' + goal.status" in page,
|
|
|
|
|
+ "goal rows badge their status like the goals list")
|
|
|
|
|
+
|
|
|
|
|
+ print("== rules: the in-place editor + the demoted delete ==")
|
|
|
|
|
+ rules_state = slot_public(auth, "/rules", "rules")
|
|
|
|
|
+ act(auth, rules_state["create"], {
|
|
|
|
|
+ "name": "T9 blast rule", "body": "The blast radius probe rule."})
|
|
|
|
|
+ conn = db()
|
|
|
|
|
+ rule_id = int(conn.execute(
|
|
|
|
|
+ "select id from rules where name = 'T9 blast rule'").fetchone()[0])
|
|
|
|
|
+ # Attach it to the probe project and to one task.
|
|
|
|
|
+ conn.execute(
|
|
|
|
|
+ "insert into rule_projects (rule_id, project_id, created_at)"
|
|
|
|
|
+ " values (?, ?, %s)" % now, (rule_id, project_id))
|
|
|
|
|
+ conn.execute(
|
|
|
|
|
+ "insert into tasks (project_id, title, status, priority, created_at)"
|
|
|
|
|
+ " values (?, 'T9 blast task', 'queued', 5, %s)" % now, (project_id,))
|
|
|
|
|
+ blast_task = int(conn.execute("select max(id) from tasks").fetchone()[0])
|
|
|
|
|
+ conn.execute(
|
|
|
|
|
+ "insert into task_rules (rule_id, task_id, created_at)"
|
|
|
|
|
+ " values (?, ?, %s)" % now, (rule_id, blast_task))
|
|
|
|
|
+ conn.commit()
|
|
|
|
|
+ conn.close()
|
|
|
|
|
+
|
|
|
|
|
+ rules_state = slot_public(auth, "/rules", "rules")
|
|
|
|
|
+ row = [r for r in rules_state["rules"]
|
|
|
|
|
+ if r["name"] == "T9 blast rule"][0]
|
|
|
|
|
+ check([(a["kind"], a["label"]) for a in row["attachments"]]
|
|
|
|
|
+ == [("project", "t9-library"), ("task", "T9 blast task")],
|
|
|
|
|
+ "the rule row lists its blast radius in attachment order (%s)"
|
|
|
|
|
+ % [(a["kind"], a["label"]) for a in row["attachments"]])
|
|
|
|
|
+ check(all(int(a["id"]) > 0 for a in row["attachments"]),
|
|
|
|
|
+ "each attachment carries the linked row's id")
|
|
|
|
|
+
|
|
|
|
|
+ unattached = [r for r in rules_state["rules"]
|
|
|
|
|
+ if len(r.get("attachments") or []) == 0]
|
|
|
|
|
+ check(len(unattached) >= 1
|
|
|
|
|
+ or len(rules_state["rules"]) == 1,
|
|
|
|
|
+ "rules attached nowhere list no attachments (no crash)")
|
|
|
|
|
+
|
|
|
|
|
+ page = http_get_page(auth, "/rules")
|
|
|
|
|
+ check("<summary><strong>New rule</strong></summary>" not in page
|
|
|
|
|
+ and "rule-create-dialog" in page
|
|
|
|
|
+ and "openAppDialog('rule-create-dialog')" in page,
|
|
|
|
|
+ "the create expander became a dialog behind a button")
|
|
|
|
|
+ check("<summary>Edit</summary>" not in page,
|
|
|
|
|
+ "the per-rule Edit expander is gone")
|
|
|
|
|
+ check('data-long-form data-field="rule-row"' in page
|
|
|
|
|
+ and 'data-lf-toggle' in page,
|
|
|
|
|
+ "each rule edits through the in-place engine")
|
|
|
|
|
+ # The compiled page entity-encodes the body text ("…" -> …);
|
|
|
|
|
+ # assert on the escaped form and on the ghost class itself.
|
|
|
|
|
+ check("Delete rule…</button>" in page
|
|
|
|
|
+ and 'class="btn-ghost btn-sm"' in page,
|
|
|
|
|
+ "delete is demoted to a ghost button out of the row's face")
|
|
|
|
|
+ check('stm-text="attachment.label"' in page,
|
|
|
|
|
+ "the blast radius renders as links on the rule card")
|
|
|
|
|
+
|
|
|
|
|
+ print("== discoveries: the in-place editor ==")
|
|
|
|
|
+ page = http_get_page(auth, "/discoveries")
|
|
|
|
|
+ check("<summary>Edit</summary>" not in page,
|
|
|
|
|
+ "the discoveries Edit expander is gone")
|
|
|
|
|
+ check('data-long-form data-field="discovery-row"' in page,
|
|
|
|
|
+ "each discovery edits through the in-place engine")
|
|
|
|
|
+ check('stm-text="discovery.body"' in page
|
|
|
|
|
+ and 'name="body"' in page,
|
|
|
|
|
+ "the edit textarea prefills from the rendered row value")
|
|
|
|
|
+ check('discoveryRowVisible' in page and 'setDiscoveryQuery' in page,
|
|
|
|
|
+ "the client-side search semantics are unchanged")
|
|
|
|
|
+ state = slot_public(auth, "/discoveries", "discoveries")
|
|
|
|
|
+ check("query" in state and "more" in state,
|
|
|
|
|
+ "the slot still seeds the search box and the more count")
|
|
|
|
|
+
|
|
|
|
|
+ print("== chats: breadcrumbs on the detail page ==")
|
|
|
|
|
+ # Seed the chat row directly (the slot builder reads any owned row;
|
|
|
|
|
+ # this bundle does not restart the app with a scripted LLM - the
|
|
|
|
|
+ # chatui bundle owns that surface).
|
|
|
|
|
+ conn = db()
|
|
|
|
|
+ conn.execute(
|
|
|
|
|
+ "insert into advisor_chats (user_id, title, status, created_at,"
|
|
|
|
|
+ " updated_at) values ((select id from spry_users where username ="
|
|
|
|
|
+ " 'admin'), 'T9 crumbs probe', 'idle', %s, %s)" % (now, now))
|
|
|
|
|
+ chat_id = int(conn.execute("select max(id) from advisor_chats")
|
|
|
|
|
+ .fetchone()[0])
|
|
|
|
|
+ conn.commit()
|
|
|
|
|
+ conn.close()
|
|
|
|
|
+
|
|
|
|
|
+ chat_uri = "/chats/%d" % chat_id
|
|
|
|
|
+ chat_state = slot_public(auth, chat_uri, "chat")
|
|
|
|
|
+ check(chat_state["id"] == chat_id, "the probe chat exists (%s)" % chat_uri)
|
|
|
|
|
+ crumbs = chat_state["crumbs"]
|
|
|
|
|
+ check([c["label"] for c in crumbs]
|
|
|
|
|
+ == ["Dashboard", "Chats", "T9 crumbs probe"]
|
|
|
|
|
+ and crumbs[0]["href"] == "/" and crumbs[1]["href"] == "/chats"
|
|
|
|
|
+ and crumbs[2].get("href") is None,
|
|
|
|
|
+ "the chat slot trails Dashboard / Chats / <title> (%s)"
|
|
|
|
|
+ % [c["label"] for c in crumbs])
|
|
|
|
|
+ page = http_get_page(auth, chat_uri)
|
|
|
|
|
+ check('stm-def-crumbs-as="chat.crumbs"' in page,
|
|
|
|
|
+ "the breadcrumb partial rides the chat detail page")
|
|
|
|
|
+ check("chat-thread" in page and "chat-composer" in page,
|
|
|
|
|
+ "the conversation still leads with the composer below")
|
|
|
|
|
+ detail = slot_public(auth, "/chats/%d" % chat_id, "chat")
|
|
|
|
|
+ check(detail["delete"] is not None,
|
|
|
|
|
+ "the seeded chat still authors its delete action")
|
|
|
|
|
+
|
|
|
|
|
+ print("== chats list: the demoted delete ==")
|
|
|
|
|
+ page = http_get_page(auth, "/chats")
|
|
|
|
|
+ check('class="btn-ghost btn-sm" stm-action="chat.delete"' in page,
|
|
|
|
|
+ "the list's delete is a quiet ghost button")
|
|
|
|
|
+ check("alert alert-warning" in page,
|
|
|
|
|
+ "the unconfigured-advisor notice uses the Spry alert")
|