From 8faa2a9e5754c8060a04b7b61872928767e084fb Mon Sep 17 00:00:00 2001 From: yydawx <195621032+yydawx@users.noreply.github.com> Date: Sat, 17 Oct 2026 02:07:59 +0800 Subject: [PATCH 1/7] feat(dashboard): add text search to the Experience list The Methods page lets users search Skills but only browse Experiences, even though the family-scoped Artifact search added in #1862 covers the family. Load the Experience collection through POST /v1/scopes/{scope_id}/artifacts/experience/search when the Methods page carries a query, and adapt each hit to the exact-revision record shape the existing card, detail and paging path already uses. An empty query keeps the cursor-based browsing, and the result window follows the Skill search so a broad query cannot render hundreds of cards at once. Page links keep the kind and the query, the clear action resets the search position, and no-match and read failures stay separate with matching Chinese and English labels. Document the search entry point on the Dashboard page-API table. --- docs/en/development/dashboard.md | 2 +- docs/zh/development/dashboard.md | 2 +- src/powercontext/server/dashboard/api.py | 30 ++++- src/powercontext/server/dashboard/content.py | 7 +- .../server/dashboard/labels.en.json | 3 + src/powercontext/server/dashboard/labels.json | 3 + .../server/dashboard/pagination.py | 6 +- .../templates/components/search.html | 2 +- .../server/dashboard/templates/methods.html | 6 +- tests/test_dashboard.py | 114 ++++++++++++++++++ 10 files changed, 162 insertions(+), 13 deletions(-) diff --git a/docs/en/development/dashboard.md b/docs/en/development/dashboard.md index b0d431e3c4..8b63bfa591 100644 --- a/docs/en/development/dashboard.md +++ b/docs/en/development/dashboard.md @@ -134,7 +134,7 @@ The HTTP API specification defines request fields and limits. | Scope selection | `GET /v1/scopes`, `GET /v1/scopes/default`, `GET /v1/scopes/{scope_id}` | Treat defaults, explicit selection, hierarchy and readable scopes separately | | Memory collection and text | `POST /v1/atomic-memory/list`, `POST /v1/memory/search`, exact Atomic Memory revision and state reads | Preserve Atomic Memory identities, lifecycle filters, the selected search mode and hit evidence; return up to 50 matches | | Handoff collection and text | `GET /v1/scopes/{scope_id}/artifacts/handoff` and exact revision reads | Retain cursors; list order does not imply chronological order | -| Experience collection and text | `GET /v1/scopes/{scope_id}/artifacts/experience`, `POST /v1/experience/get` | Dashboard provides paged browsing; see [Artifact search](../docs/workflows/search-artifacts.md) for Family relevance retrieval | +| Experience collection and text | `GET /v1/scopes/{scope_id}/artifacts/experience`, `POST /v1/experience/get`, `POST /v1/scopes/{scope_id}/artifacts/experience/search` | Dashboard provides paged browsing and Family relevance search; search covers approved active revisions only, returns up to 200 matches, and suggests a narrower query at that limit | | Skill collection and text | `POST /v1/skill/library`, `POST /v1/skill/get` | Library queries return up to 200 entries; suggest a narrower query at the limit and preserve source identities | | Source material | `GET /v1/scopes/{scope_id}/sources/{source_type}/{source_id}` | Verify the source's relationship to the record; `content` is currently the source type with readable text | | Usage | `POST /v1/stats` | Read the current scope with `exact` and use Server reporting periods and statistical definitions | diff --git a/docs/zh/development/dashboard.md b/docs/zh/development/dashboard.md index 479ab626c3..3e55e33e7d 100644 --- a/docs/zh/development/dashboard.md +++ b/docs/zh/development/dashboard.md @@ -134,7 +134,7 @@ Home 的记忆、交接、经验和技能可以独立阅读。一类内容缺失 | 范围选择 | `GET /v1/scopes`、`GET /v1/scopes/default`、`GET /v1/scopes/{scope_id}` | 默认值、显式选择、父子关系和可读范围分别处理 | | 记忆目录与正文 | `POST /v1/atomic-memory/list`、`POST /v1/memory/search`、Atomic Memory 精确版本与状态读取 | 保留独立制品身份、生命周期筛选、搜索模式与命中依据;最多 50 条匹配结果 | | 交接目录与正文 | `GET /v1/scopes/{scope_id}/artifacts/handoff` 及精确版本读取 | 保留游标;列表顺序不解释为时间顺序 | -| 经验目录与正文 | `GET /v1/scopes/{scope_id}/artifacts/experience`、`POST /v1/experience/get` | Dashboard 提供分页浏览;Family 相关性检索见 [Artifact 检索](../docs/workflows/search-artifacts.md) | +| 经验目录与正文 | `GET /v1/scopes/{scope_id}/artifacts/experience`、`POST /v1/experience/get`、`POST /v1/scopes/{scope_id}/artifacts/experience/search` | Dashboard 提供分页浏览与 Family 相关性搜索;搜索只覆盖 approved 且 active 的当前版本,最多返回 200 条,达到上限时提示缩小查询 | | 技能目录与正文 | `POST /v1/skill/library`、`POST /v1/skill/get` | 库检索最多 200 项,达到上限时提示缩小查询;保留来源身份 | | 原始材料 | `GET /v1/scopes/{scope_id}/sources/{source_type}/{source_id}` | 验证材料与记录的关联;当前可读取正文的来源类型为 `content` | | 用量 | `POST /v1/stats` | 以 `exact` 读取当前范围,使用服务端周期与统计口径 | diff --git a/src/powercontext/server/dashboard/api.py b/src/powercontext/server/dashboard/api.py index d9cdf5c2c4..1bb91897d3 100644 --- a/src/powercontext/server/dashboard/api.py +++ b/src/powercontext/server/dashboard/api.py @@ -38,6 +38,7 @@ from powercontext.server.dashboard.errors import ReadError as ReadError from powercontext.server.dashboard.session import authentication_headers +SEARCH_LIMIT = 200 def segment(value: str) -> str: return quote(value, safe="") @@ -248,6 +249,21 @@ def skill_record(value: dict[str, Any]) -> dict[str, Any]: "artifacts": value["artifact_refs"], } + @staticmethod + def search_record(item: dict[str, Any]) -> dict[str, Any]: + """Adapt one family-scoped search hit to the Dashboard record shape.""" + + lineage = item["lineage"] + return { + **item["content"], + "artifact_id": item["artifact_id"], + "revision": item["revision"], + "sources": [ + {"source_type": source["source_type"], "source_id": source["source_id"]} for source in lineage["sources"] + ], + "artifacts": lineage["artifacts"], + } + async def authorize_scope(self, scope: str, operation: str) -> None: from powercontext.server.app import _authorize_checks @@ -267,12 +283,22 @@ async def records( self, scope: str, family: str, *, cursor: str | None = None, limit: int = 12, query: str | None = None ) -> dict[str, Any]: if family == "skill": - maximum = 1 if limit == 1 else 200 + maximum = 1 if limit == 1 else SEARCH_LIMIT result = await self.read("/v1/skill/library", {"scope_id": scope, "limit": maximum, "query": query}) return { "items": [self.skill_record(item) for item in result["skills"]], "next_cursor": None, - "search_limited": maximum == 200 and len(result["skills"]) == maximum, + "search_limited": maximum == SEARCH_LIMIT and len(result["skills"]) == maximum, + } + if family == "experience" and query: + result = await self.read( + f"/v1/scopes/{segment(scope)}/artifacts/{segment(family)}/search", + {"query": query, "limit": SEARCH_LIMIT}, + ) + return { + "items": [self.search_record(item) for item in result["results"]], + "next_cursor": None, + "search_limited": len(result["results"]) == SEARCH_LIMIT, } if self.builtin is None: url = httpx.URL(f"/v1/scopes/{segment(scope)}/artifacts/{family}", params={"limit": limit}) diff --git a/src/powercontext/server/dashboard/content.py b/src/powercontext/server/dashboard/content.py index cf62a17dc7..e1b0ee30a7 100644 --- a/src/powercontext/server/dashboard/content.py +++ b/src/powercontext/server/dashboard/content.py @@ -34,16 +34,17 @@ async def load_collection(api: DashboardAPI, request: Request, ctx: dict[str, An ctx["collection_page_size"] = PAGE_SIZE try: cursor = request.query_params.get(f"{family}_cursor" if page == "methods" else "cursor") + query = ctx["search_query"] if page == "methods" else None result = await api.records( scope, family, cursor=cursor, limit=1 if page == "home" else PAGE_SIZE, - query=ctx["search_query"] if family == "skill" and page == "methods" else None, + query=query, ) if page != "home": - if family == "skill": - window = list_page(result["items"], request.query_params.get("skill_page")) + if family == "skill" or query: + window = list_page(result["items"], request.query_params.get(f"{family}_page")) result["items"] = window["items"] result["pager"] = list_links(ctx, family, window) else: diff --git a/src/powercontext/server/dashboard/labels.en.json b/src/powercontext/server/dashboard/labels.en.json index b060ba0d79..66b04d5c87 100644 --- a/src/powercontext/server/dashboard/labels.en.json +++ b/src/powercontext/server/dashboard/labels.en.json @@ -224,8 +224,11 @@ "light": "Light", "dark": "Dark", "display_preferences": "Display Settings", + "search_experiences": "Search experiences", "search_skills": "Search skills", "search": "Search", + "experiences_limit": "Showing the first 200 experiences. Search to narrow the results.", + "experiences_no_match": "No matching experiences", "skills_limit": "Showing the first 200 skills. Search to narrow the results.", "skills_no_match": "No matching skills", "pagination": "Pagination", diff --git a/src/powercontext/server/dashboard/labels.json b/src/powercontext/server/dashboard/labels.json index 5e966a7598..14e95ef65b 100644 --- a/src/powercontext/server/dashboard/labels.json +++ b/src/powercontext/server/dashboard/labels.json @@ -224,8 +224,11 @@ "light": "浅色", "dark": "深色", "display_preferences": "显示设置", + "search_experiences": "搜索经验", "search_skills": "搜索技能", "search": "搜索", + "experiences_limit": "显示前 200 条经验,可通过搜索缩小结果范围。", + "experiences_no_match": "没有匹配的经验", "skills_limit": "显示前 200 项技能,可通过搜索缩小结果范围。", "skills_no_match": "没有匹配的技能", "pagination": "分页", diff --git a/src/powercontext/server/dashboard/pagination.py b/src/powercontext/server/dashboard/pagination.py index f8e5193c9a..b3b1631672 100644 --- a/src/powercontext/server/dashboard/pagination.py +++ b/src/powercontext/server/dashboard/pagination.py @@ -46,8 +46,10 @@ def list_links(ctx: dict[str, Any], family: str, window: dict[str, Any]) -> dict params: dict[str, Any] = {} if family == "notes": params.update(artifact=None, revision=None, notes_cursor=None, notes_history=None) - if family == "skill": - params["kind"] = "skill" + if family in {"skill", "experience"}: + params["kind"] = family + if family == "experience": + params.update(experience_cursor=None, experience_history=None) return { "page": window["page"], **{ diff --git a/src/powercontext/server/dashboard/templates/components/search.html b/src/powercontext/server/dashboard/templates/components/search.html index ed4c913db4..a00b0f9375 100644 --- a/src/powercontext/server/dashboard/templates/components/search.html +++ b/src/powercontext/server/dashboard/templates/components/search.html @@ -31,7 +31,7 @@ -
+
{% endmacro %} diff --git a/src/powercontext/server/dashboard/templates/methods.html b/src/powercontext/server/dashboard/templates/methods.html index 5846a03983..adff9f8134 100644 --- a/src/powercontext/server/dashboard/templates/methods.html +++ b/src/powercontext/server/dashboard/templates/methods.html @@ -31,8 +31,8 @@ -{% if method_kind == 'skill' %}{{ search_form(t.search_skills, 2000, 'skill') }}{% endif %} -{% if collection.get('search_limited') %}

{{ t.skills_limit }}

{% endif %} +{{ search_form(t.search_experiences if method_kind == 'experience' else t.search_skills, 2000, method_kind) }} +{% if collection.get('search_limited') %}

{{ t.experiences_limit if method_kind == 'experience' else t.skills_limit }}

{% endif %} {% from 'components/read-error.html' import read_error with context %} {% if errors.get(family) %}{{ read_error(errors[family], t[family]) }}{% endif %}
@@ -45,7 +45,7 @@

{{ t.read_experience if family == 'experience' else t.read_skill }} {% endif %} {% endfor %} - {% if not data[family] and not errors.get(family) %}
{% if search_query and family == 'skill' %}

{{ t.skills_no_match }}

{% else %}

{{ t.experience_empty if family == 'experience' else t.skills_empty }}

{% endif %}
{% endif %} + {% if not data[family] and not errors.get(family) %}
{% if search_query %}

{{ t.experiences_no_match if family == 'experience' else t.skills_no_match }}

{% else %}

{{ t.experience_empty if family == 'experience' else t.skills_empty }}

{% endif %}
{% endif %}

diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index b2c6f78dcb..7860c51b8b 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -30,6 +30,9 @@ from powercontext.builtin.persistence.sqlite import SQLiteConfig from powercontext.builtin.persistence.tables import MODEL_USAGE_DAILY_TABLE +from powercontext.server.dashboard.api import DashboardAPI +from powercontext.server.dashboard.errors import ReadError +from powercontext.server.dashboard.preferences import CATALOGS from powercontext.server.dashboard.routes import LABELS from powercontext.server.factory import create_server_app from powercontext.server.settings import ( @@ -240,6 +243,32 @@ async def authorize(self, connection, scope_id, context, action, ref=None): assert dashboard.portal is not None dashboard.portal.call(injected.close) +def capture_source(client: TestClient, scope: str, content: str) -> dict[str, Any]: + result = client.post("/v1/sources/content", json={"scope_id": scope, "source_id": "evidence", "content": content}) + assert result.is_success + return result.json()["source"] + + +def create_experience(client: TestClient, scope: str, source: dict[str, Any], situation: str, lesson: str) -> str: + proposal = { + "situation": situation, + "action": "Reuse the artifact search API", + "outcome": "Matching experiences return", + "lesson": lesson, + } + candidate = client.post( + "/v1/experience/propose", + json={"scope_id": scope, "proposal": proposal, "source_refs": [source], "artifact_refs": []}, + ) + assert candidate.status_code == 201 + body = candidate.json() + approved = client.post( + "/v1/candidates/approve", + json={"scope_id": scope, "candidate_id": body["candidate_id"], "expected_version": body["version"]}, + ) + assert approved.status_code == 200 + return approved.json()["result_artifact"]["artifact_id"] + def test_scope_selection_default_and_unknown_scope(dashboard: TestClient) -> None: parent = create_scope(dashboard, "Project") @@ -598,6 +627,91 @@ def test_method_pagination_returns_to_the_previous_records(dashboard: TestClient assert collect_pages(dashboard, first.text, f"/dashboard/{family}", "artifact") == expected +@pytest.mark.parametrize("language", ["zh", "en"]) +def test_experience_search_filters_results_and_restores_browsing(dashboard: TestClient, language: str) -> None: + labels = CATALOGS[language] + scope = create_scope(dashboard, f"Experience search {language}")["scope_id"] + source = capture_source(dashboard, scope, "Search the dashboard experiences.") + create_experience( + dashboard, scope, source, situation="Adding dashboard search", lesson="Deploy the dashboard search form" + ) + create_experience( + dashboard, scope, source, situation="Rotating the signing key", lesson="Never reuse a retired signing key" + ) + + browsing = dashboard.get("/dashboard/methods", params={"scope": scope, "kind": "experience", "lang": language}) + assert browsing.status_code == 200 + assert labels["search_experiences"] in browsing.text + assert "Deploy the dashboard search form" in browsing.text + assert "Never reuse a retired signing key" in browsing.text + + searched = dashboard.get( + "/dashboard/methods", params={"scope": scope, "kind": "experience", "q": "dashboard", "lang": language} + ) + assert searched.status_code == 200 + assert 'value="dashboard"' in searched.text + assert "Deploy the dashboard search form" in searched.text + assert "Never reuse a retired signing key" not in searched.text + + cleared = dashboard.get(page_link(searched.text, labels["clear_search"])) + assert cleared.status_code == 200 + assert cleared.url.params["kind"] == "experience" + assert "Never reuse a retired signing key" in cleared.text + assert 'value="dashboard"' not in cleared.text + + empty = dashboard.get( + "/dashboard/methods", + params={"scope": scope, "kind": "experience", "q": "absent-token", "lang": language}, + ) + assert empty.status_code == 200 + assert labels["experiences_no_match"] in empty.text + assert labels["experience_empty"] not in empty.text + + +def test_experience_search_keeps_the_query_across_result_pages(dashboard: TestClient) -> None: + scope = create_scope(dashboard, "Experience search paging")["scope_id"] + source = capture_source(dashboard, scope, "Page through the matching experiences.") + expected = { + create_experience( + dashboard, + scope, + source, + situation=f"Reviewing handoff {index}", + lesson=f"Keep handoff note {index} documented", + ) + for index in range(7) + } + + first = dashboard.get("/dashboard/methods", params={"scope": scope, "kind": "experience", "q": "handoff"}) + assert first.status_code == 200 + assert len(record_links(first.text, "/dashboard/experience", "artifact")) == 6 + following = page_link(first.text, LABELS["next_page"]) + assert parse_qs(urlsplit(following).query)["q"] == ["handoff"] + assert collect_pages(dashboard, first.text, "/dashboard/experience", "artifact") == expected + + +def test_experience_search_reports_request_failures_distinctly(dashboard: TestClient, monkeypatch) -> None: + scope = create_scope(dashboard, "Experience search failure")["scope_id"] + source = capture_source(dashboard, scope, "Search failures stay separate from empty results.") + create_experience(dashboard, scope, source, situation="Losing the search backend", lesson="Surface a read failure") + read = DashboardAPI.read + search_path = f"/v1/scopes/{scope}/artifacts/experience/search" + seen: list[str] = [] + + async def failing(api, path, payload=None): + if path == search_path: + seen.append(path) + raise ReadError(503, "service_unavailable") + return await read(api, path, payload) + + monkeypatch.setattr(DashboardAPI, "read", failing) + response = dashboard.get("/dashboard/methods", params={"scope": scope, "kind": "experience", "q": "search"}) + assert response.status_code == 200 + assert seen == [search_path] + assert LABELS["error_503"] in response.text + assert LABELS["experiences_no_match"] not in response.text + + def test_skill_with_usage_provenance_is_readable_and_searchable(dashboard: TestClient) -> None: scope = create_scope(dashboard, "Skill review")["scope_id"] source = dashboard.post( From f0ba4f36d984de66f0b143261ce06643bac11922 Mon Sep 17 00:00:00 2001 From: yydawx <195621032+yydawx@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:21:53 +0800 Subject: [PATCH 2/7] fix(dashboard): keep search state through detail navigation Return from an Experience or Skill detail page to the result page the reader came from: collection links carry a validated return_to for the Methods page, and the detail back link, its error state, revision comparison and rollback keep it. Return navigation applies the current interface language. Retry after a failed search request keeps experience_page because the same-page link allowlist includes it, while a new query still starts on the first page. Both families share the Methods template, so Skill detail pages gain the same return path. --- src/powercontext/server/dashboard/api.py | 4 +- .../server/dashboard/navigation.py | 54 +++- src/powercontext/server/dashboard/routes.py | 39 ++- .../templates/components/read-error.html | 2 +- .../components/revision-history.html | 2 + .../dashboard/templates/experience.html | 2 +- .../server/dashboard/templates/methods.html | 6 +- .../server/dashboard/templates/skill.html | 2 +- tests/test_dashboard.py | 286 ++++++++++++++++++ 9 files changed, 373 insertions(+), 24 deletions(-) diff --git a/src/powercontext/server/dashboard/api.py b/src/powercontext/server/dashboard/api.py index 1bb91897d3..d9bcaad45b 100644 --- a/src/powercontext/server/dashboard/api.py +++ b/src/powercontext/server/dashboard/api.py @@ -40,6 +40,7 @@ SEARCH_LIMIT = 200 + def segment(value: str) -> str: return quote(value, safe="") @@ -259,7 +260,8 @@ def search_record(item: dict[str, Any]) -> dict[str, Any]: "artifact_id": item["artifact_id"], "revision": item["revision"], "sources": [ - {"source_type": source["source_type"], "source_id": source["source_id"]} for source in lineage["sources"] + {"source_type": source["source_type"], "source_id": source["source_id"]} + for source in lineage["sources"] ], "artifacts": lineage["artifacts"], } diff --git a/src/powercontext/server/dashboard/navigation.py b/src/powercontext/server/dashboard/navigation.py index 063bb89560..bc157a38ea 100644 --- a/src/powercontext/server/dashboard/navigation.py +++ b/src/powercontext/server/dashboard/navigation.py @@ -24,6 +24,9 @@ COMMON = {"scope", "period", "lang", "theme"} HISTORY = {"view", "profile_cursor", "profile_history"} COLLECTION = {"cursor", "handoff_history"} +METHODS = {"kind", "q", "experience_page", "skill_page"} +# A 2,000-character query can expand to 24 KiB when percent encoded. +METHODS_RETURN_MAX = 32 * 1024 def positive_revision(raw: str | None) -> int: @@ -36,15 +39,19 @@ def _has_controls(value: str) -> bool: return any(ord(char) < 32 or ord(char) == 127 for char in value) -def _parameters(raw: str, maximum: int) -> tuple[str, dict[str, str]]: - if len(raw.encode()) > maximum or _has_controls(raw) or "\\" in raw: +def _parameters( + raw: str, maximum: int, *, backslash_values: frozenset[str] = frozenset() +) -> tuple[str, dict[str, str]]: + if len(raw.encode()) > maximum or _has_controls(raw): raise ValueError url = urlsplit(raw) - if url.scheme or url.netloc or url.fragment or not url.path.startswith("/dashboard/"): + if url.scheme or url.netloc or url.fragment or not url.path.startswith("/dashboard/") or "\\" in url.path: raise ValueError pairs = parse_qsl(url.query, keep_blank_values=True, max_num_fields=12, errors="strict") params = dict(pairs) - if len(params) != len(pairs) or any(_has_controls(value) or "\\" in value for value in params.values()): + if len(params) != len(pairs) or any( + _has_controls(value) or ("\\" in value and key not in backslash_values) for key, value in params.items() + ): raise ValueError scope = params.get("scope", "") if not scope.strip() or len(scope) > 256: @@ -73,24 +80,49 @@ def _validate_history(params: dict[str, str]) -> None: raise ValueError +def _validate_methods(params: dict[str, str]) -> None: + if params.get("kind", "experience") not in {"experience", "skill"}: + raise ValueError + for key in ("experience_page", "skill_page"): + if key in params and (not params[key].isascii() or not params[key].isdecimal() or int(params[key]) < 1): + raise ValueError + + def collection_return(raw: str | None, scope: str, *, page: str | None = None) -> str | None: if not raw: return None try: - path, params = _parameters(raw, 8192) + if page == "methods": + path, params = _parameters(raw, METHODS_RETURN_MAX, backslash_values=frozenset({"q"})) + else: + path, params = _parameters(raw, 8192) if params["scope"] != scope or (page and path != f"/dashboard/{page}"): return None - allowed = COMMON | COLLECTION if path == "/dashboard/handoff" else COMMON | HISTORY - if path not in {"/dashboard/handoff", "/dashboard/profile"} or params.keys() - allowed: - return None - if path == "/dashboard/profile" and params.get("view") != "history": - return None - _validate_history(params) + if page == "methods": + if params.keys() - (COMMON | METHODS): + return None + _validate_methods(params) + else: + allowed = COMMON | COLLECTION if path == "/dashboard/handoff" else COMMON | HISTORY + if path not in {"/dashboard/handoff", "/dashboard/profile"} or params.keys() - allowed: + return None + if path == "/dashboard/profile" and params.get("view") != "history": + return None + _validate_history(params) except (ValueError, UnicodeError): return None return path + "?" + urlencode(params) +def with_language(destination: str, language: str) -> str: + """Apply the current interface language to a validated return destination.""" + + url = urlsplit(destination) + params = dict(parse_qsl(url.query, keep_blank_values=True)) + params["lang"] = language + return url.path + "?" + urlencode(params) + + def _reading_fields(path: str, params: dict[str, str]) -> set[str]: if path == "/dashboard/profile": allowed = HISTORY diff --git a/src/powercontext/server/dashboard/routes.py b/src/powercontext/server/dashboard/routes.py index f2f5a10b93..763bfe6c7c 100644 --- a/src/powercontext/server/dashboard/routes.py +++ b/src/powercontext/server/dashboard/routes.py @@ -34,6 +34,7 @@ directory_context, positive_revision, request_reading_return, + with_language, ) from powercontext.server.dashboard.page import load_builtin_page from powercontext.server.dashboard.pagination import PAGE_SIZE @@ -130,6 +131,7 @@ def link(destination: str | None = None, fragment: str = "", **params: Any) -> s "notes_history", "note_state", "skill_page", + "experience_page", "experience_history", "handoff_history", "resource_type", @@ -221,6 +223,7 @@ def reading_link_context( def initial_context(request: Request, page: str) -> dict[str, Any]: method_kind = request.query_params.get("kind", "experience") + return_page = "profile" if page == "profile" else "methods" if page in {"experience", "skill"} else "handoff" ctx: dict[str, Any] = { **presentation(request), "page": page, @@ -279,10 +282,12 @@ def initial_context(request: Request, page: str) -> dict[str, Any]: "return_to": collection_return( request.query_params.get("return_to"), request.query_params.get("scope", ""), - page="profile" if page == "profile" else "handoff", + page=return_page, ), "reading_return": request_reading_return(request), } + if ctx["return_to"]: + ctx["return_to"] = with_language(ctx["return_to"], ctx["language"]) ctx["link"] = links(request, ctx) return ctx @@ -469,6 +474,17 @@ async def rollback_revision(request: Request) -> Response: family = str(form.get("family") or "") artifact_id = str(form.get("artifact_id") or "") reason = str(form.get("reason") or "") + return_page = { + "experience": "methods", + "skill": "methods", + "handoff": "handoff", + "profile": "profile", + }.get(family) + return_to = ( + collection_return(str(form.get("return_to") or "") or None, scope, page=return_page) + if return_page is not None + else None + ) try: source_revision = positive_revision(str(form.get("source_revision") or "")) expected_revision = positive_revision(str(form.get("expected_revision") or "")) @@ -487,7 +503,9 @@ async def rollback_revision(request: Request) -> Response: ) except ReadError as error: code = "forbidden" if error.status == 403 else "rejected" - return RedirectResponse(_rollback_return(scope, family, artifact_id, source_revision, code), status_code=303) + return RedirectResponse( + _rollback_return(scope, family, artifact_id, source_revision, code, return_to), status_code=303 + ) finally: await api.client.aclose() if response.status_code == 412: @@ -498,12 +516,21 @@ async def rollback_revision(request: Request) -> Response: code = "rejected" else: created = response.json()["revision"] - return RedirectResponse(_rollback_return(scope, family, artifact_id, created, None), status_code=303) - return RedirectResponse(_rollback_return(scope, family, artifact_id, source_revision, code), status_code=303) + return RedirectResponse(_rollback_return(scope, family, artifact_id, created, None, return_to), status_code=303) + return RedirectResponse( + _rollback_return(scope, family, artifact_id, source_revision, code, return_to), status_code=303 + ) -def _rollback_return(scope: str, family: str, artifact_id: str, revision: int, error: str | None) -> str: - params: dict[str, Any] = {"scope": scope, "rollback_error": error} +def _rollback_return( + scope: str, + family: str, + artifact_id: str, + revision: int, + error: str | None, + return_to: str | None = None, +) -> str: + params: dict[str, Any] = {"scope": scope, "rollback_error": error, "return_to": return_to} if family == "profile": page = "profile" params["revision"] = revision diff --git a/src/powercontext/server/dashboard/templates/components/read-error.html b/src/powercontext/server/dashboard/templates/components/read-error.html index fa30d212a0..a00f6ddf9b 100644 --- a/src/powercontext/server/dashboard/templates/components/read-error.html +++ b/src/powercontext/server/dashboard/templates/components/read-error.html @@ -20,7 +20,7 @@

{{ label or t.error_heading }}

{{ t.get('error_' ~ error.status, t.error_503) }}

{{ t.retry }} - {% if page == 'handoff-download' and reading_return %}{{ t.return_handoff }}{% elif page == 'profile' %}{{ t.profile_history if profile_history_view else t.profile_current }}{% elif page == 'handoff-detail' and return_to %}{{ t.back_list }}{% elif not scope_descriptor %}{{ t.scope_label }}{% else %}{{ t.back_home if page == 'home' else t.back_list }}{% endif %} + {% if page == 'handoff-download' and reading_return %}{{ t.return_handoff }}{% elif page == 'profile' %}{{ t.profile_history if profile_history_view else t.profile_current }}{% elif page in ['handoff-detail', 'experience', 'skill'] and return_to %}{{ t.back_list }}{% elif not scope_descriptor %}{{ t.scope_label }}{% else %}{{ t.back_home if page == 'home' else t.back_list }}{% endif %}
{% if error.request_id %}

{{ t.request_id }}: {{ error.request_id }}

{% endif %} diff --git a/src/powercontext/server/dashboard/templates/components/revision-history.html b/src/powercontext/server/dashboard/templates/components/revision-history.html index 93318febf0..0e26ef7df8 100644 --- a/src/powercontext/server/dashboard/templates/components/revision-history.html +++ b/src/powercontext/server/dashboard/templates/components/revision-history.html @@ -77,6 +77,7 @@

{{ row.name }}

{% if history.selected != history.head %}
+ {% if return_to %}{% endif %} {% if history.family == "profile" %}{% endif %} {% if history.family not in ["profile", "prompt", "topic-memory"] %} @@ -98,6 +99,7 @@

{{ row.name }}

{% if history.can_rollback and history.selected != history.head and not history.same_content %} + {% if return_to %}{% endif %} diff --git a/src/powercontext/server/dashboard/templates/experience.html b/src/powercontext/server/dashboard/templates/experience.html index ebafb1dacb..48887b787a 100644 --- a/src/powercontext/server/dashboard/templates/experience.html +++ b/src/powercontext/server/dashboard/templates/experience.html @@ -24,7 +24,7 @@
- ← {{ t.back_methods }} + ← {{ t.back_methods }} {{ t.experience }} · {{ data.title }}

{{ data.experience.lesson }}

diff --git a/src/powercontext/server/dashboard/templates/methods.html b/src/powercontext/server/dashboard/templates/methods.html index adff9f8134..bb3027c1cb 100644 --- a/src/powercontext/server/dashboard/templates/methods.html +++ b/src/powercontext/server/dashboard/templates/methods.html @@ -29,7 +29,7 @@ {% from 'components/search.html' import search_form with context %} {{ page_heading(t.methods, t.methods_subtitle) }} {{ search_form(t.search_experiences if method_kind == 'experience' else t.search_skills, 2000, method_kind) }} {% if collection.get('search_limited') %}

{{ t.experiences_limit if method_kind == 'experience' else t.skills_limit }}

{% endif %} @@ -40,9 +40,9 @@ {% for record in collection.get('items', []) %} {% if 'error' not in record %}{% endif %} {% endfor %} {% if not data[family] and not errors.get(family) %}
{% if search_query %}

{{ t.experiences_no_match if family == 'experience' else t.skills_no_match }}

{% else %}

{{ t.experience_empty if family == 'experience' else t.skills_empty }}

{% endif %}
{% endif %} diff --git a/src/powercontext/server/dashboard/templates/skill.html b/src/powercontext/server/dashboard/templates/skill.html index 83f81cbed4..37846a440b 100644 --- a/src/powercontext/server/dashboard/templates/skill.html +++ b/src/powercontext/server/dashboard/templates/skill.html @@ -21,7 +21,7 @@ {% if data.skill %}
- ← {{ t.back_methods }} + ← {{ t.back_methods }}
{{ t.skill_reading }} · {{ data.title }} · {{ t.revision }} {{ data.skill.revision }}

{{ data.skill.name }}

{{ data.skill.description }}

{{ t.skill_instructions }}

{{ data.skill.instructions or t.skill_body_unavailable }}

diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 7860c51b8b..e195629ed9 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -243,6 +243,7 @@ async def authorize(self, connection, scope_id, context, action, ref=None): assert dashboard.portal is not None dashboard.portal.call(injected.close) + def capture_source(client: TestClient, scope: str, content: str) -> dict[str, Any]: result = client.post("/v1/sources/content", json={"scope_id": scope, "source_id": "evidence", "content": content}) assert result.is_success @@ -270,6 +271,35 @@ def create_experience(client: TestClient, scope: str, source: dict[str, Any], si return approved.json()["result_artifact"]["artifact_id"] +def create_skill(client: TestClient, scope: str, source: dict[str, Any], name: str) -> str: + proposal = { + "name": name, + "description": "Review the report", + "instructions": "Keep the documented limits.", + "validation": ["Keep the documented limits."], + } + candidate = client.post( + "/v1/skill/propose", + json={"scope_id": scope, "proposal": proposal, "source_refs": [source], "artifact_refs": []}, + ) + assert candidate.status_code == 201 + body = candidate.json() + approved = client.post( + "/v1/candidates/approve", + json={"scope_id": scope, "candidate_id": body["candidate_id"], "expected_version": body["version"]}, + ) + assert approved.status_code == 200 + return approved.json()["result_artifact"]["artifact_id"] + + +def labelled_link(html: str, label: str) -> str: + """Return the first link whose text contains ``label`` (e.g. a back arrow).""" + + match = re.search(r']*href="([^"]+)"[^>]*>[^<]*' + re.escape(label) + r"[^<]*", html) + assert match is not None + return unescape(match[1]) + + def test_scope_selection_default_and_unknown_scope(dashboard: TestClient) -> None: parent = create_scope(dashboard, "Project") child = create_scope(dashboard, "Documentation", parent["scope_id"]) @@ -690,6 +720,262 @@ def test_experience_search_keeps_the_query_across_result_pages(dashboard: TestCl assert collect_pages(dashboard, first.text, "/dashboard/experience", "artifact") == expected +def test_experience_search_preserves_state_through_detail_and_back(dashboard: TestClient) -> None: + scope = create_scope(dashboard, "Experience detail return")["scope_id"] + source = capture_source(dashboard, scope, "Return to the searched experience list.") + for index in range(13): + create_experience( + dashboard, + scope, + source, + situation=f"Reviewing needle {index}", + lesson=f"Keep needle note {index} documented", + ) + second = dashboard.get( + "/dashboard/methods", + params={"scope": scope, "kind": "experience", "q": "needle", "experience_page": "2"}, + ) + assert second.status_code == 200 + page_records = record_links(second.text, "/dashboard/experience", "artifact") + assert len(page_records) == 6 + detail_url = next( + unescape(value) + for value in re.findall(r'href="([^"]+)"', second.text) + if urlsplit(unescape(value)).path == "/dashboard/experience" + ) + return_to = parse_qs(urlsplit(detail_url).query)["return_to"][0] + assert parse_qs(urlsplit(return_to).query)["q"] == ["needle"] + assert parse_qs(urlsplit(return_to).query)["experience_page"] == ["2"] + assert parse_qs(urlsplit(return_to).query)["kind"] == ["experience"] + + detail = dashboard.get(detail_url) + assert detail.status_code == 200 + restored = dashboard.get(labelled_link(detail.text, LABELS["back_methods"])) + assert restored.status_code == 200 + assert restored.url.params["q"] == "needle" + assert restored.url.params["experience_page"] == "2" + assert restored.url.params["kind"] == "experience" + assert record_links(restored.text, "/dashboard/experience", "artifact") == page_records + + +def test_skill_search_preserves_state_through_detail_and_back(dashboard: TestClient) -> None: + scope = create_scope(dashboard, "Skill detail return")["scope_id"] + source = capture_source(dashboard, scope, "Return to the searched skill list.") + create_skill(dashboard, scope, source, "needle-skill") + create_skill(dashboard, scope, source, "other-skill") + searched = dashboard.get("/dashboard/methods", params={"scope": scope, "kind": "skill", "q": "needle"}) + assert searched.status_code == 200 + assert "needle-skill" in searched.text + assert "other-skill" not in searched.text + detail_url = next( + unescape(value) + for value in re.findall(r'href="([^"]+)"', searched.text) + if urlsplit(unescape(value)).path == "/dashboard/skill" + ) + detail = dashboard.get(detail_url) + assert detail.status_code == 200 + restored = dashboard.get(labelled_link(detail.text, LABELS["back_methods"])) + assert restored.status_code == 200 + assert restored.url.params["q"] == "needle" + assert restored.url.params["kind"] == "skill" + assert "needle-skill" in restored.text + assert "other-skill" not in restored.text + + +@pytest.mark.parametrize("family", ["experience", "skill"]) +def test_methods_return_uses_the_current_language(dashboard: TestClient, family: str) -> None: + scope = create_scope(dashboard, f"{family} language return")["scope_id"] + source = capture_source(dashboard, scope, f"Keep the {family} language searchable.") + if family == "experience": + create_experience(dashboard, scope, source, situation="needle situation", lesson="Keep the needle lesson") + else: + create_skill(dashboard, scope, source, "needle-skill") + searched = dashboard.get("/dashboard/methods", params={"scope": scope, "kind": family, "q": "needle", "lang": "en"}) + assert searched.status_code == 200 + detail_url = next( + unescape(value) + for value in re.findall(r'href="([^"]+)"', searched.text) + if urlsplit(unescape(value)).path == f"/dashboard/{family}" + ) + return_to = parse_qs(urlsplit(detail_url).query)["return_to"][0] + assert parse_qs(urlsplit(return_to).query)["lang"] == ["en"] + detail = dashboard.get(detail_url) + assert detail.status_code == 200 + switched = dashboard.get(f"{detail_url}&lang=zh") + assert switched.status_code == 200 + restored = dashboard.get(labelled_link(switched.text, LABELS["back_methods"])) + assert restored.status_code == 200 + assert restored.url.params["lang"] == "zh" + assert restored.url.params["q"] == "needle" + assert restored.url.params["kind"] == family + assert ("搜索经验" if family == "experience" else "搜索技能") in restored.text + + +def test_experience_search_return_to_accepts_backslashes(dashboard: TestClient) -> None: + scope = create_scope(dashboard, "Experience backslash search")["scope_id"] + source = capture_source(dashboard, scope, "Keep the needle path searchable.") + create_experience(dashboard, scope, source, situation="needle path", lesson="Keep the needle path") + create_experience(dashboard, scope, source, situation="unrelated lesson", lesson="Keep the unrelated lesson") + searched = dashboard.get("/dashboard/methods", params={"scope": scope, "kind": "experience", "q": "needle\\path"}) + assert searched.status_code == 200 + assert "Keep the needle path" in searched.text + assert "Keep the unrelated lesson" not in searched.text + detail_url = next( + unescape(value) + for value in re.findall(r'href="([^"]+)"', searched.text) + if urlsplit(unescape(value)).path == "/dashboard/experience" + ) + detail = dashboard.get(detail_url) + assert detail.status_code == 200 + restored = dashboard.get(labelled_link(detail.text, LABELS["back_methods"])) + assert restored.status_code == 200 + assert restored.url.params["q"] == "needle\\path" + assert "Keep the needle path" in restored.text + assert "Keep the unrelated lesson" not in restored.text + + +def test_experience_search_return_to_accepts_a_long_query(dashboard: TestClient) -> None: + query = "经验 " * 460 + scope = create_scope(dashboard, "Experience long query")["scope_id"] + source = capture_source(dashboard, scope, "Keep the long query searchable.") + create_experience(dashboard, scope, source, situation="经验 检索", lesson=query) + searched = dashboard.get("/dashboard/methods", params={"scope": scope, "kind": "experience", "q": query}) + assert searched.status_code == 200 + assert "经验 检索" in searched.text + detail_url = next( + unescape(value) + for value in re.findall(r'href="([^"]+)"', searched.text) + if urlsplit(unescape(value)).path == "/dashboard/experience" + ) + assert "return_to" in parse_qs(urlsplit(detail_url).query) + detail = dashboard.get(detail_url) + assert detail.status_code == 200 + restored = dashboard.get(labelled_link(detail.text, LABELS["back_methods"])) + assert restored.status_code == 200 + assert restored.url.params["q"] == query + assert "经验 检索" in restored.text + + +def test_experience_detail_rejects_a_foreign_return_to(dashboard: TestClient) -> None: + scope = create_scope(dashboard, "Experience return boundary")["scope_id"] + other = create_scope(dashboard, "Other experience return")["scope_id"] + source = capture_source(dashboard, scope, "Keep the return inside its scope.") + artifact = create_experience(dashboard, scope, source, situation="Reviewing", lesson="Stay in the scope") + detail = dashboard.get( + "/dashboard/experience", + params={ + "scope": scope, + "artifact": artifact, + "revision": "1", + "return_to": f"/dashboard/methods?scope={other}&kind=experience&q=needle", + }, + ) + assert detail.status_code == 200 + restored = dashboard.get(labelled_link(detail.text, LABELS["back_methods"])) + assert restored.status_code == 200 + assert restored.url.params["scope"] == scope + assert "q" not in restored.url.params + + +def test_experience_search_state_survives_compare_and_rollback(dashboard: TestClient) -> None: + scope = create_scope(dashboard, "Experience search history")["scope_id"] + content = { + "situation": "Reviewing the needle search", + "action": "Keep the search origin", + "outcome": "The result page survives detail navigation", + "lesson": "Keep the needle search state", + } + created = dashboard.post(f"/v1/scopes/{scope}/artifacts", json={"family": "experience", "content": content}) + assert created.status_code == 201, created.text + artifact = created.json()["artifact_id"] + current = dashboard.get(f"/v1/scopes/{scope}/artifacts/experience/{artifact}") + revised = dashboard.put( + f"/v1/scopes/{scope}/artifacts/experience/{artifact}", + json={"content": {**content, "lesson": "A later needle lesson"}}, + headers={"If-Match": current.headers["etag"]}, + ) + assert revised.status_code == 200, revised.text + + searched = dashboard.get("/dashboard/methods", params={"scope": scope, "kind": "experience", "q": "needle"}) + assert searched.status_code == 200 + detail_url = next( + unescape(value) + for value in re.findall(r'href="([^"]+)"', searched.text) + if urlsplit(unescape(value)).path == "/dashboard/experience" + ) + return_to = parse_qs(urlsplit(detail_url).query)["return_to"][0] + revision = dashboard.get( + "/dashboard/experience", + params={ + "scope": scope, + "artifact": artifact, + "revision": "1", + "compare": "2", + "return_to": return_to, + }, + ) + assert revision.status_code == 200, revision.text + assert 'action="/dashboard/rollback"' in revision.text + compare_form = re.search( + r'.*?', + revision.text, + re.S, + ) + assert compare_form is not None + assert 'name="return_to"' in compare_form[0] + rollback_form = re.search( + r'
.*?
', revision.text, re.S + ) + assert rollback_form is not None + assert 'name="return_to"' in rollback_form[0] + + restored = dashboard.post( + "/dashboard/rollback", + data={ + "scope": scope, + "family": "experience", + "artifact_id": artifact, + "source_revision": "1", + "expected_revision": "2", + "reason": "Restore the searched revision", + "return_to": return_to, + }, + headers={"Origin": "http://testserver"}, + ) + assert restored.status_code == 200, restored.text + assert restored.url.params["return_to"] == return_to + back = dashboard.get(labelled_link(restored.text, LABELS["back_methods"])) + assert back.status_code == 200 + assert back.url.params["q"] == "needle" + assert back.url.params["kind"] == "experience" + + +def test_experience_search_retry_keeps_the_result_page(dashboard: TestClient, monkeypatch) -> None: + scope = create_scope(dashboard, "Experience retry page")["scope_id"] + search_path = f"/v1/scopes/{scope}/artifacts/experience/search" + read = DashboardAPI.read + + async def failing(api, path, payload=None): + if path == search_path: + raise ReadError(503, "service_unavailable") + return await read(api, path, payload) + + monkeypatch.setattr(DashboardAPI, "read", failing) + response = dashboard.get( + "/dashboard/methods", + params={"scope": scope, "kind": "experience", "q": "needle", "experience_page": "2"}, + ) + assert response.status_code == 200 + retry = page_link(response.text, LABELS["retry"]) + params = parse_qs(urlsplit(retry).query) + assert params["q"] == ["needle"] + assert params["experience_page"] == ["2"] + assert params["kind"] == ["experience"] + form = re.search(r'
', response.text, re.S) + assert form is not None + assert 'name="experience_page"' not in form[0] + + def test_experience_search_reports_request_failures_distinctly(dashboard: TestClient, monkeypatch) -> None: scope = create_scope(dashboard, "Experience search failure")["scope_id"] source = capture_source(dashboard, scope, "Search failures stay separate from empty results.") From f6891711a7c7bc65b2ceab70254d9d0d9ca60d19 Mon Sep 17 00:00:00 2001 From: yydawx <195621032+yydawx@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:50:45 +0800 Subject: [PATCH 3/7] test(dashboard): cover methods return-context edge cases Cover supported query text and its encoded size, rejected scope, path, kind and page contexts, Skill result-page restoration, and the detail read-error back path. --- tests/test_dashboard.py | 90 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 89 insertions(+), 1 deletion(-) diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index e195629ed9..129247b06b 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -20,7 +20,7 @@ from pathlib import Path from secrets import token_urlsafe from typing import Any, cast -from urllib.parse import parse_qs, urlsplit +from urllib.parse import parse_qs, urlencode, urlsplit import pytest from fastapi import FastAPI @@ -32,6 +32,7 @@ from powercontext.builtin.persistence.tables import MODEL_USAGE_DAILY_TABLE from powercontext.server.dashboard.api import DashboardAPI from powercontext.server.dashboard.errors import ReadError +from powercontext.server.dashboard.navigation import collection_return from powercontext.server.dashboard.preferences import CATALOGS from powercontext.server.dashboard.routes import LABELS from powercontext.server.factory import create_server_app @@ -782,6 +783,32 @@ def test_skill_search_preserves_state_through_detail_and_back(dashboard: TestCli assert "other-skill" not in restored.text +def test_skill_search_preserves_the_result_page_through_detail_and_back(dashboard: TestClient) -> None: + scope = create_scope(dashboard, "Skill search paging")["scope_id"] + source = capture_source(dashboard, scope, "Page through the matching skills.") + for index in range(7): + create_skill(dashboard, scope, source, f"needle-skill-{index}") + second = dashboard.get( + "/dashboard/methods", params={"scope": scope, "kind": "skill", "q": "needle", "skill_page": "2"} + ) + assert second.status_code == 200 + page_records = record_links(second.text, "/dashboard/skill", "artifact") + assert len(page_records) == 1 + detail_url = next( + unescape(value) + for value in re.findall(r'href="([^"]+)"', second.text) + if urlsplit(unescape(value)).path == "/dashboard/skill" + ) + detail = dashboard.get(detail_url) + assert detail.status_code == 200 + restored = dashboard.get(labelled_link(detail.text, LABELS["back_methods"])) + assert restored.status_code == 200 + assert restored.url.params["q"] == "needle" + assert restored.url.params["skill_page"] == "2" + assert restored.url.params["kind"] == "skill" + assert record_links(restored.text, "/dashboard/skill", "artifact") == page_records + + @pytest.mark.parametrize("family", ["experience", "skill"]) def test_methods_return_uses_the_current_language(dashboard: TestClient, family: str) -> None: scope = create_scope(dashboard, f"{family} language return")["scope_id"] @@ -811,6 +838,40 @@ def test_methods_return_uses_the_current_language(dashboard: TestClient, family: assert ("搜索经验" if family == "experience" else "搜索技能") in restored.text +def test_methods_return_to_accepts_supported_text() -> None: + scope = "scp_demo" + for query in ("needle\\path", "needle & path = 100% #1", "中" * 2000): + raw = "/dashboard/methods?" + urlencode({ + "scope": scope, + "period": "7d", + "kind": "experience", + "q": query, + "experience_page": "2", + }) + result = collection_return(raw, scope, page="methods") + assert result is not None + params = parse_qs(urlsplit(result).query) + assert params["q"] == [query] + assert params["experience_page"] == ["2"] + + +@pytest.mark.parametrize( + "raw", + [ + "/dashboard/methods?scope=other&kind=experience&q=needle", + "/dashboard/handoff?scope=scp_demo&cursor=next", + "/dashboard/methods?scope=scp_demo&kind=all&q=needle", + "/dashboard/methods?scope=scp_demo&kind=experience&q=needle&experience_page=0", + "/dashboard/methods?scope=scp_demo&kind=experience&q=needle&experience_page=1%5C2", + "/dashboard/methods?scope=scp_demo&kind=experience&q=needle%0D%0ASet-Cookie", + "/dashboard/methods?scope=scp_demo&kind=experience&q=needle&extra=1", + "/dashboard/methods?scope=scp_demo&kind=experience&q=" + "a" * (33 * 1024), + ], +) +def test_methods_return_to_rejects_unsupported_context(raw: str) -> None: + assert collection_return(raw, "scp_demo", page="methods") is None + + def test_experience_search_return_to_accepts_backslashes(dashboard: TestClient) -> None: scope = create_scope(dashboard, "Experience backslash search")["scope_id"] source = capture_source(dashboard, scope, "Keep the needle path searchable.") @@ -998,6 +1059,33 @@ async def failing(api, path, payload=None): assert LABELS["experiences_no_match"] not in response.text +def test_experience_detail_error_returns_to_the_search_page(dashboard: TestClient, monkeypatch) -> None: + scope = create_scope(dashboard, "Experience detail error")["scope_id"] + source = capture_source(dashboard, scope, "Keep the needle error searchable.") + create_experience(dashboard, scope, source, situation="needle error", lesson="Keep the needle error") + searched = dashboard.get("/dashboard/methods", params={"scope": scope, "kind": "experience", "q": "needle"}) + assert searched.status_code == 200 + detail_url = next( + unescape(value) + for value in re.findall(r'href="([^"]+)"', searched.text) + if urlsplit(unescape(value)).path == "/dashboard/experience" + ) + read = DashboardAPI.read + + async def failing(api, path, payload=None): + if path.startswith(f"/v1/scopes/{scope}/artifacts/experience/"): + raise ReadError(503, "service_unavailable") + return await read(api, path, payload) + + monkeypatch.setattr(DashboardAPI, "read", failing) + detail = dashboard.get(detail_url) + assert detail.status_code == 503 + assert LABELS["error_503"] in detail.text + restored = dashboard.get(page_link(detail.text, LABELS["back_list"])) + assert restored.status_code == 200 + assert restored.url.params["q"] == "needle" + + def test_skill_with_usage_provenance_is_readable_and_searchable(dashboard: TestClient) -> None: scope = create_scope(dashboard, "Skill review")["scope_id"] source = dashboard.post( From 906e0a7c5a2f251f79f745e4cbd421d263b25237 Mon Sep 17 00:00:00 2001 From: yydawx <195621032+yydawx@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:24:08 +0800 Subject: [PATCH 4/7] test(dashboard): keep skill search context in browser check Assert the Skill detail back link returns to the filtered Methods page instead of only checking the kind. --- scripts/dashboard_browser.cjs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/dashboard_browser.cjs b/scripts/dashboard_browser.cjs index 0908ae6465..290af5781a 100644 --- a/scripts/dashboard_browser.cjs +++ b/scripts/dashboard_browser.cjs @@ -782,14 +782,16 @@ async function main() { await page.waitForURL(url => url.searchParams.get('kind') === 'skill'); const skills = (await api('/v1/skill/library', { scope_id: readingScope })).skills; if (skills.length) { - await page.getByRole('searchbox').fill(skills[0].content.name); + const skillName = skills[0].content.name; + await page.getByRole('searchbox').fill(skillName); await page.getByRole('button', { name: 'Search', exact: true }).click(); - await page.waitForURL(url => url.searchParams.get('q') === skills[0].content.name); + await page.waitForURL(url => url.searchParams.get('q') === skillName); assert(await page.getByRole('navigation', { name: 'Pagination', exact: true }).isVisible()); - await page.getByRole('link', { name: skills[0].content.name, exact: true }).click(); + await page.getByRole('link', { name: skillName, exact: true }).click(); await page.waitForURL(url => url.pathname.endsWith('/skill')); await page.locator('.back-link').click(); - await page.waitForURL(url => url.pathname.endsWith('/methods') && url.searchParams.get('kind') === 'skill'); + await page.waitForURL(url => + url.pathname.endsWith('/methods') && url.searchParams.get('kind') === 'skill' && url.searchParams.get('q') === skillName); } await page.goto(base + '/dashboard/home'); await context.setOffline(true); From f8505d76c23856745539c2ad6aa89e45e0d2fb8e Mon Sep 17 00:00:00 2001 From: yydawx <195621032+yydawx@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:36:14 +0800 Subject: [PATCH 5/7] fix(dashboard): retry invalid result pages from the first page A 404 from an out-of-range Experience or Skill result page kept the invalid page in the Retry link, so retrying could never recover. Drop result-page parameters on 404 while keeping them for transient failures. --- .../templates/components/read-error.html | 2 +- tests/test_dashboard.py | 28 ++++++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/powercontext/server/dashboard/templates/components/read-error.html b/src/powercontext/server/dashboard/templates/components/read-error.html index a00f6ddf9b..44fac54730 100644 --- a/src/powercontext/server/dashboard/templates/components/read-error.html +++ b/src/powercontext/server/dashboard/templates/components/read-error.html @@ -19,7 +19,7 @@

{{ label or t.error_heading }}

{{ t.get('error_' ~ error.status, t.error_503) }}

- {{ t.retry }} + {{ t.retry }} {% if page == 'handoff-download' and reading_return %}{{ t.return_handoff }}{% elif page == 'profile' %}{{ t.profile_history if profile_history_view else t.profile_current }}{% elif page in ['handoff-detail', 'experience', 'skill'] and return_to %}{{ t.back_list }}{% elif not scope_descriptor %}{{ t.scope_label }}{% else %}{{ t.back_home if page == 'home' else t.back_list }}{% endif %}
{% if error.request_id %}

{{ t.request_id }}: {{ error.request_id }}

{% endif %} diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 129247b06b..38d1ce4f8d 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -1027,7 +1027,11 @@ async def failing(api, path, payload=None): params={"scope": scope, "kind": "experience", "q": "needle", "experience_page": "2"}, ) assert response.status_code == 200 - retry = page_link(response.text, LABELS["retry"]) + alert = re.search(r'', response.text, re.S) + assert alert is not None + retry_match = re.search(r'href="([^"]+)"[^>]*>' + re.escape(LABELS["retry"]) + "", alert[0]) + assert retry_match is not None + retry = unescape(retry_match[1]) params = parse_qs(urlsplit(retry).query) assert params["q"] == ["needle"] assert params["experience_page"] == ["2"] @@ -1037,6 +1041,28 @@ async def failing(api, path, payload=None): assert 'name="experience_page"' not in form[0] +def test_out_of_range_result_page_retry_returns_to_the_first_page(dashboard: TestClient) -> None: + scope = create_scope(dashboard, "Experience page range")["scope_id"] + source = capture_source(dashboard, scope, "Keep the page range searchable.") + create_experience(dashboard, scope, source, situation="needle range", lesson="Keep the needle range") + response = dashboard.get( + "/dashboard/methods", + params={"scope": scope, "kind": "experience", "q": "needle", "experience_page": "99"}, + ) + assert response.status_code == 200 + alert = re.search(r'', response.text, re.S) + assert alert is not None + retry_match = re.search(r'href="([^"]+)"[^>]*>' + re.escape(LABELS["retry"]) + "", alert[0]) + assert retry_match is not None + retry = unescape(retry_match[1]) + params = parse_qs(urlsplit(retry).query) + assert params["q"] == ["needle"] + assert "experience_page" not in params + restored = dashboard.get(retry) + assert restored.status_code == 200 + assert "Keep the needle range" in restored.text + + def test_experience_search_reports_request_failures_distinctly(dashboard: TestClient, monkeypatch) -> None: scope = create_scope(dashboard, "Experience search failure")["scope_id"] source = capture_source(dashboard, scope, "Search failures stay separate from empty results.") From 2166edc709a053b6926432f814a6e2707e4591a0 Mon Sep 17 00:00:00 2001 From: yydawx <195621032+yydawx@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:44:29 +0800 Subject: [PATCH 6/7] test(dashboard): reduce duplicated search test setup Share detail and retry link extraction, merge the experience paging and detail-back cases, and cover the edge query strings in one parametrized case. --- tests/test_dashboard.py | 172 +++++++++++----------------------------- 1 file changed, 46 insertions(+), 126 deletions(-) diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 38d1ce4f8d..499a0d1123 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -301,6 +301,22 @@ def labelled_link(html: str, label: str) -> str: return unescape(match[1]) +def detail_link(html: str, family: str) -> str: + return next( + unescape(value) + for value in re.findall(r'href="([^"]+)"', html) + if urlsplit(unescape(value)).path == f"/dashboard/{family}" + ) + + +def alert_retry(html: str) -> str: + alert = re.search(r'', html, re.S) + assert alert is not None + match = re.search(r'href="([^"]+)"[^>]*>' + re.escape(LABELS["retry"]) + "", alert[0]) + assert match is not None + return unescape(match[1]) + + def test_scope_selection_default_and_unknown_scope(dashboard: TestClient) -> None: parent = create_scope(dashboard, "Project") child = create_scope(dashboard, "Documentation", parent["scope_id"]) @@ -699,56 +715,30 @@ def test_experience_search_filters_results_and_restores_browsing(dashboard: Test assert labels["experience_empty"] not in empty.text -def test_experience_search_keeps_the_query_across_result_pages(dashboard: TestClient) -> None: +def test_experience_search_preserves_query_and_page_through_detail_and_back(dashboard: TestClient) -> None: scope = create_scope(dashboard, "Experience search paging")["scope_id"] source = capture_source(dashboard, scope, "Page through the matching experiences.") - expected = { + for index in range(7): create_experience( dashboard, scope, source, - situation=f"Reviewing handoff {index}", - lesson=f"Keep handoff note {index} documented", + situation=f"Reviewing needle {index}", + lesson=f"Keep needle note {index} documented", ) - for index in range(7) - } - - first = dashboard.get("/dashboard/methods", params={"scope": scope, "kind": "experience", "q": "handoff"}) + first = dashboard.get("/dashboard/methods", params={"scope": scope, "kind": "experience", "q": "needle"}) assert first.status_code == 200 assert len(record_links(first.text, "/dashboard/experience", "artifact")) == 6 following = page_link(first.text, LABELS["next_page"]) - assert parse_qs(urlsplit(following).query)["q"] == ["handoff"] - assert collect_pages(dashboard, first.text, "/dashboard/experience", "artifact") == expected - - -def test_experience_search_preserves_state_through_detail_and_back(dashboard: TestClient) -> None: - scope = create_scope(dashboard, "Experience detail return")["scope_id"] - source = capture_source(dashboard, scope, "Return to the searched experience list.") - for index in range(13): - create_experience( - dashboard, - scope, - source, - situation=f"Reviewing needle {index}", - lesson=f"Keep needle note {index} documented", - ) - second = dashboard.get( - "/dashboard/methods", - params={"scope": scope, "kind": "experience", "q": "needle", "experience_page": "2"}, - ) + assert parse_qs(urlsplit(following).query)["q"] == ["needle"] + second = dashboard.get(following) assert second.status_code == 200 page_records = record_links(second.text, "/dashboard/experience", "artifact") - assert len(page_records) == 6 - detail_url = next( - unescape(value) - for value in re.findall(r'href="([^"]+)"', second.text) - if urlsplit(unescape(value)).path == "/dashboard/experience" - ) + assert len(page_records) == 1 + detail_url = detail_link(second.text, "experience") return_to = parse_qs(urlsplit(detail_url).query)["return_to"][0] assert parse_qs(urlsplit(return_to).query)["q"] == ["needle"] assert parse_qs(urlsplit(return_to).query)["experience_page"] == ["2"] - assert parse_qs(urlsplit(return_to).query)["kind"] == ["experience"] - detail = dashboard.get(detail_url) assert detail.status_code == 200 restored = dashboard.get(labelled_link(detail.text, LABELS["back_methods"])) @@ -759,30 +749,6 @@ def test_experience_search_preserves_state_through_detail_and_back(dashboard: Te assert record_links(restored.text, "/dashboard/experience", "artifact") == page_records -def test_skill_search_preserves_state_through_detail_and_back(dashboard: TestClient) -> None: - scope = create_scope(dashboard, "Skill detail return")["scope_id"] - source = capture_source(dashboard, scope, "Return to the searched skill list.") - create_skill(dashboard, scope, source, "needle-skill") - create_skill(dashboard, scope, source, "other-skill") - searched = dashboard.get("/dashboard/methods", params={"scope": scope, "kind": "skill", "q": "needle"}) - assert searched.status_code == 200 - assert "needle-skill" in searched.text - assert "other-skill" not in searched.text - detail_url = next( - unescape(value) - for value in re.findall(r'href="([^"]+)"', searched.text) - if urlsplit(unescape(value)).path == "/dashboard/skill" - ) - detail = dashboard.get(detail_url) - assert detail.status_code == 200 - restored = dashboard.get(labelled_link(detail.text, LABELS["back_methods"])) - assert restored.status_code == 200 - assert restored.url.params["q"] == "needle" - assert restored.url.params["kind"] == "skill" - assert "needle-skill" in restored.text - assert "other-skill" not in restored.text - - def test_skill_search_preserves_the_result_page_through_detail_and_back(dashboard: TestClient) -> None: scope = create_scope(dashboard, "Skill search paging")["scope_id"] source = capture_source(dashboard, scope, "Page through the matching skills.") @@ -794,11 +760,7 @@ def test_skill_search_preserves_the_result_page_through_detail_and_back(dashboar assert second.status_code == 200 page_records = record_links(second.text, "/dashboard/skill", "artifact") assert len(page_records) == 1 - detail_url = next( - unescape(value) - for value in re.findall(r'href="([^"]+)"', second.text) - if urlsplit(unescape(value)).path == "/dashboard/skill" - ) + detail_url = detail_link(second.text, "skill") detail = dashboard.get(detail_url) assert detail.status_code == 200 restored = dashboard.get(labelled_link(detail.text, LABELS["back_methods"])) @@ -819,11 +781,7 @@ def test_methods_return_uses_the_current_language(dashboard: TestClient, family: create_skill(dashboard, scope, source, "needle-skill") searched = dashboard.get("/dashboard/methods", params={"scope": scope, "kind": family, "q": "needle", "lang": "en"}) assert searched.status_code == 200 - detail_url = next( - unescape(value) - for value in re.findall(r'href="([^"]+)"', searched.text) - if urlsplit(unescape(value)).path == f"/dashboard/{family}" - ) + detail_url = detail_link(searched.text, family) return_to = parse_qs(urlsplit(detail_url).query)["return_to"][0] assert parse_qs(urlsplit(return_to).query)["lang"] == ["en"] detail = dashboard.get(detail_url) @@ -861,10 +819,8 @@ def test_methods_return_to_accepts_supported_text() -> None: "/dashboard/methods?scope=other&kind=experience&q=needle", "/dashboard/handoff?scope=scp_demo&cursor=next", "/dashboard/methods?scope=scp_demo&kind=all&q=needle", - "/dashboard/methods?scope=scp_demo&kind=experience&q=needle&experience_page=0", "/dashboard/methods?scope=scp_demo&kind=experience&q=needle&experience_page=1%5C2", "/dashboard/methods?scope=scp_demo&kind=experience&q=needle%0D%0ASet-Cookie", - "/dashboard/methods?scope=scp_demo&kind=experience&q=needle&extra=1", "/dashboard/methods?scope=scp_demo&kind=experience&q=" + "a" * (33 * 1024), ], ) @@ -872,49 +828,29 @@ def test_methods_return_to_rejects_unsupported_context(raw: str) -> None: assert collection_return(raw, "scp_demo", page="methods") is None -def test_experience_search_return_to_accepts_backslashes(dashboard: TestClient) -> None: - scope = create_scope(dashboard, "Experience backslash search")["scope_id"] - source = capture_source(dashboard, scope, "Keep the needle path searchable.") +@pytest.mark.parametrize( + ("query", "text"), + [ + ("needle\\path", "Keep the needle path"), + ("needle & path = 100% #1", "Keep special characters"), + ("经验 " * 460, "经验 long query"), + ], +) +def test_experience_search_accepts_edge_query_text(dashboard: TestClient, query: str, text: str) -> None: + scope = create_scope(dashboard, "Experience edge queries")["scope_id"] + source = capture_source(dashboard, scope, "Keep edge queries searchable.") create_experience(dashboard, scope, source, situation="needle path", lesson="Keep the needle path") - create_experience(dashboard, scope, source, situation="unrelated lesson", lesson="Keep the unrelated lesson") - searched = dashboard.get("/dashboard/methods", params={"scope": scope, "kind": "experience", "q": "needle\\path"}) - assert searched.status_code == 200 - assert "Keep the needle path" in searched.text - assert "Keep the unrelated lesson" not in searched.text - detail_url = next( - unescape(value) - for value in re.findall(r'href="([^"]+)"', searched.text) - if urlsplit(unescape(value)).path == "/dashboard/experience" - ) - detail = dashboard.get(detail_url) - assert detail.status_code == 200 - restored = dashboard.get(labelled_link(detail.text, LABELS["back_methods"])) - assert restored.status_code == 200 - assert restored.url.params["q"] == "needle\\path" - assert "Keep the needle path" in restored.text - assert "Keep the unrelated lesson" not in restored.text - - -def test_experience_search_return_to_accepts_a_long_query(dashboard: TestClient) -> None: - query = "经验 " * 460 - scope = create_scope(dashboard, "Experience long query")["scope_id"] - source = capture_source(dashboard, scope, "Keep the long query searchable.") - create_experience(dashboard, scope, source, situation="经验 检索", lesson=query) + create_experience(dashboard, scope, source, situation="needle & path = 100% #1", lesson="Keep special characters") + create_experience(dashboard, scope, source, situation="经验 long query", lesson="经验 " * 460) searched = dashboard.get("/dashboard/methods", params={"scope": scope, "kind": "experience", "q": query}) assert searched.status_code == 200 - assert "经验 检索" in searched.text - detail_url = next( - unescape(value) - for value in re.findall(r'href="([^"]+)"', searched.text) - if urlsplit(unescape(value)).path == "/dashboard/experience" - ) - assert "return_to" in parse_qs(urlsplit(detail_url).query) + assert text in searched.text + detail_url = detail_link(searched.text, "experience") detail = dashboard.get(detail_url) assert detail.status_code == 200 restored = dashboard.get(labelled_link(detail.text, LABELS["back_methods"])) assert restored.status_code == 200 assert restored.url.params["q"] == query - assert "经验 检索" in restored.text def test_experience_detail_rejects_a_foreign_return_to(dashboard: TestClient) -> None: @@ -959,11 +895,7 @@ def test_experience_search_state_survives_compare_and_rollback(dashboard: TestCl searched = dashboard.get("/dashboard/methods", params={"scope": scope, "kind": "experience", "q": "needle"}) assert searched.status_code == 200 - detail_url = next( - unescape(value) - for value in re.findall(r'href="([^"]+)"', searched.text) - if urlsplit(unescape(value)).path == "/dashboard/experience" - ) + detail_url = detail_link(searched.text, "experience") return_to = parse_qs(urlsplit(detail_url).query)["return_to"][0] revision = dashboard.get( "/dashboard/experience", @@ -1027,11 +959,7 @@ async def failing(api, path, payload=None): params={"scope": scope, "kind": "experience", "q": "needle", "experience_page": "2"}, ) assert response.status_code == 200 - alert = re.search(r'', response.text, re.S) - assert alert is not None - retry_match = re.search(r'href="([^"]+)"[^>]*>' + re.escape(LABELS["retry"]) + "", alert[0]) - assert retry_match is not None - retry = unescape(retry_match[1]) + retry = alert_retry(response.text) params = parse_qs(urlsplit(retry).query) assert params["q"] == ["needle"] assert params["experience_page"] == ["2"] @@ -1050,11 +978,7 @@ def test_out_of_range_result_page_retry_returns_to_the_first_page(dashboard: Tes params={"scope": scope, "kind": "experience", "q": "needle", "experience_page": "99"}, ) assert response.status_code == 200 - alert = re.search(r'', response.text, re.S) - assert alert is not None - retry_match = re.search(r'href="([^"]+)"[^>]*>' + re.escape(LABELS["retry"]) + "", alert[0]) - assert retry_match is not None - retry = unescape(retry_match[1]) + retry = alert_retry(response.text) params = parse_qs(urlsplit(retry).query) assert params["q"] == ["needle"] assert "experience_page" not in params @@ -1091,11 +1015,7 @@ def test_experience_detail_error_returns_to_the_search_page(dashboard: TestClien create_experience(dashboard, scope, source, situation="needle error", lesson="Keep the needle error") searched = dashboard.get("/dashboard/methods", params={"scope": scope, "kind": "experience", "q": "needle"}) assert searched.status_code == 200 - detail_url = next( - unescape(value) - for value in re.findall(r'href="([^"]+)"', searched.text) - if urlsplit(unescape(value)).path == "/dashboard/experience" - ) + detail_url = detail_link(searched.text, "experience") read = DashboardAPI.read async def failing(api, path, payload=None): From 9ac1386222468235ced45ba446f153e14b4514ad Mon Sep 17 00:00:00 2001 From: yydawx <195621032+yydawx@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:22:14 +0800 Subject: [PATCH 7/7] test(dashboard): keep long return-context input out of test ids Build the oversized query inside the test and use short ids for edge query cases so PYTEST_CURRENT_TEST stays within Windows' environment limit. --- tests/test_dashboard.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 499a0d1123..a92de66bf5 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -821,13 +821,17 @@ def test_methods_return_to_accepts_supported_text() -> None: "/dashboard/methods?scope=scp_demo&kind=all&q=needle", "/dashboard/methods?scope=scp_demo&kind=experience&q=needle&experience_page=1%5C2", "/dashboard/methods?scope=scp_demo&kind=experience&q=needle%0D%0ASet-Cookie", - "/dashboard/methods?scope=scp_demo&kind=experience&q=" + "a" * (33 * 1024), ], ) def test_methods_return_to_rejects_unsupported_context(raw: str) -> None: assert collection_return(raw, "scp_demo", page="methods") is None +def test_methods_return_to_rejects_oversized_context() -> None: + raw = "/dashboard/methods?scope=scp_demo&kind=experience&q=" + "a" * (33 * 1024) + assert collection_return(raw, "scp_demo", page="methods") is None + + @pytest.mark.parametrize( ("query", "text"), [ @@ -835,6 +839,7 @@ def test_methods_return_to_rejects_unsupported_context(raw: str) -> None: ("needle & path = 100% #1", "Keep special characters"), ("经验 " * 460, "经验 long query"), ], + ids=["backslash", "special", "long"], ) def test_experience_search_accepts_edge_query_text(dashboard: TestClient, query: str, text: str) -> None: scope = create_scope(dashboard, "Experience edge queries")["scope_id"]