Skip to content

Commit b2db731

Browse files
Merge pull request #726 from lukecotter/fix-incorrect-minimap-bucket-colors
fix: correct minimap bucket category resolution
2 parents 9abcdf0 + 66e0fa9 commit b2db731

File tree

2 files changed

+210
-78
lines changed

2 files changed

+210
-78
lines changed
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
/*
2+
* Copyright (c) 2026 Certinia Inc. All rights reserved.
3+
*/
4+
5+
/**
6+
* Unit tests for MinimapDensityQuery
7+
*
8+
* Tests category resolution for minimap coloring, ensuring:
9+
* - Long-spanning parent frames are not skipped during frame collection
10+
* - Skyline (on-top time) algorithm correctly identifies dominant category
11+
* - Both fallback and segment tree paths produce consistent results
12+
*/
13+
14+
import type { LogEvent } from 'apex-log-parser';
15+
import { MinimapDensityQuery } from '../optimised/minimap/MinimapDensityQuery.js';
16+
import type { PrecomputedRect } from '../optimised/RectangleManager.js';
17+
import { TemporalSegmentTree } from '../optimised/TemporalSegmentTree.js';
18+
19+
/**
20+
* Helper to create a mock PrecomputedRect.
21+
*/
22+
function createRect(
23+
category: string,
24+
timeStart: number,
25+
timeEnd: number,
26+
depth: number,
27+
selfDuration?: number,
28+
): PrecomputedRect {
29+
const duration = timeEnd - timeStart;
30+
return {
31+
id: `${category}-${timeStart}-${depth}`,
32+
timeStart,
33+
timeEnd,
34+
depth,
35+
duration,
36+
selfDuration: selfDuration ?? duration,
37+
category,
38+
x: 0,
39+
y: 0,
40+
width: 0,
41+
height: 20,
42+
eventRef: { timestamp: timeStart } as LogEvent,
43+
};
44+
}
45+
46+
/**
47+
* Build rectsByCategory from a flat list of rects.
48+
*/
49+
function buildRectsByCategory(rects: PrecomputedRect[]): Map<string, PrecomputedRect[]> {
50+
const map = new Map<string, PrecomputedRect[]>();
51+
for (const rect of rects) {
52+
let arr = map.get(rect.category);
53+
if (!arr) {
54+
arr = [];
55+
map.set(rect.category, arr);
56+
}
57+
arr.push(rect);
58+
}
59+
return map;
60+
}
61+
62+
describe('MinimapDensityQuery', () => {
63+
describe('category resolution with long-spanning parent frames', () => {
64+
/**
65+
* Regression test: A long parent Method frame spanning many buckets
66+
* with a short DML child in the middle.
67+
*
68+
* The parent Method frame must be included in all overlapping buckets
69+
* for correct skyline computation. If frames are collected via binary
70+
* search on timeEnd (with timeStart-sorted data), long-spanning parent
71+
* frames can be skipped, causing incorrect coloring.
72+
*
73+
* Layout:
74+
* depth 0: |-------- Method (0-1000) --------|
75+
* depth 1: |-- DML (300-400) --|
76+
*
77+
* Expected: Buckets outside DML range should be Method (green).
78+
* Bucket covering DML range should be DML (brown) due to weight.
79+
*/
80+
const rects = [
81+
createRect('Method', 0, 1000, 0, 600), // parent, selfDuration excludes DML child time
82+
createRect('DML', 300, 400, 1, 100),
83+
];
84+
85+
it('should show Method in buckets outside DML range (fallback path)', () => {
86+
const rectsByCategory = buildRectsByCategory(rects);
87+
const query = new MinimapDensityQuery(rectsByCategory, 1000, 1);
88+
89+
// 10 buckets: each covers 100ns
90+
// Bucket 0 [0-100]: only Method → Method
91+
// Bucket 3 [300-400]: Method + DML → DML wins (2.5x weight)
92+
// Bucket 9 [900-1000]: only Method → Method
93+
const result = query.query(10);
94+
95+
expect(result.buckets[0]!.dominantCategory).toBe('Method');
96+
expect(result.buckets[1]!.dominantCategory).toBe('Method');
97+
expect(result.buckets[9]!.dominantCategory).toBe('Method');
98+
99+
// DML bucket: DML at depth 1 is deeper, with 2.5x weight
100+
expect(result.buckets[3]!.dominantCategory).toBe('DML');
101+
});
102+
103+
it('should show Method in buckets outside DML range (segment tree path)', () => {
104+
const rectsByCategory = buildRectsByCategory(rects);
105+
const segmentTree = new TemporalSegmentTree(rectsByCategory);
106+
const query = new MinimapDensityQuery(rectsByCategory, 1000, 1, segmentTree);
107+
108+
const result = query.query(10);
109+
110+
// These buckets must be Method - the parent frame spans all of them
111+
expect(result.buckets[0]!.dominantCategory).toBe('Method');
112+
expect(result.buckets[1]!.dominantCategory).toBe('Method');
113+
expect(result.buckets[5]!.dominantCategory).toBe('Method');
114+
expect(result.buckets[9]!.dominantCategory).toBe('Method');
115+
116+
// DML bucket
117+
expect(result.buckets[3]!.dominantCategory).toBe('DML');
118+
});
119+
120+
it('should produce consistent results between fallback and segment tree paths', () => {
121+
const rectsByCategory = buildRectsByCategory(rects);
122+
const segmentTree = new TemporalSegmentTree(rectsByCategory);
123+
124+
const fallbackQuery = new MinimapDensityQuery(rectsByCategory, 1000, 1);
125+
const treeQuery = new MinimapDensityQuery(rectsByCategory, 1000, 1, segmentTree);
126+
127+
const fallbackResult = fallbackQuery.query(10);
128+
const treeResult = treeQuery.query(10);
129+
130+
for (let i = 0; i < 10; i++) {
131+
expect(treeResult.buckets[i]!.dominantCategory).toBe(
132+
fallbackResult.buckets[i]!.dominantCategory,
133+
);
134+
}
135+
});
136+
});
137+
138+
describe('multiple depth levels with overlapping frames', () => {
139+
/**
140+
* Layout:
141+
* depth 0: |-------- Code Unit (0-1000) --------|
142+
* depth 1: |-------- Method (0-1000) ------------|
143+
* depth 2: |-- SOQL (200-300) --| |-- DML (600-700) --|
144+
*
145+
* This tests that parent frames at multiple depths are all correctly
146+
* collected even when short children exist between them.
147+
*/
148+
it('should resolve Method where no SOQL/DML children exist', () => {
149+
const rects = [
150+
createRect('Code Unit', 0, 1000, 0, 0), // code unit has 0 self duration (all children)
151+
createRect('Method', 0, 1000, 1, 800), // method covers most of the time
152+
createRect('SOQL', 200, 300, 2, 100),
153+
createRect('DML', 600, 700, 2, 100),
154+
];
155+
156+
const rectsByCategory = buildRectsByCategory(rects);
157+
const segmentTree = new TemporalSegmentTree(rectsByCategory);
158+
const query = new MinimapDensityQuery(rectsByCategory, 1000, 2, segmentTree);
159+
160+
const result = query.query(10);
161+
162+
// Bucket 0 [0-100]: Code Unit + Method → Method wins (deeper)
163+
expect(result.buckets[0]!.dominantCategory).toBe('Method');
164+
165+
// Bucket 4 [400-500]: Code Unit + Method → Method wins (deeper)
166+
expect(result.buckets[4]!.dominantCategory).toBe('Method');
167+
168+
// Bucket 2 [200-300]: Code Unit + Method + SOQL → SOQL wins (deepest + 2.5x weight)
169+
expect(result.buckets[2]!.dominantCategory).toBe('SOQL');
170+
171+
// Bucket 6 [600-700]: Code Unit + Method + DML → DML wins (deepest + 2.5x weight)
172+
expect(result.buckets[6]!.dominantCategory).toBe('DML');
173+
});
174+
});
175+
176+
describe('edge cases', () => {
177+
it('should handle single frame spanning all buckets', () => {
178+
const rects = [createRect('Method', 0, 1000, 0)];
179+
const rectsByCategory = buildRectsByCategory(rects);
180+
const segmentTree = new TemporalSegmentTree(rectsByCategory);
181+
const query = new MinimapDensityQuery(rectsByCategory, 1000, 0, segmentTree);
182+
183+
const result = query.query(5);
184+
185+
for (const bucket of result.buckets) {
186+
expect(bucket.dominantCategory).toBe('Method');
187+
}
188+
});
189+
190+
it('should handle empty timeline', () => {
191+
const rectsByCategory = new Map<string, PrecomputedRect[]>();
192+
const query = new MinimapDensityQuery(rectsByCategory, 0, 0);
193+
194+
const result = query.query(10);
195+
expect(result.buckets).toHaveLength(0);
196+
});
197+
});
198+
});

log-viewer/src/features/timeline/optimised/minimap/MinimapDensityQuery.ts

Lines changed: 12 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -332,9 +332,6 @@ export class MinimapDensityQuery {
332332
* - New: O(N) single pass + O(B × k) skyline computation = ~10-20ms
333333
* (where k = avg frames per bucket, much smaller than N)
334334
*
335-
* PERF: Uses binary search to find frames for each bucket on-demand,
336-
* avoiding O(N × bucketSpan) frame collection (~10-15ms saved).
337-
*
338335
* @param bucketCount - Number of output buckets
339336
* @returns MinimapDensityData
340337
*/
@@ -350,16 +347,19 @@ export class MinimapDensityQuery {
350347

351348
const frames = this.segmentTree.getAllFramesSorted();
352349
const bucketTimeWidth = this.totalDuration / bucketCount;
353-
const frameCount = frames.length;
354350

355351
// Pre-allocate bucket arrays
356352
const maxDepths = new Uint16Array(bucketCount);
357353
const eventCounts = new Uint32Array(bucketCount);
358354
const selfDurationSums = new Float64Array(bucketCount);
359355

360-
// First pass: compute maxDepth, eventCount, and selfDurationSums per bucket
361-
// This still needs to iterate frames spanning multiple buckets, but we avoid
362-
// storing frame references (which was the main memory/allocation cost)
356+
// Collect frames per bucket for skyline computation
357+
const framesPerBucket: SkylineFrame[][] = new Array(bucketCount);
358+
for (let i = 0; i < bucketCount; i++) {
359+
framesPerBucket[i] = [];
360+
}
361+
362+
// Single pass: compute maxDepth, eventCount, selfDurationSums, and collect frames
363363
for (const frame of frames) {
364364
const startBucket = Math.max(0, Math.floor(frame.timeStart / bucketTimeWidth));
365365
const endBucket = Math.min(bucketCount - 1, Math.floor(frame.timeEnd / bucketTimeWidth));
@@ -385,16 +385,16 @@ export class MinimapDensityQuery {
385385
const overlapRatio = frameDuration > 0 ? visibleTime / frameDuration : 0;
386386
const proportionalSelfDuration = frame.selfDuration * overlapRatio;
387387
selfDurationSums[b]! += proportionalSelfDuration;
388+
389+
// Collect frame for skyline computation
390+
framesPerBucket[b]!.push(frame);
388391
}
389392
}
390393

391-
// Build output buckets with on-demand frame lookup for skyline computation
394+
// Build output buckets
392395
const buckets: MinimapDensityBucket[] = new Array(bucketCount);
393396
let maxEventCount = 0;
394397

395-
// Track search position for binary search optimization
396-
let searchStartIdx = 0;
397-
398398
for (let i = 0; i < bucketCount; i++) {
399399
const eventCount = eventCounts[i]!;
400400
if (eventCount > maxEventCount) {
@@ -404,25 +404,9 @@ export class MinimapDensityQuery {
404404
const bucketStart = i * bucketTimeWidth;
405405
const bucketEnd = (i + 1) * bucketTimeWidth;
406406

407-
// PERF: Use binary search to find frames for this bucket on-demand
408-
// instead of pre-collecting all frames into all buckets
409-
const bucketFrames = this.getFramesInRange(
410-
frames,
411-
frameCount,
412-
bucketStart,
413-
bucketEnd,
414-
searchStartIdx,
415-
);
416-
417-
// Update search start for next bucket (frames are sorted by timeStart)
418-
// Advance searchStartIdx to skip frames that end before this bucket
419-
while (searchStartIdx < frameCount && frames[searchStartIdx]!.timeEnd <= bucketStart) {
420-
searchStartIdx++;
421-
}
422-
423407
// Resolve dominant category using skyline (on-top time) algorithm
424408
const dominantCategory = this.resolveCategoryFromSkyline(
425-
bucketFrames,
409+
framesPerBucket[i]!,
426410
bucketStart,
427411
bucketEnd,
428412
);
@@ -445,56 +429,6 @@ export class MinimapDensityQuery {
445429
};
446430
}
447431

448-
/**
449-
* Binary search to find frames overlapping a time range.
450-
* Uses the fact that frames are sorted by timeStart.
451-
*
452-
* PERF: O(log N + k) where k = frames in range, vs O(N) for pre-collection.
453-
*
454-
* @param frames - Sorted frames array
455-
* @param frameCount - Number of frames
456-
* @param bucketStart - Bucket start time
457-
* @param bucketEnd - Bucket end time
458-
* @param startIdx - Starting index for search (optimization for sequential buckets)
459-
* @returns Frames overlapping the bucket
460-
*/
461-
private getFramesInRange(
462-
frames: SkylineFrame[],
463-
frameCount: number,
464-
bucketStart: number,
465-
bucketEnd: number,
466-
startIdx: number,
467-
): SkylineFrame[] {
468-
// Binary search for first frame that could overlap (timeEnd > bucketStart)
469-
let lo = startIdx;
470-
let hi = frameCount;
471-
472-
while (lo < hi) {
473-
const mid = (lo + hi) >>> 1;
474-
if (frames[mid]!.timeEnd <= bucketStart) {
475-
lo = mid + 1;
476-
} else {
477-
hi = mid;
478-
}
479-
}
480-
481-
// Linear scan from found position to collect overlapping frames
482-
const result: SkylineFrame[] = [];
483-
for (let i = lo; i < frameCount; i++) {
484-
const frame = frames[i]!;
485-
// Frames are sorted by timeStart, so stop when past bucket
486-
if (frame.timeStart >= bucketEnd) {
487-
break;
488-
}
489-
// Check for overlap: frame overlaps bucket if frame.timeEnd > bucketStart
490-
if (frame.timeEnd > bucketStart) {
491-
result.push(frame);
492-
}
493-
}
494-
495-
return result;
496-
}
497-
498432
/**
499433
* Compute density data using per-bucket tree queries.
500434
* O(B × log N) complexity - for each bucket, query the segment tree.

0 commit comments

Comments
 (0)