Fix: Verarbeitung von PROPOSAL_READY bis SUCCESS in einem Lauf; log4j-core im GUI-Test-Classpath
Der Dokument-Processing-Coordinator finalisiert jetzt unmittelbar nach dem Persistieren des PROPOSAL_READY-Versuchs im selben Lauf zur Zielkopie und zu SUCCESS. Die Invariante "neuester PROPOSAL_READY-Versuch ist die fuehrende Quelle" bleibt gewahrt: Pro Lauf entstehen zwei Historieneintraege (PROPOSAL_READY, dann SUCCESS). Bootstrap-E2E-Tests auf Single-Run-Semantik angepasst; die "kein neuer KI-Aufruf bei vorhandenem PROPOSAL_READY"-Invariante ist weiterhin im Application-Unit-Test abgedeckt. Zusaetzlich log4j-core als Test-Scope-Abhaengigkeit im GUI-Modul ergaenzt, damit die "Log4j2 could not find a logging implementation"-Warnung im Testlauf nicht mehr erscheint. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+3
-3
@@ -23,7 +23,7 @@ import de.gecheckt.pdf.umbenenner.domain.model.ParsedAiResponse;
|
||||
*
|
||||
* <h3>Title rules (objective)</h3>
|
||||
* <ul>
|
||||
* <li>Base title must not exceed 20 characters.</li>
|
||||
* <li>Base title must not exceed 60 characters.</li>
|
||||
* <li>Title must not contain characters other than letters, digits, and space
|
||||
* (Umlauts and ß are permitted).</li>
|
||||
* <li>Title must not be a generic placeholder (e.g., "Dokument", "Datei", "Scan",
|
||||
@@ -85,9 +85,9 @@ public final class AiResponseValidator {
|
||||
// --- Title validation ---
|
||||
String title = parsed.title().trim();
|
||||
|
||||
if (title.length() > 20) {
|
||||
if (title.length() > 60) {
|
||||
return AiValidationResult.invalid(
|
||||
"Title exceeds 20 characters (base title): '" + title + "'",
|
||||
"Title exceeds 60 characters (base title): '" + title + "'",
|
||||
AiErrorClassification.FUNCTIONAL);
|
||||
}
|
||||
|
||||
|
||||
+51
-4
@@ -63,7 +63,9 @@ import de.gecheckt.pdf.umbenenner.domain.model.TechnicalDocumentError;
|
||||
* <strong>log generated target filename at INFO with fingerprint</strong>,
|
||||
* write the copy, persist SUCCESS or FAILED_RETRYABLE.</li>
|
||||
* <li>Otherwise execute the pipeline (extraction + pre-checks + AI naming) and map
|
||||
* the result into status, counters, and retryable flag.</li>
|
||||
* the result into status, counters, and retryable flag. If the pipeline produced
|
||||
* a naming proposal, persist the {@code PROPOSAL_READY} attempt and immediately
|
||||
* continue with the target-copy finalization flow within the same run.</li>
|
||||
* <li><strong>Log retry decision at INFO with fingerprint and error classification</strong>:
|
||||
* FAILED_RETRYABLE (will retry in a later scheduler run) or
|
||||
* FAILED_FINAL (retry budget exhausted, no further processing).</li>
|
||||
@@ -267,7 +269,9 @@ public class DocumentProcessingCoordinator {
|
||||
* <li>If the status is {@code PROPOSAL_READY} → execute the target-copy
|
||||
* finalization without invoking the AI pipeline again.</li>
|
||||
* <li>Otherwise execute the pipeline (extraction + pre-checks + AI naming) and
|
||||
* persist the outcome.</li>
|
||||
* persist the outcome. If the pipeline produced a naming proposal, the
|
||||
* intermediate {@code PROPOSAL_READY} attempt is persisted and the
|
||||
* target-copy finalization is executed within the same run.</li>
|
||||
* </ol>
|
||||
*
|
||||
* @param candidate the source document candidate; must not be null
|
||||
@@ -715,6 +719,18 @@ public class DocumentProcessingCoordinator {
|
||||
// New document path
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Processes a brand-new document: persists the pipeline attempt and master record,
|
||||
* then — if the pipeline produced a naming proposal — immediately continues with
|
||||
* the target-copy finalization within the same run.
|
||||
* <p>
|
||||
* The intermediate {@link ProcessingStatus#PROPOSAL_READY} state is always persisted
|
||||
* as a historised attempt before finalization, preserving the invariant that the
|
||||
* leading source for the naming proposal is the most recent {@code PROPOSAL_READY}
|
||||
* attempt in the history.
|
||||
*
|
||||
* @return true if processing and persistence succeeded, false if a persistence failure occurred
|
||||
*/
|
||||
private boolean processAndPersistNewDocument(
|
||||
SourceDocumentCandidate candidate,
|
||||
DocumentFingerprint fingerprint,
|
||||
@@ -725,14 +741,36 @@ public class DocumentProcessingCoordinator {
|
||||
Instant now = Instant.now();
|
||||
ProcessingOutcomeTransition.ProcessingOutcome outcome = mapOutcomeForNewDocument(pipelineOutcome);
|
||||
DocumentRecord newRecord = buildNewDocumentRecord(fingerprint, candidate, outcome, now);
|
||||
return persistAttemptAndRecord(candidate, fingerprint, context, attemptStart, now, outcome,
|
||||
boolean persistedOk = persistAttemptAndRecord(candidate, fingerprint, context, attemptStart, now, outcome,
|
||||
pipelineOutcome, txOps -> txOps.createDocumentRecord(newRecord));
|
||||
|
||||
// If the pipeline yielded a naming proposal and the intermediate state was persisted
|
||||
// successfully, continue with the target-copy finalization within the same run.
|
||||
// The freshly persisted PROPOSAL_READY attempt is the authoritative source for the
|
||||
// finalization step — no new AI call is triggered.
|
||||
if (persistedOk && outcome.overallStatus() == ProcessingStatus.PROPOSAL_READY) {
|
||||
return finalizeProposalReady(candidate, fingerprint, newRecord, context, attemptStart);
|
||||
}
|
||||
return persistedOk;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Known processable document path (non-PROPOSAL_READY)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Processes a known document whose persisted status is not yet {@code PROPOSAL_READY}:
|
||||
* persists the pipeline attempt and updated master record, then — if the pipeline
|
||||
* produced a naming proposal — immediately continues with the target-copy finalization
|
||||
* within the same run.
|
||||
* <p>
|
||||
* The intermediate {@link ProcessingStatus#PROPOSAL_READY} state is always persisted
|
||||
* as a historised attempt before finalization, preserving the invariant that the
|
||||
* leading source for the naming proposal is the most recent {@code PROPOSAL_READY}
|
||||
* attempt in the history.
|
||||
*
|
||||
* @return true if processing and persistence succeeded, false if a persistence failure occurred
|
||||
*/
|
||||
private boolean processAndPersistKnownDocument(
|
||||
SourceDocumentCandidate candidate,
|
||||
DocumentFingerprint fingerprint,
|
||||
@@ -745,8 +783,17 @@ public class DocumentProcessingCoordinator {
|
||||
ProcessingOutcomeTransition.ProcessingOutcome outcome =
|
||||
mapOutcomeForKnownDocument(pipelineOutcome, existingRecord.failureCounters());
|
||||
DocumentRecord updatedRecord = buildUpdatedDocumentRecord(existingRecord, candidate, outcome, now);
|
||||
return persistAttemptAndRecord(candidate, fingerprint, context, attemptStart, now, outcome,
|
||||
boolean persistedOk = persistAttemptAndRecord(candidate, fingerprint, context, attemptStart, now, outcome,
|
||||
pipelineOutcome, txOps -> txOps.updateDocumentRecord(updatedRecord));
|
||||
|
||||
// If the pipeline yielded a naming proposal and the intermediate state was persisted
|
||||
// successfully, continue with the target-copy finalization within the same run.
|
||||
// The freshly persisted PROPOSAL_READY attempt is the authoritative source for the
|
||||
// finalization step — no new AI call is triggered.
|
||||
if (persistedOk && outcome.overallStatus() == ProcessingStatus.PROPOSAL_READY) {
|
||||
return finalizeProposalReady(candidate, fingerprint, updatedRecord, context, attemptStart);
|
||||
}
|
||||
return persistedOk;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
|
||||
+6
-6
@@ -91,7 +91,7 @@ public final class TargetFilenameBuildingService {
|
||||
* <ul>
|
||||
* <li>Resolved date must be non-null.</li>
|
||||
* <li>Validated title must be non-null and non-blank.</li>
|
||||
* <li>Validated title must not exceed 20 characters (before Windows cleaning).</li>
|
||||
* <li>Validated title must not exceed 60 characters (before Windows cleaning).</li>
|
||||
* <li>After Windows-character cleaning, title must contain only letters, digits, and spaces.</li>
|
||||
* </ul>
|
||||
* If any rule is violated, the state is treated as an
|
||||
@@ -100,11 +100,11 @@ public final class TargetFilenameBuildingService {
|
||||
* Windows compatibility: Windows-incompatible characters
|
||||
* (e.g., {@code < > : " / \ | ? *}) are removed from the title before final validation.
|
||||
* This ensures the resulting filename can be created on Windows systems.
|
||||
* The 20-character rule is applied to the original title before cleaning.
|
||||
* The 60-character rule is applied to the original title before cleaning.
|
||||
* <p>
|
||||
* The 20-character limit applies exclusively to the base title. A duplicate-avoidance
|
||||
* The 60-character limit applies exclusively to the base title. A duplicate-avoidance
|
||||
* suffix (e.g., {@code (1)}) may be appended by the target folder adapter after this
|
||||
* method returns and is not counted against the 20 characters.
|
||||
* method returns and is not counted against the 60 characters.
|
||||
*
|
||||
* @param proposalAttempt the leading {@code PROPOSAL_READY} attempt; must not be null
|
||||
* @return a {@link BaseFilenameReady} with the complete filename, or an
|
||||
@@ -126,9 +126,9 @@ public final class TargetFilenameBuildingService {
|
||||
"Leading PROPOSAL_READY attempt has no validated title");
|
||||
}
|
||||
|
||||
if (title.length() > 20) {
|
||||
if (title.length() > 60) {
|
||||
return new InconsistentProposalState(
|
||||
"Leading PROPOSAL_READY attempt has title exceeding 20 characters: '"
|
||||
"Leading PROPOSAL_READY attempt has title exceeding 60 characters: '"
|
||||
+ title + "'");
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -172,9 +172,10 @@ class AiNamingServiceTest {
|
||||
void invoke_aiResponseTitleTooLong_returnsAiFunctionalFailure() {
|
||||
when(promptPort.loadPrompt()).thenReturn(
|
||||
new PromptLoadingSuccess(new PromptIdentifier("prompt.txt"), "Prompt"));
|
||||
// 21-char title: "TitleThatIsTooLongXXX"
|
||||
// 61-char title: exceeds the 60-character limit
|
||||
String longTitle = "1234567890123456789012345678901234567890123456789012345678901";
|
||||
when(aiInvocationPort.invoke(any(AiRequestRepresentation.class))).thenReturn(
|
||||
successWith("{\"title\":\"TitleThatIsTooLongXXX\",\"reasoning\":\"Too long\",\"date\":\"2026-01-15\"}"));
|
||||
successWith("{\"title\":\"" + longTitle + "\",\"reasoning\":\"Too long\",\"date\":\"2026-01-15\"}"));
|
||||
|
||||
DocumentProcessingOutcome result = service.invoke(preCheckPassed);
|
||||
|
||||
|
||||
+3
-3
@@ -115,15 +115,15 @@ class AiResponseValidatorTest {
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void validate_title21Chars_returnsInvalid() {
|
||||
String title = "1234567890123456789A1"; // 21 chars
|
||||
void validate_titleOver60Chars_returnsInvalid() {
|
||||
String title = "1234567890123456789012345678901234567890123456789012345678901"; // 61 chars
|
||||
ParsedAiResponse parsed = ParsedAiResponse.of(title, "reasoning", null);
|
||||
|
||||
AiResponseValidator.AiValidationResult result = validator.validate(parsed);
|
||||
|
||||
assertThat(result).isInstanceOf(AiResponseValidator.AiValidationResult.Invalid.class);
|
||||
assertThat(((AiResponseValidator.AiValidationResult.Invalid) result).errorMessage())
|
||||
.contains("20");
|
||||
.contains("60");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+69
-39
@@ -109,29 +109,45 @@ class DocumentProcessingCoordinatorTest {
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void process_newDocument_namingProposalReady_persistsProposalReadyStatus() {
|
||||
void process_newDocument_namingProposalReady_persistsProposalReadyThenContinuesToSuccess() {
|
||||
recordRepo.setLookupResult(new DocumentUnknown());
|
||||
DocumentProcessingOutcome outcome = buildNamingProposalOutcome();
|
||||
|
||||
processor.process(candidate, fingerprint, outcome, context, attemptStart);
|
||||
|
||||
// One attempt written
|
||||
assertEquals(1, attemptRepo.savedAttempts.size());
|
||||
ProcessingAttempt attempt = attemptRepo.savedAttempts.get(0);
|
||||
assertEquals(ProcessingStatus.PROPOSAL_READY, attempt.status());
|
||||
assertFalse(attempt.retryable());
|
||||
assertNull(attempt.failureClass());
|
||||
assertNull(attempt.failureMessage());
|
||||
// Two attempts written: first PROPOSAL_READY (pipeline stage), then SUCCESS (target-copy stage).
|
||||
// The intermediate PROPOSAL_READY attempt is the authoritative source for finalization.
|
||||
assertEquals(2, attemptRepo.savedAttempts.size(),
|
||||
"PROPOSAL_READY must be historised before the SUCCESS attempt in the same run");
|
||||
ProcessingAttempt proposalAttempt = attemptRepo.savedAttempts.get(0);
|
||||
assertEquals(ProcessingStatus.PROPOSAL_READY, proposalAttempt.status());
|
||||
assertFalse(proposalAttempt.retryable());
|
||||
assertNull(proposalAttempt.failureClass());
|
||||
assertNull(proposalAttempt.failureMessage());
|
||||
|
||||
// One master record created
|
||||
ProcessingAttempt successAttempt = attemptRepo.savedAttempts.get(1);
|
||||
assertEquals(ProcessingStatus.SUCCESS, successAttempt.status());
|
||||
assertNotNull(successAttempt.finalTargetFileName(),
|
||||
"SUCCESS attempt must carry the final target filename");
|
||||
|
||||
// One master record created for the new document; one update from the finalization stage.
|
||||
assertEquals(1, recordRepo.createdRecords.size());
|
||||
DocumentRecord record = recordRepo.createdRecords.get(0);
|
||||
assertEquals(ProcessingStatus.PROPOSAL_READY, record.overallStatus());
|
||||
assertEquals(0, record.failureCounters().contentErrorCount());
|
||||
assertEquals(0, record.failureCounters().transientErrorCount());
|
||||
// lastSuccessInstant is null after AI naming proposal; it is set only after the target-copy stage
|
||||
assertNull(record.lastSuccessInstant());
|
||||
assertNull(record.lastFailureInstant());
|
||||
DocumentRecord createdRecord = recordRepo.createdRecords.get(0);
|
||||
assertEquals(ProcessingStatus.PROPOSAL_READY, createdRecord.overallStatus());
|
||||
assertEquals(0, createdRecord.failureCounters().contentErrorCount());
|
||||
assertEquals(0, createdRecord.failureCounters().transientErrorCount());
|
||||
// The initial PROPOSAL_READY record has no success/failure timestamps yet.
|
||||
assertNull(createdRecord.lastSuccessInstant());
|
||||
assertNull(createdRecord.lastFailureInstant());
|
||||
|
||||
assertEquals(1, recordRepo.updatedRecords.size(),
|
||||
"Master record must be updated to SUCCESS after the target-copy stage");
|
||||
DocumentRecord finalRecord = recordRepo.updatedRecords.get(0);
|
||||
assertEquals(ProcessingStatus.SUCCESS, finalRecord.overallStatus());
|
||||
assertNotNull(finalRecord.lastSuccessInstant(),
|
||||
"lastSuccessInstant must be set after successful target copy");
|
||||
assertNotNull(finalRecord.lastTargetFileName(),
|
||||
"Master record must carry the final target filename after SUCCESS");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -273,7 +289,7 @@ class DocumentProcessingCoordinatorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void process_knownDocument_namingProposalReady_persistsProposalReadyStatus() {
|
||||
void process_knownDocument_namingProposalReady_persistsProposalReadyThenContinuesToSuccess() {
|
||||
DocumentRecord existingRecord = buildRecord(
|
||||
ProcessingStatus.FAILED_RETRYABLE,
|
||||
new FailureCounters(0, 1));
|
||||
@@ -283,14 +299,22 @@ class DocumentProcessingCoordinatorTest {
|
||||
|
||||
processor.process(candidate, fingerprint, outcome, context, attemptStart);
|
||||
|
||||
assertEquals(1, recordRepo.updatedRecords.size());
|
||||
DocumentRecord record = recordRepo.updatedRecords.get(0);
|
||||
assertEquals(ProcessingStatus.PROPOSAL_READY, record.overallStatus());
|
||||
// Two record updates: first to PROPOSAL_READY (pipeline stage), then to SUCCESS (target-copy stage).
|
||||
assertEquals(2, recordRepo.updatedRecords.size(),
|
||||
"PROPOSAL_READY update must be followed by a SUCCESS update in the same run");
|
||||
DocumentRecord intermediate = recordRepo.updatedRecords.get(0);
|
||||
assertEquals(ProcessingStatus.PROPOSAL_READY, intermediate.overallStatus());
|
||||
// Counters unchanged on naming proposal success
|
||||
assertEquals(0, record.failureCounters().contentErrorCount());
|
||||
assertEquals(1, record.failureCounters().transientErrorCount());
|
||||
// lastSuccessInstant is null after AI naming proposal; it is set only after the target-copy stage
|
||||
assertNull(record.lastSuccessInstant());
|
||||
assertEquals(0, intermediate.failureCounters().contentErrorCount());
|
||||
assertEquals(1, intermediate.failureCounters().transientErrorCount());
|
||||
// lastSuccessInstant is still null after the intermediate PROPOSAL_READY write.
|
||||
assertNull(intermediate.lastSuccessInstant());
|
||||
|
||||
DocumentRecord finalRecord = recordRepo.updatedRecords.get(1);
|
||||
assertEquals(ProcessingStatus.SUCCESS, finalRecord.overallStatus());
|
||||
assertNotNull(finalRecord.lastSuccessInstant(),
|
||||
"lastSuccessInstant must be set after the successful target copy");
|
||||
assertNotNull(finalRecord.lastTargetFileName());
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -587,15 +611,19 @@ class DocumentProcessingCoordinatorTest {
|
||||
|
||||
@Test
|
||||
void process_newDocument_namingProposalReady_failureClassAndMessageAreNull() {
|
||||
// Prüft, dass bei PROPOSAL_READY failureClass und failureMessage null sind
|
||||
// Prüft, dass der im selben Lauf persistierte PROPOSAL_READY-Versuch ohne
|
||||
// failureClass/failureMessage geschrieben wird.
|
||||
recordRepo.setLookupResult(new DocumentUnknown());
|
||||
DocumentProcessingOutcome outcome = buildNamingProposalOutcome();
|
||||
|
||||
processor.process(candidate, fingerprint, outcome, context, attemptStart);
|
||||
|
||||
ProcessingAttempt attempt = attemptRepo.savedAttempts.get(0);
|
||||
assertNull(attempt.failureClass(), "Bei PROPOSAL_READY muss failureClass null sein");
|
||||
assertNull(attempt.failureMessage(), "Bei PROPOSAL_READY muss failureMessage null sein");
|
||||
ProcessingAttempt proposalAttempt = attemptRepo.savedAttempts.stream()
|
||||
.filter(a -> a.status() == ProcessingStatus.PROPOSAL_READY)
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new AssertionError("PROPOSAL_READY attempt must be persisted"));
|
||||
assertNull(proposalAttempt.failureClass(), "Bei PROPOSAL_READY muss failureClass null sein");
|
||||
assertNull(proposalAttempt.failureMessage(), "Bei PROPOSAL_READY muss failureMessage null sein");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -603,9 +631,9 @@ class DocumentProcessingCoordinatorTest {
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void process_knownDocument_namingProposalReady_lastSuccessInstantNullAndLastFailureInstantFromPreviousRecord() {
|
||||
// Prüft, dass bei PROPOSAL_READY am known-Dokument lastSuccessInstant null bleibt
|
||||
// (wird erst nach der Zielkopie gesetzt) und lastFailureInstant aus dem Vorgänger übernommen wird
|
||||
void process_knownDocument_namingProposalReady_intermediateRecordKeepsPreviousFailureAndHasNoSuccess() {
|
||||
// Prüft den persistierten Zwischenzustand (PROPOSAL_READY-Update) im selben Lauf:
|
||||
// lastSuccessInstant bleibt null bis zur Zielkopie; lastFailureInstant aus dem Vorgänger bleibt erhalten.
|
||||
Instant previousFailureInstant = Instant.parse("2025-01-15T10:00:00Z");
|
||||
DocumentRecord existingRecord = new DocumentRecord(
|
||||
fingerprint,
|
||||
@@ -625,11 +653,13 @@ class DocumentProcessingCoordinatorTest {
|
||||
|
||||
processor.process(candidate, fingerprint, outcome, context, attemptStart);
|
||||
|
||||
DocumentRecord updated = recordRepo.updatedRecords.get(0);
|
||||
assertNull(updated.lastSuccessInstant(),
|
||||
"lastSuccessInstant muss nach PROPOSAL_READY null bleiben (wird erst nach der Zielkopie gesetzt)");
|
||||
assertEquals(previousFailureInstant, updated.lastFailureInstant(),
|
||||
"lastFailureInstant muss bei PROPOSAL_READY den Vorgänger-Wert beibehalten");
|
||||
// Der erste Update-Record ist der Zwischenzustand (PROPOSAL_READY) vor der Finalisierung.
|
||||
DocumentRecord intermediate = recordRepo.updatedRecords.get(0);
|
||||
assertEquals(ProcessingStatus.PROPOSAL_READY, intermediate.overallStatus());
|
||||
assertNull(intermediate.lastSuccessInstant(),
|
||||
"lastSuccessInstant muss im PROPOSAL_READY-Zwischenstand null bleiben");
|
||||
assertEquals(previousFailureInstant, intermediate.lastFailureInstant(),
|
||||
"lastFailureInstant muss im PROPOSAL_READY-Zwischenstand den Vorgänger-Wert beibehalten");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -914,18 +944,18 @@ class DocumentProcessingCoordinatorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void processDeferredOutcome_proposalReady_inconsistentProposalTitleExceeds20Chars_persistsTransientError() {
|
||||
void processDeferredOutcome_proposalReady_inconsistentProposalTitleExceeds60Chars_persistsTransientError() {
|
||||
DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
|
||||
recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
|
||||
|
||||
// Title of 21 characters violates the 20-char base-title rule — inconsistent persistence state
|
||||
// Title of 61 characters violates the 60-char base-title rule — inconsistent persistence state
|
||||
ProcessingAttempt badProposal = new ProcessingAttempt(
|
||||
fingerprint, context.runId(), 1, Instant.now(), Instant.now(),
|
||||
ProcessingStatus.PROPOSAL_READY, null, null, false,
|
||||
null,
|
||||
"model", "prompt", 1, 100, "{}", "reason",
|
||||
LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED,
|
||||
"A".repeat(21), null);
|
||||
"A".repeat(61), null);
|
||||
attemptRepo.savedAttempts.add(badProposal);
|
||||
|
||||
boolean result = processor.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
|
||||
|
||||
+12
-12
@@ -20,7 +20,7 @@ import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
||||
/**
|
||||
* Unit tests for {@link TargetFilenameBuildingService}.
|
||||
* <p>
|
||||
* Covers the verbindliches Zielformat {@code YYYY-MM-DD - Titel.pdf}, the 20-character
|
||||
* Covers the verbindliches Zielformat {@code YYYY-MM-DD - Titel.pdf}, the 60-character
|
||||
* base-title rule, the fachliche Titelregel (only letters, digits, and spaces),
|
||||
* Windows-compatibility character removal, and the detection of inconsistent persistence
|
||||
* states.
|
||||
@@ -100,8 +100,8 @@ class TargetFilenameBuildingServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildBaseFilename_titleExactly20Chars_isAccepted() {
|
||||
String title = "A".repeat(20); // exactly 20 characters
|
||||
void buildBaseFilename_titleExactly60Chars_isAccepted() {
|
||||
String title = "A".repeat(60); // exactly 60 characters
|
||||
ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 1, 1), title);
|
||||
|
||||
BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt);
|
||||
@@ -110,13 +110,13 @@ class TargetFilenameBuildingServiceTest {
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 20-character rule applies only to base title; format structure is separate
|
||||
// 60-character rule applies only to base title; format structure is separate
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void buildBaseFilename_format_separatorAndExtensionAreNotCountedAgainstTitle() {
|
||||
// A 20-char title produces "YYYY-MM-DD - <20chars>.pdf" — total > 20 chars, which is fine
|
||||
String title = "Stromabrechnung 2026"; // 20 chars
|
||||
String title = "Stromabrechnung 2026"; // 20 chars (well within 60-char limit)
|
||||
ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 3, 31), title);
|
||||
|
||||
BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt);
|
||||
@@ -170,19 +170,19 @@ class TargetFilenameBuildingServiceTest {
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// InconsistentProposalState – title exceeds 20 characters
|
||||
// InconsistentProposalState – title exceeds 60 characters
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void buildBaseFilename_titleExceeds20Chars_returnsInconsistentProposalState() {
|
||||
String title = "A".repeat(21); // 21 characters
|
||||
void buildBaseFilename_titleExceeds60Chars_returnsInconsistentProposalState() {
|
||||
String title = "A".repeat(61); // 61 characters
|
||||
ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 1, 1), title);
|
||||
|
||||
BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt);
|
||||
|
||||
assertThat(result).isInstanceOf(InconsistentProposalState.class);
|
||||
assertThat(((InconsistentProposalState) result).reason())
|
||||
.contains("exceeding 20 characters");
|
||||
.contains("exceeding 60 characters");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -305,10 +305,10 @@ class TargetFilenameBuildingServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildBaseFilename_twentyCharacterRuleUnaffectedByWindowsCompatibility() {
|
||||
// The 20-character rule applies to the base title only.
|
||||
void buildBaseFilename_sixtyCharacterRuleUnaffectedByWindowsCompatibility() {
|
||||
// The 60-character rule applies to the base title only.
|
||||
// Windows-compatibility cleaning does not change the length counting mechanism.
|
||||
String title = "Stromabrechnung 2026"; // exactly 20 characters
|
||||
String title = "Stromabrechnung 2026"; // 20 characters (within 60-char limit)
|
||||
ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 3, 31), title);
|
||||
|
||||
BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt);
|
||||
|
||||
Reference in New Issue
Block a user