Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions __TEST__/hyperaudio-lite-extension.test.js
Original file line number Diff line number Diff line change
@@ -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 `<span class="speaker" data-m="${i * 1000}" data-d="0">${w[1]} </span>`;
}
return `<span data-m="${i * 1000}" data-d="500">${w[0]} </span>`;
})
.join("");
document.body.innerHTML = `<div id="hypertranscript"><p>${spans}</p></div>`;
}

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, ");
});
});
6 changes: 6 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -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 `<mark class="search-mark">`. 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`.
Expand Down
49 changes: 42 additions & 7 deletions js/hyperaudio-lite-extension.js
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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 = () => {
Expand All @@ -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 <mark class="search-mark">. Punctuation stays outside the mark.
// text with <mark class="search-mark">. 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');
Expand Down Expand Up @@ -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 };
}
Loading