156 lines
6.2 KiB
JavaScript
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\(/);
|
|
});
|