Загрузка данных


сцен
                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",
]