Files
dc/client/src/app/shared/viewboxes/viewboxes.component.spec.ts
T
YuryShkoda e5a5bf2144
Build / Build-and-ng-test (pull_request) Failing after 1m41s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Successful in 20m36s
feat(viewboxes): add drag-to-resize on all four edges and corners
Adds right, left, bottom, and bottom-left-corner resize handles
alongside the existing bottom-right corner, and raises resize handle
z-index above Handsontable's frozen header clones so clicks reach the
handle instead of the table underneath it.
2026-08-10 15:35:46 +03:00

622 lines
20 KiB
TypeScript

import { ViewboxesComponent } from './viewboxes.component'
/**
* resize()'s debounced callback (run synchronously here, see helperService
* below) also calls viewboxChanged() -> router.navigate() and
* prepareFilterCache() -> this.viewboxes - these stubs cover exactly that
* surface, not the full real services (no TestBed/DI needed, same
* plain-instantiation-with-stubs precedent as app.service.spec.ts).
*/
const buildComponent = () => {
const helperService: any = {
debounceCall: (_time: number, callback: () => void) => callback()
}
const component = new ViewboxesComponent(
{
runOutsideAngular: (fn: () => void) => fn(),
run: (fn: () => void) => fn()
} as any, // NgZone
{ licenceState: { value: {} } } as any, // LicenceService
{} as any, // SasService
{ dispatchEvent: () => {} } as any, // EventService
{} as any, // SasStoreService
{} as any, // LoggerService
helperService,
{ navigate: () => {} } as any, // Router
{} as any, // ActivatedRoute
{} as any // ChangeDetectorRef
)
// setAllHandleTransform() (called synchronously by resize()) iterates
// these two @ViewChildren query lists - empty arrays keep it a no-op
// without needing a real view to populate them.
component.resizeBoxQuery = [] as any
component.dragHandleCornerQuery = [] as any
component.viewboxes = []
return component
}
// Real DOM elements, positioned absolutely with known coordinates - Karma
// runs in an actual browser (ChromeHeadlessCI), so getBoundingClientRect()
// returns genuine, accurate values with no need to mock it.
const positionedDiv = (
left: number,
top: number,
width: number,
height: number
): HTMLDivElement => {
const el = document.createElement('div')
el.style.position = 'absolute'
el.style.left = `${left}px`
el.style.top = `${top}px`
el.style.width = `${width}px`
el.style.height = `${height}px`
document.body.appendChild(el)
return el
}
describe('ViewboxesComponent - resize', () => {
let toRemove: HTMLElement[]
beforeEach(() => {
toRemove = []
})
afterEach(() => {
toRemove.forEach((el) => el.remove())
})
const addDiv = (left: number, top: number, width: number, height: number) => {
const el = positionedDiv(left, top, width, height)
toRemove.push(el)
return el
}
it('computes width/height as the distance from the target corner to the drag handle', () => {
const component = buildComponent()
const target = addDiv(100, 100, 200, 150)
// Positioned so the handle's own bottom-right corner (280+20, 230+20)
// exactly coincides with target's current bottom-right corner
// (100+200, 100+150) - the handle "hasn't moved" from a fresh render,
// so the computed size should match the target's current size exactly.
const dragHandle = addDiv(280, 230, 20, 20)
const result = component.resize(dragHandle, target)
expect(result).toEqual({ width: 200, height: 150 })
})
it('grows the target element when the handle is dragged further from the corner', () => {
const component = buildComponent()
const target = addDiv(100, 100, 200, 150)
// Dragged 100px right and down from the exact-corner position used
// above.
const dragHandle = addDiv(400, 350, 20, 20)
const result = component.resize(dragHandle, target)
expect(result).toEqual({ width: 320, height: 270 })
expect(target.style.width).toBe('320px')
expect(target.style.height).toBe('270px')
})
it('shrinks the target element when the handle is dragged toward the corner', () => {
const component = buildComponent()
const target = addDiv(100, 100, 200, 150)
const dragHandle = addDiv(220, 180, 20, 20)
const result = component.resize(dragHandle, target)
expect(result).toEqual({ width: 140, height: 100 })
})
})
describe('ViewboxesComponent - setHandleTransform', () => {
let toRemove: HTMLElement[]
beforeEach(() => {
toRemove = []
})
afterEach(() => {
toRemove.forEach((el) => el.remove())
})
const addDiv = (width: number, height: number) => {
const el = document.createElement('div')
el.style.position = 'absolute'
el.style.width = `${width}px`
el.style.height = `${height}px`
document.body.appendChild(el)
toRemove.push(el)
return el
}
it('translates the handle to the target rect bottom-right corner, plus a 5px fine-tune, for position "both"', () => {
const component = buildComponent()
const dragHandle = addDiv(20, 20)
const targetRect = { width: 200, height: 150 } as DOMRect
component.setHandleTransform(dragHandle, targetRect, 'both')
// translateX = 200 - 20 + 5 = 185, translateY = 150 - 20 + 5 = 135
expect(dragHandle.style.transform).toBe('translate(185px, 135px)')
})
it('only translates along x for position "x"', () => {
const component = buildComponent()
const dragHandle = addDiv(20, 20)
const targetRect = { width: 200, height: 150 } as DOMRect
component.setHandleTransform(dragHandle, targetRect, 'x')
// The browser normalizes the unitless '0' the source writes back to
// '0px' when the style is read back.
expect(dragHandle.style.transform).toBe('translate(185px, 0px)')
})
it('only translates along y for position "y"', () => {
const component = buildComponent()
const dragHandle = addDiv(20, 20)
const targetRect = { width: 200, height: 150 } as DOMRect
component.setHandleTransform(dragHandle, targetRect, 'y')
expect(dragHandle.style.transform).toBe('translate(0px, 135px)')
})
it('translates to a small fixed left-edge offset for position "left", independent of target size', () => {
const component = buildComponent()
const dragHandle = addDiv(20, 20)
const targetRect = { width: 200, height: 150 } as DOMRect
component.setHandleTransform(dragHandle, targetRect, 'left')
expect(dragHandle.style.transform).toBe('translate(-5px, 0px)')
})
it('combines the left-edge x offset with the bottom-edge y calculation for position "bottomLeft"', () => {
const component = buildComponent()
const dragHandle = addDiv(20, 20)
const targetRect = { width: 200, height: 150 } as DOMRect
component.setHandleTransform(dragHandle, targetRect, 'bottomLeft')
// translateY = 150 - 20 + 5 = 135, same calculation as position "y"
expect(dragHandle.style.transform).toBe('translate(-5px, 135px)')
})
})
describe('ViewboxesComponent - parseHandleTranslate', () => {
it('returns {x: 0, y: 0} for an empty/unset transform', () => {
const component: any = buildComponent()
expect(component.parseHandleTranslate('')).toEqual({ x: 0, y: 0 })
})
it('parses an existing translate(...) transform, including negative values', () => {
const component: any = buildComponent()
expect(component.parseHandleTranslate('translate(12px, -34px)')).toEqual({
x: 12,
y: -34
})
})
})
describe('ViewboxesComponent - startResize', () => {
let toRemove: HTMLElement[]
beforeEach(() => {
toRemove = []
})
afterEach(() => {
document.dispatchEvent(new PointerEvent('pointerup'))
toRemove.forEach((el) => el.remove())
})
const addDiv = (left: number, top: number, width: number, height: number) => {
const el = document.createElement('div')
el.style.position = 'absolute'
el.style.left = `${left}px`
el.style.top = `${top}px`
el.style.width = `${width}px`
el.style.height = `${height}px`
document.body.appendChild(el)
toRemove.push(el)
return el
}
it('grows the target as the pointer moves after pointerdown on the handle', () => {
const component = buildComponent()
const target = addDiv(100, 100, 200, 150)
// Positioned exactly at target's current bottom-right corner - see the
// identical setup in the "resize" describe block above.
const dragHandle = addDiv(280, 230, 20, 20)
const viewbox: any = { width: 200, height: 150 }
const pointerDown = new PointerEvent('pointerdown', {
clientX: 300,
clientY: 250
})
component.startResize(pointerDown, dragHandle, target, viewbox)
document.dispatchEvent(
new PointerEvent('pointermove', { clientX: 400, clientY: 350 })
)
expect(viewbox.width).toBe(300)
expect(viewbox.height).toBe(250)
})
it('stops tracking the pointer once pointerup fires', () => {
const component = buildComponent()
const target = addDiv(100, 100, 200, 150)
const dragHandle = addDiv(280, 230, 20, 20)
const viewbox: any = { width: 200, height: 150 }
const pointerDown = new PointerEvent('pointerdown', {
clientX: 300,
clientY: 250
})
component.startResize(pointerDown, dragHandle, target, viewbox)
document.dispatchEvent(new PointerEvent('pointerup'))
const widthAfterPointerUp = viewbox.width
// A move after pointerup must not still be tracked - the listener was
// meant to be removed.
document.dispatchEvent(
new PointerEvent('pointermove', { clientX: 600, clientY: 600 })
)
expect(viewbox.width).toBe(widthAfterPointerUp)
})
})
describe('ViewboxesComponent - resizeWidth/resizeHeight (edge-only resize)', () => {
let toRemove: HTMLElement[]
beforeEach(() => {
toRemove = []
})
afterEach(() => {
toRemove.forEach((el) => el.remove())
})
const addDiv = (left: number, top: number, width: number, height: number) => {
const el = document.createElement('div')
el.style.position = 'absolute'
el.style.left = `${left}px`
el.style.top = `${top}px`
el.style.width = `${width}px`
el.style.height = `${height}px`
document.body.appendChild(el)
toRemove.push(el)
return el
}
it('resizeWidth grows/shrinks width from the target left edge, leaving height untouched', () => {
const component = buildComponent()
const target = addDiv(0, 0, 200, 150)
const dragHandle = addDiv(220, 0, 10, 10)
const width = component.resizeWidth(dragHandle, target)
expect(width).toBe(230)
expect(target.style.width).toBe('230px')
expect(target.style.height).toBe('150px')
})
it('resizeHeight grows/shrinks height from the target top edge, leaving width untouched', () => {
const component = buildComponent()
const target = addDiv(0, 0, 200, 150)
const dragHandle = addDiv(0, 170, 10, 10)
const height = component.resizeHeight(dragHandle, target)
expect(height).toBe(180)
expect(target.style.height).toBe('180px')
expect(target.style.width).toBe('200px')
})
})
describe('ViewboxesComponent - resizeFromLeftEdge', () => {
let toRemove: HTMLElement[]
beforeEach(() => {
toRemove = []
})
afterEach(() => {
toRemove.forEach((el) => el.remove())
})
const addDiv = (left: number, top: number, width: number, height: number) => {
const el = document.createElement('div')
el.style.position = 'absolute'
el.style.left = `${left}px`
el.style.top = `${top}px`
el.style.width = `${width}px`
el.style.height = `${height}px`
document.body.appendChild(el)
toRemove.push(el)
return el
}
it('grows width and moves x left by the same amount when dragged left (right edge stays put)', () => {
const component = buildComponent()
const target = addDiv(100, 100, 200, 150) // right edge = 300
const result = component.resizeFromLeftEdge(
/* pointerClientX */ 50,
target,
/* startTargetRight */ 300,
/* startX */ 10,
/* startWidth */ 200
)
// intended width = 300 - 50 = 250 (grew by 50)
expect(result.width).toBe(250)
// x moves left by exactly how much width grew, so the right edge stays
// visually fixed.
expect(result.x).toBe(-40) // 10 + (200 - 250)
})
it('shrinks width and moves x right when dragged right, without exceeding min-width', () => {
const component = buildComponent()
const target = addDiv(100, 100, 200, 150)
const result = component.resizeFromLeftEdge(250, target, 300, 10, 200)
// intended width = 300 - 250 = 50
expect(result.width).toBe(50)
expect(result.x).toBe(160) // 10 + (200 - 50)
})
it("does not let x drift past what the target's CSS min-width actually allows", () => {
const component = buildComponent()
const target = addDiv(100, 100, 200, 150)
// Simulates .viewbox's real min-width: 200px constraint, which a plain
// test div doesn't have unless set explicitly.
target.style.minWidth = '200px'
const result = component.resizeFromLeftEdge(250, target, 300, 10, 200) // would intend width 50
// The browser clamps target's rendered width to 200px (min-width) -
// resizeFromLeftEdge must read that back rather than trust its own
// unclamped math, or x would drift while the box visibly stops moving.
expect(result.width).toBe(200)
expect(result.x).toBe(10)
})
it('does not compound growth across repeated moves even if the target itself gets repositioned via a transform in between (what CDK does as viewbox.x changes)', () => {
const component = buildComponent()
const target = addDiv(100, 100, 200, 150) // right edge = 300
const startTargetRight = 300
const startX = 10
const startWidth = 200
// First pointermove: dragged 20px left from an implied drag-start of
// clientX=100.
const first = component.resizeFromLeftEdge(
80,
target,
startTargetRight,
startX,
startWidth
)
expect(first.width).toBe(220)
// Simulate CDK repositioning the box in response to first.x - a real
// transform on target, exactly like [cdkDragFreeDragPosition] applies.
// This must not feed back into the next call's width.
target.style.transform = `translate(${first.x - startX}px, 0)`
// Second pointermove: another 20px left, same speed as the first move.
const second = component.resizeFromLeftEdge(
60,
target,
startTargetRight,
startX,
startWidth
)
// Constant pointer speed must produce constant growth - not more the
// second time just because the box moved in response to the first.
expect(second.width - first.width).toBe(20)
expect(second.width).toBe(240)
})
})
describe('ViewboxesComponent - startResizeRight/startResizeBottom (edge handles)', () => {
let toRemove: HTMLElement[]
beforeEach(() => {
toRemove = []
})
afterEach(() => {
document.dispatchEvent(new PointerEvent('pointerup'))
toRemove.forEach((el) => el.remove())
})
const addDiv = (left: number, top: number, width: number, height: number) => {
const el = document.createElement('div')
el.style.position = 'absolute'
el.style.left = `${left}px`
el.style.top = `${top}px`
el.style.width = `${width}px`
el.style.height = `${height}px`
document.body.appendChild(el)
toRemove.push(el)
return el
}
it('startResizeRight only changes width as the pointer moves', () => {
const component = buildComponent()
const target = addDiv(100, 100, 200, 150) // right edge = 300
// Handle's own bottom-right-ish corner (290+10) exactly at target's
// current right edge (300) - the "hasn't moved yet" baseline, same
// convention as the resize()/resizeWidth() tests above.
const dragHandle = addDiv(290, 100, 10, 10)
const viewbox: any = { width: 200, height: 150, x: 0, y: 0 }
const pointerDown = new PointerEvent('pointerdown', {
clientX: 300,
clientY: 100
})
component.startResizeRight(pointerDown, dragHandle, target, viewbox)
document.dispatchEvent(
new PointerEvent('pointermove', { clientX: 400, clientY: 100 })
)
expect(viewbox.width).toBe(300)
expect(viewbox.height).toBe(150)
expect(viewbox.x).toBe(0)
})
it('startResizeBottom only changes height as the pointer moves', () => {
const component = buildComponent()
const target = addDiv(100, 100, 200, 150) // bottom edge = 250
// Handle's own bottom edge (240+10) exactly at target's current bottom
// edge (250) - the "hasn't moved yet" baseline.
const dragHandle = addDiv(100, 240, 10, 10)
const viewbox: any = { width: 200, height: 150, x: 0, y: 0 }
const pointerDown = new PointerEvent('pointerdown', {
clientX: 100,
clientY: 250
})
component.startResizeBottom(pointerDown, dragHandle, target, viewbox)
document.dispatchEvent(
new PointerEvent('pointermove', { clientX: 100, clientY: 350 })
)
expect(viewbox.height).toBe(250)
expect(viewbox.width).toBe(200)
})
it('stops tracking the pointer for startResizeRight once pointerup fires', () => {
const component = buildComponent()
const target = addDiv(100, 100, 200, 150)
const dragHandle = addDiv(290, 100, 10, 10)
const viewbox: any = { width: 200, height: 150, x: 0, y: 0 }
const pointerDown = new PointerEvent('pointerdown', {
clientX: 300,
clientY: 100
})
component.startResizeRight(pointerDown, dragHandle, target, viewbox)
document.dispatchEvent(new PointerEvent('pointerup'))
const widthAfterPointerUp = viewbox.width
document.dispatchEvent(
new PointerEvent('pointermove', { clientX: 600, clientY: 100 })
)
expect(viewbox.width).toBe(widthAfterPointerUp)
})
})
describe('ViewboxesComponent - startResizeLeft/startResizeBottomLeft', () => {
let toRemove: HTMLElement[]
beforeEach(() => {
toRemove = []
})
afterEach(() => {
document.dispatchEvent(new PointerEvent('pointerup'))
toRemove.forEach((el) => el.remove())
})
const addDiv = (left: number, top: number, width: number, height: number) => {
const el = document.createElement('div')
el.style.position = 'absolute'
el.style.left = `${left}px`
el.style.top = `${top}px`
el.style.width = `${width}px`
el.style.height = `${height}px`
document.body.appendChild(el)
toRemove.push(el)
return el
}
it('startResizeLeft grows width and moves x left, leaving height untouched', () => {
const component = buildComponent()
const target = addDiv(100, 100, 200, 150) // right edge = 300
const dragHandle = addDiv(100, 100, 10, 10)
const viewbox: any = { width: 200, height: 150, x: 10, y: 0 }
const pointerDown = new PointerEvent('pointerdown', {
clientX: 100,
clientY: 100
})
component.startResizeLeft(pointerDown, dragHandle, target, viewbox)
// Dragged 50px left.
document.dispatchEvent(
new PointerEvent('pointermove', { clientX: 50, clientY: 100 })
)
expect(viewbox.width).toBe(250)
expect(viewbox.x).toBe(-40) // 10 + (200 - 250)
expect(viewbox.height).toBe(150)
})
it('startResizeBottomLeft grows width+x and height together', () => {
const component = buildComponent()
const target = addDiv(100, 100, 200, 150) // bottom edge = 250
// top=240 so the handle's own bottom edge (240+10) starts exactly at
// target's current bottom edge (250) - the height "hasn't moved yet"
// baseline (left=100 needs no equivalent adjustment - resizeFromLeftEdge
// only reads dragRect.left, never dragRect.width, see its own tests).
const dragHandle = addDiv(100, 240, 10, 10)
const viewbox: any = { width: 200, height: 150, x: 10, y: 0 }
const pointerDown = new PointerEvent('pointerdown', {
clientX: 100,
clientY: 250
})
component.startResizeBottomLeft(pointerDown, dragHandle, target, viewbox)
// Dragged 50px left and 50px down.
document.dispatchEvent(
new PointerEvent('pointermove', { clientX: 50, clientY: 300 })
)
expect(viewbox.width).toBe(250)
expect(viewbox.x).toBe(-40)
expect(viewbox.height).toBe(200)
})
it('stops tracking the pointer for startResizeLeft once pointerup fires', () => {
const component = buildComponent()
const target = addDiv(100, 100, 200, 150)
const dragHandle = addDiv(100, 100, 10, 10)
const viewbox: any = { width: 200, height: 150, x: 10, y: 0 }
const pointerDown = new PointerEvent('pointerdown', {
clientX: 100,
clientY: 100
})
component.startResizeLeft(pointerDown, dragHandle, target, viewbox)
document.dispatchEvent(new PointerEvent('pointerup'))
const xAfterPointerUp = viewbox.x
document.dispatchEvent(
new PointerEvent('pointermove', { clientX: -200, clientY: 100 })
)
expect(viewbox.x).toBe(xAfterPointerUp)
})
})