diff --git a/.changeset/eleven-shrimps-tap.md b/.changeset/eleven-shrimps-tap.md new file mode 100644 index 0000000000..1a62e90dda --- /dev/null +++ b/.changeset/eleven-shrimps-tap.md @@ -0,0 +1,5 @@ +--- +"@siteimprove/alfa-selector": minor +--- + +**Added:** `Selector` now contain their own `Specificity`. diff --git a/.changeset/rude-rivers-check.md b/.changeset/rude-rivers-check.md new file mode 100644 index 0000000000..4338a81570 --- /dev/null +++ b/.changeset/rude-rivers-check.md @@ -0,0 +1,8 @@ +--- +"@siteimprove/alfa-test": minor +--- + +**Added:** Test can now accept a `Controller` to generate random tests. + +See the [README](./README.md) for more information. + diff --git a/docs/review/api/alfa-selector.api.md b/docs/review/api/alfa-selector.api.md index b1d217c2ee..4061b84570 100644 --- a/docs/review/api/alfa-selector.api.md +++ b/docs/review/api/alfa-selector.api.md @@ -7,6 +7,8 @@ import { Array as Array_2 } from '@siteimprove/alfa-array'; import { Element } from '@siteimprove/alfa-dom'; import { Equatable } from '@siteimprove/alfa-equatable'; +import { Hash } from '@siteimprove/alfa-hash'; +import { Hashable } from '@siteimprove/alfa-hash'; import { Iterable as Iterable_2 } from '@siteimprove/alfa-iterable'; import * as json from '@siteimprove/alfa-json'; import { Nth } from '@siteimprove/alfa-css'; diff --git a/docs/review/api/alfa-test.api.md b/docs/review/api/alfa-test.api.md index d259dc6a56..24bbe69287 100644 --- a/docs/review/api/alfa-test.api.md +++ b/docs/review/api/alfa-test.api.md @@ -24,6 +24,21 @@ export interface Assertions { throws(block: Function, error?: RegExp | Function | Object | Error, message?: string): void; } +// @public (undocumented) +export interface Controller { + // (undocumented) + iterations: number; + // (undocumented) + seed?: number; + // (undocumented) + wrapper: (rng: RNG, iteration: number) => RNG; +} + +// Warning: (ae-internal-missing-underscore) The name "defaultController" should be prefixed with an underscore because the declaration is marked as @internal +// +// @internal (undocumented) +export const defaultController: Controller; + // Warning: (ae-internal-missing-underscore) The name "format" should be prefixed with an underscore because the declaration is marked as @internal // // @internal (undocumented) @@ -37,6 +52,14 @@ export interface Notifier { error(message: string): void; } +// @public (undocumented) +export type RNG = () => T; + +// Warning: (ae-internal-missing-underscore) The name "seedableRNG" should be prefixed with an underscore because the declaration is marked as @internal +// +// @internal +export function seedableRNG(seed: number): RNG; + // Warning: (ae-forgotten-export) The symbol "Frame" needs to be exported by the entry point index.d.ts // Warning: (ae-internal-missing-underscore) The name "stack" should be prefixed with an underscore because the declaration is marked as @internal // @@ -46,10 +69,10 @@ export function stack(error: Error): Iterable; // Warning: (ae-internal-mixed-release-tag) Mixed release tags are not allowed for "test" because one of its declarations is marked as @internal // // @public (undocumented) -export function test(name: string, assertion: (assert: Assertions) => void | Promise): Promise; +export function test(name: string, assertion: (assert: Assertions, rng: RNG, seed: number) => void | Promise, controller?: Partial>): Promise; // @internal (undocumented) -export function test(name: string, assertion: (assert: Assertions) => void | Promise, notifier: Notifier): Promise; +export function test(name: string, assertion: (assert: Assertions, rng: RNG, seed: number) => void | Promise, notifier: Notifier, controller?: Partial>): Promise; // (No @packageDocumentation comment for this package) diff --git a/packages/alfa-cascade/src/selector-map.ts b/packages/alfa-cascade/src/selector-map.ts index fcb9b86c32..5cf302e62d 100644 --- a/packages/alfa-cascade/src/selector-map.ts +++ b/packages/alfa-cascade/src/selector-map.ts @@ -329,14 +329,11 @@ export namespace SelectorMap { // For style rules that are presentational hints, the specificity will // always be 0 regardless of the selector. - if (StyleRule.isStyleRule(rule) && rule.hint) { - this._specificity = 0; - } - - // Otherwise, determine the specificity of the selector. - else { - this._specificity = getSpecificity(selector); - } + // Otherwise, use the specificity of the selector. + this._specificity = + StyleRule.isStyleRule(rule) && rule.hint + ? 0 + : selector.specificity.value; } public get rule(): Rule { @@ -457,58 +454,6 @@ function getKeySelector(selector: Selector): Id | Class | Type | null { return null; } -type Specificity = number; - -// The number of bits to use for every component of the specificity computation. -// As bitwise operations in JavaScript are limited to 32 bits, we can only use -// at most 10 bits per component as 3 components are used. -const componentBits = 10; - -// The maximum value that any given component can have. Since we can only use 10 -// bits for every component, this in effect means that any given component count -// must be strictly less than 1024. -const componentMax = (1 << componentBits) - 1; - -/** - * {@link https://www.w3.org/TR/selectors/#specificity} - */ -function getSpecificity(selector: Selector): Specificity { - let a = 0; - let b = 0; - let c = 0; - - const queue: Array = [selector]; - - while (queue.length > 0) { - const selector = queue.pop()!; - - if (isId(selector)) { - a++; - } else if ( - isClass(selector) || - isAttribute(selector) || - isPseudoClass(selector) - ) { - b++; - } else if (isType(selector) || isPseudoElement(selector)) { - c++; - } else if (isComplex(selector)) { - queue.push(selector.left, selector.right); - } else if (isCompound(selector)) { - queue.push(...selector.selectors); - } - } - - // Concatenate the components to a single number indicating the specificity of - // the selector. This allows us to treat specificities as simple numbers and - // hence use normal comparison operators when comparing specificities. - return ( - (Math.min(a, componentMax) << (componentBits * 2)) | - (Math.min(b, componentMax) << (componentBits * 1)) | - Math.min(c, componentMax) - ); -} - /** * Check if a selector can be rejected based on an ancestor filter. */ diff --git a/packages/alfa-map/test/property.spec.ts b/packages/alfa-map/test/property.spec.ts index 150732e9d3..5970aa3f58 100755 --- a/packages/alfa-map/test/property.spec.ts +++ b/packages/alfa-map/test/property.spec.ts @@ -1,55 +1,14 @@ -import { test } from "@siteimprove/alfa-test"; +import { RNG, test } from "@siteimprove/alfa-test"; import { Hash } from "@siteimprove/alfa-hash"; import { Iterable } from "@siteimprove/alfa-iterable"; import { Map } from "../src/map"; -/** - * In order to do correct property based testing on Map (and hash tables), we - * need: - * * a seedable Pseudo-Random Numbers Generator, so we can re-use the seed to - * investigate problems; - * * full control over the hashes of the keys, notably not relying on - * Hash.writeObject since the objects list might be "polluted" by previous - * actions - * - * The PRNG doesn't need to be very good (i.e. not cryptographic-grade). We also - * wants the hashes to be both among a relatively small set (so that collisions - * are likely and can be tested without huge number of items); and relatively - * spread over the space so that Sparses are frequent and can be tested. - * - * 16 bits entropy seems a nice number since it leaves room for ~65,000 keys. By - * making runs of 1000 keys, collisions are very likely to happen and there - * is a relatively small number of iterations so tests don't run forever… - * - * We also need to output the failing seed systematically to allow investigation - * in case of problems… - * - * Since #has looks for keys using hashes while Iterable.includes(map.keys()) - * is iterating through the keys without using hashes, both are systematically - * used in tests as a way to ensure that no hash somehow becomes unreachable. - */ - -/** - * PRNG taken from - * {@link https://stackoverflow.com/questions/521295/seeding-the-random-number-generator-in-javascript} - */ - -function mulberry32(seed: number) { - return function () { - let t = (seed += 0x6d2b79f5); - t = Math.imul(t ^ (t >>> 15), t | 1); - t ^= t + Math.imul(t ^ (t >>> 7), t | 61); - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; -} - /** * Turning a float between 0 and 1 into a 32 bits int, and keeping only * 16 bits to reduce entropy while keeping good coverage of the space… */ - function makeInt(x: number): number { return Math.floor(x * 2 ** 32) & 0b01010101010101010101010101010101; } @@ -76,85 +35,91 @@ function key(id: number): Key { return self; } -function randKey(rng: () => number): () => Key { +function wrapper(rng: RNG): RNG { return () => key(makeInt(rng())); } -test(`#add and #delete behave when used in bulk`, (t) => { - const iterations = 1000; - const seed = Math.random(); - const rng = mulberry32(seed); - const keyGen = randKey(rng); - - let map: Map = Map.empty(); - const keys: Array = []; - - // Adding elements - for (let i = 0; i < iterations; i++) { - const key = keyGen(); - - t.deepEqual(map.size, i, `Pre-add map.size() fails with seed ${seed}`); - t.deepEqual( - map.has(key), - false, - `Pre-add map.has() fails with seed ${seed}`, - ); - t.deepEqual( - Iterable.includes(map.keys(), key), - false, - `Pre-add includes fails with seed ${seed}`, - ); - - map = map.set(key, true); - keys.push(key); - - t.deepEqual(map.size, i + 1, `Post-add map.size() fails with seed ${seed}`); - t.deepEqual( - map.has(key), - true, - `Post-add map.has() fails with seed ${seed}`, - ); - t.deepEqual( - Iterable.includes(map.keys(), key), - true, - `Post-add includes fails with seed ${seed}`, - ); - } - - // Removing those elements. Note that the keys array keep them in an order - // fairly different from the #keys() enumeration (which is essentially - // depth-first order in the Map, hence depends on hashes). - // Hopefully, this creates enough entropy to test various scenarios. - for (let i = iterations; i > 0; i--) { - const key = keys[iterations - i]; - t.deepEqual(map.size, i, `Pre-delete map.size() fails with seed ${seed}`); - t.deepEqual( - map.has(key), - true, - `Pre-delete map.has() fails with seed ${seed}`, - ); - t.deepEqual( - Iterable.includes(map.keys(), key), - true, - `Pre-delete includes fails with seed ${seed}`, - ); - - map = map.delete(key); - - t.deepEqual( - map.size, - i - 1, - `Post-delete map.size() fails with seed ${seed}`, - ); - t.deepEqual( - map.has(key), - false, - `Post-delete map.has() fails with seed ${seed}`, - ); - t.deepEqual( - Iterable.includes(map.keys(), key), - false, - `Post-delete includes fails with seed ${seed}`, - ); - } -}); +test( + `#add and #delete behave when used in bulk`, + (t, rng, seed) => { + // How many elements are we adding/removing in each iteration of the test? + const size = 1000; + + let map: Map = Map.empty(); + const keys: Array = []; + + // Adding elements + for (let i = 0; i < size; i++) { + const key = rng(); + + t.deepEqual(map.size, i, `Pre-add map.size() fails with seed ${seed}`); + t.deepEqual( + map.has(key), + false, + `Pre-add map.has() fails with seed ${seed}`, + ); + t.deepEqual( + Iterable.includes(map.keys(), key), + false, + `Pre-add includes fails with seed ${seed}`, + ); + + map = map.set(key, true); + keys.push(key); + + t.deepEqual( + map.size, + i + 1, + `Post-add map.size() fails with seed ${seed}`, + ); + t.deepEqual( + map.has(key), + true, + `Post-add map.has() fails with seed ${seed}`, + ); + t.deepEqual( + Iterable.includes(map.keys(), key), + true, + `Post-add includes fails with seed ${seed}`, + ); + } + + // Removing those elements. Note that the keys array keep them in an order + // fairly different from the #keys() enumeration (which is essentially + // depth-first order in the Map, hence depends on hashes). + // Hopefully, this creates enough entropy to test various scenarios. + for (let i = size; i > 0; i--) { + const key = keys[size - i]; + t.deepEqual(map.size, i, `Pre-delete map.size() fails with seed ${seed}`); + t.deepEqual( + map.has(key), + true, + `Pre-delete map.has() fails with seed ${seed}`, + ); + t.deepEqual( + Iterable.includes(map.keys(), key), + true, + `Pre-delete includes fails with seed ${seed}`, + ); + + map = map.delete(key); + + t.deepEqual( + map.size, + i - 1, + `Post-delete map.size() fails with seed ${seed}`, + ); + t.deepEqual( + map.has(key), + false, + `Post-delete map.has() fails with seed ${seed}`, + ); + t.deepEqual( + Iterable.includes(map.keys(), key), + false, + `Post-delete includes fails with seed ${seed}`, + ); + } + }, + { wrapper }, +); diff --git a/packages/alfa-selector/package.json b/packages/alfa-selector/package.json index 1564c03494..66782f6114 100644 --- a/packages/alfa-selector/package.json +++ b/packages/alfa-selector/package.json @@ -23,6 +23,7 @@ "@siteimprove/alfa-css": "workspace:^0.69.0", "@siteimprove/alfa-dom": "workspace:^0.69.0", "@siteimprove/alfa-equatable": "workspace:^0.69.0", + "@siteimprove/alfa-hash": "workspace:^0.69.0", "@siteimprove/alfa-iterable": "workspace:^0.69.0", "@siteimprove/alfa-json": "workspace:^0.69.0", "@siteimprove/alfa-map": "workspace:^0.69.0", diff --git a/packages/alfa-selector/src/selector/complex.ts b/packages/alfa-selector/src/selector/complex.ts index 4345756e35..4f0a8bc46b 100644 --- a/packages/alfa-selector/src/selector/complex.ts +++ b/packages/alfa-selector/src/selector/complex.ts @@ -6,6 +6,7 @@ import { Thunk } from "@siteimprove/alfa-thunk"; import { Context } from "../context"; import type { Absolute } from "../selector"; +import { Specificity } from "../specificity"; import { Combinator } from "./combinator"; import { Compound } from "./compound"; @@ -38,7 +39,7 @@ export class Complex extends Selector<"complex"> { left: Simple | Compound | Complex, right: Simple | Compound, ) { - super("complex"); + super("complex", Specificity.sum(left.specificity, right.specificity)); this._combinator = combinator; this._left = left; this._right = right; diff --git a/packages/alfa-selector/src/selector/compound.ts b/packages/alfa-selector/src/selector/compound.ts index 2da60637e4..34ccfa19a6 100644 --- a/packages/alfa-selector/src/selector/compound.ts +++ b/packages/alfa-selector/src/selector/compound.ts @@ -6,6 +6,7 @@ import { Parser } from "@siteimprove/alfa-parser"; import { Slice } from "@siteimprove/alfa-slice"; import type { Context } from "../context"; +import { Specificity } from "../specificity"; import type { Absolute } from "./index"; import { Selector } from "./selector"; @@ -27,7 +28,10 @@ export class Compound extends Selector<"compound"> { private readonly _length: number; private constructor(selectors: Array) { - super("compound"); + super( + "compound", + Specificity.sum(...selectors.map((selector) => selector.specificity)), + ); this._selectors = selectors; this._length = selectors.length; } diff --git a/packages/alfa-selector/src/selector/list.ts b/packages/alfa-selector/src/selector/list.ts index a5c0cdd6f3..9f5fd96798 100644 --- a/packages/alfa-selector/src/selector/list.ts +++ b/packages/alfa-selector/src/selector/list.ts @@ -7,6 +7,7 @@ import { Parser } from "@siteimprove/alfa-parser"; import type { Thunk } from "@siteimprove/alfa-thunk"; import type { Context } from "../context"; +import { Specificity } from "../specificity"; import type { Absolute } from "./index"; @@ -34,7 +35,10 @@ export class List extends Selector<"list"> { private readonly _length: number; private constructor(selectors: Array) { - super("list"); + super( + "list", + Specificity.max(...selectors.map((selector) => selector.specificity)), + ); this._selectors = selectors; this._length = selectors.length; } diff --git a/packages/alfa-selector/src/selector/relative.ts b/packages/alfa-selector/src/selector/relative.ts index 084f92b9e3..ecba6eb2e5 100644 --- a/packages/alfa-selector/src/selector/relative.ts +++ b/packages/alfa-selector/src/selector/relative.ts @@ -24,7 +24,7 @@ export class Relative extends Selector<"relative"> { combinator: Combinator, selector: Simple | Compound | Complex, ) { - super("relative"); + super("relative", selector.specificity); this._combinator = combinator; this._selector = selector; } diff --git a/packages/alfa-selector/src/selector/selector.ts b/packages/alfa-selector/src/selector/selector.ts index efa66bce19..0d26c02f9d 100644 --- a/packages/alfa-selector/src/selector/selector.ts +++ b/packages/alfa-selector/src/selector/selector.ts @@ -6,6 +6,7 @@ import { Serializable } from "@siteimprove/alfa-json"; import * as json from "@siteimprove/alfa-json"; import type { Context } from "../context"; +import { Specificity } from "../specificity"; import type { Complex } from "./complex"; import type { Compound } from "./compound"; @@ -22,9 +23,11 @@ export abstract class Selector Serializable { private readonly _type: T; + private readonly _specificity: Specificity; - protected constructor(type: T) { + protected constructor(type: T, specificity: Specificity) { this._type = type; + this._specificity = specificity; } /** @public (knip) */ @@ -32,6 +35,10 @@ export abstract class Selector return this._type; } + public get specificity(): Specificity { + return this._specificity; + } + /** * {@link https://drafts.csswg.org/selectors/#match} */ @@ -42,7 +49,11 @@ export abstract class Selector public equals(value: unknown): value is this; public equals(value: unknown): boolean { - return value instanceof Selector && value._type === this._type; + return ( + value instanceof Selector && + value._type === this._type && + value._specificity.equals(this._specificity) + ); } /** @public (knip) */ @@ -53,6 +64,7 @@ export abstract class Selector public toJSON(): Selector.JSON { return { type: this._type, + specificity: this._specificity.toJSON(), }; } } @@ -62,6 +74,7 @@ export namespace Selector { [key: string]: json.JSON; type: T; + specificity: Specificity.JSON; } } @@ -79,8 +92,8 @@ export abstract class WithName< N extends string = string, > extends Selector { protected readonly _name: N; - protected constructor(type: T, name: N) { - super(type); + protected constructor(type: T, name: N, specificity: Specificity) { + super(type, specificity); this._name = name; } diff --git a/packages/alfa-selector/src/selector/simple/attribute.ts b/packages/alfa-selector/src/selector/simple/attribute.ts index f11de78580..89e2a45aed 100644 --- a/packages/alfa-selector/src/selector/simple/attribute.ts +++ b/packages/alfa-selector/src/selector/simple/attribute.ts @@ -6,6 +6,8 @@ import { None, Option } from "@siteimprove/alfa-option"; import { Parser } from "@siteimprove/alfa-parser"; import { Predicate } from "@siteimprove/alfa-predicate"; +import { Specificity } from "../../specificity"; + import { WithName } from "../selector"; import { parseName } from "./parser"; @@ -41,7 +43,7 @@ export class Attribute extends WithName<"attribute"> { matcher: Option, modifier: Option, ) { - super("attribute", name); + super("attribute", name, Specificity.of(0, 1, 0)); this._namespace = namespace; this._value = value; this._matcher = matcher; diff --git a/packages/alfa-selector/src/selector/simple/class.ts b/packages/alfa-selector/src/selector/simple/class.ts index 2684e548e8..04ac9325e8 100644 --- a/packages/alfa-selector/src/selector/simple/class.ts +++ b/packages/alfa-selector/src/selector/simple/class.ts @@ -3,6 +3,8 @@ import type { Element } from "@siteimprove/alfa-dom"; import { Iterable } from "@siteimprove/alfa-iterable"; import { Parser } from "@siteimprove/alfa-parser"; +import { Specificity } from "../../specificity"; + import { WithName } from "../selector"; const { map, right } = Parser; @@ -17,7 +19,7 @@ export class Class extends WithName<"class"> { return new Class(name); } private constructor(name: string) { - super("class", name); + super("class", name, Specificity.of(0, 1, 0)); } public matches(element: Element): boolean { diff --git a/packages/alfa-selector/src/selector/simple/id.ts b/packages/alfa-selector/src/selector/simple/id.ts index 610ca8f612..6c8d2a882d 100644 --- a/packages/alfa-selector/src/selector/simple/id.ts +++ b/packages/alfa-selector/src/selector/simple/id.ts @@ -2,6 +2,8 @@ import { Token } from "@siteimprove/alfa-css"; import type { Element } from "@siteimprove/alfa-dom"; import { Parser } from "@siteimprove/alfa-parser"; +import { Specificity } from "../../specificity"; + import { WithName } from "../selector"; const { map } = Parser; @@ -17,7 +19,7 @@ export class Id extends WithName<"id"> { } private constructor(name: string) { - super("id", name); + super("id", name, Specificity.of(1, 0, 0)); } public matches(element: Element): boolean { diff --git a/packages/alfa-selector/src/selector/simple/pseudo-class/has.ts b/packages/alfa-selector/src/selector/simple/pseudo-class/has.ts index 79b53db1f8..98043d174c 100644 --- a/packages/alfa-selector/src/selector/simple/pseudo-class/has.ts +++ b/packages/alfa-selector/src/selector/simple/pseudo-class/has.ts @@ -14,7 +14,7 @@ export class Has extends WithSelector<"has"> { } private constructor(selector: Absolute) { - super("has", selector); + super("has", selector, selector.specificity); } /** @public (knip) */ diff --git a/packages/alfa-selector/src/selector/simple/pseudo-class/is.ts b/packages/alfa-selector/src/selector/simple/pseudo-class/is.ts index ad6ef0ba31..b013a7c1cf 100644 --- a/packages/alfa-selector/src/selector/simple/pseudo-class/is.ts +++ b/packages/alfa-selector/src/selector/simple/pseudo-class/is.ts @@ -16,7 +16,7 @@ export class Is extends WithSelector<"is"> { } private constructor(selector: Absolute) { - super("is", selector); + super("is", selector, selector.specificity); } /** @public (knip) */ diff --git a/packages/alfa-selector/src/selector/simple/pseudo-class/not.ts b/packages/alfa-selector/src/selector/simple/pseudo-class/not.ts index 7887a972f1..23de85d422 100644 --- a/packages/alfa-selector/src/selector/simple/pseudo-class/not.ts +++ b/packages/alfa-selector/src/selector/simple/pseudo-class/not.ts @@ -16,7 +16,7 @@ export class Not extends WithSelector<"not"> { } private constructor(selector: Absolute) { - super("not", selector); + super("not", selector, selector.specificity); } /** @public (knip) */ diff --git a/packages/alfa-selector/src/selector/simple/pseudo-class/pseudo-class.ts b/packages/alfa-selector/src/selector/simple/pseudo-class/pseudo-class.ts index f14b65e654..3ab2e4d401 100644 --- a/packages/alfa-selector/src/selector/simple/pseudo-class/pseudo-class.ts +++ b/packages/alfa-selector/src/selector/simple/pseudo-class/pseudo-class.ts @@ -9,6 +9,7 @@ import { Parser } from "@siteimprove/alfa-parser"; import { Thunk } from "@siteimprove/alfa-thunk"; import type { Absolute } from "../../../selector"; +import { Specificity } from "../../../specificity"; import { WithName } from "../../selector"; @@ -21,8 +22,11 @@ const { parseColon } = Token; export abstract class PseudoClassSelector< N extends string = string, > extends WithName<"pseudo-class", N> { - protected constructor(name: N) { - super("pseudo-class", name); + // Some pseudo-class manipulate specificity, so we cannot just set it + // to (0, 1, 0) for all and must allow for overwriting it. + // https://www.w3.org/TR/selectors/#specificity + protected constructor(name: N, specificity?: Specificity) { + super("pseudo-class", name, specificity ?? Specificity.of(0, 1, 0)); } public equals(value: PseudoClassSelector): boolean; @@ -133,8 +137,8 @@ export abstract class WithSelector< > extends PseudoClassSelector { protected readonly _selector: Absolute; - protected constructor(name: N, selector: Absolute) { - super(name); + protected constructor(name: N, selector: Absolute, specificity: Specificity) { + super(name, specificity); this._selector = selector; } diff --git a/packages/alfa-selector/src/selector/simple/pseudo-element/pseudo-element.ts b/packages/alfa-selector/src/selector/simple/pseudo-element/pseudo-element.ts index 76987b114b..20e6fb814b 100644 --- a/packages/alfa-selector/src/selector/simple/pseudo-element/pseudo-element.ts +++ b/packages/alfa-selector/src/selector/simple/pseudo-element/pseudo-element.ts @@ -1,6 +1,7 @@ import { type Parser as CSSParser, Token } from "@siteimprove/alfa-css"; import { Parser } from "@siteimprove/alfa-parser"; import type { Thunk } from "@siteimprove/alfa-thunk"; +import { Specificity } from "../../../specificity"; import { WithName } from "../../selector"; @@ -11,7 +12,7 @@ export abstract class PseudoElementSelector< N extends string = string, > extends WithName<"pseudo-element", N> { protected constructor(name: N) { - super("pseudo-element", name); + super("pseudo-element", name, Specificity.of(0, 0, 1)); } public equals(value: PseudoElementSelector): boolean; diff --git a/packages/alfa-selector/src/selector/simple/type.ts b/packages/alfa-selector/src/selector/simple/type.ts index 5d9989648d..45196a3506 100644 --- a/packages/alfa-selector/src/selector/simple/type.ts +++ b/packages/alfa-selector/src/selector/simple/type.ts @@ -2,6 +2,8 @@ import type { Element } from "@siteimprove/alfa-dom"; import { Option } from "@siteimprove/alfa-option"; import { Parser } from "@siteimprove/alfa-parser"; +import { Specificity } from "../../specificity"; + import { WithName } from "../selector"; import { parseName } from "./parser"; @@ -21,7 +23,7 @@ export class Type extends WithName<"type"> { private readonly _namespace: Option; private constructor(namespace: Option, name: string) { - super("type", name); + super("type", name, Specificity.of(0, 0, 1)); this._namespace = namespace; } diff --git a/packages/alfa-selector/src/selector/simple/universal.ts b/packages/alfa-selector/src/selector/simple/universal.ts index 58d07e4b88..5b53b03913 100644 --- a/packages/alfa-selector/src/selector/simple/universal.ts +++ b/packages/alfa-selector/src/selector/simple/universal.ts @@ -3,6 +3,8 @@ import type { Element } from "@siteimprove/alfa-dom"; import { None, type Option } from "@siteimprove/alfa-option"; import { Parser } from "@siteimprove/alfa-parser"; +import { Specificity } from "../../specificity"; + import { Selector } from "../selector"; import { parseNamespace } from "./parser"; @@ -28,7 +30,7 @@ export class Universal extends Selector<"universal"> { private readonly _namespace: Option; private constructor(namespace: Option) { - super("universal"); + super("universal", Specificity.empty()); this._namespace = namespace; } diff --git a/packages/alfa-selector/src/specificity.ts b/packages/alfa-selector/src/specificity.ts new file mode 100644 index 0000000000..9b071e903e --- /dev/null +++ b/packages/alfa-selector/src/specificity.ts @@ -0,0 +1,141 @@ +import { Equatable } from "@siteimprove/alfa-equatable"; +import { Hash, Hashable } from "@siteimprove/alfa-hash"; +import { Serializable } from "@siteimprove/alfa-json"; + +import * as json from "@siteimprove/alfa-json"; + +// The number of bits to use for every component of the specificity computation. +// As bitwise operations in JavaScript are limited to 32 bits, we can only use +// at most 10 bits per component as 3 components are used. +const componentBits = 10; + +// The maximum value that any given component can have. Since we can only use 10 +// bits for every component, this in effect means that any given component count +// must be strictly less than 1024. +const componentMax = (1 << componentBits) - 1; + +/** + * {@link https://www.w3.org/TR/selectors/#specificity} + * + * @remarks + * Specificities are triplet (a, b, c), ordered lexicographically. + * We also store a 32 bits integer representing the specificity with 10 bits + * per components (and 2 wasted bits). This allows for quick lexicographic + * comparison, which is the frequent operation on specificities. Components are + * therefore limited to 1024 values (10 bits). + * + * @privateRemarks + * This class purposefully doesn't implement the Comparable interface. The + * interface introduce a tiny overhead in converting the native comparison into + * the Comparison enum. While this price is gladly payed for more readable code + * in general, specificity comparison is a hot-path in cascading style which + * we try to keep as efficient as possible. + */ +export class Specificity + implements Serializable, Equatable, Hashable +{ + public static of(a: number, b: number, c: number): Specificity { + return new Specificity(a, b, c); + } + + private static _empty = new Specificity(0, 0, 0); + + public static empty(): Specificity { + return Specificity._empty; + } + + private readonly _a: number; + private readonly _b: number; + private readonly _c: number; + private readonly _value: number; + + private constructor(a: number, b: number, c: number) { + this._a = a; + this._b = b; + this._c = c; + + this._value = + (Math.min(a, componentMax) << (componentBits * 2)) | + (Math.min(b, componentMax) << (componentBits * 1)) | + Math.min(c, componentMax); + } + + public get a(): number { + return this._a; + } + + public get b(): number { + return this._b; + } + + public get c(): number { + return this._c; + } + + public get value(): number { + return this._value; + } + + public equals(value: Specificity): boolean; + + public equals(value: unknown): value is this; + + public equals(value: unknown): boolean { + return value instanceof Specificity && value._value === this._value; + } + + public hash(hash: Hash) { + hash.writeInt32(this._value); + } + + public toJSON(): Specificity.JSON { + return { a: this._a, b: this._b, c: this._c }; + } + + public toString(): string { + return `(${this._a}, ${this._b}, ${this._c})`; + } +} + +export namespace Specificity { + export interface JSON { + [key: string]: json.JSON; + a: number; + b: number; + c: number; + } + + /** public (knip) */ + export function isSpecificity(value: unknown): value is Specificity { + return value instanceof Specificity; + } + + export function sum( + ...specificities: ReadonlyArray + ): Specificity { + if (specificities.length === 0) { + return Specificity.empty(); + } + + const [first, ...rest] = specificities; + + return rest.reduce( + (pre, cur) => Specificity.of(pre.a + cur.a, pre.b + cur.b, pre.c + cur.c), + first, + ); + } + + export function max( + ...specificities: ReadonlyArray + ): Specificity { + if (specificities.length === 0) { + return Specificity.empty(); + } + + const [first, ...rest] = specificities; + return rest.reduce( + (pre, cur) => (pre.value > cur.value ? pre : cur), + first, + ); + } +} diff --git a/packages/alfa-selector/test/basic.spec.ts b/packages/alfa-selector/test/basic.spec.ts index 4d399b6383..feeeb034d6 100644 --- a/packages/alfa-selector/test/basic.spec.ts +++ b/packages/alfa-selector/test/basic.spec.ts @@ -8,6 +8,7 @@ test(".parse() parses a type selector", (t) => { type: "type", name: "div", namespace: null, + specificity: { a: 0, b: 0, c: 1 }, }); }); @@ -16,6 +17,7 @@ test(".parse() parses an uppercase type selector", (t) => { type: "type", name: "DIV", namespace: null, + specificity: { a: 0, b: 0, c: 1 }, }); }); @@ -24,6 +26,7 @@ test(".parse() parses a type selector with a namespace", (t) => { type: "type", name: "a", namespace: "svg", + specificity: { a: 0, b: 0, c: 1 }, }); }); @@ -32,6 +35,7 @@ test(".parse() parses a type selector with an empty namespace", (t) => { type: "type", name: "a", namespace: "", + specificity: { a: 0, b: 0, c: 1 }, }); }); @@ -40,6 +44,7 @@ test(".parse() parses a type selector with the universal namespace", (t) => { type: "type", name: "a", namespace: "*", + specificity: { a: 0, b: 0, c: 1 }, }); }); @@ -47,6 +52,7 @@ test(".parse() parses the universal selector", (t) => { t.deepEqual(serialize("*"), { type: "universal", namespace: null, + specificity: { a: 0, b: 0, c: 0 }, }); }); @@ -54,6 +60,7 @@ test(".parse() parses the universal selector with an empty namespace", (t) => { t.deepEqual(serialize("|*"), { type: "universal", namespace: "", + specificity: { a: 0, b: 0, c: 0 }, }); }); @@ -61,6 +68,7 @@ test(".parse() parses the universal selector with the universal namespace", (t) t.deepEqual(serialize("*|*"), { type: "universal", namespace: "*", + specificity: { a: 0, b: 0, c: 0 }, }); }); @@ -68,6 +76,7 @@ test(".parse() parses a class selector", (t) => { t.deepEqual(serialize(".foo"), { type: "class", name: "foo", + specificity: { a: 0, b: 1, c: 0 }, }); }); @@ -75,6 +84,7 @@ test(".parse() parses an ID selector", (t) => { t.deepEqual(serialize("#foo"), { type: "id", name: "foo", + specificity: { a: 1, b: 0, c: 0 }, }); }); @@ -86,6 +96,7 @@ test(".parse() parses an attribute selector without a value", (t) => { value: null, matcher: null, modifier: null, + specificity: { a: 0, b: 1, c: 0 }, }); }); @@ -97,6 +108,7 @@ test(".parse() parses an attribute selector with an ident value", (t) => { value: "bar", matcher: Attribute.Matcher.Equal, modifier: null, + specificity: { a: 0, b: 1, c: 0 }, }); }); @@ -108,6 +120,7 @@ test(".parse() parses an attribute selector with a string value", (t) => { value: "bar", matcher: Attribute.Matcher.Equal, modifier: null, + specificity: { a: 0, b: 1, c: 0 }, }); }); @@ -119,6 +132,7 @@ test(".parse() parses an attribute selector with a matcher", (t) => { value: "bar", matcher: Attribute.Matcher.Substring, modifier: null, + specificity: { a: 0, b: 1, c: 0 }, }); }); @@ -130,6 +144,7 @@ test(".parse() parses an attribute selector with a casing modifier", (t) => { value: "bar", matcher: Attribute.Matcher.Equal, modifier: "i", + specificity: { a: 0, b: 1, c: 0 }, }); }); @@ -141,6 +156,7 @@ test(".parse() parses an attribute selector with a namespace", (t) => { value: null, matcher: null, modifier: null, + specificity: { a: 0, b: 1, c: 0 }, }); }); @@ -152,6 +168,7 @@ test(".parse() parses an attribute selector with a namespace", (t) => { value: null, matcher: null, modifier: null, + specificity: { a: 0, b: 1, c: 0 }, }); }); @@ -163,6 +180,7 @@ test(".parse() parses an attribute selector with a namespace", (t) => { value: null, matcher: null, modifier: null, + specificity: { a: 0, b: 1, c: 0 }, }); }); @@ -174,6 +192,7 @@ test(".parse() parses an attribute selector with a namespace", (t) => { value: "baz", matcher: Attribute.Matcher.Equal, modifier: null, + specificity: { a: 0, b: 1, c: 0 }, }); }); @@ -185,5 +204,6 @@ test(".parse() parses an attribute selector with a namespace", (t) => { value: "baz", matcher: Attribute.Matcher.DashMatch, modifier: null, + specificity: { a: 0, b: 1, c: 0 }, }); }); diff --git a/packages/alfa-selector/test/complex.spec.ts b/packages/alfa-selector/test/complex.spec.ts index c74fc17538..6059241165 100644 --- a/packages/alfa-selector/test/complex.spec.ts +++ b/packages/alfa-selector/test/complex.spec.ts @@ -7,8 +7,14 @@ test(".parse() parses a single descendant selector", (t) => { t.deepEqual(serialize("div .foo"), { type: "complex", combinator: Combinator.Descendant, - left: { type: "type", name: "div", namespace: null }, - right: { type: "class", name: "foo" }, + left: { + type: "type", + name: "div", + namespace: null, + specificity: { a: 0, b: 0, c: 1 }, + }, + right: { type: "class", name: "foo", specificity: { a: 0, b: 1, c: 0 } }, + specificity: { a: 0, b: 1, c: 1 }, }); }); @@ -16,8 +22,19 @@ test(".parse() parses a single descendant selector with a right-hand type select t.deepEqual(serialize("div span"), { type: "complex", combinator: Combinator.Descendant, - left: { type: "type", name: "div", namespace: null }, - right: { type: "type", name: "span", namespace: null }, + left: { + type: "type", + name: "div", + namespace: null, + specificity: { a: 0, b: 0, c: 1 }, + }, + right: { + type: "type", + name: "span", + namespace: null, + specificity: { a: 0, b: 0, c: 1 }, + }, + specificity: { a: 0, b: 0, c: 2 }, }); }); @@ -28,10 +45,17 @@ test(".parse() parses a double descendant selector", (t) => { left: { type: "complex", combinator: Combinator.Descendant, - left: { type: "type", name: "div", namespace: null }, - right: { type: "class", name: "foo" }, + left: { + type: "type", + name: "div", + namespace: null, + specificity: { a: 0, b: 0, c: 1 }, + }, + right: { type: "class", name: "foo", specificity: { a: 0, b: 1, c: 0 } }, + specificity: { a: 0, b: 1, c: 1 }, }, - right: { type: "id", name: "bar" }, + right: { type: "id", name: "bar", specificity: { a: 1, b: 0, c: 0 } }, + specificity: { a: 1, b: 1, c: 1 }, }); }); @@ -39,8 +63,14 @@ test(".parse() parses a direct descendant selector", (t) => { t.deepEqual(serialize("div > .foo"), { type: "complex", combinator: Combinator.DirectDescendant, - left: { type: "type", name: "div", namespace: null }, - right: { type: "class", name: "foo" }, + left: { + type: "type", + name: "div", + namespace: null, + specificity: { a: 0, b: 0, c: 1 }, + }, + right: { type: "class", name: "foo", specificity: { a: 0, b: 1, c: 0 } }, + specificity: { a: 0, b: 1, c: 1 }, }); }); @@ -48,8 +78,14 @@ test(".parse() parses a sibling selector", (t) => { t.deepEqual(serialize("div ~ .foo"), { type: "complex", combinator: Combinator.Sibling, - left: { type: "type", name: "div", namespace: null }, - right: { type: "class", name: "foo" }, + left: { + type: "type", + name: "div", + namespace: null, + specificity: { a: 0, b: 0, c: 1 }, + }, + right: { type: "class", name: "foo", specificity: { a: 0, b: 1, c: 0 } }, + specificity: { a: 0, b: 1, c: 1 }, }); }); @@ -57,8 +93,14 @@ test(".parse() parses a direct sibling selector", (t) => { t.deepEqual(serialize("div + .foo"), { type: "complex", combinator: Combinator.DirectSibling, - left: { type: "type", name: "div", namespace: null }, - right: { type: "class", name: "foo" }, + left: { + type: "type", + name: "div", + namespace: null, + specificity: { a: 0, b: 0, c: 1 }, + }, + right: { type: "class", name: "foo", specificity: { a: 0, b: 1, c: 0 } }, + specificity: { a: 0, b: 1, c: 1 }, }); }); @@ -66,14 +108,21 @@ test(".parse() parses a compound selector relative to a class selector", (t) => t.deepEqual(serialize(".foo div.bar"), { type: "complex", combinator: Combinator.Descendant, - left: { type: "class", name: "foo" }, + left: { type: "class", name: "foo", specificity: { a: 0, b: 1, c: 0 } }, right: { type: "compound", selectors: [ - { type: "type", name: "div", namespace: null }, - { type: "class", name: "bar" }, + { + type: "type", + name: "div", + namespace: null, + specificity: { a: 0, b: 0, c: 1 }, + }, + { type: "class", name: "bar", specificity: { a: 0, b: 1, c: 0 } }, ], + specificity: { a: 0, b: 1, c: 1 }, }, + specificity: { a: 0, b: 2, c: 1 }, }); }); @@ -84,17 +133,30 @@ test(".parse() parses a compound selector relative to a compound selector", (t) left: { type: "compound", selectors: [ - { type: "type", name: "span", namespace: null }, - { type: "class", name: "foo" }, + { + type: "type", + name: "span", + namespace: null, + specificity: { a: 0, b: 0, c: 1 }, + }, + { type: "class", name: "foo", specificity: { a: 0, b: 1, c: 0 } }, ], + specificity: { a: 0, b: 1, c: 1 }, }, right: { type: "compound", selectors: [ - { type: "type", name: "div", namespace: null }, - { type: "class", name: "bar" }, + { + type: "type", + name: "div", + namespace: null, + specificity: { a: 0, b: 0, c: 1 }, + }, + { type: "class", name: "bar", specificity: { a: 0, b: 1, c: 0 } }, ], + specificity: { a: 0, b: 1, c: 1 }, }, + specificity: { a: 0, b: 2, c: 2 }, }); }); @@ -105,10 +167,22 @@ test(".parse() parses a descendant selector relative to a sibling selector", (t) left: { type: "complex", combinator: Combinator.Sibling, - left: { type: "type", name: "div", namespace: null }, - right: { type: "type", name: "span", namespace: null }, + left: { + type: "type", + name: "div", + namespace: null, + specificity: { a: 0, b: 0, c: 1 }, + }, + right: { + type: "type", + name: "span", + namespace: null, + specificity: { a: 0, b: 0, c: 1 }, + }, + specificity: { a: 0, b: 0, c: 2 }, }, - right: { type: "class", name: "foo" }, + right: { type: "class", name: "foo", specificity: { a: 0, b: 1, c: 0 } }, + specificity: { a: 0, b: 1, c: 2 }, }); }); @@ -116,7 +190,12 @@ test(".parse() parses an attribute selector when part of a descendant selector", t.deepEqual(serialize("div [foo]"), { type: "complex", combinator: Combinator.Descendant, - left: { type: "type", name: "div", namespace: null }, + left: { + type: "type", + name: "div", + namespace: null, + specificity: { a: 0, b: 0, c: 1 }, + }, right: { type: "attribute", name: "foo", @@ -124,7 +203,9 @@ test(".parse() parses an attribute selector when part of a descendant selector", value: null, matcher: null, modifier: null, + specificity: { a: 0, b: 1, c: 0 }, }, + specificity: { a: 0, b: 1, c: 1 }, }); }); @@ -135,11 +216,17 @@ test(".parse() parses an attribute selector when part of a compound selector rel left: { type: "class", name: "foo", + specificity: { a: 0, b: 1, c: 0 }, }, right: { type: "compound", selectors: [ - { type: "type", name: "div", namespace: null }, + { + type: "type", + name: "div", + namespace: null, + specificity: { a: 0, b: 0, c: 1 }, + }, { type: "attribute", name: "foo", @@ -147,9 +234,12 @@ test(".parse() parses an attribute selector when part of a compound selector rel value: null, matcher: null, modifier: null, + specificity: { a: 0, b: 1, c: 0 }, }, ], + specificity: { a: 0, b: 1, c: 1 }, }, + specificity: { a: 0, b: 2, c: 1 }, }); }); @@ -157,8 +247,18 @@ test(".parse() parses a pseudo-element selector when part of a descendant select t.deepEqual(serialize("div ::before"), { type: "complex", combinator: Combinator.Descendant, - left: { type: "type", name: "div", namespace: null }, - right: { type: "pseudo-element", name: "before" }, + left: { + type: "type", + name: "div", + namespace: null, + specificity: { a: 0, b: 0, c: 1 }, + }, + right: { + type: "pseudo-element", + name: "before", + specificity: { a: 0, b: 0, c: 1 }, + }, + specificity: { a: 0, b: 0, c: 2 }, }); }); @@ -166,14 +266,25 @@ test(".parse() parses a pseudo-element selector when part of a compound selector t.deepEqual(serialize(".foo div::before"), { type: "complex", combinator: Combinator.Descendant, - left: { type: "class", name: "foo" }, + left: { type: "class", name: "foo", specificity: { a: 0, b: 1, c: 0 } }, right: { type: "compound", selectors: [ - { type: "type", name: "div", namespace: null }, - { type: "pseudo-element", name: "before" }, + { + type: "type", + name: "div", + namespace: null, + specificity: { a: 0, b: 0, c: 1 }, + }, + { + type: "pseudo-element", + name: "before", + specificity: { a: 0, b: 0, c: 1 }, + }, ], + specificity: { a: 0, b: 0, c: 2 }, }, + specificity: { a: 0, b: 1, c: 2 }, }); }); @@ -181,14 +292,25 @@ test(".parse() parses a pseudo-class selector when part of a compound selector r t.deepEqual(serialize(".foo div:hover"), { type: "complex", combinator: Combinator.Descendant, - left: { type: "class", name: "foo" }, + left: { type: "class", name: "foo", specificity: { a: 0, b: 1, c: 0 } }, right: { type: "compound", selectors: [ - { type: "type", name: "div", namespace: null }, - { type: "pseudo-class", name: "hover" }, + { + type: "type", + name: "div", + namespace: null, + specificity: { a: 0, b: 0, c: 1 }, + }, + { + type: "pseudo-class", + name: "hover", + specificity: { a: 0, b: 1, c: 0 }, + }, ], + specificity: { a: 0, b: 1, c: 1 }, }, + specificity: { a: 0, b: 2, c: 1 }, }); }); @@ -196,15 +318,26 @@ test(".parse() parses a compound type, class, and pseudo-class selector relative t.deepEqual(serialize(".foo div.bar:hover"), { type: "complex", combinator: Combinator.Descendant, - left: { type: "class", name: "foo" }, + left: { type: "class", name: "foo", specificity: { a: 0, b: 1, c: 0 } }, right: { type: "compound", selectors: [ - { type: "type", name: "div", namespace: null }, - { type: "class", name: "bar" }, - { type: "pseudo-class", name: "hover" }, + { + type: "type", + name: "div", + namespace: null, + specificity: { a: 0, b: 0, c: 1 }, + }, + { type: "class", name: "bar", specificity: { a: 0, b: 1, c: 0 } }, + { + type: "pseudo-class", + name: "hover", + specificity: { a: 0, b: 1, c: 0 }, + }, ], + specificity: { a: 0, b: 2, c: 1 }, }, + specificity: { a: 0, b: 3, c: 1 }, }); }); @@ -212,14 +345,21 @@ test(".parse() parses a simple selector relative to a compound selector", (t) => t.deepEqual(serialize(".foo > div.bar"), { type: "complex", combinator: Combinator.DirectDescendant, - left: { type: "class", name: "foo" }, + left: { type: "class", name: "foo", specificity: { a: 0, b: 1, c: 0 } }, right: { type: "compound", selectors: [ - { type: "type", name: "div", namespace: null }, - { type: "class", name: "bar" }, + { + type: "type", + name: "div", + namespace: null, + specificity: { a: 0, b: 0, c: 1 }, + }, + { type: "class", name: "bar", specificity: { a: 0, b: 1, c: 0 } }, ], + specificity: { a: 0, b: 1, c: 1 }, }, + specificity: { a: 0, b: 2, c: 1 }, }); }); @@ -230,15 +370,23 @@ test(".parse() parses a relative selector relative to a compound selector", (t) left: { type: "complex", combinator: Combinator.DirectDescendant, - left: { type: "class", name: "foo" }, - right: { type: "class", name: "bar" }, + left: { type: "class", name: "foo", specificity: { a: 0, b: 1, c: 0 } }, + right: { type: "class", name: "bar", specificity: { a: 0, b: 1, c: 0 } }, + specificity: { a: 0, b: 2, c: 0 }, }, right: { type: "compound", selectors: [ - { type: "type", name: "div", namespace: null }, - { type: "class", name: "baz" }, + { + type: "type", + name: "div", + namespace: null, + specificity: { a: 0, b: 0, c: 1 }, + }, + { type: "class", name: "baz", specificity: { a: 0, b: 1, c: 0 } }, ], + specificity: { a: 0, b: 1, c: 1 }, }, + specificity: { a: 0, b: 3, c: 1 }, }); }); diff --git a/packages/alfa-selector/test/compound.spec.ts b/packages/alfa-selector/test/compound.spec.ts index 6926546cc2..b8104062d7 100644 --- a/packages/alfa-selector/test/compound.spec.ts +++ b/packages/alfa-selector/test/compound.spec.ts @@ -6,9 +6,10 @@ test(".parse() parses a compound selector", (t) => { t.deepEqual(serialize("#foo.bar"), { type: "compound", selectors: [ - { type: "id", name: "foo" }, - { type: "class", name: "bar" }, + { type: "id", name: "foo", specificity: { a: 1, b: 0, c: 0 } }, + { type: "class", name: "bar", specificity: { a: 0, b: 1, c: 0 } }, ], + specificity: { a: 1, b: 1, c: 0 }, }); }); @@ -16,9 +17,15 @@ test(".parse() parses a compound selector with a type in prefix position", (t) = t.deepEqual(serialize("div.foo"), { type: "compound", selectors: [ - { type: "type", name: "div", namespace: null }, - { type: "class", name: "foo" }, + { + type: "type", + name: "div", + namespace: null, + specificity: { a: 0, b: 0, c: 1 }, + }, + { type: "class", name: "foo", specificity: { a: 0, b: 1, c: 0 } }, ], + specificity: { a: 0, b: 1, c: 1 }, }); }); @@ -26,7 +33,7 @@ test(".parse() parses an attribute selector when part of a compound selector", ( t.deepEqual(serialize(".foo[foo]"), { type: "compound", selectors: [ - { type: "class", name: "foo" }, + { type: "class", name: "foo", specificity: { a: 0, b: 1, c: 0 } }, { type: "attribute", name: "foo", @@ -34,8 +41,10 @@ test(".parse() parses an attribute selector when part of a compound selector", ( value: null, matcher: null, modifier: null, + specificity: { a: 0, b: 1, c: 0 }, }, ], + specificity: { a: 0, b: 2, c: 0 }, }); }); @@ -43,9 +52,14 @@ test(".parse() parses a pseudo-element selector when part of a compound selector t.deepEqual(serialize(".foo::before"), { type: "compound", selectors: [ - { type: "class", name: "foo" }, - { type: "pseudo-element", name: "before" }, + { type: "class", name: "foo", specificity: { a: 0, b: 1, c: 0 } }, + { + type: "pseudo-element", + name: "before", + specificity: { a: 0, b: 0, c: 1 }, + }, ], + specificity: { a: 0, b: 1, c: 1 }, }); }); @@ -53,8 +67,18 @@ test(".parse() parses a pseudo-class selector when part of a compound selector", t.deepEqual(serialize("div:hover"), { type: "compound", selectors: [ - { type: "type", name: "div", namespace: null }, - { type: "pseudo-class", name: "hover" }, + { + type: "type", + name: "div", + namespace: null, + specificity: { a: 0, b: 0, c: 1 }, + }, + { + type: "pseudo-class", + name: "hover", + specificity: { a: 0, b: 1, c: 0 }, + }, ], + specificity: { a: 0, b: 1, c: 1 }, }); }); diff --git a/packages/alfa-selector/test/list.spec.ts b/packages/alfa-selector/test/list.spec.ts index c80cf94d49..4020dea579 100644 --- a/packages/alfa-selector/test/list.spec.ts +++ b/packages/alfa-selector/test/list.spec.ts @@ -7,10 +7,11 @@ test(".parse() parses a list of simple selectors", (t) => { t.deepEqual(serialize(".foo, .bar, .baz"), { type: "list", selectors: [ - { type: "class", name: "foo" }, - { type: "class", name: "bar" }, - { type: "class", name: "baz" }, + { type: "class", name: "foo", specificity: { a: 0, b: 1, c: 0 } }, + { type: "class", name: "bar", specificity: { a: 0, b: 1, c: 0 } }, + { type: "class", name: "baz", specificity: { a: 0, b: 1, c: 0 } }, ], + specificity: { a: 0, b: 1, c: 0 }, }); }); @@ -18,15 +19,17 @@ test(".parse() parses a list of simple and compound selectors", (t) => { t.deepEqual(serialize(".foo, #bar.baz"), { type: "list", selectors: [ - { type: "class", name: "foo" }, + { type: "class", name: "foo", specificity: { a: 0, b: 1, c: 0 } }, { type: "compound", selectors: [ - { type: "id", name: "bar" }, - { type: "class", name: "baz" }, + { type: "id", name: "bar", specificity: { a: 1, b: 0, c: 0 } }, + { type: "class", name: "baz", specificity: { a: 0, b: 1, c: 0 } }, ], + specificity: { a: 1, b: 1, c: 0 }, }, ], + specificity: { a: 1, b: 1, c: 0 }, }); }); @@ -37,16 +40,37 @@ test(".parse() parses a list of descendant selectors", (t) => { { type: "complex", combinator: Combinator.Descendant, - left: { type: "type", name: "div", namespace: null }, - right: { type: "class", name: "foo" }, + left: { + type: "type", + name: "div", + namespace: null, + specificity: { a: 0, b: 0, c: 1 }, + }, + right: { + type: "class", + name: "foo", + specificity: { a: 0, b: 1, c: 0 }, + }, + specificity: { a: 0, b: 1, c: 1 }, }, { type: "complex", combinator: Combinator.Descendant, - left: { type: "type", name: "span", namespace: null }, - right: { type: "class", name: "baz" }, + left: { + type: "type", + name: "span", + namespace: null, + specificity: { a: 0, b: 0, c: 1 }, + }, + right: { + type: "class", + name: "baz", + specificity: { a: 0, b: 1, c: 0 }, + }, + specificity: { a: 0, b: 1, c: 1 }, }, ], + specificity: { a: 0, b: 1, c: 1 }, }); }); @@ -57,16 +81,37 @@ test(".parse() parses a list of sibling selectors", (t) => { { type: "complex", combinator: Combinator.Sibling, - left: { type: "type", name: "div", namespace: null }, - right: { type: "class", name: "foo" }, + left: { + type: "type", + name: "div", + namespace: null, + specificity: { a: 0, b: 0, c: 1 }, + }, + right: { + type: "class", + name: "foo", + specificity: { a: 0, b: 1, c: 0 }, + }, + specificity: { a: 0, b: 1, c: 1 }, }, { type: "complex", combinator: Combinator.Sibling, - left: { type: "type", name: "span", namespace: null }, - right: { type: "class", name: "baz" }, + left: { + type: "type", + name: "span", + namespace: null, + specificity: { a: 0, b: 0, c: 1 }, + }, + right: { + type: "class", + name: "baz", + specificity: { a: 0, b: 1, c: 0 }, + }, + specificity: { a: 0, b: 1, c: 1 }, }, ], + specificity: { a: 0, b: 1, c: 1 }, }); }); @@ -74,8 +119,9 @@ test(".parse() parses a list of selectors with no whitespace", (t) => { t.deepEqual(serialize(".foo,.bar"), { type: "list", selectors: [ - { type: "class", name: "foo" }, - { type: "class", name: "bar" }, + { type: "class", name: "foo", specificity: { a: 0, b: 1, c: 0 } }, + { type: "class", name: "bar", specificity: { a: 0, b: 1, c: 0 } }, ], + specificity: { a: 0, b: 1, c: 0 }, }); }); diff --git a/packages/alfa-selector/test/pseudo-class.spec.tsx b/packages/alfa-selector/test/pseudo-class.spec.tsx index 715d66eee5..51b3be6d4f 100644 --- a/packages/alfa-selector/test/pseudo-class.spec.tsx +++ b/packages/alfa-selector/test/pseudo-class.spec.tsx @@ -7,6 +7,7 @@ test(".parse() parses a named pseudo-class selector", (t) => { t.deepEqual(serialize(":hover"), { type: "pseudo-class", name: "hover", + specificity: { a: 0, b: 1, c: 0 }, }); }); @@ -14,6 +15,7 @@ test(".parse() parses :host pseudo-class selector", (t) => { t.deepEqual(serialize(":host"), { type: "pseudo-class", name: "host", + specificity: { a: 0, b: 1, c: 0 }, }); }); @@ -24,7 +26,9 @@ test(".parse() parses a functional pseudo-class selector", (t) => { selector: { type: "class", name: "foo", + specificity: { a: 0, b: 1, c: 0 }, }, + specificity: { a: 0, b: 1, c: 0 }, }); }); @@ -334,5 +338,6 @@ test(".parse() parses an :nth-child selector", (t) => { step: 2, offset: 1, }, + specificity: { a: 0, b: 1, c: 0 }, }); }); diff --git a/packages/alfa-selector/test/pseudo-element.spec.ts b/packages/alfa-selector/test/pseudo-element.spec.ts index c1f1fae93c..c28ed7ae7f 100644 --- a/packages/alfa-selector/test/pseudo-element.spec.ts +++ b/packages/alfa-selector/test/pseudo-element.spec.ts @@ -6,11 +6,13 @@ test(".parse() parses a pseudo-element selector", (t) => { t.deepEqual(serialize("::before"), { type: "pseudo-element", name: "before", + specificity: { a: 0, b: 0, c: 1 }, }); t.deepEqual(serialize(":before"), { type: "pseudo-element", name: "before", + specificity: { a: 0, b: 0, c: 1 }, }); }); @@ -24,8 +26,13 @@ test(`.parse() parses ::cue both as functional and non-functional selector`, (t) name: "cue", selector: { type: "some", - value: { type: "universal", namespace: null }, + value: { + type: "universal", + namespace: null, + specificity: { a: 0, b: 0, c: 0 }, + }, }, + specificity: { a: 0, b: 0, c: 1 }, }); t.deepEqual(serialize("::cue"), { @@ -34,6 +41,7 @@ test(`.parse() parses ::cue both as functional and non-functional selector`, (t) selector: { type: "none", }, + specificity: { a: 0, b: 0, c: 1 }, }); }); diff --git a/packages/alfa-selector/test/specificity.spec.ts b/packages/alfa-selector/test/specificity.spec.ts new file mode 100644 index 0000000000..83126025bf --- /dev/null +++ b/packages/alfa-selector/test/specificity.spec.ts @@ -0,0 +1,91 @@ +import { RNG, test } from "@siteimprove/alfa-test"; + +import { Specificity } from "../src/specificity"; +import { parse } from "./parser"; + +function wrapper(rng: RNG): RNG { + // The max value for specificity components is 1024. Picking a "weird" + // number that is slightly smaller than half to avoid overflows. + return () => Math.floor(rng() * 487); +} + +const controller = { iterations: 10, wrapper }; + +test( + ".add() adds two specificities components-wise", + (t, rng, seed) => { + const [a1, b1, c1] = [rng(), rng(), rng()]; + const [a2, b2, c2] = [rng(), rng(), rng()]; + + t.deepEqual( + Specificity.sum(Specificity.of(a1, b1, c1), Specificity.of(a2, b2, c2)), + Specificity.of(a1 + a2, b1 + b2, c1 + c2), + `Problem with adding two specificities with seed ${seed} and ${controller.iterations} iterations`, + ); + }, + controller, +); + +test( + ".max() maxes two specificities lexicographically", + (t, rng, seed) => { + const [a1, b1, c1] = [rng(), rng(), rng()]; + const [a2, b2, c2] = [rng(), rng(), rng()]; + + const specificity1 = Specificity.of(a1, b1, c1); + const specificity2 = Specificity.of(a2, b2, c2); + + const max = + a1 > a2 + ? specificity1 + : a2 > a1 + ? specificity2 + : b1 > b2 + ? specificity1 + : b2 > b1 + ? specificity2 + : c1 > c2 + ? specificity1 + : specificity2; + + t.deepEqual( + Specificity.max(specificity1, specificity2), + max, + `Problem with maxing two specificities with seed ${seed} and ${controller.iterations} iterations`, + ); + }, + controller, +); + +test("Specificity of :is is correctly computed", (t) => { + for (const [selector, a, b, c] of [ + [".foo, #bar.baz", 1, 1, 0], + ["em, #foo", 1, 0, 0], + ] as const) { + const actual = parse(`:is(${selector})`).specificity; + + t.deepEqual(actual, Specificity.of(a, b, c), selector); + } +}); + +test("Specificity of :not is correctly computed", (t) => { + for (const [selector, a, b, c] of [ + [".foo, #bar.baz", 1, 1, 0], + ["em, strong#foo", 1, 0, 1], + ] as const) { + const actual = parse(`:not(${selector})`).specificity; + + t.deepEqual(actual, Specificity.of(a, b, c), selector); + } +}); + +test("Specificity of :has is correctly computed", (t) => { + for (const [selector, a, b, c] of [ + [".foo, #bar.baz", 1, 1, 0], + ["em, strong#foo", 1, 0, 1], + ] as const) { + const actual = parse(`:has(${selector})`).specificity; + + t.deepEqual(actual, Specificity.of(a, b, c), selector); + } +}); diff --git a/packages/alfa-selector/tsconfig.json b/packages/alfa-selector/tsconfig.json index d3be05b95e..082d8cfa0b 100644 --- a/packages/alfa-selector/tsconfig.json +++ b/packages/alfa-selector/tsconfig.json @@ -67,13 +67,15 @@ "src/selector/simple/pseudo-element/slotted.ts", "src/selector/simple/pseudo-element/spelling-error.ts", "src/selector/simple/pseudo-element/target-text.ts", + "src/specificity.ts", "test/parser.ts", "test/basic.spec.ts", "test/complex.spec.ts", "test/compound.spec.ts", "test/list.spec.ts", "test/pseudo-class.spec.tsx", - "test/pseudo-element.spec.ts" + "test/pseudo-element.spec.ts", + "test/specificity.spec.ts" ], "references": [ { "path": "../alfa-array" }, @@ -81,6 +83,7 @@ { "path": "../alfa-css" }, { "path": "../alfa-dom" }, { "path": "../alfa-equatable" }, + { "path": "../alfa-hash" }, { "path": "../alfa-iterable" }, { "path": "../alfa-json" }, { "path": "../alfa-map" }, diff --git a/packages/alfa-test/README.md b/packages/alfa-test/README.md new file mode 100644 index 0000000000..ec0928f7ce --- /dev/null +++ b/packages/alfa-test/README.md @@ -0,0 +1,63 @@ +# Alfa test + +Thanks to the referential transparency ensured by [ADR 6](../../docs/architecture/decisions/adr-006.md), unit test of Alfa code is usually very easy, simply comparing the actual result with the expected one (often as their serialisation), without need for complex setup, mocks, or other test tricks. + +We're therefore implementing a very lightweight wrapper for tests. + +```typescript +import { test } from "@siteimprove/alfa-test"; + +test("My test", (t) => { + const actual = …; + + t.deepEqual(actual.toJSON(), { type: …, …}) +}); +``` + +## Property testing + +Sometimes, it is convenient to generate random tests with random values. The `alfa-test` library is offering test controller to handle that. + +- The `assertion` function that is passed to `test(name, assertion)` receives additional `rng` and `seed` parameters. The `rng` is a function `() => number`. The `seed` was used to initialize the Random Number Generator, can be used for better displaying errors and for re-playability. +- The `test` function itself accepts an optional `Controller` object which can be used to set the `seed` for the RNG, or to change the number of `iterations` to run the test (default to 1 since most tests are not random tests). The `Controller` object also accepts a `wrapper` function of type `(iteration: number, rng: RNG) => RNG` that can be used to turn the random numbers into useful data, or for introspection. + +The provided `rng` function is guaranteed to generate the same sequence of numbers on sequential calls, if the same seed is provided by the controller. If no seed is provided, a random one will be used. + +By default, each test is only run once. Use the `Controller` object to change the number of iterations. + +Tests that make use of the RNG are encouraged to print the seed in their error message in order to allow re-playability and investigation by feeding the failing seed back to the test. + +For re-playability, use the `Controller` parameter to select the seed to use (which guarantees the exact same sequence of numbers is produced), and to introspect on fine details by wrapping the RNG, e.g., + +```typescript +/** + * Return a random string between "0" and "100" (inclusive). + * Print the generated number, as well as the iteration number (use for debugging). + */ +function wrapper(rng: RNG, iteration: number): RNG { + return () => { + const res = rng(); + console.log(`On iteration ${iteration}, I generated ${res}`); + return `${res * 100}`; + }; +} + +test( + "Sum computes the sum of two numbers represented as strings", + (t, rng, seed) => { + // These use the post-wrapper RNG. + const a = rng(); + const b = rng(); + // Print the seed in error message to allow introspection. + const actual = sum(a, b, `Failed with seed ${seed}`); + + t.deepEqual(actual, `${a + b}`); + }, + { + wrapper, + iterations: 100, + // Set the seed for debugging, if you want to replay the same sequence of numbers. + seed: 1234, + }, +); +``` diff --git a/packages/alfa-test/src/index.ts b/packages/alfa-test/src/index.ts index 81524479d4..b0040d8caf 100644 --- a/packages/alfa-test/src/index.ts +++ b/packages/alfa-test/src/index.ts @@ -1,4 +1,5 @@ export * from "./format"; +export * from "./rng"; export * from "./stack"; export * from "./test"; export * from "./types"; diff --git a/packages/alfa-test/src/rng.ts b/packages/alfa-test/src/rng.ts new file mode 100644 index 0000000000..1b13fe43f5 --- /dev/null +++ b/packages/alfa-test/src/rng.ts @@ -0,0 +1,36 @@ +/** + * @public + */ +export type RNG = () => T; + +/** + * PRNG taken from + * {@link https://stackoverflow.com/questions/521295/seeding-the-random-number-generator-in-javascript} + * + * @internal + */ +export function seedableRNG(seed: number): RNG { + return function () { + let t = (seed += 0x6d2b79f5); + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** + * @public + */ +export interface Controller { + iterations: number; + wrapper: (rng: RNG, iteration: number) => RNG; + seed?: number; +} + +/** + * @internal + */ +export const defaultController: Controller = { + iterations: 1, + wrapper: (rng, iteration: number) => rng, +}; diff --git a/packages/alfa-test/src/test.ts b/packages/alfa-test/src/test.ts index 94ec05a10e..e723cc6857 100644 --- a/packages/alfa-test/src/test.ts +++ b/packages/alfa-test/src/test.ts @@ -3,6 +3,7 @@ import * as assert from "assert"; import { format } from "./format"; +import { Controller, defaultController, RNG, seedableRNG } from "./rng"; import { Assertions } from "./types"; /** @@ -19,30 +20,76 @@ const defaultNotifier: Notifier = { }, }; +// This is not super robust, but sufficient in our use case. +// Take care before using it elsewhere. +function isNotifier(value: unknown): value is Notifier { + return typeof value === "object" && value !== null && "error" in value; +} + /** * @public */ -export async function test( +export async function test( name: string, - assertion: (assert: Assertions) => void | Promise, + assertion: ( + assert: Assertions, + rng: RNG, + seed: number, + ) => void | Promise, + controller?: Partial>, ): Promise; /** * @internal */ -export async function test( +export async function test( name: string, - assertion: (assert: Assertions) => void | Promise, + assertion: ( + assert: Assertions, + rng: RNG, + seed: number, + ) => void | Promise, notifier: Notifier, + controller?: Partial>, ): Promise; -export async function test( +export async function test( name: string, - assertion: (assert: Assertions) => void | Promise, - notifier = defaultNotifier, + assertion: ( + assert: Assertions, + rng: RNG, + seed: number, + ) => void | Promise, + notifierOrController?: Notifier | Partial>, + controller?: Partial>, ): Promise { + const notifier: Notifier = isNotifier(notifierOrController) + ? notifierOrController + : defaultNotifier; + // If the controlled is not overwritten, then T should be number. + const fullController = { + ...defaultController, + ...controller, + ...notifierOrController, + } as Controller; + // "error" may have been copied over from the notifier. + if ("error" in fullController) { + delete fullController.error; + } + + const seed = fullController.seed ?? Math.random(); + const rng = seedableRNG(seed); + try { - await assertion("strict" in assert ? assert.strict : assert); + for (let i = 0; i < fullController.iterations; i++) { + await assertion( + "strict" in assert ? assert.strict : assert, + // eta-expansion ensures that the wrapper is evaluated on each call of + // the rng, not just once per iteration. + () => fullController.wrapper(rng, i)(), + seed, + ); + } } catch (err) { const error = err as Error; diff --git a/packages/alfa-test/tsconfig.json b/packages/alfa-test/tsconfig.json index bef789ee92..d099f9126e 100644 --- a/packages/alfa-test/tsconfig.json +++ b/packages/alfa-test/tsconfig.json @@ -4,6 +4,7 @@ "files": [ "src/format.ts", "src/index.ts", + "src/rng.ts", "src/stack.ts", "src/test.ts", "src/types.ts", diff --git a/yarn.lock b/yarn.lock index 4276812773..80138e76d2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1582,6 +1582,7 @@ __metadata: "@siteimprove/alfa-css": "workspace:^0.69.0" "@siteimprove/alfa-dom": "workspace:^0.69.0" "@siteimprove/alfa-equatable": "workspace:^0.69.0" + "@siteimprove/alfa-hash": "workspace:^0.69.0" "@siteimprove/alfa-iterable": "workspace:^0.69.0" "@siteimprove/alfa-json": "workspace:^0.69.0" "@siteimprove/alfa-map": "workspace:^0.69.0"