fix(report): restore sender route and mono parity

This commit is contained in:
柏智
2026-08-26 15:39:18 +08:00
parent efe9a0aa22
commit 46dc016dd2
7 changed files with 353 additions and 138 deletions
@@ -5,4 +5,4 @@ category: Changed
- **Report latest lookup** — scans bounded, strictly advancing outbox pages, reconciles duplicate IDs, and reads back the uniquely newest report instead of failing on the first continuation page.
- **Sheet create-with-data result** — returns the already probed `sheetId` at the top level while preserving legacy `.result.nodeId` and outer `requestId`, avoids repeating the sheet-list probe, keeps the main-compatible single readback check, and reports post-create partial/unknown state without unsafe whole-workflow retries.
- **Sheet workflow routing** — distinguishes local analysis from Excel-to-online import, exposes template discovery and apply routes, and preserves the full data-validation tri-state contract.
- **Received-report helper** — uses bounded complete pagination, renders epoch timestamps in the Shanghai timezone, fails closed instead of returning incomplete data, and keeps midnight query windows valid.
- **Received-report helper and routing** — restores same-profile sender resolution before inbox filtering, keeps Mono and Multi helpers identical, uses bounded complete pagination, renders epoch timestamps in the Shanghai timezone, fails closed instead of returning incomplete data, and keeps midnight query windows valid.
+19 -5
View File
@@ -14,7 +14,7 @@ from unittest import mock
ROOT = Path(__file__).resolve().parents[2]
sys.dont_write_bytecode = True
SCRIPT = (
MULTI_SCRIPT = (
ROOT
/ "skills"
/ "multi"
@@ -22,10 +22,18 @@ SCRIPT = (
/ "scripts"
/ "report_received_today.py"
)
SPEC = importlib.util.spec_from_file_location("report_received_today", SCRIPT)
assert SPEC and SPEC.loader
REPORT = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(REPORT)
MONO_SCRIPT = ROOT / "skills" / "mono" / "scripts" / "report_received_today.py"
def load_report_module(path: Path, name: str):
spec = importlib.util.spec_from_file_location(name, path)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
REPORT = load_report_module(MULTI_SCRIPT, "report_received_today_multi")
def inbox_page(reports: list[dict], *, complete: bool, next_token: int = 0) -> dict:
@@ -47,6 +55,12 @@ def inbox_page(reports: list[dict], *, complete: bool, next_token: int = 0) -> d
class ReportReceivedTodayTest(unittest.TestCase):
def test_mono_multi_scripts_are_identical_and_executable(self) -> None:
self.assertEqual(MONO_SCRIPT.read_bytes(), MULTI_SCRIPT.read_bytes())
self.assertNotEqual(MONO_SCRIPT.stat().st_mode & 0o111, 0)
self.assertNotEqual(MULTI_SCRIPT.stat().st_mode & 0o111, 0)
load_report_module(MONO_SCRIPT, "report_received_today_mono")
def test_query_window_clamps_first_second_after_midnight(self) -> None:
now = datetime(2026, 8, 26, 0, 0, 0, 500000, tzinfo=REPORT.SHANGHAI)
start, end = REPORT.query_window(1, now)
+3 -4
View File
@@ -150,6 +150,9 @@ omit_global:
# Reviewed file pairs. Most are byte-identical; layout-only link differences
# must be declared as data-driven substitutions.
paired_files:
- mono: scripts/report_received_today.py
multi: dingtalk-misc/scripts/report_received_today.py
mode: identical
- mono: references/products/sheet.md
multi: dingtalk-misc/references/sheet.md
mode: link-normalized
@@ -177,9 +180,5 @@ orphan_scripts_allowlist:
- path: dingtalk-minutes/scripts/minutes_list_parse.py
disposition: defer
reason: "pending doc reference"
- path: dingtalk-misc/scripts/report_received_today.py
disposition: defer
reason: "pending report.md reference"
# Skills allowed to omit references/ directory.
skills_without_references_allowlist: []
@@ -32,6 +32,7 @@
|----------|----------------|----------|
| 查我发过的日志 / 我创建的日志 | `dws report outbox list --cursor 0 --size 20 --format json` | 从返回里取 `reportId`,再执行 `dws report entry get --report-id <reportId> --format json` |
| 查我收到的日志 / 别人发给我的日志 | `dws report inbox list --start "<YYYY-MM-DDT00:00:00+08:00>" --end "<YYYY-MM-DDT23:59:59+08:00>" --cursor 0 --size 20 --format json` | 必须先按用户时间词补齐完整 ISO 起止时间;取 `reportId` 后调用 `entry get` |
| 按发件人查我收到的日志 | 先 `dws aisearch person --query "<姓名>" --dimension name --format json`,再 `dws report +inbox-list --start "<ISO>" --end "<ISO>" --sender-user-ids <USER_ID> --cursor 0 --size 20 --format json` | 只过滤当前 profile 的收件箱;人员零命中或多候选时停止并消歧,禁止默认选择第一项或改查他人的发件箱 |
| 查看某条日志正文 / 日志详情 | `dws report entry get --report-id <reportId> --format json` | 如果用户没给 `reportId`,先用 `outbox list` 或 `inbox list` 找候选 |
| 查某条日志统计 / 已读统计 | `dws report entry stats --report-id <reportId> --format json` | 如果用户没给 `reportId`,先用 `outbox list` 或 `inbox list` 找候选 |
| 查日志模版 / 有哪些周报模板 | `dws report template list --format json` | 需要字段定义时继续 `dws report template get --name "<模版名>" --format json` |
@@ -405,6 +406,9 @@ dws report outbox list --cursor 0 --size 20 --format json
| 脚本 | 场景 | 用法 |
|------|------|------|
| [report_received_today.py](../../scripts/report_received_today.py) | 有界分页列出今天或最近几天收到的日志摘要;失败和不完整结果返回非零状态,不逐篇读取正文 | `python3 scripts/report_received_today.py --days <N>` |
该脚本与 Multi 版本保持完全一致,使用 `dws report +inbox-list`,最多扫描 10 页、200 条并受总超时约束。需要正文时,从摘要中选择明确的 `reportId` 后只调用一次 `entry get`;不得恢复解析 `_internalDetailCommands`、逐条 N+1 读取或把命令失败伪装成空结果的旧流程。
+279 -124
View File
@@ -1,160 +1,315 @@
#!/usr/bin/env python3
"""
查看今天收到的日志列表及详情
"""有界分页列出今天或最近几天收到的日志摘要。
基于 `dws report inbox list` + `dws report entry get`(旧的 report list / report detail
已废弃)。inbox list 返回的 result[] 使用中文展示键:日期 / 标题 / 发送人 / 状态 / 钉钉链接;
reportId 不在 result[] 里,只在 _internalDetailCommands[].command 中,按页与 result[] 同序对应。
用法:
python report_received_today.py
python report_received_today.py --days 3 # 最近 3 天
python report_received_today.py --detail # 额外拉取每条正文 (entry get)
python report_received_today.py --dry-run
脚本只读取列表投影,不再为每条日志调用 ``entry get``。需要正文时,调用方应
从结果中选择明确的 reportId,再单独读取那一条,避免列表任务退化成 N+1。
"""
import sys
from __future__ import annotations
import argparse
import json
import subprocess
import argparse
import sys
import time
from datetime import datetime, timedelta
from typing import List, Any, Optional, Tuple
from typing import Any, NamedTuple
from zoneinfo import ZoneInfo
SHANGHAI = ZoneInfo("Asia/Shanghai")
PAGE_SIZE = 20
DEFAULT_MAX_PAGES = 10
HARD_MAX_PAGES = 10
MAX_REPORTS = PAGE_SIZE * HARD_MAX_PAGES
DEFAULT_DISPLAY_LIMIT = 20
PER_COMMAND_TIMEOUT_SECONDS = 60
TOTAL_TIMEOUT_SECONDS = 120
MAX_ERROR_DETAIL_CHARS = 4096
class ReportCommandError(RuntimeError):
"""DWS 执行或响应契约失败,不能降级成合法空结果。"""
class InboxScanResult(NamedTuple):
"""完整扫描证据与受展示上限约束的摘要。"""
total_count: int
visible_items: list[dict[str, Any]]
def query_window(days: int, now: datetime | None = None) -> tuple[datetime, datetime]:
"""冻结查询时间窗,并保证午夜调度也得到严格递增的范围。"""
current = now or datetime.now(SHANGHAI)
start = (current - timedelta(days=days - 1)).replace(
hour=0, minute=0, second=0, microsecond=0
)
end = current.replace(microsecond=0)
if end <= start:
end = start + timedelta(seconds=1)
return start, end
def format_create_time(value: Any) -> str:
"""把服务端 epoch 毫秒转换为带时区的可读时间,未知形态如实保留。"""
if isinstance(value, (int, float)) and not isinstance(value, bool):
return datetime.fromtimestamp(value / 1000, SHANGHAI).strftime(
"%Y-%m-%d %H:%M:%S %z"
)
return str(value or "")
def clip_detail(value: Any, limit: int = MAX_ERROR_DETAIL_CHARS) -> str:
"""把诊断压到固定上限,避免响应正文进入错误日志或模型上下文。"""
if isinstance(value, (dict, list)):
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
else:
text = str(value or "")
text = text.strip()
if len(text) <= limit:
return text
return text[: limit - 14] + "…[已截断]"
def process_error_detail(result: subprocess.CompletedProcess[str]) -> str:
"""优先保留结构化错误与 stderr;原始 stdout 仅做有界兜底。"""
parts: list[str] = []
try:
payload = json.loads(result.stdout)
except (json.JSONDecodeError, TypeError):
payload = None
if isinstance(payload, dict):
structured = payload.get("error") or payload.get("message")
if structured:
parts.append("error=" + clip_detail(structured, 2048))
stderr = clip_detail(result.stderr, 2048)
if stderr:
parts.append("stderr=" + stderr)
if not parts:
stdout = clip_detail(result.stdout, 2048)
if stdout:
parts.append("stdout=" + stdout)
return clip_detail("; ".join(parts)) or "无错误详情"
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
cmd = ['dws'] + args
args: list[str], *, dry_run: bool = False, timeout_seconds: float = 60
) -> Any | None:
cmd = ["dws", *args]
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
print("[dry-run] " + " ".join(cmd))
return None
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
cmd, capture_output=True, text=True, timeout=timeout_seconds
)
if result.returncode != 0:
print(f"错误:{result.stderr.strip()}", file=sys.stderr)
return None
except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
raise ReportCommandError(f"DWS 执行失败: {exc}") from exc
if result.returncode != 0:
raise ReportCommandError(
f"DWS 返回非零状态 exit={result.returncode}: "
f"{process_error_detail(result)}"
)
try:
return json.loads(result.stdout)
except (subprocess.TimeoutExpired, json.JSONDecodeError,
FileNotFoundError) as e:
print(f"错误:{e}", file=sys.stderr)
return None
except json.JSONDecodeError as exc:
raise ReportCommandError(f"DWS 返回的不是合法 JSON: {exc}") from exc
def iso_start(dt: datetime) -> str:
return dt.strftime('%Y-%m-%dT00:00:00+08:00')
def parse_inbox_page(
payload: Any, current_cursor: int
) -> tuple[list[dict[str, Any]], int | None]:
if not isinstance(payload, dict):
raise ReportCommandError(
f"收件箱响应应为对象,实际为 {type(payload).__name__}"
)
if payload.get("ok") is not True or payload.get("outcome") != "success":
error = payload.get("error")
raise ReportCommandError(
"收件箱调用未成功: " + clip_detail(error or payload)
)
data = payload.get("data")
if not isinstance(data, dict):
raise ReportCommandError("收件箱成功响应缺少 data 对象")
reports = data.get("reports")
if not isinstance(reports, list) or any(
not isinstance(item, dict) for item in reports
):
raise ReportCommandError("收件箱 data.reports 必须是对象数组")
count = data.get("count")
if (
not isinstance(count, int)
or isinstance(count, bool)
or count != len(reports)
):
raise ReportCommandError("收件箱 count 与 reports 数量不一致")
complete = data.get("complete")
if not isinstance(complete, bool):
raise ReportCommandError("收件箱响应缺少布尔 complete")
meta = payload.get("meta")
pagination = meta.get("pagination") if isinstance(meta, dict) else None
if not isinstance(pagination, dict):
raise ReportCommandError("收件箱响应缺少 meta.pagination")
exhausted = pagination.get("endpoint_exhausted")
if not isinstance(exhausted, bool) or exhausted != complete:
raise ReportCommandError("收件箱 data.complete 与分页终止证据冲突")
if exhausted:
return reports, None
raw_next = pagination.get("next_token")
try:
next_cursor = int(raw_next)
except (TypeError, ValueError) as exc:
raise ReportCommandError("收件箱续页缺少整数 next_token") from exc
if next_cursor <= current_cursor:
raise ReportCommandError("收件箱 continuation cursor 没有严格前进")
return reports, next_cursor
def iso_end(dt: datetime) -> str:
return dt.strftime('%Y-%m-%dT23:59:59+08:00')
def report_id_from_command(cmd: str) -> str:
parts = cmd.split()
if '--report-id' in parts:
i = parts.index('--report-id')
if i + 1 < len(parts):
return parts[i + 1]
return ''
def fetch_inbox(
start: str, end: str, dry_run: bool,
) -> List[Tuple[dict, str]]:
"""按 cursor 翻页拉全 inbox;返回 (result_item, reportId) 列表。"""
pairs: List[Tuple[dict, str]] = []
def scan_inbox(
start: datetime,
end: datetime,
max_pages: int,
*,
display_limit: int = DEFAULT_DISPLAY_LIMIT,
total_timeout_seconds: float = TOTAL_TIMEOUT_SECONDS,
) -> InboxScanResult:
if not 1 <= display_limit <= MAX_REPORTS:
raise ReportCommandError(
f"展示上限必须在 1..{MAX_REPORTS} 之间"
)
cursor = 0
while True:
data = run_dws([
'report', 'inbox', 'list',
'--start', start,
'--end', end,
'--cursor', str(cursor),
'--size', '20',
'--format', 'json',
], dry_run=dry_run)
if dry_run or not isinstance(data, dict):
return pairs
items = data.get('result') or []
cmds = data.get('_internalDetailCommands') or []
for idx, item in enumerate(items):
rid = ''
if idx < len(cmds):
rid = report_id_from_command(
cmds[idx].get('command', '')
total_count = 0
visible_items: list[dict[str, Any]] = []
seen: dict[str, Any] = {}
deadline = time.monotonic() + total_timeout_seconds
for _ in range(max_pages):
remaining = deadline - time.monotonic()
if remaining <= 0:
raise ReportCommandError(
f"收件箱分页超过总时限 {total_timeout_seconds:g} 秒"
)
payload = run_dws([
"report", "+inbox-list",
"--start", start.isoformat(timespec="seconds"),
"--end", end.isoformat(timespec="seconds"),
"--cursor", str(cursor),
"--size", str(PAGE_SIZE),
"--format", "json",
], timeout_seconds=max(
0.1, min(PER_COMMAND_TIMEOUT_SECONDS, remaining)
))
page, next_cursor = parse_inbox_page(payload, cursor)
for item in page:
report_id = item.get("reportId")
if not isinstance(report_id, str) or not report_id.strip():
raise ReportCommandError("收件箱条目缺少稳定 reportId")
created = item.get("createTime")
if report_id in seen:
if seen[report_id] != created:
raise ReportCommandError(
f"收件箱重复 reportId 的 createTime 冲突: {report_id}"
)
continue
if total_count >= MAX_REPORTS:
raise ReportCommandError(
f"收件箱结果超过有界条数上限 {MAX_REPORTS}"
)
pairs.append((item, rid))
if data.get('hasMore') and data.get('nextCursor') is not None:
cursor = data['nextCursor']
else:
break
return pairs
seen[report_id] = created
total_count += 1
if len(visible_items) < display_limit:
visible_items.append(item)
if next_cursor is None:
return InboxScanResult(total_count, visible_items)
cursor = next_cursor
raise ReportCommandError(
f"达到 --max-pages={max_pages} 时收件箱仍有后续页;"
"拒绝把部分结果伪装成完整列表"
)
def print_detail(rid: str) -> None:
detail = run_dws([
'report', 'entry', 'get',
'--report-id', rid, '--format', 'json',
])
if not isinstance(detail, dict):
return
result = detail.get('result')
if not isinstance(result, dict):
return
for c in (result.get('report_content') or [])[:3]:
key = c.get('key', '')
val = c.get('value', '')
if key and val:
print(f" {key}: {str(val).strip()[:60]}")
def main():
parser = argparse.ArgumentParser(description='查看收到的日志')
def main() -> int:
parser = argparse.ArgumentParser(description="查看收到的日志摘要")
parser.add_argument(
'--days', type=int, default=1, help='查询天数 (默认 1,即今天)'
"--days", type=int, default=1, help="查询天数(默认 1)"
)
parser.add_argument(
'--detail', action='store_true', help='额外拉取每条正文'
"--max-pages",
type=int,
default=DEFAULT_MAX_PAGES,
help=f"最大分页数(默认 {DEFAULT_MAX_PAGES},范围 1..{HARD_MAX_PAGES})",
)
parser.add_argument('--dry-run', action='store_true')
parser.add_argument(
"--display-limit",
type=int,
default=DEFAULT_DISPLAY_LIMIT,
help=f"最多展开的摘要数(默认 {DEFAULT_DISPLAY_LIMIT},范围 1..{MAX_REPORTS})",
)
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
if args.days < 1:
parser.error("--days must be >= 1")
if not 1 <= args.max_pages <= HARD_MAX_PAGES:
parser.error(
f"--max-pages must be between 1 and {HARD_MAX_PAGES}"
)
if not 1 <= args.display_limit <= MAX_REPORTS:
parser.error(
f"--display-limit must be between 1 and {MAX_REPORTS}"
)
now = datetime.now()
start_dt = now - timedelta(days=max(args.days - 1, 0))
start = iso_start(start_dt)
end = iso_end(now)
# 以调用开始时刻冻结查询窗,避免分页过程中把未来新增条目插进结果集。
start, end = query_window(args.days)
label = "今天" if args.days == 1 else f"最近 {args.days} 天"
label = '今天' if args.days == 1 else f'最近 {args.days} 天'
print(f'查看{label}收到的日志...\n')
pairs = fetch_inbox(start, end, args.dry_run)
if args.dry_run:
return
if not pairs:
print(' 暂无收到的日志')
return
run_dws([
"report", "+inbox-list",
"--start", start.isoformat(timespec="seconds"),
"--end", end.isoformat(timespec="seconds"),
"--cursor", "0",
"--size", str(PAGE_SIZE),
"--format", "json",
], dry_run=True)
return 0
print(f"{label}日志 ({len(pairs)} 条)")
print('=' * 50)
scan = scan_inbox(
start, end, args.max_pages, display_limit=args.display_limit
)
if scan.total_count == 0:
print(f"{label}暂无收到的日志")
return 0
for item, rid in pairs:
title = item.get('标题') or '日志'
sender = item.get('发送人') or '未知'
date = item.get('日期') or ''
status = item.get('状态') or ''
link = item.get('钉钉链接') or ''
print(f"\n {title} - {sender}")
print(f" 时间: {date}")
if status:
print(f" 状态: {status}")
if link:
print(f" 链接: {link}")
if args.detail and rid:
print_detail(rid)
print(f"{label}收到的日志({scan.total_count} 条,已完成分页)")
for item in scan.visible_items:
creator = (
item.get("creatorName")
or item.get("creatorUserId")
or "未知创建人"
)
template = item.get("templateName") or "日志"
print(
f"- {template} | {creator} | "
f"{format_create_time(item.get('createTime'))} | {item['reportId']}"
)
if len(scan.visible_items) < scan.total_count:
print(
f"另有 {scan.total_count - len(scan.visible_items)} 条未展开;"
f"需要时用 --display-limit {min(scan.total_count, MAX_REPORTS)} 显示。"
)
print(
"需要正文时,请选择上面的明确 reportId "
"再执行 dws report entry get。"
)
return 0
if __name__ == '__main__':
main()
if __name__ == "__main__":
try:
raise SystemExit(main())
except ReportCommandError as exc:
print(f"错误:{exc}", file=sys.stderr)
raise SystemExit(2) from exc
@@ -33,6 +33,7 @@ Report 查询优先使用下方严格 Shortcut;它们会校验响应、稳定
| 意图 | 首选命令 | 关键约束 |
|---|---|---|
| 列出收到的日志 | `dws report +inbox-list --start <ISO> --end <ISO> --cursor 0 --size 20 --format json` | 从返回的 `reports[]` 使用稳定 `reportId`;模板名筛选在当前返回页本地完成 |
| 按发件人列出收到的日志 | 先 `dws aisearch person --query "<姓名>" --dimension name --format json`,再 `dws report +inbox-list --start <ISO> --end <ISO> --sender-user-ids <USER_ID> --cursor 0 --size 20 --format json` | 只过滤当前 profile 的收件箱;人员零命中或多候选时停止并消歧,禁止默认选择第一项或改查他人的发件箱 |
| 列出自己发出的日志 | `dws report +outbox-list --start <ISO> --end <ISO> --template-name <NAME> --cursor 0 --size 20 --format json` | 创建/修改时间窗最多 20 天;模板明确时服务端过滤 |
| 自己最近一篇日志详情 | `dws report +report-latest --format json` | 需要模板关键词时加 `--keyword <TEXT>` |
| 搜索模板 | `dws report +template-search --query <TEXT> --format json` | 返回当前用户模板中的匹配项和稳定 `templateId` |
@@ -60,10 +61,15 @@ Report 查询优先使用下方严格 Shortcut;它们会校验响应、稳定
## 收件箱:范围、筛选与分页
1. 把“最近 N 天”“今天”等自然语言按 `Asia/Shanghai` 转成带 `+08:00` 的 ISO-8601 起止时间。
2. 每页 `--size` 最大为 20;默认使用 20。若用户要“全部”,只沿当前响应中真实存在且严格前进的 continuation cursor 翻页,直到响应证明 endpoint exhausted;禁止用 50/100 绕过分页。
3. Shortcut 没有模板 flag。按 `templateName` 在每个已返回页本地筛选,并继续翻真实续页;不要因为第一页没有目标模板就跨产品搜索。
4. “最近一篇/两篇”先在限定范围内收集匹配项,再按真实 `createTime` 排序,最后对选中的 `reportId` 调 `entry get`。不要用列表摘要冒充正文。
5. 返回零条或服务端已穷尽时停止。只有用户明确要求,才扩大时间窗;扩大后仍使用 Report 收件箱。
2. 按发件人筛选时,先用 `aisearch person` 在同一 profile 下解析稳定 `userId/staffId`,再把唯一 ID 传给 `--sender-user-ids`;零命中、多候选或身份不完整时停止并消歧,禁止选择第一项。
3. 每页 `--size` 最大为 20;默认使用 20。若用户要“全部”,只沿当前响应中真实存在且严格前进的 continuation cursor 翻页,直到响应证明 endpoint exhausted;禁止用 50/100 绕过分页。
4. Shortcut 没有模板 flag。按 `templateName` 在每个已返回页本地筛选,并继续翻真实续页;不要因为第一页没有目标模板就跨产品搜索。
5. “最近一篇/两篇”先在限定范围内收集匹配项,再按真实 `createTime` 排序,最后对选中的 `reportId` 调 `entry get`。不要用列表摘要冒充正文。
6. 返回零条或服务端已穷尽时停止。只有用户明确要求,才扩大时间窗;扩大后仍使用 Report 收件箱。
### 今日/最近收到的日志摘要脚本
用户只需要今天或最近几天的收件箱摘要时,可执行 [`report_received_today.py`](../scripts/report_received_today.py):`python3 scripts/report_received_today.py --days <N>`。脚本使用 `+inbox-list`,最多扫描 10 页、200 条并受总超时约束;命令失败、响应不完整或达到上限时返回非零状态,不得解释成空结果。脚本不读取每篇正文;用户需要正文时,从摘要中选择明确的 `reportId` 后只调用一次 `entry get`。
## 模板列表与比较
+37
View File
@@ -0,0 +1,37 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package unit_test
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestCrossPlatformCoverageReportSenderRouteAndHelper(t *testing.T) {
root := repoRoot(t)
paths := []string{
filepath.Join(root, "skills", "multi", "dingtalk-misc", "references", "report.md"),
filepath.Join(root, "skills", "mono", "references", "products", "report.md"),
}
for _, path := range paths {
body, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
text := string(body)
for _, required := range []string{
`dws aisearch person --query "<姓名>" --dimension name --format json`,
"dws report +inbox-list",
"--sender-user-ids <USER_ID>",
"零命中或多候选",
"report_received_today.py",
} {
if !strings.Contains(text, required) {
t.Errorf("%s missing reviewed Report route fragment %q", path, required)
}
}
}
}