mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-02 19:49:58 +00:00
[feat] Add node title buttons with icon-only rendering (#1186)
This commit is contained in:
185
test/LGraphButton.test.ts
Normal file
185
test/LGraphButton.test.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { Rectangle } from "@/infrastructure/Rectangle"
|
||||
import { LGraphButton } from "@/LGraphButton"
|
||||
|
||||
describe("LGraphButton", () => {
|
||||
describe("Constructor", () => {
|
||||
it("should create a button with default options", () => {
|
||||
const button = new LGraphButton({})
|
||||
expect(button).toBeInstanceOf(LGraphButton)
|
||||
expect(button.name).toBeUndefined()
|
||||
expect(button._last_area).toBeInstanceOf(Rectangle)
|
||||
})
|
||||
|
||||
it("should create a button with custom name", () => {
|
||||
const button = new LGraphButton({ name: "test_button" })
|
||||
expect(button.name).toBe("test_button")
|
||||
})
|
||||
|
||||
it("should inherit badge properties", () => {
|
||||
const button = new LGraphButton({
|
||||
text: "Test",
|
||||
fgColor: "#FF0000",
|
||||
bgColor: "#0000FF",
|
||||
fontSize: 16,
|
||||
})
|
||||
expect(button.text).toBe("Test")
|
||||
expect(button.fgColor).toBe("#FF0000")
|
||||
expect(button.bgColor).toBe("#0000FF")
|
||||
expect(button.fontSize).toBe(16)
|
||||
expect(button.visible).toBe(true) // visible is computed based on text length
|
||||
})
|
||||
})
|
||||
|
||||
describe("draw", () => {
|
||||
it("should not draw if not visible", () => {
|
||||
const button = new LGraphButton({ text: "" }) // Empty text makes it invisible
|
||||
const ctx = {
|
||||
measureText: vi.fn().mockReturnValue({ width: 100 }),
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
const superDrawSpy = vi.spyOn(Object.getPrototypeOf(Object.getPrototypeOf(button)), "draw")
|
||||
|
||||
button.draw(ctx, 50, 100)
|
||||
|
||||
expect(superDrawSpy).not.toHaveBeenCalled()
|
||||
expect(button._last_area.width).toBe(0) // Rectangle default width
|
||||
})
|
||||
|
||||
it("should draw and update last area when visible", () => {
|
||||
const button = new LGraphButton({
|
||||
text: "Click",
|
||||
xOffset: 5,
|
||||
yOffset: 10,
|
||||
})
|
||||
|
||||
const ctx = {
|
||||
measureText: vi.fn().mockReturnValue({ width: 60 }),
|
||||
fillRect: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
roundRect: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
font: "",
|
||||
fillStyle: "",
|
||||
globalAlpha: 1,
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
const mockGetWidth = vi.fn().mockReturnValue(80)
|
||||
button.getWidth = mockGetWidth
|
||||
|
||||
const x = 100
|
||||
const y = 50
|
||||
|
||||
button.draw(ctx, x, y)
|
||||
|
||||
// Check that last area was updated correctly
|
||||
expect(button._last_area[0]).toBe(x + button.xOffset) // 100 + 5 = 105
|
||||
expect(button._last_area[1]).toBe(y + button.yOffset) // 50 + 10 = 60
|
||||
expect(button._last_area[2]).toBe(80)
|
||||
expect(button._last_area[3]).toBe(button.height)
|
||||
})
|
||||
|
||||
it("should calculate last area without offsets", () => {
|
||||
const button = new LGraphButton({
|
||||
text: "Test",
|
||||
})
|
||||
|
||||
const ctx = {
|
||||
measureText: vi.fn().mockReturnValue({ width: 40 }),
|
||||
fillRect: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
roundRect: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
font: "",
|
||||
fillStyle: "",
|
||||
globalAlpha: 1,
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
const mockGetWidth = vi.fn().mockReturnValue(50)
|
||||
button.getWidth = mockGetWidth
|
||||
|
||||
button.draw(ctx, 200, 100)
|
||||
|
||||
expect(button._last_area[0]).toBe(200)
|
||||
expect(button._last_area[1]).toBe(100)
|
||||
expect(button._last_area[2]).toBe(50)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isPointInside", () => {
|
||||
it("should return true when point is inside button area", () => {
|
||||
const button = new LGraphButton({ text: "Test" })
|
||||
// Set the last area manually
|
||||
button._last_area[0] = 100
|
||||
button._last_area[1] = 50
|
||||
button._last_area[2] = 80
|
||||
button._last_area[3] = 20
|
||||
|
||||
// Test various points inside
|
||||
expect(button.isPointInside(100, 50)).toBe(true) // Top-left corner
|
||||
expect(button.isPointInside(179, 69)).toBe(true) // Bottom-right corner
|
||||
expect(button.isPointInside(140, 60)).toBe(true) // Center
|
||||
})
|
||||
|
||||
it("should return false when point is outside button area", () => {
|
||||
const button = new LGraphButton({ text: "Test" })
|
||||
// Set the last area manually
|
||||
button._last_area[0] = 100
|
||||
button._last_area[1] = 50
|
||||
button._last_area[2] = 80
|
||||
button._last_area[3] = 20
|
||||
|
||||
// Test various points outside
|
||||
expect(button.isPointInside(99, 50)).toBe(false) // Just left
|
||||
expect(button.isPointInside(181, 50)).toBe(false) // Just right
|
||||
expect(button.isPointInside(100, 49)).toBe(false) // Just above
|
||||
expect(button.isPointInside(100, 71)).toBe(false) // Just below
|
||||
expect(button.isPointInside(0, 0)).toBe(false) // Far away
|
||||
})
|
||||
|
||||
it("should work with buttons that have not been drawn yet", () => {
|
||||
const button = new LGraphButton({ text: "Test" })
|
||||
// _last_area has default values (0, 0, 0, 0)
|
||||
|
||||
expect(button.isPointInside(10, 10)).toBe(false)
|
||||
expect(button.isPointInside(0, 0)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Integration with LGraphBadge", () => {
|
||||
it("should properly inherit and use badge functionality", () => {
|
||||
const button = new LGraphButton({
|
||||
text: "→",
|
||||
fontSize: 20,
|
||||
color: "#FFFFFF",
|
||||
backgroundColor: "#333333",
|
||||
xOffset: -10,
|
||||
yOffset: 5,
|
||||
})
|
||||
|
||||
const ctx = {
|
||||
measureText: vi.fn().mockReturnValue({ width: 20 }),
|
||||
fillRect: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
roundRect: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
font: "",
|
||||
fillStyle: "",
|
||||
globalAlpha: 1,
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
// Draw the button
|
||||
button.draw(ctx, 100, 50)
|
||||
|
||||
// Verify button draws text without background
|
||||
expect(ctx.beginPath).not.toHaveBeenCalled() // No background
|
||||
expect(ctx.roundRect).not.toHaveBeenCalled() // No background
|
||||
expect(ctx.fill).not.toHaveBeenCalled() // No background
|
||||
expect(ctx.fillText).toHaveBeenCalledWith("→", expect.any(Number), expect.any(Number)) // Just text
|
||||
})
|
||||
})
|
||||
})
|
||||
274
test/LGraphCanvas.titleButtons.test.ts
Normal file
274
test/LGraphCanvas.titleButtons.test.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { LGraphCanvas } from "@/LGraphCanvas"
|
||||
import { LGraphNode, LiteGraph } from "@/litegraph"
|
||||
|
||||
describe("LGraphCanvas Title Button Rendering", () => {
|
||||
let canvas: LGraphCanvas
|
||||
let ctx: CanvasRenderingContext2D
|
||||
let node: LGraphNode
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a mock canvas element
|
||||
const canvasElement = document.createElement("canvas")
|
||||
ctx = {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
translate: vi.fn(),
|
||||
scale: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
strokeRect: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
measureText: vi.fn().mockReturnValue({ width: 50 }),
|
||||
beginPath: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
closePath: vi.fn(),
|
||||
arc: vi.fn(),
|
||||
rect: vi.fn(),
|
||||
clip: vi.fn(),
|
||||
clearRect: vi.fn(),
|
||||
setTransform: vi.fn(),
|
||||
roundRect: vi.fn(),
|
||||
font: "",
|
||||
fillStyle: "",
|
||||
strokeStyle: "",
|
||||
lineWidth: 1,
|
||||
globalAlpha: 1,
|
||||
textAlign: "left" as CanvasTextAlign,
|
||||
textBaseline: "alphabetic" as CanvasTextBaseline,
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
canvasElement.getContext = vi.fn().mockReturnValue(ctx)
|
||||
|
||||
canvas = new LGraphCanvas(canvasElement, null, {
|
||||
skip_render: true,
|
||||
skip_events: true,
|
||||
})
|
||||
|
||||
node = new LGraphNode("Test Node")
|
||||
node.pos = [100, 200]
|
||||
node.size = [200, 100]
|
||||
|
||||
// Mock required methods
|
||||
node.drawTitleBarBackground = vi.fn()
|
||||
node.drawTitleBarText = vi.fn()
|
||||
node.drawBadges = vi.fn()
|
||||
node.drawToggles = vi.fn()
|
||||
node.drawNodeShape = vi.fn()
|
||||
node.drawSlots = vi.fn()
|
||||
node.drawContent = vi.fn()
|
||||
node.drawWidgets = vi.fn()
|
||||
node.drawCollapsedSlots = vi.fn()
|
||||
node.drawTitleBox = vi.fn()
|
||||
node.drawTitleText = vi.fn()
|
||||
node.drawProgressBar = vi.fn()
|
||||
node._setConcreteSlots = vi.fn()
|
||||
node.arrange = vi.fn()
|
||||
node.isSelectable = vi.fn().mockReturnValue(true)
|
||||
})
|
||||
|
||||
describe("drawNode title button rendering", () => {
|
||||
it("should render visible title buttons", () => {
|
||||
const button1 = node.addTitleButton({
|
||||
name: "button1",
|
||||
text: "A",
|
||||
visible: true,
|
||||
})
|
||||
|
||||
const button2 = node.addTitleButton({
|
||||
name: "button2",
|
||||
text: "B",
|
||||
visible: true,
|
||||
})
|
||||
|
||||
// Mock button methods
|
||||
const getWidth1 = vi.fn().mockReturnValue(20)
|
||||
const getWidth2 = vi.fn().mockReturnValue(25)
|
||||
const draw1 = vi.spyOn(button1, "draw")
|
||||
const draw2 = vi.spyOn(button2, "draw")
|
||||
|
||||
button1.getWidth = getWidth1
|
||||
button2.getWidth = getWidth2
|
||||
|
||||
// Draw the node (this is a simplified version of what drawNode does)
|
||||
canvas.drawNode(node, ctx)
|
||||
|
||||
// Verify both buttons' getWidth was called
|
||||
expect(getWidth1).toHaveBeenCalledWith(ctx)
|
||||
expect(getWidth2).toHaveBeenCalledWith(ctx)
|
||||
|
||||
// Verify both buttons were drawn
|
||||
expect(draw1).toHaveBeenCalled()
|
||||
expect(draw2).toHaveBeenCalled()
|
||||
|
||||
// Check draw positions (right-aligned from node width)
|
||||
// First button (rightmost): 200 - 5 = 195, then subtract width
|
||||
// Second button: first button position - 5 - button width
|
||||
const titleHeight = LiteGraph.NODE_TITLE_HEIGHT
|
||||
const buttonY = -titleHeight + (titleHeight - 20) / 2 // Centered
|
||||
expect(draw1).toHaveBeenCalledWith(ctx, 180, buttonY) // 200 - 20
|
||||
expect(draw2).toHaveBeenCalledWith(ctx, 153, buttonY) // 180 - 2 - 25
|
||||
})
|
||||
|
||||
it("should skip invisible title buttons", () => {
|
||||
const visibleButton = node.addTitleButton({
|
||||
name: "visible",
|
||||
text: "V",
|
||||
visible: true,
|
||||
})
|
||||
|
||||
const invisibleButton = node.addTitleButton({
|
||||
name: "invisible",
|
||||
text: "", // Empty text makes it invisible
|
||||
})
|
||||
|
||||
const getWidthVisible = vi.fn().mockReturnValue(30)
|
||||
const getWidthInvisible = vi.fn().mockReturnValue(30)
|
||||
const drawVisible = vi.spyOn(visibleButton, "draw")
|
||||
const drawInvisible = vi.spyOn(invisibleButton, "draw")
|
||||
|
||||
visibleButton.getWidth = getWidthVisible
|
||||
invisibleButton.getWidth = getWidthInvisible
|
||||
|
||||
canvas.drawNode(node, ctx)
|
||||
|
||||
// Only visible button should be measured and drawn
|
||||
expect(getWidthVisible).toHaveBeenCalledWith(ctx)
|
||||
expect(getWidthInvisible).not.toHaveBeenCalled()
|
||||
|
||||
expect(drawVisible).toHaveBeenCalled()
|
||||
expect(drawInvisible).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should handle nodes without title buttons", () => {
|
||||
// Node has no title buttons
|
||||
expect(node.title_buttons).toHaveLength(0)
|
||||
|
||||
// Should draw without errors
|
||||
expect(() => canvas.drawNode(node, ctx)).not.toThrow()
|
||||
})
|
||||
|
||||
it("should position multiple buttons with correct spacing", () => {
|
||||
const buttons = []
|
||||
const drawSpies = []
|
||||
|
||||
// Add 3 buttons
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const button = node.addTitleButton({
|
||||
name: `button${i}`,
|
||||
text: String(i),
|
||||
visible: true,
|
||||
})
|
||||
button.getWidth = vi.fn().mockReturnValue(15) // All same width for simplicity
|
||||
const spy = vi.spyOn(button, "draw")
|
||||
buttons.push(button)
|
||||
drawSpies.push(spy)
|
||||
}
|
||||
|
||||
canvas.drawNode(node, ctx)
|
||||
|
||||
const titleHeight = LiteGraph.NODE_TITLE_HEIGHT
|
||||
|
||||
// Check positions are correctly spaced (right to left)
|
||||
// Starting position: 200
|
||||
const buttonY = -titleHeight + (titleHeight - 20) / 2 // Button height is 20 (default)
|
||||
expect(drawSpies[0]).toHaveBeenCalledWith(ctx, 185, buttonY) // 200 - 15
|
||||
expect(drawSpies[1]).toHaveBeenCalledWith(ctx, 168, buttonY) // 185 - 2 - 15
|
||||
expect(drawSpies[2]).toHaveBeenCalledWith(ctx, 151, buttonY) // 168 - 2 - 15
|
||||
})
|
||||
|
||||
it("should render buttons in low quality mode", () => {
|
||||
const button = node.addTitleButton({
|
||||
name: "test",
|
||||
text: "T",
|
||||
visible: true,
|
||||
})
|
||||
|
||||
button.getWidth = vi.fn().mockReturnValue(20)
|
||||
const drawSpy = vi.spyOn(button, "draw")
|
||||
|
||||
// Set low quality rendering
|
||||
canvas.lowQualityRenderingRequired = true
|
||||
|
||||
canvas.drawNode(node, ctx)
|
||||
|
||||
// Buttons should still be rendered in low quality mode
|
||||
const buttonY = -LiteGraph.NODE_TITLE_HEIGHT + (LiteGraph.NODE_TITLE_HEIGHT - 20) / 2
|
||||
expect(drawSpy).toHaveBeenCalledWith(ctx, 180, buttonY)
|
||||
})
|
||||
|
||||
it("should handle buttons with different widths", () => {
|
||||
const smallButton = node.addTitleButton({
|
||||
name: "small",
|
||||
text: "S",
|
||||
visible: true,
|
||||
})
|
||||
|
||||
const largeButton = node.addTitleButton({
|
||||
name: "large",
|
||||
text: "LARGE",
|
||||
visible: true,
|
||||
})
|
||||
|
||||
smallButton.getWidth = vi.fn().mockReturnValue(15)
|
||||
largeButton.getWidth = vi.fn().mockReturnValue(50)
|
||||
|
||||
const drawSmall = vi.spyOn(smallButton, "draw")
|
||||
const drawLarge = vi.spyOn(largeButton, "draw")
|
||||
|
||||
canvas.drawNode(node, ctx)
|
||||
|
||||
const titleHeight = LiteGraph.NODE_TITLE_HEIGHT
|
||||
|
||||
// Small button (rightmost): 200 - 15 = 185
|
||||
const buttonY = -titleHeight + (titleHeight - 20) / 2
|
||||
expect(drawSmall).toHaveBeenCalledWith(ctx, 185, buttonY)
|
||||
|
||||
// Large button: 185 - 2 - 50 = 133
|
||||
expect(drawLarge).toHaveBeenCalledWith(ctx, 133, buttonY)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Integration with node properties", () => {
|
||||
it("should respect node size for button positioning", () => {
|
||||
node.size = [300, 150] // Wider node
|
||||
|
||||
const button = node.addTitleButton({
|
||||
name: "test",
|
||||
text: "X",
|
||||
visible: true,
|
||||
})
|
||||
|
||||
button.getWidth = vi.fn().mockReturnValue(20)
|
||||
const drawSpy = vi.spyOn(button, "draw")
|
||||
|
||||
canvas.drawNode(node, ctx)
|
||||
|
||||
const titleHeight = LiteGraph.NODE_TITLE_HEIGHT
|
||||
// Should use new width: 300 - 20 = 280
|
||||
const buttonY = -titleHeight + (titleHeight - 20) / 2
|
||||
expect(drawSpy).toHaveBeenCalledWith(ctx, 280, buttonY)
|
||||
})
|
||||
|
||||
it("should NOT render buttons on collapsed nodes", () => {
|
||||
node.flags.collapsed = true
|
||||
|
||||
const button = node.addTitleButton({
|
||||
name: "test",
|
||||
text: "C",
|
||||
})
|
||||
|
||||
button.getWidth = vi.fn().mockReturnValue(20)
|
||||
const drawSpy = vi.spyOn(button, "draw")
|
||||
|
||||
canvas.drawNode(node, ctx)
|
||||
|
||||
// Title buttons should NOT be rendered on collapsed nodes
|
||||
expect(drawSpy).not.toHaveBeenCalled()
|
||||
expect(button.getWidth).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
276
test/LGraphNode.titleButtons.test.ts
Normal file
276
test/LGraphNode.titleButtons.test.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { LGraphButton } from "@/LGraphButton"
|
||||
import { LGraphCanvas } from "@/LGraphCanvas"
|
||||
import { LGraphNode } from "@/LGraphNode"
|
||||
|
||||
describe("LGraphNode Title Buttons", () => {
|
||||
describe("addTitleButton", () => {
|
||||
it("should add a title button to the node", () => {
|
||||
const node = new LGraphNode("Test Node")
|
||||
|
||||
const button = node.addTitleButton({
|
||||
name: "test_button",
|
||||
text: "X",
|
||||
fgColor: "#FF0000",
|
||||
})
|
||||
|
||||
expect(button).toBeInstanceOf(LGraphButton)
|
||||
expect(button.name).toBe("test_button")
|
||||
expect(button.text).toBe("X")
|
||||
expect(button.fgColor).toBe("#FF0000")
|
||||
expect(node.title_buttons).toHaveLength(1)
|
||||
expect(node.title_buttons[0]).toBe(button)
|
||||
})
|
||||
|
||||
it("should add multiple title buttons", () => {
|
||||
const node = new LGraphNode("Test Node")
|
||||
|
||||
const button1 = node.addTitleButton({ name: "button1", text: "A" })
|
||||
const button2 = node.addTitleButton({ name: "button2", text: "B" })
|
||||
const button3 = node.addTitleButton({ name: "button3", text: "C" })
|
||||
|
||||
expect(node.title_buttons).toHaveLength(3)
|
||||
expect(node.title_buttons[0]).toBe(button1)
|
||||
expect(node.title_buttons[1]).toBe(button2)
|
||||
expect(node.title_buttons[2]).toBe(button3)
|
||||
})
|
||||
|
||||
it("should create buttons with default options", () => {
|
||||
const node = new LGraphNode("Test Node")
|
||||
|
||||
const button = node.addTitleButton({})
|
||||
|
||||
expect(button).toBeInstanceOf(LGraphButton)
|
||||
expect(button.name).toBeUndefined()
|
||||
expect(node.title_buttons).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("onMouseDown with title buttons", () => {
|
||||
it("should handle click on title button", () => {
|
||||
const node = new LGraphNode("Test Node")
|
||||
node.pos = [100, 200]
|
||||
node.size = [180, 60]
|
||||
|
||||
const button = node.addTitleButton({
|
||||
name: "close_button",
|
||||
text: "X",
|
||||
visible: true,
|
||||
})
|
||||
|
||||
// Mock button dimensions
|
||||
button.getWidth = vi.fn().mockReturnValue(20)
|
||||
button.height = 16
|
||||
|
||||
// Simulate button being drawn to populate _last_area
|
||||
// Button is drawn at node-relative coordinates
|
||||
// Button x: node.size[0] - 5 - button_width = 180 - 5 - 20 = 155
|
||||
// Button y: -LiteGraph.NODE_TITLE_HEIGHT = -30
|
||||
button._last_area[0] = 155
|
||||
button._last_area[1] = -30
|
||||
button._last_area[2] = 20
|
||||
button._last_area[3] = 16
|
||||
|
||||
const canvas = {
|
||||
ctx: {} as CanvasRenderingContext2D,
|
||||
dispatch: vi.fn(),
|
||||
} as unknown as LGraphCanvas
|
||||
|
||||
const event = {
|
||||
canvasX: 265, // node.pos[0] + node.size[0] - 5 - button_width = 100 + 180 - 5 - 20 = 255, click in middle = 265
|
||||
canvasY: 178, // node.pos[1] - LiteGraph.NODE_TITLE_HEIGHT + 8 = 200 - 30 + 8 = 178
|
||||
} as any
|
||||
|
||||
// Calculate node-relative position for the click
|
||||
const clickPosRelativeToNode: [number, number] = [
|
||||
265 - node.pos[0], // 265 - 100 = 165
|
||||
178 - node.pos[1], // 178 - 200 = -22
|
||||
]
|
||||
|
||||
// Simulate the click - onMouseDown should detect button click
|
||||
const handled = node.onMouseDown(event, clickPosRelativeToNode, canvas)
|
||||
|
||||
expect(handled).toBe(true)
|
||||
expect(canvas.dispatch).toHaveBeenCalledWith("litegraph:node-title-button-clicked", {
|
||||
node: node,
|
||||
button: button,
|
||||
})
|
||||
})
|
||||
|
||||
it("should not handle click outside title buttons", () => {
|
||||
const node = new LGraphNode("Test Node")
|
||||
node.pos = [100, 200]
|
||||
node.size = [180, 60]
|
||||
|
||||
const button = node.addTitleButton({
|
||||
name: "test_button",
|
||||
text: "T",
|
||||
visible: true,
|
||||
})
|
||||
|
||||
button.getWidth = vi.fn().mockReturnValue(20)
|
||||
button.height = 16
|
||||
|
||||
// Simulate button being drawn at node-relative coordinates
|
||||
button._last_area[0] = 155 // 180 - 5 - 20
|
||||
button._last_area[1] = -30 // -NODE_TITLE_HEIGHT
|
||||
button._last_area[2] = 20
|
||||
button._last_area[3] = 16
|
||||
|
||||
const canvas = {
|
||||
ctx: {} as CanvasRenderingContext2D,
|
||||
dispatch: vi.fn(),
|
||||
} as unknown as LGraphCanvas
|
||||
|
||||
const event = {
|
||||
canvasX: 150, // Click in the middle of the node, not on button
|
||||
canvasY: 180,
|
||||
} as any
|
||||
|
||||
// Calculate node-relative position
|
||||
const clickPosRelativeToNode: [number, number] = [
|
||||
150 - node.pos[0], // 150 - 100 = 50
|
||||
180 - node.pos[1], // 180 - 200 = -20
|
||||
]
|
||||
|
||||
const handled = node.onMouseDown(event, clickPosRelativeToNode, canvas)
|
||||
|
||||
expect(handled).toBe(false)
|
||||
expect(canvas.dispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should handle multiple buttons correctly", () => {
|
||||
const node = new LGraphNode("Test Node")
|
||||
node.pos = [100, 200]
|
||||
node.size = [200, 60]
|
||||
|
||||
const button1 = node.addTitleButton({
|
||||
name: "button1",
|
||||
text: "A",
|
||||
visible: true,
|
||||
})
|
||||
|
||||
const button2 = node.addTitleButton({
|
||||
name: "button2",
|
||||
text: "B",
|
||||
visible: true,
|
||||
})
|
||||
|
||||
// Mock button dimensions
|
||||
button1.getWidth = vi.fn().mockReturnValue(20)
|
||||
button2.getWidth = vi.fn().mockReturnValue(20)
|
||||
button1.height = button2.height = 16
|
||||
|
||||
// Simulate buttons being drawn at node-relative coordinates
|
||||
// First button (rightmost): 200 - 5 - 20 = 175
|
||||
button1._last_area[0] = 175
|
||||
button1._last_area[1] = -30 // -NODE_TITLE_HEIGHT
|
||||
button1._last_area[2] = 20
|
||||
button1._last_area[3] = 16
|
||||
|
||||
// Second button: 175 - 5 - 20 = 150
|
||||
button2._last_area[0] = 150
|
||||
button2._last_area[1] = -30 // -NODE_TITLE_HEIGHT
|
||||
button2._last_area[2] = 20
|
||||
button2._last_area[3] = 16
|
||||
|
||||
const canvas = {
|
||||
ctx: {} as CanvasRenderingContext2D,
|
||||
dispatch: vi.fn(),
|
||||
} as unknown as LGraphCanvas
|
||||
|
||||
// Click on second button (leftmost, since they're right-aligned)
|
||||
const titleY = 170 + 8 // node.pos[1] - NODE_TITLE_HEIGHT + 8 = 200 - 30 + 8 = 178
|
||||
const event = {
|
||||
canvasX: 255, // First button at: 100 + 200 - 5 - 20 = 275, Second button at: 275 - 5 - 20 = 250, click in middle = 255
|
||||
canvasY: titleY,
|
||||
} as any
|
||||
|
||||
// Calculate node-relative position
|
||||
const clickPosRelativeToNode: [number, number] = [
|
||||
255 - node.pos[0], // 255 - 100 = 155
|
||||
titleY - node.pos[1], // 178 - 200 = -22
|
||||
]
|
||||
|
||||
const handled = node.onMouseDown(event, clickPosRelativeToNode, canvas)
|
||||
|
||||
expect(handled).toBe(true)
|
||||
expect(canvas.dispatch).toHaveBeenCalledWith("litegraph:node-title-button-clicked", {
|
||||
node: node,
|
||||
button: button2,
|
||||
})
|
||||
})
|
||||
|
||||
it("should skip invisible buttons", () => {
|
||||
const node = new LGraphNode("Test Node")
|
||||
node.pos = [100, 200]
|
||||
node.size = [180, 60]
|
||||
|
||||
const button1 = node.addTitleButton({
|
||||
name: "invisible_button",
|
||||
text: "", // Empty text makes it invisible
|
||||
})
|
||||
|
||||
const button2 = node.addTitleButton({
|
||||
name: "visible_button",
|
||||
text: "V",
|
||||
})
|
||||
|
||||
button1.getWidth = vi.fn().mockReturnValue(20)
|
||||
button2.getWidth = vi.fn().mockReturnValue(20)
|
||||
button1.height = button2.height = 16
|
||||
|
||||
// Simulate buttons being drawn at node-relative coordinates
|
||||
// Only visible button gets drawn area
|
||||
button2._last_area[0] = 155 // 180 - 5 - 20
|
||||
button2._last_area[1] = -30 // -NODE_TITLE_HEIGHT
|
||||
button2._last_area[2] = 20
|
||||
button2._last_area[3] = 16
|
||||
|
||||
const canvas = {
|
||||
ctx: {} as CanvasRenderingContext2D,
|
||||
dispatch: vi.fn(),
|
||||
} as unknown as LGraphCanvas
|
||||
|
||||
// Click where the visible button is (invisible button is skipped)
|
||||
const titleY = 178 // node.pos[1] - NODE_TITLE_HEIGHT + 8 = 200 - 30 + 8 = 178
|
||||
const event = {
|
||||
canvasX: 265, // Visible button at: 100 + 180 - 5 - 20 = 255, click in middle = 265
|
||||
canvasY: titleY,
|
||||
} as any
|
||||
|
||||
// Calculate node-relative position
|
||||
const clickPosRelativeToNode: [number, number] = [
|
||||
265 - node.pos[0], // 265 - 100 = 165
|
||||
titleY - node.pos[1], // 178 - 200 = -22
|
||||
]
|
||||
|
||||
const handled = node.onMouseDown(event, clickPosRelativeToNode, canvas)
|
||||
|
||||
expect(handled).toBe(true)
|
||||
expect(canvas.dispatch).toHaveBeenCalledWith("litegraph:node-title-button-clicked", {
|
||||
node: node,
|
||||
button: button2, // Should click visible button, not invisible
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("onTitleButtonClick", () => {
|
||||
it("should dispatch litegraph:node-title-button-clicked event", () => {
|
||||
const node = new LGraphNode("Test Node")
|
||||
const button = new LGraphButton({ name: "test_button" })
|
||||
|
||||
const canvas = {
|
||||
dispatch: vi.fn(),
|
||||
} as unknown as LGraphCanvas
|
||||
|
||||
node.onTitleButtonClick(button, canvas)
|
||||
|
||||
expect(canvas.dispatch).toHaveBeenCalledWith("litegraph:node-title-button-clicked", {
|
||||
node: node,
|
||||
button: button,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -81,6 +81,7 @@ LGraph {
|
||||
"lostFocusAt": undefined,
|
||||
"mode": 0,
|
||||
"mouseOver": undefined,
|
||||
"onMouseDown": [Function],
|
||||
"order": 0,
|
||||
"outputs": [],
|
||||
"progress": undefined,
|
||||
@@ -97,6 +98,7 @@ LGraph {
|
||||
"selected": [Function],
|
||||
},
|
||||
"title": "LGraphNode",
|
||||
"title_buttons": [],
|
||||
"type": "mustBeSet",
|
||||
"widgets": undefined,
|
||||
"widgets_start_y": undefined,
|
||||
@@ -149,6 +151,7 @@ LGraph {
|
||||
"lostFocusAt": undefined,
|
||||
"mode": 0,
|
||||
"mouseOver": undefined,
|
||||
"onMouseDown": [Function],
|
||||
"order": 0,
|
||||
"outputs": [],
|
||||
"progress": undefined,
|
||||
@@ -165,6 +168,7 @@ LGraph {
|
||||
"selected": [Function],
|
||||
},
|
||||
"title": "LGraphNode",
|
||||
"title_buttons": [],
|
||||
"type": "mustBeSet",
|
||||
"widgets": undefined,
|
||||
"widgets_start_y": undefined,
|
||||
@@ -218,6 +222,7 @@ LGraph {
|
||||
"lostFocusAt": undefined,
|
||||
"mode": 0,
|
||||
"mouseOver": undefined,
|
||||
"onMouseDown": [Function],
|
||||
"order": 0,
|
||||
"outputs": [],
|
||||
"progress": undefined,
|
||||
@@ -234,6 +239,7 @@ LGraph {
|
||||
"selected": [Function],
|
||||
},
|
||||
"title": "LGraphNode",
|
||||
"title_buttons": [],
|
||||
"type": "mustBeSet",
|
||||
"widgets": undefined,
|
||||
"widgets_start_y": undefined,
|
||||
|
||||
@@ -83,6 +83,7 @@ LGraph {
|
||||
"lostFocusAt": undefined,
|
||||
"mode": 0,
|
||||
"mouseOver": undefined,
|
||||
"onMouseDown": [Function],
|
||||
"order": 0,
|
||||
"outputs": [],
|
||||
"progress": undefined,
|
||||
@@ -99,6 +100,7 @@ LGraph {
|
||||
"selected": [Function],
|
||||
},
|
||||
"title": undefined,
|
||||
"title_buttons": [],
|
||||
"type": "",
|
||||
"widgets": undefined,
|
||||
"widgets_start_y": undefined,
|
||||
@@ -153,6 +155,7 @@ LGraph {
|
||||
"lostFocusAt": undefined,
|
||||
"mode": 0,
|
||||
"mouseOver": undefined,
|
||||
"onMouseDown": [Function],
|
||||
"order": 0,
|
||||
"outputs": [],
|
||||
"progress": undefined,
|
||||
@@ -169,6 +172,7 @@ LGraph {
|
||||
"selected": [Function],
|
||||
},
|
||||
"title": undefined,
|
||||
"title_buttons": [],
|
||||
"type": "",
|
||||
"widgets": undefined,
|
||||
"widgets_start_y": undefined,
|
||||
@@ -224,6 +228,7 @@ LGraph {
|
||||
"lostFocusAt": undefined,
|
||||
"mode": 0,
|
||||
"mouseOver": undefined,
|
||||
"onMouseDown": [Function],
|
||||
"order": 0,
|
||||
"outputs": [],
|
||||
"progress": undefined,
|
||||
@@ -240,6 +245,7 @@ LGraph {
|
||||
"selected": [Function],
|
||||
},
|
||||
"title": undefined,
|
||||
"title_buttons": [],
|
||||
"type": "",
|
||||
"widgets": undefined,
|
||||
"widgets_start_y": undefined,
|
||||
|
||||
@@ -81,6 +81,7 @@ LGraph {
|
||||
"lostFocusAt": undefined,
|
||||
"mode": 0,
|
||||
"mouseOver": undefined,
|
||||
"onMouseDown": [Function],
|
||||
"order": 0,
|
||||
"outputs": [],
|
||||
"progress": undefined,
|
||||
@@ -97,6 +98,7 @@ LGraph {
|
||||
"selected": [Function],
|
||||
},
|
||||
"title": "LGraphNode",
|
||||
"title_buttons": [],
|
||||
"type": "mustBeSet",
|
||||
"widgets": undefined,
|
||||
"widgets_start_y": undefined,
|
||||
@@ -149,6 +151,7 @@ LGraph {
|
||||
"lostFocusAt": undefined,
|
||||
"mode": 0,
|
||||
"mouseOver": undefined,
|
||||
"onMouseDown": [Function],
|
||||
"order": 0,
|
||||
"outputs": [],
|
||||
"progress": undefined,
|
||||
@@ -165,6 +168,7 @@ LGraph {
|
||||
"selected": [Function],
|
||||
},
|
||||
"title": "LGraphNode",
|
||||
"title_buttons": [],
|
||||
"type": "mustBeSet",
|
||||
"widgets": undefined,
|
||||
"widgets_start_y": undefined,
|
||||
@@ -218,6 +222,7 @@ LGraph {
|
||||
"lostFocusAt": undefined,
|
||||
"mode": 0,
|
||||
"mouseOver": undefined,
|
||||
"onMouseDown": [Function],
|
||||
"order": 0,
|
||||
"outputs": [],
|
||||
"progress": undefined,
|
||||
@@ -234,6 +239,7 @@ LGraph {
|
||||
"selected": [Function],
|
||||
},
|
||||
"title": "LGraphNode",
|
||||
"title_buttons": [],
|
||||
"type": "mustBeSet",
|
||||
"widgets": undefined,
|
||||
"widgets_start_y": undefined,
|
||||
|
||||
231
test/subgraph/SubgraphNode.titleButton.test.ts
Normal file
231
test/subgraph/SubgraphNode.titleButton.test.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { LGraphButton } from "@/LGraphButton"
|
||||
import { LGraphCanvas } from "@/LGraphCanvas"
|
||||
|
||||
import { createTestSubgraph, createTestSubgraphNode } from "./fixtures/subgraphHelpers"
|
||||
|
||||
describe("SubgraphNode Title Button", () => {
|
||||
describe("Constructor", () => {
|
||||
it("should automatically add enter_subgraph button", () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
name: "Test Subgraph",
|
||||
inputs: [{ name: "input", type: "number" }],
|
||||
})
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
expect(subgraphNode.title_buttons).toHaveLength(1)
|
||||
|
||||
const button = subgraphNode.title_buttons[0]
|
||||
expect(button).toBeInstanceOf(LGraphButton)
|
||||
expect(button.name).toBe("enter_subgraph")
|
||||
expect(button.text).toBe("\uE93B") // pi-window-maximize
|
||||
expect(button.xOffset).toBe(-10)
|
||||
expect(button.yOffset).toBe(0)
|
||||
expect(button.fontSize).toBe(16)
|
||||
})
|
||||
|
||||
it("should preserve enter_subgraph button when adding more buttons", () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
// Add another button
|
||||
const customButton = subgraphNode.addTitleButton({
|
||||
name: "custom_button",
|
||||
text: "C",
|
||||
})
|
||||
|
||||
expect(subgraphNode.title_buttons).toHaveLength(2)
|
||||
expect(subgraphNode.title_buttons[0].name).toBe("enter_subgraph")
|
||||
expect(subgraphNode.title_buttons[1]).toBe(customButton)
|
||||
})
|
||||
})
|
||||
|
||||
describe("onTitleButtonClick", () => {
|
||||
it("should open subgraph when enter_subgraph button is clicked", () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
name: "Test Subgraph",
|
||||
})
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const enterButton = subgraphNode.title_buttons[0]
|
||||
|
||||
const canvas = {
|
||||
openSubgraph: vi.fn(),
|
||||
dispatch: vi.fn(),
|
||||
} as unknown as LGraphCanvas
|
||||
|
||||
subgraphNode.onTitleButtonClick(enterButton, canvas)
|
||||
|
||||
expect(canvas.openSubgraph).toHaveBeenCalledWith(subgraph)
|
||||
expect(canvas.dispatch).not.toHaveBeenCalled() // Should not call parent implementation
|
||||
})
|
||||
|
||||
it("should call parent implementation for other buttons", () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
const customButton = subgraphNode.addTitleButton({
|
||||
name: "custom_button",
|
||||
text: "X",
|
||||
})
|
||||
|
||||
const canvas = {
|
||||
openSubgraph: vi.fn(),
|
||||
dispatch: vi.fn(),
|
||||
} as unknown as LGraphCanvas
|
||||
|
||||
subgraphNode.onTitleButtonClick(customButton, canvas)
|
||||
|
||||
expect(canvas.openSubgraph).not.toHaveBeenCalled()
|
||||
expect(canvas.dispatch).toHaveBeenCalledWith("litegraph:node-title-button-clicked", {
|
||||
node: subgraphNode,
|
||||
button: customButton,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Integration with node click handling", () => {
|
||||
it("should handle clicks on enter_subgraph button", () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
name: "Nested Subgraph",
|
||||
nodeCount: 3,
|
||||
})
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
subgraphNode.pos = [100, 100]
|
||||
subgraphNode.size = [200, 100]
|
||||
|
||||
const enterButton = subgraphNode.title_buttons[0]
|
||||
enterButton.getWidth = vi.fn().mockReturnValue(25)
|
||||
enterButton.height = 20
|
||||
|
||||
// Simulate button being drawn at node-relative coordinates
|
||||
// Button x: 200 - 5 - 25 = 170
|
||||
// Button y: -30 (title height)
|
||||
enterButton._last_area[0] = 170
|
||||
enterButton._last_area[1] = -30
|
||||
enterButton._last_area[2] = 25
|
||||
enterButton._last_area[3] = 20
|
||||
|
||||
const canvas = {
|
||||
ctx: {
|
||||
measureText: vi.fn().mockReturnValue({ width: 25 }),
|
||||
} as unknown as CanvasRenderingContext2D,
|
||||
openSubgraph: vi.fn(),
|
||||
dispatch: vi.fn(),
|
||||
} as unknown as LGraphCanvas
|
||||
|
||||
// Simulate click on the enter button
|
||||
const event = {
|
||||
canvasX: 275, // Near right edge where button should be
|
||||
canvasY: 80, // In title area
|
||||
} as any
|
||||
|
||||
// Calculate node-relative position
|
||||
const clickPosRelativeToNode: [number, number] = [
|
||||
275 - subgraphNode.pos[0], // 275 - 100 = 175
|
||||
80 - subgraphNode.pos[1], // 80 - 100 = -20
|
||||
]
|
||||
|
||||
const handled = subgraphNode.onMouseDown(event, clickPosRelativeToNode, canvas)
|
||||
|
||||
expect(handled).toBe(true)
|
||||
expect(canvas.openSubgraph).toHaveBeenCalledWith(subgraph)
|
||||
})
|
||||
|
||||
it("should not interfere with normal node operations", () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
subgraphNode.pos = [100, 100]
|
||||
subgraphNode.size = [200, 100]
|
||||
|
||||
const canvas = {
|
||||
ctx: {
|
||||
measureText: vi.fn().mockReturnValue({ width: 25 }),
|
||||
} as unknown as CanvasRenderingContext2D,
|
||||
openSubgraph: vi.fn(),
|
||||
dispatch: vi.fn(),
|
||||
} as unknown as LGraphCanvas
|
||||
|
||||
// Click in the body of the node, not on button
|
||||
const event = {
|
||||
canvasX: 200, // Middle of node
|
||||
canvasY: 150, // Body area
|
||||
} as any
|
||||
|
||||
// Calculate node-relative position
|
||||
const clickPosRelativeToNode: [number, number] = [
|
||||
200 - subgraphNode.pos[0], // 200 - 100 = 100
|
||||
150 - subgraphNode.pos[1], // 150 - 100 = 50
|
||||
]
|
||||
|
||||
const handled = subgraphNode.onMouseDown(event, clickPosRelativeToNode, canvas)
|
||||
|
||||
expect(handled).toBe(false)
|
||||
expect(canvas.openSubgraph).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should not process button clicks when node is collapsed", () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
subgraphNode.pos = [100, 100]
|
||||
subgraphNode.size = [200, 100]
|
||||
subgraphNode.flags.collapsed = true
|
||||
|
||||
const enterButton = subgraphNode.title_buttons[0]
|
||||
enterButton.getWidth = vi.fn().mockReturnValue(25)
|
||||
enterButton.height = 20
|
||||
|
||||
// Set button area as if it was drawn
|
||||
enterButton._last_area[0] = 170
|
||||
enterButton._last_area[1] = -30
|
||||
enterButton._last_area[2] = 25
|
||||
enterButton._last_area[3] = 20
|
||||
|
||||
const canvas = {
|
||||
ctx: {
|
||||
measureText: vi.fn().mockReturnValue({ width: 25 }),
|
||||
} as unknown as CanvasRenderingContext2D,
|
||||
openSubgraph: vi.fn(),
|
||||
dispatch: vi.fn(),
|
||||
} as unknown as LGraphCanvas
|
||||
|
||||
// Try to click on where the button would be
|
||||
const event = {
|
||||
canvasX: 275,
|
||||
canvasY: 80,
|
||||
} as any
|
||||
|
||||
const clickPosRelativeToNode: [number, number] = [
|
||||
275 - subgraphNode.pos[0], // 175
|
||||
80 - subgraphNode.pos[1], // -20
|
||||
]
|
||||
|
||||
const handled = subgraphNode.onMouseDown(event, clickPosRelativeToNode, canvas)
|
||||
|
||||
// Should not handle the click when collapsed
|
||||
expect(handled).toBe(false)
|
||||
expect(canvas.openSubgraph).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Visual properties", () => {
|
||||
it("should have appropriate visual properties for enter button", () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
const enterButton = subgraphNode.title_buttons[0]
|
||||
|
||||
// Check visual properties
|
||||
expect(enterButton.text).toBe("\uE93B") // pi-window-maximize
|
||||
expect(enterButton.fontSize).toBe(16) // Icon size
|
||||
expect(enterButton.xOffset).toBe(-10) // Positioned from right edge
|
||||
expect(enterButton.yOffset).toBe(0) // Centered vertically
|
||||
|
||||
// Should be visible by default
|
||||
expect(enterButton.visible).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
82
test/utils/textUtils.test.ts
Normal file
82
test/utils/textUtils.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { truncateText } from "@/utils/textUtils"
|
||||
|
||||
describe("truncateText", () => {
|
||||
const createMockContext = (charWidth: number = 10) => {
|
||||
return {
|
||||
measureText: vi.fn((text: string) => ({ width: text.length * charWidth })),
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
}
|
||||
|
||||
it("should return original text if it fits within maxWidth", () => {
|
||||
const ctx = createMockContext()
|
||||
const result = truncateText(ctx, "Short", 100)
|
||||
expect(result).toBe("Short")
|
||||
})
|
||||
|
||||
it("should return original text if maxWidth is 0 or negative", () => {
|
||||
const ctx = createMockContext()
|
||||
expect(truncateText(ctx, "Text", 0)).toBe("Text")
|
||||
expect(truncateText(ctx, "Text", -10)).toBe("Text")
|
||||
})
|
||||
|
||||
it("should truncate text and add ellipsis when text is too long", () => {
|
||||
const ctx = createMockContext(10) // 10 pixels per character
|
||||
const result = truncateText(ctx, "This is a very long text", 100)
|
||||
// 100px total, "..." takes 30px, leaving 70px for text (7 chars)
|
||||
expect(result).toBe("This is...")
|
||||
})
|
||||
|
||||
it("should use custom ellipsis when provided", () => {
|
||||
const ctx = createMockContext(10)
|
||||
const result = truncateText(ctx, "This is a very long text", 100, "…")
|
||||
// 100px total, "…" takes 10px, leaving 90px for text (9 chars)
|
||||
expect(result).toBe("This is a…")
|
||||
})
|
||||
|
||||
it("should return only ellipsis if available width is too small", () => {
|
||||
const ctx = createMockContext(10)
|
||||
const result = truncateText(ctx, "Text", 20) // Only room for 2 chars, but "..." needs 3
|
||||
expect(result).toBe("...")
|
||||
})
|
||||
|
||||
it("should handle empty text", () => {
|
||||
const ctx = createMockContext()
|
||||
const result = truncateText(ctx, "", 100)
|
||||
expect(result).toBe("")
|
||||
})
|
||||
|
||||
it("should use binary search efficiently", () => {
|
||||
const ctx = createMockContext(10)
|
||||
const longText = "A".repeat(100) // 100 characters
|
||||
|
||||
const result = truncateText(ctx, longText, 200) // Room for 20 chars total
|
||||
expect(result).toBe(`${"A".repeat(17)}...`) // 17 chars + "..." = 20 chars = 200px
|
||||
|
||||
// Verify binary search efficiency - should not measure every possible substring
|
||||
// Binary search for 100 chars should take around log2(100) ≈ 7 iterations
|
||||
// Plus a few extra calls for measuring the full text and ellipsis
|
||||
const callCount = (ctx.measureText as any).mock.calls.length
|
||||
expect(callCount).toBeLessThan(20)
|
||||
expect(callCount).toBeGreaterThan(5)
|
||||
})
|
||||
|
||||
it("should handle unicode characters correctly", () => {
|
||||
const ctx = createMockContext(10)
|
||||
const result = truncateText(ctx, "Hello 👋 World", 80)
|
||||
// Assuming each char (including emoji) is 10px, total is 130px
|
||||
// 80px total, "..." takes 30px, leaving 50px for text (5 chars)
|
||||
expect(result).toBe("Hello...")
|
||||
})
|
||||
|
||||
it("should handle exact boundary cases", () => {
|
||||
const ctx = createMockContext(10)
|
||||
|
||||
// Text exactly fits
|
||||
expect(truncateText(ctx, "Exact", 50)).toBe("Exact") // 5 chars = 50px
|
||||
|
||||
// Text is exactly 1 pixel too long
|
||||
expect(truncateText(ctx, "Exact!", 50)).toBe("Ex...") // 6 chars = 60px, truncated
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user