144 lines
16 KiB
Python
144 lines
16 KiB
Python
"""Manual native smoke test: synthetic 3-phase PWM, embedded DSView screenshot.
|
||
|
||
Run with a Python containing PySide6. No acquisition hardware is accessed.
|
||
"""
|
||
import argparse
|
||
import ctypes # Win32 WM_NULL проверяет отзывчивость реального потока интерфейса дочернего DSView.
|
||
import json
|
||
import math
|
||
import os
|
||
import re # Готовый HWND читается из штатного ответа host protocol 1.
|
||
from pathlib import Path
|
||
import subprocess
|
||
import time
|
||
import zipfile
|
||
|
||
|
||
def fixture(path, seconds=0.060032, carrier_hz=2000, layout='split', max_height='1X', show_text=False, text_only=False): # Видимость текстов и графиков воспроизводит пользовательскую компоновку и позволяет сравнить интервалы при разных высотах.
|
||
rate = 1000000 # Частота 1 МГц даёт 50 отсчётов на период при нагрузочной несущей 20 кГц.
|
||
if seconds <= 0 or not 0 < carrier_hz <= rate / 2: # Каждый период должен занимать хотя бы два отсчёта.
|
||
raise ValueError('Positive duration and carrier <= 500000 Hz are required.') # Некорректный сценарий не должен создавать повреждённую запись.
|
||
count, period = math.ceil(seconds * rate / 64) * 64, round(rate / carrier_hz) # Формат DSL требует выравнивания числа отсчётов по 64 битам.
|
||
decoders = [dict(id='pwm_waveform', version=2, label='PWM sine A / B / C',
|
||
view_index=3, channel=[{'a': 0}, {'b': 1}, {'c': 2}],
|
||
options=dict(active_a='high', active_b='high', active_c='high',
|
||
carrier_hz=0, smoothing_ms=0.0, waveform_layout=layout), # Используем штатный параметр Decoders, который также сохраняется в записи DSL.
|
||
**{'stacked decoders': [], 'show': {'pwm_waveform': True,
|
||
**{'pwm_waveform: '+ch+' duty / frequency': False for ch in 'ABC'}}})]
|
||
session = dict(Device='virtual-session', DeviceMode=0, Version=3, Title='DSView v1.3.2',
|
||
decoder=decoders, channel=[dict(index=i, enabled=True, name='D'+str(i),
|
||
colour='default', strigger=0, type=10000, view_index=i) for i in range(3)],
|
||
**{'Sample count': str(count), 'Sample rate': str(rate), 'Max Height': max_height}) # Большая высота дорожек не должна увеличивать фон числовых аннотаций.
|
||
if show_text or text_only: # Текстовые строки нужны для проверки расстояний и переключения видимых полей.
|
||
decoders[0]['show'].update({'pwm_waveform: ' + ch + ' duty / frequency': True for ch in 'ABC'}) # Показываем исходные числовые поля всех трёх каналов без изменения расчётных параметров.
|
||
if text_only: # Изолированная текстовая компоновка позволяет проверить независимость от масштаба графиков.
|
||
decoders[0]['show'].update({'pwm_waveform: ' + ch + ': -1 .. +1': False for ch in 'ABC'}) # Скрываем только графические строки, сохраняя их рассчитанные данные.
|
||
header = ('[version]\nversion = 3\n[header]\ndriver = virtual-session\n'
|
||
'device mode = 0\ncapturefile = data\n'
|
||
f'total samples = {count}\ntotal probes = 3\ntotal blocks = 1\n'
|
||
f'samplerate = {rate} Hz\ntrigger time = 0\ntrigger pos = 0\n'
|
||
'probe0 = D0\nprobe1 = D1\nprobe2 = D2\n')
|
||
with zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED) as archive:
|
||
archive.writestr('header', header)
|
||
archive.writestr('session', json.dumps(session))
|
||
archive.writestr('decoders', json.dumps(decoders))
|
||
for ch in range(3):
|
||
bits = bytearray(count // 8)
|
||
for i in range(0, count, period): # Обходим периоды вместо всех отсчётов, чтобы длинная фикстура создавалась быстро.
|
||
width = min(count - i, round(period * (0.5 + 0.4 * math.sin(2 * math.pi * i / rate * 50 + ch * 2 * math.pi / 3)))) # Синус 50 Гц задаёт заполнение от 10 до 90 процентов со сдвигом фаз 120 градусов.
|
||
if not width: # Очень короткий завершающий импульс может округлиться до нуля.
|
||
continue # Нулевая длительность сохраняет исходные нулевые биты.
|
||
first, last = i // 8, (i + width - 1) // 8 # Вычисляем крайние байты активного импульса.
|
||
if first == last: # Импульс внутри одного байта заполняется одной битовой маской.
|
||
bits[first] |= ((1 << width) - 1) << (i % 8) # Смещение сохраняет позицию фронта с точностью одного отсчёта.
|
||
else: # Длинный импульс состоит из двух неполных байтов и полного внутреннего диапазона.
|
||
bits[first] |= (255 << (i % 8)) & 255 # В первом байте активны биты начиная с фронта до конца байта.
|
||
bits[first + 1:last] = b'\xff' * (last - first - 1) # Внутренние полные байты заполняются без цикла по отдельным отсчётам.
|
||
bits[last] |= (1 << ((i + width - 1) % 8 + 1)) - 1 # Последний байт обрезается точно по спаду импульса.
|
||
archive.writestr(f'L-{ch}/0', bits)
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description=__doc__)
|
||
parser.add_argument('--runtime', type=Path, required=True)
|
||
parser.add_argument('--output', type=Path, required=True)
|
||
parser.add_argument('--seconds', type=float, default=0.060032) # Прежний короткий сценарий остаётся значением по умолчанию.
|
||
parser.add_argument('--carrier-hz', type=int, default=2000) # Нагрузочная проверка использует 20 кГц и сотни тысяч периодов.
|
||
parser.add_argument('--timeout', type=float, default=30) # Ограничиваем время ожидания завершения декодирования.
|
||
parser.add_argument('--layout', choices=('split', 'combined'), default='split') # Один нативный сценарий проверяет оба пользовательских вида графиков.
|
||
parser.add_argument('--max-height', choices=('1X', '2X', '3X', '4X'), default='1X') # Проверка на увеличенной дорожке воспроизводит прежнее растягивание текстовых полей.
|
||
parser.add_argument('--show-text', action='store_true') # Включаем duty/frequency для проверки всей смешанной компоновки.
|
||
parser.add_argument('--text-only', action='store_true') # Фиксированные интервалы проверяются также при полностью скрытых графиках.
|
||
parser.add_argument('--check-display-cache', action='store_true') # Нативный сценарий принимает реальные диалоги видимости, неизменённых настроек и вида PWM без нового анализа.
|
||
args = parser.parse_args()
|
||
from PySide6.QtWidgets import QApplication, QWidget
|
||
runtime, output = args.runtime.resolve(), args.output.resolve()
|
||
output.mkdir(parents=True, exist_ok=True)
|
||
capture = output / 'pwm-three-phase.dsl'
|
||
screenshot = output / 'pwm-native.png'
|
||
fixture(capture, args.seconds, args.carrier_hz, args.layout, args.max_height, args.show_text, args.text_only) # Все параметры восстанавливаются из обычного DSL, включая видимость строк и масштаб дорожек.
|
||
app = QApplication([])
|
||
window = QWidget()
|
||
window.resize(1400, 850)
|
||
window.setWindowTitle('PWM native rendering test')
|
||
window.show()
|
||
env = {k: v for k, v in os.environ.items() if not k.startswith(('PYTHON', 'QT_', 'QML'))}
|
||
env['PATH'] = str(runtime) + os.pathsep + str(Path(os.environ['SystemRoot']) / 'System32')
|
||
env['DSVIEW_HOST_SCREENSHOT'] = str(screenshot)
|
||
cache_report = output / 'display-cache.txt' # Отчёт нативных проверок отделён от журнала единственного декодирования.
|
||
if args.check_display_cache: # Обычный smoke-тест не открывает проверочные модальные диалоги.
|
||
cache_report.write_text('', encoding='utf-8') # Исключаем ложный успех от артефактов прежнего запуска.
|
||
env['DSVIEW_HOST_CHECK'] = str(cache_report) # Используем существующий механизм нативных проверок встроенного DSView.
|
||
env['DSVIEW_HOST_CHECK_DISPLAY_CACHE'] = '1' # Проверяем сохранение готовых результатов вместо удаления анализаторов.
|
||
began = time.time()
|
||
with (output / 'pwm-native.log').open('w', encoding='utf-8') as log:
|
||
process = subprocess.Popen([str(runtime / 'DSView.exe'), '--loglevel', '3', '--embed-parent', # Информационный журнал подтверждает фактическое завершение анализа всей записи.
|
||
str(int(window.winId())), str(capture)], cwd=runtime, env=env,
|
||
stdin=subprocess.PIPE, stdout=log, stderr=log, creationflags=subprocess.CREATE_NO_WINDOW)
|
||
try:
|
||
deadline = time.monotonic() + args.timeout # Длинная запись должна полностью обработаться до штатного закрытия.
|
||
completed, hwnd, response_times = False, None, [] # Фиксируем завершение и задержку обработки сообщений настоящим интерфейсом DSView.
|
||
user32 = ctypes.WinDLL('user32', use_last_error=True) # Системный API проверяет только дочерний процесс, запущенный этим тестом.
|
||
user32.SendMessageTimeoutW.argtypes = [ctypes.c_void_p, ctypes.c_uint, ctypes.c_size_t, ctypes.c_ssize_t, ctypes.c_uint, ctypes.c_uint, ctypes.POINTER(ctypes.c_size_t)] # Типы указателей учитывают 64-битный HWND.
|
||
while time.monotonic() < deadline and process.poll() is None:
|
||
app.processEvents()
|
||
report = (output / 'pwm-native.log').read_text(encoding='utf-8', errors='replace') # Чтение журнала не изменяет состояние декодера или прибора.
|
||
ready = re.search(r'DSVIEW_READY (\d+)', report) # Используем подтверждённый HWND из штатного протокола встраивания.
|
||
if ready: # До готовности нативного окна измерять его отзывчивость нельзя.
|
||
hwnd = int(ready.group(1)) # Дескриптор относится только к нашему тестовому экземпляру.
|
||
result, started = ctypes.c_size_t(), time.monotonic() # Монотонные часы измеряют обработку одного сообщения.
|
||
responded = user32.SendMessageTimeoutW(hwnd, 0, 0, 0, 2, 1000, ctypes.byref(result)) # WM_NULL с SMTO_ABORTIFHUNG ожидает не более одной секунды и не меняет приложение.
|
||
response_times.append(time.monotonic() - started) # Задержка показывает блокировку цикла событий во время анализа и рисования.
|
||
assert responded, 'DSView UI probe failed, Win32 error={}'.format(ctypes.get_last_error()) # Код Win32 отличает реальное зависание от запрета доступа к чужому HWND или смены рабочего стола.
|
||
cache = cache_report.read_text(encoding='utf-8') if args.check_display_cache else 'PASS' # Ожидаем завершения всех действий в реальном диалоге настроек.
|
||
assert 'FAIL' not in cache, cache # Ошибочное требование повторного анализа немедленно завершает сценарий с диагностикой фазы.
|
||
if 'Decoded sample count:' in report and screenshot.exists() and 'PASS' in cache: # Успех требует полного исходного анализа и подтверждённого сохранения кэша.
|
||
completed = True # В журнале есть подтверждение обработки всех отсчётов.
|
||
window.screen().grabWindow(hwnd).save(str(output / 'pwm-complete.png')) # Сохраняем итоговый экран после завершения длинного анализа.
|
||
break # Далее проверяем штатное закрытие завершённого экземпляра.
|
||
time.sleep(.02)
|
||
assert completed, 'PWM decode did not complete; inspect pwm-native.log' # Тайм-аут не должен маскироваться успешным принудительным закрытием.
|
||
print('UI max response ms:', round(max(response_times, default=0) * 1000, 3)) # Отчёт связывает нагрузочный сценарий с реальной задержкой интерфейса.
|
||
if process.poll() is None:
|
||
process.stdin.write(b'CLOSE\n')
|
||
process.stdin.flush()
|
||
deadline = time.monotonic() + 5
|
||
while time.monotonic() < deadline and process.poll() is None:
|
||
app.processEvents()
|
||
time.sleep(.02)
|
||
assert process.poll() == 0, 'DSView did not close cleanly; inspect pwm-native.log'
|
||
assert screenshot.exists() and screenshot.stat().st_mtime >= began, 'No fresh render artifact'
|
||
if args.check_display_cache: # Проверка по журналу дополняет результат принятия каждого настоящего диалога.
|
||
assert report.count('Decoded sample count:') == 1, 'Display changes triggered another decode' # Все переключения обязаны использовать результаты единственного анализа записи.
|
||
print(cache.strip()) # Отчёт показывает отдельный успех скрытия строки, неизменённого OK и изменения вида графиков.
|
||
finally:
|
||
if process.poll() is None:
|
||
process.terminate()
|
||
process.wait(timeout=5)
|
||
window.close()
|
||
print('Inspect three phase-shifted sine curves:', screenshot)
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|