From d740b64be74f7e2f35199e8232487aa5ea50e853 Mon Sep 17 00:00:00 2001 From: Carla Severe Date: Mon, 1 Jun 2026 16:02:30 -0700 Subject: [PATCH 1/3] add client-side mode-detection helpers to kde.js (ported from kde-widget) --- src/__tests__/kdeModes.test.ts | 211 ++++++++++++++++++++++++++ src/__tests__/utils/setupTests.ts | 6 + src/utils/kde.d.ts | 32 ++++ src/utils/kde.js | 243 ++++++++++++++++++++++++++++++ 4 files changed, 492 insertions(+) create mode 100644 src/__tests__/kdeModes.test.ts diff --git a/src/__tests__/kdeModes.test.ts b/src/__tests__/kdeModes.test.ts new file mode 100644 index 000000000..27bfbba91 --- /dev/null +++ b/src/__tests__/kdeModes.test.ts @@ -0,0 +1,211 @@ +import { + areaFracs, + assignLetters, + fitModesFromKde, + matchModes, + splitByMode, +} from '../utils/kde.js'; + +// Build a synthetic KDE: a sum of narrow Gaussian bumps on a uniform grid. +// Useful for tests that need a curve with known peak locations. +function gaussianBumps( + grid: { lo: number; hi: number; n: number }, + bumps: Array<{ mu: number; sigma: number; weight: number }>, +) { + const { lo, hi, n } = grid; + const x: number[] = []; + const y: number[] = []; + for (let i = 0; i < n; i++) { + const xi = lo + ((hi - lo) * i) / (n - 1); + let yi = 0; + for (const { mu, sigma, weight } of bumps) { + yi += + (weight * Math.exp(-((xi - mu) ** 2) / (2 * sigma ** 2))) / + (sigma * Math.sqrt(2 * Math.PI)); + } + x.push(xi); + y.push(yi); + } + return { x, y }; +} + +describe('areaFracs', () => { + it('integrates a flat curve uniformly across buckets', () => { + // Constant y over [0, 10] with one boundary at 5 → two equal halves. + const x = Array.from({ length: 101 }, (_, i) => i * 0.1); + const y = x.map(() => 1); + const fracs = areaFracs(x, y, [5]); + expect(fracs).toHaveLength(2); + expect(fracs[0]).toBeCloseTo(0.5, 5); + expect(fracs[1]).toBeCloseTo(0.5, 5); + }); + + it('weights area toward whichever side has more density', () => { + // Triangle from y=2 at x=0 down to y=0 at x=10; boundary at 5 splits a + // 3:1 area ratio (left half integrates to 7.5, right half to 2.5). + const x = Array.from({ length: 101 }, (_, i) => i * 0.1); + const y = x.map((xi) => Math.max(0, 2 - xi * 0.2)); + const fracs = areaFracs(x, y, [5]); + expect(fracs[0]).toBeCloseTo(0.75, 2); + expect(fracs[1]).toBeCloseTo(0.25, 2); + }); + + it('falls back to uniform fractions when total area is zero', () => { + const x = [0, 1, 2, 3]; + const y = [0, 0, 0, 0]; + expect(areaFracs(x, y, [1.5])).toEqual([0.5, 0.5]); + }); + + it('handles no boundaries (everything in one bucket)', () => { + const x = Array.from({ length: 11 }, (_, i) => i); + const y = x.map(() => 1); + const fracs = areaFracs(x, y, []); + expect(fracs).toEqual([1]); + }); +}); + +describe('fitModesFromKde', () => { + it('returns a single mode for a unimodal curve', () => { + const { x, y } = gaussianBumps({ lo: 0, hi: 100, n: 1024 }, [ + { mu: 50, sigma: 5, weight: 1 }, + ]); + const modes = fitModesFromKde(x, y, 0.5); + expect(modes.peakLocs).toHaveLength(1); + expect(modes.peakLocs[0]).toBeCloseTo(50, 0); + expect(modes.boundaries).toEqual([]); + }); + + it('detects two well-separated modes', () => { + const { x, y } = gaussianBumps({ lo: 0, hi: 100, n: 1024 }, [ + { mu: 20, sigma: 3, weight: 1 }, + { mu: 80, sigma: 3, weight: 1 }, + ]); + const modes = fitModesFromKde(x, y, 0.5); + expect(modes.peakLocs).toHaveLength(2); + expect(modes.peakLocs[0]).toBeCloseTo(20, 0); + expect(modes.peakLocs[1]).toBeCloseTo(80, 0); + expect(modes.boundaries).toHaveLength(1); + expect(modes.boundaries[0]).toBeGreaterThan(20); + expect(modes.boundaries[0]).toBeLessThan(80); + }); + + it('collapses overlapping bumps when valley is shallow (vt strict)', () => { + // Two nearby bumps share a shallow valley. vt = 0.1 (strict) keeps them + // merged, vt = 0.9 (lenient) splits them. + const { x, y } = gaussianBumps({ lo: 0, hi: 100, n: 1024 }, [ + { mu: 48, sigma: 5, weight: 1 }, + { mu: 56, sigma: 5, weight: 1 }, + ]); + const strict = fitModesFromKde(x, y, 0.1); + const lenient = fitModesFromKde(x, y, 0.99); + expect(strict.peakLocs).toHaveLength(1); + // Lenient may still merge depending on the valley depth — what matters + // is that strict ≤ lenient mode count, never the other way around. + expect(lenient.peakLocs.length).toBeGreaterThanOrEqual( + strict.peakLocs.length, + ); + }); + + it('applies the minimum-separation guard on near-integer data', () => { + // Two bumps closer than minSep = max(2, 5% of range) — they're > 5% + // valley-deep but < 2 units apart, so the guard collapses them. + const { x, y } = gaussianBumps({ lo: 0, hi: 100, n: 1024 }, [ + { mu: 50, sigma: 0.3, weight: 1 }, + { mu: 51, sigma: 0.3, weight: 1 }, + ]); + const modes = fitModesFromKde(x, y, 0.9); + expect(modes.peakLocs).toHaveLength(1); + }); + + it('returns the global max location when no peak clears mpf', () => { + // Strictly increasing curve has no interior local maxima. + const x = Array.from({ length: 100 }, (_, i) => i); + const y = x.map((xi) => xi); + const modes = fitModesFromKde(x, y, 0.5); + expect(modes.peakLocs).toEqual([99]); + expect(modes.boundaries).toEqual([]); + }); +}); + +describe('assignLetters', () => { + it('assigns A to the smallest location', () => { + expect(assignLetters([30, 10, 20])).toEqual(['C', 'A', 'B']); + }); + + it('preserves a single mode as A', () => { + expect(assignLetters([42])).toEqual(['A']); + }); + + it('handles already-sorted input', () => { + expect(assignLetters([1, 2, 3, 4])).toEqual(['A', 'B', 'C', 'D']); + }); +}); + +describe('matchModes', () => { + it('pairs modes by proximity when counts match', () => { + // Base modes at 10, 90; new modes at 12, 88 — should match index-to-index. + const result = matchModes([10, 90], [0.5, 0.5], [12, 88], [0.5, 0.5]); + expect(result.pairs).toEqual([ + [0, 0], + [1, 1], + ]); + expect(result.ub).toEqual([]); + expect(result.un).toEqual([]); + }); + + it('flips pairing when the closer match crosses indices', () => { + // Base at [10, 90], new at [85, 15]: indices are reversed, so the optimal + // pairing crosses: base[0] ↔ new[1], base[1] ↔ new[0]. + const result = matchModes([10, 90], [0.5, 0.5], [85, 15], [0.5, 0.5]); + expect(result.pairs).toEqual([ + [0, 1], + [1, 0], + ]); + }); + + it('reports unmatched new modes when new has more than base', () => { + const result = matchModes([50], [1], [10, 50, 90], [0.3, 0.4, 0.3]); + expect(result.pairs).toEqual([[0, 1]]); + expect(result.ub).toEqual([]); + expect(result.un.sort()).toEqual([0, 2]); + }); + + it('reports unmatched base modes when base has more than new', () => { + const result = matchModes([10, 50, 90], [0.3, 0.4, 0.3], [50], [1]); + expect(result.pairs).toEqual([[1, 0]]); + expect(result.un).toEqual([]); + expect(result.ub.sort()).toEqual([0, 2]); + }); + + it('returns empty pairs when either side is empty', () => { + expect(matchModes([], [], [10], [1])).toEqual({ + pairs: [], + ub: [], + un: [0], + }); + expect(matchModes([10], [1], [], [])).toEqual({ + pairs: [], + ub: [0], + un: [], + }); + }); +}); + +describe('splitByMode', () => { + it('buckets samples by boundary', () => { + expect(splitByMode([1, 3, 5, 7, 9], [4, 8])).toEqual([[1, 3], [5, 7], [9]]); + }); + + it('puts samples equal to a boundary into the lower bucket', () => { + // splitByMode uses v > boundary, so boundary values go into bucket[m-1]. + expect(splitByMode([5], [5])).toEqual([[5], []]); + }); + + it('handles no boundaries (one bucket with everything)', () => { + expect(splitByMode([1, 2, 3], [])).toEqual([[1, 2, 3]]); + }); + + it('returns empty buckets for empty data', () => { + expect(splitByMode([], [4, 8])).toEqual([[], [], []]); + }); +}); diff --git a/src/__tests__/utils/setupTests.ts b/src/__tests__/utils/setupTests.ts index 68d35dc38..2e642a626 100644 --- a/src/__tests__/utils/setupTests.ts +++ b/src/__tests__/utils/setupTests.ts @@ -52,7 +52,13 @@ jest.mock('echarts', () => ({ })); const MockedEchartsInit = echartsInit as jest.Mock; +// Partial mock: only fftkde is mocked (each test sets its own implementation). +// The mode-detection helpers (argrelmax, fitModesFromKde, areaFracs, …) keep +// their real implementations so unit tests can exercise them directly. jest.mock('../../utils/kde.js', () => ({ + ...jest.requireActual( + '../../utils/kde.js', + ), fftkde: jest.fn(), })); const MockedFftkde = fftkde as jest.Mock; diff --git a/src/utils/kde.d.ts b/src/utils/kde.d.ts index 63e12dc6a..539097dc4 100644 --- a/src/utils/kde.d.ts +++ b/src/utils/kde.d.ts @@ -59,3 +59,35 @@ export declare function fitKdeModes( minPeakFraction?: number, minDataFraction?: number, ): KDEModeResult; +export type FitModesFromKdeResult = { + peakLocs: number[]; + boundaries: number[]; +}; +export declare function fitModesFromKde( + x: ArrayLike, + y: ArrayLike, + vt: number, + mpf?: number, + mdf?: number, +): FitModesFromKdeResult; +export declare function areaFracs( + x: ArrayLike, + y: ArrayLike, + boundaries: number[], +): number[]; +export declare function assignLetters(locs: number[]): string[]; +export type MatchModesResult = { + pairs: Array<[number, number]>; + ub: number[]; + un: number[]; +}; +export declare function matchModes( + bLocs: number[], + bFracs: number[], + nLocs: number[], + nFracs: number[], +): MatchModesResult; +export declare function splitByMode( + data: number[], + boundaries: number[], +): number[][]; diff --git a/src/utils/kde.js b/src/utils/kde.js index 26a4aadd4..3ccf8d92d 100644 --- a/src/utils/kde.js +++ b/src/utils/kde.js @@ -583,6 +583,249 @@ export function argrelmax(y, order = 1) { return peaks; } // --------------------------------------------------------------------------- +// Mode detection on a pre-computed KDE +// +// The helpers below operate on (x, y) arrays produced by fftkde rather than +// running KDE themselves. This lets an interactive UI tune the valley-depth +// threshold without recomputing the KDE on every change. +// --------------------------------------------------------------------------- +/** + * Compute the fraction of total KDE area falling in each mode bucket. + * + * Buckets are delimited by `boundaries`: bucket[0] covers x < boundaries[0], + * bucket[k] covers boundaries[k-1] < x ≤ boundaries[k], and the final bucket + * covers x > last boundary. Area is integrated via the trapezoid rule over + * the KDE grid. + * + * @param x - KDE x grid + * @param y - KDE density at each x + * @param boundaries - sorted mode boundaries (x values, increasing) + * @returns array of length boundaries.length + 1 summing to 1; falls back to + * uniform 1/N if the total integrated area is zero + */ +export function areaFracs(x, y, boundaries) { + const buckets = new Array(boundaries.length + 1).fill(0); + let total = 0; + for (let i = 1; i < x.length; i++) { + const area = 0.5 * (y[i] + y[i - 1]) * (x[i] - x[i - 1]); + total += area; + let m = 0; + while (m < boundaries.length && x[i] > boundaries[m]) m++; + buckets[m] += area; + } + return total > 0 + ? buckets.map((b) => b / total) + : buckets.map(() => 1 / buckets.length); +} +/** + * Detect modes from a pre-computed KDE curve. + * + * Like fitKdeModes but takes (x, y) instead of raw data, so an interactive + * widget can re-fit modes on a slider change without recomputing the KDE. + * + * Differences from fitKdeModes: + * - Filters modes by KDE *area* (areaFracs) rather than the fraction of + * raw data points falling in each bucket. + * - Adds a minimum-separation guard: peaks closer than max(2, 5% of the x + * range) collapse to the higher peak. This suppresses spurious modes + * that KDE can produce on near-integer data (e.g. samples ∈ {0, 1}). + * + * @param x - KDE x grid (uniform spacing) + * @param y - KDE density at each x + * @param vt - valley-depth threshold; the valley between two peaks must be + * shallower than vt × min(peak heights) for them to count as + * separate modes (0 = never split, 1 = always split) + * @param mpf - minimum peak height as a fraction of the global max (default 0.05) + * @param mdf - minimum area fraction a mode must contain to be kept (default 0.05) + * @returns { peakLocs, boundaries } in the same units as x + */ +export function fitModesFromKde(x, y, vt, mpf = 0.05, mdf = 0.05) { + let yMax = 0; + for (let i = 0; i < y.length; i++) if (y[i] > yMax) yMax = y[i]; + const peaks = argrelmax(y, 3).filter((i) => y[i] >= mpf * yMax); + if (!peaks.length) { + let gm = 0; + for (let i = 1; i < y.length; i++) if (y[i] > y[gm]) gm = i; + return { peakLocs: [x[gm]], boundaries: [] }; + } + // Valley-depth filter — walk peaks left to right, keeping a peak only if + // the valley between it and the previous kept peak is deep enough. + const good = [peaks[0]]; + for (let k = 1; k < peaks.length; k++) { + const nxt = peaks[k]; + const prev = good[good.length - 1]; + let valleyMin = y[prev]; + for (let j = prev; j <= nxt; j++) if (y[j] < valleyMin) valleyMin = y[j]; + if (valleyMin < vt * Math.min(y[prev], y[nxt])) { + good.push(nxt); + } else if (y[nxt] > y[good[good.length - 1]]) { + good[good.length - 1] = nxt; + } + } + function computeBoundaries(ps) { + const bs = []; + for (let i = 0; i < ps.length - 1; i++) { + let mi = ps[i]; + for (let j = ps[i]; j <= ps[i + 1]; j++) if (y[j] < y[mi]) mi = j; + bs.push(x[mi]); + } + return bs; + } + // Area-fraction filter — drop modes whose KDE area is below mdf. + const bs0 = computeBoundaries(good); + const fr0 = areaFracs(x, y, bs0); + const keep = good.map((_, i) => i).filter((i) => fr0[i] >= mdf); + if (keep.length < 2) { + const bp = good.reduce((a, b) => (y[a] > y[b] ? a : b)); + return { peakLocs: [x[bp]], boundaries: [] }; + } + const fg = keep.map((i) => good[i]); + const fb = computeBoundaries(fg); + const locs = fg.map((i) => x[i]); + // Minimum-separation guard: KDE artefacts on near-integer data can put + // distinct peaks within a sample of each other. Collapse those to one. + const dataRange = x[x.length - 1] - x[0]; + const minSep = Math.max(2, dataRange * 0.05); + for (let k = 1; k < locs.length; k++) { + if (locs[k] - locs[k - 1] < minSep) { + const bestIdx = fg.reduce((a, b) => (y[a] > y[b] ? a : b)); + return { peakLocs: [x[bestIdx]], boundaries: [] }; + } + } + return { peakLocs: locs, boundaries: fb }; +} +/** + * Assign single-letter labels to modes, with A = lowest peak location. + * + * Performance convention: A is the fastest (lowest) path, B is the next, + * etc. Locations don't need to be pre-sorted — this function sorts internally + * and returns letters in the *original* input order, so out[i] is the letter + * for locs[i]. + * + * @param locs - array of peak x positions + * @returns array of single-character letter labels, same length as locs + */ +export function assignLetters(locs) { + const idx = locs.map((_, i) => i).sort((a, b) => locs[a] - locs[b]); + const out = new Array(locs.length); + idx.forEach((i, rank) => { + out[i] = String.fromCharCode(65 + rank); + }); + return out; +} +function popcount(x) { + let c = 0; + let v = x; + while (v) { + c += v & 1; + v >>>= 1; + } + return c; +} +function range(n) { + return Array.from({ length: n }, (_, i) => i); +} +/** + * Pair modes between base and comparison runs by minimum total distance. + * + * When comparing base vs. new, mode A in the base may correspond to mode B + * in the new run (same code path, just shifted or re-ordered by peak height). + * Naive index-by-index matching can pair the wrong paths. + * + * Solves a minimum-cost assignment problem: pair each base mode to a new + * mode so the total distance is minimised, where distance is + * 0.75 × |bLoc - nLoc| / span + 0.25 × |bFrac - nFrac| + * + * Uses bitmask DP over subsets of new modes — exact, and fast for the small + * mode counts seen in practice (n, m ≤ ~8). + * + * @returns { pairs, ub, un } + * pairs : array of [baseIdx, newIdx] tuples + * ub : base indices with no match (path disappeared) + * un : new indices with no match (new path appeared) + */ +export function matchModes(bLocs, bFracs, nLocs, nFracs) { + const n = bLocs.length; + const m = nLocs.length; + if (!n || !m) return { pairs: [], ub: range(n), un: range(m) }; + // If base has more modes than new, swap roles and flip the resulting pairs. + if (n > m) { + const sw = matchModes(nLocs, nFracs, bLocs, bFracs); + return { + pairs: sw.pairs.map((p) => [p[1], p[0]]), + ub: sw.un, + un: sw.ub, + }; + } + const all = bLocs.concat(nLocs); + const span = Math.max(...all) - Math.min(...all) || 1; + const cost = bLocs.map((bl, i) => + nLocs.map( + (nl, j) => + (0.75 * Math.abs(bl - nl)) / span + + 0.25 * Math.abs(bFracs[i] - nFracs[j]), + ), + ); + const INF = 1e9; + const states = 1 << m; + const dp = new Float64Array(states).fill(INF); + const prev = new Int16Array(states).fill(-1); + dp[0] = 0; + for (let mask = 0; mask < states; mask++) { + if (dp[mask] === INF) continue; + const i = popcount(mask); + if (i >= n) continue; + for (let j = 0; j < m; j++) { + if ((mask >> j) & 1) continue; + const nm = mask | (1 << j); + const c = dp[mask] + cost[i][j]; + if (c < dp[nm]) { + dp[nm] = c; + prev[nm] = j; + } + } + } + let best = -1; + let bc = INF; + for (let mask = 0; mask < states; mask++) { + if (popcount(mask) === n && dp[mask] < bc) { + bc = dp[mask]; + best = mask; + } + } + const pairs = []; + let cur = best; + for (let i = n - 1; i >= 0; i--) { + const j = prev[cur]; + pairs.unshift([i, j]); + cur ^= 1 << j; + } + const mNew = new Set(pairs.map((p) => p[1])); + return { pairs, ub: [], un: range(m).filter((j) => !mNew.has(j)) }; +} +/** + * Bucket raw samples into mode-aligned arrays. + * + * Returns buckets[0..boundaries.length], where buckets[k] holds samples + * with value > boundaries[k-1] and ≤ boundaries[k]. The first bucket has + * no lower bound, the last has no upper bound. Used to compute per-mode + * bootstrap CIs on the actual samples rather than on the KDE density. + * + * @param data - raw sample values + * @param boundaries - sorted mode boundaries + * @returns array of bucketed sample arrays, length boundaries.length + 1 + */ +export function splitByMode(data, boundaries) { + const buckets = boundaries.map(() => []); + buckets.push([]); + data.forEach((v) => { + let m = 0; + while (m < boundaries.length && v > boundaries[m]) m++; + buckets[m].push(v); + }); + return buckets; +} +// --------------------------------------------------------------------------- // fitKdeModes — port of perf_compare_stats.fit_kde_modes // // Runs FFTKDE with ISJ bandwidth, finds local maxima, applies valley-depth From 24d146c372be07396c7392d073da0d2b2702de7a Mon Sep 17 00:00:00 2001 From: Carla Severe Date: Mon, 1 Jun 2026 16:39:24 -0700 Subject: [PATCH 2/3] wire client-side mode detection into CommonGraph: valley-depth slider, mode-line overlays, peak labels (ported from kde-widget) --- .../CompareResults/CommonGraph.test.tsx | 63 +++++- .../__snapshots__/ResultsView.test.tsx.snap | 126 ++++++++++- .../SubtestsResultsView.test.tsx.snap | 48 ++--- src/components/CompareResults/CommonGraph.tsx | 199 +++++++++++++++++- .../CompareResults/RevisionRowExpandable.tsx | 9 + 5 files changed, 407 insertions(+), 38 deletions(-) diff --git a/src/__tests__/CompareResults/CommonGraph.test.tsx b/src/__tests__/CompareResults/CommonGraph.test.tsx index 9ff26d762..056aca4ec 100644 --- a/src/__tests__/CompareResults/CommonGraph.test.tsx +++ b/src/__tests__/CompareResults/CommonGraph.test.tsx @@ -88,6 +88,8 @@ describe('CommonGraph', () => { newValues={[3, 4]} unit='ms' isSubtest={false} + vt={0.5} + onVtChange={jest.fn()} />, ); @@ -132,6 +134,8 @@ describe('CommonGraph', () => { newValues={[3, 4]} unit='ms' isSubtest={false} + vt={0.5} + onVtChange={jest.fn()} />, ); @@ -164,6 +168,8 @@ describe('CommonGraph', () => { newValues={[3, 4]} unit='ms' isSubtest={true} + vt={0.5} + onVtChange={jest.fn()} />, ); @@ -194,12 +200,18 @@ describe('CommonGraph', () => { newValues={[]} unit='ms' isSubtest={false} + vt={0.5} + onVtChange={jest.fn()} />, ); const option = getLatestEChartsOption(); const allSeries = option.series as LineSeriesOption[]; - const series = allSeries.filter((s) => s.type === 'line'); + // Exclude the mode-overlay markLine series (named "_mode-*") — only count + // the two underlying KDE curves. + const series = allSeries.filter( + (s) => s.type === 'line' && !String(s.name ?? '').startsWith('_mode-'), + ); expect(series).toHaveLength(2); // Base side has a resampled density curve. expect(series[0].data as unknown[]).toHaveLength(1024); @@ -232,6 +244,8 @@ describe('CommonGraph', () => { newValues={[3, 4]} unit='ms' isSubtest={false} + vt={0.5} + onVtChange={jest.fn()} />, ); @@ -254,6 +268,8 @@ describe('CommonGraph', () => { newValues={[3, 4]} unit='ms' isSubtest={false} + vt={0.5} + onVtChange={jest.fn()} />, ); @@ -296,6 +312,8 @@ describe('CommonGraph', () => { newValues={[3, 4]} unit='ms' isSubtest={false} + vt={0.5} + onVtChange={jest.fn()} />, ); @@ -336,6 +354,8 @@ describe('CommonGraph', () => { newValues={[3, 4]} unit={null} isSubtest={false} + vt={0.5} + onVtChange={jest.fn()} />, ); @@ -353,4 +373,45 @@ describe('CommonGraph', () => { // No "(unit)" suffix after the value when unit is null. expect(rendered).toBe('Value: 5.00
Base: 0.1000'); }); + + it('emits a mode-overlay markLine series for each detected peak', () => { + // Strictly increasing fake KDE — fitModesFromKde returns a single peak at + // the global max (last x). That yields exactly one "_mode-*" overlay per + // series, with a label tagged by series and letter A. + (fftkde as jest.Mock).mockImplementation(() => ({ + x: [10, 20, 30], + y: [0.1, 0.2, 0.3], + bandwidth: 1, + })); + + render( + , + ); + + const option = getLatestEChartsOption(); + const series = option.series as Array<{ + name?: string; + markLine?: { + data?: Array<{ xAxis?: number }>; + label?: { formatter?: string }; + lineStyle?: { color?: string }; + }; + }>; + const overlays = series.filter((s) => + String(s.name ?? '').startsWith('_mode-'), + ); + // One overlay per series (Base + New), both peaking at the same x. + expect(overlays).toHaveLength(2); + expect(overlays[0].markLine?.data?.[0]?.xAxis).toBe(30); + expect(overlays[1].markLine?.data?.[0]?.xAxis).toBe(30); + expect(overlays[0].markLine?.label?.formatter).toMatch(/^Base A: 30/); + expect(overlays[1].markLine?.label?.formatter).toMatch(/^New A: 30/); + }); }); diff --git a/src/__tests__/CompareResults/__snapshots__/ResultsView.test.tsx.snap b/src/__tests__/CompareResults/__snapshots__/ResultsView.test.tsx.snap index 2867458f7..b034456fe 100644 --- a/src/__tests__/CompareResults/__snapshots__/ResultsView.test.tsx.snap +++ b/src/__tests__/CompareResults/__snapshots__/ResultsView.test.tsx.snap @@ -4,7 +4,7 @@ exports[`Results View Should display Base, New and Common graphs with replicates
Runs Density Distribution +
+ + Valley depth threshold + + : + + + + + + + + + + 50 + % + +
@@ -482,11 +542,71 @@ exports[`Results View Should display Base, New and Common graphs with tooltips 1 > Runs Density Distribution +
+ + Valley depth threshold + + : + + + + + + + + + + 50 + % + +
diff --git a/src/__tests__/CompareResults/__snapshots__/SubtestsResultsView.test.tsx.snap b/src/__tests__/CompareResults/__snapshots__/SubtestsResultsView.test.tsx.snap index d053ad239..84f40523e 100644 --- a/src/__tests__/CompareResults/__snapshots__/SubtestsResultsView.test.tsx.snap +++ b/src/__tests__/CompareResults/__snapshots__/SubtestsResultsView.test.tsx.snap @@ -2643,7 +2643,7 @@ exports[`SubtestsViewCompareOverTime Component Tests in mann-whitney-u testVersi aria-invalid="false" aria-label="Search by title" class="MuiInputBase-input MuiOutlinedInput-input MuiInputBase-inputSizeSmall MuiInputBase-inputAdornedStart MuiInputBase-inputAdornedEnd css-3v3un6-MuiInputBase-input-MuiOutlinedInput-input" - id="_r_r3_" + id="_r_r9_" placeholder="Filter results" type="search" value="" @@ -3153,7 +3153,7 @@ exports[`SubtestsViewCompareOverTime Component Tests in mann-whitney-u testVersi role="cell" >