Загрузка данных
сцен
f"chart_title: {parsed_report.chart_title_raw}\n"
f"embedded_chart_refs: "
f"{mt_report_utils.format_embedded_chart_formulas_for_allure(parsed_report.embedded_chart_formulas)}",
name="Шапка отчёта о режиме МТ",
attachment_type=allure.attachment_type.TEXT,
)
allure.attach(
mt_report_utils.format_mt_mode_section_rows_for_allure(parsed_report.section_rows),
name="Строки участков отчёта",
attachment_type=allure.attachment_type.TEXT,
)
with allure.step("Подготовка данных таблицы отчёта для проверки"):
parsed_report = report_state.actual_parsed_report
section_rows = parsed_report.section_rows
total_duration_seconds = parsed_report.total_duration_seconds
duration_tolerance = MtReportConst.TOTAL_DURATION_TOLERANCE_SECONDS
mode_totals = mt_report_utils.sum_duration_columns_across_rows(
section_rows,
MtReportConst.MODE_DURATION_COLUMNS,
)
with allure.step("Проверка содержимого таблицы отчёта о режиме МТ"):
StepCheck("Лист xlsx открыт", "worksheet").actual(report_state.actual_worksheet).is_not_none()
with SoftAssertions() as soft_failures:
StepCheck(
"Количество строк участков в отчёте",
"section_rows_count",
soft_failures,
).actual(len(section_rows)).expected(len(report_state.expected_section_names)).equal_to()
for section_index, expected_section_name in enumerate(report_state.expected_section_names):
actual_section_name = (
section_rows[section_index].section_name if section_index < len(section_rows) else None
)
StepCheck(
f"Наименование участка #{section_index + 1}",
MtReportConst.COL_SECTION,
soft_failures,
).actual(actual_section_name).expected(expected_section_name).equal_to()
for section_row in section_rows:
for column_name in MtReportConst.MODE_DURATION_COLUMNS:
cell_value = section_row.cells.get(column_name)
duration_text = (section_row.cells.get(column_name) or "").strip() or MtReportConst.ZERO_DURATION_TEXT
StepCheck(
f"Участок '{section_row.section_name}': колонка '{column_name}'",
"длительность",
soft_failures,
).actual(mt_report_utils.is_duration_cell_filled(cell_value)).is_true_with_details(
expected_text="указано время в формате H:MM:SS (допускается 0:00:00)",
actual_text=duration_text,
)
total_duration_text = (
parsed_report.total_duration_raw
or lds_report_utils.format_duration_seconds(total_duration_seconds or 0)
)
total_label_row = parsed_report.total_label_row_index
StepCheck(
"В отчёте присутствует строка 'Суммарное время работы:'",
"структура отчёта",
soft_failures,
).actual(total_label_row is not None).is_true_with_details(
expected_text=f"найдена строка с текстом '{MtReportConst.TOTAL_WORK_DURATION_LABEL}'",
actual_text=(
f"строка {total_label_row} содержит '{MtReportConst.TOTAL_WORK_DURATION_LABEL}'"
if total_label_row is not None
else "строка не найдена"
),
)
StepCheck(
"Суммарное время работы в отчёте больше нуля",
"суммарное время",
soft_failures,
).actual((total_duration_seconds or 0) > 0).is_true_with_details(
expected_text="суммарное время больше 0:00:00",
actual_text=total_duration_text,
)
for section_row in section_rows:
duration_diff = abs(section_row.modes_sum_seconds - (total_duration_seconds or 0))
section_sum_text = lds_report_utils.format_duration_seconds(section_row.modes_sum_seconds)
diff_text = lds_report_utils.format_duration_seconds(duration_diff)
sums_match = duration_diff < duration_tolerance + 1
StepCheck(
f"Участок '{section_row.section_name}': сумма режимов МТ совпадает с общим временем "
f"(±{duration_tolerance} с)",
"согласованность длительностей",
soft_failures,
).actual(sums_match).is_true_with_details(
expected_text=(
f"сумма режимов ({section_sum_text}) равна суммарному времени ({total_duration_text}), "
f"погрешность не более {duration_tolerance} с"
),
actual_text=(
f"сумма режимов: {section_sum_text}, суммарное время: {total_duration_text}, "
f"разница: {diff_text} ({duration_diff} с)"
),
)
dominant_column = report_state.expected_dominant_mode_column
dominant_total = mode_totals.get(dominant_column, 0)
max_column = max(mode_totals, key=mode_totals.get)
max_total = mode_totals[max_column]
all_modes_text = ", ".join(
f"'{column_name}': {lds_report_utils.format_duration_seconds(total_seconds)}"
for column_name, total_seconds in mode_totals.items()
)
StepCheck(
f"Режим '{dominant_column}' имеет максимальное суммарное время по всем участкам",
"доминирующий режим МТ",
soft_failures,
).actual(
mt_report_utils.is_expected_dominant_mode_column(mode_totals, dominant_column)
).is_true_with_details(
expected_text=(
f"'{dominant_column}': {lds_report_utils.format_duration_seconds(dominant_total)} - "
f"наибольшее время; по всем участкам: {all_modes_text}"
),
actual_text=(
f"наибольшее время у режима '{max_column}': "
f"{lds_report_utils.format_duration_seconds(max_total)}"
),
)
allure.attach(
"\n".join(
f"{column_name}: {lds_report_utils.format_duration_seconds(total_seconds)}"
for column_name, total_seconds in mode_totals.items()
),
name="Суммарные длительности режимов МТ по всем участкам",
attachment_type=allure.attachment_type.TEXT,
)
with allure.step("Проверка диаграммы режимов МТ"):
chart_refs_text = mt_report_utils.format_embedded_chart_formulas_for_allure(
parsed_report.embedded_chart_formulas
)
with SoftAssertions() as soft_failures:
StepCheck(
f"Заголовок диаграммы в ячейке F{MtReportConst.CHART_TITLE_ROW}",
"заголовок диаграммы",
soft_failures,
).actual(
mt_report_utils.is_chart_title_valid(
parsed_report.chart_title_raw,
cfg.technological_unit.description,
)
).is_true_with_details(
expected_text=(
f"содержит '{MtReportConst.CHART_TITLE_PREFIX}' "
f"и название ТУ '{cfg.technological_unit.description}'"
),
actual_text=parsed_report.chart_title_raw.replace("\n", " / ") or "(пусто)",
)
StepCheck(
f"Встроенная диаграмма построена по данным листа '{MtReportConst.CHART_DATA_SHEET_NAME}'",
"источник данных диаграммы",
soft_failures,
).actual(
mt_report_utils.is_valid_mt_mode_embedded_chart(parsed_report.embedded_chart_formulas)
).is_true_with_details(
expected_text=(
f"в xlsx есть встроенная диаграмма, серия которой ссылается на лист "
f"'{MtReportConst.CHART_DATA_SHEET_NAME}': категории {MtReportConst.CHART_CATEGORY_RANGE}, "
f"значения {MtReportConst.CHART_VALUES_RANGE}"
),
actual_text=chart_refs_text,
)
with allure.step("Подготовка данных шапки отчёта для проверки"):
тест
"6) Проверка xlsx: участки, длительности режимов МТ, суммарное время, доминирующий режим\n"
"7) Проверка встроенной диаграммы: заголовок в F2 и ссылки серии в XML диаграммы\n"
utils/helpers/mt_mode_report_xlsx_utils.py
"""
Утилиты для разбора xlsx-отчёта о режиме работы МТ.
"""
from __future__ import annotations
import xml.etree.ElementTree as ET
import zipfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional
from openpyxl.worksheet.worksheet import Worksheet
from constants.test_constants import ExportMtModeReportConstants as MtReportConst
from utils.helpers.lds_status_report_xlsx_utils import (
find_total_work_duration,
format_duration_seconds,
is_duration_cell_filled,
parse_duration_seconds,
)
from utils.helpers.report_xlsx_utils import (
ReportTitleInfo,
_stringify_cell,
build_column_cells,
get_report_column_headers,
parse_report_title,
read_worksheet_cell_value,
sum_duration_columns_across_rows,
)
@dataclass
class MtModeReportSectionRow:
"""Строка участка с длительностями режимов МТ."""
row_index: int
section_name: str
cells: Dict[str, str] = field(default_factory=dict)
@property
def mode_durations_seconds(self) -> Dict[str, int]:
"""Длительности режимов МТ в секундах по колонкам отчёта."""
return {
column_name: parse_duration_seconds(self.cells.get(column_name)) or 0
for column_name in MtReportConst.MODE_DURATION_COLUMNS
}
@property
def modes_sum_seconds(self) -> int:
"""Сумма длительностей всех режимов МТ для участка."""
return sum(self.mode_durations_seconds.values())
@dataclass
class MtModeReportParsed:
"""Разобранный отчёт о режиме работы МТ."""
title_info: ReportTitleInfo
column_headers: List[str]
section_rows: List[MtModeReportSectionRow]
total_duration_seconds: Optional[int] = None
total_duration_raw: str = ""
total_label_row_index: Optional[int] = None
chart_title_raw: str = ""
embedded_chart_formulas: List[str] = field(default_factory=list)
def _compact_chart_reference(reference: str) -> str:
"""Убирает пробелы и кавычки для сравнения ссылок вида 'Лист'!$A$1."""
return reference.replace("'", "").replace(" ", "").upper()
def extract_embedded_chart_formulas(source_file_path: Path) -> List[str]:
"""
Извлекает формулы ссылок (<c:f>) из XML встроенных диаграмм xlsx.
Серия диаграммы в Excel хранится не в ячейке, а в xl/charts/chart*.xml.
"""
if not source_file_path.exists():
return []
formulas: List[str] = []
try:
with zipfile.ZipFile(source_file_path, "r") as archive:
chart_files = sorted(
name
for name in archive.namelist()
if name.startswith("xl/charts/chart") and name.endswith(".xml")
)
for chart_file in chart_files:
root = ET.fromstring(archive.read(chart_file))
for element in root.iter():
tag_name = element.tag.rsplit("}", 1)[-1]
if tag_name == "f" and element.text:
formula = element.text.strip()
if formula:
formulas.append(formula)
except Exception:
return []
return formulas
def _chart_reference_matches(reference: str, *, sheet_name: str, range_address: str) -> bool:
compact_reference = _compact_chart_reference(reference)
compact_sheet = _compact_chart_reference(sheet_name)
compact_range = range_address.replace(" ", "").upper()
return compact_sheet in compact_reference and compact_range in compact_reference
def is_valid_mt_mode_embedded_chart(chart_formulas: List[str]) -> bool:
"""
Проверяет, что встроенная диаграмма ссылается на ожидаемые диапазоны листа данных.
Эквивалент формулы =РЯД('Режим работы МТ'!$I$3,'Режим работы МТ'!$B$2:$D$2,'Режим работы МТ'!$I$5:$L$5,1),
в OOXML ссылки хранятся отдельными элементами <c:f>.
"""
if not chart_formulas:
return False
sheet_name = MtReportConst.CHART_DATA_SHEET_NAME
has_category = any(
_chart_reference_matches(formula, sheet_name=sheet_name, range_address=MtReportConst.CHART_CATEGORY_RANGE)
for formula in chart_formulas
)
has_values = any(
_chart_reference_matches(formula, sheet_name=sheet_name, range_address=MtReportConst.CHART_VALUES_RANGE)
for formula in chart_formulas
)
return has_category and has_values
def format_embedded_chart_formulas_for_allure(chart_formulas: List[str]) -> str:
"""Форматирует ссылки встроенной диаграммы для отображения в Allure."""
if not chart_formulas:
return "встроенная диаграмма не найдена"
return "; ".join(chart_formulas)
def read_mt_mode_chart_title(source_file_path: Path) -> str:
"""Читает заголовок диаграммы из ячейки F2."""
chart_title_value = read_worksheet_cell_value(
source_file_path,
MtReportConst.CHART_TITLE_ROW,
MtReportConst.CHART_TITLE_COLUMN,
data_only=True,
)
return _stringify_cell(chart_title_value)
def is_chart_title_valid(chart_title_raw: str, tu_description: str) -> bool:
"""Проверяет заголовок диаграммы: префикс режима МТ и название ТУ."""
return MtReportConst.CHART_TITLE_PREFIX in chart_title_raw and tu_description in chart_title_raw
def is_expected_dominant_mode_column(mode_totals: Dict[str, int], expected_column: str) -> bool:
"""
Проверяет, что суммарное время ожидаемого режима строго максимально и больше нуля.
Не допускает «мягкую» проверку <=, чтобы не пропустить ничью с другим режимом.
"""
expected_total = mode_totals.get(expected_column, 0)
if expected_total <= 0:
return False
return expected_total == max(mode_totals.values())
def parse_mt_mode_report_worksheet(
worksheet: Worksheet,
expected_section_names: List[str],
*,
source_file_path: Optional[Path] = None,
) -> MtModeReportParsed:
"""
Разбирает лист xlsx-отчёта о режиме МТ: шапка, колонки, строки участков и суммарное время.
В section_rows попадают только участки из expected_section_names (без учёта регистра).
Метаданные диаграммы читаются из source_file_path: заголовок из F2, серия - из XML диаграммы.
"""
headers = get_report_column_headers(worksheet, MtReportConst.REPORT_COLUMN_HEADERS_ROW)
title_info = parse_report_title(
worksheet.cell(row=MtReportConst.REPORT_TITLE_ROW, column=1).value,
MtReportConst.REPORT_HEADER_PERIOD_PATTERN,
)
total_duration_seconds, total_duration_raw, total_label_row_index = find_total_work_duration(
worksheet,
data_first_row=MtReportConst.REPORT_DATA_FIRST_ROW,
total_work_duration_label=MtReportConst.TOTAL_WORK_DURATION_LABEL,
)
section_rows: List[MtModeReportSectionRow] = []
expected_names_lower = {name.lower() for name in expected_section_names}
for row_index, row_values in enumerate(
worksheet.iter_rows(
min_row=MtReportConst.REPORT_DATA_FIRST_ROW,
max_col=len(headers) if headers else 5,
values_only=True,
),
start=MtReportConst.REPORT_DATA_FIRST_ROW,
):
if total_label_row_index is not None and row_index >= total_label_row_index:
break
cells = build_column_cells(row_values, headers)
section_name = cells.get(MtReportConst.COL_SECTION, "").strip()
if not section_name:
continue
if section_name.lower() not in expected_names_lower:
continue
section_rows.append(
MtModeReportSectionRow(
row_index=row_index,
section_name=section_name,
cells=cells,
)
)
chart_title_raw = ""
embedded_chart_formulas: List[str] = []
if source_file_path is not None:
chart_title_raw = read_mt_mode_chart_title(source_file_path)
embedded_chart_formulas = extract_embedded_chart_formulas(source_file_path)
return MtModeReportParsed(
title_info=title_info,
column_headers=headers,
section_rows=section_rows,
total_duration_seconds=total_duration_seconds,
total_duration_raw=total_duration_raw,
total_label_row_index=total_label_row_index,
chart_title_raw=chart_title_raw,
embedded_chart_formulas=embedded_chart_formulas,
)
def format_mt_mode_section_rows_for_allure(section_rows: List[MtModeReportSectionRow]) -> str:
"""Форматирует строки участков отчёта о режиме МТ для вложения в Allure."""
lines = []
for row in section_rows:
durations_text = ", ".join(
f"{column}={format_duration_seconds(seconds)}"
for column, seconds in row.mode_durations_seconds.items()
)
lines.append(
f"row#{row.row_index}: {row.section_name} | sum={format_duration_seconds(row.modes_sum_seconds)} | "
f"{durations_text}"
)
return "\n".join(lines)
__all__ = [
"MtModeReportParsed",
"MtModeReportSectionRow",
"extract_embedded_chart_formulas",
"format_embedded_chart_formulas_for_allure",
"format_mt_mode_section_rows_for_allure",
"is_chart_title_valid",
"is_duration_cell_filled",
"is_expected_dominant_mode_column",
"is_valid_mt_mode_embedded_chart",
"parse_mt_mode_report_worksheet",
"read_mt_mode_chart_title",
"sum_duration_columns_across_rows",
]