fix: override plugin tool stream context and remove repository tests

- force plugin tools to use runtime stream_id/chat_id from execution context
- remove repository test assets and vitest config
- document that temporary test files must be deleted after use
This commit is contained in:
Losita
2026-05-12 22:36:32 +08:00
parent 702316ae57
commit 8d0f6d4401
98 changed files with 4 additions and 30458 deletions

View File

@@ -1,427 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
import { screen } from '@testing-library/dom'
import { render } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { DynamicConfigForm } from '../DynamicConfigForm'
import { FieldHookRegistry } from '@/lib/field-hooks'
import type { ConfigSchema } from '@/types/config-schema'
import type { FieldHookComponentProps } from '@/lib/field-hooks'
describe('DynamicConfigForm', () => {
describe('basic rendering', () => {
it('renders simple fields', () => {
const schema: ConfigSchema = {
className: 'TestConfig',
classDoc: 'Test configuration',
fields: [
{
name: 'field1',
type: 'string',
label: 'Field 1',
description: 'First field',
required: false,
default: 'value1',
},
{
name: 'field2',
type: 'boolean',
label: 'Field 2',
description: 'Second field',
required: false,
default: false,
},
],
}
const values = { field1: 'value1', field2: false }
const onChange = vi.fn()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} />)
expect(screen.getByText('Field 1')).toBeInTheDocument()
expect(screen.getByText('Field 2')).toBeInTheDocument()
expect(screen.getByText('First field')).toBeInTheDocument()
expect(screen.getByText('Second field')).toBeInTheDocument()
})
it('renders nested schema', () => {
const schema: ConfigSchema = {
className: 'MainConfig',
classDoc: 'Main configuration',
fields: [
{
name: 'top_field',
type: 'string',
label: 'Top Field',
description: 'Top level field',
required: false,
},
],
nested: {
sub_config: {
className: 'SubConfig',
classDoc: 'Sub configuration',
fields: [
{
name: 'nested_field',
type: 'number',
label: 'Nested Field',
description: 'Nested field',
required: false,
default: 42,
},
],
},
},
}
const values = {
top_field: 'top',
sub_config: {
nested_field: 42,
},
}
const onChange = vi.fn()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} />)
expect(screen.getByText('Top Field')).toBeInTheDocument()
expect(screen.getByText('Sub configuration')).toBeInTheDocument()
expect(screen.getByText('Nested Field')).toBeInTheDocument()
})
})
describe('Hook system', () => {
it('renders Hook component in replace mode', () => {
const TestHookComponent: React.FC<FieldHookComponentProps> = ({ fieldPath, value }) => {
return <div data-testid="hook-component">Hook: {fieldPath} = {String(value)}</div>
}
const hooks = new FieldHookRegistry()
hooks.register('hooked_field', TestHookComponent, 'replace')
const schema: ConfigSchema = {
className: 'TestConfig',
classDoc: 'Test configuration',
fields: [
{
name: 'hooked_field',
type: 'string',
label: 'Hooked Field',
description: 'A field with hook',
required: false,
},
{
name: 'normal_field',
type: 'string',
label: 'Normal Field',
description: 'A normal field',
required: false,
},
],
}
const values = { hooked_field: 'test', normal_field: 'normal' }
const onChange = vi.fn()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} hooks={hooks} />)
expect(screen.getByTestId('hook-component')).toBeInTheDocument()
expect(screen.getByText('Hook: hooked_field = test')).toBeInTheDocument()
expect(screen.queryByText('Hooked Field')).not.toBeInTheDocument()
expect(screen.getByText('Normal Field')).toBeInTheDocument()
})
it('renders Hook component in wrapper mode', () => {
const WrapperHookComponent: React.FC<FieldHookComponentProps> = ({ fieldPath, children }) => {
return (
<div data-testid="wrapper-hook">
<div>Wrapper for: {fieldPath}</div>
{children}
</div>
)
}
const hooks = new FieldHookRegistry()
hooks.register('wrapped_field', WrapperHookComponent, 'wrapper')
const schema: ConfigSchema = {
className: 'TestConfig',
classDoc: 'Test configuration',
fields: [
{
name: 'wrapped_field',
type: 'string',
label: 'Wrapped Field',
description: 'A wrapped field',
required: false,
},
],
}
const values = { wrapped_field: 'test' }
const onChange = vi.fn()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} hooks={hooks} />)
expect(screen.getByTestId('wrapper-hook')).toBeInTheDocument()
expect(screen.getByText('Wrapper for: wrapped_field')).toBeInTheDocument()
expect(screen.getByText('Wrapped Field')).toBeInTheDocument()
})
it('passes correct props to Hook component', () => {
const TestHookComponent: React.FC<FieldHookComponentProps> = ({ fieldPath, value, onChange }) => {
return (
<div>
<div data-testid="field-path">{fieldPath}</div>
<div data-testid="field-value">{String(value)}</div>
<button onClick={() => onChange?.('new_value')}>Change</button>
</div>
)
}
const hooks = new FieldHookRegistry()
hooks.register('test_field', TestHookComponent, 'replace')
const schema: ConfigSchema = {
className: 'TestConfig',
classDoc: 'Test configuration',
fields: [
{
name: 'test_field',
type: 'string',
label: 'Test Field',
description: 'A test field',
required: false,
},
],
}
const values = { test_field: 'original' }
const onChange = vi.fn()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} hooks={hooks} />)
expect(screen.getByTestId('field-path')).toHaveTextContent('test_field')
expect(screen.getByTestId('field-value')).toHaveTextContent('original')
})
})
describe('onChange propagation', () => {
it('propagates onChange from simple field', async () => {
const schema: ConfigSchema = {
className: 'TestConfig',
classDoc: 'Test configuration',
fields: [
{
name: 'test_field',
type: 'string',
label: 'Test Field',
description: 'A test field',
required: false,
},
],
}
const values = { test_field: '' }
const onChange = vi.fn()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} />)
const input = screen.getByRole('textbox')
input.focus()
await userEvent.keyboard('Hello')
expect(onChange).toHaveBeenCalledTimes(5)
expect(onChange.mock.calls.every(call => call[0] === 'test_field')).toBe(true)
expect(onChange).toHaveBeenNthCalledWith(1, 'test_field', 'H')
expect(onChange).toHaveBeenNthCalledWith(5, 'test_field', 'o')
})
it('propagates onChange from nested field with correct path', async () => {
const schema: ConfigSchema = {
className: 'MainConfig',
classDoc: 'Main configuration',
fields: [],
nested: {
sub_config: {
className: 'SubConfig',
classDoc: 'Sub configuration',
fields: [
{
name: 'nested_field',
type: 'string',
label: 'Nested Field',
description: 'Nested field',
required: false,
},
],
},
},
}
const values = {
sub_config: {
nested_field: '',
},
}
const onChange = vi.fn()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} />)
const input = screen.getByRole('textbox')
input.focus()
await userEvent.keyboard('Test')
expect(onChange).toHaveBeenCalledTimes(4)
expect(onChange.mock.calls.every(call => call[0] === 'sub_config.nested_field')).toBe(true)
expect(onChange).toHaveBeenNthCalledWith(1, 'sub_config.nested_field', 'T')
expect(onChange).toHaveBeenNthCalledWith(4, 'sub_config.nested_field', 't')
})
it('propagates onChange from Hook component', async () => {
const TestHookComponent: React.FC<FieldHookComponentProps> = ({ onChange }) => {
return <button onClick={() => onChange?.('hook_value')}>Set Value</button>
}
const hooks = new FieldHookRegistry()
hooks.register('hooked_field', TestHookComponent, 'replace')
const schema: ConfigSchema = {
className: 'TestConfig',
classDoc: 'Test configuration',
fields: [
{
name: 'hooked_field',
type: 'string',
label: 'Hooked Field',
description: 'A hooked field',
required: false,
},
],
}
const values = { hooked_field: '' }
const onChange = vi.fn()
const user = userEvent.setup()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} hooks={hooks} />)
await user.click(screen.getByRole('button'))
expect(onChange).toHaveBeenCalledWith('hooked_field', 'hook_value')
})
it('renders nested Hook component with full field path', async () => {
const NestedHookComponent: React.FC<FieldHookComponentProps> = ({ fieldPath, onChange }) => {
return (
<button onClick={() => onChange?.([{ enabled: true }])}>
{fieldPath}
</button>
)
}
const hooks = new FieldHookRegistry()
hooks.register('mcp.servers', NestedHookComponent, 'replace')
const schema: ConfigSchema = {
className: 'RootConfig',
classDoc: 'Root configuration',
fields: [],
nested: {
mcp: {
className: 'MCPConfig',
classDoc: 'MCP 配置',
fields: [
{
name: 'enable',
type: 'boolean',
label: '启用 MCP',
description: '是否启用 MCP',
required: false,
},
{
name: 'servers',
type: 'array',
label: '服务器列表',
description: '复杂对象数组',
required: false,
items: {
type: 'object',
},
},
],
nested: {
servers: {
className: 'MCPServerItemConfig',
classDoc: 'MCP 服务器项',
fields: [],
},
},
},
},
}
const values = {
mcp: {
enable: true,
servers: [],
},
}
const onChange = vi.fn()
const user = userEvent.setup()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} hooks={hooks} />)
await user.click(screen.getByRole('button', { name: 'mcp.servers' }))
expect(onChange).toHaveBeenCalledWith('mcp.servers', [{ enabled: true }])
})
})
describe('edge cases', () => {
it('renders with empty nested values', () => {
const schema: ConfigSchema = {
className: 'MainConfig',
classDoc: 'Main configuration',
fields: [],
nested: {
sub_config: {
className: 'SubConfig',
classDoc: 'Sub configuration',
fields: [
{
name: 'nested_field',
type: 'string',
label: 'Nested Field',
description: 'Nested field',
required: false,
},
],
},
},
}
const values = {}
const onChange = vi.fn()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} />)
expect(screen.getByText('Sub configuration')).toBeInTheDocument()
expect(screen.getByText('Nested Field')).toBeInTheDocument()
})
it('uses default hook registry when not provided', () => {
const schema: ConfigSchema = {
className: 'TestConfig',
classDoc: 'Test configuration',
fields: [
{
name: 'test_field',
type: 'string',
label: 'Test Field',
description: 'A test field',
required: false,
},
],
}
const values = { test_field: 'test' }
const onChange = vi.fn()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} />)
expect(screen.getByText('Test Field')).toBeInTheDocument()
})
})
})

View File

@@ -1,475 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
import { screen } from '@testing-library/dom'
import { render } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { DynamicField } from '../DynamicField'
import type { FieldSchema } from '@/types/config-schema'
describe('DynamicField', () => {
describe('x-widget priority', () => {
it('renders Slider when x-widget is slider', () => {
const schema: FieldSchema = {
name: 'test_slider',
type: 'number',
label: 'Test Slider',
description: 'A test slider',
required: false,
'x-widget': 'slider',
minValue: 0,
maxValue: 100,
default: 50,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value={50} onChange={onChange} />)
expect(screen.getByText('Test Slider')).toBeInTheDocument()
expect(screen.getByRole('slider')).toBeInTheDocument()
expect(screen.getByText('50')).toBeInTheDocument()
})
it('renders Switch when x-widget is switch', () => {
const schema: FieldSchema = {
name: 'test_switch',
type: 'boolean',
label: 'Test Switch',
description: 'A test switch',
required: false,
'x-widget': 'switch',
default: false,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value={false} onChange={onChange} />)
expect(screen.getByText('Test Switch')).toBeInTheDocument()
expect(screen.getByRole('switch')).toBeInTheDocument()
})
it('renders Textarea when x-widget is textarea', () => {
const schema: FieldSchema = {
name: 'test_textarea',
type: 'string',
label: 'Test Textarea',
description: 'A test textarea',
required: false,
'x-widget': 'textarea',
default: 'Hello',
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="Hello" onChange={onChange} />)
expect(screen.getByText('Test Textarea')).toBeInTheDocument()
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('textbox')).toHaveValue('Hello')
})
it('renders Select when x-widget is select', () => {
const schema: FieldSchema = {
name: 'test_select',
type: 'string',
label: 'Test Select',
description: 'A test select',
required: false,
'x-widget': 'select',
options: ['Option 1', 'Option 2', 'Option 3'],
default: 'Option 1',
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="Option 1" onChange={onChange} />)
expect(screen.getByText('Test Select')).toBeInTheDocument()
expect(screen.getByRole('combobox')).toBeInTheDocument()
})
it('renders placeholder for custom widget', () => {
const schema: FieldSchema = {
name: 'test_custom',
type: 'string',
label: 'Test Custom',
description: 'A test custom field',
required: false,
'x-widget': 'custom',
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="" onChange={onChange} />)
expect(screen.getByText('Custom field requires Hook')).toBeInTheDocument()
})
it('renders number Input when x-widget is input but type is integer', () => {
const schema: FieldSchema = {
name: 'test_integer_input_widget',
type: 'integer',
label: 'Test Integer Input Widget',
description: 'A numeric field rendered as input',
required: false,
'x-widget': 'input',
default: 0,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value={2} onChange={onChange} />)
const input = screen.getByRole('spinbutton')
expect(input).toBeInTheDocument()
expect(input).toHaveValue(2)
})
it('parses string values for numeric input widgets', () => {
const schema: FieldSchema = {
name: 'test_string_number_input_widget',
type: 'integer',
label: 'Test String Number Input Widget',
description: 'A numeric field with legacy string value',
required: false,
'x-widget': 'input',
default: 0,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="2" onChange={onChange} />)
expect(screen.getByRole('spinbutton')).toHaveValue(2)
})
})
describe('type fallback', () => {
it('renders Input for string type', () => {
const schema: FieldSchema = {
name: 'test_string',
type: 'string',
label: 'Test String',
description: 'A test string',
required: false,
default: 'Hello',
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="Hello" onChange={onChange} />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('textbox')).toHaveValue('Hello')
})
it('renders Switch for boolean type', () => {
const schema: FieldSchema = {
name: 'test_bool',
type: 'boolean',
label: 'Test Boolean',
description: 'A test boolean',
required: false,
default: true,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value={true} onChange={onChange} />)
expect(screen.getByRole('switch')).toBeInTheDocument()
expect(screen.getByRole('switch')).toBeChecked()
})
it('renders number Input for number type', () => {
const schema: FieldSchema = {
name: 'test_number',
type: 'number',
label: 'Test Number',
description: 'A test number',
required: false,
default: 42,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value={42} onChange={onChange} />)
const input = screen.getByRole('spinbutton')
expect(input).toBeInTheDocument()
expect(input).toHaveValue(42)
})
it('renders number Input for integer type', () => {
const schema: FieldSchema = {
name: 'test_integer',
type: 'integer',
label: 'Test Integer',
description: 'A test integer',
required: false,
default: 10,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value={10} onChange={onChange} />)
const input = screen.getByRole('spinbutton')
expect(input).toBeInTheDocument()
expect(input).toHaveValue(10)
})
it('renders Textarea for textarea type', () => {
const schema: FieldSchema = {
name: 'test_textarea_type',
type: 'textarea',
label: 'Test Textarea Type',
description: 'A test textarea type',
required: false,
default: 'Long text',
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="Long text" onChange={onChange} />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('textbox')).toHaveValue('Long text')
})
it('renders Select for select type', () => {
const schema: FieldSchema = {
name: 'test_select_type',
type: 'select',
label: 'Test Select Type',
description: 'A test select type',
required: false,
options: ['A', 'B', 'C'],
default: 'A',
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="A" onChange={onChange} />)
expect(screen.getByRole('combobox')).toBeInTheDocument()
})
it('renders textarea editor for primitive array type', () => {
const schema: FieldSchema = {
name: 'test_array',
type: 'array',
label: 'Test Array',
description: 'A test array',
required: false,
items: {
type: 'string',
},
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value={['a', 'b']} onChange={onChange} />)
expect(screen.getByRole('textbox')).toHaveValue('a\nb')
})
it('renders key-value editor for object type', () => {
const schema: FieldSchema = {
name: 'test_object',
type: 'object',
label: 'Test Object',
description: 'A test object',
required: false,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value={{ foo: 'bar' }} onChange={onChange} />)
expect(screen.getByText('可视化编辑')).toBeInTheDocument()
expect(screen.getByDisplayValue('foo')).toBeInTheDocument()
})
})
describe('onChange events', () => {
it('triggers onChange for Switch', async () => {
const schema: FieldSchema = {
name: 'test_switch',
type: 'boolean',
label: 'Test Switch',
description: 'A test switch',
required: false,
default: false,
}
const onChange = vi.fn()
const user = userEvent.setup()
render(<DynamicField schema={schema} value={false} onChange={onChange} />)
await user.click(screen.getByRole('switch'))
expect(onChange).toHaveBeenCalledWith(true)
})
it('triggers onChange for Input', async () => {
const schema: FieldSchema = {
name: 'test_input',
type: 'string',
label: 'Test Input',
description: 'A test input',
required: false,
default: '',
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="" onChange={onChange} />)
const input = screen.getByRole('textbox')
input.focus()
await userEvent.keyboard('Hello')
expect(onChange).toHaveBeenCalledTimes(5)
expect(onChange).toHaveBeenNthCalledWith(1, 'H')
expect(onChange).toHaveBeenNthCalledWith(2, 'e')
expect(onChange).toHaveBeenNthCalledWith(3, 'l')
expect(onChange).toHaveBeenNthCalledWith(4, 'l')
expect(onChange).toHaveBeenNthCalledWith(5, 'o')
})
it('triggers onChange for number Input', async () => {
const schema: FieldSchema = {
name: 'test_number',
type: 'number',
label: 'Test Number',
description: 'A test number',
required: false,
default: 0,
}
const onChange = vi.fn()
const user = userEvent.setup()
render(<DynamicField schema={schema} value={0} onChange={onChange} />)
const input = screen.getByRole('spinbutton')
await user.clear(input)
await user.type(input, '123')
expect(onChange).toHaveBeenCalled()
})
it('triggers numeric onChange for input widget with integer type', async () => {
const schema: FieldSchema = {
name: 'test_integer_input_widget_change',
type: 'integer',
label: 'Test Integer Input Widget Change',
description: 'A numeric field rendered as input',
required: false,
'x-widget': 'input',
default: 0,
}
const onChange = vi.fn()
const user = userEvent.setup()
render(<DynamicField schema={schema} value={0} onChange={onChange} />)
const input = screen.getByRole('spinbutton')
await user.clear(input)
await user.type(input, '5')
expect(onChange).toHaveBeenLastCalledWith(5)
})
})
describe('visual features', () => {
it('renders label with icon', () => {
const schema: FieldSchema = {
name: 'test_icon',
type: 'string',
label: 'Test Icon',
description: 'A test with icon',
required: false,
'x-icon': 'Settings',
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="" onChange={onChange} />)
expect(screen.getByText('Test Icon')).toBeInTheDocument()
})
it('renders required indicator', () => {
const schema: FieldSchema = {
name: 'test_required',
type: 'string',
label: 'Test Required',
description: 'A required field',
required: true,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="" onChange={onChange} />)
expect(screen.getByText('*')).toBeInTheDocument()
})
it('renders description', () => {
const schema: FieldSchema = {
name: 'test_desc',
type: 'string',
label: 'Test Description',
description: 'This is a description',
required: false,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="" onChange={onChange} />)
expect(screen.getByText('This is a description')).toBeInTheDocument()
})
})
describe('slider features', () => {
it('renders slider with min/max/step', () => {
const schema: FieldSchema = {
name: 'test_slider_props',
type: 'number',
label: 'Test Slider Props',
description: 'A slider with props',
required: false,
'x-widget': 'slider',
minValue: 10,
maxValue: 50,
step: 5,
default: 25,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value={25} onChange={onChange} />)
expect(screen.getByText('10')).toBeInTheDocument()
expect(screen.getByText('50')).toBeInTheDocument()
expect(screen.getByText('25')).toBeInTheDocument()
})
it('parses string values for slider widgets', () => {
const schema: FieldSchema = {
name: 'test_slider_string_value',
type: 'number',
label: 'Test Slider String Value',
description: 'A slider with legacy string value',
required: false,
'x-widget': 'slider',
minValue: 0,
maxValue: 10,
default: 0,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="2.5" onChange={onChange} />)
expect(screen.getByText('2.5')).toBeInTheDocument()
})
})
describe('select features', () => {
it('renders placeholder when no options', () => {
const schema: FieldSchema = {
name: 'test_select_no_options',
type: 'string',
label: 'Test Select No Options',
description: 'A select with no options',
required: false,
'x-widget': 'select',
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="" onChange={onChange} />)
expect(screen.getByText('No options available for select')).toBeInTheDocument()
})
})
})

View File

@@ -1,253 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { FieldHookRegistry } from '../field-hooks'
import type { FieldHookComponent } from '../field-hooks'
describe('FieldHookRegistry', () => {
let registry: FieldHookRegistry
beforeEach(() => {
registry = new FieldHookRegistry()
})
describe('register', () => {
it('registers a hook with replace type', () => {
const component: FieldHookComponent = () => null
registry.register('test.field', component, 'replace')
expect(registry.has('test.field')).toBe(true)
})
it('registers a hook with wrapper type', () => {
const component: FieldHookComponent = () => null
registry.register('test.field', component, 'wrapper')
expect(registry.has('test.field')).toBe(true)
const entry = registry.get('test.field')
expect(entry?.type).toBe('wrapper')
})
it('defaults to replace type when not specified', () => {
const component: FieldHookComponent = () => null
registry.register('test.field', component)
const entry = registry.get('test.field')
expect(entry?.type).toBe('replace')
})
it('overwrites existing hook for same field path', () => {
const component1: FieldHookComponent = () => null
const component2: FieldHookComponent = () => null
registry.register('test.field', component1, 'replace')
registry.register('test.field', component2, 'wrapper')
const entry = registry.get('test.field')
expect(entry?.component).toBe(component2)
expect(entry?.type).toBe('wrapper')
})
})
describe('get', () => {
it('returns hook entry for registered field path', () => {
const component: FieldHookComponent = () => null
registry.register('test.field', component, 'replace')
const entry = registry.get('test.field')
expect(entry).toBeDefined()
expect(entry?.component).toBe(component)
expect(entry?.type).toBe('replace')
})
it('returns undefined for unregistered field path', () => {
const entry = registry.get('nonexistent.field')
expect(entry).toBeUndefined()
})
it('returns correct entry for nested field paths', () => {
const component: FieldHookComponent = () => null
registry.register('config.section.field', component, 'wrapper')
const entry = registry.get('config.section.field')
expect(entry).toBeDefined()
expect(entry?.type).toBe('wrapper')
})
})
describe('has', () => {
it('returns true for registered field path', () => {
const component: FieldHookComponent = () => null
registry.register('test.field', component)
expect(registry.has('test.field')).toBe(true)
})
it('returns false for unregistered field path', () => {
expect(registry.has('nonexistent.field')).toBe(false)
})
it('returns false after unregistering', () => {
const component: FieldHookComponent = () => null
registry.register('test.field', component)
registry.unregister('test.field')
expect(registry.has('test.field')).toBe(false)
})
})
describe('unregister', () => {
it('removes a registered hook', () => {
const component: FieldHookComponent = () => null
registry.register('test.field', component)
expect(registry.has('test.field')).toBe(true)
registry.unregister('test.field')
expect(registry.has('test.field')).toBe(false)
})
it('does not throw when unregistering non-existent hook', () => {
expect(() => registry.unregister('nonexistent.field')).not.toThrow()
})
it('only removes specified hook, not others', () => {
const component1: FieldHookComponent = () => null
const component2: FieldHookComponent = () => null
registry.register('field1', component1)
registry.register('field2', component2)
registry.unregister('field1')
expect(registry.has('field1')).toBe(false)
expect(registry.has('field2')).toBe(true)
})
})
describe('clear', () => {
it('removes all registered hooks', () => {
const component1: FieldHookComponent = () => null
const component2: FieldHookComponent = () => null
const component3: FieldHookComponent = () => null
registry.register('field1', component1)
registry.register('field2', component2)
registry.register('field3', component3)
expect(registry.getAllPaths()).toHaveLength(3)
registry.clear()
expect(registry.getAllPaths()).toHaveLength(0)
expect(registry.has('field1')).toBe(false)
expect(registry.has('field2')).toBe(false)
expect(registry.has('field3')).toBe(false)
})
it('works correctly on empty registry', () => {
expect(() => registry.clear()).not.toThrow()
expect(registry.getAllPaths()).toHaveLength(0)
})
})
describe('getAllPaths', () => {
it('returns empty array when no hooks registered', () => {
expect(registry.getAllPaths()).toEqual([])
})
it('returns all registered field paths', () => {
const component: FieldHookComponent = () => null
registry.register('field1', component)
registry.register('field2', component)
registry.register('field3', component)
const paths = registry.getAllPaths()
expect(paths).toHaveLength(3)
expect(paths).toContain('field1')
expect(paths).toContain('field2')
expect(paths).toContain('field3')
})
it('returns updated paths after unregister', () => {
const component: FieldHookComponent = () => null
registry.register('field1', component)
registry.register('field2', component)
registry.register('field3', component)
registry.unregister('field2')
const paths = registry.getAllPaths()
expect(paths).toHaveLength(2)
expect(paths).toContain('field1')
expect(paths).toContain('field3')
expect(paths).not.toContain('field2')
})
it('handles nested field paths correctly', () => {
const component: FieldHookComponent = () => null
registry.register('config.chat.enabled', component)
registry.register('config.chat.model', component)
registry.register('config.api.key', component)
const paths = registry.getAllPaths()
expect(paths).toHaveLength(3)
expect(paths).toContain('config.chat.enabled')
expect(paths).toContain('config.chat.model')
expect(paths).toContain('config.api.key')
})
})
describe('integration scenarios', () => {
it('supports full lifecycle of multiple hooks', () => {
const replaceComponent: FieldHookComponent = () => null
const wrapperComponent: FieldHookComponent = () => null
registry.register('field1', replaceComponent, 'replace')
registry.register('field2', wrapperComponent, 'wrapper')
expect(registry.getAllPaths()).toHaveLength(2)
const entry1 = registry.get('field1')
expect(entry1?.type).toBe('replace')
expect(entry1?.component).toBe(replaceComponent)
const entry2 = registry.get('field2')
expect(entry2?.type).toBe('wrapper')
expect(entry2?.component).toBe(wrapperComponent)
registry.unregister('field1')
expect(registry.getAllPaths()).toHaveLength(1)
expect(registry.has('field2')).toBe(true)
registry.clear()
expect(registry.getAllPaths()).toHaveLength(0)
})
it('handles rapid register/unregister cycles', () => {
const component: FieldHookComponent = () => null
for (let i = 0; i < 100; i++) {
registry.register(`field${i}`, component)
}
expect(registry.getAllPaths()).toHaveLength(100)
for (let i = 0; i < 50; i++) {
registry.unregister(`field${i}`)
}
expect(registry.getAllPaths()).toHaveLength(50)
registry.clear()
expect(registry.getAllPaths()).toHaveLength(0)
})
})
})

View File

@@ -1,80 +0,0 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ReactNode } from 'react'
import { PluginConfigPage } from '../plugin-config'
import * as pluginApi from '@/lib/plugin-api'
const toastMock = vi.fn()
vi.mock('@/hooks/use-toast', () => ({
useToast: () => ({ toast: toastMock }),
}))
vi.mock('@/lib/restart-context', () => ({
RestartProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
useRestart: () => ({
showRestartPrompt: false,
markRestartRequired: vi.fn(),
clearRestartRequired: vi.fn(),
}),
}))
vi.mock('@/components/restart-overlay', () => ({
RestartOverlay: () => null,
}))
vi.mock('@/components', () => ({
CodeEditor: ({ value }: { value: string }) => <pre>{value}</pre>,
ListFieldEditor: () => <div>list-field-editor</div>,
}))
vi.mock('@/lib/plugin-api', () => ({
getInstalledPlugins: vi.fn(),
getPluginConfigSchema: vi.fn(),
getPluginConfig: vi.fn(),
getPluginConfigRaw: vi.fn(),
updatePluginConfig: vi.fn(),
updatePluginConfigRaw: vi.fn(),
resetPluginConfig: vi.fn(),
togglePlugin: vi.fn(),
}))
describe('PluginConfigPage', () => {
beforeEach(() => {
toastMock.mockReset()
vi.mocked(pluginApi.getInstalledPlugins).mockResolvedValue({
success: true,
data: [
{
id: 'test.emoji',
path: '/plugins/test_emoji',
manifest: {
manifest_version: 2,
name: 'Emoji Plugin',
version: '1.0.0',
description: 'emoji tools',
author: { name: 'tester' },
license: 'MIT',
host_application: { min_version: '1.0.0' },
},
},
],
})
vi.mocked(pluginApi.getPluginConfigSchema).mockResolvedValue({} as never)
vi.mocked(pluginApi.getPluginConfig).mockResolvedValue({} as never)
vi.mocked(pluginApi.getPluginConfigRaw).mockResolvedValue({} as never)
vi.mocked(pluginApi.updatePluginConfig).mockResolvedValue({} as never)
vi.mocked(pluginApi.updatePluginConfigRaw).mockResolvedValue({} as never)
vi.mocked(pluginApi.resetPluginConfig).mockResolvedValue({} as never)
vi.mocked(pluginApi.togglePlugin).mockResolvedValue({} as never)
})
it('shows real plugins and no longer surfaces A_Memorix in plugin config list', async () => {
render(<PluginConfigPage />)
expect(await screen.findByText('Emoji Plugin')).toBeInTheDocument()
expect(screen.getByText('点击插件查看和编辑配置')).toBeInTheDocument()
expect(screen.queryByText(/A_Memorix/i)).not.toBeInTheDocument()
})
})

View File

@@ -1,802 +0,0 @@
import { act, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { KnowledgeBasePage } from '../knowledge-base'
import * as memoryApi from '@/lib/memory-api'
const navigateMock = vi.fn()
const toastMock = vi.fn()
vi.mock('@tanstack/react-router', () => ({
useNavigate: () => navigateMock,
}))
vi.mock('@/hooks/use-toast', () => ({
useToast: () => ({ toast: toastMock }),
}))
vi.mock('@/components', () => ({
CodeEditor: ({ value }: { value: string }) => <pre data-testid="code-editor">{value}</pre>,
MarkdownRenderer: ({ content }: { content: string }) => <div>{content}</div>,
}))
vi.mock('@/components/memory/MemoryConfigEditor', () => ({
MemoryConfigEditor: () => <div data-testid="memory-config-editor">memory-config-editor</div>,
}))
vi.mock('@/components/memory/MemoryDeleteDialog', () => ({
MemoryDeleteDialog: ({
open,
onExecute,
onRestore,
preview,
result,
}: {
open: boolean
preview?: { mode?: string; item_count?: number } | null
result?: { operation_id?: string } | null
onExecute?: () => void
onRestore?: () => void
}) => (
open ? (
<div data-testid="memory-delete-dialog">
<div>{`preview:${preview?.mode ?? 'none'}:${preview?.item_count ?? 0}`}</div>
<div>{`result:${result?.operation_id ?? 'none'}`}</div>
<button type="button" onClick={onExecute}></button>
<button type="button" onClick={onRestore}></button>
</div>
) : null
),
}))
vi.mock('@/lib/memory-api', () => ({
getMemoryConfigSchema: vi.fn(),
getMemoryConfig: vi.fn(),
getMemoryConfigRaw: vi.fn(),
getMemoryRuntimeConfig: vi.fn(),
getMemoryImportGuide: vi.fn(),
getMemoryImportSettings: vi.fn(),
getMemoryImportPathAliases: vi.fn(),
getMemoryImportTasks: vi.fn(),
getMemoryImportTask: vi.fn(),
getMemoryImportTaskChunks: vi.fn(),
createMemoryUploadImport: vi.fn(),
createMemoryPasteImport: vi.fn(),
createMemoryRawScanImport: vi.fn(),
createMemoryLpmmOpenieImport: vi.fn(),
createMemoryLpmmConvertImport: vi.fn(),
createMemoryTemporalBackfillImport: vi.fn(),
createMemoryMaibotMigrationImport: vi.fn(),
cancelMemoryImportTask: vi.fn(),
retryMemoryImportTask: vi.fn(),
resolveMemoryImportPath: vi.fn(),
refreshMemoryRuntimeSelfCheck: vi.fn(),
updateMemoryConfig: vi.fn(),
updateMemoryConfigRaw: vi.fn(),
getMemoryTuningProfile: vi.fn(),
getMemoryTuningTasks: vi.fn(),
createMemoryTuningTask: vi.fn(),
applyBestMemoryTuningProfile: vi.fn(),
getMemorySources: vi.fn(),
getMemoryDeleteOperations: vi.fn(),
getMemoryDeleteOperation: vi.fn(),
getMemoryFeedbackCorrections: vi.fn(),
getMemoryFeedbackCorrection: vi.fn(),
previewMemoryDelete: vi.fn(),
executeMemoryDelete: vi.fn(),
restoreMemoryDelete: vi.fn(),
rollbackMemoryFeedbackCorrection: vi.fn(),
}))
function mockImportTask(taskId: string, status: string = 'running'): memoryApi.MemoryImportTaskPayload {
return {
task_id: taskId,
source: 'webui',
status,
current_step: status === 'completed' ? 'completed' : 'running',
total_chunks: 120,
done_chunks: status === 'completed' ? 120 : 36,
failed_chunks: status === 'completed' ? 0 : 2,
cancelled_chunks: 0,
progress: status === 'completed' ? 100 : 30,
error: '',
file_count: 2,
created_at: 1_710_000_000,
started_at: 1_710_000_001,
finished_at: status === 'completed' ? 1_710_000_099 : null,
updated_at: 1_710_000_100,
task_kind: 'paste',
params: {},
files: [],
}
}
function mockImportDetail(taskId: string): memoryApi.MemoryImportTaskPayload {
return {
...mockImportTask(taskId),
files: [
{
file_id: 'file-alpha',
name: 'alpha.txt',
source_kind: 'paste',
input_mode: 'text',
status: 'running',
current_step: 'running',
detected_strategy_type: 'auto',
total_chunks: 80,
done_chunks: 30,
failed_chunks: 1,
cancelled_chunks: 0,
progress: 37.5,
error: '',
created_at: 1_710_000_000,
updated_at: 1_710_000_100,
},
{
file_id: 'file-beta',
name: 'beta.txt',
source_kind: 'paste',
input_mode: 'text',
status: 'failed',
current_step: 'extracting',
detected_strategy_type: 'auto',
total_chunks: 40,
done_chunks: 6,
failed_chunks: 4,
cancelled_chunks: 0,
progress: 25,
error: 'mock error',
created_at: 1_710_000_000,
updated_at: 1_710_000_100,
},
],
}
}
function mockImportCompletedWithErrorsDetail(taskId: string): memoryApi.MemoryImportTaskPayload {
return {
...mockImportDetail(taskId),
status: 'completed_with_errors',
current_step: 'completed_with_errors',
total_chunks: 12,
done_chunks: 9,
failed_chunks: 3,
cancelled_chunks: 0,
progress: 75,
files: [
{
file_id: 'file-error',
name: 'error.txt',
source_kind: 'paste',
input_mode: 'text',
status: 'failed',
current_step: 'failed',
detected_strategy_type: 'auto',
total_chunks: 12,
done_chunks: 9,
failed_chunks: 3,
cancelled_chunks: 0,
progress: 75,
error: 'mock error',
created_at: 1_710_000_000,
updated_at: 1_710_000_100,
},
],
}
}
describe('KnowledgeBasePage import workflow', () => {
beforeEach(() => {
navigateMock.mockReset()
toastMock.mockReset()
vi.mocked(memoryApi.getMemoryConfigSchema).mockResolvedValue({
success: true,
path: 'config/a_memorix.toml',
schema: {
plugin_id: 'a_memorix',
plugin_info: {
name: 'A_Memorix',
version: '2.0.0',
description: '长期记忆子系统',
author: 'A_Dawn',
},
_note: 'raw-only 字段仍可通过 TOML 编辑',
layout: {
type: 'tabs',
tabs: [{ id: 'basic', title: '基础', sections: ['plugin'], order: 1 }],
},
sections: {
plugin: {
name: 'plugin',
title: '子系统状态',
collapsed: false,
order: 1,
fields: {},
},
},
},
})
vi.mocked(memoryApi.getMemoryConfig).mockResolvedValue({
success: true,
path: 'config/a_memorix.toml',
config: { plugin: { enabled: true } },
})
vi.mocked(memoryApi.getMemoryConfigRaw).mockResolvedValue({
success: true,
path: 'config/a_memorix.toml',
config: '[plugin]\nenabled = true\n',
})
vi.mocked(memoryApi.getMemoryRuntimeConfig).mockResolvedValue({
success: true,
config: { plugin: { enabled: true } },
data_dir: 'data/plugins/a-dawn.a-memorix',
embedding_dimension: 1024,
auto_save: true,
relation_vectors_enabled: false,
runtime_ready: true,
embedding_degraded: false,
embedding_degraded_reason: '',
embedding_degraded_since: null,
embedding_last_check: null,
paragraph_vector_backfill_pending: 2,
paragraph_vector_backfill_running: 0,
paragraph_vector_backfill_failed: 1,
paragraph_vector_backfill_done: 3,
})
vi.mocked(memoryApi.getMemoryImportGuide).mockResolvedValue({
success: true,
content: '# 导入指南\n导入说明',
})
vi.mocked(memoryApi.getMemoryImportSettings).mockResolvedValue({
success: true,
settings: {
max_paste_chars: 200_000,
max_file_concurrency: 8,
max_chunk_concurrency: 16,
default_file_concurrency: 2,
default_chunk_concurrency: 4,
poll_interval_ms: 60_000,
maibot_source_db_default: 'data/maibot.db',
},
})
vi.mocked(memoryApi.getMemoryImportPathAliases).mockResolvedValue({
success: true,
path_aliases: {
lpmm: 'data/lpmm',
plugin_data: 'data/plugins/a-dawn.a-memorix',
raw: 'data/raw',
},
})
vi.mocked(memoryApi.getMemoryImportTasks).mockResolvedValue({
success: true,
items: [
mockImportTask('import-run-1', 'running'),
mockImportTask('import-queued-1', 'queued'),
mockImportTask('import-done-1', 'completed'),
],
})
vi.mocked(memoryApi.getMemoryImportTask).mockResolvedValue({
success: true,
task: mockImportDetail('import-run-1'),
})
vi.mocked(memoryApi.getMemoryImportTaskChunks).mockImplementation(async (_taskId, fileId, offset = 0) => ({
success: true,
task_id: 'import-run-1',
file_id: fileId,
offset,
limit: 50,
total: 120,
items: [
{
chunk_id: `${fileId}-${offset + 0}`,
index: offset + 0,
chunk_type: 'text',
status: 'running',
step: 'extracting',
failed_at: '',
retryable: true,
error: '',
progress: 50,
content_preview: `chunk-preview-${offset + 0}`,
updated_at: 1_710_000_111,
},
],
}))
vi.mocked(memoryApi.createMemoryUploadImport).mockResolvedValue({
success: true,
task: mockImportTask('upload-task-1', 'queued'),
})
vi.mocked(memoryApi.createMemoryPasteImport).mockResolvedValue({
success: true,
task: mockImportTask('paste-task-1', 'queued'),
})
vi.mocked(memoryApi.createMemoryRawScanImport).mockResolvedValue({
success: true,
task: mockImportTask('raw-task-1', 'queued'),
})
vi.mocked(memoryApi.createMemoryLpmmOpenieImport).mockResolvedValue({
success: true,
task: mockImportTask('openie-task-1', 'queued'),
})
vi.mocked(memoryApi.createMemoryLpmmConvertImport).mockResolvedValue({
success: true,
task: mockImportTask('convert-task-1', 'queued'),
})
vi.mocked(memoryApi.createMemoryTemporalBackfillImport).mockResolvedValue({
success: true,
task: mockImportTask('backfill-task-1', 'queued'),
})
vi.mocked(memoryApi.createMemoryMaibotMigrationImport).mockResolvedValue({
success: true,
task: mockImportTask('migration-task-1', 'queued'),
})
vi.mocked(memoryApi.cancelMemoryImportTask).mockResolvedValue({
success: true,
task: mockImportTask('import-run-1', 'cancel_requested'),
})
vi.mocked(memoryApi.retryMemoryImportTask).mockResolvedValue({
success: true,
task: mockImportTask('retry-task-1', 'queued'),
})
vi.mocked(memoryApi.resolveMemoryImportPath).mockResolvedValue({
success: true,
alias: 'raw',
relative_path: 'exports',
resolved_path: 'D:/Dev/rdev/MaiBot/data/raw/exports',
exists: true,
is_file: false,
is_dir: true,
})
vi.mocked(memoryApi.getMemoryTuningProfile).mockResolvedValue({
success: true,
profile: { retrieval: { top_k: 10 } },
toml: '[retrieval]\ntop_k = 10\n',
})
vi.mocked(memoryApi.getMemoryTuningTasks).mockResolvedValue({
success: true,
items: [{ task_id: 'tune-1', status: 'done' }],
})
vi.mocked(memoryApi.createMemoryTuningTask).mockResolvedValue({ success: true } as never)
vi.mocked(memoryApi.applyBestMemoryTuningProfile).mockResolvedValue({ success: true } as never)
vi.mocked(memoryApi.getMemorySources).mockResolvedValue({
success: true,
items: [{ source: 'demo-1', paragraph_count: 2, relation_count: 1 }],
count: 1,
})
vi.mocked(memoryApi.getMemoryDeleteOperations).mockResolvedValue({
success: true,
items: [
{
operation_id: 'del-1',
mode: 'source',
status: 'executed',
summary: { counts: { paragraphs: 2, relations: 1, sources: 1 } },
},
],
count: 1,
})
vi.mocked(memoryApi.getMemoryDeleteOperation).mockResolvedValue({
success: true,
operation: {
operation_id: 'del-1',
mode: 'source',
status: 'executed',
selector: { sources: ['demo-1'] },
summary: { counts: { paragraphs: 2, relations: 1, sources: 1 }, sources: ['demo-1'] },
items: [],
},
})
vi.mocked(memoryApi.getMemoryFeedbackCorrections).mockResolvedValue({
success: true,
items: [
{
task_id: 11,
query_tool_id: 'tool-query-11',
session_id: 'session-1',
query_text: '测试用户最喜欢的颜色是什么',
query_timestamp: 1_710_000_010,
task_status: 'applied',
decision: 'correct',
decision_confidence: 0.97,
feedback_message_count: 1,
rollback_status: 'none',
affected_counts: {
relations: 1,
stale_paragraphs: 1,
episode_sources: 2,
profile_person_ids: 1,
correction_paragraphs: 1,
corrected_relations: 1,
},
created_at: 1_710_000_011,
updated_at: 1_710_000_012,
},
],
count: 1,
})
vi.mocked(memoryApi.getMemoryFeedbackCorrection).mockResolvedValue({
success: true,
task: {
task_id: 11,
query_tool_id: 'tool-query-11',
session_id: 'session-1',
query_text: '测试用户最喜欢的颜色是什么',
query_timestamp: 1_710_000_010,
task_status: 'applied',
decision: 'correct',
decision_confidence: 0.97,
feedback_message_count: 1,
rollback_status: 'none',
affected_counts: {
relations: 1,
stale_paragraphs: 1,
episode_sources: 2,
profile_person_ids: 1,
correction_paragraphs: 1,
corrected_relations: 1,
},
query_snapshot: { query: '测试用户最喜欢的颜色是什么', hits: [{ hash: 'paragraph-1' }] },
decision_payload: { decision: 'correct', confidence: 0.97 },
rollback_plan_summary: {
forgotten_relations: [{ hash: 'rel-old', subject: '测试用户', predicate: '最喜欢的颜色是', object: '蓝色' }],
corrected_write: {
paragraph_hashes: ['paragraph-new'],
corrected_relations: [{ hash: 'rel-new', subject: '测试用户', predicate: '最喜欢的颜色是', object: '绿色' }],
},
},
rollback_result: {},
action_logs: [
{
id: 1,
task_id: 11,
query_tool_id: 'tool-query-11',
action_type: 'forget_relation',
target_hash: 'rel-old',
reason: '用户明确纠正为绿色',
before_payload: { hash: 'rel-old', subject: '测试用户', predicate: '最喜欢的颜色是', object: '蓝色' },
after_payload: { is_inactive: true },
created_at: 1_710_000_013,
},
],
created_at: 1_710_000_011,
updated_at: 1_710_000_012,
},
})
vi.mocked(memoryApi.previewMemoryDelete).mockResolvedValue({
success: true,
mode: 'source',
selector: { sources: ['demo-1'] },
counts: { sources: 1, paragraphs: 2, relations: 1 },
sources: ['demo-1'],
items: [{ item_type: 'paragraph', item_hash: 'p-1', label: 'demo-1' }],
item_count: 1,
dry_run: true,
} as never)
vi.mocked(memoryApi.executeMemoryDelete).mockResolvedValue({
success: true,
mode: 'source',
operation_id: 'del-2',
counts: { sources: 1, paragraphs: 2, relations: 1 },
sources: ['demo-1'],
deleted_count: 4,
deleted_entity_count: 0,
deleted_relation_count: 1,
deleted_paragraph_count: 2,
deleted_source_count: 1,
} as never)
vi.mocked(memoryApi.restoreMemoryDelete).mockResolvedValue({ success: true } as never)
vi.mocked(memoryApi.rollbackMemoryFeedbackCorrection).mockResolvedValue({
success: true,
result: { restored_relation_hashes: ['rel-old'] },
task: {
task_id: 11,
query_tool_id: 'tool-query-11',
session_id: 'session-1',
query_text: '测试用户最喜欢的颜色是什么',
query_timestamp: 1_710_000_010,
task_status: 'applied',
decision: 'correct',
decision_confidence: 0.97,
feedback_message_count: 1,
rollback_status: 'rolled_back',
affected_counts: {
relations: 1,
stale_paragraphs: 1,
episode_sources: 2,
profile_person_ids: 1,
correction_paragraphs: 1,
corrected_relations: 1,
},
query_snapshot: { query: '测试用户最喜欢的颜色是什么', hits: [{ hash: 'paragraph-1' }] },
decision_payload: { decision: 'correct', confidence: 0.97 },
rollback_plan_summary: {},
rollback_result: { restored_relation_hashes: ['rel-old'] },
action_logs: [],
created_at: 1_710_000_011,
updated_at: 1_710_000_012,
},
})
vi.mocked(memoryApi.refreshMemoryRuntimeSelfCheck).mockResolvedValue({
success: true,
report: { ok: true },
})
vi.mocked(memoryApi.updateMemoryConfig).mockResolvedValue({ success: true } as never)
vi.mocked(memoryApi.updateMemoryConfigRaw).mockResolvedValue({ success: true } as never)
})
it('loads import settings/guide/tasks on first render', async () => {
const user = userEvent.setup()
render(<KnowledgeBasePage />)
expect(await screen.findByText('长期记忆控制台', undefined, { timeout: 10_000 })).toBeInTheDocument()
await user.click(screen.getByRole('tab', { name: '导入' }))
expect(await screen.findByRole('button', { name: '创建导入任务' })).toBeInTheDocument()
expect((await screen.findAllByText('import-run-1')).length).toBeGreaterThan(0)
expect(memoryApi.getMemoryImportSettings).toHaveBeenCalled()
expect(memoryApi.getMemoryImportPathAliases).toHaveBeenCalled()
expect(memoryApi.getMemoryImportTasks).toHaveBeenCalled()
})
it('creates import tasks for all 7 modes and calls correct endpoints', async () => {
const user = userEvent.setup()
const { container } = render(<KnowledgeBasePage />)
const openImportTab = async () => {
await user.click(screen.getByRole('tab', { name: '导入' }))
await screen.findByRole('button', { name: '创建导入任务' })
}
await screen.findByText('长期记忆控制台', undefined, { timeout: 10_000 })
await openImportTab()
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
const uploadFiles = [
new File(['hello'], 'demo.txt', { type: 'text/plain' }),
new File(['{"name":"mai"}'], 'demo.json', { type: 'application/json' }),
new File(['a,b\n1,2'], 'demo.csv', { type: 'text/csv' }),
new File(['# note'], 'demo.md', { type: 'text/markdown' }),
]
await user.upload(fileInput, uploadFiles)
await user.click(screen.getByRole('button', { name: '创建导入任务' }))
await waitFor(() => expect(memoryApi.createMemoryUploadImport).toHaveBeenCalledTimes(1))
await openImportTab()
await user.click(screen.getByRole('tab', { name: '粘贴导入' }))
const editableTextarea = Array.from(container.querySelectorAll('textarea')).find((item) => !item.readOnly)
if (!editableTextarea) {
throw new Error('missing editable textarea')
}
await user.type(editableTextarea, 'paste content')
await user.click(screen.getByRole('button', { name: '创建导入任务' }))
await waitFor(() => expect(memoryApi.createMemoryPasteImport).toHaveBeenCalledTimes(1))
await openImportTab()
await user.click(screen.getByRole('tab', { name: '本地扫描' }))
await user.click(screen.getByRole('button', { name: '创建导入任务' }))
await waitFor(() => expect(memoryApi.createMemoryRawScanImport).toHaveBeenCalledTimes(1))
await openImportTab()
await user.click(screen.getByRole('tab', { name: 'LPMM OpenIE' }))
await user.click(screen.getByRole('button', { name: '创建导入任务' }))
await waitFor(() => expect(memoryApi.createMemoryLpmmOpenieImport).toHaveBeenCalledTimes(1))
await openImportTab()
await user.click(screen.getByRole('tab', { name: 'LPMM 转换' }))
await user.click(screen.getByRole('button', { name: '创建导入任务' }))
await waitFor(() => expect(memoryApi.createMemoryLpmmConvertImport).toHaveBeenCalledTimes(1))
await openImportTab()
await user.click(screen.getByRole('tab', { name: '时序回填' }))
await user.click(screen.getByRole('button', { name: '创建导入任务' }))
await waitFor(() => expect(memoryApi.createMemoryTemporalBackfillImport).toHaveBeenCalledTimes(1))
await openImportTab()
await user.click(screen.getByRole('tab', { name: 'MaiBot 迁移' }))
await user.click(screen.getByRole('button', { name: '创建导入任务' }))
await waitFor(() => expect(memoryApi.createMemoryMaibotMigrationImport).toHaveBeenCalledTimes(1))
const [uploadedFiles, uploadPayload] = vi.mocked(memoryApi.createMemoryUploadImport).mock.calls[0]
expect(uploadedFiles).toHaveLength(4)
expect(uploadedFiles.map((file) => file.name)).toEqual(['demo.txt', 'demo.json', 'demo.csv', 'demo.md'])
expect(uploadPayload).toMatchObject({
input_mode: 'text',
llm_enabled: true,
strategy_override: 'auto',
dedupe_policy: 'content_hash',
})
}, 60_000)
it('loads task detail and supports chunk pagination', async () => {
const user = userEvent.setup()
render(<KnowledgeBasePage />)
await screen.findByText('长期记忆控制台', undefined, { timeout: 10_000 })
await user.click(screen.getByRole('tab', { name: '导入' }))
expect(await screen.findByText('alpha.txt')).toBeInTheDocument()
expect(await screen.findByText('chunk-preview-0')).toBeInTheDocument()
const betaButton = screen.getByText('beta.txt').closest('button')
if (!betaButton) {
throw new Error('missing file beta button')
}
await user.click(betaButton)
await waitFor(() =>
expect(memoryApi.getMemoryImportTaskChunks).toHaveBeenCalledWith('import-run-1', 'file-beta', 0, 50),
)
await user.click(screen.getByRole('button', { name: '下一页分块' }))
await waitFor(() =>
expect(memoryApi.getMemoryImportTaskChunks).toHaveBeenCalledWith('import-run-1', 'file-beta', 50, 50),
)
}, 20_000)
it('shows import failures separately from successful chunks', async () => {
vi.mocked(memoryApi.getMemoryImportTask).mockResolvedValue({
success: true,
task: mockImportCompletedWithErrorsDetail('import-run-1'),
})
const user = userEvent.setup()
render(<KnowledgeBasePage />)
await screen.findByText('长期记忆控制台', undefined, { timeout: 10_000 })
await user.click(screen.getByRole('tab', { name: '导入' }))
expect((await screen.findAllByText('完成(有错误)')).length).toBeGreaterThan(0)
expect(await screen.findByText('成功 9 / 12 分块 · 失败 3')).toBeInTheDocument()
}, 20_000)
it('supports cancel and retry actions for selected task', async () => {
const user = userEvent.setup()
render(<KnowledgeBasePage />)
await screen.findByText('长期记忆控制台', undefined, { timeout: 10_000 })
await user.click(screen.getByRole('tab', { name: '导入' }))
await screen.findByText('任务详情')
await user.click(screen.getByRole('button', { name: '取消选中导入任务' }))
await waitFor(() => expect(memoryApi.cancelMemoryImportTask).toHaveBeenCalledWith('import-run-1'))
await user.click(screen.getByRole('button', { name: '重试选中导入任务' }))
await waitFor(() => expect(memoryApi.retryMemoryImportTask).toHaveBeenCalled())
const [taskId, retryPayload] = vi.mocked(memoryApi.retryMemoryImportTask).mock.calls[0]
expect(taskId).toBe('import-run-1')
expect(retryPayload).toMatchObject({
overrides: {
llm_enabled: true,
strategy_override: 'auto',
},
})
}, 20_000)
it('auto polling updates queue and keeps page stable when refresh fails once', async () => {
vi.mocked(memoryApi.getMemoryImportSettings).mockResolvedValue({
success: true,
settings: {
max_paste_chars: 200_000,
max_file_concurrency: 8,
max_chunk_concurrency: 16,
default_file_concurrency: 2,
default_chunk_concurrency: 4,
poll_interval_ms: 200,
maibot_source_db_default: 'data/maibot.db',
},
})
const user = userEvent.setup()
render(<KnowledgeBasePage />)
await screen.findByText('长期记忆控制台', undefined, { timeout: 10_000 })
await user.click(screen.getByRole('tab', { name: '导入' }))
await screen.findByText('导入队列')
const initialCalls = vi.mocked(memoryApi.getMemoryImportTasks).mock.calls.length
vi.mocked(memoryApi.getMemoryImportTasks).mockRejectedValueOnce(new Error('poll failure'))
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 350))
})
expect(screen.getByText('长期记忆控制台')).toBeInTheDocument()
expect(vi.mocked(memoryApi.getMemoryImportTasks).mock.calls.length).toBeGreaterThan(initialCalls)
}, 20_000)
it('creates tuning task and applies best profile (tuning module)', async () => {
const user = userEvent.setup()
render(<KnowledgeBasePage />)
await screen.findByText('长期记忆控制台', undefined, { timeout: 10_000 })
await user.click(screen.getByRole('tab', { name: '调优' }))
await screen.findByText('调优任务')
await user.click(screen.getByRole('button', { name: '创建调优任务' }))
await waitFor(() =>
expect(memoryApi.createMemoryTuningTask).toHaveBeenCalledWith({
objective: 'precision_priority',
intensity: 'standard',
sample_size: 24,
top_k_eval: 20,
}),
)
await user.click(screen.getByRole('button', { name: '应用最佳' }))
await waitFor(() => expect(memoryApi.applyBestMemoryTuningProfile).toHaveBeenCalledWith('tune-1'))
}, 20_000)
it('previews executes and restores source delete (delete module)', async () => {
const user = userEvent.setup()
render(<KnowledgeBasePage />)
await screen.findByText('长期记忆控制台', undefined, { timeout: 10_000 })
await user.click(screen.getByRole('tab', { name: '删除' }))
await screen.findByText('来源批量删除')
const sourceCellCandidates = await screen.findAllByText('demo-1')
const sourceRow = sourceCellCandidates
.map((item) => item.closest('tr'))
.find((row): row is HTMLTableRowElement => Boolean(row && within(row).queryByRole('checkbox')))
if (!sourceRow) {
throw new Error('missing source row')
}
await user.click(within(sourceRow).getByRole('checkbox'))
await user.click(screen.getByRole('button', { name: '预览删除' }))
await waitFor(() =>
expect(memoryApi.previewMemoryDelete).toHaveBeenCalledWith({
mode: 'source',
selector: { sources: ['demo-1'] },
reason: 'knowledge_base_source_delete',
requested_by: 'knowledge_base',
}),
)
const dialog = await screen.findByTestId('memory-delete-dialog')
expect(dialog).toHaveTextContent('preview:source:1')
await user.click(screen.getByRole('button', { name: '执行删除' }))
await waitFor(() =>
expect(memoryApi.executeMemoryDelete).toHaveBeenCalledWith({
mode: 'source',
selector: { sources: ['demo-1'] },
reason: 'knowledge_base_source_delete',
requested_by: 'knowledge_base',
}),
)
await user.click(screen.getByRole('button', { name: '执行恢复' }))
await waitFor(() =>
expect(memoryApi.restoreMemoryDelete).toHaveBeenCalledWith({
operation_id: 'del-2',
requested_by: 'knowledge_base',
}),
)
}, 20_000)
it('shows feedback correction history and supports rollback', async () => {
const user = userEvent.setup()
render(<KnowledgeBasePage />)
await screen.findByText('长期记忆控制台', undefined, { timeout: 10_000 })
await user.click(screen.getByRole('tab', { name: '纠错历史' }))
await screen.findByText('反馈纠错历史')
await screen.findByText('测试用户最喜欢的颜色是什么')
await waitFor(() => expect(memoryApi.getMemoryFeedbackCorrection).toHaveBeenCalledWith(11))
await user.click(screen.getByRole('button', { name: '回退本次纠错' }))
const rollbackReason = await screen.findByLabelText('回退原因')
await user.type(rollbackReason, '人工确认回退')
await user.click(screen.getByRole('button', { name: '确认回退' }))
await waitFor(() =>
expect(memoryApi.rollbackMemoryFeedbackCorrection).toHaveBeenCalledWith(11, {
requested_by: 'knowledge_base',
reason: '人工确认回退',
}),
)
}, 20_000)
})

View File

@@ -1,440 +0,0 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { KnowledgeGraphPage } from '../knowledge-graph'
import * as memoryApi from '@/lib/memory-api'
const navigateMock = vi.fn()
const toastMock = vi.fn()
vi.mock('@tanstack/react-router', () => ({
useNavigate: () => navigateMock,
}))
vi.mock('@/hooks/use-toast', () => ({
useToast: () => ({ toast: toastMock }),
}))
vi.mock('@/components/memory/MemoryDeleteDialog', () => ({
MemoryDeleteDialog: ({
open,
preview,
}: {
open: boolean
preview?: { mode?: string; item_count?: number } | null
}) => (
open ? <div data-testid="memory-delete-dialog">{`delete:${preview?.mode ?? 'none'}:${preview?.item_count ?? 0}`}</div> : null
),
}))
vi.mock('../knowledge-graph/GraphVisualization', () => ({
GraphVisualization: ({
graphData,
onNodeClick,
onEdgeClick,
}: {
graphData: { nodes: Array<{ id: string }>; edges: Array<{ source: string; target: string }> }
onNodeClick: (event: React.MouseEvent, node: { id: string }) => void
onEdgeClick: (event: React.MouseEvent, edge: { source: string; target: string }) => void
}) => (
<div data-testid="graph-visualization">
<div>{`nodes:${graphData.nodes.length},edges:${graphData.edges.length}`}</div>
{graphData.nodes[0] ? (
<button type="button" onClick={(event) => onNodeClick(event as never, { id: graphData.nodes[0].id })}>
</button>
) : null}
{graphData.edges[0] ? (
<button
type="button"
onClick={(event) =>
onEdgeClick(event as never, {
source: graphData.edges[0].source,
target: graphData.edges[0].target,
})}
>
</button>
) : null}
</div>
),
}))
vi.mock('../knowledge-graph/GraphDialogs', () => ({
NodeDetailDialog: ({
selectedNodeData,
nodeDetail,
onOpenEvidence,
onDeleteEntity,
}: {
selectedNodeData: { id: string } | null
nodeDetail: { relations?: Array<{ predicate: string }>; paragraphs?: Array<unknown> } | null
onOpenEvidence?: () => void
onDeleteEntity?: (options: { includeParagraphs: boolean }) => void
}) => (
selectedNodeData ? (
<div data-testid="node-detail-dialog">
<div>{`node:${selectedNodeData.id}`}</div>
<div>{`relations:${nodeDetail?.relations?.[0]?.predicate ?? 'none'}`}</div>
<div>{`paragraphs:${nodeDetail?.paragraphs?.length ?? 0}`}</div>
<button type="button" onClick={onOpenEvidence}></button>
<button type="button" onClick={() => onDeleteEntity?.({ includeParagraphs: true })}></button>
</div>
) : null
),
EdgeDetailDialog: ({
selectedEdgeData,
edgeDetail,
onOpenEvidence,
}: {
selectedEdgeData: { source: { id: string }; target: { id: string } } | null
edgeDetail: { edge?: { predicates?: string[] }; paragraphs?: Array<unknown> } | null
onOpenEvidence?: () => void
}) => (
selectedEdgeData ? (
<div data-testid="edge-detail-dialog">
<div>{`edge:${selectedEdgeData.source.id}->${selectedEdgeData.target.id}`}</div>
<div>{`predicates:${edgeDetail?.edge?.predicates?.join(',') ?? 'none'}`}</div>
<div>{`paragraphs:${edgeDetail?.paragraphs?.length ?? 0}`}</div>
<button type="button" onClick={onOpenEvidence}></button>
</div>
) : null
),
RelationDetailDialog: () => null,
ParagraphDetailDialog: () => null,
}))
vi.mock('@/lib/memory-api', () => ({
getMemoryGraph: vi.fn(),
getMemoryGraphSearch: vi.fn(),
getMemoryGraphNodeDetail: vi.fn(),
getMemoryGraphEdgeDetail: vi.fn(),
previewMemoryDelete: vi.fn(),
executeMemoryDelete: vi.fn(),
restoreMemoryDelete: vi.fn(),
}))
describe('KnowledgeGraphPage', () => {
beforeEach(() => {
navigateMock.mockReset()
toastMock.mockReset()
vi.mocked(memoryApi.getMemoryGraph).mockResolvedValue({
success: true,
nodes: [
{ id: 'alpha', name: 'Alpha' },
{ id: 'beta', name: 'Beta' },
],
edges: [
{
source: 'alpha',
target: 'beta',
weight: 1,
predicates: ['关联'],
relation_count: 1,
evidence_count: 2,
relation_hashes: ['rel-1'],
label: '关联',
},
],
total_nodes: 2,
total_edges: 1,
})
vi.mocked(memoryApi.getMemoryGraphSearch).mockResolvedValue({
success: true,
query: 'alpha',
limit: 50,
count: 0,
items: [],
})
vi.mocked(memoryApi.getMemoryGraphNodeDetail).mockResolvedValue({
success: true,
node: { id: 'alpha', type: 'entity', content: 'Alpha', hash: 'entity-1', appearance_count: 3 },
relations: [
{
hash: 'rel-1',
subject: 'alpha',
predicate: '关联',
object: 'beta',
text: 'alpha 关联 beta',
confidence: 0.9,
paragraph_count: 1,
paragraph_hashes: ['p-1'],
source_paragraph: 'p-1',
},
],
paragraphs: [
{
hash: 'p-1',
content: 'Alpha 提到了 Beta',
preview: 'Alpha 提到了 Beta',
source: 'demo',
entity_count: 2,
relation_count: 1,
entities: ['Alpha', 'Beta'],
relations: ['alpha 关联 beta'],
},
],
evidence_graph: {
nodes: [
{ id: 'entity:alpha', type: 'entity', content: 'Alpha' },
{ id: 'relation:rel-1', type: 'relation', content: 'alpha 关联 beta' },
{ id: 'paragraph:p-1', type: 'paragraph', content: 'Alpha 提到了 Beta' },
],
edges: [
{ source: 'paragraph:p-1', target: 'entity:alpha', kind: 'mentions', label: '提及', weight: 1 },
{ source: 'paragraph:p-1', target: 'relation:rel-1', kind: 'supports', label: '支撑', weight: 1 },
],
focus_entities: ['alpha'],
},
})
vi.mocked(memoryApi.getMemoryGraphEdgeDetail).mockResolvedValue({
success: true,
edge: {
source: 'alpha',
target: 'beta',
weight: 1,
predicates: ['关联'],
relation_count: 1,
evidence_count: 1,
relation_hashes: ['rel-1'],
label: '关联',
},
relations: [
{
hash: 'rel-1',
subject: 'alpha',
predicate: '关联',
object: 'beta',
text: 'alpha 关联 beta',
confidence: 0.9,
paragraph_count: 1,
paragraph_hashes: ['p-1'],
source_paragraph: 'p-1',
},
],
paragraphs: [
{
hash: 'p-1',
content: 'Alpha 提到了 Beta',
preview: 'Alpha 提到了 Beta',
source: 'demo',
entity_count: 2,
relation_count: 1,
entities: ['Alpha', 'Beta'],
relations: ['alpha 关联 beta'],
},
],
evidence_graph: {
nodes: [
{ id: 'entity:alpha', type: 'entity', content: 'Alpha' },
{ id: 'entity:beta', type: 'entity', content: 'Beta' },
{ id: 'relation:rel-1', type: 'relation', content: 'alpha 关联 beta' },
],
edges: [
{ source: 'relation:rel-1', target: 'entity:alpha', kind: 'subject', label: '主语', weight: 1 },
{ source: 'relation:rel-1', target: 'entity:beta', kind: 'object', label: '宾语', weight: 1 },
],
focus_entities: ['alpha', 'beta'],
},
})
vi.mocked(memoryApi.previewMemoryDelete).mockResolvedValue({
success: true,
mode: 'mixed',
selector: { entity_hashes: ['entity-1'] },
counts: { entities: 1, relations: 1, paragraphs: 1 },
sources: ['demo'],
items: [{ item_type: 'entity', item_hash: 'entity-1', label: 'Alpha' }],
item_count: 1,
dry_run: true,
} as never)
vi.mocked(memoryApi.executeMemoryDelete).mockResolvedValue({
success: true,
mode: 'mixed',
operation_id: 'del-1',
counts: { entities: 1, relations: 1, paragraphs: 1 },
sources: ['demo'],
deleted_count: 3,
deleted_entity_count: 1,
deleted_relation_count: 1,
deleted_paragraph_count: 1,
deleted_source_count: 0,
} as never)
vi.mocked(memoryApi.restoreMemoryDelete).mockResolvedValue({ success: true } as never)
})
it('calls backend graph search and renders no-hit state', async () => {
const user = userEvent.setup()
render(<KnowledgeGraphPage />)
expect(await screen.findByText('长期记忆图谱')).toBeInTheDocument()
expect(screen.getByText(/总节点 2/)).toBeInTheDocument()
expect(screen.getByTestId('graph-visualization')).toHaveTextContent('nodes:2,edges:1')
await user.type(screen.getByPlaceholderText('搜索实体、关系、hash后端全库'), 'missing')
expect(memoryApi.getMemoryGraph).toHaveBeenCalledTimes(1)
await user.click(screen.getByRole('button', { name: '搜索' }))
await waitFor(() => {
expect(memoryApi.getMemoryGraphSearch).toHaveBeenCalledWith('missing', 50)
})
expect(await screen.findByText('未命中实体或关系。')).toBeInTheDocument()
})
it('supports clicking entity search result to locate evidence', async () => {
const user = userEvent.setup()
vi.mocked(memoryApi.getMemoryGraphSearch).mockResolvedValue({
success: true,
query: 'alpha',
limit: 50,
count: 1,
items: [
{
type: 'entity',
title: 'Alpha',
matched_field: 'name',
matched_value: 'Alpha',
entity_name: 'alpha',
entity_hash: 'entity-1',
appearance_count: 3,
},
],
})
render(<KnowledgeGraphPage />)
await screen.findByTestId('graph-visualization')
await user.type(screen.getByPlaceholderText('搜索实体、关系、hash后端全库'), 'alpha')
await user.click(screen.getByRole('button', { name: '搜索' }))
await screen.findByText('搜索词alpha')
await user.click(screen.getByRole('button', { name: /Alpha/ }))
await waitFor(() => {
expect(memoryApi.getMemoryGraphNodeDetail).toHaveBeenCalledWith('alpha')
})
expect(screen.getByRole('tab', { name: '证据视图' })).toHaveAttribute('data-state', 'active')
})
it('supports clicking relation search result to locate evidence', async () => {
const user = userEvent.setup()
vi.mocked(memoryApi.getMemoryGraphSearch).mockResolvedValue({
success: true,
query: '关联',
limit: 50,
count: 1,
items: [
{
type: 'relation',
title: 'alpha 关联 beta',
matched_field: 'predicate',
matched_value: '关联',
subject: 'alpha',
predicate: '关联',
object: 'beta',
relation_hash: 'rel-1',
confidence: 0.9,
},
],
})
render(<KnowledgeGraphPage />)
await screen.findByTestId('graph-visualization')
await user.type(screen.getByPlaceholderText('搜索实体、关系、hash后端全库'), '关联')
await user.click(screen.getByRole('button', { name: '搜索' }))
await user.click(screen.getByRole('button', { name: /alpha 关联 beta/ }))
await waitFor(() => {
expect(memoryApi.getMemoryGraphEdgeDetail).toHaveBeenCalledWith('alpha', 'beta')
})
expect(screen.getByRole('tab', { name: '证据视图' })).toHaveAttribute('data-state', 'active')
})
it('falls back to local filtering when backend search fails', async () => {
const user = userEvent.setup()
vi.mocked(memoryApi.getMemoryGraphSearch).mockRejectedValue(new Error('search unavailable'))
render(<KnowledgeGraphPage />)
await screen.findByTestId('graph-visualization')
await user.type(screen.getByPlaceholderText('搜索实体、关系、hash后端全库'), 'missing')
await user.click(screen.getByRole('button', { name: '搜索' }))
expect(await screen.findByText('还没有可展示的长期记忆图谱')).toBeInTheDocument()
expect(toastMock).toHaveBeenCalledWith(
expect.objectContaining({
title: '后端检索失败,已回退本地筛选',
}),
)
})
it('shows empty state when switching to evidence view without a selection', async () => {
const user = userEvent.setup()
render(<KnowledgeGraphPage />)
expect(await screen.findByTestId('graph-visualization')).toBeInTheDocument()
await user.click(screen.getByRole('tab', { name: '证据视图' }))
expect(await screen.findByText('证据视图还没有可展示的选择')).toBeInTheDocument()
})
it('closes node dialog when switching to evidence view and renders evidence graph', async () => {
const user = userEvent.setup()
render(<KnowledgeGraphPage />)
await screen.findByTestId('graph-visualization')
await user.click(screen.getByRole('button', { name: '选择节点' }))
expect(await screen.findByTestId('node-detail-dialog')).toHaveTextContent('relations:关联')
expect(screen.getByTestId('node-detail-dialog')).toHaveTextContent('paragraphs:1')
await user.click(screen.getByRole('button', { name: '切到证据视图' }))
await waitFor(() => {
expect(screen.queryByTestId('node-detail-dialog')).not.toBeInTheDocument()
})
await waitFor(() => {
expect(screen.getByTestId('graph-visualization')).toHaveTextContent('nodes:3,edges:2')
})
})
it('loads edge detail with predicates and support paragraphs', async () => {
const user = userEvent.setup()
render(<KnowledgeGraphPage />)
await screen.findByTestId('graph-visualization')
await user.click(screen.getByRole('button', { name: '选择边' }))
expect(await screen.findByTestId('edge-detail-dialog')).toHaveTextContent('predicates:关联')
expect(screen.getByTestId('edge-detail-dialog')).toHaveTextContent('paragraphs:1')
await user.click(screen.getByRole('button', { name: '切到证据视图' }))
await waitFor(() => {
expect(screen.queryByTestId('edge-detail-dialog')).not.toBeInTheDocument()
})
})
it('opens delete preview dialog from node detail', async () => {
const user = userEvent.setup()
render(<KnowledgeGraphPage />)
await screen.findByTestId('graph-visualization')
await user.click(screen.getByRole('button', { name: '选择节点' }))
await screen.findByTestId('node-detail-dialog')
await user.click(screen.getByRole('button', { name: '删除实体' }))
await waitFor(() => {
expect(memoryApi.previewMemoryDelete).toHaveBeenCalled()
})
expect(await screen.findByTestId('memory-delete-dialog')).toHaveTextContent('delete:mixed:1')
})
})

View File

@@ -1,7 +0,0 @@
{
"extends": "./tsconfig.app.json",
"compilerOptions": {
"types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"]
},
"include": ["src"]
}

View File

@@ -1,18 +0,0 @@
/// <reference types="vitest" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})