Skip to content

Commit 2876473

Browse files
feat: add module-level tests focus on core business (#491)
* feature: support data intelligence orchestation front * test(datamate-python): add comprehensive module test scenarios * chore(frontend): remove peer flags from package-lock
1 parent 8f47ceb commit 2876473

15 files changed

Lines changed: 1158 additions & 0 deletions
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from __future__ import annotations
2+
3+
import sys
4+
from pathlib import Path
5+
from types import ModuleType
6+
7+
8+
def _register_namespace(module_name: str, module_path: Path) -> None:
9+
namespace_pkg = ModuleType(module_name)
10+
namespace_pkg.__path__ = [str(module_path)] # type: ignore[attr-defined]
11+
sys.modules.setdefault(module_name, namespace_pkg)
12+
13+
14+
def pytest_sessionstart(session) -> None:
15+
"""避免测试导入 app.module.* 时触发 app/module/__init__.py 的重依赖加载。"""
16+
root = Path(__file__).resolve().parents[1] / "app" / "module"
17+
18+
_register_namespace("app.module", root)
19+
_register_namespace("app.module.cleaning", root / "cleaning")
20+
_register_namespace("app.module.cleaning.service", root / "cleaning" / "service")
21+
_register_namespace("app.module.rag", root / "rag")
22+
_register_namespace("app.module.rag.service", root / "rag" / "service")
23+
_register_namespace("app.module.rag.service.common", root / "rag" / "service" / "common")
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# -*- coding: utf-8 -*-
2+
3+
import unittest
4+
from unittest.mock import MagicMock, AsyncMock
5+
import sys
6+
import os
7+
8+
# 确保 runtime/datamate-python 目录在 sys.path 中
9+
TEST_DIR = os.path.dirname(os.path.abspath(__file__))
10+
APP_DIR = os.path.dirname(TEST_DIR)
11+
sys.path.insert(0, APP_DIR)
12+
13+
from app.module.dataset.service.service import Service
14+
from app.module.dataset.schema import DatasetResponse, PagedDatasetFileResponse, DatasetFileResponse
15+
from app.db.models import Dataset, DatasetFiles
16+
17+
18+
class TestDatasetService(unittest.IsolatedAsyncioTestCase):
19+
20+
def setUp(self):
21+
# 创建模拟的 AsyncSession 对象
22+
self.mock_db = MagicMock()
23+
self.mock_db.execute = AsyncMock()
24+
self.mock_db.commit = AsyncMock()
25+
self.mock_db.rollback = AsyncMock()
26+
self.mock_db.flush = AsyncMock()
27+
28+
# 初始化 Service
29+
self.service = Service(self.mock_db)
30+
31+
async def test_get_dataset_success(self):
32+
"""测试正常获取数据集详情"""
33+
# 准备 Mock 数据
34+
mock_dataset = Dataset(
35+
id="test-dataset-id",
36+
name="Test Dataset",
37+
description="A test description",
38+
dataset_type="TEXT",
39+
status="DRAFT",
40+
file_count=5,
41+
size_bytes=1024,
42+
created_by="system"
43+
)
44+
45+
# 模拟 db.execute 返回值
46+
mock_result = MagicMock()
47+
mock_result.scalar_one_or_none.return_value = mock_dataset
48+
self.mock_db.execute.return_value = mock_result
49+
50+
# 执行测试
51+
response = await self.service.get_dataset("test-dataset-id")
52+
53+
# 校验结果
54+
self.assertIsNotNone(response)
55+
self.assertEqual(response.id, "test-dataset-id")
56+
self.assertEqual(response.name, "Test Dataset")
57+
self.assertEqual(response.description, "A test description")
58+
self.assertEqual(response.datasetType, "TEXT")
59+
self.assertEqual(response.status, "DRAFT")
60+
self.assertEqual(response.fileCount, 5)
61+
self.assertEqual(response.totalSize, 1024)
62+
63+
async def test_get_dataset_not_found(self):
64+
"""测试获取不存在的数据集时返回 None"""
65+
# 模拟数据库未找到数据
66+
mock_result = MagicMock()
67+
mock_result.scalar_one_or_none.return_value = None
68+
self.mock_db.execute.return_value = mock_result
69+
70+
# 执行并验证
71+
response = await self.service.get_dataset("non-existent-id")
72+
self.assertIsNone(response)
73+
74+
async def test_create_dataset_success(self):
75+
"""测试创建数据集成功流程"""
76+
# 1. 模拟名称不存在检查 (select Dataset.name) -> 返回 None
77+
mock_result_check = MagicMock()
78+
mock_result_check.scalar_one_or_none.return_value = None
79+
self.mock_db.execute.return_value = mock_result_check
80+
81+
# 2. 调用创建服务
82+
response = await self.service.create_dataset(
83+
name="New Dataset",
84+
dataset_type="IMAGE",
85+
description="Testing create_dataset API",
86+
status="PUBLISHED"
87+
)
88+
89+
# 3. 验证结果
90+
self.assertIsNotNone(response)
91+
self.assertEqual(response.name, "New Dataset")
92+
self.assertEqual(response.datasetType, "IMAGE")
93+
self.assertEqual(response.description, "Testing create_dataset API")
94+
self.assertEqual(response.status, "PUBLISHED")
95+
96+
# 确认 db.add 和 db.commit 被调用
97+
self.mock_db.add.assert_called_once()
98+
self.mock_db.commit.assert_called_once()
99+
100+
async def test_create_dataset_duplicated_name(self):
101+
"""测试创建重名的数据集时抛出异常"""
102+
# 模拟冲突的已有数据集
103+
existing_dataset = Dataset(
104+
id="existing-id",
105+
name="Existing Dataset"
106+
)
107+
mock_result = MagicMock()
108+
mock_result.scalar_one_or_none.return_value = existing_dataset
109+
self.mock_db.execute.return_value = mock_result
110+
111+
# 检查是否正如预期抛出包含关键字 Exception
112+
with self.assertRaises(Exception) as context:
113+
await self.service.create_dataset(
114+
name="Existing Dataset",
115+
dataset_type="AUDIO"
116+
)
117+
self.assertIn("already exists", str(context.exception))
118+
119+
# 校验事务有无进行 commit
120+
self.mock_db.commit.assert_not_called()
121+
122+
123+
if __name__ == "__main__":
124+
unittest.main()
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
from app.module.annotation.utils.config_validator import LabelStudioConfigValidator
2+
import pytest
3+
4+
5+
def test_validate_xml_success_with_object_and_control() -> None:
6+
xml = """<View>
7+
<Image name=\"image\" value=\"$image\"/>
8+
<Choices name=\"label\" toName=\"image\">
9+
<Choice value=\"Cat\"/>
10+
<Choice value=\"Dog\"/>
11+
</Choices>
12+
</View>"""
13+
14+
valid, error = LabelStudioConfigValidator.validate_xml(xml)
15+
16+
assert valid is True
17+
assert error is None
18+
19+
20+
def test_validate_xml_fails_when_no_controls() -> None:
21+
xml = """<View><Image name=\"image\" value=\"$image\"/></View>"""
22+
23+
valid, error = LabelStudioConfigValidator.validate_xml(xml)
24+
25+
assert valid is False
26+
assert "No annotation controls" in (error or "")
27+
28+
29+
def test_validate_configuration_json_rejects_unknown_object_reference() -> None:
30+
config = {
31+
"labels": [
32+
{
33+
"fromName": "sentiment",
34+
"toName": "missing_object",
35+
"type": "Choices",
36+
"options": ["positive", "negative"],
37+
}
38+
],
39+
"objects": [
40+
{"name": "text", "type": "Text", "value": "$text"}
41+
],
42+
}
43+
44+
valid, error = LabelStudioConfigValidator.validate_configuration_json(config)
45+
46+
assert valid is False
47+
assert "unknown object" in (error or "")
48+
49+
50+
def test_extract_label_values() -> None:
51+
xml = """<View>
52+
<Text name=\"text\" value=\"$text\"/>
53+
<Choices name=\"sentiment\" toName=\"text\">
54+
<Choice value=\"positive\"/>
55+
<Choice value=\"negative\"/>
56+
</Choices>
57+
</View>"""
58+
59+
labels = LabelStudioConfigValidator.extract_label_values(xml)
60+
61+
assert labels == {"sentiment": ["positive", "negative"]}
62+
63+
64+
def test_validate_xml_rejects_invalid_root() -> None:
65+
xml = """<Root><Text name=\"text\" value=\"$text\"/></Root>"""
66+
67+
valid, error = LabelStudioConfigValidator.validate_xml(xml)
68+
69+
assert valid is False
70+
assert "Root element must be <View>" in (error or "")
71+
72+
73+
def test_validate_configuration_json_requires_labels() -> None:
74+
valid, error = LabelStudioConfigValidator.validate_configuration_json({"objects": []})
75+
76+
assert valid is False
77+
assert "Missing 'labels' field" in (error or "")
78+
79+
80+
def test_validate_xml_fails_for_invalid_xml() -> None:
81+
xml = "<View><Text></View>"
82+
valid, error = LabelStudioConfigValidator.validate_xml(xml)
83+
assert valid is False
84+
assert "XML parse error" in (error or "")
85+
86+
87+
@pytest.mark.parametrize(
88+
"label,error_text",
89+
[
90+
({"toName": "obj", "type": "Choices", "options": ["A"]}, "fromName"),
91+
({"fromName": "lbl", "type": "Choices", "options": ["A"]}, "toName"),
92+
({"fromName": "lbl", "toName": "obj", "options": ["A"]}, "type"),
93+
],
94+
)
95+
def test_validate_label_definition_required_fields(label, error_text: str) -> None:
96+
valid, error = LabelStudioConfigValidator._validate_label_definition(label)
97+
assert valid is False
98+
assert error_text in (error or "")
99+
100+
101+
def test_validate_label_definition_rejects_unsupported_type() -> None:
102+
label = {
103+
"fromName": "x",
104+
"toName": "obj",
105+
"type": "NotSupported",
106+
}
107+
valid, error = LabelStudioConfigValidator._validate_label_definition(label)
108+
assert valid is False
109+
assert "Unsupported control type" in (error or "")
110+
111+
112+
def test_validate_object_definition_rejects_value_without_dollar_prefix() -> None:
113+
obj = {"name": "txt", "type": "Text", "value": "text"}
114+
valid, error = LabelStudioConfigValidator._validate_object_definition(obj)
115+
assert valid is False
116+
assert "must start with '$'" in (error or "")
117+
118+
119+
def test_extract_label_values_returns_empty_on_invalid_xml() -> None:
120+
labels = LabelStudioConfigValidator.extract_label_values("<broken")
121+
assert labels == {}
122+
123+
124+
def test_validate_xml_requires_control_name_and_to_name() -> None:
125+
xml = """<View>
126+
<Text name=\"text\" value=\"$text\"/>
127+
<Choices toName=\"text\"><Choice value=\"A\"/></Choices>
128+
</View>"""
129+
valid, error = LabelStudioConfigValidator.validate_xml(xml)
130+
assert valid is False
131+
assert "Missing 'name' attribute" in (error or "")
132+
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import pytest
2+
3+
from app.core.exception import BusinessError
4+
from app.module.cleaning.schema.cleaning import OperatorInstanceDto
5+
from app.module.cleaning.service.cleaning_task_validator import CleaningTaskValidator
6+
from app.module.operator.constants import CATEGORY_DATAMATE_ID, CATEGORY_DATA_JUICER_ID
7+
8+
9+
def _op(op_id: str, inputs: str | None, outputs: str | None, categories: list[str] | None = None) -> OperatorInstanceDto:
10+
return OperatorInstanceDto(id=op_id, inputs=inputs, outputs=outputs, categories=categories)
11+
12+
13+
def test_check_input_and_output_passes_with_multimodal() -> None:
14+
instances = [
15+
_op("a", "text", "multimodal"),
16+
_op("b", "image", "text"),
17+
]
18+
19+
CleaningTaskValidator.check_input_and_output(instances)
20+
21+
22+
def test_check_input_and_output_raises_on_type_mismatch() -> None:
23+
instances = [
24+
_op("a", "text", "image"),
25+
_op("b", "text", "text"),
26+
]
27+
28+
with pytest.raises(BusinessError):
29+
CleaningTaskValidator.check_input_and_output(instances)
30+
31+
32+
def test_check_and_get_executor_type_raises_when_mixed_categories() -> None:
33+
instances = [
34+
_op("a", None, None, [CATEGORY_DATAMATE_ID]),
35+
_op("b", None, None, [CATEGORY_DATA_JUICER_ID]),
36+
]
37+
38+
with pytest.raises(BusinessError):
39+
CleaningTaskValidator.check_and_get_executor_type(instances)
40+
41+
42+
def test_check_and_get_executor_type_defaults_to_datamate() -> None:
43+
instances = [_op("a", None, None, None)]
44+
45+
executor = CleaningTaskValidator.check_and_get_executor_type(instances)
46+
47+
assert executor == "datamate"
48+
49+
50+
def test_check_task_id_raises_when_empty() -> None:
51+
with pytest.raises(BusinessError):
52+
CleaningTaskValidator.check_task_id("")
53+
54+
55+
def test_check_task_id_accepts_normal_value() -> None:
56+
CleaningTaskValidator.check_task_id("task-1")
57+
58+
59+
def test_check_input_and_output_returns_for_empty_instances() -> None:
60+
CleaningTaskValidator.check_input_and_output([])
61+
62+
63+
def test_check_input_and_output_raises_when_current_has_no_outputs() -> None:
64+
instances = [
65+
_op("a", "text", None),
66+
_op("b", "text", "text"),
67+
]
68+
with pytest.raises(BusinessError):
69+
CleaningTaskValidator.check_input_and_output(instances)
70+
71+
72+
def test_check_input_and_output_raises_when_next_has_no_inputs() -> None:
73+
instances = [
74+
_op("a", "text", "text"),
75+
_op("b", None, "text"),
76+
]
77+
with pytest.raises(BusinessError):
78+
CleaningTaskValidator.check_input_and_output(instances)
79+
80+
81+
@pytest.mark.parametrize(
82+
"out_type,in_type",
83+
[
84+
("text", "text"),
85+
(" image ", "image"),
86+
("AUDIO", "audio"),
87+
],
88+
)
89+
def test_check_input_and_output_allows_exact_match_with_normalization(out_type: str, in_type: str) -> None:
90+
instances = [
91+
_op("a", "x", out_type),
92+
_op("b", in_type, "y"),
93+
]
94+
CleaningTaskValidator.check_input_and_output(instances)
95+
96+
97+
def test_check_and_get_executor_type_prefers_datajuicer_when_only_datajuicer() -> None:
98+
instances = [_op("a", None, None, [CATEGORY_DATA_JUICER_ID])]
99+
assert CleaningTaskValidator.check_and_get_executor_type(instances) == "default"

0 commit comments

Comments
 (0)