#!/usr/bin/env bash # # `97_verify_sample.sql` 的**反向用例**:证明那五段断言**真的会报红**。 # # 为什么必须有这个文件(AGENTS.md §1.1 推论 1): # 一份"跑出来是空的"的校验,**不能**证明数据是对的——它同时兼容"数据确实没问题" # 和"这条断言根本不可能有输出"两种情形,而这两种情形在终端上长得一模一样。 # 本地第一次跑 97 时,§2a 报了上万行(5.7 的 `int(11)` vs 8.0 的 `int`)—— # 那次是**假红**,但它顺带证明了 §2a 不是空转的。这个脚本把这类证据固定下来: # 对每一段各造一个**必然命中**的破坏,断言它确实报出来,而且**报的是该段该报的东西**。 # # ⚠️ 断言为什么用"特征串"而不是"输出非空": # 本脚本会故意把库弄坏,mysql 会往 stderr 打 `ERROR 1146 ... connector_info doesn't exist`, # 而下面每条都 `2>&1` 一起收——若只判"非空",那个 ERROR 就能把五条用例全部"喂饱", # 于是五条都通过,而真实情况是五段断言一个都没响。所以特征串必须**只可能来自该段的输出行**: # §1 → `zt_chargeconnector_info`(ERROR 里是 `parking_cloud.connector_info`,不同) # §2a → `bigintint` (只有 §2a 印类型) # §2b → `zz_negative_test_col` (破坏时新加的列名) # §3/§4 → `不该有数据` / `抽样没落地` (只有这两段印这两个结论词) # §5 → `utf8mb3` (只有 §5 印排序规则) # 特征串选得够"窄",ERROR 行就不可能冒充它——这是**选特征串**的责任,不是靠 # 另外加一条"输出里不许出现 ERROR"来兜。 # (第一版真加了那条禁令,结果 A 用例永远失败:A 故意删表,§4 的 `COUNT(*)` # 必然 `ERROR 1146`,而 §1 其实已经正确报出来了。那条禁令把"预期内的噪声" # 和"这段没跑成"混为一谈——所以现在 ERROR 只作为提示打印,不作判据。) # # 用法(**只能对本地开发库跑**,它会改表;选 zt_charge 是因为重灌一次只要 2 秒): # MYSQL_BIN=/opt/homebrew/opt/mysql-client/bin/mysql \ # DUMP_BIN=/opt/homebrew/opt/mysql-client/bin/mysqldump \ # TARGET_HOST=127.0.0.1 TARGET_USER=root TARGET_PASSWORD= \ # bash tests/97_negative_test.sh # 依赖:目标库上已经有 SAMPLE=N 的抽样镜像(先跑 10_dump_legacy.sh dump && load)。 set -uo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" MIG="$(cd "$HERE/.." && pwd)" VERIFY="$MIG/97_verify_sample.sql" DB="${DB:-zt_charge}" [[ -f "$VERIFY" ]] || { echo "缺少 ${VERIFY}(先跑 tools/gen_migration_sql.py)" >&2; exit 1; } MYSQL_BIN="${MYSQL_BIN:-mysql}" command -v "$MYSQL_BIN" >/dev/null || { echo "缺少 mysql 客户端(${MYSQL_BIN})" >&2; exit 1; } : "${TARGET_HOST:?请设置 TARGET_HOST}" : "${TARGET_USER:?请设置 TARGET_USER}" TARGET_DB="${TARGET_DB:-parking_cloud}" TARGET_PORT="${TARGET_PORT:-3306}" : "${TARGET_PASSWORD-}" export MYSQL_PWD="$TARGET_PASSWORD" mysql_() { "$MYSQL_BIN" --host="$TARGET_HOST" --port="$TARGET_PORT" \ --user="$TARGET_USER" --default-character-set=utf8mb4 "$@"; } # 跑校验时加两个开关: # --force —— 一段出错(比如 §4 撞到被删掉的表 `ERROR 1146`)**不要中止**, # 否则 §5 永远不会执行,一份校验只报出前半截。 # -N —— 不输出列名。要的是"干净时**完全没有任何输出**", # 有列名的话空结果集也会印一行表头,"空=通过"这个判读就不成立了。 mysql_verify() { mysql_ --force -N "$@"; } pass=0; fail=0 ok() { printf ' \033[32m✅\033[0m %s\n' "$*"; pass=$((pass+1)); } bad() { printf ' \033[31m❌\033[0m %s\n' "$*"; fail=$((fail+1)); } # 还原:重新灌这一个库(dump 里是 DROP TABLE IF EXISTS + CREATE + INSERT,幂等) # # ⚠️ 重灌**只能**还原"dump 里有的那几张表"——它永远**不会**删掉多出来的东西。 # 所以破坏手法要挑**重灌能完全吃掉的**:DROP TABLE / ALTER(都被 # `DROP TABLE IF EXISTS` + `CREATE TABLE` 覆盖),**不要用 RENAME**—— # 改名会留下一张 dump 里没有的表(本文件的第一个版本就踩了, # 在库里留了个 `connector_info_hidden`,而且失败路径上还没还原)。 restore() { ( cd "$MIG" && ONLY="$DB" SAMPLE="${SAMPLE:-500}" bash 10_dump_legacy.sh load >/dev/null 2>&1 ) } # 基准:装载完整时 97 必须是**静默**的(否则后面的"报红"断言毫无意义) baseline_must_be_silent() { local out out=$(mysql_verify < "$VERIFY" 2>&1) || true if [[ -z "$out" ]]; then ok "基准:抽样镜像完整时 97 静默(§1~§5 全 0 行)" else bad "基准就不干净,后面的断言没法判读:${out:0:300}"; return 1; fi } # $1=用例名 $2=该段输出里只可能由它产生的特征串 $3=破坏语句 # # ⚠️ **失败路径也必须 restore**:不还原的话,后面每个用例都会因为上一条的破坏而连环失败, # 于是看到的是"五条全错",真实原因却只有第一条——排查方向被带偏。 expect_red() { local name="$1" want="$2" ddl="$3" out err if ! err=$(mysql_ -e "$ddl" 2>&1); then bad "${name}:破坏语句没执行成功(错误原文:${err})" restore return fi out=$(mysql_verify < "$VERIFY" 2>&1) || true if [[ "$out" == *"$want"* ]]; then ok "$name —— 报红了,且报的是该段该报的东西" # ERROR 只提示、不判负:删表的用例里 §4 的 COUNT(*) 撞到它属预期, # 而 §1 已经正确报出来了。把"预期内的噪声"当成失败会造出假故障。 if [[ "$out" == *ERROR* ]]; then printf ' ⚠️ 校验输出里有 ERROR(多段校验是 `--force` 跑的,一段出错不中止其余):%s\n' \ "$(printf '%s' "$out" | grep -m1 'ERROR')" fi else bad "${name} —— **没报红**!这段断言是空转的。期望输出含「${want}」,实际:${out:0:300}" fi restore } TAB=$'\t' echo "== 起点:先把这一个库重灌成已知good状态 ==" # ⚠️ 这一步不是多余的:本脚本上一次如果在半途死掉(比如被 Ctrl-C), # 库里会留着上一条破坏语句的效果,于是"基准"一上来就是脏的, # 后面的用例也跟着连环失败——看到的全是假故障。先自愈,再开始。 restore echo "== 基准 ==" baseline_must_be_silent echo "== A. §1 表都在(把一张表删掉)==" expect_red "§1 目标库缺表" "zt_charge${TAB}connector_info" \ "DROP TABLE \`$TARGET_DB\`.\`connector_info\`" echo "== B. §2a 列类型(bigint → int,是真正改类型)==" expect_red "§2a 列类型变了" "${TAB}bigint${TAB}int${TAB}" \ "ALTER TABLE \`$TARGET_DB\`.\`connector_info\` MODIFY COLUMN \`id\` int NOT NULL AUTO_INCREMENT" echo "== C. §2b 目标库多出来的列 ==" expect_red "§2b 多了一列" "zz_negative_test_col" \ "ALTER TABLE \`$TARGET_DB\`.\`connector_info\` ADD COLUMN \`zz_negative_test_col\` int NULL" echo "== D. §3/§4 —— 最值钱的一段,两个方向都要验 ==" # D1「不该有数据」:只搬结构的表(含密钥/口令)在抽样镜像里必须 0 行 expect_red "§3 只搬结构的表被灌进了数据" "不该有数据" \ "INSERT INTO \`$TARGET_DB\`.\`operator_info\` (\`operator_id\`,\`operator_name\`,\`operator_tell\`) VALUES ('zz','zz','zz')" # D2「抽样没落地」:这张表源库明明有数据,必须 >= 1 行 expect_red "§3 抽样表一行都没落地" "抽样没落地" \ "DELETE FROM \`$TARGET_DB\`.\`connector_info\`" echo "== E. §5 字符集(把一张表转回 utf8mb3)==" expect_red "§5 有表不是 utf8mb4" "utf8mb3" \ "ALTER TABLE \`$TARGET_DB\`.\`connector_info\` CONVERT TO CHARACTER SET utf8mb3" echo echo "== 收尾:还原后再跑一次基准 ==" restore baseline_must_be_silent echo if [[ "$fail" -eq 0 ]]; then printf '\033[32m全部通过\033[0m ✅ (%d 项)—— 五段断言都能报红,不是空转\n' "$pass" else printf '\033[31m%d 项失败\033[0m ❌\n' "$fail" exit 1 fi