Compare commits
3 Commits
cf457cb96f
...
0693cfddd1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0693cfddd1 | ||
|
|
f656f7c1ff | ||
|
|
7316c51d4a |
@@ -431,7 +431,11 @@ public class DocumentService {
|
|||||||
doc.setScriptType(dto.getScriptType());
|
doc.setScriptType(dto.getScriptType());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Datei austauschen (nur wenn eine neue ausgewählt wurde)
|
// 4. Datei austauschen (nur wenn eine neue ausgewählt wurde).
|
||||||
|
// NB (#726): this reassigns originalFilename to the uploaded file's name. The title's index
|
||||||
|
// segment is originalFilename, so after a replace the stored title no longer matches
|
||||||
|
// build(currentState) and the row is treated as manual — neither save-time nor backfill
|
||||||
|
// rewrites it. Accepted fail-safe (ADR-031), and autoTitleBefore was already captured above.
|
||||||
boolean fileReplaced = newFile != null && !newFile.isEmpty();
|
boolean fileReplaced = newFile != null && !newFile.isEmpty();
|
||||||
if (fileReplaced) {
|
if (fileReplaced) {
|
||||||
FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename());
|
FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename());
|
||||||
@@ -479,44 +483,49 @@ public class DocumentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The document state the regenerated title is built from, mirroring {@code updateDocument}'s
|
* The document state the regenerated title is built from. It is composed from the SAME
|
||||||
* setter asymmetry exactly: {@code documentDate}/{@code location} are overwritten from the DTO
|
* resolvers the real setters use — {@code documentDate}/{@code location} overwritten from the
|
||||||
* (a null value clears the field), while precision/end/raw are taken from the DTO only when
|
* DTO (a null value clears the field), precision/end/raw resolved skip-null via
|
||||||
* non-null and otherwise kept from the stored entity (see {@link #applyDatePrecision}). The
|
* {@link #effectivePrecision}/{@link #effectiveMetaDateEnd}/{@link #effectiveMetaDateRaw} — so
|
||||||
* index ({@code originalFilename}) is never touched by a metadata edit. A mismatch here would
|
* the projection cannot drift from {@link #updateDocument}. The index ({@code originalFilename})
|
||||||
* silently produce a wrong label, so it is kept lock-step with the real setters.
|
* is never touched by a metadata edit.
|
||||||
*/
|
*/
|
||||||
private Document projectedState(Document doc, DocumentUpdateDTO dto) {
|
private Document projectedState(Document doc, DocumentUpdateDTO dto) {
|
||||||
return Document.builder()
|
return Document.builder()
|
||||||
.originalFilename(doc.getOriginalFilename())
|
.originalFilename(doc.getOriginalFilename())
|
||||||
.documentDate(dto.getDocumentDate())
|
.documentDate(dto.getDocumentDate())
|
||||||
.location(dto.getLocation())
|
.location(dto.getLocation())
|
||||||
.metaDatePrecision(dto.getMetaDatePrecision() != null
|
.metaDatePrecision(effectivePrecision(doc, dto))
|
||||||
? dto.getMetaDatePrecision() : doc.getMetaDatePrecision())
|
.metaDateEnd(effectiveMetaDateEnd(doc, dto))
|
||||||
.metaDateEnd(dto.getMetaDateEnd() != null
|
.metaDateRaw(effectiveMetaDateRaw(doc, dto))
|
||||||
? dto.getMetaDateEnd() : doc.getMetaDateEnd())
|
|
||||||
.metaDateRaw(dto.getMetaDateRaw() != null
|
|
||||||
? dto.getMetaDateRaw() : doc.getMetaDateRaw())
|
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies the three date-precision fields only when the DTO carries them.
|
* Applies the three date-precision fields skip-null: a null DTO field means "not submitted",
|
||||||
* A null field means "not submitted" — overwriting the stored value with null
|
* so the stored value is kept rather than overwritten with null — which would fabricate a
|
||||||
* would fabricate a precision the user never chose, the exact dishonesty #666
|
* precision the user never chose, the exact dishonesty #666 exists to prevent. Expressed via
|
||||||
* exists to prevent. A row with a genuinely-unknown precision must keep it when
|
* the shared {@code effective*} resolvers so {@link #projectedState} stays lock-step (writing
|
||||||
* an unrelated edit (e.g. a location typo) is saved.
|
* the stored value back when the DTO omits a field is a harmless no-op).
|
||||||
*/
|
*/
|
||||||
private void applyDatePrecision(Document doc, DocumentUpdateDTO dto) {
|
private void applyDatePrecision(Document doc, DocumentUpdateDTO dto) {
|
||||||
if (dto.getMetaDatePrecision() != null) {
|
doc.setMetaDatePrecision(effectivePrecision(doc, dto));
|
||||||
doc.setMetaDatePrecision(dto.getMetaDatePrecision());
|
doc.setMetaDateEnd(effectiveMetaDateEnd(doc, dto));
|
||||||
}
|
doc.setMetaDateRaw(effectiveMetaDateRaw(doc, dto));
|
||||||
if (dto.getMetaDateEnd() != null) {
|
}
|
||||||
doc.setMetaDateEnd(dto.getMetaDateEnd());
|
|
||||||
}
|
// Skip-null date-field resolution shared by applyDatePrecision (the real setters) and
|
||||||
if (dto.getMetaDateRaw() != null) {
|
// projectedState (the title projection) — the single rule keeps them from diverging (#726).
|
||||||
doc.setMetaDateRaw(dto.getMetaDateRaw());
|
private static DatePrecision effectivePrecision(Document doc, DocumentUpdateDTO dto) {
|
||||||
}
|
return dto.getMetaDatePrecision() != null ? dto.getMetaDatePrecision() : doc.getMetaDatePrecision();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LocalDate effectiveMetaDateEnd(Document doc, DocumentUpdateDTO dto) {
|
||||||
|
return dto.getMetaDateEnd() != null ? dto.getMetaDateEnd() : doc.getMetaDateEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String effectiveMetaDateRaw(Document doc, DocumentUpdateDTO dto) {
|
||||||
|
return dto.getMetaDateRaw() != null ? dto.getMetaDateRaw() : doc.getMetaDateRaw();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -371,6 +371,43 @@ class DocumentServiceTest {
|
|||||||
assertThat(stored.getTitle()).isEqualTo("C-0029 – Frühling 1943");
|
assertThat(stored.getTitle()).isEqualTo("C-0029 – Frühling 1943");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateDocument_carriesStoredPrecisionAndRaw_whenDtoOmitsThem() throws Exception {
|
||||||
|
// Only the year changes; precision/end/raw are omitted from the DTO, so projectedState
|
||||||
|
// must carry them from the entity (exercises the skip-null effective* resolvers).
|
||||||
|
Document stored = makeStored("C-0029", LocalDate.of(1943, 4, 1), DatePrecision.SEASON, null);
|
||||||
|
stored.setMetaDateRaw("Frühling 1943");
|
||||||
|
stored.setTitle(documentTitleFactory.build(stored)); // "C-0029 – Frühling 1943"
|
||||||
|
DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1944, 4, 1), null, null);
|
||||||
|
|
||||||
|
runUpdate(stored, dto);
|
||||||
|
|
||||||
|
assertThat(stored.getTitle()).isEqualTo("C-0029 – Frühling 1944");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateDocument_roundTripsRangeLabel_atSaveTime() throws Exception {
|
||||||
|
Document stored = Document.builder()
|
||||||
|
.id(UUID.randomUUID())
|
||||||
|
.originalFilename("C-0029")
|
||||||
|
.documentDate(LocalDate.of(1917, 1, 10))
|
||||||
|
.metaDatePrecision(DatePrecision.RANGE)
|
||||||
|
.metaDateEnd(LocalDate.of(1917, 1, 11))
|
||||||
|
.receivers(new HashSet<>())
|
||||||
|
.tags(new HashSet<>())
|
||||||
|
.build();
|
||||||
|
stored.setTitle(documentTitleFactory.build(stored)); // "C-0029 – 10.–11. Jan. 1917"
|
||||||
|
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||||
|
dto.setTitle(stored.getTitle());
|
||||||
|
dto.setDocumentDate(LocalDate.of(1918, 1, 10));
|
||||||
|
dto.setMetaDatePrecision(DatePrecision.RANGE);
|
||||||
|
dto.setMetaDateEnd(LocalDate.of(1918, 1, 11));
|
||||||
|
|
||||||
|
runUpdate(stored, dto);
|
||||||
|
|
||||||
|
assertThat(stored.getTitle()).isEqualTo("C-0029 – 10.–11. Jan. 1918");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void updateDocument_doesNotRegenerateToBlank_whenSubmittedTitleEmpty() throws Exception {
|
void updateDocument_doesNotRegenerateToBlank_whenSubmittedTitleEmpty() throws Exception {
|
||||||
Document stored = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
Document stored = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ class DocumentTitleBackfillIntegrationTest {
|
|||||||
|
|
||||||
int count = documentService.backfillTitles();
|
int count = documentService.backfillTitles();
|
||||||
|
|
||||||
assertThat(count).isGreaterThanOrEqualTo(1);
|
assertThat(count).isEqualTo(1); // exactly the one stale row seeded (clean test DB)
|
||||||
assertThat(documentRepository.findById(stale.getId()).orElseThrow().getTitle())
|
assertThat(documentRepository.findById(stale.getId()).orElseThrow().getTitle())
|
||||||
.isEqualTo("C-0029 – 1928 – Berlin");
|
.isEqualTo("C-0029 – 1928 – Berlin");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,6 +118,30 @@ class DocumentTitleBackfillMatcherTest {
|
|||||||
assertThat(overwritable("Ganz anderer Titel", null)).isFalse();
|
assertThat(overwritable("Ganz anderer Titel", null)).isFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── near-miss: shapes that look almost machine-built but are not ──────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ascii_hyphen_instead_of_en_dash_separator_is_skipped() {
|
||||||
|
// The separator is " – " (en dash); a plain " - " is not the machine separator.
|
||||||
|
assertThat(overwritable("C-0029 - 1916", null)).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void date_label_without_separator_before_trailing_text_is_skipped() {
|
||||||
|
// "1916 Berlin" is not a date label and is not joined by " – "; prose, not machine.
|
||||||
|
assertThat(overwritable("C-0029 – 1916 Berlin", null)).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void year_with_trailing_letters_is_not_a_year_label() {
|
||||||
|
assertThat(overwritable("C-0029 – 1916er Brief", null)).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void index_immediately_followed_by_text_without_separator_is_skipped() {
|
||||||
|
assertThat(overwritable("C-0029x – 1916", null)).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
// ─── fail-closed guards ───────────────────────────────────────────────────
|
// ─── fail-closed guards ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -71,6 +71,13 @@ class DocumentTitleFactoryTest {
|
|||||||
assertThat(factory.build(d)).isEqualTo("C-0029 – 1928");
|
assertThat(factory.build(d)).isEqualTo("C-0029 – 1928");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bare_document_with_null_index_builds_empty_string_not_npe() {
|
||||||
|
// originalFilename is NOT NULL in production; the guard keeps a synthetic/partial entity
|
||||||
|
// from tripping StringBuilder(null) with an opaque NPE.
|
||||||
|
assertThat(factory.build(Document.builder().build())).isEqualTo("");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void day_precision_renders_the_full_german_label() {
|
void day_precision_renders_the_full_german_label() {
|
||||||
Document d = doc("C-0029")
|
Document d = doc("C-0029")
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ const titleValue = $derived(titleDirty ? currentTitle : suggestedTitle || curren
|
|||||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
/>
|
/>
|
||||||
{#if showTitleHelp}
|
{#if showTitleHelp}
|
||||||
<p id="title-help" class="mt-1 text-xs text-ink-3">
|
<p id="title-help" class="mt-1 text-sm text-ink-3">
|
||||||
{m.form_helper_title_autogenerated()}
|
{m.form_helper_title_autogenerated()}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { afterEach, describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import DescriptionSection from './DescriptionSection.svelte';
|
import DescriptionSection from './DescriptionSection.svelte';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
afterEach(() => cleanup());
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
@@ -57,11 +58,13 @@ describe('DescriptionSection — onMount seeding (Felix B1/B2 fix regression fen
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('DescriptionSection — auto-generated title helper (FR-TITLE-005)', () => {
|
describe('DescriptionSection — auto-generated title helper (FR-TITLE-005)', () => {
|
||||||
it('shows the helper and wires aria-describedby when showTitleHelp is set', async () => {
|
it('shows the helper with the localized text and wires aria-describedby when showTitleHelp is set', async () => {
|
||||||
render(DescriptionSection, { showTitleHelp: true });
|
render(DescriptionSection, { showTitleHelp: true });
|
||||||
const help = document.querySelector('#title-help') as HTMLElement;
|
const help = document.querySelector('#title-help') as HTMLElement;
|
||||||
expect(help).not.toBeNull();
|
expect(help).not.toBeNull();
|
||||||
expect(help.textContent?.trim().length ?? 0).toBeGreaterThan(0);
|
expect(help.textContent?.trim()).toBe(m.form_helper_title_autogenerated());
|
||||||
|
// ≥14px for the 60+ audience (FR-005 prefers a larger size than the 12px field hints).
|
||||||
|
expect(help.classList.contains('text-sm')).toBe(true);
|
||||||
const titleInput = document.querySelector('input#title') as HTMLInputElement;
|
const titleInput = document.querySelector('input#title') as HTMLInputElement;
|
||||||
expect(titleInput.getAttribute('aria-describedby')).toBe('title-help');
|
expect(titleInput.getAttribute('aria-describedby')).toBe('title-help');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user