feat(themen): count documents across the whole sub-topic tree (#698) #701

Merged
marcel merged 8 commits from worktree-feat+issue-698-themen-subtree-count into main 2026-05-31 12:58:10 +02:00
18 changed files with 415 additions and 41 deletions

View File

@@ -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.

View File

@@ -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();
}

View File

@@ -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()
));
}

View File

@@ -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) {}

View File

@@ -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"))

View File

@@ -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);
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -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/);

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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
});

View File

@@ -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', () => {

View File

@@ -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(

View File

@@ -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

View File

@@ -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>

View File

@@ -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)];