二七王:补 9 处 design 规则的正/反/边界用例(均通过变异检验)

对 design §1~§12 重做「每条规则是否三类用例齐全」的审计。此前的覆盖矩阵按
「被测函数」组织,容易漏掉跨函数才体现的规则,本轮补齐 9 处:

- G1/G2 §4.2 叫分:下限5与非首家不叫接受,负数/缺call/步进外/同分拒;
  「不叫即退出本局叫分」——反悔再叫被 SEAT 拒、callproc 不变、仍轮到下一家
- G3 §4.5 埋牌:非庄家 SEAT、step2/step5 STEP、7张/9张/空数组 PARAM
- G4 §5.1 每轮由上一轮牌面最大的一方先出(闲1赢/闲2赢两种)
- G5 §6.2 闲家赢计入台面全部分牌(含庄家自己打出的)、庄家赢作废、
  两个闲家谁赢结果一致
- G6 §8 算奖快照:庄家埋后28(已埋不在内)、闲家28、投降36、打出后不缩水
- G7 §3 对子只认同花色同点数两副:跨花色副7/副2、正副7、大小王均不成对
- G8 §6.3 赢末轮的牌不全是主牌就不扣底(副牌拖拉机/混合出牌/顺序颠倒)
- G9 §11 无超时托管守卫:对局阶段 min_ontimeout 调用次数必须为 0,
  将来有人加超时代打就会转红

每条都在副本上注入违反该规则的改动确认转红(7 组变异全部被抓住)。

顺带修一个测试自身的问题:mkBury 桩缺 do_burycard,导致「埋牌张数校验被
改松」这类变异表现为整个文件崩溃而非某条断言转红,后续断言全不执行。桩已
补完整到能走完成功路径。

全套单测 462 → 511 项全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-23 13:36:09 +08:00
co-authored by Claude Opus 5
parent 1b76683704
commit a4361a802d
4 changed files with 218 additions and 2 deletions
+75
View File
@@ -138,4 +138,79 @@ t.eq('甩牌集成 跟牌拆散主对→拒', badF.result, false);
const goodF = P.do_playcard(fpf, [id(1, 1, 11), id(2, 1, 11), id(1, 1, 9)]); // 用主J对对位
t.eq('甩牌集成 跟牌用主对→接受', goodF.result, true);
// ============ §5.1 控制权 + §6.2 捡分归属 ============
// 造一轮三家各出 1 张同花色副牌的完整墩:主花色=♦(1),本轮花色=♥(3)。
// deal: [[牌id, 座位], ...];scores: [[牌id, 分值], ...]
function mkTrick(deal, scores) {
const cards = [];
for (let i = 0; i < 108; i++) cards[i] = { id: i, dealowner: 99, playround: -1, playowner: -1, score: 0, flower: A.id_to_flower(i), number: A.id_to_number(i) };
for (const [cid, seat] of deal) cards[cid].dealowner = seat + 1;
for (const [cid, s] of scores) cards[cid].score = s;
const sl = () => [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]];
const o = { step: 5, banker: 0, flower: 1, cards, seatlist: [sl(), sl(), sl()], playproc: {} };
o.method = { have_baofu: () => o.seatlist.some(s => s[4][0] == 0) };
P.new_playround(o, 1, 0); // 第 1 轮由庄家(0)首出
return o;
}
const H5 = id(1, 3, 5), H8 = id(1, 3, 8), HK = id(1, 3, 13), HA = id(1, 3, 1); // ♥5 ♥8 ♥K ♥A(副牌)
const SC = [[H5, 5], [HK, 10]]; // §6.1:5→5分、K→10分;♥8/♥A 不计分
// —— 反:庄家赢下本轮 → 台面分牌全部作废,不计入闲家捡分 ——
const wb = mkTrick([[HA, 0], [HK, 1], [H5, 2]], SC);
P.do_playcard(wb, [HA]); P.do_playcard(wb, [HK]);
const wbRe = P.do_playcard(wb, [H5]);
// 注:第三家出完后 do_playcard 已开新一轮、playproc 被重置,胜者只能从返回值读
t.eq('§6.2 反 庄家赢本轮 maxseat=庄', wbRe.maxseat, 0);
t.eq('§6.2 反 庄家赢 → 下发面无本轮得分', wbRe.grade, undefined);
t.eq('§6.2 反 庄家赢 → 闲家捡分 0(分牌作废)', P.get_jian_grade(wb).grade, 0);
// —— 正:闲家1 赢下本轮 → 台面【全部】分牌(含庄家自己打出的♥5)计入闲家 ——
const w1 = mkTrick([[H5, 0], [HK, 1], [H8, 2]], SC);
P.do_playcard(w1, [H5]); P.do_playcard(w1, [HK]);
const w1Re = P.do_playcard(w1, [H8]);
t.eq('§6.2 正 闲1赢本轮 maxseat=1', w1Re.maxseat, 1);
t.eq('§6.2 正 本轮闲家得分=5+10(含庄家打出的♥5)', w1Re.grade, 15);
t.eq('§6.2 正 累计捡分=15', P.get_jian_grade(w1).grade, 15);
// §5.1:下一轮由本轮牌面最大的一方先出(不是固定庄家、也不是顺序下家)
t.eq('§5.1 正 下一轮由本轮最大者(闲1)先出', [w1.playproc.round, w1.playproc.start, w1.playproc.currseat], [2, 1, 1]);
// —— 边界:两个闲家谁赢都算「闲家捡到」,结果与闲1赢完全一致(design §6.2)——
const w2 = mkTrick([[H5, 0], [H8, 1], [HK, 2]], SC);
P.do_playcard(w2, [H5]); P.do_playcard(w2, [H8]);
const w2Re = P.do_playcard(w2, [HK]);
t.eq('§6.2 边界 闲2赢本轮 maxseat=2', w2Re.maxseat, 2);
t.eq('§6.2 边界 闲2赢与闲1赢同样计闲家捡分', [w2Re.grade, P.get_jian_grade(w2).grade], [15, 15]);
t.eq('§5.1 边界 下一轮改由闲2先出', [w2.playproc.round, w2.playproc.start], [2, 2]);
// ============ §8 算奖快照 get_seat_cards_award ============
// design §8:庄家按【埋牌完成后】的 28 张、闲家按【发牌后】的 28 张;快照全程固定,
// 不随之后的出牌改变。例外:70 分投降局庄家未埋牌,快照为「发牌 + 暗牌」共 36 张。
function mkAward() {
const cards = [];
for (let i = 0; i < 108; i++) cards[i] = { id: i, dealowner: -1, playround: -1, playowner: -1, score: 0 };
let n = 0;
const take = k => { const out = []; while (out.length < k) { out.push(n); n++; } return out; };
const bankerDeal = take(28), an = take(8), x1 = take(28), x2 = take(28);
bankerDeal.forEach(i => cards[i].dealowner = 1);
an.forEach(i => cards[i].dealowner = 0); // 暗牌
x1.forEach(i => cards[i].dealowner = 2);
x2.forEach(i => cards[i].dealowner = 3);
return { o: { banker: 0, flower: 1, cards }, bankerDeal, an, x1 };
}
// 投降局:未选主未埋牌 → 庄家快照 = 发牌 28 + 暗牌 8 = 36
const aw0 = mkAward();
t.eq('§8 边界 投降局 庄家快照=发牌+暗牌 36 张', P.get_seat_cards_award(aw0.o, 0).length, 36);
// 正常局:埋掉 8 张后 → 庄家快照 28 张,且已埋的不在内
const aw = mkAward();
const bury = aw.bankerDeal.slice(0, 5).concat(aw.an.slice(0, 3)); // 从发牌与暗牌里各埋几张
bury.forEach(i => aw.o.cards[i].playround = 0);
const snap = P.get_seat_cards_award(aw.o, 0);
t.eq('§8 正 庄家快照=埋牌后 28 张', snap.length, 28);
t.eq('§8 正 已埋的 8 张不在快照里', bury.some(i => snap.indexOf(i) >= 0), false);
t.eq('§8 正 闲家快照=发牌后 28 张', [P.get_seat_cards_award(aw.o, 1).length, P.get_seat_cards_award(aw.o, 2).length], [28, 28]);
// 快照是静态的:之后打出的牌仍算在内(否则闲家重连时算奖/亮牌会缩水)
snap.slice(0, 10).forEach(i => aw.o.cards[i].playround = 3);
t.eq('§8 边界 打出 10 张后 庄家快照仍是同样的 28 张', P.get_seat_cards_award(aw.o, 0), snap);
t.eq('§8 边界 打出后闲家快照也不缩水', P.get_seat_cards_award(aw.o, 1).length, 28);
process.exit(t.done('paiju') ? 0 : 1);