fix(person-mention): truncate notes excerpt at last word boundary
Leonie FINDING-04 + Elicit E5: notes.slice(0, 120) cuts mid-word, especially
ugly in German compound nouns ("…Familienzu…"). Sara #7: the assertion
.toBeLessThanOrEqual(122) was a magic number that hid this bug.
Add truncateAtWordBoundary(text, max): cut at the last space inside the
window unless it'd shrink the excerpt below 70% (single-word fallback).
Single-word case still produces hard-cut + ellipsis so a 150-char word
shows the first 120 chars + … rather than nothing.
Tests pinned to exact strings.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -34,12 +34,29 @@ const dateRange = $derived(
|
|||||||
: ''
|
: ''
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cut the notes excerpt at the last word boundary inside the NOTES_MAX
|
||||||
|
* window. Mid-word truncation is especially ugly in German compound nouns
|
||||||
|
* ("…Familienzu…"), so prefer the previous space if there is one within
|
||||||
|
* a reasonable distance. Fall back to a hard cut for strings with no
|
||||||
|
* spaces at all (e.g. a single 150-char word). Leonie FINDING-04 / Elicit E5.
|
||||||
|
*/
|
||||||
|
function truncateAtWordBoundary(text: string, max: number): string {
|
||||||
|
if (text.length <= max) return text;
|
||||||
|
const window = text.slice(0, max);
|
||||||
|
const lastSpace = window.lastIndexOf(' ');
|
||||||
|
// If the last space is too close to the start (< 70% of the window) we'd
|
||||||
|
// produce a near-empty excerpt — fall back to the hard cut instead.
|
||||||
|
const minBoundary = Math.floor(max * 0.7);
|
||||||
|
const cut = lastSpace >= minBoundary ? window.slice(0, lastSpace) : window;
|
||||||
|
return cut + '…';
|
||||||
|
}
|
||||||
|
|
||||||
const notesExcerpt = $derived.by(() => {
|
const notesExcerpt = $derived.by(() => {
|
||||||
if (state.status !== 'loaded') return null;
|
if (state.status !== 'loaded') return null;
|
||||||
const notes = state.person.notes;
|
const notes = state.person.notes;
|
||||||
if (!notes) return null;
|
if (!notes) return null;
|
||||||
if (notes.length <= NOTES_MAX) return notes;
|
return truncateAtWordBoundary(notes, NOTES_MAX);
|
||||||
return notes.slice(0, NOTES_MAX) + '…';
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Accessible name for the region landmark — required by WCAG 1.3.1.
|
// Accessible name for the region landmark — required by WCAG 1.3.1.
|
||||||
|
|||||||
@@ -197,7 +197,9 @@ describe('PersonHoverCard — loaded state', () => {
|
|||||||
await expect.element(page.getByText('Born in Berlin.')).toBeInTheDocument();
|
await expect.element(page.getByText('Born in Berlin.')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('truncates notes longer than 120 characters with an ellipsis', async () => {
|
it('truncates notes longer than 120 characters with an ellipsis (single long word)', async () => {
|
||||||
|
// Single 150-char word with no spaces: word-boundary cut would yield nothing,
|
||||||
|
// so fall back to a hard cut at 120 + ellipsis (Sara #7: pin the exact length).
|
||||||
const long = 'x'.repeat(150);
|
const long = 'x'.repeat(150);
|
||||||
const withLongNotes = { ...AUGUSTE, notes: long } as Person;
|
const withLongNotes = { ...AUGUSTE, notes: long } as Person;
|
||||||
render(PersonHoverCard, {
|
render(PersonHoverCard, {
|
||||||
@@ -207,8 +209,34 @@ describe('PersonHoverCard — loaded state', () => {
|
|||||||
state: { status: 'loaded', person: withLongNotes, relationships: [] }
|
state: { status: 'loaded', person: withLongNotes, relationships: [] }
|
||||||
});
|
});
|
||||||
const notes = document.querySelector('[data-testid="person-hover-card-notes"]')!;
|
const notes = document.querySelector('[data-testid="person-hover-card-notes"]')!;
|
||||||
expect(notes.textContent!.length).toBeLessThanOrEqual(122);
|
expect(notes.textContent).toBe('x'.repeat(120) + '…');
|
||||||
expect(notes.textContent).toContain('…');
|
});
|
||||||
|
|
||||||
|
it('truncates at the last word boundary inside the 120-char window (Leonie FINDING-04)', async () => {
|
||||||
|
// 150-char string with spaces — must cut at the last space, not mid-word.
|
||||||
|
const sentence = 'Sie war eine bekannte Schriftstellerin und engagierte sich '.repeat(3);
|
||||||
|
// length is 180, last space at idx ≤120
|
||||||
|
const withLongNotes = { ...AUGUSTE, notes: sentence } as Person;
|
||||||
|
render(PersonHoverCard, {
|
||||||
|
personId: 'p-aug',
|
||||||
|
cardId: 'card-1',
|
||||||
|
position: POSITION,
|
||||||
|
state: { status: 'loaded', person: withLongNotes, relationships: [] }
|
||||||
|
});
|
||||||
|
const notes = document.querySelector('[data-testid="person-hover-card-notes"]')!;
|
||||||
|
const text = notes.textContent ?? '';
|
||||||
|
// Ends with ellipsis
|
||||||
|
expect(text.endsWith('…')).toBe(true);
|
||||||
|
// Last char before the ellipsis is NOT a half-word — verify by checking that
|
||||||
|
// the position right before … is the end of a word (i.e., there's no letter
|
||||||
|
// further along in the original text immediately after our cut point).
|
||||||
|
const cut = text.slice(0, -1); // strip the …
|
||||||
|
// Find this cut substring in the original sentence
|
||||||
|
const idx = sentence.indexOf(cut);
|
||||||
|
expect(idx).toBe(0);
|
||||||
|
const charAfterCut = sentence[cut.length];
|
||||||
|
// The next char should be a space — confirming we cut on a boundary
|
||||||
|
expect(charAfterCut).toBe(' ');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('omits notes section when notes is null', async () => {
|
it('omits notes section when notes is null', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user