Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e13e0c9b2a |
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
## Чего в модуле нет намеренно
|
||||
|
||||
- Калибровка/поверка приборов — вынесена за скоуп самим клиентом. Структура данных её примет отдельной таблицей без переделок.
|
||||
- Импорт истории выполненных работ из старых карточек — журналы в присланных файлах пустые.
|
||||
@@ -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 # только пункты ТЗ
|
||||
```
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 <base href="/userarea/">, 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('<?php echo 1;'),
|
||||
})
|
||||
|
||||
await expect(page.locator('.swal2-title')).toContainText('Errore')
|
||||
await expect(page.locator('.swal2-html-container')).toContainText('non consentito')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Attachments inside the modals', () => {
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
const { cleanup } = require('./helpers/env')
|
||||
|
||||
/** Removes users, equipment, maintenances and attachments created by the tests. */
|
||||
module.exports = async () => {
|
||||
cleanup()
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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`) })
|
||||
})
|
||||
})
|
||||
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 779 KiB |
|
After Width: | Height: | Size: 130 KiB |
@@ -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()
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||