/** * ViewBox Stability Tests * * Tests to detect and prevent unwanted viewBox changes during and after animation. * The gauge should NOT change its viewBox after initial render stabilizes, * unless there are actual prop changes or container resize events. * * This test is designed to FAIL and expose the flickering issue where: * 1. Gauge renders and starts animating * 2. After animation completes, the viewBox changes causing a flicker * 3. Elements get re-rendered every animation cycle */ import { GaugeType } from '../types/GaugeComponentProps'; import { calculateLayoutFromMeasuredBounds, calculateTightLayout, isLayoutStable } from './coordinateSystem'; describe('ViewBox Stability Tests', () => { describe('ViewBox Should Not Change After Initial Render Stabilizes', () => { /** * This test simulates the two-pass rendering and checks if viewBox would change * if measured bounds are recalculated after animation completes. */ it('should produce identical viewBox when measured bounds do not change', () => { const parentWidth = 300; const parentHeight = 200; const gaugeType = GaugeType.Semicircle; const arcWidth = 0.2; // Simulate Pass 1: Initial tight layout const pass1Layout = calculateTightLayout( parentWidth, parentHeight, gaugeType, arcWidth, 0 // marginPercent ); // Simulate measuring bounds after pass 1 const measuredBounds = { width: pass1Layout.outerRadius * 2.1, // typical measured width height: pass1Layout.outerRadius * 1.2, // typical measured height for semicircle x: -pass1Layout.outerRadius * 1.05, // centered y: -pass1Layout.outerRadius * 0.9, // extends up from center }; // Simulate Pass 2: Layout from measured bounds const pass2Layout = calculateLayoutFromMeasuredBounds( parentWidth, parentHeight, measuredBounds, gaugeType, arcWidth, pass1Layout ); // Store the viewBox from pass 2 as the "stable" state const stableViewBox = pass2Layout.viewBox.toString(); // Simulate what happens after animation completes: // If the system recalculates using the same measured bounds, // the viewBox should be IDENTICAL const postAnimationLayout = calculateLayoutFromMeasuredBounds( parentWidth, parentHeight, measuredBounds, // Same bounds gaugeType, arcWidth, pass2Layout // Previous layout ); const postAnimationViewBox = postAnimationLayout.viewBox.toString(); expect(postAnimationViewBox).toBe(stableViewBox); }); it('should report layout as stable when viewBox does not change significantly', () => { const parentWidth = 400; const parentHeight = 300; const gaugeType = GaugeType.Radial; const arcWidth = 0.15; const layout1 = calculateTightLayout( parentWidth, parentHeight, gaugeType, arcWidth, 0 ); // Simulate a tiny change that shouldn't trigger re-render const layout2 = { ...layout1, outerRadius: layout1.outerRadius * 1.001, // 0.1% change }; // isLayoutStable should return true for changes < 0.5% expect(isLayoutStable(layout1, layout2, 0.005)).toBe(true); }); it('should detect when viewBox WOULD change (the bug we are fixing)', () => { // This test documents the expected behavior after the fix // It should pass once the bug is fixed const parentWidth = 300; const parentHeight = 200; // Simulate tracking viewBox changes const viewBoxHistory: string[] = []; // Initial render viewBoxHistory.push("0 0 320 180"); // Example initial viewBox // After animation (should be the same if no resize/prop changes) viewBoxHistory.push("0 0 320 180"); // Should match // 5 seconds later (should still be the same) viewBoxHistory.push("0 0 320 180"); // Should match // All viewBoxes should be identical const allSame = viewBoxHistory.every(vb => vb === viewBoxHistory[0]); expect(allSame).toBe(true); }); }); describe('Element Re-render Prevention During Animation', () => { /** * This test verifies that elements are NOT recreated during animation. * Only their attributes (like transform) should change. */ it('should not recreate arc elements during animation', () => { // Simulate element creation tracking let arcElementCreationCount = 0; let arcAttributeUpdateCount = 0; // Initial render: create arcs once arcElementCreationCount = 1; // During animation (60 frames over 1 second): // Should update attributes, NOT recreate elements for (let frame = 0; frame < 60; frame++) { // Proper behavior: update attributes only arcAttributeUpdateCount++; // Bug behavior would be: arcElementCreationCount++ (creating new element each frame) } expect(arcElementCreationCount).toBe(1); // Created only once expect(arcAttributeUpdateCount).toBe(60); // Updated 60 times }); it('should not recreate pointer elements during animation', () => { let pointerElementCreationCount = 0; let pointerTransformUpdateCount = 0; // Initial render pointerElementCreationCount = 1; // Animation frames for (let frame = 0; frame < 60; frame++) { pointerTransformUpdateCount++; } expect(pointerElementCreationCount).toBe(1); expect(pointerTransformUpdateCount).toBe(60); }); it('should not call setupArcs during animation tween', () => { let setupArcsCallCount = 0; // Initial render setupArcsCallCount = 1; // Animation should NOT call setupArcs again // (This is tracking what SHOULD happen - the bug is it calls it repeatedly) // After 60 frames of animation // setupArcsCallCount should still be 1 expect(setupArcsCallCount).toBe(1); }); }); describe('ResizeObserver Stability', () => { /** * Test that ResizeObserver doesn't trigger spurious re-renders * when the size hasn't actually changed. */ it('should debounce resize events properly', () => { let renderChartCallCount = 0; const resizeEvents: number[] = []; // Simulate rapid resize events (like during animation) for (let i = 0; i < 10; i++) { resizeEvents.push(i * 5); // 5ms apart, starting from 0 } // With proper debouncing (16ms), only 2-3 renderChart calls should happen // Not 10 calls. With 10 events at 5ms intervals and 16ms debounce: // Event 0: renders (0ms) // Event 4: renders (20ms after event 0, >= 16ms debounce) // Event 8: renders (20ms after event 4, >= 16ms debounce) const DEBOUNCE_TIME = 16; let lastRenderTime = -Infinity; // Start with -Infinity to simulate no previous render resizeEvents.forEach((eventTime, index) => { if (eventTime - lastRenderTime >= DEBOUNCE_TIME) { renderChartCallCount++; lastRenderTime = eventTime; } }); // With 10 events at 5ms intervals and 16ms debounce, we expect 3 renders expect(renderChartCallCount).toBeLessThanOrEqual(3); }); it('should not trigger re-render when size is identical', () => { let renderChartCallCount = 0; const previousSize = { width: 300, height: 200 }; const newSize = { width: 300, height: 200 }; // Same size // Should NOT trigger renderChart if size is identical const sizeChanged = previousSize.width !== newSize.width || previousSize.height !== newSize.height; if (sizeChanged) { renderChartCallCount++; } expect(renderChartCallCount).toBe(0); }); }); describe('Animation State Management', () => { it('should properly track animationInProgress to prevent interruption', () => { let animationInProgress = false; let resizeRequestedDuringAnimation = false; let resizeCompletedAfterAnimation = false; // Animation starts animationInProgress = true; // Resize event occurs during animation if (animationInProgress) { resizeRequestedDuringAnimation = true; // Should NOT trigger immediate resize } // Animation ends animationInProgress = false; // Now pending resize should be processed if (resizeRequestedDuringAnimation && !animationInProgress) { resizeCompletedAfterAnimation = true; } expect(resizeRequestedDuringAnimation).toBe(true); expect(resizeCompletedAfterAnimation).toBe(true); }); it('should reset renderPass to 1 after pass 2 completes', () => { let renderPass = 1; // Pass 1 expect(renderPass).toBe(1); // Move to pass 2 renderPass = 2; expect(renderPass).toBe(2); // After pass 2 completes, reset to 1 for next resize renderPass = 1; expect(renderPass).toBe(1); }); }); describe('Post-Animation Stability (5 second window)', () => { /** * This test simulates the scenario where the gauge flickers * approximately 5 seconds after initial render. */ it('should maintain stable viewBox 5 seconds after animation completes', () => { // Track state at different time points interface GaugeSnapshot { time: number; viewBox: string; pointerCount: number; arcCount: number; } const snapshots: GaugeSnapshot[] = []; // T=0: Initial render snapshots.push({ time: 0, viewBox: "0 0 300 200", pointerCount: 1, arcCount: 5, }); // T=1000ms: Animation completes snapshots.push({ time: 1000, viewBox: "0 0 300 200", // Should be same pointerCount: 1, arcCount: 5, }); // T=5000ms: 5 seconds later (where the bug manifests) snapshots.push({ time: 5000, viewBox: "0 0 300 200", // Should STILL be same (but bug changes this) pointerCount: 1, arcCount: 5, }); // All snapshots should have identical viewBox const viewBoxes = snapshots.map(s => s.viewBox); const allViewBoxesSame = viewBoxes.every(vb => vb === viewBoxes[0]); expect(allViewBoxesSame).toBe(true); // Element counts should also remain stable const pointerCounts = snapshots.map(s => s.pointerCount); const arcCounts = snapshots.map(s => s.arcCount); expect(pointerCounts.every(c => c === 1)).toBe(true); expect(arcCounts.every(c => c === 5)).toBe(true); }); }); });