var STORAGE_KEY = "nkt-test-protocol-v1"; var SHEETJS_SRC = "https://cdn.sheetjs.com/xlsx-0.20.2/package/dist/xlsx.full.min.js"; var SECTIONS = [ { id: "reg", title: "Регистрация", cases: [ { t: "Регистрация (ИП): новый пользователь", pre: "Доступ к тестовой среде сайта НКТ; чистый email, не зарегистрированный в системе", steps: ["Перейти на страницу регистрации", "Выбрать тип субъекта ИП; заполнить ИИН представителя организации, ИИН/БИН, наименование организации, имя, фамилию, номер телефона, email, пароль и подтвердить пароль", "Отметить «Согласен с условиями» и пройти проверку «Я не робот»", "Выполнить действие «Отправить код подтверждения»", "Ввести код подтверждения"], exp: "Новый пользователь ИП зарегистрирован (подтверждение по почте)" }, { t: "Регистрация (ИП): существующий email", pre: "Есть email, который уже зарегистрирован в системе", steps: ["Перейти на страницу регистрации", "Выбрать тип субъекта ИП и ввести обязательные данные, уже зарегистрированный email", "Отметить «Согласен с условиями» и пройти проверку «Я не робот»", "Выполнить действие «Отправить код подтверждения»", "Ввести код подтверждения"], exp: "Отображается ошибка «Этот email уже зарегистрирован»" }, { t: "Регистрация (ИП): повторные ИИН/БИН организации и ИИН представителя", pre: "В системе уже зарегистрированы значения ИИН/БИН организации и ИИН представителя", steps: ["Перейти на страницу регистрации", "Выбрать тип субъекта ИП и ввести обязательные данные с ранее зарегистрированными значениями полей ИИН/БИН организации и ИИН представителя организации", "Отметить «Согласен с условиями» и пройти проверку «Я не робот»", "Выполнить действие «Отправить код подтверждения»", "Ввести код подтверждения и выполнить действие «Подтвердить»"], exp: "Отображается ошибка «Представитель с таким ИИН уже зарегистрирован в данной организации»" }, { t: "Регистрация (ЮЛ): новый пользователь", pre: "Доступ к тестовой среде; чистый email организации", steps: ["Перейти на страницу регистрации", "Выбрать тип субъекта ЮЛ; заполнить ИИН представителя организации, ИИН/БИН, наименование организации, имя, фамилию, номер телефона, email, пароль и подтвердить пароль", "Отметить «Согласен с условиями» и пройти проверку «Я не робот»", "Выполнить действие «Отправить код подтверждения»", "Ввести полученный код подтверждения и выполнить действие «Подтвердить»"], exp: "Новый пользователь ЮЛ зарегистрирован (подтверждение по почте)" }, { t: "Регистрация (ЮЛ): повторные ИИН/БИН организации и ИИН представителя", pre: "В системе уже зарегистрированы значения ИИН/БИН организации и ИИН представителя", steps: ["Перейти на страницу регистрации", "Выбрать в поле «Тип субъекта» значение «Юридическое лицо»; заполнить ИИН представителя организации, ИИН/БИН, наименование организации, имя, фамилию, номер телефона, email, пароль и подтвердить пароль", "Отметить «Согласен с условиями» и пройти проверку «Я не робот»", "Выполнить действие «Отправить код подтверждения»", "Ввести полученный код подтверждения и выполнить действие «Подтвердить»"], exp: "Ошибка: значения ИИН/БИН организации и ИИН представителя уже зарегистрированы в системе" } ]}, { id: "auth", title: "Авторизация", cases: [ { t: "Авторизация: пользователь ИП с ЭЦП", pre: "Зарегистрированный пользователь ИП; его ЭЦП и пароль от неё", steps: ["Перейти на страницу авторизации", "Ввести логин и пароль", "Выполнить действие «Войти»", "В окне ЭЦП выбрать свой сертификат", "Ввести пароль от ЭЦП и выполнить действие «Войти» или «Подписать»"], exp: "Пользователь авторизован, зашёл в профиль" }, { t: "Авторизация (ИП): ЭЦП с другой почтой, чем в системе", pre: "Зарегистрированный пользователь ИП; его сертификат ЭЦП содержит другую почту, чем логин в системе", steps: ["Перейти на страницу авторизации", "Ввести логин и пароль", "Выполнить действие «Войти»", "В окне ЭЦП выбрать сертификат с другой почтой", "Ввести пароль от ЭЦП и выполнить действие «Войти» или «Подписать»"], exp: "Отображается ошибка «Пользователь сертификата не совпадает с введённым логином. Вход в личный кабинет не выполнен»" }, { t: "Авторизация (ИП): ЭЦП принадлежит ЮЛ", pre: "Зарегистрированный пользователь ИП; доступ к ЭЦП, выданной на юридическое лицо", steps: ["Перейти на страницу авторизации", "Ввести логин и пароль", "Выполнить действие «Войти»", "В окне ЭЦП выбрать сертификат ЭЦП юрлица", "Ввести пароль от ЭЦП и выполнить действие «Войти» или «Подписать»"], exp: "Вход не выполнен" }, { t: "Авторизация: пользователь ЮЛ с ЭЦП", pre: "Зарегистрированный пользователь ЮЛ; его сертификат ЭЦП на юрлицо", steps: ["Перейти на страницу авторизации", "Ввести логин и пароль", "Выполнить действие «Войти»", "В окне ЭЦП выбрать сертификат ЭЦП юрлица", "Ввести пароль от ЭЦП и выполнить действие «Войти» или «Подписать»"], exp: "Пользователь авторизован, зашёл в профиль" }, { t: "Авторизация (ЮЛ): ЭЦП принадлежит ИП", pre: "Зарегистрированный пользователь ЮЛ; доступ к сертификату ЭЦП, выданному индивидуальному предпринимателю", steps: ["Перейти на страницу авторизации", "Ввести логин и пароль", "Выполнить действие «Войти»", "В окне ЭЦП выбрать сертификат ЭЦП ИП", "Ввести пароль от ЭЦП и выполнить действие «Подписать»"], exp: "Вход не выполнен" } ]}, { id: "reset", title: "Восстановление пароля", cases: [ { t: "Восстановление пароля", pre: "Регистрированный аккаунт, доступ к email-ящику", steps: ["Перейти на страницу авторизации", "Выполнить действие «Забыли пароль?»", "Ввести почту в окне «Восстановление пароля»", "Выполнить действие «Отправить код»", "Получить код сброса на почту, вставить его в окно и нажать «Подтвердить»", "Отобразится окно «Создание нового пароля»: ввести новый пароль и его подтверждение", "Выполнить действие «Сохранить новый пароль»"], exp: "Пароль изменён, возврат на страницу авторизации; вход с новым паролем доступен" } ]}, { id: "adm", title: "Кабинет администратора", cases: [ { t: "Управление пользователями: роли и блокировки", pre: "Вход как администратор; несколько учётных записей в системе (активные и с правами)", steps: ["Открыть модуль управления пользователями", "Присвоить/сменить роль тестовой учётной записи", "Заблокировать учётную запись", "Разблокировать заблокированную учётную запись", "Выполнить сброс ЭЦП для тестовой учётной записи", "Проверить карточки с информацией об учётных записях"], exp: "Роли меняются, блокировки/разблокировки применяются, сброс ЭЦП выполняется, данные учётных записей отображаются корректно" }, { t: "Управление атрибутами: создание, редактирование, удаление", pre: "Вход как администратор; есть доступ к справочнику атрибутов", steps: ["Создать новый атрибут", "Отредактировать созданный атрибут", "Выполнить логическое удаление атрибута", "Проверить, что атрибут скрыт из активного использования, но сохранён в системе"], exp: "Операции выполняются, удалённый атрибут недоступен для выбора, но остаётся в базе" }, { t: "Управление составными атрибутами: создание, редактирование, удаление", pre: "Вход как администратор; есть доступ к справочнику составных атрибутов", steps: ["Создать составной атрибут", "Отредактировать составной атрибут", "Выполнить логическое удаление составного атрибута", "Проверить состояние атрибута в справочнике"], exp: "Операции выполняются, удаляется логически, данные не пропадают" }, { t: "Привязка расширенных атрибутов к категориям", pre: "Вход как администратор; созданы категории товаров и расширенные атрибуты", steps: ["Выбрать категорию товара", "Привязать к ней расширенные атрибуты", "Сохранить привязку", "Открыть карточку товара в этой категории и проверить атрибуты"], exp: "Привязка сохраняется; атрибуты видны в карточках товаров выбранной категории" } ]}, { id: "user", title: "Кабинет пользователя", cases: [ { t: "Профиль: подтверждение ЭЦП", pre: "Вход в кабинет пользователя; сертификат ЭЦП на пользователя", steps: ["Открыть профиль", "Подтвердить ЭЦП"], exp: "ЭЦП подтверждена, статус в профиле обновлён" }, { t: "Заявки: просмотр, создание, редактирование, удаление", pre: "Вход в кабинет пользователя", steps: ["Открыть список моих заявок", "Создать новую заявку (через карточку)", "Отредактировать заявку", "Удалить заявку"], exp: "Заявки создаются, редактируются и удаляются; списки обновляются" }, { t: "Заявки на редактирование: карточка, модерация, публикация изменений", pre: "Вход в кабинет пользователя; есть действующая карточка товара", steps: ["Создать заявку на редактирование через карточку товара", "Отправить заявку на модерацию", "Учесть результат модерации", "Опубликовать изменения по карточке"], exp: "Изменения проходят модерацию и публикуются в карточке" }, { t: "Создание похожего товара на основе действующего", pre: "В кабинете пользователя есть действующий товар", steps: ["Открыть действующую карточку товара", "Выбрать действие «Похожий товар»", "Заполнить отличающиеся поля", "Сохранить заявку"], exp: "Создана заявка с предзаполненными полями исходного товара" }, { t: "Мои товары и мои заявки: просмотр", pre: "Опубликовано несколько товаров; созданы заявки", steps: ["Открыть «Мои товары»", "Открыть «Мои заявки»", "Открыть детали позиции"], exp: "Списки корректны, данные совпадают с созданным" }, { t: "Массовая загрузка заявок и работа с шаблоном", pre: "Готов шаблон загрузки (Excel) по формату сайта", steps: ["Скачать/открыть шаблон", "Заполнить шаблон данными", "Выполнить массовую загрузку", "Проверить созданные заявки"], exp: "Загрузка принята; заявки и ошибки отображаются корректно" }, { t: "Моя номенклатура: вкладки Избранное", pre: "Есть товары, добавленные и не добавленные в избранное", steps: ["Добавить товар в «Моя номенклатура» (вкладка Избранное)", "Снять товар с избранного", "Проверить вкладку Избранное"], exp: "Добавление/снятие работают, вкладка обновляется" } ]}, { id: "mod", title: "Кабинет модератора", cases: [ { t: "Модерация заявок: принятие, согласование, отклонение, возврат", pre: "В очереди есть заявки разных статусов", steps: ["Принять заявку в работу", "Согласовать заявку", "Отклонить заявку с комментарием", "Вернуть заявку на доработку", "Проверить статусы в кабинете пользователя"], exp: "Статусы корректно меняются, пользователь видит изменения" }, { t: "Пакетная модерация заявок", pre: "В очереди несколько заявок, подлежащих одному решению", steps: ["Выбрать несколько заявок", "Применить пакетное действие (согласовать/отклонить)", "Проверить статусы каждой выбранной заявки"], exp: "Действие применяется ко всем выбранным, статусы обновляются" } ]}, { id: "main", title: "Главный экран", cases: [ { t: "Поиск товаров", pre: "Опубликованы товары", steps: ["Ввести поисковый запрос по названию/характеристике", "Открыть результат из выдачи"], exp: "Выдача релевантна запросу, карточки открываются" }, { t: "Просмотр информации о проекте", pre: "Главный экран сайта", steps: ["Открыть раздел «О проекте» (ссылка из шапки/подвала)"], exp: "Раздел открывается, информация читается" }, { t: "Просмотр карточки товара", pre: "Опубликованы товары с составными атрибутами", steps: ["Открыть карточку товара из списка", "Проверить фото, составные атрибуты, описание"], exp: "Карточка отображается полностью, атрибуты соответствуют привязкам" }, { t: "Умный фильтр по товарам", pre: "Созданы атрибуты, привязанные к категориям", steps: ["Открыть умный фильтр", "Выбрать несколько значений атрибутов", "Применить фильтр", "Открыть карточку из отфильтрованного списка"], exp: "Фильтрация работает, комбинация значений даёт корректный список" } ]} ]; function h(s) { s = String(s == null ? "" : s); return s.replace(/&/g, "&").replace(//g, ">"); } function attr(s) { return h(s).replace(/"/g, """); } function pad2(n) { return String(n).padStart(2, "0"); } function todayStr() { var d = new Date(); return pad2(d.getDate()) + "." + pad2(d.getMonth() + 1) + "." + d.getFullYear(); } function fmtDate(iso) { if (!iso) return ""; var p = iso.split("T"); return p[0].split("-").reverse().join("."); } function buildInfoRows() { return [ { k: "what", l: "Что тестируется", ph: "Название продукта, версия" }, { k: "goal", l: "Цель тестирования", ph: "Например: приёмка версии 1.0 сайта НКТ" }, { k: "dev", l: "Разработал", ph: "Имя или роль" }, { k: "tester", l: "Исполнитель тестов", ph: "Кто выполняет проверки" }, { k: "date", l: "Дата проведения", ph: "ДД.ММ.ГГГГ", w: 200 } ]; } function infoTableHTML(rows, info) { var trs = ""; rows.forEach(function (r) { trs += '" + h(r.l) + "" + ''; }); return '' + trs + "
"; } function stepsHTML(steps) { steps = steps || [""]; if (!steps.length) steps = [""]; var s = ""; steps.forEach(function (st, i) { s += '
  • ' + (i + 1) + '' + '' + '
  • '; }); return s; } function caseHTML(c) { var st = c.status || "Не выполнен"; var opts = ["Не выполнен", "Пройден", "Провален", "Заблокирован"].map(function (o) { return '" + o + ""; }).join(""); return '
    ' + '
    ' + '00' + '' + '' + '' + "
    " + '' + '' + '' + '' + '' + "
    Предусловия' + '
    Шаги
      ' + stepsHTML(c.steps) + '
    Ожидаемый результат' + '
    Фактический результат' + '
    "; } function sectionsHTML(state, seed) { return SECTIONS.map(function (sec) { var key = "sec-" + sec.id; var cases = (state && state.sections && state.sections[key]) || seed; var hidden = state && state.hidden && state.hidden[key]; var body = cases.map(caseHTML).join(""); return '
    ' + '
    ' + '

    ' + h(sec.title) + '

    ' + '
    ' + body + '
    '; }).join(""); } document.addEventListener("DOMContentLoaded", function () { var seed = SECTIONS.map(function (s) { return s.cases; }); var state = null; var raw = null; try { raw = localStorage.getItem(STORAGE_KEY); } catch (e) {} if (raw) { try { state = JSON.parse(raw); } catch (e) { state = null; } } if (!state || state.v !== 2) { state = { v: 2, hidden: {}, sections: {}, info: {}, meta: { number: "PT-NKT-001", date: todayStr() }, issues: [{}] }; } if (!state.sections || !Object.keys(state.sections).length) { state.sections = {}; SECTIONS.forEach(function (s, i) { state.sections["sec-" + s.id] = seed[i]; }); } if (!state.info) state.info = {}; var banner = document.getElementById("banner-meta"); var date = state.meta.date || todayStr(); banner.innerHTML = '№ ' + 'от ' + 'Итог: ' + '' + ''; var info = infoTableHTML(buildInfoRows(), state.info); document.getElementById("info-mount").innerHTML = info; var envMount = document.getElementById("env-mount"); envMount.innerHTML = infoTableHTML([ { k: "env", l: "Окружение / адрес", ph: "Например: site.nkt.kz (тестовый стенд)" }, { k: "device", l: "Устройство", ph: "ПК, смартфон, планшет" }, { k: "browser", l: "Браузер / канал связи", ph: "Chrome 126, 4G, Wi-Fi" }, { k: "data", l: "Тестовые данные", ph: "Тестовые аккаунты ИП/ЮЛ, сертификаты ЭЦП" } ], state.info); document.getElementById("content-mount").innerHTML = sectionsHTML(state, null); var issuesBody = document.querySelector("#issues-body"); var issues = (state.issues || [{}]).map(function (i, idx) { return '' + (idx + 1) + '' + '' + '' + priSelect(i.pri) + '' + '' + statusSelect(i.st) + ''; }).join(""); issuesBody.innerHTML = issues; bindAll(); refresh(); }); function priSelect(v) { var opts = ["—", "Критический", "Высокий", "Средний", "Низкий"]; var s = ""; opts.forEach(function (o) { s += "" + o + ""; }); return '"; } function statusSelect(v) { var opts = ["Открыта", "В работе", "Исправлена"]; var s = ""; opts.forEach(function (o) { s += "" + o + ""; }); return '"; } function refresh() { var counts = { p: 0, f: 0, b: 0, n: 0 }; var total = 0; document.querySelectorAll("#content-mount .test-block").forEach(function (b) { total += 1; var sel = b.querySelector('[data-kind="status"]'); var st = sel ? sel.value : "Не выполнен"; if (st === "Пройден") counts.p += 1; else if (st === "Провален") counts.f += 1; else if (st === "Заблокирован") counts.b += 1; else counts.n += 1; }); document.querySelectorAll("#content-mount .doc-section").forEach(function (s) { var n = s.querySelectorAll(".test-block").length; var pill = s.querySelector(".sec-count"); if (pill) pill.textContent = n + " кейсов"; }); document.querySelectorAll("#content-mount .doc-section").forEach(function (sec) { var list = sec.querySelectorAll(".test-block .num"); list.forEach(function (n, i) { n.textContent = pad2(i + 1); }); }); var setT = function (id, v) { var e = document.getElementById(id); if (e) e.textContent = String(v); }; setT("s-total", total); setT("s-pass", counts.p); setT("s-fail", counts.f); setT("s-block", counts.b); var cP = document.getElementById("c-pass"); var cF = document.getElementById("c-fail"); var cB = document.getElementById("c-block"); if (cP) { cP.textContent = "Прошли: " + counts.p; cP.hidden = counts.p === 0; } if (cF) { cF.textContent = "Провалены: " + counts.f; cF.hidden = counts.f === 0; } if (cB) { cB.textContent = "Заблокировано: " + counts.b; cB.hidden = counts.b === 0; } var verdict = document.getElementById("p-verdict"); var done = total - counts.n; var v, tone; if (total === 0) { v = "Нет кейсов"; tone = "gray"; } else if (done === 0) { v = "Не начат"; tone = "gray"; } else if (counts.f > 0) { v = "Есть проблемы"; tone = "red"; } else if (counts.b > 0) { v = "Есть блокировки"; tone = "orange"; } else if (counts.n > 0) { v = "В процессе"; tone = "blue"; } else { v = "Все проверки пройдены"; tone = "green"; } verdict.textContent = v; verdict.setAttribute("data-tone", tone); var donePct = total ? Math.round((done / total) * 100) : 0; document.getElementById("s-done").textContent = done + " / " + total + " (" + donePct + "%)"; } var saveTimer = null; function save() { clearTimeout(saveTimer); saveTimer = setTimeout(function () { try { var st = { v: 2 }; st.meta = { number: document.getElementById("f-number") && document.getElementById("f-number").value, date: document.getElementById("f-date") && document.getElementById("f-date").value }; st.info = {}; document.querySelectorAll(".kt-ai-input[data-k]").forEach(function (el) { st.info[el.dataset.k] = el.value; }); st.sections = {}; st.hidden = {}; document.querySelectorAll("#content-mount .doc-section").forEach(function (sec) { var key = "sec-" + sec.dataset.id; st.hidden[key] = sec.style.display === "none" || sec.classList.contains("collapsed"); st.sections[key] = Array.prototype.map.call(sec.querySelectorAll(".test-block"), function (b) { return { t: (b.querySelector('[data-kind="title"]').value || ""), status: b.querySelector('[data-kind="status"]').value, pre: b.querySelector('[data-kind="pre"]').value || "", steps: Array.prototype.map.call(b.querySelectorAll('[data-kind="step"]'), function (s) { return s.value; }), exp: b.querySelector('[data-kind="expected"]').value || "", act: b.querySelector('[data-kind="actual"]').value || "" }; }); }); st.issues = Array.prototype.map.call(document.querySelectorAll("#issues-body tr"), function (tr) { return { desc: tr.querySelector('[data-k="issue-desc"]').value || "", pri: (tr.querySelectorAll("select")[0] || {}).value || "", st: (tr.querySelectorAll("select")[1] || {}).value || "" }; }); localStorage.setItem(STORAGE_KEY, JSON.stringify(st)); } catch (e) {} }, 300); } function loadSheetJS() { return new Promise(function (res) { if (window.XLSX) return res(); var s = document.createElement("script"); s.src = SHEETJS_SRC; s.onload = function () { res(); }; s.onerror = function () { window.alert("Не удалось загрузить библиотеку Excel — проверьте интернет"); res(); }; document.head.appendChild(s); }); } function downloadExcel() { loadSheetJS().then(function () { if (window.XLSX) downloadExcelInner(); }); } function downloadExcelInner() { if (!window.XLSX) { alert("Не удалось загрузить библиотеку Excel — проверьте интернет"); return; } var wb = XLSX.utils.book_new(); var infoRows = [["Поле", "Значение"]]; document.querySelectorAll("#info-mount tr").forEach(function (tr) { var td = tr.querySelectorAll("td"); if (td.length >= 2) infoRows.push([td[0].textContent.trim(), (td[1].querySelector(".kt-ai-input") || {}).value || ""]); }); infoRows.push(["Дата документа", document.getElementById("f-date").value]); infoRows.push(["Номер документа", document.getElementById("f-number").value]); XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(infoRows), "Информация"); var header = ["№", "Раздел", "Тест-кейс", "Предусловия", "Шаги", "Ожидаемый результат", "Фактический результат", "Статус"]; var rows = [header]; var n = 0; document.querySelectorAll("#content-mount .doc-section").forEach(function (sec) { var title = sec.querySelector(".sec-title").textContent.trim(); sec.querySelectorAll(".test-block").forEach(function (b) { n += 1; rows.push([ n, title, b.querySelector('[data-kind="title"]').value || "", b.querySelector('[data-kind="pre"]').value || "", Array.prototype.map.call(b.querySelectorAll('[data-kind="step"]'), function (s) { return s.value; }).filter(Boolean).join("\n"), b.querySelector('[data-kind="expected"]').value || "", b.querySelector('[data-kind="actual"]').value || "", b.querySelector('[data-kind="status"]').value ]); }); }); var ws = XLSX.utils.aoa_to_sheet(rows); ws["!cols"] = [{ wch: 4 }, { wch: 22 }, { wch: 38 }, { wch: 40 }, { wch: 70 }, { wch: 42 }, { wch: 42 }, { wch: 14 }]; XLSX.utils.book_append_sheet(wb, ws, "Тест-кейсы"); var irows = [["№", "Описание проблемы", "Приоритет", "Статус"]]; document.querySelectorAll("#issues-body tr").forEach(function (tr, i) { var desc = tr.querySelector('[data-k="issue-desc"]').value; if (desc) irows.push([i + 1, desc, (tr.querySelectorAll("select")[0] || {}).value || "", (tr.querySelectorAll("select")[1] || {}).value || ""]); }); if (irows.length === 1) irows.push(["—", "Проблем не обнаружено", "", ""]); var iws = XLSX.utils.aoa_to_sheet(irows); iws["!cols"] = [{ wch: 4 }, { wch: 70 }, { wch: 14 }, { wch: 14 }]; XLSX.utils.book_append_sheet(wb, iws, "Проблемы"); var name = (document.getElementById("f-number").value || "protocol").replace(/[\\/:*?"<>|]/g, "_"); XLSX.writeFile(wb, name + ".xlsx"); } function downloadJSON() { var data = { meta: { number: document.getElementById("f-number").value, date: document.getElementById("f-date").value }, info: {}, sections: [], issues: [] }; document.querySelectorAll(".kt-ai-input[data-k]").forEach(function (el) { data.info[el.dataset.k] = el.value; }); document.querySelectorAll("#content-mount .doc-section").forEach(function (sec) { var cases = Array.from(sec.querySelectorAll(".test-block")).map(function (b) { return { title: b.querySelector('[data-kind="title"]').value, status: b.querySelector('[data-kind="status"]').value, pre: b.querySelector('[data-kind="pre"]').value, steps: Array.from(b.querySelectorAll('[data-kind="step"]')).map(function (s) { return s.value; }), expected: b.querySelector('[data-kind="expected"]').value, actual: b.querySelector('[data-kind="actual"]').value }; }); data.sections.push({ name: sec.querySelector(".sec-title").textContent.trim(), cases: cases }); }); document.querySelectorAll("#issues-body tr").forEach(function (tr) { var desc = tr.querySelector('[data-k="issue-desc"]').value; if (desc) data.issues.push({ desc: desc, pri: (tr.querySelectorAll("select")[0] || {}).value || "", st: (tr.querySelectorAll("select")[1] || {}).value || "" }); }); var blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); var a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "test-protocol.json"; document.body.appendChild(a); a.click(); a.remove(); setTimeout(function () { URL.revokeObjectURL(a.href); }, 5000); } function bindAll() { document.addEventListener("click", function (e) { var t = e.target; if (t && t.closest && t.closest("svg")) t = t.closest("button"); if (!t || !t.classList) return; if (t.classList.contains("case-del")) { var block = t.closest(".test-block"); block.remove(); refresh(); save(); } else if (t.classList.contains("step-del")) { var li = t.closest("li"); var ol = li.parentNode; if (ol.children.length > 1) { li.remove(); numberSteps(ol); save(); } } else if (t.classList.contains("step-add")) { var ol2 = t.closest(".steps-cell").querySelector(".test-steps"); var li2 = document.createElement("li"); li2.innerHTML = ''; ol2.appendChild(li2); numberSteps(ol2); var inp = li2.querySelector(".kt-ai-input"); if (inp) inp.focus(); save(); } else if (t.classList.contains("sec-toggle")) { var sec = t.closest(".doc-section"); sec.classList.toggle("collapsed"); save(); } else if (t.classList.contains("issue-add")) { document.querySelector("#issues-body").insertAdjacentHTML("beforeend", '' + priSelect("") + '' + statusSelect("") + ""); renumberIssues(); save(); } else if (t.classList.contains("issue-del")) { var row = t.closest("tr"); var body = document.querySelector("#issues-body"); if (body.children.length > 1) { row.remove(); renumberIssues(); save(); } } else if (t.id === "btn-add") { var last = document.querySelector("#content-mount .doc-section:last-child"); last.insertAdjacentHTML("beforeend", caseHTML({ t: "", status: "Не выполнен", pre: "", steps: [""], exp: "", act: "" })); refresh(); save(); last.querySelector(".test-block:last-child").scrollIntoView({ behavior: "smooth", block: "center" }); } else if (t.id === "btn-print") { window.print(); } else if (t.id === "btn-xlsx") { downloadExcel(); } else if (t.id === "btn-json") { downloadJSON(); } else if (t.id === "btn-clear") { if (!confirm("Очистить весь протокол и начать заново? Сохранённый черновик тоже будет удалён.")) return; try { localStorage.removeItem(STORAGE_KEY); } catch (e) {} location.reload(); } }); document.addEventListener("input", function (e) { var el = e.target; if (!el) return; var isInfoInput = el.classList && el.classList.contains("kt-ai-input"); if (!isInfoInput && el.id !== "f-number" && el.id !== "f-date") return; if (el.dataset && el.dataset.kind === "step") numberSteps(el.closest(".test-steps")); save(); }); document.addEventListener("change", function (e) { if (e.target.matches(".kt-ai-select[data-kind='status']")) refresh(); if (e.target.matches("select, input")) save(); }); } function numberSteps(ol) { if (!ol) return; ol.querySelectorAll(".step-num").forEach(function (n, i) { n.textContent = i + 1; }); } function renumberIssues() { document.querySelectorAll("#issues-body tr").forEach(function (tr, i) { tr.querySelector(".issue-num").textContent = i + 1; }); }