d4bf172990
leo 2026-09-07:「拆,誰說『同名跨 repo 是 mainline 機制的一部分』,早就定了只有一個 milestone,不同 repo 用指針」。上一版 scripts/mainline 檔頭把現場的樣子(5 個同名)寫成 「既有做法」,成員也照標題跨 repo 收;總管照它做、抄進 ops-facts、還在 arcrun-rag 補建了一個同名的。 改了什麼(判準全部是 Gitea 欄位,沒有標題比對): - hooks/lib/mainline.py:collect_members()=hub 里程碑裡的票+hub 票,沿 /dependencies 邊 (跨 repo)收下去;belongs() 要同時拿到「掛哪個 milestone」+「誰把它當相依」才准說「不屬於」, 只知一半回 None(閘放行);project_roots() 給主線檔與帳本共用 - scripts/mainline:set --hub <票>、adopt 別 repo ⇒ POST 相依到 hub 票(--via 可指定成員), 沒 hub 票時擋下並給兩條走得通的路,不建同名里程碑;has 查 /blocks; refresh --members 才重收(真 Gitea 25 張要 19 秒,SessionStart 成本不變) - scripts/ticket:add_dependency() 一份(subtask 與 adopt 共用,409 冪等);pick 主線組走成員清單, 逾期組一個里程碑物件一組;claim 的主線判定改看相依;pickable(None) 給靠邊進來的票 - hooks/mainline-focus-guard.sh:新鮮查詢問 milestone+/blocks,補收出路明講不要開同名里程碑 - scripts/milestone-account:帳本找家用同一把尺,退回原始碼目錄只在它有 .git(plugin 快取沒有) 測試:新 hooks/tests/mainline-members-by-dependency.test.sh 56 條(假 Gitea 照真的回 201/409); scripts/test-ticket-pick.sh 48→53 條(池子改用相依邊,補「同名沒邊 ⇒ 不抓」); 既有 mainline-focus-guard/repo-mirror/gitea-fallback/idle/milestone-account/debt-worklist/ handoff-writeback 全綠。真 Gitea 唯讀實跑:set --hub inkstone/InkStoneCo#44 收到 25 張、5 個 repo, mira#6/ISEP#130/ISEP#140/arcrun-rag#104 都在,不必新增任何邊。 版本:待總管定版(改了會被載入的 hook/scripts)。 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0178ef1fGw3XeZtpN7LaZrm4
250 lines
16 KiB
Bash
Executable File
250 lines
16 KiB
Bash
Executable File
#!/bin/bash
|
||
# 雲端工人抓票規則的測試(inkstone/ISEP#131)
|
||
#
|
||
# 票上的驗收第 1 條:「`scripts/ticket pick` 給定主線檔,回一張符合規則的票;
|
||
# 池子裡塞一張 `Human`、一張有 assignee、一張 backlog,三張都不會被挑到(測試綠)。」
|
||
#
|
||
# 這支測四件事:
|
||
# ① `pickable()` 純函式——規則本體,一格一條,不打網路
|
||
# ② `pick` 走整條路(把 api() 換成錄音機餵一個假池子):挑到誰、順序、主線抓盡才抓逾期、
|
||
# 沒有可抓的票時離開碼 1、沒有主線時離開碼 2 且訊息講得出出路
|
||
# ③ `claim` 一個動作三件事(指派/s/doing/【身份】留言),以及該擋的擋在**打 API 之前**
|
||
# ④ 不該擋的(誤攔比漏擋更該修)
|
||
#
|
||
# 全程離線:假池子在下面的 POOL;TICKET_HOST 指到連不上的位址,任何一條真的走到網路會當場炸,
|
||
# 不會靜靜變成假綠。不開票、不改票、不留任何東西在 Gitea 上。
|
||
cd "$(dirname "$0")/.." || exit 1
|
||
T=scripts/ticket
|
||
TMP=$(mktemp -d)
|
||
trap 'rm -rf "$TMP"' EXIT
|
||
PASS=0; FAIL=0
|
||
export TICKET_HOST=http://127.0.0.1:9 GITEA_TOKEN=x
|
||
export ISEP_COUNTDOWN_STATE_DIR="$TMP/state"; mkdir -p "$ISEP_COUNTDOWN_STATE_DIR"
|
||
export ISEP_COUNTDOWN_NOW=$(python3 -c 'import datetime as d;print(int(d.datetime(2026,9,7,0,0,tzinfo=d.timezone.utc).timestamp()))')
|
||
unset ISEP_MAINLINE_FILE ISEP_IDENTITY CLAUDE_PROJECT_DIR
|
||
|
||
MAIN="$TMP/mainline.json"
|
||
cat > "$MAIN" <<'JSON'
|
||
{"ref":"inkstone/Arcrun#48","owner":"inkstone","repo":"Arcrun","id":48,
|
||
"title":"主線 M","description":"測試用主線","due_on":"2026-09-12T23:59:59Z",
|
||
"open_issues":3,"closed_issues":0,"members":[],"set_at":"2026-09-07 07:30"}
|
||
JSON
|
||
|
||
# ── 假池子:三個 repo、三個 open milestone、十一張票 ─────────────────────
|
||
# 主線 M(未逾期,主線)/逾期 M(2026-08-19 到期)/未來 M(2026-10-01 到期,不是主線)
|
||
cat > "$TMP/pool.py" <<'PY'
|
||
import json, os
|
||
MS = {"main": {"id": 48, "title": "主線 M", "due_on": "2026-09-12T23:59:59Z"},
|
||
"late": {"id": 49, "title": "逾期 M", "due_on": "2026-08-19T23:59:59Z"},
|
||
"future": {"id": 50, "title": "未來 M", "due_on": "2026-10-01T23:59:59Z"}}
|
||
def I(repo, n, labels, ms=None, assignees=(), created="2026-09-01T00:00:00Z", pr=False, title=None):
|
||
d = {"number": n, "title": title or f"票 {repo}#{n}", "state": "open",
|
||
"labels": [{"name": l} for l in labels],
|
||
"assignees": [{"login": a} for a in assignees],
|
||
"milestone": MS[ms] if ms else None, "created_at": created,
|
||
"repository": {"full_name": f"inkstone/{repo}"}}
|
||
if pr: d["pull_request"] = {"merged": False}
|
||
return d
|
||
POOL = {
|
||
"ISEP": [I("ISEP", 1, ["s/todo"], None), # ★ 可抓:沒里程碑,靠相依邊在主線上
|
||
I("ISEP", 13, ["s/todo"], "main"), # ISEP 裡「同名」的里程碑:不是主線(ISEP#133)
|
||
I("ISEP", 2, ["s/todo", "Human"], "main"), # 票上第 1 條:Human
|
||
I("ISEP", 3, ["s/todo"], "main", assignees=("someone",)), # 票上第 1 條:有 assignee
|
||
I("ISEP", 4, ["s/backlog"], None), # 票上第 1 條:backlog
|
||
I("ISEP", 5, ["s/todo"], None), # 沒里程碑
|
||
I("ISEP", 10, ["s/todo"], "main", pr=True), # PR 不是票
|
||
I("ISEP", 11, ["s/todo", "human/exec"], "main")], # 執行者是人
|
||
"Arcrun": [I("Arcrun", 6, ["s/todo", "p/high"], "main", created="2026-08-27T00:00:00Z"), # ★ 主線裡最優先
|
||
I("Arcrun", 7, ["s/todo"], "late"), # 逾期里程碑:主線抓盡才輪到
|
||
I("Arcrun", 8, ["s/todo"], "future")], # 不是主線也沒逾期:永遠不抓
|
||
"mira": [I("mira", 9, ["s/todo", "s/doing"], "main")], # 09-07 實查 mira#6 的形狀:有人在做
|
||
}
|
||
MSN = {"ISEP": ["main", "late", "future"], "Arcrun": ["main", "late", "future"], "mira": ["main"]}
|
||
# 相依邊(inkstone/ISEP#133):主線錨在 Arcrun#48,Arcrun#6 把 ISEP#1 當相依 ⇒ ISEP#1 在主線上;
|
||
# ISEP#13 掛在 ISEP 的同名里程碑但沒有任何邊 ⇒ 不在主線上。mira#9 同理(同名、沒邊)。
|
||
DEPS = {"Arcrun#6": ["ISEP#1"]}
|
||
BLOCKS = {}
|
||
for _p, _cs in DEPS.items():
|
||
for _c in _cs:
|
||
BLOCKS.setdefault(_c, []).append(_p)
|
||
def _by_ref(ref):
|
||
repo, n = ref.split("#")
|
||
return next(i for i in POOL[repo] if i["number"] == int(n))
|
||
DROP = set(json.loads(os.environ.get("DROP", "[]"))) # 測「主線抓盡」用:把某幾張從池子拿掉
|
||
def fake_api(path, payload=None, method=None):
|
||
rec.append((method or ("POST" if payload is not None else "GET"), path, payload))
|
||
if path.startswith("/orgs/inkstone/repos"):
|
||
return [{"name": r} for r in POOL]
|
||
if path == "/user":
|
||
return {"login": "claude-code"}
|
||
if "/milestones?" in path:
|
||
repo = path.split("/")[3]
|
||
return [MS[k] for k in MSN.get(repo, [])]
|
||
if "/issues?" in path:
|
||
repo = path.split("/")[3]
|
||
import urllib.parse
|
||
q = urllib.parse.parse_qs(path.split("?", 1)[1])
|
||
want = q.get("milestones", [""])[0]
|
||
return [i for i in POOL[repo] if (i["milestone"] or {}).get("title") == want
|
||
and f"{repo}#{i['number']}" not in DROP]
|
||
if path.endswith("/labels?limit=100"):
|
||
return [{"name": n, "id": k} for k, n in
|
||
enumerate(["s/todo", "s/doing", "s/review", "p/high", "Human"], 1)]
|
||
import re
|
||
m = re.match(r"/repos/inkstone/(\w+)/issues/(\d+)/(dependencies|blocks)(\?.*)?$", path)
|
||
if m:
|
||
table = DEPS if m.group(3) == "dependencies" else BLOCKS
|
||
return [_by_ref(r) for r in table.get(f"{m.group(1)}#{m.group(2)}", [])]
|
||
m = re.match(r"/repos/inkstone/(\w+)/issues/(\d+)$", path)
|
||
if m and (method or "GET") == "GET":
|
||
return next(i for i in POOL[m.group(1)] if i["number"] == int(m.group(2)))
|
||
return {}
|
||
rec = []
|
||
PY
|
||
|
||
# probe <python 片段> —— 把 scripts/ticket 當模組載進來、api() 換成假池子,印出片段的值
|
||
probe() {
|
||
python3 - "$1" "$2" <<'PY'
|
||
import sys, os, json, io, contextlib, importlib.util as u
|
||
from importlib.machinery import SourceFileLoader as L
|
||
spec = u.spec_from_loader("t", L("t", "scripts/ticket"))
|
||
m = u.module_from_spec(spec); spec.loader.exec_module(m)
|
||
ns = {}
|
||
exec(open(os.environ["POOL_PY"]).read(), ns)
|
||
m.api = ns["fake_api"]; rec = ns["rec"]
|
||
argv = json.loads(sys.argv[2]) if sys.argv[2] else None
|
||
buf = io.StringIO(); rc = None
|
||
if argv is not None:
|
||
with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf):
|
||
try:
|
||
getattr(m, argv[0])(argv[1:])
|
||
except SystemExit as e:
|
||
rc = e.code
|
||
print(eval(sys.argv[1], {"m": m, "rec": rec, "rc": rc, "out": buf.getvalue(), "I": ns["I"], "json": json}))
|
||
PY
|
||
}
|
||
export POOL_PY="$TMP/pool.py"
|
||
|
||
p() { # p <說明> <want> <python 片段> [argv json]
|
||
got=$(probe "$3" "${4:-}" 2>&1)
|
||
if [ "$got" = "$2" ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
|
||
printf 'want=%s got=%s %s\n' "$2" "$got" "$1"
|
||
}
|
||
|
||
echo "── ① pickable():規則本體,一格一條(純函式,不打網路)──"
|
||
p "★ s/todo+沒人+沒 Human+主線 → 可抓" "True" "m.pickable(I('ISEP',1,['s/todo'],'main'),'主線 M')[0]"
|
||
p "★ 掛 Human → 不可抓(票上第 1 條)" "False" "m.pickable(I('ISEP',2,['s/todo','Human'],'main'),'主線 M')[0]"
|
||
p "★ 有 assignee → 不可抓(票上第 1 條)" "False" "m.pickable(I('ISEP',3,['s/todo'],'main',assignees=('x',)),'主線 M')[0]"
|
||
p "★ s/backlog、沒里程碑 → 不可抓(票上第 1 條)" "False" "m.pickable(I('ISEP',4,['s/backlog'],None),'主線 M')[0]"
|
||
p "s/todo 但沒里程碑 → 不可抓(不抓沒里程碑的)" "False" "m.pickable(I('ISEP',5,['s/todo'],None),'主線 M')[0]"
|
||
p "human/exec(執行者是人)→ 不可抓" "False" "m.pickable(I('ISEP',11,['s/todo','human/exec'],'main'),'主線 M')[0]"
|
||
p "s/todo+s/doing 同時掛著(mira#6 的形狀)→ 不可抓,有人在做" "False" "m.pickable(I('mira',9,['s/todo','s/doing'],'main'),'主線 M')[0]"
|
||
p "hub(scope 容器,09-07 實查 ISEP#31–35 的形狀)→ 不可抓" "False" "m.pickable(I('ISEP',12,['s/todo','hub'],'main'),'主線 M')[0]"
|
||
p "PR 不是票 → 不可抓" "False" "m.pickable(I('ISEP',10,['s/todo'],'main',pr=True),'主線 M')[0]"
|
||
p "里程碑不是要求的那個 → 不可抓" "False" "m.pickable(I('Arcrun',8,['s/todo'],'future'),'主線 M')[0]"
|
||
p "里程碑標題只差空白/大小寫 → 仍算同一個(norm 只有 mainline.py 那一份)" "True" "m.pickable(I('ISEP',1,['s/todo'],'main'),' 主線 m ')[0]"
|
||
p "理由講得出「為什麼不能抓」(不是只回 False)" "True" "'Human' in m.pickable(I('ISEP',2,['s/todo','Human'],'main'),'主線 M')[1]"
|
||
p "順序:p/high 先於沒標,沒標先於 p/low" "True" \
|
||
"m.pick_order(I('a',1,['s/todo','p/high'],'main')) < m.pick_order(I('a',2,['s/todo'],'main')) < m.pick_order(I('a',3,['s/todo','p/low'],'main'))"
|
||
p "順序:同優先序看開票時間,舊的先" "True" \
|
||
"m.pick_order(I('a',9,['s/todo'],'main',created='2026-08-01T00:00:00Z')) < m.pick_order(I('a',1,['s/todo'],'main',created='2026-09-01T00:00:00Z'))"
|
||
|
||
echo
|
||
echo "── ② pick 走整條路(假池子 11 張,三個 repo)──"
|
||
ARGS="[\"cmd_pick\",\"--mainline\",\"$MAIN\"]"
|
||
ALL="[\"cmd_pick\",\"--mainline\",\"$MAIN\",\"--all\",\"--json\"]"
|
||
p "★ 給定主線檔 → 回一張,而且是主線裡最優先的 Arcrun#6(p/high)" "True" "'inkstone/Arcrun#6' in out and rc is None" "$ARGS"
|
||
p "只回一張(不是整份清單)" "1" "out.count(' ● ')" "$ARGS"
|
||
p "★ --all:主線裡可抓的恰好是 Arcrun#6、ISEP#1 兩張——Human/assignee/backlog 三張都不在" \
|
||
"['inkstone/Arcrun#6', 'inkstone/ISEP#1']" "[r['ref'] for r in json.loads(out) if r['group']=='主線']" "$ALL"
|
||
p "★ ISEP#1 沒掛里程碑,是靠「Arcrun#6 把它當相依」進主線的(成員=相依邊,ISEP#133)" "True" \
|
||
"'inkstone/ISEP#1' in out and any(p.endswith('/issues/6/dependencies?limit=100') for _,p,_ in rec)" "$ALL"
|
||
p "★ ISEP#13 掛在 ISEP 裡**同名**的里程碑、沒有任何邊 → 不在主線裡(同名不算,leo 09-07)" "False" \
|
||
"'inkstone/ISEP#13' in out" "$ALL"
|
||
p "主線那一組只讀 hub 里程碑那一個 repo 的 milestone 清單(不去別的 repo 找同名)" "True" \
|
||
"not any('/repos/inkstone/ISEP/issues?' in p and '主線' in p for _,p,_ in rec)" "$ALL"
|
||
p "★ --all:逾期里程碑那張 Arcrun#7 排在主線之後(主線抓盡才輪到)" \
|
||
"['inkstone/Arcrun#7']" "[r['ref'] for r in json.loads(out) if r['group']!='主線']" "$ALL"
|
||
p "沒逾期也不是主線的 Arcrun#8 不在清單裡" "False" "'inkstone/Arcrun#8' in out" "$ALL"
|
||
p "backlog(ISEP#4)/沒里程碑(ISEP#5)不在清單裡" "False" "'ISEP#4' in out or 'ISEP#5' in out" "$ALL"
|
||
p "沒有任何一條真的走到網路(api 全被錄音機接住)" "True" "all(p.startswith('/') for _,p,_ in rec)" "$ALL"
|
||
p "pick 只讀不寫(沒有 POST/PATCH/PUT)" "True" "all(mm=='GET' for mm,_,_ in rec)" "$ALL"
|
||
p "訊息印出下一步(認領那一行用絕對路徑,貼到別的 cwd 也叫得到正本)" "True" "'claim inkstone/Arcrun#6' in out and m.self_path() in out" "$ARGS"
|
||
|
||
echo
|
||
echo " (主線抓盡 → 才輪到逾期里程碑)"
|
||
export DROP='["Arcrun#6","ISEP#1"]'
|
||
p "★ 主線兩張都被拿走後 → 回逾期里程碑的 Arcrun#7" "True" "'inkstone/Arcrun#7' in out and rc is None" "$ARGS"
|
||
export DROP='["Arcrun#6","ISEP#1","Arcrun#7"]'
|
||
p "★ 主線與逾期都抓盡 → 離開碼 1(真的沒有,不是故障)" "1" "rc" "$ARGS"
|
||
p "訊息明講「不要去抓 backlog」(出路是收工回報,不是換規則)" "True" "'backlog' in out and '收工' in out" "$ARGS"
|
||
p "抓盡時**沒有**回 Arcrun#8(未來里程碑)" "False" "'Arcrun#8' in out" "$ARGS"
|
||
unset DROP
|
||
|
||
echo
|
||
echo " (沒有主線 ≠ 去抓別的)"
|
||
NOML="[\"cmd_pick\",\"--mainline\",\"$TMP/none.json\"]"
|
||
p "主線檔讀不到 → 離開碼 2(讀不到 ≠ 沒有)" "2" "rc" "$NOML"
|
||
p "…而且一通 API 都沒打" "0" "len(rec)" "$NOML"
|
||
p "沒給 --mainline、狀態檔也沒有、cwd 也沒有 → 離開碼 2,訊息給出兩條出路" "True" \
|
||
"rc==2 and 'mainline set' in out and 'system-dev/mainline.json' in out" "[\"cmd_pick\"]"
|
||
|
||
echo
|
||
echo "── ③ claim:一個動作三件事 ──"
|
||
CL="[\"cmd_claim\",\"inkstone/Arcrun#6\",\"--mainline\",\"$MAIN\",\"--name\",\"isep-hand\"]"
|
||
p "★ 指派給本 token 的使用者(欄位,不是一句話)" "[['claude-code']]" \
|
||
"[pl['assignees'] for mm,p,pl in rec if mm=='PATCH' and p=='/repos/inkstone/Arcrun/issues/6']" "$CL"
|
||
p "★ s/todo 換成 s/doing,p/high 留著(不是整組蓋掉)" "True" \
|
||
"[sorted(pl['labels'])==[2,4] for mm,p,pl in rec if mm=='PUT' and p.endswith('/issues/6/labels')]==[True]" "$CL"
|
||
p "★ 留言第一行是【身份】isep-hand/inkstone/Arcrun/-" "True" \
|
||
"[pl['body'].split(chr(10))[0] for mm,p,pl in rec if mm=='POST' and p.endswith('/issues/6/comments')]==['【身份】isep-hand/inkstone/Arcrun/-']" "$CL"
|
||
p "留言講了做完要走 handback(不推 main、不出貨)" "True" \
|
||
"any('handback' in pl['body'] and '不推 main' in pl['body'] for mm,p,pl in rec if mm=='POST' and p.endswith('/comments'))" "$CL"
|
||
p "三件事都做了,離開碼 0" "None" "rc" "$CL"
|
||
p "--as 指定登入名時用它" "[['cloud-bot']]" \
|
||
"[pl['assignees'] for mm,p,pl in rec if mm=='PATCH']" "[\"cmd_claim\",\"inkstone/Arcrun#6\",\"--mainline\",\"$MAIN\",\"--as\",\"cloud-bot\"]"
|
||
p "逾期里程碑的票(Arcrun#7)也認領得了——主線抓盡時輪到它" "None" "rc" \
|
||
"[\"cmd_claim\",\"inkstone/Arcrun#7\",\"--mainline\",\"$MAIN\"]"
|
||
p "pick --claim:挑到誰就認領誰(Arcrun#6)" "True" \
|
||
"any(mm=='PATCH' and p.endswith('/issues/6') for mm,p,pl in rec)" "[\"cmd_pick\",\"--mainline\",\"$MAIN\",\"--claim\",\"--name\",\"isep-hand\"]"
|
||
|
||
echo
|
||
echo " (該擋的:規則在 claim 這一端再驗一次,擋在**寫入之前**)"
|
||
blocked() { # blocked <說明> <ref> [extra argv...]
|
||
what=$1; ref=$2; shift 2
|
||
extra=$(printf '%s\n' "$@" | python3 -c "import sys,json;print(','.join(json.dumps(l.rstrip()) for l in sys.stdin if l.strip()))")
|
||
argv="[\"cmd_claim\",\"$ref\",\"--mainline\",\"$MAIN\"${extra:+,$extra}]"
|
||
p "$what → 離開碼 2,且沒有任何寫入" "True" \
|
||
"rc==2 and not any(mm in ('PATCH','PUT','POST') for mm,_,_ in rec)" "$argv"
|
||
}
|
||
blocked "掛 Human 的 ISEP#2" inkstone/ISEP#2
|
||
blocked "已指派的 ISEP#3" inkstone/ISEP#3
|
||
blocked "backlog 的 ISEP#4" inkstone/ISEP#4
|
||
blocked "沒逾期也不是主線的 Arcrun#8" inkstone/Arcrun#8
|
||
blocked "有人在做的 mira#9(s/doing 也掛著)" inkstone/mira#9
|
||
blocked "掛在 ISEP 同名里程碑、沒有相依邊的 ISEP#13(同名不是主線)" inkstone/ISEP#13
|
||
p "claim 靠相依進主線的 ISEP#1(沒里程碑)→ 過(留言寫「靠相依在主線上」)" "True" \
|
||
"rc is None and any('靠相依' in pl['body'] for mm,p,pl in rec if mm=='POST' and p.endswith('/comments'))" \
|
||
"[\"cmd_claim\",\"inkstone/ISEP#1\",\"--mainline\",\"$MAIN\"]"
|
||
p "擋下時訊息講得出理由與出路(pick)" "True" "'Human' in out and 'pick' in out" \
|
||
"[\"cmd_claim\",\"inkstone/ISEP#2\",\"--mainline\",\"$MAIN\"]"
|
||
p "身份名不在名單上 → 在打任何 API 之前就擋(一通都沒打)" "True" "rc==2 and len(rec)==0" \
|
||
"[\"cmd_claim\",\"inkstone/Arcrun#6\",\"--mainline\",\"$MAIN\",\"--name\",\"路人\"]"
|
||
|
||
echo
|
||
echo "── ④ 動詞接得上(不帶假池子,走到網路才炸)──"
|
||
out=$(python3 "$T" pick --mainline "$MAIN" 2>&1); got=$?
|
||
if [ "$got" = 2 ] && printf '%s' "$out" | grep -q '拿不到 Gitea'; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
|
||
printf 'want=2 got=%s pick 連不上 Gitea → 離開碼 2 並說「讀不到 ≠ 沒有」(不是離開碼 1 的假「沒票」)\n' "$got"
|
||
out=$(python3 "$T" 2>&1)
|
||
if printf '%s' "$out" | grep -q "ticket pick" && printf '%s' "$out" | grep -q "ticket claim"; then PASS=$((PASS+1)); printf ' ✅ '
|
||
else FAIL=$((FAIL+1)); printf ' ❌ '; fi
|
||
printf '不帶參數的說明裡列得出 pick/claim(撈不到的東西等於不存在)\n'
|
||
out=$(python3 "$T" usage pick 2>&1); got=$?
|
||
if [ "$got" = 0 ] && printf '%s' "$out" | grep -q -- '--mainline'; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
|
||
printf 'ticket usage pick 印得出用法\n'
|
||
|
||
echo
|
||
echo "通過 $PASS 條,失敗 $FAIL 條"
|
||
[ "$FAIL" = 0 ] || exit 1
|