From d89fb616b80bd07e97fa6337e66f8a38ac8dc555 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Sun, 8 Nov 2026 21:18:55 +0000 Subject: [PATCH] feat(orchestrator): S2 agent and node popups with leader line, node search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requirement 001 phase 6, S2: - Agent popup: a click on an agent opens one fading with face, name, earlier names, offices, tasks, claims, subagents, history and the composer; Escape or a click outside closes it. The agent drawer and the Details side panel are gone. - Node popup: a click on a node in a graph view enlarges it and marks it red, and an SVG leader line ties it to the translucent popup; a click elsewhere restores the node. popup.js places the popup right, left, below or above the node, caps its height to the room there and returns the line. - Search: in the 3D views a search field with suggestions replaces the list of every node, one chip per distinct label. - Fit: body.fit now applies at every width; without the side panel the orbit view otherwise grew a page scrollbar at 390 px. - Two page modules had silently shadowed each other's functions of the same name while this was built; test_no_two_page_modules_declare_the_same_function now guards that. Artifact ids carry NUL, which CSS.escape turns into U+FFFD, so the node lookup compares dataset values. - Composer: a message goes to the agent whose popup it was written in. deliver() read the global ui.agent after awaiting the upload, so closing the popup or opening another agent during an upload sent the signed message to null or to the other agent (found by the security skeptic, present before, easier now that a backdrop click closes). Limits: the 3D leader line is placed when the popup opens and does not follow the camera while the layout settles; the search keeps the first node per label, so a second repository's main branch is found in the graph, not through the search. Evidence: make test 667 OK, ruff and markdownlint clean (/tmp/s2-make-test-1.log); e2e 19 OK in Chromium and Firefox at 1920, 1366 and 390 px, none skipped, rerun after the composer fix (/tmp/s2-e2e-4.log); screenshots in /tmp/orchestrator-phase4-shots/; MEDUSA 22 files, 6 findings (a "PLA" pattern in "templates", the UI names openAgent and drawAgent) accepted after an independent skeptic that also found no XSS or selector injection in the popup code. 🎯 Fix: 93 % 💥 Break: 4 % 💸 Cost: 100 % ⏱️ Time: – 🛡️ Security: 100 % 🏆 Score: 89 🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 % Co-Authored-By: Claude Opus 5.5 --- docs/requirements/001-agent-orchestrator.md | 6 +- orchestrator/templates/index.html.j2 | 49 +++--- orchestrator/templates/page-agent.js | 51 +++--- orchestrator/templates/page-artifacts.js | 47 ++--- orchestrator/templates/page-boot.js | 6 + orchestrator/templates/page-graph-charts.js | 27 ++- orchestrator/templates/page-graph-layout.js | 18 +- orchestrator/templates/page-graph3d.js | 51 ++++-- orchestrator/templates/page-hierarchy.js | 2 +- orchestrator/templates/page-keyboard.js | 4 +- orchestrator/templates/page-load.js | 2 +- orchestrator/templates/page-popup.js | 79 +++++++++ orchestrator/templates/page-screens.js | 5 +- orchestrator/templates/page-space-steer.js | 7 +- orchestrator/templates/page-spawn.js | 2 +- orchestrator/templates/page-state.js | 4 +- orchestrator/templates/page-stream.js | 2 +- orchestrator/templates/popup.js | 79 +++++++++ tests/e2e/test_orchestrator_e2e.py | 184 ++++++++++++++++++-- tests/hub_support.py | 1 + tests/test_hub_page.py | 15 +- tests/test_hub_popup.py | 116 ++++++++++++ 22 files changed, 596 insertions(+), 161 deletions(-) create mode 100644 orchestrator/templates/page-popup.js create mode 100644 orchestrator/templates/popup.js create mode 100644 tests/test_hub_popup.py diff --git a/docs/requirements/001-agent-orchestrator.md b/docs/requirements/001-agent-orchestrator.md index 0b6213a..a813792 100644 --- a/docs/requirements/001-agent-orchestrator.md +++ b/docs/requirements/001-agent-orchestrator.md @@ -594,11 +594,11 @@ flowchart TD ### Phase 6: popups and search (S2) -- [ ] A click on an agent opens the popup with a fade; Escape and a click +- [x] A click on an agent opens the popup with a fade; Escape and a click outside close it; the Details side panel no longer exists. -- [ ] A click on a node enlarges it, marks it red and draws a leader line to +- [x] A click on a node enlarges it, marks it red and draws a leader line to a translucent popup; a second click elsewhere restores the node. -- [ ] A search field with suggestions replaces the node chip list; no view +- [x] A search field with suggestions replaces the node chip list; no view renders more than one chip per search hit. ### Phase 6: faces (S3) diff --git a/orchestrator/templates/index.html.j2 b/orchestrator/templates/index.html.j2 index b98aad5..2d40eb7 100644 --- a/orchestrator/templates/index.html.j2 +++ b/orchestrator/templates/index.html.j2 @@ -28,7 +28,7 @@ body { min-height: 100vh; margin: 0; background: transparent; color: var(--text) button, input, select { font: inherit; color: inherit; } code, pre { font-family: var(--mono); font-size: var(--fs-sm); } :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; box-shadow: 0 0 0 5px color-mix(in srgb, var(--accent) 22%, transparent); } -.top, .navbar, main, .drawer, .sheet-box, .palette-box, .toast, .sky-panel, .menu-box { -webkit-backdrop-filter: var(--blur); backdrop-filter: var(--blur); transition: background-color .8s var(--ease), border-color .8s var(--ease); } +.top, .navbar, main, .popup-box, .sheet-box, .palette-box, .toast, .sky-panel, .menu-box { -webkit-backdrop-filter: var(--blur); backdrop-filter: var(--blur); transition: background-color .8s var(--ease), border-color .8s var(--ease); } ::view-transition-old(root), ::view-transition-new(root) { animation-duration: .22s; animation-timing-function: var(--ease); } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition-duration: 0s !important; animation-duration: 0s !important; scroll-behavior: auto !important; } @@ -140,7 +140,7 @@ pre.notes { max-height: 260px; margin: 12px 0 0; padding: 10px 12px; overflow: a .chip.preset { font-weight: 650; } .chip .x { padding: 0 2px; border: 0; background: none; color: inherit; cursor: pointer; } .split { display: grid; grid-template-columns: minmax(220px, 320px) minmax(0, 1fr); gap: 14px; align-items: start; } -@media (max-width: 860px) { .split, .graph-body { grid-template-columns: minmax(0, 1fr); } } +@media (max-width: 860px) { .split { grid-template-columns: minmax(0, 1fr); } } .tree { position: sticky; top: 0; max-height: 70vh; padding: 10px 6px; overflow: auto; } .tree-title { margin: 6px 10px 4px; color: var(--muted); font-size: var(--fs-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; } .tree-list { margin: 0; padding: 0 0 0 10px; list-style: none; } @@ -206,24 +206,17 @@ table.diff tr.more td { padding: 8px 16px; color: var(--muted); font-style: ital .replay { display: flex; flex: 1; align-items: center; gap: 10px; min-width: 280px; } .replay input[type="range"] { flex: 1; accent-color: var(--accent); } .window-label { color: var(--muted); font-size: var(--fs-sm); font-variant-numeric: tabular-nums; white-space: nowrap; } -.graph-body { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 14px; align-items: start; } .stage-card { padding: 8px; } .stage svg { display: block; width: 100%; height: auto; max-height: calc(100vh - var(--head) - 120px); } -@media (min-width: 861px) { - body.fit { display: flex; flex-direction: column; height: 100vh; } - body.fit .shell, body.fit main, body.fit .graph, body.fit .stage-card, body.fit .stage { display: flex; flex: 1; flex-direction: column; min-height: 0; } - body.fit main { padding-bottom: var(--sp-5); } - body.fit .graph-body { flex: 1; align-items: stretch; min-height: 0; } - body.fit .stage svg, body.fit .stage canvas.space, body.fit .graph3d { flex: 1; height: auto; min-height: 0; max-height: none; } - body.fit .panel { position: static; max-height: none; min-height: 0; overflow: auto; } -} +body.fit { display: flex; flex-direction: column; height: 100vh; } +body.fit .shell, body.fit main, body.fit .graph, body.fit .stage-card, body.fit .stage { display: flex; flex: 1; flex-direction: column; min-height: 0; } +body.fit main { padding-bottom: var(--sp-5); } +body.fit .stage svg, body.fit .stage canvas.space, body.fit .graph3d { flex: 1; height: auto; min-height: 0; max-height: none; } .legend { display: flex; flex-wrap: wrap; gap: 6px 16px; padding: 6px 10px 4px; color: var(--muted); font-size: var(--fs-sm); } .legend span { display: inline-flex; align-items: center; gap: 6px; } .swatch { display: inline-block; width: 18px; height: 4px; border-radius: 2px; background: var(--c); } -.panel { position: sticky; top: calc(var(--head) + 12px); max-height: calc(100vh - var(--head) - 24px); overflow: auto; } -.panel-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; } .panel-title { margin: 0; font-size: var(--fs-xl); font-weight: 650; overflow-wrap: anywhere; } -.panel h3, .drawer-body h3 { display: flex; align-items: center; gap: 8px; margin: 18px 0 8px; color: var(--muted); font-size: var(--fs-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; } +.popup-box h3 { display: flex; align-items: center; gap: 8px; margin: 18px 0 8px; color: var(--muted); font-size: var(--fs-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; } .mini { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; } .mini li { display: grid; gap: 3px; padding: 9px 10px; border: 1px solid var(--line); border-radius: 10px; background: var(--sunken); font-size: var(--fs-sm); overflow-wrap: anywhere; } .mini .row { display: flex; align-items: center; justify-content: space-between; gap: 8px; } @@ -248,7 +241,6 @@ svg .node.k-operator circle { fill: var(--accent); stroke: color-mix(in srgb, va svg .node.k-chair circle { fill: var(--accent-soft); stroke: var(--accent); stroke-width: 2; } svg .node.k-subagent circle { fill: var(--violet-soft); stroke: var(--violet); } svg .node.claimed circle { stroke: var(--ok); stroke-width: 2.2; } -svg .node.selected circle { stroke: var(--accent); stroke-width: 3.5; } svg .label { fill: var(--text); font-size: var(--fs-sm); font-weight: 560; paint-order: stroke; stroke: var(--panel); stroke-width: 4px; stroke-linejoin: round; } svg .initial { fill: var(--muted); font-size: var(--fs-sm); font-weight: 700; pointer-events: none; } svg .k-operator .initial { fill: var(--on-accent); font-size: var(--fs-xl); } @@ -278,7 +270,9 @@ svg .node.tone-warn circle { fill: var(--warn-soft); stroke: var(--warn); } svg .node.tone-bad circle { fill: var(--bad-soft); stroke: var(--bad); } svg .node.tone-info circle { fill: var(--info-soft); stroke: var(--info); } svg .node.tone-violet circle { fill: var(--violet-soft); stroke: var(--violet); } -svg .node.selected circle { stroke: var(--accent); stroke-width: 3.5; } +svg .node.picked circle { fill: var(--alarm); stroke: var(--alarm); transform: scale(1.6); transform-box: fill-box; transform-origin: center; } +svg .lane-label.picked { fill: var(--alarm); font-size: var(--fs-lg); font-weight: 750; } +.linkish.picked { color: var(--alarm); transform: scale(1.15); } svg .edge.merge { stroke-dasharray: 5 5; } svg .lane-mark { stroke-width: 1.2; } svg .lane-mark.tone-ok { fill: var(--ok); stroke: var(--ok); } @@ -291,11 +285,16 @@ input[type="datetime-local"] { padding: 3px 6px; border: 1px solid var(--line-st .avatar.big { width: 52px; height: 52px; font-size: var(--fs-3xl); } .linkish { padding: 0; border: 0; background: none; color: inherit; font-weight: 650; text-align: left; cursor: pointer; overflow-wrap: anywhere; } .linkish:hover { color: var(--accent); text-decoration: underline; } -.drawer { position: fixed; top: 0; right: 0; bottom: 0; z-index: 20; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; width: min(480px, 100vw); background: var(--glass-card); border-left: 1px solid var(--line); box-shadow: var(--shadow-lift); } -.drawer[hidden] { display: none; } -.drawer-head { display: flex; align-items: center; gap: 12px; padding: 16px 18px; border-bottom: 1px solid var(--line); } -.drawer-body { overflow: auto; padding: 4px 18px 12px; } -.drawer-form { display: grid; gap: 8px; padding: 12px 18px 18px; border-top: 1px solid var(--line); } +.popup { position: fixed; inset: 0; z-index: 25; width: auto; height: auto; max-width: none; max-height: none; margin: 0; padding: 0; overflow: hidden; border: 0; background: transparent; color: var(--text); } +.fade { animation: fade .2s var(--ease); } +.leader { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; } +.leader line { stroke: var(--alarm); stroke-width: 2; stroke-linecap: round; } +.popup-box { position: absolute; display: flex; flex-direction: column; gap: var(--sp-2); width: min(440px, calc(100% - 2 * var(--sp-4))); max-height: calc(100% - 2 * var(--sp-4)); padding: var(--sp-4); overflow: auto; background: var(--glass-card); border: 1px solid var(--line-strong); border-radius: var(--radius); box-shadow: var(--shadow-lift); opacity: .94; } +.popup-head { display: flex; flex: none; align-items: center; gap: 12px; } +.popup-body { flex: 0 1 auto; min-height: 7em; overflow: auto; } +.popup-form { display: grid; flex: none; gap: 8px; padding-top: 12px; border-top: 1px solid var(--line); } +.node-search { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } +.node-search .chips { margin-bottom: 0; } textarea { width: 100%; min-height: 84px; padding: 8px 10px; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--panel); color: var(--text); font: inherit; resize: vertical; } .bubble { display: grid; gap: 3px; margin: 0 0 8px; padding: 8px 11px; border-radius: 12px; background: var(--sunken); border: 1px solid var(--line); font-size: var(--fs-sm); overflow-wrap: anywhere; white-space: pre-wrap; } .bubble.mine { margin-left: 34px; background: var(--accent-soft); border-color: transparent; } @@ -379,8 +378,6 @@ textarea { width: 100%; min-height: 84px; padding: 8px 10px; border: 1px solid v .stage canvas.space { display: block; width: 100%; height: clamp(360px, 68vh, 820px); border-radius: var(--radius-sm); cursor: grab; touch-action: none; } .stage canvas.space:active { cursor: grabbing; } .space-note { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; padding: 8px 10px 0; color: var(--muted); font-size: var(--fs-sm); } -.space-list { padding: 6px 10px 2px; } -.space-list summary { margin: 4px 0; color: var(--muted); font-size: var(--fs-sm); cursor: pointer; } body.sky-look .shell { visibility: hidden; } body.sky-look .sky { z-index: 5; pointer-events: auto; cursor: grab; touch-action: none; } body.sky-look .sky:active { cursor: grabbing; } @@ -441,7 +438,7 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
- +
@@ -454,6 +451,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi {% include "voice.js" %} {% include "nav.js" %} + +{% include "popup.js" %} ", "")) self.assertEqual((state["register"][0]["title"], state["radio"][0]["from"]), ("", "")) - def test_the_index_scripts_parse_and_embed_the_ambience_voice_nav_and_space_modules(self): + def test_the_index_scripts_parse_and_embed_the_ambience_voice_nav_popup_and_space_modules(self): html = self.index() - for module in (AMBIENCE, VOICE, NAV, SPACE): + for module in (AMBIENCE, VOICE, NAV, POPUP, SPACE): with self.subTest(module=module.name): self.assertIn(module.read_text(encoding="utf-8").strip(), html) scripts = re.findall(r'', html, re.DOTALL) @@ -73,11 +74,17 @@ class TestHubPage(HubCase): page.write_text("\n;\n".join(scripts), encoding="utf-8") done = subprocess.run(["node", "--check", str(page)], capture_output=True, text=True, timeout=60, check=False) self.assertEqual(done.returncode, 0, done.stderr) - for module in (AMBIENCE, VOICE, NAV, SPACE): + for module in (AMBIENCE, VOICE, NAV, POPUP, SPACE): with self.subTest(module=module.name): done = subprocess.run(["node", "--check", str(module)], capture_output=True, text=True, timeout=60, check=False) self.assertEqual(done.returncode, 0, done.stderr) + def test_no_two_page_modules_declare_the_same_function(self): + page = re.findall(r'', self.index(), re.DOTALL)[-1] + names = Counter(re.findall(r"^ (?:async )?function (\w+)\(", page, re.MULTILINE)) + self.assertGreater(len(names), 100) + self.assertEqual([name for name, count in names.items() if count > 1], []) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_hub_popup.py b/tests/test_hub_popup.py new file mode 100644 index 0000000..4e97340 --- /dev/null +++ b/tests/test_hub_popup.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import json +import subprocess +import unittest + +from hub_support import POPUP + +POPUP_PROBE = """ +const P = require(process.argv[1]); +let seed = 11; +const next = () => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; + return seed / 4294967296; +}; +const node = (left, top) => ({left, top, width: 20, height: 20}); +const wide = {width: 1366, height: 768}; +const phone = {width: 390, height: 844}; +const random = Array.from({length: 600}, () => { + const view = {width: 300 + Math.round(next() * 1700), height: 300 + Math.round(next() * 900)}; + const size = {width: Math.min(440, view.width - 2 * P.GAP), height: Math.round(80 + next() * (view.height - 2 * P.GAP - 80))}; + const anchor = {left: Math.round(next() * view.width) - 10, top: Math.round(next() * view.height) - 10, width: 20, height: 20}; + return {view, size, anchor, at: P.place(anchor, size, view)}; +}); +const nodes = [ + {id: "s1", label: "Skills session"}, {id: "s2", label: "Stage"}, {id: "agent:Stage@/a", label: "Stage"}, {id: "agent:Stage@/b", label: "Stage"}, + {id: "b1", label: "feature/stage-fix"}, {id: "b2", label: "main"}, {id: "b3", label: "main"}, {id: "x", label: "Backstage"}, +]; +const ids = (list) => list.map((entry) => entry.id); +console.log(JSON.stringify({ + constants: [P.GAP, P.REACH, P.MIN], + right: P.place(node(100, 300), {width: 440, height: 300}, wide), + left: P.place(node(1200, 300), {width: 440, height: 300}, wide), + below: P.place(node(185, 100), {width: 358, height: 300}, phone), + above: P.place(node(185, 700), {width: 358, height: 300}, phone), + capped: P.place(node(185, 400), {width: 358, height: 700}, phone), + covered: P.place(node(185, 180), {width: 358, height: 368}, {width: 390, height: 400}), + centre: P.place(null, {width: 440, height: 300}, wide), + random, + hits: { + stage: ids(P.hits(nodes, "sta", 8)), + main: ids(P.hits(nodes, " MAIN ", 8)), + blank: P.hits(nodes, " ", 8), + limit: ids(P.hits(nodes, "a", 2)), + none: P.hits(nodes, "zzz", 8), + same: P.hits(nodes, "stage", 8)[0] === nodes[1], + }, +})); +""" +SLACK = 0.06 + + +class TestPopupModule(unittest.TestCase): + @classmethod + def setUpClass(cls): + done = subprocess.run(["node", "-e", POPUP_PROBE, str(POPUP)], capture_output=True, text=True, timeout=60, check=True) + cls.data = json.loads(done.stdout) + cls.gap, cls.reach, cls.min = cls.data["constants"] + + def test_the_popup_sits_right_of_the_node_and_the_leader_spans_the_gap_between_them(self): + self.assertEqual(self.data["right"], {"left": 160, "top": 160, "height": None, "line": {"x1": 120, "y1": 310, "x2": 160, "y2": 310}}) + self.assertEqual(self.data["right"]["line"]["x2"] - self.data["right"]["line"]["x1"], self.reach) + + def test_a_node_near_the_right_edge_gets_the_popup_on_its_left(self): + self.assertEqual(self.data["left"], {"left": 720, "top": 160, "height": None, "line": {"x1": 1200, "y1": 310, "x2": 1160, "y2": 310}}) + + def test_on_a_phone_the_popup_goes_below_or_above_the_node(self): + self.assertEqual(self.data["below"], {"left": 16, "top": 160, "height": None, "line": {"x1": 195, "y1": 120, "x2": 195, "y2": 160}}) + self.assertEqual(self.data["above"], {"left": 16, "top": 360, "height": None, "line": {"x1": 195, "y1": 700, "x2": 195, "y2": 660}}) + + def test_a_popup_too_tall_for_either_side_is_capped_to_the_larger_room_and_keeps_its_leader(self): + self.assertEqual(self.data["capped"], {"left": 16, "top": 460, "height": 368, "line": {"x1": 195, "y1": 420, "x2": 195, "y2": 460}}) + + def test_without_room_the_popup_covers_the_node_and_draws_no_leader(self): + self.assertEqual(self.data["covered"], {"left": 16, "top": 16, "height": None, "line": None}) + + def test_a_popup_without_a_node_is_centred_without_a_leader(self): + self.assertEqual(self.data["centre"], {"left": 463, "top": 234, "height": None, "line": None}) + + def test_every_placement_stays_in_the_viewport_and_every_leader_runs_from_the_node_edge_to_the_popup_edge(self): + leaders = 0 + for case in self.data["random"]: + view, size, anchor, at = case["view"], case["size"], case["anchor"], case["at"] + height = size["height"] if at["height"] is None else at["height"] + box = (at["left"], at["top"], at["left"] + size["width"], at["top"] + height) + with self.subTest(case=case): + self.assertGreaterEqual(min(box[0], box[1]), self.gap - SLACK) + self.assertLessEqual(box[2], view["width"] - self.gap + SLACK) + self.assertLessEqual(box[3], view["height"] - self.gap + SLACK) + if at["height"] is not None: + self.assertGreaterEqual(at["height"], self.min - SLACK) + self.assertLess(at["height"], size["height"]) + rect = (anchor["left"], anchor["top"], anchor["left"] + anchor["width"], anchor["top"] + anchor["height"]) + line = at["line"] + if line is None: + self.assertTrue(box[0] <= rect[2] + SLACK and rect[0] <= box[2] + SLACK and box[1] <= rect[3] + SLACK and rect[1] <= box[3] + SLACK, "no leader although the popup misses the node") + continue + leaders += 1 + self.assertTrue(any(abs(line["x2"] - edge) <= SLACK for edge in (box[0], box[2])) or any(abs(line["y2"] - edge) <= SLACK for edge in (box[1], box[3]))) + self.assertTrue(any(abs(line["x1"] - edge) <= SLACK for edge in (rect[0], rect[2])) or any(abs(line["y1"] - edge) <= SLACK for edge in (rect[1], rect[3]))) + self.assertTrue(rect[0] - SLACK <= line["x1"] <= rect[2] + SLACK and rect[1] - SLACK <= line["y1"] <= rect[3] + SLACK) + self.assertGreater(leaders, len(self.data["random"]) * 0.9) + + def test_search_hits_are_one_per_label_prefix_first_case_insensitive_and_limited(self): + hits = self.data["hits"] + self.assertEqual(hits["stage"], ["s2", "b1", "x"]) + self.assertEqual(hits["main"], ["b2"]) + self.assertEqual(hits["limit"], ["s2", "b1"]) + self.assertTrue(hits["same"]) + + def test_a_blank_or_unknown_query_finds_nothing(self): + self.assertEqual((self.data["hits"]["blank"], self.data["hits"]["none"]), ([], [])) + + +if __name__ == "__main__": + unittest.main()