mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 13:32:11 +00:00
Compare commits
4 Commits
ext-api/i-
...
glary/form
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8df5ac979e | ||
|
|
fe69964ef5 | ||
|
|
81f604edf8 | ||
|
|
d04450dac4 |
@@ -1,7 +1,7 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
import { BaseDialog } from '@e2e/fixtures/components/BaseDialog'
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
|
||||
export class TopUpCreditsDialog extends BaseDialog {
|
||||
readonly heading: Locator
|
||||
@@ -13,8 +13,21 @@ export class TopUpCreditsDialog extends BaseDialog {
|
||||
readonly payAmountInput: Locator
|
||||
readonly pricingLink: Locator
|
||||
|
||||
readonly payInput: Locator
|
||||
readonly creditsInput: Locator
|
||||
readonly decrementPay: Locator
|
||||
readonly incrementPay: Locator
|
||||
readonly decrementCredits: Locator
|
||||
readonly incrementCredits: Locator
|
||||
readonly presetButtons: Locator
|
||||
readonly buyButton: Locator
|
||||
override readonly closeButton: Locator
|
||||
readonly minWarning: Locator
|
||||
readonly ceilingWarning: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page)
|
||||
|
||||
this.heading = this.root.getByRole('heading', { name: 'Add more credits' })
|
||||
this.insufficientHeading = this.root.getByRole('heading', {
|
||||
name: 'Add more credits to run'
|
||||
@@ -41,6 +54,29 @@ export class TopUpCreditsDialog extends BaseDialog {
|
||||
this.pricingLink = this.root.getByRole('link', {
|
||||
name: 'View pricing details'
|
||||
})
|
||||
|
||||
const steppers = this.root.locator('label')
|
||||
const payStepper = steppers.first()
|
||||
const creditsStepper = steppers.nth(1)
|
||||
|
||||
this.payInput = payStepper.locator('input[inputmode="numeric"]')
|
||||
this.creditsInput = creditsStepper.locator('input[inputmode="numeric"]')
|
||||
this.decrementPay = payStepper.getByRole('button', { name: 'Decrement' })
|
||||
this.incrementPay = payStepper.getByRole('button', { name: 'Increment' })
|
||||
this.decrementCredits = creditsStepper.getByRole('button', {
|
||||
name: 'Decrement'
|
||||
})
|
||||
this.incrementCredits = creditsStepper.getByRole('button', {
|
||||
name: 'Increment'
|
||||
})
|
||||
this.presetButtons = this.root.getByRole('button', { name: /^\$\d+$/ })
|
||||
this.buyButton = this.root.getByRole('button', {
|
||||
name: /continue to payment|add credits/i
|
||||
})
|
||||
// Headless dialog uses its own X button, not PrimeVue's header close
|
||||
this.closeButton = this.root.locator('button:has([class*="lucide--x"])')
|
||||
this.minWarning = this.root.getByText(/minimum/i)
|
||||
this.ceilingWarning = this.root.getByText(/maximum/i)
|
||||
}
|
||||
|
||||
async open(options?: { isInsufficientCredits?: boolean }) {
|
||||
@@ -51,4 +87,8 @@ export class TopUpCreditsDialog extends BaseDialog {
|
||||
}, options)
|
||||
await this.waitForVisible()
|
||||
}
|
||||
|
||||
getPresetButton(amount: number): Locator {
|
||||
return this.root.getByRole('button', { name: `$${amount}`, exact: true })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,26 @@
|
||||
import type { operations } from '@comfyorg/registry-types'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { CREDITS_PER_USD } from '@/base/credits/comfyCredits'
|
||||
import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TopUpCreditsDialog } from '@e2e/fixtures/components/TopUpCreditsDialog'
|
||||
|
||||
type GetCustomerBalanceResponse =
|
||||
operations['GetCustomerBalance']['responses']['200']['content']['application/json']
|
||||
|
||||
// Step: $5 when pay < $100, $50 when pay < $1000, $100 otherwise
|
||||
// MIN_AMOUNT = $5, MAX_AMOUNT = $10,000
|
||||
|
||||
function expectedCredits(usd: number): string {
|
||||
return (usd * CREDITS_PER_USD).toLocaleString('en-US')
|
||||
}
|
||||
|
||||
function expectedCreditsInput(usd: number): string {
|
||||
return expectedCredits(usd).replaceAll(',', '')
|
||||
}
|
||||
|
||||
test.describe('TopUpCredits dialog', { tag: '@ui' }, () => {
|
||||
let dialog: TopUpCreditsDialog
|
||||
|
||||
@@ -56,3 +74,113 @@ test.describe('TopUpCredits dialog', { tag: '@ui' }, () => {
|
||||
await expect(dialog.pricingLink).toHaveAttribute('target', '_blank')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Top Up Credits Dialog', { tag: '@ui' }, () => {
|
||||
let dialog: TopUpCreditsDialog
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.page.route(
|
||||
'**/cloud-subscription-status**',
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
is_active: true,
|
||||
subscription_tier: 'PRO'
|
||||
} satisfies CloudSubscriptionStatusResponse
|
||||
})
|
||||
}
|
||||
)
|
||||
await comfyPage.page.route('**/customers/balance**', async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
amount_micros: 1_000_000,
|
||||
currency: 'usd'
|
||||
} satisfies GetCustomerBalanceResponse
|
||||
})
|
||||
})
|
||||
|
||||
dialog = new TopUpCreditsDialog(comfyPage.page)
|
||||
await dialog.open()
|
||||
})
|
||||
|
||||
test('preset buttons update both steppers', async () => {
|
||||
await dialog.getPresetButton(25).click()
|
||||
|
||||
await expect(dialog.payInput).toHaveValue('25')
|
||||
await expect(dialog.creditsInput).toHaveValue(expectedCredits(25))
|
||||
})
|
||||
|
||||
test('increment pay stepper updates credits', async () => {
|
||||
await dialog.getPresetButton(25).click()
|
||||
await dialog.incrementPay.click()
|
||||
|
||||
await expect(dialog.payInput).toHaveValue('30')
|
||||
await expect(dialog.creditsInput).toHaveValue(expectedCredits(30))
|
||||
})
|
||||
|
||||
test('decrement pay stepper updates credits', async () => {
|
||||
await dialog.getPresetButton(50).click()
|
||||
await dialog.decrementPay.click()
|
||||
|
||||
await expect(dialog.payInput).toHaveValue('45')
|
||||
await expect(dialog.creditsInput).toHaveValue(expectedCredits(45))
|
||||
})
|
||||
|
||||
test('typing in pay stepper updates credits', async () => {
|
||||
await dialog.payInput.fill('')
|
||||
await dialog.payInput.pressSequentially('500')
|
||||
await dialog.payInput.blur()
|
||||
|
||||
await expect(dialog.creditsInput).toHaveValue(expectedCredits(500))
|
||||
})
|
||||
|
||||
test('typing in credits stepper updates pay', async () => {
|
||||
const credits = expectedCreditsInput(50)
|
||||
|
||||
await dialog.creditsInput.fill('')
|
||||
await dialog.creditsInput.pressSequentially(credits)
|
||||
await dialog.creditsInput.blur()
|
||||
|
||||
await expect(dialog.payInput).toHaveValue('50')
|
||||
})
|
||||
|
||||
test('max ceiling warning appears when exceeding max', async () => {
|
||||
await dialog.payInput.fill('')
|
||||
await dialog.payInput.pressSequentially('99999')
|
||||
await dialog.payInput.blur()
|
||||
|
||||
await expect(dialog.ceilingWarning).toBeVisible()
|
||||
await expect(dialog.payInput).toHaveValue('10,000')
|
||||
await expect(dialog.creditsInput).toHaveValue(expectedCredits(10_000))
|
||||
})
|
||||
|
||||
test('min amount warning appears for values below minimum', async () => {
|
||||
await dialog.payInput.fill('')
|
||||
await dialog.payInput.pressSequentially('2')
|
||||
await dialog.payInput.blur()
|
||||
|
||||
await expect(dialog.minWarning).toBeVisible()
|
||||
})
|
||||
|
||||
test('buy button disabled for sub-minimum amount', async () => {
|
||||
await dialog.payInput.fill('')
|
||||
await dialog.payInput.pressSequentially('3')
|
||||
await dialog.payInput.blur()
|
||||
|
||||
await expect(dialog.buyButton).toBeDisabled()
|
||||
await expect(dialog.minWarning).toBeVisible()
|
||||
})
|
||||
|
||||
test('buy button disabled when amount is zero', async () => {
|
||||
await dialog.payInput.fill('')
|
||||
await dialog.payInput.pressSequentially('0')
|
||||
await dialog.payInput.blur()
|
||||
|
||||
await expect(dialog.buyButton).toBeDisabled()
|
||||
})
|
||||
|
||||
test('dialog closes via close button', async () => {
|
||||
await dialog.closeButton.click()
|
||||
await expect(dialog.root).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
334
src/components/ui/stepper/FormattedNumberStepper.test.ts
Normal file
334
src/components/ui/stepper/FormattedNumberStepper.test.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
decrement: 'Decrement',
|
||||
increment: 'Increment'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function flush() {
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
function renderStepper(
|
||||
props?: Partial<{
|
||||
modelValue: number
|
||||
min: number
|
||||
max: number
|
||||
step: number | ((value: number) => number)
|
||||
formatOptions: Intl.NumberFormatOptions
|
||||
disabled: boolean
|
||||
'onUpdate:modelValue': (value: number) => void
|
||||
onMaxReached: () => void
|
||||
}>
|
||||
) {
|
||||
const user = userEvent.setup()
|
||||
const result = render(FormattedNumberStepper, {
|
||||
props: { modelValue: 0, ...props },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
return { user, ...result }
|
||||
}
|
||||
|
||||
describe('FormattedNumberStepper', () => {
|
||||
describe('rendering', () => {
|
||||
it('renders increment and decrement buttons', () => {
|
||||
renderStepper()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Decrement' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Increment' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders formatted initial value', () => {
|
||||
renderStepper({ modelValue: 1000 })
|
||||
expect(screen.getByRole('textbox')).toHaveValue('1,000')
|
||||
})
|
||||
|
||||
it('renders prefix slot content', () => {
|
||||
render(FormattedNumberStepper, {
|
||||
props: { modelValue: 0 },
|
||||
slots: { prefix: '<span data-testid="prefix">$</span>' },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
expect(screen.getByTestId('prefix')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders suffix slot content', () => {
|
||||
render(FormattedNumberStepper, {
|
||||
props: { modelValue: 0 },
|
||||
slots: { suffix: '<span data-testid="suffix">USD</span>' },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
expect(screen.getByTestId('suffix')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies disabled state to input and buttons', () => {
|
||||
renderStepper({ disabled: true })
|
||||
expect(screen.getByRole('textbox')).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Decrement' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Increment' })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleStep', () => {
|
||||
it('clicking + increments by step amount', async () => {
|
||||
const onUpdate = vi.fn<(value: number) => void>()
|
||||
const { user } = renderStepper({
|
||||
modelValue: 1,
|
||||
step: 2,
|
||||
'onUpdate:modelValue': onUpdate
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Increment' }))
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith(3)
|
||||
expect(screen.getByRole('textbox')).toHaveValue('3')
|
||||
})
|
||||
|
||||
it('clicking − decrements by step amount', async () => {
|
||||
const onUpdate = vi.fn<(value: number) => void>()
|
||||
const { user } = renderStepper({
|
||||
modelValue: 10,
|
||||
min: 0,
|
||||
step: 2,
|
||||
'onUpdate:modelValue': onUpdate
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Decrement' }))
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith(8)
|
||||
expect(screen.getByRole('textbox')).toHaveValue('8')
|
||||
})
|
||||
|
||||
it('disables − button at min', () => {
|
||||
renderStepper({ modelValue: 0, min: 0 })
|
||||
expect(screen.getByRole('button', { name: 'Decrement' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('disables + button at max', () => {
|
||||
renderStepper({ modelValue: 10, max: 10 })
|
||||
expect(screen.getByRole('button', { name: 'Increment' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('clamps value to [min, max] range', async () => {
|
||||
const onUpdate = vi.fn<(value: number) => void>()
|
||||
const { user } = renderStepper({
|
||||
modelValue: 9,
|
||||
min: 0,
|
||||
max: 10,
|
||||
step: 5,
|
||||
'onUpdate:modelValue': onUpdate
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Increment' }))
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith(10)
|
||||
expect(screen.getByRole('textbox')).toHaveValue('10')
|
||||
})
|
||||
|
||||
it('calls function-based step prop with current value', async () => {
|
||||
const onUpdate = vi.fn<(value: number) => void>()
|
||||
const stepFn = vi.fn((value: number) => value + 1)
|
||||
const { user } = renderStepper({
|
||||
modelValue: 4,
|
||||
step: stepFn,
|
||||
'onUpdate:modelValue': onUpdate
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Increment' }))
|
||||
|
||||
expect(stepFn).toHaveBeenCalledWith(4)
|
||||
expect(onUpdate).toHaveBeenCalledWith(9)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleInputChange', () => {
|
||||
it('typing digits updates modelValue', async () => {
|
||||
const onUpdate = vi.fn<(value: number) => void>()
|
||||
const { user } = renderStepper({
|
||||
modelValue: 0,
|
||||
'onUpdate:modelValue': onUpdate
|
||||
})
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
await user.clear(input)
|
||||
await user.type(input, '1234')
|
||||
|
||||
expect(onUpdate).toHaveBeenLastCalledWith(1234)
|
||||
})
|
||||
|
||||
it('strips non-numeric characters', async () => {
|
||||
const onUpdate = vi.fn<(value: number) => void>()
|
||||
const { user } = renderStepper({
|
||||
modelValue: 0,
|
||||
'onUpdate:modelValue': onUpdate
|
||||
})
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
await user.clear(input)
|
||||
await user.type(input, 'a1b2c3')
|
||||
|
||||
expect(onUpdate).toHaveBeenLastCalledWith(123)
|
||||
expect(input).toHaveValue('123')
|
||||
})
|
||||
|
||||
it('formats input with grouping separators', async () => {
|
||||
const { user } = renderStepper({ modelValue: 0 })
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
await user.clear(input)
|
||||
await user.type(input, '1000')
|
||||
|
||||
expect(input).toHaveValue('1,000')
|
||||
})
|
||||
|
||||
it('emits max-reached when input exceeds max', async () => {
|
||||
const onMaxReached = vi.fn()
|
||||
const { user } = renderStepper({
|
||||
modelValue: 0,
|
||||
max: 500,
|
||||
onMaxReached
|
||||
})
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
await user.clear(input)
|
||||
await user.type(input, '999')
|
||||
|
||||
expect(onMaxReached).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clamps to max when input exceeds max', async () => {
|
||||
const onUpdate = vi.fn<(value: number) => void>()
|
||||
const { user } = renderStepper({
|
||||
modelValue: 0,
|
||||
max: 500,
|
||||
'onUpdate:modelValue': onUpdate
|
||||
})
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
await user.clear(input)
|
||||
await user.type(input, '999')
|
||||
|
||||
expect(onUpdate).toHaveBeenLastCalledWith(500)
|
||||
expect(input).toHaveValue('500')
|
||||
})
|
||||
|
||||
it('resolves empty input to 0', async () => {
|
||||
const onUpdate = vi.fn<(value: number) => void>()
|
||||
const { user } = renderStepper({
|
||||
modelValue: 123,
|
||||
'onUpdate:modelValue': onUpdate
|
||||
})
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
await user.clear(input)
|
||||
|
||||
expect(onUpdate).toHaveBeenLastCalledWith(0)
|
||||
expect(input).toHaveValue('0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleInputBlur', () => {
|
||||
it('clamps value below min to min on blur', async () => {
|
||||
const onUpdate = vi.fn<(value: number) => void>()
|
||||
const { user } = renderStepper({
|
||||
modelValue: 5,
|
||||
min: 10,
|
||||
'onUpdate:modelValue': onUpdate
|
||||
})
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
await user.click(input)
|
||||
await user.tab()
|
||||
|
||||
expect(onUpdate).toHaveBeenLastCalledWith(10)
|
||||
expect(input).toHaveValue('10')
|
||||
})
|
||||
|
||||
it('reformats display on blur', async () => {
|
||||
const { user } = renderStepper({ modelValue: 1000 })
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
await user.click(input)
|
||||
await user.tab()
|
||||
|
||||
expect(input).toHaveValue('1,000')
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleInputFocus', () => {
|
||||
it('moves cursor to end of input on focus', async () => {
|
||||
const { user } = renderStepper({ modelValue: 1000 })
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement
|
||||
|
||||
input.setSelectionRange(0, 0)
|
||||
await user.click(input)
|
||||
await flush()
|
||||
|
||||
expect(input.selectionStart).toBe(input.value.length)
|
||||
expect(input.selectionEnd).toBe(input.value.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('v-model reactivity', () => {
|
||||
it('external modelValue change updates displayed text', async () => {
|
||||
const { rerender } = renderStepper({ modelValue: 1000 })
|
||||
expect(screen.getByRole('textbox')).toHaveValue('1,000')
|
||||
|
||||
await rerender({ modelValue: 2500 })
|
||||
await flush()
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveValue('2,500')
|
||||
})
|
||||
|
||||
it('external modelValue change does not overwrite while focused', async () => {
|
||||
const { user, rerender } = renderStepper({ modelValue: 1000 })
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
await user.click(input)
|
||||
await user.clear(input)
|
||||
await user.type(input, '2')
|
||||
await rerender({ modelValue: 9000 })
|
||||
await flush()
|
||||
|
||||
expect(input).toHaveValue('2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatNumber', () => {
|
||||
it('respects formatOptions prop', () => {
|
||||
renderStepper({
|
||||
modelValue: 1000,
|
||||
formatOptions: {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveValue('$1,000')
|
||||
})
|
||||
|
||||
it('applies locale formatting with default options', () => {
|
||||
renderStepper({ modelValue: 1234567 })
|
||||
expect(screen.getByRole('textbox')).toHaveValue('1,234,567')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user