Skip to content

Commit 2d74b86

Browse files
committed
v9.0 Phase3: 网络模拟+设备池+前端5视图 - 786个测试全绿
Phase3新增: - NetworkSimulator: 7种预设网络环境(perfect/wifi/4g/3g/slow/unstable/offline) - DevicePoolManager: 设备注册/分配/释放/健康检查/批量分配 - 13个新API端点(网络模拟5+设备池8) - 前端多端协同页新增5视图页签(网格/时序轴/AI扮演/录制回放/一致性) - 48个新增测试(NetworkSimulator18+DevicePool30) - v9.0全部完成: Phase1协调器+Phase2 AI/录制/一致性+Phase3网络/设备池
1 parent e2ebcc3 commit 2d74b86

7 files changed

Lines changed: 1245 additions & 16 deletions

File tree

desktop/src/pages/MultiPlayerTestPage.tsx

Lines changed: 345 additions & 12 deletions
Large diffs are not rendered by default.

src/api/routes.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1523,4 +1523,139 @@ async def consistency_summary() -> dict:
15231523
"""获取一致性检查历史摘要。"""
15241524
return _checker.get_summary()
15251525

1526+
# ── 多端协同 Phase3(v9.0)────────────────────
1527+
1528+
from src.testing.network_simulator import NetworkSimulator, NetworkConfig, NetworkProfile
1529+
from src.testing.device_pool import DevicePoolManager, DeviceType, DeviceState
1530+
1531+
_network = NetworkSimulator()
1532+
_device_pool = DevicePoolManager()
1533+
1534+
@router.post("/multiplayer/network/enable", tags=["多端协同-网络模拟"])
1535+
async def enable_network_sim() -> dict:
1536+
"""启用网络模拟。"""
1537+
_network.enable()
1538+
return {"enabled": True}
1539+
1540+
@router.post("/multiplayer/network/disable", tags=["多端协同-网络模拟"])
1541+
async def disable_network_sim() -> dict:
1542+
"""禁用网络模拟。"""
1543+
_network.disable()
1544+
return {"enabled": False}
1545+
1546+
@router.post("/multiplayer/network/profile", tags=["多端协同-网络模拟"])
1547+
async def set_network_profile(req: dict) -> dict:
1548+
"""设置网络环境预设。
1549+
1550+
Body: profile(perfect/wifi/4g/3g/slow/unstable/offline), player_id(可选)
1551+
"""
1552+
profile = NetworkProfile(req.get("profile", "perfect"))
1553+
config = NetworkConfig.from_profile(profile)
1554+
player_id = req.get("player_id")
1555+
if player_id:
1556+
_network.set_player(player_id, config)
1557+
else:
1558+
_network.set_global(config)
1559+
_network.enable()
1560+
return {
1561+
"profile": profile.value,
1562+
"player_id": player_id or "全局",
1563+
"latency_ms": config.latency_ms,
1564+
"packet_loss": config.packet_loss,
1565+
}
1566+
1567+
@router.post("/multiplayer/network/custom", tags=["多端协同-网络模拟"])
1568+
async def set_network_custom(req: dict) -> dict:
1569+
"""设置自定义网络条件。
1570+
1571+
Body: latency_ms, jitter_ms, packet_loss, bandwidth_kbps, player_id(可选)
1572+
"""
1573+
config = NetworkConfig(
1574+
latency_ms=req.get("latency_ms", 0),
1575+
jitter_ms=req.get("jitter_ms", 0),
1576+
packet_loss=req.get("packet_loss", 0.0),
1577+
bandwidth_kbps=req.get("bandwidth_kbps", 0),
1578+
)
1579+
player_id = req.get("player_id")
1580+
if player_id:
1581+
_network.set_player(player_id, config)
1582+
else:
1583+
_network.set_global(config)
1584+
_network.enable()
1585+
return {"player_id": player_id or "全局", "config": {
1586+
"latency_ms": config.latency_ms, "jitter_ms": config.jitter_ms,
1587+
"packet_loss": config.packet_loss, "bandwidth_kbps": config.bandwidth_kbps,
1588+
}}
1589+
1590+
@router.get("/multiplayer/network/stats", tags=["多端协同-网络模拟"])
1591+
async def network_stats() -> dict:
1592+
"""获取网络模拟统计。"""
1593+
return _network.get_stats()
1594+
1595+
@router.post("/multiplayer/devices/register", tags=["多端协同-设备池"])
1596+
async def register_device(req: dict) -> dict:
1597+
"""注册设备到池。
1598+
1599+
Body: device_id, device_type(browser/android/ios/desktop/miniprogram), name, capabilities, tags
1600+
"""
1601+
device = _device_pool.register(
1602+
device_id=req.get("device_id", ""),
1603+
device_type=DeviceType(req.get("device_type", "browser")),
1604+
name=req.get("name", ""),
1605+
capabilities=req.get("capabilities", {}),
1606+
tags=req.get("tags", []),
1607+
)
1608+
return {"device_id": device.device_id, "type": device.device_type.value, "total": _device_pool.device_count}
1609+
1610+
@router.delete("/multiplayer/devices/{device_id}", tags=["多端协同-设备池"])
1611+
async def unregister_device(device_id: str) -> dict:
1612+
"""注销设备。"""
1613+
_device_pool.unregister(device_id)
1614+
return {"device_id": device_id, "total": _device_pool.device_count}
1615+
1616+
@router.post("/multiplayer/devices/acquire", tags=["多端协同-设备池"])
1617+
async def acquire_device(req: dict) -> dict:
1618+
"""为玩家分配设备。
1619+
1620+
Body: player_id, device_type(可选), tags(可选)
1621+
"""
1622+
dtype = DeviceType(req["device_type"]) if "device_type" in req else None
1623+
device = _device_pool.acquire(req.get("player_id", ""), dtype, req.get("tags"))
1624+
if device:
1625+
return {"device_id": device.device_id, "type": device.device_type.value, "assigned_to": device.assigned_to}
1626+
raise HTTPException(status_code=404, detail="无可用设备")
1627+
1628+
@router.post("/multiplayer/devices/release/{device_id}", tags=["多端协同-设备池"])
1629+
async def release_device(device_id: str) -> dict:
1630+
"""释放设备。"""
1631+
_device_pool.release(device_id)
1632+
return {"device_id": device_id, "state": "available"}
1633+
1634+
@router.post("/multiplayer/devices/auto-assign", tags=["多端协同-设备池"])
1635+
async def auto_assign_devices(req: dict) -> dict:
1636+
"""批量自动分配。
1637+
1638+
Body: configs: [{player_id, device_type, tags}]
1639+
"""
1640+
result = _device_pool.auto_assign(req.get("configs", []))
1641+
return {"assignments": result}
1642+
1643+
@router.post("/multiplayer/devices/release-all", tags=["多端协同-设备池"])
1644+
async def release_all_devices() -> dict:
1645+
"""释放所有在用设备。"""
1646+
count = _device_pool.release_all()
1647+
return {"released": count}
1648+
1649+
@router.get("/multiplayer/devices/summary", tags=["多端协同-设备池"])
1650+
async def device_pool_summary() -> dict:
1651+
"""获取设备池摘要。"""
1652+
return _device_pool.get_summary()
1653+
1654+
@router.post("/multiplayer/devices/health-check", tags=["多端协同-设备池"])
1655+
async def device_health_check(req: dict = None) -> dict:
1656+
"""设备健康检查。"""
1657+
req = req or {}
1658+
offline = _device_pool.check_health(req.get("timeout", 60))
1659+
return {"offline_count": len(offline), "offline_devices": offline}
1660+
15261661
return router

src/testing/device_pool.py

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
"""
2+
设备池管理器(v9.0 Phase3)
3+
4+
管理可用测试设备/浏览器实例,支持:设备注册/注销、自动分配、健康检查、并发控制。
5+
"""
6+
7+
import time
8+
from dataclasses import dataclass, field
9+
from enum import Enum
10+
from typing import Any, Optional
11+
12+
from loguru import logger
13+
14+
15+
class DeviceType(str, Enum):
16+
"""设备类型。"""
17+
BROWSER = "browser"
18+
ANDROID = "android"
19+
IOS = "ios"
20+
DESKTOP = "desktop"
21+
MINIPROGRAM = "miniprogram"
22+
23+
24+
class DeviceState(str, Enum):
25+
"""设备状态。"""
26+
AVAILABLE = "available"
27+
IN_USE = "in_use"
28+
OFFLINE = "offline"
29+
MAINTENANCE = "maintenance"
30+
31+
32+
@dataclass
33+
class DeviceInfo:
34+
"""设备信息。"""
35+
device_id: str
36+
device_type: DeviceType
37+
name: str = ""
38+
state: DeviceState = DeviceState.AVAILABLE
39+
capabilities: dict = field(default_factory=dict)
40+
assigned_to: str = "" # 分配给哪个玩家
41+
last_heartbeat: float = 0.0
42+
registered_at: float = 0.0
43+
tags: list[str] = field(default_factory=list)
44+
45+
46+
class DevicePoolManager:
47+
"""设备池管理器。"""
48+
49+
def __init__(self, max_devices: int = 20) -> None:
50+
self.max_devices = max_devices
51+
self._devices: dict[str, DeviceInfo] = {}
52+
53+
@property
54+
def device_count(self) -> int:
55+
return len(self._devices)
56+
57+
@property
58+
def available_count(self) -> int:
59+
return sum(1 for d in self._devices.values() if d.state == DeviceState.AVAILABLE)
60+
61+
def register(self, device_id: str, device_type: DeviceType,
62+
name: str = "", capabilities: dict = None, tags: list[str] = None) -> DeviceInfo:
63+
"""注册设备到池中。"""
64+
if len(self._devices) >= self.max_devices:
65+
raise ValueError(f"设备池已满 ({self.max_devices})")
66+
if device_id in self._devices:
67+
raise ValueError(f"设备 {device_id} 已存在")
68+
69+
device = DeviceInfo(
70+
device_id=device_id,
71+
device_type=device_type,
72+
name=name or device_id,
73+
capabilities=capabilities or {},
74+
tags=tags or [],
75+
registered_at=time.time(),
76+
last_heartbeat=time.time(),
77+
)
78+
self._devices[device_id] = device
79+
logger.info("设备注册 | {} | 类型: {} | 池中: {}", device_id, device_type.value, len(self._devices))
80+
return device
81+
82+
def unregister(self, device_id: str) -> None:
83+
"""从池中注销设备。"""
84+
if device_id not in self._devices:
85+
raise KeyError(f"设备 {device_id} 不存在")
86+
del self._devices[device_id]
87+
logger.info("设备注销 | {} | 剩余: {}", device_id, len(self._devices))
88+
89+
def get(self, device_id: str) -> Optional[DeviceInfo]:
90+
return self._devices.get(device_id)
91+
92+
def acquire(self, player_id: str, device_type: DeviceType = None,
93+
tags: list[str] = None) -> Optional[DeviceInfo]:
94+
"""为玩家分配一个可用设备。
95+
96+
Args:
97+
player_id: 请求设备的玩家
98+
device_type: 需要的设备类型(None=任意)
99+
tags: 需要匹配的标签(全部匹配)
100+
"""
101+
for device in self._devices.values():
102+
if device.state != DeviceState.AVAILABLE:
103+
continue
104+
if device_type and device.device_type != device_type:
105+
continue
106+
if tags and not all(t in device.tags for t in tags):
107+
continue
108+
109+
device.state = DeviceState.IN_USE
110+
device.assigned_to = player_id
111+
logger.info("设备分配 | {} -> {} | 类型: {}", device.device_id, player_id, device.device_type.value)
112+
return device
113+
114+
logger.warning("无可用设备 | 玩家: {} | 类型: {} | 标签: {}", player_id, device_type, tags)
115+
return None
116+
117+
def release(self, device_id: str) -> None:
118+
"""释放设备,使其可重新分配。"""
119+
device = self._devices.get(device_id)
120+
if not device:
121+
raise KeyError(f"设备 {device_id} 不存在")
122+
device.state = DeviceState.AVAILABLE
123+
device.assigned_to = ""
124+
logger.info("设备释放 | {}", device_id)
125+
126+
def heartbeat(self, device_id: str) -> None:
127+
"""更新设备心跳。"""
128+
device = self._devices.get(device_id)
129+
if device:
130+
device.last_heartbeat = time.time()
131+
132+
def set_state(self, device_id: str, state: DeviceState) -> None:
133+
"""设置设备状态。"""
134+
device = self._devices.get(device_id)
135+
if not device:
136+
raise KeyError(f"设备 {device_id} 不存在")
137+
device.state = state
138+
139+
def check_health(self, timeout_seconds: float = 60) -> list[str]:
140+
"""检查设备健康:超过 timeout 无心跳的标记为 offline。返回离线设备ID列表。"""
141+
now = time.time()
142+
offline = []
143+
for device in self._devices.values():
144+
if device.state == DeviceState.OFFLINE:
145+
continue
146+
if now - device.last_heartbeat > timeout_seconds:
147+
device.state = DeviceState.OFFLINE
148+
offline.append(device.device_id)
149+
logger.warning("设备离线 | {} | 最后心跳: {:.0f}s前", device.device_id, now - device.last_heartbeat)
150+
return offline
151+
152+
def list_devices(self, device_type: DeviceType = None,
153+
state: DeviceState = None) -> list[DeviceInfo]:
154+
"""列出设备(可按类型/状态过滤)。"""
155+
devices = list(self._devices.values())
156+
if device_type:
157+
devices = [d for d in devices if d.device_type == device_type]
158+
if state:
159+
devices = [d for d in devices if d.state == state]
160+
return devices
161+
162+
def auto_assign(self, player_configs: list[dict]) -> dict[str, Optional[str]]:
163+
"""批量自动分配设备。
164+
165+
Args:
166+
player_configs: [{"player_id": "p1", "device_type": "browser", "tags": [...]}]
167+
Returns:
168+
{player_id: device_id or None}
169+
"""
170+
result = {}
171+
for cfg in player_configs:
172+
pid = cfg.get("player_id", "")
173+
dtype = DeviceType(cfg["device_type"]) if "device_type" in cfg else None
174+
tags = cfg.get("tags", [])
175+
device = self.acquire(pid, dtype, tags)
176+
result[pid] = device.device_id if device else None
177+
return result
178+
179+
def release_all(self) -> int:
180+
"""释放所有在用设备。"""
181+
count = 0
182+
for device in self._devices.values():
183+
if device.state == DeviceState.IN_USE:
184+
device.state = DeviceState.AVAILABLE
185+
device.assigned_to = ""
186+
count += 1
187+
return count
188+
189+
def clear(self) -> None:
190+
"""清空设备池。"""
191+
self._devices.clear()
192+
193+
def get_summary(self) -> dict:
194+
"""获取设备池摘要。"""
195+
by_type = {}
196+
by_state = {}
197+
for d in self._devices.values():
198+
by_type[d.device_type.value] = by_type.get(d.device_type.value, 0) + 1
199+
by_state[d.state.value] = by_state.get(d.state.value, 0) + 1
200+
return {
201+
"total": len(self._devices),
202+
"max": self.max_devices,
203+
"available": self.available_count,
204+
"by_type": by_type,
205+
"by_state": by_state,
206+
"devices": [
207+
{
208+
"id": d.device_id,
209+
"type": d.device_type.value,
210+
"name": d.name,
211+
"state": d.state.value,
212+
"assigned_to": d.assigned_to,
213+
}
214+
for d in self._devices.values()
215+
],
216+
}

0 commit comments

Comments
 (0)