smozhesh-sozdat-protokol-tes/script.js

493 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function attr(s) {
return h(s).replace(/"/g, "&quot;");
}
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 += '<tr><td class="lab"' + (r.w ? ' style="width:' + r.w + 'px"' : "") + ">" + h(r.l) + "</td><td>" +
'<span class="kt-ai-input" data-k="' + r.k + '" value="' + attr((info || {})[r.k] || "") + '" placeholder="' + h(r.ph) + '"></span></td></tr>';
});
return '<table class="doc-table"><tbody>' + trs + "</tbody></table>";
}
function stepsHTML(steps) {
steps = steps || [""];
if (!steps.length) steps = [""];
var s = "";
steps.forEach(function (st, i) {
s += '<li><span class="step-num">' + (i + 1) + '</span><span class="step-inner">' +
'<span class="kt-ai-input" data-kind="step" placeholder="Шаг ' + (i + 1) + '" value="' + attr(st) + '"></span>' +
'<button class="kt-ai-icon-button step-del" title="Удалить шаг"><svg width="12" height="12" aria-hidden="true"><use href="#x"></use></svg></button></span></li>';
});
return s;
}
function caseHTML(c) {
var st = c.status || "Не выполнен";
var opts = ["Не выполнен", "Пройден", "Провален", "Заблокирован"].map(function (o) {
return '<option' + (o === st ? " selected" : "") + ">" + o + "</option>";
}).join("");
return '<div class="test-block">' +
'<div class="test-block-head">' +
'<span class="num">00</span>' +
'<span class="kt-ai-input" data-kind="title" placeholder="Название проверки" value="' + attr(c.t) + '"></span>' +
'<select class="kt-ai-select" data-kind="status" title="Статус">' + opts + '</select>' +
'<button class="kt-ai-icon-button case-del" title="Удалить кейс"><svg width="14" height="14" aria-hidden="true"><use href="#trash"></use></svg></button>' +
"</div>" +
'<table>' +
'<tr><td class="lab">Предусловия</td><td>' +
'<span class="kt-ai-input" data-kind="pre" placeholder="Что должно быть готово до проверки" value="' + attr(c.pre) + '"></span></td></tr>' +
'<tr><td class="lab">Шаги</td><td><div class="steps-cell"><ol class="test-steps">' + stepsHTML(c.steps) +
'</ol><button class="kt-ai-btn step-add" data-variant="ghost" data-size="sm"><svg width="12" height="12" aria-hidden="true"><use href="#plus"></use></svg> Шаг</button></div></td></tr>' +
'<tr><td class="lab">Ожидаемый результат</td><td colspan="1">' +
'<span class="kt-ai-input" data-kind="expected" placeholder="Что должно произойти" value="' + attr(c.exp) + '"></span></td></tr>' +
'<tr><td class="lab">Фактический результат</td><td>' +
'<span class="kt-ai-input" data-kind="actual" placeholder="Что случилось на самом деле" value="' + attr(c.act) + '"></span></td></tr>' +
"</table></div>";
}
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 '<section class="doc-section" data-id="' + sec.id + '" style="' + (hidden ? "display:none" : "") + '">' +
'<div class="section-toggle"><button class="kt-ai-icon-button sec-toggle" title="Свернуть/развернуть"><svg width="14" height="14" aria-hidden="true"><use href="#chevronDown"></use></svg></button>' +
'<h2 class="doc-h2 sec-title">' + h(sec.title) + '</h2><span class="sec-count"></span></div>' +
'<div class="sec-cases">' + body + '</div></section>';
}).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 =
'<span>№ <span class="kt-ai-input" style="width:120px" id="f-number" value="' + attr(state.meta.number || "PT-NKT-001") + '"></span></span>' +
'<span>от <span class="kt-ai-input" style="width:104px" id="f-date" value="' + attr(state.meta.date || date) + '"></span></span>' +
'<span>Итог: <span class="kt-ai-chip stat-pill" id="c-pass" data-tone="green" hidden>Прошли: 0</span>' +
'<span class="kt-ai-chip stat-pill" id="c-fail" data-tone="red" hidden>Провалены: 0</span>' +
'<span class="kt-ai-chip stat-pill" id="c-block" data-tone="orange" hidden>Заблокировано: 0</span></span>';
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 '<tr><td class="issue-num">' + (idx + 1) + '</td>' +
'<td><span class="kt-ai-input" data-k="issue-desc" placeholder="Что не работает, как воспроизвести" value="' + attr(i.desc) + '"></span></td>' +
'<td>' + priSelect(i.pri) + '</td>' +
'<td>' + statusSelect(i.st) + '</td></tr>';
}).join("");
issuesBody.innerHTML = issues;
bindAll();
refresh();
});
function priSelect(v) {
var opts = ["—", "Критический", "Высокий", "Средний", "Низкий"];
var s = "";
opts.forEach(function (o) { s += "<option" + (o === v ? " selected" : "") + ">" + o + "</option>"; });
return '<select class="kt-ai-select"' + (v ? ' data-val="' + v + '"' : "") + ">" + s + "</select>";
}
function statusSelect(v) {
var opts = ["Открыта", "В работе", "Исправлена"];
var s = "";
opts.forEach(function (o) { s += "<option" + (o === v ? " selected" : "") + ">" + o + "</option>"; });
return '<select class="kt-ai-select"' + (v ? ' data-val="' + v + '"' : "") + ">" + s + "</select>";
}
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 = '<span class="step-num"></span><span class="step-inner"><span class="kt-ai-input" data-kind="step" placeholder="Новый шаг"></span><button class="kt-ai-icon-button step-del" title="Удалить шаг"><svg width="12" height="12" aria-hidden="true"><use href="#x"></use></svg></button></span>';
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",
'<tr><td class="issue-num"></td><td><span class="kt-ai-input" data-k="issue-desc" placeholder="Опишите проблему"></span></td><td>' + priSelect("") + '</td><td>' + statusSelect("") + "</td></tr>");
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;
});
}