fix(ComparisonView): fix character morphing thresholds and add tracking support

This commit is contained in:
Ilia Mashkov
2026-04-20 10:52:28 +03:00
parent f9f96e2797
commit 141126530d
4 changed files with 138 additions and 91 deletions
@@ -109,56 +109,52 @@ describe('CharacterComparisonEngine', () => {
expect(r2).not.toBe(r1);
});
it('getCharState returns proximity 1 when slider is exactly over char center', () => {
// 'A' only: FontA width=10. Container=500px. Line centered.
// lineXOffset = (500 - maxWidth) / 2. maxWidth = max(10, 15) = 15 (FontB is wider).
// charCenterX = lineXOffset + xA + widthA/2.
// Using xA=0, widthA=10: charCenterX = (500-15)/2 + 0 + 5 = 247.5 + 5 = 252.5
// charGlobalPercent = (252.5 / 500) * 100 = 50.5
// distance = |50.5 - 50.5| = 0 => proximity = 1
it('getLineCharStates returns proximity 1 when slider is exactly over char center', () => {
const containerWidth = 500;
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', containerWidth, 20);
// Recalculate expected percent manually:
const lineWidth = Math.max(FONT_A_WIDTH, FONT_B_WIDTH); // 15 (unified worst-case)
const lineXOffset = (containerWidth - lineWidth) / 2;
const charCenterX = lineXOffset + 0 + FONT_A_WIDTH / 2;
const charPercent = (charCenterX / containerWidth) * 100;
const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', containerWidth, 20);
// Single char: no neighbors → totalWidth = widthA, threshold = containerWidth/2.
// When isPast=false, visual center = (containerWidth - widthB)/2 + widthB/2 = containerWidth/2.
// So proximity=1 at exactly 50%.
const charPercent = 50;
const state = engine.getCharState(0, 0, charPercent, containerWidth);
expect(state.proximity).toBe(1);
expect(state.isPast).toBe(false);
const states = engine.getLineCharStates(result.lines[0], charPercent, containerWidth);
expect(states[0]?.proximity).toBe(1);
expect(states[0]?.isPast).toBe(false);
});
it('getCharState returns proximity 0 when slider is far from char', () => {
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
// Slider at 0%, char is near 50% — distance > 5 range => proximity = 0
const state = engine.getCharState(0, 0, 0, 500);
expect(state.proximity).toBe(0);
it('getLineCharStates returns proximity 0 when slider is far from char', () => {
const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const states = engine.getLineCharStates(result.lines[0], 0, 500);
expect(states[0]?.proximity).toBe(0);
});
it('getCharState isPast is true when slider has passed char center', () => {
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const state = engine.getCharState(0, 0, 100, 500);
expect(state.isPast).toBe(true);
it('getLineCharStates isPast is true when slider has passed char center', () => {
const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const states = engine.getLineCharStates(result.lines[0], 100, 500);
expect(states[0]?.isPast).toBe(true);
});
it('getCharState returns safe default for out-of-range lineIndex', () => {
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const state = engine.getCharState(99, 0, 50, 500);
expect(state.proximity).toBe(0);
expect(state.isPast).toBe(false);
it('getLineCharStates returns empty array for out-of-range lineIndex', () => {
const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
// Passing an undefined object because the index doesn't exist.
const states = engine.getLineCharStates(result.lines[99], 50, 500);
expect(states).toEqual([]);
});
it('getCharState returns safe default for out-of-range charIndex', () => {
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const state = engine.getCharState(0, 99, 50, 500);
expect(state.proximity).toBe(0);
expect(state.isPast).toBe(false);
it('getLineCharStates returns empty array before layout() has been called', () => {
// Passing an undefined object because layout() hasn't been called.
const states = engine.getLineCharStates(undefined as any, 50, 500);
expect(states).toEqual([]);
});
it('getCharState returns safe default before layout() has been called', () => {
const state = engine.getCharState(0, 0, 50, 500);
expect(state.proximity).toBe(0);
expect(state.isPast).toBe(false);
it('getLineCharStates returns safe defaults for all chars', () => {
const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const states = engine.getLineCharStates(result.lines[0], 50, 500);
expect(states.length).toBeGreaterThan(0);
for (const s of states) {
expect(s.proximity).toBeGreaterThanOrEqual(0);
expect(s.proximity).toBeLessThanOrEqual(1);
expect(typeof s.isPast).toBe('boolean');
}
});
});