release: alpha 0.6.1 local player plugins and capture reconciliation
check / local-gate (push) Failing after 3m24s
check / local-gate (push) Failing after 3m24s
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
"""Lost final-draw events must not strand an isolated incoming message."""
|
||||
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from test_wechat_local_ocr import MODULE
|
||||
|
||||
|
||||
class ReconcileTests(unittest.TestCase):
|
||||
def test_final_drawing_without_damage_is_captured_and_duplicates_are_suppressed(self):
|
||||
class Monitor:
|
||||
window_id = 42
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_):
|
||||
pass
|
||||
|
||||
def wait_for_message_change(self):
|
||||
return next(self.changes)
|
||||
|
||||
changes = iter((True, False, False))
|
||||
|
||||
buffer = MODULE.CaptureFrameBuffer(5)
|
||||
outcomes = []
|
||||
offer = buffer.offer
|
||||
|
||||
def observe(frame):
|
||||
outcome = offer(frame)
|
||||
outcomes.append(outcome)
|
||||
if len(outcomes) == 3:
|
||||
buffer.close()
|
||||
return outcome
|
||||
|
||||
images = [MODULE.Image.new("RGB", (2, 2), color) for color in ("white", "white", "black")]
|
||||
recorder = MODULE.Recorder.__new__(MODULE.Recorder)
|
||||
with mock.patch.object(MODULE, "XDamageMonitor", return_value=Monitor()), \
|
||||
mock.patch.object(MODULE, "window_id", return_value="42"), \
|
||||
mock.patch.object(MODULE, "capture_image", side_effect=images) as captures, \
|
||||
mock.patch.object(MODULE, "message_view_at_loaded_bottom", return_value=True), \
|
||||
mock.patch.object(MODULE, "message_image", side_effect=lambda image: image), \
|
||||
mock.patch.object(MODULE, "incoming_frame_hashes", side_effect=[
|
||||
("old-layout", "old-visual"), ("old-layout", "old-visual"), ("new-layout", "new-visual")]), \
|
||||
mock.patch.object(buffer, "offer", side_effect=observe), \
|
||||
mock.patch.object(MODULE, "telemetry_event") as telemetry:
|
||||
recorder.capture_damage_frames(buffer)
|
||||
self.assertEqual(captures.call_count, 3)
|
||||
self.assertEqual(outcomes, ["enqueued", "duplicate", "enqueued"])
|
||||
self.assertEqual(buffer.get().visual_hash, "old-visual")
|
||||
self.assertEqual(buffer.get().visual_hash, "new-visual")
|
||||
self.assertEqual([call.kwargs["source"] for call in telemetry.call_args_list
|
||||
if call.args == ("ocr.capture",)], ["damage", "reconcile", "reconcile"])
|
||||
self.assertLessEqual(MODULE.DAMAGE_HEARTBEAT_SECONDS, 2)
|
||||
|
||||
def test_reconciliation_does_not_bypass_window_identity_check(self):
|
||||
class Monitor:
|
||||
window_id = 42
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_):
|
||||
pass
|
||||
|
||||
def wait_for_message_change(self):
|
||||
return False
|
||||
|
||||
buffer = MODULE.CaptureFrameBuffer(2)
|
||||
recorder = MODULE.Recorder.__new__(MODULE.Recorder)
|
||||
with mock.patch.object(MODULE, "XDamageMonitor", return_value=Monitor()), \
|
||||
mock.patch.object(MODULE, "window_id", return_value="43"), \
|
||||
mock.patch.object(MODULE, "capture_image") as capture:
|
||||
recorder.capture_damage_frames(buffer)
|
||||
capture.assert_not_called()
|
||||
with self.assertRaises(MODULE.CaptureProducerError):
|
||||
buffer.get()
|
||||
|
||||
def test_xlib_buffered_event_is_drained_before_waiting_for_socket(self):
|
||||
monitor = MODULE.XDamageMonitor.__new__(MODULE.XDamageMonitor)
|
||||
monitor._display = object()
|
||||
monitor._descriptor = 42
|
||||
monitor._x11 = mock.Mock()
|
||||
monitor._x11.XPending.side_effect = [1, 0]
|
||||
with mock.patch.object(monitor, "_drain_events", return_value=True) as drain, \
|
||||
mock.patch.object(MODULE.select, "select", return_value=([], [], [])) as select:
|
||||
self.assertTrue(monitor.wait_for_message_change())
|
||||
drain.assert_called_once()
|
||||
select.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -704,7 +704,7 @@ class LedgerAndApplicationTests(unittest.TestCase):
|
||||
self.assertEqual(response["route"], "local")
|
||||
self.assertEqual(response["execution_path"], "local-command")
|
||||
self.assertIn("由内核和插件描述自动生成", response["text"])
|
||||
self.assertIn("[jwebot/dice]", response["text"])
|
||||
self.assertIn("/丢骰子", response["text"])
|
||||
self.assertIn("骰子和多人骰子挑战", response["text"])
|
||||
self.assertIn("/steam", response["text"])
|
||||
self.assertIn("[DeepSeek]", response["text"])
|
||||
|
||||
@@ -69,7 +69,6 @@ class LocalExtensionInventoryTests(unittest.TestCase):
|
||||
with self.subTest(extension=extension.extension_id):
|
||||
self.assertTrue(extension.description)
|
||||
self.assertTrue(extension.help_entries)
|
||||
self.assertIn(extension.extension_id, help_text)
|
||||
self.assertIn(extension.description, help_text)
|
||||
for entry in extension.help_entries:
|
||||
self.assertIn(entry, help_text)
|
||||
@@ -81,6 +80,9 @@ class LocalExtensionInventoryTests(unittest.TestCase):
|
||||
for extension in LOCAL_EXTENSIONS
|
||||
},
|
||||
{
|
||||
"context-summary": OFFICIAL_AUTHOR,
|
||||
"person-directory": OFFICIAL_AUTHOR,
|
||||
"single-player-games": JWEBOT_AUTHOR,
|
||||
"person-memory": OFFICIAL_AUTHOR,
|
||||
"server-status": OFFICIAL_AUTHOR,
|
||||
"jwebot-version": OFFICIAL_AUTHOR,
|
||||
@@ -108,6 +110,8 @@ class LocalExtensionInventoryTests(unittest.TestCase):
|
||||
self.assertEqual(
|
||||
combined,
|
||||
{
|
||||
"single-player-games": ("wallet", "prize_draw", "lottery", "blackjack", "game_rules"),
|
||||
"person-directory": ("person_directory", "person_info"),
|
||||
"person-memory": (
|
||||
"remember",
|
||||
"my_memory",
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Behavioral coverage for local context, identity and single-player funds."""
|
||||
|
||||
import json
|
||||
import random
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from test_jwebot_edge import MODULE, config
|
||||
from jwebot_official_extensions.local_commands import local_command_parts
|
||||
from jwebot_official_extensions.player_games import hand_value
|
||||
|
||||
|
||||
class Release061Tests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp.name)
|
||||
self.engine = MODULE.EdgeApplication(config(self.root))
|
||||
self.local = self.engine.local
|
||||
self.counter = 0
|
||||
|
||||
def tearDown(self):
|
||||
self.engine.ledger.connection.close()
|
||||
self.temp.cleanup()
|
||||
|
||||
def command(self, question, *, sender="Alice", group="group-a", event_id=None):
|
||||
self.counter += 1
|
||||
row = {"question": question, "sender": sender, "group_fingerprint": group,
|
||||
"event_id": event_id or f"test-{self.counter}"}
|
||||
return self.local.handle(row, {})
|
||||
|
||||
def state(self, sender="Alice", group="group-a"):
|
||||
key = self.engine.ledger.resolve_person(group, sender)["person_key"]
|
||||
row = self.engine.ledger.connection.execute(
|
||||
"SELECT state_json FROM player_accounts WHERE group_fingerprint=? AND person_key=?",
|
||||
(group, key)).fetchone()
|
||||
return json.loads(row[0])
|
||||
|
||||
def test_aliases_are_corresponding_and_case_insensitive(self):
|
||||
pairs = [("上下文", "Context"), ("金币", "wallet"), ("抽奖", "draw"),
|
||||
("买彩票", "lottery"), ("21点", "blackjack"),
|
||||
("人物列表", "people"), ("人物", "person"), ("游戏帮助", "games")]
|
||||
for chinese, english in pairs:
|
||||
self.assertEqual(local_command_parts("/" + chinese), local_command_parts("/" + english))
|
||||
self.assertEqual(local_command_parts("/Context200"), ("context_summary", "200"))
|
||||
|
||||
def test_context_scope_count_order_and_current_request_exclusion(self):
|
||||
ledger = self.engine.ledger
|
||||
for i in range(305):
|
||||
ledger.record_repeat_history(event_id=f"history-{i:04d}", group_fingerprint="group-a",
|
||||
sender="Alice", text=f"项目计划讨论编号 {i}",
|
||||
observed_at=f"2026-10-15T00:{i // 60:02d}:{i % 60:02d}+00:00")
|
||||
ledger.record_repeat_history(event_id="private-other", group_fingerprint="group-b",
|
||||
sender="Bob", text="隔离群秘密话题", observed_at="2026-10-15T01:00:00+00:00")
|
||||
for count in (100, 200, 300):
|
||||
rows = ledger.recent_context_messages("group-a", count, exclude_event_id="history-0304")
|
||||
self.assertEqual(len(rows), count)
|
||||
self.assertEqual(rows[-1]["event_id"], "history-0303")
|
||||
self.assertLess(rows[0]["observed_at"], rows[-1]["observed_at"])
|
||||
with mock.patch.object(ledger, "bootstrap_repeat_history"):
|
||||
result = self.command(f"/context {count}")
|
||||
self.assertIn(f"实际{count}条", result)
|
||||
self.assertNotIn("隔离群", result)
|
||||
self.assertIn("用法", self.command("/context 101"))
|
||||
|
||||
def test_person_lookup_uses_alias_but_does_not_expose_steam_ids(self):
|
||||
self.assertIn("alice", self.command("/people"))
|
||||
self.assertIn("Alice is a configured test profile", self.command("/person a1"))
|
||||
self.assertIn("群内索引", self.command("/个人属性"))
|
||||
self.assertIn("没有", self.command("/person unconfigured"))
|
||||
self.assertIn("只有", self.command("/people 99"))
|
||||
|
||||
def test_context_keeps_short_messages_omitted_from_repeat_statistics(self):
|
||||
ledger = self.engine.ledger
|
||||
self.assertFalse(ledger.record_repeat_history(
|
||||
event_id="short", group_fingerprint="group-a", sender="Alice",
|
||||
text="好", observed_at="2026-10-15T00:00:00+00:00"))
|
||||
records = ledger.recent_context_messages("group-a", 100)
|
||||
self.assertEqual([row["text"] for row in records], ["好"])
|
||||
self.assertEqual(ledger.search_repeat_history("group-a", "好"), [])
|
||||
|
||||
def test_initial_funds_aliases_groups_and_replay(self):
|
||||
self.assertIn("金币 1000", self.command("/wallet", event_id="wallet-1"))
|
||||
with mock.patch.object(self.local.players.rng, "randrange", return_value=99):
|
||||
first = self.command("/draw 10", event_id="draw-1")
|
||||
replay = self.command("/draw 10", sender="a1", event_id="draw-1")
|
||||
self.assertEqual(first, replay)
|
||||
self.assertEqual(self.state("a1")["balance"], 990)
|
||||
self.assertIn("金币 1000", self.command("/wallet", sender="Bob"))
|
||||
self.assertIn("金币 1000", self.command("/wallet", group="group-b"))
|
||||
self.engine.ledger.connection.close()
|
||||
self.engine = MODULE.EdgeApplication(config(self.root))
|
||||
self.local = self.engine.local
|
||||
self.assertIn("金币 990", self.command("/wallet", event_id="restart"))
|
||||
|
||||
def test_concurrent_replay_debits_once_and_rolls_back_failure(self):
|
||||
replies = []
|
||||
with mock.patch.object(self.local.players.rng, "randrange", return_value=99):
|
||||
threads = [threading.Thread(target=lambda: replies.append(
|
||||
self.command("/draw 100", event_id="concurrent"))) for _ in range(8)]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
self.assertEqual(len(replies), 8)
|
||||
self.assertEqual(len(set(replies)), 1)
|
||||
self.assertEqual(self.state()["balance"], 900)
|
||||
def broken(state):
|
||||
state["balance"] = -1
|
||||
return "bad"
|
||||
with self.assertRaises(MODULE.LocalCommandError):
|
||||
self.engine.ledger.apply_player_action("group-a", "Alice", "broken", "test", broken)
|
||||
self.assertEqual(self.state()["balance"], 900)
|
||||
with self.assertRaises(MODULE.LocalCommandError):
|
||||
self.command("/draw 100", sender="Bob", event_id="concurrent")
|
||||
|
||||
def test_invalid_bets_insufficient_funds_and_lottery(self):
|
||||
for question in ("/draw -1", "/draw 0", "/draw 101", "/lottery xyz",
|
||||
"/blackjack start -1", "/blackjack hit 10"):
|
||||
self.assertRegex(self.command(question), "用法|下注")
|
||||
with mock.patch.object(self.local.players.rng, "randrange", return_value=99):
|
||||
for _ in range(10):
|
||||
self.command("/draw 100")
|
||||
self.assertIn("金币不足", self.command("/draw 2"))
|
||||
self.assertEqual(self.state()["balance"], 0)
|
||||
with mock.patch.object(self.local.players.rng, "randrange", return_value=123):
|
||||
reply = self.command("/买彩票 123 10", sender="Bob")
|
||||
self.assertIn("返还 5000", reply)
|
||||
self.assertEqual(self.state("Bob")["balance"], 5990)
|
||||
|
||||
def test_blackjack_is_private_persistent_and_settles_exactly_once(self):
|
||||
# Player 10+7, dealer 9+8: standing produces a tie.
|
||||
def deck(cards):
|
||||
ordered = [9, 6, 8, 7] + [card for card in cards if card not in {9, 6, 8, 7}]
|
||||
cards[:] = ordered
|
||||
with mock.patch.object(self.local.players.rng, "shuffle", side_effect=deck):
|
||||
start = self.command("/21点 开始 50")
|
||||
self.assertIn("暗牌", start)
|
||||
self.assertEqual(self.state()["balance"], 950)
|
||||
self.assertIn("没有进行中", self.command("/blackjack status", sender="Bob"))
|
||||
self.assertIn("已有一局", self.command("/draw"))
|
||||
self.engine.ledger.connection.close()
|
||||
self.engine = MODULE.EdgeApplication(config(self.root))
|
||||
self.local = self.engine.local
|
||||
settled = self.command("/blackjack stand", sender="a1", event_id="stand-1")
|
||||
self.assertIn("平局", settled)
|
||||
self.assertEqual(self.state()["balance"], 1000)
|
||||
self.assertEqual(self.command("/blackjack stand", event_id="stand-1"), settled)
|
||||
self.assertIsNone(self.state()["game"])
|
||||
self.assertEqual(self.state()["ties"], 1)
|
||||
|
||||
def test_aces_natural_bust_timeout_and_end(self):
|
||||
self.assertEqual(hand_value([0, 13, 9]), 12)
|
||||
self.assertEqual(hand_value([0, 9]), 21)
|
||||
def natural(cards):
|
||||
first = [0, 9, 8, 7]
|
||||
cards[:] = first + [card for card in cards if card not in first]
|
||||
with mock.patch.object(self.local.players.rng, "shuffle", side_effect=natural):
|
||||
reply = self.command("/blackjack start 50")
|
||||
self.assertIn("自然21点获胜", reply)
|
||||
self.assertEqual(self.state()["balance"], 1075)
|
||||
self.local.players.rng = random.Random(4)
|
||||
self.command("/blackjack start 50")
|
||||
if self.state()["game"]:
|
||||
with mock.patch("jwebot_official_extensions.player_games.time.time", return_value=10**10):
|
||||
self.assertIn("超时", self.command("/wallet"))
|
||||
self.assertIsNone(self.state()["game"])
|
||||
self.local.players.rng = random.Random(4)
|
||||
self.command("/blackjack start 50")
|
||||
if self.state()["game"]:
|
||||
self.assertIn("不退下注", self.command("/blackjack end"))
|
||||
self.assertIsNone(self.state()["game"])
|
||||
|
||||
def test_adapter_and_direct_paths_agree_for_new_plugins(self):
|
||||
for index, question in enumerate(("/games", "/wallet", "/people", "/person a1", "/context 200")):
|
||||
kind, _ = local_command_parts(question)
|
||||
row = {"question": question, "sender": "Alice", "group_fingerprint": "group-a",
|
||||
"event_id": f"adapter-{index}"}
|
||||
with mock.patch.object(self.engine.ledger, "bootstrap_repeat_history"):
|
||||
direct = self.local.handle(row, {})
|
||||
adapter = self.engine.extensions.handle(row, {})
|
||||
self.assertEqual(adapter, direct)
|
||||
|
||||
def test_blackjack_bust_cannot_be_acted_on_by_another_player(self):
|
||||
def bust_deck(cards):
|
||||
first = [9, 22, 8, 7]
|
||||
cards[:] = first + [card for card in cards if card not in first and card != 35] + [35]
|
||||
with mock.patch.object(self.local.players.rng, "shuffle", side_effect=bust_deck):
|
||||
self.command("/blackjack start 100")
|
||||
self.assertIn("没有进行中", self.command("/blackjack hit", sender="Bob"))
|
||||
self.assertEqual(len(self.state()["game"]["player"]), 2)
|
||||
result = self.command("/blackjack hit", event_id="bust")
|
||||
self.assertIn("你输了", result)
|
||||
self.assertEqual(self.state()["balance"], 900)
|
||||
self.assertIsNone(self.state()["game"])
|
||||
self.assertEqual(self.command("/blackjack hit", event_id="bust"), result)
|
||||
self.assertEqual(self.state()["losses"], 1)
|
||||
|
||||
def test_simultaneous_natural_blackjack_returns_stake(self):
|
||||
def tied_deck(cards):
|
||||
first = [0, 9, 13, 22]
|
||||
cards[:] = first + [card for card in cards if card not in first]
|
||||
with mock.patch.object(self.local.players.rng, "shuffle", side_effect=tied_deck):
|
||||
self.assertIn("平局", self.command("/blackjack start 50"))
|
||||
self.assertEqual(self.state()["balance"], 1000)
|
||||
self.assertIsNone(self.state()["game"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user