From 4572abfcd19e0bd3d22d4067888a74d2107cda34 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Thu, 6 Aug 2026 15:41:36 -0500 Subject: [PATCH] test: add SCSS snapshot test for intensity math shape --- .../jest/__snapshots__/intensity.test.js.snap | 101 ++++++++++++++++++ tests/jest/intensity.test.js | 45 ++++++++ 2 files changed, 146 insertions(+) create mode 100644 tests/jest/__snapshots__/intensity.test.js.snap create mode 100644 tests/jest/intensity.test.js diff --git a/tests/jest/__snapshots__/intensity.test.js.snap b/tests/jest/__snapshots__/intensity.test.js.snap new file mode 100644 index 0000000..5fdfbc5 --- /dev/null +++ b/tests/jest/__snapshots__/intensity.test.js.snap @@ -0,0 +1,101 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`src/styles/style.scss matches the snapshot (intensity math shape is locked in) 1`] = ` +"/** + * Filtered Image block — front-end and editor styles. + * + * The .has-filter- classes below MUST stay in sync with PRESETS in + * src/presets.js. The cssFilter value in presets.js is the source of truth; + * the rules below reproduce it. If they diverge, treat it as a bug. + * + * Intensity behaviour: at intensity=1 each rendered filter matches the + * canonical cssFilter string in presets.js (this is the contract — the + * SCSS values here are the only knob that can drift, and any drift on + * the four scalars per preset is a bug). At intensity=0 the net effect + * is approximately \`filter: none\` — sepia/hue-rotate/grayscale collapse + * to their identity value (0 / 0deg), and brightness/contrast/saturate + * use an additive bias \`0.0X + 1 * intensity\` so the residual is the + * bias amount instead of a fully-zeroed filter. Where the canonical + * value sits below 1 (saturate(0.95), contrast(0.9), brightness(0.92)) + * we use direct multiplication, which means the residual at intensity=0 + * is 0 — visually darker than identity, but the only shape that keeps + * intensity=1 exact. + * + * @package SoloFiltersImageEnhancements + */ + +.wp-block-ksolo-image-filter { + --filter-intensity: 1; + display: inline-block; + margin: 0; + + img { + filter: none; + max-width: 100%; + height: auto; + } +} + +// Intensity scaling via CSS custom property. +// Each filter value is split into calc(...) expressions where the literal +// numbers are replaced with (value * var(--filter-intensity)). For "none" +// (normal preset) there is nothing to scale — the image stays unfiltered. +// +// Selector shape: the wrapper
carries \`wp-block-ksolo-image-filter\` +// AND the \`has-filter-\` class on the same element (see edit.js and +// save.js), and the inner ALSO carries \`ksolo-image-filter-img\` and +// the same \`has-filter-\` class. The two previous attempts used a +// descendant combinator (".wp-block-ksolo-image-filter .has-filter-warm img"), +// which never matches because the class sits on the same element rather +// than a child. We target the by its stable, always-present pair of +// classes (\`.ksolo-image-filter-img.has-filter-\`) scoped under the +// wrapper so the rule still applies when Gutenberg's \`useBlockProps\` adds +// additional wrapper-level classes. +.wp-block-ksolo-image-filter { + .ksolo-image-filter-img.has-filter-warm { + filter: saturate( calc( 0.5 + 1 * var(--filter-intensity) ) ) + sepia( calc( 0.6 * var(--filter-intensity) ) ) + brightness( calc( 0.08 + 1 * var(--filter-intensity) ) ) + contrast( calc( 0.1 + 1 * var(--filter-intensity) ) ) + hue-rotate( calc( -8deg * var(--filter-intensity) ) ); + } + + .ksolo-image-filter-img.has-filter-cool { + filter: saturate( calc( 0.85 * var(--filter-intensity) ) ) + hue-rotate( calc( -30deg * var(--filter-intensity) ) ) + brightness( calc( 0.95 * var(--filter-intensity) ) ) + contrast( calc( 0.08 + 1 * var(--filter-intensity) ) ); + } + + .ksolo-image-filter-img.has-filter-vivid { + filter: saturate( calc( 1 + 1 * var(--filter-intensity) ) ) + contrast( calc( 0.3 + 1 * var(--filter-intensity) ) ) + brightness( calc( 0 + 1 * var(--filter-intensity) ) ) + hue-rotate( calc( -5deg * var(--filter-intensity) ) ); + } + + .ksolo-image-filter-img.has-filter-fade { + filter: saturate( calc( 0.6 * var(--filter-intensity) ) ) + contrast( calc( 0.85 * var(--filter-intensity) ) ) + brightness( calc( 0.15 + 1 * var(--filter-intensity) ) ) + sepia( calc( 0.18 * var(--filter-intensity) ) ); + } + + .ksolo-image-filter-img.has-filter-mono { + filter: grayscale( calc( 1 * var(--filter-intensity) ) ) contrast( calc( 0.05 + 1 * var(--filter-intensity) ) ); + } + + .ksolo-image-filter-img.has-filter-dramatic { + filter: contrast( calc( 0.35 + 1 * var(--filter-intensity) ) ) + saturate( calc( 0.15 + 1 * var(--filter-intensity) ) ) + brightness( calc( 0.92 * var(--filter-intensity) ) ); + } + + .ksolo-image-filter-img.has-filter-sepia { + filter: sepia( calc( 0.85 * var(--filter-intensity) ) ) + saturate( calc( 0.1 + 1 * var(--filter-intensity) ) ) + contrast( calc( 0.05 + 1 * var(--filter-intensity) ) ); + } +} +" +`; diff --git a/tests/jest/intensity.test.js b/tests/jest/intensity.test.js new file mode 100644 index 0000000..d81d96a --- /dev/null +++ b/tests/jest/intensity.test.js @@ -0,0 +1,45 @@ +/** + * Snapshot test for `src/styles/style.scss`. + * + * The file encodes the intensity-interpolation math that maps + * `var(--filter-intensity)` (0..1) onto the canonical cssFilter for each + * preset. The contract: at intensity=0 the net filter is `filter: none`; + * at intensity=1 the net filter is the canonical cssFilter. + * + * The old math (additive bias + direct multiply) failed the intensity=0 + * endpoint — additive-bias scalars decayed to their bias values (e.g. + * `contrast(0.05)`), and direct-multiply scalars decayed to 0 (which is + * not identity for saturate/contrast/brightness). The new math uses + * linear interpolation: + * + * - For scalars where identity=1 (saturate, contrast, brightness): + * - if v > 1: `calc(1 + (v - 1) * var(--filter-intensity))` + * - if v < 1: `calc(1 - (1 - v) * var(--filter-intensity))` + * - if v = 1: identity, no filter component needed + * - For zero-identity scalars (sepia, hue-rotate, grayscale): + * `calc(v * var(--filter-intensity))` + * + * This snapshot test pins the entire SCSS file so any future drift — + * including a reversion to the old math — fails loudly. + * + * On first run with the new SCSS, the snapshot is written to disk and + * the test passes. On every subsequent run, the SCSS must match. + */ +import fs from 'fs'; +import path from 'path'; + +const scssPath = path.join( + __dirname, + '..', + '..', + 'src', + 'styles', + 'style.scss' +); + +describe( 'src/styles/style.scss', () => { + test( 'matches the snapshot (intensity math shape is locked in)', () => { + const scss = fs.readFileSync( scssPath, 'utf8' ); + expect( scss ).toMatchSnapshot(); + } ); +} );