"""Unit: the RAG view's drill-down catalog tree (phase 97, task 04). The RAG (Knowledge base) view lists the catalog the way the agent's ``ls`` sees it (the phase-94 concept, ONE end to end): sources at the top, then per level the subfolders (recursive count + the STORED description) and the level's files (the existing 5-column ``#docs-table`` — unchanged). The view's catalog load is now ``loadTree()``: ONE fetch of ``GET /api/docs/tree`` (the full recursive tree in a single payload — task 02), then client-side drill navigation (no per-level fetch, no URL change). This module pins the source-level contract (the house pattern of ``tests/unit/test_sync_button.py`` — read the frontend files as text, no browser; the behavior is E2E-covered by the phase's dedicated suite, task 06): * the shell's RAG view ships the static tree skeleton (``#kb-crumb``, ``#kb-level`` + ``#kb-level-title``/``#kb-level-summary``, ``#folders-wrap`` + ``#folders-table`` — ONE table for every level, the Folder | Documents | Description head) in order after ``#stat-cards`` and BEFORE the unchanged file table; * the exact ``/api/docs/tree`` fetch (and the flat ``/api/docs`` fetch is gone from the view module); * the drill state (``current`` / ``kbTree``), the state transitions (source row click, folder row click, breadcrumb up, top reset), and the aria-current last segment; * the textContent contract (no ``innerHTML`` with document-derived data — the module's standing rule); * the top-level semantics (the rows ARE the sources — the ``ls()`` equivalence — and the file table is hidden at the top); * the level block's ls rule (hidden when no description is stored); * the stat cards' whole-tree walk; * the empty-state semantic (``#sources-empty`` ONLY on zero sources — the deliberate phase-97 change: a registered 0-document source renders its row instead); * the never-stale reset-to-top fallback (PLAN §7.4 — a vanished location resets ``current`` to the top BEFORE rendering); * ``loadTree`` wired at exactly the three refresh points (view-refresh, sync success, upload success) plus the boot load — and the anonymous branch still never fetches the catalog. Phase 97 (task 05) adds the FOLDER-DESCRIPTION EDITOR (the phase-57 affordance, mirrored) — the source pins this module gains for it: * the static ``#kb-level-edit`` button in the level block (type=button, the .kb-summary-edit class, the "Edit" label, after the description ``
`` — in the .kb-level-body the editor swaps inside);
* the Description cell of EVERY source/folder row (``makeDescCell``,
called from both row builders) — the stored description as a text
node + the ALWAYS-present Edit button (a description can be CREATED
where none is stored: no gate on the stored value);
* the shared editor (``wireDescriptionEdit``): the swap builds a
textarea prefilled via ``.value`` (never innerHTML — the XSS
contract), Save / Cancel, and the ``role="status"``
``aria-live="polite"`` live region;
* the exact PATCH — ``/api/folders/summary``, method PATCH, body
``{ source, folder_path, summary }`` with ``folder_path ""`` for the
source root (exactly one call site in the module);
* the outcomes — success updates the in-memory ``kbTree`` node IN
PLACE (no re-fetch) + re-renders the text via textContent + "
Description updated."; a cleared echo (summary null) empties the
text (row cell) / hides the level block (the phase-57 announcement
beat, guarded) + "Description cleared."; a failure (non-ok OR
network) keeps the editor open with neutral retry copy (phase-55);
Cancel restores the text node without a fetch;
* the double-click guard (disabled before the fetch, re-enabled in the
``finally``); the level editor's ``getTarget()`` getter + the
``reset()`` handle called on every re-render (PLAN §7.4);
* the five ``.kb-summary-*`` classes in styles.css (house palette, no
new hue in the phase-97 block).
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
ASSETS = FRONTEND / "assets"
SOURCES_JS = ASSETS / "sources.js"
STYLES_CSS = ASSETS / "styles.css"
SHELL_HTML = FRONTEND / "index.html"
def _text(path: Path) -> str:
assert path.is_file(), f"missing frontend file: {path}"
return path.read_text(encoding="utf-8")
def _js() -> str:
return _text(SOURCES_JS)
def _rag_view(html: str) -> str:
"""The RAG view section of the shell (view-scoped scope — the shell
carries many views, so whole-file matches hit the wrong view)."""
i = html.find('',
'
Folder ",
"Documents ",
"Description ",
):
assert column in head.group(1), f"#folders-table head must carry {column!r}"
# The deliberate placement: after the stat cards, before the file
# table (the file table keeps its own wrap + head, unchanged).
assert (
view.find('id="stat-cards"')
< view.find('id="kb-crumb"')
< view.find('id="kb-level"')
< view.find('id="folders-wrap"')
< view.find('id="docs-table"')
), "the skeleton must sit between the stat cards and the file table"
# The file table is UNCHANGED (the 5-column contract, makeRow's home).
doc_head = re.search(
r'.*?(.*?)', view, re.S
)
assert doc_head, "the file table must keep its static thead"
for column in ("Source", "Path", "Title", "Chunks", "Indexed"):
assert f">{column}" in doc_head.group(1), f"#docs-table head must keep {column!r}"
# ---------- the tree load: the ONE fetch + the race token ----------
def test_sources_js_fetches_the_tree_endpoint_only() -> None:
"""The catalog load is `loadTree()`: ONE fetch of the tree endpoint.
The flat `GET /api/docs` fetch is GONE from the view module (the
endpoint itself stays — the API surface is untouched, task 02)."""
js = _js()
assert 'fetch("/api/docs/tree")' in js, "the load must fetch /api/docs/tree"
assert js.count('fetch("/api/docs/tree")') == 1, (
"the tree is the view's SINGLE catalog fetch (zero per-level fetches)"
)
assert 'fetch("/api/docs")' not in js, "the flat /api/docs fetch must be gone"
def test_load_tree_is_race_tokened_and_resets_before_rendering() -> None:
"""Phase 79 carries over: the monotonic loadSeq token — only the
newest load may touch the DOM after its await. On success the load
stores the tree in the module-scoped `kbTree`, runs the never-stale
reset, and renders (in that order — the reset must see the NEW
tree and happen BEFORE the render)."""
js = _js()
assert "async function loadTree()" in js, "loadTree must exist"
body = js[js.find("async function loadTree()") :]
body = body[: body.find("\n }")]
assert "const my = ++loadSeq;" in body, "the race token stays (phase 79)"
assert "if (my !== loadSeq) return;" in body, "stale loads must not touch the DOM"
store_i = body.find("kbTree = tree && Array.isArray(tree.sources) ? tree : { sources: [] };")
reset_i = body.find("resetVanishedLocation();")
render_i = body.find("renderLevel();")
assert -1 < store_i < reset_i < render_i, (
"store the tree → reset a vanished location → render (in order)"
)
# ---------- the drill state + the navigation transitions ----------
def test_drill_state_and_module_tree_are_scoped_to_the_mount() -> None:
"""`current` ({ source, folder } — null/null = the top level,
folder "" = the source root) and `kbTree` are mount-scoped module
state (the router mounts a view ONCE — the state survives every
switch, as the sync state machine does)."""
js = _js()
assert "let kbTree = { sources: [] };" in js
assert "let current = { source: null, folder: null };" in js, (
"the drill state starts at the top level"
)
def test_source_row_click_drills_into_the_source_root() -> None:
"""Top-level row = a SOURCE (the ls() equivalence): the name cell
is a `.folder-link` whose click sets `{ source, "" }` (the source
ROOT) and re-renders — client-side only (no fetch, no URL change).
The row carries the recursive count and the stored (source, "")
description — textContent only."""
js = _js()
body = js[js.find("function makeSourceRow(") : js.find("function makeSourceRow(") + 1200]
assert 'link.className = "folder-link"' in body, "the source row uses the row drill link"
assert "link.textContent = s.name" in body
assert "goTo({ source: s.name, folder: \"\" })" in body, (
"a source row drills to the source ROOT (folder \"\")"
)
assert "countTd.textContent = String(s.documents)" in body, "the recursive count"
assert 'makeDescCell(s, s.name, "", s.name)' in body, (
"the Description cell (task 05): the stored (source, \"\") "
"description + the ALWAYS-present Edit button — folder_path \"\" "
"for the source root"
)
assert "link.href = \"#\"" in body, "client-side drill — no URL change"
def test_folder_row_click_drills_into_the_folder() -> None:
"""A level's subfolder row: the folder's LAST path segment as the
label (the full source-relative path rides the title), the
recursive count, the stored description — and the click sets
`{ source, folder: f.path }` (the source-relative folder path)."""
js = _js()
start = js.find("function makeFolderRow(")
body = js[start : start + 1400]
assert 'link.className = "folder-link"' in body
assert "link.textContent = f.path.split(\"/\").pop()" in body, (
"the label is the last segment (the breadcrumb language)"
)
assert "link.title = current.source + \"/\" + f.path" in body, "full path on hover"
assert "goTo({ source: current.source, folder: f.path })" in body, (
"a folder row drills to that folder"
)
assert "countTd.textContent = String(f.documents)" in body
assert (
'makeDescCell(f, current.source, f.path, current.source + "/" + f.path)'
in body
), "the Description cell (task 05): the stored row + the ALWAYS-present Edit"
def test_breadcrumb_links_go_up_and_the_top_reset() -> None:
"""The breadcrumb: hidden at the top level; when drilled in, one
link per ancestor — the TOP level (→ `{ null, null }` — the reset),
the source (→ `{ source, "" }`), then the folder chain (→ each
ancestor folder) — the LAST segment a span with
aria-current=\"page\" (not a link)."""
js = _js()
start = js.find("function renderCrumb(")
body = js[start : js.find("function crumbSegment(")]
assert "if (current.source === null) {" in body
assert "crumbEl.hidden = true" in body, "the breadcrumb is hidden at the top level"
assert "crumbSegment(\"Knowledge base\", { source: null, folder: null })" in body, (
"the top-level segment goes back to the sources list (the reset)"
)
assert "crumbSegment(current.source, { source: current.source, folder: \"\" })" in body, (
"the source segment goes up to the source root"
)
assert "crumbSegment(part, { source: current.source, folder: acc })" in body, (
"each folder-ancestor segment goes up to that folder"
)
assert "crumbCurrent(part)" in body, "the last segment is the current span"
seg = js[js.find("function crumbSegment(") : js.find("function crumbCurrent(")]
assert 'a.className = "kb-crumb-link"' in seg
assert "a.textContent = label" in seg, "segment labels are textContent"
cur = js[js.find("function crumbCurrent(") : js.find("function goTo(")]
assert 'span.className = "kb-crumb-current"' in cur
assert 'span.setAttribute("aria-current", "page")' in cur, (
"the last segment is aria-current (a span, never a link)"
)
assert "span.textContent = label" in cur
def test_navigation_is_client_side_only() -> None:
"""goTo() is the ONLY navigation: set `current`, re-render. No
fetch (the tree is already in memory), no URL change (history is
untouched — the deep-link contract of the shell is unchanged)."""
js = _js()
body = js[js.find("function goTo(") : js.find("function makeSourceRow(")]
assert "current = { source: target.source, folder: target.folder };" in body
assert "renderLevel();" in body
assert "fetch(" not in body, "the drill never fetches (client-side only)"
assert "pushState" not in body and "replaceState" not in body, "no URL change"
# ---------- the level rendering ----------
def test_top_level_lists_sources_and_hides_the_file_table() -> None:
"""Top level: the breadcrumb + level block are hidden; the folders
table lists the SOURCES themselves (makeSourceRow for every source)
and the file table is ALWAYS hidden (files are seen per source —
`ls()` shows none at the top)."""
js = _js()
start = js.find("function renderLevel(")
body = js[start : js.find("function renderEmpty(")]
top = body[body.find("if (current.source === null) {") :]
assert "levelEl.hidden = true;" in top, "the level block is hidden at the top level"
assert "for (const s of kbTree.sources) foldersTbody.appendChild(makeSourceRow(s));" in top, (
"ONE table for every level: at the top the rows are the sources"
)
assert "foldersWrap.hidden = false;" in top
assert "if (tableWrap) tableWrap.hidden = true;" in top, (
"the file table is always hidden at the top level"
)
def test_level_block_shows_the_stored_description_only() -> None:
"""Inside a source/folder: the level block shows the CURRENT
level's stored description — the title is the full source-relative
path (e.g. `alpha/two`; the source root shows the source name) —
and is HIDDEN when none is stored (the ls rule: count only, no
placeholder)."""
js = _js()
start = js.find("function renderLevel(")
body = js[start : js.find("function renderEmpty(")]
assert "if (node.summary) {" in body, "the ls rule: no description → no block"
summary_if = body.find("if (node.summary) {")
shown = body[summary_if : body.find("} else {", summary_if)]
assert "levelTitleEl.textContent = current.folder" in shown
assert "current.source + \"/\" + current.folder" in shown, (
"the folder title is the full source-relative path"
)
assert ": current.source;" in shown, "the source root title is the source name"
assert "levelSummaryEl.textContent = node.summary;" in shown
assert "levelEl.hidden = false;" in shown
hidden = body[body.find("} else {", body.find("if (node.summary) {")) :]
assert "levelEl.hidden = true;" in hidden[:200], "no stored row → the block is hidden"
def test_level_lists_subfolders_and_direct_files_only() -> None:
"""A level's folders table = its DIRECT subfolders (hidden when
none); its file table = its DIRECT files (hidden when none) —
makeRow UNCHANGED (the file node carries no source: the row object
restores the flat /api/docs shape makeRow reads, so the phase-10/26
pins on makeRow stay green)."""
js = _js()
start = js.find("function renderLevel(")
body = js[start : js.find("function renderEmpty(")]
assert 'children.filter((c) => c.kind === "folder")' in body
assert 'children.filter((c) => c.kind === "file")' in body
assert "for (const f of subfolders) foldersTbody.appendChild(makeFolderRow(f));" in body
assert "foldersWrap.hidden = subfolders.length === 0;" in body, (
"#folders-wrap hidden when the level has no subfolders"
)
assert "for (const f of files) {" in body
assert "source: current.source," in body, "the row object restores the source"
assert "makeRow({" in body, "makeRow is unchanged (the phase-10/26 pins)"
assert "if (tableWrap) tableWrap.hidden = files.length === 0;" in body, (
"the file table hides when the level has no direct files"
)
# makeRow itself is untouched — the standing pins on its body.
assert "link.href = documentUrl(d.source, d.path)" in js
assert "openDocumentModal(d.source, d.path, link)" in js
def test_render_clears_both_row_containers_before_filling() -> None:
"""Re-entrancy (the phase-77 pattern, extended to the second
container): the render clears BOTH tbodies at the top — a refresh
from a populated level into a sparser one leaves no ghost rows."""
js = _js()
start = js.find("function renderLevel(")
body = js[start : js.find("\n }", start)]
clear_folders = body.find("if (foldersTbody) foldersTbody.replaceChildren();")
clear_docs = body.find("if (tbody) tbody.replaceChildren();")
assert 0 <= clear_folders < clear_docs, "both containers clear at the top"
for append in ("foldersTbody.appendChild", "tbody.appendChild"):
assert clear_docs < body.find(append), f"{append} must come after the clears"
# ---------- the stat cards: the whole-tree walk ----------
def test_stat_cards_walk_the_whole_tree() -> None:
"""The KB-wide stat cards walk the WHOLE tree (every file node at
any depth): document count, chunks sum, max indexed_at (fmtDate
reuse) — the values identical to the former flat walk. A
0-document registered source contributes nothing (and still lists
its row — the ls invariant)."""
js = _js()
body = js[js.find("function treeStats()") : js.find("function renderCrumb(")]
assert "docs += 1;" in body
assert "totalChunks += child.chunks;" in body
assert "if (child.indexed_at > last) last = child.indexed_at;" in body
assert "walk(child);" in body, "the walk recurses into subfolders"
assert "for (const s of kbTree.sources) walk(s);" in body, "every source is walked"
render = js[js.find("function renderLevel(") : js.find("function renderEmpty(")]
assert "const st = treeStats();" in render
assert "statDocs.textContent = String(st.docs);" in render
assert "statChunks.textContent = String(st.totalChunks);" in render
assert 'st.last ? fmtDate(st.last) : "–"' in render, "the fmtDate reuse"
# ---------- the empty state: the zero-sources semantic ----------
def test_empty_state_is_zero_sources_only() -> None:
"""The deliberate phase-97 semantic change (module docstring):
#sources-empty shows ONLY when the tree has zero sources — a
registered 0-document source renders its row instead (the ls
invariant). A failed tree fetch renders the same no-data state
(the former loadDocs failure behavior, unchanged in kind)."""
js = _js()
render = js[js.find("function renderLevel(") : js.find("function renderEmpty(")]
assert "if (!kbTree.sources.length) {" in render, (
"the empty state fires on ZERO SOURCES (not zero documents)"
)
assert "renderEmpty();" in render[render.find("if (!kbTree.sources.length) {"):]
empty_start = js.find("function renderEmpty(")
empty = js[empty_start : js.find("\n }", empty_start)]
assert 'statDocs.textContent = "0";' in empty
assert 'statChunks.textContent = "0";' in empty
assert 'statLast.textContent = "–";' in empty
assert "if (foldersWrap) foldersWrap.hidden = true;" in empty, "both wraps hidden"
assert "if (tableWrap) tableWrap.hidden = true;" in empty
assert "emptyEl.hidden = false;" in empty, "#sources-empty shows"
# The failure paths of the load land in the same no-data state.
load = js[js.find("async function loadTree()") : js.find("async function loadTree()") + 900]
assert load.count("renderEmpty();") >= 2, "a failed fetch renders the no-data state"
def test_module_docstring_documents_the_semantic_change() -> None:
"""The house module-docstring convention: the phase-97 section
records the tree load, the drill state, and the deliberate
0-document-source semantic change."""
js = _js()
header = js[: js.find("import { fetchIsAdmin }")]
assert "Phase 97 (task 04)" in header
assert "zero SOURCES" in header, "the empty-state semantic change is documented"
assert "0-document source renders its row" in header, (
"the registered 0-document source exception is documented"
)
assert "loadTree()" in header
# ---------- the never-stale reset-to-top fallback ----------
def test_reset_to_top_when_the_location_vanished() -> None:
"""PLAN §7.4: after a re-fetch, if the current source is no longer
in the tree (unregistered/pruned) OR the current folder no longer
exists under it, `current` RESETS to the top level BEFORE rendering
— no stale breadcrumb, no stale block. The folder existence check
mirrors the phase-94 rule: some indexed path starts with
`folder + "/"` (the source root — \"\" — always exists)."""
js = _js()
body = js[js.find("function resetVanishedLocation()") : js.find("function folderExistsIn(")]
assert "if (current.source === null) return;" in body, "the top level never vanishes"
assert "kbTree.sources.find((s) => s.name === current.source)" in body
assert body.count("current = { source: null, folder: null };") == 2, (
"both vanishing arms reset to the top level"
)
exists = js[js.find("function folderExistsIn(") : js.find("function currentLevelNode(")]
assert 'if (folderPath === "") return true;' in exists, "the source root always exists"
assert "p.startsWith(folderPath + \"/\")" in exists, "the phase-94 existence rule"
# The reset runs on the success path of the load, before the render
# (pinned in the load-order test — here: it is defined and called).
assert "resetVanishedLocation();" in js[js.find("async function loadTree()"):]
# ---------- the refresh wirings: loadTree at the three points ----------
def test_load_tree_wired_at_exactly_the_three_refresh_points_plus_boot() -> None:
"""loadDocs's call sites become loadTree's (same points, same
semantics): the boot load, the bor:view-refresh listener (admin
branch), applySyncSuccess (the KB just changed), and the
upload-success branch of startSyncPolling — and the old name is
gone. Exactly 5 occurrences of `loadTree()`: the definition +
those 4 calls (no other call site exists)."""
js = _js()
assert "loadDocs" not in js, "the old load name is gone from the module"
assert _code(js).count("loadTree()") == 5, (
"the definition + the 4 call sites, no others (code, not comments)"
)
# 1. the view-refresh listener, armed in the admin branch.
listener = js.find('addEventListener("bor:view-refresh"')
assert listener != -1
gate = js.find("const admin = await fetchIsAdmin();")
assert -1 < gate < listener, "armed after the whoami gate (the phase-77 pin)"
assert "() => loadTree()" in js[listener : listener + 120]
# 2. the boot load — the last statement of mount, right after the
# listener is armed (the mount's own load is the first fetch).
assert 'root.addEventListener("bor:view-refresh", () => loadTree());\n loadTree();' in js, (
"the boot load follows the listener"
)
# 3. applySyncSuccess (the sync's terminal — the KB just changed).
success = js[js.find("function applySyncSuccess(") : js.find("function applySyncFailure(")]
assert "loadTree();" in success
# 4. the upload-success branch of the tick (settle + refresh).
tick = js[js.find("function startSyncPolling(") : js.find("function startSync()")]
up_ok = tick.find('uploadStatus && uploadStatus.state === "success"')
up_fail = tick.find('uploadStatus && uploadStatus.state === "failed"')
assert -1 < up_ok < up_fail
assert "loadTree();" in tick[up_ok:up_fail], "the upload success refreshes the tree"
def test_anonymous_branch_never_fetches_the_tree() -> None:
"""The phase-16 soft rule (unchanged in kind): the anonymous branch
gates the catalog in / out with NO catalog fetch — no /api/docs/
tree request at all, and the tree surfaces stay hidden (they ship
hidden and never fill)."""
js = _js()
start = js.find("if (!admin) {")
end = js.find("if (gateEl) gateEl.hidden = true;")
assert -1 < start < end, "the anonymous branch must exist"
branch = js[start:end]
assert "fetch(" not in branch, "anonymous never fetches the catalog"
assert "loadTree" not in branch, "anonymous never starts a tree load"
assert "if (foldersWrap) foldersWrap.hidden = true;" in branch, (
"the folders wrap is part of the anonymous hide"
)
assert "if (tableWrap) tableWrap.hidden = true;" in branch
assert "if (gateEl) gateEl.hidden = false;" in branch
# ---------- the textContent contract ----------
def _code(js: str) -> str:
"""sources.js with comments stripped (a comment may legally carry
any string — the count pins below must only see code)."""
no_block = re.sub(r"/\*.*?\*/", "", js, flags=re.S)
return re.sub(r"//.*", "", no_block)
def test_tree_cells_never_use_innerhtml_with_derived_data() -> None:
"""The module's standing rule: document-derived text goes through
textContent only. The ONE innerHTML left in the module CODE is the
static (non-derived) sync-error modal skeleton — unchanged since
phase 41."""
js = _js()
code = _code(js)
assert code.count("innerHTML") == 1, (
"only the static sync-modal skeleton may use innerHTML (code, not comments)"
)
modal = code.find("function createSyncModal()")
assert modal != -1
assert code.find("innerHTML") > modal, "the single innerHTML is the sync-modal creation"
# The tree's derived-text sinks are all textContent (spot the
# contract across the builders + the level block).
for sink in (
"link.textContent = s.name",
"link.textContent = f.path.split(\"/\").pop()",
'text.textContent = (node && node.summary) || ""',
"levelSummaryEl.textContent = node.summary;",
"a.textContent = label",
"span.textContent = label",
"countTd.textContent = String(s.documents)",
"countTd.textContent = String(f.documents)",
):
assert sink in js, f"the derived-text contract: {sink!r}"
# ---------- the file-table wrap lookup (the new class collision) ----------
def test_file_wrap_lookup_avoids_the_new_folders_wrap() -> None:
"""#folders-wrap ALSO carries the shared .table-wrap class (the
card language) — the FILE table's wrap is looked up through its
own table, not the class (a class lookup would hit #folders-wrap
first in document order and hide/show the wrong card)."""
js = _js()
assert 'root.querySelector("#docs-table").parentElement' in js
assert 'root.querySelector(".table-wrap")' not in js, (
"the bare class lookup must not survive (it would hit #folders-wrap)"
)
# ---------- the styles: the tree's classes, no new hue ----------
def test_styles_carry_the_tree_classes_with_no_new_hue() -> None:
"""The new classes exist (.kb-crumb + link/current/separator,
.kb-level, .kb-folders-table, .folder-link) and the phase-97 block
introduces NO new hue (the phase-92 monochrome invariant — every
color via a var(); no hex/rgb literal)."""
css = _text(STYLES_CSS)
for selector in (
".kb-crumb",
".kb-crumb-link",
".kb-crumb-current",
".kb-crumb-sep",
".kb-level",
".kb-folders-table",
".folder-link",
):
assert re.search(r"(? None:
"""The level block ships the phase-57 Edit button in the static
HTML: a real type=button with the .kb-summary-edit class and the
"Edit" label, INSIDE #kb-level after p#kb-level-summary — in the
.kb-level-body (the editor swaps inside it, the
stays put)."""
view = _rag_view(_text(SHELL_HTML))
assert '