diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentRepository.java index 8b56a3f8..850f9024 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentRepository.java @@ -179,7 +179,7 @@ public interface DocumentRepository extends JpaRepository, JpaSp ORDER BY HASHTEXT(d.id::text || EXTRACT(WEEK FROM NOW())::int::text) LIMIT :limit """) - List findSegmentationQueue(@Param("limit") int limit); + List findSegmentationQueue(@Param("limit") int limit); /** Documents with annotations but not yet fully reviewed — Transkription column. */ @Query(nativeQuery = true, value = """ @@ -200,7 +200,7 @@ public interface DocumentRepository extends JpaRepository, JpaSp HASHTEXT(d.id::text || EXTRACT(WEEK FROM NOW())::int::text) LIMIT :limit """) - List findTranscriptionQueue(@Param("limit") int limit); + List findTranscriptionQueue(@Param("limit") int limit); /** Documents with reviewed_pct >= 90 % — Lesefertig column. */ @Query(nativeQuery = true, value = """ @@ -223,7 +223,7 @@ public interface DocumentRepository extends JpaRepository, JpaSp ) DESC LIMIT :limit """) - List findReadyToReadQueue(@Param("limit") int limit); + List findReadyToReadQueue(@Param("limit") int limit); /** Weekly pulse: distinct documents that received new work in each pipeline stage. */ @Query(nativeQuery = true, value = """ @@ -237,6 +237,6 @@ public interface DocumentRepository extends JpaRepository, JpaSp WHERE tb.updated_at >= NOW() - INTERVAL '7 days' AND tb.reviewed = true) AS readyCount """) - Object[] findWeeklyStats(); + TranscriptionWeeklyStatsProjection findWeeklyStats(); } \ No newline at end of file diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionQueueProjection.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionQueueProjection.java new file mode 100644 index 00000000..ff23d43e --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionQueueProjection.java @@ -0,0 +1,17 @@ +package org.raddatz.familienarchiv.repository; + +import java.time.LocalDate; +import java.util.UUID; + +/** + * Spring Data projection for a single row in one of the three Mission Control Strip queues. + * Column aliases in the native SQL queries must match these getter names exactly. + */ +public interface TranscriptionQueueProjection { + UUID getId(); + String getTitle(); + LocalDate getDocumentDate(); + int getAnnotationCount(); + int getTextedBlockCount(); + int getReviewedBlockCount(); +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionWeeklyStatsProjection.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionWeeklyStatsProjection.java new file mode 100644 index 00000000..729e0224 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionWeeklyStatsProjection.java @@ -0,0 +1,11 @@ +package org.raddatz.familienarchiv.repository; + +/** + * Spring Data projection for the weekly activity pulse stats. + * Column aliases in the native SQL query must match these getter names exactly. + */ +public interface TranscriptionWeeklyStatsProjection { + long getSegmentationCount(); + long getTranscriptionCount(); + long getReadyCount(); +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionQueueService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionQueueService.java index 9d500e5d..e8936dc8 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionQueueService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionQueueService.java @@ -4,12 +4,10 @@ import lombok.RequiredArgsConstructor; import org.raddatz.familienarchiv.dto.TranscriptionQueueItemDTO; import org.raddatz.familienarchiv.dto.TranscriptionWeeklyStatsDTO; import org.raddatz.familienarchiv.repository.DocumentRepository; +import org.raddatz.familienarchiv.repository.TranscriptionQueueProjection; import org.springframework.stereotype.Service; -import java.math.BigDecimal; -import java.time.LocalDate; import java.util.List; -import java.util.UUID; /** * Serves the three Mission Control Strip queues (Segmentierung / Transkription / Lesefertig) @@ -26,69 +24,41 @@ public class TranscriptionQueueService { public List getSegmentationQueue() { return documentRepository.findSegmentationQueue(DEFAULT_QUEUE_SIZE) .stream() - .map(this::mapRow) + .map(this::toDTO) .toList(); } public List getTranscriptionQueue() { return documentRepository.findTranscriptionQueue(DEFAULT_QUEUE_SIZE) .stream() - .map(this::mapRow) + .map(this::toDTO) .toList(); } public List getReadyToReadQueue() { return documentRepository.findReadyToReadQueue(DEFAULT_QUEUE_SIZE) .stream() - .map(this::mapRow) + .map(this::toDTO) .toList(); } public TranscriptionWeeklyStatsDTO getWeeklyStats() { - Object[] row = documentRepository.findWeeklyStats(); + var stats = documentRepository.findWeeklyStats(); return new TranscriptionWeeklyStatsDTO( - toLong(row[0]), - toLong(row[1]), - toLong(row[2]) + stats.getSegmentationCount(), + stats.getTranscriptionCount(), + stats.getReadyCount() ); } - // --- mapping helpers --- - - private TranscriptionQueueItemDTO mapRow(Object[] row) { - UUID id = toUUID(row[0]); - String title = (String) row[1]; - LocalDate documentDate = toLocalDate(row[2]); - int annotationCount = toInt(row[3]); - int textedBlockCount = toInt(row[4]); - int reviewedBlockCount = toInt(row[5]); - return new TranscriptionQueueItemDTO(id, title, documentDate, - annotationCount, textedBlockCount, reviewedBlockCount); - } - - private UUID toUUID(Object o) { - if (o instanceof UUID u) return u; - return UUID.fromString(o.toString()); - } - - private LocalDate toLocalDate(Object o) { - if (o == null) return null; - if (o instanceof LocalDate d) return d; - if (o instanceof java.sql.Date d) return d.toLocalDate(); - return LocalDate.parse(o.toString()); - } - - private int toInt(Object o) { - if (o == null) return 0; - if (o instanceof Number n) return n.intValue(); - if (o instanceof BigDecimal bd) return bd.intValue(); - return Integer.parseInt(o.toString()); - } - - private long toLong(Object o) { - if (o == null) return 0L; - if (o instanceof Number n) return n.longValue(); - if (o instanceof BigDecimal bd) return bd.longValue(); - return Long.parseLong(o.toString()); + private TranscriptionQueueItemDTO toDTO(TranscriptionQueueProjection p) { + return new TranscriptionQueueItemDTO( + p.getId(), + p.getTitle(), + p.getDocumentDate(), + p.getAnnotationCount(), + p.getTextedBlockCount(), + p.getReviewedBlockCount() + ); } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionQueueServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionQueueServiceTest.java new file mode 100644 index 00000000..cd087b37 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionQueueServiceTest.java @@ -0,0 +1,136 @@ +package org.raddatz.familienarchiv.service; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.raddatz.familienarchiv.dto.TranscriptionQueueItemDTO; +import org.raddatz.familienarchiv.dto.TranscriptionWeeklyStatsDTO; +import org.raddatz.familienarchiv.repository.DocumentRepository; +import org.raddatz.familienarchiv.repository.TranscriptionQueueProjection; +import org.raddatz.familienarchiv.repository.TranscriptionWeeklyStatsProjection; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class TranscriptionQueueServiceTest { + + @Mock DocumentRepository documentRepository; + @InjectMocks TranscriptionQueueService service; + + // ─── getSegmentationQueue ───────────────────────────────────────────────── + + @Test + void getSegmentationQueue_delegatesToRepositoryWithDefaultSize() { + UUID id = UUID.randomUUID(); + TranscriptionQueueProjection proj = mockQueueProjection(id, "Brief von 1920", null, 0, 0, 0); + when(documentRepository.findSegmentationQueue(5)).thenReturn(List.of(proj)); + + List result = service.getSegmentationQueue(); + + verify(documentRepository).findSegmentationQueue(5); + assertThat(result).hasSize(1); + assertThat(result.get(0).id()).isEqualTo(id); + assertThat(result.get(0).title()).isEqualTo("Brief von 1920"); + assertThat(result.get(0).documentDate()).isNull(); + assertThat(result.get(0).annotationCount()).isEqualTo(0); + } + + @Test + void getSegmentationQueue_mapsDocumentDateWhenPresent() { + LocalDate date = LocalDate.of(1920, 6, 15); + TranscriptionQueueProjection proj = mockQueueProjection(UUID.randomUUID(), "Brief", date, 0, 0, 0); + when(documentRepository.findSegmentationQueue(5)).thenReturn(List.of(proj)); + + List result = service.getSegmentationQueue(); + + assertThat(result.get(0).documentDate()).isEqualTo(date); + } + + // ─── getTranscriptionQueue ──────────────────────────────────────────────── + + @Test + void getTranscriptionQueue_delegatesToRepositoryWithDefaultSize() { + UUID id = UUID.randomUUID(); + TranscriptionQueueProjection proj = mockQueueProjection(id, "Tagebuch", LocalDate.of(1943, 1, 1), 3, 1, 0); + when(documentRepository.findTranscriptionQueue(5)).thenReturn(List.of(proj)); + + List result = service.getTranscriptionQueue(); + + verify(documentRepository).findTranscriptionQueue(5); + assertThat(result).hasSize(1); + assertThat(result.get(0).annotationCount()).isEqualTo(3); + assertThat(result.get(0).textedBlockCount()).isEqualTo(1); + assertThat(result.get(0).reviewedBlockCount()).isEqualTo(0); + } + + // ─── getReadyToReadQueue ────────────────────────────────────────────────── + + @Test + void getReadyToReadQueue_delegatesToRepositoryWithDefaultSize() { + TranscriptionQueueProjection proj = mockQueueProjection(UUID.randomUUID(), "Urkunde", null, 4, 4, 4); + when(documentRepository.findReadyToReadQueue(5)).thenReturn(List.of(proj)); + + List result = service.getReadyToReadQueue(); + + verify(documentRepository).findReadyToReadQueue(5); + assertThat(result).hasSize(1); + assertThat(result.get(0).reviewedBlockCount()).isEqualTo(4); + } + + // ─── getWeeklyStats ─────────────────────────────────────────────────────── + + @Test + void getWeeklyStats_mapsProjectionToDTO() { + TranscriptionWeeklyStatsProjection proj = mockStatsProjection(3L, 7L, 2L); + when(documentRepository.findWeeklyStats()).thenReturn(proj); + + TranscriptionWeeklyStatsDTO result = service.getWeeklyStats(); + + assertThat(result.segmentationCount()).isEqualTo(3L); + assertThat(result.transcriptionCount()).isEqualTo(7L); + assertThat(result.readyCount()).isEqualTo(2L); + } + + @Test + void getWeeklyStats_returnsZeros_whenAllCountsAreZero() { + TranscriptionWeeklyStatsProjection proj = mockStatsProjection(0L, 0L, 0L); + when(documentRepository.findWeeklyStats()).thenReturn(proj); + + TranscriptionWeeklyStatsDTO result = service.getWeeklyStats(); + + assertThat(result.segmentationCount()).isEqualTo(0L); + assertThat(result.transcriptionCount()).isEqualTo(0L); + assertThat(result.readyCount()).isEqualTo(0L); + } + + // ─── helpers ───────────────────────────────────────────────────────────── + + private TranscriptionQueueProjection mockQueueProjection( + UUID id, String title, LocalDate documentDate, + int annotationCount, int textedBlockCount, int reviewedBlockCount) { + TranscriptionQueueProjection proj = mock(TranscriptionQueueProjection.class); + when(proj.getId()).thenReturn(id); + when(proj.getTitle()).thenReturn(title); + when(proj.getDocumentDate()).thenReturn(documentDate); + when(proj.getAnnotationCount()).thenReturn(annotationCount); + when(proj.getTextedBlockCount()).thenReturn(textedBlockCount); + when(proj.getReviewedBlockCount()).thenReturn(reviewedBlockCount); + return proj; + } + + private TranscriptionWeeklyStatsProjection mockStatsProjection( + long segmentationCount, long transcriptionCount, long readyCount) { + TranscriptionWeeklyStatsProjection proj = mock(TranscriptionWeeklyStatsProjection.class); + when(proj.getSegmentationCount()).thenReturn(segmentationCount); + when(proj.getTranscriptionCount()).thenReturn(transcriptionCount); + when(proj.getReadyCount()).thenReturn(readyCount); + return proj; + } +}