Files
deepseek-harness/packages/client/ui-dockkit/tests/spring.client.spec.ts
T
yudshj 38c1b9b835 fix(ui-dockkit): bound the edge return and keep clicks on projected panels
- A release that reaches the press's legal rectangle only raises, so a
  click on a panel drawn projected no longer records a move or resize.
- The return starts at the drawn panel's speed, and launchVelocity caps
  its outward part so the whole trajectory stays within 120 px.
- A panel larger than the viewport draws at the viewport's size, keeping
  its header controls and resize corner reachable.
- A return ends on an undo, dock or close of its panel, and at its target
  when another panel is pressed; a gesture uses the bounds of a window
  resized mid-gesture; drawn samples outside the velocity window drop.
- Gestures and returns may draw above the top floor; at rest the .float
  rule clamps every panel into the viewport and below the caption floor.
- Web e2e drags a Sidebar float past the right and bottom edges.
- The note is renamed to spring-based edge recovery, compressed, and
  cross-linked to the docking and retained-layout notes.
2026-10-26 09:04:23 +08:00

89 lines
3.8 KiB
TypeScript

/**
* The edge-recovery spring: settling, the inward overshoot bound, frame-rate
* independence, and determinism, all with fixed frame steps.
*/
import { describe, expect, it } from 'vitest'
import { launchVelocity, SPRING_DEADLINE_MS, SPRING_MAX_VELOCITY, springSettled, stepSpring } from '../src/engine/spring.ts'
import type { SpringAxis } from '../src/engine/spring.ts'
/** Analytic overshoot bound for the shipped damping ratio, exp(-pi*zeta/sqrt(1-zeta^2)). */
const OVERSHOOT_BOUND = Math.exp(-Math.PI * 0.72 / Math.sqrt(1 - 0.72 ** 2))
/** Run a return at a fixed frame rate until it rests; positions sampled once per frame. */
function settle(start: SpringAxis, target: number, hz: number): { readonly ms: number; readonly positions: number[] } {
let axis = start
const positions: number[] = []
for (let frame = 1; frame <= 1000; frame++) {
axis = stepSpring(axis, target, 1 / hz)
positions.push(axis.position)
if (springSettled(axis, target)) return { ms: frame * 1000 / hz, positions }
}
throw new Error('spring did not settle')
}
describe('stepSpring', () => {
it('returns a 200 px overflow inside the deadline with an inward overshoot within the analytic bound', () => {
const { ms, positions } = settle({ position: -200, velocity: 0 }, 0, 60)
expect(ms).toBeLessThan(SPRING_DEADLINE_MS)
expect(ms).toBeCloseTo(366.7, 0)
const overshoot = Math.max(...positions) / 200
expect(overshoot).toBeGreaterThan(0)
expect(Math.abs(overshoot - OVERSHOOT_BOUND)).toBeLessThan(0.01)
})
it('moves the same way whatever the frame rate, because it integrates in fixed substeps', () => {
const reference = settle({ position: 120, velocity: 0 }, 0, 60).ms
for (const hz of [30, 120]) {
const { ms } = settle({ position: 120, velocity: 0 }, 0, hz)
expect(Math.abs(ms - reference)).toBeLessThanOrEqual(1000 / 30)
}
})
it('carries a release velocity: a fling outward travels further before it returns', () => {
const still = settle({ position: 60, velocity: 0 }, 0, 60).positions
const flung = settle({ position: 60, velocity: 1500 }, 0, 60).positions
expect(Math.max(...flung)).toBeGreaterThan(Math.max(...still))
})
it('is deterministic for the same inputs', () => {
expect(settle({ position: -90, velocity: 400 }, 10, 60)).toEqual(settle({ position: -90, velocity: 400 }, 10, 60))
})
it('stays put at its target', () => {
expect(stepSpring({ position: 5, velocity: 0 }, 5, 1 / 60)).toEqual({ position: 5, velocity: 0 })
})
})
describe('launchVelocity', () => {
it('keeps an axis that is already at its target still', () => {
expect(launchVelocity(40, 40, 900, 120)).toBe(0)
})
it('passes an inward velocity through unchanged', () => {
expect(launchVelocity(60, 0, -900, 120)).toBe(-900)
expect(launchVelocity(-60, 0, 900, 120)).toBe(900)
})
it('caps an outward velocity on either side, to nothing once the axis is at the reach', () => {
expect(launchVelocity(119, 0, 1500, 120)).toBeLessThan(1500)
expect(launchVelocity(-119, 0, -1500, 120)).toBeGreaterThan(-1500)
expect(launchVelocity(130, 0, 1500, 120)).toBe(0)
})
it('keeps every return within the reach of its target, however fast the fling', () => {
for (let offset = 1; offset < 120; offset += 1) {
const velocity = launchVelocity(offset, 0, SPRING_MAX_VELOCITY, 120)
const { positions } = settle({ position: offset, velocity }, 0, 60)
expect(Math.max(offset, ...positions)).toBeLessThan(120)
}
})
})
describe('springSettled', () => {
it('needs both closeness and slowness', () => {
expect(springSettled({ position: 0.5, velocity: 10 }, 0)).toBe(true)
expect(springSettled({ position: 2, velocity: 0 }, 0)).toBe(false)
expect(springSettled({ position: 0, velocity: 100 }, 0)).toBe(false)
})
})