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:
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"extends": "./tsconfig.app.json",
|
||||
"compilerOptions": {
|
||||
"types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -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'),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user