feat(themen): count documents across the whole sub-topic tree (#698) #701
@@ -7,6 +7,13 @@ Hierarchical document categories. Tags form a tree via a self-referencing `paren
|
||||
Entity: `Tag` (self-referencing `parent_id` tree).
|
||||
Features: tag CRUD, hierarchical deletion (cascade to descendants), tag typeahead, admin tag management (rename, reparent, merge).
|
||||
|
||||
## Tag tree counts (`getTagTree`)
|
||||
|
||||
`GET /api/tags/tree` returns each node with **two** document counts, from two aggregate queries (no N+1):
|
||||
|
||||
- `documentCount` — documents tagged with that **exact** tag (direct). Read by the admin surfaces (sidebar tree, merge preview, delete-impact guard), which describe direct-document operations.
|
||||
- `subtreeDocumentCount` — **distinct** documents tagged with that tag **or any descendant** (subtree rollup, recursive-CTE closure, depth guard ≤50). Read by the reader surfaces (`/themen` page, dashboard `ThemenWidget`) so the box number matches what `/documents?tag=X` actually finds.
|
||||
|
||||
## What this domain does NOT own
|
||||
|
||||
- Documents — the `document_tags` join table is on the document side. `Tag` does not hold document references.
|
||||
|
||||
@@ -126,4 +126,31 @@ public interface TagRepository extends JpaRepository<Tag, UUID> {
|
||||
*/
|
||||
@Query(value = "SELECT tag_id AS tagId, COUNT(*) AS count FROM document_tags GROUP BY tag_id", nativeQuery = true)
|
||||
List<TagCount> findDocumentCountsPerTag();
|
||||
|
||||
/**
|
||||
* Returns (tagId, count) pairs where count is the number of <b>distinct</b> documents tagged
|
||||
* with that tag <b>or any of its descendants</b> (full subtree rollup).
|
||||
* <p>
|
||||
* Builds a tag closure of (ancestor_id, descendant_id) pairs via a recursive CTE — each tag is
|
||||
* its own ancestor at depth 0, then descends into children (depth guard of 50 levels prevents a
|
||||
* cycle or pathological depth from running away) — joins it to {@code document_tags} on the
|
||||
* descendant, and counts distinct documents per ancestor. A document tagged with several tags in
|
||||
* the same subtree is therefore counted once. Tags whose entire subtree holds no documents do
|
||||
* not appear in the result (they default to 0 in the tree). One aggregate query for all tags.
|
||||
*/
|
||||
@Query(value = """
|
||||
WITH RECURSIVE closure AS (
|
||||
SELECT id AS ancestor_id, id AS descendant_id, 0 AS depth FROM tag
|
||||
UNION ALL
|
||||
SELECT c.ancestor_id, t.id AS descendant_id, c.depth + 1
|
||||
FROM tag t
|
||||
JOIN closure c ON t.parent_id = c.descendant_id
|
||||
WHERE c.depth < 50
|
||||
)
|
||||
SELECT c.ancestor_id AS tagId, COUNT(DISTINCT dt.document_id) AS count
|
||||
FROM closure c
|
||||
JOIN document_tags dt ON dt.tag_id = c.descendant_id
|
||||
GROUP BY c.ancestor_id
|
||||
""", nativeQuery = true)
|
||||
List<TagCount> findSubtreeDocumentCountsPerTag();
|
||||
}
|
||||
|
||||
@@ -172,19 +172,27 @@ public class TagService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all tags assembled into a tree with document counts per node.
|
||||
* Uses a single aggregate query to avoid N+1 behaviour.
|
||||
* NOTE: document counts are global per tag, not scoped to any search filter.
|
||||
* The tree endpoint is only used for the admin sidebar, so this is intentional.
|
||||
* Returns all tags assembled into a tree, each node carrying two counts:
|
||||
* {@code documentCount} — documents tagged with that exact tag (direct) — and
|
||||
* {@code subtreeDocumentCount} — distinct documents tagged with that tag or any descendant
|
||||
* (subtree rollup). Each count comes from one aggregate query (no N+1).
|
||||
* NOTE: counts are global per tag, not scoped to any search filter.
|
||||
* Consumed by the reader surfaces (/themen page, dashboard ThemenWidget — which read the
|
||||
* subtree rollup) as well as the admin sidebar and tag operation previews (which read the
|
||||
* direct count).
|
||||
*/
|
||||
public List<TagTreeNodeDTO> getTagTree() {
|
||||
List<Tag> all = tagRepository.findAll();
|
||||
Map<UUID, Long> counts = tagRepository.findDocumentCountsPerTag().stream()
|
||||
.collect(Collectors.toMap(
|
||||
TagRepository.TagCount::getTagId,
|
||||
TagRepository.TagCount::getCount
|
||||
));
|
||||
return buildTree(all, counts);
|
||||
Map<UUID, Long> counts = toCountMap(tagRepository.findDocumentCountsPerTag());
|
||||
Map<UUID, Long> subtreeCounts = toCountMap(tagRepository.findSubtreeDocumentCountsPerTag());
|
||||
return buildTree(all, counts, subtreeCounts);
|
||||
}
|
||||
|
||||
private static Map<UUID, Long> toCountMap(List<TagRepository.TagCount> counts) {
|
||||
return counts.stream().collect(Collectors.toMap(
|
||||
TagRepository.TagCount::getTagId,
|
||||
TagRepository.TagCount::getCount
|
||||
));
|
||||
}
|
||||
|
||||
// ─── private helpers ─────────────────────────────────────────────────────
|
||||
@@ -259,12 +267,14 @@ public class TagService {
|
||||
}
|
||||
}
|
||||
|
||||
private List<TagTreeNodeDTO> buildTree(List<Tag> tags, Map<UUID, Long> counts) {
|
||||
private List<TagTreeNodeDTO> buildTree(List<Tag> tags, Map<UUID, Long> counts,
|
||||
Map<UUID, Long> subtreeCounts) {
|
||||
Map<UUID, TagTreeNodeDTO> nodeById = new LinkedHashMap<>();
|
||||
for (Tag tag : tags) {
|
||||
int documentCount = counts.getOrDefault(tag.getId(), 0L).intValue();
|
||||
int subtreeDocumentCount = subtreeCounts.getOrDefault(tag.getId(), 0L).intValue();
|
||||
nodeById.put(tag.getId(), new TagTreeNodeDTO(
|
||||
tag.getId(), tag.getName(), tag.getColor(), documentCount,
|
||||
tag.getId(), tag.getName(), tag.getColor(), documentCount, subtreeDocumentCount,
|
||||
new ArrayList<>(), tag.getParentId()
|
||||
));
|
||||
}
|
||||
|
||||
@@ -10,5 +10,8 @@ public record TagTreeNodeDTO(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String name,
|
||||
String color,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int documentCount,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED,
|
||||
description = "Distinct documents tagged with this tag or any descendant tag (subtree rollup)")
|
||||
int subtreeDocumentCount,
|
||||
List<TagTreeNodeDTO> children,
|
||||
@Schema(description = "Parent tag ID, null for root tags") UUID parentId) {}
|
||||
|
||||
@@ -102,8 +102,8 @@ class TagControllerTest {
|
||||
void getTagTree_returns200_withTreeStructure() throws Exception {
|
||||
UUID parentId = UUID.randomUUID();
|
||||
UUID childId = UUID.randomUUID();
|
||||
TagTreeNodeDTO child = new TagTreeNodeDTO(childId, "Haus", null, 0, List.of(), parentId);
|
||||
TagTreeNodeDTO parent = new TagTreeNodeDTO(parentId, "Immobilie", "teal", 0, List.of(child), null);
|
||||
TagTreeNodeDTO child = new TagTreeNodeDTO(childId, "Haus", null, 0, 0, List.of(), parentId);
|
||||
TagTreeNodeDTO parent = new TagTreeNodeDTO(parentId, "Immobilie", "teal", 0, 0, List.of(child), null);
|
||||
when(tagService.getTagTree()).thenReturn(List.of(parent));
|
||||
|
||||
mockMvc.perform(get("/api/tags/tree"))
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package org.raddatz.familienarchiv.tag;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||
import org.raddatz.familienarchiv.document.DocumentSpecifications;
|
||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||
import org.springframework.context.annotation.Import;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
|
||||
/**
|
||||
* Real-Postgres validation of the subtree document-count rollup ({@link TagRepository
|
||||
* #findSubtreeDocumentCountsPerTag}). The recursive CTE + COUNT(DISTINCT) cannot be exercised on
|
||||
* H2, so these run against {@code postgres:16-alpine} via Testcontainers. Covers issue #698
|
||||
* AC#1–#4, #6 (REQ-THEMEN-06 cycle guard) and #7 (count↔destination parity).
|
||||
*/
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@Import({PostgresContainerConfig.class, FlywayConfig.class})
|
||||
class TagRollupRepositoryIntegrationTest {
|
||||
|
||||
@Autowired private TagRepository tagRepository;
|
||||
@Autowired private DocumentRepository documentRepository;
|
||||
@Autowired private EntityManager entityManager;
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private Tag tag(String name, UUID parentId) {
|
||||
return tagRepository.save(Tag.builder().name(name).parentId(parentId).build());
|
||||
}
|
||||
|
||||
private Document docWithTags(String title, Tag... tags) {
|
||||
return documentRepository.save(Document.builder()
|
||||
.title(title)
|
||||
.originalFilename(title + ".pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.tags(new HashSet<>(Set.of(tags)))
|
||||
.build());
|
||||
}
|
||||
|
||||
private Map<UUID, Long> rollup() {
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
return tagRepository.findSubtreeDocumentCountsPerTag().stream()
|
||||
.collect(Collectors.toMap(TagRepository.TagCount::getTagId, TagRepository.TagCount::getCount));
|
||||
}
|
||||
|
||||
// ─── AC#4 — rollup of a leaf equals its direct count ────────────────────────
|
||||
|
||||
@Test
|
||||
void leafTag_subtreeCount_equalsItsDirectCount() {
|
||||
Tag leaf = tag("Tagebuch", null);
|
||||
docWithTags("a", leaf);
|
||||
docWithTags("b", leaf);
|
||||
docWithTags("c", leaf);
|
||||
|
||||
assertThat(rollup().get(leaf.getId())).isEqualTo(3L);
|
||||
}
|
||||
|
||||
// ─── AC#1 + AC#2 — parent rolls up children, distinct (shared doc counted once) ──
|
||||
|
||||
@Test
|
||||
void parentTag_rollsUpChildDocuments_countingSharedDocumentOnce() {
|
||||
Tag reisen = tag("Reisen", null);
|
||||
Tag italien = tag("Italien", reisen.getId());
|
||||
|
||||
Document shared = docWithTags("shared", reisen, italien); // tagged with both
|
||||
docWithTags("reisenOnly", reisen);
|
||||
docWithTags("it1", italien);
|
||||
docWithTags("it2", italien);
|
||||
docWithTags("it3", italien);
|
||||
docWithTags("it4", italien);
|
||||
|
||||
Map<UUID, Long> rollup = rollup();
|
||||
|
||||
// Reisen direct {shared, reisenOnly} = 2; Italien {shared, it1..it4} = 5; union distinct = 6
|
||||
assertThat(rollup.get(reisen.getId())).isEqualTo(6L);
|
||||
assertThat(rollup.get(italien.getId())).isEqualTo(5L);
|
||||
assertThat(shared.getId()).isNotNull();
|
||||
}
|
||||
|
||||
// ─── AC#3 — full descendant depth (grandchildren included) ──────────────────
|
||||
|
||||
@Test
|
||||
void rollup_includesGrandchildDocuments_atFullDepth() {
|
||||
Tag reisen = tag("Reisen", null);
|
||||
Tag italien = tag("Italien", reisen.getId());
|
||||
Tag rom = tag("Rom", italien.getId());
|
||||
|
||||
docWithTags("r1", reisen);
|
||||
docWithTags("i1", italien);
|
||||
docWithTags("rom1", rom);
|
||||
docWithTags("rom2", rom);
|
||||
docWithTags("rom3", rom);
|
||||
|
||||
Map<UUID, Long> rollup = rollup();
|
||||
|
||||
assertThat(rollup.get(reisen.getId())).isEqualTo(5L); // 1 + 1 + 3, all distinct
|
||||
assertThat(rollup.get(italien.getId())).isEqualTo(4L); // 1 + 3
|
||||
assertThat(rollup.get(rom.getId())).isEqualTo(3L);
|
||||
}
|
||||
|
||||
// ─── REQ-THEMEN-05 — a tag whose whole subtree is empty is absent (→ 0) ─────
|
||||
|
||||
@Test
|
||||
void tagWithEmptySubtree_isAbsentFromRollup() {
|
||||
Tag empty = tag("Leer", null);
|
||||
Tag emptyChild = tag("LeerKind", empty.getId());
|
||||
|
||||
Map<UUID, Long> rollup = rollup();
|
||||
|
||||
assertThat(rollup).doesNotContainKey(empty.getId());
|
||||
assertThat(rollup).doesNotContainKey(emptyChild.getId());
|
||||
}
|
||||
|
||||
// ─── REQ-THEMEN-06 — a hierarchy cycle terminates safely via the depth guard ──
|
||||
|
||||
@Test
|
||||
void rollup_terminatesSafely_whenHierarchyContainsCycle() {
|
||||
Tag a = tag("CycleA", null);
|
||||
Tag b = tag("CycleB", a.getId());
|
||||
// Close the loop: A.parent = B (DB only forbids parent_id == id, so a 2-node cycle is insertable)
|
||||
a.setParentId(b.getId());
|
||||
tagRepository.save(a);
|
||||
|
||||
docWithTags("ca", a);
|
||||
docWithTags("cb", b);
|
||||
|
||||
assertThatCode(this::rollup).doesNotThrowAnyException(); // depth guard prevents a runaway recursion
|
||||
Map<UUID, Long> rollup = rollup();
|
||||
|
||||
// COUNT(DISTINCT document_id) dedupes documents reached via repeated cycle paths
|
||||
assertThat(rollup.get(a.getId())).isEqualTo(2L);
|
||||
assertThat(rollup.get(b.getId())).isEqualTo(2L);
|
||||
}
|
||||
|
||||
// ─── AC#7 — count↔destination parity with the real search expansion ─────────
|
||||
|
||||
@Test
|
||||
void subtreeCount_equalsDistinctDocumentsFoundByTagSearch_parity() {
|
||||
// Uniquely-named root so name-based search expansion lines up with the per-id rollup.
|
||||
Tag root = tag("ZzzParitaetReise", null);
|
||||
Tag child = tag("ZzzParitaetItalien", root.getId());
|
||||
Tag grandchild = tag("ZzzParitaetRom", child.getId());
|
||||
|
||||
docWithTags("p_shared", root, child); // overlap inside the subtree
|
||||
docWithTags("p_root", root);
|
||||
docWithTags("p_child", child);
|
||||
docWithTags("p_gc1", grandchild);
|
||||
docWithTags("p_gc2", grandchild);
|
||||
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
long rollupCount = rollup().get(root.getId());
|
||||
|
||||
List<UUID> searchExpansionIds = tagRepository.findDescendantIdsByName("ZzzParitaetReise");
|
||||
var spec = DocumentSpecifications.hasTags(List.of(new HashSet<>(searchExpansionIds)), true);
|
||||
long distinctSearchResults = documentRepository.findAll(spec).stream()
|
||||
.map(Document::getId).distinct().count();
|
||||
|
||||
assertThat(rollupCount).isEqualTo(distinctSearchResults);
|
||||
}
|
||||
}
|
||||
@@ -199,6 +199,7 @@ class TagServiceTest {
|
||||
void getTagTree_returnsEmptyList_whenNoTags() {
|
||||
when(tagRepository.findAll()).thenReturn(List.of());
|
||||
when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of());
|
||||
when(tagRepository.findSubtreeDocumentCountsPerTag()).thenReturn(List.of());
|
||||
|
||||
assertThat(tagService.getTagTree()).isEmpty();
|
||||
}
|
||||
@@ -213,6 +214,7 @@ class TagServiceTest {
|
||||
);
|
||||
when(tagRepository.findAll()).thenReturn(tags);
|
||||
when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of());
|
||||
when(tagRepository.findSubtreeDocumentCountsPerTag()).thenReturn(List.of());
|
||||
|
||||
var tree = tagService.getTagTree();
|
||||
|
||||
@@ -228,6 +230,7 @@ class TagServiceTest {
|
||||
Tag child = Tag.builder().id(childId).name("Child").parentId(parentId).build();
|
||||
when(tagRepository.findAll()).thenReturn(List.of(parent, child));
|
||||
when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of());
|
||||
when(tagRepository.findSubtreeDocumentCountsPerTag()).thenReturn(List.of());
|
||||
|
||||
var tree = tagService.getTagTree();
|
||||
|
||||
@@ -247,6 +250,7 @@ class TagServiceTest {
|
||||
Tag child = Tag.builder().id(childId).name("Child").parentId(parentId).build();
|
||||
when(tagRepository.findAll()).thenReturn(List.of(parent, child));
|
||||
when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of());
|
||||
when(tagRepository.findSubtreeDocumentCountsPerTag()).thenReturn(List.of());
|
||||
|
||||
var tree = tagService.getTagTree();
|
||||
|
||||
@@ -262,6 +266,7 @@ class TagServiceTest {
|
||||
when(countEntry.getCount()).thenReturn(5L);
|
||||
when(tagRepository.findAll()).thenReturn(List.of(tag));
|
||||
when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of(countEntry));
|
||||
when(tagRepository.findSubtreeDocumentCountsPerTag()).thenReturn(List.of());
|
||||
|
||||
var tree = tagService.getTagTree();
|
||||
|
||||
@@ -272,12 +277,60 @@ class TagServiceTest {
|
||||
void getTagTree_callsFindDocumentCountsPerTag_exactlyOnce() {
|
||||
when(tagRepository.findAll()).thenReturn(List.of());
|
||||
when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of());
|
||||
when(tagRepository.findSubtreeDocumentCountsPerTag()).thenReturn(List.of());
|
||||
|
||||
tagService.getTagTree();
|
||||
|
||||
verify(tagRepository, times(1)).findDocumentCountsPerTag();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getTagTree_populatesSubtreeDocumentCount_fromRollupQuery() {
|
||||
UUID tagId = UUID.randomUUID();
|
||||
Tag tag = Tag.builder().id(tagId).name("Reisen").build();
|
||||
TagRepository.TagCount subtreeEntry = mock(TagRepository.TagCount.class);
|
||||
when(subtreeEntry.getTagId()).thenReturn(tagId);
|
||||
when(subtreeEntry.getCount()).thenReturn(7L);
|
||||
when(tagRepository.findAll()).thenReturn(List.of(tag));
|
||||
when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of());
|
||||
when(tagRepository.findSubtreeDocumentCountsPerTag()).thenReturn(List.of(subtreeEntry));
|
||||
|
||||
var tree = tagService.getTagTree();
|
||||
|
||||
assertThat(tree.get(0).subtreeDocumentCount()).isEqualTo(7);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getTagTree_keepsDirectAndSubtreeCountsIndependent() {
|
||||
UUID tagId = UUID.randomUUID();
|
||||
Tag tag = Tag.builder().id(tagId).name("Reisen").build();
|
||||
TagRepository.TagCount directEntry = mock(TagRepository.TagCount.class);
|
||||
when(directEntry.getTagId()).thenReturn(tagId);
|
||||
when(directEntry.getCount()).thenReturn(2L);
|
||||
TagRepository.TagCount subtreeEntry = mock(TagRepository.TagCount.class);
|
||||
when(subtreeEntry.getTagId()).thenReturn(tagId);
|
||||
when(subtreeEntry.getCount()).thenReturn(7L);
|
||||
when(tagRepository.findAll()).thenReturn(List.of(tag));
|
||||
when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of(directEntry));
|
||||
when(tagRepository.findSubtreeDocumentCountsPerTag()).thenReturn(List.of(subtreeEntry));
|
||||
|
||||
var node = tagService.getTagTree().get(0);
|
||||
|
||||
assertThat(node.documentCount()).isEqualTo(2);
|
||||
assertThat(node.subtreeDocumentCount()).isEqualTo(7);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getTagTree_callsFindSubtreeDocumentCountsPerTag_exactlyOnce() {
|
||||
when(tagRepository.findAll()).thenReturn(List.of());
|
||||
when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of());
|
||||
when(tagRepository.findSubtreeDocumentCountsPerTag()).thenReturn(List.of());
|
||||
|
||||
tagService.getTagTree();
|
||||
|
||||
verify(tagRepository, times(1)).findSubtreeDocumentCountsPerTag();
|
||||
}
|
||||
|
||||
// ─── resolveEffectiveColors ───────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -2230,6 +2230,11 @@ export interface components {
|
||||
color?: string;
|
||||
/** Format: int32 */
|
||||
documentCount: number;
|
||||
/**
|
||||
* Format: int32
|
||||
* @description Distinct documents tagged with this tag or any descendant tag (subtree rollup)
|
||||
*/
|
||||
subtreeDocumentCount: number;
|
||||
children?: components["schemas"]["TagTreeNodeDTO"][];
|
||||
/**
|
||||
* Format: uuid
|
||||
|
||||
@@ -41,8 +41,8 @@ const shownTags = $derived(visibleTags.slice(0, MAX_VISIBLE_TAGS));
|
||||
{#each shownTags as tag (tag.id)}
|
||||
<a
|
||||
href="/documents?tag={encodeURIComponent(tag.name)}"
|
||||
aria-label="{tag.name}{tag.documentCount > 0
|
||||
? ', ' + m.themen_dokumente({ count: tag.documentCount })
|
||||
aria-label="{tag.name}{tag.subtreeDocumentCount > 0
|
||||
? ', ' + m.themen_dokumente({ count: tag.subtreeDocumentCount })
|
||||
: ''}"
|
||||
class="flex cursor-pointer items-stretch overflow-hidden rounded-sm border border-line bg-canvas hover:bg-surface focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
||||
style="min-height: 56px"
|
||||
@@ -54,9 +54,9 @@ const shownTags = $derived(visibleTags.slice(0, MAX_VISIBLE_TAGS));
|
||||
></span>
|
||||
<span class="flex min-w-0 flex-1 flex-col justify-center gap-0.5 px-3 py-3">
|
||||
<span class="truncate font-serif text-sm font-semibold text-ink">{tag.name}</span>
|
||||
{#if tag.documentCount > 0}
|
||||
{#if tag.subtreeDocumentCount > 0}
|
||||
<span class="font-sans text-xs text-ink-3 tabular-nums">
|
||||
{m.themen_dokumente({ count: tag.documentCount })}
|
||||
{m.themen_dokumente({ count: tag.subtreeDocumentCount })}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
@@ -12,9 +12,10 @@ type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
|
||||
function makeTag(
|
||||
name: string,
|
||||
documentCount: number,
|
||||
children: TagTreeNodeDTO[] = []
|
||||
children: TagTreeNodeDTO[] = [],
|
||||
subtreeDocumentCount: number = documentCount
|
||||
): TagTreeNodeDTO {
|
||||
return { id: 'id-' + name, name, documentCount, children };
|
||||
return { id: 'id-' + name, name, documentCount, subtreeDocumentCount, children };
|
||||
}
|
||||
|
||||
describe('ThemenWidget', () => {
|
||||
@@ -32,6 +33,14 @@ describe('ThemenWidget', () => {
|
||||
expect(document.body.textContent).not.toContain('Leer');
|
||||
});
|
||||
|
||||
it('displays the subtree rollup count for a tag with 0 direct documents', async () => {
|
||||
// 0 direct documents, but the subtree rolls up to 8 — the widget shows the rollup.
|
||||
const tags = [makeTag('Reisen', 0, [], 8)];
|
||||
render(ThemenWidget, { tags });
|
||||
expect(document.body.textContent).toContain('Reisen');
|
||||
expect(document.body.textContent).toContain('8');
|
||||
});
|
||||
|
||||
it('shows the empty state text when all tags are filtered out', async () => {
|
||||
render(ThemenWidget, { tags: [makeTag('Leer', 0)] });
|
||||
expect(document.body.textContent).toMatch(/Noch keine Themen/);
|
||||
|
||||
@@ -4,26 +4,29 @@ import type { components } from '$lib/generated/api';
|
||||
|
||||
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
|
||||
|
||||
function makeNode(documentCount: number, children: TagTreeNodeDTO[] = []): TagTreeNodeDTO {
|
||||
return { id: 'id', name: 'name', documentCount, children };
|
||||
function makeNode(
|
||||
documentCount: number,
|
||||
subtreeDocumentCount: number,
|
||||
children: TagTreeNodeDTO[] = []
|
||||
): TagTreeNodeDTO {
|
||||
return { id: 'id', name: 'name', documentCount, subtreeDocumentCount, children };
|
||||
}
|
||||
|
||||
describe('hasAnyDocuments', () => {
|
||||
it('returns false for a leaf node with documentCount=0', () => {
|
||||
expect(hasAnyDocuments(makeNode(0))).toBe(false);
|
||||
it('returns false for a node whose subtree holds no documents', () => {
|
||||
expect(hasAnyDocuments(makeNode(0, 0))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for a leaf node with documentCount=3', () => {
|
||||
expect(hasAnyDocuments(makeNode(3))).toBe(true);
|
||||
it('returns true for a node whose subtree holds documents', () => {
|
||||
expect(hasAnyDocuments(makeNode(3, 3))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for a root with documentCount=0 but a child with documentCount=5', () => {
|
||||
const node = makeNode(0, [makeNode(5)]);
|
||||
expect(hasAnyDocuments(node)).toBe(true);
|
||||
it('keys on the subtree rollup, not direct documentCount: 0 direct but rollup 5 → true', () => {
|
||||
// The rollup already includes descendants — a single field read, no recursion over children.
|
||||
expect(hasAnyDocuments(makeNode(0, 5))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for a root with documentCount=0 and all children also 0', () => {
|
||||
const node = makeNode(0, [makeNode(0), makeNode(0)]);
|
||||
expect(hasAnyDocuments(node)).toBe(false);
|
||||
it('keys on the subtree rollup, not direct documentCount: 5 direct but rollup 0 → false', () => {
|
||||
expect(hasAnyDocuments(makeNode(5, 0))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,12 @@ import type { components } from '$lib/generated/api';
|
||||
|
||||
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
|
||||
|
||||
/**
|
||||
* Whether a tag's whole subtree holds any documents — keyed on the subtree rollup
|
||||
* (`subtreeDocumentCount`), which the backend already computes across all descendants.
|
||||
* Used by the reader surfaces (/themen page, dashboard ThemenWidget) to hide empty topics.
|
||||
* A single field read: no recursion needed, the rollup is authoritative.
|
||||
*/
|
||||
export function hasAnyDocuments(node: TagTreeNodeDTO): boolean {
|
||||
return (node.documentCount ?? 0) > 0 || (node.children ?? []).some(hasAnyDocuments);
|
||||
return (node.subtreeDocumentCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ const leafNode = (overrides: Record<string, unknown> = {}) => ({
|
||||
name: 'Personen',
|
||||
color: 'sage',
|
||||
documentCount: 5,
|
||||
subtreeDocumentCount: 5,
|
||||
parentId: null,
|
||||
children: [],
|
||||
...overrides
|
||||
@@ -32,8 +33,18 @@ const parentNode = (overrides: Record<string, unknown> = {}) => ({
|
||||
name: 'Orte',
|
||||
color: 'sienna',
|
||||
documentCount: 0,
|
||||
subtreeDocumentCount: 2,
|
||||
parentId: null,
|
||||
children: [{ id: 'tc1', name: 'Berlin', color: null, documentCount: 2, children: [] }],
|
||||
children: [
|
||||
{
|
||||
id: 'tc1',
|
||||
name: 'Berlin',
|
||||
color: null,
|
||||
documentCount: 2,
|
||||
subtreeDocumentCount: 2,
|
||||
children: []
|
||||
}
|
||||
],
|
||||
...overrides
|
||||
});
|
||||
|
||||
|
||||
@@ -68,6 +68,21 @@ describe('TagDeleteGuard', () => {
|
||||
renderWithConfirm();
|
||||
await expect.element(page.getByText(/3 Dokument/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Characterization (#698): the delete-impact warning describes a destructive single-tag delete,
|
||||
// which removes only the tag's DIRECT document_tags rows — so it must show documentCount, never
|
||||
// a subtree rollup. Pinned so a future change can't silently desync this warning.
|
||||
it('shows the direct documentCount in the impact summary, not a subtree rollup', async () => {
|
||||
const tagWithStraySubtree = {
|
||||
id: 't1',
|
||||
name: 'Familie',
|
||||
documentCount: 3,
|
||||
subtreeDocumentCount: 99
|
||||
};
|
||||
renderWithConfirm({ tag: tagWithStraySubtree, allTags });
|
||||
await expect.element(page.getByText(/3 Dokument/)).toBeInTheDocument();
|
||||
expect(document.body.textContent).not.toContain('99');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TagDeleteGuard – confirmation dialog', () => {
|
||||
|
||||
@@ -66,6 +66,32 @@ describe('TagMergeZone – step flow', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('TagMergeZone – preview uses the direct document count (characterization, #698)', () => {
|
||||
it('shows the tag direct documentCount in the merge preview, not a subtree rollup', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue([{ id: 't2', name: 'Reise' }])
|
||||
})
|
||||
);
|
||||
// documentCount (direct) = 3; a stray subtree rollup of 99 must NOT leak into the preview —
|
||||
// merge re-tags only direct documents, so the preview has to stay the direct count.
|
||||
const mergeTag = { id: 't1', name: 'Familie', documentCount: 3, subtreeDocumentCount: 99 };
|
||||
render(TagMergeZone, { tag: mergeTag, allTags, form: null });
|
||||
|
||||
const input = page.getByRole('combobox');
|
||||
await input.fill('R');
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
await page.getByRole('option', { name: 'Reise' }).click();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
await expect.element(page.getByTestId('merge-step2')).toBeInTheDocument();
|
||||
expect(document.body.textContent).toContain('3 Dokumente');
|
||||
expect(document.body.textContent).not.toContain('99');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TagMergeZone – stale state reset', () => {
|
||||
it('resets target selection when tag prop changes', async () => {
|
||||
vi.stubGlobal(
|
||||
|
||||
@@ -15,9 +15,18 @@ const tree = [
|
||||
name: 'Familie',
|
||||
color: undefined,
|
||||
documentCount: 3,
|
||||
subtreeDocumentCount: 5,
|
||||
parentId: undefined,
|
||||
children: [
|
||||
{ id: 't2', name: 'Eltern', color: undefined, documentCount: 2, parentId: 't1', children: [] }
|
||||
{
|
||||
id: 't2',
|
||||
name: 'Eltern',
|
||||
color: undefined,
|
||||
documentCount: 2,
|
||||
subtreeDocumentCount: 2,
|
||||
parentId: 't1',
|
||||
children: []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -25,6 +34,7 @@ const tree = [
|
||||
name: 'Urlaub',
|
||||
color: 'teal',
|
||||
documentCount: 0,
|
||||
subtreeDocumentCount: 0,
|
||||
parentId: undefined,
|
||||
children: []
|
||||
}
|
||||
@@ -128,6 +138,7 @@ describe('TagsListPanel — color dot', () => {
|
||||
id: 't1',
|
||||
name: 'Familie',
|
||||
documentCount: 0,
|
||||
subtreeDocumentCount: 0,
|
||||
parentId: undefined,
|
||||
children: [],
|
||||
color: undefined
|
||||
|
||||
@@ -41,14 +41,14 @@ const visibleTree = $derived.by(() => data.tree.filter(hasAnyDocuments));
|
||||
|
||||
<a
|
||||
href="/documents?tag={encodeURIComponent(tag.name)}"
|
||||
aria-label="{tag.name}{tag.documentCount > 0
|
||||
? ', ' + m.themen_dokumente({ count: tag.documentCount })
|
||||
aria-label="{tag.name}{tag.subtreeDocumentCount > 0
|
||||
? ', ' + m.themen_dokumente({ count: tag.subtreeDocumentCount })
|
||||
: ''}"
|
||||
class="flex min-h-[56px] items-center justify-between px-4 pt-4 pb-3 hover:bg-canvas focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none focus-visible:ring-inset"
|
||||
>
|
||||
<span class="font-serif text-base font-semibold text-ink">{tag.name}</span>
|
||||
<span class="mr-1 ml-auto font-sans text-sm text-ink-3 tabular-nums">
|
||||
{#if tag.documentCount > 0}{tag.documentCount}{/if}
|
||||
{#if tag.subtreeDocumentCount > 0}{tag.subtreeDocumentCount}{/if}
|
||||
</span>
|
||||
<span aria-hidden="true" class="h-3.5 w-3.5 flex-shrink-0 text-brand-mint">›</span>
|
||||
</a>
|
||||
@@ -63,7 +63,7 @@ const visibleTree = $derived.by(() => data.tree.filter(hasAnyDocuments));
|
||||
>
|
||||
<span class="font-sans text-sm text-ink">{child.name}</span>
|
||||
<span class="mr-1 ml-auto font-sans text-xs text-ink-3 tabular-nums">
|
||||
{#if child.documentCount > 0}{child.documentCount}{/if}
|
||||
{#if child.subtreeDocumentCount > 0}{child.subtreeDocumentCount}{/if}
|
||||
</span>
|
||||
<span aria-hidden="true" class="h-3 w-3 flex-shrink-0 text-brand-mint">›</span>
|
||||
</a>
|
||||
|
||||
@@ -12,9 +12,10 @@ type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
|
||||
function makeTag(
|
||||
name: string,
|
||||
documentCount: number,
|
||||
children: TagTreeNodeDTO[] = []
|
||||
children: TagTreeNodeDTO[] = [],
|
||||
subtreeDocumentCount: number = documentCount
|
||||
): TagTreeNodeDTO {
|
||||
return { id: 'id-' + name, name, documentCount, children };
|
||||
return { id: 'id-' + name, name, documentCount, subtreeDocumentCount, children };
|
||||
}
|
||||
|
||||
describe('/themen +page', () => {
|
||||
@@ -48,6 +49,14 @@ describe('/themen +page', () => {
|
||||
expect(document.body.textContent).toContain('Kriegsbriefe');
|
||||
});
|
||||
|
||||
it('shows the subtree rollup in the header for a parent with 0 direct documents', async () => {
|
||||
// AC#5: 0 direct docs, but the subtree rolls up to 7 (child contributes 3).
|
||||
const tree = [makeTag('Reisen', 0, [makeTag('Italien', 3)], 7)];
|
||||
render(ThemenPage, { data: { tree } });
|
||||
expect(document.body.textContent).toContain('Reisen');
|
||||
expect(document.body.textContent).toContain('7');
|
||||
});
|
||||
|
||||
it('shows "+ N weitere" when a root tag has more than 5 children', async () => {
|
||||
const children = Array.from({ length: 7 }, (_, i) => makeTag(`Kind${i}`, i + 1));
|
||||
const tree = [makeTag('Briefe', 10, children)];
|
||||
|
||||
Reference in New Issue
Block a user