Files
upstream-tldraw/packages/dotcom-shared/src/shapeClusters.test.ts
upstream-mirror a774ac673a
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
2026-09-23 16:12:52 +05:00

334 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { TLShape } from '@tldraw/tlschema'
import { describe, expect, it } from 'vitest'
import {
boundsGapDistance,
getShapeText,
labelClusters,
ClusterBounds,
computeAutoEps,
getShapeClusters,
MAX_CLUSTERING_ATOMS,
} from './shapeClusters'
const PAGE = 'page:a'
const shape = (id: string, parentId: string = PAGE) => ({ id, parentId }) as unknown as TLShape
const arrow = (id: string) => ({ id, parentId: PAGE, type: 'arrow' }) as unknown as TLShape
const box = (minX: number, minY: number, w = 10, h = 10): ClusterBounds => ({
minX,
minY,
maxX: minX + w,
maxY: minY + h,
})
/** A row of top-level shapes at the given x positions, with matching bounds. */
function row(positions: Record<string, number>) {
const shapes = Object.keys(positions).map((id) => shape(id))
const bounds = Object.fromEntries(Object.entries(positions).map(([id, x]) => [id, box(x, 0)]))
return { shapes, bounds }
}
const idsOf = (clusters: { shapes: TLShape[] }[]) =>
clusters
.map((c) =>
c.shapes
.map((s) => s.id)
.sort()
.join(',')
)
.sort()
describe('getShapeClusters without bounds', () => {
// The Worker has no editor, so hierarchy is all the structure available to it.
it('makes each top-level shape its own cluster', () => {
const clusters = getShapeClusters([shape('a'), shape('b'), shape('c')], PAGE)
expect(idsOf(clusters)).toEqual(['a', 'b', 'c'])
})
it('keeps a frame and its descendants together', () => {
const clusters = getShapeClusters(
[shape('frame'), shape('child', 'frame'), shape('grandchild', 'child'), shape('loose')],
PAGE
)
expect(idsOf(clusters)).toEqual(['child,frame,grandchild', 'loose'])
})
})
describe('getShapeClusters with bounds', () => {
it('merges atoms that sit near each other', () => {
const { shapes, bounds } = row({ a: 0, b: 20, c: 40, far: 3000 })
expect(idsOf(getShapeClusters(shapes, PAGE, bounds))).toEqual(['a,b,c', 'far'])
})
// The property that distinguishes single linkage: membership chains, so a long row is one
// cluster even though its ends are far apart.
it('chains through intermediates rather than requiring mutual closeness', () => {
const { shapes, bounds } = row({ a: 0, b: 40, c: 80, d: 120 })
expect(boundsGapDistance(bounds.a, bounds.d)).toBe(110)
expect(idsOf(getShapeClusters(shapes, PAGE, bounds))).toEqual(['a,b,c,d'])
})
it('splits where the gap is large relative to the page', () => {
const { shapes, bounds } = row({ a: 0, b: 20, c: 40, x: 2000, y: 2020, z: 2040 })
expect(idsOf(getShapeClusters(shapes, PAGE, bounds))).toEqual(['a,b,c', 'x,y,z'])
})
it('carries a frame’s descendants into the merged cluster', () => {
const shapes = [shape('frame'), shape('child', 'frame'), shape('near')]
const bounds = { frame: box(0, 0, 100, 100), child: box(10, 10), near: box(110, 0) }
expect(idsOf(getShapeClusters(shapes, PAGE, bounds))).toEqual(['child,frame,near'])
})
it('puts every shape in exactly one cluster', () => {
const { shapes, bounds } = row({ a: 0, b: 20, lonely: 9000 })
const clusters = getShapeClusters(shapes, PAGE, bounds)
expect(clusters.flatMap((c) => c.shapes.map((s) => s.id)).sort()).toEqual(['a', 'b', 'lonely'])
})
// A shape the caller could not measure has no position to cluster on, so it is left alone rather
// than given an invented one.
it('leaves an unmeasured atom as its own cluster', () => {
const { shapes, bounds } = row({ a: 0, b: 20 })
shapes.push(shape('unmeasured'))
expect(idsOf(getShapeClusters(shapes, PAGE, bounds))).toEqual(['a,b', 'unmeasured'])
})
it('is independent of the order shapes arrive in', () => {
const { shapes, bounds } = row({ a: 0, b: 20, far: 3000 })
expect(idsOf(getShapeClusters([...shapes].reverse(), PAGE, bounds))).toEqual(
idsOf(getShapeClusters(shapes, PAGE, bounds))
)
})
// Ids are derived from membership, so the same page always produces the same handles — that is
// what lets one MCP call hand an id to the next.
it('gives the same ids on every call, and new ids when membership changes', () => {
const { shapes, bounds } = row({ a: 0, b: 20, far: 3000 })
const first = getShapeClusters(shapes, PAGE, bounds).map((c) => c.id)
expect(getShapeClusters(shapes, PAGE, bounds).map((c) => c.id)).toEqual(first)
const regrouped = getShapeClusters(shapes, PAGE, { ...bounds, far: box(40, 0) })
expect(regrouped).toHaveLength(1)
expect(regrouped[0].id).not.toBe(first[0])
})
it('returns atoms unmerged past the cap rather than doing the O(n²) work', () => {
const positions: Record<string, number> = {}
for (let i = 0; i <= MAX_CLUSTERING_ATOMS; i++) positions[`s${i}`] = i * 5
const { shapes, bounds } = row(positions)
expect(getShapeClusters(shapes, PAGE, bounds)).toHaveLength(shapes.length)
})
it('handles empty and single-shape pages', () => {
expect(getShapeClusters([], PAGE, {})).toEqual([])
expect(getShapeClusters([shape('only')], PAGE, { only: box(0, 0) })).toHaveLength(1)
})
})
// An arrow drawn between two distant groups has bounds spanning the whole gap, so its gap distance
// to both ends is zero. With single-linkage chaining, one arrow would otherwise pull two unrelated
// groups — and everything they touch — into a single cluster.
describe('arrows do not bridge clusters', () => {
it('keeps two distant groups apart when an arrow spans them', () => {
const shapes = [shape('a1'), shape('a2'), shape('b1'), shape('b2'), arrow('link')]
const bounds = {
a1: box(0, 0),
a2: box(20, 0),
b1: box(3000, 0),
b2: box(3020, 0),
// Spans the gap, touching both groups.
link: box(10, 0, 3000, 2),
}
const clusters = idsOf(getShapeClusters(shapes, PAGE, bounds))
expect(clusters).toContain('a1,a2')
expect(clusters).toContain('b1,b2')
})
it('still gives the arrow a cluster of its own, so every shape stays addressable', () => {
const shapes = [shape('a'), arrow('link')]
const bounds = { a: box(0, 0), link: box(10, 0, 3000, 2) }
const all = getShapeClusters(shapes, PAGE, bounds).flatMap((c) => c.shapes.map((s) => s.id))
expect(all.sort()).toEqual(['a', 'link'])
})
// Arrows are left out of the threshold too: their long thin boxes sit on top of their endpoints
// and would drag the median nearest-neighbour distance down.
it('excludes arrows from the eps calculation', () => {
const shapes = [shape('a'), shape('b'), arrow('link')]
const withArrow = { a: box(0, 0), b: box(600, 0), link: box(5, 0, 600, 2) }
const withoutArrow = { a: box(0, 0), b: box(600, 0) }
expect(idsOf(getShapeClusters(shapes, PAGE, withArrow)).filter((k) => k !== 'link')).toEqual(
idsOf(getShapeClusters([shape('a'), shape('b')], PAGE, withoutArrow))
)
})
})
describe('containment', () => {
it('pulls a shape fully inside another into its cluster', () => {
const shapes = [shape('outer'), shape('inner'), shape('far')]
const bounds = { outer: box(0, 0, 500, 500), inner: box(100, 100, 50, 50), far: box(9000, 0) }
expect(idsOf(getShapeClusters(shapes, PAGE, bounds))).toContain('inner,outer')
})
// Containment cannot bridge: a container only ever absorbs what it encloses.
it('does not merge two shapes that merely overlap', () => {
const shapes = [shape('a'), shape('b')]
const bounds = { a: box(0, 0, 100, 100), b: box(90, 0, 100, 100) }
// These two are adjacent, so proximity may merge them — but not via containment.
expect(getShapeClusters(shapes, PAGE, bounds).length).toBeLessThanOrEqual(2)
})
})
describe('computeAutoEps', () => {
it('scales with the spacing of the page', () => {
const tight = Array.from({ length: 6 }, (_, i) => box(i * 20, 0))
const sparse = Array.from({ length: 6 }, (_, i) => box(i * 400, 0))
expect(computeAutoEps(sparse)).toBeGreaterThan(computeAutoEps(tight))
})
// The point of deriving eps: the same arrangement at a different scale clusters the same way,
// rather than a fixed threshold swallowing a zoomed-out board whole.
it('clusters a layout the same way at 10x scale', () => {
const small = row({ a: 0, b: 20, c: 600 })
const large = {
shapes: small.shapes,
bounds: { a: box(0, 0, 100, 100), b: box(200, 0, 100, 100), c: box(6000, 0, 100, 100) },
}
expect(idsOf(getShapeClusters(large.shapes, PAGE, large.bounds))).toEqual(
idsOf(getShapeClusters(small.shapes, PAGE, small.bounds))
)
})
it('stays within its clamps', () => {
expect(computeAutoEps([box(0, 0), box(0, 0)])).toBeGreaterThanOrEqual(8)
expect(computeAutoEps([box(0, 0), box(1e6, 0)])).toBeLessThanOrEqual(5000)
})
})
describe('getShapeText', () => {
const withProps = (props: object) => ({ type: 'text', props }) as unknown as TLShape
it('reads a plain text prop', () => {
expect(getShapeText(withProps({ text: 'hello world' }))).toBe('hello world')
})
it('walks a rich text document for its text nodes', () => {
const rich = withProps({
richText: {
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'first' }] },
{ type: 'paragraph', content: [{ type: 'text', text: 'second' }] },
],
},
})
expect(getShapeText(rich)).toBe('first second')
})
it('returns empty for a shape carrying no text', () => {
expect(getShapeText(withProps({ w: 10, h: 10 }))).toBe('')
})
})
describe('labelClusters', () => {
const text = (value: string) => ({ type: 'text', props: { text: value } }) as unknown as TLShape
const frame = (name: string) => ({ type: 'frame', props: { name } }) as unknown as TLShape
// The reason c-TF-IDF is used at all: a term shared by every cluster carries no information about
// which cluster you are looking at, so at equal frequency it loses to a term confined to one.
it('prefers a distinguishing term over an equally frequent shared one', () => {
const [login, pricing] = labelClusters([
[text('button'), text('password')],
[text('button'), text('invoice')],
[text('button'), text('shipping')],
[text('button'), text('avatar')],
])
expect(login.keywords[0]).toBe('password')
expect(pricing.keywords[0]).toBe('invoice')
})
// The discount is a discount, not a veto: log(1 + n/df) spans only log2..log3 across two clusters,
// so a shared term at twice the frequency still wins. It separates properly as the cluster count
// grows. Worth knowing before reading a two-cluster label as meaningless.
it('lets a much more frequent shared term win when there are only two clusters', () => {
const [labelled] = labelClusters([
[text('button'), text('button'), text('password')],
[text('button'), text('button'), text('invoice')],
])
expect(labelled.keywords[0]).toBe('button')
})
it('uses a frame name verbatim as the label', () => {
const [labelled] = labelClusters([[frame('Login flow'), text('email'), text('password')]])
expect(labelled.label).toBe('Login flow')
expect(labelled.frameNames).toEqual(['Login flow'])
})
it('joins multiple frame names in one cluster', () => {
expect(labelClusters([[frame('Header'), frame('Footer')]])[0].label).toBe('Header + Footer')
})
it('falls back to keywords when there is no frame', () => {
const [labelled] = labelClusters([
[text('checkout payment'), text('checkout payment')],
[text('onboarding tour')],
])
expect(labelled.label).toContain('checkout')
})
it('gives an empty label to a cluster with no text at all', () => {
expect(labelClusters([[{ type: 'geo', props: {} } as unknown as TLShape]])[0]).toEqual({
label: '',
keywords: [],
frameNames: [],
})
})
it('drops stopwords, short tokens and bare numbers', () => {
const [labelled] = labelClusters([[text('the and of a 42 ok signup')]])
expect(labelled.keywords).toContain('signup')
for (const noise of ['the', 'and', 'of', 'a', '42', 'ok']) {
expect(labelled.keywords).not.toContain(noise)
}
})
it('prefers a bigram and suppresses the unigrams it covers', () => {
const [labelled] = labelClusters([
[text('user research'), text('user research')],
[text('billing')],
])
expect(labelled.keywords[0]).toBe('user research')
expect(labelled.keywords).not.toContain('user')
})
// Two unrelated labels sitting next to each other never wrote a phrase between them.
it('does not form bigrams across shape boundaries', () => {
expect(labelClusters([[text('alpha'), text('beta')]])[0].keywords).not.toContain('alpha beta')
})
it('is deterministic for the same input', () => {
const build = () => labelClusters([[text('alpha alpha'), text('beta')], [text('gamma')]])
expect(build()).toEqual(build())
})
})
describe('clusters carry their labels', () => {
it('labels each cluster relative to the others on the page', () => {
const shapes = [
{ id: 'a1', parentId: PAGE, type: 'text', props: { text: 'password reset' } },
{ id: 'a2', parentId: PAGE, type: 'text', props: { text: 'password' } },
{ id: 'b1', parentId: PAGE, type: 'text', props: { text: 'invoice total' } },
{ id: 'b2', parentId: PAGE, type: 'text', props: { text: 'invoice' } },
] as unknown as TLShape[]
const bounds = { a1: box(0, 0), a2: box(20, 0), b1: box(3000, 0), b2: box(3020, 0) }
const clusters = getShapeClusters(shapes, PAGE, bounds)
expect(clusters).toHaveLength(2)
expect(clusters.map((c) => c.label).sort()).toEqual([
'invoice · invoice total',
'password · password reset',
])
})
})