refactor(node-replacement): reorganize domain components and expand comprehensive test suite (#9301)

## Summary

Resolves six open issues by reorganizing node replacement components
into a domain-driven folder structure, refactoring event handling to
follow the emit pattern, and adding comprehensive test coverage across
all affected modules.

## Changes

- **What**:
- Moved `SwapNodeGroupRow.vue` and `SwapNodesCard.vue` from
`src/components/rightSidePanel/errors/` to
`src/platform/nodeReplacement/components/` (Issues #9255)
- Moved `useMissingNodeScan.ts` from `src/composables/` to
`src/platform/nodeReplacement/missingNodeScan.ts`, renamed to reflect it
is a plain function not a Vue composable (Issues #9254)
- Refactored `SwapNodeGroupRow.vue` to emit a `'replace'` event instead
of calling `useNodeReplacement()` and `useExecutionErrorStore()`
directly; replacement logic now handled in `TabErrors.vue` (Issue #9267)
- Added unit tests for `removeMissingNodesByType`
(`executionErrorStore.test.ts`), `scanMissingNodes`
(`missingNodeScan.test.ts`), and `swapNodeGroups` computed
(`swapNodeGroups.test.ts`, `useErrorGroups.test.ts`) (Issue #9270)
- Added placeholder detection tests covering unregistered-type detection
when `has_errors` is false, and exclusion of registered types
(`useNodeReplacement.test.ts`) (Issue #9271)
- Added component tests for `MissingNodeCard` and `MissingPackGroupRow`
covering rendering, expand/collapse, events, install states, and edge
cases (Issue #9231)
- Added component tests for `SwapNodeGroupRow` and `SwapNodesCard`
(Issues #9255, #9267)

## Additional Changes (Post-Review)

- **Edge case guard in placeholder detection**
(`useNodeReplacement.ts`): When `last_serialization.type` is absent (old
serialization format), the predicate falls back to `n.type`, which the
app may have already run through `sanitizeNodeName` — stripping HTML
special characters (`& < > " ' \` =`). In that case, a `Set.has()`
lookup against the original unsanitized type name would silently miss,
causing replacement to be skipped.

Fixed by including sanitized variants of each target type in the
`targetTypes` Set at construction time. For the overwhelmingly common
case (no special characters in type names), the Set deduplicates the
entries and runtime behavior is identical to before.

A regression test was added to cover the specific scenario:
`last_serialization.type` absent + live `n.type` already sanitized.

## Review Focus

- `TabErrors.vue`: confirm the new `@replace` event handler correctly
replaces nodes and removes them from missing nodes list (mirrors the old
inline logic in `SwapNodeGroupRow`)
- `missingNodeScan.ts`: filename/export name change from
`useMissingNodeScan` — verify all call sites updated via `app.ts`
- Test mocking strategy: module-level `vi.mock()` factories use closures
over `ref`/plain objects to allow per-test overrides without global
mutable state

- Fixes #9231
- Fixes #9254
- Fixes #9255
- Fixes #9267
- Fixes #9270
- Fixes #9271
This commit is contained in:
jaeone94
2026-02-28 23:17:30 +09:00
committed by GitHub
parent 45f112e226
commit a0e518aa98
15 changed files with 2356 additions and 25 deletions

View File

@@ -0,0 +1,244 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { SwapNodeGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
import type { MissingNodeType } from '@/types/comfy'
import SwapNodeGroupRow from './SwapNodeGroupRow.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
rightSidePanel: {
locateNode: 'Locate Node',
missingNodePacks: {
collapse: 'Collapse',
expand: 'Expand'
}
},
nodeReplacement: {
willBeReplacedBy: 'This node will be replaced by:',
replaceNode: 'Replace Node',
unknownNode: 'Unknown'
}
}
},
missingWarn: false,
fallbackWarn: false
})
function makeGroup(overrides: Partial<SwapNodeGroup> = {}): SwapNodeGroup {
return {
type: 'OldNodeType',
newNodeId: 'NewNodeType',
nodeTypes: [
{ type: 'OldNodeType', nodeId: '1', isReplaceable: true },
{ type: 'OldNodeType', nodeId: '2', isReplaceable: true }
],
...overrides
}
}
function mountRow(
props: Partial<{
group: SwapNodeGroup
showNodeIdBadge: boolean
}> = {}
) {
return mount(SwapNodeGroupRow, {
props: {
group: makeGroup(),
showNodeIdBadge: false,
...props
},
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue, i18n],
stubs: {
TransitionCollapse: { template: '<div><slot /></div>' }
}
}
})
}
describe('SwapNodeGroupRow', () => {
describe('Basic Rendering', () => {
it('renders the group type name', () => {
const wrapper = mountRow()
expect(wrapper.text()).toContain('OldNodeType')
})
it('renders node count in parentheses', () => {
const wrapper = mountRow()
expect(wrapper.text()).toContain('(2)')
})
it('renders node count of 5 for 5 nodeTypes', () => {
const wrapper = mountRow({
group: makeGroup({
nodeTypes: Array.from({ length: 5 }, (_, i) => ({
type: 'OldNodeType',
nodeId: String(i),
isReplaceable: true
}))
})
})
expect(wrapper.text()).toContain('(5)')
})
it('renders the replacement target name', () => {
const wrapper = mountRow()
expect(wrapper.text()).toContain('NewNodeType')
})
it('shows "Unknown" when newNodeId is undefined', () => {
const wrapper = mountRow({
group: makeGroup({ newNodeId: undefined })
})
expect(wrapper.text()).toContain('Unknown')
})
it('renders "Replace Node" button', () => {
const wrapper = mountRow()
expect(wrapper.text()).toContain('Replace Node')
})
})
describe('Expand / Collapse', () => {
it('starts collapsed — node list not visible', () => {
const wrapper = mountRow({ showNodeIdBadge: true })
expect(wrapper.text()).not.toContain('#1')
})
it('expands when chevron is clicked', async () => {
const wrapper = mountRow({ showNodeIdBadge: true })
await wrapper.get('button[aria-label="Expand"]').trigger('click')
expect(wrapper.text()).toContain('#1')
expect(wrapper.text()).toContain('#2')
})
it('collapses when chevron is clicked again', async () => {
const wrapper = mountRow({ showNodeIdBadge: true })
await wrapper.get('button[aria-label="Expand"]').trigger('click')
expect(wrapper.text()).toContain('#1')
await wrapper.get('button[aria-label="Collapse"]').trigger('click')
expect(wrapper.text()).not.toContain('#1')
})
it('updates the toggle control state when expanded', async () => {
const wrapper = mountRow()
expect(wrapper.find('button[aria-label="Expand"]').exists()).toBe(true)
await wrapper.get('button[aria-label="Expand"]').trigger('click')
expect(wrapper.find('button[aria-label="Collapse"]').exists()).toBe(true)
})
})
describe('Node Type List (Expanded)', () => {
async function expand(wrapper: ReturnType<typeof mountRow>) {
await wrapper.get('button[aria-label="Expand"]').trigger('click')
}
it('renders all nodeTypes when expanded', async () => {
const wrapper = mountRow({
group: makeGroup({
nodeTypes: [
{ type: 'OldNodeType', nodeId: '10', isReplaceable: true },
{ type: 'OldNodeType', nodeId: '20', isReplaceable: true },
{ type: 'OldNodeType', nodeId: '30', isReplaceable: true }
]
}),
showNodeIdBadge: true
})
await expand(wrapper)
expect(wrapper.text()).toContain('#10')
expect(wrapper.text()).toContain('#20')
expect(wrapper.text()).toContain('#30')
})
it('shows nodeId badge when showNodeIdBadge is true', async () => {
const wrapper = mountRow({ showNodeIdBadge: true })
await expand(wrapper)
expect(wrapper.text()).toContain('#1')
expect(wrapper.text()).toContain('#2')
})
it('hides nodeId badge when showNodeIdBadge is false', async () => {
const wrapper = mountRow({ showNodeIdBadge: false })
await expand(wrapper)
expect(wrapper.text()).not.toContain('#1')
expect(wrapper.text()).not.toContain('#2')
})
it('renders Locate button for each nodeType with nodeId', async () => {
const wrapper = mountRow({ showNodeIdBadge: true })
await expand(wrapper)
expect(wrapper.findAll('button[aria-label="Locate Node"]')).toHaveLength(
2
)
})
it('does not render Locate button for nodeTypes without nodeId', async () => {
const wrapper = mountRow({
group: makeGroup({
// Intentionally omits nodeId to test graceful handling of incomplete node data
nodeTypes: [
{ type: 'NoIdNode', isReplaceable: true }
] as unknown as MissingNodeType[]
})
})
await expand(wrapper)
expect(wrapper.find('button[aria-label="Locate Node"]').exists()).toBe(
false
)
})
})
describe('Events', () => {
it('emits locate-node with correct nodeId', async () => {
const wrapper = mountRow({ showNodeIdBadge: true })
await wrapper.get('button[aria-label="Expand"]').trigger('click')
const locateBtns = wrapper.findAll('button[aria-label="Locate Node"]')
await locateBtns[0].trigger('click')
expect(wrapper.emitted('locate-node')).toBeTruthy()
expect(wrapper.emitted('locate-node')?.[0]).toEqual(['1'])
await locateBtns[1].trigger('click')
expect(wrapper.emitted('locate-node')?.[1]).toEqual(['2'])
})
it('emits replace with group when Replace button is clicked', async () => {
const group = makeGroup()
const wrapper = mountRow({ group })
const replaceBtn = wrapper
.findAll('button')
.find((b) => b.text().includes('Replace Node'))
if (!replaceBtn) throw new Error('Replace button not found')
await replaceBtn.trigger('click')
expect(wrapper.emitted('replace')).toBeTruthy()
expect(wrapper.emitted('replace')?.[0][0]).toEqual(group)
})
})
describe('Edge Cases', () => {
it('handles empty nodeTypes array', () => {
const wrapper = mountRow({
group: makeGroup({ nodeTypes: [] })
})
expect(wrapper.text()).toContain('(0)')
})
it('handles string nodeType entries', async () => {
const wrapper = mountRow({
group: makeGroup({
// Intentionally uses a plain string entry to test legacy node type handling
nodeTypes: ['StringType'] as unknown as MissingNodeType[]
})
})
await wrapper.get('button[aria-label="Expand"]').trigger('click')
expect(wrapper.text()).toContain('StringType')
})
})
})