diff --git a/docs/spec/web-ui.md b/docs/spec/web-ui.md index 8f9b9b4..8de5bfc 100644 --- a/docs/spec/web-ui.md +++ b/docs/spec/web-ui.md @@ -35,6 +35,7 @@ | GET /{tenant-uuid}/cftree/doc/{doc-uuid}/item/{item-uuid} | Same tree view with an item selected via the **URL path** — the canonical, shareable, reload-safe form that in-tree navigation pushes (`hx-push-url`). Opening/reloading/sharing reconstructs the tree (the ancestor path expanded to the item) + the item's full detail via SSR. | | GET /{tenant-uuid}/cftree/doc/{doc-uuid}/detail/{item-uuid} | HTML fragment of an item's detail (for the HTMX right pane). | | GET /{tenant-uuid}/cftree/doc/{doc-uuid}/document | HTML fragment of the document's own detail (right pane). Separate from `/detail/{item}` to avoid identifier collisions. | +| GET /{tenant-uuid}/subject/{subject-uuid}/items | HTML fragment: the next page of "items setting this subject" for the CFSubject detail page ("Show more" pagination). Same `subject_items.html` markup as the inline SSR. 404 unless the id is a CFSubject in this tenant. | | GET /{tenant-uuid}/uri/{uuid} | Resource detail page (HTML by default; **303 See Other** to the matching CASE API endpoint when the `Accept` header signals a JSON client — see [api-spec.md](api-spec.md#tenanturiuuid-content-negotiation)). | **Manual list ordering (`display_order`)**: both lists honor an optional `display_order` integer (on `tenants` / `cf_documents`) so an operator can pin/arrange entries without renaming. Smaller = higher; `NULL` (the default) sinks below all explicitly-ordered entries and then sorts alphabetically. It's a compeito-local display field — not a CASE field, never emitted in CASE export, and preserved on re-import. Set it via `cli.py tenant update --display-order N` / `--clear-order` and `cli.py doc update --display-order N` / `--clear-order` (see [cli.md](cli.md)). @@ -216,6 +217,8 @@ When `/uri/{uuid}` resolves to a lookup, show common + specific fields: | Specific fields | typeCode, hierarchyCode, licenseText, etc. (only when present) | | lastChangeDateTime | ISO 8601 | +**CFSubject — "Items setting this subject" (reverse lookup):** a CFSubject's card adds a section listing CFItems **in the same tenant** whose `subjectURI` references this subject (the reverse of CFItem → CFSubject). It shows the total count + the first page (top 20) inline (SSR), then a **"Show more"** button that appends the next page via HTMX from `GET /{tenant}/subject/{subject-id}/items?offset=&limit=` (`Cache-Control: public, max-age=86400`; the button is self-perpetuating — each click swaps it for the next page + a fresh button, like the lazy tree). Resolved server-side by `cf_item_repository.list_items_by_subject` / `count_items_by_subject` via the JSONB containment query `subject_uri @> '[{"identifier": ...}]'` (GIN-indexed: `ix_cf_items_subject_uri_gin`). Each row links to the item in its own document's tree. Scope notes: **within-tenant only** (a subject is tenant-owned; no cross-tenant/private gating); items that reference the subject only via the plain `subject` string (no `subjectURI`) are **not** matched; the reusable `fragments/subject_items.html` partial backs both the inline SSR and the fragment route (and is intended for reuse by a future search results list). The section is **not** shown in the tree right pane (the pane is document-scoped). Other lookups (CFItemType/Concept/License) do not yet have an equivalent reverse list. + ### CFRubric When `/uri/{uuid}` resolves to a CFRubric, show the rubric detail along with Criteria/Levels in a table: diff --git a/src/locales/en.json b/src/locales/en.json index 20675f0..7f8ecbc 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -65,6 +65,9 @@ "related_other_institution": "Other institution ↗", "related_other_institution_in": "Other institution ↩", "incoming_refs_label": "Referenced by (other institutions)", + "subject_items_label": "Items setting this subject", + "subject_items_count": "{count} total", + "show_more": "Show more", "related_external": "External", "f_subject": "Subject", "f_subject_uri": "Subject URI", diff --git a/src/locales/ja.json b/src/locales/ja.json index 04dcd8f..fd487a2 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -65,6 +65,9 @@ "related_other_institution": "他機関 ↗", "related_other_institution_in": "他機関 ↩", "incoming_refs_label": "参照元(他機関)", + "subject_items_label": "この分野を設定している項目", + "subject_items_count": "全 {count} 件", + "show_more": "もっと見る", "related_external": "外部", "f_subject": "教科", "f_subject_uri": "教科 URI", diff --git a/src/repositories/cf_item_repository.py b/src/repositories/cf_item_repository.py index ea71cb6..5a081ac 100644 --- a/src/repositories/cf_item_repository.py +++ b/src/repositories/cf_item_repository.py @@ -1,6 +1,7 @@ import uuid -from sqlalchemy import select +from sqlalchemy import cast, func, select +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import joinedload @@ -8,6 +9,15 @@ from src.models.cf_item import CFItem +def _subject_uri_contains(subject_identifier: str): + """JSONB containment predicate: subject_uri @> '[{"identifier": }]'. + + The operand is cast to JSONB explicitly so the GIN index + (ix_cf_items_subject_uri_gin, jsonb_path_ops) is used rather than a seq scan. + """ + return CFItem.subject_uri.contains(cast([{"identifier": subject_identifier}], JSONB)) + + async def map_identifiers_to_documents( session: AsyncSession, tenant_id: uuid.UUID, @@ -95,3 +105,65 @@ async def get_cf_item_by_identifier( .where(CFItem.tenant_id == tenant_id, CFItem.identifier == identifier) ) return result.scalar_one_or_none() + + +async def list_items_by_subject( + session: AsyncSession, + tenant_id: uuid.UUID, + subject_identifier: str, + *, + offset: int = 0, + limit: int = 20, +) -> list[dict]: + """CFItems in this tenant whose ``subject_uri`` references the given subject. + + Reverse lookup for the CFSubject detail page ("items setting this subject"). + Matches via JSONB containment ``subject_uri @> '[{"identifier": }]'`` + (GIN-indexed). Tenant-scoped. Ordered by human_coding_scheme → full_statement + → identifier for a stable offset-pagination boundary. Returns + ``[{identifier, human_coding_scheme, full_statement, doc_identifier, doc_title}]``. + """ + rows = await session.execute( + select( + CFItem.identifier, + CFItem.human_coding_scheme, + CFItem.full_statement, + CFDocument.identifier, + CFDocument.title, + ) + .join(CFDocument, CFItem.cf_document_id == CFDocument.id) + .where(CFItem.tenant_id == tenant_id, _subject_uri_contains(subject_identifier)) + .order_by( + CFItem.human_coding_scheme.nullslast(), + CFItem.full_statement, + CFItem.identifier, + ) + .offset(offset) + .limit(limit) + ) + return [ + { + "identifier": str(item_ident), + "human_coding_scheme": hcs, + "full_statement": full_statement, + "doc_identifier": str(doc_ident), + "doc_title": doc_title, + } + for item_ident, hcs, full_statement, doc_ident, doc_title in rows.all() + ] + + +async def count_items_by_subject( + session: AsyncSession, + tenant_id: uuid.UUID, + subject_identifier: str, +) -> int: + """Total CFItems in this tenant referencing the given subject (for the count + label). Call once on the SSR page; the "load more" fragment derives has_more + from a limit+1 fetch instead of recounting.""" + result = await session.execute( + select(func.count()) + .select_from(CFItem) + .where(CFItem.tenant_id == tenant_id, _subject_uri_contains(subject_identifier)) + ) + return int(result.scalar_one()) diff --git a/src/routers/web.py b/src/routers/web.py index 5bd6903..6948efd 100644 --- a/src/routers/web.py +++ b/src/routers/web.py @@ -63,6 +63,10 @@ def _uri_other_tenant(uri: str | None, current_tenant_id) -> bool: CACHE_CONTROL = "public, max-age=3600" CACHE_CONTROL_FRAGMENT = "public, max-age=86400" +# Page size for the CFSubject "items setting this subject" reverse-lookup list +# (inline top-N on the subject page + "load more" HTMX pagination). +SUBJECT_ITEMS_PAGE = 20 + # Map UriResult.resource_type to the CASE v1.1 API path segment. # Resource types without an individual API endpoint (CFRubricCriterion, # CFRubricCriterionLevel — nested only inside CFRubrics) are absent. @@ -405,7 +409,14 @@ async def _incoming_refs(session: AsyncSession, tenant_id, item) -> list[dict]: async def _detail_extras( - session: AsyncSession, tenant_id, resource_type: str, resource, doc=None, tree_index=None + session: AsyncSession, + tenant_id, + resource_type: str, + resource, + doc=None, + tree_index=None, + *, + include_subject_items: bool = True, ) -> dict: """Extra context the resource-detail card needs beyond the resource itself: rubrics (CFDocument), referring criteria + related groupings (CFItem). @@ -446,6 +457,15 @@ async def _detail_extras( # this item's node_uri ("referenced by other institutions"). Private origins # are excluded by `_incoming_refs` (case A — existence + count hidden). incoming_refs: list[dict] = [] + # CFSubject reverse lookup: items in THIS tenant that set this subject (first + # page + total + has_more). Within-tenant only (a subject is tenant-owned). + subject_items: dict = { + "rows": [], + "total": 0, + "has_more": False, + "next_offset": 0, + "limit": SUBJECT_ITEMS_PAGE, + } if resource_type == "CFDocument": rubrics = await cf_rubric_repository.list_by_document(session, resource.id) elif resource_type == "CFAssociation": @@ -547,6 +567,23 @@ async def _detail_extras( related_other_tenant = await _resolve_cross_tenant(session, tenant_id, unresolved) hierarchy_upper, hierarchy_lower = await _cross_doc_hierarchy(session, tenant_id, resource, doc) incoming_refs = await _incoming_refs(session, tenant_id, resource) + elif resource_type == "CFSubject" and include_subject_items: + # Only the standalone /uri/ page lists "items setting this subject". The + # tree right pane is document-scoped, so pane callers pass + # include_subject_items=False — keeping it hidden there AND skipping the + # (potentially large) reverse-lookup query. + sid = str(resource.identifier) + rows = await cf_item_repository.list_items_by_subject( + session, tenant_id, sid, offset=0, limit=SUBJECT_ITEMS_PAGE + ) + total = await cf_item_repository.count_items_by_subject(session, tenant_id, sid) + subject_items = { + "rows": rows, + "total": total, + "has_more": total > len(rows), + "next_offset": len(rows), + "limit": SUBJECT_ITEMS_PAGE, + } return { "rubrics": rubrics, "referring_criteria": referring_criteria, @@ -560,6 +597,7 @@ async def _detail_extras( "hierarchy_upper": hierarchy_upper, "hierarchy_lower": hierarchy_lower, "incoming_refs": incoming_refs, + "subject_items": subject_items, } @@ -577,6 +615,7 @@ async def _detail_extras( "hierarchy_upper", "hierarchy_lower", "incoming_refs", + "subject_items", ) @@ -801,6 +840,9 @@ async def _render_tree_page( "hierarchy_upper": hierarchy_upper, "hierarchy_lower": hierarchy_lower, "incoming_refs": incoming_refs, + # The tree pane is document-scoped; a subject's cross-document "items + # setting this subject" list is shown only on the standalone /uri/ page. + "subject_items": {"rows": [], "total": 0, "has_more": False, "next_offset": 0, "limit": SUBJECT_ITEMS_PAGE}, } ctx = _detail_pane_context( pane_extras, @@ -965,7 +1007,9 @@ async def _pane_fragment_response( """Render the shared full-detail card as a right-pane HTMX fragment.""" lang = _get_lang(request) t = get_translator(lang) - extras = await _detail_extras(session, tenant_obj.id, resource_type, resource, doc) + # in_pane: the tree pane is document-scoped, so suppress the CFSubject + # "items setting this subject" reverse list here (don't compute or render it). + extras = await _detail_extras(session, tenant_obj.id, resource_type, resource, doc, include_subject_items=False) # in_pane=True → the redundant "Show in tree" link is hidden by the partial. ctx = _detail_pane_context( extras, @@ -1084,6 +1128,58 @@ async def children_fragment( return response +@router.get( + "/{tenant}/subject/{subject_id}/items", + response_class=HTMLResponse, +) +async def subject_items_fragment( + tenant: str, + subject_id: str, + request: Request, + offset: int = 0, + limit: int = SUBJECT_ITEMS_PAGE, + session: AsyncSession = Depends(get_session), +) -> HTMLResponse: + """HTMX fragment: the next page of "items setting this subject", appended on + the CFSubject detail page ("load more"). Mirrors the tree's lazy-load shape — + returns the same `subject_items.html` markup (rows + a self-perpetuating + button), so each click swaps the button for the next page + a fresh button. + Within-tenant only. has_more is derived from a limit+1 fetch (no recount).""" + t = get_translator(_get_lang(request)) + tenant_obj = await tenant_service.resolve_tenant(session, tenant) + if tenant_obj is None: + return _error_fragment(404, t("error_tenant_not_found")) + + subject_uuid = _parse_uuid(subject_id) + if subject_uuid is None: + return _error_fragment(400, t("error_bad_request")) + # The id must resolve to a CFSubject in THIS tenant (same "validate the + # target" invariant as children_fragment) — never drive this for arbitrary ids. + result = await uri_service.find_resource_by_identifier(session, tenant_obj.id, subject_uuid) + if result is None or result.resource_type != "CFSubject": + return _error_fragment(404, t("error_not_found")) + + offset = max(0, offset) + limit = max(1, min(limit, SUBJECT_ITEMS_PAGE)) + rows = await cf_item_repository.list_items_by_subject( + session, tenant_obj.id, str(subject_uuid), offset=offset, limit=limit + 1 + ) + has_more = len(rows) > limit + rows = rows[:limit] + ctx = { + "rows": rows, + "next_offset": offset + len(rows), + "limit": limit, + "has_more": has_more, + "subject_id": str(subject_uuid), + "tenant_url": _tenant_url_segment(tenant, tenant_obj), + "t": t, + } + response = templates.TemplateResponse(request, "fragments/subject_items.html", ctx) + response.headers["Cache-Control"] = CACHE_CONTROL_FRAGMENT + return response + + @router.get( "/{tenant}/cftree/doc/{doc_id}/document", response_class=HTMLResponse, diff --git a/src/templates/fragments/resource_detail.html b/src/templates/fragments/resource_detail.html index 35c4f51..178558a 100644 --- a/src/templates/fragments/resource_detail.html +++ b/src/templates/fragments/resource_detail.html @@ -952,6 +952,22 @@

{{ t("rubric_levels") }} + {# CFSubject reverse lookup: items in this tenant that set this subject. #} + {% if resource_type == "CFSubject" and subject_items.rows %} + {% call section(t("subject_items_label")) %} +
+
+

{{ t("subject_items_count", count=subject_items.total|string) }}

+
    + {% with rows=subject_items.rows, next_offset=subject_items.next_offset, limit=subject_items.limit, has_more=subject_items.has_more, subject_id=resource.identifier %} + {% include "fragments/subject_items.html" %} + {% endwith %} +
+
+
+ {% endcall %} + {% endif %} + {% call section(t("sec_technical"), muted=true) %} {{ code_field(t("f_identifier"), resource.identifier) }} {{ uri_field(t("f_uri"), resource.uri) }} diff --git a/src/templates/fragments/subject_items.html b/src/templates/fragments/subject_items.html new file mode 100644 index 0000000..a9fce35 --- /dev/null +++ b/src/templates/fragments/subject_items.html @@ -0,0 +1,23 @@ +{# Reusable list fragment: CFItems setting a subject. + Used both as an SSR include on the CFSubject detail card and as the response + of the /{tenant}/subject/{id}/items "load more" route (same markup, like + tree_nodes.html). Context: rows, next_offset, limit, has_more, subject_id, + tenant_url, t. Each row links to the item in its own document's tree (a + subject has no document, so always the cross-document deep-link form). #} +{% for row in rows %} +
  • + + {% if row.human_coding_scheme %}{{ row.human_coding_scheme }} {% endif %}{{ row.full_statement[:100] }}{% if row.full_statement and row.full_statement|length > 100 %}…{% endif %} + +
  • +{% endfor %} +{% if has_more %} +
  • + +
  • +{% endif %} diff --git a/tests/unit/test_subject_items.py b/tests/unit/test_subject_items.py new file mode 100644 index 0000000..2dc52c7 --- /dev/null +++ b/tests/unit/test_subject_items.py @@ -0,0 +1,303 @@ +"""Tests for the CFSubject reverse lookup: "items setting this subject". + +Repository-level (JSONB containment, tenant scope, pagination) + Web UI +(subject detail section, "load more" fragment, validation, caching, empty state). +""" + +import uuid +from datetime import datetime, timezone + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.cf_document import CFDocument +from src.models.cf_item import CFItem +from src.models.cf_subject import CFSubject +from src.models.tenant import Tenant +from src.repositories import cf_item_repository +from src.routers.web import _detail_extras + +_TS = datetime(2025, 1, 1, tzinfo=timezone.utc) + + +def _make_subject(tenant: Tenant, *, identifier: uuid.UUID | None = None, title: str = "Statistics") -> CFSubject: + ident = identifier or uuid.uuid4() + return CFSubject( + tenant_id=tenant.id, + identifier=ident, + uri=f"https://example.com/uri/{ident}", + title=title, + last_change_date_time=_TS, + ) + + +def _make_item_with_subject( + tenant: Tenant, + doc: CFDocument, + subject_identifier: str | None, + *, + hcs: str | None = None, + full_statement: str = "stmt", + identifier: uuid.UUID | None = None, +) -> CFItem: + ident = identifier or uuid.uuid4() + subject_uri = None + if subject_identifier is not None: + subject_uri = [ + {"title": "S", "identifier": subject_identifier, "uri": f"https://example.com/uri/{subject_identifier}"} + ] + return CFItem( + tenant_id=tenant.id, + cf_document_id=doc.id, + identifier=ident, + uri=f"https://example.com/uri/{ident}", + full_statement=full_statement, + human_coding_scheme=hcs, + subject_uri=subject_uri, + depth=0, + last_change_date_time=_TS, + ) + + +# --------------------------------------------------------------------------- +# Repository tests +# --------------------------------------------------------------------------- + + +class TestListItemsBySubject: + async def test_containment_match_and_exclusion( + self, db_session: AsyncSession, tenant: Tenant, sample_document: CFDocument + ): + subj = _make_subject(tenant) + sid = str(subj.identifier) + other = str(uuid.uuid4()) + db_session.add(subj) + db_session.add(_make_item_with_subject(tenant, sample_document, sid, full_statement="match A")) + db_session.add(_make_item_with_subject(tenant, sample_document, other, full_statement="other subj")) + db_session.add(_make_item_with_subject(tenant, sample_document, None, full_statement="no subject")) + await db_session.flush() + + rows = await cf_item_repository.list_items_by_subject(db_session, tenant.id, sid) + statements = {r["full_statement"] for r in rows} + assert statements == {"match A"} + assert rows[0]["doc_identifier"] == str(sample_document.identifier) + + async def test_tenant_scoped(self, db_session: AsyncSession, tenant: Tenant, sample_document: CFDocument): + # The SAME subject identifier exists in two tenants → only the queried + # tenant's items come back (shared frameworks reuse identifiers). + shared_id = uuid.uuid4() + db_session.add(_make_subject(tenant, identifier=shared_id)) + db_session.add(_make_item_with_subject(tenant, sample_document, str(shared_id), full_statement="mine")) + + other_tenant = Tenant(id=uuid.uuid4(), name="Other", is_private=False) + db_session.add(other_tenant) + await db_session.flush() + other_doc = CFDocument( + id=uuid.uuid4(), + tenant_id=other_tenant.id, + identifier=uuid.uuid4(), + uri="https://example.com/uri/otherdoc", + title="Other Doc", + creator="x", + language="ja", + last_change_date_time=_TS, + ) + db_session.add(other_doc) + db_session.add(_make_subject(other_tenant, identifier=shared_id)) + db_session.add(_make_item_with_subject(other_tenant, other_doc, str(shared_id), full_statement="theirs")) + await db_session.flush() + + rows = await cf_item_repository.list_items_by_subject(db_session, tenant.id, str(shared_id)) + assert {r["full_statement"] for r in rows} == {"mine"} + + async def test_pagination_boundary(self, db_session: AsyncSession, tenant: Tenant, sample_document: CFDocument): + subj = _make_subject(tenant) + sid = str(subj.identifier) + db_session.add(subj) + for i in range(25): + db_session.add( + _make_item_with_subject(tenant, sample_document, sid, hcs=f"{i:03d}", full_statement=f"item {i:03d}") + ) + await db_session.flush() + + page1 = await cf_item_repository.list_items_by_subject(db_session, tenant.id, sid, offset=0, limit=20) + page2 = await cf_item_repository.list_items_by_subject(db_session, tenant.id, sid, offset=20, limit=20) + assert len(page1) == 20 + assert len(page2) == 5 + # No overlap / no skip across the boundary (stable order by hcs). + ids1 = [r["identifier"] for r in page1] + ids2 = [r["identifier"] for r in page2] + assert set(ids1).isdisjoint(ids2) + assert len(set(ids1) | set(ids2)) == 25 + + async def test_count(self, db_session: AsyncSession, tenant: Tenant, sample_document: CFDocument): + subj = _make_subject(tenant) + sid = str(subj.identifier) + db_session.add(subj) + for i in range(3): + db_session.add(_make_item_with_subject(tenant, sample_document, sid, full_statement=f"x{i}")) + await db_session.flush() + assert await cf_item_repository.count_items_by_subject(db_session, tenant.id, sid) == 3 + + async def test_empty(self, db_session: AsyncSession, tenant: Tenant, sample_document: CFDocument): + subj = _make_subject(tenant) + db_session.add(subj) + await db_session.flush() + sid = str(subj.identifier) + assert await cf_item_repository.list_items_by_subject(db_session, tenant.id, sid) == [] + assert await cf_item_repository.count_items_by_subject(db_session, tenant.id, sid) == 0 + + +# --------------------------------------------------------------------------- +# Web UI tests +# --------------------------------------------------------------------------- + + +class TestSubjectDetailPage: + async def test_section_shows_topn_and_count( + self, db_session: AsyncSession, db_client, tenant: Tenant, sample_document: CFDocument + ): + subj = _make_subject(tenant) + sid = str(subj.identifier) + db_session.add(subj) + for i in range(25): + db_session.add( + _make_item_with_subject(tenant, sample_document, sid, hcs=f"{i:03d}", full_statement=f"stmt {i:03d}") + ) + await db_session.flush() + + resp = await db_client.get(f"/{tenant.id}/uri/{subj.identifier}") + assert resp.status_code == 200 + # Top-20 SSR'd; the 21st (021) is not on the initial page. + assert "stmt 000" in resp.text + assert "stmt 019" in resp.text + assert "stmt 020" not in resp.text + # Count + "load more" button + fragment URL. + assert "25" in resp.text + assert f"/{tenant.id}/subject/{subj.identifier}/items?offset=20" in resp.text + assert resp.headers["Cache-Control"] == "public, max-age=3600" + + async def test_no_button_when_within_one_page( + self, db_session: AsyncSession, db_client, tenant: Tenant, sample_document: CFDocument + ): + subj = _make_subject(tenant) + sid = str(subj.identifier) + db_session.add(subj) + db_session.add(_make_item_with_subject(tenant, sample_document, sid, full_statement="only one")) + await db_session.flush() + + resp = await db_client.get(f"/{tenant.id}/uri/{subj.identifier}") + assert resp.status_code == 200 + assert "only one" in resp.text + assert f"/subject/{subj.identifier}/items" not in resp.text + + async def test_empty_section_hidden( + self, db_session: AsyncSession, db_client, tenant: Tenant, sample_document: CFDocument + ): + subj = _make_subject(tenant, title="Lonely Subject") + db_session.add(subj) + await db_session.flush() + resp = await db_client.get(f"/{tenant.id}/uri/{subj.identifier}") + assert resp.status_code == 200 + assert "Lonely Subject" in resp.text # the subject itself renders + # No reverse-lookup section / fragment link when there are no items. + assert f"/subject/{subj.identifier}/items" not in resp.text + + +class TestSubjectItemsFragment: + async def test_next_page_and_self_perpetuating_button( + self, db_session: AsyncSession, db_client, tenant: Tenant, sample_document: CFDocument + ): + subj = _make_subject(tenant) + sid = str(subj.identifier) + db_session.add(subj) + for i in range(45): + db_session.add( + _make_item_with_subject(tenant, sample_document, sid, hcs=f"{i:03d}", full_statement=f"row {i:03d}") + ) + await db_session.flush() + + resp = await db_client.get(f"/{tenant.id}/subject/{subj.identifier}/items?offset=20&limit=20") + assert resp.status_code == 200 + assert "row 020" in resp.text + assert "row 039" in resp.text + # Still more (45 total) → button advances to offset 40. + assert f"/subject/{subj.identifier}/items?offset=40" in resp.text + assert resp.headers["Cache-Control"] == "public, max-age=86400" + + async def test_last_page_has_no_button( + self, db_session: AsyncSession, db_client, tenant: Tenant, sample_document: CFDocument + ): + subj = _make_subject(tenant) + sid = str(subj.identifier) + db_session.add(subj) + for i in range(25): + db_session.add( + _make_item_with_subject(tenant, sample_document, sid, hcs=f"{i:03d}", full_statement=f"row {i:03d}") + ) + await db_session.flush() + + resp = await db_client.get(f"/{tenant.id}/subject/{subj.identifier}/items?offset=20&limit=20") + assert resp.status_code == 200 + assert "row 024" in resp.text + assert "offset=40" not in resp.text # exhausted → no further button + + async def test_non_subject_id_404( + self, db_session: AsyncSession, db_client, tenant: Tenant, sample_document: CFDocument + ): + # An item id is not a CFSubject → 404 (don't drive the fragment for arbitrary ids). + item = _make_item_with_subject(tenant, sample_document, None, full_statement="an item") + db_session.add(item) + await db_session.flush() + resp = await db_client.get(f"/{tenant.id}/subject/{item.identifier}/items") + assert resp.status_code == 404 + + async def test_bad_uuid_400(self, db_client, tenant: Tenant): + resp = await db_client.get(f"/{tenant.id}/subject/not-a-uuid/items") + assert resp.status_code == 400 + + async def test_unknown_subject_404(self, db_client, tenant: Tenant): + resp = await db_client.get(f"/{tenant.id}/subject/{uuid.uuid4()}/items") + assert resp.status_code == 404 + + +class TestPaneSuppression: + """The reverse list is for the standalone /uri/ page only — the tree right + pane is document-scoped and must NOT show it (and must NOT run the query).""" + + async def test_detail_extras_suppressed_when_in_pane( + self, db_session: AsyncSession, tenant: Tenant, sample_document: CFDocument + ): + subj = _make_subject(tenant) + sid = str(subj.identifier) + db_session.add(subj) + db_session.add(_make_item_with_subject(tenant, sample_document, sid, full_statement="ref item")) + await db_session.flush() + + # Pane path: include_subject_items=False → empty even though items exist. + suppressed = await _detail_extras(db_session, tenant.id, "CFSubject", subj, include_subject_items=False) + assert suppressed["subject_items"]["rows"] == [] + assert suppressed["subject_items"]["total"] == 0 + + # Standalone page path: default True → populated. + shown = await _detail_extras(db_session, tenant.id, "CFSubject", subj) + assert shown["subject_items"]["total"] == 1 + assert shown["subject_items"]["rows"][0]["full_statement"] == "ref item" + + async def test_pane_fragment_hides_subject_items( + self, db_session: AsyncSession, db_client, tenant: Tenant, sample_document: CFDocument + ): + # A subject referenced by an item in this doc becomes a tree-node + # (Definitions), so its detail fragment is reachable in the pane. The + # reverse-lookup section must NOT appear there. + subj = _make_subject(tenant, title="Pane Subject") + sid = str(subj.identifier) + db_session.add(subj) + db_session.add(_make_item_with_subject(tenant, sample_document, sid, full_statement="ref in pane")) + await db_session.flush() + + resp = await db_client.get(f"/{tenant.id}/cftree/doc/{sample_document.identifier}/detail/{subj.identifier}") + assert resp.status_code == 200 + assert "Pane Subject" in resp.text # the subject card itself renders + # ...but not its reverse list (no "show more" fragment link, no ref item). + assert f"/subject/{subj.identifier}/items" not in resp.text + assert "ref in pane" not in resp.text