diff --git a/docs/manutenzioni-test-plan.md b/docs/manutenzioni-test-plan.md new file mode 100644 index 0000000..3f9fea9 --- /dev/null +++ b/docs/manutenzioni-test-plan.md @@ -0,0 +1,275 @@ +# Manutenzioni — тест-план + +Полный список проверок. **Бо́льшая часть автоматизирована** — руками остаётся только то, что действительно требует глаз (список ниже). + +--- + +## Автопрогон — одна команда + +```bash +make test # всё: 20 юнитов + 68 API-проверок + 69 браузерных тестов +``` + +По отдельности (`make` без аргументов покажет весь список): + +```bash +make test-unit # логика расписания, без базы и сервера (0.02 с) +make test-api # логин, ajax, БД, файлы, права — 68 проверок +make test-e2e # браузер на 1440 px — 69 тестов +make test-sizes # полный прогон на каждом размере: 1440 / 820 / 390 px +make test-e2e-ui # интерактивный режим, видно каждый шаг +``` + +### Прогон по размерам экрана + +`make test-sizes` гоняет **всю** сюиту трижды — на PC (1440), планшете (820) и телефоне (390): + +| Размер | Тестов | Что проверяется отдельно | +|---|---|---| +| desktop 1440 | 69 | + вёрстка на трёх ширинах и сквозной обход | +| tablet 820 | 56 | целевое устройство по ТЗ | +| mobile 390 | 56 | карточки вместо таблиц, полноэкранные модалки | + +Итого 181 браузерная проверка. Тесты написаны без привязки к вёрстке: хелпер `listItem()` берёт строку таблицы или карточку — смотря что видно на этой ширине. + +## Видео вместо кликанья + +```bash +make demo # ~50 с: сквозной обход модуля → tests/e2e/video/demo.mp4 +make video # весь прогон, склеенный по устройствам → tests/e2e/video/ +make video-sizes # то же для каждого размера → tests/e2e/video/<формат>/ +``` + +`make demo` прогоняет `tour.spec.js` — один непрерывный сценарий: реестр и фильтры → импортированная карточка `mod. 6.3-1` → создание оборудования → добавление ТО → регистрация работы с подписью мышью → пересчёт срока → история → печатная форма → оба календаря → категории → телефон и планшет. Это полноценный тест: если что-то сломано, он упадёт. + +`make video` записывает все 69 тестов и склеивает их в пять файлов (`ffmpeg`, склейка по разрешениям — иначе кадры разного размера не соединить): + +| Файл | Что внутри | Длительность | +|---|---|---| +| `demo.mp4` | сквозной обход | ~50 с | +| `suite-funzionali.mp4` | реестр, ТО, интервенции, календари, печать, права | ~70 с | +| `suite-desktop.mp4` | вёрстка 1440 px | ~15 с | +| `suite-tablet.mp4` | вёрстка 820 px + подпись пальцем | ~35 с | +| `suite-mobile.mp4` | вёрстка 390 px | ~15 с | + +Прогоны API и браузерный делают настоящий логин, работают с живой страницей и сверяют результат в базе. Заводят только данные с префиксом `ZZTEST_` / `ZZE2E_` и **удаляют их за собой**, в том числе при падении. Оба возвращают ненулевой код при ошибке — годятся для CI. + +Полезное для e2e: + +```bash +npm run test:e2e:ui # интерактивный режим: видно каждый шаг +npm run test:e2e:headed # с открытым браузером +npx playwright show-trace tests/e2e/.artifacts/<папка>/trace.zip # разбор падения +``` + +**Скриншоты.** Каждый прогон e2e складывает в `tests/e2e/screenshots/` по 5 картинок на устройство (390 / 820 / 1440 px): реестр, список ТО, карточка, календарь, модалка интервенции, плюс `tablet-signature.png` с реальным росчерком пальцем. Проще пролистать их, чем кликать вручную. + +**Что закрыто автоматикой:** разделы 1, 2, 3, 5, 6, 7, 8, 9 (включая генерацию PDF), 10, 11, 12 (запуск без ошибок). + +## Что смотреть глазами (короткий список) + +Осталось немного — то, что машина проверить не может в принципе: + +| Что | Пункты | +|---|---| +| Субъективное качество вёрстки — по скриншотам из `tests/e2e/screenshots/` | 2.1, 11.1–11.4 | +| Как выглядит PDF после реальной печати из браузера | 9.4, 9.5 | +| **Реальная доставка писем** на живой ящик | 12.2, 12.4 | +| Импортированные из XLS данные — выборочная сверка с их файлами | 13.2–13.7 | +| Подпись пальцем на **настоящем** планшете (в браузере эмулируется, но железо стоит потрогать) | 6.6 | + +Ниже — полный список, если хочется пройти всё подряд руками. + +--- + +## 0. Подготовка + +- База: `http://localhost:8001/zibo-dashboard/public` (контейнеры `zibo-app` + `zibo-db`, база на порту 3308). + Если не подняты: `docker compose up -d` (порт 3307 может быть занят — тогда нужен override на 3308). +- Логин под админом (роль Admin, id 1). В форме логина поле называется **username**, туда e-mail. +- Дев-база уже наполнена импортом: **101 единица оборудования, 126 ТО, 20 подрядчиков, 5 категорий**. +- Всё меню модуля: сайдбар → **Manutenzioni**. + +--- + +## 1. Ничего старого не сломалось (сначала это!) + +Переименование `production_tools` → `inv_equipment` затронуло модуль производства. + +- [ ] **1.1** `userarea/production_tools.php` — открывается по прежнему адресу, в списке видны `Taglierina` (×2), `Marcatura Laser`, `Coestrusore diametro 70` **и** оборудование из импорта. +- [ ] **1.2** Там же: кнопка «Aggiungi Strumento» — создать тестовый инструмент. Сохраняется, появляется в списке. +- [ ] **1.3** Он же появился в новом реестре (`Manutenzioni → Registro Attrezzature`) в категории **Attrezzature**. +- [ ] **1.4** Редактирование инструмента на старой странице: в поле «Stato» теперь три варианта (Attivo / Fuori servizio / Dismesso). Меняем на «Fuori servizio» → сохранилось. +- [ ] **1.5** Кнопка удаления на старой странице — раньше **всегда падала** с «Invalid ID.», теперь помечает как «Dismesso». Проверить на тестовом инструменте. +- [ ] **1.6** `userarea/skills.php` — открывается, выпадающий список «Attrezzature» заполнен. +- [ ] **1.7** `userarea/production_line_view2.php` — открывается, колонка с инструментами у записей производства не пустая и не сломана. +- [ ] **1.8** `userarea/production_dashboard.php` — открывается, кнопка «Attrezzature» ведёт куда вела. + +--- + +## 2. Реестр оборудования + +`Manutenzioni → Registro Attrezzature` + +- [ ] **2.1** Список открывается, у части позиций видна обложка-заглушка (шестерёнка), у категорий свои цветные точки. +- [ ] **2.2** Поиск «CARRELLO» → находит погрузчики. +- [ ] **2.3** Фильтр по категории **Edifici e impianti** → ~24 позиции (здания, противопожарные двери, чиллеры, котёл). +- [ ] **2.4** Фильтр по линии **Linea 1 — UHF** → 10 позиций, все с «LINEA 1» в названии. +- [ ] **2.5** Фильтр по статусу «Dismesso» → пусто (или только то, что сам пометил в п.1.4). +- [ ] **2.6** Кнопка «Azzera» сбрасывает все фильтры. +- [ ] **2.7** «Nuova attrezzatura» → заполнить имя + категорию **Attrezzature** → сохраняется, появляется в списке. +- [ ] **2.8** В той же форме выбрать категорию **Linea di produzione** → появляется обязательное поле «Linea di produzione». Попытка сохранить без линии → предупреждение. +- [ ] **2.9** Редактирование (карандаш) — форма открывается **с заполненными** полями, изменения сохраняются. +- [ ] **2.10** Удаление (корзина) своей тестовой записи → удаляется после подтверждения. +- [ ] **2.11** Удаление `Taglierina` (id 1) → **отказ**: «используется в 2 записях производства, пометьте как Dismesso». + +--- + +## 3. Категории + +`Manutenzioni → Categorie` + +- [ ] **3.1** Пять категорий: Linea di produzione, Edifici e impianti, Attrezzature, Accessori, Qualità. У «Linea di produzione» стоит галка «Richiede linea». +- [ ] **3.2** Создать категорию «Test QA» с цветом и порядком → появилась. +- [ ] **3.3** Создать вторую с тем же именем → отказ «Esiste già una categoria con questo nome». +- [ ] **3.4** Отредактировать «Test QA», поставить «Richiede una linea di produzione» → сохранилось; в форме оборудования при выборе этой категории появляется поле линии. +- [ ] **3.5** Удалить «Test QA» (пустую) → удаляется. +- [ ] **3.6** Удалить «Attrezzature» → **отказ**: используется N оборудованием. + +--- + +## 4. Карточка оборудования, фото и документы + +Открыть `CARRELLO ELEVATORE CAR.MI. MOD. PL416` (из реестра). + +- [ ] **4.1** Слева блок «Dati»: матрикола `55551002N5069`, производитель `CAR.MI.`, дата ввода в эксплуатацию 01/01/2002. +- [ ] **4.2** Справа 8 работ с кодами **A–H**, частоты: A — «Ogni 2 settimane», B–E — «Ogni 3 mesi», F,G — «Ogni 6 mesi», H — «Ogni 1 anno». +- [ ] **4.3** Загрузить фото (кнопка «+» в блоке «Foto») → появилось в галерее и **сразу стало обложкой** (рамка), обложка видна в списке реестра. +- [ ] **4.4** Загрузить второе фото → обложка не поменялась. Нажать на нём звёздочку → стало обложкой. +- [ ] **4.5** Загрузить документ PDF в блок «Documenti» → появился, открывается по клику в новой вкладке. +- [ ] **4.6** Попробовать загрузить `.php` файл → отказ «Tipo di file non consentito». +- [ ] **4.7** Скопировать ссылку на фото, открыть в **приватном окне** (без сессии) → 401, файл не отдаётся. +- [ ] **4.8** Удалить фото и документ → исчезают. + +--- + +## 5. Манутенции (ТО) + +- [ ] **5.1** «Nuova manutenzione» на карточке: код `Z`, название, тип **Programmata**, исполнение **Interna**, частота «Ogni 2 mesi», преавизо 15, исполнитель и ответственный → сохранилось, видно в списке. +- [ ] **5.2** Исполнение **Esterna** → появляется обязательный «Fornitore esterno». Сохранить без него → предупреждение. +- [ ] **5.3** Частота **«Al bisogno»** → поле «Ogni» прячется; сохранённая запись показывает «Al bisogno», даты «Prossimo» нет. +- [ ] **5.4** Поле «Nota sulla frequenza»: вписать «prima dell'accensione» → текст виден в строке ТО после частоты. +- [ ] **5.5** Галка «Manutenzione critica» → красная плашка «Critica» и красная полоса слева. +- [ ] **5.6** Галка «Attiva» снята → строка тускнеет, плашка «Disattivata»; такое ТО пропадает из общего списка `Manutenzioni`. +- [ ] **5.7** Приложить файл-инструкцию в форме ТО → после сохранения счётчик «N istruzioni» в строке; при повторном открытии формы файл в списке, скачивается, удаляется. +- [ ] **5.8** Удалить своё тестовое ТО → предупреждение о числе интервентов, после подтверждения исчезает. +- [ ] **5.9** Открыть `CHILLER UFFICI E POMPA DI CALORE ELCO` → 4 работы A–D **без даты** (сезонные), в примечании частоты полный текст «prima dell'accensione / durante il periodo estivo… / dopo lo spegnimento». Плюс одна внешняя от `Piacenza Impianti`. +- [ ] **5.10** Открыть `EVAPORATORE SOTTOVUOTO…` → **три** ТО: внутреннее + два внешних, `Ecotechno` и `Ecogenerazione` (не должны схлопнуться в одно). + +--- + +## 6. Регистрация интервенций и пересчёт срока + +Это ядро модуля — проверить внимательно. Взять любое ТО с частотой (например CARMI, код B — «Ogni 3 mesi»). + +- [ ] **6.1** Кнопка «✓» → открывается форма, дата подставлена сегодняшняя, вверху написано, к какому оборудованию и ТО относится. +- [ ] **6.2** Поставить дату **три месяца назад**, статус **Completato**, эсито **Conforme**, оператора, заметку, материалы → сохранить. +- [ ] **6.3** В строке ТО: «Ultimo» = введённая дата, «Prossimo» = она же + 3 месяца, плашка состояния пересчиталась. +- [ ] **6.4** Кнопка «Storico interventi (N)» разворачивает таймлайн, там видна запись с оператором, эсито и заметками. +- [ ] **6.5** **Подпись:** в форме интервенции расписаться мышью в поле подписи → после сохранения миниатюра подписи видна в таймлайне и в печатной форме. +- [ ] **6.6** На планшете/телефоне: подпись рисуется пальцем, страница при этом не скроллится. +- [ ] **6.7** «Cancella firma» очищает поле; при редактировании существующей интервенции стирает и сохранённую подпись. +- [ ] **6.8** Добавить интервенцию в статусе **Pianificato** на будущую дату → «Prossimo» **не изменился** (двигают только завершённые). +- [ ] **6.9** Приложить файл к интервенции (модуль подрядчика) → счётчик скрепки в таймлайне, файл открывается. +- [ ] **6.10** Удалить последнюю завершённую интервенцию → «Prossimo» откатился на предыдущую дату (или опустел, если она была единственной). +- [ ] **6.11** Редактирование интервенции (карандаш в таймлайне) → поля подставлены, изменение даты пересчитывает срок. + +--- + +## 7. Общий список ТО + +`Manutenzioni → Manutenzioni` + +- [ ] **7.1** Четыре плитки сверху: Scadute / In scadenza / In regola / Al bisogno, сумма ≈ числу активных ТО активного оборудования. +- [ ] **7.2** Клик по плитке «Scadute» фильтрует список; повторный клик снимает фильтр. +- [ ] **7.3** Фильтр «Solo programmata» / «Solo straordinaria» работает. +- [ ] **7.4** Галка «Solo critiche» → остаются только с красной плашкой. +- [ ] **7.5** Галка «Assegnate a me» → остаются ТО, где ты исполнитель или ответственный (нужно, чтобы твой `auth_users` был связан с `employees.auth_user_id`; если связи нет — список пустой, это ожидаемо). +- [ ] **7.6** Сортировка: просроченные и ближайшие сверху, «Al bisogno» — в конце. +- [ ] **7.7** Кнопка «✓» прямо из списка открывает ту же форму интервенции. + +--- + +## 8. Календари + +- [ ] **8.1** `Manutenzioni → Calendario`: события на датах, цвет по состоянию (красный — просрочено, жёлтый — скоро, иначе цвет категории). +- [ ] **8.2** Клик по событию → открывается карточка нужного оборудования. +- [ ] **8.3** Фильтры «тип» и «категория» перерисовывают календарь. +- [ ] **8.4** На телефоне календарь открывается сразу в виде списка (listMonth), не ломается по ширине. +- [ ] **8.5** `Manutenzioni → Calendario Cumulativo`: три переключателя — Scadenze 📅, Formazione 🎓, Manutenzioni 🔧. +- [ ] **8.6** Выключение слоя убирает его события, включение возвращает. +- [ ] **8.7** Клик по событию scadenza ведёт в scadenzario, по formazione — в trainings, по manutenzione — в карточку оборудования. +- [ ] **8.8** Выключить все три слоя → календарь пуст, ошибок нет. + +--- + +## 9. Печать / PDF + +- [ ] **9.1** На карточке кнопка «Scheda PDF» → открывается вёрстка их бланка: шапка «SCHEDA MANUTENZIONE», справа «ZIBOGOMMA / mod. 6.3-1», строка «MACCHINA:». +- [ ] **9.2** Таблица «Interventi previsti» — коды, описания, частоты, исполнение (внутр./фирма). +- [ ] **9.3** Таблица «Registro interventi eseguiti» — дата, код, описание, оператор, картинка подписи. +- [ ] **9.4** Ctrl/Cmd+P → в предпросмотре печати кнопки скрыты, таблицы не рвутся по строкам, шапка таблицы повторяется на второй странице. +- [ ] **9.5** Сохранить как PDF — файл читаемый. + +--- + +## 10. Права (важно — проверяется на бэке, не только в меню) + +Понадобится второй пользователь с ролью без прав `maintenance.*` (например `employee`). + +- [ ] **10.1** Под таким пользователем пункт «Manutenzioni» в меню не виден. +- [ ] **10.2** Прямой заход на `userarea/manutenzioni/index.php` → «Permesso negato», а не страница. +- [ ] **10.3** Выдать в «Autorizzazioni» роли только `maintenance.equipment.view` → реестр открывается, но **кнопок создания/редактирования нет**, блок «Manutenzioni» на карточке пишет, что прав нет. +- [ ] **10.4** Добавить `maintenance.manage` → кнопки появились, создание работает. + +--- + +## 11. Мобильный и планшет + +Проверять на реальном планшете или в devtools (375px и 768px). + +- [ ] **11.1** 375px: реестр показан **карточками**, не таблицей; ничего не уезжает за край, горизонтального скролла нет. +- [ ] **11.2** 375px: список ТО — карточками; плитки-счётчики по две в ряд. +- [ ] **11.3** Модалки открываются на весь экран, кнопки внизу доступны. +- [ ] **11.4** 768px (планшет — целевое устройство по ТЗ): таблицы читаемы, сайдбар по умолчанию скрыт, гамбургер работает. +- [ ] **11.5** Форма интервенции на планшете: подпись пальцем, все поля доступны без зума. + +--- + +## 12. Уведомления (cron) + +- [ ] **12.1** Поставить у ТО исполнителя, у которого `employees.auth_user_id` связан с реальным почтовым ящиком, и дату «Prossimo» в прошлом. +- [ ] **12.2** Запустить: `docker exec zibo-app php /var/www/zibo-dashboard/public/userarea/manutenzioni/cron/send_notifications.php` + → в консоли «Inviate: N», письмо приходит исполнителю **и** ответственному. +- [ ] **12.3** Запустить второй раз в тот же день → «saltate», повторных писем нет. +- [ ] **12.4** В письме: тема с названием оборудования, дата, для критичного ТО — пометка, кнопка ведёт на карточку. +- [ ] **12.5** ТО с частотой «Al bisogno» писем не порождает. + +--- + +## 13. Импортированные данные (выборочно, глазами) + +- [ ] **13.1** Всего в реестре ~101 позиция; фильтр по категориям даёт ~74 Attrezzature, ~24 Edifici e impianti, 3 Linee. +- [ ] **13.2** У `FABBRICATI` — 8 работ уборки от `PULINET di Angelini Bassano` (giornaliero / settimanale / mensile / trimestrale / al bisogno). +- [ ] **13.3** У позиций с «LINEA 1/2/3» в названии проставлена соответствующая линия (в карточке строка «Linea»). +- [ ] **13.4** Исполнители расставлены: Alfonso, Gabriele, Junior, Marco, Ester, Marina. +- [ ] **13.5** У `DERATTIZZAZIONE CON ESCHE DOMESTICHE` исполнитель пуст, а в заметках ТО написано «Incaricato da XLS: M.GRANATA» — это фирма, записанная в колонке внутренних работ; надо решить, переводить ли её в подрядчики. +- [ ] **13.6** В заметках оборудования сохранены имена их бумажных карточек («Schede originali: …»). +- [ ] **13.7** **Ожидаемо и требует решения клиента:** у 110 из 125 ТО частота «Al bisogno» — в сводном XLS частот попросту нет, они лежат в отдельных карточках `mod. 6.3-1`, а прислали три из 57. + +--- + +## Чего в модуле нет намеренно + +- Калибровка/поверка приборов — вынесена за скоуп самим клиентом. Структура данных её примет отдельной таблицей без переделок. +- Импорт истории выполненных работ из старых карточек — журналы в присланных файлах пустые. diff --git a/docs/manutenzioni-tracciabilita.md b/docs/manutenzioni-tracciabilita.md new file mode 100644 index 0000000..6a442c9 --- /dev/null +++ b/docs/manutenzioni-tracciabilita.md @@ -0,0 +1,105 @@ +# Manutenzioni — трассируемость: ТЗ → тесты + +Каждый пункт исходного документа клиента (`Manutenzioni - ACS.pdf`) и то, чем он подтверждён. +Условные обозначения: **UI** — браузерный тест (Playwright, гоняется на 1440/820/390), **API** — `tests/manutenzioni/api_test.php`, **U** — юнит-тест PHPUnit. + +--- + +## 1. Реестр оборудования + +| Пункт ТЗ | Чем подтверждён | Тест | +|---|---|---| +| Единая база оборудования, у каждой позиции категория | UI | `§1.2 le quattro categorie del capitolato esistono e sono gestibili` | +| Категории: Linea di produzione, Attrezzature, Accessori, Qualità | UI | там же | +| Категории **не хардкод**, свой CRUD | UI + API | `Categories › CRUD from the modal, and categories in use cannot be deleted`; API: создание, дубль имени, запрет удаления занятой | +| Для категории «линия» обязательно указать линию | UI + API | `§1.5 il campo «linea» compare solo per le categorie che lo richiedono`; API: отказ без линии | +| Фильтрация по категории / линии / отделу | UI | `§1.5 the registry filters by production line and by department`; `Equipment registry › the filters really narrow the list` | +| Перенос существующих `production_lines` и `production_tools` | UI + API | `§1.6 le linee e gli attrezzi già esistenti sono nel registro unico`; API: старые страницы производства работают после переименования | +| Поля: название, серийник, партия, дата покупки, дата ввода, производитель, тип, ubicazione, отдел, заметки | UI | `§1.7 tutti i campi dell'anagrafica si salvano e si rivedono` — сохранение, возврат в форму и отображение в карточке | +| Статус (attivo / fuori servizio / dismesso) исключает неактивное из планировочных вью | UI | `§1.7 un'attrezzatura non attiva sparisce dalle viste di pianificazione` — проверяет и список ТО, и календарь | +| Вложения: файлы-инструкции и мануалы | UI + API | `Photos, cover image and documents › documents upload and stay downloadable`; отказ на недопустимый тип | +| Обложка + дополнительные фото | UI | `la prima foto caricata diventa copertina, anche nel registro`; `la stellina sposta la copertina sulla seconda foto`; `la foto si elimina previa conferma` | +| Калибровка — вне скоупа | — | намеренно не реализована; отдельной таблицы нет | + +## 2. Модуль обслуживания + +| Пункт ТЗ | Чем подтверждён | Тест | +|---|---|---| +| 0..N обслуживаний на единицу; 0 = вне планирования | UI | `§2.1 un'attrezzatura senza manutenzioni resta fuori dalla pianificazione` | +| Несколько видов ТО одновременно | UI | `§2.2 la stessa attrezzatura porta più manutenzioni insieme` (три работы на одной машине) | +| Типы: программное и внеплановое | UI + API | `«solo critiche» e «programmate/straordinarie» restringono l'elenco`; `riaprendo una manutenzione i campi sono quelli salvati` | +| Флаг критичности | UI | `una manutenzione critica è evidenziata nella scheda` | +| Внешние исполнители: указать подрядчика | UI + API | `«esterna» chiede il fornitore e blocca il salvataggio senza` | +| Приложить форму подрядчика | UI | `un allegato dell'intervento compare nello storico` | +| Название/описание | UI | `riaprendo una manutenzione i campi sono quelli salvati` | +| Частота через выпадающий список интервалов | UI + U | `«al bisogno» nasconde il campo della frequenza`; `la nota di frequenza resta visibile`; юниты на все единицы интервала | +| Файлы-инструкции к ТО | UI | `le istruzioni allegate a una manutenzione si vedono, si riaprono e si eliminano` | +| Срок предупреждения | UI | `riaprendo una manutenzione i campi sono quelli salvati` (alert_days) | +| Один исполнитель + один ответственный | UI | `§2.6 incaricato e responsabile si salvano e si leggono nella scheda` | +| Следующий срок = от последней выполненной работы | UI + U | `registrare un intervento completato aggiorna le date a schermo`; `un intervento pianificato non sposta la scadenza`; `eliminando l'ultimo intervento completato la scadenza torna indietro`; 20 юнитов на арифметику дат | + +## 3. Журнал интервенций + +| Пункт ТЗ | Чем подтверждён | Тест | +|---|---|---| +| Привязка к конкретному ТО | UI | `la registrazione rapida dall'elenco aggiorna la scadenza` (контекст в модалке) | +| Дата | UI | `registrare un intervento completato aggiorna le date a schermo` | +| Заметки | UI | `riaprendo un intervento i campi sono quelli salvati` | +| Оператор + подпись | UI | `la firma disegnata finisce nello storico`; `la firma si cancella con il pulsante dedicato`; `si firma col dito e la pagina non scorre sotto il tratto` (планшет/телефон) | +| Статус (pianificato / in corso / completato) | UI | `un intervento pianificato non sposta la scadenza` | +| Результат (conforme / bloccato …) | UI | `riaprendo un intervento i campi sono quelli salvati` | +| Материалы / замененные части | UI | там же | +| Для внешних: подрядчик и его форма | UI | `un allegato dell'intervento compare nello storico` | + +## 4. Фильтры и вью + +| Пункт ТЗ | Чем подтверждён | Тест | +|---|---|---| +| Фильтр между программными и внеплановыми | UI | `«solo critiche» e «programmate/straordinarie» restringono l'elenco` | +| Календарь ТО по срокам | UI | `il calendario disegna gli eventi e il click porta alla scheda`; `i filtri del calendario rifanno la richiesta` | + +## 5. Выгрузка PDF + +| Пункт ТЗ | Чем подтверждён | Тест | +|---|---|---| +| Карточка с историей обслуживания по единице | UI | `la scheda riproduce il modulo cartaceo` (шапка mod. 6.3-1, работы, журнал) | +| Собственно PDF | UI | `in stampa la barra dei pulsanti sparisce e il PDF si genera` | + +## 6. Уведомления + +| Пункт ТЗ | Чем подтверждён | Тест | +|---|---|---| +| Письма о просрочке и заранее | API | cron запускается без ошибок; выборка находит просроченное ТО | +| Получатели: ответственный **и** исполнитель | — | **браузером не проверяется**: письма уходят из cron, а не из UI. Логика получателей сверена запросом; фактическая доставка — ручной пункт 12 тест-плана | + +## 7. Сводный календарь + +| Пункт ТЗ | Чем подтверждён | Тест | +|---|---|---| +| Scadenze + formazione + manutenzioni в одном календаре | UI | `ogni strato porta i propri eventi`; `il calendario cumulativo accende e spegne gli strati` | +| Разные цветные бейджи по типу срока | UI | `§7 i tre tipi di scadenza hanno colori distinti` (свой цвет и свой значок у каждого слоя) | +| Переход из календаря в нужный модуль | UI | `dal calendario cumulativo si arriva alla scheda dell'attrezzatura` | + +## 8. Техтребования + +| Пункт ТЗ | Чем подтверждён | Тест | +|---|---|---| +| Работа на PC и планшете → адаптивность | UI × 3 | весь набор гоняется на 1440 / 820 / 390; `Layout › …` (нет горизонтальной прокрутки, карточки против таблицы, модалка достижима, сайдбар не прыгает) | + +--- + +## Что осталось за пределами автотестов + +| Пункт | Почему | Где проверять | +|---|---|---| +| Фактическая доставка писем | нужен живой SMTP и ящик | ручной чеклист, раздел 12 | +| Внешний вид PDF после печати из браузера | субъективно | ручной чеклист, разделы 9.4–9.5 | +| Подпись на физическом планшете | эмуляция ≠ железо | ручной чеклист, 6.6 | +| Соответствие импортированных данных их XLS | сверка с бумагой | ручной чеклист, раздел 13 | + +## Как перепроверить + +```bash +make test-sizes # весь набор на трёх размерах +npx playwright test tests/e2e/capitolato.spec.js # только пункты ТЗ +``` diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..7ebbe93 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,82 @@ +// @ts-check +const { defineConfig, devices } = require('@playwright/test') + +/** + * E2E suite for the Manutenzioni (maintenance) module. + * + * Covers what the PHP tests cannot see: modals, canvas signature, calendars, + * printing and the layout on tablet/phone. + * + * Prerequisite: the project containers are up + * docker compose up -d + * Base URL can be overridden with MNT_BASE_URL. + */ +// The trailing slash is mandatory: without it the relative paths used by the +// tests would resolve against the domain root instead of the app. +const baseURL = (process.env.MNT_BASE_URL || 'http://localhost:8001/zibo-dashboard/public').replace(/\/?$/, '/') + +/** Viewports under test: phone, tablet (the reference device), desktop. */ +const SIZES = { + desktop: { width: 1440, height: 900 }, + tablet: { width: 820, height: 1180 }, + mobile: { width: 390, height: 844 }, +} + +const SIZE = SIZES[process.env.MNT_SIZE] ? process.env.MNT_SIZE : 'desktop' + +module.exports = defineConfig({ + testDir: './tests/e2e', + outputDir: './tests/e2e/.artifacts', + globalSetup: require.resolve('./tests/e2e/global-setup'), + globalTeardown: require.resolve('./tests/e2e/global-teardown'), + + // the tests write to the same fixture data: no parallelism + workers: 1, + fullyParallel: false, + + // Video recording slows every page down noticeably: with the same limits the + // longer tests would time out without anything actually being broken. + timeout: process.env.MNT_VIDEO ? 90_000 : 45_000, + expect: { timeout: process.env.MNT_VIDEO ? 20_000 : 10_000 }, + + // The HTML report moves attachments into hash-named folders: handy to browse, + // but it makes grouping the videos per device impossible. With MNT_VIDEO we + // therefore keep the plain reporter and leave the files in .artifacts. + reporter: process.env.MNT_VIDEO + // The JSON feeds the stitching step: it maps every video to the exact test + // title, which then gets burned into the caption bar. + ? [['list'], ['json', { outputFile: 'tests/e2e/.report/results.json' }]] + : process.env.CI + ? [['list'], ['html', { open: 'never', outputFolder: 'tests/e2e/.report' }]] + : [['list']], + + use: { + baseURL, + storageState: 'tests/e2e/.auth/state.json', + locale: 'it-IT', + screenshot: 'only-on-failure', + trace: process.env.MNT_VIDEO ? 'on' : 'retain-on-failure', + + // By default the video is kept only for failing tests: with MNT_VIDEO=1 + // (npm run test:e2e:video) everything is recorded, one .webm per test, + // handy to show the module running without clicking through it. + video: process.env.MNT_VIDEO + ? { mode: 'on', size: { width: 1280, height: 720 } } + : 'retain-on-failure', + }, + + // One viewport per invocation: the tests create data under fixed names, and + // running them in parallel across viewports would make them collide on the + // same database. The loop over the three viewports lives in the Makefile + // (make test-sizes). + projects: [ + { + name: SIZE, + use: { + ...devices['Desktop Chrome'], + viewport: SIZES[SIZE], + hasTouch: SIZE !== 'desktop', + }, + }, + ], +}) diff --git a/tests/e2e/__pycache__/stitch.cpython-314.pyc b/tests/e2e/__pycache__/stitch.cpython-314.pyc new file mode 100644 index 0000000..ca0dfb5 Binary files /dev/null and b/tests/e2e/__pycache__/stitch.cpython-314.pyc differ diff --git a/tests/e2e/calendar.spec.js b/tests/e2e/calendar.spec.js new file mode 100644 index 0000000..9a0c316 --- /dev/null +++ b/tests/e2e/calendar.spec.js @@ -0,0 +1,97 @@ +const { test, expect } = require('@playwright/test') +const { CALENDAR, GLOBAL_CALENDAR, createEquipment, createMaintenance, name } = require('./helpers/fixtures') +const { sql } = require('./helpers/env') + +test.describe('Calendars', () => { + let equipmentId + const title = name('Manutenzione a calendario') + + test.beforeAll(async ({ browser }) => { + const page = await browser.newPage() + equipmentId = await createEquipment(page, name('Macchina calendario')) + await createMaintenance(page, equipmentId, { code: 'K', title, months: 6 }) + await page.close() + + // a fixed due date in the future, so the event lands in a known month + sql(`UPDATE maint_maintenances SET last_done_date = '2026-09-15', next_due_date = '2026-09-15' + WHERE equipment_id = ${equipmentId}`) + }) + + test('the calendar draws the events and clicking one opens the equipment page', async ({ page }) => { + await page.goto(`${CALENDAR}?_=1`) + await page.locator('.fc-view-harness').waitFor() + + // page through to September 2026 (the toolbar title is localised in Italian) + await page.locator('.fc-toolbar-title').waitFor() + for (let i = 0; i < 24; i++) { + const title = await page.locator('.fc-toolbar-title').textContent() + if (/settembre 2026/i.test(title)) break + await page.locator('.fc-next-button').click() + await page.waitForTimeout(120) + } + + const event = page.locator('.fc-event', { hasText: name('Macchina calendario') }).first() + await expect(event).toBeVisible() + + await event.click() + await page.waitForURL(/equipment\.php\?id=\d+/) + await expect(page.getByRole('heading', { name: new RegExp(name('Macchina calendario')) })).toBeVisible() + }) + + test('the calendar filters re-issue the request', async ({ page }) => { + await page.goto(CALENDAR) + await page.locator('.fc-view-harness').waitFor() + + const [request] = await Promise.all([ + page.waitForRequest((r) => r.url().includes('get_calendar_events.php') && r.url().includes('type=extraordinary')), + page.selectOption('#filterType', 'extraordinary'), + ]) + + expect(request.url()).toContain('type=extraordinary') + }) + + test('the combined calendar switches layers on and off', async ({ page }) => { + const errors = [] + page.on('pageerror', (e) => errors.push(e.message)) + + await page.goto(GLOBAL_CALENDAR) + await page.locator('.fc-view-harness').waitFor() + + const toggles = page.locator('.mnt-layer-toggle') + await expect(toggles).toHaveCount(3) + await expect(page.locator('.mnt-layer-toggle.is-on')).toHaveCount(3) + + // the checkbox is hidden inside the label: click the label + const maintenanceToggle = page.locator('.mnt-layer-toggle', { hasText: 'Manutenzioni' }) + await maintenanceToggle.click() + await expect(maintenanceToggle).not.toHaveClass(/is-on/) + + // with every layer off the calendar stays empty, and no JS error is raised + for (const layer of ['Scadenze', 'Formazione']) { + await page.locator('.mnt-layer-toggle', { hasText: layer }).click() + } + + await expect(page.locator('.fc-event')).toHaveCount(0) + expect(errors).toEqual([]) + }) + + test('every layer brings its own events', async ({ page }) => { + const countEvents = async (layers) => { + const response = await page.request.get( + `userarea/manutenzioni/ajax/get_global_calendar_events.php?start=2026-01-01&end=2027-12-31&layers=${layers}` + ) + return (await response.json()).length + } + + await page.goto(GLOBAL_CALENDAR) + + const all = await countEvents('deadlines,trainings,maintenances') + const onlyMaintenances = await countEvents('maintenances') + const onlyDeadlines = await countEvents('deadlines') + + expect(onlyMaintenances).toBeGreaterThan(0) + expect(onlyDeadlines).toBeGreaterThan(0) + expect(all).toBeGreaterThan(onlyMaintenances) + expect(all).toBeGreaterThan(onlyDeadlines) + }) +}) diff --git a/tests/e2e/capitolato.spec.js b/tests/e2e/capitolato.spec.js new file mode 100644 index 0000000..9e5ee1e --- /dev/null +++ b/tests/e2e/capitolato.spec.js @@ -0,0 +1,243 @@ +const { test, expect } = require('@playwright/test') +const { + REGISTRY, MAINTENANCES, CATEGORIES, GLOBAL_CALENDAR, + createEquipment, createMaintenance, submitAndReload, waitForModal, listItem, name, +} = require('./helpers/fixtures') +const { sql, scalar } = require('./helpers/env') + +/** + * Checks the client's specification (Manutenzioni - ACS.pdf) point by point. + * The titles quote the paragraph number, so traceability stays readable without + * an external table: docs/manutenzioni-tracciabilita.md points here. + */ + +test.describe('§1 Equipment registry', () => { + test('§1.2 the four categories of the spec exist and are manageable', async ({ page }) => { + await page.goto(CATEGORIES) + + for (const category of ['Linea di produzione', 'Attrezzature', 'Accessori', 'Qualità']) { + await expect(page.locator('#categoryList')).toContainText(category) + } + + // «not hard-coded»: the page has its own CRUD + await expect(page.locator('#btnAddCategory')).toBeVisible() + await expect(listItem(page, 'Linea di produzione').locator('.btn-edit')).toBeVisible() + }) + + test('§1.6 the existing production lines and tools are in the single registry', async ({ page }) => { + const lines = sql("SELECT name FROM production_lines ORDER BY line_number").split('\n').filter(Boolean) + test.skip(!lines.length, 'no production line in this database') + + await page.goto(`${REGISTRY}?status=all`) + + for (const line of lines) { + const row = listItem(page, line) + await expect(row, `line ${line} is not in the registry`).toBeVisible() + await expect(row).toContainText('Linea di produzione') + } + }) + + test('§1.5 the registry filters by production line and by department', async ({ page }) => { + const lineId = scalar('SELECT id FROM production_lines ORDER BY line_number LIMIT 1') + const lineName = scalar(`SELECT name FROM production_lines WHERE id = ${lineId}`) + + await page.goto(`${REGISTRY}?line=${lineId}&status=all`) + await expect(listItem(page, lineName)).toBeVisible() + + // every listed row really belongs to that line + const shown = Number(scalar(`SELECT COUNT(*) FROM inv_equipment WHERE line_id = ${lineId}`)) + const rows = await page.locator('#equipmentList tbody tr, #equipmentList .mnt-item-card') + .filter({ visible: true }).count() + expect(rows).toBe(shown) + + // department: a non-existent value must leave no rows behind + await page.goto(`${REGISTRY}?department=999999&status=all`) + await expect(page.locator('.empty-state')).toBeVisible() + }) + + test('§1.7 every registry field is saved and shown again', async ({ page }) => { + const label = name('Anagrafica completa') + + await page.goto(REGISTRY) + await page.click('#btnAddEquipment') + await waitForModal(page, '#equipmentModal') + + const values = { + '#eqName': label, + '#eqRegistration': 'MATR-4711', + '#eqSerial': 'SN-99-ABC', + '#eqBatch': 'LOTTO-2024-07', + '#eqManufacturer': 'Colmec', + '#eqPurchase': '2024-03-15', + '#eqCommissioning': '2024-04-01', + '#eqType': 'Estrusore', + '#eqLocation': 'Soppalco linea 2', + '#eqDescription': 'Descrizione della macchina.', + '#eqNotes': 'Nota interna di servizio.', + } + + await page.selectOption('#eqCategory', { label: 'Attrezzature' }) + for (const [selector, value] of Object.entries(values)) { + await page.fill(selector, value) + } + await submitAndReload(page, '#equipmentForm button[type="submit"]') + + // 1. the values come back in the modal + await page.goto(`${REGISTRY}?q=${encodeURIComponent(label)}`) + await listItem(page, label).locator('.btn-edit').click() + await waitForModal(page, '#equipmentModal') + + for (const [selector, value] of Object.entries(values)) { + await expect(page.locator(selector), `field ${selector}`).toHaveValue(value) + } + + // 2. and they are readable on the detail page, dates in Italian format + const id = scalar(`SELECT id FROM inv_equipment WHERE name = '${label}'`) + await page.goto(`userarea/manutenzioni/equipment.php?id=${id}`) + + for (const text of [ + 'MATR-4711', 'SN-99-ABC', 'LOTTO-2024-07', 'Colmec', + '15/03/2024', '01/04/2024', 'Soppalco linea 2', + 'Descrizione della macchina.', 'Nota interna di servizio.', + ]) { + await expect(page.locator('.page-content'), `the detail page does not show ${text}`).toContainText(text) + } + }) + + test('§1.7 a decommissioned machine disappears from the planning views', async ({ page }) => { + const label = name('Da dismettere') + const title = name('Lavoro su dismessa') + + const id = await createEquipment(page, label) + await createMaintenance(page, id, { code: 'D', title, months: 3 }) + sql(`UPDATE maint_maintenances SET last_done_date = CURDATE(), next_due_date = DATE_ADD(CURDATE(), INTERVAL 3 MONTH) + WHERE equipment_id = ${id}`) + + await page.goto(MAINTENANCES) + await expect(page.locator('#maintenanceList')).toContainText(title) + + // it is taken out of service from the registry modal + await page.goto(`${REGISTRY}?q=${encodeURIComponent(label)}`) + await listItem(page, label).locator('.btn-edit').click() + await waitForModal(page, '#equipmentModal') + await page.selectOption('#eqStatus', 'decommissioned') + await submitAndReload(page, '#equipmentForm button[type="submit"]') + + await page.goto(MAINTENANCES) + await expect(page.locator('.mnt-card .card-body')).not.toContainText(title) + + const events = await (await page.request.get( + 'userarea/manutenzioni/ajax/get_calendar_events.php?start=2020-01-01&end=2030-12-31' + )).json() + expect(events.some((e) => String(e.title).includes(title))).toBe(false) + }) +}) + +test.describe('§2 Maintenance tasks', () => { + test('§2.1 equipment without maintenance tasks stays out of the planning', async ({ page }) => { + const label = name('Senza manutenzioni') + const id = await createEquipment(page, label) + + await page.goto(`userarea/manutenzioni/equipment.php?id=${id}`) + await expect(page.locator('.empty-state')).toContainText('Nessuna manutenzione definita') + await expect(page.locator('.empty-state')).toContainText('esclusa dalle pianificazioni') + + await page.goto(MAINTENANCES) + await expect(page.locator('.mnt-card .card-body')).not.toContainText(label) + }) + + test('§2.2 one machine carries several maintenance tasks at once', async ({ page }) => { + const label = name('Con tre lavori') + const id = await createEquipment(page, label) + + const titles = [name('Lavoro A'), name('Lavoro B'), name('Lavoro C')] + await createMaintenance(page, id, { code: 'A', title: titles[0], months: 1 }) + await createMaintenance(page, id, { code: 'B', title: titles[1], months: 6 }) + await createMaintenance(page, id, { code: 'C', title: titles[2], months: 12 }) + + await page.goto(`userarea/manutenzioni/equipment.php?id=${id}`) + for (const title of titles) { + await expect(page.locator('#maintenanceList')).toContainText(title) + } + await expect(page.locator('#maintenanceList .mnt-item-card')).toHaveCount(3) + + // and the registry counts three of them + await page.goto(`${REGISTRY}?q=${encodeURIComponent(label)}`) + await expect(listItem(page, label)).toContainText('3') + }) + + test('§2.6 assignee and supervisor are saved and shown on the detail page', async ({ page }) => { + const label = name('Con incaricati') + const title = name('Lavoro con incaricati') + const id = await createEquipment(page, label) + + await page.goto(`userarea/manutenzioni/equipment.php?id=${id}`) + await page.click('#btnAddMaintenance') + await waitForModal(page, '#maintenanceModal') + + await page.fill('#mtTitle', title) + + // two different people from the employee registry + const people = await page.locator('#mtAssignee option').allTextContents() + const [assignee, supervisor] = people.filter((p) => p.trim() && p.trim() !== '—').slice(0, 2) + test.skip(!supervisor, 'at least two active employees are required') + + await page.selectOption('#mtAssignee', { label: assignee }) + await page.selectOption('#mtSupervisor', { label: supervisor }) + await submitAndReload(page, '#maintenanceForm button[type="submit"]') + + const card = page.locator(`#maintenanceList .mnt-item-card:has-text("${title}")`).first() + await expect(card).toContainText(assignee.trim()) + await expect(card).toContainText(supervisor.trim()) + + // and they come back in the modal + await card.locator('.btn-edit-maintenance').click() + await waitForModal(page, '#maintenanceModal') + await expect(page.locator('#mtAssignee')).toHaveValue( + await page.locator('#mtAssignee option', { hasText: assignee.trim() }).getAttribute('value') + ) + }) +}) + +test.describe('§7 Combined calendar', () => { + test('§7 the three kinds of deadline have distinct colours', async ({ page }) => { + // The imported data has no frequencies, so the «maintenances» layer would be + // empty: this test creates its own due date. + const id = await createEquipment(page, name('Macchina calendario cumulativo')) + await createMaintenance(page, id, { code: 'G', title: name('Lavoro a calendario'), months: 3 }) + sql(`UPDATE maint_maintenances SET last_done_date = CURDATE(), next_due_date = DATE_ADD(CURDATE(), INTERVAL 3 MONTH) + WHERE equipment_id = ${id}`) + + await page.goto(GLOBAL_CALENDAR) + + const events = await (await page.request.get( + 'userarea/manutenzioni/ajax/get_global_calendar_events.php' + + '?start=2020-01-01&end=2030-12-31&layers=deadlines,trainings,maintenances' + )).json() + + const byLayer = {} + for (const event of events) { + const layer = event.extendedProps.layer + byLayer[layer] = byLayer[layer] || new Set() + byLayer[layer].add(event.backgroundColor) + } + + expect(Object.keys(byLayer).sort()).toEqual(['deadlines', 'maintenances', 'trainings']) + + // no colour shared between layers (apart from the red used for «overdue», + // which by design signals urgency on every kind) + const base = { + deadlines: '#6f42c1', + trainings: '#198754', + maintenances: '#2f7d8f', + } + for (const [layer, colour] of Object.entries(base)) { + expect([...byLayer[layer]], `layer ${layer} does not use its own colour`).toContain(colour) + } + + // and the markers in the title differ per kind + // charAt would split the emoji (surrogate pairs): we need the first code point + const icons = new Set(events.map((e) => [...String(e.title).trim()][0])) + expect(icons.size).toBeGreaterThanOrEqual(3) + }) +}) diff --git a/tests/e2e/files.spec.js b/tests/e2e/files.spec.js new file mode 100644 index 0000000..51d57b4 --- /dev/null +++ b/tests/e2e/files.spec.js @@ -0,0 +1,168 @@ +const { test, expect } = require('@playwright/test') +const { + REGISTRY, createEquipment, createMaintenance, submitAndReload, waitForModal, name, listItem +} = require('./helpers/fixtures') +const { scalar } = require('./helpers/env') + +/** A valid 1×1 PNG: avoids dropping temporary files on disk. */ +const PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64' +) +const PDF = Buffer.from('%PDF-1.4 prova allegato', 'utf8') + +const photo = (n) => ({ name: n, mimeType: 'image/png', buffer: PNG }) +const doc = (n) => ({ name: n, mimeType: 'application/pdf', buffer: PDF }) + +test.describe('Photos, cover image and documents', () => { + let equipmentId + const label = name('Macchina allegati') + + test.beforeAll(async ({ browser }) => { + const page = await browser.newPage() + equipmentId = await createEquipment(page, label) + await page.close() + }) + + test('the first uploaded photo becomes the cover, in the registry too', async ({ page }) => { + await page.goto(`userarea/manutenzioni/equipment.php?id=${equipmentId}`) + await expect(page.locator('text=Nessuna foto caricata')).toBeVisible() + + const reloaded = page.waitForEvent('load') + await page.setInputFiles('#photoInput', photo('prima.png')) + await reloaded + + const gallery = page.locator('.mnt-photo-grid .mnt-photo-item') + await expect(gallery).toHaveCount(1) + await expect(gallery.first()).toHaveClass(/is-cover/) + + // the cover shows up in the list as well + await page.goto(`${REGISTRY}?q=${encodeURIComponent(label)}`) + await expect(listItem(page, `${label}`).locator('img.mnt-thumb')).toBeVisible() + }) + + test('the star moves the cover onto the second photo', async ({ page }) => { + await page.goto(`userarea/manutenzioni/equipment.php?id=${equipmentId}`) + + const reloaded = page.waitForEvent('load') + await page.setInputFiles('#photoInput', photo('seconda.png')) + await reloaded + + const gallery = page.locator('.mnt-photo-grid .mnt-photo-item') + await expect(gallery).toHaveCount(2) + + // the cover does not move on its own: it stays the one already set. + // The comparison is by id, not by position: created_at has second + // precision and two close uploads can tie. + const coverBefore = scalar(`SELECT cover_file_id FROM inv_equipment WHERE id = ${equipmentId}`) + await expect(page.locator('.mnt-photo-item.is-cover .btn-set-cover')).toHaveAttribute('data-id', coverBefore) + + const other = page.locator(`.mnt-photo-item:not(.is-cover) .btn-set-cover`).first() + const otherId = await other.getAttribute('data-id') + + const afterCover = page.waitForEvent('load') + await other.click() + await afterCover + + expect(scalar(`SELECT cover_file_id FROM inv_equipment WHERE id = ${equipmentId}`)).toBe(otherId) + await expect(page.locator('.mnt-photo-item.is-cover .btn-set-cover')).toHaveAttribute('data-id', otherId) + }) + + test('a photo is deleted only after confirmation', async ({ page }) => { + await page.goto(`userarea/manutenzioni/equipment.php?id=${equipmentId}`) + + await page.locator('.mnt-photo-grid .mnt-photo-item').first().locator('.btn-del-photo').click() + await expect(page.locator('.swal2-title')).toContainText('Eliminare il file') + + const reloaded = page.waitForEvent('load') + await page.click('.swal2-confirm') + await reloaded + + await expect(page.locator('.mnt-photo-grid .mnt-photo-item')).toHaveCount(1) + }) + + test('documents upload and stay downloadable', async ({ page }) => { + await page.goto(`userarea/manutenzioni/equipment.php?id=${equipmentId}`) + await expect(page.locator('text=Nessun documento caricato')).toBeVisible() + + const reloaded = page.waitForEvent('load') + await page.setInputFiles('#docInput', doc('manuale-uso.pdf')) + await reloaded + + const row = page.locator('.mnt-file-row', { hasText: 'manuale-uso.pdf' }) + await expect(row).toBeVisible() + await expect(row.locator('a')).toHaveAttribute('href', /download_file\.php\?scope=equipment&id=\d+/) + + // the protected link really answers + const href = await row.locator('a').getAttribute('href') + // href is relative to , not to baseURL + const response = await page.request.get('userarea/' + href) + expect(response.status()).toBe(200) + }) + + test('a disallowed file type is rejected with a message', async ({ page }) => { + await page.goto(`userarea/manutenzioni/equipment.php?id=${equipmentId}`) + + await page.setInputFiles('#docInput', { + name: 'script.php', mimeType: 'application/x-php', buffer: Buffer.from(' { + let equipmentId + const maintenanceTitle = name('Manutenzione con istruzioni') + + test.beforeAll(async ({ browser }) => { + const page = await browser.newPage() + equipmentId = await createEquipment(page, name('Macchina istruzioni')) + await page.close() + }) + + test('instructions attached to a maintenance task are listed, reopened and deleted', async ({ page }) => { + await page.goto(`userarea/manutenzioni/equipment.php?id=${equipmentId}`) + await page.click('#btnAddMaintenance') + await waitForModal(page, '#maintenanceModal') + + await page.fill('#mtCode', 'I') + await page.fill('#mtTitle', maintenanceTitle) + await page.setInputFiles('#mtFilesInput', doc('istruzioni.pdf')) + await submitAndReload(page, '#maintenanceForm button[type="submit"]') + + const card = page.locator(`.mnt-item-card:has-text("${maintenanceTitle}")`).first() + await expect(card).toContainText('1 istruzioni') + + // reopening the modal, the file is listed + await card.locator('.btn-edit-maintenance').click() + await waitForModal(page, '#maintenanceModal') + + const fileRow = page.locator('#mtFilesList .mnt-file-row', { hasText: 'istruzioni.pdf' }) + await expect(fileRow).toBeVisible() + + await fileRow.locator('.btn-del-file').click() + await expect(page.locator('#mtFilesList .mnt-file-row')).toHaveCount(0) + }) + + test('an attachment of an intervention shows up in the history', async ({ page }) => { + const title = name('Manutenzione con allegato') + await createMaintenance(page, equipmentId, { code: 'J', title }) + + await page.locator(`.mnt-item-card:has-text("${title}") .btn-register`).first().click() + await waitForModal(page, '#interventionModal') + + await page.fill('#ivDate', '2026-04-18') + await page.fill('#ivNotes', 'Intervento con modulo del fornitore') + await page.setInputFiles('#ivFilesInput', doc('modulo-fornitore.pdf')) + await submitAndReload(page, '#interventionForm button[type="submit"]') + + const card = page.locator(`.mnt-item-card:has-text("${title}")`).first() + await card.locator('.btn-toggle-history').click() + + const entry = card.locator('.mnt-timeline-item').first() + await expect(entry).toContainText('modulo del fornitore') + await expect(entry.locator('.fa-paperclip')).toBeVisible() + }) +}) diff --git a/tests/e2e/forms.spec.js b/tests/e2e/forms.spec.js new file mode 100644 index 0000000..0caf843 --- /dev/null +++ b/tests/e2e/forms.spec.js @@ -0,0 +1,133 @@ +const { test, expect } = require('@playwright/test') +const { + createEquipment, createMaintenance, submitAndReload, waitForModal, name, listItem +} = require('./helpers/fixtures') +const { scalar } = require('./helpers/env') + +/** + * Modal validation and reload of the values: the fields must come back exactly + * as they were saved, and incomplete data must not get through. + */ +test.describe('Modals: validation and reload of the values', () => { + let equipmentId + + test.beforeAll(async ({ browser }) => { + const page = await browser.newPage() + equipmentId = await createEquipment(page, name('Macchina moduli')) + await page.close() + }) + + test.beforeEach(async ({ page }) => { + await page.goto(`userarea/manutenzioni/equipment.php?id=${equipmentId}`) + }) + + // Title and date carry the required attribute: the browser validation stops + // the submit, so the modal stays open and no request is fired. + test('maintenance without a title: the form blocks and nothing is saved', async ({ page }) => { + const before = scalar(`SELECT COUNT(*) FROM maint_maintenances WHERE equipment_id = ${equipmentId}`) + + await page.click('#btnAddMaintenance') + await waitForModal(page, '#maintenanceModal') + await page.click('#maintenanceForm button[type="submit"]') + + await expect(page.locator('#maintenanceModal')).toBeVisible() + expect(await page.locator('#mtTitle').evaluate((el) => el.checkValidity())).toBe(false) + + expect(scalar(`SELECT COUNT(*) FROM maint_maintenances WHERE equipment_id = ${equipmentId}`)).toBe(before) + }) + + test('intervention without a date: the form blocks and nothing is saved', async ({ page }) => { + const title = name('Lavoro per validazione') + await createMaintenance(page, equipmentId, { code: 'V', title }) + + await page.locator(`.mnt-item-card:has-text("${title}") .btn-register`).first().click() + await waitForModal(page, '#interventionModal') + + await page.fill('#ivDate', '') + await page.click('#interventionForm button[type="submit"]') + + await expect(page.locator('#interventionModal')).toBeVisible() + expect(await page.locator('#ivDate').evaluate((el) => el.checkValidity())).toBe(false) + + expect(scalar(`SELECT COUNT(*) FROM maint_interventions + WHERE maintenance_id = (SELECT id FROM maint_maintenances + WHERE equipment_id = ${equipmentId} AND title = '${title}')`)).toBe('0') + }) + + test('reopening a maintenance task shows the saved values', async ({ page }) => { + const title = name('Manutenzione completa') + + await page.click('#btnAddMaintenance') + await waitForModal(page, '#maintenanceModal') + + await page.fill('#mtCode', 'Q') + await page.fill('#mtTitle', title) + await page.fill('#mtDescription', 'Descrizione dettagliata del controllo.') + await page.selectOption('#mtType', 'extraordinary') + await page.selectOption('#mtFrequencyUnit', 'week') + await page.fill('#mtFrequencyValue', '6') + await page.fill('#mtFrequencyNote', 'solo durante il periodo estivo') + await page.fill('#mtAlertDays', '21') + await page.check('#mtCritical') + await submitAndReload(page, '#maintenanceForm button[type="submit"]') + + await page.locator(`.mnt-item-card:has-text("${title}") .btn-edit-maintenance`).first().click() + await waitForModal(page, '#maintenanceModal') + + await expect(page.locator('#mtCode')).toHaveValue('Q') + await expect(page.locator('#mtTitle')).toHaveValue(title) + await expect(page.locator('#mtDescription')).toHaveValue('Descrizione dettagliata del controllo.') + await expect(page.locator('#mtType')).toHaveValue('extraordinary') + await expect(page.locator('#mtFrequencyUnit')).toHaveValue('week') + await expect(page.locator('#mtFrequencyValue')).toHaveValue('6') + await expect(page.locator('#mtFrequencyNote')).toHaveValue('solo durante il periodo estivo') + await expect(page.locator('#mtAlertDays')).toHaveValue('21') + await expect(page.locator('#mtCritical')).toBeChecked() + await expect(page.locator('#mtActive')).toBeChecked() + }) + + test('reopening an intervention shows the saved values', async ({ page }) => { + const title = name('Lavoro con storico') + await createMaintenance(page, equipmentId, { code: 'W', title }) + + await page.locator(`.mnt-item-card:has-text("${title}") .btn-register`).first().click() + await waitForModal(page, '#interventionModal') + + await page.fill('#ivDate', '2026-03-12') + await page.selectOption('#ivResult', 'compliant') + await page.fill('#ivNotes', 'Sostituita guarnizione') + await page.fill('#ivMaterials', 'Guarnizione 40x60') + await page.fill('#ivOperatorName', 'Tecnico esterno Rossi') + await submitAndReload(page, '#interventionForm button[type="submit"]') + + const card = page.locator(`.mnt-item-card:has-text("${title}")`).first() + await card.locator('.btn-toggle-history').click() + await card.locator('.mnt-timeline-item').first().locator('.btn-edit-intervention').click() + await waitForModal(page, '#interventionModal') + + await expect(page.locator('#ivDate')).toHaveValue('2026-03-12') + await expect(page.locator('#ivResult')).toHaveValue('compliant') + await expect(page.locator('#ivNotes')).toHaveValue('Sostituita guarnizione') + await expect(page.locator('#ivMaterials')).toHaveValue('Guarnizione 40x60') + await expect(page.locator('#ivOperatorName')).toHaveValue('Tecnico esterno Rossi') + await expect(page.locator('#interventionModalTitle')).toHaveText('Modifica intervento') + }) + + test('equipment used in production cannot be deleted', async ({ page }) => { + // the imported Taglierina is linked to real production records + const usedId = scalar('SELECT tool_id FROM productiondata_tools LIMIT 1') + test.skip(!usedId, 'no equipment linked to production in this database') + + const usedName = scalar(`SELECT name FROM inv_equipment WHERE id = ${usedId}`) + await page.goto(`userarea/manutenzioni/index.php?q=${encodeURIComponent(usedName)}&status=all`) + + await listItem(page, `${usedName}`).locator('.btn-delete').first().click() + await expect(page.locator('.swal2-popup')).toBeVisible() + await page.click('.swal2-confirm') + + await expect(page.locator('.swal2-title')).toContainText('Errore') + await expect(page.locator('.swal2-html-container')).toContainText('registrazioni di produzione') + + expect(scalar(`SELECT COUNT(*) FROM inv_equipment WHERE id = ${usedId}`)).toBe('1') + }) +}) diff --git a/tests/e2e/global-setup.js b/tests/e2e/global-setup.js new file mode 100644 index 0000000..123a7d4 --- /dev/null +++ b/tests/e2e/global-setup.js @@ -0,0 +1,85 @@ +const { chromium } = require('@playwright/test') +const fs = require('fs') +const path = require('path') +const { PREFIX, PASSWORD, EMAIL, EMAIL_LOW, sql, php, cleanup } = require('./helpers/env') + +/** + * Prepares the environment once: + * 1. wipes any leftovers from a previous run + * 2. creates two test users — one Admin, one without the maintenance.* + * permissions — plus a test supplier + * 3. stores the cookies of both in tests/e2e/.auth/ + */ +async function login(browser, baseURL, email, statePath, attempt = 1) { + const page = await browser.newPage({ baseURL }) + + await page.goto('login') + await page.fill('input[name="username"]', email) + await page.fill('input[name="password"]', PASSWORD) + await page.click('button[type="submit"], input[type="submit"]') + + try { + await page.waitForURL(/userarea/, { timeout: 20_000 }) + } catch (error) { + // The login can bounce because of the attempt limit: one more try after a + // pause, then we report the message shown on the page. + const message = (await page.locator('.alert, .invalid-feedback').allTextContents()).join(' ').trim() + await page.close() + + if (attempt < 2) { + await new Promise((resolve) => setTimeout(resolve, 15_000)) + return login(browser, baseURL, email, statePath, attempt + 1) + } + + throw new Error(`Login failed for ${email}${message ? ' — ' + message : ''}`) + } + + // the PHP session (PHPSESSID) is created on the first userarea page + const response = await page.goto('userarea/manutenzioni/index.php') + + await page.context().storageState({ path: statePath }) + await page.close() + + return response ? response.status() : 0 +} + +module.exports = async (config) => { + const baseURL = config.projects[0].use.baseURL + + cleanup() + + const hash = php('echo password_hash("' + PASSWORD + '", PASSWORD_BCRYPT);') + + // role 1 = Admin (has the maintenance.* permissions), role 4 = employee (has not) + sql(` + INSERT INTO auth_users (email, password, status, role_id, first_name, last_name, created_at, updated_at) + VALUES ('${EMAIL}', '${hash}', 'Active', 1, 'ZZ', 'E2E', NOW(), NOW()), + ('${EMAIL_LOW}', '${hash}', 'Active', 4, 'ZZ', 'Limitato', NOW(), NOW()); + INSERT INTO suppliers (supplier_name) VALUES ('${PREFIX}Fornitore'); + `) + + const authDir = path.join(__dirname, '.auth') + fs.mkdirSync(authDir, { recursive: true }) + + const browser = await chromium.launch() + + const adminStatus = await login(browser, baseURL, EMAIL, path.join(authDir, 'state.json')) + if (adminStatus !== 200) { + await browser.close() + throw new Error( + `The registry answers ${adminStatus} to the Admin user: ` + + 'check the maintenance.* permissions of role 1 (sql/4_permissions.sql).' + ) + } + + const lowStatus = await login(browser, baseURL, EMAIL_LOW, path.join(authDir, 'state-low.json')) + if (lowStatus !== 403) { + await browser.close() + throw new Error( + `The unprivileged user gets ${lowStatus} instead of 403: ` + + 'role 4 has maintenance.* permissions it should not have.' + ) + } + + await browser.close() +} diff --git a/tests/e2e/global-teardown.js b/tests/e2e/global-teardown.js new file mode 100644 index 0000000..52ef205 --- /dev/null +++ b/tests/e2e/global-teardown.js @@ -0,0 +1,6 @@ +const { cleanup } = require('./helpers/env') + +/** Removes users, equipment, maintenances and attachments created by the tests. */ +module.exports = async () => { + cleanup() +} diff --git a/tests/e2e/helpers/env.js b/tests/e2e/helpers/env.js new file mode 100644 index 0000000..7338016 --- /dev/null +++ b/tests/e2e/helpers/env.js @@ -0,0 +1,88 @@ +const { execFileSync } = require('child_process') +const fs = require('fs') +const path = require('path') + +const PREFIX = 'ZZE2E_' +const PASSWORD = 'ZzE2e!2026' + +/** + * A different address on every run: the Laravel login throttles attempts per + * address, and launching the suite several times in a row ends up locking us + * out right in the setup. Cleanup keeps working because it filters by prefix. + */ +const RUN_ID = `${Date.now().toString(36)}` +const EMAIL = `${PREFIX}admin_${RUN_ID}@test.local` +const EMAIL_LOW = `${PREFIX}low_${RUN_ID}@test.local` + +const DB_CONTAINER = process.env.MNT_DB_CONTAINER || 'zibo-db' +const APP_CONTAINER = process.env.MNT_APP_CONTAINER || 'zibo-app' +const DB_NAME = process.env.MNT_DB_NAME || 'zibodashboard' + +const ATTACHMENTS = path.join(__dirname, '..', '..', '..', 'public', 'userarea', 'manutenzioni', 'attachments') + +/** Runs SQL inside the database container and returns the raw output. */ +function sql(query) { + return execFileSync( + 'docker', + ['exec', '-i', DB_CONTAINER, 'mysql', '-uroot', '-proot', '-N', '-B', DB_NAME, '-e', query], + { encoding: 'utf8' } + ).trim() +} + +/** First column of the first row, or null. */ +function scalar(query) { + const out = sql(query) + if (!out) return null + return out.split('\n')[0].split('\t')[0] +} + +/** Rows as an array of strings (one per row). */ +function rows(query) { + const out = sql(query) + return out ? out.split('\n') : [] +} + +function php(code) { + return execFileSync('docker', ['exec', APP_CONTAINER, 'php', '-r', code], { encoding: 'utf8' }).trim() +} + +/** + * Deletes everything the tests created: prefixed rows and the uploaded files, + * which would otherwise stay on disk (the database cascade removes the rows, + * not the attachments). + */ +function cleanup() { + const stored = [ + ...rows(`SELECT f.stored_name FROM inv_equipment_files f + JOIN inv_equipment e ON e.id = f.equipment_id WHERE e.name LIKE '${PREFIX}%'`), + ...rows(`SELECT f.stored_name FROM maint_maintenance_files f + JOIN maint_maintenances m ON m.id = f.maintenance_id + JOIN inv_equipment e ON e.id = m.equipment_id WHERE e.name LIKE '${PREFIX}%'`), + ...rows(`SELECT f.stored_name FROM maint_intervention_files f + JOIN maint_interventions i ON i.id = f.intervention_id + JOIN inv_equipment e ON e.id = i.equipment_id WHERE e.name LIKE '${PREFIX}%'`), + ...rows(`SELECT i.signature_path FROM maint_interventions i + JOIN inv_equipment e ON e.id = i.equipment_id + WHERE e.name LIKE '${PREFIX}%' AND i.signature_path IS NOT NULL`), + ] + + sql(` + UPDATE inv_equipment SET cover_file_id = NULL WHERE name LIKE '${PREFIX}%'; + DELETE FROM inv_equipment WHERE name LIKE '${PREFIX}%'; + DELETE FROM inv_categories WHERE name LIKE '${PREFIX}%'; + DELETE FROM suppliers WHERE supplier_name LIKE '${PREFIX}%'; + DELETE FROM auth_users WHERE email LIKE '${PREFIX}%'; + `) + + for (const name of stored) { + if (!name) continue + const file = path.join(ATTACHMENTS, path.basename(name)) + if (fs.existsSync(file)) fs.unlinkSync(file) + } +} + +module.exports = { + PREFIX, PASSWORD, EMAIL, EMAIL_LOW, RUN_ID, + sql, scalar, rows, php, cleanup, + DB_CONTAINER, APP_CONTAINER, ATTACHMENTS, +} diff --git a/tests/e2e/helpers/fixtures.js b/tests/e2e/helpers/fixtures.js new file mode 100644 index 0000000..6a55e5a --- /dev/null +++ b/tests/e2e/helpers/fixtures.js @@ -0,0 +1,89 @@ +const { PREFIX } = require('./env') + +const REGISTRY = 'userarea/manutenzioni/index.php' +const MAINTENANCES = 'userarea/manutenzioni/maintenances.php' +const CALENDAR = 'userarea/manutenzioni/calendar.php' +const GLOBAL_CALENDAR = 'userarea/manutenzioni/calendar_global.php' +const CATEGORIES = 'userarea/manutenzioni/categories/index.php' + +/** + * The module's forms save over ajax and then call location.reload(): we have to + * wait for that reload, otherwise the next navigation aborts it + * (net::ERR_ABORTED). + */ +async function submitAndReload(page, selector) { + const reloaded = page.waitForEvent('load') + await page.click(selector) + await reloaded +} + +/** + * The same list entry in its two guises: a table row from 768px up, a card + * below. Filtering by visibility always picks the right one, so the same test + * holds on phone, tablet and desktop. + */ +function listItem(page, text) { + // tbody only: the table header can contain the same word (the Categories + // page has an «Attrezzature» column) and would be matched first + return page + .locator(`tbody tr:has-text("${text}"), .mnt-item-card:has-text("${text}")`) + .filter({ visible: true }) + .first() +} + +/** + * Waits until the modal is really open: the .show class lands at the start of + * the transition, while the signature canvas is cleared and resized on the + * shown.bs.modal event, i.e. once the transition is over. + */ +async function waitForModal(page, id) { + await page.waitForSelector(`${id}.show`) + await page.waitForFunction( + (selector) => { + const modal = document.querySelector(selector) + return modal && getComputedStyle(modal).opacity === '1' + }, + id + ) + await page.waitForTimeout(150) +} + +/** + * Creates a piece of equipment from the registry modal and returns its id, + * read from the «open detail» link of the row. + */ +async function createEquipment(page, name, { category = 'Attrezzature' } = {}) { + await page.goto(REGISTRY) + await page.click('#btnAddEquipment') + await waitForModal(page, '#equipmentModal') + + await page.fill('#eqName', name) + await page.selectOption('#eqCategory', { label: category }) + await submitAndReload(page, '#equipmentForm button[type="submit"]') + await page.goto(`${REGISTRY}?q=${encodeURIComponent(name)}`) + + const href = await page.locator('a[href*="equipment.php?id="]').first().getAttribute('href') + return Number(href.match(/id=(\d+)/)[1]) +} + +/** Adds a maintenance task from the equipment detail page. */ +async function createMaintenance(page, equipmentId, { code = 'A', title, months = 3 } = {}) { + await page.goto(`userarea/manutenzioni/equipment.php?id=${equipmentId}`) + await page.click('#btnAddMaintenance') + await waitForModal(page, '#maintenanceModal') + + await page.fill('#mtCode', code) + await page.fill('#mtTitle', title) + await page.selectOption('#mtFrequencyUnit', 'month') + await page.fill('#mtFrequencyValue', String(months)) + await submitAndReload(page, '#maintenanceForm button[type="submit"]') + await page.waitForSelector(`text=${title}`) +} + +const name = (suffix) => `${PREFIX}${suffix}` + +module.exports = { + submitAndReload, waitForModal, listItem, + REGISTRY, MAINTENANCES, CALENDAR, GLOBAL_CALENDAR, CATEGORIES, + createEquipment, createMaintenance, name, +} diff --git a/tests/e2e/intervention.spec.js b/tests/e2e/intervention.spec.js new file mode 100644 index 0000000..e885e86 --- /dev/null +++ b/tests/e2e/intervention.spec.js @@ -0,0 +1,141 @@ +const { test, expect } = require('@playwright/test') +const { createEquipment, createMaintenance, submitAndReload, waitForModal, name } = require('./helpers/fixtures') +const { scalar } = require('./helpers/env') + +/** Scribbles on the signature canvas, the way a finger would. */ +async function sign(page) { + const canvas = page.locator('#ivSignatureCanvas') + // on small screens the canvas sits below the fold of the modal: without this + // the mouse would draw outside the window and no stroke would appear + await canvas.scrollIntoViewIfNeeded() + const box = await canvas.boundingBox() + + await page.mouse.move(box.x + 30, box.y + box.height / 2) + await page.mouse.down() + for (let i = 1; i <= 12; i++) { + await page.mouse.move( + box.x + 30 + (box.width - 60) * (i / 12), + box.y + box.height / 2 + Math.sin(i) * 22 + ) + } + await page.mouse.up() +} + +test.describe('Recording interventions', () => { + let equipmentId + const title = name('Controllo trimestrale') + + test.beforeAll(async ({ browser }) => { + const page = await browser.newPage() + equipmentId = await createEquipment(page, name('Macchina interventi')) + await createMaintenance(page, equipmentId, { code: 'A', title, months: 3 }) + await page.close() + }) + + test('recording a completed intervention updates the dates on screen', async ({ page }) => { + await page.goto(`userarea/manutenzioni/equipment.php?id=${equipmentId}`) + + const card = page.locator(`.mnt-item-card:has-text("${title}")`).first() + await expect(card).toContainText('Ultimo: —') + + await card.locator('.btn-register').click() + await waitForModal(page, '#interventionModal') + + // the modal states what it refers to + await expect(page.locator('#ivContext')).toContainText(name('Macchina interventi')) + await expect(page.locator('#ivContext')).toContainText(title) + + await page.fill('#ivDate', '2026-06-10') + await page.selectOption('#ivStatus', 'completed') + await page.selectOption('#ivResult', 'compliant') + await page.fill('#ivNotes', 'Controllo eseguito, nessuna anomalia') + await page.fill('#ivMaterials', 'Olio idraulico 1 l') + await submitAndReload(page, '#interventionForm button[type="submit"]') + + const updated = page.locator(`.mnt-item-card:has-text("${title}")`).first() + await expect(updated).toContainText('Ultimo: 10/06/2026') + await expect(updated).toContainText('Prossimo: 10/09/2026') + }) + + test('the drawn signature ends up in the history', async ({ page }) => { + await page.goto(`userarea/manutenzioni/equipment.php?id=${equipmentId}`) + + const card = page.locator(`.mnt-item-card:has-text("${title}")`).first() + await card.locator('.btn-register').click() + await waitForModal(page, '#interventionModal') + + await page.fill('#ivDate', '2026-06-20') + await sign(page) + await submitAndReload(page, '#interventionForm button[type="submit"]') + + const signatures = scalar(` + SELECT COUNT(*) FROM maint_interventions i + JOIN maint_maintenances m ON m.id = i.maintenance_id + WHERE m.equipment_id = ${equipmentId} AND i.signature_path IS NOT NULL + `) + expect(Number(signatures)).toBeGreaterThan(0) + + const updated = page.locator(`.mnt-item-card:has-text("${title}")`).first() + await updated.locator('.btn-toggle-history').click() + + const timeline = updated.locator('.mnt-timeline') + await expect(timeline).toBeVisible() + await expect(timeline.locator('img[alt="Firma"]').first()).toBeVisible() + await expect(timeline).toContainText('Controllo eseguito') + await expect(timeline).toContainText('Olio idraulico') + }) + + test('a planned intervention does not move the due date', async ({ page }) => { + await page.goto(`userarea/manutenzioni/equipment.php?id=${equipmentId}`) + + const before = await page + .locator(`.mnt-item-card:has-text("${title}")`) + .first() + .textContent() + + const card = page.locator(`.mnt-item-card:has-text("${title}")`).first() + await card.locator('.btn-register').click() + await waitForModal(page, '#interventionModal') + + await page.fill('#ivDate', '2027-05-05') + await page.selectOption('#ivStatus', 'planned') + await submitAndReload(page, '#interventionForm button[type="submit"]') + + const after = await page.locator(`.mnt-item-card:has-text("${title}")`).first().textContent() + expect(after).toContain('Prossimo: 20/09/2026') + expect(before).toContain('Prossimo:') + }) + + test('deleting the last completed intervention rolls the due date back', async ({ page }) => { + await page.goto(`userarea/manutenzioni/equipment.php?id=${equipmentId}`) + + const card = page.locator(`.mnt-item-card:has-text("${title}")`).first() + await card.locator('.btn-toggle-history').click() + + // the timeline is sorted by descending date, so on top sits the intervention + // planned for 2027: we need the completed one from 20/06 + await card + .locator('.mnt-timeline-item', { hasText: '20/06/2026' }) + .locator('.btn-delete-intervention') + .click() + await expect(page.locator('.swal2-title')).toContainText('intervento') + await submitAndReload(page, '.swal2-confirm') + + await expect(page.locator(`.mnt-item-card:has-text("${title}")`).first()).toContainText('Prossimo: 10/09/2026') + }) + + test('the signature is wiped by its own button', async ({ page }) => { + await page.goto(`userarea/manutenzioni/equipment.php?id=${equipmentId}`) + + await page.locator(`.mnt-item-card:has-text("${title}") .btn-register`).first().click() + await waitForModal(page, '#interventionModal') + + await sign(page) + const drawn = await page.locator('#ivSignatureCanvas').screenshot() + + await page.click('#ivSignatureClear') + const cleared = await page.locator('#ivSignatureCanvas').screenshot() + + expect(drawn.equals(cleared)).toBe(false) + }) +}) diff --git a/tests/e2e/list.spec.js b/tests/e2e/list.spec.js new file mode 100644 index 0000000..1120cc1 --- /dev/null +++ b/tests/e2e/list.spec.js @@ -0,0 +1,157 @@ +const { test, expect } = require('@playwright/test') +const { + REGISTRY, MAINTENANCES, createEquipment, createMaintenance, submitAndReload, waitForModal, name, listItem +} = require('./helpers/fixtures') +const { sql, scalar } = require('./helpers/env') + +/** + * The two lists of the module: the registry and the maintenance list. Filters, + * summary tiles, empty states — the part people use every day. + */ + +test.describe('Maintenance list', () => { + let equipmentId + const overdueTitle = name('Scaduta da tempo') + const okTitle = name('In regola') + + test.beforeAll(async ({ browser }) => { + const page = await browser.newPage() + equipmentId = await createEquipment(page, name('Macchina elenco')) + await createMaintenance(page, equipmentId, { code: 'S', title: overdueTitle, months: 3 }) + await createMaintenance(page, equipmentId, { code: 'R', title: okTitle, months: 6 }) + await page.close() + + // one due date in the past and one in the future, independent of today + sql(`UPDATE maint_maintenances SET last_done_date = '2020-01-01', next_due_date = '2020-04-01' + WHERE equipment_id = ${equipmentId} AND title = '${overdueTitle}'`) + sql(`UPDATE maint_maintenances SET last_done_date = CURDATE(), next_due_date = DATE_ADD(CURDATE(), INTERVAL 6 MONTH) + WHERE equipment_id = ${equipmentId} AND title = '${okTitle}'`) + }) + + test('the summary tiles count and filter', async ({ page }) => { + await page.goto(MAINTENANCES) + + const tiles = page.locator('.row.g-2 .mnt-item-card') + await expect(tiles).toHaveCount(4) + + // the «Scadute» (overdue) tile must count at least ours + const overdueTile = page.locator('a:has(.ic-meta:text-is("Scadute")) .ic-title') + expect(Number(await overdueTile.textContent())).toBeGreaterThan(0) + + await page.locator('a:has(.ic-meta:text-is("Scadute"))').click() + await page.waitForLoadState('load') + + await expect(page.locator('#maintenanceList')).toContainText(overdueTitle) + await expect(page.locator('#maintenanceList')).not.toContainText(okTitle) + + // the badge really reads «Scaduta» + const row = listItem(page, `${overdueTitle}`) + await expect(row.locator('.mnt-badge-overdue')).toHaveText('Scaduta') + }) + + test('clicking the same tile twice clears the filter', async ({ page }) => { + await page.goto(`${MAINTENANCES}?state=overdue`) + await expect(page.locator('#maintenanceList')).not.toContainText(okTitle) + + await page.locator('a:has(.ic-meta:text-is("Scadute"))').click() + await page.waitForLoadState('load') + + await expect(page.locator('#maintenanceList')).toContainText(okTitle) + }) + + test('«critical only» and «scheduled/extraordinary» narrow the list', async ({ page }) => { + await page.goto(MAINTENANCES) + + // when the filter leaves no rows the list is not rendered at all, so the + // negative check has to run against a container that is always there + const body = page.locator('.mnt-card .card-body') + + await page.check('#fCritical') + await page.waitForURL(/critical=1/) + await expect(body).not.toContainText(okTitle) + + await page.goto(`${MAINTENANCES}?type=extraordinary`) + await expect(body).not.toContainText(okTitle) + + await page.goto(`${MAINTENANCES}?type=scheduled`) + await expect(page.locator('#maintenanceList')).toContainText(okTitle) + }) + + test('the empty state appears when no row matches', async ({ page }) => { + // non-existent category: no maintenance task can match + await page.goto(`${MAINTENANCES}?category=999999`) + + await expect(page.locator('.empty-state')).toBeVisible() + await expect(page.locator('.empty-state')).toContainText('Nessuna manutenzione') + await expect(page.locator('#maintenanceList')).toHaveCount(0) + }) + + test('registering straight from the list updates the due date', async ({ page }) => { + await page.goto(MAINTENANCES) + + const row = listItem(page, `${overdueTitle}`) + await row.locator('.btn-register').click() + await waitForModal(page, '#interventionModal') + + // the modal knows which machine it belongs to + await expect(page.locator('#ivContext')).toContainText(name('Macchina elenco')) + + await page.fill('#ivDate', '2026-05-05') + await submitAndReload(page, '#interventionForm button[type="submit"]') + + expect( + scalar(`SELECT next_due_date FROM maint_maintenances + WHERE equipment_id = ${equipmentId} AND title = '${overdueTitle}'`) + ).toBe('2026-08-05') + }) +}) + +test.describe('Registry: statuses and filters', () => { + test('the empty state appears when the search finds nothing', async ({ page }) => { + await page.goto(`${REGISTRY}?q=${encodeURIComponent(name('inesistente-xyz'))}`) + + await expect(page.locator('.empty-state')).toBeVisible() + await expect(page.locator('.empty-state')).toContainText('Nessuna attrezzatura trovata') + }) + + test('changing the status changes the badge and drops the row from the «Attivo» filter', async ({ page }) => { + const label = name('Macchina dismessa') + const id = await createEquipment(page, label) + + await page.goto(`${REGISTRY}?q=${encodeURIComponent(label)}`) + // «Attivo» is not printed on the cards (it would be noise): we check that + // the opposite status does not show up, in either guise + await expect(listItem(page, `${label}`)).not.toContainText('Dismesso') + + await listItem(page, `${label}`).locator('.btn-edit').click() + await waitForModal(page, '#equipmentModal') + await page.selectOption('#eqStatus', 'decommissioned') + await submitAndReload(page, '#equipmentForm button[type="submit"]') + + // the default filter shows active equipment only + await page.goto(`${REGISTRY}?q=${encodeURIComponent(label)}`) + await expect(page.locator('.empty-state')).toBeVisible() + + await page.goto(`${REGISTRY}?q=${encodeURIComponent(label)}&status=all`) + await expect(listItem(page, `${label}`)).toContainText('Dismesso') + + expect(scalar(`SELECT status FROM inv_equipment WHERE id = ${id}`)).toBe('decommissioned') + }) + + test('on the phone the card also carries edit and delete', async ({ page }) => { + const label = name('Macchina da telefono') + await createEquipment(page, label) + + await page.setViewportSize({ width: 390, height: 844 }) + await page.goto(`${REGISTRY}?q=${encodeURIComponent(label)}`) + + const card = page.locator('.d-md-none .mnt-item-card', { hasText: label }) + await expect(card).toBeVisible() + await expect(card.locator('.btn-edit')).toBeVisible() + await expect(card.locator('.btn-delete')).toBeVisible() + + await card.locator('.btn-edit').click() + await waitForModal(page, '#equipmentModal') + await expect(page.locator('#eqName')).toHaveValue(label) + }) +}) diff --git a/tests/e2e/maintenance.spec.js b/tests/e2e/maintenance.spec.js new file mode 100644 index 0000000..dae8b19 --- /dev/null +++ b/tests/e2e/maintenance.spec.js @@ -0,0 +1,93 @@ +const { test, expect } = require('@playwright/test') +const { createEquipment, createMaintenance, submitAndReload, waitForModal, name, listItem } = require('./helpers/fixtures') +const { PREFIX } = require('./helpers/env') + +test.describe('Maintenance modal', () => { + let equipmentId + + test.beforeAll(async ({ browser }) => { + const page = await browser.newPage() + equipmentId = await createEquipment(page, name('Macchina manutenzioni')) + await page.close() + }) + + test.beforeEach(async ({ page }) => { + await page.goto(`userarea/manutenzioni/equipment.php?id=${equipmentId}`) + }) + + test('«on demand» hides the frequency field', async ({ page }) => { + await page.click('#btnAddMaintenance') + await waitForModal(page, '#maintenanceModal') + + await expect(page.locator('#mtFrequencyValue')).toBeVisible() + await page.selectOption('#mtFrequencyUnit', 'on_demand') + await expect(page.locator('#mtFrequencyValue')).toBeHidden() + + await page.selectOption('#mtFrequencyUnit', 'month') + await expect(page.locator('#mtFrequencyValue')).toBeVisible() + }) + + test('«external» asks for the supplier and blocks saving without one', async ({ page }) => { + await page.click('#btnAddMaintenance') + await waitForModal(page, '#maintenanceModal') + + await expect(page.locator('#mtSupplierWrap')).toBeHidden() + await page.selectOption('#mtExecution', 'external') + await expect(page.locator('#mtSupplierWrap')).toBeVisible() + + await page.fill('#mtTitle', name('Esterna senza fornitore')) + await page.click('#maintenanceForm button[type="submit"]') + + await expect(page.locator('.swal2-title')).toContainText('fornitore') + }) + + test('a critical maintenance task is highlighted on the detail page', async ({ page }) => { + await page.click('#btnAddMaintenance') + await waitForModal(page, '#maintenanceModal') + + await page.fill('#mtCode', 'C') + await page.fill('#mtTitle', name('Critica')) + await page.check('#mtCritical') + await submitAndReload(page, '#maintenanceForm button[type="submit"]') + + const card = page.locator(`.mnt-item-card:has-text("${name('Critica')}")`) + await expect(card.locator('.mnt-badge-critical')).toHaveText('Critica') + }) + + test('the frequency note stays visible next to the frequency', async ({ page }) => { + await page.click('#btnAddMaintenance') + await waitForModal(page, '#maintenanceModal') + + await page.fill('#mtTitle', name('Stagionale')) + await page.selectOption('#mtFrequencyUnit', 'on_demand') + await page.fill('#mtFrequencyNote', "prima dell'accensione, d'estate settimanale") + await submitAndReload(page, '#maintenanceForm button[type="submit"]') + + const card = page.locator(`.mnt-item-card:has-text("${name('Stagionale')}")`) + await expect(card).toContainText('Al bisogno') + await expect(card).toContainText("prima dell'accensione") + }) + + test('a deactivated maintenance task is marked as such and drops out of the global list', async ({ page }) => { + const title = name('Disattivata') + await createMaintenance(page, equipmentId, { code: 'D', title }) + + await page.locator(`.mnt-item-card:has-text("${title}") .btn-edit-maintenance`).click() + await waitForModal(page, '#maintenanceModal') + await expect(page.locator('#mtTitle')).toHaveValue(title) + + await page.uncheck('#mtActive') + await submitAndReload(page, '#maintenanceForm button[type="submit"]') + + await expect(page.locator(`.mnt-item-card:has-text("${title}") .mnt-badge-none`).first()).toBeVisible() + + await page.goto('userarea/manutenzioni/maintenances.php') + await expect(page.locator('#maintenanceList').getByText(title, { exact: true })).toHaveCount(0) + }) + + test('the maintenance count shows up in the registry', async ({ page }) => { + await page.goto(`userarea/manutenzioni/index.php?q=${encodeURIComponent(PREFIX)}`) + const row = listItem(page, `${name('Macchina manutenzioni')}`) + await expect(row).toContainText('attive') + }) +}) diff --git a/tests/e2e/navigation.spec.js b/tests/e2e/navigation.spec.js new file mode 100644 index 0000000..1d26a88 --- /dev/null +++ b/tests/e2e/navigation.spec.js @@ -0,0 +1,91 @@ +const { test, expect } = require('@playwright/test') +const { + REGISTRY, MAINTENANCES, CALENDAR, GLOBAL_CALENDAR, CATEGORIES, + createEquipment, createMaintenance, name, +} = require('./helpers/fixtures') + +/** The links between the module pages: you must be able to get around without typing URLs. */ +test.describe('Navigation between pages', () => { + let equipmentId + + test.beforeAll(async ({ browser }) => { + const page = await browser.newPage() + equipmentId = await createEquipment(page, name('Macchina navigazione')) + await createMaintenance(page, equipmentId, { code: 'N', title: name('Lavoro navigazione') }) + await page.close() + }) + + test('registry → maintenances → calendar → combined calendar and back', async ({ page }) => { + await page.goto(REGISTRY) + + await page.locator('.card-header a', { hasText: 'Manutenzioni' }).click() + await expect(page).toHaveURL(/maintenances\.php/) + await expect(page.getByRole('heading', { name: 'Manutenzioni' })).toBeVisible() + + await page.locator('.card-header a', { hasText: 'Calendario' }).first().click() + await expect(page).toHaveURL(/calendar\.php/) + await expect(page.getByRole('heading', { name: 'Calendario manutenzioni' })).toBeVisible() + + await page.locator('.card-header a', { hasText: 'Calendario cumulativo' }).click() + await expect(page).toHaveURL(/calendar_global\.php/) + await expect(page.getByRole('heading', { name: 'Calendario cumulativo' })).toBeVisible() + + await page.locator('.card-header a', { hasText: 'Solo manutenzioni' }).click() + await expect(page).toHaveURL(/calendar\.php/) + + await page.locator('.card-header a', { hasText: 'Elenco' }).click() + await expect(page).toHaveURL(/maintenances\.php/) + + await page.locator('.card-header a', { hasText: 'Registro' }).first().click() + await expect(page).toHaveURL(/manutenzioni\/index\.php/) + }) + + test('the breadcrumb leads from the detail page back to the registry', async ({ page }) => { + await page.goto(`userarea/manutenzioni/equipment.php?id=${equipmentId}`) + + await page.locator('.breadcrumb a', { hasText: 'Registro attrezzature' }).click() + await expect(page).toHaveURL(/manutenzioni\/index\.php/) + await expect(page.getByRole('heading', { name: 'Registro attrezzature' })).toBeVisible() + }) + + test('the categories page leads back to the registry', async ({ page }) => { + await page.goto(CATEGORIES) + + await page.locator('.card-header a', { hasText: 'Registro' }).first().click() + await expect(page).toHaveURL(/manutenzioni\/index\.php/) + }) + + test('«Scheda PDF» opens the print view in a new browser tab', async ({ page, context }) => { + await page.goto(`userarea/manutenzioni/equipment.php?id=${equipmentId}`) + + const [printPage] = await Promise.all([ + context.waitForEvent('page'), + page.locator('.card-header a', { hasText: 'Scheda PDF' }).click(), + ]) + + await printPage.waitForLoadState() + await expect(printPage).toHaveURL(/print\.php\?id=\d+/) + await expect(printPage.locator('.form-ref')).toContainText('mod. 6.3-1') + await printPage.close() + }) + + test('the combined calendar leads to the equipment detail page', async ({ page }) => { + // a due date we can actually click + await page.goto(GLOBAL_CALENDAR) + await page.locator('.fc-view-harness').waitFor() + + const events = page.locator('.fc-event') + if (await events.count() === 0) { + test.skip(true, 'no event in the current month') + } + + const first = events.first() + const layerUrl = await first.evaluate((el) => el.getAttribute('href')) + // FullCalendar uses no href: the click is handled in JS, so we assert on the navigation + expect(layerUrl).toBeNull() + + await first.click() + await page.waitForLoadState('load') + expect(page.url()).toMatch(/equipment\.php|scadenzario|trainings\.php/) + }) +}) diff --git a/tests/e2e/permissions.spec.js b/tests/e2e/permissions.spec.js new file mode 100644 index 0000000..259f014 --- /dev/null +++ b/tests/e2e/permissions.spec.js @@ -0,0 +1,73 @@ +const { test, expect } = require('@playwright/test') +const path = require('path') +const { REGISTRY, MAINTENANCES, CALENDAR, CATEGORIES, createEquipment, name } = require('./helpers/fixtures') + +/** + * The interface hides the buttons, but the decision belongs to the server: here + * we check that whoever lacks the permissions sees nothing and cannot get there + * by typing the address either. + */ + +const LOW_STATE = path.join(__dirname, '.auth', 'state-low.json') + +test.describe('User without permissions', () => { + test.use({ storageState: LOW_STATE }) + + for (const [label, url] of [ + ['registry', REGISTRY], + ['maintenances', MAINTENANCES], + ['calendar', CALENDAR], + ['categories', CATEGORIES], + ]) { + test(`the «${label}» page answers 403 even when the address is typed by hand`, async ({ page }) => { + const response = await page.goto(url) + expect(response.status()).toBe(403) + await expect(page.locator('body')).toContainText('Permesso negato') + }) + } + + test('the «Manutenzioni» menu entry is not rendered', async ({ page }) => { + await page.goto('userarea/production_dashboard.php') + await expect(page.locator('.sidebar-wrapper')).toBeVisible() + await expect(page.locator('.sidebar-wrapper .menu-title', { hasText: 'Manutenzioni' })).toHaveCount(0) + }) + + test('the write endpoints answer 403', async ({ page }) => { + const response = await page.request.post('userarea/manutenzioni/ajax/save_equipment.php', { + form: { name: name('tentativo'), category_id: '1', status: 'active' }, + }) + + expect(response.status()).toBe(403) + expect(await response.json()).toMatchObject({ success: false }) + }) +}) + +test.describe('User with permissions', () => { + test('sees the management buttons on the registry and the detail page', async ({ page }) => { + const equipmentId = await createEquipment(page, name('Macchina permessi')) + + await page.goto(REGISTRY) + await expect(page.locator('#btnAddEquipment')).toBeVisible() + + await page.goto(`userarea/manutenzioni/equipment.php?id=${equipmentId}`) + await expect(page.locator('#btnEditEquipment')).toBeVisible() + await expect(page.locator('#btnAddMaintenance')).toBeVisible() + await expect(page.locator('#btnAddPhotos')).toBeVisible() + await expect(page.locator('#btnAddDocs')).toBeVisible() + }) + + test('the «Manutenzioni» menu entry opens all of its pages', async ({ page }) => { + await page.goto(REGISTRY) + + const menu = page.locator('.sidebar-wrapper li', { has: page.locator('.menu-title', { hasText: 'Manutenzioni' }) }) + await expect(menu).toBeVisible() + + const links = menu.locator('ul a') + await expect(links).toHaveCount(5) + + const expected = [ + 'Registro Attrezzature', 'Manutenzioni', 'Calendario', 'Calendario Cumulativo', 'Categorie', + ] + expect((await links.allTextContents()).map((t) => t.trim())).toEqual(expected) + }) +}) diff --git a/tests/e2e/print.spec.js b/tests/e2e/print.spec.js new file mode 100644 index 0000000..90b23e7 --- /dev/null +++ b/tests/e2e/print.spec.js @@ -0,0 +1,53 @@ +const { test, expect } = require('@playwright/test') +const path = require('path') +const { createEquipment, createMaintenance, submitAndReload, waitForModal, name } = require('./helpers/fixtures') + +test.describe('Printable sheet mod. 6.3-1', () => { + let equipmentId + const title = name('Lavoro da stampare') + + test.beforeAll(async ({ browser }) => { + const page = await browser.newPage() + equipmentId = await createEquipment(page, name('Macchina stampa')) + await createMaintenance(page, equipmentId, { code: 'A', title, months: 12 }) + + await page.goto(`userarea/manutenzioni/equipment.php?id=${equipmentId}`) + await page.locator(`.mnt-item-card:has-text("${title}") .btn-register`).first().click() + await waitForModal(page, '#interventionModal') + await page.fill('#ivDate', '2026-05-04') + await page.fill('#ivNotes', 'Intervento di prova per la stampa') + await submitAndReload(page, '#interventionForm button[type="submit"]') + await page.close() + }) + + test('the sheet reproduces the paper form', async ({ page }) => { + await page.goto(`userarea/manutenzioni/print.php?id=${equipmentId}`) + + await expect(page.getByRole('heading', { name: 'Scheda manutenzione' })).toBeVisible() + await expect(page.locator('.form-ref')).toContainText('ZIBOGOMMA') + await expect(page.locator('.form-ref')).toContainText('mod. 6.3-1') + await expect(page.locator('.machine-row')).toContainText(name('Macchina stampa')) + + await expect(page.locator('caption', { hasText: 'Interventi previsti' })).toBeVisible() + await expect(page.locator('caption', { hasText: 'Registro interventi eseguiti' })).toBeVisible() + + await expect(page.locator('table').first()).toContainText(title) + await expect(page.locator('table').nth(1)).toContainText('04/05/2026') + await expect(page.locator('table').nth(1)).toContainText('Intervento di prova') + }) + + test('in print mode the toolbar disappears and the PDF is generated', async ({ page }, testInfo) => { + await page.goto(`userarea/manutenzioni/print.php?id=${equipmentId}`) + await expect(page.locator('.toolbar')).toBeVisible() + + await page.emulateMedia({ media: 'print' }) + await expect(page.locator('.toolbar')).toBeHidden() + + const pdf = path.join(testInfo.outputDir, 'maintenance-sheet.pdf') + await page.pdf({ path: pdf, format: 'A4', printBackground: true }) + + const fs = require('fs') + expect(fs.statSync(pdf).size).toBeGreaterThan(1000) + await testInfo.attach('maintenance-sheet.pdf', { path: pdf, contentType: 'application/pdf' }) + }) +}) diff --git a/tests/e2e/registry.spec.js b/tests/e2e/registry.spec.js new file mode 100644 index 0000000..cb2dcfc --- /dev/null +++ b/tests/e2e/registry.spec.js @@ -0,0 +1,102 @@ +const { test, expect } = require('@playwright/test') +const { REGISTRY, CATEGORIES, createEquipment, submitAndReload, waitForModal, name, listItem } = require('./helpers/fixtures') +const { scalar } = require('./helpers/env') + +test.describe('Equipment registry', () => { + test('the list shows rows, category dots and status badges', async ({ page }) => { + await page.goto(REGISTRY) + + await expect(page.getByRole('heading', { name: 'Registro attrezzature' })).toBeVisible() + // the list has one visible entry in either guise (table or card) + await expect(page.locator('#equipmentList tr, #equipmentList .mnt-item-card') + .filter({ visible: true }).first()).toBeVisible() + // the category is readable in either guise (dot in the table, text on the cards) + await expect(page.locator('#equipmentList')).toContainText('Attrezzature') + }) + + test('the edit modal opens with the fields already filled in', async ({ page }) => { + const label = name('Registro precompilato') + const id = await createEquipment(page, label) + + await page.goto(`${REGISTRY}?q=${encodeURIComponent(label)}`) + await listItem(page, `${label}`).locator('.btn-edit').click() + + await expect(page.locator('#equipmentModal')).toBeVisible() + await expect(page.locator('#equipmentModalTitle')).toHaveText('Modifica attrezzatura') + await expect(page.locator('#eqName')).toHaveValue(label) + await expect(page.locator('#eqId')).toHaveValue(String(id)) + }) + + test('the «production line» field appears only for the categories that need it', async ({ page }) => { + await page.goto(REGISTRY) + await page.click('#btnAddEquipment') + await waitForModal(page, '#equipmentModal') + + await page.selectOption('#eqCategory', { label: 'Attrezzature' }) + await expect(page.locator('#eqLineWrap')).toBeHidden() + + await page.selectOption('#eqCategory', { label: 'Linea di produzione' }) + await expect(page.locator('#eqLineWrap')).toBeVisible() + await expect(page.locator('#eqLine')).toHaveAttribute('required', '') + + // without a line the save must not go through: the field is required, so + // the browser's native validation stops it and the modal stays open + await page.fill('#eqName', name('Senza linea')) + await page.click('#equipmentForm button[type="submit"]') + + await expect(page.locator('#equipmentModal')).toBeVisible() + const lineIsInvalid = await page.locator('#eqLine').evaluate((el) => !el.checkValidity()) + expect(lineIsInvalid, 'the line field should come out invalid').toBe(true) + + expect(scalar(`SELECT COUNT(*) FROM inv_equipment WHERE name = '${name('Senza linea')}'`)).toBe('0') + }) + + test('the filters really narrow the list', async ({ page }) => { + const label = name('Filtrata') + await createEquipment(page, label) + + await page.goto(`${REGISTRY}?q=${encodeURIComponent(label)}`) + await expect(listItem(page, `${label}`)).toBeVisible() + + await page.goto(`${REGISTRY}?q=${encodeURIComponent(label)}&status=decommissioned`) + await expect(listItem(page, `${label}`)).toHaveCount(0) + await expect(page.locator('.empty-state')).toBeVisible() + }) + + test('the delete confirmation warns before removing anything', async ({ page }) => { + const label = name('Da eliminare') + await createEquipment(page, label) + + await page.goto(`${REGISTRY}?q=${encodeURIComponent(label)}`) + await listItem(page, `${label}`).locator('.btn-delete').click() + + await expect(page.locator('.swal2-popup')).toBeVisible() + await expect(page.locator('.swal2-title')).toContainText(label) + + await submitAndReload(page, '.swal2-confirm') + + expect(scalar(`SELECT COUNT(*) FROM inv_equipment WHERE name = '${label}'`)).toBe('0') + }) +}) + +test.describe('Categories', () => { + test('CRUD from the modal, and categories in use cannot be deleted', async ({ page }) => { + const label = name('Categoria') + + await page.goto(CATEGORIES) + await page.click('#btnAddCategory') + await waitForModal(page, '#categoryModal') + + await page.fill('#catName', label) + await page.check('#catRequiresLine') + await submitAndReload(page, '#categoryForm button[type="submit"]') + + const row = listItem(page, `${label}`) + await expect(row).toBeVisible() + await expect(row).toContainText('Richiede linea') + + // a category already in use cannot be removed + await listItem(page, 'Attrezzature').locator('.btn-delete').click() + await expect(page.locator('.swal2-title')).toContainText('Impossibile eliminare') + }) +}) diff --git a/tests/e2e/responsive.spec.js b/tests/e2e/responsive.spec.js new file mode 100644 index 0000000..24a1e33 --- /dev/null +++ b/tests/e2e/responsive.spec.js @@ -0,0 +1,156 @@ +const { test, expect } = require('@playwright/test') +const path = require('path') +const fs = require('fs') +const { + REGISTRY, MAINTENANCES, CALENDAR, createEquipment, createMaintenance, waitForModal, name, +} = require('./helpers/fixtures') + +/** + * Layout at the viewport of the current run: the same test runs on phone (390), + * tablet (820 — the device named in the spec) and desktop (1440). + * The images land in tests/e2e/screenshots/ named after the viewport, so the + * visual review is «flip through pictures» instead of «click everything». + */ + +const SHOTS = path.join(__dirname, 'screenshots') +fs.mkdirSync(SHOTS, { recursive: true }) + +let equipmentId + +test.beforeAll(async ({ browser }) => { + const page = await browser.newPage() + equipmentId = await createEquipment(page, name('Macchina responsive')) + await createMaintenance(page, equipmentId, { code: 'R', title: name('Lavoro responsive'), months: 4 }) + await page.close() +}) + +/** No page may scroll horizontally. */ +async function expectNoHorizontalScroll(page, label) { + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth - document.documentElement.clientWidth + ) + expect(overflow, `${label}: the page scrolls horizontally by ${overflow}px`).toBeLessThanOrEqual(1) +} + +test.describe('Layout', () => { + test('registry, maintenance list, detail page and calendar', async ({ page }, testInfo) => { + const device = testInfo.project.name + + for (const [label, url] of [ + ['registry', REGISTRY], + ['maintenances', MAINTENANCES], + ['detail', `userarea/manutenzioni/equipment.php?id=${equipmentId}`], + ['calendar', CALENDAR], + ]) { + await page.goto(url) + await page.waitForLoadState('networkidle') + await expectNoHorizontalScroll(page, `${device}/${label}`) + await page.screenshot({ path: path.join(SHOTS, `${device}-${label}.png`), fullPage: true }) + } + }) + + test('the lists use cards on the phone and the table from tablet up', async ({ page }, testInfo) => { + await page.goto(REGISTRY) + + const cards = page.locator('.d-md-none .mnt-item-card').first() + const table = page.locator('.d-none.d-md-block table').first() + const narrow = testInfo.project.use.viewport.width < 768 + + if (narrow) { + await expect(cards).toBeVisible() + await expect(table).toBeHidden() + } else { + await expect(table).toBeVisible() + await expect(cards).toBeHidden() + } + }) + + test('the modal stays usable: title and buttons within reach', async ({ page }, testInfo) => { + await page.goto(`userarea/manutenzioni/equipment.php?id=${equipmentId}`) + await page.locator(`.mnt-item-card:has-text("${name('Lavoro responsive')}") .btn-register`).first().click() + await waitForModal(page, '#interventionModal') + + await expect(page.locator('#interventionModalTitle')).toBeVisible() + await expect(page.locator('#ivDate')).toBeVisible() + + const submit = page.locator('#interventionForm button[type="submit"]') + await submit.scrollIntoViewIfNeeded() + await expect(submit).toBeVisible() + + await page.screenshot({ path: path.join(SHOTS, `${testInfo.project.name}-intervention-modal.png`) }) + await expectNoHorizontalScroll(page, `${testInfo.project.name}/modal`) + }) + + test('the sidebar does not change width after loading', async ({ page }) => { + // The collapse class used to land on DOMContentLoaded and the sidebar slid + // from 250px to 70px on every page change. + await page.goto(REGISTRY, { waitUntil: 'commit' }) + await page.waitForSelector('.sidebar-wrapper', { state: 'attached' }) + + const widths = new Set() + for (let i = 0; i < 12; i++) { + const width = await page.evaluate(() => { + const nav = document.querySelector('.sidebar-wrapper') + return nav ? Math.round(nav.getBoundingClientRect().width) : null + }) + if (width !== null) widths.add(width) + await page.waitForTimeout(40) + } + + expect([...widths], 'the sidebar resizes before your eyes').toHaveLength(1) + }) + + test('the maintenance table does not overflow its card', async ({ page }, testInfo) => { + // From tablet down the table may scroll inside its container: that is the + // intended behaviour. On desktop it must fit entirely, otherwise «Prossima» + // and «Azioni» end up out of sight. + test.skip(testInfo.project.use.viewport.width < 1440, 'checked on desktop only') + + await page.goto(MAINTENANCES) + const overflow = await page.locator('.table-responsive').first().evaluate( + (el) => el.scrollWidth - el.clientWidth + ) + + expect(overflow, `the table overflows by ${overflow}px`).toBeLessThanOrEqual(1) + }) + + test('signing with a finger works and the page does not scroll under the stroke', async ({ page }, testInfo) => { + test.skip(!testInfo.project.use.hasTouch, 'only on the viewports with a touch screen') + + await page.goto(`userarea/manutenzioni/equipment.php?id=${equipmentId}`) + await page.locator(`.mnt-item-card:has-text("${name('Lavoro responsive')}") .btn-register`).first().click() + await waitForModal(page, '#interventionModal') + + const canvas = page.locator('#ivSignatureCanvas') + await canvas.scrollIntoViewIfNeeded() + const box = await canvas.boundingBox() + + const before = await canvas.screenshot() + const scrollBefore = await page.evaluate(() => document.querySelector('#interventionModal').scrollTop) + + // finger gesture: touchstart / touchmove / touchend with real Touch objects + await page.evaluate(({ x, y, w }) => { + const el = document.getElementById('ivSignatureCanvas') + const touch = (cx, cy) => new Touch({ identifier: 1, target: el, clientX: cx, clientY: cy }) + const fire = (type, cx, cy) => + el.dispatchEvent(new TouchEvent(type, { + bubbles: true, cancelable: true, + touches: type === 'touchend' ? [] : [touch(cx, cy)], + targetTouches: type === 'touchend' ? [] : [touch(cx, cy)], + changedTouches: [touch(cx, cy)], + })) + + fire('touchstart', x, y) + for (let i = 1; i <= 10; i++) fire('touchmove', x + (w / 2) * (i / 10), y + (i % 2 ? 18 : -18)) + fire('touchend', x + w / 2, y) + }, { x: box.x + 30, y: box.y + box.height / 2, w: box.width - 60 }) + + const after = await canvas.screenshot() + expect(before.equals(after), 'no stroke appeared on the canvas').toBe(false) + + const scrollAfter = await page.evaluate(() => document.querySelector('#interventionModal').scrollTop) + expect(scrollAfter, 'the page scrolled while signing').toBe(scrollBefore) + + await page.screenshot({ path: path.join(SHOTS, `${testInfo.project.name}-signature.png`) }) + }) +}) diff --git a/tests/e2e/screenshots/desktop-calendar.png b/tests/e2e/screenshots/desktop-calendar.png new file mode 100644 index 0000000..01044d4 Binary files /dev/null and b/tests/e2e/screenshots/desktop-calendar.png differ diff --git a/tests/e2e/screenshots/desktop-detail.png b/tests/e2e/screenshots/desktop-detail.png new file mode 100644 index 0000000..c14126c Binary files /dev/null and b/tests/e2e/screenshots/desktop-detail.png differ diff --git a/tests/e2e/screenshots/desktop-intervention-modal.png b/tests/e2e/screenshots/desktop-intervention-modal.png new file mode 100644 index 0000000..745e1e0 Binary files /dev/null and b/tests/e2e/screenshots/desktop-intervention-modal.png differ diff --git a/tests/e2e/screenshots/desktop-maintenances.png b/tests/e2e/screenshots/desktop-maintenances.png new file mode 100644 index 0000000..621d052 Binary files /dev/null and b/tests/e2e/screenshots/desktop-maintenances.png differ diff --git a/tests/e2e/screenshots/desktop-registry.png b/tests/e2e/screenshots/desktop-registry.png new file mode 100644 index 0000000..b8b8c50 Binary files /dev/null and b/tests/e2e/screenshots/desktop-registry.png differ diff --git a/tests/e2e/screenshots/mobile-calendar.png b/tests/e2e/screenshots/mobile-calendar.png new file mode 100644 index 0000000..c6e85a3 Binary files /dev/null and b/tests/e2e/screenshots/mobile-calendar.png differ diff --git a/tests/e2e/screenshots/mobile-detail.png b/tests/e2e/screenshots/mobile-detail.png new file mode 100644 index 0000000..9f12d57 Binary files /dev/null and b/tests/e2e/screenshots/mobile-detail.png differ diff --git a/tests/e2e/screenshots/mobile-intervention-modal.png b/tests/e2e/screenshots/mobile-intervention-modal.png new file mode 100644 index 0000000..dca0c10 Binary files /dev/null and b/tests/e2e/screenshots/mobile-intervention-modal.png differ diff --git a/tests/e2e/screenshots/mobile-maintenances.png b/tests/e2e/screenshots/mobile-maintenances.png new file mode 100644 index 0000000..10ca5c0 Binary files /dev/null and b/tests/e2e/screenshots/mobile-maintenances.png differ diff --git a/tests/e2e/screenshots/mobile-registry.png b/tests/e2e/screenshots/mobile-registry.png new file mode 100644 index 0000000..72d5801 Binary files /dev/null and b/tests/e2e/screenshots/mobile-registry.png differ diff --git a/tests/e2e/screenshots/mobile-signature.png b/tests/e2e/screenshots/mobile-signature.png new file mode 100644 index 0000000..88964f8 Binary files /dev/null and b/tests/e2e/screenshots/mobile-signature.png differ diff --git a/tests/e2e/screenshots/tablet-calendar.png b/tests/e2e/screenshots/tablet-calendar.png new file mode 100644 index 0000000..a68c084 Binary files /dev/null and b/tests/e2e/screenshots/tablet-calendar.png differ diff --git a/tests/e2e/screenshots/tablet-detail.png b/tests/e2e/screenshots/tablet-detail.png new file mode 100644 index 0000000..d15f9d0 Binary files /dev/null and b/tests/e2e/screenshots/tablet-detail.png differ diff --git a/tests/e2e/screenshots/tablet-intervention-modal.png b/tests/e2e/screenshots/tablet-intervention-modal.png new file mode 100644 index 0000000..2581e42 Binary files /dev/null and b/tests/e2e/screenshots/tablet-intervention-modal.png differ diff --git a/tests/e2e/screenshots/tablet-maintenances.png b/tests/e2e/screenshots/tablet-maintenances.png new file mode 100644 index 0000000..e6edc41 Binary files /dev/null and b/tests/e2e/screenshots/tablet-maintenances.png differ diff --git a/tests/e2e/screenshots/tablet-registry.png b/tests/e2e/screenshots/tablet-registry.png new file mode 100644 index 0000000..52675d1 Binary files /dev/null and b/tests/e2e/screenshots/tablet-registry.png differ diff --git a/tests/e2e/screenshots/tablet-signature.png b/tests/e2e/screenshots/tablet-signature.png new file mode 100644 index 0000000..dbd1876 Binary files /dev/null and b/tests/e2e/screenshots/tablet-signature.png differ diff --git a/tests/e2e/stitch.py b/tests/e2e/stitch.py new file mode 100644 index 0000000..f2fc317 --- /dev/null +++ b/tests/e2e/stitch.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +""" +Joins the videos recorded by Playwright into a handful of watchable files, +writing the title of the running test across the top. + + python3 tests/e2e/stitch.py + +The titles come from tests/e2e/.report/results.json (the JSON reporter, enabled +with MNT_VIDEO=1): that way the caption is the real test name and not the +truncated folder name. Fragments with no test attached — the setup pages created +in beforeAll — are dropped: those were what made the recording look choppy. + +Grouped per device, because concatenation wants a single resolution: + demo.mp4 the guided tour (tour.spec.js) + suite-funzionali.mp4 registry, maintenances, signature, attachments, permissions… + suite-impaginazione.mp4 layout suite (responsive.spec.js) + +The .mp4 names are kept as they are so the files already in tests/e2e/video/ +keep being overwritten instead of piling up next to renamed copies. +""" + +import json +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + +ROOT = Path(__file__).resolve().parents[2] +ARTIFACTS = ROOT / 'tests' / 'e2e' / '.artifacts' +RESULTS = ROOT / 'tests' / 'e2e' / '.report' / 'results.json' +# optional argument: destination subfolder (e.g. the viewport under test) +SUBDIR = sys.argv[1] if len(sys.argv) > 1 else '' +OUT = ROOT / 'tests' / 'e2e' / 'video' / SUBDIR if SUBDIR else ROOT / 'tests' / 'e2e' / 'video' + +WIDTH, HEIGHT = 1280, 720 +BAR_HEIGHT = 46 +FPS = 15 + +FONTS = [ + '/System/Library/Fonts/Supplemental/Arial.ttf', + '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', + '/Library/Fonts/Arial.ttf', +] + + +def font(size): + for path in FONTS: + if os.path.exists(path): + return ImageFont.truetype(path, size) + return ImageFont.load_default() + + +def collect(): + """[(video, group, caption)] in execution order.""" + if not RESULTS.exists(): + sys.exit('Missing tests/e2e/.report/results.json — run this first: make video') + + report = json.loads(RESULTS.read_text()) + items = [] + + def walk(suite, trail): + title = suite.get('title', '') + path = trail + [title] if title and not title.endswith('.js') else trail + + for spec in suite.get('specs', []): + for test in spec.get('tests', []): + for result in test.get('results', []): + videos = [ + a['path'] for a in result.get('attachments', []) + if a.get('name') == 'video' and a.get('path') + ] + title = ' › '.join(path + [spec.get('title', '')]) + + for number, video in enumerate(videos, 1): + # A test can have several videos when it opens a second + # window (e.g. printing with target="_blank"). + suffix = f' [window {number}]' if len(videos) > 1 else '' + items.append({ + 'file': spec.get('file', ''), + 'title': title + suffix, + 'video': video, + 'ok': result.get('status') == 'passed', + }) + + for child in suite.get('suites', []): + walk(child, path) + + for suite in report.get('suites', []): + walk(suite, []) + + return items + + +def group_of(item): + # Each viewport already has its own folder: here it is enough to split the + # guided tour and the layout tests off from the rest. + if 'tour.spec' in item['file']: + return 'demo' + if 'responsive.spec' in item['file']: + return 'suite-impaginazione' + return 'suite-funzionali' + + +def make_label(text, index, total, ok, dest): + """Caption strip with the test title, to be overlaid at the top.""" + image = Image.new('RGBA', (WIDTH, BAR_HEIGHT), (30, 58, 68, 235)) + draw = ImageDraw.Draw(image) + + # a dot instead of a check mark: not every font carries the glyph + colour = (126, 217, 165) if ok else (255, 138, 138) + draw.ellipse((18, BAR_HEIGHT // 2 - 6, 30, BAR_HEIGHT // 2 + 6), fill=colour) + draw.text((42, 15), f'{index}/{total}', font=font(17), fill=(150, 185, 195)) + + label = text + caption_font = font(19) + max_width = WIDTH - 130 + while draw.textlength(label, font=caption_font) > max_width and len(label) > 10: + label = label[:-2] + if label != text: + label += '…' + + draw.text((110, 14), label, font=caption_font, fill=(255, 255, 255)) + image.save(dest) + + +def normalise(video, label, dest): + """Scales to 1280×720 and burns in the caption.""" + subprocess.run([ + 'ffmpeg', '-y', '-loglevel', 'error', + '-i', str(video), '-i', str(label), + '-filter_complex', + f'[0:v]scale={WIDTH}:{HEIGHT}:force_original_aspect_ratio=decrease,' + f'pad={WIDTH}:{HEIGHT}:(ow-iw)/2:(oh-ih)/2:color=white,fps={FPS}[v];' + f'[v][1:v]overlay=0:0[out]', + '-map', '[out]', + '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '26', + '-pix_fmt', 'yuv420p', '-an', str(dest), + ], check=True) + + +def concat(parts, dest): + listing = dest.with_suffix('.txt') + listing.write_text(''.join(f"file '{p}'\n" for p in parts)) + subprocess.run([ + 'ffmpeg', '-y', '-loglevel', 'error', + '-f', 'concat', '-safe', '0', '-i', str(listing), + '-c', 'copy', str(dest), + ], check=True) + listing.unlink() + + +def main(): + if not shutil.which('ffmpeg'): + sys.exit('ffmpeg not found: brew install ffmpeg') + + items = collect() + if not items: + sys.exit('No video in the report. Run: make video') + + on_disk = len(list(ARTIFACTS.rglob('*.webm'))) if ARTIFACTS.exists() else 0 + print(f'Tests with video: {len(items)} (files on disk: {on_disk})') + if on_disk > len(items): + print(f' {on_disk - len(items)} setup fragments dropped (beforeAll pages)') + + OUT.mkdir(parents=True, exist_ok=True) + groups = {} + for item in items: + groups.setdefault(group_of(item), []).append(item) + + workdir = Path(tempfile.mkdtemp()) + manifest = [] + + try: + for group, entries in groups.items(): + parts = [] + for i, entry in enumerate(entries, 1): + label = workdir / f'{group}_{i}.png' + part = workdir / f'{group}_{i}.mp4' + make_label(entry['title'], i, len(entries), entry['ok'], label) + normalise(entry['video'], label, part) + parts.append(part) + manifest.append(f'{group}: {i}. {entry["title"]}') + + destination = OUT / f'{group}.mp4' + if len(parts) == 1: + shutil.copy(parts[0], destination) + else: + concat(parts, destination) + print(f' {destination.name} ({len(parts)} fragments)') + finally: + shutil.rmtree(workdir, ignore_errors=True) + + (OUT / 'contenuto.txt').write_text('\n'.join(manifest) + '\n') + print(f'\nFull list of fragments: {OUT / "contenuto.txt"}') + + for file in sorted(OUT.glob('*.mp4')): + size = file.stat().st_size / 1024 / 1024 + print(f' {file.name:<24} {size:.1f} MB') + + +if __name__ == '__main__': + main() diff --git a/tests/e2e/tour.spec.js b/tests/e2e/tour.spec.js new file mode 100644 index 0000000..8f956f8 --- /dev/null +++ b/tests/e2e/tour.spec.js @@ -0,0 +1,194 @@ +const { test, expect } = require('@playwright/test') +const { + REGISTRY, MAINTENANCES, CALENDAR, GLOBAL_CALENDAR, CATEGORIES, + submitAndReload, waitForModal, name, +} = require('./helpers/fixtures') + +/** + * Guided tour of the module in a single session: it exists to produce ONE video + * instead of thirty-four fragments (make demo). + * + * It is a real test too: if a step does not work, it fails. + * The pauses only exist to keep the recording readable. + */ + +const BEAT = 900 + +test.describe('Guided tour of the module', () => { + test('from the registry to the signature, through printing and calendars', async ({ page }) => { + test.setTimeout(180_000) + + const pause = (ms = BEAT) => page.waitForTimeout(ms) + + // -------------------------------------------------- 1. the registry + await page.goto(REGISTRY) + await expect(page.getByRole('heading', { name: 'Registro attrezzature' })).toBeVisible() + await pause(1500) + + // filter by category: the buildings and plants imported from the XLS + await page.selectOption('select[name="category"]', { label: 'Edifici e impianti' }) + await page.waitForLoadState('load') + await pause() + + // free-text search + await page.goto(`${REGISTRY}?q=CARRELLO`) + await pause() + + // ------------------------------------ 2. an actually imported record + const carmi = page.locator('a[href*="equipment.php"]').filter({ visible: true }).first() + + if (await carmi.count()) { + await carmi.click() + await page.waitForLoadState('load') + await pause(1200) + + // the eight A–H operations of the paper sheet mod. 6.3-1 + await expect(page.locator('.mnt-item-card').first()).toBeVisible() + await page.mouse.wheel(0, 600) + await pause(1200) + await page.mouse.wheel(0, -600) + } + + // ---------------------------------------------- 3. new equipment + await page.goto(REGISTRY) + await page.click('#btnAddEquipment') + await waitForModal(page, '#equipmentModal') + await pause() + + const equipmentName = name('Tour — Compressore') + await page.fill('#eqName', equipmentName) + await page.selectOption('#eqCategory', { label: 'Attrezzature' }) + await page.fill('#eqRegistration', 'TOUR-001') + await page.fill('#eqManufacturer', 'Atlas Copco') + await page.fill('#eqLocation', 'Centrale aria compressa') + await pause() + + // the «production line» category also asks for the line itself + await page.selectOption('#eqCategory', { label: 'Linea di produzione' }) + await expect(page.locator('#eqLineWrap')).toBeVisible() + await pause() + await page.selectOption('#eqCategory', { label: 'Attrezzature' }) + await pause(400) + + await submitAndReload(page, '#equipmentForm button[type="submit"]') + await page.goto(`${REGISTRY}?q=${encodeURIComponent(equipmentName)}`) + await pause() + + const href = await page.locator('a[href*="equipment.php?id="]').first().getAttribute('href') + const equipmentId = Number(href.match(/id=(\d+)/)[1]) + + // -------------------------------------------- 4. a maintenance task + await page.goto(`userarea/manutenzioni/equipment.php?id=${equipmentId}`) + await pause() + await page.click('#btnAddMaintenance') + await waitForModal(page, '#maintenanceModal') + await pause() + + const maintenanceTitle = name('Tour — Controllo filtri') + await page.fill('#mtCode', 'A') + await page.fill('#mtTitle', maintenanceTitle) + await page.fill('#mtDescription', 'Pulizia filtri e verifica perdite del circuito.') + await page.selectOption('#mtFrequencyUnit', 'month') + await page.fill('#mtFrequencyValue', '3') + await page.fill('#mtAlertDays', '10') + await page.check('#mtCritical') + await pause() + + // external tasks require a supplier + await page.selectOption('#mtExecution', 'external') + await expect(page.locator('#mtSupplierWrap')).toBeVisible() + await pause(700) + await page.selectOption('#mtExecution', 'internal') + await pause(400) + + await submitAndReload(page, '#maintenanceForm button[type="submit"]') + await expect(page.locator(`.mnt-item-card:has-text("${maintenanceTitle}")`).first()).toBeVisible() + await pause(1200) + + // ------------------------------ 5. intervention and hand signature + await page.locator(`.mnt-item-card:has-text("${maintenanceTitle}") .btn-register`).first().click() + await waitForModal(page, '#interventionModal') + await pause() + + await page.fill('#ivDate', '2026-06-10') + await page.selectOption('#ivResult', 'compliant') + await page.fill('#ivNotes', 'Filtri puliti, nessuna perdita rilevata.') + await page.fill('#ivMaterials', 'Filtro aria PD32') + await pause() + + const canvas = page.locator('#ivSignatureCanvas') + await canvas.scrollIntoViewIfNeeded() + const box = await canvas.boundingBox() + + await page.mouse.move(box.x + 40, box.y + box.height / 2) + await page.mouse.down() + for (let i = 1; i <= 40; i++) { + await page.mouse.move( + box.x + 40 + ((box.width - 80) * i) / 40, + box.y + box.height / 2 + Math.sin(i / 2) * 26 + ) + await page.waitForTimeout(12) + } + await page.mouse.up() + await pause(1200) + + await submitAndReload(page, '#interventionForm button[type="submit"]') + + // the due date moved three months on from the intervention + const card = page.locator(`.mnt-item-card:has-text("${maintenanceTitle}")`).first() + await expect(card).toContainText('Ultimo: 10/06/2026') + await expect(card).toContainText('Prossimo: 10/09/2026') + await pause(1500) + + // ---------------------------------------------------- 6. the history + await card.locator('.btn-toggle-history').click() + await expect(card.locator('.mnt-timeline img[alt="Firma"]').first()).toBeVisible() + await pause(1800) + + // --------------------------------------------- 7. list and printing + await page.goto(MAINTENANCES) + await pause(1500) + + await page.goto(`userarea/manutenzioni/print.php?id=${equipmentId}`) + await expect(page.locator('.form-ref')).toContainText('mod. 6.3-1') + await pause(2000) + + // -------------------------------------------------- 8. the calendars + await page.goto(CALENDAR) + await page.locator('.fc-view-harness').waitFor() + await pause() + + for (let i = 0; i < 12; i++) { + const label = await page.locator('.fc-toolbar-title').textContent() + if (/settembre 2026/i.test(label)) break + await page.locator('.fc-next-button').click() + await page.waitForTimeout(160) + } + await expect( + page.locator('.fc-event, .fc-list-event', { hasText: equipmentName }).first() + ).toBeVisible() + await pause(1500) + + await page.goto(GLOBAL_CALENDAR) + await page.locator('.fc-view-harness').waitFor() + await pause() + + for (const layer of ['Scadenze', 'Formazione']) { + await page.locator('.mnt-layer-toggle', { hasText: layer }).click() + await pause(700) + } + await pause(800) + + // ------------------------------------------------- 9. the categories + await page.goto(CATEGORIES) + await expect(page.locator('#categoryList')).toContainText('Linea di produzione') + await pause(1500) + + // ------------------------------------------ 10. back to the registry + await page.goto(REGISTRY) + await page.waitForLoadState('networkidle') + await pause(1200) + await page.mouse.wheel(0, 700) + await pause(1500) + }) +}) diff --git a/tests/e2e/video/desktop/contenuto.txt b/tests/e2e/video/desktop/contenuto.txt new file mode 100644 index 0000000..5fca009 --- /dev/null +++ b/tests/e2e/video/desktop/contenuto.txt @@ -0,0 +1,64 @@ +suite-funzionali: 1. Calendari › il calendario disegna gli eventi e il click porta alla scheda +suite-funzionali: 2. Calendari › i filtri del calendario rifanno la richiesta +suite-funzionali: 3. Calendari › il calendario cumulativo accende e spegne gli strati +suite-funzionali: 4. Calendari › ogni strato porta i propri eventi +suite-funzionali: 5. Foto, copertina e documenti › la prima foto caricata diventa copertina, anche nel registro +suite-funzionali: 6. Foto, copertina e documenti › la stellina sposta la copertina sulla seconda foto +suite-funzionali: 7. Foto, copertina e documenti › la foto si elimina previa conferma +suite-funzionali: 8. Foto, copertina e documenti › i documenti si caricano e restano scaricabili +suite-funzionali: 9. Foto, copertina e documenti › un file non consentito viene rifiutato con un messaggio +suite-funzionali: 10. Allegati dentro i modali › le istruzioni allegate a una manutenzione si vedono, si riaprono e si eliminano +suite-funzionali: 11. Allegati dentro i modali › un allegato dell'intervento compare nello storico +suite-funzionali: 12. Modali: validazioni e ricarica dei valori › manutenzione senza titolo: il modulo si blocca e non salva niente +suite-funzionali: 13. Modali: validazioni e ricarica dei valori › intervento senza data: il modulo si blocca e non salva niente +suite-funzionali: 14. Modali: validazioni e ricarica dei valori › riaprendo una manutenzione i campi sono quelli salvati +suite-funzionali: 15. Modali: validazioni e ricarica dei valori › riaprendo un intervento i campi sono quelli salvati +suite-funzionali: 16. Modali: validazioni e ricarica dei valori › l'attrezzatura non si elimina se è usata in produzione +suite-funzionali: 17. Registrazione interventi › registrare un intervento completato aggiorna le date a schermo +suite-funzionali: 18. Registrazione interventi › la firma disegnata finisce nello storico +suite-funzionali: 19. Registrazione interventi › un intervento pianificato non sposta la scadenza +suite-funzionali: 20. Registrazione interventi › eliminando l'ultimo intervento completato la scadenza torna indietro +suite-funzionali: 21. Registrazione interventi › la firma si cancella con il pulsante dedicato +suite-funzionali: 22. Elenco manutenzioni › i riquadri contano e filtrano +suite-funzionali: 23. Elenco manutenzioni › lo stesso riquadro cliccato due volte toglie il filtro +suite-funzionali: 24. Elenco manutenzioni › «solo critiche» e «programmate/straordinarie» restringono l'elenco +suite-funzionali: 25. Elenco manutenzioni › lo stato vuoto compare quando nessuna riga corrisponde +suite-funzionali: 26. Elenco manutenzioni › la registrazione rapida dall'elenco aggiorna la scadenza +suite-funzionali: 27. Registro: stati e filtri › lo stato vuoto compare quando la ricerca non trova nulla +suite-funzionali: 28. Registro: stati e filtri › cambiando stato cambia il badge e la riga sparisce dal filtro «Attivo» +suite-funzionali: 29. Registro: stati e filtri › sul telefono la card ha anche modifica ed elimina +suite-funzionali: 30. Modale manutenzione › «al bisogno» nasconde il campo della frequenza +suite-funzionali: 31. Modale manutenzione › «esterna» chiede il fornitore e blocca il salvataggio senza +suite-funzionali: 32. Modale manutenzione › una manutenzione critica è evidenziata nella scheda +suite-funzionali: 33. Modale manutenzione › la nota di frequenza resta visibile accanto alla frequenza +suite-funzionali: 34. Modale manutenzione › una manutenzione disattivata si vede come tale e sparisce dall'elenco generale +suite-funzionali: 35. Modale manutenzione › il conteggio delle manutenzioni compare nel registro +suite-funzionali: 36. Navigazione fra le pagine › registro → manutenzioni → calendario → calendario cumulativo e ritorno +suite-funzionali: 37. Navigazione fra le pagine › dalla scheda si torna al registro con le briciole di pane +suite-funzionali: 38. Navigazione fra le pagine › dalle categorie si torna al registro +suite-funzionali: 39. Navigazione fra le pagine › «Scheda PDF» apre la stampa in una nuova scheda del browser [finestra 1] +suite-funzionali: 40. Navigazione fra le pagine › «Scheda PDF» apre la stampa in una nuova scheda del browser [finestra 2] +suite-funzionali: 41. Navigazione fra le pagine › dal calendario cumulativo si arriva alla scheda dell'attrezzatura +suite-funzionali: 42. Utente senza permessi › la pagina «registro» risponde 403 anche scrivendo l'indirizzo +suite-funzionali: 43. Utente senza permessi › la pagina «manutenzioni» risponde 403 anche scrivendo l'indirizzo +suite-funzionali: 44. Utente senza permessi › la pagina «calendario» risponde 403 anche scrivendo l'indirizzo +suite-funzionali: 45. Utente senza permessi › la pagina «categorie» risponde 403 anche scrivendo l'indirizzo +suite-funzionali: 46. Utente senza permessi › la voce di menu «Manutenzioni» non compare +suite-funzionali: 47. Utente senza permessi › gli endpoint di scrittura rispondono 403 +suite-funzionali: 48. Utente con permessi › vede i pulsanti di gestione su registro e scheda +suite-funzionali: 49. Utente con permessi › la voce di menu «Manutenzioni» apre tutte le sue pagine +suite-funzionali: 50. Scheda di stampa mod. 6.3-1 › la scheda riproduce il modulo cartaceo +suite-funzionali: 51. Scheda di stampa mod. 6.3-1 › in stampa la barra dei pulsanti sparisce e il PDF si genera +suite-funzionali: 52. Registro attrezzature › la lista mostra righe, pallini di categoria e badge di stato +suite-funzionali: 53. Registro attrezzature › il modale di modifica si apre con i campi già compilati +suite-funzionali: 54. Registro attrezzature › il campo «linea» compare solo per le categorie che lo richiedono +suite-funzionali: 55. Registro attrezzature › i filtri restringono davvero la lista +suite-funzionali: 56. Registro attrezzature › la conferma di eliminazione avvisa prima di cancellare +suite-funzionali: 57. Categorie › CRUD dal modale e blocco sulle categorie in uso +suite-impaginazione: 1. Impaginazione › registro, elenco manutenzioni, scheda e calendario +suite-impaginazione: 2. Impaginazione › le liste usano le card sul telefono e la tabella da tablet in su +suite-impaginazione: 3. Impaginazione › il modale resta utilizzabile: titolo e pulsanti raggiungibili +suite-impaginazione: 4. Impaginazione › la barra laterale non cambia larghezza dopo il caricamento +suite-impaginazione: 5. Impaginazione › la tabella delle manutenzioni non sfora la scheda +suite-impaginazione: 6. Impaginazione › si firma col dito e la pagina non scorre sotto il tratto +demo: 1. Visita guidata del modulo › dal registro alla firma, fino a stampa e calendari diff --git a/tests/e2e/video/desktop/demo.mp4 b/tests/e2e/video/desktop/demo.mp4 new file mode 100644 index 0000000..6cee990 Binary files /dev/null and b/tests/e2e/video/desktop/demo.mp4 differ diff --git a/tests/e2e/video/desktop/suite-funzionali.mp4 b/tests/e2e/video/desktop/suite-funzionali.mp4 new file mode 100644 index 0000000..ee0c307 Binary files /dev/null and b/tests/e2e/video/desktop/suite-funzionali.mp4 differ diff --git a/tests/e2e/video/desktop/suite-impaginazione.mp4 b/tests/e2e/video/desktop/suite-impaginazione.mp4 new file mode 100644 index 0000000..652db83 Binary files /dev/null and b/tests/e2e/video/desktop/suite-impaginazione.mp4 differ diff --git a/tests/e2e/video/mobile/contenuto.txt b/tests/e2e/video/mobile/contenuto.txt new file mode 100644 index 0000000..5fca009 --- /dev/null +++ b/tests/e2e/video/mobile/contenuto.txt @@ -0,0 +1,64 @@ +suite-funzionali: 1. Calendari › il calendario disegna gli eventi e il click porta alla scheda +suite-funzionali: 2. Calendari › i filtri del calendario rifanno la richiesta +suite-funzionali: 3. Calendari › il calendario cumulativo accende e spegne gli strati +suite-funzionali: 4. Calendari › ogni strato porta i propri eventi +suite-funzionali: 5. Foto, copertina e documenti › la prima foto caricata diventa copertina, anche nel registro +suite-funzionali: 6. Foto, copertina e documenti › la stellina sposta la copertina sulla seconda foto +suite-funzionali: 7. Foto, copertina e documenti › la foto si elimina previa conferma +suite-funzionali: 8. Foto, copertina e documenti › i documenti si caricano e restano scaricabili +suite-funzionali: 9. Foto, copertina e documenti › un file non consentito viene rifiutato con un messaggio +suite-funzionali: 10. Allegati dentro i modali › le istruzioni allegate a una manutenzione si vedono, si riaprono e si eliminano +suite-funzionali: 11. Allegati dentro i modali › un allegato dell'intervento compare nello storico +suite-funzionali: 12. Modali: validazioni e ricarica dei valori › manutenzione senza titolo: il modulo si blocca e non salva niente +suite-funzionali: 13. Modali: validazioni e ricarica dei valori › intervento senza data: il modulo si blocca e non salva niente +suite-funzionali: 14. Modali: validazioni e ricarica dei valori › riaprendo una manutenzione i campi sono quelli salvati +suite-funzionali: 15. Modali: validazioni e ricarica dei valori › riaprendo un intervento i campi sono quelli salvati +suite-funzionali: 16. Modali: validazioni e ricarica dei valori › l'attrezzatura non si elimina se è usata in produzione +suite-funzionali: 17. Registrazione interventi › registrare un intervento completato aggiorna le date a schermo +suite-funzionali: 18. Registrazione interventi › la firma disegnata finisce nello storico +suite-funzionali: 19. Registrazione interventi › un intervento pianificato non sposta la scadenza +suite-funzionali: 20. Registrazione interventi › eliminando l'ultimo intervento completato la scadenza torna indietro +suite-funzionali: 21. Registrazione interventi › la firma si cancella con il pulsante dedicato +suite-funzionali: 22. Elenco manutenzioni › i riquadri contano e filtrano +suite-funzionali: 23. Elenco manutenzioni › lo stesso riquadro cliccato due volte toglie il filtro +suite-funzionali: 24. Elenco manutenzioni › «solo critiche» e «programmate/straordinarie» restringono l'elenco +suite-funzionali: 25. Elenco manutenzioni › lo stato vuoto compare quando nessuna riga corrisponde +suite-funzionali: 26. Elenco manutenzioni › la registrazione rapida dall'elenco aggiorna la scadenza +suite-funzionali: 27. Registro: stati e filtri › lo stato vuoto compare quando la ricerca non trova nulla +suite-funzionali: 28. Registro: stati e filtri › cambiando stato cambia il badge e la riga sparisce dal filtro «Attivo» +suite-funzionali: 29. Registro: stati e filtri › sul telefono la card ha anche modifica ed elimina +suite-funzionali: 30. Modale manutenzione › «al bisogno» nasconde il campo della frequenza +suite-funzionali: 31. Modale manutenzione › «esterna» chiede il fornitore e blocca il salvataggio senza +suite-funzionali: 32. Modale manutenzione › una manutenzione critica è evidenziata nella scheda +suite-funzionali: 33. Modale manutenzione › la nota di frequenza resta visibile accanto alla frequenza +suite-funzionali: 34. Modale manutenzione › una manutenzione disattivata si vede come tale e sparisce dall'elenco generale +suite-funzionali: 35. Modale manutenzione › il conteggio delle manutenzioni compare nel registro +suite-funzionali: 36. Navigazione fra le pagine › registro → manutenzioni → calendario → calendario cumulativo e ritorno +suite-funzionali: 37. Navigazione fra le pagine › dalla scheda si torna al registro con le briciole di pane +suite-funzionali: 38. Navigazione fra le pagine › dalle categorie si torna al registro +suite-funzionali: 39. Navigazione fra le pagine › «Scheda PDF» apre la stampa in una nuova scheda del browser [finestra 1] +suite-funzionali: 40. Navigazione fra le pagine › «Scheda PDF» apre la stampa in una nuova scheda del browser [finestra 2] +suite-funzionali: 41. Navigazione fra le pagine › dal calendario cumulativo si arriva alla scheda dell'attrezzatura +suite-funzionali: 42. Utente senza permessi › la pagina «registro» risponde 403 anche scrivendo l'indirizzo +suite-funzionali: 43. Utente senza permessi › la pagina «manutenzioni» risponde 403 anche scrivendo l'indirizzo +suite-funzionali: 44. Utente senza permessi › la pagina «calendario» risponde 403 anche scrivendo l'indirizzo +suite-funzionali: 45. Utente senza permessi › la pagina «categorie» risponde 403 anche scrivendo l'indirizzo +suite-funzionali: 46. Utente senza permessi › la voce di menu «Manutenzioni» non compare +suite-funzionali: 47. Utente senza permessi › gli endpoint di scrittura rispondono 403 +suite-funzionali: 48. Utente con permessi › vede i pulsanti di gestione su registro e scheda +suite-funzionali: 49. Utente con permessi › la voce di menu «Manutenzioni» apre tutte le sue pagine +suite-funzionali: 50. Scheda di stampa mod. 6.3-1 › la scheda riproduce il modulo cartaceo +suite-funzionali: 51. Scheda di stampa mod. 6.3-1 › in stampa la barra dei pulsanti sparisce e il PDF si genera +suite-funzionali: 52. Registro attrezzature › la lista mostra righe, pallini di categoria e badge di stato +suite-funzionali: 53. Registro attrezzature › il modale di modifica si apre con i campi già compilati +suite-funzionali: 54. Registro attrezzature › il campo «linea» compare solo per le categorie che lo richiedono +suite-funzionali: 55. Registro attrezzature › i filtri restringono davvero la lista +suite-funzionali: 56. Registro attrezzature › la conferma di eliminazione avvisa prima di cancellare +suite-funzionali: 57. Categorie › CRUD dal modale e blocco sulle categorie in uso +suite-impaginazione: 1. Impaginazione › registro, elenco manutenzioni, scheda e calendario +suite-impaginazione: 2. Impaginazione › le liste usano le card sul telefono e la tabella da tablet in su +suite-impaginazione: 3. Impaginazione › il modale resta utilizzabile: titolo e pulsanti raggiungibili +suite-impaginazione: 4. Impaginazione › la barra laterale non cambia larghezza dopo il caricamento +suite-impaginazione: 5. Impaginazione › la tabella delle manutenzioni non sfora la scheda +suite-impaginazione: 6. Impaginazione › si firma col dito e la pagina non scorre sotto il tratto +demo: 1. Visita guidata del modulo › dal registro alla firma, fino a stampa e calendari diff --git a/tests/e2e/video/mobile/demo.mp4 b/tests/e2e/video/mobile/demo.mp4 new file mode 100644 index 0000000..77eef9d Binary files /dev/null and b/tests/e2e/video/mobile/demo.mp4 differ diff --git a/tests/e2e/video/mobile/suite-funzionali.mp4 b/tests/e2e/video/mobile/suite-funzionali.mp4 new file mode 100644 index 0000000..e368936 Binary files /dev/null and b/tests/e2e/video/mobile/suite-funzionali.mp4 differ diff --git a/tests/e2e/video/mobile/suite-impaginazione.mp4 b/tests/e2e/video/mobile/suite-impaginazione.mp4 new file mode 100644 index 0000000..c8cd1d7 Binary files /dev/null and b/tests/e2e/video/mobile/suite-impaginazione.mp4 differ diff --git a/tests/e2e/video/tablet/contenuto.txt b/tests/e2e/video/tablet/contenuto.txt new file mode 100644 index 0000000..5fca009 --- /dev/null +++ b/tests/e2e/video/tablet/contenuto.txt @@ -0,0 +1,64 @@ +suite-funzionali: 1. Calendari › il calendario disegna gli eventi e il click porta alla scheda +suite-funzionali: 2. Calendari › i filtri del calendario rifanno la richiesta +suite-funzionali: 3. Calendari › il calendario cumulativo accende e spegne gli strati +suite-funzionali: 4. Calendari › ogni strato porta i propri eventi +suite-funzionali: 5. Foto, copertina e documenti › la prima foto caricata diventa copertina, anche nel registro +suite-funzionali: 6. Foto, copertina e documenti › la stellina sposta la copertina sulla seconda foto +suite-funzionali: 7. Foto, copertina e documenti › la foto si elimina previa conferma +suite-funzionali: 8. Foto, copertina e documenti › i documenti si caricano e restano scaricabili +suite-funzionali: 9. Foto, copertina e documenti › un file non consentito viene rifiutato con un messaggio +suite-funzionali: 10. Allegati dentro i modali › le istruzioni allegate a una manutenzione si vedono, si riaprono e si eliminano +suite-funzionali: 11. Allegati dentro i modali › un allegato dell'intervento compare nello storico +suite-funzionali: 12. Modali: validazioni e ricarica dei valori › manutenzione senza titolo: il modulo si blocca e non salva niente +suite-funzionali: 13. Modali: validazioni e ricarica dei valori › intervento senza data: il modulo si blocca e non salva niente +suite-funzionali: 14. Modali: validazioni e ricarica dei valori › riaprendo una manutenzione i campi sono quelli salvati +suite-funzionali: 15. Modali: validazioni e ricarica dei valori › riaprendo un intervento i campi sono quelli salvati +suite-funzionali: 16. Modali: validazioni e ricarica dei valori › l'attrezzatura non si elimina se è usata in produzione +suite-funzionali: 17. Registrazione interventi › registrare un intervento completato aggiorna le date a schermo +suite-funzionali: 18. Registrazione interventi › la firma disegnata finisce nello storico +suite-funzionali: 19. Registrazione interventi › un intervento pianificato non sposta la scadenza +suite-funzionali: 20. Registrazione interventi › eliminando l'ultimo intervento completato la scadenza torna indietro +suite-funzionali: 21. Registrazione interventi › la firma si cancella con il pulsante dedicato +suite-funzionali: 22. Elenco manutenzioni › i riquadri contano e filtrano +suite-funzionali: 23. Elenco manutenzioni › lo stesso riquadro cliccato due volte toglie il filtro +suite-funzionali: 24. Elenco manutenzioni › «solo critiche» e «programmate/straordinarie» restringono l'elenco +suite-funzionali: 25. Elenco manutenzioni › lo stato vuoto compare quando nessuna riga corrisponde +suite-funzionali: 26. Elenco manutenzioni › la registrazione rapida dall'elenco aggiorna la scadenza +suite-funzionali: 27. Registro: stati e filtri › lo stato vuoto compare quando la ricerca non trova nulla +suite-funzionali: 28. Registro: stati e filtri › cambiando stato cambia il badge e la riga sparisce dal filtro «Attivo» +suite-funzionali: 29. Registro: stati e filtri › sul telefono la card ha anche modifica ed elimina +suite-funzionali: 30. Modale manutenzione › «al bisogno» nasconde il campo della frequenza +suite-funzionali: 31. Modale manutenzione › «esterna» chiede il fornitore e blocca il salvataggio senza +suite-funzionali: 32. Modale manutenzione › una manutenzione critica è evidenziata nella scheda +suite-funzionali: 33. Modale manutenzione › la nota di frequenza resta visibile accanto alla frequenza +suite-funzionali: 34. Modale manutenzione › una manutenzione disattivata si vede come tale e sparisce dall'elenco generale +suite-funzionali: 35. Modale manutenzione › il conteggio delle manutenzioni compare nel registro +suite-funzionali: 36. Navigazione fra le pagine › registro → manutenzioni → calendario → calendario cumulativo e ritorno +suite-funzionali: 37. Navigazione fra le pagine › dalla scheda si torna al registro con le briciole di pane +suite-funzionali: 38. Navigazione fra le pagine › dalle categorie si torna al registro +suite-funzionali: 39. Navigazione fra le pagine › «Scheda PDF» apre la stampa in una nuova scheda del browser [finestra 1] +suite-funzionali: 40. Navigazione fra le pagine › «Scheda PDF» apre la stampa in una nuova scheda del browser [finestra 2] +suite-funzionali: 41. Navigazione fra le pagine › dal calendario cumulativo si arriva alla scheda dell'attrezzatura +suite-funzionali: 42. Utente senza permessi › la pagina «registro» risponde 403 anche scrivendo l'indirizzo +suite-funzionali: 43. Utente senza permessi › la pagina «manutenzioni» risponde 403 anche scrivendo l'indirizzo +suite-funzionali: 44. Utente senza permessi › la pagina «calendario» risponde 403 anche scrivendo l'indirizzo +suite-funzionali: 45. Utente senza permessi › la pagina «categorie» risponde 403 anche scrivendo l'indirizzo +suite-funzionali: 46. Utente senza permessi › la voce di menu «Manutenzioni» non compare +suite-funzionali: 47. Utente senza permessi › gli endpoint di scrittura rispondono 403 +suite-funzionali: 48. Utente con permessi › vede i pulsanti di gestione su registro e scheda +suite-funzionali: 49. Utente con permessi › la voce di menu «Manutenzioni» apre tutte le sue pagine +suite-funzionali: 50. Scheda di stampa mod. 6.3-1 › la scheda riproduce il modulo cartaceo +suite-funzionali: 51. Scheda di stampa mod. 6.3-1 › in stampa la barra dei pulsanti sparisce e il PDF si genera +suite-funzionali: 52. Registro attrezzature › la lista mostra righe, pallini di categoria e badge di stato +suite-funzionali: 53. Registro attrezzature › il modale di modifica si apre con i campi già compilati +suite-funzionali: 54. Registro attrezzature › il campo «linea» compare solo per le categorie che lo richiedono +suite-funzionali: 55. Registro attrezzature › i filtri restringono davvero la lista +suite-funzionali: 56. Registro attrezzature › la conferma di eliminazione avvisa prima di cancellare +suite-funzionali: 57. Categorie › CRUD dal modale e blocco sulle categorie in uso +suite-impaginazione: 1. Impaginazione › registro, elenco manutenzioni, scheda e calendario +suite-impaginazione: 2. Impaginazione › le liste usano le card sul telefono e la tabella da tablet in su +suite-impaginazione: 3. Impaginazione › il modale resta utilizzabile: titolo e pulsanti raggiungibili +suite-impaginazione: 4. Impaginazione › la barra laterale non cambia larghezza dopo il caricamento +suite-impaginazione: 5. Impaginazione › la tabella delle manutenzioni non sfora la scheda +suite-impaginazione: 6. Impaginazione › si firma col dito e la pagina non scorre sotto il tratto +demo: 1. Visita guidata del modulo › dal registro alla firma, fino a stampa e calendari diff --git a/tests/e2e/video/tablet/demo.mp4 b/tests/e2e/video/tablet/demo.mp4 new file mode 100644 index 0000000..c592a4a Binary files /dev/null and b/tests/e2e/video/tablet/demo.mp4 differ diff --git a/tests/e2e/video/tablet/suite-funzionali.mp4 b/tests/e2e/video/tablet/suite-funzionali.mp4 new file mode 100644 index 0000000..1d5a351 Binary files /dev/null and b/tests/e2e/video/tablet/suite-funzionali.mp4 differ diff --git a/tests/e2e/video/tablet/suite-impaginazione.mp4 b/tests/e2e/video/tablet/suite-impaginazione.mp4 new file mode 100644 index 0000000..b742a0d Binary files /dev/null and b/tests/e2e/video/tablet/suite-impaginazione.mp4 differ