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).
|
Entity: `Tag` (self-referencing `parent_id` tree).
|
||||||
Features: tag CRUD, hierarchical deletion (cascade to descendants), tag typeahead, admin tag management (rename, reparent, merge).
|
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
|
## What this domain does NOT own
|
||||||
|
|
||||||
- Documents — the `document_tags` join table is on the document side. `Tag` does not hold document references.
|
- 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)
|
@Query(value = "SELECT tag_id AS tagId, COUNT(*) AS count FROM document_tags GROUP BY tag_id", nativeQuery = true)
|
||||||
List<TagCount> findDocumentCountsPerTag();
|
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.
|
* Returns all tags assembled into a tree, each node carrying two counts:
|
||||||
* Uses a single aggregate query to avoid N+1 behaviour.
|
* {@code documentCount} — documents tagged with that exact tag (direct) — and
|
||||||
* NOTE: document counts are global per tag, not scoped to any search filter.
|
* {@code subtreeDocumentCount} — distinct documents tagged with that tag or any descendant
|
||||||
* The tree endpoint is only used for the admin sidebar, so this is intentional.
|
* (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() {
|
public List<TagTreeNodeDTO> getTagTree() {
|
||||||
List<Tag> all = tagRepository.findAll();
|
List<Tag> all = tagRepository.findAll();
|
||||||
Map<UUID, Long> counts = tagRepository.findDocumentCountsPerTag().stream()
|
Map<UUID, Long> counts = toCountMap(tagRepository.findDocumentCountsPerTag());
|
||||||
.collect(Collectors.toMap(
|
Map<UUID, Long> subtreeCounts = toCountMap(tagRepository.findSubtreeDocumentCountsPerTag());
|
||||||
TagRepository.TagCount::getTagId,
|
return buildTree(all, counts, subtreeCounts);
|
||||||
TagRepository.TagCount::getCount
|
}
|
||||||
));
|
|
||||||
return buildTree(all, counts);
|
private static Map<UUID, Long> toCountMap(List<TagRepository.TagCount> counts) {
|
||||||
|
return counts.stream().collect(Collectors.toMap(
|
||||||
|
TagRepository.TagCount::getTagId,
|
||||||
|
TagRepository.TagCount::getCount
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── private helpers ─────────────────────────────────────────────────────
|
// ─── 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<>();
|
Map<UUID, TagTreeNodeDTO> nodeById = new LinkedHashMap<>();
|
||||||
for (Tag tag : tags) {
|
for (Tag tag : tags) {
|
||||||
int documentCount = counts.getOrDefault(tag.getId(), 0L).intValue();
|
int documentCount = counts.getOrDefault(tag.getId(), 0L).intValue();
|
||||||
|
int subtreeDocumentCount = subtreeCounts.getOrDefault(tag.getId(), 0L).intValue();
|
||||||
nodeById.put(tag.getId(), new TagTreeNodeDTO(
|
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()
|
new ArrayList<>(), tag.getParentId()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,5 +10,8 @@ public record TagTreeNodeDTO(
|
|||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String name,
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String name,
|
||||||
String color,
|
String color,
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int documentCount,
|
@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,
|
List<TagTreeNodeDTO> children,
|
||||||
@Schema(description = "Parent tag ID, null for root tags") UUID parentId) {}
|
@Schema(description = "Parent tag ID, null for root tags") UUID parentId) {}
|
||||||
|
|||||||
@@ -102,8 +102,8 @@ class TagControllerTest {
|
|||||||
void getTagTree_returns200_withTreeStructure() throws Exception {
|
void getTagTree_returns200_withTreeStructure() throws Exception {
|
||||||
UUID parentId = UUID.randomUUID();
|
UUID parentId = UUID.randomUUID();
|
||||||
UUID childId = UUID.randomUUID();
|
UUID childId = UUID.randomUUID();
|
||||||
TagTreeNodeDTO child = new TagTreeNodeDTO(childId, "Haus", null, 0, List.of(), parentId);
|
TagTreeNodeDTO child = new TagTreeNodeDTO(childId, "Haus", null, 0, 0, List.of(), parentId);
|
||||||
TagTreeNodeDTO parent = new TagTreeNodeDTO(parentId, "Immobilie", "teal", 0, List.of(child), null);
|
TagTreeNodeDTO parent = new TagTreeNodeDTO(parentId, "Immobilie", "teal", 0, 0, List.of(child), null);
|
||||||
when(tagService.getTagTree()).thenReturn(List.of(parent));
|
when(tagService.getTagTree()).thenReturn(List.of(parent));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/tags/tree"))
|
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() {
|
void getTagTree_returnsEmptyList_whenNoTags() {
|
||||||
when(tagRepository.findAll()).thenReturn(List.of());
|
when(tagRepository.findAll()).thenReturn(List.of());
|
||||||
when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of());
|
when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of());
|
||||||
|
when(tagRepository.findSubtreeDocumentCountsPerTag()).thenReturn(List.of());
|
||||||
|
|
||||||
assertThat(tagService.getTagTree()).isEmpty();
|
assertThat(tagService.getTagTree()).isEmpty();
|
||||||
}
|
}
|
||||||
@@ -213,6 +214,7 @@ class TagServiceTest {
|
|||||||
);
|
);
|
||||||
when(tagRepository.findAll()).thenReturn(tags);
|
when(tagRepository.findAll()).thenReturn(tags);
|
||||||
when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of());
|
when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of());
|
||||||
|
when(tagRepository.findSubtreeDocumentCountsPerTag()).thenReturn(List.of());
|
||||||
|
|
||||||
var tree = tagService.getTagTree();
|
var tree = tagService.getTagTree();
|
||||||
|
|
||||||
@@ -228,6 +230,7 @@ class TagServiceTest {
|
|||||||
Tag child = Tag.builder().id(childId).name("Child").parentId(parentId).build();
|
Tag child = Tag.builder().id(childId).name("Child").parentId(parentId).build();
|
||||||
when(tagRepository.findAll()).thenReturn(List.of(parent, child));
|
when(tagRepository.findAll()).thenReturn(List.of(parent, child));
|
||||||
when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of());
|
when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of());
|
||||||
|
when(tagRepository.findSubtreeDocumentCountsPerTag()).thenReturn(List.of());
|
||||||
|
|
||||||
var tree = tagService.getTagTree();
|
var tree = tagService.getTagTree();
|
||||||
|
|
||||||
@@ -247,6 +250,7 @@ class TagServiceTest {
|
|||||||
Tag child = Tag.builder().id(childId).name("Child").parentId(parentId).build();
|
Tag child = Tag.builder().id(childId).name("Child").parentId(parentId).build();
|
||||||
when(tagRepository.findAll()).thenReturn(List.of(parent, child));
|
when(tagRepository.findAll()).thenReturn(List.of(parent, child));
|
||||||
when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of());
|
when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of());
|
||||||
|
when(tagRepository.findSubtreeDocumentCountsPerTag()).thenReturn(List.of());
|
||||||
|
|
||||||
var tree = tagService.getTagTree();
|
var tree = tagService.getTagTree();
|
||||||
|
|
||||||
@@ -262,6 +266,7 @@ class TagServiceTest {
|
|||||||
when(countEntry.getCount()).thenReturn(5L);
|
when(countEntry.getCount()).thenReturn(5L);
|
||||||
when(tagRepository.findAll()).thenReturn(List.of(tag));
|
when(tagRepository.findAll()).thenReturn(List.of(tag));
|
||||||
when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of(countEntry));
|
when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of(countEntry));
|
||||||
|
when(tagRepository.findSubtreeDocumentCountsPerTag()).thenReturn(List.of());
|
||||||
|
|
||||||
var tree = tagService.getTagTree();
|
var tree = tagService.getTagTree();
|
||||||
|
|
||||||
@@ -272,12 +277,60 @@ class TagServiceTest {
|
|||||||
void getTagTree_callsFindDocumentCountsPerTag_exactlyOnce() {
|
void getTagTree_callsFindDocumentCountsPerTag_exactlyOnce() {
|
||||||
when(tagRepository.findAll()).thenReturn(List.of());
|
when(tagRepository.findAll()).thenReturn(List.of());
|
||||||
when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of());
|
when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of());
|
||||||
|
when(tagRepository.findSubtreeDocumentCountsPerTag()).thenReturn(List.of());
|
||||||
|
|
||||||
tagService.getTagTree();
|
tagService.getTagTree();
|
||||||
|
|
||||||
verify(tagRepository, times(1)).findDocumentCountsPerTag();
|
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 ───────────────────────────────────────────────
|
// ─── resolveEffectiveColors ───────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -2230,6 +2230,11 @@ export interface components {
|
|||||||
color?: string;
|
color?: string;
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
documentCount: number;
|
documentCount: number;
|
||||||
|
/**
|
||||||
|
* Format: int32
|
||||||
|
* @description Distinct documents tagged with this tag or any descendant tag (subtree rollup)
|
||||||
|
*/
|
||||||
|
subtreeDocumentCount: number;
|
||||||
children?: components["schemas"]["TagTreeNodeDTO"][];
|
children?: components["schemas"]["TagTreeNodeDTO"][];
|
||||||
/**
|
/**
|
||||||
* Format: uuid
|
* Format: uuid
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ const shownTags = $derived(visibleTags.slice(0, MAX_VISIBLE_TAGS));
|
|||||||
{#each shownTags as tag (tag.id)}
|
{#each shownTags as tag (tag.id)}
|
||||||
<a
|
<a
|
||||||
href="/documents?tag={encodeURIComponent(tag.name)}"
|
href="/documents?tag={encodeURIComponent(tag.name)}"
|
||||||
aria-label="{tag.name}{tag.documentCount > 0
|
aria-label="{tag.name}{tag.subtreeDocumentCount > 0
|
||||||
? ', ' + m.themen_dokumente({ count: tag.documentCount })
|
? ', ' + 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"
|
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"
|
style="min-height: 56px"
|
||||||
@@ -54,9 +54,9 @@ const shownTags = $derived(visibleTags.slice(0, MAX_VISIBLE_TAGS));
|
|||||||
></span>
|
></span>
|
||||||
<span class="flex min-w-0 flex-1 flex-col justify-center gap-0.5 px-3 py-3">
|
<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>
|
<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">
|
<span class="font-sans text-xs text-ink-3 tabular-nums">
|
||||||
{m.themen_dokumente({ count: tag.documentCount })}
|
{m.themen_dokumente({ count: tag.subtreeDocumentCount })}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
|
|||||||
function makeTag(
|
function makeTag(
|
||||||
name: string,
|
name: string,
|
||||||
documentCount: number,
|
documentCount: number,
|
||||||
children: TagTreeNodeDTO[] = []
|
children: TagTreeNodeDTO[] = [],
|
||||||
|
subtreeDocumentCount: number = documentCount
|
||||||
): TagTreeNodeDTO {
|
): TagTreeNodeDTO {
|
||||||
return { id: 'id-' + name, name, documentCount, children };
|
return { id: 'id-' + name, name, documentCount, subtreeDocumentCount, children };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('ThemenWidget', () => {
|
describe('ThemenWidget', () => {
|
||||||
@@ -32,6 +33,14 @@ describe('ThemenWidget', () => {
|
|||||||
expect(document.body.textContent).not.toContain('Leer');
|
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 () => {
|
it('shows the empty state text when all tags are filtered out', async () => {
|
||||||
render(ThemenWidget, { tags: [makeTag('Leer', 0)] });
|
render(ThemenWidget, { tags: [makeTag('Leer', 0)] });
|
||||||
expect(document.body.textContent).toMatch(/Noch keine Themen/);
|
expect(document.body.textContent).toMatch(/Noch keine Themen/);
|
||||||
|
|||||||
@@ -4,26 +4,29 @@ import type { components } from '$lib/generated/api';
|
|||||||
|
|
||||||
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
|
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
|
||||||
|
|
||||||
function makeNode(documentCount: number, children: TagTreeNodeDTO[] = []): TagTreeNodeDTO {
|
function makeNode(
|
||||||
return { id: 'id', name: 'name', documentCount, children };
|
documentCount: number,
|
||||||
|
subtreeDocumentCount: number,
|
||||||
|
children: TagTreeNodeDTO[] = []
|
||||||
|
): TagTreeNodeDTO {
|
||||||
|
return { id: 'id', name: 'name', documentCount, subtreeDocumentCount, children };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('hasAnyDocuments', () => {
|
describe('hasAnyDocuments', () => {
|
||||||
it('returns false for a leaf node with documentCount=0', () => {
|
it('returns false for a node whose subtree holds no documents', () => {
|
||||||
expect(hasAnyDocuments(makeNode(0))).toBe(false);
|
expect(hasAnyDocuments(makeNode(0, 0))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns true for a leaf node with documentCount=3', () => {
|
it('returns true for a node whose subtree holds documents', () => {
|
||||||
expect(hasAnyDocuments(makeNode(3))).toBe(true);
|
expect(hasAnyDocuments(makeNode(3, 3))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns true for a root with documentCount=0 but a child with documentCount=5', () => {
|
it('keys on the subtree rollup, not direct documentCount: 0 direct but rollup 5 → true', () => {
|
||||||
const node = makeNode(0, [makeNode(5)]);
|
// The rollup already includes descendants — a single field read, no recursion over children.
|
||||||
expect(hasAnyDocuments(node)).toBe(true);
|
expect(hasAnyDocuments(makeNode(0, 5))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns false for a root with documentCount=0 and all children also 0', () => {
|
it('keys on the subtree rollup, not direct documentCount: 5 direct but rollup 0 → false', () => {
|
||||||
const node = makeNode(0, [makeNode(0), makeNode(0)]);
|
expect(hasAnyDocuments(makeNode(5, 0))).toBe(false);
|
||||||
expect(hasAnyDocuments(node)).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import type { components } from '$lib/generated/api';
|
|||||||
|
|
||||||
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
|
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 {
|
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',
|
name: 'Personen',
|
||||||
color: 'sage',
|
color: 'sage',
|
||||||
documentCount: 5,
|
documentCount: 5,
|
||||||
|
subtreeDocumentCount: 5,
|
||||||
parentId: null,
|
parentId: null,
|
||||||
children: [],
|
children: [],
|
||||||
...overrides
|
...overrides
|
||||||
@@ -32,8 +33,18 @@ const parentNode = (overrides: Record<string, unknown> = {}) => ({
|
|||||||
name: 'Orte',
|
name: 'Orte',
|
||||||
color: 'sienna',
|
color: 'sienna',
|
||||||
documentCount: 0,
|
documentCount: 0,
|
||||||
|
subtreeDocumentCount: 2,
|
||||||
parentId: null,
|
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
|
...overrides
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,21 @@ describe('TagDeleteGuard', () => {
|
|||||||
renderWithConfirm();
|
renderWithConfirm();
|
||||||
await expect.element(page.getByText(/3 Dokument/)).toBeInTheDocument();
|
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', () => {
|
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', () => {
|
describe('TagMergeZone – stale state reset', () => {
|
||||||
it('resets target selection when tag prop changes', async () => {
|
it('resets target selection when tag prop changes', async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
|
|||||||
@@ -15,9 +15,18 @@ const tree = [
|
|||||||
name: 'Familie',
|
name: 'Familie',
|
||||||
color: undefined,
|
color: undefined,
|
||||||
documentCount: 3,
|
documentCount: 3,
|
||||||
|
subtreeDocumentCount: 5,
|
||||||
parentId: undefined,
|
parentId: undefined,
|
||||||
children: [
|
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',
|
name: 'Urlaub',
|
||||||
color: 'teal',
|
color: 'teal',
|
||||||
documentCount: 0,
|
documentCount: 0,
|
||||||
|
subtreeDocumentCount: 0,
|
||||||
parentId: undefined,
|
parentId: undefined,
|
||||||
children: []
|
children: []
|
||||||
}
|
}
|
||||||
@@ -128,6 +138,7 @@ describe('TagsListPanel — color dot', () => {
|
|||||||
id: 't1',
|
id: 't1',
|
||||||
name: 'Familie',
|
name: 'Familie',
|
||||||
documentCount: 0,
|
documentCount: 0,
|
||||||
|
subtreeDocumentCount: 0,
|
||||||
parentId: undefined,
|
parentId: undefined,
|
||||||
children: [],
|
children: [],
|
||||||
color: undefined
|
color: undefined
|
||||||
|
|||||||
@@ -41,14 +41,14 @@ const visibleTree = $derived.by(() => data.tree.filter(hasAnyDocuments));
|
|||||||
|
|
||||||
<a
|
<a
|
||||||
href="/documents?tag={encodeURIComponent(tag.name)}"
|
href="/documents?tag={encodeURIComponent(tag.name)}"
|
||||||
aria-label="{tag.name}{tag.documentCount > 0
|
aria-label="{tag.name}{tag.subtreeDocumentCount > 0
|
||||||
? ', ' + m.themen_dokumente({ count: tag.documentCount })
|
? ', ' + 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"
|
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="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">
|
<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>
|
||||||
<span aria-hidden="true" class="h-3.5 w-3.5 flex-shrink-0 text-brand-mint">›</span>
|
<span aria-hidden="true" class="h-3.5 w-3.5 flex-shrink-0 text-brand-mint">›</span>
|
||||||
</a>
|
</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="font-sans text-sm text-ink">{child.name}</span>
|
||||||
<span class="mr-1 ml-auto font-sans text-xs text-ink-3 tabular-nums">
|
<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>
|
||||||
<span aria-hidden="true" class="h-3 w-3 flex-shrink-0 text-brand-mint">›</span>
|
<span aria-hidden="true" class="h-3 w-3 flex-shrink-0 text-brand-mint">›</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
|
|||||||
function makeTag(
|
function makeTag(
|
||||||
name: string,
|
name: string,
|
||||||
documentCount: number,
|
documentCount: number,
|
||||||
children: TagTreeNodeDTO[] = []
|
children: TagTreeNodeDTO[] = [],
|
||||||
|
subtreeDocumentCount: number = documentCount
|
||||||
): TagTreeNodeDTO {
|
): TagTreeNodeDTO {
|
||||||
return { id: 'id-' + name, name, documentCount, children };
|
return { id: 'id-' + name, name, documentCount, subtreeDocumentCount, children };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('/themen +page', () => {
|
describe('/themen +page', () => {
|
||||||
@@ -48,6 +49,14 @@ describe('/themen +page', () => {
|
|||||||
expect(document.body.textContent).toContain('Kriegsbriefe');
|
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 () => {
|
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 children = Array.from({ length: 7 }, (_, i) => makeTag(`Kind${i}`, i + 1));
|
||||||
const tree = [makeTag('Briefe', 10, children)];
|
const tree = [makeTag('Briefe', 10, children)];
|
||||||
|
|||||||
Reference in New Issue
Block a user