✨ feat: workable
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
@@ -0,0 +1,28 @@
|
||||
"""关卡号解析测试(纯函数,无需 MaaFw / OCR 模型)。"""
|
||||
|
||||
from hbc.level import parse_cn_level
|
||||
|
||||
|
||||
def test_parse_chinese_levels():
|
||||
assert parse_cn_level("关卡一") == 1
|
||||
assert parse_cn_level("关卡二") == 2
|
||||
assert parse_cn_level("关卡七") == 7
|
||||
assert parse_cn_level("第三关") == 3
|
||||
|
||||
|
||||
def test_parse_arabic_and_mixed():
|
||||
assert parse_cn_level("关卡 1") == 1
|
||||
assert parse_cn_level("LEVEL 5") == 5
|
||||
assert parse_cn_level("第2关") == 2
|
||||
|
||||
|
||||
def test_parse_tens():
|
||||
assert parse_cn_level("第十关") == 10
|
||||
assert parse_cn_level("关卡十一") == 11
|
||||
assert parse_cn_level("二十") == 20
|
||||
|
||||
|
||||
def test_parse_failures():
|
||||
assert parse_cn_level("") is None
|
||||
assert parse_cn_level("暂停") is None
|
||||
assert parse_cn_level(None) is None
|
||||
@@ -0,0 +1,187 @@
|
||||
"""模板识别 + 标定工具测试(用合成图片,无需真机)。需要 opencv/numpy。"""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
cv2 = pytest.importorskip("cv2")
|
||||
|
||||
from hbc.board import CellKind # noqa: E402
|
||||
from hbc.calibrate import _fit_scale, _map_box_to_original # noqa: E402
|
||||
from hbc.calibrate import main as calib_main # noqa: E402
|
||||
from hbc.config import ( # noqa: E402
|
||||
GridConfig,
|
||||
TemplateMatchConfig,
|
||||
level_layout_path,
|
||||
load_level_grid,
|
||||
)
|
||||
from hbc.recognizer import recognize_board # noqa: E402
|
||||
from hbc.templates import TemplateLibrary # noqa: E402
|
||||
|
||||
CELL = 40
|
||||
|
||||
# 合成棋盘布局:数字=正常块颜色, 'W'=墙(石), 'I'=墙(冰), '.'=空
|
||||
LAYOUT = [
|
||||
["0", "1", "2", "W"],
|
||||
["1", "I", "0", "."],
|
||||
["2", "0", "1", "2"],
|
||||
]
|
||||
|
||||
# 每种外观对应的 BGR 纯色块(足够区分即可)
|
||||
APPEARANCE = {
|
||||
"0": (60, 60, 220), # 红
|
||||
"1": (60, 200, 60), # 绿
|
||||
"2": (220, 120, 60), # 蓝
|
||||
"W": (90, 90, 90), # 石墙(灰)
|
||||
"I": (230, 220, 180), # 冰墙(浅蓝白)
|
||||
".": (10, 10, 10), # 空(近黑)
|
||||
}
|
||||
|
||||
|
||||
def _swatch(bgr):
|
||||
img = np.zeros((CELL, CELL, 3), np.uint8)
|
||||
img[:] = bgr
|
||||
return img
|
||||
|
||||
|
||||
def _build_board_image():
|
||||
rows, cols = len(LAYOUT), len(LAYOUT[0])
|
||||
img = np.zeros((rows * CELL, cols * CELL, 3), np.uint8)
|
||||
for r in range(rows):
|
||||
for c in range(cols):
|
||||
img[r * CELL:(r + 1) * CELL, c * CELL:(c + 1) * CELL] = _swatch(APPEARANCE[LAYOUT[r][c]])
|
||||
return img
|
||||
|
||||
|
||||
def _write_templates(root):
|
||||
(root / "normal").mkdir(parents=True)
|
||||
(root / "wall").mkdir(parents=True)
|
||||
(root / "empty").mkdir(parents=True)
|
||||
cv2.imwrite(str(root / "normal" / "red.png"), _swatch(APPEARANCE["0"]))
|
||||
cv2.imwrite(str(root / "normal" / "green.png"), _swatch(APPEARANCE["1"]))
|
||||
cv2.imwrite(str(root / "normal" / "blue.png"), _swatch(APPEARANCE["2"]))
|
||||
cv2.imwrite(str(root / "wall" / "stone.png"), _swatch(APPEARANCE["W"]))
|
||||
cv2.imwrite(str(root / "wall" / "ice.png"), _swatch(APPEARANCE["I"]))
|
||||
cv2.imwrite(str(root / "empty" / "hole.png"), _swatch(APPEARANCE["."]))
|
||||
|
||||
|
||||
def test_template_recognition_multi_wall(tmp_path):
|
||||
root = tmp_path / "templates"
|
||||
_write_templates(root)
|
||||
lib = TemplateLibrary.load(TemplateMatchConfig(templates_dir=str(root), canonical_size=32))
|
||||
|
||||
img = _build_board_image()
|
||||
rows, cols = len(LAYOUT), len(LAYOUT[0])
|
||||
grid = GridConfig(x=0, y=0, w=cols * CELL, h=rows * CELL, rows=rows, cols=cols)
|
||||
board = recognize_board(img, grid, lib)
|
||||
|
||||
# 校验每格类型
|
||||
for r in range(rows):
|
||||
for c in range(cols):
|
||||
cell = board.get(r, c)
|
||||
token = LAYOUT[r][c]
|
||||
if token in ("0", "1", "2"):
|
||||
assert cell.kind == CellKind.NORMAL, (r, c, cell)
|
||||
elif token in ("W", "I"):
|
||||
assert cell.kind == CellKind.BRICK, (r, c, cell)
|
||||
else:
|
||||
assert cell.kind == CellKind.EMPTY, (r, c, cell)
|
||||
|
||||
# 两种墙应被区分到不同 meta['wall']
|
||||
assert board.get(0, 3).meta["wall"] == "stone"
|
||||
assert board.get(1, 1).meta["wall"] == "ice"
|
||||
|
||||
# 相同外观的正常块应得到相同 color id(求解器据此判等)
|
||||
assert board.color_at(0, 0) == board.color_at(1, 2) # 两个红
|
||||
assert board.color_at(0, 1) == board.color_at(1, 0) # 两个绿
|
||||
assert board.color_at(0, 0) != board.color_at(0, 1) # 红 != 绿
|
||||
|
||||
|
||||
def test_low_confidence_becomes_ice_wall(tmp_path):
|
||||
"""置信度不足(如冰块/未登记方块)一律判为冰墙,而非空格。"""
|
||||
root = tmp_path / "templates"
|
||||
_write_templates(root) # 只有红/绿/蓝 + 墙 + 空
|
||||
lib = TemplateLibrary.load(TemplateMatchConfig(templates_dir=str(root), canonical_size=32))
|
||||
|
||||
purple = _swatch((200, 40, 200)) # 库里没有的外观 -> 低置信度
|
||||
cell = lib.classify(purple)
|
||||
assert cell.is_brick and cell.meta.get("wall") == "ice"
|
||||
|
||||
# 真正的空块(与空模板一致,高分)仍判为空格
|
||||
empty_cell = lib.classify(_swatch(APPEARANCE["."]))
|
||||
assert empty_cell.is_empty and not empty_cell.is_brick
|
||||
|
||||
|
||||
def test_layout_save_load(tmp_path):
|
||||
p = tmp_path / "layout.json"
|
||||
g = GridConfig(x=12, y=34, w=400, h=300, rows=6, cols=8)
|
||||
g.save_json(p)
|
||||
g2 = GridConfig.load_json(p)
|
||||
assert g2.to_dict() == g.to_dict()
|
||||
|
||||
|
||||
def test_level_layout_files(tmp_path):
|
||||
"""7 关网格逐渐变大:每关一个 level{N}.json,文件名带关号,统一放 layouts/。"""
|
||||
d = tmp_path / "layouts"
|
||||
GridConfig(0, 0, 200, 200, 5, 5).save_json(_mk(level_layout_path(1, d)))
|
||||
GridConfig(0, 0, 240, 240, 6, 6).save_json(level_layout_path(2, d))
|
||||
GridConfig(0, 0, 280, 280, 8, 8).save_json(level_layout_path(7, d))
|
||||
|
||||
assert level_layout_path(1, d).name == "level1.json"
|
||||
assert load_level_grid(1, d).rows == 5
|
||||
assert load_level_grid(2, d).cols == 6
|
||||
assert load_level_grid(7, d).w == 280
|
||||
# 未标定的关卡应报错
|
||||
import pytest as _pt
|
||||
with _pt.raises(FileNotFoundError):
|
||||
load_level_grid(3, d)
|
||||
|
||||
|
||||
def _mk(p):
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
|
||||
|
||||
def test_roi_scale_mapping():
|
||||
"""缩放窗口里选的框,要能正确换算回原图坐标。"""
|
||||
# 原图 1800 高,限制 900 -> scale=0.5
|
||||
scale = _fit_scale(1000, 1800, 900)
|
||||
assert abs(scale - 0.5) < 1e-9
|
||||
# 在缩放图上选 (50,100,200,300) -> 原图应为两倍
|
||||
assert _map_box_to_original((50, 100, 200, 300), scale) == (100, 200, 400, 600)
|
||||
# 不需要缩放时原样返回
|
||||
assert _fit_scale(400, 300, 900) == 1.0
|
||||
assert _map_box_to_original((1, 2, 3, 4), 1.0) == (1, 2, 3, 4)
|
||||
|
||||
|
||||
def test_calibrate_export_and_preview(tmp_path):
|
||||
img_path = tmp_path / "board.png"
|
||||
cv2.imwrite(str(img_path), _build_board_image())
|
||||
layouts_dir = tmp_path / "layouts"
|
||||
rows, cols = len(LAYOUT), len(LAYOUT[0])
|
||||
GridConfig(0, 0, cols * CELL, rows * CELL, rows, cols).save_json(_mk(level_layout_path(1, layouts_dir)))
|
||||
|
||||
cells_dir = tmp_path / "cells"
|
||||
rc = calib_main(["export", "--image", str(img_path), "--level", "1",
|
||||
"--layouts-dir", str(layouts_dir), "--out", str(cells_dir)])
|
||||
assert rc == 0
|
||||
exported = list(cells_dir.glob("*.png"))
|
||||
assert len(exported) == rows * cols
|
||||
|
||||
preview = tmp_path / "preview.png"
|
||||
rc = calib_main(["preview", "--image", str(img_path), "--level", "1",
|
||||
"--layouts-dir", str(layouts_dir), "--out", str(preview)])
|
||||
assert rc == 0
|
||||
assert preview.exists()
|
||||
|
||||
|
||||
def test_calibrate_roi_with_box(tmp_path):
|
||||
"""无 GUI:用 --box 直接给坐标,写入指定关卡文件。"""
|
||||
img_path = tmp_path / "board.png"
|
||||
cv2.imwrite(str(img_path), _build_board_image())
|
||||
layouts_dir = tmp_path / "layouts"
|
||||
rc = calib_main(["roi", "--image", str(img_path), "--level", "3",
|
||||
"--rows", "5", "--cols", "5", "--box", "0,0,200,200",
|
||||
"--layouts-dir", str(layouts_dir)])
|
||||
assert rc == 0
|
||||
g = load_level_grid(3, layouts_dir)
|
||||
assert (g.x, g.y, g.w, g.h, g.rows, g.cols) == (0, 0, 200, 200, 5, 5)
|
||||
@@ -0,0 +1,127 @@
|
||||
"""求解器与棋盘模型的单元测试(不依赖 MaaFramework / 真机)。"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from hbc.board import Board, Cell, CellKind # noqa: E402
|
||||
from hbc.solver import Solver, Swap # noqa: E402
|
||||
|
||||
BRICK = -2
|
||||
EMPTY = -1
|
||||
|
||||
|
||||
def test_cell_types():
|
||||
assert Cell.normal(3).is_swappable
|
||||
assert not Cell.brick().is_swappable
|
||||
assert not Cell.empty().is_swappable
|
||||
assert Cell.brick().is_brick
|
||||
assert Cell.empty().is_empty
|
||||
|
||||
|
||||
def test_dynamic_rectangular_sizes():
|
||||
for rows, cols in [(5, 9), (8, 8), (6, 7), (3, 3)]:
|
||||
b = Board(rows, cols)
|
||||
assert b.rows == rows and b.cols == cols
|
||||
assert b.in_bounds(rows - 1, cols - 1)
|
||||
assert not b.in_bounds(rows, cols)
|
||||
|
||||
|
||||
def test_find_horizontal_match():
|
||||
board = Board.from_colors([
|
||||
[0, 0, 0, 1],
|
||||
[2, 3, 4, 5],
|
||||
])
|
||||
matches = Solver().find_matches(board)
|
||||
assert len(matches) == 1
|
||||
assert matches[0].length == 3
|
||||
assert matches[0].color == 0
|
||||
|
||||
|
||||
def test_brick_breaks_the_line():
|
||||
# 砖块 (#) 打断同色连线 -> 不应形成消除
|
||||
board = Board.from_colors([
|
||||
[0, 0, BRICK, 0],
|
||||
])
|
||||
assert not Solver().has_match(board)
|
||||
|
||||
|
||||
def test_empty_breaks_the_line():
|
||||
board = Board.from_colors([
|
||||
[0, 0, EMPTY, 0],
|
||||
])
|
||||
assert not Solver().has_match(board)
|
||||
|
||||
|
||||
def test_best_swap_makes_a_match():
|
||||
# 把 (0,3) 的 0 与 (1,3) 的 X 交换? 这里构造:交换 (0,2)&(0,3) 形成三连
|
||||
board = Board.from_colors([
|
||||
[0, 0, 1, 0],
|
||||
[2, 3, 0, 4],
|
||||
])
|
||||
# 交换 (0,2)=1 与 (1,2)=0 -> 第0行变 0 0 0 0(前三连),合法
|
||||
solver = Solver()
|
||||
result = solver.find_best_swap(board)
|
||||
assert result is not None
|
||||
assert result.cleared >= 3
|
||||
|
||||
|
||||
def test_cannot_swap_brick_or_empty():
|
||||
board = Board.from_colors([
|
||||
[0, BRICK, 0],
|
||||
[0, 0, 0],
|
||||
])
|
||||
solver = Solver()
|
||||
# 砖块不可交换:尝试交换砖块应被判非法
|
||||
assert solver.evaluate_swap(board, Swap(0, 1, 1, 1)) is None
|
||||
|
||||
|
||||
def test_no_swap_available():
|
||||
# 棋盘上任何交换都无法形成消除(三色互不相邻,且任意相邻交换都凑不出 3 连)
|
||||
board = Board.from_colors([
|
||||
[0, 1, 2],
|
||||
])
|
||||
assert Solver().find_best_swap(board) is None
|
||||
|
||||
|
||||
def test_prefer_longer_match():
|
||||
# 一处可形成 3 连,另一处可形成 4 连 —— 应优先 4 连(得分更高)
|
||||
board = Board.from_colors([
|
||||
[0, 0, 0, 5, 1, 1, 9, 1],
|
||||
[9, 9, 0, 9, 9, 9, 1, 9],
|
||||
])
|
||||
solver = Solver()
|
||||
best = solver.find_best_swap(board)
|
||||
assert best is not None
|
||||
# 至少应找到一个可行交换
|
||||
assert best.score > 0
|
||||
|
||||
|
||||
def test_horizontal_swipe_is_right_to_left():
|
||||
"""水平交换必须从右往左滑(避免触发返回手势);竖直交换不受限制。"""
|
||||
import sys as _sys
|
||||
from pathlib import Path as _Path
|
||||
_sys.path.insert(0, str(_Path(__file__).resolve().parents[1] / "src"))
|
||||
from hbc.actuator import swap_to_swipe
|
||||
from hbc.config import GridConfig
|
||||
|
||||
grid = GridConfig(0, 0, 500, 500, 5, 5)
|
||||
|
||||
# 求解器产生的水平交换通常是 (r,c)->(r,c+1),即左->右;应被翻转成右->左
|
||||
x1, y1, x2, y2 = swap_to_swipe(Swap(2, 1, 2, 2), grid)
|
||||
assert x1 > x2 and y1 == y2 # 起点在右,终点在左
|
||||
|
||||
# 即使给的是右->左,也应保持右->左
|
||||
x1, y1, x2, y2 = swap_to_swipe(Swap(2, 3, 2, 2), grid)
|
||||
assert x1 > x2
|
||||
|
||||
# 竖直交换:x 相同,方向不限
|
||||
x1, y1, x2, y2 = swap_to_swipe(Swap(1, 2, 2, 2), grid)
|
||||
assert x1 == x2 and y1 != y2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import pytest
|
||||
|
||||
raise SystemExit(pytest.main([__file__, "-v"]))
|
||||
Reference in New Issue
Block a user