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 '";
}).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 =
'№ ' +
'от ' +
'Итог: Прошли: 0' +
'Провалены: 0' +
'Заблокировано: 0';
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 += ""; });
return '";
}
function statusSelect(v) {
var opts = ["Открыта", "В работе", "Исправлена"];
var s = "";
opts.forEach(function (o) { s += ""; });
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;
});
}