diff --git a/__TEST__/hyperaudio-lite-extension.test.js b/__TEST__/hyperaudio-lite-extension.test.js new file mode 100644 index 0000000..0822be8 --- /dev/null +++ b/__TEST__/hyperaudio-lite-extension.test.js @@ -0,0 +1,123 @@ +/** + * @jest-environment jsdom + * + * Tests for the search half of js/hyperaudio-lite-extension.js — needle + * normalisation, phrase matching across spans, and mark placement on words + * containing internal punctuation (#260). + */ + +const { + searchPhrase, + findRawRange, + clearPreviousSearch, +} = require("../js/hyperaudio-lite-extension"); + +// Build a transcript of [data-m] word spans; a ["speaker", text] tuple emits +// a speaker span (data-d 0), mirroring transcripts produced by the editor. +function buildTranscript(words) { + const spans = words + .map((w, i) => { + if (w[0] === "speaker") { + return `${w[1]} `; + } + return `${w[0]} `; + }) + .join(""); + document.body.innerHTML = `

${spans}

`; +} + +const marks = () => + Array.from(document.querySelectorAll("mark.search-mark")).map( + (m) => m.textContent + ); + +const matchedSpans = () => + Array.from(document.querySelectorAll(".search-match")).map((s) => + s.textContent.trim() + ); + +describe("findRawRange", () => { + test("plain substring", () => { + expect(findRawRange("Lorem ", "lorem")).toEqual([0, 5]); + }); + + test("skips punctuation inside the match", () => { + expect(findRawRange("[SPEAKER-2] ", "[speaker2]")).toEqual([0, 11]); + }); + + test("leaves trailing punctuation outside the range", () => { + // the trailing "." is not consumed once the needle is exhausted + expect(findRawRange("U.S. ", "us")).toEqual([0, 3]); + }); + + test("leaves leading punctuation outside the range", () => { + expect(findRawRange("-well ", "well")).toEqual([1, 5]); + }); + + test("returns null when the needle is absent", () => { + expect(findRawRange("Lorem ", "ipsum")).toBeNull(); + }); +}); + +describe("searchPhrase", () => { + test("marks a plain word, punctuation outside the mark", () => { + buildTranscript([["Lorem,"], ["ipsum"]]); + searchPhrase("lorem"); + expect(marks()).toEqual(["Lorem"]); + expect(matchedSpans()).toEqual(["Lorem,"]); + }); + + test("marks a speaker label containing a dash (#260)", () => { + buildTranscript([["speaker", "[SPEAKER-2]"], ["Hello"]]); + searchPhrase("[speaker-2]"); + expect(marks()).toEqual(["[SPEAKER-2]"]); + expect(matchedSpans()).toEqual(["[SPEAKER-2]"]); + }); + + test("marks a hyphenated word", () => { + buildTranscript([["a"], ["well-known"], ["fact"]]); + searchPhrase("well-known"); + expect(marks()).toEqual(["well-known"]); + }); + + test("marks dotted abbreviations", () => { + buildTranscript([["the"], ["U.S."], ["economy"]]); + searchPhrase("U.S."); + // the final "." follows the last needle character, so it stays outside + expect(marks()).toEqual(["U.S"]); + }); + + test("multi-word phrases still mark consecutive spans", () => { + buildTranscript([["one"], ["two"], ["three"]]); + searchPhrase("two three"); + expect(marks()).toEqual(["two", "three"]); + expect(matchedSpans()).toEqual(["two", "three"]); + }); + + test("a new search clears previous marks", () => { + buildTranscript([["alpha"], ["beta"]]); + searchPhrase("alpha"); + searchPhrase("beta"); + expect(marks()).toEqual(["beta"]); + expect(matchedSpans()).toEqual(["beta"]); + }); + + test("an empty or punctuation-only query marks nothing", () => { + buildTranscript([["alpha"], ["beta"]]); + searchPhrase("alpha"); + searchPhrase("---"); + expect(marks()).toEqual([]); + }); +}); + +describe("clearPreviousSearch", () => { + test("unwraps marks and merges the text nodes back", () => { + buildTranscript([["Lorem,"]]); + searchPhrase("lorem"); + clearPreviousSearch(); + const span = document.querySelector("[data-m]"); + expect(document.querySelectorAll("mark").length).toBe(0); + expect(span.childNodes.length).toBe(1); + expect(span.textContent).toBe("Lorem, "); + }); +}); diff --git a/changelog.md b/changelog.md index c693ba4..741c8d9 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,9 @@ +# Version 2.6.2 (unreleased) + +## Fixed + +- **Search marks land on words with internal punctuation** (#260). `searchPhrase()` compares punctuation-stripped text, but `highlightSubstring()` looked the stripped needle up in the *raw* span text with `indexOf`, so a query like `speaker-2` matched `[SPEAKER-2]` (the span gained `search-match`) yet never received its ``. The highlighter now walks the raw text, skipping punctuation inside the match, so the mark covers the visible word (dash included) while leading/trailing punctuation stays outside. Affected any match containing a stripped character: hyphenated words, dotted abbreviations, bracketed speaker labels. + # Version 2.6.1 Patch release: the npm package now includes `caption.js`. diff --git a/js/hyperaudio-lite-extension.js b/js/hyperaudio-lite-extension.js index fbf208e..c6dea7c 100644 --- a/js/hyperaudio-lite-extension.js +++ b/js/hyperaudio-lite-extension.js @@ -1,5 +1,5 @@ /*! (C) The Hyperaudio Project. MIT @license: en.wikipedia.org/wiki/MIT_License. */ -/*! Version 2.5.1 */ +/*! Version 2.6.2 */ 'use strict'; @@ -9,6 +9,8 @@ // highlight covers the query, not the whole word. const SEARCH_PUNCT = /[.,\-\/#!$%\^&\*;:{}=_`~()\?\s]/g; +// Non-global copy for single-character tests (a /g regex is stateful in .test()). +const SEARCH_PUNCT_CHAR = new RegExp(SEARCH_PUNCT.source); const normalise = (text) => text.toLowerCase().replace(SEARCH_PUNCT, ''); const clearPreviousSearch = () => { @@ -21,15 +23,43 @@ const clearPreviousSearch = () => { }); }; +// Find the range of `original` whose normalised form matches `needle` (already +// lowercased and punctuation-stripped): walk the raw text consuming needle +// characters and skipping punctuation inside the match (#260). Returns +// [start, end) indices into `original`, or null when the needle isn't present. +const findRawRange = (original, needle) => { + const lower = original.toLowerCase(); + for (let start = 0; start < lower.length; start++) { + if (lower[start] !== needle[0]) continue; + let oi = start; + let ni = 0; + while (oi < lower.length && ni < needle.length) { + if (lower[oi] === needle[ni]) { + oi++; + ni++; + } else if (SEARCH_PUNCT_CHAR.test(lower[oi])) { + oi++; + } else { + break; + } + } + if (ni === needle.length) return [start, oi]; + } + return null; +}; + // Wrap the first occurrence of `needle` (case-insensitive) inside `span`'s -// text with . Punctuation stays outside the mark. +// text with . Leading and trailing punctuation stay +// outside the mark; punctuation inside the match (the dash in "SPEAKER-2" for +// the needle "speaker2") is included, so the visible word is highlighted +// whole even though matching compares punctuation-stripped text (#260). const highlightSubstring = (span, needle) => { const original = span.textContent; - const idx = original.toLowerCase().indexOf(needle); - if (idx < 0) return; - const before = original.slice(0, idx); - const hit = original.slice(idx, idx + needle.length); - const after = original.slice(idx + needle.length); + const range = findRawRange(original, needle); + if (range === null) return; + const before = original.slice(0, range[0]); + const hit = original.slice(range[0], range[1]); + const after = original.slice(range[1]); span.textContent = ''; if (before) span.append(before); const mark = document.createElement('mark'); @@ -83,3 +113,8 @@ window.addEventListener('load', () => { } }); }); + +// Export for testing or module usage +if (typeof module !== 'undefined' && module.exports) { + module.exports = { searchPhrase, highlightSubstring, findRawRange, normalise, clearPreviousSearch }; +}