Files
MBN_STOCK_WEBVIEW/tests/LegacyParityWeb/stock-result-keyboard.test.cjs

156 lines
6.2 KiB
JavaScript

"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const repositoryRoot = path.resolve(__dirname, "..", "..");
const app = fs.readFileSync(path.join(
repositoryRoot,
"src",
"MBN_STOCK_WEBVIEW.LegacyParityApp",
"Web",
"app.js"), "utf8");
const styles = fs.readFileSync(path.join(
repositoryRoot,
"src",
"MBN_STOCK_WEBVIEW.LegacyParityApp",
"Web",
"styles.css"), "utf8");
function sliceBetween(startMarker, endMarker) {
const start = app.indexOf(startMarker);
const end = app.indexOf(endMarker, start + startMarker.length);
assert.ok(start >= 0, `${startMarker} must exist`);
assert.ok(end > start, `${endMarker} must follow ${startMarker}`);
return app.slice(start, end);
}
function list(items) {
items.item = function (index) { return items[index] || null; };
return items;
}
function option(index, selected) {
const attributes = new Map([["aria-selected", selected ? "true" : "false"]]);
const classes = new Set(selected ? ["selected"] : []);
return {
dataset: { resultIndex: String(index) },
offsetHeight: 24,
tabIndex: selected ? 0 : -1,
focused: false,
scrolled: false,
classList: {
toggle(name, enabled) {
if (enabled) classes.add(name);
else classes.delete(name);
},
contains(name) { return classes.has(name); }
},
getAttribute(name) { return attributes.get(name) || null; },
setAttribute(name, value) { attributes.set(name, String(value)); },
focus() { this.focused = true; },
scrollIntoView() { this.scrolled = true; }
};
}
function keyboardHarness(selectedIndex = -1, count = 30) {
const options = list(Array.from(
{ length: count },
(_, index) => option(index, index === selectedIndex)));
const stockResults = {
clientHeight: 240,
querySelectorAll() { return options; }
};
const source = sliceBetween(
"function stockResultPageOffset",
"function selectStockResult");
const factory = new Function("stockResults", `
${source}
return { stockResultPageOffset, stockResultKeyTarget };
`);
return Object.assign({ options }, factory(stockResults));
}
test("stock result arrows and boundaries match the native single-select ListBox", () => {
const middle = keyboardHarness(10);
assert.equal(middle.stockResultKeyTarget(middle.options, "ArrowUp").dataset.resultIndex, "9");
assert.equal(middle.stockResultKeyTarget(middle.options, "ArrowDown").dataset.resultIndex, "11");
assert.equal(middle.stockResultKeyTarget(middle.options, "Home").dataset.resultIndex, "0");
assert.equal(middle.stockResultKeyTarget(middle.options, "End").dataset.resultIndex, "29");
const first = keyboardHarness(0);
assert.equal(first.stockResultKeyTarget(first.options, "ArrowUp").dataset.resultIndex, "0");
const last = keyboardHarness(29);
assert.equal(last.stockResultKeyTarget(last.options, "ArrowDown").dataset.resultIndex, "29");
assert.equal(last.stockResultKeyTarget(last.options, "Enter"), null);
});
test("PageUp and PageDown move nine rows in the original ten-row viewport", () => {
const middle = keyboardHarness(13);
assert.equal(middle.stockResultPageOffset(middle.options.item(0)), 9);
assert.equal(middle.stockResultKeyTarget(middle.options, "PageUp").dataset.resultIndex, "4");
assert.equal(middle.stockResultKeyTarget(middle.options, "PageDown").dataset.resultIndex, "22");
const nearStart = keyboardHarness(4);
assert.equal(nearStart.stockResultKeyTarget(nearStart.options, "PageUp").dataset.resultIndex, "0");
const nearEnd = keyboardHarness(25);
assert.equal(nearEnd.stockResultKeyTarget(nearEnd.options, "PageDown").dataset.resultIndex, "29");
const none = keyboardHarness(-1);
assert.equal(none.stockResultKeyTarget(none.options, "ArrowDown").dataset.resultIndex, "0");
assert.equal(none.stockResultKeyTarget(none.options, "PageDown").dataset.resultIndex, "9");
});
test("stock selection uses one roving tab stop and preserves focused selection", () => {
const options = list([option(0, true), option(1, false), option(2, false)]);
const sent = [];
const stockResults = { querySelectorAll() { return options; } };
const source = sliceBetween(
"function selectStockResult",
"function createStockResultElement");
const selectStockResult = new Function("stockResults", "send", `
${source}
return selectStockResult;
`)(stockResults, (type, payload) => sent.push({ type, payload }));
selectStockResult(options.item(2), true);
assert.deepEqual(options.map(row => row.tabIndex), [-1, -1, 0]);
assert.deepEqual(options.map(row => row.getAttribute("aria-selected")),
["false", "false", "true"]);
assert.equal(options.item(2).focused, true);
assert.equal(options.item(2).scrolled, true);
assert.deepEqual(sent, [{ type: "select-stock", payload: { resultIndex: 2 } }]);
// A second click from a browser dblclick gesture only reaffirms selection;
// it neither emits another selection transition nor appends a playlist row.
selectStockResult(options.item(2), false);
assert.equal(sent.length, 1);
});
test("stock option keydown consumes list navigation but lets Home and End bubble", () => {
const factory = sliceBetween(
"function createStockResultElement",
"function renderStockResults");
for (const key of ["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End"]) {
assert.match(app, new RegExp(`key === \\"${key}\\"`));
}
assert.match(factory, /event\.preventDefault\(\)/);
assert.doesNotMatch(factory, /stopPropagation|stopImmediatePropagation/);
assert.doesNotMatch(factory, /dblclick|add-playlist|append-playlist|cut-pointer-down/);
assert.match(styles,
/\.stock-result \{[^}]*height: 24px;[^}]*line-height: 24px;/s);
assert.match(styles, /\.stock-results \{ height: 242px; \}/);
});
test("rendering keeps exactly one tab stop, falling back to the first unselected row", () => {
const renderer = sliceBetween(
"function renderStockResults",
"function pointerPosition");
assert.match(renderer, /button\.tabIndex = isSelected \? 0 : -1/);
assert.match(renderer, /if \(!hasSelectedResult && stockResults\.children\.length > 0\)/);
assert.match(renderer, /stockResults\.children\.item\(0\)\.tabIndex = 0/);
assert.doesNotMatch(renderer, /\.focus\(/);
});