Files
templates/tools/dsview/tests/test_install_decoders.py

164 lines
12 KiB
Python

# Проверки install_decoders: фиксируют ожидаемое поведение на воспроизводимых входных данных.
# Сценарии для навигации по файлу:
# test_install_import_and_update_backup
# test_check_only_does_not_install
# test_copy_failure_restores_previous_decoders
# test_incomplete_target_is_untouched
# test_missing_source_is_detected_before_changes
# Тестовые значения и подмены окружения относятся к проверке контракта; они не являются
# настройками рабочего устройства.
"""Exercise the standalone PowerShell installer without modifying installed DSView."""
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
SHELL = shutil.which('powershell.exe') or shutil.which('pwsh')
@unittest.skipUnless(os.name == 'nt' and SHELL, 'Windows PowerShell required')
class InstallerTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix='dsview installer ')
self.addCleanup(self.temp.cleanup)
self.app = Path(self.temp.name)/'DSView test'
self.app.mkdir()
(self.app/'DSView.exe').write_bytes(b'fixture, not executable')
(self.app/'decoders/common').mkdir(parents=True)
(self.app/'decoders/common/__init__.py').write_text('# existing common package\n')
(self.app/'decoders/common/keep.txt').write_text('unrelated')
def run_installer(self, *extra, script=None):
return subprocess.run([SHELL, '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File',
str(script or ROOT/'install_decoders.ps1'),
'-DsViewPath', str(self.app), *extra], capture_output=True, text=True)
def test_install_import_and_update_backup(self):
result = self.run_installer()
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
code = '''
import importlib, pathlib, sys, types
root = pathlib.Path(sys.argv[1])
sys.path.insert(0, str(root))
stub = types.ModuleType('sigrokdecode')
stub.Decoder = type('Decoder', (), {})
sys.modules['sigrokdecode'] = stub
for name in ('gate_driver_timing', 'transistor_pair', 'set_uart', 'set_can', 'pm35_uart', 'tms_uart', 'plates_pbus', 'plates_sbus', 'pwm_waveform'): # Все анализаторы, включая обе 96-контактные шины, загружаются без установленного SETGUI.
module = importlib.import_module(name + '.pd')
assert module.Decoder.api_version == 3
core = importlib.import_module('common.setgui_decoders.gate_timing')
pathlib.Path(core.__file__).resolve().relative_to(root)
assert '1SP0635' in core.PROFILES
protocols = importlib.import_module('common.setgui_decoders.template_protocols') # Проверяет прикладные парсеры в чистом процессе Python с флагом -I.
result = protocols.decode_can(0xBA0014, bytes.fromhex('0000000080101234'), 'balsam') # Вектор использует фактическое положение адресного слова Balsam.
assert result['registers'] == [(16, 0x1234)] # Импорт должен завершаться реальным прикладным разбором, а не только обнаружением модуля.
for dependency in (protocols.pc, protocols.balsam, protocols.ump_can): # Канонические библиотеки должны поступать из установленной копии декодеров.
pathlib.Path(dependency.__file__).resolve().relative_to(root) # Исключает случайный успех за счёт пакетов рабочего checkout.
assert len(protocols.LEGACY_PROJECTS) == 9 # Установка включает весь текущий каталог исторических проектов.
'''
check = subprocess.run([sys.executable, '-I', '-c', code, str(self.app/'decoders')],
cwd=self.app, capture_output=True, text=True)
self.assertEqual(check.returncode, 0, check.stderr)
target = self.app/'decoders/set_uart/pd.py'
target.write_text('# old decoder\n')
result = self.run_installer()
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
backups = list((self.app/'set-decoder-backups').glob('*/set_uart/pd.py'))
self.assertEqual(len(backups), 1)
self.assertEqual(backups[0].read_text(), '# old decoder\n')
self.assertEqual(target.read_bytes(), (ROOT/'decoders/set_uart/pd.py').read_bytes())
self.assertEqual((self.app/'decoders/common/keep.txt').read_text(), 'unrelated')
self.assertEqual((self.app/'decoders/common/__init__.py').read_text(), '# existing common package\n')
for name in ('gate_timing', 'transistor_pair', 'set_uart', 'set_can', 'pm35_uart'):
self.assertEqual((self.app/('decoders/common/setgui_decoders/'+name+'.py')).read_bytes(),
(ROOT.parents[1]/('python/logic_analyzer/decoders/'+name+'.py')).read_bytes())
# Права на EXE не должны требоваться при обновлении только Python-декодеров.
def test_read_only_executable_is_supported(self): # Повторяет причину ложного отказа старой проверки DSView.exe.
import stat # Атрибут read-only выставляется без изменения содержимого EXE.
executable = self.app / 'DSView.exe' # Используется фиктивная установка, не установленный DSView.
executable.chmod(stat.S_IREAD) # Только EXE недоступен для записи; каталоги декодеров доступны.
try: # Атрибут обязательно снимается перед очисткой временной папки.
result = self.run_installer() # Установщик должен проверять каталоги, а не открывать EXE на запись.
self.assertEqual(result.returncode, 0, result.stdout + result.stderr) # Read-only EXE не мешает копированию декодеров.
self.assertTrue((self.app / 'decoders/plates_pbus/pd.py').is_file()) # Ведомость действительно включает PBus.
self.assertTrue((self.app / 'decoders/plates_sbus/pd.py').is_file()) # Ведомость действительно включает SBus.
self.assertEqual(executable.read_bytes(), b'fixture, not executable') # Содержимое EXE остаётся исходным.
self.assertEqual(list(self.app.rglob('.set-decoder-write-*.tmp')), []) # Пробные файлы не остаются после проверки прав.
finally: # Windows запрещает удалить read-only файл обычной очисткой TemporaryDirectory.
executable.chmod(stat.S_IREAD | stat.S_IWRITE) # Возвращаем право удаления только временной фикстуре.
# Запущенная целевая установка должна обнаруживаться без обращения к EXE на запись.
def test_running_target_is_rejected_before_copy(self): # Подмена процесса исключает запуск настоящего приложения.
runner = Path(self.temp.name) / 'running-target.ps1' # Обёртка существует только внутри тестовой папки.
runner.write_text('''
param([string]$Installer, [string]$Target) # Пути приходят от временной фикстуры теста.
function Get-Process { # Подмена ограничена текущим тестовым процессом PowerShell.
param([string]$Name, [string]$ErrorAction) # Параметры соответствуют вызову установщика.
[pscustomobject]@{ MainModule = [pscustomobject]@{ FileName = (Join-Path $Target 'DSView.exe') } } # Фиктивный процесс относится к выбранной установке.
} # Настоящие процессы не запускаются и не завершаются.
& $Installer -DsViewPath $Target # Общий установщик должен отвергнуть работу до копирования.
''', encoding='utf-8') # Сценарий хранится в UTF-8, как остальные исходники проекта.
result = subprocess.run([SHELL, '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', str(runner), '-Installer', str(ROOT / 'install_decoders.ps1'), '-Target', str(self.app)], capture_output=True, text=True) # Изолированное выполнение сохраняет состояние настоящего DSView.
self.assertNotEqual(result.returncode, 0) # Запущенный целевой процесс требует закрытия приложения.
self.assertFalse((self.app / 'decoders/plates_pbus').exists()) # Первый новый анализатор ещё не должен появиться.
self.assertFalse((self.app / 'set-decoder-backups').exists()) # Резервное копирование также не должно начаться.
def test_check_only_does_not_install(self):
result = self.run_installer('-CheckOnly')
self.assertEqual(result.returncode, 0, result.stderr)
self.assertFalse((self.app/'decoders/set_uart').exists())
self.assertFalse((self.app/'set-decoder-backups').exists())
def test_copy_failure_restores_previous_decoders(self):
old = self.app/'decoders/gate_driver_timing/pd.py'
old.parent.mkdir()
old.write_text('# original\n')
runner = Path(self.temp.name)/'inject-failure.ps1'
runner.write_text('''
param([string]$Installer, [string]$Target)
$ErrorActionPreference = 'Stop'
$global:installerTestFailed = $false
function Copy-Item {
param([string]$LiteralPath, [string]$Destination, [switch]$Force)
if (-not $global:installerTestFailed -and $Destination.Replace('\\', '/').EndsWith('/decoders/set_uart/pd.py')) {
$global:installerTestFailed = $true
throw 'Injected copy failure'
}
Microsoft.PowerShell.Management\\Copy-Item -LiteralPath $LiteralPath -Destination $Destination -Force:$Force
}
& $Installer -DsViewPath $Target
''', encoding='utf-8')
result = subprocess.run([SHELL, '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File',
str(runner), '-Installer', str(ROOT/'install_decoders.ps1'),
'-Target', str(self.app)], capture_output=True)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(old.read_text(), '# original\n')
self.assertFalse((old.parent/'__init__.py').exists())
self.assertFalse((self.app/'decoders/set_uart/__init__.py').exists())
self.assertEqual(len(list((self.app/'set-decoder-backups').glob('*/gate_driver_timing/pd.py'))), 1, result.stderr)
def test_incomplete_target_is_untouched(self):
(self.app/'DSView.exe').unlink()
result = self.run_installer()
self.assertNotEqual(result.returncode, 0)
self.assertFalse((self.app/'decoders/set_uart').exists())
def test_missing_source_is_detected_before_changes(self):
source = Path(self.temp.name)/'empty-template/tools/dsview'
source.mkdir(parents=True)
shutil.copy2(ROOT/'install_decoders.ps1', source/'install_decoders.ps1')
result = self.run_installer(script=source/'install_decoders.ps1')
self.assertNotEqual(result.returncode, 0)
self.assertFalse((self.app/'decoders/gate_driver_timing').exists())
self.assertFalse((self.app/'set-decoder-backups').exists())
if __name__ == '__main__':
unittest.main()