Compare commits
17 Commits
ed32de9f10
...
fix/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ec4815e24 | ||
|
|
a7bbf2424f | ||
|
|
7c2c4741ab | ||
|
|
d464bca9f3 | ||
|
|
2283f733cc | ||
|
|
cc20583ae6 | ||
|
|
86d75d91be | ||
|
|
a98ca0e5d3 | ||
|
|
1c515a3145 | ||
|
|
43d36c898c | ||
|
|
60326cfb0a | ||
|
|
e598f5a506 | ||
|
|
e1c78e3fbe | ||
|
|
ae6355d206 | ||
|
|
b5f9fcfdfd | ||
|
|
2f48dfabd1 | ||
|
|
495210052f |
@@ -40,6 +40,10 @@ jobs:
|
|||||||
run: npm test
|
run: npm test
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
run: npm run build
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
- name: Upload screenshots
|
- name: Upload screenshots
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|||||||
@@ -100,45 +100,7 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
ORDER BY ts_rank(d.search_vector, q.pq) DESC,
|
ORDER BY ts_rank(d.search_vector, q.pq) DESC,
|
||||||
d.meta_date DESC NULLS LAST
|
d.meta_date DESC NULLS LAST
|
||||||
""")
|
""")
|
||||||
// Unpaged path — for bulk-edit "select all" and density chart
|
List<UUID> findRankedIdsByFts(@Param("query") String query);
|
||||||
List<UUID> findAllMatchingIdsByFts(@Param("query") String query);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns one page of FTS-ranked document IDs with the total match count.
|
|
||||||
*
|
|
||||||
* <p>Each row contains (in column order):
|
|
||||||
* <ol>
|
|
||||||
* <li>UUID — document id</li>
|
|
||||||
* <li>double — ts_rank score</li>
|
|
||||||
* <li>long — COUNT(*) OVER () — full match count, not page count</li>
|
|
||||||
* </ol>
|
|
||||||
*
|
|
||||||
* <p>Returns an empty list when the query matches no documents (including
|
|
||||||
* stopword-only queries where websearch_to_tsquery returns an empty tsquery).
|
|
||||||
* Use findAllMatchingIdsByFts for the unpaged bulk-edit path.
|
|
||||||
*/
|
|
||||||
@Query(nativeQuery = true, value = """
|
|
||||||
WITH q AS (
|
|
||||||
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
|
|
||||||
THEN to_tsquery('simple', regexp_replace(
|
|
||||||
websearch_to_tsquery('german', :query)::text,
|
|
||||||
'''([^'']+)''',
|
|
||||||
'''\\1'':*',
|
|
||||||
'g'))
|
|
||||||
END AS pq
|
|
||||||
), matches AS (
|
|
||||||
SELECT d.id, ts_rank(d.search_vector, q.pq) AS rank
|
|
||||||
FROM documents d, q
|
|
||||||
WHERE d.search_vector @@ q.pq
|
|
||||||
)
|
|
||||||
SELECT id, rank, COUNT(*) OVER () AS total
|
|
||||||
FROM matches
|
|
||||||
ORDER BY rank DESC, id
|
|
||||||
OFFSET :offset LIMIT :limit
|
|
||||||
""")
|
|
||||||
List<Object[]> findFtsPageRaw(@Param("query") String query,
|
|
||||||
@Param("offset") int offset,
|
|
||||||
@Param("limit") int limit);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns match-enrichment data for a set of documents identified by their IDs.
|
* Returns match-enrichment data for a set of documents identified by their IDs.
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ public class DocumentService {
|
|||||||
*/
|
*/
|
||||||
private List<UUID> resolveFtsIds(String text) {
|
private List<UUID> resolveFtsIds(String text) {
|
||||||
if (!StringUtils.hasText(text)) return null;
|
if (!StringUtils.hasText(text)) return null;
|
||||||
return documentRepository.findAllMatchingIdsByFts(text);
|
return documentRepository.findRankedIdsByFts(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Loads matching documents and projects to non-null {@link LocalDate}s. */
|
/** Loads matching documents and projects to non-null {@link LocalDate}s. */
|
||||||
@@ -485,7 +485,7 @@ public class DocumentService {
|
|||||||
boolean hasText = StringUtils.hasText(text);
|
boolean hasText = StringUtils.hasText(text);
|
||||||
List<UUID> rankedIds = null;
|
List<UUID> rankedIds = null;
|
||||||
if (hasText) {
|
if (hasText) {
|
||||||
rankedIds = documentRepository.findAllMatchingIdsByFts(text);
|
rankedIds = documentRepository.findRankedIdsByFts(text);
|
||||||
if (rankedIds.isEmpty()) return List.of();
|
if (rankedIds.isEmpty()) return List.of();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -645,43 +645,39 @@ public class DocumentService {
|
|||||||
// 1. Allgemeine Suche (für das Suchfeld im Frontend)
|
// 1. Allgemeine Suche (für das Suchfeld im Frontend)
|
||||||
public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir, TagOperator tagOperator, Pageable pageable) {
|
public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir, TagOperator tagOperator, Pageable pageable) {
|
||||||
boolean hasText = StringUtils.hasText(text);
|
boolean hasText = StringUtils.hasText(text);
|
||||||
|
|
||||||
// Pure-text RELEVANCE: push pagination into SQL — skip findAllMatchingIdsByFts entirely (ADR-008).
|
|
||||||
if (isPureTextRelevance(hasText, sort, from, to, sender, receiver, tags, tagQ, status)) {
|
|
||||||
return relevanceSortedPageFromSql(text, pageable);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<UUID> rankedIds = null;
|
List<UUID> rankedIds = null;
|
||||||
|
|
||||||
if (hasText) {
|
if (hasText) {
|
||||||
rankedIds = documentRepository.findAllMatchingIdsByFts(text);
|
rankedIds = documentRepository.findRankedIdsByFts(text);
|
||||||
if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of());
|
if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
Specification<Document> spec = buildSearchSpec(
|
Specification<Document> spec = buildSearchSpec(
|
||||||
hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator);
|
hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator);
|
||||||
|
|
||||||
// SENDER and RECEIVER sorts load the full match set and slice in-memory.
|
// SENDER, RECEIVER and RELEVANCE sorts load the full match set and slice in memory.
|
||||||
// JPA's Sort.by("sender.lastName") generates an INNER JOIN that silently drops
|
// JPA's Sort.by("sender.lastName") generates an INNER JOIN that silently drops
|
||||||
// documents with null sender/receivers. Cost scales with match count —
|
// documents with null sender/receivers; RELEVANCE maps a DB order to an external
|
||||||
// acceptable while documents stays under ~10k rows. (ADR-008)
|
// rank list. Cost scales linearly with match count — acceptable while documents
|
||||||
|
// stays under ~10k rows. Past that, replace with SQL-level LEFT JOIN sort.
|
||||||
if (sort == DocumentSort.RECEIVER) {
|
if (sort == DocumentSort.RECEIVER) {
|
||||||
// In-memory sort on page slice (≤ page size rows) — acceptable
|
|
||||||
List<Document> sorted = sortByFirstReceiver(documentRepository.findAll(spec), dir);
|
List<Document> sorted = sortByFirstReceiver(documentRepository.findAll(spec), dir);
|
||||||
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
|
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
|
||||||
}
|
}
|
||||||
if (sort == DocumentSort.SENDER) {
|
if (sort == DocumentSort.SENDER) {
|
||||||
// In-memory sort on page slice (≤ page size rows) — acceptable
|
|
||||||
List<Document> sorted = sortBySender(documentRepository.findAll(spec), dir);
|
List<Document> sorted = sortBySender(documentRepository.findAll(spec), dir);
|
||||||
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
|
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
// RELEVANCE with active filters: load filtered subset and sort in-memory by rank.
|
// RELEVANCE: default when text present and no explicit sort given
|
||||||
boolean useRankOrder = hasText && (sort == null || sort == DocumentSort.RELEVANCE);
|
boolean useRankOrder = hasText && (sort == null || sort == DocumentSort.RELEVANCE);
|
||||||
if (useRankOrder) {
|
if (useRankOrder) {
|
||||||
|
List<Document> results = documentRepository.findAll(spec);
|
||||||
Map<UUID, Integer> rankMap = new HashMap<>();
|
Map<UUID, Integer> rankMap = new HashMap<>();
|
||||||
for (int i = 0; i < rankedIds.size(); i++) rankMap.put(rankedIds.get(i), i);
|
for (int i = 0; i < rankedIds.size(); i++) rankMap.put(rankedIds.get(i), i);
|
||||||
List<Document> sorted = documentRepository.findAll(spec).stream()
|
List<Document> sorted = results.stream()
|
||||||
.sorted(Comparator.comparingInt(doc -> rankMap.getOrDefault(doc.getId(), Integer.MAX_VALUE)))
|
.sorted(Comparator.comparingInt(
|
||||||
|
doc -> rankMap.getOrDefault(doc.getId(), Integer.MAX_VALUE)))
|
||||||
.toList();
|
.toList();
|
||||||
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
|
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
|
||||||
}
|
}
|
||||||
@@ -692,39 +688,6 @@ public class DocumentService {
|
|||||||
return buildResultPaged(page.getContent(), text, pageable, page.getTotalElements());
|
return buildResultPaged(page.getContent(), text, pageable, page.getTotalElements());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean isPureTextRelevance(boolean hasText, DocumentSort sort,
|
|
||||||
LocalDate from, LocalDate to, UUID sender, UUID receiver,
|
|
||||||
List<String> tags, String tagQ, DocumentStatus status) {
|
|
||||||
return hasText && (sort == null || sort == DocumentSort.RELEVANCE)
|
|
||||||
&& from == null && to == null && sender == null && receiver == null
|
|
||||||
&& (tags == null || tags.isEmpty()) && (tagQ == null || tagQ.isBlank()) && status == null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure-text RELEVANCE path — pagination and ts_rank ordering pushed into SQL.
|
|
||||||
* Called when no non-text filters are active (ADR-008).
|
|
||||||
*/
|
|
||||||
private DocumentSearchResult relevanceSortedPageFromSql(String text, Pageable pageable) {
|
|
||||||
long rawOffset = pageable.getOffset();
|
|
||||||
if (rawOffset > Integer.MAX_VALUE) return DocumentSearchResult.of(List.of());
|
|
||||||
int offset = (int) rawOffset;
|
|
||||||
int limit = pageable.getPageSize();
|
|
||||||
FtsPage ftsPage = toFtsPage(documentRepository.findFtsPageRaw(text, offset, limit));
|
|
||||||
if (ftsPage.hits().isEmpty()) return DocumentSearchResult.of(List.of());
|
|
||||||
|
|
||||||
// Preserve ts_rank order from SQL across the JPA findAllById call.
|
|
||||||
Map<UUID, Integer> rankMap = new HashMap<>();
|
|
||||||
List<UUID> pageIds = new ArrayList<>();
|
|
||||||
for (int i = 0; i < ftsPage.hits().size(); i++) {
|
|
||||||
rankMap.put(ftsPage.hits().get(i).id(), i);
|
|
||||||
pageIds.add(ftsPage.hits().get(i).id());
|
|
||||||
}
|
|
||||||
List<Document> docs = documentRepository.findAllById(pageIds).stream()
|
|
||||||
.sorted(Comparator.comparingInt(d -> rankMap.getOrDefault(d.getId(), Integer.MAX_VALUE)))
|
|
||||||
.toList();
|
|
||||||
return buildResultPaged(docs, text, pageable, ftsPage.total());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static <T> List<T> pageSlice(List<T> sorted, Pageable pageable) {
|
private static <T> List<T> pageSlice(List<T> sorted, Pageable pageable) {
|
||||||
int from = Math.min((int) pageable.getOffset(), sorted.size());
|
int from = Math.min((int) pageable.getOffset(), sorted.size());
|
||||||
int to = Math.min(from + pageable.getPageSize(), sorted.size());
|
int to = Math.min(from + pageable.getPageSize(), sorted.size());
|
||||||
@@ -1050,28 +1013,6 @@ public class DocumentService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final int COL_ID = 0;
|
|
||||||
private static final int COL_RANK = 1;
|
|
||||||
private static final int COL_TOTAL = 2;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maps raw Object[] rows from {@link DocumentRepository#findFtsPageRaw} to an
|
|
||||||
* {@link FtsPage}. Uses pattern-matching UUID cast to guard against driver-level
|
|
||||||
* type variance (some JDBC drivers return UUID as String).
|
|
||||||
*/
|
|
||||||
private static FtsPage toFtsPage(List<Object[]> rows) {
|
|
||||||
if (rows.isEmpty()) return new FtsPage(List.of(), 0);
|
|
||||||
long total = ((Number) rows.get(0)[COL_TOTAL]).longValue();
|
|
||||||
List<FtsHit> hits = rows.stream()
|
|
||||||
.map(r -> {
|
|
||||||
UUID id = r[COL_ID] instanceof UUID u ? u : UUID.fromString(r[COL_ID].toString());
|
|
||||||
double rank = ((Number) r[COL_RANK]).doubleValue();
|
|
||||||
return new FtsHit(id, rank);
|
|
||||||
})
|
|
||||||
.toList();
|
|
||||||
return new FtsPage(hits, total);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Clean text + highlight offsets parsed from a {@code ts_headline} sentinel-delimited string. */
|
/** Clean text + highlight offsets parsed from a {@code ts_headline} sentinel-delimited string. */
|
||||||
public record ParsedHighlight(String cleanText, List<MatchOffset> offsets) {}
|
public record ParsedHighlight(String cleanText, List<MatchOffset> offsets) {}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.document;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/** A single document hit from a paginated FTS query — id and its ts_rank score. */
|
|
||||||
record FtsHit(UUID id, double rank) {}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.document;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/** One page of FTS results — the ranked hit list for this page and the total match count. */
|
|
||||||
record FtsPage(List<FtsHit> hits, long total) {}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.document;
|
|
||||||
|
|
||||||
import jakarta.persistence.EntityManager;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
|
||||||
import org.raddatz.familienarchiv.config.FlywayConfig;
|
|
||||||
import org.raddatz.familienarchiv.document.DocumentRepository;
|
|
||||||
import org.raddatz.familienarchiv.document.Document;
|
|
||||||
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 org.springframework.test.annotation.DirtiesContext;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.assertj.core.api.Assertions.assertThatNoException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Repository-level integration tests for {@code findFtsPageRaw}: verifies that the
|
|
||||||
* paginated FTS query returns exactly page-size rows and that the window-function
|
|
||||||
* total reflects the full match count, not just the page count.
|
|
||||||
*
|
|
||||||
* <p>Uses real Postgres via Testcontainers so the GIN index, tsvector trigger, and
|
|
||||||
* {@code websearch_to_tsquery} semantics are identical to production.
|
|
||||||
*
|
|
||||||
* <p>{@code AFTER_CLASS} dirty-context keeps the Spring context alive for all tests
|
|
||||||
* in this class and rebuilds it once at the end, rather than after every test.
|
|
||||||
*/
|
|
||||||
@DataJpaTest
|
|
||||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
|
||||||
@Import({PostgresContainerConfig.class, FlywayConfig.class})
|
|
||||||
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
|
|
||||||
class DocumentFtsPagedIntegrationTest {
|
|
||||||
|
|
||||||
@Autowired DocumentRepository documentRepository;
|
|
||||||
@Autowired EntityManager em;
|
|
||||||
|
|
||||||
// 60 docs match "Walter"; 10 docs with "Hans" do not.
|
|
||||||
private static final int WALTER_COUNT = 60;
|
|
||||||
private static final int PAGE_SIZE = 50;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void seed() {
|
|
||||||
documentRepository.deleteAll();
|
|
||||||
em.flush();
|
|
||||||
for (int i = 0; i < WALTER_COUNT; i++) {
|
|
||||||
documentRepository.saveAndFlush(doc("Brief von Walter Nr. " + i));
|
|
||||||
}
|
|
||||||
for (int i = 0; i < 10; i++) {
|
|
||||||
documentRepository.saveAndFlush(doc("Brief von Hans Nr. " + i));
|
|
||||||
}
|
|
||||||
em.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void findFtsPageRaw_firstPage_returnsPageSizeRows() {
|
|
||||||
List<Object[]> rows = documentRepository.findFtsPageRaw("Walter", 0, PAGE_SIZE);
|
|
||||||
|
|
||||||
assertThat(rows).hasSize(PAGE_SIZE);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void findFtsPageRaw_windowTotal_equalsFullMatchCount_notPageSize() {
|
|
||||||
List<Object[]> rows = documentRepository.findFtsPageRaw("Walter", 0, PAGE_SIZE);
|
|
||||||
|
|
||||||
long total = ((Number) rows.get(0)[2]).longValue();
|
|
||||||
assertThat(total).isEqualTo(WALTER_COUNT);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void findFtsPageRaw_lastPage_returnsRemainder() {
|
|
||||||
int remainder = WALTER_COUNT % PAGE_SIZE; // 60 % 50 = 10
|
|
||||||
List<Object[]> rows = documentRepository.findFtsPageRaw("Walter", PAGE_SIZE, PAGE_SIZE);
|
|
||||||
|
|
||||||
assertThat(rows).hasSize(remainder);
|
|
||||||
long total = ((Number) rows.get(0)[2]).longValue();
|
|
||||||
assertThat(total).isEqualTo(WALTER_COUNT);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void findFtsPageRaw_noMatches_returnsEmptyList() {
|
|
||||||
List<Object[]> rows = documentRepository.findFtsPageRaw("XYZ_KEIN_TREFFER", 0, PAGE_SIZE);
|
|
||||||
|
|
||||||
assertThat(rows).isEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void findFtsPageRaw_stopwordOnlyQuery_returnsEmptyList_noException() {
|
|
||||||
assertThatNoException().isThrownBy(() -> {
|
|
||||||
List<Object[]> rows = documentRepository.findFtsPageRaw("der die das und", 0, PAGE_SIZE);
|
|
||||||
assertThat(rows).isEmpty();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Helper ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private Document doc(String title) {
|
|
||||||
return Document.builder()
|
|
||||||
.title(title)
|
|
||||||
.originalFilename(title.replace(" ", "_") + ".pdf")
|
|
||||||
.status(DocumentStatus.UPLOADED)
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -69,7 +69,7 @@ class DocumentFtsTest {
|
|||||||
documentRepository.saveAndFlush(document("Alter Brief"));
|
documentRepository.saveAndFlush(document("Alter Brief"));
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("Brief");
|
List<UUID> ids = documentRepository.findRankedIdsByFts("Brief");
|
||||||
|
|
||||||
assertThat(ids).hasSize(1);
|
assertThat(ids).hasSize(1);
|
||||||
}
|
}
|
||||||
@@ -79,7 +79,7 @@ class DocumentFtsTest {
|
|||||||
documentRepository.saveAndFlush(document("Alter Brief"));
|
documentRepository.saveAndFlush(document("Alter Brief"));
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("Briefe");
|
List<UUID> ids = documentRepository.findRankedIdsByFts("Briefe");
|
||||||
|
|
||||||
assertThat(ids).hasSize(1);
|
assertThat(ids).hasSize(1);
|
||||||
}
|
}
|
||||||
@@ -89,7 +89,7 @@ class DocumentFtsTest {
|
|||||||
documentRepository.saveAndFlush(document("Ein furchtbarer Brief"));
|
documentRepository.saveAndFlush(document("Ein furchtbarer Brief"));
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("furchtb");
|
List<UUID> ids = documentRepository.findRankedIdsByFts("furchtb");
|
||||||
|
|
||||||
assertThat(ids).hasSize(1);
|
assertThat(ids).hasSize(1);
|
||||||
}
|
}
|
||||||
@@ -99,7 +99,7 @@ class DocumentFtsTest {
|
|||||||
documentRepository.saveAndFlush(document("Familienfoto"));
|
documentRepository.saveAndFlush(document("Familienfoto"));
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("Brief");
|
List<UUID> ids = documentRepository.findRankedIdsByFts("Brief");
|
||||||
|
|
||||||
assertThat(ids).isEmpty();
|
assertThat(ids).isEmpty();
|
||||||
}
|
}
|
||||||
@@ -115,7 +115,7 @@ class DocumentFtsTest {
|
|||||||
em.flush();
|
em.flush();
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("schreiben");
|
List<UUID> ids = documentRepository.findRankedIdsByFts("schreiben");
|
||||||
|
|
||||||
assertThat(ids).contains(doc.getId());
|
assertThat(ids).contains(doc.getId());
|
||||||
}
|
}
|
||||||
@@ -125,14 +125,14 @@ class DocumentFtsTest {
|
|||||||
Document doc = documentRepository.saveAndFlush(document("Leeres Dokument"));
|
Document doc = documentRepository.saveAndFlush(document("Leeres Dokument"));
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
assertThat(documentRepository.findAllMatchingIdsByFts("Grundbuch")).isEmpty();
|
assertThat(documentRepository.findRankedIdsByFts("Grundbuch")).isEmpty();
|
||||||
|
|
||||||
UUID annotationId = annotation(doc.getId());
|
UUID annotationId = annotation(doc.getId());
|
||||||
blockRepository.saveAndFlush(block(doc.getId(), annotationId, "Grundbuch Eintrag 1923", 0));
|
blockRepository.saveAndFlush(block(doc.getId(), annotationId, "Grundbuch Eintrag 1923", 0));
|
||||||
em.flush();
|
em.flush();
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
assertThat(documentRepository.findAllMatchingIdsByFts("Grundbuch")).contains(doc.getId());
|
assertThat(documentRepository.findRankedIdsByFts("Grundbuch")).contains(doc.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -144,13 +144,13 @@ class DocumentFtsTest {
|
|||||||
em.flush();
|
em.flush();
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
assertThat(documentRepository.findAllMatchingIdsByFts("Grundbuch")).contains(doc.getId());
|
assertThat(documentRepository.findRankedIdsByFts("Grundbuch")).contains(doc.getId());
|
||||||
|
|
||||||
blockRepository.deleteById(block.getId());
|
blockRepository.deleteById(block.getId());
|
||||||
em.flush();
|
em.flush();
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
assertThat(documentRepository.findAllMatchingIdsByFts("Grundbuch")).doesNotContain(doc.getId());
|
assertThat(documentRepository.findRankedIdsByFts("Grundbuch")).doesNotContain(doc.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Ranking ───────────────────────────────────────────────────────────────
|
// ─── Ranking ───────────────────────────────────────────────────────────────
|
||||||
@@ -166,7 +166,7 @@ class DocumentFtsTest {
|
|||||||
em.flush();
|
em.flush();
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("Grundbuch");
|
List<UUID> ids = documentRepository.findRankedIdsByFts("Grundbuch");
|
||||||
|
|
||||||
assertThat(ids).hasSize(2);
|
assertThat(ids).hasSize(2);
|
||||||
assertThat(ids.get(0)).isEqualTo(docA.getId());
|
assertThat(ids.get(0)).isEqualTo(docA.getId());
|
||||||
@@ -179,7 +179,7 @@ class DocumentFtsTest {
|
|||||||
documentRepository.saveAndFlush(document("Ein Brief von der Oma"));
|
documentRepository.saveAndFlush(document("Ein Brief von der Oma"));
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("der die das und");
|
List<UUID> ids = documentRepository.findRankedIdsByFts("der die das und");
|
||||||
|
|
||||||
assertThat(ids).isEmpty();
|
assertThat(ids).isEmpty();
|
||||||
}
|
}
|
||||||
@@ -195,7 +195,7 @@ class DocumentFtsTest {
|
|||||||
em.flush();
|
em.flush();
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("Wille");
|
List<UUID> ids = documentRepository.findRankedIdsByFts("Wille");
|
||||||
|
|
||||||
assertThat(ids).contains(doc.getId());
|
assertThat(ids).contains(doc.getId());
|
||||||
}
|
}
|
||||||
@@ -205,7 +205,7 @@ class DocumentFtsTest {
|
|||||||
documentRepository.saveAndFlush(document("Brief"));
|
documentRepository.saveAndFlush(document("Brief"));
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
assertThatNoException().isThrownBy(() -> documentRepository.findAllMatchingIdsByFts("((("));
|
assertThatNoException().isThrownBy(() -> documentRepository.findRankedIdsByFts("((("));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Weight C: sender/receiver names ───────────────────────────────────────
|
// ─── Weight C: sender/receiver names ───────────────────────────────────────
|
||||||
@@ -223,7 +223,7 @@ class DocumentFtsTest {
|
|||||||
em.flush();
|
em.flush();
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("Schmidt");
|
List<UUID> ids = documentRepository.findRankedIdsByFts("Schmidt");
|
||||||
|
|
||||||
assertThat(ids).contains(doc.getId());
|
assertThat(ids).contains(doc.getId());
|
||||||
}
|
}
|
||||||
@@ -241,7 +241,7 @@ class DocumentFtsTest {
|
|||||||
em.flush();
|
em.flush();
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("Raddatz");
|
List<UUID> ids = documentRepository.findRankedIdsByFts("Raddatz");
|
||||||
|
|
||||||
assertThat(ids).contains(doc.getId());
|
assertThat(ids).contains(doc.getId());
|
||||||
}
|
}
|
||||||
@@ -260,7 +260,7 @@ class DocumentFtsTest {
|
|||||||
em.flush();
|
em.flush();
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("Familiengeschichte");
|
List<UUID> ids = documentRepository.findRankedIdsByFts("Familiengeschichte");
|
||||||
|
|
||||||
assertThat(ids).hasSize(1);
|
assertThat(ids).hasSize(1);
|
||||||
}
|
}
|
||||||
@@ -278,7 +278,7 @@ class DocumentFtsTest {
|
|||||||
em.flush();
|
em.flush();
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
List<UUID> rankedIds = documentRepository.findAllMatchingIdsByFts("Grundbuch");
|
List<UUID> rankedIds = documentRepository.findRankedIdsByFts("Grundbuch");
|
||||||
Specification<Document> spec = Specification.where(hasIds(rankedIds))
|
Specification<Document> spec = Specification.where(hasIds(rankedIds))
|
||||||
.and(hasStatus(DocumentStatus.UPLOADED));
|
.and(hasStatus(DocumentStatus.UPLOADED));
|
||||||
|
|
||||||
|
|||||||
@@ -21,22 +21,17 @@ import org.springframework.data.domain.Pageable;
|
|||||||
import org.springframework.data.jpa.domain.Specification;
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyInt;
|
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
|
||||||
import static org.mockito.Mockito.never;
|
|
||||||
import static org.mockito.Mockito.verify;
|
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
class DocumentServiceSortTest {
|
class DocumentServiceSortTest {
|
||||||
|
|
||||||
private static final Pageable PAGE = org.springframework.data.domain.PageRequest.of(0, 10_000);
|
private static final Pageable UNPAGED = org.springframework.data.domain.PageRequest.of(0, 10_000);
|
||||||
|
|
||||||
@Mock DocumentRepository documentRepository;
|
@Mock DocumentRepository documentRepository;
|
||||||
@Mock PersonService personService;
|
@Mock PersonService personService;
|
||||||
@@ -48,12 +43,12 @@ class DocumentServiceSortTest {
|
|||||||
@Mock TranscriptionBlockQueryService transcriptionBlockQueryService;
|
@Mock TranscriptionBlockQueryService transcriptionBlockQueryService;
|
||||||
@InjectMocks DocumentService documentService;
|
@InjectMocks DocumentService documentService;
|
||||||
|
|
||||||
// ─── DATE sort ────────────────────────────────────────────────────────────
|
// ─── searchDocuments — DATE sort ──────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void searchDocuments_with_DATE_sort_and_text_sorts_chronologically_not_by_relevance() {
|
void searchDocuments_with_DATE_sort_and_text_sorts_chronologically_not_by_relevance() {
|
||||||
UUID id1 = UUID.randomUUID(); // higher relevance, older doc
|
UUID id1 = UUID.randomUUID(); // rank position 0 (higher relevance, older doc)
|
||||||
UUID id2 = UUID.randomUUID(); // lower relevance, newer doc
|
UUID id2 = UUID.randomUUID(); // rank position 1 (lower relevance, newer doc)
|
||||||
|
|
||||||
Document older = Document.builder().id(id1)
|
Document older = Document.builder().id(id1)
|
||||||
.title("Brief").status(DocumentStatus.UPLOADED)
|
.title("Brief").status(DocumentStatus.UPLOADED)
|
||||||
@@ -62,48 +57,38 @@ class DocumentServiceSortTest {
|
|||||||
.title("Brief").status(DocumentStatus.UPLOADED)
|
.title("Brief").status(DocumentStatus.UPLOADED)
|
||||||
.documentDate(LocalDate.of(1960, 1, 1)).build();
|
.documentDate(LocalDate.of(1960, 1, 1)).build();
|
||||||
|
|
||||||
when(documentRepository.findAllMatchingIdsByFts("Brief")).thenReturn(List.of(id1, id2));
|
// FTS returns id1 first (higher rank), id2 second
|
||||||
|
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(id1, id2));
|
||||||
|
// findAll(spec, pageable) — the correct date path — returns date-DESC order
|
||||||
when(documentRepository.findAll(any(Specification.class), any(Pageable.class)))
|
when(documentRepository.findAll(any(Specification.class), any(Pageable.class)))
|
||||||
.thenReturn(new PageImpl<>(List.of(newer, older)));
|
.thenReturn(new PageImpl<>(List.of(newer, older)));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
"Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null, PAGE);
|
"Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null, UNPAGED);
|
||||||
|
|
||||||
|
// Expect: date order (newer 1960 first), NOT rank order (older 1940 first)
|
||||||
assertThat(result.items()).hasSize(2);
|
assertThat(result.items()).hasSize(2);
|
||||||
assertThat(result.items().get(0).document().getId()).isEqualTo(id2); // newer first
|
assertThat(result.items().get(0).document().getId()).isEqualTo(id2); // newer doc first
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── RELEVANCE sort — pure text (no filters) ──────────────────────────────
|
// ─── searchDocuments — RELEVANCE sort ─────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
|
||||||
void searchDocuments_relevance_pureText_calls_findFtsPageRaw_not_findAllMatchingIds() {
|
|
||||||
UUID id1 = UUID.randomUUID();
|
|
||||||
List<Object[]> ftsRows = ftsRows(id1, 0.5d, 1L);
|
|
||||||
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
|
||||||
when(documentRepository.findAllById(any()))
|
|
||||||
.thenReturn(List.of(doc(id1)));
|
|
||||||
|
|
||||||
documentService.searchDocuments(
|
|
||||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, PAGE);
|
|
||||||
|
|
||||||
verify(documentRepository).findFtsPageRaw(anyString(), anyInt(), anyInt());
|
|
||||||
verify(documentRepository, never()).findAllMatchingIdsByFts(anyString());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void searchDocuments_with_RELEVANCE_sort_and_text_preserves_fts_rank_order() {
|
void searchDocuments_with_RELEVANCE_sort_and_text_preserves_fts_rank_order() {
|
||||||
UUID id1 = UUID.randomUUID(); // higher rank — must appear first
|
UUID id1 = UUID.randomUUID(); // rank position 0
|
||||||
UUID id2 = UUID.randomUUID(); // lower rank
|
UUID id2 = UUID.randomUUID(); // rank position 1
|
||||||
|
|
||||||
List<Object[]> ftsRows = new ArrayList<>();
|
Document doc1 = Document.builder().id(id1).title("Brief").status(DocumentStatus.UPLOADED).build();
|
||||||
ftsRows.add(new Object[]{id1, 0.8d, 2L});
|
Document doc2 = Document.builder().id(id2).title("Brief").status(DocumentStatus.UPLOADED).build();
|
||||||
ftsRows.add(new Object[]{id2, 0.3d, 2L});
|
|
||||||
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(id1, id2));
|
||||||
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(id2), doc(id1))); // unordered from JPA
|
when(documentRepository.findAll(any(Specification.class)))
|
||||||
|
.thenReturn(List.of(doc2, doc1)); // unordered from DB
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, PAGE);
|
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, UNPAGED);
|
||||||
|
|
||||||
|
// Expect: rank order restored (id1 first)
|
||||||
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
|
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,82 +97,16 @@ class DocumentServiceSortTest {
|
|||||||
UUID id1 = UUID.randomUUID();
|
UUID id1 = UUID.randomUUID();
|
||||||
UUID id2 = UUID.randomUUID();
|
UUID id2 = UUID.randomUUID();
|
||||||
|
|
||||||
List<Object[]> ftsRows = new ArrayList<>();
|
Document doc1 = Document.builder().id(id1).title("Brief").status(DocumentStatus.UPLOADED).build();
|
||||||
ftsRows.add(new Object[]{id1, 0.8d, 2L});
|
Document doc2 = Document.builder().id(id2).title("Brief").status(DocumentStatus.UPLOADED).build();
|
||||||
ftsRows.add(new Object[]{id2, 0.3d, 2L});
|
|
||||||
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(id1, id2));
|
||||||
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(id2), doc(id1)));
|
when(documentRepository.findAll(any(Specification.class)))
|
||||||
|
.thenReturn(List.of(doc2, doc1));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
"Brief", null, null, null, null, null, null, null, null, null, null, PAGE);
|
"Brief", null, null, null, null, null, null, null, null, null, null, UNPAGED);
|
||||||
|
|
||||||
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
|
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── RELEVANCE sort — overflow guard ─────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void searchDocuments_relevance_returns_empty_when_offset_exceeds_maxInt() {
|
|
||||||
// offset = pageNumber * pageSize; choose values so offset > Integer.MAX_VALUE
|
|
||||||
Pageable hugePage = org.springframework.data.domain.PageRequest.of(Integer.MAX_VALUE / 10 + 1, 10);
|
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
|
||||||
"Brief", null, null, null, null, null, null, null,
|
|
||||||
DocumentSort.RELEVANCE, null, null, hugePage);
|
|
||||||
|
|
||||||
assertThat(result.items()).isEmpty();
|
|
||||||
verify(documentRepository, never()).findFtsPageRaw(anyString(), anyInt(), anyInt());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── toFtsPage — UUID-as-String JDBC driver variance ────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void searchDocuments_relevance_handles_string_uuid_from_jdbc_driver() {
|
|
||||||
String stringId = "11111111-1111-1111-1111-111111111111";
|
|
||||||
UUID uuidId = UUID.fromString(stringId);
|
|
||||||
// Simulate a JDBC driver that returns the id column as String instead of UUID
|
|
||||||
List<Object[]> ftsRows = new ArrayList<>();
|
|
||||||
ftsRows.add(new Object[]{stringId, 0.5d, 1L});
|
|
||||||
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
|
||||||
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(uuidId)));
|
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
|
||||||
"Brief", null, null, null, null, null, null, null,
|
|
||||||
DocumentSort.RELEVANCE, null, null, PAGE);
|
|
||||||
|
|
||||||
assertThat(result.items()).hasSize(1);
|
|
||||||
assertThat(result.items().get(0).document().getId()).isEqualTo(uuidId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── RELEVANCE sort — text + active filter ────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void searchDocuments_relevance_with_active_filter_uses_inMemory_path() {
|
|
||||||
UUID id1 = UUID.randomUUID();
|
|
||||||
UUID id2 = UUID.randomUUID();
|
|
||||||
|
|
||||||
when(documentRepository.findAllMatchingIdsByFts("Brief")).thenReturn(List.of(id1, id2));
|
|
||||||
when(documentRepository.findAll(any(Specification.class)))
|
|
||||||
.thenReturn(List.of(doc(id2), doc(id1)));
|
|
||||||
|
|
||||||
// sender filter is active → triggers in-memory path, not findFtsPageRaw
|
|
||||||
LocalDate from = LocalDate.of(1900, 1, 1);
|
|
||||||
documentService.searchDocuments(
|
|
||||||
"Brief", from, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, PAGE);
|
|
||||||
|
|
||||||
verify(documentRepository, never()).findFtsPageRaw(anyString(), anyInt(), anyInt());
|
|
||||||
verify(documentRepository).findAllMatchingIdsByFts("Brief");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private static Document doc(UUID id) {
|
|
||||||
return Document.builder().id(id).title("Brief").status(DocumentStatus.UPLOADED).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<Object[]> ftsRows(UUID id, double rank, long total) {
|
|
||||||
List<Object[]> rows = new ArrayList<>();
|
|
||||||
rows.add(new Object[]{id, rank, total});
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1620,10 +1620,9 @@ class DocumentServiceTest {
|
|||||||
// chr(1)=\u0001 marks start, chr(2)=\u0002 marks end of highlighted term
|
// chr(1)=\u0001 marks start, chr(2)=\u0002 marks end of highlighted term
|
||||||
List<Object[]> rows = Collections.singletonList(new Object[]{docId, "\u0001Brief\u0002 an Anna", null, false, null, null, null});
|
List<Object[]> rows = Collections.singletonList(new Object[]{docId, "\u0001Brief\u0002 an Anna", null, false, null, null, null});
|
||||||
|
|
||||||
List<Object[]> ftsRows = new java.util.ArrayList<>();
|
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(docId));
|
||||||
ftsRows.add(new Object[]{docId, 0.5d, 1L});
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
||||||
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
.thenReturn(List.of(doc));
|
||||||
when(documentRepository.findAllById(any())).thenReturn(List.of(doc));
|
|
||||||
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
@@ -1655,10 +1654,9 @@ class DocumentServiceTest {
|
|||||||
String snippetHeadline = "Hier ist der \u0001Brief\u0002 aus Berlin";
|
String snippetHeadline = "Hier ist der \u0001Brief\u0002 aus Berlin";
|
||||||
List<Object[]> rows = Collections.singletonList(new Object[]{docId, "Dok", snippetHeadline, false, null, null, null});
|
List<Object[]> rows = Collections.singletonList(new Object[]{docId, "Dok", snippetHeadline, false, null, null, null});
|
||||||
|
|
||||||
List<Object[]> snippetFtsRows = new java.util.ArrayList<>();
|
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(docId));
|
||||||
snippetFtsRows.add(new Object[]{docId, 0.5d, 1L});
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
||||||
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(snippetFtsRows);
|
.thenReturn(List.of(doc));
|
||||||
when(documentRepository.findAllById(any())).thenReturn(List.of(doc));
|
|
||||||
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
@@ -2204,7 +2202,7 @@ class DocumentServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void findIdsForFilter_returnsEmpty_whenFtsHasNoMatches() {
|
void findIdsForFilter_returnsEmpty_whenFtsHasNoMatches() {
|
||||||
when(documentRepository.findAllMatchingIdsByFts("xyz")).thenReturn(List.of());
|
when(documentRepository.findRankedIdsByFts("xyz")).thenReturn(List.of());
|
||||||
|
|
||||||
List<UUID> result = documentService.findIdsForFilter(
|
List<UUID> result = documentService.findIdsForFilter(
|
||||||
"xyz", null, null, null, null, null, null, null, null);
|
"xyz", null, null, null, null, null, null, null, null);
|
||||||
@@ -2388,7 +2386,7 @@ class DocumentServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getDensity_shortCircuits_whenFtsReturnsNoMatches() {
|
void getDensity_shortCircuits_whenFtsReturnsNoMatches() {
|
||||||
when(documentRepository.findAllMatchingIdsByFts("xyz")).thenReturn(List.of());
|
when(documentRepository.findRankedIdsByFts("xyz")).thenReturn(List.of());
|
||||||
|
|
||||||
DocumentDensityResult result = documentService.getDensity(
|
DocumentDensityResult result = documentService.getDensity(
|
||||||
new DensityFilters("xyz", null, null, null, null, null, null));
|
new DensityFilters("xyz", null, null, null, null, null, null));
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
# ADR-008: SQL-level pagination for full-text search via window-function CTE
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Accepted
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
`DocumentRepository.findAllMatchingIdsByFts` (formerly `findRankedIdsByFts`) returns all matching document IDs for a FTS query. `DocumentService.searchDocuments` then paginates in memory on the RELEVANCE sort path.
|
|
||||||
|
|
||||||
A pre-production audit against 1,520 documents measured:
|
|
||||||
|
|
||||||
```
|
|
||||||
rows_per_call: 911 / call (query: "walter")
|
|
||||||
```
|
|
||||||
|
|
||||||
At current scale this is acceptable — 911 UUIDs ≈ 14 KB, ms-level DB time. At 100 K+ documents two failure modes emerge:
|
|
||||||
|
|
||||||
1. **Memory**: a broad query returns ~60 K UUIDs ≈ 1 MB per request, multiplied by concurrent users.
|
|
||||||
2. **Latency**: the `LATERAL` join does work proportional to match-set size; at 60 K matches the FTS step alone exceeds 100 ms per query.
|
|
||||||
|
|
||||||
Tracked as finding **F-31 (High)** in the pre-production architectural review.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
Push pagination and rank ordering into SQL for the RELEVANCE sort path when no non-text filters are active (pure full-text search):
|
|
||||||
|
|
||||||
```sql
|
|
||||||
WITH q AS (
|
|
||||||
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
|
|
||||||
THEN to_tsquery('simple', regexp_replace(
|
|
||||||
websearch_to_tsquery('german', :query)::text,
|
|
||||||
'''([^'']+)''', '''\\1'':*', 'g'))
|
|
||||||
END AS pq
|
|
||||||
), matches AS (
|
|
||||||
SELECT d.id, ts_rank(d.search_vector, q.pq) AS rank
|
|
||||||
FROM documents d, q
|
|
||||||
WHERE d.search_vector @@ q.pq
|
|
||||||
)
|
|
||||||
SELECT id, rank, COUNT(*) OVER () AS total
|
|
||||||
FROM matches
|
|
||||||
ORDER BY rank DESC, id
|
|
||||||
OFFSET :offset LIMIT :limit
|
|
||||||
```
|
|
||||||
|
|
||||||
`COUNT(*) OVER ()` returns the full match count alongside each page row in a single round-trip — no separate count query needed.
|
|
||||||
|
|
||||||
`rows_per_call` for the FTS query drops from match-set size (911) to page size (≤ 50).
|
|
||||||
|
|
||||||
When non-text filters (date range, sender, receiver, tags, status) are also active, the existing path is preserved: `findAllMatchingIdsByFts` returns all ranked IDs, which are passed as an `IN` clause to the JPA Specification, and `totalElements` comes from the JPA `Page.getTotalElements()`. This keeps the count accurate across the combined filter set.
|
|
||||||
|
|
||||||
## Alternatives Considered
|
|
||||||
|
|
||||||
**1. Two-query approach (separate COUNT + paged SELECT)**
|
|
||||||
Correct, but doubles round-trips. The window function achieves the same result in one query.
|
|
||||||
|
|
||||||
**2. Capped result set with a user-visible warning**
|
|
||||||
Return at most N results (e.g. 500) and show "showing top 500 of many results". Simpler, but degrades UX for broad queries and doesn't reduce latency proportionally (still scans N rows).
|
|
||||||
|
|
||||||
**3. Full SQL rewrite combining FTS + JPA Specification filters**
|
|
||||||
Possible via a native query that embeds all filter predicates. Eliminates the in-memory SENDER/RECEIVER sort paths and the two-phase approach. High complexity, tight coupling to schema details, loses type-safe JPA Specification composition. Deferred to a future refactor if scale demands it.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- **`rows_per_call` for pure-text FTS searches drops to ≤ page size** — the primary metric.
|
|
||||||
- **SENDER and RECEIVER sort paths stay in-memory** for combined text+filter queries. For pure-text queries with SENDER/RECEIVER sort, the current approach (fetch all matched IDs, build spec, load all matched entities, sort in-memory) still runs. This is acceptable while the archive stays under ~10 K documents.
|
|
||||||
- **RELEVANCE sort with text+filters still loads the full filtered entity set in-memory.** The filtered set is typically much smaller than the raw FTS match set, so the cost is bounded by filter selectivity, not total match count.
|
|
||||||
- **`findAllMatchingIdsByFts` is retained** for: (a) the bulk-edit "select all" fast path (`findIdsForFilter`), (b) the document density chart (`getDensity`), and (c) the SENDER/RECEIVER in-memory sort paths.
|
|
||||||
@@ -459,9 +459,17 @@
|
|||||||
"dashboard_reader_recent_docs_heading": "Zuletzt aktualisiert",
|
"dashboard_reader_recent_docs_heading": "Zuletzt aktualisiert",
|
||||||
"dashboard_reader_recent_stories_heading": "Neue Geschichten",
|
"dashboard_reader_recent_stories_heading": "Neue Geschichten",
|
||||||
"dashboard_badge_new": "Neu",
|
"dashboard_badge_new": "Neu",
|
||||||
"dashboard_badge_updated": "Aktualisiert",
|
|
||||||
"dashboard_reader_all_stories": "Alle Geschichten →",
|
"dashboard_reader_all_stories": "Alle Geschichten →",
|
||||||
"dashboard_reader_doc_count_suffix": "Dok.",
|
"dashboard_reader_doc_count_suffix": "Dok.",
|
||||||
|
"dashboard_all_documents": "Alle Dokumente",
|
||||||
|
"dashboard_greeting_time_morning": "Morgen",
|
||||||
|
"dashboard_greeting_time_afternoon": "Mittag",
|
||||||
|
"dashboard_greeting_time_evening": "Abend",
|
||||||
|
"dashboard_welcome": "Herzlich willkommen, {name}.",
|
||||||
|
"dashboard_reader_stats_documents_short": "Dok.",
|
||||||
|
"dashboard_reader_stats_persons_short": "Pers.",
|
||||||
|
"dashboard_reader_stats_stories_short": "Gesch.",
|
||||||
|
"dashboard_reader_draft_meta": "Entwurf · zuletzt bearbeitet {relative}",
|
||||||
"dashboard_resume_label": "Zuletzt geöffnet:",
|
"dashboard_resume_label": "Zuletzt geöffnet:",
|
||||||
"dashboard_resume_fallback": "Unbekanntes Dokument",
|
"dashboard_resume_fallback": "Unbekanntes Dokument",
|
||||||
"doc_status_placeholder": "Platzhalter",
|
"doc_status_placeholder": "Platzhalter",
|
||||||
|
|||||||
@@ -459,9 +459,17 @@
|
|||||||
"dashboard_reader_recent_docs_heading": "Recently Updated",
|
"dashboard_reader_recent_docs_heading": "Recently Updated",
|
||||||
"dashboard_reader_recent_stories_heading": "New Stories",
|
"dashboard_reader_recent_stories_heading": "New Stories",
|
||||||
"dashboard_badge_new": "New",
|
"dashboard_badge_new": "New",
|
||||||
"dashboard_badge_updated": "Updated",
|
|
||||||
"dashboard_reader_all_stories": "All Stories →",
|
"dashboard_reader_all_stories": "All Stories →",
|
||||||
"dashboard_reader_doc_count_suffix": "docs.",
|
"dashboard_reader_doc_count_suffix": "docs.",
|
||||||
|
"dashboard_all_documents": "All Documents",
|
||||||
|
"dashboard_greeting_time_morning": "Morning",
|
||||||
|
"dashboard_greeting_time_afternoon": "Afternoon",
|
||||||
|
"dashboard_greeting_time_evening": "Evening",
|
||||||
|
"dashboard_welcome": "Welcome, {name}.",
|
||||||
|
"dashboard_reader_stats_documents_short": "Docs.",
|
||||||
|
"dashboard_reader_stats_persons_short": "Pers.",
|
||||||
|
"dashboard_reader_stats_stories_short": "Stor.",
|
||||||
|
"dashboard_reader_draft_meta": "Draft · last edited {relative}",
|
||||||
"dashboard_resume_label": "Last opened:",
|
"dashboard_resume_label": "Last opened:",
|
||||||
"dashboard_resume_fallback": "Unknown document",
|
"dashboard_resume_fallback": "Unknown document",
|
||||||
"doc_status_placeholder": "Placeholder",
|
"doc_status_placeholder": "Placeholder",
|
||||||
|
|||||||
@@ -459,9 +459,17 @@
|
|||||||
"dashboard_reader_recent_docs_heading": "Actualizados recientemente",
|
"dashboard_reader_recent_docs_heading": "Actualizados recientemente",
|
||||||
"dashboard_reader_recent_stories_heading": "Nuevas historias",
|
"dashboard_reader_recent_stories_heading": "Nuevas historias",
|
||||||
"dashboard_badge_new": "Nuevo",
|
"dashboard_badge_new": "Nuevo",
|
||||||
"dashboard_badge_updated": "Actualizado",
|
|
||||||
"dashboard_reader_all_stories": "Todas las historias →",
|
"dashboard_reader_all_stories": "Todas las historias →",
|
||||||
"dashboard_reader_doc_count_suffix": "docs.",
|
"dashboard_reader_doc_count_suffix": "docs.",
|
||||||
|
"dashboard_all_documents": "Todos los documentos",
|
||||||
|
"dashboard_greeting_time_morning": "Mañana",
|
||||||
|
"dashboard_greeting_time_afternoon": "Tarde",
|
||||||
|
"dashboard_greeting_time_evening": "Noche",
|
||||||
|
"dashboard_welcome": "Bienvenido, {name}.",
|
||||||
|
"dashboard_reader_stats_documents_short": "Docs.",
|
||||||
|
"dashboard_reader_stats_persons_short": "Pers.",
|
||||||
|
"dashboard_reader_stats_stories_short": "Hist.",
|
||||||
|
"dashboard_reader_draft_meta": "Borrador · editado hace {relative}",
|
||||||
"dashboard_resume_label": "Último abierto:",
|
"dashboard_resume_label": "Último abierto:",
|
||||||
"dashboard_resume_fallback": "Documento desconocido",
|
"dashboard_resume_fallback": "Documento desconocido",
|
||||||
"doc_status_placeholder": "Marcador",
|
"doc_status_placeholder": "Marcador",
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ interface Props {
|
|||||||
restrictToCorrespondentsOf?: string;
|
restrictToCorrespondentsOf?: string;
|
||||||
excludePersonId?: string;
|
excludePersonId?: string;
|
||||||
badge?: 'additive' | 'replace';
|
badge?: 'additive' | 'replace';
|
||||||
resetKey?: number;
|
|
||||||
onchange?: (value: string) => void;
|
onchange?: (value: string) => void;
|
||||||
onfocused?: () => void;
|
onfocused?: () => void;
|
||||||
}
|
}
|
||||||
@@ -40,20 +39,17 @@ let {
|
|||||||
restrictToCorrespondentsOf,
|
restrictToCorrespondentsOf,
|
||||||
excludePersonId,
|
excludePersonId,
|
||||||
badge,
|
badge,
|
||||||
resetKey = 0,
|
|
||||||
onchange,
|
onchange,
|
||||||
onfocused
|
onfocused
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
// searchTerm must be both prop-derived AND locally writable (user typing), so $state +
|
// searchTerm must be both prop-derived AND locally writable (user typing), so $state +
|
||||||
// $effect is the correct pattern here — writable $derived is read-only and won't work.
|
// $effect is the correct pattern here — writable $derived is read-only and won't work.
|
||||||
|
// eslint-disable-next-line svelte/prefer-writable-derived
|
||||||
let searchTerm = $state(initialName);
|
let searchTerm = $state(initialName);
|
||||||
|
|
||||||
// Sync display text when initialName changes OR when resetKey increments (navigation reset).
|
// Sync display text when the selected person changes externally (e.g. swap, navigation).
|
||||||
// resetKey is incremented by the page on every SvelteKit navigation so that a manually-typed
|
|
||||||
// term that was never committed (no person selected) gets cleared even if initialName stays ''.
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
void resetKey;
|
|
||||||
searchTerm = initialName;
|
searchTerm = initialName;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -270,33 +270,6 @@ describe('PersonTypeahead – correspondent mode', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── resetKey ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('PersonTypeahead – resetKey', () => {
|
|
||||||
// Note: rerender() in vitest-browser-svelte causes a full re-mount, not an in-place prop
|
|
||||||
// update. This is a smoke test — the $effect(resetKey) path that fires during SvelteKit
|
|
||||||
// navigation (prop update on a live instance) cannot be isolated at this level.
|
|
||||||
it('clears a manually-typed term when resetKey changes even if initialName stays empty', async () => {
|
|
||||||
mockFetchWithPersons([]);
|
|
||||||
const { rerender } = render(PersonTypeahead, {
|
|
||||||
name: 'senderId',
|
|
||||||
label: 'Absender',
|
|
||||||
initialName: '',
|
|
||||||
resetKey: 0
|
|
||||||
});
|
|
||||||
const input = page.getByPlaceholder('Namen tippen...');
|
|
||||||
|
|
||||||
// User types something without selecting a person
|
|
||||||
await input.fill('Max');
|
|
||||||
await waitForDebounce();
|
|
||||||
await expect.element(input).toHaveValue('Max');
|
|
||||||
|
|
||||||
// Navigation resets: initialName stays '', but resetKey increments
|
|
||||||
await rerender({ name: 'senderId', label: 'Absender', initialName: '', resetKey: 1 });
|
|
||||||
await expect.element(input).toHaveValue('');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Click outside ────────────────────────────────────────────────────────────
|
// ─── Click outside ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('PersonTypeahead – click outside', () => {
|
describe('PersonTypeahead – click outside', () => {
|
||||||
|
|||||||
@@ -12,24 +12,47 @@ interface Props {
|
|||||||
const { drafts }: Props = $props();
|
const { drafts }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
<div
|
||||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
class="flex flex-col overflow-hidden rounded-sm border border-l-[3px] border-line border-l-brand-mint bg-surface"
|
||||||
{m.dashboard_reader_drafts_heading()}
|
>
|
||||||
</h2>
|
<!-- Card-head -->
|
||||||
|
<div class="flex items-center border-b border-line px-3 py-1.5">
|
||||||
|
<h3 class="text-[11px] font-bold tracking-[.12em] text-ink-3 uppercase">
|
||||||
|
{m.dashboard_reader_drafts_heading()}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if drafts.length === 0}
|
{#if drafts.length === 0}
|
||||||
<p class="font-sans text-sm text-ink-3">{m.dashboard_reader_drafts_empty()}</p>
|
<p class="px-3 py-3 font-sans text-sm text-ink-3">{m.dashboard_reader_drafts_empty()}</p>
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="flex flex-col gap-2">
|
<ul class="flex flex-col">
|
||||||
{#each drafts as draft (draft.id)}
|
{#each drafts as draft (draft.id)}
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="/geschichten/{draft.id}/edit"
|
href="/geschichten/{draft.id}/edit"
|
||||||
class="flex min-h-[44px] items-center justify-between gap-4 rounded-sm py-2 transition-colors hover:text-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
class="flex min-h-[44px] items-center justify-between border-b border-line/50 px-3 py-1.5 last:border-b-0 hover:bg-muted focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
||||||
>
|
>
|
||||||
<span class="text-ink-1 truncate font-serif text-sm">{draft.title}</span>
|
<span class="flex min-w-0 flex-col">
|
||||||
<span class="shrink-0 font-sans text-xs text-ink-3">
|
<span class="truncate font-serif text-sm text-ink">{draft.title}</span>
|
||||||
{relativeTimeDe(new Date(draft.updatedAt))}
|
<span class="text-[11px] text-ink-3">
|
||||||
|
{m.dashboard_reader_draft_meta({ relative: relativeTimeDe(new Date(draft.updatedAt)) })}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
<svg
|
||||||
|
width="7"
|
||||||
|
height="7"
|
||||||
|
viewBox="0 0 7 7"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="shrink-0 text-ink-3"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M1.5 1 L5.5 3.5 L1.5 6"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -36,10 +36,12 @@ describe('ReaderDraftsModule', () => {
|
|||||||
await expect.element(link2).toHaveAttribute('href', '/geschichten/g2/edit');
|
await expect.element(link2).toHaveAttribute('href', '/geschichten/g2/edit');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows heading "Meine Entwürfe"', async () => {
|
it('shows heading as h3 (not h2)', async () => {
|
||||||
render(ReaderDraftsModule, { drafts: [draft1] });
|
render(ReaderDraftsModule, { drafts: [draft1] });
|
||||||
const heading = page.getByRole('heading', { name: /Meine Entwürfe/i });
|
const h3 = page.getByRole('heading', { level: 3 });
|
||||||
await expect.element(heading).toBeInTheDocument();
|
await expect.element(h3).toBeInTheDocument();
|
||||||
|
const h2 = page.getByRole('heading', { level: 2 });
|
||||||
|
await expect.element(h2).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows empty state when drafts is empty', async () => {
|
it('shows empty state when drafts is empty', async () => {
|
||||||
@@ -53,4 +55,45 @@ describe('ReaderDraftsModule', () => {
|
|||||||
const emptyText = page.getByText(/Keine Entwürfe/i);
|
const emptyText = page.getByText(/Keine Entwürfe/i);
|
||||||
await expect.element(emptyText).not.toBeInTheDocument();
|
await expect.element(emptyText).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('card wrapper has mint left-border classes', async () => {
|
||||||
|
render(ReaderDraftsModule, { drafts: [draft1] });
|
||||||
|
const h3 = page.getByRole('heading', { level: 3 });
|
||||||
|
const card = ((await h3.element()) as HTMLElement).closest('div[class]');
|
||||||
|
const rootCard = card?.parentElement;
|
||||||
|
const cls = rootCard?.className ?? '';
|
||||||
|
expect(cls).toMatch(/border-l-\[3px\]/);
|
||||||
|
expect(cls).toMatch(/border-l-brand-mint/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draft-row link has min-h-[44px] touch target', async () => {
|
||||||
|
render(ReaderDraftsModule, { drafts: [draft1] });
|
||||||
|
const link = page.getByRole('link', { name: /Mein erster Entwurf/ });
|
||||||
|
const cls = ((await link.element()) as HTMLElement).className;
|
||||||
|
expect(cls).toMatch(/min-h-\[44px\]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draft title has text-ink class', async () => {
|
||||||
|
render(ReaderDraftsModule, { drafts: [draft1] });
|
||||||
|
const link = page.getByRole('link', { name: /Mein erster Entwurf/ });
|
||||||
|
const el = (await link.element()) as HTMLElement;
|
||||||
|
const titleEl = el.querySelector('[class*="text-ink"]');
|
||||||
|
expect(titleEl).not.toBeNull();
|
||||||
|
expect(titleEl?.textContent?.trim()).toBe('Mein erster Entwurf');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draft meta contains "Entwurf" text', async () => {
|
||||||
|
render(ReaderDraftsModule, { drafts: [draft1] });
|
||||||
|
const link = page.getByRole('link', { name: /Mein erster Entwurf/ });
|
||||||
|
const el = (await link.element()) as HTMLElement;
|
||||||
|
expect(el.textContent).toMatch(/Entwurf/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chevron SVG is present in each draft row', async () => {
|
||||||
|
render(ReaderDraftsModule, { drafts: [draft1] });
|
||||||
|
const link = page.getByRole('link', { name: /Mein erster Entwurf/ });
|
||||||
|
const el = (await link.element()) as HTMLElement;
|
||||||
|
const svg = el.querySelector('svg');
|
||||||
|
expect(svg).not.toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
87
frontend/src/lib/shared/dashboard/ReaderHeaderBar.svelte
Normal file
87
frontend/src/lib/shared/dashboard/ReaderHeaderBar.svelte
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
name: string;
|
||||||
|
documents: number | null;
|
||||||
|
persons: number | null;
|
||||||
|
stories: number | null;
|
||||||
|
hour?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, documents, persons, stories, hour }: Props = $props();
|
||||||
|
|
||||||
|
const timeLabel = $derived.by(() => {
|
||||||
|
const h = hour ?? new Date().getHours();
|
||||||
|
if (h < 12) return m.dashboard_greeting_time_morning();
|
||||||
|
if (h < 18) return m.dashboard_greeting_time_afternoon();
|
||||||
|
return m.dashboard_greeting_time_evening();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header
|
||||||
|
class="flex flex-col items-start gap-4 rounded-sm border border-line bg-surface px-4 py-3 sm:flex-row sm:items-center dark:border-white/8"
|
||||||
|
>
|
||||||
|
<!-- Greeting -->
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<span class="block text-[11px] font-bold tracking-[.8px] text-ink uppercase">
|
||||||
|
{timeLabel}
|
||||||
|
</span>
|
||||||
|
<span class="block font-serif text-xl text-ink">
|
||||||
|
{m.dashboard_welcome({ name })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Vertical divider — desktop only -->
|
||||||
|
<div class="hidden w-px shrink-0 self-stretch bg-line sm:block" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<div
|
||||||
|
class="flex w-full items-center border-t border-line-2 pt-1.5 sm:w-auto sm:border-t-0 sm:pt-0"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="/documents"
|
||||||
|
class="flex min-h-[44px] flex-col items-center justify-center border-r border-line-2 px-5 focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
||||||
|
>
|
||||||
|
<span class="block text-2xl leading-none font-black text-ink">{documents ?? '—'}</span>
|
||||||
|
<span
|
||||||
|
class="mt-0.5 hidden text-[11px] font-bold tracking-[.8px] text-ink-3 uppercase sm:block"
|
||||||
|
>{m.dashboard_reader_stats_documents()}</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="mt-0.5 block text-[11px] font-bold tracking-[.8px] text-ink-3 uppercase sm:hidden"
|
||||||
|
>{m.dashboard_reader_stats_documents_short()}</span
|
||||||
|
>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/persons"
|
||||||
|
class="flex min-h-[44px] flex-col items-center justify-center border-r border-line-2 px-5 focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
||||||
|
>
|
||||||
|
<span class="block text-2xl leading-none font-black text-ink">{persons ?? '—'}</span>
|
||||||
|
<span
|
||||||
|
class="mt-0.5 hidden text-[11px] font-bold tracking-[.8px] text-ink-3 uppercase sm:block"
|
||||||
|
>{m.dashboard_reader_stats_persons()}</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="mt-0.5 block text-[11px] font-bold tracking-[.8px] text-ink-3 uppercase sm:hidden"
|
||||||
|
>{m.dashboard_reader_stats_persons_short()}</span
|
||||||
|
>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/geschichten"
|
||||||
|
class="flex min-h-[44px] flex-col items-center justify-center px-5 focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
||||||
|
>
|
||||||
|
<span class="block text-2xl leading-none font-black text-ink">{stories ?? '—'}</span>
|
||||||
|
<span
|
||||||
|
class="mt-0.5 hidden text-[11px] font-bold tracking-[.8px] text-ink-3 uppercase sm:block"
|
||||||
|
>{m.dashboard_reader_stats_stories()}</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="mt-0.5 block text-[11px] font-bold tracking-[.8px] text-ink-3 uppercase sm:hidden"
|
||||||
|
>{m.dashboard_reader_stats_stories_short()}</span
|
||||||
|
>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
|
||||||
|
import ReaderHeaderBar from './ReaderHeaderBar.svelte';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ReaderHeaderBar', () => {
|
||||||
|
it('renders a link to /documents with document count', async () => {
|
||||||
|
render(ReaderHeaderBar, { name: 'Anna', documents: 42, persons: 7, stories: 3 });
|
||||||
|
const link = page.getByRole('link', { name: /42/ });
|
||||||
|
await expect.element(link).toHaveAttribute('href', '/documents');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a link to /persons with person count', async () => {
|
||||||
|
render(ReaderHeaderBar, { name: 'Anna', documents: 42, persons: 7, stories: 3 });
|
||||||
|
const link = page.getByRole('link', { name: /7/ });
|
||||||
|
await expect.element(link).toHaveAttribute('href', '/persons');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a link to /geschichten with story count', async () => {
|
||||||
|
render(ReaderHeaderBar, { name: 'Anna', documents: 42, persons: 7, stories: 3 });
|
||||||
|
const link = page.getByRole('link', { name: /3/ });
|
||||||
|
await expect.element(link).toHaveAttribute('href', '/geschichten');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('documents stat link has min-h-[44px] for touch target', async () => {
|
||||||
|
render(ReaderHeaderBar, { name: 'Anna', documents: 42, persons: 7, stories: 3 });
|
||||||
|
const link = page.getByRole('link', { name: /42/ });
|
||||||
|
const cls = ((await link.element()) as HTMLElement).className;
|
||||||
|
expect(cls).toMatch(/min-h-\[44px\]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persons stat link has min-h-[44px] for touch target', async () => {
|
||||||
|
render(ReaderHeaderBar, { name: 'Anna', documents: 42, persons: 7, stories: 3 });
|
||||||
|
const link = page.getByRole('link', { name: /7/ });
|
||||||
|
const cls = ((await link.element()) as HTMLElement).className;
|
||||||
|
expect(cls).toMatch(/min-h-\[44px\]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stories stat link has min-h-[44px] for touch target', async () => {
|
||||||
|
render(ReaderHeaderBar, { name: 'Anna', documents: 42, persons: 7, stories: 3 });
|
||||||
|
const link = page.getByRole('link', { name: /3/ });
|
||||||
|
const cls = ((await link.element()) as HTMLElement).className;
|
||||||
|
expect(cls).toMatch(/min-h-\[44px\]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "—" when counts are null', async () => {
|
||||||
|
render(ReaderHeaderBar, { name: 'Anna', documents: null, persons: null, stories: null });
|
||||||
|
const wrapper = page.getByRole('banner');
|
||||||
|
const text = ((await wrapper.element()) as HTMLElement).textContent;
|
||||||
|
expect(text?.match(/—/g)?.length).toBeGreaterThanOrEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('time label uses text-ink class for morning hour', async () => {
|
||||||
|
render(ReaderHeaderBar, { name: 'Anna', documents: 1, persons: 1, stories: 1, hour: 8 });
|
||||||
|
const timeLabel = page.getByText(/Morgen/i);
|
||||||
|
await expect.element(timeLabel).toBeInTheDocument();
|
||||||
|
const cls = ((await timeLabel.element()) as HTMLElement).className;
|
||||||
|
expect(cls).toMatch(/\btext-ink\b/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows afternoon label for hour 14', async () => {
|
||||||
|
render(ReaderHeaderBar, { name: 'Anna', documents: 1, persons: 1, stories: 1, hour: 14 });
|
||||||
|
const timeLabel = page.getByText(/Mittag/i);
|
||||||
|
await expect.element(timeLabel).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows evening label for hour 20', async () => {
|
||||||
|
render(ReaderHeaderBar, { name: 'Anna', documents: 1, persons: 1, stories: 1, hour: 20 });
|
||||||
|
const timeLabel = page.getByText(/Abend/i);
|
||||||
|
await expect.element(timeLabel).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('welcome line contains the user name', async () => {
|
||||||
|
render(ReaderHeaderBar, { name: 'Anna', documents: 1, persons: 1, stories: 1, hour: 8 });
|
||||||
|
const welcome = page.getByText(/Anna/);
|
||||||
|
await expect.element(welcome).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wrapper uses bg-surface (CSS-variable-backed, dark-mode-aware)', async () => {
|
||||||
|
render(ReaderHeaderBar, { name: 'Anna', documents: 1, persons: 1, stories: 1 });
|
||||||
|
const wrapper = page.getByRole('banner');
|
||||||
|
const cls = ((await wrapper.element()) as HTMLElement).className;
|
||||||
|
expect(cls).toMatch(/\bbg-surface\b/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a vertical divider with bg-line class', async () => {
|
||||||
|
render(ReaderHeaderBar, { name: 'Anna', documents: 1, persons: 1, stories: 1 });
|
||||||
|
const wrapper = page.getByRole('banner');
|
||||||
|
const el = (await wrapper.element()) as HTMLElement;
|
||||||
|
const divider = el.querySelector('[aria-hidden="true"]');
|
||||||
|
expect(divider).not.toBeNull();
|
||||||
|
expect(divider!.className).toMatch(/bg-line/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -27,37 +27,38 @@ interface Props {
|
|||||||
const { persons }: Props = $props();
|
const { persons }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-4">
|
<section aria-label={m.dashboard_reader_person_chips_heading()}>
|
||||||
<h2 class="text-xs font-bold tracking-widest text-ink-3 uppercase">
|
|
||||||
{m.dashboard_reader_person_chips_heading()}
|
|
||||||
</h2>
|
|
||||||
{#if persons.length === 0}
|
{#if persons.length === 0}
|
||||||
<p class="font-sans text-sm text-ink-3">{m.dashboard_reader_no_persons()}</p>
|
<p class="font-sans text-sm text-ink-3">{m.dashboard_reader_no_persons()}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
{#each persons as p (p.id)}
|
{#each persons as p (p.id)}
|
||||||
<a
|
<a
|
||||||
href="/persons/{p.id}"
|
href="/persons/{p.id}"
|
||||||
class="flex min-h-[44px] items-center gap-2 rounded-sm border border-line bg-surface px-3 py-2 shadow-sm transition-colors hover:border-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
class="group flex min-h-[44px] flex-col items-center gap-2 rounded border border-line bg-surface px-4 py-6 text-center no-underline shadow-sm transition-all duration-200 hover:border-l-4 hover:border-accent hover:shadow-md focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white"
|
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-full text-base font-bold text-white shadow-sm dark:shadow-none dark:ring-1 dark:ring-white/10"
|
||||||
style="background-color: {personAvatarColor(p.id ?? '')}"
|
style="background-color: {personAvatarColor(p.id ?? '')}"
|
||||||
>
|
>
|
||||||
{getInitials(p.displayName ?? p.lastName ?? '')}
|
{getInitials(p.displayName ?? p.lastName ?? '')}
|
||||||
</span>
|
</span>
|
||||||
<span class="flex min-w-0 flex-col">
|
<span class="truncate font-serif text-sm font-bold text-ink group-hover:underline"
|
||||||
<span class="text-ink-1 truncate font-serif text-sm">{p.displayName ?? p.lastName}</span>
|
>{p.displayName ?? p.lastName}</span
|
||||||
<span class="font-sans text-xs text-ink-3"
|
>
|
||||||
>{p.documentCount ?? 0} {m.dashboard_reader_doc_count_suffix()}</span
|
{#if (p.documentCount ?? 0) > 0}
|
||||||
|
<span
|
||||||
|
class="mt-1 inline-flex items-center rounded-full border border-line bg-muted px-2.5 py-0.5 font-sans text-[11px] font-semibold text-ink-2"
|
||||||
>
|
>
|
||||||
</span>
|
{p.documentCount}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
href="/persons"
|
href="/persons"
|
||||||
class="inline-flex min-h-[44px] items-center self-end rounded-sm font-sans text-sm text-brand-navy underline hover:text-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none"
|
class="mt-1 flex min-h-[44px] items-center justify-end text-right text-xs font-semibold text-ink-2 no-underline focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
||||||
>{m.dashboard_reader_all_persons()}</a
|
>{m.dashboard_reader_all_persons()}</a
|
||||||
>
|
>
|
||||||
</div>
|
</section>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const person2: PersonSummaryDTO = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('ReaderPersonChips', () => {
|
describe('ReaderPersonChips', () => {
|
||||||
it('renders a chip for each person with correct href', async () => {
|
it('renders a card for each person with correct href', async () => {
|
||||||
render(ReaderPersonChips, { persons: [person1, person2] });
|
render(ReaderPersonChips, { persons: [person1, person2] });
|
||||||
const link1 = page.getByRole('link', { name: /Anna Müller/ });
|
const link1 = page.getByRole('link', { name: /Anna Müller/ });
|
||||||
await expect
|
await expect
|
||||||
@@ -44,12 +44,46 @@ describe('ReaderPersonChips', () => {
|
|||||||
.toHaveAttribute('href', '/persons/aaaaaaaa-0000-0000-0000-000000000002');
|
.toHaveAttribute('href', '/persons/aaaaaaaa-0000-0000-0000-000000000002');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows document count in each chip', async () => {
|
it('person card has min-h-[44px] touch target', async () => {
|
||||||
render(ReaderPersonChips, { persons: [person1] });
|
render(ReaderPersonChips, { persons: [person1] });
|
||||||
const chip = page.getByRole('link', { name: /Anna Müller/ });
|
const link = page.getByRole('link', { name: /Anna Müller/ });
|
||||||
await expect.element(chip).toBeInTheDocument();
|
const cls = ((await link.element()) as HTMLElement).className;
|
||||||
const text = ((await chip.element()) as HTMLElement).textContent;
|
expect(cls).toMatch(/min-h-\[44px\]/);
|
||||||
expect(text).toContain('23');
|
});
|
||||||
|
|
||||||
|
it('doc count renders as neutral chip with bg-muted', async () => {
|
||||||
|
render(ReaderPersonChips, { persons: [person1] });
|
||||||
|
const link = page.getByRole('link', { name: /Anna Müller/ });
|
||||||
|
const el = (await link.element()) as HTMLElement;
|
||||||
|
const chip = el.querySelector('[class*="bg-muted"]');
|
||||||
|
expect(chip).not.toBeNull();
|
||||||
|
expect(chip!.textContent).toContain('23');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('doc count chip has rounded-full and border-line classes', async () => {
|
||||||
|
render(ReaderPersonChips, { persons: [person1] });
|
||||||
|
const link = page.getByRole('link', { name: /Anna Müller/ });
|
||||||
|
const el = (await link.element()) as HTMLElement;
|
||||||
|
const chip = el.querySelector('[class*="bg-muted"]');
|
||||||
|
expect(chip).not.toBeNull();
|
||||||
|
expect(chip!.className).toMatch(/rounded-full/);
|
||||||
|
expect(chip!.className).toMatch(/border-line/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('person grid uses grid layout', async () => {
|
||||||
|
render(ReaderPersonChips, { persons: [person1, person2] });
|
||||||
|
const section = page.getByRole('region');
|
||||||
|
const el = (await section.element()) as HTMLElement;
|
||||||
|
const grid = el.querySelector('[class*="grid"]');
|
||||||
|
expect(grid).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wrapper is a section with aria-label', async () => {
|
||||||
|
render(ReaderPersonChips, { persons: [person1] });
|
||||||
|
const section = page.getByRole('region');
|
||||||
|
await expect.element(section).toBeInTheDocument();
|
||||||
|
const label = ((await section.element()) as HTMLElement).getAttribute('aria-label');
|
||||||
|
expect(label).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders an "Alle Personen" link to /persons', async () => {
|
it('renders an "Alle Personen" link to /persons', async () => {
|
||||||
@@ -58,6 +92,13 @@ describe('ReaderPersonChips', () => {
|
|||||||
await expect.element(allLink).toHaveAttribute('href', '/persons');
|
await expect.element(allLink).toHaveAttribute('href', '/persons');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('"Alle Personen" link has text-ink-2 class', async () => {
|
||||||
|
render(ReaderPersonChips, { persons: [person1] });
|
||||||
|
const allLink = page.getByRole('link', { name: /Alle Personen/i });
|
||||||
|
const cls = ((await allLink.element()) as HTMLElement).className;
|
||||||
|
expect(cls).toMatch(/text-ink-2/);
|
||||||
|
});
|
||||||
|
|
||||||
it('exposes a focus-visible ring on the "Alle Personen" link', async () => {
|
it('exposes a focus-visible ring on the "Alle Personen" link', async () => {
|
||||||
render(ReaderPersonChips, { persons: [person1] });
|
render(ReaderPersonChips, { persons: [person1] });
|
||||||
const allLink = page.getByRole('link', { name: /Alle Personen/i });
|
const allLink = page.getByRole('link', { name: /Alle Personen/i });
|
||||||
@@ -73,7 +114,13 @@ describe('ReaderPersonChips', () => {
|
|||||||
expect(cls).toMatch(/min-h-\[44px\]/);
|
expect(cls).toMatch(/min-h-\[44px\]/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders empty state without chips when persons array is empty', async () => {
|
it('does not render h2 heading', async () => {
|
||||||
|
render(ReaderPersonChips, { persons: [person1] });
|
||||||
|
const heading = page.getByRole('heading', { level: 2 });
|
||||||
|
await expect.element(heading).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders empty state without person cards when persons array is empty', async () => {
|
||||||
render(ReaderPersonChips, { persons: [] });
|
render(ReaderPersonChips, { persons: [] });
|
||||||
const chips = page.getByRole('link', { name: /Müller|Schmidt/ });
|
const chips = page.getByRole('link', { name: /Müller|Schmidt/ });
|
||||||
await expect.element(chips).not.toBeInTheDocument();
|
await expect.element(chips).not.toBeInTheDocument();
|
||||||
|
|||||||
@@ -16,49 +16,71 @@ function isNew(doc: Document): boolean {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
<div class="flex flex-col overflow-hidden rounded-sm border border-line bg-surface">
|
||||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
<!-- Card-head -->
|
||||||
{m.dashboard_reader_recent_docs_heading()}
|
<div class="flex items-center justify-between border-b border-line px-3 py-1.5">
|
||||||
</h2>
|
<h3 class="text-[11px] font-bold tracking-[.12em] text-ink-3 uppercase">
|
||||||
<ul class="flex flex-col divide-y divide-line">
|
{m.dashboard_reader_recent_docs_heading()}
|
||||||
|
</h3>
|
||||||
|
<a
|
||||||
|
href="/documents"
|
||||||
|
class="flex min-h-[44px] items-center text-[11px] font-semibold text-ink-2 no-underline focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
||||||
|
>
|
||||||
|
{m.dashboard_all_documents()}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Doc list -->
|
||||||
|
<ul class="flex flex-col">
|
||||||
{#each documents as doc (doc.id)}
|
{#each documents as doc (doc.id)}
|
||||||
<li class="py-3 first:pt-0 last:pb-0">
|
<li>
|
||||||
<div class="flex items-start justify-between gap-3">
|
<a
|
||||||
<div class="flex min-w-0 flex-col gap-1">
|
href="/documents/{doc.id}"
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
class="flex min-h-[44px] items-center gap-2 border-b border-line/50 px-3 py-3 last:border-b-0 hover:bg-muted focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
||||||
<a
|
>
|
||||||
href="/documents/{doc.id}"
|
<!-- Thumb -->
|
||||||
class="text-ink-1 truncate rounded-sm font-serif text-sm transition-colors hover:text-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
<span
|
||||||
>
|
class="flex h-6 w-5 shrink-0 items-center justify-center rounded-[2px] border border-line bg-canvas"
|
||||||
{doc.title}
|
>
|
||||||
</a>
|
<svg
|
||||||
|
width="10"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 10 12"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="text-ink-3"
|
||||||
|
>
|
||||||
|
<path d="M1 1h5.5L9 3.5V11H1V1z" stroke="currentColor" stroke-width="1" fill="none" />
|
||||||
|
<path d="M6 1v3h3" stroke="currentColor" stroke-width="1" fill="none" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Middle -->
|
||||||
|
<span class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<span class="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span class="truncate font-serif text-sm text-ink">{doc.title}</span>
|
||||||
{#if isNew(doc)}
|
{#if isNew(doc)}
|
||||||
<span
|
<span
|
||||||
class="rounded bg-brand-mint/20 px-1.5 py-0.5 font-sans text-xs font-bold tracking-wide text-brand-navy uppercase"
|
class="shrink-0 rounded-full bg-accent-bg px-1.5 py-px text-[11px] font-bold text-ink"
|
||||||
>
|
>
|
||||||
{m.dashboard_badge_new()}
|
{m.dashboard_badge_new()}
|
||||||
</span>
|
</span>
|
||||||
{:else}
|
|
||||||
<span
|
|
||||||
class="text-ink-1 rounded bg-ink-3/10 px-1.5 py-0.5 font-sans text-xs font-bold tracking-wide uppercase"
|
|
||||||
>
|
|
||||||
{m.dashboard_badge_updated()}
|
|
||||||
</span>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</span>
|
||||||
{#if doc.sender}
|
<span class="text-xs text-ink-3">
|
||||||
<a
|
{#if doc.sender}
|
||||||
href="/persons/{doc.sender.id}"
|
|
||||||
class="font-sans text-xs text-ink-3 transition-colors hover:text-brand-mint"
|
|
||||||
>
|
|
||||||
{doc.sender.displayName ?? doc.sender.lastName}
|
{doc.sender.displayName ?? doc.sender.lastName}
|
||||||
</a>
|
{:else}
|
||||||
{/if}
|
—
|
||||||
</div>
|
{/if}
|
||||||
<span class="shrink-0 font-sans text-xs text-ink-3">
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Date -->
|
||||||
|
<span class="shrink-0 text-[11px] text-ink-3">
|
||||||
{relativeTimeDe(new Date(doc.updatedAt))}
|
{relativeTimeDe(new Date(doc.updatedAt))}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -37,30 +37,73 @@ describe('ReaderRecentDocs', () => {
|
|||||||
await expect.element(link).toHaveAttribute('href', '/documents/doc1');
|
await expect.element(link).toHaveAttribute('href', '/documents/doc1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows "Neu" badge when createdAt equals updatedAt', async () => {
|
it('card has overflow-hidden and flex-col classes (no p-6, no shadow-sm)', async () => {
|
||||||
|
render(ReaderRecentDocs, { documents: [baseDoc] });
|
||||||
|
const heading = page.getByRole('heading', { level: 3 });
|
||||||
|
const card = (await heading.element())?.closest('div');
|
||||||
|
const rootCard = card?.parentElement;
|
||||||
|
const cls = rootCard?.className ?? '';
|
||||||
|
expect(cls).toMatch(/overflow-hidden/);
|
||||||
|
expect(cls).toMatch(/flex-col/);
|
||||||
|
expect(cls).not.toMatch(/\bp-6\b/);
|
||||||
|
expect(cls).not.toMatch(/shadow-sm/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('card-head contains an h3 (not h2)', async () => {
|
||||||
|
render(ReaderRecentDocs, { documents: [baseDoc] });
|
||||||
|
const h3 = page.getByRole('heading', { level: 3 });
|
||||||
|
await expect.element(h3).toBeInTheDocument();
|
||||||
|
const h2 = page.getByRole('heading', { level: 2 });
|
||||||
|
await expect.element(h2).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"Alle Dokumente" link in card-head points to /documents', async () => {
|
||||||
|
render(ReaderRecentDocs, { documents: [baseDoc] });
|
||||||
|
const link = page.getByRole('link', { name: /Alle Dokumente/i });
|
||||||
|
await expect.element(link).toHaveAttribute('href', '/documents');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"Alle Dokumente" link has min-h-[44px]', async () => {
|
||||||
|
render(ReaderRecentDocs, { documents: [baseDoc] });
|
||||||
|
const link = page.getByRole('link', { name: /Alle Dokumente/i });
|
||||||
|
const cls = ((await link.element()) as HTMLElement).className;
|
||||||
|
expect(cls).toMatch(/min-h-\[44px\]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('doc-row link has min-h-[44px] touch target', async () => {
|
||||||
|
render(ReaderRecentDocs, { documents: [baseDoc] });
|
||||||
|
const link = page.getByRole('link', { name: /Brief an Hans/ });
|
||||||
|
const cls = ((await link.element()) as HTMLElement).className;
|
||||||
|
expect(cls).toMatch(/min-h-\[44px\]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('thumb element has correct classes', async () => {
|
||||||
|
render(ReaderRecentDocs, { documents: [baseDoc] });
|
||||||
|
const link = page.getByRole('link', { name: /Brief an Hans/ });
|
||||||
|
const el = (await link.element()) as HTMLElement;
|
||||||
|
const thumb = el.querySelector('[class*="w-5"][class*="h-6"]');
|
||||||
|
expect(thumb).not.toBeNull();
|
||||||
|
expect(thumb!.className).toMatch(/bg-canvas/);
|
||||||
|
expect(thumb!.className).toMatch(/border-line/);
|
||||||
|
expect(thumb!.className).toMatch(/rounded-/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Neu" accent-pill badge when createdAt equals updatedAt', async () => {
|
||||||
render(ReaderRecentDocs, { documents: [baseDoc] });
|
render(ReaderRecentDocs, { documents: [baseDoc] });
|
||||||
const badge = page.getByText(/^Neu$/i);
|
const badge = page.getByText(/^Neu$/i);
|
||||||
await expect.element(badge).toBeInTheDocument();
|
await expect.element(badge).toBeInTheDocument();
|
||||||
});
|
|
||||||
|
|
||||||
it('shows "Aktualisiert" badge when updatedAt differs from createdAt', async () => {
|
|
||||||
render(ReaderRecentDocs, { documents: [updatedDoc] });
|
|
||||||
const badge = page.getByText(/^Aktualisiert$/i);
|
|
||||||
await expect.element(badge).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders the "Aktualisiert" badge with high-contrast text-ink-1', async () => {
|
|
||||||
render(ReaderRecentDocs, { documents: [updatedDoc] });
|
|
||||||
const badge = page.getByText(/^Aktualisiert$/i);
|
|
||||||
const cls = ((await badge.element()) as HTMLElement).className;
|
const cls = ((await badge.element()) as HTMLElement).className;
|
||||||
expect(cls).toMatch(/text-ink-1/);
|
expect(cls).toMatch(/bg-accent-bg/);
|
||||||
expect(cls).not.toMatch(/text-ink-3(?!\/)/);
|
expect(cls).toMatch(/rounded-full/);
|
||||||
|
expect(cls).toMatch(/\btext-ink\b/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not show "Neu" badge when updatedAt differs from createdAt', async () => {
|
it('shows no badge when updatedAt differs from createdAt', async () => {
|
||||||
render(ReaderRecentDocs, { documents: [updatedDoc] });
|
render(ReaderRecentDocs, { documents: [updatedDoc] });
|
||||||
const badge = page.getByText(/^Neu$/i);
|
const badge = page.getByText(/^Neu$/i);
|
||||||
await expect.element(badge).not.toBeInTheDocument();
|
await expect.element(badge).not.toBeInTheDocument();
|
||||||
|
const updatedBadge = page.getByText(/^Aktualisiert$/i);
|
||||||
|
await expect.element(updatedBadge).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows "Neu" badge when createdAt and updatedAt represent the same instant in different ISO formats', async () => {
|
it('shows "Neu" badge when createdAt and updatedAt represent the same instant in different ISO formats', async () => {
|
||||||
@@ -75,7 +118,7 @@ describe('ReaderRecentDocs', () => {
|
|||||||
await expect.element(badge).toBeInTheDocument();
|
await expect.element(badge).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders sender link when sender is present', async () => {
|
it('renders sender name text when sender is present', async () => {
|
||||||
const docWithSender: Document = {
|
const docWithSender: Document = {
|
||||||
...baseDoc,
|
...baseDoc,
|
||||||
sender: {
|
sender: {
|
||||||
@@ -88,7 +131,15 @@ describe('ReaderRecentDocs', () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
render(ReaderRecentDocs, { documents: [docWithSender] });
|
render(ReaderRecentDocs, { documents: [docWithSender] });
|
||||||
const senderLink = page.getByRole('link', { name: /Anna Müller/ });
|
const link = page.getByRole('link', { name: /Brief an Hans/ });
|
||||||
await expect.element(senderLink).toHaveAttribute('href', '/persons/p1');
|
const el = (await link.element()) as HTMLElement;
|
||||||
|
expect(el.textContent).toContain('Anna Müller');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows em-dash when sender is absent', async () => {
|
||||||
|
render(ReaderRecentDocs, { documents: [baseDoc] });
|
||||||
|
const link = page.getByRole('link', { name: /Brief an Hans/ });
|
||||||
|
const el = (await link.element()) as HTMLElement;
|
||||||
|
expect(el.textContent).toContain('—');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,33 +24,38 @@ function excerpt(body: string | undefined): string {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if stories.length > 0}
|
{#if stories.length > 0}
|
||||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
<div class="flex flex-col overflow-hidden rounded-sm border border-line bg-surface">
|
||||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
<!-- Card-head -->
|
||||||
{m.dashboard_reader_recent_stories_heading()}
|
<div class="flex items-center justify-between border-b border-line px-3 py-1.5">
|
||||||
</h2>
|
<h3 class="text-[11px] font-bold tracking-[.12em] text-ink-3 uppercase">
|
||||||
<ul class="flex flex-col divide-y divide-line">
|
{m.dashboard_reader_recent_stories_heading()}
|
||||||
|
</h3>
|
||||||
|
<a
|
||||||
|
href="/geschichten"
|
||||||
|
class="flex min-h-[44px] items-center text-[11px] font-semibold text-ink-2 no-underline focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
||||||
|
>
|
||||||
|
{m.dashboard_reader_all_stories()}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Story list -->
|
||||||
|
<ul class="flex flex-col">
|
||||||
{#each stories as story (story.id)}
|
{#each stories as story (story.id)}
|
||||||
<li class="py-4 first:pt-0 last:pb-0">
|
<li>
|
||||||
<a
|
<a
|
||||||
href="/geschichten/{story.id}"
|
href="/geschichten/{story.id}"
|
||||||
class="flex flex-col gap-1 rounded-sm transition-colors hover:text-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
class="flex min-h-[44px] flex-col gap-1 border-b border-line/50 px-3 py-2 last:border-b-0 hover:bg-muted focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
||||||
>
|
>
|
||||||
<span class="text-ink-1 font-serif text-base italic">{story.title}</span>
|
<span class="font-serif text-base text-ink italic">{story.title}</span>
|
||||||
{#if story.body}
|
{#if story.body}
|
||||||
<p class="line-clamp-2 font-sans text-xs text-ink-3">{excerpt(story.body)}</p>
|
<p class="line-clamp-2 text-xs leading-relaxed text-ink-2">{excerpt(story.body)}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="font-sans text-xs text-ink-3">
|
<span class="text-[11px] text-ink-3">
|
||||||
{relativeTimeDe(new Date(story.publishedAt ?? story.updatedAt))}
|
{relativeTimeDe(new Date(story.publishedAt ?? story.updatedAt))}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
<a
|
|
||||||
href="/geschichten"
|
|
||||||
class="mt-4 inline-flex min-h-[44px] items-center rounded-sm font-sans text-sm text-brand-navy underline hover:text-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none"
|
|
||||||
>
|
|
||||||
{m.dashboard_reader_all_stories()}
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ describe('ReaderRecentStories', () => {
|
|||||||
await expect.element(links).not.toBeInTheDocument();
|
await expect.element(links).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders "Alle Geschichten" link', async () => {
|
it('renders "Alle Geschichten" link pointing to /geschichten', async () => {
|
||||||
render(ReaderRecentStories, { stories: [story1] });
|
render(ReaderRecentStories, { stories: [story1] });
|
||||||
const allLink = page.getByRole('link', { name: /Alle Geschichten/i });
|
const allLink = page.getByRole('link', { name: /Alle Geschichten/i });
|
||||||
await expect.element(allLink).toHaveAttribute('href', '/geschichten');
|
await expect.element(allLink).toHaveAttribute('href', '/geschichten');
|
||||||
@@ -72,4 +72,44 @@ describe('ReaderRecentStories', () => {
|
|||||||
const cls = ((await allLink.element()) as HTMLElement).className;
|
const cls = ((await allLink.element()) as HTMLElement).className;
|
||||||
expect(cls).toMatch(/min-h-\[44px\]/);
|
expect(cls).toMatch(/min-h-\[44px\]/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('card-head contains an h3 (not h2)', async () => {
|
||||||
|
render(ReaderRecentStories, { stories: [story1] });
|
||||||
|
const h3 = page.getByRole('heading', { level: 3 });
|
||||||
|
await expect.element(h3).toBeInTheDocument();
|
||||||
|
const h2 = page.getByRole('heading', { level: 2 });
|
||||||
|
await expect.element(h2).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('card-head div has border-b and border-line classes', async () => {
|
||||||
|
render(ReaderRecentStories, { stories: [story1] });
|
||||||
|
const h3 = page.getByRole('heading', { level: 3 });
|
||||||
|
const cardHead = ((await h3.element()) as HTMLElement).parentElement;
|
||||||
|
expect(cardHead?.className).toMatch(/border-b/);
|
||||||
|
expect(cardHead?.className).toMatch(/border-line/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"Alle Geschichten" link is inside the card-head (sibling of h3)', async () => {
|
||||||
|
render(ReaderRecentStories, { stories: [story1] });
|
||||||
|
const h3 = page.getByRole('heading', { level: 3 });
|
||||||
|
const cardHead = ((await h3.element()) as HTMLElement).parentElement;
|
||||||
|
const allLink = cardHead?.querySelector('a');
|
||||||
|
expect(allLink).not.toBeNull();
|
||||||
|
expect(allLink?.textContent?.trim()).toMatch(/Alle Geschichten/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('story-row link has min-h-[44px] touch target', async () => {
|
||||||
|
render(ReaderRecentStories, { stories: [story1] });
|
||||||
|
const link = page.getByRole('link', { name: /Die Familie Müller/ });
|
||||||
|
const cls = ((await link.element()) as HTMLElement).className;
|
||||||
|
expect(cls).toMatch(/min-h-\[44px\]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excerpt has text-ink-2 class', async () => {
|
||||||
|
render(ReaderRecentStories, { stories: [story1] });
|
||||||
|
const link = page.getByRole('link', { name: /Die Familie Müller/ });
|
||||||
|
const el = (await link.element()) as HTMLElement;
|
||||||
|
const excerptEl = el.querySelector('p');
|
||||||
|
expect(excerptEl?.className).toMatch(/text-ink-2/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import * as m from '$lib/paraglide/messages.js';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
documents: number | null;
|
|
||||||
persons: number | null;
|
|
||||||
stories: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { documents, persons, stories }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="hidden gap-4 sm:flex">
|
|
||||||
<a
|
|
||||||
href="/documents"
|
|
||||||
class="flex min-h-[44px] flex-col items-center gap-1 rounded-sm border border-line bg-surface px-5 py-3 shadow-sm transition-colors hover:border-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
|
||||||
>
|
|
||||||
<span class="font-serif text-2xl font-bold text-brand-navy">{documents ?? '—'}</span>
|
|
||||||
<span class="font-sans text-xs tracking-widest text-ink-3 uppercase"
|
|
||||||
>{m.dashboard_reader_stats_documents()}</span
|
|
||||||
>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="/persons"
|
|
||||||
class="flex min-h-[44px] flex-col items-center gap-1 rounded-sm border border-line bg-surface px-5 py-3 shadow-sm transition-colors hover:border-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
|
||||||
>
|
|
||||||
<span class="font-serif text-2xl font-bold text-brand-navy">{persons ?? '—'}</span>
|
|
||||||
<span class="font-sans text-xs tracking-widest text-ink-3 uppercase"
|
|
||||||
>{m.dashboard_reader_stats_persons()}</span
|
|
||||||
>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="/geschichten"
|
|
||||||
class="flex min-h-[44px] flex-col items-center gap-1 rounded-sm border border-line bg-surface px-5 py-3 shadow-sm transition-colors hover:border-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
|
||||||
>
|
|
||||||
<span class="font-serif text-2xl font-bold text-brand-navy">{stories ?? '—'}</span>
|
|
||||||
<span class="font-sans text-xs tracking-widest text-ink-3 uppercase"
|
|
||||||
>{m.dashboard_reader_stats_stories()}</span
|
|
||||||
>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { describe, it, expect, afterEach } from 'vitest';
|
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
|
||||||
import { page } from 'vitest/browser';
|
|
||||||
|
|
||||||
import ReaderStatsStrip from './ReaderStatsStrip.svelte';
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('ReaderStatsStrip', () => {
|
|
||||||
it('renders a link to /documents', async () => {
|
|
||||||
render(ReaderStatsStrip, { documents: 42, persons: 7, stories: 3 });
|
|
||||||
const link = page.getByRole('link', { name: /42/ });
|
|
||||||
await expect.element(link).toHaveAttribute('href', '/documents');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders a link to /persons', async () => {
|
|
||||||
render(ReaderStatsStrip, { documents: 42, persons: 7, stories: 3 });
|
|
||||||
const link = page.getByRole('link', { name: /7/ });
|
|
||||||
await expect.element(link).toHaveAttribute('href', '/persons');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders a link to /geschichten', async () => {
|
|
||||||
render(ReaderStatsStrip, { documents: 42, persons: 7, stories: 3 });
|
|
||||||
const link = page.getByRole('link', { name: /3/ });
|
|
||||||
await expect.element(link).toHaveAttribute('href', '/geschichten');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows "—" when documents count is null', async () => {
|
|
||||||
render(ReaderStatsStrip, { documents: null, persons: null, stories: null });
|
|
||||||
const links = page.getByRole('link');
|
|
||||||
await expect.element(links.first()).toBeInTheDocument();
|
|
||||||
const text = ((await links.first().element()) as HTMLElement).textContent;
|
|
||||||
expect(text).toContain('—');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from 'svelte';
|
|
||||||
import { isoToGerman, handleGermanDateInput, germanToIso } from '$lib/shared/utils/date';
|
import { isoToGerman, handleGermanDateInput, germanToIso } from '$lib/shared/utils/date';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
@@ -25,16 +24,6 @@ let {
|
|||||||
|
|
||||||
let display = $state(isoToGerman(value ?? ''));
|
let display = $state(isoToGerman(value ?? ''));
|
||||||
|
|
||||||
// Re-derive display when value changes externally (e.g. timeline drag, reset nav).
|
|
||||||
// Guard prevents overwriting while the user is mid-typing a partial date:
|
|
||||||
// germanToIso returns '' for partial input, matching value '' → no re-derive.
|
|
||||||
$effect(() => {
|
|
||||||
const externalIso = value ?? '';
|
|
||||||
if (germanToIso(untrack(() => display)) !== externalIso) {
|
|
||||||
display = isoToGerman(externalIso);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Validation helper ────────────────────────────────────────────────────
|
// ─── Validation helper ────────────────────────────────────────────────────
|
||||||
function isCalendarValid(iso: string): boolean {
|
function isCalendarValid(iso: string): boolean {
|
||||||
if (!iso) return false;
|
if (!iso) return false;
|
||||||
|
|||||||
@@ -183,26 +183,6 @@ describe('DateInput – clearing the date', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── External value changes ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('DateInput – external value changes', () => {
|
|
||||||
it('clears display when value prop is reset to empty externally', async () => {
|
|
||||||
const { rerender } = render(DateInput, { value: '1920-01-01' });
|
|
||||||
const input = page.getByRole('textbox');
|
|
||||||
await expect.element(input).toHaveValue('01.01.1920');
|
|
||||||
await rerender({ value: '' });
|
|
||||||
await expect.element(input).toHaveValue('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates display when value prop changes to a new date externally', async () => {
|
|
||||||
const { rerender } = render(DateInput, { value: '1920-01-01' });
|
|
||||||
const input = page.getByRole('textbox');
|
|
||||||
await expect.element(input).toHaveValue('01.01.1920');
|
|
||||||
await rerender({ value: '1945-05-08' });
|
|
||||||
await expect.element(input).toHaveValue('08.05.1945');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Hidden input ─────────────────────────────────────────────────────────────
|
// ─── Hidden input ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('DateInput – hidden input for form submission', () => {
|
describe('DateInput – hidden input for form submission', () => {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import MissionControlStrip from '$lib/document/MissionControlStrip.svelte';
|
|||||||
import DashboardFamilyPulse from '$lib/shared/dashboard/DashboardFamilyPulse.svelte';
|
import DashboardFamilyPulse from '$lib/shared/dashboard/DashboardFamilyPulse.svelte';
|
||||||
import DashboardActivityFeed from '$lib/activity/DashboardActivityFeed.svelte';
|
import DashboardActivityFeed from '$lib/activity/DashboardActivityFeed.svelte';
|
||||||
import EnrichmentBlock from '$lib/document/EnrichmentBlock.svelte';
|
import EnrichmentBlock from '$lib/document/EnrichmentBlock.svelte';
|
||||||
import ReaderStatsStrip from '$lib/shared/dashboard/ReaderStatsStrip.svelte';
|
import ReaderHeaderBar from '$lib/shared/dashboard/ReaderHeaderBar.svelte';
|
||||||
import ReaderPersonChips from '$lib/shared/dashboard/ReaderPersonChips.svelte';
|
import ReaderPersonChips from '$lib/shared/dashboard/ReaderPersonChips.svelte';
|
||||||
import ReaderDraftsModule from '$lib/shared/dashboard/ReaderDraftsModule.svelte';
|
import ReaderDraftsModule from '$lib/shared/dashboard/ReaderDraftsModule.svelte';
|
||||||
import ReaderRecentDocs from '$lib/shared/dashboard/ReaderRecentDocs.svelte';
|
import ReaderRecentDocs from '$lib/shared/dashboard/ReaderRecentDocs.svelte';
|
||||||
@@ -30,15 +30,10 @@ const greetingText = $derived.by(() => {
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<main class="mx-auto max-w-7xl px-4 py-8 font-sans sm:px-6 lg:px-8">
|
<main class="mx-auto max-w-7xl px-4 py-8 font-sans sm:px-6 lg:px-8">
|
||||||
{#if data?.user}
|
|
||||||
<div class="mb-6">
|
|
||||||
<h1 class="font-serif text-[2rem] text-ink">{greetingText}</h1>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if data.isReader}
|
{#if data.isReader}
|
||||||
<div class="flex flex-col gap-5">
|
<div class="flex flex-col gap-5">
|
||||||
<ReaderStatsStrip
|
<ReaderHeaderBar
|
||||||
|
name={data.user?.firstName ?? ''}
|
||||||
documents={data.readerStats?.totalDocuments ?? null}
|
documents={data.readerStats?.totalDocuments ?? null}
|
||||||
persons={data.readerStats?.totalPersons ?? null}
|
persons={data.readerStats?.totalPersons ?? null}
|
||||||
stories={data.readerStats?.totalStories ?? null}
|
stories={data.readerStats?.totalStories ?? null}
|
||||||
@@ -50,16 +45,17 @@ const greetingText = $derived.by(() => {
|
|||||||
|
|
||||||
<ReaderPersonChips persons={data.topPersons ?? []} />
|
<ReaderPersonChips persons={data.topPersons ?? []} />
|
||||||
|
|
||||||
<div class="flex flex-col gap-5 md:flex-row">
|
<div class="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
|
||||||
<div class="flex-[3]">
|
<ReaderRecentDocs documents={data.recentDocs ?? []} />
|
||||||
<ReaderRecentDocs documents={data.recentDocs ?? []} />
|
<ReaderRecentStories stories={data.recentStories ?? []} />
|
||||||
</div>
|
|
||||||
<div class="flex-[2]">
|
|
||||||
<ReaderRecentStories stories={data.recentStories ?? []} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
{#if data?.user}
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="font-serif text-[2rem] text-ink">{greetingText}</h1>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_320px] lg:items-start">
|
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_320px] lg:items-start">
|
||||||
<div class="flex flex-col gap-5">
|
<div class="flex flex-col gap-5">
|
||||||
<DashboardResumeStrip resumeDoc={data.resumeDoc ?? null} />
|
<DashboardResumeStrip resumeDoc={data.resumeDoc ?? null} />
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ let {
|
|||||||
showAdvanced = $bindable(false),
|
showAdvanced = $bindable(false),
|
||||||
initialSenderName = '',
|
initialSenderName = '',
|
||||||
initialReceiverName = '',
|
initialReceiverName = '',
|
||||||
navKey = 0,
|
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
onSearch,
|
onSearch,
|
||||||
onSearchImmediate,
|
onSearchImmediate,
|
||||||
@@ -40,7 +39,6 @@ let {
|
|||||||
showAdvanced?: boolean;
|
showAdvanced?: boolean;
|
||||||
initialSenderName?: string;
|
initialSenderName?: string;
|
||||||
initialReceiverName?: string;
|
initialReceiverName?: string;
|
||||||
navKey?: number;
|
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
onSearch: () => void;
|
onSearch: () => void;
|
||||||
onSearchImmediate?: () => void;
|
onSearchImmediate?: () => void;
|
||||||
@@ -199,7 +197,6 @@ $effect(() => {
|
|||||||
label={m.docs_filter_label_sender()}
|
label={m.docs_filter_label_sender()}
|
||||||
bind:value={senderId}
|
bind:value={senderId}
|
||||||
initialName={initialSenderName}
|
initialName={initialSenderName}
|
||||||
resetKey={navKey}
|
|
||||||
onchange={onSearch}
|
onchange={onSearch}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -215,7 +212,6 @@ $effect(() => {
|
|||||||
label={m.docs_filter_label_receivers()}
|
label={m.docs_filter_label_receivers()}
|
||||||
bind:value={receiverId}
|
bind:value={receiverId}
|
||||||
initialName={initialReceiverName}
|
initialName={initialReceiverName}
|
||||||
resetKey={navKey}
|
|
||||||
onchange={onSearch}
|
onchange={onSearch}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,23 +3,6 @@ import { createApiClient } from '$lib/shared/api.server';
|
|||||||
import { getErrorMessage } from '$lib/shared/errors';
|
import { getErrorMessage } from '$lib/shared/errors';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
||||||
|
|
||||||
async function resolvePersonName(
|
|
||||||
id: string,
|
|
||||||
api: ReturnType<typeof createApiClient>
|
|
||||||
): Promise<string> {
|
|
||||||
if (!UUID_RE.test(id)) return '';
|
|
||||||
try {
|
|
||||||
const result = await api.GET('/api/persons/{id}', { params: { path: { id } } });
|
|
||||||
if (!result.response.ok) return '';
|
|
||||||
return result.data?.displayName ?? '';
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[resolvePersonName] failed for id', id, e);
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
|
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
|
||||||
|
|
||||||
const VALID_SORTS = ['DATE', 'TITLE', 'SENDER', 'RECEIVER', 'UPLOAD_DATE', 'RELEVANCE'] as const;
|
const VALID_SORTS = ['DATE', 'TITLE', 'SENDER', 'RECEIVER', 'UPLOAD_DATE', 'RELEVANCE'] as const;
|
||||||
@@ -51,30 +34,25 @@ export async function load({ url, fetch }) {
|
|||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
|
|
||||||
let result;
|
let result;
|
||||||
let initialSenderName = '';
|
|
||||||
let initialReceiverName = '';
|
|
||||||
try {
|
try {
|
||||||
[result, [initialSenderName, initialReceiverName]] = await Promise.all([
|
result = await api.GET('/api/documents/search', {
|
||||||
api.GET('/api/documents/search', {
|
params: {
|
||||||
params: {
|
query: {
|
||||||
query: {
|
q: q || undefined,
|
||||||
q: q || undefined,
|
from: from || undefined,
|
||||||
from: from || undefined,
|
to: to || undefined,
|
||||||
to: to || undefined,
|
senderId: senderId || undefined,
|
||||||
senderId: senderId || undefined,
|
receiverId: receiverId || undefined,
|
||||||
receiverId: receiverId || undefined,
|
tag: tags.length ? tags : undefined,
|
||||||
tag: tags.length ? tags : undefined,
|
tagQ: tagQ && !tags.length ? tagQ : undefined,
|
||||||
tagQ: tagQ && !tags.length ? tagQ : undefined,
|
tagOp: tagOp === 'OR' ? 'OR' : undefined,
|
||||||
tagOp: tagOp === 'OR' ? 'OR' : undefined,
|
sort,
|
||||||
sort,
|
dir: dir || undefined,
|
||||||
dir: dir || undefined,
|
page,
|
||||||
page,
|
size: PAGE_SIZE
|
||||||
size: PAGE_SIZE
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}),
|
}
|
||||||
Promise.all([resolvePersonName(senderId, api), resolvePersonName(receiverId, api)])
|
});
|
||||||
]);
|
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return {
|
||||||
items: [] as DocumentSearchItem[],
|
items: [] as DocumentSearchItem[],
|
||||||
@@ -87,8 +65,6 @@ export async function load({ url, fetch }) {
|
|||||||
to,
|
to,
|
||||||
senderId,
|
senderId,
|
||||||
receiverId,
|
receiverId,
|
||||||
initialSenderName: '',
|
|
||||||
initialReceiverName: '',
|
|
||||||
tags,
|
tags,
|
||||||
sort,
|
sort,
|
||||||
dir,
|
dir,
|
||||||
@@ -118,8 +94,6 @@ export async function load({ url, fetch }) {
|
|||||||
to,
|
to,
|
||||||
senderId,
|
senderId,
|
||||||
receiverId,
|
receiverId,
|
||||||
initialSenderName,
|
|
||||||
initialReceiverName,
|
|
||||||
tags,
|
tags,
|
||||||
sort,
|
sort,
|
||||||
dir,
|
dir,
|
||||||
|
|||||||
@@ -22,9 +22,6 @@ let from = $state(untrack(() => data.from || ''));
|
|||||||
let to = $state(untrack(() => data.to || ''));
|
let to = $state(untrack(() => data.to || ''));
|
||||||
let senderId = $state(untrack(() => data.senderId || ''));
|
let senderId = $state(untrack(() => data.senderId || ''));
|
||||||
let receiverId = $state(untrack(() => data.receiverId || ''));
|
let receiverId = $state(untrack(() => data.receiverId || ''));
|
||||||
let initialSenderName = $state(untrack(() => data.initialSenderName ?? ''));
|
|
||||||
let initialReceiverName = $state(untrack(() => data.initialReceiverName ?? ''));
|
|
||||||
let navKey = $state(0);
|
|
||||||
let tagNames = $state<{ name: string; id?: string; color?: string; parentId?: string }[]>(
|
let tagNames = $state<{ name: string; id?: string; color?: string; parentId?: string }[]>(
|
||||||
untrack(() => (data.tags || []).map((name: string) => ({ name })))
|
untrack(() => (data.tags || []).map((name: string) => ({ name })))
|
||||||
);
|
);
|
||||||
@@ -210,17 +207,12 @@ async function editAllMatching() {
|
|||||||
|
|
||||||
// Keep local filter state in sync with server data after navigation completes.
|
// Keep local filter state in sync with server data after navigation completes.
|
||||||
// Guard q: skip overwrite while the user is actively typing.
|
// Guard q: skip overwrite while the user is actively typing.
|
||||||
// navKey increments on every navigation so PersonTypeahead clears manually-typed
|
|
||||||
// terms even when initialSenderName/initialReceiverName stays '' across navigations.
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!qFocused) q = data.q || '';
|
if (!qFocused) q = data.q || '';
|
||||||
from = data.from || '';
|
from = data.from || '';
|
||||||
to = data.to || '';
|
to = data.to || '';
|
||||||
senderId = data.senderId || '';
|
senderId = data.senderId || '';
|
||||||
receiverId = data.receiverId || '';
|
receiverId = data.receiverId || '';
|
||||||
initialSenderName = data.initialSenderName ?? '';
|
|
||||||
initialReceiverName = data.initialReceiverName ?? '';
|
|
||||||
untrack(() => navKey++);
|
|
||||||
tagNames = (data.tags || []).map((name: string) => ({ name }));
|
tagNames = (data.tags || []).map((name: string) => ({ name }));
|
||||||
sort = data.sort || 'DATE';
|
sort = data.sort || 'DATE';
|
||||||
dir = data.dir || 'desc';
|
dir = data.dir || 'desc';
|
||||||
@@ -255,9 +247,6 @@ $effect(() => {
|
|||||||
bind:dir={dir}
|
bind:dir={dir}
|
||||||
bind:tagQ={tagQ}
|
bind:tagQ={tagQ}
|
||||||
bind:tagOperator={tagOperator}
|
bind:tagOperator={tagOperator}
|
||||||
initialSenderName={initialSenderName}
|
|
||||||
initialReceiverName={initialReceiverName}
|
|
||||||
navKey={navKey}
|
|
||||||
isLoading={navigating.to !== null}
|
isLoading={navigating.to !== null}
|
||||||
onSearch={handleTextSearch}
|
onSearch={handleTextSearch}
|
||||||
onSearchImmediate={handleImmediateSearch}
|
onSearchImmediate={handleImmediateSearch}
|
||||||
|
|||||||
@@ -167,76 +167,3 @@ describe('documents page load — network error fallback', () => {
|
|||||||
expect(result.items).toEqual([]);
|
expect(result.items).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── person name resolution ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('documents page load — person name resolution', () => {
|
|
||||||
function makeSearchMock(personResult?: { ok: boolean; displayName?: string }) {
|
|
||||||
const mockGet = vi.fn().mockImplementation((path: string) => {
|
|
||||||
if (path === '/api/documents/search') {
|
|
||||||
return Promise.resolve({
|
|
||||||
response: { ok: true, status: 200 },
|
|
||||||
data: { items: [], totalElements: 0, pageNumber: 0, pageSize: 50, totalPages: 0 }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// person lookup via api.GET('/api/persons/{id}', ...)
|
|
||||||
if (!personResult?.ok) {
|
|
||||||
return Promise.resolve({ response: { ok: false, status: 404 }, data: undefined });
|
|
||||||
}
|
|
||||||
return Promise.resolve({
|
|
||||||
response: { ok: true, status: 200 },
|
|
||||||
data: { displayName: personResult.displayName ?? '' }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
|
||||||
typeof createApiClient
|
|
||||||
>);
|
|
||||||
return mockGet;
|
|
||||||
}
|
|
||||||
|
|
||||||
it('returns initialSenderName from person lookup when senderId is a valid UUID', async () => {
|
|
||||||
makeSearchMock({ ok: true, displayName: 'Max Mustermann' });
|
|
||||||
|
|
||||||
const result = await load({
|
|
||||||
url: makeUrl({ senderId: '11111111-1111-1111-1111-111111111111' }),
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.initialSenderName).toBe('Max Mustermann');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns initialReceiverName from person lookup when receiverId is a valid UUID', async () => {
|
|
||||||
makeSearchMock({ ok: true, displayName: 'Anna Musterfrau' });
|
|
||||||
|
|
||||||
const result = await load({
|
|
||||||
url: makeUrl({ receiverId: '22222222-2222-2222-2222-222222222222' }),
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.initialReceiverName).toBe('Anna Musterfrau');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns empty string when senderId is not a valid UUID', async () => {
|
|
||||||
const mockGet = makeSearchMock();
|
|
||||||
|
|
||||||
const result = await load({
|
|
||||||
url: makeUrl({ senderId: 'not-a-uuid' }),
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.initialSenderName).toBe('');
|
|
||||||
// UUID guard fires before any api.GET call — only document search is called
|
|
||||||
expect(mockGet).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns empty string when person api returns 404', async () => {
|
|
||||||
makeSearchMock({ ok: false });
|
|
||||||
|
|
||||||
const result = await load({
|
|
||||||
url: makeUrl({ senderId: '11111111-1111-1111-1111-111111111111' }),
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.initialSenderName).toBe('');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ function makeData(overrides: Record<string, unknown> = {}) {
|
|||||||
to: '',
|
to: '',
|
||||||
senderId: '',
|
senderId: '',
|
||||||
receiverId: '',
|
receiverId: '',
|
||||||
initialSenderName: '',
|
|
||||||
initialReceiverName: '',
|
|
||||||
tags: [],
|
tags: [],
|
||||||
sort: 'DATE',
|
sort: 'DATE',
|
||||||
dir: 'desc',
|
dir: 'desc',
|
||||||
@@ -138,22 +136,6 @@ describe('documents page — URL building', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Sender / receiver name display ──────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('documents page — sender/receiver display', () => {
|
|
||||||
it('pre-fills sender typeahead from initialSenderName when senderId filter is active', async () => {
|
|
||||||
render(Page, {
|
|
||||||
data: makeData({
|
|
||||||
senderId: '11111111-1111-1111-1111-111111111111',
|
|
||||||
initialSenderName: 'Max Mustermann'
|
|
||||||
})
|
|
||||||
});
|
|
||||||
// Advanced filters are auto-shown when senderId is set
|
|
||||||
const inputs = page.getByPlaceholder('Namen tippen...');
|
|
||||||
await expect.element(inputs.first()).toHaveValue('Max Mustermann');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Timeline density widget wiring (#385) ────────────────────────────────────
|
// ─── Timeline density widget wiring (#385) ────────────────────────────────────
|
||||||
|
|
||||||
describe('documents page — timeline density widget', () => {
|
describe('documents page — timeline density widget', () => {
|
||||||
|
|||||||
@@ -1,3 +1 @@
|
|||||||
// Safe: handleAuth in hooks.server.ts redirects unauthenticated requests
|
|
||||||
// before prerendered HTML is visible.
|
|
||||||
export const prerender = true;
|
export const prerender = true;
|
||||||
|
|||||||
@@ -102,13 +102,19 @@ describe('Home page – dashboard layout', () => {
|
|||||||
// ─── Reader dashboard layout ──────────────────────────────────────────────────
|
// ─── Reader dashboard layout ──────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('Home page – reader dashboard layout', () => {
|
describe('Home page – reader dashboard layout', () => {
|
||||||
it('renders ReaderStatsStrip totals when isReader is true', async () => {
|
it('renders reader header-bar totals when isReader is true', async () => {
|
||||||
render(Page, { data: readerData });
|
render(Page, { data: readerData });
|
||||||
await expect.element(page.getByText('34')).toBeInTheDocument();
|
await expect.element(page.getByText('34')).toBeInTheDocument();
|
||||||
await expect.element(page.getByText('12')).toBeInTheDocument();
|
await expect.element(page.getByText('12')).toBeInTheDocument();
|
||||||
await expect.element(page.getByText('5')).toBeInTheDocument();
|
await expect.element(page.getByText('5')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reader branch does not render h1 heading', async () => {
|
||||||
|
render(Page, { data: readerData });
|
||||||
|
const h1 = page.getByRole('heading', { level: 1 });
|
||||||
|
await expect.element(h1).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('renders the recent-docs heading when isReader is true', async () => {
|
it('renders the recent-docs heading when isReader is true', async () => {
|
||||||
render(Page, { data: readerData });
|
render(Page, { data: readerData });
|
||||||
await expect.element(page.getByText('Zuletzt aktualisiert')).toBeInTheDocument();
|
await expect.element(page.getByText('Zuletzt aktualisiert')).toBeInTheDocument();
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ const config = {
|
|||||||
// Consult https://svelte.dev/docs/kit/integrations
|
// Consult https://svelte.dev/docs/kit/integrations
|
||||||
// for more information about preprocessors
|
// for more information about preprocessors
|
||||||
preprocess: vitePreprocess(),
|
preprocess: vitePreprocess(),
|
||||||
kit: { adapter: adapter() }
|
kit: {
|
||||||
|
adapter: adapter(),
|
||||||
|
prerender: { entries: ['/hilfe/transkription'] }
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
Reference in New Issue
Block a user