Expand shared Sprinter MCP with managed launch, raw reads and key input
This commit is contained in:
@@ -60,7 +60,10 @@ def main():
|
||||
raise RuntimeError('DAP завершился до авторского лога')
|
||||
if event.get('event') == 'output': outputs.append(event)
|
||||
socket = launched['body']['socket']
|
||||
tail = rpc_call(socket, 'mame_console_tail', {'count': 50}, timeout=6)
|
||||
identity = rpc_call(socket, 'status')
|
||||
tail = rpc_call(socket, 'mame_console_tail', {'count': 50}, timeout=6,
|
||||
session_id=identity['session_id'],
|
||||
build_id=identity['build_id'])
|
||||
if not any('total=1' in line for line in tail['lines']):
|
||||
raise RuntimeError('В debugger console MAME нет total=1: '+str(tail))
|
||||
print(json.dumps({'event': 'macro_dual_console_verified',
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env python3
|
||||
"""MCP сам запускает hello, затем DAP подключается к тому же MAME."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from mcp import Client, StdioServerParameters
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from run_vscode_dap_probe import send, wait_response # noqa: E402
|
||||
|
||||
|
||||
async def probe() -> None:
|
||||
home = Path(os.environ['MAME_HOME']).resolve()
|
||||
binary = Path(os.environ.get('MAME_BIN', home / 'sprinter')).resolve()
|
||||
package = ROOT / 'tests/hello/.sprinter-cc-hello'
|
||||
with tempfile.TemporaryDirectory(prefix='sprinter-managed-mcp-', dir='/tmp') as temp:
|
||||
socket = str(Path(temp) / 'session.sock')
|
||||
parameters = StdioServerParameters(
|
||||
command=sys.executable,
|
||||
args=[str(ROOT / 'toolchain/sdbg_mcp.py'), '--build', str(package),
|
||||
'--socket', socket, '--mame-home', str(home),
|
||||
'--mame-bin', str(binary)])
|
||||
async with Client(parameters) as client:
|
||||
async def call(name: str, arguments: dict | None = None) -> dict:
|
||||
result = await client.call_tool(name, arguments or {})
|
||||
if result.is_error or result.structured_content is None:
|
||||
raise RuntimeError(name + ': ' + repr(result.content))
|
||||
return result.structured_content
|
||||
|
||||
names = {tool.name for tool in (await client.list_tools()).tools}
|
||||
if not {'start_session', 'stop_session', 'where', 'read_variable',
|
||||
'press_key'} <= names:
|
||||
raise RuntimeError('Нет инструментов автономного запуска')
|
||||
initial = await call('session_status')
|
||||
if initial['phase'] != 'idle':
|
||||
raise RuntimeError('Ожидался idle: ' + repr(initial))
|
||||
started_at = time.monotonic()
|
||||
accepted = await call('start_session')
|
||||
start_ms = round((time.monotonic() - started_at) * 1000, 1)
|
||||
if not accepted['accepted'] or start_ms > 3000:
|
||||
raise RuntimeError('MCP start_session не ответил быстро: ' + repr(accepted))
|
||||
duplicate = await client.call_tool('start_session', {})
|
||||
if not duplicate.is_error or 'уже запущена' not in str(duplicate.content):
|
||||
raise RuntimeError('Повторный start_session не был отклонён')
|
||||
mame_pid = None
|
||||
try:
|
||||
deadline = time.monotonic() + 85
|
||||
while time.monotonic() < deadline:
|
||||
status = await call('session_status')
|
||||
if status['phase'] == 'ready':
|
||||
break
|
||||
if status['phase'] in ('failed', 'stopped'):
|
||||
raise RuntimeError('Launcher: ' + repr(status))
|
||||
await asyncio.sleep(.2)
|
||||
else:
|
||||
raise TimeoutError('MCP session не дошла до main')
|
||||
mame_pid = status['mame_pid']
|
||||
location = await call('where')
|
||||
if location.get('function', {}).get('name') != 'main':
|
||||
raise RuntimeError('MCP не остановился в main: ' + repr(location))
|
||||
await call('read_variable', {'name': 'errno'})
|
||||
shot = await call('screenshot')
|
||||
if Path(shot['path']).read_bytes()[:8] != b'\x89PNG\r\n\x1a\n':
|
||||
raise RuntimeError('MCP не создал PNG')
|
||||
|
||||
dap = subprocess.Popen([sys.executable, str(ROOT / 'toolchain/sdbg_dap.py')],
|
||||
cwd=ROOT, stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
buffer = b''
|
||||
try:
|
||||
send(dap, 1, 'initialize')
|
||||
_, buffer, _ = wait_response(dap, buffer, 'initialize', 10)
|
||||
send(dap, 2, 'attach', {'socket': socket})
|
||||
_, buffer, _ = wait_response(dap, buffer, 'attach', 10)
|
||||
send(dap, 3, 'configurationDone')
|
||||
_, buffer, _ = wait_response(dap, buffer, 'configurationDone', 10)
|
||||
send(dap, 4, 'stackTrace')
|
||||
frame, buffer, _ = wait_response(dap, buffer, 'stackTrace', 10)
|
||||
if frame['body']['stackFrames'][0]['name'] != 'main':
|
||||
raise RuntimeError('DAP attach не увидел main')
|
||||
send(dap, 5, 'disconnect')
|
||||
wait_response(dap, buffer, 'disconnect', 10)
|
||||
finally:
|
||||
if dap.poll() is None:
|
||||
dap.terminate()
|
||||
dap.communicate(timeout=8)
|
||||
|
||||
after_dap = await call('session_status')
|
||||
if after_dap['phase'] != 'ready' or after_dap['session_id'] != status['session_id']:
|
||||
raise RuntimeError('DAP disconnect завершил MCP-owned MAME')
|
||||
source = str(ROOT / 'tests/hello/hello.c')
|
||||
before = await call('set_line_breakpoint', {'file': source, 'line': 62})
|
||||
await call('continue_execution')
|
||||
deadline = time.monotonic() + 10
|
||||
while time.monotonic() < deadline:
|
||||
at_getchar = await call('session_status')
|
||||
if not at_getchar['running']:
|
||||
break
|
||||
await asyncio.sleep(.1)
|
||||
else:
|
||||
raise TimeoutError('MCP не остановился перед getchar')
|
||||
location = await call('where')
|
||||
if not any(item['line'] == 62 for item in location['sources']):
|
||||
raise RuntimeError('MCP остановился не перед getchar: ' + repr(location))
|
||||
await call('clear_breakpoint', {'identifier': before['id']})
|
||||
after = await call('set_line_breakpoint', {'file': source, 'line': 63})
|
||||
await call('continue_execution')
|
||||
await asyncio.sleep(.4)
|
||||
key = await call('press_key', {'key': 'x', 'frames': 3})
|
||||
deadline = time.monotonic() + 10
|
||||
while time.monotonic() < deadline:
|
||||
after_key = await call('session_status')
|
||||
if not after_key['running']:
|
||||
break
|
||||
await asyncio.sleep(.1)
|
||||
else:
|
||||
raise TimeoutError('MCP press_key не завершил getchar')
|
||||
location = await call('where')
|
||||
if not any(item['line'] == 63 for item in location['sources']):
|
||||
raise RuntimeError('После клавиши ожидалась строка 63: ' + repr(location))
|
||||
await call('clear_breakpoint', {'identifier': after['id']})
|
||||
print(json.dumps({'event': 'mcp_launch_dap_attach_verified',
|
||||
'tools': len(names), 'start_ms': start_ms,
|
||||
'session_id': status['session_id'],
|
||||
'socket': socket, 'pc': location['pc'],
|
||||
'key': key['key'], 'after_getchar_line': 63},
|
||||
ensure_ascii=False), flush=True)
|
||||
finally:
|
||||
await call('stop_session')
|
||||
deadline = time.monotonic() + 12
|
||||
while time.monotonic() < deadline:
|
||||
ended = await call('session_status')
|
||||
if ended['phase'] == 'stopped':
|
||||
break
|
||||
await asyncio.sleep(.1)
|
||||
else:
|
||||
raise RuntimeError('stop_session не завершил launcher')
|
||||
if Path(socket).exists():
|
||||
raise RuntimeError('После stop_session остался Unix socket')
|
||||
if mame_pid is not None:
|
||||
deadline = time.monotonic() + 8
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
os.kill(mame_pid, 0)
|
||||
except ProcessLookupError:
|
||||
break
|
||||
await asyncio.sleep(.1)
|
||||
else:
|
||||
raise RuntimeError('После stop_session остался процесс MAME')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(probe())
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Живой MCP 2.x пробник экрана выполняющегося hello во время getchar()."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
from mcp import Client, StdioServerParameters
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
async def probe(socket: str, proof: Path | None) -> None:
|
||||
parameters = StdioServerParameters(
|
||||
command=sys.executable,
|
||||
args=[str(ROOT / 'toolchain/sdbg_mcp.py'), '--socket', socket])
|
||||
async with Client(parameters) as client:
|
||||
async def call(name: str, arguments: dict | None = None) -> dict:
|
||||
result = await client.call_tool(name, arguments or {})
|
||||
if result.is_error or result.structured_content is None:
|
||||
raise RuntimeError(name + ': ' + repr(result.content))
|
||||
return result.structured_content
|
||||
|
||||
status = await call('session_status')
|
||||
if not status['running']:
|
||||
raise RuntimeError('Ожидался выполняющийся hello в getchar()')
|
||||
pixels = await call('read_screen_pixels', {
|
||||
'x': 0, 'y': 0, 'width': 8, 'height': 8})
|
||||
if pixels['stale_frame'] or len(pixels['hex']) != 256:
|
||||
raise RuntimeError('Нет свежих пикселей выполняющегося hello')
|
||||
shot = await call('screenshot')
|
||||
source = Path(shot['path'])
|
||||
if shot['stale_frame'] or source.read_bytes()[:8] != b'\x89PNG\r\n\x1a\n':
|
||||
raise RuntimeError('Нет PNG выполняющегося hello')
|
||||
if proof:
|
||||
proof.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(source, proof)
|
||||
print(json.dumps({'event': 'mcp_hello_running_screen',
|
||||
'frame': shot['frame'], 'size': shot['size'],
|
||||
'proof': str(proof) if proof else str(source)},
|
||||
ensure_ascii=False), flush=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--socket', required=True)
|
||||
parser.add_argument('--proof', type=Path)
|
||||
args = parser.parse_args()
|
||||
asyncio.run(probe(args.socket, args.proof))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -22,7 +22,9 @@ async def probe(socket: str, source: str, foreign_id: int):
|
||||
names = {tool.name for tool in listed.tools}
|
||||
expected = {'session_status', 'where', 'read_registers', 'read_memory',
|
||||
'recent_events', 'set_line_breakpoint', 'clear_breakpoint',
|
||||
'claim_control', 'release_control'}
|
||||
'claim_control', 'release_control', 'list_shares',
|
||||
'read_share', 'read_vram', 'read_screen_pixels', 'screenshot',
|
||||
'read_program_memory', 'list_ports'}
|
||||
if not expected <= names:
|
||||
raise RuntimeError('Не хватает MCP-инструментов: ' + str(expected - names))
|
||||
|
||||
@@ -44,6 +46,33 @@ async def probe(socket: str, source: str, foreign_id: int):
|
||||
registers['registers']['PC'] != location['pc'] or \
|
||||
len(memory['hex']) != 8 or not events['events']:
|
||||
raise RuntimeError('Неполный C-снимок MCP: ' + repr((status, location, memory)))
|
||||
program = await call('read_program_memory', {
|
||||
'address': hex(0x10000 + location['pc']), 'length': 4})
|
||||
if program['hex'] != memory['hex'] or program['space'] != 'program':
|
||||
raise RuntimeError('Raw program и logical Z80 расходятся в main')
|
||||
ports = await call('list_ports')
|
||||
if ports['truncated'] or not any(':kbd:ms_naturl:' in item['tag']
|
||||
for item in ports['ports']):
|
||||
raise RuntimeError('Порты PC-клавиатуры не найдены: ' + repr(ports)[:500])
|
||||
|
||||
shares = await call('list_shares')
|
||||
vram = [item for item in shares['shares'] if 'vram' in item['tag']]
|
||||
if len(vram) != 1 or vram[0]['size'] < 4:
|
||||
raise RuntimeError('VRAM share не найден: ' + repr(shares))
|
||||
raw_vram = await call('read_vram', {'address': '0', 'length': 4})
|
||||
same_share = await call('read_share', {
|
||||
'tag': vram[0]['tag'], 'address': '0', 'length': 4})
|
||||
if len(raw_vram['hex']) != 8 or raw_vram['hex'] != same_share['hex']:
|
||||
raise RuntimeError('Чтение VRAM/share различается')
|
||||
pixels = await call('read_screen_pixels', {
|
||||
'x': 0, 'y': 0, 'width': 2, 'height': 2})
|
||||
if len(pixels['hex']) != 16 or not pixels['stale_frame']:
|
||||
raise RuntimeError('Неверный снимок пикселей: ' + repr(pixels))
|
||||
shot = await call('screenshot')
|
||||
snapshot = Path(shot['path'])
|
||||
if snapshot.suffix != '.png' or snapshot.stat().st_size != shot['size'] or \
|
||||
snapshot.read_bytes()[:8] != b'\x89PNG\r\n\x1a\n':
|
||||
raise RuntimeError('Снимок MAME не является PNG: ' + repr(shot))
|
||||
|
||||
own = await call('set_line_breakpoint', {'file': source, 'line': 62})
|
||||
foreign = await client.call_tool('clear_breakpoint', {'identifier': foreign_id})
|
||||
@@ -59,7 +88,9 @@ async def probe(socket: str, source: str, foreign_id: int):
|
||||
'build_id': status['build_id'], 'pc': location['pc'],
|
||||
'tools': len(names), 'foreign_point_rejected': True,
|
||||
'owned_point_for_cleanup': own['id'],
|
||||
'control_claim_release': True},
|
||||
'control_claim_release': True,
|
||||
'shares_pixels_png': True,
|
||||
'program_and_ports': True},
|
||||
ensure_ascii=False), flush=True)
|
||||
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ ROOT = Path(__file__).resolve().parents[2]
|
||||
PYTHON = Path.home()/'.pyenv/shims/python'
|
||||
sys.path.insert(0, str(ROOT/'toolchain'))
|
||||
from mame_interactive import resolve
|
||||
from sdbg.server import rpc_call
|
||||
from sdbg.server import RPC_GENERATION_MUTATIONS, rpc_call as raw_rpc_call
|
||||
from sdbg.session import SessionError
|
||||
|
||||
|
||||
@@ -98,6 +98,10 @@ def main():
|
||||
help='Python 3.12 с MCP SDK 2.x для совместной живой пробы DAP/MCP')
|
||||
parser.add_argument('--orphan-expiry', action='store_true',
|
||||
help='проверить очистку точки владельца MCP без heartbeat через 30 с')
|
||||
parser.add_argument('--screen-mcp-python', default=None,
|
||||
help='Python с MCP SDK 2.x для снимка hello во время getchar')
|
||||
parser.add_argument('--screen-proof', default=None,
|
||||
help='сохранить PNG работающего hello вне временной сессии')
|
||||
parser.add_argument('--clear-while-running', action='store_true',
|
||||
help='во время WAITKEY удалить личную точку; использовать с --waitkey --emulated-key')
|
||||
parser.add_argument('--exit-while-stopped', action='store_true',
|
||||
@@ -128,6 +132,14 @@ def main():
|
||||
launch['launchPath'] = options.launch_path
|
||||
send(process, 2, 'launch', launch)
|
||||
launched, buffer, events = wait_response(process, buffer, 'launch', 80)
|
||||
session_identity = raw_rpc_call(launched['body']['socket'], 'status')
|
||||
def session_rpc(path, method, arguments=None, timeout=10):
|
||||
identity = {'session_id': session_identity['session_id'],
|
||||
'build_id': session_identity['build_id']}
|
||||
if method in RPC_GENERATION_MUTATIONS:
|
||||
current = raw_rpc_call(path, 'status', **identity)
|
||||
identity['generation'] = current['generation']
|
||||
return raw_rpc_call(path, method, arguments, timeout, **identity)
|
||||
if not any(item.get('event') == 'initialized' for item in events):
|
||||
initialized, buffer = receive(process, buffer, 5)
|
||||
if initialized.get('event') != 'initialized':
|
||||
@@ -156,13 +168,13 @@ def main():
|
||||
raise RuntimeError('MCP SDK probe: '+probe.stderr[-4000:])
|
||||
if options.orphan_expiry:
|
||||
owner = 'mcp:orphan-expiry-probe'
|
||||
orphan = rpc_call(launched['body']['socket'], 'break_line', {
|
||||
orphan = session_rpc(launched['body']['socket'], 'break_line', {
|
||||
'file': source, 'line': 62, 'owner': owner})
|
||||
rpc_call(launched['body']['socket'], 'claim_control', {'owner': owner})
|
||||
session_rpc(launched['body']['socket'], 'claim_control', {'owner': owner})
|
||||
print('Ожидание истечения MCP owner и очистки его точки...', flush=True)
|
||||
deadline = time.monotonic()+36
|
||||
while time.monotonic() < deadline:
|
||||
events = rpc_call(launched['body']['socket'], 'events', {'after': 0})
|
||||
events = session_rpc(launched['body']['socket'], 'events', {'after': 0})
|
||||
if any(event['event'] == 'owner_expired' and
|
||||
event['body']['owner'] == owner for event in events['events']):
|
||||
break
|
||||
@@ -170,7 +182,7 @@ def main():
|
||||
else:
|
||||
raise RuntimeError('MCP owner не истёк за 36 с')
|
||||
try:
|
||||
rpc_call(launched['body']['socket'], 'clear_breakpoint', {
|
||||
session_rpc(launched['body']['socket'], 'clear_breakpoint', {
|
||||
'id': orphan['id'], 'owner': owner})
|
||||
except SessionError as error:
|
||||
if 'Неизвестная логическая точка' not in str(error):
|
||||
@@ -284,37 +296,49 @@ def main():
|
||||
socket_path = launched['body']['socket']
|
||||
clear_point = None
|
||||
if options.clear_while_running:
|
||||
clear_point = rpc_call(socket_path, 'break_line', {
|
||||
clear_point = session_rpc(socket_path, 'break_line', {
|
||||
'file': str(ROOT/'tests/hello/hello.c'), 'line': 63,
|
||||
'owner': 'probe:running-clear'})
|
||||
started = time.monotonic()
|
||||
send(process, 8, 'next')
|
||||
_, buffer, _ = wait_response(process, buffer, 'next', 3)
|
||||
response_ms = round((time.monotonic()-started)*1000, 2)
|
||||
if options.screen_mcp_python:
|
||||
time.sleep(.6)
|
||||
screen_command = [options.screen_mcp_python,
|
||||
str(ROOT/'tests/sdbg/run_mcp_screen_probe.py'),
|
||||
'--socket', socket_path]
|
||||
if options.screen_proof:
|
||||
screen_command += ['--proof', options.screen_proof]
|
||||
screen_probe = subprocess.run(screen_command, capture_output=True,
|
||||
text=True, timeout=20, check=False)
|
||||
if screen_probe.returncode:
|
||||
raise RuntimeError('MCP screen probe: '+screen_probe.stderr[-3000:])
|
||||
print(screen_probe.stdout.strip(), flush=True)
|
||||
if clear_point:
|
||||
snapshot = rpc_call(socket_path, 'snapshot', timeout=5)
|
||||
snapshot = session_rpc(socket_path, 'snapshot', timeout=5)
|
||||
if snapshot['state'] != 'running':
|
||||
raise RuntimeError('CPU не выполняется перед очисткой точки')
|
||||
try:
|
||||
rpc_call(socket_path, 'read_memory', {'address': 0, 'length': 1})
|
||||
session_rpc(socket_path, 'read_memory', {'address': 0, 'length': 1})
|
||||
raise RuntimeError('Чтение памяти разрешено при running CPU')
|
||||
except SessionError as error:
|
||||
if 'остановленного CPU' not in str(error):
|
||||
raise
|
||||
rpc_call(socket_path, 'clear_breakpoint', {
|
||||
session_rpc(socket_path, 'clear_breakpoint', {
|
||||
'id': clear_point['id'], 'owner': 'probe:running-clear'})
|
||||
if '--emulated-key' in sys.argv:
|
||||
first = rpc_call(socket_path, 'snapshot', timeout=5)
|
||||
first = session_rpc(socket_path, 'snapshot', timeout=5)
|
||||
time.sleep(1)
|
||||
second = rpc_call(socket_path, 'snapshot', timeout=5)
|
||||
second = session_rpc(socket_path, 'snapshot', timeout=5)
|
||||
if second['paused'] or second['time'] <= first['time']:
|
||||
raise RuntimeError('MAME не обновляет input frames: '+
|
||||
str((first, second)))
|
||||
tag, mask, _ = resolve('x')
|
||||
rpc_call(socket_path, 'input_key',
|
||||
session_rpc(socket_path, 'input_key',
|
||||
{'tag': tag, 'mask': mask, 'down': True}, timeout=5)
|
||||
time.sleep(.15)
|
||||
rpc_call(socket_path, 'input_key',
|
||||
session_rpc(socket_path, 'input_key',
|
||||
{'tag': tag, 'mask': mask, 'down': False}, timeout=5)
|
||||
key_stop, buffer = wait_event(process, buffer, 'stopped', 10)
|
||||
if not key_stop:
|
||||
@@ -324,7 +348,7 @@ def main():
|
||||
after = after_response['body']['stackFrames'][0]
|
||||
if after['line'] != 63:
|
||||
raise RuntimeError('После клавиши ожидалась строка 63: '+str(after))
|
||||
ascii_code = rpc_call(socket_path, 'registers', timeout=5)
|
||||
ascii_code = session_rpc(socket_path, 'registers', timeout=5)
|
||||
ascii_code = ascii_code['registers']['DE'] & 0xff
|
||||
if ascii_code != ord('x'):
|
||||
raise RuntimeError(f'WAITKEY вернул {ascii_code:#x}, ожидался x (0x78)')
|
||||
@@ -340,7 +364,7 @@ def main():
|
||||
return 0
|
||||
if '--manual-key' in sys.argv:
|
||||
socket_path = launched['body']['socket']
|
||||
keyboard_state = rpc_call(socket_path, 'snapshot', timeout=5)['keyboards']
|
||||
keyboard_state = session_rpc(socket_path, 'snapshot', timeout=5)['keyboards']
|
||||
if keyboard_state.get(':kbd:ms_naturl') is not True:
|
||||
raise RuntimeError('Физическая PC-клавиатура MAME выключена: '+
|
||||
str(keyboard_state))
|
||||
@@ -357,7 +381,7 @@ def main():
|
||||
after = after_response['body']['stackFrames'][0]
|
||||
if after['line'] != 63:
|
||||
raise RuntimeError('После клавиши ожидалась строка 63: '+str(after))
|
||||
ascii_code = rpc_call(socket_path, 'registers', timeout=5)
|
||||
ascii_code = session_rpc(socket_path, 'registers', timeout=5)
|
||||
ascii_code = ascii_code['registers']['DE'] & 0xff
|
||||
if ascii_code != ord('x'):
|
||||
raise RuntimeError(f'WAITKEY вернул {ascii_code:#x}, ожидался x (0x78)')
|
||||
|
||||
@@ -14,8 +14,11 @@ class McpAdapterTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.calls = []
|
||||
|
||||
def rpc(path, method, arguments, timeout=10):
|
||||
self.calls.append((path, method, arguments, timeout))
|
||||
def rpc(path, method, arguments, timeout=10, **identity):
|
||||
self.calls.append((path, method, arguments, timeout, identity))
|
||||
if method == 'status':
|
||||
return {'session_id': 'test-session', 'build_id': 'test-build',
|
||||
'generation': 7}
|
||||
return {'method': method, 'arguments': arguments}
|
||||
|
||||
self.client = McpSession('/tmp/sprinter-test.sock', rpc=rpc)
|
||||
@@ -26,9 +29,13 @@ class McpAdapterTests(unittest.TestCase):
|
||||
self.client.clear_breakpoint(7)
|
||||
self.client.clear_owned_breakpoints()
|
||||
self.assertTrue(self.client.owner.startswith('mcp:'))
|
||||
for _, _, arguments, _ in self.calls:
|
||||
for _, method, arguments, _, identity in self.calls:
|
||||
if method == 'status':
|
||||
continue
|
||||
self.assertEqual(arguments['owner'], self.client.owner)
|
||||
self.assertEqual([item[1] for item in self.calls],
|
||||
self.assertEqual(identity['session_id'], 'test-session')
|
||||
self.assertEqual(identity['build_id'], 'test-build')
|
||||
self.assertEqual([item[1] for item in self.calls if item[1] != 'status'],
|
||||
['break_line', 'break_function', 'clear_breakpoint',
|
||||
'clear_owned_breakpoints'])
|
||||
|
||||
@@ -36,7 +43,7 @@ class McpAdapterTests(unittest.TestCase):
|
||||
self.client.read_memory('0xc000', 16)
|
||||
self.assertEqual(self.calls[-1][2], {'address': 0xc000, 'length': 16})
|
||||
self.client.events(after=4, timeout=12)
|
||||
self.assertEqual(self.calls[-1][1:],
|
||||
self.assertEqual(self.calls[-1][1:4],
|
||||
('events', {'after': 4, 'timeout': 12,
|
||||
'owner': self.client.owner}, 14))
|
||||
before = len(self.calls)
|
||||
@@ -48,6 +55,28 @@ class McpAdapterTests(unittest.TestCase):
|
||||
self.client.step_source('back')
|
||||
self.assertEqual(len(self.calls), before)
|
||||
|
||||
def test_press_key_releases_shift_after_snapshot_error(self):
|
||||
calls = []
|
||||
|
||||
def rpc(path, method, arguments, timeout=10, **identity):
|
||||
calls.append((method, arguments))
|
||||
if method == 'status':
|
||||
return {'session_id': 'test-session', 'build_id': 'test-build',
|
||||
'generation': 7, 'running': True}
|
||||
if method == 'snapshot':
|
||||
raise SessionError('Потеряна связь с MAME')
|
||||
return {}
|
||||
|
||||
client = McpSession('/tmp/sprinter-test.sock', rpc=rpc)
|
||||
with self.assertRaisesRegex(SessionError, 'Потеряна связь'):
|
||||
client.press_key('X')
|
||||
keys = [args for method, args in calls if method == 'input_key']
|
||||
self.assertEqual([item['down'] for item in keys],
|
||||
[True, True, False, False])
|
||||
self.assertEqual(keys[0]['tag'], keys[-1]['tag'])
|
||||
self.assertEqual(keys[1]['tag'], keys[-2]['tag'])
|
||||
self.assertTrue(all(item['owner'] == client.owner for item in keys))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
@@ -22,6 +22,7 @@ class DummyBridge:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
self.state = 'stopped'
|
||||
self.generation = 1
|
||||
def close(self): pass
|
||||
def request(self, command, **arguments):
|
||||
self.calls.append((command, arguments))
|
||||
@@ -189,8 +190,26 @@ class ServerTests(unittest.TestCase):
|
||||
try:
|
||||
status = rpc_call(server.path, 'status')
|
||||
self.assertEqual(status['build_id'], 'test')
|
||||
events = rpc_call(server.path, 'events', {'after': 0})
|
||||
identity = {'session_id': status['session_id'],
|
||||
'build_id': status['build_id']}
|
||||
events = rpc_call(server.path, 'events', {'after': 0}, **identity)
|
||||
self.assertEqual(events['events'][0]['event'], 'stopped')
|
||||
with self.assertRaisesRegex(SessionError, 'Устаревшая RPC-сессия'):
|
||||
rpc_call(server.path, 'events', {'after': 0})
|
||||
with self.assertRaisesRegex(SessionError, 'Устаревшая RPC-сессия'):
|
||||
rpc_call(server.path, 'events', {'after': 0},
|
||||
session_id='old', build_id=status['build_id'])
|
||||
with self.assertRaisesRegex(SessionError, 'Устаревшая RPC-сессия'):
|
||||
rpc_call(server.path, 'events', {'after': 0},
|
||||
session_id=status['session_id'], build_id='old-build')
|
||||
with self.assertRaisesRegex(SessionError, 'Устаревшая generation'):
|
||||
rpc_call(server.path, 'break_line',
|
||||
{'file': '/src/main.c', 'line': 3},
|
||||
generation=0, **identity)
|
||||
point = rpc_call(server.path, 'break_line',
|
||||
{'file': '/src/main.c', 'line': 3},
|
||||
generation=status['generation'], **identity)
|
||||
self.assertEqual(point['id'], 1)
|
||||
with self.assertRaisesRegex(SessionError, 'Неизвестный'):
|
||||
rpc_call(server.path, 'unknown')
|
||||
finally:
|
||||
@@ -290,6 +309,21 @@ class ServerTests(unittest.TestCase):
|
||||
finally:
|
||||
controller.close()
|
||||
|
||||
def test_screen_and_share_reads_reject_oversized_requests(self):
|
||||
controller = SessionController(DummySession())
|
||||
try:
|
||||
with self.assertRaisesRegex(SessionError, 'длина 1..4096'):
|
||||
controller.call('read_share', {'tag': ':vram', 'address': 0,
|
||||
'length': 4097})
|
||||
with self.assertRaisesRegex(SessionError, 'точный tag'):
|
||||
controller.call('read_share', {'tag': '', 'address': 0,
|
||||
'length': 1})
|
||||
with self.assertRaisesRegex(SessionError, '8192 пикселей'):
|
||||
controller.call('read_screen_pixels', {'x': 0, 'y': 0,
|
||||
'width': 128, 'height': 128})
|
||||
finally:
|
||||
controller.close()
|
||||
|
||||
def test_idle_snapshot_detects_invalidation(self):
|
||||
session = DummySession()
|
||||
controller = SessionController(session)
|
||||
|
||||
Reference in New Issue
Block a user