From c92035066049b8b2d8d43b89c63a36ab3cc9d9e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 10:58:42 +0000 Subject: [PATCH] Run template JS tests with node:test instead of mini_racer The JS tests for the Aliki highlighters, the Aliki search ranker and controller, and the json_index searcher now run with Node's built-in test runner. They load the template scripts into a vm context, the same way a page loads them as classic scripts. The highlighter tests drive the real DOMContentLoaded path through a small fake document, so they no longer rewrite the script source before they run it. mini_racer is no longer a development dependency. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01G1CzhozGTbKZGZrsPu3LBa --- .github/workflows/lint.yml | 2 + CONTRIBUTING.md | 11 + Gemfile | 4 - package.json | 3 +- test/js/aliki/bash_highlighter.test.js | 162 +++++ test/js/aliki/c_highlighter.test.js | 284 +++++++++ test/js/aliki/search.test.js | 497 ++++++++++++++++ test/js/helper.js | 66 +++ test/js/json_index/searcher.test.js | 303 ++++++++++ .../generator/aliki/highlight_bash_test.rb | 203 ------- test/rdoc/generator/aliki/highlight_c_test.rb | 326 ---------- test/rdoc/generator/aliki/search_test.rb | 555 ------------------ .../generator/json_index/searcher_test.rb | 336 ----------- 13 files changed, 1327 insertions(+), 1425 deletions(-) create mode 100644 test/js/aliki/bash_highlighter.test.js create mode 100644 test/js/aliki/c_highlighter.test.js create mode 100644 test/js/aliki/search.test.js create mode 100644 test/js/helper.js create mode 100644 test/js/json_index/searcher.test.js delete mode 100644 test/rdoc/generator/aliki/highlight_bash_test.rb delete mode 100644 test/rdoc/generator/aliki/highlight_c_test.rb delete mode 100644 test/rdoc/generator/aliki/search_test.rb delete mode 100644 test/rdoc/generator/json_index/searcher_test.rb diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 5a66242f43..0b80dce053 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -35,3 +35,5 @@ jobs: run: npm ci - name: Run stylelint run: npm run lint:css + - name: Run JS tests + run: npm run test:js diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0f1b8593ee..1bbe01fba7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,6 +52,17 @@ bundle exec rake verify_generated - **Test Location:** `test/` directory - **Test Helper:** `test/lib/helper.rb` +### JavaScript Tests + +The template JavaScript is tested with Node's built-in test runner. It needs Node 20 or later and no npm packages. + +```bash +npm run test:js +``` + +- **Test Location:** `test/js/` directory +- **Test Helper:** `test/js/helper.js` + ## Linting ### RuboCop (Ruby Code) diff --git a/Gemfile b/Gemfile index 4b2be8f5d5..31d0c3008e 100644 --- a/Gemfile +++ b/Gemfile @@ -16,7 +16,3 @@ if ENV['PRISM_VERSION'] == 'head' elsif ENV['PRISM_VERSION'] gem 'prism', ENV['PRISM_VERSION'] end - -platforms :ruby do - gem 'mini_racer' # For testing the searcher.js file -end diff --git a/package.json b/package.json index a1c219ec5a..22515bd046 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,8 @@ "name": "rdoc", "private": true, "scripts": { - "lint:css": "stylelint 'lib/rdoc/generator/template/**/*.css'" + "lint:css": "stylelint 'lib/rdoc/generator/template/**/*.css'", + "test:js": "node --test test/js/*/*.test.js" }, "devDependencies": { "stylelint": "^16.25.0", diff --git a/test/js/aliki/bash_highlighter.test.js b/test/js/aliki/bash_highlighter.test.js new file mode 100644 index 0000000000..2c98df387a --- /dev/null +++ b/test/js/aliki/bash_highlighter.test.js @@ -0,0 +1,162 @@ +'use strict'; + +const { test } = require('node:test'); +const { assert, assertIncludes, refuteIncludes, loadHighlighter } = require('../helper'); + +const highlight = loadHighlighter('aliki/js/bash_highlighter.js'); + +test('prompts', () => { + // $ followed by space or end of line is a prompt + for (const [input, expected] of [ + ['$ bundle exec rake', '$'], + [' $ npm install', '$'], + ['$', '$'], + ]) { + assertIncludes(highlight(input), expected, `Failed for: ${input}`); + } + + // $VAR is a variable, not a prompt + refuteIncludes(highlight('$HOME/bin'), ''); +}); + +test('comments', () => { + for (const [input, expected] of [ + ['# This is a comment', '# This is a comment'], + ['bundle exec rake # Run tests', '# Run tests'], + ]) { + assertIncludes(highlight(input), expected, `Failed for: ${input}`); + } +}); + +test('options', () => { + for (const [input, expected] of [ + ['ls -l', '-l'], + ['npm install --save-dev', '--save-dev'], + ['git commit --message=fix', '--message=fix'], + ['ls -la --color=auto', ['-la', '--color=auto']], + ]) { + for (const exp of [expected].flat()) { + assertIncludes(highlight(input), exp, `Failed for: ${input}`); + } + } +}); + +test('options with quoted values', () => { + // Option with quoted value should stop at the quote + let result = highlight('git commit --message="initial commit"'); + assertIncludes(result, '--message='); + assertIncludes(result, '"initial commit"'); + + // Single-quoted value + result = highlight("git commit --message='initial commit'"); + assertIncludes(result, '--message='); + assertIncludes(result, ''initial commit''); +}); + +test('strings', () => { + for (const [input, expected] of [ + ['echo "hello world"', '"hello world"'], + ["echo 'hello world'", ''hello world''], + ['echo "hello \\"world\\""', '"hello \\"world\\""'], + ['npx @herb-tools/linter "**/*.rhtml"', '"**/*.rhtml"'], + ]) { + assertIncludes(highlight(input), expected, `Failed for: ${input}`); + } +}); + +test('commands', () => { + const result = highlight('bundle exec rake'); + assertIncludes(result, 'bundle'); + // Only the first word is highlighted as command + refuteIncludes(result, 'exec'); + refuteIncludes(result, 'rake'); +}); + +test('path commands', () => { + for (const [input, expected] of [ + ['./configure --prefix=/usr/local', './configure'], + ['../configure --enable-gcov', '../configure'], + ['./autogen.sh', './autogen.sh'], + ['~/.rubies/ruby-master/bin/ruby -e "puts 1"', '~/.rubies/ruby-master/bin/ruby'], + ]) { + assertIncludes(highlight(input), expected, `Failed for: ${input}`); + } +}); + +test('absolute path commands', () => { + for (const [input, expected] of [ + ['/bin/sh -c "echo hello"', '/bin/sh'], + ['/usr/bin/env ruby', '/usr/bin/env'], + ['/opt/homebrew/bin/ruby -v', '/opt/homebrew/bin/ruby'], + ]) { + assertIncludes(highlight(input), expected, `Failed for: ${input}`); + } +}); + +test('environment variables', () => { + for (const [input, expected] of [ + ['COVERAGE=true make test', ['COVERAGE=', 'make']], + ['CC=clang CXX=clang++ make', ['CC=', 'CXX=', 'make']], + ['RUBY_TEST_TIMEOUT_SCALE=5 make check', ['RUBY_TEST_TIMEOUT_SCALE=', 'make']], + ]) { + for (const exp of [expected].flat()) { + assertIncludes(highlight(input), exp, `Failed for: ${input}`); + } + } +}); + +test('hyphens in words not options', () => { + // Hyphen in @herb-tools/linter should NOT be treated as option + let result = highlight('npx @herb-tools/linter'); + assertIncludes(result, 'npx'); + refuteIncludes(result, '-tools/linter'); + assertIncludes(result, '@herb-tools/linter'); + + // Command with hyphen gets highlighted as command, not option + result = highlight('some-command arg'); + assertIncludes(result, 'some-command'); + refuteIncludes(result, ''); +}); + +test('complex commands', () => { + // Typical shell command with prompt + let result = highlight('$ bundle exec rubocop -A'); + assertIncludes(result, '$'); + assertIncludes(result, 'bundle'); + assertIncludes(result, '-A'); + + // Complex git command + result = highlight('$ git commit -m "Fix bug" --no-verify'); + assertIncludes(result, '$'); + assertIncludes(result, 'git'); + assertIncludes(result, '-m'); + assertIncludes(result, '"Fix bug"'); + assertIncludes(result, '--no-verify'); +}); + +test('multiline with comments', () => { + const code = `# Generate documentation (creates _site directory) +bundle exec rake rdoc + +# Force regenerate documentation +bundle exec rake rerdoc +`; + + const result = highlight(code); + assertIncludes(result, '# Generate documentation (creates _site directory)'); + assertIncludes(result, '# Force regenerate documentation'); +}); + +test('empty and whitespace', () => { + assert.equal(highlight(''), ''); + assert.equal(highlight(' \n\t \n '), ' \n\t \n '); +}); + +test('html escaping', () => { + let result = highlight('echo ""'); + assertIncludes(result, '<script>'); + assertIncludes(result, '</script>'); + + result = highlight('echo "a && b"'); + assertIncludes(result, '&&'); +}); diff --git a/test/js/aliki/c_highlighter.test.js b/test/js/aliki/c_highlighter.test.js new file mode 100644 index 0000000000..5323bae8b8 --- /dev/null +++ b/test/js/aliki/c_highlighter.test.js @@ -0,0 +1,284 @@ +'use strict'; + +const { test } = require('node:test'); +const { assert, assertIncludes, refuteIncludes, loadHighlighter } = require('../helper'); + +const highlight = loadHighlighter('aliki/js/c_highlighter.js'); + +test('keywords', () => { + const result = highlight('int main() { return 0; }'); + + assertIncludes(result, 'int'); + assertIncludes(result, 'return'); +}); + +test('identifiers', () => { + const result = highlight('int x = 5; char *name;'); + + assertIncludes(result, 'x'); + assertIncludes(result, 'name'); +}); + +test('operators', () => { + const result = highlight('a == b && c != d'); + + assertIncludes(result, '=='); + assertIncludes(result, '&&'); + assertIncludes(result, '!='); +}); + +test('single char operators', () => { + const result = highlight('a + b - c * d / e'); + + assertIncludes(result, '+'); + assertIncludes(result, '-'); + assertIncludes(result, '*'); + assertIncludes(result, '/'); +}); + +test('preprocessor directives', () => { + const result = highlight('#include \n#define MAX 100'); + + assertIncludes(result, '#include <stdio.h>'); + assertIncludes(result, '#define MAX 100'); +}); + +test('preprocessor with line continuation', () => { + const result = highlight('#define LONG_MACRO \\\n value'); + + // Preprocessor should capture everything including the line continuation + assertIncludes(result, '#define LONG_MACRO \\'); + assertIncludes(result, ' value'); +}); + +test('single line comment', () => { + const result = highlight('// This is a comment'); + + assertIncludes(result, '// This is a comment'); +}); + +test('multi line comment', () => { + const result = highlight('/* Multi\\nline\\ncomment */'); + + assertIncludes(result, '/* Multi\\nline\\ncomment */'); +}); + +test('string literals', () => { + const result = highlight('"hello world"'); + + assertIncludes(result, '"hello world"'); +}); + +test('string with escapes', () => { + const result = highlight('"hello \\"world\\""'); + + assertIncludes(result, '"hello \\"world\\""'); +}); + +test('character literals', () => { + const result = highlight("'a'"); + + assertIncludes(result, ''a''); +}); + +test('character literals with escapes', () => { + const result = highlight("'\\n' '\\\\' '\\''"); + + assertIncludes(result, ''\\n''); + assertIncludes(result, ''\\\\''); + assertIncludes(result, ''\\'''); +}); + +test('decimal numbers', () => { + const result = highlight('42 3.14 2.5e10'); + + assertIncludes(result, '42'); + assertIncludes(result, '3.14'); + assertIncludes(result, '2.5e10'); +}); + +test('hexadecimal numbers', () => { + const result = highlight('0xFF 0xDEADBEEF'); + + assertIncludes(result, '0xFF'); + assertIncludes(result, '0xDEADBEEF'); +}); + +test('octal numbers', () => { + const result = highlight('0755'); + + assertIncludes(result, '0755'); +}); + +test('number suffixes', () => { + const result = highlight('42u 3.14f 100L'); + + assertIncludes(result, '42u'); + assertIncludes(result, '3.14f'); + assertIncludes(result, '100L'); +}); + +test('html escaping', () => { + const result = highlight('if (x < 5 && y > 10) {}'); + + assertIncludes(result, '<'); + assertIncludes(result, '>'); + assertIncludes(result, '&&'); +}); + +test('complex c code', () => { + const code = `#include + +/* Main function */ +int main() { + char *str = "Hello, World!"; + int x = 0xFF; + + // Print the string + if (x > 0) { + printf("%s\\n", str); + } + return 0; +} +`; + + const result = highlight(code); + + // Verify key components are highlighted + assertIncludes(result, '#include <stdio.h>'); + assertIncludes(result, '/* Main function */'); + assertIncludes(result, 'int'); + assertIncludes(result, 'char'); + assertIncludes(result, 'return'); + assertIncludes(result, 'main'); + assertIncludes(result, 'printf'); + assertIncludes(result, '"Hello, World!"'); + assertIncludes(result, '0xFF'); + assertIncludes(result, '0'); + assertIncludes(result, '// Print the string'); +}); + +test('empty code', () => { + const result = highlight(''); + + assert.equal(result, ''); +}); + +test('only whitespace', () => { + const result = highlight(' \n\t \n '); + + assert.equal(result, ' \n\t \n '); +}); + +test('c11 keywords', () => { + const result = highlight('_Atomic _Bool _Complex _Thread_local'); + + assertIncludes(result, '_Atomic'); + assertIncludes(result, '_Bool'); + assertIncludes(result, '_Complex'); + assertIncludes(result, '_Thread_local'); +}); + +test('preprocessor only at line start', () => { + // # in the middle of code should not be treated as preprocessor + const result = highlight('int x = 5 # 3;'); + + refuteIncludes(result, ''); + // The # should be treated as regular text + assertIncludes(result, '#'); +}); + +test('typical function definition', () => { + const code = `static VALUE +rb_ary_all_p(int argc, VALUE *argv, VALUE ary) +{ + long i; + + for (i = 0; i < RARRAY_LEN(ary); i++) { + if (!RTEST(RARRAY_AREF(ary, i))) { + return Qfalse; + } + } + return Qtrue; +} +`; + + const result = highlight(code); + + // Verify it highlights correctly without errors + assertIncludes(result, 'static'); + assertIncludes(result, 'VALUE'); + assertIncludes(result, 'rb_ary_all_p'); + assertIncludes(result, 'int'); + assertIncludes(result, 'long'); + assertIncludes(result, 'for'); + assertIncludes(result, 'return'); + assertIncludes(result, 'Qtrue'); + assertIncludes(result, 'Qfalse'); + assertIncludes(result, 'RARRAY_LEN'); + assertIncludes(result, 'RTEST'); + assertIncludes(result, 'RARRAY_AREF'); +}); + +test('macros all caps', () => { + const result = highlight('RARRAY_LEN(ary) ARY_EMBED_P(x) FL_UNSET_EMBED'); + + assertIncludes(result, 'RARRAY_LEN'); + assertIncludes(result, 'ARY_EMBED_P'); + assertIncludes(result, 'FL_UNSET_EMBED'); +}); + +test('types common ruby types', () => { + const result = highlight('VALUE obj; ID name; size_t len;'); + + assertIncludes(result, 'VALUE'); + assertIncludes(result, 'ID'); + assertIncludes(result, 'size_t'); +}); + +test('types with t suffix', () => { + const result = highlight('uint32_t count; ssize_t result; ptrdiff_t diff;'); + + assertIncludes(result, 'uint32_t'); + assertIncludes(result, 'ssize_t'); + assertIncludes(result, 'ptrdiff_t'); +}); + +test('function names', () => { + const result = highlight('rb_ary_replace(copy, orig); ary_memcpy(dest, src);'); + + assertIncludes(result, 'rb_ary_replace'); + assertIncludes(result, 'ary_memcpy'); +}); + +test('function definition', () => { + const result = highlight('VALUE rb_ary_new(void) {'); + + assertIncludes(result, 'VALUE'); + assertIncludes(result, 'rb_ary_new'); + assertIncludes(result, 'void'); +}); + +test('variable names', () => { + const result = highlight('int count = 5; char *ptr = NULL;'); + + assertIncludes(result, 'count'); + assertIncludes(result, 'ptr'); + assertIncludes(result, 'NULL'); +}); + +test('mixed identifiers in expression', () => { + const result = highlight('if (ARY_EMBED_P(ary) && len > 0)'); + + assertIncludes(result, 'ARY_EMBED_P'); + assertIncludes(result, 'ary'); + assertIncludes(result, 'len'); +}); + +test('macro vs function distinction', () => { + // Macros are ALL_CAPS, functions are not + const result = highlight('RARRAY_LEN(ary) vs ary_length(ary)'); + + assertIncludes(result, 'RARRAY_LEN'); + assertIncludes(result, 'ary_length'); +}); diff --git a/test/js/aliki/search.test.js b/test/js/aliki/search.test.js new file mode 100644 index 0000000000..6c619bf105 --- /dev/null +++ b/test/js/aliki/search.test.js @@ -0,0 +1,497 @@ +'use strict'; + +const { describe, test } = require('node:test'); +const { assert, assertIncludes, refuteIncludes, loadScripts } = require('../helper'); + +function runSearch({ query, data }) { + const context = loadScripts(['aliki/js/search_ranker.js']); + // Copy the results out of the script context so deepEqual compares plain arrays. + return structuredClone(context.search(query, data)); +} + +// Goes through SearchController to catch bugs like the query being +// lowercased before it reaches the ranker. +function runSearchThroughController({ query, data }) { + const context = loadScripts( + ['aliki/js/search_navigation.js', 'aliki/js/search_ranker.js', 'aliki/js/search_controller.js'], + { document: { addEventListener() {} } } + ); + const input = { + value: '', + addEventListener() {}, + setAttribute() {}, + select() {} + }; + const result = { + innerHTML: '', + parentNode: { scrollTop: 0, offsetHeight: 100 }, + childElementCount: 0, + firstChild: null, + setAttribute() {}, + appendChild(item) { + this.childElementCount++; + if (!this.firstChild) this.firstChild = item; + } + }; + const controller = new context.SearchController({ index: data }, input, result); + + // Capture the ranked entries before SearchController formats them. + let rawResults = []; + const originalFind = controller.ranker.find.bind(controller.ranker); + controller.ranker.find = function(q) { + rawResults = context.search(q, this.index); + originalFind(q); + }; + controller.renderItem = () => ({ classList: { add() {} }, setAttribute() {} }); + + controller.search(query); + return rawResults; +} + +describe('search', () => { + // Minimum query length requirement (1 character) + test('minimum query length works for single char', () => { + const results = runSearch({ + query: 'H', + data: [ + { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' }, + { name: 'Help', full_name: 'Help', type: 'class', path: 'Help.html' }, + ], + }); + + assert.equal(results.length, 2); + }); + + test('minimum query length works for two chars', () => { + const results = runSearch({ + query: 'Ha', + data: [ + { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' }, + { name: 'Help', full_name: 'Help', type: 'class', path: 'Help.html' }, + ], + }); + + assert.equal(results.length, 1); + assert.equal(results[0].name, 'Hash'); + }); + + // Prefix matching ranks higher than substring matching + test('prefix match ranks higher than substring match', () => { + const results = runSearch({ + query: 'Ha', + data: [ + { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' }, + { name: 'Aha', full_name: 'Aha', type: 'class', path: 'Aha.html' }, + ], + }); + + assert.equal(results.length, 2); + assert.equal(results[0].name, 'Hash', 'Prefix match should rank first'); + assert.equal(results[1].name, 'Aha', 'Substring match should rank second'); + }); + + // Substring matching support + test('substring match finds suffix matches', () => { + const results = runSearch({ + query: 'ter', + data: [ + { name: 'filter', full_name: 'Array#filter', type: 'instance_method', path: 'Array.html#filter' }, + { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' }, + ], + }); + + assert.equal(results.length, 1); + assert.equal(results[0].name, 'filter'); + }); + + // Fuzzy matching support (characters in order) + test('fuzzy match finds non contiguous matches', () => { + const results = runSearch({ + query: 'addalias', + data: [ + { name: 'add_foo_alias', full_name: 'RDoc::Context#add_foo_alias', type: 'instance_method', path: 'x' }, + { name: 'add_alias', full_name: 'RDoc::Context#add_alias', type: 'instance_method', path: 'x' }, + { name: 'Hash', full_name: 'Hash', type: 'class', path: 'x' }, + ], + }); + + assert.equal(results.length, 2); + // Both are fuzzy matches; shorter name wins + assert.equal(results[0].name, 'add_alias'); + assert.equal(results[1].name, 'add_foo_alias'); + }); + + // Case-based type priority: uppercase query prioritizes classes/modules + test('uppercase query prioritizes class over method', () => { + const results = runSearch({ + query: 'Hash', + data: [ + { name: 'hash', full_name: 'Object#hash', type: 'instance_method', path: 'Object.html#hash' }, + { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' }, + ], + }); + + assert.equal(results.length, 2); + assert.equal(results[0].name, 'Hash'); + assert.equal(results[0].type, 'class'); + assert.equal(results[1].name, 'hash'); + }); + + // Case-based type priority: lowercase query prioritizes methods + test('lowercase query prioritizes method over class', () => { + const results = runSearch({ + query: 'hash', + data: [ + { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' }, + { name: 'hash', full_name: 'Object#hash', type: 'instance_method', path: 'Object.html#hash' }, + ], + }); + + assert.equal(results.length, 2); + assert.equal(results[0].name, 'hash'); + assert.equal(results[0].type, 'instance_method'); + assert.equal(results[1].name, 'Hash'); + }); + + // Unqualified match > qualified match + test('unqualified match prioritized over qualified', () => { + const results = runSearch({ + query: 'Foo', + data: [ + { name: 'Foo', full_name: 'Bar::Foo', type: 'class', path: 'Bar/Foo.html' }, + { name: 'Foo', full_name: 'Foo', type: 'class', path: 'Foo.html' }, + ], + }); + + assert.equal(results.length, 2); + assert.equal(results[0].full_name, 'Foo'); + assert.equal(results[1].full_name, 'Bar::Foo'); + }); + + // Shorter name > longer name + test('shorter name prioritized', () => { + const results = runSearch({ + query: 'Hash', + data: [ + { name: 'Hashable', full_name: 'Hashable', type: 'module', path: 'Hashable.html' }, + { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' }, + { name: 'HashWithIndifferentAccess', full_name: 'HashWithIndifferentAccess', type: 'class', path: 'HashWithIndifferentAccess.html' }, + ], + }); + + assert.equal(results.length, 3); + assert.equal(results[0].name, 'Hash'); + assert.equal(results[1].name, 'Hashable'); + assert.equal(results[2].name, 'HashWithIndifferentAccess'); + }); + + // Class method > instance method + test('class method prioritized over instance method', () => { + const results = runSearch({ + query: 'hash', + data: [ + { name: 'hash', full_name: 'Object#hash', type: 'instance_method', path: 'Object.html#hash' }, + { name: 'hash', full_name: 'Digest::Base::hash', type: 'class_method', path: 'Digest/Base.html#hash' }, + ], + }); + + assert.equal(results.length, 2); + assert.equal(results[0].type, 'class_method'); + assert.equal(results[1].type, 'instance_method'); + }); + + // Exact full match wins + test('exact full name match wins', () => { + const results = runSearch({ + query: 'Bar::Foo', + data: [ + { name: 'Foo', full_name: 'Foo', type: 'class', path: 'Foo.html' }, + { name: 'Foo', full_name: 'Bar::Foo', type: 'class', path: 'Bar/Foo.html' }, + { name: 'Baz', full_name: 'Foo::Baz', type: 'class', path: 'Foo/Baz.html' }, + ], + }); + + assert.equal(results.length, 1); + assert.equal(results[0].full_name, 'Bar::Foo'); + }); + + // Namespace query matches within namespace + test('namespace query matches namespace', () => { + const results = runSearch({ + query: 'Foo::B', + data: [ + { name: 'Bar', full_name: 'Bar', type: 'class', path: 'Bar.html' }, + { name: 'Bar', full_name: 'Foo::Bar', type: 'class', path: 'Foo/Bar.html' }, + { name: 'Baz', full_name: 'Foo::Baz', type: 'class', path: 'Foo/Baz.html' }, + ], + }); + + assert.equal(results.length, 2); + const names = results.map((r) => r.full_name); + assertIncludes(names, 'Foo::Bar'); + assertIncludes(names, 'Foo::Baz'); + }); + + // Method query with # matches against full_name + test('instance method query matches full name', () => { + const results = runSearch({ + query: 'Array#filter', + data: [ + { name: 'filter', full_name: 'Array#filter', type: 'instance_method', path: 'Array.html#filter' }, + { name: 'filter', full_name: 'Enumerable#filter', type: 'instance_method', path: 'Enumerable.html#filter' }, + { name: 'filter', full_name: 'Hash#filter', type: 'instance_method', path: 'Hash.html#filter' }, + ], + }); + + assert.equal(results.length, 1); + assert.equal(results[0].full_name, 'Array#filter'); + }); + + // Method query with . matches against full_name (class methods) + // Note: RDoc uses :: for class methods in full_name, but users may type . (Ruby convention) + // The search normalizes . to :: so "Array.try_convert" matches "Array::try_convert" + test('class method query matches full name', () => { + const results = runSearch({ + query: 'Array.try_convert', + data: [ + { name: 'try_convert', full_name: 'Array::try_convert', type: 'class_method', path: 'Array.html#try_convert' }, + { name: 'try_convert', full_name: 'Hash::try_convert', type: 'class_method', path: 'Hash.html#try_convert' }, + { name: 'try_convert', full_name: 'String::try_convert', type: 'class_method', path: 'String.html#try_convert' }, + ], + }); + + assert.equal(results.length, 1); + assert.equal(results[0].full_name, 'Array::try_convert'); + }); + + // Method query prefix matching against full_name + test('method query prefix matching', () => { + const results = runSearch({ + query: 'Array#fi', + data: [ + { name: 'filter', full_name: 'Array#filter', type: 'instance_method', path: 'Array.html#filter' }, + { name: 'find', full_name: 'Array#find', type: 'instance_method', path: 'Array.html#find' }, + { name: 'first', full_name: 'Array#first', type: 'instance_method', path: 'Array.html#first' }, + { name: 'filter', full_name: 'Hash#filter', type: 'instance_method', path: 'Hash.html#filter' }, + ], + }); + + assert.equal(results.length, 3); + const fullNames = results.map((r) => r.full_name); + assertIncludes(fullNames, 'Array#filter'); + assertIncludes(fullNames, 'Array#find'); + assertIncludes(fullNames, 'Array#first'); + refuteIncludes(fullNames, 'Hash#filter'); + }); + + // Method query substring matching against full_name + test('method query substring matching', () => { + const results = runSearch({ + query: '#filter', + data: [ + { name: 'filter', full_name: 'Array#filter', type: 'instance_method', path: 'Array.html#filter' }, + { name: 'filter', full_name: 'Hash#filter', type: 'instance_method', path: 'Hash.html#filter' }, + { name: 'filter_map', full_name: 'Array#filter_map', type: 'instance_method', path: 'Array.html#filter_map' }, + ], + }); + + assert.equal(results.length, 3); + // All entries contain #filter in their full_name + for (const r of results) { + assert.match(r.full_name, /#filter/); + } + }); + + // Special characters + test('special characters searchable', () => { + const results = runSearch({ + query: '<<', + data: [ + { name: '<<', full_name: 'Array#<<', type: 'instance_method', path: 'Array.html#<<' }, + { name: 'Array', full_name: 'Array', type: 'class', path: 'Array.html' }, + ], + }); + + assert.equal(results.length, 1); + assert.equal(results[0].name, '<<'); + }); + + test('bracket method searchable', () => { + const results = runSearch({ + query: '[]', + data: [ + { name: '[]', full_name: 'Hash#[]', type: 'instance_method', path: 'Hash.html#[]' }, + { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' }, + ], + }); + + assert.equal(results.length, 1); + assert.equal(results[0].name, '[]'); + }); + + // Result limit + test('result limit 30', () => { + const data = Array.from({ length: 50 }, (_, i) => ({ name: `Test${i}`, full_name: `Test${i}`, type: 'class', path: `Test${i}.html` })); + + const results = runSearch({ query: 'Test', data: data }); + + assert.equal(results.length, 30); + }); + + // Empty query + test('empty query returns empty', () => { + const results = runSearch({ + query: '', + data: [ + { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' }, + ], + }); + + assert.deepEqual(results, []); + }); + + // No matches + test('no matches returns empty', () => { + const results = runSearch({ + query: 'xyz', + data: [ + { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' }, + { name: 'Array', full_name: 'Array', type: 'class', path: 'Array.html' }, + ], + }); + + assert.deepEqual(results, []); + }); + + // Case insensitive matching + test('case insensitive matching', () => { + const results = runSearch({ + query: 'HASH', + data: [ + { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' }, + ], + }); + + assert.equal(results.length, 1); + assert.equal(results[0].name, 'Hash'); + }); + + // Constant search + test('constant search', () => { + const results = runSearch({ + query: 'VER', + data: [ + { name: 'VERSION', full_name: 'RDoc::VERSION', type: 'constant', path: 'RDoc.html' }, + { name: 'Verifier', full_name: 'Verifier', type: 'class', path: 'Verifier.html' }, + ], + }); + + assert.equal(results.length, 2); + // Verifier is unqualified (name == full_name), VERSION is qualified (RDoc::VERSION) + // Unqualified wins over qualified, then shorter wins within same qualification + assert.equal(results[0].name, 'Verifier'); + assert.equal(results[1].name, 'VERSION'); + }); + + // Exact name match should win over prefix match + test('exact name match beats prefix match', () => { + const results = runSearch({ + query: 'RDoc', + data: [ + { name: 'rdoc_version', full_name: 'RDoc::RubygemsHook#rdoc_version', type: 'instance_method', path: 'RDoc/RubygemsHook.html#rdoc_version' }, + { name: 'RDoc', full_name: 'RDoc', type: 'module', path: 'RDoc.html' }, + { name: 'RDoc', full_name: 'RDoc::RDoc', type: 'class', path: 'RDoc/RDoc.html' }, + ], + }); + + assert.equal(results.length, 3); + // Exact name matches should come first + assert.equal(results[0].full_name, 'RDoc', 'Expected top-level RDoc module first'); + assert.equal(results[1].full_name, 'RDoc::RDoc', 'Expected RDoc::RDoc class second'); + assert.equal(results[2].full_name, 'RDoc::RubygemsHook#rdoc_version', 'Expected rdoc_version method last'); + }); + + // Hash class should rank higher than #hash methods when searching "Hash" + // This replicates the bug where methods appear before the class + test('hash class ranks higher than hash methods', () => { + const results = runSearch({ + query: 'Hash', + data: [ + { name: 'hash', full_name: 'Gem::Resolver::IndexSpecification#hash', type: 'instance_method', path: 'Gem/Resolver/IndexSpecification.html#hash' }, + { name: 'hash', full_name: 'URI::Generic#hash', type: 'instance_method', path: 'URI/Generic.html#hash' }, + { name: 'hash', full_name: 'Struct#hash', type: 'instance_method', path: 'Struct.html#hash' }, + { name: 'hash', full_name: 'Regexp#hash', type: 'instance_method', path: 'Regexp.html#hash' }, + { name: 'hash', full_name: 'Object#hash', type: 'instance_method', path: 'Object.html#hash' }, + { name: 'hash', full_name: 'Time#hash', type: 'instance_method', path: 'Time.html#hash' }, + { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' }, + { name: 'Hash', full_name: 'Gem::SafeMarshal::Elements::Hash', type: 'class', path: 'Gem/SafeMarshal/Elements/Hash.html' }, + ], + }); + + assert.equal(results.length, 8); + // Hash class should come first (uppercase query + exact name match + unqualified) + assert.equal(results[0].full_name, 'Hash', 'Expected Hash class first'); + // Nested Hash class second (uppercase query + exact name match, but qualified) + assert.equal(results[1].full_name, 'Gem::SafeMarshal::Elements::Hash', 'Expected nested Hash class second'); + // Methods should come after classes + assert.equal(results[2].type, 'instance_method', 'Expected methods after classes'); + }); + + // Combined priority test matching user's expectation + // User query: "Ha" + // Expected order: Hash, Hashable, HashWithIndifferentAccess, Foo::Hash, #hash + test('combined priority matching user expectation', () => { + const results = runSearch({ + query: 'Ha', + data: [ + { name: 'hash', full_name: 'Object#hash', type: 'instance_method', path: 'Object.html#hash' }, + { name: 'HashWithIndifferentAccess', full_name: 'HashWithIndifferentAccess', type: 'class', path: 'HashWithIndifferentAccess.html' }, + { name: 'Hashable', full_name: 'Hashable', type: 'module', path: 'Hashable.html' }, + { name: 'Hash', full_name: 'Foo::Hash', type: 'class', path: 'Foo/Hash.html' }, + { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' }, + ], + }); + + assert.equal(results.length, 5); + // Order: class/module first, unqualified before qualified, shorter before longer, methods last + assert.equal(results[0].full_name, 'Hash', 'Expected top-level Hash first'); + assert.equal(results[1].full_name, 'Hashable', 'Expected Hashable second (shorter unqualified)'); + assert.equal(results[2].full_name, 'HashWithIndifferentAccess', 'Expected HashWithIndifferentAccess third'); + assert.equal(results[3].full_name, 'Foo::Hash', 'Expected Foo::Hash fourth (qualified)'); + assert.equal(results[4].full_name, 'Object#hash', 'Expected method last'); + }); +}); + +describe('SearchController', () => { + // This test catches the bug where SearchController.search() was lowercasing + // the query before passing it to the ranker, breaking case-based type priority + test('uppercase query preserves case for type priority', () => { + const results = runSearchThroughController({ + query: 'Hash', + data: [ + { name: 'hash', full_name: 'Object#hash', type: 'instance_method', path: 'Object.html#hash' }, + { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' }, + ], + }); + + assert.equal(results.length, 2); + // With uppercase "Hash" query, class should come first due to type priority + assert.equal(results[0].full_name, 'Hash', 'Expected Hash class first with uppercase query'); + assert.equal(results[1].full_name, 'Object#hash', 'Expected hash method second'); + }); + + test('lowercase query prioritizes methods', () => { + const results = runSearchThroughController({ + query: 'hash', + data: [ + { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' }, + { name: 'hash', full_name: 'Object#hash', type: 'instance_method', path: 'Object.html#hash' }, + ], + }); + + assert.equal(results.length, 2); + // With lowercase "hash" query, method should come first due to type priority + assert.equal(results[0].full_name, 'Object#hash', 'Expected hash method first with lowercase query'); + assert.equal(results[1].full_name, 'Hash', 'Expected Hash class second'); + }); +}); diff --git a/test/js/helper.js b/test/js/helper.js new file mode 100644 index 0000000000..d12f56ef31 --- /dev/null +++ b/test/js/helper.js @@ -0,0 +1,66 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { inspect } = require('node:util'); +const vm = require('node:vm'); + +const TEMPLATE_DIR = path.join(__dirname, '../../lib/rdoc/generator/template'); + +// The template scripts are classic browser scripts that share globals. +// Load them into one fresh context, in the order a page loads them. +function loadScripts(files, globals = {}) { + const context = vm.createContext(globals); + for (const file of files) { + const source = fs.readFileSync(path.join(TEMPLATE_DIR, file), 'utf8'); + vm.runInContext(source, context, { filename: file }); + } + return context; +} + +// Load a highlighter script with a fake document and return a function that +// highlights one code block the way the page does on DOMContentLoaded. +function loadHighlighter(file) { + let onLoad = null; + let blocks = []; + const document = { + readyState: 'loading', + addEventListener(type, listener) { + if (type === 'DOMContentLoaded') onLoad = listener; + }, + querySelectorAll() { + return blocks; + } + }; + loadScripts([file], { document }); + + return function highlight(code) { + const attributes = {}; + const block = { + textContent: code, + innerHTML: code, + getAttribute: (name) => attributes[name], + setAttribute: (name, value) => { attributes[name] = value; } + }; + blocks = [block]; + onLoad(); + return block.innerHTML; + }; +} + +function assertIncludes(collection, item, message) { + assert.ok( + collection.includes(item), + message ?? `Expected ${inspect(collection)} to include ${inspect(item)}` + ); +} + +function refuteIncludes(collection, item, message) { + assert.ok( + !collection.includes(item), + message ?? `Expected ${inspect(collection)} not to include ${inspect(item)}` + ); +} + +module.exports = { assert, assertIncludes, refuteIncludes, loadHighlighter, loadScripts }; diff --git a/test/js/json_index/searcher.test.js b/test/js/json_index/searcher.test.js new file mode 100644 index 0000000000..aedb77b5fb --- /dev/null +++ b/test/js/json_index/searcher.test.js @@ -0,0 +1,303 @@ +'use strict'; + +const { test } = require('node:test'); +const { assert, loadScripts } = require('../helper'); + +function stripHighlights(str) { + return str.replace(/[\u0001\u0002]/g, ''); +} + +// The data sets here are small enough that Searcher#find delivers every +// result before it returns, so the test needs no timers. +function runSearch({ query, data }) { + const context = loadScripts(['json_index/js/searcher.js']); + const searcher = new context.Searcher(data); + let results = []; + searcher.ready((res) => { + results = results.concat(res); + }); + searcher.find(query); + return results; +} + +test('exact match prioritized', () => { + const results = runSearch({ + query: 'string', + data: { + searchIndex: ['string', 'string', 'strings'], + longSearchIndex: ['gem::safemarshal::elements::string', 'string', 'strings'], + info: [ + ['String', 'Gem::SafeMarshal::Elements', 'Gem/SafeMarshal/Elements/String.html', '', 'Nested String class', '', 'class'], + ['String', '', 'String.html', '', 'Top-level String class', '', 'class'], + ['Strings', '', 'Strings.html', '', 'Strings class', '', 'class'], + ], + }, + }); + + assert.equal(results.length, 3); + // Top-level String should come first despite being second in the array + assert.equal(stripHighlights(results[0].title), 'String'); + assert.equal(results[0].namespace, '', 'Top-level String should be prioritized over nested String'); + assert.equal(results[0].path, 'String.html'); + + // Nested String should come second + assert.equal(stripHighlights(results[1].title), 'String'); + assert.equal(stripHighlights(results[1].namespace), 'Gem::SafeMarshal::Elements'); +}); + +test('exact method match', () => { + const results = runSearch({ + query: 'attribute', + data: { + searchIndex: ['attributemanager', 'attributes', 'attribute()'], + longSearchIndex: ['rdoc::markup::attributemanager', 'rdoc::markup::attributes', 'rdoc::markup::attributemanager#attribute()'], + info: [ + ['AttributeManager', 'RDoc::Markup', 'RDoc/Markup/AttributeManager.html', '', 'AttributeManager class', '', 'class'], + ['Attributes', 'RDoc::Markup', 'RDoc/Markup/Attributes.html', '', 'Attributes class', '', 'class'], + ['attribute', 'RDoc::Markup::AttributeManager', 'RDoc/Markup/AttributeManager.html#method-i-attribute', '()', 'Attribute method', '', 'method'], + ], + }, + }); + + assert.equal(results.length, 3); + // attribute() method should come first despite being last in the array + assert.equal(stripHighlights(results[0].title), 'attribute'); + assert.equal(stripHighlights(results[0].namespace), 'RDoc::Markup::AttributeManager'); +}); + +test('capitalized query prioritizes exact class', () => { + const results = runSearch({ + query: 'String', + data: { + searchIndex: ['string', 'string()'], + longSearchIndex: ['string', 'object#string()'], + info: [ + ['String', '', 'String.html', '', 'String class', '', 'class'], + ['string', 'Object', 'Object.html#method-i-string', '()', 'String method', '', 'method'], + ], + }, + }); + + assert.equal(results.length, 2); + // Capitalized query: exact class (Pass 0) beats exact method (Pass 1) + assert.equal(stripHighlights(results[0].title), 'String'); + assert.equal(results[0].namespace, ''); + assert.equal(results[0].path, 'String.html'); + + // Method comes second + assert.equal(stripHighlights(results[1].title), 'string'); + assert.equal(stripHighlights(results[1].namespace), 'Object'); +}); + +test('lowercase query prioritizes method', () => { + const results = runSearch({ + query: 'options', + data: { + searchIndex: ['options', 'options()'], + longSearchIndex: ['rdoc::options', 'rdoc::codeobject#options()'], + info: [ + ['Options', 'RDoc', 'RDoc/Options.html', '', 'Options class', '', 'class'], + ['options', 'RDoc::CodeObject', 'RDoc/CodeObject.html#method-i-options', '()', 'Options method', '', 'method'], + ], + }, + }); + + assert.equal(results.length, 2); + // Lowercase query should prioritize method over class + assert.equal(stripHighlights(results[0].title), 'options'); + assert.equal(stripHighlights(results[0].namespace), 'RDoc::CodeObject'); + assert.equal(results[0].path, 'RDoc/CodeObject.html#method-i-options'); + + // Class comes second + assert.equal(stripHighlights(results[1].title), 'Options'); + assert.equal(stripHighlights(results[1].namespace), 'RDoc'); +}); + +test('beginning match', () => { + const results = runSearch({ + query: 'attr', + data: { + searchIndex: ['attribute()', 'attributemanager', 'generator'], + longSearchIndex: ['rdoc::markup#attribute()', 'rdoc::markup::attributemanager', 'rdoc::generator'], + info: [ + ['attribute', 'RDoc::Markup', 'RDoc/Markup.html#method-i-attribute', '()', 'Attribute method', '', 'method'], + ['AttributeManager', 'RDoc::Markup', 'RDoc/Markup/AttributeManager.html', '', 'Manager class', '', 'class'], + ['Generator', 'RDoc', 'RDoc/Generator.html', '', 'Generator class', '', 'class'], + ], + }, + }); + + assert.equal(results.length, 2); + assert.equal(stripHighlights(results[0].title), 'attribute'); + assert.equal(stripHighlights(results[1].title), 'AttributeManager'); +}); + +test('long index match', () => { + const results = runSearch({ + query: 'rdoc::markup', + data: { + searchIndex: ['attributes', 'parser'], + longSearchIndex: ['rdoc::markup::attributes', 'rdoc::parser'], + info: [ + ['Attributes', 'RDoc::Markup', 'RDoc/Markup/Attributes.html', '', 'Attributes class', '', 'class'], + ['Parser', 'RDoc', 'RDoc/Parser.html', '', 'Parser class', '', 'class'], + ], + }, + }); + + assert.equal(results.length, 1); + assert.equal(stripHighlights(results[0].title), 'Attributes'); + assert.equal(stripHighlights(results[0].namespace), 'RDoc::Markup'); +}); + +test('contains match', () => { + const results = runSearch({ + query: 'manager', + data: { + searchIndex: ['attributemanager', 'parser'], + longSearchIndex: ['rdoc::markup::attributemanager', 'rdoc::parser'], + info: [ + ['AttributeManager', 'RDoc::Markup', 'RDoc/Markup/AttributeManager.html', '', 'Manager class', '', 'class'], + ['Parser', 'RDoc', 'RDoc/Parser.html', '', 'Parser class', '', 'class'], + ], + }, + }); + + assert.equal(results.length, 1); + assert.equal(stripHighlights(results[0].title), 'AttributeManager'); +}); + +test('regexp match', () => { + const results = runSearch({ + query: 'atrbt', + data: { + searchIndex: ['attribute()', 'generator'], + longSearchIndex: ['rdoc::markup#attribute()', 'rdoc::generator'], + info: [ + ['attribute', 'RDoc::Markup', 'RDoc/Markup.html#method-i-attribute', '()', 'Attribute method', '', 'method'], + ['Generator', 'RDoc', 'RDoc/Generator.html', '', 'Generator class', '', 'class'], + ], + }, + }); + + assert.equal(results.length, 1); + assert.equal(stripHighlights(results[0].title), 'attribute'); +}); + +test('empty query', () => { + const results = runSearch({ + query: '', + data: { + searchIndex: ['string'], + longSearchIndex: ['string'], + info: [['String', '', 'String.html', '', 'String class', '', 'class']], + }, + }); + + assert.equal(results.length, 0); +}); + +test('no matches', () => { + const results = runSearch({ + query: 'nonexistent', + data: { + searchIndex: ['string', 'attribute()'], + longSearchIndex: ['string', 'rdoc#attribute()'], + info: [ + ['String', '', 'String.html', '', 'String class', '', 'class'], + ['attribute', 'RDoc', 'RDoc.html#attribute', '()', 'Attribute method', '', 'method'], + ], + }, + }); + + assert.equal(results.length, 0); +}); + +test('multiple exact matches', () => { + const results = runSearch({ + query: 'test', + data: { + searchIndex: ['test', 'test', 'testing'], + longSearchIndex: ['test', 'rdoc::test', 'testing'], + info: [ + ['Test', '', 'Test.html', '', 'Top-level Test', '', 'class'], + ['Test', 'RDoc', 'RDoc/Test.html', '', 'RDoc Test', '', 'class'], + ['Testing', '', 'Testing.html', '', 'Testing class', '', 'class'], + ], + }, + }); + + assert.equal(results.length, 3); + // First result should be the exact match with both indexes matching + assert.equal(stripHighlights(results[0].title), 'Test'); + assert.equal(results[0].namespace, ''); +}); + +// Test case insensitive search +test('case insensitive', () => { + const results = runSearch({ + query: 'STRING', + data: { + searchIndex: ['string'], + longSearchIndex: ['string'], + info: [['String', '', 'String.html', '', 'String class', '', 'class']], + }, + }); + + assert.equal(results.length, 1); + assert.equal(stripHighlights(results[0].title), 'String'); +}); + +test('multi word query', () => { + const results = runSearch({ + query: 'rdoc markup', + data: { + searchIndex: ['attributemanager'], + longSearchIndex: ['rdoc::markup::attributemanager'], + info: [['AttributeManager', 'RDoc::Markup', 'RDoc/Markup/AttributeManager.html', '', 'Manager', '', 'class']], + }, + }); + + assert.equal(results.length, 1); + assert.equal(results[0].title, 'AttributeManager'); +}); + +test('highlighting', () => { + const results = runSearch({ + query: 'string', + data: { + searchIndex: ['string'], + longSearchIndex: ['string'], + info: [['String', '', 'String.html', '', 'String class', '', 'class']], + }, + }); + + assert.equal(results.length, 1); + // Check that highlighting markers (unicode \u0001 and \u0002) are present + assert.match(results[0].title, /[\u0001\u0002]/); +}); + +test('max results limit', () => { + // Create 150 entries (more than MAX_RESULTS = 100) + const searchIndex = []; + const longSearchIndex = []; + const info = []; + + for (let i = 0; i < 150; i++) { + searchIndex.push(`test${i}`); + longSearchIndex.push(`test${i}`); + info.push([`Test${i}`, '', `Test${i}.html`, '', `Test class ${i}`, '', 'class']); + } + + const results = runSearch({ + query: 'test', + data: { + searchIndex: searchIndex, + longSearchIndex: longSearchIndex, + info: info, + }, + }); + + // Should return at most 100 results + assert.ok(results.length <= 100); +}); diff --git a/test/rdoc/generator/aliki/highlight_bash_test.rb b/test/rdoc/generator/aliki/highlight_bash_test.rb deleted file mode 100644 index 3e5de5a96b..0000000000 --- a/test/rdoc/generator/aliki/highlight_bash_test.rb +++ /dev/null @@ -1,203 +0,0 @@ -# frozen_string_literal: true - -require_relative '../../helper' - -return if RUBY_DESCRIPTION =~ /truffleruby/ || RUBY_DESCRIPTION =~ /jruby/ - -begin - require 'mini_racer' -rescue LoadError - return -end - -class RDocGeneratorAlikiHighlightBashTest < Test::Unit::TestCase - HIGHLIGHT_BASH_JS_PATH = File.expand_path( - '../../../../lib/rdoc/generator/template/aliki/js/bash_highlighter.js', - __dir__ - ) - - HIGHLIGHT_BASH_JS = begin - highlight_bash_js = File.read(HIGHLIGHT_BASH_JS_PATH) - - # We need to modify the JS slightly to make it work in the context of a test. - highlight_bash_js.gsub( - /\(function\(\) \{[\s\S]*'use strict';/, - "// Test wrapper\n" - ).gsub( - /if \(document\.readyState[\s\S]*\}\)\(\);/, - "// Removed DOM initialization for testing" - ) - end.freeze - - def setup - @context = MiniRacer::Context.new - @context.eval(HIGHLIGHT_BASH_JS) - end - - def teardown - @context.dispose - end - - def test_prompts - # $ followed by space or end of line is a prompt - [ - ['$ bundle exec rake', '$'], - [' $ npm install', '$'], - ['$', '$'], - ].each do |input, expected| - assert_includes highlight(input), expected, "Failed for: #{input}" - end - - # $VAR is a variable, not a prompt - refute_includes highlight('$HOME/bin'), '' - end - - def test_comments - [ - ['# This is a comment', '# This is a comment'], - ['bundle exec rake # Run tests', '# Run tests'], - ].each do |input, expected| - assert_includes highlight(input), expected, "Failed for: #{input}" - end - end - - def test_options - [ - ['ls -l', '-l'], - ['npm install --save-dev', '--save-dev'], - ['git commit --message=fix', '--message=fix'], - ['ls -la --color=auto', ['-la', '--color=auto']], - ].each do |input, expected| - Array(expected).each do |exp| - assert_includes highlight(input), exp, "Failed for: #{input}" - end - end - end - - def test_options_with_quoted_values - # Option with quoted value should stop at the quote - result = highlight('git commit --message="initial commit"') - assert_includes result, '--message=' - assert_includes result, '"initial commit"' - - # Single-quoted value - result = highlight("git commit --message='initial commit'") - assert_includes result, '--message=' - assert_includes result, "'initial commit'" - end - - def test_strings - [ - ['echo "hello world"', '"hello world"'], - ["echo 'hello world'", "'hello world'"], - ['echo "hello \"world\""', '"hello \"world\""'], - ['npx @herb-tools/linter "**/*.rhtml"', '"**/*.rhtml"'], - ].each do |input, expected| - assert_includes highlight(input), expected, "Failed for: #{input}" - end - end - - def test_commands - result = highlight('bundle exec rake') - assert_includes result, 'bundle' - # Only the first word is highlighted as command - refute_includes result, 'exec' - refute_includes result, 'rake' - end - - def test_path_commands - [ - ['./configure --prefix=/usr/local', './configure'], - ['../configure --enable-gcov', '../configure'], - ['./autogen.sh', './autogen.sh'], - ['~/.rubies/ruby-master/bin/ruby -e "puts 1"', '~/.rubies/ruby-master/bin/ruby'], - ].each do |input, expected| - assert_includes highlight(input), expected, "Failed for: #{input}" - end - end - - def test_absolute_path_commands - [ - ['/bin/sh -c "echo hello"', '/bin/sh'], - ['/usr/bin/env ruby', '/usr/bin/env'], - ['/opt/homebrew/bin/ruby -v', '/opt/homebrew/bin/ruby'], - ].each do |input, expected| - assert_includes highlight(input), expected, "Failed for: #{input}" - end - end - - def test_environment_variables - [ - ['COVERAGE=true make test', ['COVERAGE=', 'make']], - ['CC=clang CXX=clang++ make', ['CC=', 'CXX=', 'make']], - ['RUBY_TEST_TIMEOUT_SCALE=5 make check', ['RUBY_TEST_TIMEOUT_SCALE=', 'make']], - ].each do |input, expected| - Array(expected).each do |exp| - assert_includes highlight(input), exp, "Failed for: #{input}" - end - end - end - - def test_hyphens_in_words_not_options - # Hyphen in @herb-tools/linter should NOT be treated as option - result = highlight('npx @herb-tools/linter') - assert_includes result, 'npx' - refute_includes result, '-tools/linter' - assert_includes result, '@herb-tools/linter' - - # Command with hyphen gets highlighted as command, not option - result = highlight('some-command arg') - assert_includes result, 'some-command' - refute_includes result, '' - end - - def test_complex_commands - # Typical shell command with prompt - result = highlight('$ bundle exec rubocop -A') - assert_includes result, '$' - assert_includes result, 'bundle' - assert_includes result, '-A' - - # Complex git command - result = highlight('$ git commit -m "Fix bug" --no-verify') - assert_includes result, '$' - assert_includes result, 'git' - assert_includes result, '-m' - assert_includes result, '"Fix bug"' - assert_includes result, '--no-verify' - end - - def test_multiline_with_comments - code = <<~SHELL - # Generate documentation (creates _site directory) - bundle exec rake rdoc - - # Force regenerate documentation - bundle exec rake rerdoc - SHELL - - result = highlight(code) - assert_includes result, '# Generate documentation (creates _site directory)' - assert_includes result, '# Force regenerate documentation' - end - - def test_empty_and_whitespace - assert_equal '', highlight('') - assert_equal " \n\t \n ", highlight(" \n\t \n ") - end - - def test_html_escaping - result = highlight('echo ""') - assert_includes result, '<script>' - assert_includes result, '</script>' - - result = highlight('echo "a && b"') - assert_includes result, '&&' - end - - private - - def highlight(code) - @context.eval("highlightShell(#{code.to_json})") - end -end diff --git a/test/rdoc/generator/aliki/highlight_c_test.rb b/test/rdoc/generator/aliki/highlight_c_test.rb deleted file mode 100644 index 4d7a1089db..0000000000 --- a/test/rdoc/generator/aliki/highlight_c_test.rb +++ /dev/null @@ -1,326 +0,0 @@ -# frozen_string_literal: true - -require_relative '../../helper' - -return if RUBY_DESCRIPTION =~ /truffleruby/ || RUBY_DESCRIPTION =~ /jruby/ - -begin - require 'mini_racer' -rescue LoadError - return -end - -class RDocGeneratorAlikiHighlightCTest < Test::Unit::TestCase - HIGHLIGHT_C_JS_PATH = File.expand_path( - '../../../../lib/rdoc/generator/template/aliki/js/c_highlighter.js', - __dir__ - ) - - HIGHLIGHT_C_JS = begin - highlight_c_js = File.read(HIGHLIGHT_C_JS_PATH) - - # We need to modify the JS slightly to make it work in the context of a test. - highlight_c_js.gsub( - /\(function\(\) \{[\s\S]*'use strict';/, - "// Test wrapper\n" - ).gsub( - /if \(document\.readyState[\s\S]*\}\)\(\);/, - "// Removed DOM initialization for testing" - ) - end.freeze - - def setup - @context = MiniRacer::Context.new - @context.eval(HIGHLIGHT_C_JS) - end - - def teardown - @context.dispose - end - - def test_keywords - result = highlight('int main() { return 0; }') - - assert_includes result, 'int' - assert_includes result, 'return' - end - - def test_identifiers - result = highlight('int x = 5; char *name;') - - assert_includes result, 'x' - assert_includes result, 'name' - end - - def test_operators - result = highlight('a == b && c != d') - - assert_includes result, '==' - assert_includes result, '&&' - assert_includes result, '!=' - end - - def test_single_char_operators - result = highlight('a + b - c * d / e') - - assert_includes result, '+' - assert_includes result, '-' - assert_includes result, '*' - assert_includes result, '/' - end - - def test_preprocessor_directives - result = highlight("#include \n#define MAX 100") - - assert_includes result, '#include <stdio.h>' - assert_includes result, '#define MAX 100' - end - - def test_preprocessor_with_line_continuation - result = highlight("#define LONG_MACRO \\\n value") - - # Preprocessor should capture everything including the line continuation - assert_includes result, '#define LONG_MACRO \\' - assert_includes result, ' value' - end - - def test_single_line_comment - result = highlight('// This is a comment') - - assert_includes result, '// This is a comment' - end - - def test_multi_line_comment - result = highlight('/* Multi\nline\ncomment */') - - assert_includes result, '/* Multi\nline\ncomment */' - end - - def test_string_literals - result = highlight('"hello world"') - - assert_includes result, '"hello world"' - end - - def test_string_with_escapes - result = highlight('"hello \"world\""') - - assert_includes result, '"hello \"world\""' - end - - def test_character_literals - result = highlight("'a'") - - assert_includes result, "'a'" - end - - def test_character_literals_with_escapes - result = highlight("'\\n' '\\\\' '\\''") - - assert_includes result, "'\\n'" - assert_includes result, "'\\\\'" - assert_includes result, "'\\''" - end - - def test_decimal_numbers - result = highlight('42 3.14 2.5e10') - - assert_includes result, '42' - assert_includes result, '3.14' - assert_includes result, '2.5e10' - end - - def test_hexadecimal_numbers - result = highlight('0xFF 0xDEADBEEF') - - assert_includes result, '0xFF' - assert_includes result, '0xDEADBEEF' - end - - def test_octal_numbers - result = highlight('0755') - - assert_includes result, '0755' - end - - def test_number_suffixes - result = highlight('42u 3.14f 100L') - - assert_includes result, '42u' - assert_includes result, '3.14f' - assert_includes result, '100L' - end - - def test_html_escaping - result = highlight('if (x < 5 && y > 10) {}') - - assert_includes result, '<' - assert_includes result, '>' - assert_includes result, '&&' - end - - def test_complex_c_code - code = <<~C - #include - - /* Main function */ - int main() { - char *str = "Hello, World!"; - int x = 0xFF; - - // Print the string - if (x > 0) { - printf("%s\\n", str); - } - return 0; - } - C - - result = highlight(code) - - # Verify key components are highlighted - assert_includes result, '#include <stdio.h>' - assert_includes result, '/* Main function */' - assert_includes result, 'int' - assert_includes result, 'char' - assert_includes result, 'return' - assert_includes result, 'main' - assert_includes result, 'printf' - assert_includes result, '"Hello, World!"' - assert_includes result, '0xFF' - assert_includes result, '0' - assert_includes result, '// Print the string' - end - - def test_empty_code - result = highlight('') - - assert_equal '', result - end - - def test_only_whitespace - result = highlight(" \n\t \n ") - - assert_equal " \n\t \n ", result - end - - def test_c11_keywords - result = highlight('_Atomic _Bool _Complex _Thread_local') - - assert_includes result, '_Atomic' - assert_includes result, '_Bool' - assert_includes result, '_Complex' - assert_includes result, '_Thread_local' - end - - def test_preprocessor_only_at_line_start - # # in the middle of code should not be treated as preprocessor - result = highlight('int x = 5 # 3;') - - refute_includes result, '' - # The # should be treated as regular text - assert_includes result, '#' - end - - def test_typical_function_definition - code = <<~C - static VALUE - rb_ary_all_p(int argc, VALUE *argv, VALUE ary) - { - long i; - - for (i = 0; i < RARRAY_LEN(ary); i++) { - if (!RTEST(RARRAY_AREF(ary, i))) { - return Qfalse; - } - } - return Qtrue; - } - C - - result = highlight(code) - - # Verify it highlights correctly without errors - assert_includes result, 'static' - assert_includes result, 'VALUE' - assert_includes result, 'rb_ary_all_p' - assert_includes result, 'int' - assert_includes result, 'long' - assert_includes result, 'for' - assert_includes result, 'return' - assert_includes result, 'Qtrue' - assert_includes result, 'Qfalse' - assert_includes result, 'RARRAY_LEN' - assert_includes result, 'RTEST' - assert_includes result, 'RARRAY_AREF' - end - - def test_macros_all_caps - result = highlight('RARRAY_LEN(ary) ARY_EMBED_P(x) FL_UNSET_EMBED') - - assert_includes result, 'RARRAY_LEN' - assert_includes result, 'ARY_EMBED_P' - assert_includes result, 'FL_UNSET_EMBED' - end - - def test_types_common_ruby_types - result = highlight('VALUE obj; ID name; size_t len;') - - assert_includes result, 'VALUE' - assert_includes result, 'ID' - assert_includes result, 'size_t' - end - - def test_types_with_t_suffix - result = highlight('uint32_t count; ssize_t result; ptrdiff_t diff;') - - assert_includes result, 'uint32_t' - assert_includes result, 'ssize_t' - assert_includes result, 'ptrdiff_t' - end - - def test_function_names - result = highlight('rb_ary_replace(copy, orig); ary_memcpy(dest, src);') - - assert_includes result, 'rb_ary_replace' - assert_includes result, 'ary_memcpy' - end - - def test_function_definition - result = highlight('VALUE rb_ary_new(void) {') - - assert_includes result, 'VALUE' - assert_includes result, 'rb_ary_new' - assert_includes result, 'void' - end - - def test_variable_names - result = highlight('int count = 5; char *ptr = NULL;') - - assert_includes result, 'count' - assert_includes result, 'ptr' - assert_includes result, 'NULL' - end - - def test_mixed_identifiers_in_expression - result = highlight('if (ARY_EMBED_P(ary) && len > 0)') - - assert_includes result, 'ARY_EMBED_P' - assert_includes result, 'ary' - assert_includes result, 'len' - end - - def test_macro_vs_function_distinction - # Macros are ALL_CAPS, functions are not - result = highlight('RARRAY_LEN(ary) vs ary_length(ary)') - - assert_includes result, 'RARRAY_LEN' - assert_includes result, 'ary_length' - end - - private - - def highlight(code) - @context.eval("highlightC(#{code.to_json})") - end -end diff --git a/test/rdoc/generator/aliki/search_test.rb b/test/rdoc/generator/aliki/search_test.rb deleted file mode 100644 index 5f9d72bb39..0000000000 --- a/test/rdoc/generator/aliki/search_test.rb +++ /dev/null @@ -1,555 +0,0 @@ -# frozen_string_literal: true - -require_relative '../../support/test_case' - -return if RUBY_DESCRIPTION =~ /truffleruby/ || RUBY_DESCRIPTION =~ /jruby/ - -begin - require 'mini_racer' -rescue LoadError - return -end - -class RDocGeneratorAlikiSearchRankerTest < Test::Unit::TestCase - def setup - @context = MiniRacer::Context.new - - search_ranker_js_path = File.expand_path( - '../../../../lib/rdoc/generator/template/aliki/js/search_ranker.js', - __dir__ - ) - search_ranker_js = File.read(search_ranker_js_path) - @context.eval(search_ranker_js) - end - - def teardown - @context.dispose - end - - # Minimum query length requirement (1 character) - def test_minimum_query_length_works_for_single_char - results = run_search( - query: 'H', - data: [ - { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' }, - { name: 'Help', full_name: 'Help', type: 'class', path: 'Help.html' } - ] - ) - - assert_equal 2, results.length - end - - def test_minimum_query_length_works_for_two_chars - results = run_search( - query: 'Ha', - data: [ - { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' }, - { name: 'Help', full_name: 'Help', type: 'class', path: 'Help.html' } - ] - ) - - assert_equal 1, results.length - assert_equal 'Hash', results[0]['name'] - end - - # Prefix matching ranks higher than substring matching - def test_prefix_match_ranks_higher_than_substring_match - results = run_search( - query: 'Ha', - data: [ - { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' }, - { name: 'Aha', full_name: 'Aha', type: 'class', path: 'Aha.html' } - ] - ) - - assert_equal 2, results.length - assert_equal 'Hash', results[0]['name'], "Prefix match should rank first" - assert_equal 'Aha', results[1]['name'], "Substring match should rank second" - end - - # Substring matching support - def test_substring_match_finds_suffix_matches - results = run_search( - query: 'ter', - data: [ - { name: 'filter', full_name: 'Array#filter', type: 'instance_method', path: 'Array.html#filter' }, - { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' } - ] - ) - - assert_equal 1, results.length - assert_equal 'filter', results[0]['name'] - end - - # Fuzzy matching support (characters in order) - def test_fuzzy_match_finds_non_contiguous_matches - results = run_search( - query: 'addalias', - data: [ - { name: 'add_foo_alias', full_name: 'RDoc::Context#add_foo_alias', type: 'instance_method', path: 'x' }, - { name: 'add_alias', full_name: 'RDoc::Context#add_alias', type: 'instance_method', path: 'x' }, - { name: 'Hash', full_name: 'Hash', type: 'class', path: 'x' } - ] - ) - - assert_equal 2, results.length - # Both are fuzzy matches; shorter name wins - assert_equal 'add_alias', results[0]['name'] - assert_equal 'add_foo_alias', results[1]['name'] - end - - # Case-based type priority: uppercase query prioritizes classes/modules - def test_uppercase_query_prioritizes_class_over_method - results = run_search( - query: 'Hash', - data: [ - { name: 'hash', full_name: 'Object#hash', type: 'instance_method', path: 'Object.html#hash' }, - { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' } - ] - ) - - assert_equal 2, results.length - assert_equal 'Hash', results[0]['name'] - assert_equal 'class', results[0]['type'] - assert_equal 'hash', results[1]['name'] - end - - # Case-based type priority: lowercase query prioritizes methods - def test_lowercase_query_prioritizes_method_over_class - results = run_search( - query: 'hash', - data: [ - { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' }, - { name: 'hash', full_name: 'Object#hash', type: 'instance_method', path: 'Object.html#hash' } - ] - ) - - assert_equal 2, results.length - assert_equal 'hash', results[0]['name'] - assert_equal 'instance_method', results[0]['type'] - assert_equal 'Hash', results[1]['name'] - end - - # Unqualified match > qualified match - def test_unqualified_match_prioritized_over_qualified - results = run_search( - query: 'Foo', - data: [ - { name: 'Foo', full_name: 'Bar::Foo', type: 'class', path: 'Bar/Foo.html' }, - { name: 'Foo', full_name: 'Foo', type: 'class', path: 'Foo.html' } - ] - ) - - assert_equal 2, results.length - assert_equal 'Foo', results[0]['full_name'] - assert_equal 'Bar::Foo', results[1]['full_name'] - end - - # Shorter name > longer name - def test_shorter_name_prioritized - results = run_search( - query: 'Hash', - data: [ - { name: 'Hashable', full_name: 'Hashable', type: 'module', path: 'Hashable.html' }, - { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' }, - { name: 'HashWithIndifferentAccess', full_name: 'HashWithIndifferentAccess', type: 'class', path: 'HashWithIndifferentAccess.html' } - ] - ) - - assert_equal 3, results.length - assert_equal 'Hash', results[0]['name'] - assert_equal 'Hashable', results[1]['name'] - assert_equal 'HashWithIndifferentAccess', results[2]['name'] - end - - # Class method > instance method - def test_class_method_prioritized_over_instance_method - results = run_search( - query: 'hash', - data: [ - { name: 'hash', full_name: 'Object#hash', type: 'instance_method', path: 'Object.html#hash' }, - { name: 'hash', full_name: 'Digest::Base::hash', type: 'class_method', path: 'Digest/Base.html#hash' } - ] - ) - - assert_equal 2, results.length - assert_equal 'class_method', results[0]['type'] - assert_equal 'instance_method', results[1]['type'] - end - - # Exact full match wins - def test_exact_full_name_match_wins - results = run_search( - query: 'Bar::Foo', - data: [ - { name: 'Foo', full_name: 'Foo', type: 'class', path: 'Foo.html' }, - { name: 'Foo', full_name: 'Bar::Foo', type: 'class', path: 'Bar/Foo.html' }, - { name: 'Baz', full_name: 'Foo::Baz', type: 'class', path: 'Foo/Baz.html' } - ] - ) - - assert_equal 1, results.length - assert_equal 'Bar::Foo', results[0]['full_name'] - end - - # Namespace query matches within namespace - def test_namespace_query_matches_namespace - results = run_search( - query: 'Foo::B', - data: [ - { name: 'Bar', full_name: 'Bar', type: 'class', path: 'Bar.html' }, - { name: 'Bar', full_name: 'Foo::Bar', type: 'class', path: 'Foo/Bar.html' }, - { name: 'Baz', full_name: 'Foo::Baz', type: 'class', path: 'Foo/Baz.html' } - ] - ) - - assert_equal 2, results.length - names = results.map { |r| r['full_name'] } - assert_includes names, 'Foo::Bar' - assert_includes names, 'Foo::Baz' - end - - # Method query with # matches against full_name - def test_instance_method_query_matches_full_name - results = run_search( - query: 'Array#filter', - data: [ - { name: 'filter', full_name: 'Array#filter', type: 'instance_method', path: 'Array.html#filter' }, - { name: 'filter', full_name: 'Enumerable#filter', type: 'instance_method', path: 'Enumerable.html#filter' }, - { name: 'filter', full_name: 'Hash#filter', type: 'instance_method', path: 'Hash.html#filter' } - ] - ) - - assert_equal 1, results.length - assert_equal 'Array#filter', results[0]['full_name'] - end - - # Method query with . matches against full_name (class methods) - # Note: RDoc uses :: for class methods in full_name, but users may type . (Ruby convention) - # The search normalizes . to :: so "Array.try_convert" matches "Array::try_convert" - def test_class_method_query_matches_full_name - results = run_search( - query: 'Array.try_convert', - data: [ - { name: 'try_convert', full_name: 'Array::try_convert', type: 'class_method', path: 'Array.html#try_convert' }, - { name: 'try_convert', full_name: 'Hash::try_convert', type: 'class_method', path: 'Hash.html#try_convert' }, - { name: 'try_convert', full_name: 'String::try_convert', type: 'class_method', path: 'String.html#try_convert' } - ] - ) - - assert_equal 1, results.length - assert_equal 'Array::try_convert', results[0]['full_name'] - end - - # Method query prefix matching against full_name - def test_method_query_prefix_matching - results = run_search( - query: 'Array#fi', - data: [ - { name: 'filter', full_name: 'Array#filter', type: 'instance_method', path: 'Array.html#filter' }, - { name: 'find', full_name: 'Array#find', type: 'instance_method', path: 'Array.html#find' }, - { name: 'first', full_name: 'Array#first', type: 'instance_method', path: 'Array.html#first' }, - { name: 'filter', full_name: 'Hash#filter', type: 'instance_method', path: 'Hash.html#filter' } - ] - ) - - assert_equal 3, results.length - full_names = results.map { |r| r['full_name'] } - assert_includes full_names, 'Array#filter' - assert_includes full_names, 'Array#find' - assert_includes full_names, 'Array#first' - refute_includes full_names, 'Hash#filter' - end - - # Method query substring matching against full_name - def test_method_query_substring_matching - results = run_search( - query: '#filter', - data: [ - { name: 'filter', full_name: 'Array#filter', type: 'instance_method', path: 'Array.html#filter' }, - { name: 'filter', full_name: 'Hash#filter', type: 'instance_method', path: 'Hash.html#filter' }, - { name: 'filter_map', full_name: 'Array#filter_map', type: 'instance_method', path: 'Array.html#filter_map' } - ] - ) - - assert_equal 3, results.length - # All entries contain #filter in their full_name - results.each do |r| - assert_match(/#filter/, r['full_name']) - end - end - - # Special characters - def test_special_characters_searchable - results = run_search( - query: '<<', - data: [ - { name: '<<', full_name: 'Array#<<', type: 'instance_method', path: 'Array.html#<<' }, - { name: 'Array', full_name: 'Array', type: 'class', path: 'Array.html' } - ] - ) - - assert_equal 1, results.length - assert_equal '<<', results[0]['name'] - end - - def test_bracket_method_searchable - results = run_search( - query: '[]', - data: [ - { name: '[]', full_name: 'Hash#[]', type: 'instance_method', path: 'Hash.html#[]' }, - { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' } - ] - ) - - assert_equal 1, results.length - assert_equal '[]', results[0]['name'] - end - - # Result limit - def test_result_limit_30 - data = 50.times.map do |i| - { name: "Test#{i}", full_name: "Test#{i}", type: 'class', path: "Test#{i}.html" } - end - - results = run_search(query: 'Test', data: data) - - assert_equal 30, results.length - end - - # Empty query - def test_empty_query_returns_empty - results = run_search( - query: '', - data: [ - { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' } - ] - ) - - assert_equal [], results - end - - # No matches - def test_no_matches_returns_empty - results = run_search( - query: 'xyz', - data: [ - { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' }, - { name: 'Array', full_name: 'Array', type: 'class', path: 'Array.html' } - ] - ) - - assert_equal [], results - end - - # Case insensitive matching - def test_case_insensitive_matching - results = run_search( - query: 'HASH', - data: [ - { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' } - ] - ) - - assert_equal 1, results.length - assert_equal 'Hash', results[0]['name'] - end - - # Constant search - def test_constant_search - results = run_search( - query: 'VER', - data: [ - { name: 'VERSION', full_name: 'RDoc::VERSION', type: 'constant', path: 'RDoc.html' }, - { name: 'Verifier', full_name: 'Verifier', type: 'class', path: 'Verifier.html' } - ] - ) - - assert_equal 2, results.length - # Verifier is unqualified (name == full_name), VERSION is qualified (RDoc::VERSION) - # Unqualified wins over qualified, then shorter wins within same qualification - assert_equal 'Verifier', results[0]['name'] - assert_equal 'VERSION', results[1]['name'] - end - - # Exact name match should win over prefix match - def test_exact_name_match_beats_prefix_match - results = run_search( - query: 'RDoc', - data: [ - { name: 'rdoc_version', full_name: 'RDoc::RubygemsHook#rdoc_version', type: 'instance_method', path: 'RDoc/RubygemsHook.html#rdoc_version' }, - { name: 'RDoc', full_name: 'RDoc', type: 'module', path: 'RDoc.html' }, - { name: 'RDoc', full_name: 'RDoc::RDoc', type: 'class', path: 'RDoc/RDoc.html' } - ] - ) - - assert_equal 3, results.length - # Exact name matches should come first - assert_equal 'RDoc', results[0]['full_name'], "Expected top-level RDoc module first" - assert_equal 'RDoc::RDoc', results[1]['full_name'], "Expected RDoc::RDoc class second" - assert_equal 'RDoc::RubygemsHook#rdoc_version', results[2]['full_name'], "Expected rdoc_version method last" - end - - # Hash class should rank higher than #hash methods when searching "Hash" - # This replicates the bug where methods appear before the class - def test_hash_class_ranks_higher_than_hash_methods - results = run_search( - query: 'Hash', - data: [ - { name: 'hash', full_name: 'Gem::Resolver::IndexSpecification#hash', type: 'instance_method', path: 'Gem/Resolver/IndexSpecification.html#hash' }, - { name: 'hash', full_name: 'URI::Generic#hash', type: 'instance_method', path: 'URI/Generic.html#hash' }, - { name: 'hash', full_name: 'Struct#hash', type: 'instance_method', path: 'Struct.html#hash' }, - { name: 'hash', full_name: 'Regexp#hash', type: 'instance_method', path: 'Regexp.html#hash' }, - { name: 'hash', full_name: 'Object#hash', type: 'instance_method', path: 'Object.html#hash' }, - { name: 'hash', full_name: 'Time#hash', type: 'instance_method', path: 'Time.html#hash' }, - { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' }, - { name: 'Hash', full_name: 'Gem::SafeMarshal::Elements::Hash', type: 'class', path: 'Gem/SafeMarshal/Elements/Hash.html' } - ] - ) - - assert_equal 8, results.length - # Hash class should come first (uppercase query + exact name match + unqualified) - assert_equal 'Hash', results[0]['full_name'], "Expected Hash class first" - # Nested Hash class second (uppercase query + exact name match, but qualified) - assert_equal 'Gem::SafeMarshal::Elements::Hash', results[1]['full_name'], "Expected nested Hash class second" - # Methods should come after classes - assert_equal 'instance_method', results[2]['type'], "Expected methods after classes" - end - - # Combined priority test matching user's expectation - # User query: "Ha" - # Expected order: Hash, Hashable, HashWithIndifferentAccess, Foo::Hash, #hash - def test_combined_priority_matching_user_expectation - results = run_search( - query: 'Ha', - data: [ - { name: 'hash', full_name: 'Object#hash', type: 'instance_method', path: 'Object.html#hash' }, - { name: 'HashWithIndifferentAccess', full_name: 'HashWithIndifferentAccess', type: 'class', path: 'HashWithIndifferentAccess.html' }, - { name: 'Hashable', full_name: 'Hashable', type: 'module', path: 'Hashable.html' }, - { name: 'Hash', full_name: 'Foo::Hash', type: 'class', path: 'Foo/Hash.html' }, - { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' } - ] - ) - - assert_equal 5, results.length - # Order: class/module first, unqualified before qualified, shorter before longer, methods last - assert_equal 'Hash', results[0]['full_name'], "Expected top-level Hash first" - assert_equal 'Hashable', results[1]['full_name'], "Expected Hashable second (shorter unqualified)" - assert_equal 'HashWithIndifferentAccess', results[2]['full_name'], "Expected HashWithIndifferentAccess third" - assert_equal 'Foo::Hash', results[3]['full_name'], "Expected Foo::Hash fourth (qualified)" - assert_equal 'Object#hash', results[4]['full_name'], "Expected method last" - end - - private - - def run_search(query:, data:) - @context.eval("search(#{query.to_json}, #{data.to_json})") - end -end - -# Integration test that goes through SearchController to catch bugs like -# the query being lowercased before reaching the ranker -class RDocGeneratorAlikiSearchControllerTest < Test::Unit::TestCase - def setup - @context = MiniRacer::Context.new - - # Mock DOM elements and document BEFORE loading JS files - @context.eval(<<~JS) - var document = { - addEventListener: function() {} - }; - var mockInput = { - value: '', - addEventListener: function() {}, - setAttribute: function() {}, - select: function() {} - }; - var mockResult = { - innerHTML: '', - parentNode: { scrollTop: 0, offsetHeight: 100 }, - childElementCount: 0, - firstChild: null, - setAttribute: function() {}, - appendChild: function(item) { - this.childElementCount++; - if (!this.firstChild) this.firstChild = item; - } - }; - JS - - # Load all search-related JS files in order - js_dir = File.expand_path('../../../../lib/rdoc/generator/template/aliki/js', __dir__) - - @context.eval(File.read(File.join(js_dir, 'search_navigation.js'))) - @context.eval(File.read(File.join(js_dir, 'search_ranker.js'))) - @context.eval(File.read(File.join(js_dir, 'search_controller.js'))) - end - - def teardown - @context.dispose - end - - # This test catches the bug where SearchController.search() was lowercasing - # the query before passing it to the ranker, breaking case-based type priority - def test_uppercase_query_preserves_case_for_type_priority - results = run_search_through_controller( - query: 'Hash', - data: [ - { name: 'hash', full_name: 'Object#hash', type: 'instance_method', path: 'Object.html#hash' }, - { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' } - ] - ) - - assert_equal 2, results.length - # With uppercase "Hash" query, class should come first due to type priority - assert_equal 'Hash', results[0]['full_name'], "Expected Hash class first with uppercase query" - assert_equal 'Object#hash', results[1]['full_name'], "Expected hash method second" - end - - def test_lowercase_query_prioritizes_methods - results = run_search_through_controller( - query: 'hash', - data: [ - { name: 'Hash', full_name: 'Hash', type: 'class', path: 'Hash.html' }, - { name: 'hash', full_name: 'Object#hash', type: 'instance_method', path: 'Object.html#hash' } - ] - ) - - assert_equal 2, results.length - # With lowercase "hash" query, method should come first due to type priority - assert_equal 'Object#hash', results[0]['full_name'], "Expected hash method first with lowercase query" - assert_equal 'Hash', results[1]['full_name'], "Expected Hash class second" - end - - private - - def run_search_through_controller(query:, data:) - # Set up search data - @context.eval("var search_data = { index: #{data.to_json} };") - - # Create SearchController and intercept ranker to capture raw results - @context.eval(<<~JS) - var capturedRawResults = []; - var controller = new SearchController(search_data, mockInput, mockResult); - - // Override ranker.find to capture raw results before formatting - var originalFind = controller.ranker.find.bind(controller.ranker); - controller.ranker.find = function(query) { - var rawResults = search(query, this.index); - capturedRawResults = rawResults; - // Call original to continue normal flow - originalFind(query); - }; - - controller.renderItem = function(result) { - return { classList: { add: function() {} }, setAttribute: function() {} }; - }; - JS - - # Simulate search - @context.eval("controller.search(#{query.to_json})") - - # Return captured raw results (entries with full_name, type, etc.) - @context.eval("capturedRawResults") - end -end diff --git a/test/rdoc/generator/json_index/searcher_test.rb b/test/rdoc/generator/json_index/searcher_test.rb deleted file mode 100644 index d5f0635ef7..0000000000 --- a/test/rdoc/generator/json_index/searcher_test.rb +++ /dev/null @@ -1,336 +0,0 @@ -# frozen_string_literal: true - -require_relative '../../helper' - -return if RUBY_DESCRIPTION =~ /truffleruby/ || RUBY_DESCRIPTION =~ /jruby/ - -begin - require 'mini_racer' -rescue LoadError - return -end - -# This test is a simpler setup for testing the searcher.js file without pulling all the JS dependencies. -# If there are more JS functionalities to test in the future, we can move to use JS test frameworks. -class RDocGeneratorJsonIndexSearcherTest < Test::Unit::TestCase - def setup - @context = MiniRacer::Context.new - - searcher_js_path = File.expand_path( - '../../../../lib/rdoc/generator/template/json_index/js/searcher.js', - __dir__ - ) - searcher_js = File.read(searcher_js_path) - @context.eval(searcher_js) - end - - def teardown - @context.dispose - end - - def test_exact_match_prioritized - results = run_search( - query: 'string', - data: { - searchIndex: ['string', 'string', 'strings'], - longSearchIndex: ['gem::safemarshal::elements::string', 'string', 'strings'], - info: [ - ['String', 'Gem::SafeMarshal::Elements', 'Gem/SafeMarshal/Elements/String.html', '', 'Nested String class', '', 'class'], - ['String', '', 'String.html', '', 'Top-level String class', '', 'class'], - ['Strings', '', 'Strings.html', '', 'Strings class', '', 'class'] - ] - } - ) - - assert_equal 3, results.length - # Top-level String should come first despite being second in the array - assert_equal 'String', strip_highlights(results[0]['title']) - assert_equal '', results[0]['namespace'], 'Top-level String should be prioritized over nested String' - assert_equal 'String.html', results[0]['path'] - - # Nested String should come second - assert_equal 'String', strip_highlights(results[1]['title']) - assert_equal 'Gem::SafeMarshal::Elements', strip_highlights(results[1]['namespace']) - end - - def test_exact_method_match - results = run_search( - query: 'attribute', - data: { - searchIndex: ['attributemanager', 'attributes', 'attribute()'], - longSearchIndex: ['rdoc::markup::attributemanager', 'rdoc::markup::attributes', 'rdoc::markup::attributemanager#attribute()'], - info: [ - ['AttributeManager', 'RDoc::Markup', 'RDoc/Markup/AttributeManager.html', '', 'AttributeManager class', '', 'class'], - ['Attributes', 'RDoc::Markup', 'RDoc/Markup/Attributes.html', '', 'Attributes class', '', 'class'], - ['attribute', 'RDoc::Markup::AttributeManager', 'RDoc/Markup/AttributeManager.html#method-i-attribute', '()', 'Attribute method', '', 'method'] - ] - } - ) - - assert_equal 3, results.length - # attribute() method should come first despite being last in the array - assert_equal 'attribute', strip_highlights(results[0]['title']) - assert_equal 'RDoc::Markup::AttributeManager', strip_highlights(results[0]['namespace']) - end - - def test_capitalized_query_prioritizes_exact_class - results = run_search( - query: 'String', - data: { - searchIndex: ['string', 'string()'], - longSearchIndex: ['string', 'object#string()'], - info: [ - ['String', '', 'String.html', '', 'String class', '', 'class'], - ['string', 'Object', 'Object.html#method-i-string', '()', 'String method', '', 'method'] - ] - } - ) - - assert_equal 2, results.length - # Capitalized query: exact class (Pass 0) beats exact method (Pass 1) - assert_equal 'String', strip_highlights(results[0]['title']) - assert_equal '', results[0]['namespace'] - assert_equal 'String.html', results[0]['path'] - - # Method comes second - assert_equal 'string', strip_highlights(results[1]['title']) - assert_equal 'Object', strip_highlights(results[1]['namespace']) - end - - def test_lowercase_query_prioritizes_method - results = run_search( - query: 'options', - data: { - searchIndex: ['options', 'options()'], - longSearchIndex: ['rdoc::options', 'rdoc::codeobject#options()'], - info: [ - ['Options', 'RDoc', 'RDoc/Options.html', '', 'Options class', '', 'class'], - ['options', 'RDoc::CodeObject', 'RDoc/CodeObject.html#method-i-options', '()', 'Options method', '', 'method'] - ] - } - ) - - assert_equal 2, results.length - # Lowercase query should prioritize method over class - assert_equal 'options', strip_highlights(results[0]['title']) - assert_equal 'RDoc::CodeObject', strip_highlights(results[0]['namespace']) - assert_equal 'RDoc/CodeObject.html#method-i-options', results[0]['path'] - - # Class comes second - assert_equal 'Options', strip_highlights(results[1]['title']) - assert_equal 'RDoc', strip_highlights(results[1]['namespace']) - end - - def test_beginning_match - results = run_search( - query: 'attr', - data: { - searchIndex: ['attribute()', 'attributemanager', 'generator'], - longSearchIndex: ['rdoc::markup#attribute()', 'rdoc::markup::attributemanager', 'rdoc::generator'], - info: [ - ['attribute', 'RDoc::Markup', 'RDoc/Markup.html#method-i-attribute', '()', 'Attribute method', '', 'method'], - ['AttributeManager', 'RDoc::Markup', 'RDoc/Markup/AttributeManager.html', '', 'Manager class', '', 'class'], - ['Generator', 'RDoc', 'RDoc/Generator.html', '', 'Generator class', '', 'class'] - ] - } - ) - - assert_equal 2, results.length - assert_equal 'attribute', strip_highlights(results[0]['title']) - assert_equal 'AttributeManager', strip_highlights(results[1]['title']) - end - - def test_long_index_match - results = run_search( - query: 'rdoc::markup', - data: { - searchIndex: ['attributes', 'parser'], - longSearchIndex: ['rdoc::markup::attributes', 'rdoc::parser'], - info: [ - ['Attributes', 'RDoc::Markup', 'RDoc/Markup/Attributes.html', '', 'Attributes class', '', 'class'], - ['Parser', 'RDoc', 'RDoc/Parser.html', '', 'Parser class', '', 'class'] - ] - } - ) - - assert_equal 1, results.length - assert_equal 'Attributes', strip_highlights(results[0]['title']) - assert_equal 'RDoc::Markup', strip_highlights(results[0]['namespace']) - end - - def test_contains_match - results = run_search( - query: 'manager', - data: { - searchIndex: ['attributemanager', 'parser'], - longSearchIndex: ['rdoc::markup::attributemanager', 'rdoc::parser'], - info: [ - ['AttributeManager', 'RDoc::Markup', 'RDoc/Markup/AttributeManager.html', '', 'Manager class', '', 'class'], - ['Parser', 'RDoc', 'RDoc/Parser.html', '', 'Parser class', '', 'class'] - ] - } - ) - - assert_equal 1, results.length - assert_equal 'AttributeManager', strip_highlights(results[0]['title']) - end - - def test_regexp_match - results = run_search( - query: 'atrbt', - data: { - searchIndex: ['attribute()', 'generator'], - longSearchIndex: ['rdoc::markup#attribute()', 'rdoc::generator'], - info: [ - ['attribute', 'RDoc::Markup', 'RDoc/Markup.html#method-i-attribute', '()', 'Attribute method', '', 'method'], - ['Generator', 'RDoc', 'RDoc/Generator.html', '', 'Generator class', '', 'class'] - ] - } - ) - - assert_equal 1, results.length - assert_equal 'attribute', strip_highlights(results[0]['title']) - end - - def test_empty_query - results = run_search( - query: '', - data: { - searchIndex: ['string'], - longSearchIndex: ['string'], - info: [['String', '', 'String.html', '', 'String class', '', 'class']] - } - ) - - assert_equal 0, results.length - end - - def test_no_matches - results = run_search( - query: 'nonexistent', - data: { - searchIndex: ['string', 'attribute()'], - longSearchIndex: ['string', 'rdoc#attribute()'], - info: [ - ['String', '', 'String.html', '', 'String class', '', 'class'], - ['attribute', 'RDoc', 'RDoc.html#attribute', '()', 'Attribute method', '', 'method'] - ] - } - ) - - assert_equal 0, results.length - end - - def test_multiple_exact_matches - results = run_search( - query: 'test', - data: { - searchIndex: ['test', 'test', 'testing'], - longSearchIndex: ['test', 'rdoc::test', 'testing'], - info: [ - ['Test', '', 'Test.html', '', 'Top-level Test', '', 'class'], - ['Test', 'RDoc', 'RDoc/Test.html', '', 'RDoc Test', '', 'class'], - ['Testing', '', 'Testing.html', '', 'Testing class', '', 'class'] - ] - } - ) - - assert_equal 3, results.length - # First result should be the exact match with both indexes matching - assert_equal 'Test', strip_highlights(results[0]['title']) - assert_equal '', results[0]['namespace'] - end - - # Test case insensitive search - def test_case_insensitive - results = run_search( - query: 'STRING', - data: { - searchIndex: ['string'], - longSearchIndex: ['string'], - info: [['String', '', 'String.html', '', 'String class', '', 'class']] - } - ) - - assert_equal 1, results.length - assert_equal 'String', strip_highlights(results[0]['title']) - end - - def test_multi_word_query - results = run_search( - query: 'rdoc markup', - data: { - searchIndex: ['attributemanager'], - longSearchIndex: ['rdoc::markup::attributemanager'], - info: [['AttributeManager', 'RDoc::Markup', 'RDoc/Markup/AttributeManager.html', '', 'Manager', '', 'class']] - } - ) - - assert_equal 1, results.length - assert_equal 'AttributeManager', results[0]['title'] - end - - def test_highlighting - results = run_search( - query: 'string', - data: { - searchIndex: ['string'], - longSearchIndex: ['string'], - info: [['String', '', 'String.html', '', 'String class', '', 'class']] - } - ) - - assert_equal 1, results.length - # Check that highlighting markers (unicode \u0001 and \u0002) are present - assert_match(/[\u0001\u0002]/, results[0]['title']) - end - - def test_max_results_limit - # Create 150 entries (more than MAX_RESULTS = 100) - search_index = [] - long_search_index = [] - info = [] - - 150.times do |i| - search_index << "test#{i}" - long_search_index << "test#{i}" - info << ["Test#{i}", '', "Test#{i}.html", '', "Test class #{i}", '', 'class'] - end - - results = run_search( - query: 'test', - data: { - searchIndex: search_index, - longSearchIndex: long_search_index, - info: info - } - ) - - # Should return at most 100 results - assert_operator results.length, :<=, 100 - end - - private - - def run_search(query:, data:) - @context.eval("var testResults = [];") - @context.eval(<<~JS) - var data = #{data.to_json}; - var searcher = new Searcher(data); - searcher.ready(function(res, isLast) { - testResults = testResults.concat(res); - }); - searcher.find(#{query.to_json}); - JS - - # Give some time for async operations - sleep 0.01 - - @context.eval('testResults') - end - - # Helper to strip highlighting markers from a string - def strip_highlights(str) - str.gsub(/[\u0001\u0002]/, '') - end -end