@flighthq/capture 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/captureBaseline.d.ts +24 -0
- package/dist/captureBaseline.d.ts.map +1 -0
- package/dist/captureBaseline.js +52 -0
- package/dist/captureBaseline.js.map +1 -0
- package/dist/captureComparison.d.ts +26 -0
- package/dist/captureComparison.d.ts.map +1 -0
- package/dist/captureComparison.js +43 -0
- package/dist/captureComparison.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/package.json +38 -0
- package/src/captureBaseline.test.ts +112 -0
- package/src/captureComparison.test.ts +96 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { CaptureBaseline, CaptureColumnBaseline } from '@flighthq/types';
|
|
2
|
+
/** Allocates an empty baseline record. Columns are added via setCaptureBaselineField. */
|
|
3
|
+
export declare function createCaptureBaseline(): CaptureBaseline;
|
|
4
|
+
/**
|
|
5
|
+
* Serializes a baseline to its committed text form: JSON with columns in sorted key order, each
|
|
6
|
+
* column's fields in canonical `fingerprint` then `sha256` order, 2-space indent, and a trailing
|
|
7
|
+
* newline. Matches the tooling's on-disk baseline store byte-for-byte, so a re-baseline of one column
|
|
8
|
+
* produces a minimal diff and the format gate stays green. Only defined fields are emitted.
|
|
9
|
+
*/
|
|
10
|
+
export declare function formatCaptureBaseline(baseline: Readonly<CaptureBaseline>): string;
|
|
11
|
+
/**
|
|
12
|
+
* The value of one column's field, or `null` when the column or field is absent. `field` is
|
|
13
|
+
* `'fingerprint'` or `'sha256'`.
|
|
14
|
+
*/
|
|
15
|
+
export declare function getCaptureBaselineField(baseline: Readonly<CaptureBaseline>, column: string, field: keyof CaptureColumnBaseline): string | null;
|
|
16
|
+
/**
|
|
17
|
+
* Parses the text form produced by formatCaptureBaseline. Returns `null` for malformed input — invalid
|
|
18
|
+
* JSON, or a top-level value that is not a plain object — so a corrupt baseline reads as "no baseline"
|
|
19
|
+
* rather than crashing.
|
|
20
|
+
*/
|
|
21
|
+
export declare function parseCaptureBaseline(text: string): CaptureBaseline | null;
|
|
22
|
+
/** Sets one column's field to `value`, creating the column entry if it does not yet exist. */
|
|
23
|
+
export declare function setCaptureBaselineField(baseline: CaptureBaseline, column: string, field: keyof CaptureColumnBaseline, value: string): void;
|
|
24
|
+
//# sourceMappingURL=captureBaseline.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"captureBaseline.d.ts","sourceRoot":"","sources":["../src/captureBaseline.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AAE9E,yFAAyF;AACzF,wBAAgB,qBAAqB,IAAI,eAAe,CAEvD;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,QAAQ,CAAC,eAAe,CAAC,GAAG,MAAM,CAUjF;AAED;;;GAGG;AACH,wBAAgB,uBAAuB,CACrC,QAAQ,EAAE,QAAQ,CAAC,eAAe,CAAC,EACnC,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,MAAM,qBAAqB,GACjC,MAAM,GAAG,IAAI,CAEf;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,eAAe,GAAG,IAAI,CASzE;AAED,8FAA8F;AAC9F,wBAAgB,uBAAuB,CACrC,QAAQ,EAAE,eAAe,EACzB,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,MAAM,qBAAqB,EAClC,KAAK,EAAE,MAAM,GACZ,IAAI,CAEN"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** Allocates an empty baseline record. Columns are added via setCaptureBaselineField. */
|
|
2
|
+
export function createCaptureBaseline() {
|
|
3
|
+
return {};
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Serializes a baseline to its committed text form: JSON with columns in sorted key order, each
|
|
7
|
+
* column's fields in canonical `fingerprint` then `sha256` order, 2-space indent, and a trailing
|
|
8
|
+
* newline. Matches the tooling's on-disk baseline store byte-for-byte, so a re-baseline of one column
|
|
9
|
+
* produces a minimal diff and the format gate stays green. Only defined fields are emitted.
|
|
10
|
+
*/
|
|
11
|
+
export function formatCaptureBaseline(baseline) {
|
|
12
|
+
const sorted = {};
|
|
13
|
+
for (const column of Object.keys(baseline).sort()) {
|
|
14
|
+
const entry = baseline[column];
|
|
15
|
+
const out = {};
|
|
16
|
+
if (entry.fingerprint !== undefined)
|
|
17
|
+
out.fingerprint = entry.fingerprint;
|
|
18
|
+
if (entry.sha256 !== undefined)
|
|
19
|
+
out.sha256 = entry.sha256;
|
|
20
|
+
sorted[column] = out;
|
|
21
|
+
}
|
|
22
|
+
return JSON.stringify(sorted, null, 2) + '\n';
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The value of one column's field, or `null` when the column or field is absent. `field` is
|
|
26
|
+
* `'fingerprint'` or `'sha256'`.
|
|
27
|
+
*/
|
|
28
|
+
export function getCaptureBaselineField(baseline, column, field) {
|
|
29
|
+
return baseline[column]?.[field] ?? null;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Parses the text form produced by formatCaptureBaseline. Returns `null` for malformed input — invalid
|
|
33
|
+
* JSON, or a top-level value that is not a plain object — so a corrupt baseline reads as "no baseline"
|
|
34
|
+
* rather than crashing.
|
|
35
|
+
*/
|
|
36
|
+
export function parseCaptureBaseline(text) {
|
|
37
|
+
let parsed;
|
|
38
|
+
try {
|
|
39
|
+
parsed = JSON.parse(text);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
45
|
+
return null;
|
|
46
|
+
return parsed;
|
|
47
|
+
}
|
|
48
|
+
/** Sets one column's field to `value`, creating the column entry if it does not yet exist. */
|
|
49
|
+
export function setCaptureBaselineField(baseline, column, field, value) {
|
|
50
|
+
(baseline[column] ??= {})[field] = value;
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=captureBaseline.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"captureBaseline.js","sourceRoot":"","sources":["../src/captureBaseline.ts"],"names":[],"mappings":"AAEA,yFAAyF;AACzF,MAAM,UAAU,qBAAqB;IACnC,OAAO,EAAE,CAAC;AACZ,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,qBAAqB,CAAC,QAAmC;IACvE,MAAM,MAAM,GAAoB,EAAE,CAAC;IACnC,KAAK,MAAM,MAAM,IAAI,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC;QAClD,MAAM,KAAK,GAAG,QAAQ,CAAC,MAAM,CAAC,CAAC;QAC/B,MAAM,GAAG,GAA0B,EAAE,CAAC;QACtC,IAAI,KAAK,CAAC,WAAW,KAAK,SAAS;YAAE,GAAG,CAAC,WAAW,GAAG,KAAK,CAAC,WAAW,CAAC;QACzE,IAAI,KAAK,CAAC,MAAM,KAAK,SAAS;YAAE,GAAG,CAAC,MAAM,GAAG,KAAK,CAAC,MAAM,CAAC;QAC1D,MAAM,CAAC,MAAM,CAAC,GAAG,GAAG,CAAC;IACvB,CAAC;IACD,OAAO,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,GAAG,IAAI,CAAC;AAChD,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,uBAAuB,CACrC,QAAmC,EACnC,MAAc,EACd,KAAkC;IAElC,OAAO,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,KAAK,CAAC,IAAI,IAAI,CAAC;AAC3C,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,oBAAoB,CAAC,IAAY;IAC/C,IAAI,MAAe,CAAC;IACpB,IAAI,CAAC;QACH,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IAC5B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;IACD,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,MAAM,KAAK,IAAI,IAAI,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC;QAAE,OAAO,IAAI,CAAC;IACxF,OAAO,MAAyB,CAAC;AACnC,CAAC;AAED,8FAA8F;AAC9F,MAAM,UAAU,uBAAuB,CACrC,QAAyB,EACzB,MAAc,EACd,KAAkC,EAClC,KAAa;IAEb,CAAC,QAAQ,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,KAAK,CAAC;AAC3C,CAAC"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { CaptureCheckResult } from '@flighthq/types';
|
|
2
|
+
export declare const CAPTURE_PARITY_TOLERANCE = 15;
|
|
3
|
+
export declare const CAPTURE_REGRESSION_TOLERANCE = 5;
|
|
4
|
+
/**
|
|
5
|
+
* The tolerant distance between two coarse render fingerprints in `<gridSize>:<hex>` text form (see
|
|
6
|
+
* formatSurfaceFingerprint): the mean absolute per-channel difference (0..255), where ~0 is identical
|
|
7
|
+
* and larger values mean a real visual change. Returns `Number.POSITIVE_INFINITY` as a sentinel when
|
|
8
|
+
* either string fails to parse, or when the two fingerprints use different grid sizes and so are not
|
|
9
|
+
* comparable — Infinity fails any finite tolerance, so a corrupt or mismatched baseline reads as a
|
|
10
|
+
* failing check rather than crashing.
|
|
11
|
+
*/
|
|
12
|
+
export declare function compareCaptureFingerprints(a: string, b: string): number;
|
|
13
|
+
/**
|
|
14
|
+
* Parity: whether two render backends rendering the same scene in the same run agree within
|
|
15
|
+
* `tolerance`. Environment-independent — it needs no committed baseline, only the two live
|
|
16
|
+
* fingerprints. `difference` is the tolerant distance (Infinity when either fingerprint is
|
|
17
|
+
* unparseable); `pass` is `difference <= tolerance`.
|
|
18
|
+
*/
|
|
19
|
+
export declare function evaluateCaptureParity(a: string, b: string, tolerance?: number): CaptureCheckResult;
|
|
20
|
+
/**
|
|
21
|
+
* Regression: whether a freshly captured `fingerprint` still matches its own committed
|
|
22
|
+
* `baselineFingerprint` within `tolerance`. `difference` is the tolerant distance (Infinity when
|
|
23
|
+
* either fingerprint is unparseable); `pass` is `difference <= tolerance`.
|
|
24
|
+
*/
|
|
25
|
+
export declare function evaluateCaptureRegression(fingerprint: string, baselineFingerprint: string, tolerance?: number): CaptureCheckResult;
|
|
26
|
+
//# sourceMappingURL=captureComparison.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"captureComparison.d.ts","sourceRoot":"","sources":["../src/captureComparison.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAC;AAI1D,eAAO,MAAM,wBAAwB,KAAK,CAAC;AAK3C,eAAO,MAAM,4BAA4B,IAAI,CAAC;AAE9C;;;;;;;GAOG;AACH,wBAAgB,0BAA0B,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAKvE;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,EAAE,SAAS,SAA2B,GAAG,kBAAkB,CAGpH;AAED;;;;GAIG;AACH,wBAAgB,yBAAyB,CACvC,WAAW,EAAE,MAAM,EACnB,mBAAmB,EAAE,MAAM,EAC3B,SAAS,SAA+B,GACvC,kBAAkB,CAGpB"}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { compareSurfaceFingerprints, parseSurfaceFingerprint } from '@flighthq/surface';
|
|
2
|
+
// Default parity tolerance (mean absolute per-channel difference, 0..255). Cross-backend agreement is
|
|
3
|
+
// ≤ ~6.5 even for antialiasing-heavy scenes, so real divergence between two backends is well over 15.
|
|
4
|
+
export const CAPTURE_PARITY_TOLERANCE = 15;
|
|
5
|
+
// Default regression tolerance (mean absolute per-channel difference, 0..255). Same-backend run-to-run
|
|
6
|
+
// noise for a stable test is well under 5, so a target that drifts past this against its own committed
|
|
7
|
+
// baseline is a real visual regression, not antialiasing jitter.
|
|
8
|
+
export const CAPTURE_REGRESSION_TOLERANCE = 5;
|
|
9
|
+
/**
|
|
10
|
+
* The tolerant distance between two coarse render fingerprints in `<gridSize>:<hex>` text form (see
|
|
11
|
+
* formatSurfaceFingerprint): the mean absolute per-channel difference (0..255), where ~0 is identical
|
|
12
|
+
* and larger values mean a real visual change. Returns `Number.POSITIVE_INFINITY` as a sentinel when
|
|
13
|
+
* either string fails to parse, or when the two fingerprints use different grid sizes and so are not
|
|
14
|
+
* comparable — Infinity fails any finite tolerance, so a corrupt or mismatched baseline reads as a
|
|
15
|
+
* failing check rather than crashing.
|
|
16
|
+
*/
|
|
17
|
+
export function compareCaptureFingerprints(a, b) {
|
|
18
|
+
const fa = parseSurfaceFingerprint(a);
|
|
19
|
+
const fb = parseSurfaceFingerprint(b);
|
|
20
|
+
if (fa === null || fb === null || fa.gridSize !== fb.gridSize)
|
|
21
|
+
return Number.POSITIVE_INFINITY;
|
|
22
|
+
return compareSurfaceFingerprints(fa, fb);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Parity: whether two render backends rendering the same scene in the same run agree within
|
|
26
|
+
* `tolerance`. Environment-independent — it needs no committed baseline, only the two live
|
|
27
|
+
* fingerprints. `difference` is the tolerant distance (Infinity when either fingerprint is
|
|
28
|
+
* unparseable); `pass` is `difference <= tolerance`.
|
|
29
|
+
*/
|
|
30
|
+
export function evaluateCaptureParity(a, b, tolerance = CAPTURE_PARITY_TOLERANCE) {
|
|
31
|
+
const difference = compareCaptureFingerprints(a, b);
|
|
32
|
+
return { pass: difference <= tolerance, difference, tolerance };
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Regression: whether a freshly captured `fingerprint` still matches its own committed
|
|
36
|
+
* `baselineFingerprint` within `tolerance`. `difference` is the tolerant distance (Infinity when
|
|
37
|
+
* either fingerprint is unparseable); `pass` is `difference <= tolerance`.
|
|
38
|
+
*/
|
|
39
|
+
export function evaluateCaptureRegression(fingerprint, baselineFingerprint, tolerance = CAPTURE_REGRESSION_TOLERANCE) {
|
|
40
|
+
const difference = compareCaptureFingerprints(fingerprint, baselineFingerprint);
|
|
41
|
+
return { pass: difference <= tolerance, difference, tolerance };
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=captureComparison.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"captureComparison.js","sourceRoot":"","sources":["../src/captureComparison.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,0BAA0B,EAAE,uBAAuB,EAAE,MAAM,mBAAmB,CAAC;AAGxF,sGAAsG;AACtG,sGAAsG;AACtG,MAAM,CAAC,MAAM,wBAAwB,GAAG,EAAE,CAAC;AAE3C,uGAAuG;AACvG,uGAAuG;AACvG,iEAAiE;AACjE,MAAM,CAAC,MAAM,4BAA4B,GAAG,CAAC,CAAC;AAE9C;;;;;;;GAOG;AACH,MAAM,UAAU,0BAA0B,CAAC,CAAS,EAAE,CAAS;IAC7D,MAAM,EAAE,GAAG,uBAAuB,CAAC,CAAC,CAAC,CAAC;IACtC,MAAM,EAAE,GAAG,uBAAuB,CAAC,CAAC,CAAC,CAAC;IACtC,IAAI,EAAE,KAAK,IAAI,IAAI,EAAE,KAAK,IAAI,IAAI,EAAE,CAAC,QAAQ,KAAK,EAAE,CAAC,QAAQ;QAAE,OAAO,MAAM,CAAC,iBAAiB,CAAC;IAC/F,OAAO,0BAA0B,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC;AAC5C,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,qBAAqB,CAAC,CAAS,EAAE,CAAS,EAAE,SAAS,GAAG,wBAAwB;IAC9F,MAAM,UAAU,GAAG,0BAA0B,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IACpD,OAAO,EAAE,IAAI,EAAE,UAAU,IAAI,SAAS,EAAE,UAAU,EAAE,SAAS,EAAE,CAAC;AAClE,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,yBAAyB,CACvC,WAAmB,EACnB,mBAA2B,EAC3B,SAAS,GAAG,4BAA4B;IAExC,MAAM,UAAU,GAAG,0BAA0B,CAAC,WAAW,EAAE,mBAAmB,CAAC,CAAC;IAChF,OAAO,EAAE,IAAI,EAAE,UAAU,IAAI,SAAS,EAAE,UAAU,EAAE,SAAS,EAAE,CAAC;AAClE,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,mBAAmB,CAAC;AAClC,cAAc,qBAAqB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,mBAAmB,CAAC;AAClC,cAAc,qBAAqB,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@flighthq/capture",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"src/**/*.test.ts",
|
|
16
|
+
"!dist/**/*.test.js",
|
|
17
|
+
"!dist/**/*.test.d.ts",
|
|
18
|
+
"!dist/**/*.test.js.map",
|
|
19
|
+
"!dist/**/*.test.d.ts.map"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc -b",
|
|
23
|
+
"clean": "tsc -b --clean",
|
|
24
|
+
"test": "vitest run --config vitest.config.ts",
|
|
25
|
+
"test:watch": "vitest --watch --config vitest.config.ts",
|
|
26
|
+
"prepack": "npm run clean && npm run clean:dist && npm run build",
|
|
27
|
+
"clean:dist": "tsx ../../scripts/clean-package-dist.ts"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@flighthq/surface": "0.1.0",
|
|
31
|
+
"@flighthq/types": "0.1.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"typescript": "^5.3.0"
|
|
35
|
+
},
|
|
36
|
+
"description": "Deterministic render capture verification policy and baseline format — tolerant fingerprint comparison and the committed baseline store shape",
|
|
37
|
+
"sideEffects": false
|
|
38
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createCaptureBaseline,
|
|
5
|
+
formatCaptureBaseline,
|
|
6
|
+
getCaptureBaselineField,
|
|
7
|
+
parseCaptureBaseline,
|
|
8
|
+
setCaptureBaselineField,
|
|
9
|
+
} from './captureBaseline';
|
|
10
|
+
|
|
11
|
+
describe('createCaptureBaseline', () => {
|
|
12
|
+
it('allocates an empty record', () => {
|
|
13
|
+
const baseline = createCaptureBaseline();
|
|
14
|
+
expect(baseline).toEqual({});
|
|
15
|
+
expect(getCaptureBaselineField(baseline, 'canvas', 'fingerprint')).toBeNull();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('allocates a distinct record each call', () => {
|
|
19
|
+
const a = createCaptureBaseline();
|
|
20
|
+
const b = createCaptureBaseline();
|
|
21
|
+
setCaptureBaselineField(a, 'canvas', 'fingerprint', '1:000000');
|
|
22
|
+
expect(getCaptureBaselineField(b, 'canvas', 'fingerprint')).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('formatCaptureBaseline', () => {
|
|
27
|
+
it('sorts columns, orders fields, indents by two spaces, and ends with a newline', () => {
|
|
28
|
+
const baseline = createCaptureBaseline();
|
|
29
|
+
setCaptureBaselineField(baseline, 'flight:webgl', 'sha256', 'aaa');
|
|
30
|
+
setCaptureBaselineField(baseline, 'flight:webgl', 'fingerprint', '1:ffffff');
|
|
31
|
+
setCaptureBaselineField(baseline, 'canvas', 'fingerprint', '1:000000');
|
|
32
|
+
expect(formatCaptureBaseline(baseline)).toBe(
|
|
33
|
+
'{\n' +
|
|
34
|
+
' "canvas": {\n' +
|
|
35
|
+
' "fingerprint": "1:000000"\n' +
|
|
36
|
+
' },\n' +
|
|
37
|
+
' "flight:webgl": {\n' +
|
|
38
|
+
' "fingerprint": "1:ffffff",\n' +
|
|
39
|
+
' "sha256": "aaa"\n' +
|
|
40
|
+
' }\n' +
|
|
41
|
+
'}\n',
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('omits undefined fields', () => {
|
|
46
|
+
const baseline = createCaptureBaseline();
|
|
47
|
+
setCaptureBaselineField(baseline, 'canvas', 'sha256', 'hash');
|
|
48
|
+
expect(formatCaptureBaseline(baseline)).toBe('{\n "canvas": {\n "sha256": "hash"\n }\n}\n');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('serializes the empty baseline as an empty object with a trailing newline', () => {
|
|
52
|
+
expect(formatCaptureBaseline(createCaptureBaseline())).toBe('{}\n');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('getCaptureBaselineField', () => {
|
|
57
|
+
it('returns null for absent columns and fields', () => {
|
|
58
|
+
const baseline = createCaptureBaseline();
|
|
59
|
+
setCaptureBaselineField(baseline, 'canvas', 'fingerprint', '1:000000');
|
|
60
|
+
expect(getCaptureBaselineField(baseline, 'canvas', 'sha256')).toBeNull();
|
|
61
|
+
expect(getCaptureBaselineField(baseline, 'webgl', 'fingerprint')).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('reads a set field across independent columns', () => {
|
|
65
|
+
const baseline = createCaptureBaseline();
|
|
66
|
+
setCaptureBaselineField(baseline, 'canvas', 'fingerprint', '1:000000');
|
|
67
|
+
setCaptureBaselineField(baseline, 'webgl', 'fingerprint', '1:ffffff');
|
|
68
|
+
expect(getCaptureBaselineField(baseline, 'canvas', 'fingerprint')).toBe('1:000000');
|
|
69
|
+
expect(getCaptureBaselineField(baseline, 'webgl', 'fingerprint')).toBe('1:ffffff');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('parseCaptureBaseline', () => {
|
|
74
|
+
it('round-trips format output to a stable, equal record', () => {
|
|
75
|
+
const baseline = createCaptureBaseline();
|
|
76
|
+
setCaptureBaselineField(baseline, 'flight:webgl', 'fingerprint', '1:ffffff');
|
|
77
|
+
setCaptureBaselineField(baseline, 'flight:webgl', 'sha256', 'aaa');
|
|
78
|
+
setCaptureBaselineField(baseline, 'canvas', 'fingerprint', '1:000000');
|
|
79
|
+
const text = formatCaptureBaseline(baseline);
|
|
80
|
+
const parsed = parseCaptureBaseline(text);
|
|
81
|
+
expect(parsed).toEqual(baseline);
|
|
82
|
+
expect(parsed).not.toBeNull();
|
|
83
|
+
expect(formatCaptureBaseline(parsed!)).toBe(text);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('returns null for malformed JSON', () => {
|
|
87
|
+
expect(parseCaptureBaseline('{not json')).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('returns null for a non-object top-level value', () => {
|
|
91
|
+
expect(parseCaptureBaseline('42')).toBeNull();
|
|
92
|
+
expect(parseCaptureBaseline('[]')).toBeNull();
|
|
93
|
+
expect(parseCaptureBaseline('null')).toBeNull();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('setCaptureBaselineField', () => {
|
|
98
|
+
it('creates the column entry on first write and merges later fields', () => {
|
|
99
|
+
const baseline = createCaptureBaseline();
|
|
100
|
+
setCaptureBaselineField(baseline, 'canvas', 'fingerprint', '1:000000');
|
|
101
|
+
setCaptureBaselineField(baseline, 'canvas', 'sha256', 'hash');
|
|
102
|
+
expect(getCaptureBaselineField(baseline, 'canvas', 'fingerprint')).toBe('1:000000');
|
|
103
|
+
expect(getCaptureBaselineField(baseline, 'canvas', 'sha256')).toBe('hash');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('overwrites an existing field in place', () => {
|
|
107
|
+
const baseline = createCaptureBaseline();
|
|
108
|
+
setCaptureBaselineField(baseline, 'canvas', 'fingerprint', '1:000000');
|
|
109
|
+
setCaptureBaselineField(baseline, 'canvas', 'fingerprint', '1:ffffff');
|
|
110
|
+
expect(getCaptureBaselineField(baseline, 'canvas', 'fingerprint')).toBe('1:ffffff');
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
CAPTURE_PARITY_TOLERANCE,
|
|
5
|
+
CAPTURE_REGRESSION_TOLERANCE,
|
|
6
|
+
compareCaptureFingerprints,
|
|
7
|
+
evaluateCaptureParity,
|
|
8
|
+
evaluateCaptureRegression,
|
|
9
|
+
} from './captureComparison';
|
|
10
|
+
|
|
11
|
+
// Short 1x1 fingerprints: `1:<rr><gg><bb>`, three RGB bytes as six hex chars. The per-channel mean
|
|
12
|
+
// distance is (|dR| + |dG| + |dB|) / 3, so a single channel offset of N produces a distance of N / 3.
|
|
13
|
+
const BLACK = '1:000000';
|
|
14
|
+
const NUDGE_15 = '1:0f0000'; // dR = 15 -> distance 5 (== regression tolerance)
|
|
15
|
+
const NUDGE_18 = '1:120000'; // dR = 18 -> distance 6 (> regression tolerance)
|
|
16
|
+
const NUDGE_45 = '1:2d0000'; // dR = 45 -> distance 15 (== parity tolerance)
|
|
17
|
+
const NUDGE_48 = '1:300000'; // dR = 48 -> distance 16 (> parity tolerance)
|
|
18
|
+
const WHITE = '1:ffffff'; // distance 255 from black
|
|
19
|
+
|
|
20
|
+
describe('compareCaptureFingerprints', () => {
|
|
21
|
+
it('is 0 for identical fingerprints', () => {
|
|
22
|
+
expect(compareCaptureFingerprints(BLACK, BLACK)).toBe(0);
|
|
23
|
+
expect(compareCaptureFingerprints(WHITE, WHITE)).toBe(0);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('measures the mean absolute per-channel distance', () => {
|
|
27
|
+
expect(compareCaptureFingerprints(BLACK, NUDGE_15)).toBe(5);
|
|
28
|
+
expect(compareCaptureFingerprints(BLACK, WHITE)).toBe(255);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('returns Infinity when either fingerprint is unparseable', () => {
|
|
32
|
+
expect(compareCaptureFingerprints('not-a-fingerprint', BLACK)).toBe(Number.POSITIVE_INFINITY);
|
|
33
|
+
expect(compareCaptureFingerprints(BLACK, '')).toBe(Number.POSITIVE_INFINITY);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('returns Infinity when the grid sizes differ and are not comparable', () => {
|
|
37
|
+
const twoByTwo = '2:' + '00'.repeat(2 * 2 * 3);
|
|
38
|
+
expect(compareCaptureFingerprints(BLACK, twoByTwo)).toBe(Number.POSITIVE_INFINITY);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('evaluateCaptureParity', () => {
|
|
43
|
+
it('defaults to the parity tolerance and passes at the boundary', () => {
|
|
44
|
+
const result = evaluateCaptureParity(BLACK, NUDGE_45);
|
|
45
|
+
expect(result.tolerance).toBe(CAPTURE_PARITY_TOLERANCE);
|
|
46
|
+
expect(result.difference).toBe(15);
|
|
47
|
+
expect(result.pass).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('fails just past the parity tolerance', () => {
|
|
51
|
+
const result = evaluateCaptureParity(BLACK, NUDGE_48);
|
|
52
|
+
expect(result.difference).toBe(16);
|
|
53
|
+
expect(result.pass).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('fails on an unparseable fingerprint via the Infinity sentinel', () => {
|
|
57
|
+
const result = evaluateCaptureParity(BLACK, 'broken');
|
|
58
|
+
expect(result.difference).toBe(Number.POSITIVE_INFINITY);
|
|
59
|
+
expect(result.pass).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('honors an explicit tolerance override', () => {
|
|
63
|
+
expect(evaluateCaptureParity(BLACK, NUDGE_48, 20).pass).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('evaluateCaptureRegression', () => {
|
|
68
|
+
it('defaults to the regression tolerance and passes at the boundary', () => {
|
|
69
|
+
const result = evaluateCaptureRegression(BLACK, NUDGE_15);
|
|
70
|
+
expect(result.tolerance).toBe(CAPTURE_REGRESSION_TOLERANCE);
|
|
71
|
+
expect(result.difference).toBe(5);
|
|
72
|
+
expect(result.pass).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('passes for an identical fingerprint', () => {
|
|
76
|
+
const result = evaluateCaptureRegression(WHITE, WHITE);
|
|
77
|
+
expect(result.difference).toBe(0);
|
|
78
|
+
expect(result.pass).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('fails just past the regression tolerance', () => {
|
|
82
|
+
const result = evaluateCaptureRegression(BLACK, NUDGE_18);
|
|
83
|
+
expect(result.difference).toBe(6);
|
|
84
|
+
expect(result.pass).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('fails on an unparseable baseline via the Infinity sentinel', () => {
|
|
88
|
+
const result = evaluateCaptureRegression(BLACK, 'no-baseline');
|
|
89
|
+
expect(result.difference).toBe(Number.POSITIVE_INFINITY);
|
|
90
|
+
expect(result.pass).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('honors an explicit tolerance override', () => {
|
|
94
|
+
expect(evaluateCaptureRegression(BLACK, NUDGE_18, 10).pass).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
});
|