| /* | |
| * Licensed to the Apache Software Foundation (ASF) under one | |
| * or more contributor license agreements. See the NOTICE file | |
| * distributed with this work for additional information | |
| * regarding copyright ownership. The ASF licenses this file | |
| * to you under the Apache License, Version 2.0 (the | |
| * "License"); you may not use this file except in compliance | |
| * with the License. You may obtain a copy of the License at | |
| * | |
| * http://www.apache.org/licenses/LICENSE-2.0 | |
| * | |
| * Unless required by applicable law or agreed to in writing, | |
| * software distributed under the License is distributed on an | |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| * KIND, either express or implied. See the License for the | |
| * specific language governing permissions and limitations | |
| * under the License. | |
| */ | |
| import assert from 'node:assert/strict'; | |
| import test from 'node:test'; | |
| import { | |
| auditAxTree, | |
| findAmbiguousActionableAxNodes, | |
| findMissingActionableState, | |
| findUnnamedRequiredContainers, | |
| } from './ax-tree-audit.mjs'; | |
| function value(value) { | |
| return { value }; | |
| } | |
| test('rejects trees whose only source records are ignored', () => { | |
| const audit = auditAxTree([ | |
| { nodeId: 'root', ignored: true }, | |
| { nodeId: 'hidden', ignored: true, parentId: 'root' }, | |
| ]); | |
| assert.equal(audit.sourceNodeCount, 2); | |
| assert.equal(audit.exposedNodeCount, 0); | |
| assert.ok(audit.problems.some((problem) => problem.code === 'empty_ax_tree')); | |
| }); | |
| test('rejects a document shell whose application subtree is fully ignored', () => { | |
| const audit = auditAxTree([ | |
| { nodeId: 'document', role: value('RootWebArea'), name: value('Maka') }, | |
| { nodeId: 'app', parentId: 'document', ignored: true }, | |
| ]); | |
| assert.equal(audit.exposedNodeCount, 1); | |
| assert.equal(audit.applicationNodeCount, 0); | |
| assert.ok(audit.problems.some((problem) => problem.code === 'empty_ax_tree')); | |
| }); | |
| test('rejects duplicate actions in the same named scope', () => { | |
| const nodes = [ | |
| { nodeId: 'root', role: value('navigation'), name: value('Tasks') }, | |
| { | |
| nodeId: 'a', | |
| parentId: 'root', | |
| backendDOMNodeId: 1, | |
| role: value('button'), | |
| name: value('Task actions'), | |
| }, | |
| { | |
| nodeId: 'b', | |
| parentId: 'root', | |
| backendDOMNodeId: 2, | |
| role: value('button'), | |
| name: value('Task actions'), | |
| }, | |
| ]; | |
| assert.deepEqual(findAmbiguousActionableAxNodes(nodes), [ | |
| { | |
| role: 'button', | |
| name: 'Task actions', | |
| scope: 'navigation:Tasks', | |
| count: 2, | |
| backendDOMNodeIds: [1, 2], | |
| }, | |
| ]); | |
| }); | |
| test('allows the same action name in distinct named dialogs', () => { | |
| const nodes = [ | |
| { nodeId: 'one', role: value('dialog'), name: value('First') }, | |
| { nodeId: 'two', role: value('dialog'), name: value('Second') }, | |
| { nodeId: 'a', parentId: 'one', role: value('button'), name: value('Close') }, | |
| { nodeId: 'b', parentId: 'two', role: value('button'), name: value('Close') }, | |
| ]; | |
| assert.deepEqual(findAmbiguousActionableAxNodes(nodes), []); | |
| }); | |
| test('uses visible status content to distinguish repeated dismiss actions', () => { | |
| const nodes = [ | |
| { nodeId: 'one', role: value('status'), name: value(''), childIds: ['one-title', 'a'] }, | |
| { nodeId: 'one-title', parentId: 'one', role: value('StaticText'), name: value('Saved') }, | |
| { nodeId: 'two', role: value('status'), name: value(''), childIds: ['two-title', 'b'] }, | |
| { nodeId: 'two-title', parentId: 'two', role: value('StaticText'), name: value('Failed') }, | |
| { nodeId: 'a', parentId: 'one', role: value('button'), name: value('Close') }, | |
| { nodeId: 'b', parentId: 'two', role: value('button'), name: value('Close') }, | |
| ]; | |
| assert.deepEqual(findAmbiguousActionableAxNodes(nodes), []); | |
| }); | |
| test('does not merge long scope names that differ after the diagnostic limit', () => { | |
| const prefix = 'x'.repeat(180); | |
| const nodes = [ | |
| { nodeId: 'one', role: value('status'), name: value(`${prefix}-one`) }, | |
| { nodeId: 'two', role: value('status'), name: value(`${prefix}-two`) }, | |
| { nodeId: 'a', parentId: 'one', role: value('button'), name: value('Close') }, | |
| { nodeId: 'b', parentId: 'two', role: value('button'), name: value('Close') }, | |
| ]; | |
| assert.deepEqual(findAmbiguousActionableAxNodes(nodes), []); | |
| }); | |
| test('audits large trees without rescanning non-scope ancestor subtrees', () => { | |
| const buttonCount = 800; | |
| const childIds = Array.from({ length: buttonCount }, (_, index) => `button-${index}`); | |
| let wrapperSubtreeReads = 0; | |
| const nodes = [ | |
| { nodeId: 'root', role: value('main'), name: value('Catalog'), childIds: ['wrapper'] }, | |
| { | |
| nodeId: 'wrapper', | |
| parentId: 'root', | |
| role: value('generic'), | |
| get childIds() { | |
| wrapperSubtreeReads += 1; | |
| return childIds; | |
| }, | |
| }, | |
| ...childIds.map((nodeId, index) => ({ | |
| nodeId, | |
| parentId: 'wrapper', | |
| role: value('button'), | |
| name: value(`Action ${index}`), | |
| })), | |
| ]; | |
| assert.deepEqual(findAmbiguousActionableAxNodes(nodes), []); | |
| assert.equal(wrapperSubtreeReads, 0); | |
| }); | |
| test('requires state on state-bearing controls', () => { | |
| const nodes = [ | |
| { role: value('checkbox'), name: value('Remember me'), properties: [] }, | |
| { | |
| role: value('tab'), | |
| name: value('Details'), | |
| properties: [{ name: 'selected', value: value(false) }], | |
| }, | |
| ]; | |
| assert.deepEqual(findMissingActionableState(nodes), [ | |
| { | |
| role: 'checkbox', | |
| name: 'Remember me', | |
| property: 'checked', | |
| }, | |
| ]); | |
| assert.ok( | |
| auditAxTree(nodes).problems.some((problem) => problem.code === 'missing_actionable_state'), | |
| ); | |
| }); | |
| test('requires expanded state on controls that own a disclosure target', () => { | |
| const nodes = [ | |
| { | |
| role: value('button'), | |
| name: value('Details'), | |
| properties: [{ name: 'controls', value: value(['details-panel']) }], | |
| }, | |
| { | |
| role: value('button'), | |
| name: value('Advanced'), | |
| properties: [ | |
| { name: 'controls', value: value(['advanced-panel']) }, | |
| { name: 'expanded', value: value(false) }, | |
| ], | |
| }, | |
| ]; | |
| assert.deepEqual(findMissingActionableState(nodes), [ | |
| { | |
| role: 'button', | |
| name: 'Details', | |
| property: 'expanded', | |
| }, | |
| ]); | |
| }); | |
| test('does not treat a tab panel relationship as a disclosure', () => { | |
| assert.deepEqual( | |
| findMissingActionableState([ | |
| { | |
| role: value('tab'), | |
| name: value('Details'), | |
| properties: [ | |
| { name: 'controls', value: value(['details-panel']) }, | |
| { name: 'selected', value: value(false) }, | |
| ], | |
| }, | |
| ]), | |
| [], | |
| ); | |
| }); | |
| test('requires names on dialogs', () => { | |
| assert.deepEqual( | |
| findUnnamedRequiredContainers([ | |
| { role: value('dialog'), name: value(''), backendDOMNodeId: 7 }, | |
| ]), | |
| [{ role: 'dialog', backendDOMNodeId: 7 }], | |
| ); | |
| }); |