Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Compute selector specificity at parse time. #1514

Merged
merged 16 commits into from
Nov 29, 2023
5 changes: 5 additions & 0 deletions .changeset/eleven-shrimps-tap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@siteimprove/alfa-selector": minor
---

**Added:** `Selector` now contain their own `Specificity`.
26 changes: 26 additions & 0 deletions .changeset/rude-rivers-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"@siteimprove/alfa-test": minor
---

**Added:** Test can now accept a `Controller` to generate random tests.

- 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 one). The `Controller` object also accepts a `wrapper` function of type `(iteration: number, rng: RNG) => RNG` that can be used 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
function wrapper(iteration: number, rng: RNG): RNG {
return () => {
const res = rng();
console.log(`On iteration ${iteration}, I generated ${res}`);
return res;
}
}
```
2 changes: 2 additions & 0 deletions docs/review/api/alfa-selector.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
27 changes: 25 additions & 2 deletions docs/review/api/alfa-test.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: (iteration: number, rng: RNG) => 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)
Expand All @@ -37,6 +52,14 @@ export interface Notifier {
error(message: string): void;
}

// @public (undocumented)
export type RNG = () => number;

// 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
//
Expand All @@ -46,10 +69,10 @@ export function stack(error: Error): Iterable<Frame>;
// 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<void>): Promise<void>;
export function test(name: string, assertion: (assert: Assertions, rng: RNG, seed: number) => void | Promise<void>, controller?: Partial<Controller>): Promise<void>;

// @internal (undocumented)
export function test(name: string, assertion: (assert: Assertions) => void | Promise<void>, notifier: Notifier): Promise<void>;
export function test(name: string, assertion: (assert: Assertions, rng: RNG, seed: number) => void | Promise<void>, notifier: Notifier, controller?: Partial<Controller>): Promise<void>;

// (No @packageDocumentation comment for this package)

Expand Down
65 changes: 5 additions & 60 deletions packages/alfa-cascade/src/selector-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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> = [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.
*/
Expand Down
56 changes: 7 additions & 49 deletions packages/alfa-map/test/property.spec.ts
Original file line number Diff line number Diff line change
@@ -1,55 +1,14 @@
import { test } from "@siteimprove/alfa-test";
import { Assertions, RNG, seedableRNG, test } from "@siteimprove/alfa-test";
Fixed Show fixed Hide fixed

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;
}
Expand Down Expand Up @@ -80,17 +39,16 @@
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);
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;
const keyGen = randKey(rng);

let map: Map<Key, boolean> = Map.empty();
const keys: Array<Key> = [];

// Adding elements
for (let i = 0; i < iterations; i++) {
for (let i = 0; i < size; i++) {
const key = keyGen();

t.deepEqual(map.size, i, `Pre-add map.size() fails with seed ${seed}`);
Expand Down Expand Up @@ -125,8 +83,8 @@
// 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];
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),
Expand Down
3 changes: 2 additions & 1 deletion packages/alfa-selector/src/selector/complex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 5 additions & 1 deletion packages/alfa-selector/src/selector/compound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -27,7 +28,10 @@ export class Compound extends Selector<"compound"> {
private readonly _length: number;

private constructor(selectors: Array<Simple>) {
super("compound");
super(
"compound",
Specificity.sum(...selectors.map((selector) => selector.specificity)),
);
this._selectors = selectors;
this._length = selectors.length;
}
Expand Down
6 changes: 5 additions & 1 deletion packages/alfa-selector/src/selector/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -34,7 +35,10 @@ export class List<T extends Item = Item> extends Selector<"list"> {
private readonly _length: number;

private constructor(selectors: Array<T>) {
super("list");
super(
"list",
Specificity.max(...selectors.map((selector) => selector.specificity)),
);
this._selectors = selectors;
this._length = selectors.length;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/alfa-selector/src/selector/relative.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
21 changes: 17 additions & 4 deletions packages/alfa-selector/src/selector/selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -22,16 +23,22 @@ export abstract class Selector<T extends string = string>
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) */
public get type(): T {
return this._type;
}

public get specificity(): Specificity {
return this._specificity;
}

/**
* {@link https://drafts.csswg.org/selectors/#match}
*/
Expand All @@ -42,7 +49,11 @@ export abstract class Selector<T extends string = string>
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) */
Expand All @@ -53,6 +64,7 @@ export abstract class Selector<T extends string = string>
public toJSON(): Selector.JSON<T> {
return {
type: this._type,
specificity: this._specificity.toJSON(),
};
}
}
Expand All @@ -62,6 +74,7 @@ export namespace Selector {
[key: string]: json.JSON;

type: T;
specificity: Specificity.JSON;
}
}

Expand All @@ -79,8 +92,8 @@ export abstract class WithName<
N extends string = string,
> extends Selector<T> {
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;
}

Expand Down
Loading
Loading