Translate the Playwright e2e suite to English

Test titles, describe blocks, comments and assertion messages are now in
English, so the runner output and the code are readable without Italian.

Deliberately left in Italian, because they are matched against the app or
typed into it:
- UI text used in locators (page headings, badges, empty states, SweetAlert
  messages, sidebar entries, category names)
- form values filled in by the tests, so screenshots and demo videos keep
  showing an Italian UI with Italian data
- fixture labels passed to name(), for the same reason

Screenshot files are renamed to the English labels the suite now writes
(desktop-registro.png -> desktop-registry.png and so on); the docs that
quote test titles or screenshot names are updated to match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-10 21:11:59 +03:00
parent 41c4838dab
commit e13e0c9b2a
51 changed files with 2829 additions and 0 deletions
+275
View File
@@ -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.111.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 работ с кодами **AH**, частоты: A — «Ogni 2 settimane», BE — «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 работы AD **без даты** (сезонные), в примечании частоты полный текст «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.
---
## Чего в модуле нет намеренно
- Калибровка/поверка приборов — вынесена за скоуп самим клиентом. Структура данных её примет отдельной таблицей без переделок.
- Импорт истории выполненных работ из старых карточек — журналы в присланных файлах пустые.
+105
View File
@@ -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 # только пункты ТЗ
```
+82
View File
@@ -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',
},
},
],
})
Binary file not shown.
+97
View File
@@ -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)
})
})
+243
View File
@@ -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)
})
})
+168
View File
@@ -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()
})
})
+133
View File
@@ -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')
})
})
+85
View File
@@ -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()
}
+6
View File
@@ -0,0 +1,6 @@
const { cleanup } = require('./helpers/env')
/** Removes users, equipment, maintenances and attachments created by the tests. */
module.exports = async () => {
cleanup()
}
+88
View File
@@ -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,
}
+89
View File
@@ -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,
}
+141
View File
@@ -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)
})
})
+157
View File
@@ -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)
})
})
+93
View File
@@ -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')
})
})
+91
View File
@@ -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/)
})
})
+73
View File
@@ -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)
})
})
+53
View File
@@ -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' })
})
})
+102
View File
@@ -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')
})
})
+156
View File
@@ -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`) })
})
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 779 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

+206
View File
@@ -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()
+194
View File
@@ -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 AH 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)
})
})
+64
View File
@@ -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
Binary file not shown.
Binary file not shown.
Binary file not shown.
+64
View File
@@ -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
Binary file not shown.
Binary file not shown.
Binary file not shown.
+64
View File
@@ -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
Binary file not shown.
Binary file not shown.
Binary file not shown.