mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
feat: add iconpark lookup for lark slides (#1123)
This commit is contained in:
@@ -21,6 +21,7 @@ metadata:
|
||||
| 上传或使用图片 | 先上传为 `file_token`,禁止直接写 http(s) 外链 | `slides +media-upload`,或 `+create --slides` 的 `@./path` 占位符 |
|
||||
| 在 slide 中绘制柱/条/折线/面积/雷达/饼等有数据序列的图表 | 使用原生 `<chart>` 元素 | `xml-schema-quick-ref.md` |
|
||||
| 在 slide 中绘制流程图、时序图、架构图、散点图、漏斗图或装饰图案 | 必须先用 Read 工具读取参考文档,再生成 `<whiteboard>` 元素 | [`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md) |
|
||||
| 使用语义图标 | 先检索 IconPark,再写 `<icon iconType="...">` | `iconpark_tool.py search → resolve`、`iconpark.md` |
|
||||
| 用户提到模板、主题、版式 | 先检索模板,再摘要,必要时裁切骨架 | `template_tool.py search → summarize → extract` |
|
||||
| 创建失败、空白页、3350001、布局异常 | 先回读状态,再按排障清单修复,不假设原操作原子成功 | `troubleshooting.md`、`validation-checklist.md` |
|
||||
|
||||
@@ -83,6 +84,7 @@ lark-cli auth login --domain slides
|
||||
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)
|
||||
- 图片:[`lark-slides-media-upload.md`](references/lark-slides-media-upload.md)
|
||||
- 流程图 / 时序图 / 架构图 / 装饰图案:[`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md)
|
||||
- 图标:[`iconpark.md`](references/iconpark.md)、[`scripts/iconpark_tool.py`](scripts/iconpark_tool.py)
|
||||
- 模板:[`template-catalog.md`](references/template-catalog.md)、[`scripts/template_tool.py`](scripts/template_tool.py)
|
||||
- 排障:[`troubleshooting.md`](references/troubleshooting.md)
|
||||
- 完整协议:[`slides_xml_schema_definition.xml`](references/slides_xml_schema_definition.xml)
|
||||
|
||||
41901
skills/lark-slides/references/iconpark-index.json
Normal file
41901
skills/lark-slides/references/iconpark-index.json
Normal file
File diff suppressed because it is too large
Load Diff
46
skills/lark-slides/references/iconpark.md
Normal file
46
skills/lark-slides/references/iconpark.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# IconPark 图标
|
||||
|
||||
IconPark 图标通过 `<icon>` 写入 slides XML,`iconType` 必须来自本 skill 的离线索引或已验证模板,避免凭记忆拼路径。
|
||||
|
||||
## 机器优先流程
|
||||
|
||||
```bash
|
||||
python3 skills/lark-slides/scripts/iconpark_tool.py search --query "增长趋势" --limit 8
|
||||
python3 skills/lark-slides/scripts/iconpark_tool.py resolve --name chart-line
|
||||
python3 skills/lark-slides/scripts/iconpark_tool.py list-categories
|
||||
```
|
||||
|
||||
`search` 返回 JSON 数组,每项包含 `iconType`、`category`、`name`、`tags`、`score`。直接把选中的 `iconType` 写入 XML,并为图标指定可见颜色:
|
||||
|
||||
```xml
|
||||
<icon iconType="iconpark/Charts/chart-line.svg" topLeftX="80" topLeftY="120" width="32" height="32">
|
||||
<fill>
|
||||
<fillColor color="rgba(37, 99, 235, 1)"/>
|
||||
</fill>
|
||||
</icon>
|
||||
```
|
||||
|
||||
## 使用规则
|
||||
|
||||
- 默认先检索:语义图标需求必须先用 `iconpark_tool.py search --limit 8` 或 `--limit 10`,让 agent 从候选里结合版面语义二次判断;不要阅读全文索引,也不要编造不存在的 `iconType`。
|
||||
- 图标用于概念提示、步骤、状态、指标、角色和导航;不要用无关装饰图标填充版面。
|
||||
- 常用尺寸:行内状态图标 16-24px,卡片标题图标 28-40px,主视觉图标 56-96px。
|
||||
- 图标必须显式指定颜色并和背景有足够对比;深色背景优先放在浅色圆形/方形底上,或使用 `rgba(255, 255, 255, 1)` 作为图标填充色。
|
||||
- 查不到合适图标时,用 shape、line、text 画 XML-native fallback,不留空图标位。
|
||||
|
||||
## 高频示例
|
||||
|
||||
| 语义 | iconType |
|
||||
|---|---|
|
||||
| 设置/配置 | `iconpark/Base/setting.svg` |
|
||||
| 目标 | `iconpark/Base/aiming.svg` |
|
||||
| 增长趋势 | `iconpark/Charts/positive-dynamics.svg` |
|
||||
| 折线趋势 | `iconpark/Charts/chart-line.svg` |
|
||||
| 占比 | `iconpark/Charts/chart-proportion.svg` |
|
||||
| 数据看板 | `iconpark/Charts/data-screen.svg` |
|
||||
| 成功 | `iconpark/Character/check-one.svg` |
|
||||
| 失败/风险 | `iconpark/Character/close-one.svg` |
|
||||
| 团队/用户 | `iconpark/Peoples/peoples.svg` |
|
||||
| 安全防护 | `iconpark/Safe/protect.svg` |
|
||||
| 全球/市场 | `iconpark/Travel/world.svg` |
|
||||
| 邮件/联系 | `iconpark/Office/envelope-one.svg` |
|
||||
@@ -84,7 +84,7 @@ lark-cli slides +replace-slide --as user \
|
||||
| `<line>` | 直线 | 需 `startX/startY/endX/endY` |
|
||||
| `<polyline>` | 折线 | `points` 读回时被服务端规整丢弃(几何已入库) |
|
||||
| `<img>` | 图片 | `src` 必须是 [`+media-upload`](lark-slides-media-upload.md) 返回的 `file_token`,不能是 URL |
|
||||
| `<icon>` | 图标 | `iconType` 取自 iconpark 资源 |
|
||||
| `<icon>` | 图标 | `iconType` 取自 iconpark 资源;语义图标先用 `scripts/iconpark_tool.py search` 检索 |
|
||||
| `<table>` | 表格 | 整表替换会**重建内部 td id**,旧 td block_id 立即失效 |
|
||||
| `<td>` | 单元格局部替换 | 只能 `block_replace`,不能 `block_insert`;`block_id` 必须是最新 `slide.get` 拿到的 td id |
|
||||
| `<chart>` | 图表(line/bar/column/pie/area/radar/combo) | 必须嵌 `<chartPlotArea>` + `<chartData>` + `<dim1>/<dim2>/<chartField>` |
|
||||
|
||||
@@ -142,6 +142,8 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
|
||||
<icon iconType="iconpark/Base/setting.svg" topLeftX="80" topLeftY="120" width="32" height="32"/>
|
||||
```
|
||||
|
||||
`iconType` 必须来自已验证的 IconPark 路径。需要语义图标时,先运行 `scripts/iconpark_tool.py search --query "<语义>"`,不要凭记忆拼路径。更多规则见 [iconpark.md](iconpark.md)。
|
||||
|
||||
### whiteboard
|
||||
|
||||
```xml
|
||||
|
||||
362
skills/lark-slides/scripts/iconpark_tool.py
Normal file
362
skills/lark-slides/scripts/iconpark_tool.py
Normal file
@@ -0,0 +1,362 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
SKILL_ROOT = Path(__file__).resolve().parent.parent
|
||||
REFERENCES_DIR = SKILL_ROOT / "references"
|
||||
DEFAULT_INDEX_PATH = REFERENCES_DIR / "iconpark-index.json"
|
||||
DEFAULT_LIMIT = 8
|
||||
CURATED_ICON_BOOSTS = {
|
||||
"设置": {"iconpark/Base/setting.svg"},
|
||||
"配置": {"iconpark/Base/setting.svg", "iconpark/Base/config.svg"},
|
||||
"目标": {"iconpark/Base/aiming.svg", "iconpark/Sports/target-one.svg"},
|
||||
"增长": {"iconpark/Charts/positive-dynamics.svg"},
|
||||
"趋势": {"iconpark/Charts/chart-line.svg", "iconpark/Charts/positive-dynamics.svg"},
|
||||
"占比": {"iconpark/Charts/chart-proportion.svg"},
|
||||
"数据": {"iconpark/Charts/data-screen.svg"},
|
||||
"看板": {"iconpark/Charts/data-screen.svg"},
|
||||
"成功": {"iconpark/Character/check-one.svg"},
|
||||
"完成": {"iconpark/Character/check-one.svg"},
|
||||
"失败": {"iconpark/Character/close-one.svg"},
|
||||
"风险": {"iconpark/Character/close-one.svg"},
|
||||
"团队": {"iconpark/Peoples/peoples.svg"},
|
||||
"用户": {"iconpark/Peoples/peoples.svg", "iconpark/Peoples/user.svg"},
|
||||
"安全": {"iconpark/Safe/protect.svg"},
|
||||
"防护": {"iconpark/Safe/protect.svg"},
|
||||
"全球": {"iconpark/Travel/world.svg"},
|
||||
"市场": {"iconpark/Travel/world.svg"},
|
||||
"邮件": {"iconpark/Office/envelope-one.svg"},
|
||||
"联系": {"iconpark/Office/envelope-one.svg"},
|
||||
"会议": {"iconpark/Office/schedule.svg"},
|
||||
"日程": {"iconpark/Office/schedule.svg"},
|
||||
"飞书": {"iconpark/Brand/bydesign.svg"},
|
||||
}
|
||||
CURATED_BOOST_SCORE = 40
|
||||
|
||||
|
||||
class IconParkToolError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
raise IconParkToolError(message)
|
||||
|
||||
|
||||
def normalize_whitespace(value: str) -> str:
|
||||
return re.sub(r"\s+", " ", value).strip()
|
||||
|
||||
|
||||
def normalize_token(value: str) -> str:
|
||||
return normalize_whitespace(value.lower().replace("_", "-"))
|
||||
|
||||
|
||||
def append_unique(target: list[str], token: str) -> None:
|
||||
normalized = normalize_token(token)
|
||||
if normalized and normalized not in target:
|
||||
target.append(normalized)
|
||||
|
||||
|
||||
def tokenize_query(value: str) -> list[str]:
|
||||
normalized = normalize_token(value)
|
||||
if not normalized:
|
||||
return []
|
||||
|
||||
tokens: list[str] = []
|
||||
for item in re.split(r"[\s,/|,。;;::()()【】\[\]《》<>]+", normalized):
|
||||
append_unique(tokens, item)
|
||||
|
||||
for phrase in re.findall(r"[\u3400-\u9fff]+", normalized):
|
||||
if len(phrase) < 2:
|
||||
continue
|
||||
max_size = min(6, len(phrase))
|
||||
for size in range(max_size, 1, -1):
|
||||
for start in range(0, len(phrase) - size + 1):
|
||||
append_unique(tokens, phrase[start : start + size])
|
||||
|
||||
synonym_tokens = {
|
||||
"目标": ["aim", "target", "goal"],
|
||||
"聚焦": ["focus", "target"],
|
||||
"增长": ["growth", "trend", "positive"],
|
||||
"趋势": ["trend", "chart", "line"],
|
||||
"数据": ["data", "analytics", "chart"],
|
||||
"指标": ["metric", "data"],
|
||||
"看板": ["dashboard", "screen", "data"],
|
||||
"成功": ["success", "check", "done"],
|
||||
"完成": ["done", "success", "check"],
|
||||
"失败": ["fail", "close", "risk"],
|
||||
"风险": ["risk", "fail", "protect"],
|
||||
"安全": ["safe", "security", "protect"],
|
||||
"配置": ["config", "setting", "system"],
|
||||
"设置": ["setting", "config"],
|
||||
"团队": ["team", "people", "users"],
|
||||
"用户": ["user", "people"],
|
||||
"全球": ["global", "world", "earth"],
|
||||
"市场": ["market", "world", "business"],
|
||||
"邮件": ["mail", "message"],
|
||||
"mail": ["message", "envelope", "envelope-one"],
|
||||
"计划": ["plan", "schedule"],
|
||||
"时间": ["time", "schedule"],
|
||||
"学习": ["learning", "education", "book"],
|
||||
"培训": ["training", "education"],
|
||||
"自动化": ["automation", "ai"],
|
||||
"ai": ["ai", "automation", "magic"],
|
||||
}
|
||||
for token in list(tokens):
|
||||
for keyword, aliases in synonym_tokens.items():
|
||||
if is_ascii_token(keyword):
|
||||
matches = token == keyword
|
||||
else:
|
||||
matches = keyword in token
|
||||
if matches:
|
||||
for alias in aliases:
|
||||
append_unique(tokens, alias)
|
||||
|
||||
return tokens
|
||||
|
||||
|
||||
def is_ascii_token(value: str) -> bool:
|
||||
return bool(re.fullmatch(r"[a-z0-9-]+", value))
|
||||
|
||||
|
||||
def allows_substring_match(value: str) -> bool:
|
||||
return not is_ascii_token(value) or len(value) >= 3
|
||||
|
||||
|
||||
def field_tokens(*values: str) -> set[str]:
|
||||
tokens: set[str] = set()
|
||||
for value in values:
|
||||
normalized = normalize_token(value)
|
||||
if not normalized:
|
||||
continue
|
||||
tokens.add(normalized)
|
||||
for part in re.split(r"[-\s]+", normalized):
|
||||
if part:
|
||||
tokens.add(part)
|
||||
return tokens
|
||||
|
||||
|
||||
def load_index(path: str | Path = DEFAULT_INDEX_PATH) -> dict[str, Any]:
|
||||
index_path = Path(path)
|
||||
if not index_path.exists():
|
||||
fail(f"iconpark index not found: {index_path}")
|
||||
try:
|
||||
index_data = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as error:
|
||||
fail(f"invalid iconpark index JSON: {error}")
|
||||
if not isinstance(index_data.get("icons"), list):
|
||||
fail("iconpark index must contain an icons array")
|
||||
return index_data
|
||||
|
||||
|
||||
def icon_search_text(entry: dict[str, Any]) -> str:
|
||||
parts = [
|
||||
entry.get("iconType", ""),
|
||||
entry.get("category", ""),
|
||||
entry.get("name", ""),
|
||||
" ".join(entry.get("tags") or []),
|
||||
]
|
||||
return normalize_token(" ".join(parts))
|
||||
|
||||
|
||||
def score_icon(entry: dict[str, Any], query: str, tokens: list[str]) -> int:
|
||||
raw_icon_type = entry.get("iconType", "")
|
||||
icon_type = normalize_token(raw_icon_type)
|
||||
category = normalize_token(entry.get("category", ""))
|
||||
name = normalize_token(entry.get("name", ""))
|
||||
tags = [normalize_token(tag) for tag in entry.get("tags") or []]
|
||||
name_tokens = field_tokens(name)
|
||||
category_tokens = field_tokens(category)
|
||||
tag_tokens = field_tokens(*tags)
|
||||
icon_type_tokens = field_tokens(icon_type)
|
||||
search_text = icon_search_text(entry)
|
||||
normalized_query = normalize_token(query)
|
||||
|
||||
score = 0
|
||||
boosted_keywords: set[str] = set()
|
||||
if normalized_query:
|
||||
if normalized_query == icon_type or normalized_query == name:
|
||||
score += 200
|
||||
elif normalized_query in tag_tokens:
|
||||
score += 120
|
||||
elif normalized_query in icon_type_tokens:
|
||||
score += 60
|
||||
elif allows_substring_match(normalized_query) and normalized_query in search_text:
|
||||
score += 30
|
||||
|
||||
for token in tokens:
|
||||
for keyword, boosted_icon_types in CURATED_ICON_BOOSTS.items():
|
||||
if keyword in boosted_keywords:
|
||||
continue
|
||||
if keyword in token and raw_icon_type in boosted_icon_types:
|
||||
score += CURATED_BOOST_SCORE
|
||||
boosted_keywords.add(keyword)
|
||||
if token == name:
|
||||
score += 80
|
||||
elif token in name_tokens:
|
||||
score += 55
|
||||
elif allows_substring_match(token) and token in name:
|
||||
score += 45
|
||||
if token == category:
|
||||
score += 35
|
||||
elif token in category_tokens:
|
||||
score += 25
|
||||
elif allows_substring_match(token) and token in category:
|
||||
score += 15
|
||||
for tag in tags:
|
||||
if token == tag:
|
||||
score += 60
|
||||
elif token in field_tokens(tag):
|
||||
score += 45
|
||||
elif allows_substring_match(token) and token in tag:
|
||||
score += 20
|
||||
if token in icon_type_tokens:
|
||||
score += 20
|
||||
elif allows_substring_match(token) and token in icon_type:
|
||||
score += 15
|
||||
|
||||
return score
|
||||
|
||||
|
||||
def parse_limit(value: Any) -> int:
|
||||
if value is None or value is False:
|
||||
return DEFAULT_LIMIT
|
||||
if value is True:
|
||||
fail("limit requires an integer value")
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
fail(f"limit must be an integer: {value}")
|
||||
|
||||
|
||||
def public_icon(entry: dict[str, Any], score: int | None = None) -> dict[str, Any]:
|
||||
result = {
|
||||
"iconType": entry["iconType"],
|
||||
"category": entry["category"],
|
||||
"name": entry["name"],
|
||||
"tags": entry.get("tags") or [],
|
||||
}
|
||||
if score is not None:
|
||||
result["score"] = score
|
||||
return result
|
||||
|
||||
|
||||
def search_icons(index_data: dict[str, Any], options: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
query = str(options.get("query") or "")
|
||||
if not normalize_whitespace(query):
|
||||
fail("query is required")
|
||||
limit = parse_limit(options.get("limit"))
|
||||
category_filter = normalize_token(str(options.get("category") or ""))
|
||||
tokens = tokenize_query(query)
|
||||
|
||||
ranked: list[dict[str, Any]] = []
|
||||
for entry in index_data["icons"]:
|
||||
if category_filter and normalize_token(entry.get("category", "")) != category_filter:
|
||||
continue
|
||||
score = score_icon(entry, query, tokens)
|
||||
if query and score == 0:
|
||||
continue
|
||||
ranked.append(public_icon(entry, score))
|
||||
|
||||
ranked.sort(key=lambda item: (-int(item["score"]), item["category"], item["name"]))
|
||||
return ranked[: max(limit, 0)]
|
||||
|
||||
|
||||
def resolve_icon(index_data: dict[str, Any], name_or_type: str | None) -> dict[str, Any]:
|
||||
if not name_or_type:
|
||||
fail("name is required")
|
||||
target = normalize_token(name_or_type)
|
||||
matches = []
|
||||
for entry in index_data["icons"]:
|
||||
candidates = {
|
||||
normalize_token(entry["iconType"]),
|
||||
normalize_token(entry["name"]),
|
||||
normalize_token(f'{entry["category"]}/{entry["name"]}.svg'),
|
||||
}
|
||||
if target in candidates:
|
||||
matches.append(entry)
|
||||
if not matches:
|
||||
fail(f"icon not found: {name_or_type}")
|
||||
if len(matches) > 1:
|
||||
names = ", ".join(entry["iconType"] for entry in matches)
|
||||
fail(f"ambiguous icon name: {name_or_type}; matches: {names}")
|
||||
return public_icon(matches[0])
|
||||
|
||||
|
||||
def list_categories(index_data: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
counts: dict[str, int] = {}
|
||||
for entry in index_data["icons"]:
|
||||
counts[entry["category"]] = counts.get(entry["category"], 0) + 1
|
||||
return [{"category": category, "count": counts[category]} for category in sorted(counts)]
|
||||
|
||||
|
||||
def parse_cli_args(argv: list[str]) -> tuple[str | None, dict[str, Any]]:
|
||||
if not argv:
|
||||
return None, {}
|
||||
command, *rest = argv
|
||||
options: dict[str, Any] = {}
|
||||
index = 0
|
||||
while index < len(rest):
|
||||
token = rest[index]
|
||||
if not token.startswith("--"):
|
||||
fail(f"unexpected argument: {token}")
|
||||
key = token[2:]
|
||||
next_token = rest[index + 1] if index + 1 < len(rest) else None
|
||||
if next_token is None or next_token.startswith("--"):
|
||||
options[key] = True
|
||||
index += 1
|
||||
continue
|
||||
options[key] = next_token
|
||||
index += 2
|
||||
return command, options
|
||||
|
||||
|
||||
def print_usage() -> None:
|
||||
usage = [
|
||||
"Usage:",
|
||||
" python3 iconpark_tool.py search --query <text> [--category <Category>] [--limit 8]",
|
||||
" python3 iconpark_tool.py resolve --name <name|iconType>",
|
||||
" python3 iconpark_tool.py list-categories",
|
||||
]
|
||||
print("\n".join(usage), file=sys.stderr)
|
||||
|
||||
|
||||
def write_json(value: Any) -> None:
|
||||
print(json.dumps(value, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
def run_cli(argv: list[str] | None = None) -> None:
|
||||
command, options = parse_cli_args(argv or sys.argv[1:])
|
||||
if not command or command in {"--help", "help"}:
|
||||
print_usage()
|
||||
raise SystemExit(0)
|
||||
|
||||
index_data = load_index()
|
||||
if command == "search":
|
||||
write_json(search_icons(index_data, options))
|
||||
return
|
||||
if command == "resolve":
|
||||
write_json(resolve_icon(index_data, options.get("name")))
|
||||
return
|
||||
if command == "list-categories":
|
||||
write_json(list_categories(index_data))
|
||||
return
|
||||
|
||||
print_usage()
|
||||
fail(f"unknown command: {command}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
run_cli()
|
||||
except IconParkToolError as error:
|
||||
print(f"iconpark-tool error: {error}", file=sys.stderr)
|
||||
raise SystemExit(1) from error
|
||||
177
skills/lark-slides/scripts/iconpark_tool_test.py
Normal file
177
skills/lark-slides/scripts/iconpark_tool_test.py
Normal file
@@ -0,0 +1,177 @@
|
||||
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
# SPDX-License-Identifier: MIT
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import iconpark_tool
|
||||
|
||||
|
||||
SCRIPT_PATH = Path(__file__).resolve().with_name("iconpark_tool.py")
|
||||
|
||||
|
||||
class IconParkToolTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.index_data = iconpark_tool.load_index()
|
||||
|
||||
def test_search_icons_finds_growth_trend(self) -> None:
|
||||
results = iconpark_tool.search_icons(self.index_data, {"query": "增长趋势", "limit": 5})
|
||||
self.assertTrue(results)
|
||||
self.assertTrue(
|
||||
any(entry["iconType"] == "iconpark/Charts/positive-dynamics.svg" for entry in results)
|
||||
)
|
||||
|
||||
def test_search_icons_supports_english_query(self) -> None:
|
||||
results = iconpark_tool.search_icons(self.index_data, {"query": "security protect", "limit": 3})
|
||||
self.assertTrue(results)
|
||||
self.assertEqual(results[0]["iconType"], "iconpark/Safe/protect.svg")
|
||||
|
||||
def test_search_icons_supports_category_filter(self) -> None:
|
||||
results = iconpark_tool.search_icons(
|
||||
self.index_data,
|
||||
{"query": "data", "category": "Charts", "limit": 10},
|
||||
)
|
||||
self.assertTrue(results)
|
||||
self.assertTrue(all(entry["category"] == "Charts" for entry in results))
|
||||
|
||||
def test_search_icons_does_not_expand_ai_inside_words(self) -> None:
|
||||
mail_results = iconpark_tool.search_icons(self.index_data, {"query": "mail", "limit": 5})
|
||||
self.assertEqual(mail_results[0]["iconType"], "iconpark/Office/envelope-one.svg")
|
||||
self.assertNotEqual(mail_results[0]["iconType"], "iconpark/Others/magic.svg")
|
||||
|
||||
fail_results = iconpark_tool.search_icons(self.index_data, {"query": "fail", "limit": 5})
|
||||
self.assertNotEqual(fail_results[0]["iconType"], "iconpark/Others/magic.svg")
|
||||
|
||||
def test_search_icons_supports_template_icon_queries(self) -> None:
|
||||
cases = [
|
||||
("arrow", "iconpark/Arrows/arrow-right.svg"),
|
||||
("right", "iconpark/Arrows/right.svg"),
|
||||
("PPT", "iconpark/Music/ppt.svg"),
|
||||
("table", "iconpark/Office/table.svg"),
|
||||
("会议", "iconpark/Office/schedule.svg"),
|
||||
("飞书", "iconpark/Brand/bydesign.svg"),
|
||||
]
|
||||
for query, icon_type in cases:
|
||||
with self.subTest(query=query):
|
||||
results = iconpark_tool.search_icons(self.index_data, {"query": query, "limit": 10})
|
||||
self.assertTrue(
|
||||
any(entry["iconType"] == icon_type for entry in results),
|
||||
f"{icon_type} not found in {results}",
|
||||
)
|
||||
|
||||
def test_search_icons_defaults_to_wider_candidate_set(self) -> None:
|
||||
results = iconpark_tool.search_icons(self.index_data, {"query": "data"})
|
||||
self.assertEqual(len(results), 8)
|
||||
|
||||
def test_search_icons_boosts_common_slide_terms(self) -> None:
|
||||
results = iconpark_tool.search_icons(self.index_data, {"query": "会议", "limit": 3})
|
||||
self.assertTrue(
|
||||
any(entry["iconType"] == "iconpark/Office/schedule.svg" for entry in results),
|
||||
f"iconpark/Office/schedule.svg not found in {results}",
|
||||
)
|
||||
|
||||
def test_search_icons_keeps_high_value_top_results(self) -> None:
|
||||
cases = [
|
||||
("安全", "iconpark/Safe/protect.svg"),
|
||||
("邮件", "iconpark/Office/mail-open.svg"),
|
||||
("会议", "iconpark/Office/schedule.svg"),
|
||||
("增长趋势", "iconpark/Charts/chart-line.svg"),
|
||||
("飞书", "iconpark/Brand/bydesign.svg"),
|
||||
]
|
||||
for query, icon_type in cases:
|
||||
with self.subTest(query=query):
|
||||
results = iconpark_tool.search_icons(self.index_data, {"query": query, "limit": 3})
|
||||
self.assertTrue(results)
|
||||
self.assertEqual(results[0]["iconType"], icon_type)
|
||||
|
||||
def test_search_icons_requires_query(self) -> None:
|
||||
with self.assertRaises(iconpark_tool.IconParkToolError):
|
||||
iconpark_tool.search_icons(self.index_data, {"limit": 5})
|
||||
|
||||
def test_search_icons_rejects_invalid_limit(self) -> None:
|
||||
with self.assertRaises(iconpark_tool.IconParkToolError):
|
||||
iconpark_tool.search_icons(self.index_data, {"query": "data", "limit": "abc"})
|
||||
|
||||
def test_resolve_icon_accepts_name_and_icon_type(self) -> None:
|
||||
by_name = iconpark_tool.resolve_icon(self.index_data, "chart-line")
|
||||
by_type = iconpark_tool.resolve_icon(self.index_data, "iconpark/Charts/chart-line.svg")
|
||||
self.assertEqual(by_name["iconType"], "iconpark/Charts/chart-line.svg")
|
||||
self.assertEqual(by_name, by_type)
|
||||
|
||||
def test_resolve_icon_accepts_template_icon_type(self) -> None:
|
||||
result = iconpark_tool.resolve_icon(self.index_data, "iconpark/Arrows/arrow-right.svg")
|
||||
self.assertEqual(result["iconType"], "iconpark/Arrows/arrow-right.svg")
|
||||
|
||||
def test_resolve_icon_rejects_unknown_name(self) -> None:
|
||||
with self.assertRaises(iconpark_tool.IconParkToolError):
|
||||
iconpark_tool.resolve_icon(self.index_data, "not-a-real-icon")
|
||||
|
||||
def test_list_categories_counts_index(self) -> None:
|
||||
categories = iconpark_tool.list_categories(self.index_data)
|
||||
self.assertTrue(any(entry["category"] == "Charts" and entry["count"] > 0 for entry in categories))
|
||||
|
||||
|
||||
class IconParkToolCLITest(unittest.TestCase):
|
||||
def run_tool(self, *args: str) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(SCRIPT_PATH), *args],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
text=True,
|
||||
)
|
||||
|
||||
def test_cli_search_writes_json_to_stdout(self) -> None:
|
||||
result = self.run_tool("search", "--query", "增长趋势", "--limit", "5")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.stderr, "")
|
||||
|
||||
output = json.loads(result.stdout)
|
||||
self.assertTrue(output)
|
||||
self.assertTrue(
|
||||
any(entry["iconType"] == "iconpark/Charts/positive-dynamics.svg" for entry in output)
|
||||
)
|
||||
|
||||
def test_cli_resolve_writes_json_to_stdout(self) -> None:
|
||||
result = self.run_tool("resolve", "--name", "chart-line")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.stderr, "")
|
||||
|
||||
output = json.loads(result.stdout)
|
||||
self.assertEqual(output["iconType"], "iconpark/Charts/chart-line.svg")
|
||||
|
||||
def test_cli_list_categories_writes_json_to_stdout(self) -> None:
|
||||
result = self.run_tool("list-categories")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.stderr, "")
|
||||
|
||||
output = json.loads(result.stdout)
|
||||
self.assertTrue(any(entry["category"] == "Charts" and entry["count"] > 0 for entry in output))
|
||||
|
||||
def test_cli_help_writes_usage_to_stderr(self) -> None:
|
||||
result = self.run_tool("--help")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.stdout, "")
|
||||
self.assertIn("Usage:", result.stderr)
|
||||
self.assertIn("python3 iconpark_tool.py search", result.stderr)
|
||||
|
||||
def test_cli_invalid_argument_writes_error_to_stderr(self) -> None:
|
||||
result = self.run_tool("search", "增长趋势")
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertEqual(result.stdout, "")
|
||||
self.assertIn("iconpark-tool error: unexpected argument: 增长趋势", result.stderr)
|
||||
|
||||
def test_cli_unknown_command_writes_usage_and_error_to_stderr(self) -> None:
|
||||
result = self.run_tool("unknown")
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertEqual(result.stdout, "")
|
||||
self.assertIn("Usage:", result.stderr)
|
||||
self.assertIn("iconpark-tool error: unknown command: unknown", result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user