fix: keep sanitised text stable when it is truncated
The cut was marked with U+2026, which NFKC decomposes into three dots — so re-sanitising a truncated string grew it by two characters and re-cut it at a different word boundary. A title would quietly rewrite itself every time the event was re-ingested, and the module promises idempotency in its own docstring. Append what normalisation would produce instead. The existing corpus missed this because its only over-length entry has no space in its last 40%, so it happened to re-truncate to the identical string; the new test uses prose. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
02863ed008
commit
1bc7b0057c
+13
-2
@@ -149,16 +149,27 @@ function collapse(input: string): string {
|
|||||||
function truncate(input: string, max: number): string {
|
function truncate(input: string, max: number): string {
|
||||||
if (input.length <= max) return input;
|
if (input.length <= max) return input;
|
||||||
|
|
||||||
let cut = input.slice(0, max - 1);
|
let cut = input.slice(0, max - ELLIPSIS.length);
|
||||||
// Never end on half a surrogate pair.
|
// Never end on half a surrogate pair.
|
||||||
if (/[\uD800-\uDBFF]$/.test(cut)) cut = cut.slice(0, -1);
|
if (/[\uD800-\uDBFF]$/.test(cut)) cut = cut.slice(0, -1);
|
||||||
|
|
||||||
const lastSpace = cut.lastIndexOf(" ");
|
const lastSpace = cut.lastIndexOf(" ");
|
||||||
if (lastSpace > max * 0.6) cut = cut.slice(0, lastSpace);
|
if (lastSpace > max * 0.6) cut = cut.slice(0, lastSpace);
|
||||||
|
|
||||||
return `${cut.replace(/[\s,;:.–—-]+$/, "")}…`;
|
return `${cut.replace(/[\s,;:.–—-]+$/, "")}${ELLIPSIS}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Three dots, not U+2026.
|
||||||
|
*
|
||||||
|
* NFKC decomposes the ellipsis character into these three anyway, so a "…"
|
||||||
|
* appended here would grow by two characters on the next pass and re-cut the
|
||||||
|
* string at a different word boundary — breaking the idempotency this module
|
||||||
|
* promises. Writing what normalisation would produce keeps the second pass a
|
||||||
|
* no-op.
|
||||||
|
*/
|
||||||
|
const ELLIPSIS = "...";
|
||||||
|
|
||||||
export interface SanitizeTextOptions {
|
export interface SanitizeTextOptions {
|
||||||
/** Hard cap; the result is never longer. Defaults to the summary cap. */
|
/** Hard cap; the result is never longer. Defaults to the summary cap. */
|
||||||
maxLength?: number;
|
maxLength?: number;
|
||||||
|
|||||||
+36
-2
@@ -193,12 +193,19 @@ describe("sanitizeText — length", () => {
|
|||||||
const title = sanitizeText(long, { maxLength: LIMITS.title });
|
const title = sanitizeText(long, { maxLength: LIMITS.title });
|
||||||
expect(title.length).toBeLessThanOrEqual(LIMITS.title);
|
expect(title.length).toBeLessThanOrEqual(LIMITS.title);
|
||||||
expect(title.startsWith("Windblume")).toBe(true);
|
expect(title.startsWith("Windblume")).toBe(true);
|
||||||
expect(title.endsWith("…")).toBe(true);
|
expect(title.endsWith("...")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("cuts at a word boundary when there is one", () => {
|
test("cuts at a word boundary when there is one", () => {
|
||||||
const words = "Windblume Festival Returns To Mondstadt In Full Bloom";
|
const words = "Windblume Festival Returns To Mondstadt In Full Bloom";
|
||||||
expect(sanitizeText(words, { maxLength: 20 })).toBe("Windblume Festival…");
|
expect(sanitizeText(words, { maxLength: 30 })).toBe("Windblume Festival Returns...");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cuts mid-word rather than losing most of a tight cap", () => {
|
||||||
|
// With no space late enough to cut at, keeping the characters beats
|
||||||
|
// throwing half the value away to land on a boundary.
|
||||||
|
const words = "Windblume Festival Returns To Mondstadt In Full Bloom";
|
||||||
|
expect(sanitizeText(words, { maxLength: 20 })).toBe("Windblume Festiva...");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a value at the cap is left exactly as it is", () => {
|
test("a value at the cap is left exactly as it is", () => {
|
||||||
@@ -219,6 +226,33 @@ describe("sanitizeText — length", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("sanitizeText — truncation", () => {
|
||||||
|
// Prose, with spaces near the end: the shape that exposes the bug the
|
||||||
|
// over-long entry in HOSTILE missed, because that one has no space in its
|
||||||
|
// final 40% and so happened to re-cut to the identical string.
|
||||||
|
const prose = "Windblume Festival returns to Mondstadt with games and rewards ".repeat(6);
|
||||||
|
|
||||||
|
test("truncating twice changes nothing", () => {
|
||||||
|
// NFKC decomposes U+2026 into three dots, so an ellipsis character appended
|
||||||
|
// here would grow the string on the next pass and re-cut it at a different
|
||||||
|
// word boundary — quietly rewriting a title every time it was re-ingested.
|
||||||
|
const once = sanitizeText(prose, { maxLength: 190 });
|
||||||
|
expect(sanitizeText(once, { maxLength: 190 })).toBe(once);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stays within the cap it was given", () => {
|
||||||
|
for (const max of [12, 40, 190, LIMITS.title, LIMITS.summary]) {
|
||||||
|
expect(sanitizeText(prose, { maxLength: max }).length).toBeLessThanOrEqual(max);
|
||||||
|
// Astral characters truncate by code unit; the cap still holds.
|
||||||
|
expect(sanitizeText("𝔊".repeat(400), { maxLength: max }).length).toBeLessThanOrEqual(max);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("marks the cut so a reader can see it is not the whole text", () => {
|
||||||
|
expect(sanitizeText(prose, { maxLength: 190 })).toEndWith("...");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("sanitizeText — totality", () => {
|
describe("sanitizeText — totality", () => {
|
||||||
test("is idempotent for every hostile input", () => {
|
test("is idempotent for every hostile input", () => {
|
||||||
for (const [label, input] of HOSTILE) {
|
for (const [label, input] of HOSTILE) {
|
||||||
|
|||||||
Reference in New Issue
Block a user