-
Notifications
You must be signed in to change notification settings - Fork 16
Added Node.js unit tests for core JavaScript utilities #636
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
1da3a5d
Added Node.js unit tests for core JavaScript utilities
xr843 767397e
Merge branch 'main' into feat/node-tests
xr843 d2317a7
Address review feedback: relocate tests, improve assertions, add cove…
xr843 13d2f57
Merge remote-tracking branch 'upstream/main' into feat/node-tests
xr843 0815a59
refactor: load search_functions.js directly, add handleSearchMessage …
xr843 347f68c
test: add +-, --, -+ prefix combination tests for handleSearchMessage
xr843 11747da
test: address review feedback — add html-validate and missing test cases
xr843 bc8f37c
fix(test): add HTML validation in categoryName loop + flip assertion …
xr843 8a1e6d5
fix(search): use numeric for-loop instead of for-in on positions array
xr843 e5bd70e
test(search): flip test data to catch greedy blurb selection
xr843 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Address review feedback: relocate tests, improve assertions, add cove…
…rage - Moved test files from test/ to assets/js/tests/ (closer to source) - Added assets/js/tests/ to _config.yml exclude list - Updated categoryName tests to validate HTML structure instead of specific names - Added tests for addMatchHighlights and getBlurbForResult - Added inline comments explaining why vm.createContext needs explicit globals Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Loading branch information
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -88,6 +88,7 @@ exclude: | |
| - scripts/ | ||
| - .github/ | ||
| - .obsidian/ | ||
| - assets/js/tests/ | ||
|
|
||
| permalink: pretty | ||
|
|
||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,248 @@ | ||
| const { describe, it } = require('node:test'); | ||
| const assert = require('node:assert/strict'); | ||
| const vm = require('node:vm'); | ||
| const fs = require('node:fs'); | ||
| const path = require('node:path'); | ||
|
|
||
| // Helper: bring vm-realm objects into the current realm for deepEqual | ||
| function toLocal(obj) { return JSON.parse(JSON.stringify(obj)); } | ||
|
|
||
| // search_index.js is a Jekyll/Liquid template, so we cannot load it | ||
| // directly. Instead we extract the pure JavaScript functions that do | ||
| // not depend on Liquid-generated data and test them in isolation. | ||
|
|
||
| // First, load utils.js (search_index.js depends on sortedInsert and Ranges) | ||
| const utilsSrc = fs.readFileSync( | ||
| path.join(__dirname, '..', 'utils.js'), | ||
| 'utf-8' | ||
| ); | ||
|
|
||
| // Extract pure JS functions from the Liquid template | ||
| const searchSrc = fs.readFileSync( | ||
| path.join(__dirname, '..', 'search_index.js'), | ||
| 'utf-8' | ||
| ); | ||
|
|
||
| function extractFunction(src, funcName) { | ||
| const startRe = new RegExp(`^function ${funcName}\\b`, 'm'); | ||
| const match = startRe.exec(src); | ||
| if (!match) throw new Error(`Could not find function ${funcName}`); | ||
| let depth = 0; | ||
| let started = false; | ||
| let end = match.index; | ||
| for (let i = match.index; i < src.length; i++) { | ||
| if (src[i] === '{') { depth++; started = true; } | ||
| if (src[i] === '}') { depth--; } | ||
| if (started && depth === 0) { end = i + 1; break; } | ||
| } | ||
| return src.substring(match.index, end); | ||
| } | ||
|
|
||
| const fnSrcs = [ | ||
| 'categoryName', 'getPositions', 'resultMatched', | ||
| 'addMatchHighlights', 'getBlurbForResult' | ||
| ].map(name => extractFunction(searchSrc, name)); | ||
|
|
||
| // vm.createContext() creates a bare sandbox with no built-in globals. | ||
| // Unlike the main Node.js runtime, Math, Array, etc. must be provided explicitly. | ||
| const sandbox = { Set, Math, RegExp, Array, String, Number, console }; | ||
| vm.createContext(sandbox); | ||
| vm.runInContext( | ||
| utilsSrc + '\nthis.utils = utils;\nthis.Ranges = Ranges;\n' + | ||
| 'this.UpdateQueryString = UpdateQueryString;\n' + | ||
| 'this.locationOf = locationOf;\nthis.sortedInsert = sortedInsert;\n', | ||
| sandbox | ||
| ); | ||
| // Provide BMAX constant used by getBlurbForResult | ||
| vm.runInContext('var BMAX = 250;\n', sandbox); | ||
| vm.runInContext( | ||
| fnSrcs.join('\n') + | ||
| '\nthis.categoryName = categoryName;\n' + | ||
| 'this.getPositions = getPositions;\n' + | ||
| 'this.resultMatched = resultMatched;\n' + | ||
| 'this.addMatchHighlights = addMatchHighlights;\n' + | ||
| 'this.getBlurbForResult = getBlurbForResult;\n', | ||
| sandbox | ||
| ); | ||
|
|
||
| const { categoryName, getPositions, resultMatched, addMatchHighlights, getBlurbForResult } = sandbox; | ||
|
|
||
| // ── categoryName ──────────────────────────────────────────────────── | ||
|
|
||
| describe('categoryName', () => { | ||
| it('returns non-empty HTML containing an icon for known categories', () => { | ||
| const knownCategories = [ | ||
| 'av', 'articles', 'booklets', 'monographs', | ||
| 'papers', 'essays', 'canon', 'reference', 'excerpts' | ||
| ]; | ||
| for (const cat of knownCategories) { | ||
| const html = categoryName(cat); | ||
| assert.ok(html.length > 0, `Expected non-empty HTML for "${cat}"`); | ||
| assert.ok(html.includes('<i class='), `Expected icon element for "${cat}"`); | ||
|
khemarato marked this conversation as resolved.
|
||
| } | ||
| }); | ||
|
|
||
| it('returns fallback HTML for unknown category', () => { | ||
| const html = categoryName('unknown'); | ||
| assert.ok(html.includes('<i class='), 'Expected icon in fallback'); | ||
| assert.ok(html.length > 0); | ||
| }); | ||
|
|
||
| it('returns fallback HTML for null/undefined', () => { | ||
| assert.ok(categoryName(null).includes('<i class=')); | ||
| assert.ok(categoryName(undefined).includes('<i class=')); | ||
| }); | ||
|
|
||
| it('known categories produce different output than the fallback', () => { | ||
| const fallback = categoryName('unknown'); | ||
| assert.notEqual(categoryName('av'), fallback); | ||
| assert.notEqual(categoryName('articles'), fallback); | ||
| }); | ||
| }); | ||
|
|
||
| // ── getPositions ──────────────────────────────────────────────────── | ||
|
|
||
| describe('getPositions', () => { | ||
| it('returns empty array when no match data for the field', () => { | ||
| const result = { | ||
| matchData: { metadata: { term: { otherfield: { position: [[0, 4]] } } } } | ||
| }; | ||
| assert.deepEqual(toLocal(getPositions(result, 'content')), []); | ||
| }); | ||
|
|
||
| it('returns sorted positions from match metadata', () => { | ||
| const result = { | ||
| matchData: { | ||
| metadata: { | ||
| foo: { content: { position: [[10, 3], [2, 4]] } }, | ||
| bar: { content: { position: [[5, 2]] } }, | ||
| } | ||
| } | ||
| }; | ||
| const positions = getPositions(result, 'content'); | ||
| assert.deepEqual(toLocal(positions), [2, 5, 10]); | ||
| }); | ||
|
|
||
| it('returns empty array when metadata is empty', () => { | ||
| const result = { matchData: { metadata: {} } }; | ||
| assert.deepEqual(toLocal(getPositions(result, 'content')), []); | ||
| }); | ||
| }); | ||
|
|
||
| // ── resultMatched ─────────────────────────────────────────────────── | ||
|
|
||
| describe('resultMatched', () => { | ||
| it('returns true when field has match positions', () => { | ||
| const result = { | ||
| matchData: { metadata: { term: { title: { position: [[0, 5]] } } } } | ||
| }; | ||
| assert.equal(resultMatched(result, 'title'), true); | ||
| }); | ||
|
|
||
| it('returns false when field has no matches', () => { | ||
| const result = { | ||
| matchData: { metadata: { term: { content: { position: [[0, 5]] } } } } | ||
| }; | ||
| assert.equal(resultMatched(result, 'title'), false); | ||
| }); | ||
|
|
||
| it('returns false when metadata is empty', () => { | ||
| const result = { matchData: { metadata: {} } }; | ||
| assert.equal(resultMatched(result, 'title'), false); | ||
| }); | ||
| }); | ||
|
|
||
| // ── addMatchHighlights ────────────────────────────────────────────── | ||
|
|
||
| describe('addMatchHighlights', () => { | ||
| it('wraps matched positions in <strong> tags', () => { | ||
| const result = { | ||
| matchData: { metadata: { term: { content: { position: [[0, 5]] } } } } | ||
| }; | ||
| const highlighted = addMatchHighlights(result, 'hello world', 'content'); | ||
| assert.ok(highlighted.includes('<strong>hello</strong>')); | ||
| }); | ||
|
|
||
| it('returns original text when no matches for the field', () => { | ||
| const result = { | ||
| matchData: { metadata: { term: { title: { position: [[0, 3]] } } } } | ||
| }; | ||
| const highlighted = addMatchHighlights(result, 'hello world', 'content'); | ||
| assert.equal(highlighted, 'hello world'); | ||
| }); | ||
|
|
||
| it('handles multiple non-overlapping matches', () => { | ||
| const result = { | ||
| matchData: { | ||
| metadata: { | ||
| foo: { content: { position: [[0, 3]] } }, | ||
| bar: { content: { position: [[7, 3]] } }, | ||
| } | ||
| } | ||
| }; | ||
| const highlighted = addMatchHighlights(result, 'foo is bar!!', 'content'); | ||
| assert.ok(highlighted.includes('<strong>foo</strong>')); | ||
| assert.ok(highlighted.includes('<strong>bar</strong>')); | ||
| }); | ||
|
|
||
| it('respects startindex and endindex parameters', () => { | ||
| const result = { | ||
| matchData: { metadata: { term: { content: { position: [[5, 3]] } } } } | ||
| }; | ||
| // blurb is a slice of content from index 3 to 9: "lo wor" | ||
| // match at position 5 length 3 => "wor" within the blurb | ||
| const highlighted = addMatchHighlights(result, 'lo wor', 'content', 3, 9); | ||
| assert.ok(highlighted.includes('<strong>')); | ||
|
khemarato marked this conversation as resolved.
|
||
| }); | ||
| }); | ||
|
|
||
| // ── getBlurbForResult ─────────────────────────────────────────────── | ||
|
|
||
| describe('getBlurbForResult', () => { | ||
| it('returns highlighted description when title matches', () => { | ||
| const result = { | ||
| matchData: { metadata: { dharma: { title: { position: [[0, 6]] } } } } | ||
| }; | ||
| const item = { | ||
| title: 'Dharma Talk', | ||
| description: 'A talk about dharma practice', | ||
| content: 'Some long content here' | ||
| }; | ||
| const blurb = getBlurbForResult(result, item, []); | ||
| assert.ok(blurb.includes('talk about dharma')); | ||
| }); | ||
|
|
||
| it('returns description when no content positions', () => { | ||
| const result = { | ||
| matchData: { metadata: { term: { content: { position: [] } } } } | ||
| }; | ||
| const item = { | ||
| title: 'Test', | ||
| description: 'A short description', | ||
| content: 'content body' | ||
| }; | ||
| const blurb = getBlurbForResult(result, item, []); | ||
| assert.ok(blurb.includes('short description')); | ||
| }); | ||
|
|
||
| it('truncates long descriptions with ellipsis', () => { | ||
| const result = { | ||
| matchData: { metadata: { term: { title: { position: [[0, 4]] } } } } | ||
| }; | ||
| const longDesc = 'word '.repeat(100); | ||
| const item = { title: 'Test', description: longDesc, content: '' }; | ||
| const blurb = getBlurbForResult(result, item, []); | ||
| assert.ok(blurb.endsWith('...')); | ||
| assert.ok(blurb.length < longDesc.length); | ||
| }); | ||
|
|
||
| it('extracts content blurb around match positions', () => { | ||
|
khemarato marked this conversation as resolved.
|
||
| const result = { | ||
| matchData: { metadata: { term: { content: { position: [[50, 5]] } } } } | ||
| }; | ||
| const content = 'a '.repeat(50) + 'MATCH' + ' b'.repeat(200); | ||
| const item = { title: 'Test', description: null, content: content }; | ||
| const blurb = getBlurbForResult(result, item, [50]); | ||
| assert.ok(blurb.includes('MATCH') || blurb.includes('<strong>')); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. or? |
||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.