diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 135cfbd65..a0d515a2f 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -20,24 +20,58 @@ jobs:
python-version: '3.10'
cache: 'pip'
cache-dependency-path: 'main/xiaozhi-server/requirements.txt'
+ - name: Install system dependencies
+ run: |
+ # opuslib_next 是 ctypes 绑定,pip 装上的是纯 Python 包,真正的编解码
+ # 靠系统里的 libopus.so。runner 上默认没有,import 会失败,音频相关的
+ # 用例直接报错。正式镜像 Dockerfile-server-base 里也是装 libopus0 的
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends libopus0
- name: Install dependencies
working-directory: main/xiaozhi-server
run: |
pip install -r requirements.txt
pip install pytest pytest-asyncio freezegun
- - name: Run smoke test
+ - name: Prepare test config
working-directory: main/xiaozhi-server
run: |
- if [ -f tests/test_smoke.py ]; then
- pytest tests/test_smoke.py -x -q
- else
- echo "::warning::Slice 2 not done yet — tests/test_smoke.py missing"
- exit 0
- fi
+ # config/settings.py 在 import 期就要这个文件,缺了整套用例连收集都过不去。
+ # data/ 在 .gitignore 里,只能由这一步现造
+ mkdir -p data
+ printf '{}\n' > data/.config.yaml
+ - name: Run tests
+ working-directory: main/xiaozhi-server
+ run: |
+ # Edge TTS 那条要连微软公网,runner 的出口 IP 会被风控挡掉,先排除。
+ # 根治办法是给它打 live 标记,让 mock 模式下自动不收(另开一条改)
+ pytest tests/ -v -k "not test_tts_text_to_speak_writes_file"
java:
name: Java (manager-api)
runs-on: ubuntu-latest
+ # 三个 @SpringBootTest 要真连 MySQL 和 Redis,空库即可,Liquibase 会自己建表
+ services:
+ mysql:
+ image: mysql:8.0
+ env:
+ MYSQL_ROOT_PASSWORD: 123456
+ MYSQL_DATABASE: xiaozhi_esp32_server
+ ports:
+ - 3306:3306
+ options: >-
+ --health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -p123456"
+ --health-interval=10s
+ --health-timeout=5s
+ --health-retries=20
+ redis:
+ image: redis:8.0
+ ports:
+ - 6379:6379
+ options: >-
+ --health-cmd="redis-cli ping"
+ --health-interval=10s
+ --health-timeout=5s
+ --health-retries=20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
@@ -47,7 +81,12 @@ jobs:
cache: 'maven'
- name: Run tests
working-directory: main/manager-api
- run: mvn -B -q test -DfailIfNoTests=false
+ run: mvn -B test -DskipTests=false -DfailIfNoTests=false
+ - name: Check the integration tests really ran
+ working-directory: main/manager-api
+ # 守卫在依赖不齐时会把整类集成测试跳过,这里盯一下它们有没有真跑,
+ # 免得门禁被悄悄掏空
+ run: bash scripts/check-integration-tests.sh
web:
name: Vue (manager-web)
@@ -63,4 +102,6 @@ jobs:
working-directory: main/manager-web
run: |
npm ci --no-audit --no-fund
+ npm run test:contract
npm run test:unit
+ npm run build
diff --git a/main/manager-api/README.md b/main/manager-api/README.md
index 484c28dc0..19683a66b 100644
--- a/main/manager-api/README.md
+++ b/main/manager-api/README.md
@@ -16,3 +16,28 @@ Vue 3.x
# 接口文档
启动后打开:http://localhost:8002/xiaozhi/doc.html
+# 跑测试
+测试分两种:纯单元测试,和要真连 MySQL、Redis 的集成测试(`DeviceTest`、`loginControllerTest`、`ModelConfigPersistenceTest`)。
+
+集成测试需要先起好 MySQL 8 和 Redis 8。没起的时候这类测试整类跳过,不会让构建变红,日志里会写明缺的是哪个依赖。CI 上不一样:依赖连不上就直接让构建失败,不许跳过,免得门禁被悄悄掏空。
+
+本地用 Docker 起这两个依赖:
+
+```bash
+docker run -d --name xiaozhi-mysql -p 3306:3306 \
+ -e MYSQL_ROOT_PASSWORD=123456 -e MYSQL_DATABASE=xiaozhi_esp32_server mysql:8.0
+docker run -d --name xiaozhi-redis -p 6379:6379 redis:8.0
+```
+
+跑全部测试:
+
+```bash
+mvn -B test -DskipTests=false
+```
+
+只想编译、不跑测试:
+
+```bash
+mvn -B package -DskipTests=true
+```
+
diff --git a/main/manager-api/pom.xml b/main/manager-api/pom.xml
index 455ee6e11..e94004843 100644
--- a/main/manager-api/pom.xml
+++ b/main/manager-api/pom.xml
@@ -32,7 +32,7 @@
4.20.0
4.1.0
3.4.0
- true
+ false
diff --git a/main/manager-api/scripts/check-integration-tests.sh b/main/manager-api/scripts/check-integration-tests.sh
new file mode 100755
index 000000000..526b80820
--- /dev/null
+++ b/main/manager-api/scripts/check-integration-tests.sh
@@ -0,0 +1,44 @@
+#!/usr/bin/env bash
+# 检查那几个要连 MySQL 和 Redis 的集成测试是不是真的跑了。
+# 守卫要是哪天手滑把它们全跳过了,CI 必须红,不能安安静静少跑一半门禁。
+set -uo pipefail
+
+REPORT_DIR="${1:-target/surefire-reports}"
+
+WATCHED=(
+ "xiaozhi.modules.device.DeviceTest"
+ "xiaozhi.modules.model.service.impl.ModelConfigPersistenceTest"
+ "xiaozhi.modules.sys.loginControllerTest"
+)
+
+if [ ! -d "$REPORT_DIR" ]; then
+ echo "找不到测试报告目录:$REPORT_DIR"
+ exit 1
+fi
+
+missing=()
+for class in "${WATCHED[@]}"; do
+ count=0
+ for report in "$REPORT_DIR"/TEST-*.xml; do
+ [ -e "$report" ] || continue
+ name=$(sed -n 's/.*]*name="\([^"]*\)".*/\1/p' "$report" | head -1)
+ if [ "$name" = "$class" ]; then
+ count=$(sed -n 's/.*]*tests="\([0-9]*\)".*/\1/p' "$report" | head -1)
+ count=${count:-0}
+ break
+ fi
+ done
+ if [ "$count" -eq 0 ]; then
+ missing+=("$class")
+ else
+ echo "OK $class 跑了 $count 条"
+ fi
+done
+
+if [ ${#missing[@]} -ne 0 ]; then
+ echo "这几个集成测试一条都没跑:${missing[*]}"
+ echo "守卫把门禁掏空了,构建必须失败"
+ exit 1
+fi
+
+echo "集成测试都跑过了"
diff --git a/main/manager-api/src/test/java/xiaozhi/modules/correctword/service/impl/CorrectWordFileServiceImplTest.java b/main/manager-api/src/test/java/xiaozhi/modules/correctword/service/impl/CorrectWordFileServiceImplTest.java
index 25fbebea7..b41953db6 100644
--- a/main/manager-api/src/test/java/xiaozhi/modules/correctword/service/impl/CorrectWordFileServiceImplTest.java
+++ b/main/manager-api/src/test/java/xiaozhi/modules/correctword/service/impl/CorrectWordFileServiceImplTest.java
@@ -14,9 +14,14 @@ import java.util.Collections;
import java.util.Date;
import java.util.List;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+
import xiaozhi.modules.agent.dao.AgentCorrectWordMappingDao;
import xiaozhi.modules.agent.entity.AgentCorrectWordMappingEntity;
import xiaozhi.modules.correctword.dao.CorrectWordFileDao;
@@ -37,6 +42,20 @@ class CorrectWordFileServiceImplTest {
private final CorrectWordFileServiceImpl service =
new CorrectWordFileServiceImpl(fileDao, itemDao, mappingDao);
+ @BeforeAll
+ static void registerEntityMetadata() {
+ // 这条用例不起 Spring 上下文,MyBatis-Plus 的实体元数据没人注册,
+ // LambdaQueryWrapper 拼条件时找不到 lambda 缓存就抛异常。
+ // 全量跑的时候是前面的上下文测试顺带注册好的,单独跑就露馅。
+ // 这里自己补上,单独跑和跟着全量跑结果一样。
+ TableInfoHelper.initTableInfo(
+ new MapperBuilderAssistant(new MybatisConfiguration(), ""),
+ CorrectWordItemEntity.class);
+ TableInfoHelper.initTableInfo(
+ new MapperBuilderAssistant(new MybatisConfiguration(), ""),
+ CorrectWordFileEntity.class);
+ }
+
@Test
void getAllItemsByAgentId_returns_empty_list_when_no_mappings() {
when(mappingDao.selectByAgentId(AGENT_ID)).thenReturn(Collections.emptyList());
diff --git a/main/manager-api/src/test/java/xiaozhi/modules/device/DeviceTest.java b/main/manager-api/src/test/java/xiaozhi/modules/device/DeviceTest.java
index 768818439..c15a51437 100644
--- a/main/manager-api/src/test/java/xiaozhi/modules/device/DeviceTest.java
+++ b/main/manager-api/src/test/java/xiaozhi/modules/device/DeviceTest.java
@@ -3,6 +3,7 @@ package xiaozhi.modules.device;
import java.util.HashMap;
import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -15,6 +16,7 @@ import xiaozhi.common.exception.RenException;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.sys.dto.SysUserDTO;
import xiaozhi.modules.sys.service.SysUserService;
+import xiaozhi.testsupport.IntegrationTestEnvironment;
@Slf4j
@SpringBootTest
@@ -22,6 +24,11 @@ import xiaozhi.modules.sys.service.SysUserService;
@DisplayName("设备测试")
public class DeviceTest {
+ @BeforeAll
+ static void checkDependencies() {
+ IntegrationTestEnvironment.requireDatabaseAndRedis();
+ }
+
@Autowired
private RedisUtils redisUtils;
@Autowired
diff --git a/main/manager-api/src/test/java/xiaozhi/modules/model/service/impl/ModelConfigPersistenceTest.java b/main/manager-api/src/test/java/xiaozhi/modules/model/service/impl/ModelConfigPersistenceTest.java
index 279d78ae7..5e9ae4b11 100644
--- a/main/manager-api/src/test/java/xiaozhi/modules/model/service/impl/ModelConfigPersistenceTest.java
+++ b/main/manager-api/src/test/java/xiaozhi/modules/model/service/impl/ModelConfigPersistenceTest.java
@@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertNull;
import java.util.UUID;
+import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.springframework.beans.factory.annotation.Autowired;
@@ -19,12 +20,18 @@ import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.model.dto.ModelConfigBodyDTO;
import xiaozhi.modules.model.dto.ModelConfigDTO;
import xiaozhi.modules.model.service.ModelConfigService;
+import xiaozhi.testsupport.IntegrationTestEnvironment;
@SpringBootTest
@ActiveProfiles("dev")
@Transactional
class ModelConfigPersistenceTest {
+ @BeforeAll
+ static void checkDependencies() {
+ IntegrationTestEnvironment.requireDatabaseAndRedis();
+ }
+
@Autowired
private ModelConfigService service;
diff --git a/main/manager-api/src/test/java/xiaozhi/modules/sys/loginControllerTest.java b/main/manager-api/src/test/java/xiaozhi/modules/sys/loginControllerTest.java
index 4c5128842..885fb0db5 100644
--- a/main/manager-api/src/test/java/xiaozhi/modules/sys/loginControllerTest.java
+++ b/main/manager-api/src/test/java/xiaozhi/modules/sys/loginControllerTest.java
@@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.when;
+import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@@ -18,12 +19,18 @@ import xiaozhi.modules.security.dto.LoginDTO;
import xiaozhi.modules.security.dto.SmsVerificationDTO;
import xiaozhi.modules.sys.dto.RetrievePasswordDTO;
import xiaozhi.modules.sys.service.SysUserService;
+import xiaozhi.testsupport.IntegrationTestEnvironment;
@Slf4j
@SpringBootTest
@ActiveProfiles("dev")
class loginControllerTest {
+ @BeforeAll
+ static void checkDependencies() {
+ IntegrationTestEnvironment.requireDatabaseAndRedis();
+ }
+
@Autowired
LoginController loginController;
diff --git a/main/manager-api/src/test/java/xiaozhi/testsupport/IntegrationTestEnvironment.java b/main/manager-api/src/test/java/xiaozhi/testsupport/IntegrationTestEnvironment.java
new file mode 100644
index 000000000..32e33637e
--- /dev/null
+++ b/main/manager-api/src/test/java/xiaozhi/testsupport/IntegrationTestEnvironment.java
@@ -0,0 +1,149 @@
+package xiaozhi.testsupport;
+
+import java.io.IOException;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.time.Duration;
+
+import org.junit.jupiter.api.Assumptions;
+import org.springframework.boot.env.YamlPropertySourceLoader;
+import org.springframework.core.env.ConfigurableEnvironment;
+import org.springframework.core.env.PropertySource;
+import org.springframework.core.env.PropertySourcesPropertyResolver;
+import org.springframework.core.env.StandardEnvironment;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.core.io.Resource;
+
+import io.lettuce.core.RedisClient;
+import io.lettuce.core.RedisURI;
+import io.lettuce.core.api.StatefulRedisConnection;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * 几个 @SpringBootTest 要真连 MySQL 和 Redis。本地没起这两个依赖时,
+ * 整类测试跳过,别让 Spring 上下文起不来变成一堆看不懂的报错;
+ * CI 里依赖必须真的在,探不通就直接让构建红。
+ */
+@Slf4j
+public final class IntegrationTestEnvironment {
+
+ /** 集成测试用的配置文件,跟应用本身读的是同一套 */
+ private static final String[] CONFIG_FILES = {"application.yml", "application-dev.yml"};
+
+ private static Boolean available;
+ private static String problem;
+
+ private IntegrationTestEnvironment() {
+ }
+
+ /**
+ * 放在要连库的测试类的 @BeforeAll 里。
+ * 本地依赖不齐就整类跳过,CI 里依赖不齐就直接失败。
+ */
+ public static void requireDatabaseAndRedis() {
+ String issue = probe();
+ if (issue == null) {
+ return;
+ }
+ if (isCi()) {
+ throw new IllegalStateException(
+ "CI 环境里" + issue + ",这几个集成测试必须真的跑起来,不能跳过");
+ }
+ log.warn("跳过依赖 MySQL 和 Redis 的集成测试:{}", issue);
+ Assumptions.assumeTrue(false, issue);
+ }
+
+ /** GitHub Actions 上会带 CI=true */
+ public static boolean isCi() {
+ String value = System.getenv("CI");
+ if (value == null) {
+ return false;
+ }
+ String trimmed = value.trim();
+ return !trimmed.isEmpty() && !"false".equalsIgnoreCase(trimmed);
+ }
+
+ /** 依赖都齐返回 null,不齐返回缺什么 */
+ static synchronized String probe() {
+ if (available != null) {
+ return problem;
+ }
+ PropertySourcesPropertyResolver resolver = resolver();
+ StringBuilder issues = new StringBuilder();
+
+ String url = firstNonBlank(resolver.getProperty("spring.datasource.druid.url", ""),
+ resolver.getProperty("spring.datasource.url", ""));
+ String username = firstNonBlank(resolver.getProperty("spring.datasource.druid.username", ""),
+ resolver.getProperty("spring.datasource.username", ""));
+ String password = firstNonBlank(resolver.getProperty("spring.datasource.druid.password", ""),
+ resolver.getProperty("spring.datasource.password", ""));
+ if (url.isBlank()) {
+ issues.append("配置里找不到数据源地址;");
+ } else {
+ try {
+ DriverManager.setLoginTimeout(5);
+ try (Connection connection = DriverManager.getConnection(url, username, password)) {
+ connection.isValid(3);
+ }
+ } catch (Exception e) {
+ issues.append("连不上 MySQL(").append(e.getMessage()).append(");");
+ }
+ }
+
+ String redisHost = resolver.getProperty("spring.data.redis.host", "localhost");
+ int redisPort = resolver.getProperty("spring.data.redis.port", Integer.class, 6379);
+ String redisPassword = resolver.getProperty("spring.data.redis.password", "");
+ try {
+ RedisURI.Builder builder = RedisURI.builder()
+ .withHost(redisHost)
+ .withPort(redisPort)
+ .withTimeout(Duration.ofSeconds(3));
+ if (redisPassword != null && !redisPassword.isBlank()) {
+ builder.withPassword(redisPassword.toCharArray());
+ }
+ RedisClient client = RedisClient.create(builder.build());
+ try {
+ StatefulRedisConnection connection = client.connect();
+ connection.sync().ping();
+ connection.close();
+ } finally {
+ client.shutdown();
+ }
+ } catch (Exception e) {
+ issues.append("连不上 Redis(").append(e.getMessage()).append(");");
+ }
+
+ if (issues.length() == 0) {
+ available = Boolean.TRUE;
+ problem = null;
+ } else {
+ available = Boolean.FALSE;
+ problem = issues.toString();
+ }
+ return problem;
+ }
+
+ /** 读应用自己那份配置,命令行上的 -D 参数优先级更高,跟应用保持一致 */
+ private static PropertySourcesPropertyResolver resolver() {
+ ConfigurableEnvironment environment = new StandardEnvironment();
+ YamlPropertySourceLoader loader = new YamlPropertySourceLoader();
+ for (String name : CONFIG_FILES) {
+ Resource resource = new ClassPathResource(name);
+ if (!resource.exists()) {
+ continue;
+ }
+ try {
+ for (PropertySource> source : loader.load(name, resource)) {
+ environment.getPropertySources().addLast(source);
+ }
+ } catch (IOException e) {
+ log.warn("读配置 {} 失败:{}", name, e.getMessage());
+ }
+ }
+ return new PropertySourcesPropertyResolver(environment.getPropertySources());
+ }
+
+ private static String firstNonBlank(String first, String second) {
+ return first != null && !first.isBlank() ? first : (second == null ? "" : second);
+ }
+}
diff --git a/main/xiaozhi-server/tests/conftest.py b/main/xiaozhi-server/tests/conftest.py
index a269bb77f..24af688b6 100644
--- a/main/xiaozhi-server/tests/conftest.py
+++ b/main/xiaozhi-server/tests/conftest.py
@@ -47,10 +47,29 @@ if str(_PROJECT_ROOT) not in sys.path:
# ---------------------------------------------------------------------------
try:
import opuslib_next # noqa: F401
-except Exception:
+except Exception as _opus_import_error:
+ # opuslib_next 是 ctypes 绑定,pip 装上的是纯 Python 包,真正的编解码靠系统里的
+ # libopus.so。runner 上没装 libopus 时 import 会失败,这里换桩顶上。
+ # 桩只能撑住 import 链路,真要编解码得先把 libopus 装上
+ # (apt-get install libopus0,正式镜像 Dockerfile-server-base 里也是这么装的)。
+ print(
+ f"[conftest] opuslib_next 不可用,改用桩:{_opus_import_error}",
+ flush=True,
+ )
+
+ def _opus_unavailable(_self, *_args, **_kwargs):
+ raise RuntimeError(
+ "libopus 没装,Opus 编解码跑不了。"
+ "装法:apt-get install libopus0(正式镜像 Dockerfile-server-base 里也是这么装的)"
+ )
+
_stub = types.ModuleType("opuslib_next")
- _stub.Encoder = type("Encoder", (), {"__init__": lambda *a, **kw: None})
- _stub.Decoder = type("Decoder", (), {"__init__": lambda *a, **kw: None})
+ _stub.Encoder = type(
+ "Encoder", (), {"__init__": lambda *a, **kw: None, "encode": _opus_unavailable}
+ )
+ _stub.Decoder = type(
+ "Decoder", (), {"__init__": lambda *a, **kw: None, "decode": _opus_unavailable}
+ )
_stub.APPLICATION_AUDIO = 0
_stub.OpusError = type("OpusError", (Exception,), {})
_stub.constants = types.SimpleNamespace()
@@ -323,35 +342,25 @@ def _install_mock_layer() -> None:
asyncio.sleep = _fast_sleep # type: ignore[assignment]
- # 网络层 mock:如果真实 httpx 已加载,把它的 AsyncClient.send 替换成
+ # 网络层 mock:如果真实 httpx 已加载,把它的 send 替换成
# 立即抛 ConnectError 的桩,触发 ``_should_retry`` 走到 ``raise``,
# 但避免真发请求。Live 模式完全不安装这层。
if not LIVE_API_TESTS:
try:
- import httpx as _httpx # noqa: F401
+ import httpx as _httpx
- class _FakeAsyncClient:
- def __init__(self, *args, **kwargs):
- pass
+ async def _fake_async_send(self, request, **kwargs):
+ raise _httpx.ConnectError("MOCK: connect refused")
- async def send(self, *args, **kwargs):
- raise _httpx.ConnectError("MOCK: connect refused")
+ def _fake_send(self, request, **kwargs):
+ raise _httpx.ConnectError("MOCK: connect refused")
- async def aclose(self):
- return None
-
- class _FakeClient:
- def __init__(self, *args, **kwargs):
- pass
-
- def send(self, *args, **kwargs):
- raise _httpx.ConnectError("MOCK: connect refused")
-
- def close(self):
- return None
-
- _httpx.AsyncClient = _FakeAsyncClient # type: ignore[misc]
- _httpx.Client = _FakeClient # type: ignore[misc]
+ # 只换 send 这一个方法,不换整个类。有些库(openai 2.x 的
+ # SyncHttpxClientWrapper)继承 httpx.Client 再自己拼请求,
+ # 整个类被换掉的话它们继承到的是假类,拼请求时直接
+ # AttributeError,报错还完全看不出是 mock 层的问题
+ _httpx.AsyncClient.send = _fake_async_send # type: ignore[assignment]
+ _httpx.Client.send = _fake_send # type: ignore[assignment]
except ImportError:
pass
diff --git a/main/xiaozhi-server/tests/test_llm.py b/main/xiaozhi-server/tests/test_llm.py
index 931a16f9f..5a83f5826 100644
--- a/main/xiaozhi-server/tests/test_llm.py
+++ b/main/xiaozhi-server/tests/test_llm.py
@@ -65,6 +65,13 @@ def _build_provider():
ptype = get_provider_type("LLM", name)
if not ptype:
pytest.skip(f"LLM '{name}' 的 type 字段缺失")
+ if not LIVE_API_TESTS:
+ # mock 模式把网络拦了,这条链路拿不到真实回复,跑下来只是一次连接失败。
+ # 真要验这条链路,用 RUN_LIVE_API_TESTS=1 配真 key 跑
+ pytest.skip(
+ f"mock 模式不发真请求,跳过 LLM '{name}' 的链路验证。"
+ "真请求模式:export RUN_LIVE_API_TESTS=1 并填真 key 后再跑。"
+ )
from core.utils.llm import create_instance
return create_instance(ptype, cfg)