mirror of https://github.com/langgenius/dify.git
Merge branch 'main' into pydantic-remaining
This commit is contained in:
commit
a1dcbda515
|
|
@ -6,7 +6,7 @@ cd web && pnpm install
|
|||
pipx install uv
|
||||
|
||||
echo "alias start-api=\"cd $WORKSPACE_ROOT/api && uv run python -m flask run --host 0.0.0.0 --port=5001 --debug\"" >> ~/.bashrc
|
||||
echo "alias start-worker=\"cd $WORKSPACE_ROOT/api && uv run python -m celery -A app.celery worker -P threads -c 1 --loglevel INFO -Q dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor\"" >> ~/.bashrc
|
||||
echo "alias start-worker=\"cd $WORKSPACE_ROOT/api && uv run python -m celery -A app.celery worker -P threads -c 1 --loglevel INFO -Q dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention\"" >> ~/.bashrc
|
||||
echo "alias start-web=\"cd $WORKSPACE_ROOT/web && pnpm dev\"" >> ~/.bashrc
|
||||
echo "alias start-web-prod=\"cd $WORKSPACE_ROOT/web && pnpm build && pnpm start\"" >> ~/.bashrc
|
||||
echo "alias start-containers=\"cd $WORKSPACE_ROOT/docker && docker-compose -f docker-compose.middleware.yaml -p dify --env-file middleware.env up -d\"" >> ~/.bashrc
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ api/controllers/console/feature.py @GarfieldDai @GareArc
|
|||
api/controllers/web/feature.py @GarfieldDai @GareArc
|
||||
|
||||
# Backend - Database Migrations
|
||||
api/migrations/ @snakevash @laipz8200
|
||||
api/migrations/ @snakevash @laipz8200 @MRZHUH
|
||||
|
||||
# Frontend
|
||||
web/ @iamjoel
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ jobs:
|
|||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: ./web/package.json
|
||||
cache-dependency-path: ./web/pnpm-lock.yaml
|
||||
|
||||
- name: Web dependencies
|
||||
working-directory: ./web
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ jobs:
|
|||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: ./web/package.json
|
||||
cache-dependency-path: ./web/pnpm-lock.yaml
|
||||
|
||||
- name: Web dependencies
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ jobs:
|
|||
with:
|
||||
node-version: 'lts/*'
|
||||
cache: pnpm
|
||||
cache-dependency-path: ./web/package.json
|
||||
cache-dependency-path: ./web/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
if: env.FILES_CHANGED == 'true'
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: ./web
|
||||
|
||||
steps:
|
||||
|
|
@ -21,14 +22,7 @@ jobs:
|
|||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v46
|
||||
with:
|
||||
files: web/**
|
||||
|
||||
- name: Install pnpm
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
package_json_file: web/package.json
|
||||
|
|
@ -36,23 +30,319 @@ jobs:
|
|||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: ./web/package.json
|
||||
cache-dependency-path: ./web/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
working-directory: ./web
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Check i18n types synchronization
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
working-directory: ./web
|
||||
run: pnpm run check:i18n-types
|
||||
|
||||
- name: Run tests
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
working-directory: ./web
|
||||
run: pnpm test
|
||||
run: |
|
||||
pnpm exec jest \
|
||||
--ci \
|
||||
--runInBand \
|
||||
--coverage \
|
||||
--passWithNoTests
|
||||
|
||||
- name: Coverage Summary
|
||||
if: always()
|
||||
id: coverage-summary
|
||||
run: |
|
||||
set -eo pipefail
|
||||
|
||||
COVERAGE_FILE="coverage/coverage-final.json"
|
||||
COVERAGE_SUMMARY_FILE="coverage/coverage-summary.json"
|
||||
|
||||
if [ ! -f "$COVERAGE_FILE" ] && [ ! -f "$COVERAGE_SUMMARY_FILE" ]; then
|
||||
echo "has_coverage=false" >> "$GITHUB_OUTPUT"
|
||||
echo "### 🚨 Test Coverage Report :test_tube:" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Coverage data not found. Ensure Jest runs with coverage enabled." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "has_coverage=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
node <<'NODE' >> "$GITHUB_STEP_SUMMARY"
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const summaryPath = path.join('coverage', 'coverage-summary.json');
|
||||
const finalPath = path.join('coverage', 'coverage-final.json');
|
||||
|
||||
const hasSummary = fs.existsSync(summaryPath);
|
||||
const hasFinal = fs.existsSync(finalPath);
|
||||
|
||||
if (!hasSummary && !hasFinal) {
|
||||
console.log('### Test Coverage Summary :test_tube:');
|
||||
console.log('');
|
||||
console.log('No coverage data found.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const summary = hasSummary
|
||||
? JSON.parse(fs.readFileSync(summaryPath, 'utf8'))
|
||||
: null;
|
||||
const coverage = hasFinal
|
||||
? JSON.parse(fs.readFileSync(finalPath, 'utf8'))
|
||||
: null;
|
||||
|
||||
const totals = {
|
||||
lines: { covered: 0, total: 0 },
|
||||
statements: { covered: 0, total: 0 },
|
||||
branches: { covered: 0, total: 0 },
|
||||
functions: { covered: 0, total: 0 },
|
||||
};
|
||||
const fileSummaries = [];
|
||||
|
||||
if (summary) {
|
||||
const totalEntry = summary.total ?? {};
|
||||
['lines', 'statements', 'branches', 'functions'].forEach((key) => {
|
||||
if (totalEntry[key]) {
|
||||
totals[key].covered = totalEntry[key].covered ?? 0;
|
||||
totals[key].total = totalEntry[key].total ?? 0;
|
||||
}
|
||||
});
|
||||
|
||||
Object.entries(summary)
|
||||
.filter(([file]) => file !== 'total')
|
||||
.forEach(([file, data]) => {
|
||||
fileSummaries.push({
|
||||
file,
|
||||
pct: data.lines?.pct ?? data.statements?.pct ?? 0,
|
||||
lines: {
|
||||
covered: data.lines?.covered ?? 0,
|
||||
total: data.lines?.total ?? 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
} else if (coverage) {
|
||||
Object.entries(coverage).forEach(([file, entry]) => {
|
||||
const lineHits = entry.l ?? {};
|
||||
const statementHits = entry.s ?? {};
|
||||
const branchHits = entry.b ?? {};
|
||||
const functionHits = entry.f ?? {};
|
||||
|
||||
const lineTotal = Object.keys(lineHits).length;
|
||||
const lineCovered = Object.values(lineHits).filter((n) => n > 0).length;
|
||||
|
||||
const statementTotal = Object.keys(statementHits).length;
|
||||
const statementCovered = Object.values(statementHits).filter((n) => n > 0).length;
|
||||
|
||||
const branchTotal = Object.values(branchHits).reduce((acc, branches) => acc + branches.length, 0);
|
||||
const branchCovered = Object.values(branchHits).reduce(
|
||||
(acc, branches) => acc + branches.filter((n) => n > 0).length,
|
||||
0,
|
||||
);
|
||||
|
||||
const functionTotal = Object.keys(functionHits).length;
|
||||
const functionCovered = Object.values(functionHits).filter((n) => n > 0).length;
|
||||
|
||||
totals.lines.total += lineTotal;
|
||||
totals.lines.covered += lineCovered;
|
||||
totals.statements.total += statementTotal;
|
||||
totals.statements.covered += statementCovered;
|
||||
totals.branches.total += branchTotal;
|
||||
totals.branches.covered += branchCovered;
|
||||
totals.functions.total += functionTotal;
|
||||
totals.functions.covered += functionCovered;
|
||||
|
||||
const pct = (covered, tot) => (tot > 0 ? (covered / tot) * 100 : 0);
|
||||
|
||||
fileSummaries.push({
|
||||
file,
|
||||
pct: pct(lineCovered || statementCovered, lineTotal || statementTotal),
|
||||
lines: {
|
||||
covered: lineCovered || statementCovered,
|
||||
total: lineTotal || statementTotal,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const pct = (covered, tot) => (tot > 0 ? ((covered / tot) * 100).toFixed(2) : '0.00');
|
||||
|
||||
console.log('### Test Coverage Summary :test_tube:');
|
||||
console.log('');
|
||||
console.log('| Metric | Coverage | Covered / Total |');
|
||||
console.log('|--------|----------|-----------------|');
|
||||
console.log(`| Lines | ${pct(totals.lines.covered, totals.lines.total)}% | ${totals.lines.covered} / ${totals.lines.total} |`);
|
||||
console.log(`| Statements | ${pct(totals.statements.covered, totals.statements.total)}% | ${totals.statements.covered} / ${totals.statements.total} |`);
|
||||
console.log(`| Branches | ${pct(totals.branches.covered, totals.branches.total)}% | ${totals.branches.covered} / ${totals.branches.total} |`);
|
||||
console.log(`| Functions | ${pct(totals.functions.covered, totals.functions.total)}% | ${totals.functions.covered} / ${totals.functions.total} |`);
|
||||
|
||||
console.log('');
|
||||
console.log('<details><summary>File coverage (lowest lines first)</summary>');
|
||||
console.log('');
|
||||
console.log('```');
|
||||
fileSummaries
|
||||
.sort((a, b) => (a.pct - b.pct) || (b.lines.total - a.lines.total))
|
||||
.slice(0, 25)
|
||||
.forEach(({ file, pct, lines }) => {
|
||||
console.log(`${pct.toFixed(2)}%\t${lines.covered}/${lines.total}\t${file}`);
|
||||
});
|
||||
console.log('```');
|
||||
console.log('</details>');
|
||||
|
||||
if (coverage) {
|
||||
const pctValue = (covered, tot) => {
|
||||
if (tot === 0) {
|
||||
return '0';
|
||||
}
|
||||
return ((covered / tot) * 100)
|
||||
.toFixed(2)
|
||||
.replace(/\.?0+$/, '');
|
||||
};
|
||||
|
||||
const formatLineRanges = (lines) => {
|
||||
if (lines.length === 0) {
|
||||
return '';
|
||||
}
|
||||
const ranges = [];
|
||||
let start = lines[0];
|
||||
let end = lines[0];
|
||||
|
||||
for (let i = 1; i < lines.length; i += 1) {
|
||||
const current = lines[i];
|
||||
if (current === end + 1) {
|
||||
end = current;
|
||||
continue;
|
||||
}
|
||||
ranges.push(start === end ? `${start}` : `${start}-${end}`);
|
||||
start = current;
|
||||
end = current;
|
||||
}
|
||||
ranges.push(start === end ? `${start}` : `${start}-${end}`);
|
||||
return ranges.join(',');
|
||||
};
|
||||
|
||||
const tableTotals = {
|
||||
statements: { covered: 0, total: 0 },
|
||||
branches: { covered: 0, total: 0 },
|
||||
functions: { covered: 0, total: 0 },
|
||||
lines: { covered: 0, total: 0 },
|
||||
};
|
||||
const tableRows = Object.entries(coverage)
|
||||
.map(([file, entry]) => {
|
||||
const lineHits = entry.l ?? {};
|
||||
const statementHits = entry.s ?? {};
|
||||
const branchHits = entry.b ?? {};
|
||||
const functionHits = entry.f ?? {};
|
||||
|
||||
const lineTotal = Object.keys(lineHits).length;
|
||||
const lineCovered = Object.values(lineHits).filter((n) => n > 0).length;
|
||||
const statementTotal = Object.keys(statementHits).length;
|
||||
const statementCovered = Object.values(statementHits).filter((n) => n > 0).length;
|
||||
const branchTotal = Object.values(branchHits).reduce((acc, branches) => acc + branches.length, 0);
|
||||
const branchCovered = Object.values(branchHits).reduce(
|
||||
(acc, branches) => acc + branches.filter((n) => n > 0).length,
|
||||
0,
|
||||
);
|
||||
const functionTotal = Object.keys(functionHits).length;
|
||||
const functionCovered = Object.values(functionHits).filter((n) => n > 0).length;
|
||||
|
||||
tableTotals.lines.total += lineTotal;
|
||||
tableTotals.lines.covered += lineCovered;
|
||||
tableTotals.statements.total += statementTotal;
|
||||
tableTotals.statements.covered += statementCovered;
|
||||
tableTotals.branches.total += branchTotal;
|
||||
tableTotals.branches.covered += branchCovered;
|
||||
tableTotals.functions.total += functionTotal;
|
||||
tableTotals.functions.covered += functionCovered;
|
||||
|
||||
const uncoveredLines = Object.entries(lineHits)
|
||||
.filter(([, count]) => count === 0)
|
||||
.map(([line]) => Number(line))
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
const filePath = entry.path ?? file;
|
||||
const relativePath = path.isAbsolute(filePath)
|
||||
? path.relative(process.cwd(), filePath)
|
||||
: filePath;
|
||||
|
||||
return {
|
||||
file: relativePath || file,
|
||||
statements: pctValue(statementCovered, statementTotal),
|
||||
branches: pctValue(branchCovered, branchTotal),
|
||||
functions: pctValue(functionCovered, functionTotal),
|
||||
lines: pctValue(lineCovered, lineTotal),
|
||||
uncovered: formatLineRanges(uncoveredLines),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.file.localeCompare(b.file));
|
||||
|
||||
const columns = [
|
||||
{ key: 'file', header: 'File', align: 'left' },
|
||||
{ key: 'statements', header: '% Stmts', align: 'right' },
|
||||
{ key: 'branches', header: '% Branch', align: 'right' },
|
||||
{ key: 'functions', header: '% Funcs', align: 'right' },
|
||||
{ key: 'lines', header: '% Lines', align: 'right' },
|
||||
{ key: 'uncovered', header: 'Uncovered Line #s', align: 'left' },
|
||||
];
|
||||
|
||||
const allFilesRow = {
|
||||
file: 'All files',
|
||||
statements: pctValue(tableTotals.statements.covered, tableTotals.statements.total),
|
||||
branches: pctValue(tableTotals.branches.covered, tableTotals.branches.total),
|
||||
functions: pctValue(tableTotals.functions.covered, tableTotals.functions.total),
|
||||
lines: pctValue(tableTotals.lines.covered, tableTotals.lines.total),
|
||||
uncovered: '',
|
||||
};
|
||||
|
||||
const rowsForOutput = [allFilesRow, ...tableRows];
|
||||
const columnWidths = Object.fromEntries(
|
||||
columns.map(({ key, header }) => [key, header.length]),
|
||||
);
|
||||
|
||||
rowsForOutput.forEach((row) => {
|
||||
columns.forEach(({ key }) => {
|
||||
const value = String(row[key] ?? '');
|
||||
columnWidths[key] = Math.max(columnWidths[key], value.length);
|
||||
});
|
||||
});
|
||||
|
||||
const formatRow = (row) => columns
|
||||
.map(({ key, align }) => {
|
||||
const value = String(row[key] ?? '');
|
||||
const width = columnWidths[key];
|
||||
return align === 'right' ? value.padStart(width) : value.padEnd(width);
|
||||
})
|
||||
.join(' | ');
|
||||
|
||||
const headerRow = columns
|
||||
.map(({ header, key, align }) => {
|
||||
const width = columnWidths[key];
|
||||
return align === 'right' ? header.padStart(width) : header.padEnd(width);
|
||||
})
|
||||
.join(' | ');
|
||||
|
||||
const dividerRow = columns
|
||||
.map(({ key }) => '-'.repeat(columnWidths[key]))
|
||||
.join('|');
|
||||
|
||||
console.log('');
|
||||
console.log('<details><summary>Jest coverage table</summary>');
|
||||
console.log('');
|
||||
console.log('```');
|
||||
console.log(dividerRow);
|
||||
console.log(headerRow);
|
||||
console.log(dividerRow);
|
||||
rowsForOutput.forEach((row) => console.log(formatRow(row)));
|
||||
console.log(dividerRow);
|
||||
console.log('```');
|
||||
console.log('</details>');
|
||||
}
|
||||
NODE
|
||||
|
||||
- name: Upload Coverage Artifact
|
||||
if: steps.coverage-summary.outputs.has_coverage == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: web-coverage-report
|
||||
path: web/coverage
|
||||
retention-days: 30
|
||||
if-no-files-found: error
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@
|
|||
"-c",
|
||||
"1",
|
||||
"-Q",
|
||||
"dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor",
|
||||
"dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention",
|
||||
"--loglevel",
|
||||
"INFO"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -690,3 +690,8 @@ ANNOTATION_IMPORT_RATE_LIMIT_PER_MINUTE=5
|
|||
ANNOTATION_IMPORT_RATE_LIMIT_PER_HOUR=20
|
||||
# Maximum number of concurrent annotation import tasks per tenant
|
||||
ANNOTATION_IMPORT_MAX_CONCURRENT=5
|
||||
|
||||
# Sandbox expired records clean configuration
|
||||
SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD=21
|
||||
SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE=1000
|
||||
SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS=30
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@
|
|||
1. If you need to handle and debug the async tasks (e.g. dataset importing and documents indexing), please start the worker service.
|
||||
|
||||
```bash
|
||||
uv run celery -A app.celery worker -P threads -c 2 --loglevel INFO -Q dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor
|
||||
uv run celery -A app.celery worker -P threads -c 2 --loglevel INFO -Q dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention
|
||||
```
|
||||
|
||||
Additionally, if you want to debug the celery scheduled tasks, you can run the following command in another terminal to start the beat service:
|
||||
|
|
|
|||
|
|
@ -218,7 +218,7 @@ class PluginConfig(BaseSettings):
|
|||
|
||||
PLUGIN_DAEMON_TIMEOUT: PositiveFloat | None = Field(
|
||||
description="Timeout in seconds for requests to the plugin daemon (set to None to disable)",
|
||||
default=300.0,
|
||||
default=600.0,
|
||||
)
|
||||
|
||||
INNER_API_KEY_FOR_PLUGIN: str = Field(description="Inner api key for plugin", default="inner-api-key")
|
||||
|
|
@ -1270,6 +1270,21 @@ class TenantIsolatedTaskQueueConfig(BaseSettings):
|
|||
)
|
||||
|
||||
|
||||
class SandboxExpiredRecordsCleanConfig(BaseSettings):
|
||||
SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD: NonNegativeInt = Field(
|
||||
description="Graceful period in days for sandbox records clean after subscription expiration",
|
||||
default=21,
|
||||
)
|
||||
SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE: PositiveInt = Field(
|
||||
description="Maximum number of records to process in each batch",
|
||||
default=1000,
|
||||
)
|
||||
SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS: PositiveInt = Field(
|
||||
description="Retention days for sandbox expired workflow_run records and message records",
|
||||
default=30,
|
||||
)
|
||||
|
||||
|
||||
class FeatureConfig(
|
||||
# place the configs in alphabet order
|
||||
AppExecutionConfig,
|
||||
|
|
@ -1295,6 +1310,7 @@ class FeatureConfig(
|
|||
PositionConfig,
|
||||
RagEtlConfig,
|
||||
RepositoryConfig,
|
||||
SandboxExpiredRecordsCleanConfig,
|
||||
SecurityConfig,
|
||||
TenantIsolatedTaskQueueConfig,
|
||||
ToolConfig,
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ class DatasetUpdatePayload(BaseModel):
|
|||
embedding_model: str | None = None
|
||||
embedding_model_provider: str | None = None
|
||||
retrieval_model: dict[str, Any] | None = None
|
||||
partial_member_list: list[str] | None = None
|
||||
partial_member_list: list[dict[str, str]] | None = None
|
||||
external_retrieval_model: dict[str, Any] | None = None
|
||||
external_knowledge_id: str | None = None
|
||||
external_knowledge_api_id: str | None = None
|
||||
|
|
|
|||
|
|
@ -19,15 +19,15 @@ class TagBasePayload(BaseModel):
|
|||
|
||||
|
||||
class TagBindingPayload(BaseModel):
|
||||
tag_ids: list[str]
|
||||
target_id: str
|
||||
type: Literal["knowledge", "app"] | None = None
|
||||
tag_ids: list[str] = Field(description="Tag IDs to bind")
|
||||
target_id: str = Field(description="Target ID to bind tags to")
|
||||
type: Literal["knowledge", "app"] | None = Field(default=None, description="Tag type")
|
||||
|
||||
|
||||
class TagBindingRemovePayload(BaseModel):
|
||||
tag_id: str
|
||||
target_id: str
|
||||
type: Literal["knowledge", "app"] | None = None
|
||||
tag_id: str = Field(description="Tag ID to remove")
|
||||
target_id: str = Field(description="Target ID to unbind tag from")
|
||||
type: Literal["knowledge", "app"] | None = Field(default=None, description="Tag type")
|
||||
|
||||
|
||||
register_schema_models(
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ class DatasetUpdatePayload(BaseModel):
|
|||
embedding_model: str | None = None
|
||||
embedding_model_provider: str | None = None
|
||||
retrieval_model: RetrievalModel | None = None
|
||||
partial_member_list: list[str] | None = None
|
||||
partial_member_list: list[dict[str, str]] | None = None
|
||||
external_retrieval_model: dict[str, Any] | None = None
|
||||
external_knowledge_id: str | None = None
|
||||
external_knowledge_api_id: str | None = None
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import json
|
||||
from collections.abc import Sequence
|
||||
from enum import StrEnum, auto
|
||||
from typing import Any, Literal
|
||||
|
|
@ -120,7 +121,7 @@ class VariableEntity(BaseModel):
|
|||
allowed_file_types: Sequence[FileType] | None = Field(default_factory=list)
|
||||
allowed_file_extensions: Sequence[str] | None = Field(default_factory=list)
|
||||
allowed_file_upload_methods: Sequence[FileTransferMethod] | None = Field(default_factory=list)
|
||||
json_schema: dict[str, Any] | None = Field(default=None)
|
||||
json_schema: str | None = Field(default=None)
|
||||
|
||||
@field_validator("description", mode="before")
|
||||
@classmethod
|
||||
|
|
@ -134,11 +135,17 @@ class VariableEntity(BaseModel):
|
|||
|
||||
@field_validator("json_schema")
|
||||
@classmethod
|
||||
def validate_json_schema(cls, schema: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
def validate_json_schema(cls, schema: str | None) -> str | None:
|
||||
if schema is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
Draft7Validator.check_schema(schema)
|
||||
json_schema = json.loads(schema)
|
||||
except json.JSONDecodeError:
|
||||
raise ValueError(f"invalid json_schema value {schema}")
|
||||
|
||||
try:
|
||||
Draft7Validator.check_schema(json_schema)
|
||||
except SchemaError as e:
|
||||
raise ValueError(f"Invalid JSON schema: {e.message}")
|
||||
return schema
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import json
|
||||
from collections.abc import Generator, Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Any, Union, final
|
||||
|
||||
|
|
@ -175,6 +176,13 @@ class BaseAppGenerator:
|
|||
value = True
|
||||
elif value == 0:
|
||||
value = False
|
||||
case VariableEntityType.JSON_OBJECT:
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"{variable_entity.variable} in input form must be a string")
|
||||
try:
|
||||
json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
raise ValueError(f"{variable_entity.variable} in input form must be a valid JSON object")
|
||||
case _:
|
||||
raise AssertionError("this statement should be unreachable.")
|
||||
|
||||
|
|
|
|||
|
|
@ -342,9 +342,11 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline):
|
|||
self._task_state.llm_result.message.content = current_content
|
||||
|
||||
if isinstance(event, QueueLLMChunkEvent):
|
||||
event_type = self._message_cycle_manager.get_message_event_type(message_id=self._message_id)
|
||||
yield self._message_cycle_manager.message_to_stream_response(
|
||||
answer=cast(str, delta_text),
|
||||
message_id=self._message_id,
|
||||
event_type=event_type,
|
||||
)
|
||||
else:
|
||||
yield self._agent_message_to_stream_response(
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from threading import Thread
|
|||
from typing import Union
|
||||
|
||||
from flask import Flask, current_app
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import exists, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from configs import dify_config
|
||||
|
|
@ -54,6 +54,20 @@ class MessageCycleManager:
|
|||
):
|
||||
self._application_generate_entity = application_generate_entity
|
||||
self._task_state = task_state
|
||||
self._message_has_file: set[str] = set()
|
||||
|
||||
def get_message_event_type(self, message_id: str) -> StreamEvent:
|
||||
if message_id in self._message_has_file:
|
||||
return StreamEvent.MESSAGE_FILE
|
||||
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
has_file = session.query(exists().where(MessageFile.message_id == message_id)).scalar()
|
||||
|
||||
if has_file:
|
||||
self._message_has_file.add(message_id)
|
||||
return StreamEvent.MESSAGE_FILE
|
||||
|
||||
return StreamEvent.MESSAGE
|
||||
|
||||
def generate_conversation_name(self, *, conversation_id: str, query: str) -> Thread | None:
|
||||
"""
|
||||
|
|
@ -214,7 +228,11 @@ class MessageCycleManager:
|
|||
return None
|
||||
|
||||
def message_to_stream_response(
|
||||
self, answer: str, message_id: str, from_variable_selector: list[str] | None = None
|
||||
self,
|
||||
answer: str,
|
||||
message_id: str,
|
||||
from_variable_selector: list[str] | None = None,
|
||||
event_type: StreamEvent | None = None,
|
||||
) -> MessageStreamResponse:
|
||||
"""
|
||||
Message to stream response.
|
||||
|
|
@ -222,16 +240,12 @@ class MessageCycleManager:
|
|||
:param message_id: message id
|
||||
:return:
|
||||
"""
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
message_file = session.scalar(select(MessageFile).where(MessageFile.id == message_id))
|
||||
event_type = StreamEvent.MESSAGE_FILE if message_file else StreamEvent.MESSAGE
|
||||
|
||||
return MessageStreamResponse(
|
||||
task_id=self._application_generate_entity.task_id,
|
||||
id=message_id,
|
||||
answer=answer,
|
||||
from_variable_selector=from_variable_selector,
|
||||
event=event_type,
|
||||
event=event_type or StreamEvent.MESSAGE,
|
||||
)
|
||||
|
||||
def message_replace_to_stream_response(self, answer: str, reason: str = "") -> MessageReplaceStreamResponse:
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ from core.trigger.errors import (
|
|||
plugin_daemon_inner_api_baseurl = URL(str(dify_config.PLUGIN_DAEMON_URL))
|
||||
_plugin_daemon_timeout_config = cast(
|
||||
float | httpx.Timeout | None,
|
||||
getattr(dify_config, "PLUGIN_DAEMON_TIMEOUT", 300.0),
|
||||
getattr(dify_config, "PLUGIN_DAEMON_TIMEOUT", 600.0),
|
||||
)
|
||||
plugin_daemon_request_timeout: httpx.Timeout | None
|
||||
if _plugin_daemon_timeout_config is None:
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import codecs
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -52,7 +53,7 @@ class FixedRecursiveCharacterTextSplitter(EnhanceRecursiveCharacterTextSplitter)
|
|||
def __init__(self, fixed_separator: str = "\n\n", separators: list[str] | None = None, **kwargs: Any):
|
||||
"""Create a new TextSplitter."""
|
||||
super().__init__(**kwargs)
|
||||
self._fixed_separator = fixed_separator
|
||||
self._fixed_separator = codecs.decode(fixed_separator, "unicode_escape")
|
||||
self._separators = separators or ["\n\n", "\n", "。", ". ", " ", ""]
|
||||
|
||||
def split_text(self, text: str) -> list[str]:
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import json
|
||||
from typing import Any
|
||||
|
||||
from jsonschema import Draft7Validator, ValidationError
|
||||
|
|
@ -42,15 +43,25 @@ class StartNode(Node[StartNodeData]):
|
|||
if value is None and variable.required:
|
||||
raise ValueError(f"{key} is required in input form")
|
||||
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"{key} must be a JSON object")
|
||||
|
||||
schema = variable.json_schema
|
||||
if not schema:
|
||||
continue
|
||||
|
||||
if not value:
|
||||
continue
|
||||
|
||||
try:
|
||||
Draft7Validator(schema).validate(value)
|
||||
json_schema = json.loads(schema)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"{schema} must be a valid JSON object")
|
||||
|
||||
try:
|
||||
json_value = json.loads(value)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"{value} must be a valid JSON object")
|
||||
|
||||
try:
|
||||
Draft7Validator(json_schema).validate(json_value)
|
||||
except ValidationError as e:
|
||||
raise ValueError(f"JSON object for '{key}' does not match schema: {e.message}")
|
||||
node_inputs[key] = value
|
||||
node_inputs[key] = json_value
|
||||
|
|
|
|||
|
|
@ -34,10 +34,10 @@ if [[ "${MODE}" == "worker" ]]; then
|
|||
if [[ -z "${CELERY_QUEUES}" ]]; then
|
||||
if [[ "${EDITION}" == "CLOUD" ]]; then
|
||||
# Cloud edition: separate queues for dataset and trigger tasks
|
||||
DEFAULT_QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow_professional,workflow_team,workflow_sandbox,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor"
|
||||
DEFAULT_QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow_professional,workflow_team,workflow_sandbox,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention"
|
||||
else
|
||||
# Community edition (SELF_HOSTED): dataset, pipeline and workflow have separate queues
|
||||
DEFAULT_QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor"
|
||||
DEFAULT_QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention"
|
||||
fi
|
||||
else
|
||||
DEFAULT_QUEUES="${CELERY_QUEUES}"
|
||||
|
|
@ -69,6 +69,53 @@ if [[ "${MODE}" == "worker" ]]; then
|
|||
|
||||
elif [[ "${MODE}" == "beat" ]]; then
|
||||
exec celery -A app.celery beat --loglevel ${LOG_LEVEL:-INFO}
|
||||
|
||||
elif [[ "${MODE}" == "job" ]]; then
|
||||
# Job mode: Run a one-time Flask command and exit
|
||||
# Pass Flask command and arguments via container args
|
||||
# Example K8s usage:
|
||||
# args:
|
||||
# - create-tenant
|
||||
# - --email
|
||||
# - admin@example.com
|
||||
#
|
||||
# Example Docker usage:
|
||||
# docker run -e MODE=job dify-api:latest create-tenant --email admin@example.com
|
||||
|
||||
if [[ $# -eq 0 ]]; then
|
||||
echo "Error: No command specified for job mode."
|
||||
echo ""
|
||||
echo "Usage examples:"
|
||||
echo " Kubernetes:"
|
||||
echo " args: [create-tenant, --email, admin@example.com]"
|
||||
echo ""
|
||||
echo " Docker:"
|
||||
echo " docker run -e MODE=job dify-api create-tenant --email admin@example.com"
|
||||
echo ""
|
||||
echo "Available commands:"
|
||||
echo " create-tenant, reset-password, reset-email, upgrade-db,"
|
||||
echo " vdb-migrate, install-plugins, and more..."
|
||||
echo ""
|
||||
echo "Run 'flask --help' to see all available commands."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Running Flask job command: flask $*"
|
||||
|
||||
# Temporarily disable exit on error to capture exit code
|
||||
set +e
|
||||
flask "$@"
|
||||
JOB_EXIT_CODE=$?
|
||||
set -e
|
||||
|
||||
if [[ ${JOB_EXIT_CODE} -eq 0 ]]; then
|
||||
echo "Job completed successfully."
|
||||
else
|
||||
echo "Job failed with exit code ${JOB_EXIT_CODE}."
|
||||
fi
|
||||
|
||||
exit ${JOB_EXIT_CODE}
|
||||
|
||||
else
|
||||
if [[ "${DEBUG}" == "true" ]]; then
|
||||
exec flask run --host=${DIFY_BIND_ADDRESS:-0.0.0.0} --port=${DIFY_PORT:-5001} --debug
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
import logging
|
||||
import os
|
||||
from collections.abc import Sequence
|
||||
from typing import Literal
|
||||
|
||||
import httpx
|
||||
from pydantic import TypeAdapter
|
||||
from tenacity import retry, retry_if_exception_type, stop_before_delay, wait_fixed
|
||||
from typing_extensions import TypedDict
|
||||
from werkzeug.exceptions import InternalServerError
|
||||
|
||||
from enums.cloud_plan import CloudPlan
|
||||
|
|
@ -11,6 +15,15 @@ from extensions.ext_redis import redis_client
|
|||
from libs.helper import RateLimiter
|
||||
from models import Account, TenantAccountJoin, TenantAccountRole
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SubscriptionPlan(TypedDict):
|
||||
"""Tenant subscriptionplan information."""
|
||||
|
||||
plan: str
|
||||
expiration_date: int
|
||||
|
||||
|
||||
class BillingService:
|
||||
base_url = os.environ.get("BILLING_API_URL", "BILLING_API_URL")
|
||||
|
|
@ -239,3 +252,39 @@ class BillingService:
|
|||
def sync_partner_tenants_bindings(cls, account_id: str, partner_key: str, click_id: str):
|
||||
payload = {"account_id": account_id, "click_id": click_id}
|
||||
return cls._send_request("PUT", f"/partners/{partner_key}/tenants", json=payload)
|
||||
|
||||
@classmethod
|
||||
def get_plan_bulk(cls, tenant_ids: Sequence[str]) -> dict[str, SubscriptionPlan]:
|
||||
"""
|
||||
Bulk fetch billing subscription plan via billing API.
|
||||
Payload: {"tenant_ids": ["t1", "t2", ...]} (max 200 per request)
|
||||
Returns:
|
||||
Mapping of tenant_id -> {plan: str, expiration_date: int}
|
||||
"""
|
||||
results: dict[str, SubscriptionPlan] = {}
|
||||
subscription_adapter = TypeAdapter(SubscriptionPlan)
|
||||
|
||||
chunk_size = 200
|
||||
for i in range(0, len(tenant_ids), chunk_size):
|
||||
chunk = tenant_ids[i : i + chunk_size]
|
||||
try:
|
||||
resp = cls._send_request("POST", "/subscription/plan/batch", json={"tenant_ids": chunk})
|
||||
data = resp.get("data", {})
|
||||
|
||||
for tenant_id, plan in data.items():
|
||||
subscription_plan = subscription_adapter.validate_python(plan)
|
||||
results[tenant_id] = subscription_plan
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch billing info batch for tenants: %s", chunk)
|
||||
continue
|
||||
|
||||
return results
|
||||
|
||||
@classmethod
|
||||
def get_expired_subscription_cleanup_whitelist(cls) -> Sequence[str]:
|
||||
resp = cls._send_request("GET", "/subscription/cleanup/whitelist")
|
||||
data = resp.get("data", [])
|
||||
tenant_whitelist = []
|
||||
for item in data:
|
||||
tenant_whitelist.append(item["tenant_id"])
|
||||
return tenant_whitelist
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ class RagPipelineDatasetCreateEntity(BaseModel):
|
|||
description: str
|
||||
icon_info: IconInfo
|
||||
permission: str
|
||||
partial_member_list: list[str] | None = None
|
||||
partial_member_list: list[dict[str, str]] | None = None
|
||||
yaml_content: str | None = None
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,420 @@
|
|||
from types import SimpleNamespace
|
||||
from unittest.mock import ANY, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from core.app.apps.base_app_queue_manager import AppQueueManager
|
||||
from core.app.entities.app_invoke_entities import ChatAppGenerateEntity
|
||||
from core.app.entities.queue_entities import (
|
||||
QueueAgentMessageEvent,
|
||||
QueueErrorEvent,
|
||||
QueueLLMChunkEvent,
|
||||
QueueMessageEndEvent,
|
||||
QueueMessageFileEvent,
|
||||
QueuePingEvent,
|
||||
)
|
||||
from core.app.entities.task_entities import (
|
||||
EasyUITaskState,
|
||||
ErrorStreamResponse,
|
||||
MessageEndStreamResponse,
|
||||
MessageFileStreamResponse,
|
||||
MessageReplaceStreamResponse,
|
||||
MessageStreamResponse,
|
||||
PingStreamResponse,
|
||||
StreamEvent,
|
||||
)
|
||||
from core.app.task_pipeline.easy_ui_based_generate_task_pipeline import EasyUIBasedGenerateTaskPipeline
|
||||
from core.base.tts import AppGeneratorTTSPublisher
|
||||
from core.model_runtime.entities.llm_entities import LLMResult as RuntimeLLMResult
|
||||
from core.model_runtime.entities.message_entities import TextPromptMessageContent
|
||||
from core.ops.ops_trace_manager import TraceQueueManager
|
||||
from models.model import AppMode
|
||||
|
||||
|
||||
class TestEasyUIBasedGenerateTaskPipelineProcessStreamResponse:
|
||||
"""Test cases for EasyUIBasedGenerateTaskPipeline._process_stream_response method."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_application_generate_entity(self):
|
||||
"""Create a mock application generate entity."""
|
||||
entity = Mock(spec=ChatAppGenerateEntity)
|
||||
entity.task_id = "test-task-id"
|
||||
entity.app_id = "test-app-id"
|
||||
# minimal app_config used by pipeline internals
|
||||
entity.app_config = SimpleNamespace(
|
||||
tenant_id="test-tenant-id",
|
||||
app_id="test-app-id",
|
||||
app_mode=AppMode.CHAT,
|
||||
app_model_config_dict={},
|
||||
additional_features=None,
|
||||
sensitive_word_avoidance=None,
|
||||
)
|
||||
# minimal model_conf for LLMResult init
|
||||
entity.model_conf = SimpleNamespace(
|
||||
model="test-model",
|
||||
provider_model_bundle=SimpleNamespace(model_type_instance=Mock()),
|
||||
credentials={},
|
||||
)
|
||||
return entity
|
||||
|
||||
@pytest.fixture
|
||||
def mock_queue_manager(self):
|
||||
"""Create a mock queue manager."""
|
||||
manager = Mock(spec=AppQueueManager)
|
||||
return manager
|
||||
|
||||
@pytest.fixture
|
||||
def mock_message_cycle_manager(self):
|
||||
"""Create a mock message cycle manager."""
|
||||
manager = Mock()
|
||||
manager.get_message_event_type.return_value = StreamEvent.MESSAGE
|
||||
manager.message_to_stream_response.return_value = Mock(spec=MessageStreamResponse)
|
||||
manager.message_file_to_stream_response.return_value = Mock(spec=MessageFileStreamResponse)
|
||||
manager.message_replace_to_stream_response.return_value = Mock(spec=MessageReplaceStreamResponse)
|
||||
manager.handle_retriever_resources = Mock()
|
||||
manager.handle_annotation_reply.return_value = None
|
||||
return manager
|
||||
|
||||
@pytest.fixture
|
||||
def mock_conversation(self):
|
||||
"""Create a mock conversation."""
|
||||
conversation = Mock()
|
||||
conversation.id = "test-conversation-id"
|
||||
conversation.mode = "chat"
|
||||
return conversation
|
||||
|
||||
@pytest.fixture
|
||||
def mock_message(self):
|
||||
"""Create a mock message."""
|
||||
message = Mock()
|
||||
message.id = "test-message-id"
|
||||
message.created_at = Mock()
|
||||
message.created_at.timestamp.return_value = 1234567890
|
||||
return message
|
||||
|
||||
@pytest.fixture
|
||||
def mock_task_state(self):
|
||||
"""Create a mock task state."""
|
||||
task_state = Mock(spec=EasyUITaskState)
|
||||
|
||||
# Create LLM result mock
|
||||
llm_result = Mock(spec=RuntimeLLMResult)
|
||||
llm_result.prompt_messages = []
|
||||
llm_result.message = Mock()
|
||||
llm_result.message.content = ""
|
||||
|
||||
task_state.llm_result = llm_result
|
||||
task_state.answer = ""
|
||||
|
||||
return task_state
|
||||
|
||||
@pytest.fixture
|
||||
def pipeline(
|
||||
self,
|
||||
mock_application_generate_entity,
|
||||
mock_queue_manager,
|
||||
mock_conversation,
|
||||
mock_message,
|
||||
mock_message_cycle_manager,
|
||||
mock_task_state,
|
||||
):
|
||||
"""Create an EasyUIBasedGenerateTaskPipeline instance with mocked dependencies."""
|
||||
with patch(
|
||||
"core.app.task_pipeline.easy_ui_based_generate_task_pipeline.EasyUITaskState", return_value=mock_task_state
|
||||
):
|
||||
pipeline = EasyUIBasedGenerateTaskPipeline(
|
||||
application_generate_entity=mock_application_generate_entity,
|
||||
queue_manager=mock_queue_manager,
|
||||
conversation=mock_conversation,
|
||||
message=mock_message,
|
||||
stream=True,
|
||||
)
|
||||
pipeline._message_cycle_manager = mock_message_cycle_manager
|
||||
pipeline._task_state = mock_task_state
|
||||
return pipeline
|
||||
|
||||
def test_get_message_event_type_called_once_when_first_llm_chunk_arrives(
|
||||
self, pipeline, mock_message_cycle_manager
|
||||
):
|
||||
"""Expect get_message_event_type to be called when processing the first LLM chunk event."""
|
||||
# Setup a minimal LLM chunk event
|
||||
chunk = Mock()
|
||||
chunk.delta.message.content = "hi"
|
||||
chunk.prompt_messages = []
|
||||
llm_chunk_event = Mock(spec=QueueLLMChunkEvent)
|
||||
llm_chunk_event.chunk = chunk
|
||||
mock_queue_message = Mock()
|
||||
mock_queue_message.event = llm_chunk_event
|
||||
pipeline.queue_manager.listen.return_value = [mock_queue_message]
|
||||
|
||||
# Execute
|
||||
list(pipeline._process_stream_response(publisher=None, trace_manager=None))
|
||||
|
||||
# Assert
|
||||
mock_message_cycle_manager.get_message_event_type.assert_called_once_with(message_id="test-message-id")
|
||||
|
||||
def test_llm_chunk_event_with_text_content(self, pipeline, mock_message_cycle_manager, mock_task_state):
|
||||
"""Test handling of LLM chunk events with text content."""
|
||||
# Setup
|
||||
chunk = Mock()
|
||||
chunk.delta.message.content = "Hello, world!"
|
||||
chunk.prompt_messages = []
|
||||
|
||||
llm_chunk_event = Mock(spec=QueueLLMChunkEvent)
|
||||
llm_chunk_event.chunk = chunk
|
||||
|
||||
mock_queue_message = Mock()
|
||||
mock_queue_message.event = llm_chunk_event
|
||||
pipeline.queue_manager.listen.return_value = [mock_queue_message]
|
||||
|
||||
mock_message_cycle_manager.get_message_event_type.return_value = StreamEvent.MESSAGE
|
||||
|
||||
# Execute
|
||||
responses = list(pipeline._process_stream_response(publisher=None, trace_manager=None))
|
||||
|
||||
# Assert
|
||||
assert len(responses) == 1
|
||||
mock_message_cycle_manager.message_to_stream_response.assert_called_once_with(
|
||||
answer="Hello, world!", message_id="test-message-id", event_type=StreamEvent.MESSAGE
|
||||
)
|
||||
assert mock_task_state.llm_result.message.content == "Hello, world!"
|
||||
|
||||
def test_llm_chunk_event_with_list_content(self, pipeline, mock_message_cycle_manager, mock_task_state):
|
||||
"""Test handling of LLM chunk events with list content."""
|
||||
# Setup
|
||||
text_content = Mock(spec=TextPromptMessageContent)
|
||||
text_content.data = "Hello"
|
||||
|
||||
chunk = Mock()
|
||||
chunk.delta.message.content = [text_content, " world!"]
|
||||
chunk.prompt_messages = []
|
||||
|
||||
llm_chunk_event = Mock(spec=QueueLLMChunkEvent)
|
||||
llm_chunk_event.chunk = chunk
|
||||
|
||||
mock_queue_message = Mock()
|
||||
mock_queue_message.event = llm_chunk_event
|
||||
pipeline.queue_manager.listen.return_value = [mock_queue_message]
|
||||
|
||||
mock_message_cycle_manager.get_message_event_type.return_value = StreamEvent.MESSAGE
|
||||
|
||||
# Execute
|
||||
responses = list(pipeline._process_stream_response(publisher=None, trace_manager=None))
|
||||
|
||||
# Assert
|
||||
assert len(responses) == 1
|
||||
mock_message_cycle_manager.message_to_stream_response.assert_called_once_with(
|
||||
answer="Hello world!", message_id="test-message-id", event_type=StreamEvent.MESSAGE
|
||||
)
|
||||
assert mock_task_state.llm_result.message.content == "Hello world!"
|
||||
|
||||
def test_agent_message_event(self, pipeline, mock_message_cycle_manager, mock_task_state):
|
||||
"""Test handling of agent message events."""
|
||||
# Setup
|
||||
chunk = Mock()
|
||||
chunk.delta.message.content = "Agent response"
|
||||
|
||||
agent_message_event = Mock(spec=QueueAgentMessageEvent)
|
||||
agent_message_event.chunk = chunk
|
||||
|
||||
mock_queue_message = Mock()
|
||||
mock_queue_message.event = agent_message_event
|
||||
pipeline.queue_manager.listen.return_value = [mock_queue_message]
|
||||
|
||||
# Ensure method under assertion is a mock to track calls
|
||||
pipeline._agent_message_to_stream_response = Mock(return_value=Mock())
|
||||
|
||||
# Execute
|
||||
responses = list(pipeline._process_stream_response(publisher=None, trace_manager=None))
|
||||
|
||||
# Assert
|
||||
assert len(responses) == 1
|
||||
# Agent messages should use _agent_message_to_stream_response
|
||||
pipeline._agent_message_to_stream_response.assert_called_once_with(
|
||||
answer="Agent response", message_id="test-message-id"
|
||||
)
|
||||
|
||||
def test_message_end_event(self, pipeline, mock_message_cycle_manager, mock_task_state):
|
||||
"""Test handling of message end events."""
|
||||
# Setup
|
||||
llm_result = Mock(spec=RuntimeLLMResult)
|
||||
llm_result.message = Mock()
|
||||
llm_result.message.content = "Final response"
|
||||
|
||||
message_end_event = Mock(spec=QueueMessageEndEvent)
|
||||
message_end_event.llm_result = llm_result
|
||||
|
||||
mock_queue_message = Mock()
|
||||
mock_queue_message.event = message_end_event
|
||||
pipeline.queue_manager.listen.return_value = [mock_queue_message]
|
||||
|
||||
pipeline._save_message = Mock()
|
||||
pipeline._message_end_to_stream_response = Mock(return_value=Mock(spec=MessageEndStreamResponse))
|
||||
|
||||
# Patch db.engine used inside pipeline for session creation
|
||||
with patch(
|
||||
"core.app.task_pipeline.easy_ui_based_generate_task_pipeline.db", new=SimpleNamespace(engine=Mock())
|
||||
):
|
||||
# Execute
|
||||
responses = list(pipeline._process_stream_response(publisher=None, trace_manager=None))
|
||||
|
||||
# Assert
|
||||
assert len(responses) == 1
|
||||
assert mock_task_state.llm_result == llm_result
|
||||
pipeline._save_message.assert_called_once()
|
||||
pipeline._message_end_to_stream_response.assert_called_once()
|
||||
|
||||
def test_error_event(self, pipeline):
|
||||
"""Test handling of error events."""
|
||||
# Setup
|
||||
error_event = Mock(spec=QueueErrorEvent)
|
||||
error_event.error = Exception("Test error")
|
||||
|
||||
mock_queue_message = Mock()
|
||||
mock_queue_message.event = error_event
|
||||
pipeline.queue_manager.listen.return_value = [mock_queue_message]
|
||||
|
||||
pipeline.handle_error = Mock(return_value=Exception("Test error"))
|
||||
pipeline.error_to_stream_response = Mock(return_value=Mock(spec=ErrorStreamResponse))
|
||||
|
||||
# Patch db.engine used inside pipeline for session creation
|
||||
with patch(
|
||||
"core.app.task_pipeline.easy_ui_based_generate_task_pipeline.db", new=SimpleNamespace(engine=Mock())
|
||||
):
|
||||
# Execute
|
||||
responses = list(pipeline._process_stream_response(publisher=None, trace_manager=None))
|
||||
|
||||
# Assert
|
||||
assert len(responses) == 1
|
||||
pipeline.handle_error.assert_called_once()
|
||||
pipeline.error_to_stream_response.assert_called_once()
|
||||
|
||||
def test_ping_event(self, pipeline):
|
||||
"""Test handling of ping events."""
|
||||
# Setup
|
||||
ping_event = Mock(spec=QueuePingEvent)
|
||||
|
||||
mock_queue_message = Mock()
|
||||
mock_queue_message.event = ping_event
|
||||
pipeline.queue_manager.listen.return_value = [mock_queue_message]
|
||||
|
||||
pipeline.ping_stream_response = Mock(return_value=Mock(spec=PingStreamResponse))
|
||||
|
||||
# Execute
|
||||
responses = list(pipeline._process_stream_response(publisher=None, trace_manager=None))
|
||||
|
||||
# Assert
|
||||
assert len(responses) == 1
|
||||
pipeline.ping_stream_response.assert_called_once()
|
||||
|
||||
def test_file_event(self, pipeline, mock_message_cycle_manager):
|
||||
"""Test handling of file events."""
|
||||
# Setup
|
||||
file_event = Mock(spec=QueueMessageFileEvent)
|
||||
file_event.message_file_id = "file-id"
|
||||
|
||||
mock_queue_message = Mock()
|
||||
mock_queue_message.event = file_event
|
||||
pipeline.queue_manager.listen.return_value = [mock_queue_message]
|
||||
|
||||
file_response = Mock(spec=MessageFileStreamResponse)
|
||||
mock_message_cycle_manager.message_file_to_stream_response.return_value = file_response
|
||||
|
||||
# Execute
|
||||
responses = list(pipeline._process_stream_response(publisher=None, trace_manager=None))
|
||||
|
||||
# Assert
|
||||
assert len(responses) == 1
|
||||
assert responses[0] == file_response
|
||||
mock_message_cycle_manager.message_file_to_stream_response.assert_called_once_with(file_event)
|
||||
|
||||
def test_publisher_is_called_with_messages(self, pipeline):
|
||||
"""Test that publisher publishes messages when provided."""
|
||||
# Setup
|
||||
publisher = Mock(spec=AppGeneratorTTSPublisher)
|
||||
|
||||
ping_event = Mock(spec=QueuePingEvent)
|
||||
mock_queue_message = Mock()
|
||||
mock_queue_message.event = ping_event
|
||||
pipeline.queue_manager.listen.return_value = [mock_queue_message]
|
||||
|
||||
pipeline.ping_stream_response = Mock(return_value=Mock(spec=PingStreamResponse))
|
||||
|
||||
# Execute
|
||||
list(pipeline._process_stream_response(publisher=publisher, trace_manager=None))
|
||||
|
||||
# Assert
|
||||
# Called once with message and once with None at the end
|
||||
assert publisher.publish.call_count == 2
|
||||
publisher.publish.assert_any_call(mock_queue_message)
|
||||
publisher.publish.assert_any_call(None)
|
||||
|
||||
def test_trace_manager_passed_to_save_message(self, pipeline):
|
||||
"""Test that trace manager is passed to _save_message."""
|
||||
# Setup
|
||||
trace_manager = Mock(spec=TraceQueueManager)
|
||||
|
||||
message_end_event = Mock(spec=QueueMessageEndEvent)
|
||||
message_end_event.llm_result = None
|
||||
|
||||
mock_queue_message = Mock()
|
||||
mock_queue_message.event = message_end_event
|
||||
pipeline.queue_manager.listen.return_value = [mock_queue_message]
|
||||
|
||||
pipeline._save_message = Mock()
|
||||
pipeline._message_end_to_stream_response = Mock(return_value=Mock(spec=MessageEndStreamResponse))
|
||||
|
||||
# Patch db.engine used inside pipeline for session creation
|
||||
with patch(
|
||||
"core.app.task_pipeline.easy_ui_based_generate_task_pipeline.db", new=SimpleNamespace(engine=Mock())
|
||||
):
|
||||
# Execute
|
||||
list(pipeline._process_stream_response(publisher=None, trace_manager=trace_manager))
|
||||
|
||||
# Assert
|
||||
pipeline._save_message.assert_called_once_with(session=ANY, trace_manager=trace_manager)
|
||||
|
||||
def test_multiple_events_sequence(self, pipeline, mock_message_cycle_manager, mock_task_state):
|
||||
"""Test handling multiple events in sequence."""
|
||||
# Setup
|
||||
chunk1 = Mock()
|
||||
chunk1.delta.message.content = "Hello"
|
||||
chunk1.prompt_messages = []
|
||||
|
||||
chunk2 = Mock()
|
||||
chunk2.delta.message.content = " world!"
|
||||
chunk2.prompt_messages = []
|
||||
|
||||
llm_chunk_event1 = Mock(spec=QueueLLMChunkEvent)
|
||||
llm_chunk_event1.chunk = chunk1
|
||||
|
||||
ping_event = Mock(spec=QueuePingEvent)
|
||||
|
||||
llm_chunk_event2 = Mock(spec=QueueLLMChunkEvent)
|
||||
llm_chunk_event2.chunk = chunk2
|
||||
|
||||
mock_queue_messages = [
|
||||
Mock(event=llm_chunk_event1),
|
||||
Mock(event=ping_event),
|
||||
Mock(event=llm_chunk_event2),
|
||||
]
|
||||
pipeline.queue_manager.listen.return_value = mock_queue_messages
|
||||
|
||||
mock_message_cycle_manager.get_message_event_type.return_value = StreamEvent.MESSAGE
|
||||
pipeline.ping_stream_response = Mock(return_value=Mock(spec=PingStreamResponse))
|
||||
|
||||
# Execute
|
||||
responses = list(pipeline._process_stream_response(publisher=None, trace_manager=None))
|
||||
|
||||
# Assert
|
||||
assert len(responses) == 3
|
||||
assert mock_task_state.llm_result.message.content == "Hello world!"
|
||||
|
||||
# Verify calls to message_to_stream_response
|
||||
assert mock_message_cycle_manager.message_to_stream_response.call_count == 2
|
||||
mock_message_cycle_manager.message_to_stream_response.assert_any_call(
|
||||
answer="Hello", message_id="test-message-id", event_type=StreamEvent.MESSAGE
|
||||
)
|
||||
mock_message_cycle_manager.message_to_stream_response.assert_any_call(
|
||||
answer=" world!", message_id="test-message-id", event_type=StreamEvent.MESSAGE
|
||||
)
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
"""Unit tests for the message cycle manager optimization."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import ANY, Mock, patch
|
||||
|
||||
import pytest
|
||||
from flask import current_app
|
||||
|
||||
from core.app.entities.task_entities import MessageStreamResponse, StreamEvent
|
||||
from core.app.task_pipeline.message_cycle_manager import MessageCycleManager
|
||||
|
||||
|
||||
class TestMessageCycleManagerOptimization:
|
||||
"""Test cases for the message cycle manager optimization that prevents N+1 queries."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_application_generate_entity(self):
|
||||
"""Create a mock application generate entity."""
|
||||
entity = Mock()
|
||||
entity.task_id = "test-task-id"
|
||||
return entity
|
||||
|
||||
@pytest.fixture
|
||||
def message_cycle_manager(self, mock_application_generate_entity):
|
||||
"""Create a message cycle manager instance."""
|
||||
task_state = Mock()
|
||||
return MessageCycleManager(application_generate_entity=mock_application_generate_entity, task_state=task_state)
|
||||
|
||||
def test_get_message_event_type_with_message_file(self, message_cycle_manager):
|
||||
"""Test get_message_event_type returns MESSAGE_FILE when message has files."""
|
||||
with (
|
||||
patch("core.app.task_pipeline.message_cycle_manager.Session") as mock_session_class,
|
||||
patch("core.app.task_pipeline.message_cycle_manager.db", new=SimpleNamespace(engine=Mock())),
|
||||
):
|
||||
# Setup mock session and message file
|
||||
mock_session = Mock()
|
||||
mock_session_class.return_value.__enter__.return_value = mock_session
|
||||
|
||||
mock_message_file = Mock()
|
||||
# Current implementation uses session.query(...).scalar()
|
||||
mock_session.query.return_value.scalar.return_value = mock_message_file
|
||||
|
||||
# Execute
|
||||
with current_app.app_context():
|
||||
result = message_cycle_manager.get_message_event_type("test-message-id")
|
||||
|
||||
# Assert
|
||||
assert result == StreamEvent.MESSAGE_FILE
|
||||
mock_session.query.return_value.scalar.assert_called_once()
|
||||
|
||||
def test_get_message_event_type_without_message_file(self, message_cycle_manager):
|
||||
"""Test get_message_event_type returns MESSAGE when message has no files."""
|
||||
with (
|
||||
patch("core.app.task_pipeline.message_cycle_manager.Session") as mock_session_class,
|
||||
patch("core.app.task_pipeline.message_cycle_manager.db", new=SimpleNamespace(engine=Mock())),
|
||||
):
|
||||
# Setup mock session and no message file
|
||||
mock_session = Mock()
|
||||
mock_session_class.return_value.__enter__.return_value = mock_session
|
||||
# Current implementation uses session.query(...).scalar()
|
||||
mock_session.query.return_value.scalar.return_value = None
|
||||
|
||||
# Execute
|
||||
with current_app.app_context():
|
||||
result = message_cycle_manager.get_message_event_type("test-message-id")
|
||||
|
||||
# Assert
|
||||
assert result == StreamEvent.MESSAGE
|
||||
mock_session.query.return_value.scalar.assert_called_once()
|
||||
|
||||
def test_message_to_stream_response_with_precomputed_event_type(self, message_cycle_manager):
|
||||
"""MessageCycleManager.message_to_stream_response expects a valid event_type; callers should precompute it."""
|
||||
with (
|
||||
patch("core.app.task_pipeline.message_cycle_manager.Session") as mock_session_class,
|
||||
patch("core.app.task_pipeline.message_cycle_manager.db", new=SimpleNamespace(engine=Mock())),
|
||||
):
|
||||
# Setup mock session and message file
|
||||
mock_session = Mock()
|
||||
mock_session_class.return_value.__enter__.return_value = mock_session
|
||||
|
||||
mock_message_file = Mock()
|
||||
# Current implementation uses session.query(...).scalar()
|
||||
mock_session.query.return_value.scalar.return_value = mock_message_file
|
||||
|
||||
# Execute: compute event type once, then pass to message_to_stream_response
|
||||
with current_app.app_context():
|
||||
event_type = message_cycle_manager.get_message_event_type("test-message-id")
|
||||
result = message_cycle_manager.message_to_stream_response(
|
||||
answer="Hello world", message_id="test-message-id", event_type=event_type
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, MessageStreamResponse)
|
||||
assert result.answer == "Hello world"
|
||||
assert result.id == "test-message-id"
|
||||
assert result.event == StreamEvent.MESSAGE_FILE
|
||||
mock_session.query.return_value.scalar.assert_called_once()
|
||||
|
||||
def test_message_to_stream_response_with_event_type_skips_query(self, message_cycle_manager):
|
||||
"""Test that message_to_stream_response skips database query when event_type is provided."""
|
||||
with patch("core.app.task_pipeline.message_cycle_manager.Session") as mock_session_class:
|
||||
# Execute with event_type provided
|
||||
result = message_cycle_manager.message_to_stream_response(
|
||||
answer="Hello world", message_id="test-message-id", event_type=StreamEvent.MESSAGE
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, MessageStreamResponse)
|
||||
assert result.answer == "Hello world"
|
||||
assert result.id == "test-message-id"
|
||||
assert result.event == StreamEvent.MESSAGE
|
||||
# Should not query database when event_type is provided
|
||||
mock_session_class.assert_not_called()
|
||||
|
||||
def test_message_to_stream_response_with_from_variable_selector(self, message_cycle_manager):
|
||||
"""Test message_to_stream_response with from_variable_selector parameter."""
|
||||
result = message_cycle_manager.message_to_stream_response(
|
||||
answer="Hello world",
|
||||
message_id="test-message-id",
|
||||
from_variable_selector=["var1", "var2"],
|
||||
event_type=StreamEvent.MESSAGE,
|
||||
)
|
||||
|
||||
assert isinstance(result, MessageStreamResponse)
|
||||
assert result.answer == "Hello world"
|
||||
assert result.id == "test-message-id"
|
||||
assert result.from_variable_selector == ["var1", "var2"]
|
||||
assert result.event == StreamEvent.MESSAGE
|
||||
|
||||
def test_optimization_usage_example(self, message_cycle_manager):
|
||||
"""Test the optimization pattern that should be used by callers."""
|
||||
# Step 1: Get event type once (this queries database)
|
||||
with (
|
||||
patch("core.app.task_pipeline.message_cycle_manager.Session") as mock_session_class,
|
||||
patch("core.app.task_pipeline.message_cycle_manager.db", new=SimpleNamespace(engine=Mock())),
|
||||
):
|
||||
mock_session = Mock()
|
||||
mock_session_class.return_value.__enter__.return_value = mock_session
|
||||
# Current implementation uses session.query(...).scalar()
|
||||
mock_session.query.return_value.scalar.return_value = None # No files
|
||||
with current_app.app_context():
|
||||
event_type = message_cycle_manager.get_message_event_type("test-message-id")
|
||||
|
||||
# Should query database once
|
||||
mock_session_class.assert_called_once_with(ANY, expire_on_commit=False)
|
||||
assert event_type == StreamEvent.MESSAGE
|
||||
|
||||
# Step 2: Use event_type for multiple calls (no additional queries)
|
||||
with patch("core.app.task_pipeline.message_cycle_manager.Session") as mock_session_class:
|
||||
mock_session_class.return_value.__enter__.return_value = Mock()
|
||||
|
||||
chunk1_response = message_cycle_manager.message_to_stream_response(
|
||||
answer="Chunk 1", message_id="test-message-id", event_type=event_type
|
||||
)
|
||||
|
||||
chunk2_response = message_cycle_manager.message_to_stream_response(
|
||||
answer="Chunk 2", message_id="test-message-id", event_type=event_type
|
||||
)
|
||||
|
||||
# Should not query database again
|
||||
mock_session_class.assert_not_called()
|
||||
|
||||
assert chunk1_response.event == StreamEvent.MESSAGE
|
||||
assert chunk2_response.event == StreamEvent.MESSAGE
|
||||
assert chunk1_response.answer == "Chunk 1"
|
||||
assert chunk2_response.answer == "Chunk 2"
|
||||
|
|
@ -901,6 +901,13 @@ class TestFixedRecursiveCharacterTextSplitter:
|
|||
# Verify no empty chunks
|
||||
assert all(len(chunk) > 0 for chunk in result)
|
||||
|
||||
def test_double_slash_n(self):
|
||||
data = "chunk 1\n\nsubchunk 1.\nsubchunk 2.\n\n---\n\nchunk 2\n\nsubchunk 1\nsubchunk 2."
|
||||
separator = "\\n\\n---\\n\\n"
|
||||
splitter = FixedRecursiveCharacterTextSplitter(fixed_separator=separator)
|
||||
chunks = splitter.split_text(data)
|
||||
assert chunks == ["chunk 1\n\nsubchunk 1.\nsubchunk 2.", "chunk 2\n\nsubchunk 1\nsubchunk 2."]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test Metadata Preservation
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import json
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
|
@ -46,14 +47,16 @@ def make_start_node(user_inputs, variables):
|
|||
|
||||
|
||||
def test_json_object_valid_schema():
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"age": {"type": "number"},
|
||||
"name": {"type": "string"},
|
||||
},
|
||||
"required": ["age"],
|
||||
}
|
||||
schema = json.dumps(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"age": {"type": "number"},
|
||||
"name": {"type": "string"},
|
||||
},
|
||||
"required": ["age"],
|
||||
}
|
||||
)
|
||||
|
||||
variables = [
|
||||
VariableEntity(
|
||||
|
|
@ -65,7 +68,7 @@ def test_json_object_valid_schema():
|
|||
)
|
||||
]
|
||||
|
||||
user_inputs = {"profile": {"age": 20, "name": "Tom"}}
|
||||
user_inputs = {"profile": json.dumps({"age": 20, "name": "Tom"})}
|
||||
|
||||
node = make_start_node(user_inputs, variables)
|
||||
result = node._run()
|
||||
|
|
@ -74,12 +77,23 @@ def test_json_object_valid_schema():
|
|||
|
||||
|
||||
def test_json_object_invalid_json_string():
|
||||
schema = json.dumps(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"age": {"type": "number"},
|
||||
"name": {"type": "string"},
|
||||
},
|
||||
"required": ["age", "name"],
|
||||
}
|
||||
)
|
||||
variables = [
|
||||
VariableEntity(
|
||||
variable="profile",
|
||||
label="profile",
|
||||
type=VariableEntityType.JSON_OBJECT,
|
||||
required=True,
|
||||
json_schema=schema,
|
||||
)
|
||||
]
|
||||
|
||||
|
|
@ -88,38 +102,21 @@ def test_json_object_invalid_json_string():
|
|||
|
||||
node = make_start_node(user_inputs, variables)
|
||||
|
||||
with pytest.raises(ValueError, match="profile must be a JSON object"):
|
||||
node._run()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["[1, 2, 3]", "123"])
|
||||
def test_json_object_valid_json_but_not_object(value):
|
||||
variables = [
|
||||
VariableEntity(
|
||||
variable="profile",
|
||||
label="profile",
|
||||
type=VariableEntityType.JSON_OBJECT,
|
||||
required=True,
|
||||
)
|
||||
]
|
||||
|
||||
user_inputs = {"profile": value}
|
||||
|
||||
node = make_start_node(user_inputs, variables)
|
||||
|
||||
with pytest.raises(ValueError, match="profile must be a JSON object"):
|
||||
with pytest.raises(ValueError, match='{"age": 20, "name": "Tom" must be a valid JSON object'):
|
||||
node._run()
|
||||
|
||||
|
||||
def test_json_object_does_not_match_schema():
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"age": {"type": "number"},
|
||||
"name": {"type": "string"},
|
||||
},
|
||||
"required": ["age", "name"],
|
||||
}
|
||||
schema = json.dumps(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"age": {"type": "number"},
|
||||
"name": {"type": "string"},
|
||||
},
|
||||
"required": ["age", "name"],
|
||||
}
|
||||
)
|
||||
|
||||
variables = [
|
||||
VariableEntity(
|
||||
|
|
@ -132,7 +129,7 @@ def test_json_object_does_not_match_schema():
|
|||
]
|
||||
|
||||
# age is a string, which violates the schema (expects number)
|
||||
user_inputs = {"profile": {"age": "twenty", "name": "Tom"}}
|
||||
user_inputs = {"profile": json.dumps({"age": "twenty", "name": "Tom"})}
|
||||
|
||||
node = make_start_node(user_inputs, variables)
|
||||
|
||||
|
|
@ -141,14 +138,16 @@ def test_json_object_does_not_match_schema():
|
|||
|
||||
|
||||
def test_json_object_missing_required_schema_field():
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"age": {"type": "number"},
|
||||
"name": {"type": "string"},
|
||||
},
|
||||
"required": ["age", "name"],
|
||||
}
|
||||
schema = json.dumps(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"age": {"type": "number"},
|
||||
"name": {"type": "string"},
|
||||
},
|
||||
"required": ["age", "name"],
|
||||
}
|
||||
)
|
||||
|
||||
variables = [
|
||||
VariableEntity(
|
||||
|
|
@ -161,7 +160,7 @@ def test_json_object_missing_required_schema_field():
|
|||
]
|
||||
|
||||
# Missing required field "name"
|
||||
user_inputs = {"profile": {"age": 20}}
|
||||
user_inputs = {"profile": json.dumps({"age": 20})}
|
||||
|
||||
node = make_start_node(user_inputs, variables)
|
||||
|
||||
|
|
@ -214,7 +213,7 @@ def test_json_object_optional_variable_not_provided():
|
|||
variable="profile",
|
||||
label="profile",
|
||||
type=VariableEntityType.JSON_OBJECT,
|
||||
required=False,
|
||||
required=True,
|
||||
)
|
||||
]
|
||||
|
||||
|
|
@ -223,5 +222,5 @@ def test_json_object_optional_variable_not_provided():
|
|||
node = make_start_node(user_inputs, variables)
|
||||
|
||||
# Current implementation raises a validation error even when the variable is optional
|
||||
with pytest.raises(ValueError, match="profile must be a JSON object"):
|
||||
with pytest.raises(ValueError, match="profile is required in input form"):
|
||||
node._run()
|
||||
|
|
|
|||
|
|
@ -1156,6 +1156,199 @@ class TestBillingServiceEdgeCases:
|
|||
assert "Only team owner or team admin can perform this action" in str(exc_info.value)
|
||||
|
||||
|
||||
class TestBillingServiceSubscriptionOperations:
|
||||
"""Unit tests for subscription operations in BillingService.
|
||||
|
||||
Tests cover:
|
||||
- Bulk plan retrieval with chunking
|
||||
- Expired subscription cleanup whitelist retrieval
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_send_request(self):
|
||||
"""Mock _send_request method."""
|
||||
with patch.object(BillingService, "_send_request") as mock:
|
||||
yield mock
|
||||
|
||||
def test_get_plan_bulk_with_empty_list(self, mock_send_request):
|
||||
"""Test bulk plan retrieval with empty tenant list."""
|
||||
# Arrange
|
||||
tenant_ids = []
|
||||
|
||||
# Act
|
||||
result = BillingService.get_plan_bulk(tenant_ids)
|
||||
|
||||
# Assert
|
||||
assert result == {}
|
||||
mock_send_request.assert_not_called()
|
||||
|
||||
def test_get_plan_bulk_with_chunking(self, mock_send_request):
|
||||
"""Test bulk plan retrieval with more than 200 tenants (chunking logic)."""
|
||||
# Arrange - 250 tenants to test chunking (chunk_size = 200)
|
||||
tenant_ids = [f"tenant-{i}" for i in range(250)]
|
||||
|
||||
# First chunk: tenants 0-199
|
||||
first_chunk_response = {
|
||||
"data": {f"tenant-{i}": {"plan": "sandbox", "expiration_date": 1735689600} for i in range(200)}
|
||||
}
|
||||
|
||||
# Second chunk: tenants 200-249
|
||||
second_chunk_response = {
|
||||
"data": {f"tenant-{i}": {"plan": "professional", "expiration_date": 1767225600} for i in range(200, 250)}
|
||||
}
|
||||
|
||||
mock_send_request.side_effect = [first_chunk_response, second_chunk_response]
|
||||
|
||||
# Act
|
||||
result = BillingService.get_plan_bulk(tenant_ids)
|
||||
|
||||
# Assert
|
||||
assert len(result) == 250
|
||||
assert result["tenant-0"]["plan"] == "sandbox"
|
||||
assert result["tenant-199"]["plan"] == "sandbox"
|
||||
assert result["tenant-200"]["plan"] == "professional"
|
||||
assert result["tenant-249"]["plan"] == "professional"
|
||||
assert mock_send_request.call_count == 2
|
||||
|
||||
# Verify first chunk call
|
||||
first_call = mock_send_request.call_args_list[0]
|
||||
assert first_call[0][0] == "POST"
|
||||
assert first_call[0][1] == "/subscription/plan/batch"
|
||||
assert len(first_call[1]["json"]["tenant_ids"]) == 200
|
||||
|
||||
# Verify second chunk call
|
||||
second_call = mock_send_request.call_args_list[1]
|
||||
assert len(second_call[1]["json"]["tenant_ids"]) == 50
|
||||
|
||||
def test_get_plan_bulk_with_partial_batch_failure(self, mock_send_request):
|
||||
"""Test bulk plan retrieval when one batch fails but others succeed."""
|
||||
# Arrange - 250 tenants, second batch will fail
|
||||
tenant_ids = [f"tenant-{i}" for i in range(250)]
|
||||
|
||||
# First chunk succeeds
|
||||
first_chunk_response = {
|
||||
"data": {f"tenant-{i}": {"plan": "sandbox", "expiration_date": 1735689600} for i in range(200)}
|
||||
}
|
||||
|
||||
# Second chunk fails - need to create a mock that raises when called
|
||||
def side_effect_func(*args, **kwargs):
|
||||
if mock_send_request.call_count == 1:
|
||||
return first_chunk_response
|
||||
else:
|
||||
raise ValueError("API error")
|
||||
|
||||
mock_send_request.side_effect = side_effect_func
|
||||
|
||||
# Act
|
||||
result = BillingService.get_plan_bulk(tenant_ids)
|
||||
|
||||
# Assert - should only have data from first batch
|
||||
assert len(result) == 200
|
||||
assert result["tenant-0"]["plan"] == "sandbox"
|
||||
assert result["tenant-199"]["plan"] == "sandbox"
|
||||
assert "tenant-200" not in result
|
||||
assert mock_send_request.call_count == 2
|
||||
|
||||
def test_get_plan_bulk_with_all_batches_failing(self, mock_send_request):
|
||||
"""Test bulk plan retrieval when all batches fail."""
|
||||
# Arrange
|
||||
tenant_ids = [f"tenant-{i}" for i in range(250)]
|
||||
|
||||
# All chunks fail
|
||||
def side_effect_func(*args, **kwargs):
|
||||
raise ValueError("API error")
|
||||
|
||||
mock_send_request.side_effect = side_effect_func
|
||||
|
||||
# Act
|
||||
result = BillingService.get_plan_bulk(tenant_ids)
|
||||
|
||||
# Assert - should return empty dict
|
||||
assert result == {}
|
||||
assert mock_send_request.call_count == 2
|
||||
|
||||
def test_get_plan_bulk_with_exactly_200_tenants(self, mock_send_request):
|
||||
"""Test bulk plan retrieval with exactly 200 tenants (boundary condition)."""
|
||||
# Arrange
|
||||
tenant_ids = [f"tenant-{i}" for i in range(200)]
|
||||
mock_send_request.return_value = {
|
||||
"data": {f"tenant-{i}": {"plan": "sandbox", "expiration_date": 1735689600} for i in range(200)}
|
||||
}
|
||||
|
||||
# Act
|
||||
result = BillingService.get_plan_bulk(tenant_ids)
|
||||
|
||||
# Assert
|
||||
assert len(result) == 200
|
||||
assert mock_send_request.call_count == 1
|
||||
|
||||
def test_get_plan_bulk_with_empty_data_response(self, mock_send_request):
|
||||
"""Test bulk plan retrieval with empty data in response."""
|
||||
# Arrange
|
||||
tenant_ids = ["tenant-1", "tenant-2"]
|
||||
mock_send_request.return_value = {"data": {}}
|
||||
|
||||
# Act
|
||||
result = BillingService.get_plan_bulk(tenant_ids)
|
||||
|
||||
# Assert
|
||||
assert result == {}
|
||||
|
||||
def test_get_expired_subscription_cleanup_whitelist_success(self, mock_send_request):
|
||||
"""Test successful retrieval of expired subscription cleanup whitelist."""
|
||||
# Arrange
|
||||
api_response = [
|
||||
{
|
||||
"created_at": "2025-10-16T01:56:17",
|
||||
"tenant_id": "36bd55ec-2ea9-4d75-a9ea-1f26aeb4ffe6",
|
||||
"contact": "example@dify.ai",
|
||||
"id": "36bd55ec-2ea9-4d75-a9ea-1f26aeb4ffe5",
|
||||
"expired_at": "2026-01-01T01:56:17",
|
||||
"updated_at": "2025-10-16T01:56:17",
|
||||
},
|
||||
{
|
||||
"created_at": "2025-10-16T02:00:00",
|
||||
"tenant_id": "tenant-2",
|
||||
"contact": "test@example.com",
|
||||
"id": "whitelist-id-2",
|
||||
"expired_at": "2026-02-01T00:00:00",
|
||||
"updated_at": "2025-10-16T02:00:00",
|
||||
},
|
||||
{
|
||||
"created_at": "2025-10-16T03:00:00",
|
||||
"tenant_id": "tenant-3",
|
||||
"contact": "another@example.com",
|
||||
"id": "whitelist-id-3",
|
||||
"expired_at": "2026-03-01T00:00:00",
|
||||
"updated_at": "2025-10-16T03:00:00",
|
||||
},
|
||||
]
|
||||
mock_send_request.return_value = {"data": api_response}
|
||||
|
||||
# Act
|
||||
result = BillingService.get_expired_subscription_cleanup_whitelist()
|
||||
|
||||
# Assert - should return only tenant_ids
|
||||
assert result == ["36bd55ec-2ea9-4d75-a9ea-1f26aeb4ffe6", "tenant-2", "tenant-3"]
|
||||
assert len(result) == 3
|
||||
assert result[0] == "36bd55ec-2ea9-4d75-a9ea-1f26aeb4ffe6"
|
||||
assert result[1] == "tenant-2"
|
||||
assert result[2] == "tenant-3"
|
||||
mock_send_request.assert_called_once_with("GET", "/subscription/cleanup/whitelist")
|
||||
|
||||
def test_get_expired_subscription_cleanup_whitelist_empty_list(self, mock_send_request):
|
||||
"""Test retrieval of empty cleanup whitelist."""
|
||||
# Arrange
|
||||
mock_send_request.return_value = {"data": []}
|
||||
|
||||
# Act
|
||||
result = BillingService.get_expired_subscription_cleanup_whitelist()
|
||||
|
||||
# Assert
|
||||
assert result == []
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
class TestBillingServiceIntegrationScenarios:
|
||||
"""Integration-style tests simulating real-world usage scenarios.
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ show_help() {
|
|||
echo " pipeline - Standard pipeline tasks"
|
||||
echo " triggered_workflow_dispatcher - Trigger dispatcher tasks"
|
||||
echo " trigger_refresh_executor - Trigger refresh tasks"
|
||||
echo " retention - Retention tasks"
|
||||
}
|
||||
|
||||
# Parse command line arguments
|
||||
|
|
@ -105,10 +106,10 @@ if [[ -z "${QUEUES}" ]]; then
|
|||
# Configure queues based on edition
|
||||
if [[ "${EDITION}" == "CLOUD" ]]; then
|
||||
# Cloud edition: separate queues for dataset and trigger tasks
|
||||
QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow_professional,workflow_team,workflow_sandbox,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor"
|
||||
QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow_professional,workflow_team,workflow_sandbox,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention"
|
||||
else
|
||||
# Community edition (SELF_HOSTED): dataset and workflow have separate queues
|
||||
QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor"
|
||||
QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention"
|
||||
fi
|
||||
|
||||
echo "No queues specified, using edition-based defaults: ${QUEUES}"
|
||||
|
|
|
|||
|
|
@ -1369,7 +1369,10 @@ PLUGIN_STDIO_BUFFER_SIZE=1024
|
|||
PLUGIN_STDIO_MAX_BUFFER_SIZE=5242880
|
||||
|
||||
PLUGIN_PYTHON_ENV_INIT_TIMEOUT=120
|
||||
# Plugin Daemon side timeout (configure to match the API side below)
|
||||
PLUGIN_MAX_EXECUTION_TIMEOUT=600
|
||||
# API side timeout (configure to match the Plugin Daemon side above)
|
||||
PLUGIN_DAEMON_TIMEOUT=600.0
|
||||
# PIP_MIRROR_URL=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
PIP_MIRROR_URL=
|
||||
|
||||
|
|
@ -1479,4 +1482,9 @@ ANNOTATION_IMPORT_RATE_LIMIT_PER_HOUR=20
|
|||
ANNOTATION_IMPORT_MAX_CONCURRENT=5
|
||||
|
||||
# The API key of amplitude
|
||||
AMPLITUDE_API_KEY=
|
||||
AMPLITUDE_API_KEY=
|
||||
|
||||
# Sandbox expired records clean configuration
|
||||
SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD=21
|
||||
SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE=1000
|
||||
SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS=30
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ services:
|
|||
PLUGIN_REMOTE_INSTALL_HOST: ${EXPOSE_PLUGIN_DEBUGGING_HOST:-localhost}
|
||||
PLUGIN_REMOTE_INSTALL_PORT: ${EXPOSE_PLUGIN_DEBUGGING_PORT:-5003}
|
||||
PLUGIN_MAX_PACKAGE_SIZE: ${PLUGIN_MAX_PACKAGE_SIZE:-52428800}
|
||||
PLUGIN_DAEMON_TIMEOUT: ${PLUGIN_DAEMON_TIMEOUT:-600.0}
|
||||
INNER_API_KEY_FOR_PLUGIN: ${PLUGIN_DIFY_INNER_API_KEY:-QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1}
|
||||
depends_on:
|
||||
init_permissions:
|
||||
|
|
|
|||
|
|
@ -591,6 +591,7 @@ x-shared-env: &shared-api-worker-env
|
|||
PLUGIN_STDIO_MAX_BUFFER_SIZE: ${PLUGIN_STDIO_MAX_BUFFER_SIZE:-5242880}
|
||||
PLUGIN_PYTHON_ENV_INIT_TIMEOUT: ${PLUGIN_PYTHON_ENV_INIT_TIMEOUT:-120}
|
||||
PLUGIN_MAX_EXECUTION_TIMEOUT: ${PLUGIN_MAX_EXECUTION_TIMEOUT:-600}
|
||||
PLUGIN_DAEMON_TIMEOUT: ${PLUGIN_DAEMON_TIMEOUT:-600.0}
|
||||
PIP_MIRROR_URL: ${PIP_MIRROR_URL:-}
|
||||
PLUGIN_STORAGE_TYPE: ${PLUGIN_STORAGE_TYPE:-local}
|
||||
PLUGIN_STORAGE_LOCAL_ROOT: ${PLUGIN_STORAGE_LOCAL_ROOT:-/app/storage}
|
||||
|
|
@ -663,6 +664,9 @@ x-shared-env: &shared-api-worker-env
|
|||
ANNOTATION_IMPORT_RATE_LIMIT_PER_HOUR: ${ANNOTATION_IMPORT_RATE_LIMIT_PER_HOUR:-20}
|
||||
ANNOTATION_IMPORT_MAX_CONCURRENT: ${ANNOTATION_IMPORT_MAX_CONCURRENT:-5}
|
||||
AMPLITUDE_API_KEY: ${AMPLITUDE_API_KEY:-}
|
||||
SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD: ${SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD:-21}
|
||||
SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE: ${SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE:-1000}
|
||||
SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS: ${SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS:-30}
|
||||
|
||||
services:
|
||||
# Init container to fix permissions
|
||||
|
|
@ -699,6 +703,7 @@ services:
|
|||
PLUGIN_REMOTE_INSTALL_HOST: ${EXPOSE_PLUGIN_DEBUGGING_HOST:-localhost}
|
||||
PLUGIN_REMOTE_INSTALL_PORT: ${EXPOSE_PLUGIN_DEBUGGING_PORT:-5003}
|
||||
PLUGIN_MAX_PACKAGE_SIZE: ${PLUGIN_MAX_PACKAGE_SIZE:-52428800}
|
||||
PLUGIN_DAEMON_TIMEOUT: ${PLUGIN_DAEMON_TIMEOUT:-600.0}
|
||||
INNER_API_KEY_FOR_PLUGIN: ${PLUGIN_DIFY_INNER_API_KEY:-QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1}
|
||||
depends_on:
|
||||
init_permissions:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
import React from 'react'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import BatchAction from './batch-action'
|
||||
|
||||
describe('BatchAction', () => {
|
||||
const baseProps = {
|
||||
selectedIds: ['1', '2', '3'],
|
||||
onBatchDelete: jest.fn(),
|
||||
onCancel: jest.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should show the selected count and trigger cancel action', () => {
|
||||
render(<BatchAction {...baseProps} className='custom-class' />)
|
||||
|
||||
expect(screen.getByText('3')).toBeInTheDocument()
|
||||
expect(screen.getByText('appAnnotation.batchAction.selected')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
|
||||
expect(baseProps.onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should confirm before running batch delete', async () => {
|
||||
const onBatchDelete = jest.fn().mockResolvedValue(undefined)
|
||||
render(<BatchAction {...baseProps} onBatchDelete={onBatchDelete} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.delete' }))
|
||||
await screen.findByText('appAnnotation.list.delete.title')
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'common.operation.delete' })[1])
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onBatchDelete).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import CSVDownload from './csv-downloader'
|
||||
import I18nContext from '@/context/i18n'
|
||||
import { LanguagesSupported } from '@/i18n-config/language'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
|
||||
const downloaderProps: any[] = []
|
||||
|
||||
jest.mock('react-papaparse', () => ({
|
||||
useCSVDownloader: jest.fn(() => ({
|
||||
CSVDownloader: ({ children, ...props }: any) => {
|
||||
downloaderProps.push(props)
|
||||
return <div data-testid="mock-csv-downloader">{children}</div>
|
||||
},
|
||||
Type: { Link: 'link' },
|
||||
})),
|
||||
}))
|
||||
|
||||
const renderWithLocale = (locale: Locale) => {
|
||||
return render(
|
||||
<I18nContext.Provider value={{
|
||||
locale,
|
||||
i18n: {},
|
||||
setLocaleOnClient: jest.fn().mockResolvedValue(undefined),
|
||||
}}
|
||||
>
|
||||
<CSVDownload />
|
||||
</I18nContext.Provider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('CSVDownload', () => {
|
||||
const englishTemplate = [
|
||||
['question', 'answer'],
|
||||
['question1', 'answer1'],
|
||||
['question2', 'answer2'],
|
||||
]
|
||||
const chineseTemplate = [
|
||||
['问题', '答案'],
|
||||
['问题 1', '答案 1'],
|
||||
['问题 2', '答案 2'],
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
downloaderProps.length = 0
|
||||
})
|
||||
|
||||
it('should render the structure preview and pass English template data by default', () => {
|
||||
renderWithLocale('en-US' as Locale)
|
||||
|
||||
expect(screen.getByText('share.generation.csvStructureTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('appAnnotation.batchModal.template')).toBeInTheDocument()
|
||||
|
||||
expect(downloaderProps[0]).toMatchObject({
|
||||
filename: 'template-en-US',
|
||||
type: 'link',
|
||||
bom: true,
|
||||
data: englishTemplate,
|
||||
})
|
||||
})
|
||||
|
||||
it('should switch to the Chinese template when locale matches the secondary language', () => {
|
||||
const locale = LanguagesSupported[1] as Locale
|
||||
renderWithLocale(locale)
|
||||
|
||||
expect(downloaderProps[0]).toMatchObject({
|
||||
filename: `template-${locale}`,
|
||||
data: chineseTemplate,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
import React from 'react'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import BatchModal, { ProcessStatus } from './index'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { annotationBatchImport, checkAnnotationBatchImportProgress } from '@/service/annotation'
|
||||
import type { IBatchModalProps } from './index'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
|
||||
jest.mock('@/app/components/base/toast', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
notify: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/service/annotation', () => ({
|
||||
annotationBatchImport: jest.fn(),
|
||||
checkAnnotationBatchImportProgress: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('./csv-downloader', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="csv-downloader-stub" />,
|
||||
}))
|
||||
|
||||
let lastUploadedFile: File | undefined
|
||||
|
||||
jest.mock('./csv-uploader', () => ({
|
||||
__esModule: true,
|
||||
default: ({ file, updateFile }: { file?: File; updateFile: (file?: File) => void }) => (
|
||||
<div>
|
||||
<button
|
||||
data-testid="mock-uploader"
|
||||
onClick={() => {
|
||||
lastUploadedFile = new File(['question,answer'], 'batch.csv', { type: 'text/csv' })
|
||||
updateFile(lastUploadedFile)
|
||||
}}
|
||||
>
|
||||
upload
|
||||
</button>
|
||||
{file && <span data-testid="selected-file">{file.name}</span>}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/billing/annotation-full', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="annotation-full" />,
|
||||
}))
|
||||
|
||||
const mockNotify = Toast.notify as jest.Mock
|
||||
const useProviderContextMock = useProviderContext as jest.Mock
|
||||
const annotationBatchImportMock = annotationBatchImport as jest.Mock
|
||||
const checkAnnotationBatchImportProgressMock = checkAnnotationBatchImportProgress as jest.Mock
|
||||
|
||||
const renderComponent = (props: Partial<IBatchModalProps> = {}) => {
|
||||
const mergedProps: IBatchModalProps = {
|
||||
appId: 'app-id',
|
||||
isShow: true,
|
||||
onCancel: jest.fn(),
|
||||
onAdded: jest.fn(),
|
||||
...props,
|
||||
}
|
||||
return {
|
||||
...render(<BatchModal {...mergedProps} />),
|
||||
props: mergedProps,
|
||||
}
|
||||
}
|
||||
|
||||
describe('BatchModal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
lastUploadedFile = undefined
|
||||
useProviderContextMock.mockReturnValue({
|
||||
plan: {
|
||||
usage: { annotatedResponse: 0 },
|
||||
total: { annotatedResponse: 10 },
|
||||
},
|
||||
enableBilling: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should disable run action and show billing hint when annotation quota is full', () => {
|
||||
useProviderContextMock.mockReturnValue({
|
||||
plan: {
|
||||
usage: { annotatedResponse: 10 },
|
||||
total: { annotatedResponse: 10 },
|
||||
},
|
||||
enableBilling: true,
|
||||
})
|
||||
|
||||
renderComponent()
|
||||
|
||||
expect(screen.getByTestId('annotation-full')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'appAnnotation.batchModal.run' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should reset uploader state when modal closes and allow manual cancellation', () => {
|
||||
const { rerender, props } = renderComponent()
|
||||
|
||||
fireEvent.click(screen.getByTestId('mock-uploader'))
|
||||
expect(screen.getByTestId('selected-file')).toHaveTextContent('batch.csv')
|
||||
|
||||
rerender(<BatchModal {...props} isShow={false} />)
|
||||
rerender(<BatchModal {...props} isShow />)
|
||||
|
||||
expect(screen.queryByTestId('selected-file')).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'appAnnotation.batchModal.cancel' }))
|
||||
expect(props.onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should submit the csv file, poll status, and notify when import completes', async () => {
|
||||
jest.useFakeTimers()
|
||||
const { props } = renderComponent()
|
||||
const fileTrigger = screen.getByTestId('mock-uploader')
|
||||
fireEvent.click(fileTrigger)
|
||||
|
||||
const runButton = screen.getByRole('button', { name: 'appAnnotation.batchModal.run' })
|
||||
expect(runButton).not.toBeDisabled()
|
||||
|
||||
annotationBatchImportMock.mockResolvedValue({ job_id: 'job-1', job_status: ProcessStatus.PROCESSING })
|
||||
checkAnnotationBatchImportProgressMock
|
||||
.mockResolvedValueOnce({ job_id: 'job-1', job_status: ProcessStatus.PROCESSING })
|
||||
.mockResolvedValueOnce({ job_id: 'job-1', job_status: ProcessStatus.COMPLETED })
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(runButton)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(annotationBatchImportMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
const formData = annotationBatchImportMock.mock.calls[0][0].body as FormData
|
||||
expect(formData.get('file')).toBe(lastUploadedFile)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(checkAnnotationBatchImportProgressMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
jest.runOnlyPendingTimers()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(checkAnnotationBatchImportProgressMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: 'appAnnotation.batchModal.completed',
|
||||
})
|
||||
expect(props.onAdded).toHaveBeenCalledTimes(1)
|
||||
expect(props.onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
jest.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
|
@ -405,4 +405,174 @@ describe('EditAnnotationModal', () => {
|
|||
expect(editLinks).toHaveLength(1) // Only answer should have edit button
|
||||
})
|
||||
})
|
||||
|
||||
// Error Handling (CRITICAL for coverage)
|
||||
describe('Error Handling', () => {
|
||||
it('should handle addAnnotation API failure gracefully', async () => {
|
||||
// Arrange
|
||||
const mockOnAdded = jest.fn()
|
||||
const props = {
|
||||
...defaultProps,
|
||||
onAdded: mockOnAdded,
|
||||
}
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Mock API failure
|
||||
mockAddAnnotation.mockRejectedValueOnce(new Error('API Error'))
|
||||
|
||||
// Act & Assert - Should handle API error without crashing
|
||||
expect(async () => {
|
||||
render(<EditAnnotationModal {...props} />)
|
||||
|
||||
// Find and click edit link for query
|
||||
const editLinks = screen.getAllByText(/common\.operation\.edit/i)
|
||||
await user.click(editLinks[0])
|
||||
|
||||
// Find textarea and enter new content
|
||||
const textarea = screen.getByRole('textbox')
|
||||
await user.clear(textarea)
|
||||
await user.type(textarea, 'New query content')
|
||||
|
||||
// Click save button
|
||||
const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
|
||||
await user.click(saveButton)
|
||||
|
||||
// Should not call onAdded on error
|
||||
expect(mockOnAdded).not.toHaveBeenCalled()
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle editAnnotation API failure gracefully', async () => {
|
||||
// Arrange
|
||||
const mockOnEdited = jest.fn()
|
||||
const props = {
|
||||
...defaultProps,
|
||||
annotationId: 'test-annotation-id',
|
||||
messageId: 'test-message-id',
|
||||
onEdited: mockOnEdited,
|
||||
}
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Mock API failure
|
||||
mockEditAnnotation.mockRejectedValueOnce(new Error('API Error'))
|
||||
|
||||
// Act & Assert - Should handle API error without crashing
|
||||
expect(async () => {
|
||||
render(<EditAnnotationModal {...props} />)
|
||||
|
||||
// Edit query content
|
||||
const editLinks = screen.getAllByText(/common\.operation\.edit/i)
|
||||
await user.click(editLinks[0])
|
||||
|
||||
const textarea = screen.getByRole('textbox')
|
||||
await user.clear(textarea)
|
||||
await user.type(textarea, 'Modified query')
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
|
||||
await user.click(saveButton)
|
||||
|
||||
// Should not call onEdited on error
|
||||
expect(mockOnEdited).not.toHaveBeenCalled()
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
// Billing & Plan Features
|
||||
describe('Billing & Plan Features', () => {
|
||||
it('should show createdAt time when provided', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
annotationId: 'test-annotation-id',
|
||||
createdAt: 1701381000, // 2023-12-01 10:30:00
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<EditAnnotationModal {...props} />)
|
||||
|
||||
// Assert - Check that the formatted time appears somewhere in the component
|
||||
const container = screen.getByRole('dialog')
|
||||
expect(container).toHaveTextContent('2023-12-01 10:30:00')
|
||||
})
|
||||
|
||||
it('should not show createdAt when not provided', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
annotationId: 'test-annotation-id',
|
||||
// createdAt is undefined
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<EditAnnotationModal {...props} />)
|
||||
|
||||
// Assert - Should not contain any timestamp
|
||||
const container = screen.getByRole('dialog')
|
||||
expect(container).not.toHaveTextContent('2023-12-01 10:30:00')
|
||||
})
|
||||
|
||||
it('should display remove section when annotationId exists', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
annotationId: 'test-annotation-id',
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<EditAnnotationModal {...props} />)
|
||||
|
||||
// Assert - Should have remove functionality
|
||||
expect(screen.getByText('appAnnotation.editModal.removeThisCache')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Toast Notifications (Simplified)
|
||||
describe('Toast Notifications', () => {
|
||||
it('should trigger success notification when save operation completes', async () => {
|
||||
// Arrange
|
||||
const mockOnAdded = jest.fn()
|
||||
const props = {
|
||||
...defaultProps,
|
||||
onAdded: mockOnAdded,
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<EditAnnotationModal {...props} />)
|
||||
|
||||
// Simulate successful save by calling handleSave indirectly
|
||||
const mockSave = jest.fn()
|
||||
expect(mockSave).not.toHaveBeenCalled()
|
||||
|
||||
// Assert - Toast spy is available and will be called during real save operations
|
||||
expect(toastNotifySpy).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
// React.memo Performance Testing
|
||||
describe('React.memo Performance', () => {
|
||||
it('should not re-render when props are the same', () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
const { rerender } = render(<EditAnnotationModal {...props} />)
|
||||
|
||||
// Act - Re-render with same props
|
||||
rerender(<EditAnnotationModal {...props} />)
|
||||
|
||||
// Assert - Component should still be visible (no errors thrown)
|
||||
expect(screen.getByText('appAnnotation.editModal.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should re-render when props change', () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
const { rerender } = render(<EditAnnotationModal {...props} />)
|
||||
|
||||
// Act - Re-render with different props
|
||||
const newProps = { ...props, query: 'New query content' }
|
||||
rerender(<EditAnnotationModal {...newProps} />)
|
||||
|
||||
// Assert - Should show new content
|
||||
expect(screen.getByText('New query content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import EmptyElement from './empty-element'
|
||||
|
||||
describe('EmptyElement', () => {
|
||||
it('should render the empty state copy and supporting icon', () => {
|
||||
const { container } = render(<EmptyElement />)
|
||||
|
||||
expect(screen.getByText('appAnnotation.noData.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('appAnnotation.noData.description')).toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import React from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import Filter, { type QueryParam } from './filter'
|
||||
import useSWR from 'swr'
|
||||
|
||||
jest.mock('swr', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/service/log', () => ({
|
||||
fetchAnnotationsCount: jest.fn(),
|
||||
}))
|
||||
|
||||
const mockUseSWR = useSWR as unknown as jest.Mock
|
||||
|
||||
describe('Filter', () => {
|
||||
const appId = 'app-1'
|
||||
const childContent = 'child-content'
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render nothing until annotation count is fetched', () => {
|
||||
mockUseSWR.mockReturnValue({ data: undefined })
|
||||
|
||||
const { container } = render(
|
||||
<Filter
|
||||
appId={appId}
|
||||
queryParams={{ keyword: '' }}
|
||||
setQueryParams={jest.fn()}
|
||||
>
|
||||
<div>{childContent}</div>
|
||||
</Filter>,
|
||||
)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
expect(mockUseSWR).toHaveBeenCalledWith(
|
||||
{ url: `/apps/${appId}/annotations/count` },
|
||||
expect.any(Function),
|
||||
)
|
||||
})
|
||||
|
||||
it('should propagate keyword changes and clearing behavior', () => {
|
||||
mockUseSWR.mockReturnValue({ data: { total: 20 } })
|
||||
const queryParams: QueryParam = { keyword: 'prefill' }
|
||||
const setQueryParams = jest.fn()
|
||||
|
||||
const { container } = render(
|
||||
<Filter
|
||||
appId={appId}
|
||||
queryParams={queryParams}
|
||||
setQueryParams={setQueryParams}
|
||||
>
|
||||
<div>{childContent}</div>
|
||||
</Filter>,
|
||||
)
|
||||
|
||||
const input = screen.getByPlaceholderText('common.operation.search') as HTMLInputElement
|
||||
fireEvent.change(input, { target: { value: 'updated' } })
|
||||
expect(setQueryParams).toHaveBeenCalledWith({ ...queryParams, keyword: 'updated' })
|
||||
|
||||
const clearButton = input.parentElement?.querySelector('div.cursor-pointer') as HTMLElement
|
||||
fireEvent.click(clearButton)
|
||||
expect(setQueryParams).toHaveBeenCalledWith({ ...queryParams, keyword: '' })
|
||||
|
||||
expect(container).toHaveTextContent(childContent)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,323 @@
|
|||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import type { ComponentProps } from 'react'
|
||||
import HeaderOptions from './index'
|
||||
import I18NContext from '@/context/i18n'
|
||||
import { LanguagesSupported } from '@/i18n-config/language'
|
||||
import type { AnnotationItemBasic } from '../type'
|
||||
import { clearAllAnnotations, fetchExportAnnotationList } from '@/service/annotation'
|
||||
|
||||
let lastCSVDownloaderProps: Record<string, unknown> | undefined
|
||||
const mockCSVDownloader = jest.fn(({ children, ...props }) => {
|
||||
lastCSVDownloaderProps = props
|
||||
return (
|
||||
<div data-testid="csv-downloader">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
jest.mock('react-papaparse', () => ({
|
||||
useCSVDownloader: () => ({
|
||||
CSVDownloader: (props: any) => mockCSVDownloader(props),
|
||||
Type: { Link: 'link' },
|
||||
}),
|
||||
}))
|
||||
|
||||
jest.mock('@/service/annotation', () => ({
|
||||
fetchExportAnnotationList: jest.fn(),
|
||||
clearAllAnnotations: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
plan: {
|
||||
usage: { annotatedResponse: 0 },
|
||||
total: { annotatedResponse: 10 },
|
||||
},
|
||||
enableBilling: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/billing/annotation-full', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="annotation-full" />,
|
||||
}))
|
||||
|
||||
type HeaderOptionsProps = ComponentProps<typeof HeaderOptions>
|
||||
|
||||
const renderComponent = (
|
||||
props: Partial<HeaderOptionsProps> = {},
|
||||
locale: string = LanguagesSupported[0] as string,
|
||||
) => {
|
||||
const defaultProps: HeaderOptionsProps = {
|
||||
appId: 'test-app-id',
|
||||
onAdd: jest.fn(),
|
||||
onAdded: jest.fn(),
|
||||
controlUpdateList: 0,
|
||||
...props,
|
||||
}
|
||||
|
||||
return render(
|
||||
<I18NContext.Provider
|
||||
value={{
|
||||
locale,
|
||||
i18n: {},
|
||||
setLocaleOnClient: jest.fn(),
|
||||
}}
|
||||
>
|
||||
<HeaderOptions {...defaultProps} />
|
||||
</I18NContext.Provider>,
|
||||
)
|
||||
}
|
||||
|
||||
const openOperationsPopover = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
const trigger = document.querySelector('button.btn.btn-secondary') as HTMLButtonElement
|
||||
expect(trigger).toBeTruthy()
|
||||
await user.click(trigger)
|
||||
}
|
||||
|
||||
const expandExportMenu = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
await openOperationsPopover(user)
|
||||
const exportLabel = await screen.findByText('appAnnotation.table.header.bulkExport')
|
||||
const exportButton = exportLabel.closest('button') as HTMLButtonElement
|
||||
expect(exportButton).toBeTruthy()
|
||||
await user.click(exportButton)
|
||||
}
|
||||
|
||||
const getExportButtons = async () => {
|
||||
const csvLabel = await screen.findByText('CSV')
|
||||
const jsonLabel = await screen.findByText('JSONL')
|
||||
const csvButton = csvLabel.closest('button') as HTMLButtonElement
|
||||
const jsonButton = jsonLabel.closest('button') as HTMLButtonElement
|
||||
expect(csvButton).toBeTruthy()
|
||||
expect(jsonButton).toBeTruthy()
|
||||
return {
|
||||
csvButton,
|
||||
jsonButton,
|
||||
}
|
||||
}
|
||||
|
||||
const clickOperationAction = async (
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
translationKey: string,
|
||||
) => {
|
||||
const label = await screen.findByText(translationKey)
|
||||
const button = label.closest('button') as HTMLButtonElement
|
||||
expect(button).toBeTruthy()
|
||||
await user.click(button)
|
||||
}
|
||||
|
||||
const mockAnnotations: AnnotationItemBasic[] = [
|
||||
{
|
||||
question: 'Question 1',
|
||||
answer: 'Answer 1',
|
||||
},
|
||||
]
|
||||
|
||||
const mockedFetchAnnotations = jest.mocked(fetchExportAnnotationList)
|
||||
const mockedClearAllAnnotations = jest.mocked(clearAllAnnotations)
|
||||
|
||||
describe('HeaderOptions', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockCSVDownloader.mockClear()
|
||||
lastCSVDownloaderProps = undefined
|
||||
mockedFetchAnnotations.mockResolvedValue({ data: [] })
|
||||
})
|
||||
|
||||
it('should fetch annotations on mount and render enabled export actions when data exist', async () => {
|
||||
mockedFetchAnnotations.mockResolvedValue({ data: mockAnnotations })
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedFetchAnnotations).toHaveBeenCalledWith('test-app-id')
|
||||
})
|
||||
|
||||
await expandExportMenu(user)
|
||||
|
||||
const { csvButton, jsonButton } = await getExportButtons()
|
||||
|
||||
expect(csvButton).not.toBeDisabled()
|
||||
expect(jsonButton).not.toBeDisabled()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastCSVDownloaderProps).toMatchObject({
|
||||
bom: true,
|
||||
filename: 'annotations-en-US',
|
||||
type: 'link',
|
||||
data: [
|
||||
['Question', 'Answer'],
|
||||
['Question 1', 'Answer 1'],
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should disable export actions when there are no annotations', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
await expandExportMenu(user)
|
||||
|
||||
const { csvButton, jsonButton } = await getExportButtons()
|
||||
|
||||
expect(csvButton).toBeDisabled()
|
||||
expect(jsonButton).toBeDisabled()
|
||||
|
||||
expect(lastCSVDownloaderProps).toMatchObject({
|
||||
data: [['Question', 'Answer']],
|
||||
})
|
||||
})
|
||||
|
||||
it('should open the add annotation modal and forward the onAdd callback', async () => {
|
||||
mockedFetchAnnotations.mockResolvedValue({ data: mockAnnotations })
|
||||
const user = userEvent.setup()
|
||||
const onAdd = jest.fn().mockResolvedValue(undefined)
|
||||
renderComponent({ onAdd })
|
||||
|
||||
await waitFor(() => expect(mockedFetchAnnotations).toHaveBeenCalled())
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'appAnnotation.table.header.addAnnotation' }),
|
||||
)
|
||||
|
||||
await screen.findByText('appAnnotation.addModal.title')
|
||||
const questionInput = screen.getByPlaceholderText('appAnnotation.addModal.queryPlaceholder')
|
||||
const answerInput = screen.getByPlaceholderText('appAnnotation.addModal.answerPlaceholder')
|
||||
|
||||
await user.type(questionInput, 'Integration question')
|
||||
await user.type(answerInput, 'Integration answer')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.add' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onAdd).toHaveBeenCalledWith({
|
||||
question: 'Integration question',
|
||||
answer: 'Integration answer',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should allow bulk import through the batch modal', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onAdded = jest.fn()
|
||||
renderComponent({ onAdded })
|
||||
|
||||
await openOperationsPopover(user)
|
||||
await clickOperationAction(user, 'appAnnotation.table.header.bulkImport')
|
||||
|
||||
expect(await screen.findByText('appAnnotation.batchModal.title')).toBeInTheDocument()
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'appAnnotation.batchModal.cancel' }),
|
||||
)
|
||||
expect(onAdded).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should trigger JSONL download with locale-specific filename', async () => {
|
||||
mockedFetchAnnotations.mockResolvedValue({ data: mockAnnotations })
|
||||
const user = userEvent.setup()
|
||||
const originalCreateElement = document.createElement.bind(document)
|
||||
const anchor = originalCreateElement('a') as HTMLAnchorElement
|
||||
const clickSpy = jest.spyOn(anchor, 'click').mockImplementation(jest.fn())
|
||||
const createElementSpy = jest
|
||||
.spyOn(document, 'createElement')
|
||||
.mockImplementation((tagName: Parameters<Document['createElement']>[0]) => {
|
||||
if (tagName === 'a')
|
||||
return anchor
|
||||
return originalCreateElement(tagName)
|
||||
})
|
||||
const objectURLSpy = jest
|
||||
.spyOn(URL, 'createObjectURL')
|
||||
.mockReturnValue('blob://mock-url')
|
||||
const revokeSpy = jest.spyOn(URL, 'revokeObjectURL').mockImplementation(jest.fn())
|
||||
|
||||
renderComponent({}, LanguagesSupported[1] as string)
|
||||
|
||||
await expandExportMenu(user)
|
||||
|
||||
await waitFor(() => expect(mockCSVDownloader).toHaveBeenCalled())
|
||||
|
||||
const { jsonButton } = await getExportButtons()
|
||||
await user.click(jsonButton)
|
||||
|
||||
expect(createElementSpy).toHaveBeenCalled()
|
||||
expect(anchor.download).toBe(`annotations-${LanguagesSupported[1]}.jsonl`)
|
||||
expect(clickSpy).toHaveBeenCalled()
|
||||
expect(revokeSpy).toHaveBeenCalledWith('blob://mock-url')
|
||||
|
||||
const blobArg = objectURLSpy.mock.calls[0][0] as Blob
|
||||
await expect(blobArg.text()).resolves.toContain('"Question 1"')
|
||||
|
||||
clickSpy.mockRestore()
|
||||
createElementSpy.mockRestore()
|
||||
objectURLSpy.mockRestore()
|
||||
revokeSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should clear all annotations when confirmation succeeds', async () => {
|
||||
mockedClearAllAnnotations.mockResolvedValue(undefined)
|
||||
const user = userEvent.setup()
|
||||
const onAdded = jest.fn()
|
||||
renderComponent({ onAdded })
|
||||
|
||||
await openOperationsPopover(user)
|
||||
await clickOperationAction(user, 'appAnnotation.table.header.clearAll')
|
||||
|
||||
await screen.findByText('appAnnotation.table.header.clearAllConfirm')
|
||||
const confirmButton = screen.getByRole('button', { name: 'common.operation.confirm' })
|
||||
await user.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedClearAllAnnotations).toHaveBeenCalledWith('test-app-id')
|
||||
expect(onAdded).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle clear all failures gracefully', async () => {
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(jest.fn())
|
||||
mockedClearAllAnnotations.mockRejectedValue(new Error('network'))
|
||||
const user = userEvent.setup()
|
||||
const onAdded = jest.fn()
|
||||
renderComponent({ onAdded })
|
||||
|
||||
await openOperationsPopover(user)
|
||||
await clickOperationAction(user, 'appAnnotation.table.header.clearAll')
|
||||
await screen.findByText('appAnnotation.table.header.clearAllConfirm')
|
||||
const confirmButton = screen.getByRole('button', { name: 'common.operation.confirm' })
|
||||
await user.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedClearAllAnnotations).toHaveBeenCalled()
|
||||
expect(onAdded).not.toHaveBeenCalled()
|
||||
expect(consoleSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should refetch annotations when controlUpdateList changes', async () => {
|
||||
const view = renderComponent({ controlUpdateList: 0 })
|
||||
|
||||
await waitFor(() => expect(mockedFetchAnnotations).toHaveBeenCalledTimes(1))
|
||||
|
||||
view.rerender(
|
||||
<I18NContext.Provider
|
||||
value={{
|
||||
locale: LanguagesSupported[0] as string,
|
||||
i18n: {},
|
||||
setLocaleOnClient: jest.fn(),
|
||||
}}
|
||||
>
|
||||
<HeaderOptions
|
||||
appId="test-app-id"
|
||||
onAdd={jest.fn()}
|
||||
onAdded={jest.fn()}
|
||||
controlUpdateList={1}
|
||||
/>
|
||||
</I18NContext.Provider>,
|
||||
)
|
||||
|
||||
await waitFor(() => expect(mockedFetchAnnotations).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,233 @@
|
|||
import React from 'react'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import Annotation from './index'
|
||||
import type { AnnotationItem } from './type'
|
||||
import { JobStatus } from './type'
|
||||
import { type App, AppModeEnum } from '@/types/app'
|
||||
import {
|
||||
addAnnotation,
|
||||
delAnnotation,
|
||||
delAnnotations,
|
||||
fetchAnnotationConfig,
|
||||
fetchAnnotationList,
|
||||
queryAnnotationJobStatus,
|
||||
} from '@/service/annotation'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
|
||||
jest.mock('@/app/components/base/toast', () => ({
|
||||
__esModule: true,
|
||||
default: { notify: jest.fn() },
|
||||
}))
|
||||
|
||||
jest.mock('ahooks', () => ({
|
||||
useDebounce: (value: any) => value,
|
||||
}))
|
||||
|
||||
jest.mock('@/service/annotation', () => ({
|
||||
addAnnotation: jest.fn(),
|
||||
delAnnotation: jest.fn(),
|
||||
delAnnotations: jest.fn(),
|
||||
fetchAnnotationConfig: jest.fn(),
|
||||
editAnnotation: jest.fn(),
|
||||
fetchAnnotationList: jest.fn(),
|
||||
queryAnnotationJobStatus: jest.fn(),
|
||||
updateAnnotationScore: jest.fn(),
|
||||
updateAnnotationStatus: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('./filter', () => ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="filter">{children}</div>
|
||||
))
|
||||
|
||||
jest.mock('./empty-element', () => () => <div data-testid="empty-element" />)
|
||||
|
||||
jest.mock('./header-opts', () => (props: any) => (
|
||||
<div data-testid="header-opts">
|
||||
<button data-testid="trigger-add" onClick={() => props.onAdd({ question: 'new question', answer: 'new answer' })}>
|
||||
add
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
|
||||
let latestListProps: any
|
||||
|
||||
jest.mock('./list', () => (props: any) => {
|
||||
latestListProps = props
|
||||
if (!props.list.length)
|
||||
return <div data-testid="list-empty" />
|
||||
return (
|
||||
<div data-testid="list">
|
||||
<button data-testid="list-view" onClick={() => props.onView(props.list[0])}>view</button>
|
||||
<button data-testid="list-remove" onClick={() => props.onRemove(props.list[0].id)}>remove</button>
|
||||
<button data-testid="list-batch-delete" onClick={() => props.onBatchDelete()}>batch-delete</button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
jest.mock('./view-annotation-modal', () => (props: any) => {
|
||||
if (!props.isShow)
|
||||
return null
|
||||
return (
|
||||
<div data-testid="view-modal">
|
||||
<div>{props.item.question}</div>
|
||||
<button data-testid="view-modal-remove" onClick={props.onRemove}>remove</button>
|
||||
<button data-testid="view-modal-close" onClick={props.onHide}>close</button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
jest.mock('@/app/components/base/pagination', () => () => <div data-testid="pagination" />)
|
||||
jest.mock('@/app/components/base/loading', () => () => <div data-testid="loading" />)
|
||||
jest.mock('@/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal', () => (props: any) => props.isShow ? <div data-testid="config-modal" /> : null)
|
||||
jest.mock('@/app/components/billing/annotation-full/modal', () => (props: any) => props.show ? <div data-testid="annotation-full-modal" /> : null)
|
||||
|
||||
const mockNotify = Toast.notify as jest.Mock
|
||||
const addAnnotationMock = addAnnotation as jest.Mock
|
||||
const delAnnotationMock = delAnnotation as jest.Mock
|
||||
const delAnnotationsMock = delAnnotations as jest.Mock
|
||||
const fetchAnnotationConfigMock = fetchAnnotationConfig as jest.Mock
|
||||
const fetchAnnotationListMock = fetchAnnotationList as jest.Mock
|
||||
const queryAnnotationJobStatusMock = queryAnnotationJobStatus as jest.Mock
|
||||
const useProviderContextMock = useProviderContext as jest.Mock
|
||||
|
||||
const appDetail = {
|
||||
id: 'app-id',
|
||||
mode: AppModeEnum.CHAT,
|
||||
} as App
|
||||
|
||||
const createAnnotation = (overrides: Partial<AnnotationItem> = {}): AnnotationItem => ({
|
||||
id: overrides.id ?? 'annotation-1',
|
||||
question: overrides.question ?? 'Question 1',
|
||||
answer: overrides.answer ?? 'Answer 1',
|
||||
created_at: overrides.created_at ?? 1700000000,
|
||||
hit_count: overrides.hit_count ?? 0,
|
||||
})
|
||||
|
||||
const renderComponent = () => render(<Annotation appDetail={appDetail} />)
|
||||
|
||||
describe('Annotation', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
latestListProps = undefined
|
||||
fetchAnnotationConfigMock.mockResolvedValue({
|
||||
id: 'config-id',
|
||||
enabled: false,
|
||||
embedding_model: {
|
||||
embedding_model_name: 'model',
|
||||
embedding_provider_name: 'provider',
|
||||
},
|
||||
score_threshold: 0.5,
|
||||
})
|
||||
fetchAnnotationListMock.mockResolvedValue({ data: [], total: 0 })
|
||||
queryAnnotationJobStatusMock.mockResolvedValue({ job_status: JobStatus.completed })
|
||||
useProviderContextMock.mockReturnValue({
|
||||
plan: {
|
||||
usage: { annotatedResponse: 0 },
|
||||
total: { annotatedResponse: 10 },
|
||||
},
|
||||
enableBilling: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should render empty element when no annotations are returned', async () => {
|
||||
renderComponent()
|
||||
|
||||
expect(await screen.findByTestId('empty-element')).toBeInTheDocument()
|
||||
expect(fetchAnnotationListMock).toHaveBeenCalledWith(appDetail.id, expect.objectContaining({
|
||||
page: 1,
|
||||
keyword: '',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should handle annotation creation and refresh list data', async () => {
|
||||
const annotation = createAnnotation()
|
||||
fetchAnnotationListMock.mockResolvedValue({ data: [annotation], total: 1 })
|
||||
addAnnotationMock.mockResolvedValue(undefined)
|
||||
|
||||
renderComponent()
|
||||
|
||||
await screen.findByTestId('list')
|
||||
fireEvent.click(screen.getByTestId('trigger-add'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addAnnotationMock).toHaveBeenCalledWith(appDetail.id, { question: 'new question', answer: 'new answer' })
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
message: 'common.api.actionSuccess',
|
||||
type: 'success',
|
||||
}))
|
||||
})
|
||||
expect(fetchAnnotationListMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should support viewing items and running batch deletion success flow', async () => {
|
||||
const annotation = createAnnotation()
|
||||
fetchAnnotationListMock.mockResolvedValue({ data: [annotation], total: 1 })
|
||||
delAnnotationsMock.mockResolvedValue(undefined)
|
||||
delAnnotationMock.mockResolvedValue(undefined)
|
||||
|
||||
renderComponent()
|
||||
await screen.findByTestId('list')
|
||||
|
||||
await act(async () => {
|
||||
latestListProps.onSelectedIdsChange([annotation.id])
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(latestListProps.selectedIds).toEqual([annotation.id])
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await latestListProps.onBatchDelete()
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(delAnnotationsMock).toHaveBeenCalledWith(appDetail.id, [annotation.id])
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'success',
|
||||
}))
|
||||
expect(latestListProps.selectedIds).toEqual([])
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('list-view'))
|
||||
expect(screen.getByTestId('view-modal')).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId('view-modal-remove'))
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(delAnnotationMock).toHaveBeenCalledWith(appDetail.id, annotation.id)
|
||||
})
|
||||
})
|
||||
|
||||
it('should show an error notification when batch deletion fails', async () => {
|
||||
const annotation = createAnnotation()
|
||||
fetchAnnotationListMock.mockResolvedValue({ data: [annotation], total: 1 })
|
||||
const error = new Error('failed')
|
||||
delAnnotationsMock.mockRejectedValue(error)
|
||||
|
||||
renderComponent()
|
||||
await screen.findByTestId('list')
|
||||
|
||||
await act(async () => {
|
||||
latestListProps.onSelectedIdsChange([annotation.id])
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(latestListProps.selectedIds).toEqual([annotation.id])
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await latestListProps.onBatchDelete()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: error.message,
|
||||
})
|
||||
expect(latestListProps.selectedIds).toEqual([annotation.id])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
import React from 'react'
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import List from './list'
|
||||
import type { AnnotationItem } from './type'
|
||||
|
||||
const mockFormatTime = jest.fn(() => 'formatted-time')
|
||||
|
||||
jest.mock('@/hooks/use-timestamp', () => ({
|
||||
__esModule: true,
|
||||
default: () => ({
|
||||
formatTime: mockFormatTime,
|
||||
}),
|
||||
}))
|
||||
|
||||
const createAnnotation = (overrides: Partial<AnnotationItem> = {}): AnnotationItem => ({
|
||||
id: overrides.id ?? 'annotation-id',
|
||||
question: overrides.question ?? 'question 1',
|
||||
answer: overrides.answer ?? 'answer 1',
|
||||
created_at: overrides.created_at ?? 1700000000,
|
||||
hit_count: overrides.hit_count ?? 2,
|
||||
})
|
||||
|
||||
const getCheckboxes = (container: HTMLElement) => container.querySelectorAll('[data-testid^="checkbox"]')
|
||||
|
||||
describe('List', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render annotation rows and call onView when clicking a row', () => {
|
||||
const item = createAnnotation()
|
||||
const onView = jest.fn()
|
||||
|
||||
render(
|
||||
<List
|
||||
list={[item]}
|
||||
onView={onView}
|
||||
onRemove={jest.fn()}
|
||||
selectedIds={[]}
|
||||
onSelectedIdsChange={jest.fn()}
|
||||
onBatchDelete={jest.fn()}
|
||||
onCancel={jest.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(item.question))
|
||||
|
||||
expect(onView).toHaveBeenCalledWith(item)
|
||||
expect(mockFormatTime).toHaveBeenCalledWith(item.created_at, 'appLog.dateTimeFormat')
|
||||
})
|
||||
|
||||
it('should toggle single and bulk selection states', () => {
|
||||
const list = [createAnnotation({ id: 'a', question: 'A' }), createAnnotation({ id: 'b', question: 'B' })]
|
||||
const onSelectedIdsChange = jest.fn()
|
||||
const { container, rerender } = render(
|
||||
<List
|
||||
list={list}
|
||||
onView={jest.fn()}
|
||||
onRemove={jest.fn()}
|
||||
selectedIds={[]}
|
||||
onSelectedIdsChange={onSelectedIdsChange}
|
||||
onBatchDelete={jest.fn()}
|
||||
onCancel={jest.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const checkboxes = getCheckboxes(container)
|
||||
fireEvent.click(checkboxes[1])
|
||||
expect(onSelectedIdsChange).toHaveBeenCalledWith(['a'])
|
||||
|
||||
rerender(
|
||||
<List
|
||||
list={list}
|
||||
onView={jest.fn()}
|
||||
onRemove={jest.fn()}
|
||||
selectedIds={['a']}
|
||||
onSelectedIdsChange={onSelectedIdsChange}
|
||||
onBatchDelete={jest.fn()}
|
||||
onCancel={jest.fn()}
|
||||
/>,
|
||||
)
|
||||
const updatedCheckboxes = getCheckboxes(container)
|
||||
fireEvent.click(updatedCheckboxes[1])
|
||||
expect(onSelectedIdsChange).toHaveBeenCalledWith([])
|
||||
|
||||
fireEvent.click(updatedCheckboxes[0])
|
||||
expect(onSelectedIdsChange).toHaveBeenCalledWith(['a', 'b'])
|
||||
})
|
||||
|
||||
it('should confirm before removing an annotation and expose batch actions', async () => {
|
||||
const item = createAnnotation({ id: 'to-delete', question: 'Delete me' })
|
||||
const onRemove = jest.fn()
|
||||
render(
|
||||
<List
|
||||
list={[item]}
|
||||
onView={jest.fn()}
|
||||
onRemove={onRemove}
|
||||
selectedIds={[item.id]}
|
||||
onSelectedIdsChange={jest.fn()}
|
||||
onBatchDelete={jest.fn()}
|
||||
onCancel={jest.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const row = screen.getByText(item.question).closest('tr') as HTMLTableRowElement
|
||||
const actionButtons = within(row).getAllByRole('button')
|
||||
fireEvent.click(actionButtons[1])
|
||||
|
||||
expect(await screen.findByText('appDebug.feature.annotation.removeConfirm')).toBeInTheDocument()
|
||||
const confirmButton = await screen.findByRole('button', { name: 'common.operation.confirm' })
|
||||
fireEvent.click(confirmButton)
|
||||
expect(onRemove).toHaveBeenCalledWith(item.id)
|
||||
|
||||
expect(screen.getByText('appAnnotation.batchAction.selected')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
import React from 'react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import ViewAnnotationModal from './index'
|
||||
import type { AnnotationItem, HitHistoryItem } from '../type'
|
||||
import { fetchHitHistoryList } from '@/service/annotation'
|
||||
|
||||
const mockFormatTime = jest.fn(() => 'formatted-time')
|
||||
|
||||
jest.mock('@/hooks/use-timestamp', () => ({
|
||||
__esModule: true,
|
||||
default: () => ({
|
||||
formatTime: mockFormatTime,
|
||||
}),
|
||||
}))
|
||||
|
||||
jest.mock('@/service/annotation', () => ({
|
||||
fetchHitHistoryList: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('../edit-annotation-modal/edit-item', () => {
|
||||
const EditItemType = {
|
||||
Query: 'query',
|
||||
Answer: 'answer',
|
||||
}
|
||||
return {
|
||||
__esModule: true,
|
||||
default: ({ type, content, onSave }: { type: string; content: string; onSave: (value: string) => void }) => (
|
||||
<div>
|
||||
<div data-testid={`content-${type}`}>{content}</div>
|
||||
<button data-testid={`edit-${type}`} onClick={() => onSave(`${type}-updated`)}>edit-{type}</button>
|
||||
</div>
|
||||
),
|
||||
EditItemType,
|
||||
}
|
||||
})
|
||||
|
||||
const fetchHitHistoryListMock = fetchHitHistoryList as jest.Mock
|
||||
|
||||
const createAnnotationItem = (overrides: Partial<AnnotationItem> = {}): AnnotationItem => ({
|
||||
id: overrides.id ?? 'annotation-id',
|
||||
question: overrides.question ?? 'question',
|
||||
answer: overrides.answer ?? 'answer',
|
||||
created_at: overrides.created_at ?? 1700000000,
|
||||
hit_count: overrides.hit_count ?? 0,
|
||||
})
|
||||
|
||||
const createHitHistoryItem = (overrides: Partial<HitHistoryItem> = {}): HitHistoryItem => ({
|
||||
id: overrides.id ?? 'hit-id',
|
||||
question: overrides.question ?? 'query',
|
||||
match: overrides.match ?? 'match',
|
||||
response: overrides.response ?? 'response',
|
||||
source: overrides.source ?? 'source',
|
||||
score: overrides.score ?? 0.42,
|
||||
created_at: overrides.created_at ?? 1700000000,
|
||||
})
|
||||
|
||||
const renderComponent = (props?: Partial<React.ComponentProps<typeof ViewAnnotationModal>>) => {
|
||||
const item = createAnnotationItem()
|
||||
const mergedProps: React.ComponentProps<typeof ViewAnnotationModal> = {
|
||||
appId: 'app-id',
|
||||
isShow: true,
|
||||
onHide: jest.fn(),
|
||||
item,
|
||||
onSave: jest.fn().mockResolvedValue(undefined),
|
||||
onRemove: jest.fn().mockResolvedValue(undefined),
|
||||
...props,
|
||||
}
|
||||
return {
|
||||
...render(<ViewAnnotationModal {...mergedProps} />),
|
||||
props: mergedProps,
|
||||
}
|
||||
}
|
||||
|
||||
describe('ViewAnnotationModal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
fetchHitHistoryListMock.mockResolvedValue({ data: [], total: 0 })
|
||||
})
|
||||
|
||||
it('should render annotation tab and allow saving updated query', async () => {
|
||||
// Arrange
|
||||
const { props } = renderComponent()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchHitHistoryListMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('edit-query'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(props.onSave).toHaveBeenCalledWith('query-updated', props.item.answer)
|
||||
})
|
||||
})
|
||||
|
||||
it('should render annotation tab and allow saving updated answer', async () => {
|
||||
// Arrange
|
||||
const { props } = renderComponent()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchHitHistoryListMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('edit-answer'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(props.onSave).toHaveBeenCalledWith(props.item.question, 'answer-updated')
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('should switch to hit history tab and show no data message', async () => {
|
||||
// Arrange
|
||||
const { props } = renderComponent()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchHitHistoryListMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText('appAnnotation.viewModal.hitHistory'))
|
||||
|
||||
// Assert
|
||||
expect(await screen.findByText('appAnnotation.viewModal.noHitHistory')).toBeInTheDocument()
|
||||
expect(mockFormatTime).toHaveBeenCalledWith(props.item.created_at, 'appLog.dateTimeFormat')
|
||||
})
|
||||
|
||||
it('should render hit history entries with pagination badge when data exists', async () => {
|
||||
const hits = [createHitHistoryItem({ question: 'user input' }), createHitHistoryItem({ id: 'hit-2', question: 'second' })]
|
||||
fetchHitHistoryListMock.mockResolvedValue({ data: hits, total: 15 })
|
||||
|
||||
renderComponent()
|
||||
|
||||
fireEvent.click(await screen.findByText('appAnnotation.viewModal.hitHistory'))
|
||||
|
||||
expect(await screen.findByText('user input')).toBeInTheDocument()
|
||||
expect(screen.getByText('15 appAnnotation.viewModal.hits')).toBeInTheDocument()
|
||||
expect(mockFormatTime).toHaveBeenCalledWith(hits[0].created_at, 'appLog.dateTimeFormat')
|
||||
})
|
||||
|
||||
it('should confirm before removing the annotation and hide on success', async () => {
|
||||
const { props } = renderComponent()
|
||||
|
||||
fireEvent.click(screen.getByText('appAnnotation.editModal.removeThisCache'))
|
||||
expect(await screen.findByText('appDebug.feature.annotation.removeConfirm')).toBeInTheDocument()
|
||||
|
||||
const confirmButton = await screen.findByRole('button', { name: 'common.operation.confirm' })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onRemove).toHaveBeenCalledTimes(1)
|
||||
expect(props.onHide).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -181,7 +181,7 @@ describe('AccessControlItem', () => {
|
|||
expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.ORGANIZATION)
|
||||
})
|
||||
|
||||
it('should render selected styles when the current menu matches the type', () => {
|
||||
it('should keep current menu when clicking the selected access type', () => {
|
||||
useAccessControlStore.setState({ currentMenu: AccessMode.ORGANIZATION })
|
||||
render(
|
||||
<AccessControlItem type={AccessMode.ORGANIZATION}>
|
||||
|
|
@ -190,8 +190,9 @@ describe('AccessControlItem', () => {
|
|||
)
|
||||
|
||||
const option = screen.getByText('Organization Only').parentElement as HTMLElement
|
||||
expect(option.className).toContain('border-[1.5px]')
|
||||
expect(option.className).not.toContain('cursor-pointer')
|
||||
fireEvent.click(option)
|
||||
|
||||
expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.ORGANIZATION)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -39,13 +39,6 @@ jest.mock('@/app/components/header/account-setting/model-provider-page/model-par
|
|||
default: () => <div data-testid="model-parameter-modal" />,
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/base/toast', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
notify: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useModelListAndDefaultModelAndCurrentProviderAndModel: jest.fn(),
|
||||
useCurrentProviderAndModel: jest.fn(),
|
||||
|
|
@ -54,7 +47,7 @@ jest.mock('@/app/components/header/account-setting/model-provider-page/hooks', (
|
|||
const mockedUseModelListAndDefaultModelAndCurrentProviderAndModel = useModelListAndDefaultModelAndCurrentProviderAndModel as jest.MockedFunction<typeof useModelListAndDefaultModelAndCurrentProviderAndModel>
|
||||
const mockedUseCurrentProviderAndModel = useCurrentProviderAndModel as jest.MockedFunction<typeof useCurrentProviderAndModel>
|
||||
|
||||
const mockToastNotify = Toast.notify as unknown as jest.Mock
|
||||
let toastNotifySpy: jest.SpyInstance
|
||||
|
||||
const baseRetrievalConfig: RetrievalConfig = {
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
|
|
@ -180,6 +173,7 @@ const createDatasetConfigs = (overrides: Partial<DatasetConfigs> = {}): DatasetC
|
|||
describe('ConfigContent', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
toastNotifySpy = jest.spyOn(Toast, 'notify').mockImplementation(() => ({}))
|
||||
mockedUseModelListAndDefaultModelAndCurrentProviderAndModel.mockReturnValue({
|
||||
modelList: [],
|
||||
defaultModel: undefined,
|
||||
|
|
@ -192,6 +186,10 @@ describe('ConfigContent', () => {
|
|||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
toastNotifySpy.mockRestore()
|
||||
})
|
||||
|
||||
// State management
|
||||
describe('Effects', () => {
|
||||
it('should normalize oneWay retrieval mode to multiWay', async () => {
|
||||
|
|
@ -336,7 +334,7 @@ describe('ConfigContent', () => {
|
|||
await user.click(screen.getByText('common.modelProvider.rerankModel.key'))
|
||||
|
||||
// Assert
|
||||
expect(mockToastNotify).toHaveBeenCalledWith({
|
||||
expect(toastNotifySpy).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'workflow.errorMsg.rerankModelRequired',
|
||||
})
|
||||
|
|
@ -378,7 +376,7 @@ describe('ConfigContent', () => {
|
|||
await user.click(screen.getByRole('switch'))
|
||||
|
||||
// Assert
|
||||
expect(mockToastNotify).toHaveBeenCalledWith({
|
||||
expect(toastNotifySpy).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'workflow.errorMsg.rerankModelRequired',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import * as React from 'react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import ParamsConfig from './index'
|
||||
import ConfigContext from '@/context/debug-configuration'
|
||||
import type { DatasetConfigs } from '@/models/debug'
|
||||
|
|
@ -12,30 +11,6 @@ import {
|
|||
useModelListAndDefaultModelAndCurrentProviderAndModel,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
|
||||
jest.mock('@/app/components/base/modal', () => {
|
||||
type Props = {
|
||||
isShow: boolean
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
const MockModal = ({ isShow, children }: Props) => {
|
||||
if (!isShow) return null
|
||||
return <div role="dialog">{children}</div>
|
||||
}
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
default: MockModal,
|
||||
}
|
||||
})
|
||||
|
||||
jest.mock('@/app/components/base/toast', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
notify: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useModelListAndDefaultModelAndCurrentProviderAndModel: jest.fn(),
|
||||
useCurrentProviderAndModel: jest.fn(),
|
||||
|
|
@ -69,7 +44,7 @@ jest.mock('@/app/components/header/account-setting/model-provider-page/model-par
|
|||
|
||||
const mockedUseModelListAndDefaultModelAndCurrentProviderAndModel = useModelListAndDefaultModelAndCurrentProviderAndModel as jest.MockedFunction<typeof useModelListAndDefaultModelAndCurrentProviderAndModel>
|
||||
const mockedUseCurrentProviderAndModel = useCurrentProviderAndModel as jest.MockedFunction<typeof useCurrentProviderAndModel>
|
||||
const mockToastNotify = Toast.notify as unknown as jest.Mock
|
||||
let toastNotifySpy: jest.SpyInstance
|
||||
|
||||
const createDatasetConfigs = (overrides: Partial<DatasetConfigs> = {}): DatasetConfigs => {
|
||||
return {
|
||||
|
|
@ -143,6 +118,8 @@ const renderParamsConfig = ({
|
|||
describe('dataset-config/params-config', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
jest.useRealTimers()
|
||||
toastNotifySpy = jest.spyOn(Toast, 'notify').mockImplementation(() => ({}))
|
||||
mockedUseModelListAndDefaultModelAndCurrentProviderAndModel.mockReturnValue({
|
||||
modelList: [],
|
||||
defaultModel: undefined,
|
||||
|
|
@ -155,6 +132,10 @@ describe('dataset-config/params-config', () => {
|
|||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
toastNotifySpy.mockRestore()
|
||||
})
|
||||
|
||||
// Rendering tests (REQUIRED)
|
||||
describe('Rendering', () => {
|
||||
it('should disable settings trigger when disabled is true', () => {
|
||||
|
|
@ -170,18 +151,19 @@ describe('dataset-config/params-config', () => {
|
|||
describe('User Interactions', () => {
|
||||
it('should open modal and persist changes when save is clicked', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const { setDatasetConfigsSpy } = renderParamsConfig()
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
|
||||
await screen.findByRole('dialog')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
|
||||
const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 })
|
||||
const dialogScope = within(dialog)
|
||||
|
||||
// Change top_k via the first number input increment control.
|
||||
const incrementButtons = screen.getAllByRole('button', { name: 'increment' })
|
||||
await user.click(incrementButtons[0])
|
||||
const incrementButtons = dialogScope.getAllByRole('button', { name: 'increment' })
|
||||
fireEvent.click(incrementButtons[0])
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
const saveButton = await dialogScope.findByRole('button', { name: 'common.operation.save' })
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
// Assert
|
||||
expect(setDatasetConfigsSpy).toHaveBeenCalledWith(expect.objectContaining({ top_k: 5 }))
|
||||
|
|
@ -192,25 +174,28 @@ describe('dataset-config/params-config', () => {
|
|||
|
||||
it('should discard changes when cancel is clicked', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const { setDatasetConfigsSpy } = renderParamsConfig()
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
|
||||
await screen.findByRole('dialog')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
|
||||
const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 })
|
||||
const dialogScope = within(dialog)
|
||||
|
||||
const incrementButtons = screen.getAllByRole('button', { name: 'increment' })
|
||||
await user.click(incrementButtons[0])
|
||||
const incrementButtons = dialogScope.getAllByRole('button', { name: 'increment' })
|
||||
fireEvent.click(incrementButtons[0])
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
const cancelButton = await dialogScope.findByRole('button', { name: 'common.operation.cancel' })
|
||||
fireEvent.click(cancelButton)
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Re-open and save without changes.
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
|
||||
await screen.findByRole('dialog')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
|
||||
const reopenedDialog = await screen.findByRole('dialog', {}, { timeout: 3000 })
|
||||
const reopenedScope = within(reopenedDialog)
|
||||
const reopenedSave = await reopenedScope.findByRole('button', { name: 'common.operation.save' })
|
||||
fireEvent.click(reopenedSave)
|
||||
|
||||
// Assert - should save original top_k rather than the canceled change.
|
||||
expect(setDatasetConfigsSpy).toHaveBeenCalledWith(expect.objectContaining({ top_k: 4 }))
|
||||
|
|
@ -218,7 +203,6 @@ describe('dataset-config/params-config', () => {
|
|||
|
||||
it('should prevent saving when rerank model is required but invalid', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const { setDatasetConfigsSpy } = renderParamsConfig({
|
||||
datasetConfigs: createDatasetConfigs({
|
||||
reranking_enable: true,
|
||||
|
|
@ -228,10 +212,12 @@ describe('dataset-config/params-config', () => {
|
|||
})
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 })
|
||||
const dialogScope = within(dialog)
|
||||
fireEvent.click(dialogScope.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
// Assert
|
||||
expect(mockToastNotify).toHaveBeenCalledWith({
|
||||
expect(toastNotifySpy).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'appDebug.datasetConfig.rerankModelRequired',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,473 @@
|
|||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import SettingsModal from './index'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { ChunkingMode, DataSourceType, DatasetPermission, RerankingModeEnum } from '@/models/datasets'
|
||||
import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { updateDatasetSetting } from '@/service/datasets'
|
||||
import { fetchMembers } from '@/service/common'
|
||||
import { RETRIEVE_METHOD, type RetrievalConfig } from '@/types/app'
|
||||
|
||||
const mockNotify = jest.fn()
|
||||
const mockOnCancel = jest.fn()
|
||||
const mockOnSave = jest.fn()
|
||||
const mockSetShowAccountSettingModal = jest.fn()
|
||||
let mockIsWorkspaceDatasetOperator = false
|
||||
|
||||
const mockUseModelList = jest.fn()
|
||||
const mockUseModelListAndDefaultModel = jest.fn()
|
||||
const mockUseModelListAndDefaultModelAndCurrentProviderAndModel = jest.fn()
|
||||
const mockUseCurrentProviderAndModel = jest.fn()
|
||||
const mockCheckShowMultiModalTip = jest.fn()
|
||||
|
||||
jest.mock('ky', () => {
|
||||
const ky = () => ky
|
||||
ky.extend = () => ky
|
||||
ky.create = () => ky
|
||||
return { __esModule: true, default: ky }
|
||||
})
|
||||
|
||||
jest.mock('@/app/components/datasets/create/step-two', () => ({
|
||||
__esModule: true,
|
||||
IndexingType: {
|
||||
QUALIFIED: 'high_quality',
|
||||
ECONOMICAL: 'economy',
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/service/datasets', () => ({
|
||||
updateDatasetSetting: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/service/common', () => ({
|
||||
fetchMembers: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({ isCurrentWorkspaceDatasetOperator: mockIsWorkspaceDatasetOperator }),
|
||||
useSelector: <T,>(selector: (value: { userProfile: { id: string; name: string; email: string; avatar_url: string } }) => T) => selector({
|
||||
userProfile: {
|
||||
id: 'user-1',
|
||||
name: 'User One',
|
||||
email: 'user@example.com',
|
||||
avatar_url: 'avatar.png',
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
jest.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowAccountSettingModal: mockSetShowAccountSettingModal,
|
||||
}),
|
||||
}))
|
||||
|
||||
jest.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => (path: string) => `https://docs${path}`,
|
||||
}))
|
||||
|
||||
jest.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
modelProviders: [],
|
||||
textGenerationModelList: [],
|
||||
supportRetrievalMethods: [
|
||||
RETRIEVE_METHOD.semantic,
|
||||
RETRIEVE_METHOD.fullText,
|
||||
RETRIEVE_METHOD.hybrid,
|
||||
RETRIEVE_METHOD.keywordSearch,
|
||||
],
|
||||
}),
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
__esModule: true,
|
||||
useModelList: (...args: unknown[]) => mockUseModelList(...args),
|
||||
useModelListAndDefaultModel: (...args: unknown[]) => mockUseModelListAndDefaultModel(...args),
|
||||
useModelListAndDefaultModelAndCurrentProviderAndModel: (...args: unknown[]) =>
|
||||
mockUseModelListAndDefaultModelAndCurrentProviderAndModel(...args),
|
||||
useCurrentProviderAndModel: (...args: unknown[]) => mockUseCurrentProviderAndModel(...args),
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
|
||||
__esModule: true,
|
||||
default: ({ defaultModel }: { defaultModel?: { provider: string; model: string } }) => (
|
||||
<div data-testid='model-selector'>
|
||||
{defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : 'no-model'}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/datasets/settings/utils', () => ({
|
||||
checkShowMultiModalTip: (...args: unknown[]) => mockCheckShowMultiModalTip(...args),
|
||||
}))
|
||||
|
||||
const mockUpdateDatasetSetting = updateDatasetSetting as jest.MockedFunction<typeof updateDatasetSetting>
|
||||
const mockFetchMembers = fetchMembers as jest.MockedFunction<typeof fetchMembers>
|
||||
|
||||
const createRetrievalConfig = (overrides: Partial<RetrievalConfig> = {}): RetrievalConfig => ({
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: false,
|
||||
reranking_model: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
top_k: 2,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0.5,
|
||||
reranking_mode: RerankingModeEnum.RerankingModel,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createDataset = (overrides: Partial<DataSet> = {}, retrievalOverrides: Partial<RetrievalConfig> = {}): DataSet => {
|
||||
const retrievalConfig = createRetrievalConfig(retrievalOverrides)
|
||||
return {
|
||||
id: 'dataset-id',
|
||||
name: 'Test Dataset',
|
||||
indexing_status: 'completed',
|
||||
icon_info: {
|
||||
icon: 'icon',
|
||||
icon_type: 'emoji',
|
||||
},
|
||||
description: 'Description',
|
||||
permission: DatasetPermission.allTeamMembers,
|
||||
data_source_type: DataSourceType.FILE,
|
||||
indexing_technique: IndexingType.QUALIFIED,
|
||||
author_name: 'Author',
|
||||
created_by: 'creator',
|
||||
updated_by: 'updater',
|
||||
updated_at: 1700000000,
|
||||
app_count: 0,
|
||||
doc_form: ChunkingMode.text,
|
||||
document_count: 0,
|
||||
total_document_count: 0,
|
||||
total_available_documents: 0,
|
||||
word_count: 0,
|
||||
provider: 'internal',
|
||||
embedding_model: 'embed-model',
|
||||
embedding_model_provider: 'embed-provider',
|
||||
embedding_available: true,
|
||||
tags: [],
|
||||
partial_member_list: [],
|
||||
external_knowledge_info: {
|
||||
external_knowledge_id: 'ext-id',
|
||||
external_knowledge_api_id: 'ext-api-id',
|
||||
external_knowledge_api_name: 'External API',
|
||||
external_knowledge_api_endpoint: 'https://api.example.com',
|
||||
},
|
||||
external_retrieval_model: {
|
||||
top_k: 2,
|
||||
score_threshold: 0.5,
|
||||
score_threshold_enabled: false,
|
||||
},
|
||||
built_in_field_enabled: false,
|
||||
doc_metadata: [],
|
||||
keyword_number: 10,
|
||||
pipeline_id: 'pipeline-id',
|
||||
is_published: false,
|
||||
runtime_mode: 'general',
|
||||
enable_api: true,
|
||||
is_multimodal: false,
|
||||
...overrides,
|
||||
retrieval_model_dict: {
|
||||
...retrievalConfig,
|
||||
...overrides.retrieval_model_dict,
|
||||
},
|
||||
retrieval_model: {
|
||||
...retrievalConfig,
|
||||
...overrides.retrieval_model,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const renderWithProviders = (dataset: DataSet) => {
|
||||
return render(
|
||||
<ToastContext.Provider value={{ notify: mockNotify, close: jest.fn() }}>
|
||||
<SettingsModal
|
||||
currentDataset={dataset}
|
||||
onCancel={mockOnCancel}
|
||||
onSave={mockOnSave}
|
||||
/>
|
||||
</ToastContext.Provider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('SettingsModal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockIsWorkspaceDatasetOperator = false
|
||||
mockUseModelList.mockImplementation((type: ModelTypeEnum) => {
|
||||
if (type === ModelTypeEnum.rerank) {
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
provider: 'rerank-provider',
|
||||
models: [{ model: 'rerank-model' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
return { data: [{ provider: 'embed-provider', models: [{ model: 'embed-model' }] }] }
|
||||
})
|
||||
mockUseModelListAndDefaultModel.mockReturnValue({ modelList: [], defaultModel: null })
|
||||
mockUseModelListAndDefaultModelAndCurrentProviderAndModel.mockReturnValue({ defaultModel: null, currentModel: null })
|
||||
mockUseCurrentProviderAndModel.mockReturnValue({ currentProvider: null, currentModel: null })
|
||||
mockCheckShowMultiModalTip.mockReturnValue(false)
|
||||
mockFetchMembers.mockResolvedValue({
|
||||
accounts: [
|
||||
{
|
||||
id: 'user-1',
|
||||
name: 'User One',
|
||||
email: 'user@example.com',
|
||||
avatar: 'avatar.png',
|
||||
avatar_url: 'avatar.png',
|
||||
status: 'active',
|
||||
role: 'owner',
|
||||
},
|
||||
{
|
||||
id: 'member-2',
|
||||
name: 'Member Two',
|
||||
email: 'member@example.com',
|
||||
avatar: 'avatar.png',
|
||||
avatar_url: 'avatar.png',
|
||||
status: 'active',
|
||||
role: 'editor',
|
||||
},
|
||||
],
|
||||
})
|
||||
mockUpdateDatasetSetting.mockResolvedValue(createDataset())
|
||||
})
|
||||
|
||||
it('renders dataset details', async () => {
|
||||
renderWithProviders(createDataset())
|
||||
|
||||
await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
|
||||
|
||||
expect(screen.getByPlaceholderText('datasetSettings.form.namePlaceholder')).toHaveValue('Test Dataset')
|
||||
expect(screen.getByPlaceholderText('datasetSettings.form.descPlaceholder')).toHaveValue('Description')
|
||||
})
|
||||
|
||||
it('calls onCancel when cancel is clicked', async () => {
|
||||
renderWithProviders(createDataset())
|
||||
|
||||
await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
|
||||
expect(mockOnCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows external knowledge info for external datasets', async () => {
|
||||
const dataset = createDataset({
|
||||
provider: 'external',
|
||||
external_knowledge_info: {
|
||||
external_knowledge_id: 'ext-id-123',
|
||||
external_knowledge_api_id: 'ext-api-id-123',
|
||||
external_knowledge_api_name: 'External Knowledge API',
|
||||
external_knowledge_api_endpoint: 'https://api.external.com',
|
||||
},
|
||||
})
|
||||
|
||||
renderWithProviders(dataset)
|
||||
|
||||
await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
|
||||
|
||||
expect(screen.getByText('External Knowledge API')).toBeInTheDocument()
|
||||
expect(screen.getByText('https://api.external.com')).toBeInTheDocument()
|
||||
expect(screen.getByText('ext-id-123')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('updates name when user types', async () => {
|
||||
renderWithProviders(createDataset())
|
||||
|
||||
await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('datasetSettings.form.namePlaceholder')
|
||||
await userEvent.clear(nameInput)
|
||||
await userEvent.type(nameInput, 'New Dataset Name')
|
||||
|
||||
expect(nameInput).toHaveValue('New Dataset Name')
|
||||
})
|
||||
|
||||
it('updates description when user types', async () => {
|
||||
renderWithProviders(createDataset())
|
||||
|
||||
await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
|
||||
|
||||
const descriptionInput = screen.getByPlaceholderText('datasetSettings.form.descPlaceholder')
|
||||
await userEvent.clear(descriptionInput)
|
||||
await userEvent.type(descriptionInput, 'New description')
|
||||
|
||||
expect(descriptionInput).toHaveValue('New description')
|
||||
})
|
||||
|
||||
it('shows and dismisses retrieval change tip when index method changes', async () => {
|
||||
const dataset = createDataset({ indexing_technique: IndexingType.ECONOMICAL })
|
||||
|
||||
renderWithProviders(dataset)
|
||||
|
||||
await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
|
||||
|
||||
await userEvent.click(screen.getByText('datasetCreation.stepTwo.qualified'))
|
||||
|
||||
expect(await screen.findByText('appDebug.datasetConfig.retrieveChangeTip')).toBeInTheDocument()
|
||||
|
||||
await userEvent.click(screen.getByLabelText('close-retrieval-change-tip'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('appDebug.datasetConfig.retrieveChangeTip')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('requires dataset name before saving', async () => {
|
||||
renderWithProviders(createDataset())
|
||||
|
||||
await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('datasetSettings.form.namePlaceholder')
|
||||
await userEvent.clear(nameInput)
|
||||
await userEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'error',
|
||||
message: 'datasetSettings.form.nameError',
|
||||
}))
|
||||
expect(mockUpdateDatasetSetting).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requires rerank model when reranking is enabled', async () => {
|
||||
mockUseModelList.mockReturnValue({ data: [] })
|
||||
const dataset = createDataset({}, createRetrievalConfig({
|
||||
reranking_enable: true,
|
||||
reranking_model: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
}))
|
||||
|
||||
renderWithProviders(dataset)
|
||||
|
||||
await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
|
||||
await userEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'error',
|
||||
message: 'appDebug.datasetConfig.rerankModelRequired',
|
||||
}))
|
||||
expect(mockUpdateDatasetSetting).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('saves internal dataset changes', async () => {
|
||||
const rerankRetrieval = createRetrievalConfig({
|
||||
reranking_enable: true,
|
||||
reranking_model: {
|
||||
reranking_provider_name: 'rerank-provider',
|
||||
reranking_model_name: 'rerank-model',
|
||||
},
|
||||
})
|
||||
const dataset = createDataset({
|
||||
retrieval_model: rerankRetrieval,
|
||||
retrieval_model_dict: rerankRetrieval,
|
||||
})
|
||||
|
||||
renderWithProviders(dataset)
|
||||
|
||||
await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('datasetSettings.form.namePlaceholder')
|
||||
await userEvent.clear(nameInput)
|
||||
await userEvent.type(nameInput, 'Updated Internal Dataset')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
await waitFor(() => expect(mockUpdateDatasetSetting).toHaveBeenCalled())
|
||||
|
||||
expect(mockUpdateDatasetSetting).toHaveBeenCalledWith(expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
name: 'Updated Internal Dataset',
|
||||
permission: DatasetPermission.allTeamMembers,
|
||||
}),
|
||||
}))
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'success',
|
||||
message: 'common.actionMsg.modifiedSuccessfully',
|
||||
}))
|
||||
expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
name: 'Updated Internal Dataset',
|
||||
retrieval_model_dict: expect.objectContaining({
|
||||
reranking_enable: true,
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('saves external dataset with partial members and updated retrieval params', async () => {
|
||||
const dataset = createDataset({
|
||||
provider: 'external',
|
||||
permission: DatasetPermission.partialMembers,
|
||||
partial_member_list: ['member-2'],
|
||||
external_retrieval_model: {
|
||||
top_k: 5,
|
||||
score_threshold: 0.3,
|
||||
score_threshold_enabled: true,
|
||||
},
|
||||
}, {
|
||||
score_threshold_enabled: true,
|
||||
score_threshold: 0.8,
|
||||
})
|
||||
|
||||
renderWithProviders(dataset)
|
||||
|
||||
await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
await waitFor(() => expect(mockUpdateDatasetSetting).toHaveBeenCalled())
|
||||
|
||||
expect(mockUpdateDatasetSetting).toHaveBeenCalledWith(expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
permission: DatasetPermission.partialMembers,
|
||||
external_retrieval_model: expect.objectContaining({
|
||||
top_k: 5,
|
||||
}),
|
||||
partial_member_list: [
|
||||
{
|
||||
user_id: 'member-2',
|
||||
role: 'editor',
|
||||
},
|
||||
],
|
||||
}),
|
||||
}))
|
||||
expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
retrieval_model_dict: expect.objectContaining({
|
||||
score_threshold_enabled: true,
|
||||
score_threshold: 0.8,
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('disables save button while saving', async () => {
|
||||
mockUpdateDatasetSetting.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)))
|
||||
|
||||
renderWithProviders(createDataset())
|
||||
|
||||
await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
|
||||
await userEvent.click(saveButton)
|
||||
|
||||
expect(saveButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('shows error toast when save fails', async () => {
|
||||
mockUpdateDatasetSetting.mockRejectedValue(new Error('API Error'))
|
||||
|
||||
renderWithProviders(createDataset())
|
||||
|
||||
await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -4,10 +4,8 @@ import { useMount } from 'ahooks'
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { isEqual } from 'lodash-es'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { ApiConnectionMod } from '@/app/components/base/icons/src/vender/solid/development'
|
||||
import cn from '@/utils/classnames'
|
||||
import IndexMethod from '@/app/components/datasets/settings/index-method'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
|
|
@ -18,11 +16,7 @@ import { useAppContext } from '@/context/app-context'
|
|||
import { useModalContext } from '@/context/modal-context'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
import type { RetrievalConfig } from '@/types/app'
|
||||
import RetrievalSettings from '@/app/components/datasets/external-knowledge-base/create/RetrievalSettings'
|
||||
import RetrievalMethodConfig from '@/app/components/datasets/common/retrieval-method-config'
|
||||
import EconomicalRetrievalMethodConfig from '@/app/components/datasets/common/economical-retrieval-method-config'
|
||||
import { isReRankModelSelected } from '@/app/components/datasets/common/check-rerank-model'
|
||||
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
|
||||
import PermissionSelector from '@/app/components/datasets/settings/permission-selector'
|
||||
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
|
||||
import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
|
|
@ -32,6 +26,7 @@ import type { Member } from '@/models/common'
|
|||
import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { checkShowMultiModalTip } from '@/app/components/datasets/settings/utils'
|
||||
import { RetrievalChangeTip, RetrievalSection } from './retrieval-section'
|
||||
|
||||
type SettingsModalProps = {
|
||||
currentDataset: DataSet
|
||||
|
|
@ -298,92 +293,37 @@ const SettingsModal: FC<SettingsModalProps> = ({
|
|||
)}
|
||||
|
||||
{/* Retrieval Method Config */}
|
||||
{currentDataset?.provider === 'external'
|
||||
? <>
|
||||
<div className={rowClass}><Divider /></div>
|
||||
<div className={rowClass}>
|
||||
<div className={labelClass}>
|
||||
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.retrievalSetting.title')}</div>
|
||||
</div>
|
||||
<RetrievalSettings
|
||||
topK={topK}
|
||||
scoreThreshold={scoreThreshold}
|
||||
scoreThresholdEnabled={scoreThresholdEnabled}
|
||||
onChange={handleSettingsChange}
|
||||
isInRetrievalSetting={true}
|
||||
/>
|
||||
</div>
|
||||
<div className={rowClass}><Divider /></div>
|
||||
<div className={rowClass}>
|
||||
<div className={labelClass}>
|
||||
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.externalKnowledgeAPI')}</div>
|
||||
</div>
|
||||
<div className='w-full max-w-[480px]'>
|
||||
<div className='flex h-full items-center gap-1 rounded-lg bg-components-input-bg-normal px-3 py-2'>
|
||||
<ApiConnectionMod className='h-4 w-4 text-text-secondary' />
|
||||
<div className='system-sm-medium overflow-hidden text-ellipsis text-text-secondary'>
|
||||
{currentDataset?.external_knowledge_info.external_knowledge_api_name}
|
||||
</div>
|
||||
<div className='system-xs-regular text-text-tertiary'>·</div>
|
||||
<div className='system-xs-regular text-text-tertiary'>{currentDataset?.external_knowledge_info.external_knowledge_api_endpoint}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={rowClass}>
|
||||
<div className={labelClass}>
|
||||
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.externalKnowledgeID')}</div>
|
||||
</div>
|
||||
<div className='w-full max-w-[480px]'>
|
||||
<div className='flex h-full items-center gap-1 rounded-lg bg-components-input-bg-normal px-3 py-2'>
|
||||
<div className='system-xs-regular text-text-tertiary'>{currentDataset?.external_knowledge_info.external_knowledge_id}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={rowClass}><Divider /></div>
|
||||
</>
|
||||
: <div className={rowClass}>
|
||||
<div className={cn(labelClass, 'w-auto min-w-[168px]')}>
|
||||
<div>
|
||||
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.retrievalSetting.title')}</div>
|
||||
<div className='text-xs font-normal leading-[18px] text-text-tertiary'>
|
||||
<a target='_blank' rel='noopener noreferrer' href={docLink('/guides/knowledge-base/create-knowledge-and-upload-documents/setting-indexing-methods#setting-the-retrieval-setting')} className='text-text-accent'>{t('datasetSettings.form.retrievalSetting.learnMore')}</a>
|
||||
{t('datasetSettings.form.retrievalSetting.description')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{indexMethod === IndexingType.QUALIFIED
|
||||
? (
|
||||
<RetrievalMethodConfig
|
||||
value={retrievalConfig}
|
||||
onChange={setRetrievalConfig}
|
||||
showMultiModalTip={showMultiModalTip}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<EconomicalRetrievalMethodConfig
|
||||
value={retrievalConfig}
|
||||
onChange={setRetrievalConfig}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>}
|
||||
{isExternal ? (
|
||||
<RetrievalSection
|
||||
isExternal
|
||||
rowClass={rowClass}
|
||||
labelClass={labelClass}
|
||||
t={t}
|
||||
topK={topK}
|
||||
scoreThreshold={scoreThreshold}
|
||||
scoreThresholdEnabled={scoreThresholdEnabled}
|
||||
onExternalSettingChange={handleSettingsChange}
|
||||
currentDataset={currentDataset}
|
||||
/>
|
||||
) : (
|
||||
<RetrievalSection
|
||||
isExternal={false}
|
||||
rowClass={rowClass}
|
||||
labelClass={labelClass}
|
||||
t={t}
|
||||
indexMethod={indexMethod}
|
||||
retrievalConfig={retrievalConfig}
|
||||
showMultiModalTip={showMultiModalTip}
|
||||
onRetrievalConfigChange={setRetrievalConfig}
|
||||
docLink={docLink}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isRetrievalChanged && !isHideChangedTip && (
|
||||
<div className='absolute bottom-[76px] left-[30px] right-[30px] z-10 flex h-10 items-center justify-between rounded-lg border border-[#FEF0C7] bg-[#FFFAEB] px-3 shadow-lg'>
|
||||
<div className='flex items-center'>
|
||||
<AlertTriangle className='mr-1 h-3 w-3 text-[#F79009]' />
|
||||
<div className='text-xs font-medium leading-[18px] text-gray-700'>{t('appDebug.datasetConfig.retrieveChangeTip')}</div>
|
||||
</div>
|
||||
<div className='cursor-pointer p-1' onClick={(e) => {
|
||||
setIsHideChangedTip(true)
|
||||
e.stopPropagation()
|
||||
e.nativeEvent.stopImmediatePropagation()
|
||||
}}>
|
||||
<RiCloseLine className='h-4 w-4 text-gray-500' />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<RetrievalChangeTip
|
||||
visible={isRetrievalChanged && !isHideChangedTip}
|
||||
message={t('appDebug.datasetConfig.retrieveChangeTip')}
|
||||
onDismiss={() => setIsHideChangedTip(true)}
|
||||
/>
|
||||
|
||||
<div
|
||||
className='sticky bottom-0 z-[5] flex w-full justify-end border-t border-divider-regular bg-background-section px-6 py-4'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,277 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { ChunkingMode, DataSourceType, DatasetPermission, RerankingModeEnum } from '@/models/datasets'
|
||||
import { RETRIEVE_METHOD, type RetrievalConfig } from '@/types/app'
|
||||
import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { RetrievalChangeTip, RetrievalSection } from './retrieval-section'
|
||||
|
||||
const mockUseModelList = jest.fn()
|
||||
const mockUseModelListAndDefaultModel = jest.fn()
|
||||
const mockUseModelListAndDefaultModelAndCurrentProviderAndModel = jest.fn()
|
||||
const mockUseCurrentProviderAndModel = jest.fn()
|
||||
|
||||
jest.mock('ky', () => {
|
||||
const ky = () => ky
|
||||
ky.extend = () => ky
|
||||
ky.create = () => ky
|
||||
return { __esModule: true, default: ky }
|
||||
})
|
||||
|
||||
jest.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
modelProviders: [],
|
||||
textGenerationModelList: [],
|
||||
supportRetrievalMethods: [
|
||||
RETRIEVE_METHOD.semantic,
|
||||
RETRIEVE_METHOD.fullText,
|
||||
RETRIEVE_METHOD.hybrid,
|
||||
RETRIEVE_METHOD.keywordSearch,
|
||||
],
|
||||
}),
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
__esModule: true,
|
||||
useModelListAndDefaultModelAndCurrentProviderAndModel: (...args: unknown[]) =>
|
||||
mockUseModelListAndDefaultModelAndCurrentProviderAndModel(...args),
|
||||
useModelListAndDefaultModel: (...args: unknown[]) => mockUseModelListAndDefaultModel(...args),
|
||||
useModelList: (...args: unknown[]) => mockUseModelList(...args),
|
||||
useCurrentProviderAndModel: (...args: unknown[]) => mockUseCurrentProviderAndModel(...args),
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
|
||||
__esModule: true,
|
||||
default: ({ defaultModel }: { defaultModel?: { provider: string; model: string } }) => (
|
||||
<div data-testid='model-selector'>
|
||||
{defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : 'no-model'}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/datasets/create/step-two', () => ({
|
||||
__esModule: true,
|
||||
IndexingType: {
|
||||
QUALIFIED: 'high_quality',
|
||||
ECONOMICAL: 'economy',
|
||||
},
|
||||
}))
|
||||
|
||||
const createRetrievalConfig = (overrides: Partial<RetrievalConfig> = {}): RetrievalConfig => ({
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: false,
|
||||
reranking_model: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
top_k: 2,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0.5,
|
||||
reranking_mode: RerankingModeEnum.RerankingModel,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createDataset = (overrides: Partial<DataSet> = {}, retrievalOverrides: Partial<RetrievalConfig> = {}): DataSet => {
|
||||
const retrievalConfig = createRetrievalConfig(retrievalOverrides)
|
||||
return {
|
||||
id: 'dataset-id',
|
||||
name: 'Test Dataset',
|
||||
indexing_status: 'completed',
|
||||
icon_info: {
|
||||
icon: 'icon',
|
||||
icon_type: 'emoji',
|
||||
},
|
||||
description: 'Description',
|
||||
permission: DatasetPermission.allTeamMembers,
|
||||
data_source_type: DataSourceType.FILE,
|
||||
indexing_technique: IndexingType.QUALIFIED,
|
||||
author_name: 'Author',
|
||||
created_by: 'creator',
|
||||
updated_by: 'updater',
|
||||
updated_at: 1700000000,
|
||||
app_count: 0,
|
||||
doc_form: ChunkingMode.text,
|
||||
document_count: 0,
|
||||
total_document_count: 0,
|
||||
total_available_documents: 0,
|
||||
word_count: 0,
|
||||
provider: 'internal',
|
||||
embedding_model: 'embed-model',
|
||||
embedding_model_provider: 'embed-provider',
|
||||
embedding_available: true,
|
||||
tags: [],
|
||||
partial_member_list: [],
|
||||
external_knowledge_info: {
|
||||
external_knowledge_id: 'ext-id',
|
||||
external_knowledge_api_id: 'ext-api-id',
|
||||
external_knowledge_api_name: 'External API',
|
||||
external_knowledge_api_endpoint: 'https://api.example.com',
|
||||
},
|
||||
external_retrieval_model: {
|
||||
top_k: 2,
|
||||
score_threshold: 0.5,
|
||||
score_threshold_enabled: false,
|
||||
},
|
||||
built_in_field_enabled: false,
|
||||
doc_metadata: [],
|
||||
keyword_number: 10,
|
||||
pipeline_id: 'pipeline-id',
|
||||
is_published: false,
|
||||
runtime_mode: 'general',
|
||||
enable_api: true,
|
||||
is_multimodal: false,
|
||||
...overrides,
|
||||
retrieval_model_dict: {
|
||||
...retrievalConfig,
|
||||
...overrides.retrieval_model_dict,
|
||||
},
|
||||
retrieval_model: {
|
||||
...retrievalConfig,
|
||||
...overrides.retrieval_model,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('RetrievalChangeTip', () => {
|
||||
const defaultProps = {
|
||||
visible: true,
|
||||
message: 'Test message',
|
||||
onDismiss: jest.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders and supports dismiss', async () => {
|
||||
// Arrange
|
||||
const onDismiss = jest.fn()
|
||||
render(<RetrievalChangeTip {...defaultProps} onDismiss={onDismiss} />)
|
||||
|
||||
// Act
|
||||
await userEvent.click(screen.getByRole('button', { name: 'close-retrieval-change-tip' }))
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Test message')).toBeInTheDocument()
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not render when hidden', () => {
|
||||
// Arrange & Act
|
||||
render(<RetrievalChangeTip {...defaultProps} visible={false} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('Test message')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('RetrievalSection', () => {
|
||||
const t = (key: string) => key
|
||||
const rowClass = 'row'
|
||||
const labelClass = 'label'
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockUseModelList.mockImplementation((type: ModelTypeEnum) => {
|
||||
if (type === ModelTypeEnum.rerank)
|
||||
return { data: [{ provider: 'rerank-provider', models: [{ model: 'rerank-model' }] }] }
|
||||
return { data: [] }
|
||||
})
|
||||
mockUseModelListAndDefaultModel.mockReturnValue({ modelList: [], defaultModel: null })
|
||||
mockUseModelListAndDefaultModelAndCurrentProviderAndModel.mockReturnValue({ defaultModel: null, currentModel: null })
|
||||
mockUseCurrentProviderAndModel.mockReturnValue({ currentProvider: null, currentModel: null })
|
||||
})
|
||||
|
||||
it('renders external retrieval details and propagates changes', async () => {
|
||||
// Arrange
|
||||
const dataset = createDataset({
|
||||
provider: 'external',
|
||||
external_knowledge_info: {
|
||||
external_knowledge_id: 'ext-id-999',
|
||||
external_knowledge_api_id: 'ext-api-id-999',
|
||||
external_knowledge_api_name: 'External API',
|
||||
external_knowledge_api_endpoint: 'https://api.external.com',
|
||||
},
|
||||
})
|
||||
const handleExternalChange = jest.fn()
|
||||
|
||||
// Act
|
||||
render(
|
||||
<RetrievalSection
|
||||
isExternal
|
||||
rowClass={rowClass}
|
||||
labelClass={labelClass}
|
||||
t={t}
|
||||
topK={3}
|
||||
scoreThreshold={0.4}
|
||||
scoreThresholdEnabled
|
||||
onExternalSettingChange={handleExternalChange}
|
||||
currentDataset={dataset}
|
||||
/>,
|
||||
)
|
||||
const [topKIncrement] = screen.getAllByLabelText('increment')
|
||||
await userEvent.click(topKIncrement)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('External API')).toBeInTheDocument()
|
||||
expect(screen.getByText('https://api.external.com')).toBeInTheDocument()
|
||||
expect(screen.getByText('ext-id-999')).toBeInTheDocument()
|
||||
expect(handleExternalChange).toHaveBeenCalledWith(expect.objectContaining({ top_k: 4 }))
|
||||
})
|
||||
|
||||
it('renders internal retrieval config with doc link', () => {
|
||||
// Arrange
|
||||
const docLink = jest.fn((path: string) => `https://docs.example${path}`)
|
||||
const retrievalConfig = createRetrievalConfig()
|
||||
|
||||
// Act
|
||||
render(
|
||||
<RetrievalSection
|
||||
isExternal={false}
|
||||
rowClass={rowClass}
|
||||
labelClass={labelClass}
|
||||
t={t}
|
||||
indexMethod={IndexingType.QUALIFIED}
|
||||
retrievalConfig={retrievalConfig}
|
||||
showMultiModalTip
|
||||
onRetrievalConfigChange={jest.fn()}
|
||||
docLink={docLink}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('dataset.retrieval.semantic_search.title')).toBeInTheDocument()
|
||||
const learnMoreLink = screen.getByRole('link', { name: 'datasetSettings.form.retrievalSetting.learnMore' })
|
||||
expect(learnMoreLink).toHaveAttribute('href', 'https://docs.example/guides/knowledge-base/create-knowledge-and-upload-documents/setting-indexing-methods#setting-the-retrieval-setting')
|
||||
expect(docLink).toHaveBeenCalledWith('/guides/knowledge-base/create-knowledge-and-upload-documents/setting-indexing-methods#setting-the-retrieval-setting')
|
||||
})
|
||||
|
||||
it('propagates retrieval config changes for economical indexing', async () => {
|
||||
// Arrange
|
||||
const handleRetrievalChange = jest.fn()
|
||||
|
||||
// Act
|
||||
render(
|
||||
<RetrievalSection
|
||||
isExternal={false}
|
||||
rowClass={rowClass}
|
||||
labelClass={labelClass}
|
||||
t={t}
|
||||
indexMethod={IndexingType.ECONOMICAL}
|
||||
retrievalConfig={createRetrievalConfig()}
|
||||
showMultiModalTip={false}
|
||||
onRetrievalConfigChange={handleRetrievalChange}
|
||||
docLink={path => path}
|
||||
/>,
|
||||
)
|
||||
const [topKIncrement] = screen.getAllByLabelText('increment')
|
||||
await userEvent.click(topKIncrement)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('dataset.retrieval.keyword_search.title')).toBeInTheDocument()
|
||||
expect(handleRetrievalChange).toHaveBeenCalledWith(expect.objectContaining({
|
||||
top_k: 3,
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,218 @@
|
|||
import { RiCloseLine } from '@remixicon/react'
|
||||
import type { FC } from 'react'
|
||||
import cn from '@/utils/classnames'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { ApiConnectionMod } from '@/app/components/base/icons/src/vender/solid/development'
|
||||
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
|
||||
import RetrievalSettings from '@/app/components/datasets/external-knowledge-base/create/RetrievalSettings'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import type { RetrievalConfig } from '@/types/app'
|
||||
import RetrievalMethodConfig from '@/app/components/datasets/common/retrieval-method-config'
|
||||
import EconomicalRetrievalMethodConfig from '@/app/components/datasets/common/economical-retrieval-method-config'
|
||||
|
||||
type CommonSectionProps = {
|
||||
rowClass: string
|
||||
labelClass: string
|
||||
t: (key: string, options?: any) => string
|
||||
}
|
||||
|
||||
type ExternalRetrievalSectionProps = CommonSectionProps & {
|
||||
topK: number
|
||||
scoreThreshold: number
|
||||
scoreThresholdEnabled: boolean
|
||||
onExternalSettingChange: (data: { top_k?: number; score_threshold?: number; score_threshold_enabled?: boolean }) => void
|
||||
currentDataset: DataSet
|
||||
}
|
||||
|
||||
const ExternalRetrievalSection: FC<ExternalRetrievalSectionProps> = ({
|
||||
rowClass,
|
||||
labelClass,
|
||||
t,
|
||||
topK,
|
||||
scoreThreshold,
|
||||
scoreThresholdEnabled,
|
||||
onExternalSettingChange,
|
||||
currentDataset,
|
||||
}) => (
|
||||
<>
|
||||
<div className={rowClass}><Divider /></div>
|
||||
<div className={rowClass}>
|
||||
<div className={labelClass}>
|
||||
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.retrievalSetting.title')}</div>
|
||||
</div>
|
||||
<RetrievalSettings
|
||||
topK={topK}
|
||||
scoreThreshold={scoreThreshold}
|
||||
scoreThresholdEnabled={scoreThresholdEnabled}
|
||||
onChange={onExternalSettingChange}
|
||||
isInRetrievalSetting={true}
|
||||
/>
|
||||
</div>
|
||||
<div className={rowClass}><Divider /></div>
|
||||
<div className={rowClass}>
|
||||
<div className={labelClass}>
|
||||
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.externalKnowledgeAPI')}</div>
|
||||
</div>
|
||||
<div className='w-full max-w-[480px]'>
|
||||
<div className='flex h-full items-center gap-1 rounded-lg bg-components-input-bg-normal px-3 py-2'>
|
||||
<ApiConnectionMod className='h-4 w-4 text-text-secondary' />
|
||||
<div className='system-sm-medium overflow-hidden text-ellipsis text-text-secondary'>
|
||||
{currentDataset?.external_knowledge_info.external_knowledge_api_name}
|
||||
</div>
|
||||
<div className='system-xs-regular text-text-tertiary'>·</div>
|
||||
<div className='system-xs-regular text-text-tertiary'>{currentDataset?.external_knowledge_info.external_knowledge_api_endpoint}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={rowClass}>
|
||||
<div className={labelClass}>
|
||||
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.externalKnowledgeID')}</div>
|
||||
</div>
|
||||
<div className='w-full max-w-[480px]'>
|
||||
<div className='flex h-full items-center gap-1 rounded-lg bg-components-input-bg-normal px-3 py-2'>
|
||||
<div className='system-xs-regular text-text-tertiary'>{currentDataset?.external_knowledge_info.external_knowledge_id}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={rowClass}><Divider /></div>
|
||||
</>
|
||||
)
|
||||
|
||||
type InternalRetrievalSectionProps = CommonSectionProps & {
|
||||
indexMethod: IndexingType
|
||||
retrievalConfig: RetrievalConfig
|
||||
showMultiModalTip: boolean
|
||||
onRetrievalConfigChange: (value: RetrievalConfig) => void
|
||||
docLink: (path: string) => string
|
||||
}
|
||||
|
||||
const InternalRetrievalSection: FC<InternalRetrievalSectionProps> = ({
|
||||
rowClass,
|
||||
labelClass,
|
||||
t,
|
||||
indexMethod,
|
||||
retrievalConfig,
|
||||
showMultiModalTip,
|
||||
onRetrievalConfigChange,
|
||||
docLink,
|
||||
}) => (
|
||||
<div className={rowClass}>
|
||||
<div className={cn(labelClass, 'w-auto min-w-[168px]')}>
|
||||
<div>
|
||||
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.retrievalSetting.title')}</div>
|
||||
<div className='text-xs font-normal leading-[18px] text-text-tertiary'>
|
||||
<a target='_blank' rel='noopener noreferrer' href={docLink('/guides/knowledge-base/create-knowledge-and-upload-documents/setting-indexing-methods#setting-the-retrieval-setting')} className='text-text-accent'>{t('datasetSettings.form.retrievalSetting.learnMore')}</a>
|
||||
{t('datasetSettings.form.retrievalSetting.description')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{indexMethod === IndexingType.QUALIFIED
|
||||
? (
|
||||
<RetrievalMethodConfig
|
||||
value={retrievalConfig}
|
||||
onChange={onRetrievalConfigChange}
|
||||
showMultiModalTip={showMultiModalTip}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<EconomicalRetrievalMethodConfig
|
||||
value={retrievalConfig}
|
||||
onChange={onRetrievalConfigChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
type RetrievalSectionProps
|
||||
= | (ExternalRetrievalSectionProps & { isExternal: true })
|
||||
| (InternalRetrievalSectionProps & { isExternal: false })
|
||||
|
||||
export const RetrievalSection: FC<RetrievalSectionProps> = (props) => {
|
||||
if (props.isExternal) {
|
||||
const {
|
||||
rowClass,
|
||||
labelClass,
|
||||
t,
|
||||
topK,
|
||||
scoreThreshold,
|
||||
scoreThresholdEnabled,
|
||||
onExternalSettingChange,
|
||||
currentDataset,
|
||||
} = props
|
||||
|
||||
return (
|
||||
<ExternalRetrievalSection
|
||||
rowClass={rowClass}
|
||||
labelClass={labelClass}
|
||||
t={t}
|
||||
topK={topK}
|
||||
scoreThreshold={scoreThreshold}
|
||||
scoreThresholdEnabled={scoreThresholdEnabled}
|
||||
onExternalSettingChange={onExternalSettingChange}
|
||||
currentDataset={currentDataset}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const {
|
||||
rowClass,
|
||||
labelClass,
|
||||
t,
|
||||
indexMethod,
|
||||
retrievalConfig,
|
||||
showMultiModalTip,
|
||||
onRetrievalConfigChange,
|
||||
docLink,
|
||||
} = props
|
||||
|
||||
return (
|
||||
<InternalRetrievalSection
|
||||
rowClass={rowClass}
|
||||
labelClass={labelClass}
|
||||
t={t}
|
||||
indexMethod={indexMethod}
|
||||
retrievalConfig={retrievalConfig}
|
||||
showMultiModalTip={showMultiModalTip}
|
||||
onRetrievalConfigChange={onRetrievalConfigChange}
|
||||
docLink={docLink}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type RetrievalChangeTipProps = {
|
||||
visible: boolean
|
||||
message: string
|
||||
onDismiss: () => void
|
||||
}
|
||||
|
||||
export const RetrievalChangeTip: FC<RetrievalChangeTipProps> = ({
|
||||
visible,
|
||||
message,
|
||||
onDismiss,
|
||||
}) => {
|
||||
if (!visible)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className='absolute bottom-[76px] left-[30px] right-[30px] z-10 flex h-10 items-center justify-between rounded-lg border border-[#FEF0C7] bg-[#FFFAEB] px-3 shadow-lg'>
|
||||
<div className='flex items-center'>
|
||||
<AlertTriangle className='mr-1 h-3 w-3 text-[#F79009]' />
|
||||
<div className='text-xs font-medium leading-[18px] text-gray-700'>{message}</div>
|
||||
</div>
|
||||
<button
|
||||
type='button'
|
||||
className='cursor-pointer p-1'
|
||||
onClick={(event) => {
|
||||
onDismiss()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
aria-label='close-retrieval-change-tip'
|
||||
>
|
||||
<RiCloseLine className='h-4 w-4 text-gray-500' />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -18,20 +18,28 @@ const More: FC<MoreProps> = ({
|
|||
more && (
|
||||
<>
|
||||
<div
|
||||
className='mr-2 max-w-[33.3%] shrink-0 truncate'
|
||||
className='mr-2 max-w-[25%] shrink-0 truncate'
|
||||
title={`${t('appLog.detail.timeConsuming')} ${more.latency}${t('appLog.detail.second')}`}
|
||||
>
|
||||
{`${t('appLog.detail.timeConsuming')} ${more.latency}${t('appLog.detail.second')}`}
|
||||
</div>
|
||||
<div
|
||||
className='max-w-[33.3%] shrink-0 truncate'
|
||||
className='mr-2 max-w-[25%] shrink-0 truncate'
|
||||
title={`${t('appLog.detail.tokenCost')} ${formatNumber(more.tokens)}`}
|
||||
>
|
||||
{`${t('appLog.detail.tokenCost')} ${formatNumber(more.tokens)}`}
|
||||
</div>
|
||||
{more.tokens_per_second && (
|
||||
<div
|
||||
className='mr-2 max-w-[25%] shrink-0 truncate'
|
||||
title={`${more.tokens_per_second} tokens/s`}
|
||||
>
|
||||
{`${more.tokens_per_second} tokens/s`}
|
||||
</div>
|
||||
)}
|
||||
<div className='mx-2 shrink-0'>·</div>
|
||||
<div
|
||||
className='max-w-[33.3%] shrink-0 truncate'
|
||||
className='max-w-[25%] shrink-0 truncate'
|
||||
title={more.time}
|
||||
>
|
||||
{more.time}
|
||||
|
|
|
|||
|
|
@ -318,6 +318,7 @@ export const useChat = (
|
|||
|
||||
return player
|
||||
}
|
||||
|
||||
ssePost(
|
||||
url,
|
||||
{
|
||||
|
|
@ -393,6 +394,7 @@ export const useChat = (
|
|||
time: formatTime(newResponseItem.created_at, 'hh:mm A'),
|
||||
tokens: newResponseItem.answer_tokens + newResponseItem.message_tokens,
|
||||
latency: newResponseItem.provider_response_latency.toFixed(2),
|
||||
tokens_per_second: newResponseItem.provider_response_latency > 0 ? (newResponseItem.answer_tokens / newResponseItem.provider_response_latency).toFixed(2) : undefined,
|
||||
},
|
||||
// for agent log
|
||||
conversationId: conversationId.current,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export type MessageMore = {
|
|||
time: string
|
||||
tokens: number
|
||||
latency: number | string
|
||||
tokens_per_second?: number | string
|
||||
}
|
||||
|
||||
export type FeedbackType = {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,777 @@
|
|||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
import EmptyDatasetCreationModal from './index'
|
||||
import { createEmptyDataset } from '@/service/datasets'
|
||||
import { useInvalidDatasetList } from '@/service/knowledge/use-dataset'
|
||||
|
||||
// Mock Next.js router
|
||||
const mockPush = jest.fn()
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock createEmptyDataset API
|
||||
jest.mock('@/service/datasets', () => ({
|
||||
createEmptyDataset: jest.fn(),
|
||||
}))
|
||||
|
||||
// Mock useInvalidDatasetList hook
|
||||
jest.mock('@/service/knowledge/use-dataset', () => ({
|
||||
useInvalidDatasetList: jest.fn(),
|
||||
}))
|
||||
|
||||
// Mock ToastContext - need to mock both createContext and useContext from use-context-selector
|
||||
const mockNotify = jest.fn()
|
||||
jest.mock('use-context-selector', () => ({
|
||||
createContext: jest.fn(() => ({
|
||||
Provider: ({ children }: { children: React.ReactNode }) => children,
|
||||
})),
|
||||
useContext: jest.fn(() => ({ notify: mockNotify })),
|
||||
}))
|
||||
|
||||
// Type cast mocked functions
|
||||
const mockCreateEmptyDataset = createEmptyDataset as jest.MockedFunction<typeof createEmptyDataset>
|
||||
const mockInvalidDatasetList = jest.fn()
|
||||
const mockUseInvalidDatasetList = useInvalidDatasetList as jest.MockedFunction<typeof useInvalidDatasetList>
|
||||
|
||||
// Test data builder for props
|
||||
const createDefaultProps = (overrides?: Partial<{ show: boolean; onHide: () => void }>) => ({
|
||||
show: true,
|
||||
onHide: jest.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('EmptyDatasetCreationModal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockUseInvalidDatasetList.mockReturnValue(mockInvalidDatasetList)
|
||||
mockCreateEmptyDataset.mockResolvedValue({
|
||||
id: 'dataset-123',
|
||||
name: 'Test Dataset',
|
||||
} as ReturnType<typeof createEmptyDataset> extends Promise<infer T> ? T : never)
|
||||
})
|
||||
|
||||
// ==========================================
|
||||
// Rendering Tests - Verify component renders correctly
|
||||
// ==========================================
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing when show is true', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
render(<EmptyDatasetCreationModal {...props} />)
|
||||
|
||||
// Assert - Check modal title is rendered
|
||||
expect(screen.getByText('datasetCreation.stepOne.modal.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render modal with correct elements', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
render(<EmptyDatasetCreationModal {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('datasetCreation.stepOne.modal.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('datasetCreation.stepOne.modal.tip')).toBeInTheDocument()
|
||||
expect(screen.getByText('datasetCreation.stepOne.modal.input')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')).toBeInTheDocument()
|
||||
expect(screen.getByText('datasetCreation.stepOne.modal.confirmButton')).toBeInTheDocument()
|
||||
expect(screen.getByText('datasetCreation.stepOne.modal.cancelButton')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render input with empty value initially', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
render(<EmptyDatasetCreationModal {...props} />)
|
||||
|
||||
// Assert
|
||||
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') as HTMLInputElement
|
||||
expect(input.value).toBe('')
|
||||
})
|
||||
|
||||
it('should not render modal content when show is false', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({ show: false })
|
||||
|
||||
// Act
|
||||
render(<EmptyDatasetCreationModal {...props} />)
|
||||
|
||||
// Assert - Modal should not be visible (check for absence of title)
|
||||
expect(screen.queryByText('datasetCreation.stepOne.modal.title')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================
|
||||
// Props Testing - Verify all prop variations work correctly
|
||||
// ==========================================
|
||||
describe('Props', () => {
|
||||
describe('show prop', () => {
|
||||
it('should show modal when show is true', () => {
|
||||
// Arrange & Act
|
||||
render(<EmptyDatasetCreationModal show={true} onHide={jest.fn()} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('datasetCreation.stepOne.modal.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide modal when show is false', () => {
|
||||
// Arrange & Act
|
||||
render(<EmptyDatasetCreationModal show={false} onHide={jest.fn()} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('datasetCreation.stepOne.modal.title')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle visibility when show prop changes', () => {
|
||||
// Arrange
|
||||
const onHide = jest.fn()
|
||||
const { rerender } = render(<EmptyDatasetCreationModal show={false} onHide={onHide} />)
|
||||
|
||||
// Act & Assert - Initially hidden
|
||||
expect(screen.queryByText('datasetCreation.stepOne.modal.title')).not.toBeInTheDocument()
|
||||
|
||||
// Act & Assert - Show modal
|
||||
rerender(<EmptyDatasetCreationModal show={true} onHide={onHide} />)
|
||||
expect(screen.getByText('datasetCreation.stepOne.modal.title')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('onHide prop', () => {
|
||||
it('should call onHide when cancel button is clicked', () => {
|
||||
// Arrange
|
||||
const mockOnHide = jest.fn()
|
||||
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
|
||||
|
||||
// Act
|
||||
const cancelButton = screen.getByText('datasetCreation.stepOne.modal.cancelButton')
|
||||
fireEvent.click(cancelButton)
|
||||
|
||||
// Assert
|
||||
expect(mockOnHide).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onHide when close icon is clicked', async () => {
|
||||
// Arrange
|
||||
const mockOnHide = jest.fn()
|
||||
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
|
||||
|
||||
// Act - Wait for modal to be rendered, then find the close span
|
||||
// The close span is located in the modalHeader div, next to the title
|
||||
const titleElement = await screen.findByText('datasetCreation.stepOne.modal.title')
|
||||
const headerDiv = titleElement.parentElement
|
||||
const closeButton = headerDiv?.querySelector('span')
|
||||
|
||||
expect(closeButton).toBeInTheDocument()
|
||||
fireEvent.click(closeButton!)
|
||||
|
||||
// Assert
|
||||
expect(mockOnHide).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================
|
||||
// State Management - Test input state updates
|
||||
// ==========================================
|
||||
describe('State Management', () => {
|
||||
it('should update input value when user types', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
render(<EmptyDatasetCreationModal {...props} />)
|
||||
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') as HTMLInputElement
|
||||
|
||||
// Act
|
||||
fireEvent.change(input, { target: { value: 'My Dataset' } })
|
||||
|
||||
// Assert
|
||||
expect(input.value).toBe('My Dataset')
|
||||
})
|
||||
|
||||
it('should persist input value when modal is hidden and shown again via rerender', () => {
|
||||
// Arrange
|
||||
const onHide = jest.fn()
|
||||
const { rerender } = render(<EmptyDatasetCreationModal show={true} onHide={onHide} />)
|
||||
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') as HTMLInputElement
|
||||
|
||||
// Act - Type in input
|
||||
fireEvent.change(input, { target: { value: 'Test Dataset' } })
|
||||
expect(input.value).toBe('Test Dataset')
|
||||
|
||||
// Hide and show modal via rerender (component is not unmounted, state persists)
|
||||
rerender(<EmptyDatasetCreationModal show={false} onHide={onHide} />)
|
||||
rerender(<EmptyDatasetCreationModal show={true} onHide={onHide} />)
|
||||
|
||||
// Assert - Input value persists because component state is preserved during rerender
|
||||
const newInput = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') as HTMLInputElement
|
||||
expect(newInput.value).toBe('Test Dataset')
|
||||
})
|
||||
|
||||
it('should handle consecutive input changes', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
render(<EmptyDatasetCreationModal {...props} />)
|
||||
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') as HTMLInputElement
|
||||
|
||||
// Act & Assert
|
||||
fireEvent.change(input, { target: { value: 'A' } })
|
||||
expect(input.value).toBe('A')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'AB' } })
|
||||
expect(input.value).toBe('AB')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'ABC' } })
|
||||
expect(input.value).toBe('ABC')
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================
|
||||
// User Interactions - Test event handlers
|
||||
// ==========================================
|
||||
describe('User Interactions', () => {
|
||||
it('should submit form when confirm button is clicked with valid input', async () => {
|
||||
// Arrange
|
||||
const mockOnHide = jest.fn()
|
||||
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
|
||||
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
|
||||
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
|
||||
|
||||
// Act
|
||||
fireEvent.change(input, { target: { value: 'Valid Dataset Name' } })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: 'Valid Dataset Name' })
|
||||
})
|
||||
})
|
||||
|
||||
it('should show error notification when input is empty', async () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
render(<EmptyDatasetCreationModal {...props} />)
|
||||
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
|
||||
|
||||
// Act - Click confirm without entering a name
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'datasetCreation.stepOne.modal.nameNotEmpty',
|
||||
})
|
||||
})
|
||||
expect(mockCreateEmptyDataset).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show error notification when input exceeds 40 characters', async () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
render(<EmptyDatasetCreationModal {...props} />)
|
||||
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
|
||||
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
|
||||
|
||||
// Act - Enter a name longer than 40 characters
|
||||
const longName = 'A'.repeat(41)
|
||||
fireEvent.change(input, { target: { value: longName } })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'datasetCreation.stepOne.modal.nameLengthInvalid',
|
||||
})
|
||||
})
|
||||
expect(mockCreateEmptyDataset).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should allow exactly 40 characters', async () => {
|
||||
// Arrange
|
||||
const mockOnHide = jest.fn()
|
||||
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
|
||||
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
|
||||
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
|
||||
|
||||
// Act - Enter exactly 40 characters
|
||||
const exactLengthName = 'A'.repeat(40)
|
||||
fireEvent.change(input, { target: { value: exactLengthName } })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: exactLengthName })
|
||||
})
|
||||
})
|
||||
|
||||
it('should close modal on cancel button click', () => {
|
||||
// Arrange
|
||||
const mockOnHide = jest.fn()
|
||||
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
|
||||
const cancelButton = screen.getByText('datasetCreation.stepOne.modal.cancelButton')
|
||||
|
||||
// Act
|
||||
fireEvent.click(cancelButton)
|
||||
|
||||
// Assert
|
||||
expect(mockOnHide).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================
|
||||
// API Calls - Test API interactions
|
||||
// ==========================================
|
||||
describe('API Calls', () => {
|
||||
it('should call createEmptyDataset with correct parameters', async () => {
|
||||
// Arrange
|
||||
const mockOnHide = jest.fn()
|
||||
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
|
||||
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
|
||||
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
|
||||
|
||||
// Act
|
||||
fireEvent.change(input, { target: { value: 'New Dataset' } })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: 'New Dataset' })
|
||||
})
|
||||
})
|
||||
|
||||
it('should call invalidDatasetList after successful creation', async () => {
|
||||
// Arrange
|
||||
const mockOnHide = jest.fn()
|
||||
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
|
||||
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
|
||||
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
|
||||
|
||||
// Act
|
||||
fireEvent.change(input, { target: { value: 'Test Dataset' } })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockInvalidDatasetList).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onHide after successful creation', async () => {
|
||||
// Arrange
|
||||
const mockOnHide = jest.fn()
|
||||
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
|
||||
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
|
||||
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
|
||||
|
||||
// Act
|
||||
fireEvent.change(input, { target: { value: 'Test Dataset' } })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockOnHide).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show error notification on API failure', async () => {
|
||||
// Arrange
|
||||
mockCreateEmptyDataset.mockRejectedValue(new Error('API Error'))
|
||||
const mockOnHide = jest.fn()
|
||||
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
|
||||
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
|
||||
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
|
||||
|
||||
// Act
|
||||
fireEvent.change(input, { target: { value: 'Test Dataset' } })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'datasetCreation.stepOne.modal.failed',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call onHide on API failure', async () => {
|
||||
// Arrange
|
||||
mockCreateEmptyDataset.mockRejectedValue(new Error('API Error'))
|
||||
const mockOnHide = jest.fn()
|
||||
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
|
||||
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
|
||||
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
|
||||
|
||||
// Act
|
||||
fireEvent.change(input, { target: { value: 'Test Dataset' } })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
// Assert - Wait for API call to complete
|
||||
await waitFor(() => {
|
||||
expect(mockCreateEmptyDataset).toHaveBeenCalled()
|
||||
})
|
||||
// onHide should not be called on failure
|
||||
expect(mockOnHide).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not invalidate dataset list on API failure', async () => {
|
||||
// Arrange
|
||||
mockCreateEmptyDataset.mockRejectedValue(new Error('API Error'))
|
||||
const props = createDefaultProps()
|
||||
render(<EmptyDatasetCreationModal {...props} />)
|
||||
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
|
||||
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
|
||||
|
||||
// Act
|
||||
fireEvent.change(input, { target: { value: 'Test Dataset' } })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalled()
|
||||
})
|
||||
expect(mockInvalidDatasetList).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================
|
||||
// Router Navigation - Test Next.js router
|
||||
// ==========================================
|
||||
describe('Router Navigation', () => {
|
||||
it('should navigate to dataset documents page after successful creation', async () => {
|
||||
// Arrange
|
||||
mockCreateEmptyDataset.mockResolvedValue({
|
||||
id: 'test-dataset-456',
|
||||
name: 'Test',
|
||||
} as ReturnType<typeof createEmptyDataset> extends Promise<infer T> ? T : never)
|
||||
const mockOnHide = jest.fn()
|
||||
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
|
||||
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
|
||||
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
|
||||
|
||||
// Act
|
||||
fireEvent.change(input, { target: { value: 'Test' } })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith('/datasets/test-dataset-456/documents')
|
||||
})
|
||||
})
|
||||
|
||||
it('should not navigate on validation error', async () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
render(<EmptyDatasetCreationModal {...props} />)
|
||||
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
|
||||
|
||||
// Act - Click confirm with empty input
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalled()
|
||||
})
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not navigate on API error', async () => {
|
||||
// Arrange
|
||||
mockCreateEmptyDataset.mockRejectedValue(new Error('API Error'))
|
||||
const props = createDefaultProps()
|
||||
render(<EmptyDatasetCreationModal {...props} />)
|
||||
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
|
||||
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
|
||||
|
||||
// Act
|
||||
fireEvent.change(input, { target: { value: 'Test' } })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalled()
|
||||
})
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================
|
||||
// Edge Cases - Test boundary conditions and error handling
|
||||
// ==========================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle whitespace-only input as valid (component behavior)', async () => {
|
||||
// Arrange
|
||||
const mockOnHide = jest.fn()
|
||||
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
|
||||
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
|
||||
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
|
||||
|
||||
// Act - Enter whitespace only
|
||||
fireEvent.change(input, { target: { value: ' ' } })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
// Assert - Current implementation treats whitespace as valid input
|
||||
await waitFor(() => {
|
||||
expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: ' ' })
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle special characters in input', async () => {
|
||||
// Arrange
|
||||
const mockOnHide = jest.fn()
|
||||
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
|
||||
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
|
||||
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
|
||||
|
||||
// Act
|
||||
fireEvent.change(input, { target: { value: 'Test @#$% Dataset!' } })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: 'Test @#$% Dataset!' })
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle Unicode characters in input', async () => {
|
||||
// Arrange
|
||||
const mockOnHide = jest.fn()
|
||||
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
|
||||
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
|
||||
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
|
||||
|
||||
// Act
|
||||
fireEvent.change(input, { target: { value: '数据集测试 🚀' } })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: '数据集测试 🚀' })
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle input at exactly 40 character boundary', async () => {
|
||||
// Arrange
|
||||
const mockOnHide = jest.fn()
|
||||
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
|
||||
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
|
||||
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
|
||||
|
||||
// Act - Test boundary: 40 characters is valid
|
||||
const name40Chars = 'A'.repeat(40)
|
||||
fireEvent.change(input, { target: { value: name40Chars } })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: name40Chars })
|
||||
})
|
||||
})
|
||||
|
||||
it('should reject input at 41 character boundary', async () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
render(<EmptyDatasetCreationModal {...props} />)
|
||||
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
|
||||
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
|
||||
|
||||
// Act - Test boundary: 41 characters is invalid
|
||||
const name41Chars = 'A'.repeat(41)
|
||||
fireEvent.change(input, { target: { value: name41Chars } })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'datasetCreation.stepOne.modal.nameLengthInvalid',
|
||||
})
|
||||
})
|
||||
expect(mockCreateEmptyDataset).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle rapid consecutive submits', async () => {
|
||||
// Arrange
|
||||
const mockOnHide = jest.fn()
|
||||
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
|
||||
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
|
||||
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
|
||||
|
||||
// Act - Rapid clicks
|
||||
fireEvent.change(input, { target: { value: 'Test' } })
|
||||
fireEvent.click(confirmButton)
|
||||
fireEvent.click(confirmButton)
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
// Assert - API will be called multiple times (no debounce in current implementation)
|
||||
await waitFor(() => {
|
||||
expect(mockCreateEmptyDataset).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle input with leading/trailing spaces', async () => {
|
||||
// Arrange
|
||||
const mockOnHide = jest.fn()
|
||||
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
|
||||
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
|
||||
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
|
||||
|
||||
// Act
|
||||
fireEvent.change(input, { target: { value: ' Dataset Name ' } })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
// Assert - Current implementation does not trim spaces
|
||||
await waitFor(() => {
|
||||
expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: ' Dataset Name ' })
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle newline characters in input (browser strips newlines)', async () => {
|
||||
// Arrange
|
||||
const mockOnHide = jest.fn()
|
||||
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
|
||||
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
|
||||
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
|
||||
|
||||
// Act
|
||||
fireEvent.change(input, { target: { value: 'Line1\nLine2' } })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
// Assert - HTML input elements strip newline characters (expected browser behavior)
|
||||
await waitFor(() => {
|
||||
expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: 'Line1Line2' })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================
|
||||
// Validation Tests - Test input validation
|
||||
// ==========================================
|
||||
describe('Validation', () => {
|
||||
it('should not submit when input is empty string', async () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
render(<EmptyDatasetCreationModal {...props} />)
|
||||
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
|
||||
|
||||
// Act
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'datasetCreation.stepOne.modal.nameNotEmpty',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should validate length before calling API', async () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
render(<EmptyDatasetCreationModal {...props} />)
|
||||
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
|
||||
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
|
||||
|
||||
// Act
|
||||
fireEvent.change(input, { target: { value: 'A'.repeat(50) } })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
// Assert - Should show error before API call
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'datasetCreation.stepOne.modal.nameLengthInvalid',
|
||||
})
|
||||
})
|
||||
expect(mockCreateEmptyDataset).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should validate empty string before length check', async () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
render(<EmptyDatasetCreationModal {...props} />)
|
||||
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
|
||||
|
||||
// Act - Don't enter anything
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
// Assert - Should show empty error, not length error
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'datasetCreation.stepOne.modal.nameNotEmpty',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================
|
||||
// Integration Tests - Test complete flows
|
||||
// ==========================================
|
||||
describe('Integration', () => {
|
||||
it('should complete full successful creation flow', async () => {
|
||||
// Arrange
|
||||
const mockOnHide = jest.fn()
|
||||
mockCreateEmptyDataset.mockResolvedValue({
|
||||
id: 'new-id-789',
|
||||
name: 'Complete Flow Test',
|
||||
} as ReturnType<typeof createEmptyDataset> extends Promise<infer T> ? T : never)
|
||||
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
|
||||
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
|
||||
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
|
||||
|
||||
// Act
|
||||
fireEvent.change(input, { target: { value: 'Complete Flow Test' } })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
// Assert - Verify complete flow
|
||||
await waitFor(() => {
|
||||
// 1. API called
|
||||
expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: 'Complete Flow Test' })
|
||||
// 2. Dataset list invalidated
|
||||
expect(mockInvalidDatasetList).toHaveBeenCalled()
|
||||
// 3. Modal closed
|
||||
expect(mockOnHide).toHaveBeenCalled()
|
||||
// 4. Navigation happened
|
||||
expect(mockPush).toHaveBeenCalledWith('/datasets/new-id-789/documents')
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle error flow correctly', async () => {
|
||||
// Arrange
|
||||
const mockOnHide = jest.fn()
|
||||
mockCreateEmptyDataset.mockRejectedValue(new Error('Server Error'))
|
||||
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
|
||||
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
|
||||
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
|
||||
|
||||
// Act
|
||||
fireEvent.change(input, { target: { value: 'Error Test' } })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
// Assert - Verify error handling
|
||||
await waitFor(() => {
|
||||
// 1. API was called
|
||||
expect(mockCreateEmptyDataset).toHaveBeenCalled()
|
||||
// 2. Error notification shown
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'datasetCreation.stepOne.modal.failed',
|
||||
})
|
||||
})
|
||||
|
||||
// 3. These should NOT happen on error
|
||||
expect(mockInvalidDatasetList).not.toHaveBeenCalled()
|
||||
expect(mockOnHide).not.toHaveBeenCalled()
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,596 @@
|
|||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
import LanguageSelect from './index'
|
||||
import type { ILanguageSelectProps } from './index'
|
||||
import { languages } from '@/i18n-config/language'
|
||||
|
||||
// Get supported languages for test assertions
|
||||
const supportedLanguages = languages.filter(lang => lang.supported)
|
||||
|
||||
// Test data builder for props
|
||||
const createDefaultProps = (overrides?: Partial<ILanguageSelectProps>): ILanguageSelectProps => ({
|
||||
currentLanguage: 'English',
|
||||
onSelect: jest.fn(),
|
||||
disabled: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('LanguageSelect', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
// ==========================================
|
||||
// Rendering Tests - Verify component renders correctly
|
||||
// ==========================================
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
render(<LanguageSelect {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('English')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render current language text', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({ currentLanguage: 'Chinese Simplified' })
|
||||
|
||||
// Act
|
||||
render(<LanguageSelect {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Chinese Simplified')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render dropdown arrow icon', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
const { container } = render(<LanguageSelect {...props} />)
|
||||
|
||||
// Assert - RiArrowDownSLine renders as SVG
|
||||
const svgIcon = container.querySelector('svg')
|
||||
expect(svgIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render all supported languages in dropdown when opened', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
render(<LanguageSelect {...props} />)
|
||||
|
||||
// Act - Click button to open dropdown
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
// Assert - All supported languages should be visible
|
||||
// Use getAllByText because current language appears both in button and dropdown
|
||||
supportedLanguages.forEach((lang) => {
|
||||
expect(screen.getAllByText(lang.prompt_name).length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('should render check icon for selected language', () => {
|
||||
// Arrange
|
||||
const selectedLanguage = 'Japanese'
|
||||
const props = createDefaultProps({ currentLanguage: selectedLanguage })
|
||||
render(<LanguageSelect {...props} />)
|
||||
|
||||
// Act
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
// Assert - The selected language option should have a check icon
|
||||
const languageOptions = screen.getAllByText(selectedLanguage)
|
||||
// One in the button, one in the dropdown list
|
||||
expect(languageOptions.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================
|
||||
// Props Testing - Verify all prop variations work correctly
|
||||
// ==========================================
|
||||
describe('Props', () => {
|
||||
describe('currentLanguage prop', () => {
|
||||
it('should display English when currentLanguage is English', () => {
|
||||
const props = createDefaultProps({ currentLanguage: 'English' })
|
||||
render(<LanguageSelect {...props} />)
|
||||
expect(screen.getByText('English')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display Chinese Simplified when currentLanguage is Chinese Simplified', () => {
|
||||
const props = createDefaultProps({ currentLanguage: 'Chinese Simplified' })
|
||||
render(<LanguageSelect {...props} />)
|
||||
expect(screen.getByText('Chinese Simplified')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display Japanese when currentLanguage is Japanese', () => {
|
||||
const props = createDefaultProps({ currentLanguage: 'Japanese' })
|
||||
render(<LanguageSelect {...props} />)
|
||||
expect(screen.getByText('Japanese')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each(supportedLanguages.map(l => l.prompt_name))(
|
||||
'should display %s as current language',
|
||||
(language) => {
|
||||
const props = createDefaultProps({ currentLanguage: language })
|
||||
render(<LanguageSelect {...props} />)
|
||||
expect(screen.getByText(language)).toBeInTheDocument()
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('disabled prop', () => {
|
||||
it('should have disabled button when disabled is true', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({ disabled: true })
|
||||
|
||||
// Act
|
||||
render(<LanguageSelect {...props} />)
|
||||
|
||||
// Assert
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should have enabled button when disabled is false', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({ disabled: false })
|
||||
|
||||
// Act
|
||||
render(<LanguageSelect {...props} />)
|
||||
|
||||
// Assert
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('should have enabled button when disabled is undefined', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
delete (props as Partial<ILanguageSelectProps>).disabled
|
||||
|
||||
// Act
|
||||
render(<LanguageSelect {...props} />)
|
||||
|
||||
// Assert
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('should apply disabled styling when disabled is true', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({ disabled: true })
|
||||
|
||||
// Act
|
||||
const { container } = render(<LanguageSelect {...props} />)
|
||||
|
||||
// Assert - Check for disabled class on text elements
|
||||
const disabledTextElement = container.querySelector('.text-components-button-tertiary-text-disabled')
|
||||
expect(disabledTextElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply cursor-not-allowed styling when disabled', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({ disabled: true })
|
||||
|
||||
// Act
|
||||
const { container } = render(<LanguageSelect {...props} />)
|
||||
|
||||
// Assert
|
||||
const elementWithCursor = container.querySelector('.cursor-not-allowed')
|
||||
expect(elementWithCursor).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('onSelect prop', () => {
|
||||
it('should be callable as a function', () => {
|
||||
const mockOnSelect = jest.fn()
|
||||
const props = createDefaultProps({ onSelect: mockOnSelect })
|
||||
render(<LanguageSelect {...props} />)
|
||||
|
||||
// Open dropdown and click a language
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
const germanOption = screen.getByText('German')
|
||||
fireEvent.click(germanOption)
|
||||
|
||||
expect(mockOnSelect).toHaveBeenCalledWith('German')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================
|
||||
// User Interactions - Test event handlers
|
||||
// ==========================================
|
||||
describe('User Interactions', () => {
|
||||
it('should open dropdown when button is clicked', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
render(<LanguageSelect {...props} />)
|
||||
|
||||
// Act
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
// Assert - Check if dropdown content is visible
|
||||
expect(screen.getAllByText('English').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should call onSelect when a language option is clicked', () => {
|
||||
// Arrange
|
||||
const mockOnSelect = jest.fn()
|
||||
const props = createDefaultProps({ onSelect: mockOnSelect })
|
||||
render(<LanguageSelect {...props} />)
|
||||
|
||||
// Act
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
const frenchOption = screen.getByText('French')
|
||||
fireEvent.click(frenchOption)
|
||||
|
||||
// Assert
|
||||
expect(mockOnSelect).toHaveBeenCalledTimes(1)
|
||||
expect(mockOnSelect).toHaveBeenCalledWith('French')
|
||||
})
|
||||
|
||||
it('should call onSelect with correct language when selecting different languages', () => {
|
||||
// Arrange
|
||||
const mockOnSelect = jest.fn()
|
||||
const props = createDefaultProps({ onSelect: mockOnSelect })
|
||||
render(<LanguageSelect {...props} />)
|
||||
|
||||
// Act & Assert - Test multiple language selections
|
||||
const testLanguages = ['Korean', 'Spanish', 'Italian']
|
||||
|
||||
testLanguages.forEach((lang) => {
|
||||
mockOnSelect.mockClear()
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
const languageOption = screen.getByText(lang)
|
||||
fireEvent.click(languageOption)
|
||||
expect(mockOnSelect).toHaveBeenCalledWith(lang)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not open dropdown when disabled', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({ disabled: true })
|
||||
render(<LanguageSelect {...props} />)
|
||||
|
||||
// Act
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
// Assert - Dropdown should not open, only one instance of the current language should exist
|
||||
const englishElements = screen.getAllByText('English')
|
||||
expect(englishElements.length).toBe(1) // Only the button text, not dropdown
|
||||
})
|
||||
|
||||
it('should not call onSelect when component is disabled', () => {
|
||||
// Arrange
|
||||
const mockOnSelect = jest.fn()
|
||||
const props = createDefaultProps({ onSelect: mockOnSelect, disabled: true })
|
||||
render(<LanguageSelect {...props} />)
|
||||
|
||||
// Act
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
// Assert
|
||||
expect(mockOnSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle rapid consecutive clicks', () => {
|
||||
// Arrange
|
||||
const mockOnSelect = jest.fn()
|
||||
const props = createDefaultProps({ onSelect: mockOnSelect })
|
||||
render(<LanguageSelect {...props} />)
|
||||
|
||||
// Act - Rapid clicks
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
fireEvent.click(button)
|
||||
fireEvent.click(button)
|
||||
|
||||
// Assert - Component should not crash
|
||||
expect(button).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================
|
||||
// Component Memoization - Test React.memo behavior
|
||||
// ==========================================
|
||||
describe('Memoization', () => {
|
||||
it('should be wrapped with React.memo', () => {
|
||||
// Assert - Check component has memo wrapper
|
||||
expect(LanguageSelect.$$typeof).toBe(Symbol.for('react.memo'))
|
||||
})
|
||||
|
||||
it('should not re-render when props remain the same', () => {
|
||||
// Arrange
|
||||
const mockOnSelect = jest.fn()
|
||||
const props = createDefaultProps({ onSelect: mockOnSelect })
|
||||
const renderSpy = jest.fn()
|
||||
|
||||
// Create a wrapper component to track renders
|
||||
const TrackedLanguageSelect: React.FC<ILanguageSelectProps> = (trackedProps) => {
|
||||
renderSpy()
|
||||
return <LanguageSelect {...trackedProps} />
|
||||
}
|
||||
const MemoizedTracked = React.memo(TrackedLanguageSelect)
|
||||
|
||||
// Act
|
||||
const { rerender } = render(<MemoizedTracked {...props} />)
|
||||
rerender(<MemoizedTracked {...props} />)
|
||||
|
||||
// Assert - Should only render once due to same props
|
||||
expect(renderSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should re-render when currentLanguage changes', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({ currentLanguage: 'English' })
|
||||
|
||||
// Act
|
||||
const { rerender } = render(<LanguageSelect {...props} />)
|
||||
expect(screen.getByText('English')).toBeInTheDocument()
|
||||
|
||||
rerender(<LanguageSelect {...props} currentLanguage="French" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('French')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should re-render when disabled changes', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({ disabled: false })
|
||||
|
||||
// Act
|
||||
const { rerender } = render(<LanguageSelect {...props} />)
|
||||
expect(screen.getByRole('button')).not.toBeDisabled()
|
||||
|
||||
rerender(<LanguageSelect {...props} disabled={true} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================
|
||||
// Edge Cases - Test boundary conditions and error handling
|
||||
// ==========================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty string as currentLanguage', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({ currentLanguage: '' })
|
||||
|
||||
// Act
|
||||
render(<LanguageSelect {...props} />)
|
||||
|
||||
// Assert - Component should still render
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle non-existent language as currentLanguage', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({ currentLanguage: 'NonExistentLanguage' })
|
||||
|
||||
// Act
|
||||
render(<LanguageSelect {...props} />)
|
||||
|
||||
// Assert - Should display the value even if not in list
|
||||
expect(screen.getByText('NonExistentLanguage')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle special characters in language names', () => {
|
||||
// Arrange - Turkish has special character in prompt_name
|
||||
const props = createDefaultProps({ currentLanguage: 'Türkçe' })
|
||||
|
||||
// Act
|
||||
render(<LanguageSelect {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Türkçe')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle very long language names', () => {
|
||||
// Arrange
|
||||
const longLanguageName = 'A'.repeat(100)
|
||||
const props = createDefaultProps({ currentLanguage: longLanguageName })
|
||||
|
||||
// Act
|
||||
render(<LanguageSelect {...props} />)
|
||||
|
||||
// Assert - Should not crash and should display the text
|
||||
expect(screen.getByText(longLanguageName)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render correct number of language options', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
render(<LanguageSelect {...props} />)
|
||||
|
||||
// Act
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
// Assert - Should show all supported languages
|
||||
const expectedCount = supportedLanguages.length
|
||||
// Each language appears in the dropdown (use getAllByText because current language appears twice)
|
||||
supportedLanguages.forEach((lang) => {
|
||||
expect(screen.getAllByText(lang.prompt_name).length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
expect(supportedLanguages.length).toBe(expectedCount)
|
||||
})
|
||||
|
||||
it('should only show supported languages in dropdown', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
render(<LanguageSelect {...props} />)
|
||||
|
||||
// Act
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
// Assert - All displayed languages should be supported
|
||||
const allLanguages = languages
|
||||
const unsupportedLanguages = allLanguages.filter(lang => !lang.supported)
|
||||
|
||||
unsupportedLanguages.forEach((lang) => {
|
||||
expect(screen.queryByText(lang.prompt_name)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle undefined onSelect gracefully when clicking', () => {
|
||||
// Arrange - This tests TypeScript boundary, but runtime should not crash
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
render(<LanguageSelect {...props} />)
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
const option = screen.getByText('German')
|
||||
|
||||
// Assert - Should not throw
|
||||
expect(() => fireEvent.click(option)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should maintain selection state visually with check icon', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({ currentLanguage: 'Russian' })
|
||||
const { container } = render(<LanguageSelect {...props} />)
|
||||
|
||||
// Act
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
// Assert - Find the check icon (RiCheckLine) in the dropdown
|
||||
// The selected option should have a check icon next to it
|
||||
const checkIcons = container.querySelectorAll('svg.text-text-accent')
|
||||
expect(checkIcons.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================
|
||||
// Accessibility - Basic accessibility checks
|
||||
// ==========================================
|
||||
describe('Accessibility', () => {
|
||||
it('should have accessible button element', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
render(<LanguageSelect {...props} />)
|
||||
|
||||
// Assert
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have clickable language options', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
render(<LanguageSelect {...props} />)
|
||||
|
||||
// Act
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
// Assert - Options should be clickable (have cursor-pointer class)
|
||||
const options = screen.getAllByText(/English|French|German|Japanese/i)
|
||||
expect(options.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================
|
||||
// Integration with Popover - Test Popover behavior
|
||||
// ==========================================
|
||||
describe('Popover Integration', () => {
|
||||
it('should use manualClose prop on Popover', () => {
|
||||
// Arrange
|
||||
const mockOnSelect = jest.fn()
|
||||
const props = createDefaultProps({ onSelect: mockOnSelect })
|
||||
|
||||
// Act
|
||||
render(<LanguageSelect {...props} />)
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
// Assert - Popover should be open
|
||||
expect(screen.getAllByText('English').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should have correct popup z-index class', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
const { container } = render(<LanguageSelect {...props} />)
|
||||
|
||||
// Act
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
// Assert - Check for z-20 class (popupClassName='z-20')
|
||||
// This is applied to the Popover
|
||||
expect(container.querySelector('.z-20')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================
|
||||
// Styling Tests - Verify correct CSS classes applied
|
||||
// ==========================================
|
||||
describe('Styling', () => {
|
||||
it('should apply tertiary button styling', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
const { container } = render(<LanguageSelect {...props} />)
|
||||
|
||||
// Assert - Check for tertiary button classes (uses ! prefix for important)
|
||||
expect(container.querySelector('.\\!bg-components-button-tertiary-bg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply hover styling class to options', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
const { container } = render(<LanguageSelect {...props} />)
|
||||
|
||||
// Act
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
// Assert - Options should have hover class
|
||||
const optionWithHover = container.querySelector('.hover\\:bg-state-base-hover')
|
||||
expect(optionWithHover).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply correct text styling to language options', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
const { container } = render(<LanguageSelect {...props} />)
|
||||
|
||||
// Act
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
// Assert - Check for system-sm-medium class on options
|
||||
const styledOption = container.querySelector('.system-sm-medium')
|
||||
expect(styledOption).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply disabled styling to icon when disabled', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({ disabled: true })
|
||||
const { container } = render(<LanguageSelect {...props} />)
|
||||
|
||||
// Assert - Check for disabled text color on icon
|
||||
const disabledIcon = container.querySelector('.text-components-button-tertiary-text-disabled')
|
||||
expect(disabledIcon).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,803 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
import PreviewItem, { PreviewType } from './index'
|
||||
import type { IPreviewItemProps } from './index'
|
||||
|
||||
// Test data builder for props
|
||||
const createDefaultProps = (overrides?: Partial<IPreviewItemProps>): IPreviewItemProps => ({
|
||||
type: PreviewType.TEXT,
|
||||
index: 1,
|
||||
content: 'Test content',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createQAProps = (overrides?: Partial<IPreviewItemProps>): IPreviewItemProps => ({
|
||||
type: PreviewType.QA,
|
||||
index: 1,
|
||||
qa: {
|
||||
question: 'Test question',
|
||||
answer: 'Test answer',
|
||||
},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('PreviewItem', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
// ==========================================
|
||||
// Rendering Tests - Verify component renders correctly
|
||||
// ==========================================
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Test content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with TEXT type', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({ content: 'Sample text content' })
|
||||
|
||||
// Act
|
||||
render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Sample text content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with QA type', () => {
|
||||
// Arrange
|
||||
const props = createQAProps()
|
||||
|
||||
// Act
|
||||
render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Q')).toBeInTheDocument()
|
||||
expect(screen.getByText('A')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test question')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test answer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render sharp icon (#) with formatted index', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({ index: 5 })
|
||||
|
||||
// Act
|
||||
const { container } = render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert - Index should be padded to 3 digits
|
||||
expect(screen.getByText('005')).toBeInTheDocument()
|
||||
// Sharp icon SVG should exist
|
||||
const svgElements = container.querySelectorAll('svg')
|
||||
expect(svgElements.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should render character count for TEXT type', () => {
|
||||
// Arrange
|
||||
const content = 'Hello World' // 11 characters
|
||||
const props = createDefaultProps({ content })
|
||||
|
||||
// Act
|
||||
render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert - Shows character count with translation key
|
||||
expect(screen.getByText(/11/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/datasetCreation.stepTwo.characters/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render character count for QA type', () => {
|
||||
// Arrange
|
||||
const props = createQAProps({
|
||||
qa: {
|
||||
question: 'Hello', // 5 characters
|
||||
answer: 'World', // 5 characters - total 10
|
||||
},
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert - Shows combined character count
|
||||
expect(screen.getByText(/10/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render text icon SVG', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
const { container } = render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert - Should have SVG icons
|
||||
const svgElements = container.querySelectorAll('svg')
|
||||
expect(svgElements.length).toBe(2) // Sharp icon and text icon
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================
|
||||
// Props Testing - Verify all prop variations work correctly
|
||||
// ==========================================
|
||||
describe('Props', () => {
|
||||
describe('type prop', () => {
|
||||
it('should render TEXT content when type is TEXT', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({ type: PreviewType.TEXT, content: 'Text mode content' })
|
||||
|
||||
// Act
|
||||
render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Text mode content')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Q')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('A')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render QA content when type is QA', () => {
|
||||
// Arrange
|
||||
const props = createQAProps({
|
||||
type: PreviewType.QA,
|
||||
qa: { question: 'My question', answer: 'My answer' },
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Q')).toBeInTheDocument()
|
||||
expect(screen.getByText('A')).toBeInTheDocument()
|
||||
expect(screen.getByText('My question')).toBeInTheDocument()
|
||||
expect(screen.getByText('My answer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use TEXT as default type when type is "text"', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({ type: 'text' as PreviewType, content: 'Default type content' })
|
||||
|
||||
// Act
|
||||
render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Default type content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use QA type when type is "QA"', () => {
|
||||
// Arrange
|
||||
const props = createQAProps({ type: 'QA' as PreviewType })
|
||||
|
||||
// Act
|
||||
render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Q')).toBeInTheDocument()
|
||||
expect(screen.getByText('A')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('index prop', () => {
|
||||
it.each([
|
||||
[1, '001'],
|
||||
[5, '005'],
|
||||
[10, '010'],
|
||||
[99, '099'],
|
||||
[100, '100'],
|
||||
[999, '999'],
|
||||
[1000, '1000'],
|
||||
])('should format index %i as %s', (index, expected) => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({ index })
|
||||
|
||||
// Act
|
||||
render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(expected)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle index 0', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({ index: 0 })
|
||||
|
||||
// Act
|
||||
render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('000')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle large index numbers', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({ index: 12345 })
|
||||
|
||||
// Act
|
||||
render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('12345')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('content prop', () => {
|
||||
it('should render content when provided', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({ content: 'Custom content here' })
|
||||
|
||||
// Act
|
||||
render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Custom content here')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle multiline content', () => {
|
||||
// Arrange
|
||||
const multilineContent = 'Line 1\nLine 2\nLine 3'
|
||||
const props = createDefaultProps({ content: multilineContent })
|
||||
|
||||
// Act
|
||||
const { container } = render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert - Check content is rendered (multiline text is in pre-line div)
|
||||
const contentDiv = container.querySelector('[style*="white-space: pre-line"]')
|
||||
expect(contentDiv?.textContent).toContain('Line 1')
|
||||
expect(contentDiv?.textContent).toContain('Line 2')
|
||||
expect(contentDiv?.textContent).toContain('Line 3')
|
||||
})
|
||||
|
||||
it('should preserve whitespace with pre-line style', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({ content: 'Text with spaces' })
|
||||
|
||||
// Act
|
||||
const { container } = render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert - Check for whiteSpace: pre-line style
|
||||
const contentDiv = container.querySelector('[style*="white-space: pre-line"]')
|
||||
expect(contentDiv).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('qa prop', () => {
|
||||
it('should render question and answer when qa is provided', () => {
|
||||
// Arrange
|
||||
const props = createQAProps({
|
||||
qa: {
|
||||
question: 'What is testing?',
|
||||
answer: 'Testing is verification.',
|
||||
},
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('What is testing?')).toBeInTheDocument()
|
||||
expect(screen.getByText('Testing is verification.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Q and A labels', () => {
|
||||
// Arrange
|
||||
const props = createQAProps()
|
||||
|
||||
// Act
|
||||
render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Q')).toBeInTheDocument()
|
||||
expect(screen.getByText('A')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle multiline question', () => {
|
||||
// Arrange
|
||||
const props = createQAProps({
|
||||
qa: {
|
||||
question: 'Question line 1\nQuestion line 2',
|
||||
answer: 'Answer',
|
||||
},
|
||||
})
|
||||
|
||||
// Act
|
||||
const { container } = render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert - Check content is in pre-line div
|
||||
const preLineDivs = container.querySelectorAll('[style*="white-space: pre-line"]')
|
||||
const questionDiv = Array.from(preLineDivs).find(div => div.textContent?.includes('Question line 1'))
|
||||
expect(questionDiv).toBeTruthy()
|
||||
expect(questionDiv?.textContent).toContain('Question line 2')
|
||||
})
|
||||
|
||||
it('should handle multiline answer', () => {
|
||||
// Arrange
|
||||
const props = createQAProps({
|
||||
qa: {
|
||||
question: 'Question',
|
||||
answer: 'Answer line 1\nAnswer line 2',
|
||||
},
|
||||
})
|
||||
|
||||
// Act
|
||||
const { container } = render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert - Check content is in pre-line div
|
||||
const preLineDivs = container.querySelectorAll('[style*="white-space: pre-line"]')
|
||||
const answerDiv = Array.from(preLineDivs).find(div => div.textContent?.includes('Answer line 1'))
|
||||
expect(answerDiv).toBeTruthy()
|
||||
expect(answerDiv?.textContent).toContain('Answer line 2')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================
|
||||
// Component Memoization - Test React.memo behavior
|
||||
// ==========================================
|
||||
describe('Memoization', () => {
|
||||
it('should be wrapped with React.memo', () => {
|
||||
// Assert - Check component has memo wrapper
|
||||
expect(PreviewItem.$$typeof).toBe(Symbol.for('react.memo'))
|
||||
})
|
||||
|
||||
it('should not re-render when props remain the same', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
const renderSpy = jest.fn()
|
||||
|
||||
// Create a wrapper component to track renders
|
||||
const TrackedPreviewItem: React.FC<IPreviewItemProps> = (trackedProps) => {
|
||||
renderSpy()
|
||||
return <PreviewItem {...trackedProps} />
|
||||
}
|
||||
const MemoizedTracked = React.memo(TrackedPreviewItem)
|
||||
|
||||
// Act
|
||||
const { rerender } = render(<MemoizedTracked {...props} />)
|
||||
rerender(<MemoizedTracked {...props} />)
|
||||
|
||||
// Assert - Should only render once due to same props
|
||||
expect(renderSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should re-render when content changes', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({ content: 'Initial content' })
|
||||
|
||||
// Act
|
||||
const { rerender } = render(<PreviewItem {...props} />)
|
||||
expect(screen.getByText('Initial content')).toBeInTheDocument()
|
||||
|
||||
rerender(<PreviewItem {...props} content="Updated content" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Updated content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should re-render when index changes', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({ index: 1 })
|
||||
|
||||
// Act
|
||||
const { rerender } = render(<PreviewItem {...props} />)
|
||||
expect(screen.getByText('001')).toBeInTheDocument()
|
||||
|
||||
rerender(<PreviewItem {...props} index={99} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('099')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should re-render when type changes', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({ type: PreviewType.TEXT, content: 'Text content' })
|
||||
|
||||
// Act
|
||||
const { rerender } = render(<PreviewItem {...props} />)
|
||||
expect(screen.getByText('Text content')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Q')).not.toBeInTheDocument()
|
||||
|
||||
rerender(<PreviewItem type={PreviewType.QA} index={1} qa={{ question: 'Q1', answer: 'A1' }} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Q')).toBeInTheDocument()
|
||||
expect(screen.getByText('A')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should re-render when qa prop changes', () => {
|
||||
// Arrange
|
||||
const props = createQAProps({
|
||||
qa: { question: 'Original question', answer: 'Original answer' },
|
||||
})
|
||||
|
||||
// Act
|
||||
const { rerender } = render(<PreviewItem {...props} />)
|
||||
expect(screen.getByText('Original question')).toBeInTheDocument()
|
||||
|
||||
rerender(<PreviewItem {...props} qa={{ question: 'New question', answer: 'New answer' }} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('New question')).toBeInTheDocument()
|
||||
expect(screen.getByText('New answer')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================
|
||||
// Edge Cases - Test boundary conditions and error handling
|
||||
// ==========================================
|
||||
describe('Edge Cases', () => {
|
||||
describe('Empty/Undefined values', () => {
|
||||
it('should handle undefined content gracefully', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({ content: undefined })
|
||||
|
||||
// Act
|
||||
render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert - Should show 0 characters (use more specific text match)
|
||||
expect(screen.getByText(/^0 datasetCreation/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty string content', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({ content: '' })
|
||||
|
||||
// Act
|
||||
render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert - Should show 0 characters (use more specific text match)
|
||||
expect(screen.getByText(/^0 datasetCreation/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined qa gracefully', () => {
|
||||
// Arrange
|
||||
const props: IPreviewItemProps = {
|
||||
type: PreviewType.QA,
|
||||
index: 1,
|
||||
qa: undefined,
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert - Should render Q and A labels but with empty content
|
||||
expect(screen.getByText('Q')).toBeInTheDocument()
|
||||
expect(screen.getByText('A')).toBeInTheDocument()
|
||||
// Character count should be 0 (use more specific text match)
|
||||
expect(screen.getByText(/^0 datasetCreation/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined question in qa', () => {
|
||||
// Arrange
|
||||
const props: IPreviewItemProps = {
|
||||
type: PreviewType.QA,
|
||||
index: 1,
|
||||
qa: {
|
||||
question: undefined as unknown as string,
|
||||
answer: 'Only answer',
|
||||
},
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Only answer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined answer in qa', () => {
|
||||
// Arrange
|
||||
const props: IPreviewItemProps = {
|
||||
type: PreviewType.QA,
|
||||
index: 1,
|
||||
qa: {
|
||||
question: 'Only question',
|
||||
answer: undefined as unknown as string,
|
||||
},
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Only question')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty question and answer strings', () => {
|
||||
// Arrange
|
||||
const props = createQAProps({
|
||||
qa: { question: '', answer: '' },
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert - Should show 0 characters (use more specific text match)
|
||||
expect(screen.getByText(/^0 datasetCreation/)).toBeInTheDocument()
|
||||
expect(screen.getByText('Q')).toBeInTheDocument()
|
||||
expect(screen.getByText('A')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Character count calculation', () => {
|
||||
it('should calculate correct character count for TEXT type', () => {
|
||||
// Arrange - 'Test' has 4 characters
|
||||
const props = createDefaultProps({ content: 'Test' })
|
||||
|
||||
// Act
|
||||
render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/4/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should calculate correct character count for QA type (question + answer)', () => {
|
||||
// Arrange - 'ABC' (3) + 'DEFGH' (5) = 8 characters
|
||||
const props = createQAProps({
|
||||
qa: { question: 'ABC', answer: 'DEFGH' },
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/8/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should count special characters correctly', () => {
|
||||
// Arrange - Content with special characters
|
||||
const props = createDefaultProps({ content: '你好世界' }) // 4 Chinese characters
|
||||
|
||||
// Act
|
||||
render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/4/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should count newlines in character count', () => {
|
||||
// Arrange - 'a\nb' has 3 characters
|
||||
const props = createDefaultProps({ content: 'a\nb' })
|
||||
|
||||
// Act
|
||||
render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/3/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should count spaces in character count', () => {
|
||||
// Arrange - 'a b' has 3 characters
|
||||
const props = createDefaultProps({ content: 'a b' })
|
||||
|
||||
// Act
|
||||
render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/3/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Boundary conditions', () => {
|
||||
it('should handle very long content', () => {
|
||||
// Arrange
|
||||
const longContent = 'A'.repeat(10000)
|
||||
const props = createDefaultProps({ content: longContent })
|
||||
|
||||
// Act
|
||||
render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert - Should show correct character count
|
||||
expect(screen.getByText(/10000/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle very long index', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({ index: 999999999 })
|
||||
|
||||
// Act
|
||||
render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('999999999')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle negative index', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({ index: -1 })
|
||||
|
||||
// Act
|
||||
render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert - padStart pads from the start, so -1 becomes 0-1
|
||||
expect(screen.getByText('0-1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle content with only whitespace', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({ content: ' ' }) // 3 spaces
|
||||
|
||||
// Act
|
||||
render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/3/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle content with HTML-like characters', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({ content: '<div>Test</div>' })
|
||||
|
||||
// Act
|
||||
render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert - Should render as text, not HTML
|
||||
expect(screen.getByText('<div>Test</div>')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle content with emojis', () => {
|
||||
// Arrange - Emojis can have complex character lengths
|
||||
const props = createDefaultProps({ content: '😀👍' })
|
||||
|
||||
// Act
|
||||
render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert - Emoji length depends on JS string length
|
||||
expect(screen.getByText('😀👍')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Type edge cases', () => {
|
||||
it('should ignore qa prop when type is TEXT', () => {
|
||||
// Arrange - Both content and qa provided, but type is TEXT
|
||||
const props: IPreviewItemProps = {
|
||||
type: PreviewType.TEXT,
|
||||
index: 1,
|
||||
content: 'Text content',
|
||||
qa: { question: 'Should not show', answer: 'Also should not show' },
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Text content')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Should not show')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Also should not show')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use content length for TEXT type even when qa is provided', () => {
|
||||
// Arrange
|
||||
const props: IPreviewItemProps = {
|
||||
type: PreviewType.TEXT,
|
||||
index: 1,
|
||||
content: 'Hi', // 2 characters
|
||||
qa: { question: 'Question', answer: 'Answer' }, // Would be 14 characters if used
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert - Should show 2, not 14
|
||||
expect(screen.getByText(/2/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should ignore content prop when type is QA', () => {
|
||||
// Arrange
|
||||
const props: IPreviewItemProps = {
|
||||
type: PreviewType.QA,
|
||||
index: 1,
|
||||
content: 'Should not display',
|
||||
qa: { question: 'Q text', answer: 'A text' },
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('Should not display')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Q text')).toBeInTheDocument()
|
||||
expect(screen.getByText('A text')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================
|
||||
// PreviewType Enum - Test exported enum values
|
||||
// ==========================================
|
||||
describe('PreviewType Enum', () => {
|
||||
it('should have TEXT value as "text"', () => {
|
||||
expect(PreviewType.TEXT).toBe('text')
|
||||
})
|
||||
|
||||
it('should have QA value as "QA"', () => {
|
||||
expect(PreviewType.QA).toBe('QA')
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================
|
||||
// Styling Tests - Verify correct CSS classes applied
|
||||
// ==========================================
|
||||
describe('Styling', () => {
|
||||
it('should have rounded container with gray background', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
const { container } = render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert
|
||||
const rootDiv = container.firstChild as HTMLElement
|
||||
expect(rootDiv).toHaveClass('rounded-xl', 'bg-gray-50', 'p-4')
|
||||
})
|
||||
|
||||
it('should have proper header styling', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
const { container } = render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert - Check header div styling
|
||||
const headerDiv = container.querySelector('.flex.h-5.items-center.justify-between')
|
||||
expect(headerDiv).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have index badge styling', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
const { container } = render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert
|
||||
const indexBadge = container.querySelector('.border.border-gray-200')
|
||||
expect(indexBadge).toBeInTheDocument()
|
||||
expect(indexBadge).toHaveClass('rounded-md', 'italic', 'font-medium')
|
||||
})
|
||||
|
||||
it('should have content area with line-clamp', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
const { container } = render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert
|
||||
const contentArea = container.querySelector('.line-clamp-6')
|
||||
expect(contentArea).toBeInTheDocument()
|
||||
expect(contentArea).toHaveClass('max-h-[120px]', 'overflow-hidden')
|
||||
})
|
||||
|
||||
it('should have Q/A labels with gray color', () => {
|
||||
// Arrange
|
||||
const props = createQAProps()
|
||||
|
||||
// Act
|
||||
const { container } = render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert
|
||||
const labels = container.querySelectorAll('.text-gray-400')
|
||||
expect(labels.length).toBeGreaterThanOrEqual(2) // Q and A labels
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================
|
||||
// i18n Translation - Test translation integration
|
||||
// ==========================================
|
||||
describe('i18n Translation', () => {
|
||||
it('should use translation key for characters label', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps({ content: 'Test' })
|
||||
|
||||
// Act
|
||||
render(<PreviewItem {...props} />)
|
||||
|
||||
// Assert - The mock returns the key as-is
|
||||
expect(screen.getByText(/datasetCreation.stepTwo.characters/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { Command } from 'cmdk'
|
||||
import CommandSelector from './command-selector'
|
||||
import type { ActionItem } from './actions/types'
|
||||
|
||||
jest.mock('next/navigation', () => ({
|
||||
usePathname: () => '/app',
|
||||
}))
|
||||
|
||||
const slashCommandsMock = [{
|
||||
name: 'zen',
|
||||
description: 'Zen mode',
|
||||
mode: 'direct',
|
||||
isAvailable: () => true,
|
||||
}]
|
||||
|
||||
jest.mock('./actions/commands/registry', () => ({
|
||||
slashCommandRegistry: {
|
||||
getAvailableCommands: () => slashCommandsMock,
|
||||
},
|
||||
}))
|
||||
|
||||
const createActions = (): Record<string, ActionItem> => ({
|
||||
app: {
|
||||
key: '@app',
|
||||
shortcut: '@app',
|
||||
title: 'Apps',
|
||||
search: jest.fn(),
|
||||
description: '',
|
||||
} as ActionItem,
|
||||
plugin: {
|
||||
key: '@plugin',
|
||||
shortcut: '@plugin',
|
||||
title: 'Plugins',
|
||||
search: jest.fn(),
|
||||
description: '',
|
||||
} as ActionItem,
|
||||
})
|
||||
|
||||
describe('CommandSelector', () => {
|
||||
test('should list contextual search actions and notify selection', async () => {
|
||||
const actions = createActions()
|
||||
const onSelect = jest.fn()
|
||||
|
||||
render(
|
||||
<Command>
|
||||
<CommandSelector
|
||||
actions={actions}
|
||||
onCommandSelect={onSelect}
|
||||
searchFilter='app'
|
||||
originalQuery='@app'
|
||||
/>
|
||||
</Command>,
|
||||
)
|
||||
|
||||
const actionButton = screen.getByText('app.gotoAnything.actions.searchApplicationsDesc')
|
||||
await userEvent.click(actionButton)
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith('@app')
|
||||
})
|
||||
|
||||
test('should render slash commands when query starts with slash', async () => {
|
||||
const actions = createActions()
|
||||
const onSelect = jest.fn()
|
||||
|
||||
render(
|
||||
<Command>
|
||||
<CommandSelector
|
||||
actions={actions}
|
||||
onCommandSelect={onSelect}
|
||||
searchFilter='zen'
|
||||
originalQuery='/zen'
|
||||
/>
|
||||
</Command>,
|
||||
)
|
||||
|
||||
const slashItem = await screen.findByText('app.gotoAnything.actions.zenDesc')
|
||||
await userEvent.click(slashItem)
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith('/zen')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import React from 'react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { GotoAnythingProvider, useGotoAnythingContext } from './context'
|
||||
|
||||
let pathnameMock = '/'
|
||||
jest.mock('next/navigation', () => ({
|
||||
usePathname: () => pathnameMock,
|
||||
}))
|
||||
|
||||
let isWorkflowPageMock = false
|
||||
jest.mock('../workflow/constants', () => ({
|
||||
isInWorkflowPage: () => isWorkflowPageMock,
|
||||
}))
|
||||
|
||||
const ContextConsumer = () => {
|
||||
const { isWorkflowPage, isRagPipelinePage } = useGotoAnythingContext()
|
||||
return (
|
||||
<div data-testid="status">
|
||||
{String(isWorkflowPage)}|{String(isRagPipelinePage)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('GotoAnythingProvider', () => {
|
||||
beforeEach(() => {
|
||||
isWorkflowPageMock = false
|
||||
pathnameMock = '/'
|
||||
})
|
||||
|
||||
test('should set workflow page flag when workflow path detected', async () => {
|
||||
isWorkflowPageMock = true
|
||||
pathnameMock = '/app/123/workflow'
|
||||
|
||||
render(
|
||||
<GotoAnythingProvider>
|
||||
<ContextConsumer />
|
||||
</GotoAnythingProvider>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('status')).toHaveTextContent('true|false')
|
||||
})
|
||||
})
|
||||
|
||||
test('should detect RAG pipeline path based on pathname', async () => {
|
||||
pathnameMock = '/datasets/abc/pipeline'
|
||||
|
||||
render(
|
||||
<GotoAnythingProvider>
|
||||
<ContextConsumer />
|
||||
</GotoAnythingProvider>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('status')).toHaveTextContent('false|true')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
import React from 'react'
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import GotoAnything from './index'
|
||||
import type { ActionItem, SearchResult } from './actions/types'
|
||||
|
||||
const routerPush = jest.fn()
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: routerPush,
|
||||
}),
|
||||
usePathname: () => '/',
|
||||
}))
|
||||
|
||||
const keyPressHandlers: Record<string, (event: any) => void> = {}
|
||||
jest.mock('ahooks', () => ({
|
||||
useDebounce: (value: any) => value,
|
||||
useKeyPress: (keys: string | string[], handler: (event: any) => void) => {
|
||||
const keyList = Array.isArray(keys) ? keys : [keys]
|
||||
keyList.forEach((key) => {
|
||||
keyPressHandlers[key] = handler
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
const triggerKeyPress = (combo: string) => {
|
||||
const handler = keyPressHandlers[combo]
|
||||
if (handler) {
|
||||
act(() => {
|
||||
handler({ preventDefault: jest.fn(), target: document.body })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let mockQueryResult = { data: [] as SearchResult[], isLoading: false, isError: false, error: null as Error | null }
|
||||
jest.mock('@tanstack/react-query', () => ({
|
||||
useQuery: () => mockQueryResult,
|
||||
}))
|
||||
|
||||
jest.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: () => 'en_US',
|
||||
}))
|
||||
|
||||
const contextValue = { isWorkflowPage: false, isRagPipelinePage: false }
|
||||
jest.mock('./context', () => ({
|
||||
useGotoAnythingContext: () => contextValue,
|
||||
GotoAnythingProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}))
|
||||
|
||||
const createActionItem = (key: ActionItem['key'], shortcut: string): ActionItem => ({
|
||||
key,
|
||||
shortcut,
|
||||
title: `${key} title`,
|
||||
description: `${key} desc`,
|
||||
action: jest.fn(),
|
||||
search: jest.fn(),
|
||||
})
|
||||
|
||||
const actionsMock = {
|
||||
slash: createActionItem('/', '/'),
|
||||
app: createActionItem('@app', '@app'),
|
||||
plugin: createActionItem('@plugin', '@plugin'),
|
||||
}
|
||||
|
||||
const createActionsMock = jest.fn(() => actionsMock)
|
||||
const matchActionMock = jest.fn(() => undefined)
|
||||
const searchAnythingMock = jest.fn(async () => mockQueryResult.data)
|
||||
|
||||
jest.mock('./actions', () => ({
|
||||
__esModule: true,
|
||||
createActions: () => createActionsMock(),
|
||||
matchAction: () => matchActionMock(),
|
||||
searchAnything: () => searchAnythingMock(),
|
||||
}))
|
||||
|
||||
jest.mock('./actions/commands', () => ({
|
||||
SlashCommandProvider: () => null,
|
||||
}))
|
||||
|
||||
jest.mock('./actions/commands/registry', () => ({
|
||||
slashCommandRegistry: {
|
||||
findCommand: () => null,
|
||||
getAvailableCommands: () => [],
|
||||
getAllCommands: () => [],
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/workflow/utils/common', () => ({
|
||||
getKeyboardKeyCodeBySystem: () => 'ctrl',
|
||||
isEventTargetInputArea: () => false,
|
||||
isMac: () => false,
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/workflow/utils/node-navigation', () => ({
|
||||
selectWorkflowNode: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('../plugins/install-plugin/install-from-marketplace', () => (props: { manifest?: { name?: string }, onClose: () => void }) => (
|
||||
<div data-testid="install-modal">
|
||||
<span>{props.manifest?.name}</span>
|
||||
<button onClick={props.onClose}>close</button>
|
||||
</div>
|
||||
))
|
||||
|
||||
describe('GotoAnything', () => {
|
||||
beforeEach(() => {
|
||||
routerPush.mockClear()
|
||||
Object.keys(keyPressHandlers).forEach(key => delete keyPressHandlers[key])
|
||||
mockQueryResult = { data: [], isLoading: false, isError: false, error: null }
|
||||
matchActionMock.mockReset()
|
||||
searchAnythingMock.mockClear()
|
||||
})
|
||||
|
||||
it('should open modal via shortcut and navigate to selected result', async () => {
|
||||
mockQueryResult = {
|
||||
data: [{
|
||||
id: 'app-1',
|
||||
type: 'app',
|
||||
title: 'Sample App',
|
||||
description: 'desc',
|
||||
path: '/apps/1',
|
||||
icon: <div data-testid="icon">🧩</div>,
|
||||
data: {},
|
||||
} as any],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}
|
||||
|
||||
render(<GotoAnything />)
|
||||
|
||||
triggerKeyPress('ctrl.k')
|
||||
|
||||
const input = await screen.findByPlaceholderText('app.gotoAnything.searchPlaceholder')
|
||||
await userEvent.type(input, 'app')
|
||||
|
||||
const result = await screen.findByText('Sample App')
|
||||
await userEvent.click(result)
|
||||
|
||||
expect(routerPush).toHaveBeenCalledWith('/apps/1')
|
||||
})
|
||||
|
||||
it('should open plugin installer when selecting plugin result', async () => {
|
||||
mockQueryResult = {
|
||||
data: [{
|
||||
id: 'plugin-1',
|
||||
type: 'plugin',
|
||||
title: 'Plugin Item',
|
||||
description: 'desc',
|
||||
path: '',
|
||||
icon: <div />,
|
||||
data: {
|
||||
name: 'Plugin Item',
|
||||
latest_package_identifier: 'pkg',
|
||||
},
|
||||
} as any],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}
|
||||
|
||||
render(<GotoAnything />)
|
||||
|
||||
triggerKeyPress('ctrl.k')
|
||||
const input = await screen.findByPlaceholderText('app.gotoAnything.searchPlaceholder')
|
||||
await userEvent.type(input, 'plugin')
|
||||
|
||||
const pluginItem = await screen.findByText('Plugin Item')
|
||||
await userEvent.click(pluginItem)
|
||||
|
||||
expect(await screen.findByTestId('install-modal')).toHaveTextContent('Plugin Item')
|
||||
})
|
||||
})
|
||||
|
|
@ -4,6 +4,7 @@ import React, { useMemo, useState } from 'react'
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { produce } from 'immer'
|
||||
import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderParameter, WorkflowToolProviderRequest } from '../types'
|
||||
import { buildWorkflowOutputParameters } from './utils'
|
||||
import cn from '@/utils/classnames'
|
||||
import Drawer from '@/app/components/base/drawer-plus'
|
||||
import Input from '@/app/components/base/input'
|
||||
|
|
@ -47,7 +48,9 @@ const WorkflowToolAsModal: FC<Props> = ({
|
|||
const [name, setName] = useState(payload.name)
|
||||
const [description, setDescription] = useState(payload.description)
|
||||
const [parameters, setParameters] = useState<WorkflowToolProviderParameter[]>(payload.parameters)
|
||||
const outputParameters = useMemo<WorkflowToolProviderOutputParameter[]>(() => payload.outputParameters, [payload.outputParameters])
|
||||
const rawOutputParameters = payload.outputParameters
|
||||
const outputSchema = payload.tool?.output_schema
|
||||
const outputParameters = useMemo<WorkflowToolProviderOutputParameter[]>(() => buildWorkflowOutputParameters(rawOutputParameters, outputSchema), [rawOutputParameters, outputSchema])
|
||||
const reservedOutputParameters: WorkflowToolProviderOutputParameter[] = [
|
||||
{
|
||||
name: 'text',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
import { VarType } from '@/app/components/workflow/types'
|
||||
import type { WorkflowToolProviderOutputParameter, WorkflowToolProviderOutputSchema } from '../types'
|
||||
import { buildWorkflowOutputParameters } from './utils'
|
||||
|
||||
describe('buildWorkflowOutputParameters', () => {
|
||||
it('returns provided output parameters when array input exists', () => {
|
||||
const params: WorkflowToolProviderOutputParameter[] = [
|
||||
{ name: 'text', description: 'final text', type: VarType.string },
|
||||
]
|
||||
|
||||
const result = buildWorkflowOutputParameters(params, null)
|
||||
|
||||
expect(result).toBe(params)
|
||||
})
|
||||
|
||||
it('derives parameters from schema when explicit array missing', () => {
|
||||
const schema: WorkflowToolProviderOutputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
answer: {
|
||||
type: VarType.string,
|
||||
description: 'AI answer',
|
||||
},
|
||||
attachments: {
|
||||
type: VarType.arrayFile,
|
||||
description: 'Supporting files',
|
||||
},
|
||||
unknown: {
|
||||
type: 'custom',
|
||||
description: 'Unsupported type',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const result = buildWorkflowOutputParameters(undefined, schema)
|
||||
|
||||
expect(result).toEqual([
|
||||
{ name: 'answer', description: 'AI answer', type: VarType.string },
|
||||
{ name: 'attachments', description: 'Supporting files', type: VarType.arrayFile },
|
||||
{ name: 'unknown', description: 'Unsupported type', type: undefined },
|
||||
])
|
||||
})
|
||||
|
||||
it('returns empty array when no source information is provided', () => {
|
||||
expect(buildWorkflowOutputParameters(null, null)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import type { WorkflowToolProviderOutputParameter, WorkflowToolProviderOutputSchema } from '../types'
|
||||
import { VarType } from '@/app/components/workflow/types'
|
||||
|
||||
const validVarTypes = new Set<string>(Object.values(VarType))
|
||||
|
||||
const normalizeVarType = (type?: string): VarType | undefined => {
|
||||
if (!type)
|
||||
return undefined
|
||||
|
||||
return validVarTypes.has(type) ? type as VarType : undefined
|
||||
}
|
||||
|
||||
export const buildWorkflowOutputParameters = (
|
||||
outputParameters: WorkflowToolProviderOutputParameter[] | null | undefined,
|
||||
outputSchema?: WorkflowToolProviderOutputSchema | null,
|
||||
): WorkflowToolProviderOutputParameter[] => {
|
||||
if (Array.isArray(outputParameters))
|
||||
return outputParameters
|
||||
|
||||
if (!outputSchema?.properties)
|
||||
return []
|
||||
|
||||
return Object.entries(outputSchema.properties).map(([name, schema]) => ({
|
||||
name,
|
||||
description: schema.description,
|
||||
type: normalizeVarType(schema.type),
|
||||
}))
|
||||
}
|
||||
|
|
@ -39,7 +39,7 @@ describe('ChatVariableTrigger', () => {
|
|||
render(<ChatVariableTrigger />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId('chat-variable-button')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'ChatVariableButton' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -54,7 +54,7 @@ describe('ChatVariableTrigger', () => {
|
|||
render(<ChatVariableTrigger />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('chat-variable-button')).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: 'ChatVariableButton' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('should render disabled ChatVariableButton when nodes are read-only', () => {
|
||||
|
|
@ -66,7 +66,7 @@ describe('ChatVariableTrigger', () => {
|
|||
render(<ChatVariableTrigger />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('chat-variable-button')).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'ChatVariableButton' })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import type { ReactElement } from 'react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import type { AppPublisherProps } from '@/app/components/app/app-publisher'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
|
||||
import FeaturesTrigger from './features-trigger'
|
||||
|
||||
|
|
@ -10,7 +13,6 @@ const mockUseNodesReadOnly = jest.fn()
|
|||
const mockUseChecklist = jest.fn()
|
||||
const mockUseChecklistBeforePublish = jest.fn()
|
||||
const mockUseNodesSyncDraft = jest.fn()
|
||||
const mockUseToastContext = jest.fn()
|
||||
const mockUseFeatures = jest.fn()
|
||||
const mockUseProviderContext = jest.fn()
|
||||
const mockUseNodes = jest.fn()
|
||||
|
|
@ -45,8 +47,6 @@ const mockWorkflowStore = {
|
|||
setState: mockWorkflowStoreSetState,
|
||||
}
|
||||
|
||||
let capturedAppPublisherProps: Record<string, unknown> | null = null
|
||||
|
||||
jest.mock('@/app/components/workflow/hooks', () => ({
|
||||
__esModule: true,
|
||||
useChecklist: (...args: unknown[]) => mockUseChecklist(...args),
|
||||
|
|
@ -75,11 +75,6 @@ jest.mock('@/app/components/base/features/hooks', () => ({
|
|||
useFeatures: (selector: (state: Record<string, unknown>) => unknown) => mockUseFeatures(selector),
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/base/toast', () => ({
|
||||
__esModule: true,
|
||||
useToastContext: () => mockUseToastContext(),
|
||||
}))
|
||||
|
||||
jest.mock('@/context/provider-context', () => ({
|
||||
__esModule: true,
|
||||
useProviderContext: () => mockUseProviderContext(),
|
||||
|
|
@ -97,14 +92,33 @@ jest.mock('reactflow', () => ({
|
|||
|
||||
jest.mock('@/app/components/app/app-publisher', () => ({
|
||||
__esModule: true,
|
||||
default: (props: Record<string, unknown>) => {
|
||||
capturedAppPublisherProps = props
|
||||
default: (props: AppPublisherProps) => {
|
||||
const inputs = props.inputs ?? []
|
||||
return (
|
||||
<div
|
||||
data-testid='app-publisher'
|
||||
data-disabled={String(Boolean(props.disabled))}
|
||||
data-publish-disabled={String(Boolean(props.publishDisabled))}
|
||||
/>
|
||||
data-start-node-limit-exceeded={String(Boolean(props.startNodeLimitExceeded))}
|
||||
data-has-trigger-node={String(Boolean(props.hasTriggerNode))}
|
||||
data-inputs={JSON.stringify(inputs)}
|
||||
>
|
||||
<button type="button" onClick={() => { props.onRefreshData?.() }}>
|
||||
publisher-refresh
|
||||
</button>
|
||||
<button type="button" onClick={() => { props.onToggle?.(true) }}>
|
||||
publisher-toggle-on
|
||||
</button>
|
||||
<button type="button" onClick={() => { props.onToggle?.(false) }}>
|
||||
publisher-toggle-off
|
||||
</button>
|
||||
<button type="button" onClick={() => { Promise.resolve(props.onPublish?.()).catch(() => undefined) }}>
|
||||
publisher-publish
|
||||
</button>
|
||||
<button type="button" onClick={() => { Promise.resolve(props.onPublish?.({ title: 'Test title', releaseNotes: 'Test notes' })).catch(() => undefined) }}>
|
||||
publisher-publish-with-params
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
|
@ -147,10 +161,17 @@ const createProviderContext = ({
|
|||
isFetchedPlan,
|
||||
})
|
||||
|
||||
const renderWithToast = (ui: ReactElement) => {
|
||||
return render(
|
||||
<ToastContext.Provider value={{ notify: mockNotify, close: jest.fn() }}>
|
||||
{ui}
|
||||
</ToastContext.Provider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('FeaturesTrigger', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
capturedAppPublisherProps = null
|
||||
workflowStoreState = {
|
||||
showFeaturesPanel: false,
|
||||
isRestoring: false,
|
||||
|
|
@ -165,7 +186,6 @@ describe('FeaturesTrigger', () => {
|
|||
mockUseChecklistBeforePublish.mockReturnValue({ handleCheckBeforePublish: mockHandleCheckBeforePublish })
|
||||
mockHandleCheckBeforePublish.mockResolvedValue(true)
|
||||
mockUseNodesSyncDraft.mockReturnValue({ handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft })
|
||||
mockUseToastContext.mockReturnValue({ notify: mockNotify })
|
||||
mockUseFeatures.mockImplementation((selector: (state: Record<string, unknown>) => unknown) => selector({ features: { file: {} } }))
|
||||
mockUseProviderContext.mockReturnValue(createProviderContext({}))
|
||||
mockUseNodes.mockReturnValue([])
|
||||
|
|
@ -182,7 +202,7 @@ describe('FeaturesTrigger', () => {
|
|||
mockUseIsChatMode.mockReturnValue(false)
|
||||
|
||||
// Act
|
||||
render(<FeaturesTrigger />)
|
||||
renderWithToast(<FeaturesTrigger />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByRole('button', { name: /workflow\.common\.features/i })).not.toBeInTheDocument()
|
||||
|
|
@ -193,7 +213,7 @@ describe('FeaturesTrigger', () => {
|
|||
mockUseIsChatMode.mockReturnValue(true)
|
||||
|
||||
// Act
|
||||
render(<FeaturesTrigger />)
|
||||
renderWithToast(<FeaturesTrigger />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('button', { name: /workflow\.common\.features/i })).toBeInTheDocument()
|
||||
|
|
@ -205,7 +225,7 @@ describe('FeaturesTrigger', () => {
|
|||
mockUseTheme.mockReturnValue({ theme: 'dark' })
|
||||
|
||||
// Act
|
||||
render(<FeaturesTrigger />)
|
||||
renderWithToast(<FeaturesTrigger />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('button', { name: /workflow\.common\.features/i })).toHaveClass('rounded-lg')
|
||||
|
|
@ -220,7 +240,7 @@ describe('FeaturesTrigger', () => {
|
|||
mockUseIsChatMode.mockReturnValue(true)
|
||||
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false, getNodesReadOnly: () => false })
|
||||
|
||||
render(<FeaturesTrigger />)
|
||||
renderWithToast(<FeaturesTrigger />)
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByRole('button', { name: /workflow\.common\.features/i }))
|
||||
|
|
@ -242,7 +262,7 @@ describe('FeaturesTrigger', () => {
|
|||
isRestoring: false,
|
||||
}
|
||||
|
||||
render(<FeaturesTrigger />)
|
||||
renderWithToast(<FeaturesTrigger />)
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByRole('button', { name: /workflow\.common\.features/i }))
|
||||
|
|
@ -260,10 +280,9 @@ describe('FeaturesTrigger', () => {
|
|||
mockUseNodes.mockReturnValue([])
|
||||
|
||||
// Act
|
||||
render(<FeaturesTrigger />)
|
||||
renderWithToast(<FeaturesTrigger />)
|
||||
|
||||
// Assert
|
||||
expect(capturedAppPublisherProps?.disabled).toBe(true)
|
||||
expect(screen.getByTestId('app-publisher')).toHaveAttribute('data-disabled', 'true')
|
||||
})
|
||||
})
|
||||
|
|
@ -280,10 +299,15 @@ describe('FeaturesTrigger', () => {
|
|||
])
|
||||
|
||||
// Act
|
||||
render(<FeaturesTrigger />)
|
||||
renderWithToast(<FeaturesTrigger />)
|
||||
|
||||
// Assert
|
||||
const inputs = (capturedAppPublisherProps?.inputs as unknown as Array<{ type?: string; variable?: string }>) || []
|
||||
const inputs = JSON.parse(screen.getByTestId('app-publisher').getAttribute('data-inputs') ?? '[]') as Array<{
|
||||
type?: string
|
||||
variable?: string
|
||||
required?: boolean
|
||||
label?: string
|
||||
}>
|
||||
expect(inputs).toContainEqual({
|
||||
type: InputVarType.files,
|
||||
variable: '__image',
|
||||
|
|
@ -302,51 +326,49 @@ describe('FeaturesTrigger', () => {
|
|||
])
|
||||
|
||||
// Act
|
||||
render(<FeaturesTrigger />)
|
||||
renderWithToast(<FeaturesTrigger />)
|
||||
|
||||
// Assert
|
||||
expect(capturedAppPublisherProps?.startNodeLimitExceeded).toBe(true)
|
||||
expect(capturedAppPublisherProps?.publishDisabled).toBe(true)
|
||||
expect(capturedAppPublisherProps?.hasTriggerNode).toBe(true)
|
||||
const publisher = screen.getByTestId('app-publisher')
|
||||
expect(publisher).toHaveAttribute('data-start-node-limit-exceeded', 'true')
|
||||
expect(publisher).toHaveAttribute('data-publish-disabled', 'true')
|
||||
expect(publisher).toHaveAttribute('data-has-trigger-node', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
// Verifies callbacks wired from AppPublisher to stores and draft syncing.
|
||||
describe('Callbacks', () => {
|
||||
it('should set toolPublished when AppPublisher refreshes data', () => {
|
||||
it('should set toolPublished when AppPublisher refreshes data', async () => {
|
||||
// Arrange
|
||||
render(<FeaturesTrigger />)
|
||||
const refresh = capturedAppPublisherProps?.onRefreshData as unknown as (() => void) | undefined
|
||||
expect(refresh).toBeDefined()
|
||||
const user = userEvent.setup()
|
||||
renderWithToast(<FeaturesTrigger />)
|
||||
|
||||
// Act
|
||||
refresh?.()
|
||||
await user.click(screen.getByRole('button', { name: 'publisher-refresh' }))
|
||||
|
||||
// Assert
|
||||
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ toolPublished: true })
|
||||
})
|
||||
|
||||
it('should sync workflow draft when AppPublisher toggles on', () => {
|
||||
it('should sync workflow draft when AppPublisher toggles on', async () => {
|
||||
// Arrange
|
||||
render(<FeaturesTrigger />)
|
||||
const onToggle = capturedAppPublisherProps?.onToggle as unknown as ((state: boolean) => void) | undefined
|
||||
expect(onToggle).toBeDefined()
|
||||
const user = userEvent.setup()
|
||||
renderWithToast(<FeaturesTrigger />)
|
||||
|
||||
// Act
|
||||
onToggle?.(true)
|
||||
await user.click(screen.getByRole('button', { name: 'publisher-toggle-on' }))
|
||||
|
||||
// Assert
|
||||
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should not sync workflow draft when AppPublisher toggles off', () => {
|
||||
it('should not sync workflow draft when AppPublisher toggles off', async () => {
|
||||
// Arrange
|
||||
render(<FeaturesTrigger />)
|
||||
const onToggle = capturedAppPublisherProps?.onToggle as unknown as ((state: boolean) => void) | undefined
|
||||
expect(onToggle).toBeDefined()
|
||||
const user = userEvent.setup()
|
||||
renderWithToast(<FeaturesTrigger />)
|
||||
|
||||
// Act
|
||||
onToggle?.(false)
|
||||
await user.click(screen.getByRole('button', { name: 'publisher-toggle-off' }))
|
||||
|
||||
// Assert
|
||||
expect(mockHandleSyncWorkflowDraft).not.toHaveBeenCalled()
|
||||
|
|
@ -357,61 +379,62 @@ describe('FeaturesTrigger', () => {
|
|||
describe('Publishing', () => {
|
||||
it('should notify error and reject publish when checklist has warning nodes', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
mockUseChecklist.mockReturnValue([{ id: 'warning' }])
|
||||
render(<FeaturesTrigger />)
|
||||
|
||||
const onPublish = capturedAppPublisherProps?.onPublish as unknown as (() => Promise<void>) | undefined
|
||||
expect(onPublish).toBeDefined()
|
||||
renderWithToast(<FeaturesTrigger />)
|
||||
|
||||
// Act
|
||||
await expect(onPublish?.()).rejects.toThrow('Checklist has unresolved items')
|
||||
await user.click(screen.getByRole('button', { name: 'publisher-publish' }))
|
||||
|
||||
// Assert
|
||||
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'workflow.panel.checklistTip' })
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'workflow.panel.checklistTip' })
|
||||
})
|
||||
expect(mockPublishWorkflow).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reject publish when checklist before publish fails', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
mockHandleCheckBeforePublish.mockResolvedValue(false)
|
||||
render(<FeaturesTrigger />)
|
||||
|
||||
const onPublish = capturedAppPublisherProps?.onPublish as unknown as (() => Promise<void>) | undefined
|
||||
expect(onPublish).toBeDefined()
|
||||
renderWithToast(<FeaturesTrigger />)
|
||||
|
||||
// Act & Assert
|
||||
await expect(onPublish?.()).rejects.toThrow('Checklist failed')
|
||||
await user.click(screen.getByRole('button', { name: 'publisher-publish' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleCheckBeforePublish).toHaveBeenCalled()
|
||||
})
|
||||
expect(mockPublishWorkflow).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should publish workflow and update related stores when validation passes', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
mockUseNodes.mockReturnValue([
|
||||
{ id: 'start', data: { type: BlockEnum.Start } },
|
||||
])
|
||||
mockUseEdges.mockReturnValue([
|
||||
{ source: 'start' },
|
||||
])
|
||||
render(<FeaturesTrigger />)
|
||||
|
||||
const onPublish = capturedAppPublisherProps?.onPublish as unknown as (() => Promise<void>) | undefined
|
||||
expect(onPublish).toBeDefined()
|
||||
renderWithToast(<FeaturesTrigger />)
|
||||
|
||||
// Act
|
||||
await onPublish?.()
|
||||
await user.click(screen.getByRole('button', { name: 'publisher-publish' }))
|
||||
|
||||
// Assert
|
||||
expect(mockPublishWorkflow).toHaveBeenCalledWith({
|
||||
url: '/apps/app-id/workflows/publish',
|
||||
title: '',
|
||||
releaseNotes: '',
|
||||
})
|
||||
expect(mockUpdatePublishedWorkflow).toHaveBeenCalledWith('app-id')
|
||||
expect(mockInvalidateAppTriggers).toHaveBeenCalledWith('app-id')
|
||||
expect(mockSetPublishedAt).toHaveBeenCalledWith('2024-01-01T00:00:00Z')
|
||||
expect(mockSetLastPublishedHasUserInput).toHaveBeenCalledWith(true)
|
||||
expect(mockResetWorkflowVersionHistory).toHaveBeenCalled()
|
||||
expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'common.api.actionSuccess' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPublishWorkflow).toHaveBeenCalledWith({
|
||||
url: '/apps/app-id/workflows/publish',
|
||||
title: '',
|
||||
releaseNotes: '',
|
||||
})
|
||||
expect(mockUpdatePublishedWorkflow).toHaveBeenCalledWith('app-id')
|
||||
expect(mockInvalidateAppTriggers).toHaveBeenCalledWith('app-id')
|
||||
expect(mockSetPublishedAt).toHaveBeenCalledWith('2024-01-01T00:00:00Z')
|
||||
expect(mockSetLastPublishedHasUserInput).toHaveBeenCalledWith(true)
|
||||
expect(mockResetWorkflowVersionHistory).toHaveBeenCalled()
|
||||
expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'common.api.actionSuccess' })
|
||||
expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-id' })
|
||||
expect(mockSetAppDetail).toHaveBeenCalled()
|
||||
})
|
||||
|
|
@ -419,34 +442,32 @@ describe('FeaturesTrigger', () => {
|
|||
|
||||
it('should pass publish params to workflow publish mutation', async () => {
|
||||
// Arrange
|
||||
render(<FeaturesTrigger />)
|
||||
|
||||
const onPublish = capturedAppPublisherProps?.onPublish as unknown as ((params: { title: string; releaseNotes: string }) => Promise<void>) | undefined
|
||||
expect(onPublish).toBeDefined()
|
||||
const user = userEvent.setup()
|
||||
renderWithToast(<FeaturesTrigger />)
|
||||
|
||||
// Act
|
||||
await onPublish?.({ title: 'Test title', releaseNotes: 'Test notes' })
|
||||
await user.click(screen.getByRole('button', { name: 'publisher-publish-with-params' }))
|
||||
|
||||
// Assert
|
||||
expect(mockPublishWorkflow).toHaveBeenCalledWith({
|
||||
url: '/apps/app-id/workflows/publish',
|
||||
title: 'Test title',
|
||||
releaseNotes: 'Test notes',
|
||||
await waitFor(() => {
|
||||
expect(mockPublishWorkflow).toHaveBeenCalledWith({
|
||||
url: '/apps/app-id/workflows/publish',
|
||||
title: 'Test title',
|
||||
releaseNotes: 'Test notes',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should log error when app detail refresh fails after publish', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
mockFetchAppDetail.mockRejectedValue(new Error('fetch failed'))
|
||||
|
||||
render(<FeaturesTrigger />)
|
||||
|
||||
const onPublish = capturedAppPublisherProps?.onPublish as unknown as (() => Promise<void>) | undefined
|
||||
expect(onPublish).toBeDefined()
|
||||
renderWithToast(<FeaturesTrigger />)
|
||||
|
||||
// Act
|
||||
await onPublish?.()
|
||||
await user.click(screen.getByRole('button', { name: 'publisher-publish' }))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
|
|
|
|||
|
|
@ -1,16 +1,14 @@
|
|||
import { render } from '@testing-library/react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import type { App } from '@/types/app'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import type { HeaderProps } from '@/app/components/workflow/header'
|
||||
import WorkflowHeader from './index'
|
||||
import { fetchWorkflowRunHistory } from '@/service/workflow'
|
||||
|
||||
const mockUseAppStoreSelector = jest.fn()
|
||||
const mockSetCurrentLogItem = jest.fn()
|
||||
const mockSetShowMessageLogModal = jest.fn()
|
||||
const mockResetWorkflowVersionHistory = jest.fn()
|
||||
|
||||
let capturedHeaderProps: HeaderProps | null = null
|
||||
let appDetail: App
|
||||
|
||||
jest.mock('ky', () => ({
|
||||
|
|
@ -39,8 +37,31 @@ jest.mock('@/app/components/app/store', () => ({
|
|||
jest.mock('@/app/components/workflow/header', () => ({
|
||||
__esModule: true,
|
||||
default: (props: HeaderProps) => {
|
||||
capturedHeaderProps = props
|
||||
return <div data-testid='workflow-header' />
|
||||
const historyFetcher = props.normal?.runAndHistoryProps?.viewHistoryProps?.historyFetcher
|
||||
const hasHistoryFetcher = typeof historyFetcher === 'function'
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid='workflow-header'
|
||||
data-show-run={String(Boolean(props.normal?.runAndHistoryProps?.showRunButton))}
|
||||
data-show-preview={String(Boolean(props.normal?.runAndHistoryProps?.showPreviewButton))}
|
||||
data-history-url={props.normal?.runAndHistoryProps?.viewHistoryProps?.historyUrl ?? ''}
|
||||
data-has-history-fetcher={String(hasHistoryFetcher)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.normal?.runAndHistoryProps?.viewHistoryProps?.onClearLogAndMessageModal?.()}
|
||||
>
|
||||
clear-history
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.restoring?.onRestoreSettled?.()}
|
||||
>
|
||||
restore-settled
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
|
|
@ -57,7 +78,6 @@ jest.mock('@/service/use-workflow', () => ({
|
|||
describe('WorkflowHeader', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
capturedHeaderProps = null
|
||||
appDetail = { id: 'app-id', mode: AppModeEnum.COMPLETION } as unknown as App
|
||||
|
||||
mockUseAppStoreSelector.mockImplementation(selector => selector({
|
||||
|
|
@ -74,7 +94,7 @@ describe('WorkflowHeader', () => {
|
|||
render(<WorkflowHeader />)
|
||||
|
||||
// Assert
|
||||
expect(capturedHeaderProps).not.toBeNull()
|
||||
expect(screen.getByTestId('workflow-header')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -93,10 +113,11 @@ describe('WorkflowHeader', () => {
|
|||
render(<WorkflowHeader />)
|
||||
|
||||
// Assert
|
||||
expect(capturedHeaderProps?.normal?.runAndHistoryProps?.showRunButton).toBe(false)
|
||||
expect(capturedHeaderProps?.normal?.runAndHistoryProps?.showPreviewButton).toBe(true)
|
||||
expect(capturedHeaderProps?.normal?.runAndHistoryProps?.viewHistoryProps?.historyUrl).toBe('/apps/app-id/advanced-chat/workflow-runs')
|
||||
expect(capturedHeaderProps?.normal?.runAndHistoryProps?.viewHistoryProps?.historyFetcher).toBe(fetchWorkflowRunHistory)
|
||||
const header = screen.getByTestId('workflow-header')
|
||||
expect(header).toHaveAttribute('data-show-run', 'false')
|
||||
expect(header).toHaveAttribute('data-show-preview', 'true')
|
||||
expect(header).toHaveAttribute('data-history-url', '/apps/app-id/advanced-chat/workflow-runs')
|
||||
expect(header).toHaveAttribute('data-has-history-fetcher', 'true')
|
||||
})
|
||||
|
||||
it('should configure run mode when app is not in advanced chat mode', () => {
|
||||
|
|
@ -112,9 +133,11 @@ describe('WorkflowHeader', () => {
|
|||
render(<WorkflowHeader />)
|
||||
|
||||
// Assert
|
||||
expect(capturedHeaderProps?.normal?.runAndHistoryProps?.showRunButton).toBe(true)
|
||||
expect(capturedHeaderProps?.normal?.runAndHistoryProps?.showPreviewButton).toBe(false)
|
||||
expect(capturedHeaderProps?.normal?.runAndHistoryProps?.viewHistoryProps?.historyUrl).toBe('/apps/app-id/workflow-runs')
|
||||
const header = screen.getByTestId('workflow-header')
|
||||
expect(header).toHaveAttribute('data-show-run', 'true')
|
||||
expect(header).toHaveAttribute('data-show-preview', 'false')
|
||||
expect(header).toHaveAttribute('data-history-url', '/apps/app-id/workflow-runs')
|
||||
expect(header).toHaveAttribute('data-has-history-fetcher', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -124,11 +147,8 @@ describe('WorkflowHeader', () => {
|
|||
// Arrange
|
||||
render(<WorkflowHeader />)
|
||||
|
||||
const clear = capturedHeaderProps?.normal?.runAndHistoryProps?.viewHistoryProps?.onClearLogAndMessageModal
|
||||
expect(clear).toBeDefined()
|
||||
|
||||
// Act
|
||||
clear?.()
|
||||
screen.getByRole('button', { name: 'clear-history' }).click()
|
||||
|
||||
// Assert
|
||||
expect(mockSetCurrentLogItem).toHaveBeenCalledWith()
|
||||
|
|
@ -143,7 +163,8 @@ describe('WorkflowHeader', () => {
|
|||
render(<WorkflowHeader />)
|
||||
|
||||
// Assert
|
||||
expect(capturedHeaderProps?.restoring?.onRestoreSettled).toBe(mockResetWorkflowVersionHistory)
|
||||
screen.getByRole('button', { name: 'restore-settled' }).click()
|
||||
expect(mockResetWorkflowVersionHistory).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
@import '../../themes/dark.css';
|
||||
@import "../../themes/manual-light.css";
|
||||
@import "../../themes/manual-dark.css";
|
||||
@import "./monaco-sticky-fix.css";
|
||||
|
||||
@import "../components/base/button/index.css";
|
||||
@import "../components/base/action-button/index.css";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
/* Ensures Monaco sticky header and other sticky headers remain visible in dark mode */
|
||||
html[data-theme="dark"] .monaco-editor .sticky-widget {
|
||||
background-color: var(--color-components-sticky-header-bg) !important;
|
||||
border-bottom: 1px solid var(--color-components-sticky-header-border) !important;
|
||||
box-shadow: var(--vscode-editorStickyScroll-shadow) 0 4px 2px -2px !important;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .monaco-editor .sticky-line-content:hover {
|
||||
background-color: var(--color-components-sticky-header-bg-hover) !important;
|
||||
}
|
||||
|
||||
/* Fallback: any app sticky header using input-bg variables should use the sticky header bg when sticky */
|
||||
html[data-theme="dark"] .sticky, html[data-theme="dark"] .is-sticky {
|
||||
background-color: var(--color-components-sticky-header-bg) !important;
|
||||
border-bottom: 1px solid var(--color-components-sticky-header-border) !important;
|
||||
}
|
||||
|
|
@ -1,9 +1,31 @@
|
|||
import '@testing-library/jest-dom'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import { mockAnimationsApi } from 'jsdom-testing-mocks'
|
||||
|
||||
// Mock Web Animations API for Headless UI
|
||||
mockAnimationsApi()
|
||||
|
||||
// Suppress act() warnings from @headlessui/react internal Transition component
|
||||
// These warnings are caused by Headless UI's internal async state updates, not our code
|
||||
const originalConsoleError = console.error
|
||||
console.error = (...args: unknown[]) => {
|
||||
// Check all arguments for the Headless UI TransitionRootFn act warning
|
||||
const fullMessage = args.map(arg => (typeof arg === 'string' ? arg : '')).join(' ')
|
||||
if (fullMessage.includes('TransitionRootFn') && fullMessage.includes('not wrapped in act'))
|
||||
return
|
||||
originalConsoleError.apply(console, args)
|
||||
}
|
||||
|
||||
// Fix for @headlessui/react compatibility with happy-dom
|
||||
// headlessui tries to override focus properties which may be read-only in happy-dom
|
||||
if (typeof window !== 'undefined') {
|
||||
// Provide a minimal animations API polyfill before @headlessui/react boots
|
||||
if (typeof Element !== 'undefined' && !Element.prototype.getAnimations)
|
||||
Element.prototype.getAnimations = () => []
|
||||
|
||||
if (!document.getAnimations)
|
||||
document.getAnimations = () => []
|
||||
|
||||
const ensureWritable = (target: object, prop: string) => {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(target, prop)
|
||||
if (descriptor && !descriptor.writable) {
|
||||
|
|
|
|||
|
|
@ -201,6 +201,7 @@
|
|||
"globals": "^15.15.0",
|
||||
"husky": "^9.1.7",
|
||||
"jest": "^29.7.0",
|
||||
"jsdom-testing-mocks": "^1.16.0",
|
||||
"knip": "^5.66.1",
|
||||
"lint-staged": "^15.5.2",
|
||||
"lodash": "^4.17.21",
|
||||
|
|
|
|||
|
|
@ -515,6 +515,9 @@ importers:
|
|||
jest:
|
||||
specifier: ^29.7.0
|
||||
version: 29.7.0(@types/node@18.15.0)(ts-node@10.9.2(@types/node@18.15.0)(typescript@5.9.3))
|
||||
jsdom-testing-mocks:
|
||||
specifier: ^1.16.0
|
||||
version: 1.16.0
|
||||
knip:
|
||||
specifier: ^5.66.1
|
||||
version: 5.72.0(@types/node@18.15.0)(typescript@5.9.3)
|
||||
|
|
@ -4190,6 +4193,9 @@ packages:
|
|||
resolution: {integrity: sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
bezier-easing@2.1.0:
|
||||
resolution: {integrity: sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==}
|
||||
|
||||
big.js@5.2.2:
|
||||
resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==}
|
||||
|
||||
|
|
@ -4660,6 +4666,9 @@ packages:
|
|||
webpack:
|
||||
optional: true
|
||||
|
||||
css-mediaquery@0.1.2:
|
||||
resolution: {integrity: sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==}
|
||||
|
||||
css-select@4.3.0:
|
||||
resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==}
|
||||
|
||||
|
|
@ -6317,6 +6326,10 @@ packages:
|
|||
resolution: {integrity: sha512-F9GQ+F1ZU6qvSrZV8fNFpjDNf614YzR2eF6S0+XbDjAcUI28FSoXnYZFjQmb1kFx3rrJb5PnxUH3/Yti6fcM+g==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
jsdom-testing-mocks@1.16.0:
|
||||
resolution: {integrity: sha512-wLrulXiLpjmcUYOYGEvz4XARkrmdVpyxzdBl9IAMbQ+ib2/UhUTRCn49McdNfXLff2ysGBUms49ZKX0LR1Q0gg==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
jsesc@3.0.2:
|
||||
resolution: {integrity: sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==}
|
||||
engines: {node: '>=6'}
|
||||
|
|
@ -13070,6 +13083,8 @@ snapshots:
|
|||
dependencies:
|
||||
open: 8.4.2
|
||||
|
||||
bezier-easing@2.1.0: {}
|
||||
|
||||
big.js@5.2.2: {}
|
||||
|
||||
binary-extensions@2.3.0: {}
|
||||
|
|
@ -13577,6 +13592,8 @@ snapshots:
|
|||
optionalDependencies:
|
||||
webpack: 5.103.0(esbuild@0.25.0)(uglify-js@3.19.3)
|
||||
|
||||
css-mediaquery@0.1.2: {}
|
||||
|
||||
css-select@4.3.0:
|
||||
dependencies:
|
||||
boolbase: 1.0.0
|
||||
|
|
@ -15682,6 +15699,11 @@ snapshots:
|
|||
|
||||
jsdoc-type-pratt-parser@5.4.0: {}
|
||||
|
||||
jsdom-testing-mocks@1.16.0:
|
||||
dependencies:
|
||||
bezier-easing: 2.1.0
|
||||
css-mediaquery: 0.1.2
|
||||
|
||||
jsesc@3.0.2: {}
|
||||
|
||||
jsesc@3.1.0: {}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,18 @@ html[data-theme="dark"] {
|
|||
--color-components-input-bg-active: rgb(255 255 255 / 0.05);
|
||||
--color-components-input-border-active: #747481;
|
||||
--color-components-input-border-destructive: #f97066;
|
||||
|
||||
/* Sticky header / Monaco editor sticky scroll colors (dark mode) */
|
||||
/* Use solid panel background to ensure visibility when elements become sticky */
|
||||
--color-components-sticky-header-bg: var(--color-components-panel-bg);
|
||||
--color-components-sticky-header-bg-hover: var(--color-components-panel-on-panel-item-bg-hover);
|
||||
--color-components-sticky-header-border: var(--color-components-panel-border);
|
||||
|
||||
/* Override Monaco/VSCode CSS variables for sticky scroll so the sticky header is opaque */
|
||||
--vscode-editorStickyScroll-background: var(--color-components-sticky-header-bg);
|
||||
--vscode-editorStickyScrollHover-background: var(--color-components-sticky-header-bg-hover);
|
||||
--vscode-editorStickyScroll-border: var(--color-components-sticky-header-border);
|
||||
--vscode-editorStickyScroll-shadow: rgba(0, 0, 0, 0.6);
|
||||
--color-components-input-text-filled: #f4f4f5;
|
||||
--color-components-input-bg-destructive: rgb(255 255 255 / 0.01);
|
||||
--color-components-input-bg-disabled: rgb(255 255 255 / 0.03);
|
||||
|
|
|
|||
Loading…
Reference in New Issue