From b743bc9ab1bb07b569f6c9b76fdcfc4d5f57798e Mon Sep 17 00:00:00 2001 From: Jean-Yves Moyen Date: Wed, 9 Jun 2021 15:13:52 +0200 Subject: [PATCH] Implement SIA R56 (#829) * Refactor normalize * Mark members of Group as readonly * Write R56 * Add extended diagnostic --- packages/alfa-rules/src/common/group.ts | 4 +- packages/alfa-rules/src/common/normalize.ts | 3 + packages/alfa-rules/src/sia-r14/rule.ts | 20 +-- packages/alfa-rules/src/sia-r15/rule.ts | 7 +- packages/alfa-rules/src/sia-r41/rule.ts | 17 +- packages/alfa-rules/src/sia-r56/rule.ts | 166 ++++++++++++++++++ packages/alfa-rules/src/sia-r81/rule.ts | 17 +- .../alfa-rules/test/sia-r56/rule.spec.tsx | 55 ++++++ packages/alfa-rules/tsconfig.json | 3 + 9 files changed, 257 insertions(+), 35 deletions(-) create mode 100644 packages/alfa-rules/src/common/normalize.ts create mode 100644 packages/alfa-rules/src/sia-r56/rule.ts create mode 100644 packages/alfa-rules/test/sia-r56/rule.spec.tsx diff --git a/packages/alfa-rules/src/common/group.ts b/packages/alfa-rules/src/common/group.ts index 700ec9bd1a..42a9bf0508 100644 --- a/packages/alfa-rules/src/common/group.ts +++ b/packages/alfa-rules/src/common/group.ts @@ -18,9 +18,9 @@ export class Group return new Group(Array.from(members)); } - private readonly _members: Array; + private readonly _members: ReadonlyArray; - private constructor(members: Array) { + private constructor(members: ReadonlyArray) { this._members = members; } diff --git a/packages/alfa-rules/src/common/normalize.ts b/packages/alfa-rules/src/common/normalize.ts new file mode 100644 index 0000000000..d6bf6a0e57 --- /dev/null +++ b/packages/alfa-rules/src/common/normalize.ts @@ -0,0 +1,3 @@ +export function normalize(input: string): string { + return input.trim().toLowerCase().replace(/\s+/g, " "); +} diff --git a/packages/alfa-rules/src/sia-r14/rule.ts b/packages/alfa-rules/src/sia-r14/rule.ts index 2bbde1a330..a2a05cf620 100644 --- a/packages/alfa-rules/src/sia-r14/rule.ts +++ b/packages/alfa-rules/src/sia-r14/rule.ts @@ -8,12 +8,16 @@ import { Page } from "@siteimprove/alfa-web"; import { expectation } from "../common/expectation"; -import { hasAccessibleName } from "../common/predicate/has-accessible-name"; -import { hasAttribute } from "../common/predicate/has-attribute"; -import { hasDescendant } from "../common/predicate/has-descendant"; -import { hasRole } from "../common/predicate/has-role"; -import { isFocusable } from "../common/predicate/is-focusable"; -import { isPerceivable } from "../common/predicate/is-perceivable"; +import { normalize } from "../common/normalize"; + +import { + hasAccessibleName, + hasAttribute, + hasDescendant, + hasRole, + isFocusable, + isPerceivable, +} from "../common/predicate"; const { isElement, hasNamespace } = Element; const { isText } = Text; @@ -72,10 +76,6 @@ export default Rule.Atomic.of({ }, }); -function normalize(input: string): string { - return input.trim().toLowerCase().replace(/\s+/g, " "); -} - function getPerceivableTextContent(element: Element, device: Device): string { return normalize( element diff --git a/packages/alfa-rules/src/sia-r15/rule.ts b/packages/alfa-rules/src/sia-r15/rule.ts index c06ab574a5..311b9ff364 100644 --- a/packages/alfa-rules/src/sia-r15/rule.ts +++ b/packages/alfa-rules/src/sia-r15/rule.ts @@ -15,8 +15,9 @@ import { hasNonEmptyAccessibleName } from "../common/predicate/has-non-empty-acc import { isIgnored } from "../common/predicate/is-ignored"; import { referenceSameResource } from "../common/predicate/reference-same-resource"; -import { Question } from "../common/question"; import { Group } from "../common/group"; +import { normalize } from "../common/normalize"; +import { Question } from "../common/question"; const { isElement, hasName, hasNamespace } = Element; const { and, not } = Predicate; @@ -107,7 +108,3 @@ export namespace Outcomes { ) ); } - -function normalize(input: string): string { - return input.trim().toLowerCase().replace(/\s+/g, " "); -} diff --git a/packages/alfa-rules/src/sia-r41/rule.ts b/packages/alfa-rules/src/sia-r41/rule.ts index 82c3ba8144..18f4069a00 100644 --- a/packages/alfa-rules/src/sia-r41/rule.ts +++ b/packages/alfa-rules/src/sia-r41/rule.ts @@ -12,13 +12,16 @@ import { Page } from "@siteimprove/alfa-web"; import { expectation } from "../common/expectation"; -import { hasNonEmptyAccessibleName } from "../common/predicate/has-non-empty-accessible-name"; -import { hasRole } from "../common/predicate/has-role"; -import { isIgnored } from "../common/predicate/is-ignored"; +import { + hasNonEmptyAccessibleName, + hasRole, + isIgnored, + referenceSameResource, +} from "../common/predicate"; -import { Question } from "../common/question"; import { Group } from "../common/group"; -import { referenceSameResource } from "../common/predicate/reference-same-resource"; +import { normalize } from "../common/normalize"; +import { Question } from "../common/question"; const { isElement, hasNamespace } = Element; const { flatten } = Iterable; @@ -118,7 +121,3 @@ export namespace Outcomes { ) ); } - -function normalize(input: string): string { - return input.trim().toLowerCase().replace(/\s+/g, " "); -} diff --git a/packages/alfa-rules/src/sia-r56/rule.ts b/packages/alfa-rules/src/sia-r56/rule.ts new file mode 100644 index 0000000000..415e732125 --- /dev/null +++ b/packages/alfa-rules/src/sia-r56/rule.ts @@ -0,0 +1,166 @@ +import { Diagnostic, Rule } from "@siteimprove/alfa-act"; +import { Node, Role } from "@siteimprove/alfa-aria"; +import { Array } from "@siteimprove/alfa-array"; +import { Element, Namespace } from "@siteimprove/alfa-dom"; +import { Iterable } from "@siteimprove/alfa-iterable"; +import { List } from "@siteimprove/alfa-list"; +import { Map } from "@siteimprove/alfa-map"; +import { Option } from "@siteimprove/alfa-option"; +import { Predicate } from "@siteimprove/alfa-predicate"; +import { Err, Ok } from "@siteimprove/alfa-result"; +import { Page } from "@siteimprove/alfa-web"; + +import { expectation } from "../common/expectation"; +import { Group } from "../common/group"; +import { normalize } from "../common/normalize"; + +import { hasRole, isIgnored } from "../common/predicate"; + +const { and, equals, not } = Predicate; +const { hasNamespace } = Element; + +export default Rule.Atomic.of>({ + uri: "https://siteimprove.github.io/sanshikan/rules/sia-r56.html", + evaluate({ device, document }) { + return { + applicability() { + return document + .descendants({ flattened: true, nested: true }) + .filter(Element.isElement) + .filter( + and( + hasNamespace(equals(Namespace.HTML)), + not(isIgnored(device)), + hasRole(device, (role) => role.is("landmark")) + ) + ) + .reduce((groups, landmark) => { + // since we already have filtered by having a landmark role, we can + // safely get the role. + const role = Node.from(landmark, device).role.get()!; + + groups = groups.set( + role, + groups + .get(role) + .getOrElse(() => List.empty()) + .append(landmark) + ); + + return groups; + }, Map.empty>()) + .filter((elements) => elements.size > 1) + .map(Group.of) + .values(); + }, + + expectations(target) { + // empty groups have been filtered out already, so we can safely get the + // first element + const role = Node.from( + Iterable.first(target).get()!, + device + ).role.get()!.name; + + const byNames = [...target] + .reduce((groups, landmark) => { + const name = Node.from(landmark, device).name.map((name) => + normalize(name.value) + ); + groups = groups.set( + name, + groups + .get(name) + .getOrElse(() => List.empty()) + .append(landmark) + ); + + return groups; + }, Map.empty, List>()) + .filter((landmarks) => landmarks.size > 1); + + return { + 1: expectation( + byNames.size === 0, + () => Outcomes.differentNames(role), + () => Outcomes.sameNames(role, byNames.values()) + ), + }; + }, + }; + }, +}); + +export namespace Outcomes { + export const differentNames = (role: Role.Name) => + Ok.of(Diagnostic.of(`No two \`${role}\` have the same name.`)); + + export const sameNames = ( + role: Role.Name, + errors: Iterable> + ) => + Err.of(SameNames.of(`Some \`${role}\` have the same name.`, role, errors)); +} + +class SameNames extends Diagnostic implements Iterable> { + public static of( + message: string, + role: Role.Name = "none", + errors: Iterable> = [] + ): SameNames { + return new SameNames(message, role, Array.from(errors).map(List.from)); + } + + private readonly _role: Role.Name; + private readonly _errors: ReadonlyArray>; + + private constructor( + message: string, + role: Role.Name, + errors: ReadonlyArray> + ) { + super(message); + this._role = role; + this._errors = errors; + } + + public get role(): Role.Name { + return this._role; + } + + public *[Symbol.iterator](): Iterator> { + yield* this._errors; + } + + public equals(value: SameNames): boolean; + + public equals(value: unknown): value is this; + + public equals(value: unknown): boolean { + return ( + value instanceof SameNames && + value._message === this._message && + value._role === this._role && + value._errors.every((list, idx) => list.equals(this._errors[idx])) + ); + } + + public toJSON(): SameNames.JSON { + return { + ...super.toJSON(), + role: this._role, + errors: Array.toJSON(this._errors), + }; + } +} + +namespace SameNames { + export interface JSON extends Diagnostic.JSON { + role: string; + errors: Array>; + } + + export function isSameNames(value: unknown): value is SameNames { + return value instanceof SameNames; + } +} diff --git a/packages/alfa-rules/src/sia-r81/rule.ts b/packages/alfa-rules/src/sia-r81/rule.ts index 4147ab152c..e39315aef2 100644 --- a/packages/alfa-rules/src/sia-r81/rule.ts +++ b/packages/alfa-rules/src/sia-r81/rule.ts @@ -16,13 +16,16 @@ import * as dom from "@siteimprove/alfa-dom"; import { expectation } from "../common/expectation"; -import { hasNonEmptyAccessibleName } from "../common/predicate/has-non-empty-accessible-name"; -import { hasRole } from "../common/predicate/has-role"; -import { isIgnored } from "../common/predicate/is-ignored"; +import { + hasNonEmptyAccessibleName, + hasRole, + isIgnored, + referenceSameResource, +} from "../common/predicate"; -import { Question } from "../common/question"; import { Group } from "../common/group"; -import { referenceSameResource } from "../common/predicate/reference-same-resource"; +import { normalize } from "../common/normalize"; +import { Question } from "../common/question"; const { isElement, hasName, hasNamespace, hasId } = Element; const { flatten } = Iterable; @@ -124,10 +127,6 @@ export namespace Outcomes { ); } -function normalize(input: string): string { - return input.trim().toLowerCase().replace(/\s+/g, " "); -} - /** * @todo For links in table cells, account for the text in the associated table * header cell. diff --git a/packages/alfa-rules/test/sia-r56/rule.spec.tsx b/packages/alfa-rules/test/sia-r56/rule.spec.tsx new file mode 100644 index 0000000000..6c5442bc9a --- /dev/null +++ b/packages/alfa-rules/test/sia-r56/rule.spec.tsx @@ -0,0 +1,55 @@ +import { test } from "@siteimprove/alfa-test"; + +import { Document } from "@siteimprove/alfa-dom"; + +import R56, { Outcomes } from "../../src/sia-r56/rule"; + +import { evaluate } from "../common/evaluate"; +import { passed, failed, inapplicable } from "../common/outcome"; + +import { Group } from "../../src/common/group"; + +test("evaluate() passes when same landmarks have different names", async (t) => { + const author =