diff --git a/docs/mobile-smoke-polish/README.md b/docs/mobile-smoke-polish/README.md new file mode 100644 index 000000000..a1e4fdb4a --- /dev/null +++ b/docs/mobile-smoke-polish/README.md @@ -0,0 +1,82 @@ + + +# Проверка исправлений после мобильного smoke-test + +Эталон desktop: `origin/dev` на `706a574ed916c6b0a78e99402588ee4889657e74`, после восстановления desktop в #403 и форматирования в #404. Эта проверка подтверждает отсутствие новых desktop-регрессий относительно уже восстановленного dev; сравнение с состоянием до #401 находится в `docs/desktop-regression`. + +## Исправления и acceptance criteria + +| Замечание | Результат | Evidence на 390 px | +| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | +| 1. Приглашение смещено влево | Карточка центрируется внутри списка на mobile/tablet | [Приглашение](screenshots/projects-invites-390.png) | +| 2. Логотип, подсказка и верхние кнопки пересекаются | Логотип центрируется, подсказка вынесена за его границы; действия расположены вертикально с отступами | [Редактор](screenshots/project-edit-390.png) | +| 3. Узкая дата, перекрытие подсказкой, разные шрифты | Дата занимает ширину колонки; под подсказку зарезервировано место; размеры текста региона и остальных полей совпадают | [Поля](screenshots/project-edit-fields-390.png) | +| 4. Кнопка добавления цели смещена | Кнопки занимают ширину формы, текст переносится; новая цель тоже имеет вертикальную раскладку полей | [Новая цель](screenshots/project-edit-new-goal-390.png) | +| 5. Партнёр и ресурс без отступа | Между кнопками 16 px | [Партнёры](screenshots/partners-390.png) | +| 6. Поиск участника выглядит как вложенное поле | Внешний контейнер владеет рамкой и focus-состоянием; внутренний input не имеет своей рамки | [Приглашение участника](screenshots/team-invite-390.png) | +| 7. Кнопки модального окна смещены вправо | Кнопки занимают всю ширину footer с вертикальным отступом | [Приглашение участника](screenshots/team-invite-390.png) | +| 8. Бесполезные «Контакты» программы | Пустая кнопка скрыта; при наличии ссылок мобильная кнопка открывает контакты, доступные на desktop в боковой колонке | [Программа без контактов](screenshots/program-390.png) | +| 9. «Требует внимания» выглядит как полосы прогресса | Снята фиолетовая заливка shared Button; строки сохраняют числа, переходы и подсказки | [Аналитика](screenshots/analytics-attention-390.png) | +| 10. Дублирующиеся ошибки соединения | Одно закрываемое сообщение на период недоступности чата; автоматическое переподключение и снятие сообщения после восстановления | [Уведомление](screenshots/unavailable-390.png), `connection.cjs` | + +Все изменения существующей геометрии и типографики ограничены `<1000px`. Стиль новой кнопки закрытия уведомления применяется только к этой кнопке. Mont, палитра и desktop-композиция сохранены. + +## Почему было два сообщения о соединении + +`ConnectionStatusToastService` и `ChatRealtimeRepository` подписывались на один `WebsocketService.connectionLost$` и создавали два разных snackbar. После порога повторных попыток событие приходило снова, примерно каждые пять секунд, поэтому уведомления постоянно обновлялись. + +Теперь UX принадлежит одному сервису. Статус WebSocket различает подключение, недоступность и завершённую сессию. При длительном сбое появляется текст: «Чат временно недоступен. Сообщения и статусы могут обновляться с задержкой. Подключаемся автоматически». Это не утверждение о недоступности HTTP API или интернета в целом. + +Уведомление можно закрыть. Закрытое сообщение не появляется снова в течение того же сбоя. Если его не закрывать, оно исчезает при восстановлении WebSocket; следующий отдельный сбой создаёт новое сообщение. Повторные подключения продолжаются автоматически. Явное закрытие сессии отменяет ожидающий retry, а teardown освобождает обработчики и старый socket. + +API, авторизация, правила доступа, payload сообщений, очередь исходящих сообщений и бизнес-процессы проектов/программ/приглашений не менялись. Изменены транспортный lifecycle и отображение его состояния. + +## Выполненные проверки + +- `npm run test:vitest:ci`: **2016 тестов, 407 файлов, exit 0**. В существующем `vitest.config.ts` по-прежнему исключены три legacy spec: `projects.component.spec.ts`, `program.component.spec.ts`, `editor-submit-button.directive.spec.ts`; они не представлены как проверенные. +- `npm run build:social:prod`: exit 0. Существующие предупреждения сборки сохранены. +- `npm run lint:ts`: exit 0, 0 ошибок и 6 существующих предупреждений вне этого diff. +- Stylelint изменённых SCSS: exit 0. +- `npm run format:check`: проверяется на Git index snapshot с LF, без приватных build/screenshot-каталогов; команда и конфигурация совпадают с CI. +- Браузер: 16 основных экранов на **320, 390, 768, 1440, 1920 px**, плюс 6 дополнительных состояний после прокрутки/выбора участника на каждой ширине. Проверены центрирование, расстояния между кнопками, ширина даты, отсутствие перекрытия подсказками, одинаковый размер шрифта, рамка поиска, enabled/disabled приглашение и отсутствие горизонтального overflow/console errors. +- Desktop pixel regression: **44/44 точных совпадений** на 1440/1920 px, **0 изменённых RGB-пикселей**, одинаковые размеры изображений. Нет масок и допуска. Данные: [desktop-comparison.json](desktop-comparison.json). +- `connection.cjs`: синтетический сбой WebSocket при доступном HTTP; одно уведомление, ручное закрытие, отсутствие повторного появления в том же сбое, восстановление, новый отдельный сбой и автоматическое скрытие — passed. +- `visual.cjs contacts`: непустая кнопка контактов на 390 px открывает модальное окно с существующей ссылкой. + +Основные экраны: Feed, проекты, участники, программы, программа, профиль, проект, вакансии, вакансия, курсы, курс, редактор профиля, основные данные проекта, партнёры/ресурсы, аналитика программы, приглашение участника. Дополнительные состояния: список приглашений, дата/поля проекта, кнопки целей, новая цель, строки внимания, выбранный участник. + +При загрузке через локальный HTTP/proxy Chromium несколько раз прервал загрузку JS с `net::ERR_NO_BUFFER_SPACE` до bootstrap приложения. Финальные visual-проверки выполнены с `QA_BUILD_ROOT`: `preview.cjs` подаёт браузеру неизменённые файлы соответствующей сборки напрямую через Playwright route. Это устраняет сбой локальной доставки JS, не подменяя DOM, CSS или результат рендеринга. Все страницы проверяются заново; проваленные инфраструктурные запуски не считаются evidence. + +## Воспроизведение visual regression + +Нужны Chrome/Chromium, Playwright, Python с Pillow и NumPy. `PLAYWRIGHT_MODULE` и `CHROME_PATH` позволяют указать существующие runtime без изменения зависимостей проекта. Все API и WebSocket в harness заменены синтетическими fixtures; запросы на live API не отправляются. + +Для воспроизводимой проверки установить `QA_BUILD_ROOT` в путь к нужной сборке: `before-dist` для обоих запусков `before`, `after-dist` для `after`, `connection` и `contacts`. Например, PowerShell: `$env:QA_BUILD_ROOT = '.desktop-regression/mobile-smoke/after-dist'`. Без этой переменной скрипты используют обычный локальный HTTP preview. + +1. Собрать baseline из указанного выше commit в отдельном checkout и исправленную ветку одинаковой development-конфигурацией. Сохранить результаты как `before-dist` и `after-dist`. +2. В отдельных терминалах запустить preview: + +```text +node docs/desktop-regression/serve.cjs .desktop-regression/mobile-smoke/before-dist 4451 +node docs/desktop-regression/serve.cjs .desktop-regression/mobile-smoke/after-dist 4452 +``` + +3. Снять основные и дополнительные состояния для обеих сборок: + +```text +node docs/mobile-smoke-polish/visual.cjs before http://127.0.0.1:4451 320,390,768,1440,1920 .desktop-regression/mobile-smoke/screenshots +node docs/mobile-smoke-polish/visual.cjs after http://127.0.0.1:4452 320,390,768,1440,1920 .desktop-regression/mobile-smoke/screenshots +node docs/mobile-smoke-polish/visual.cjs before http://127.0.0.1:4451 320,390,768,1440,1920 .desktop-regression/mobile-smoke/screenshots details +node docs/mobile-smoke-polish/visual.cjs after http://127.0.0.1:4452 320,390,768,1440,1920 .desktop-regression/mobile-smoke/screenshots details +python docs/mobile-smoke-polish/compare.py .desktop-regression/mobile-smoke/screenshots/before .desktop-regression/mobile-smoke/screenshots/after docs/mobile-smoke-polish/desktop-comparison.json +node docs/mobile-smoke-polish/connection.cjs http://127.0.0.1:4452 +node docs/mobile-smoke-polish/visual.cjs contacts http://127.0.0.1:4452 390 +``` + +В PowerShell аргумент со списком ширин следует заключить в кавычки. Ширины можно запускать по отдельности в тот же каталог: PNG имеют уникальные имена, comparator проверяет все 44 обязательные desktop-пары и падает при отсутствии любой из них. Harness сбрасывает прокрутку окна и внутренних scroll-контейнеров перед основным скриншотом; дополнительные состояния прокручиваются явно. После генерации JSON отформатировать Prettier перед commit. Полные build/PNG-артефакты остаются в приватном `.desktop-regression`, в Git включены representative screenshots и результаты сравнения. + +## Review и ограничения + +Source review: desktop media boundary, отсутствие изменений API/permission/domain-правил, сохранность очереди сообщений, отмена retry при закрытии, lifecycle подписок, доступность close-кнопки и сохранение focus на контейнере поиска — проверены. Существенных незакрытых замечаний в этом diff нет. + +Браузерные проверки выполнены в headless Chrome на Windows с fixtures. **Реальные iOS/Android, Safari/WebKit, загрузка файлов, live API и реальный сетевой обрыв на dev-стенде не проверены.** После merge требуется повторный smoke-test на устройствах по десяти пунктам выше, включая уход приложения в фон и восстановление чата. Изменения данных и backend migration не требуются. diff --git a/docs/mobile-smoke-polish/compare.py b/docs/mobile-smoke-polish/compare.py new file mode 100644 index 000000000..1a4db2806 --- /dev/null +++ b/docs/mobile-smoke-polish/compare.py @@ -0,0 +1,43 @@ +"""Strict RGB desktop regression gate, including scrolled and modal states.""" +import argparse +import json +from pathlib import Path + +import numpy as np +from PIL import Image + +parser = argparse.ArgumentParser() +parser.add_argument("before", type=Path) +parser.add_argument("after", type=Path) +parser.add_argument("output", type=Path) +args = parser.parse_args() +rows = [] +screens = ( + "feed projects members programs program profile project vacancies vacancy courses course " + "profile-edit project-edit partners analytics team-invite projects-invites " + "project-edit-fields project-edit-goals project-edit-new-goal analytics-attention " + "team-invite-selected" +).split() +files = sorted(args.before / f"{screen}-{width}.png" for screen in screens for width in (1440, 1920)) +for file in files: + before = Image.open(file).convert("RGB") + after = Image.open(args.after / file.name).convert("RGB") + same_size = before.size == after.size + if same_size: + delta = np.max(np.abs(np.asarray(before).astype(np.int16) - np.asarray(after).astype(np.int16)), axis=2) + changed = int(np.count_nonzero(delta)) + maximum = int(delta.max()) + else: + changed = -1 + maximum = -1 + rows.append({"screen": file.stem, "beforeSize": before.size, "afterSize": after.size, + "exactChangedPixels": changed, "maxChannelDelta": maximum, + "passed": same_size and changed == 0}) + if changed != 0: + print(f"FAIL {file.stem}: {changed} changed RGB pixels") +args.output.parent.mkdir(parents=True, exist_ok=True) +args.output.write_text(json.dumps(rows, indent=2) + "\n", encoding="utf-8") +passed = sum(row["passed"] for row in rows) +print(f"Desktop pixel comparison: {passed}/{len(rows)} exact matches") +if not rows or passed != len(rows): + raise SystemExit(1) diff --git a/docs/mobile-smoke-polish/connection.cjs b/docs/mobile-smoke-polish/connection.cjs new file mode 100644 index 000000000..ba78d13bb --- /dev/null +++ b/docs/mobile-smoke-polish/connection.cjs @@ -0,0 +1,108 @@ +/** @format */ + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const { launch } = require("../responsive/browser-harness.cjs"); +require("../mobile-polish/fixtures.cjs"); +const preview = require("./preview.cjs"); + +const baseUrl = process.argv[2]; +const directory = path.resolve(process.argv[3] || ".desktop-regression/mobile-smoke/connection"); +(async () => { + fs.mkdirSync(directory, { recursive: true }); + const harness = await launch(390, { baseUrl, isMobile: true, hasTouch: true }); + await preview(harness.context, baseUrl); + try { + // Управляемые WebSocket события; HTTP API остаётся доступен через fixtures. + await harness.context.addInitScript(() => { + window.qaChatOffline = true; + window.qaChatConnected = false; + window.qaChatSockets = []; + window.WebSocket = class { + static OPEN = 1; + static CONNECTING = 0; + static CLOSING = 2; + static CLOSED = 3; + readyState = 0; + constructor() { + window.qaChatSockets.push(this); + setTimeout(() => { + if (this.closed) return; + if (window.qaChatOffline) { + this.readyState = 3; + this.onerror?.(new Event("error")); + } else { + this.readyState = 1; + window.qaChatConnected = true; + this.onopen?.(new Event("open")); + } + }, 20); + } + send() {} + close() { + this.closed = true; + this.readyState = 3; + } + }; + }); + const page = harness.page; + await page.goto(baseUrl + "/office/feed"); + const toast = page.locator(".snackbar__item"); + await toast.waitFor({ state: "visible", timeout: 30000 }); + assert.equal(await toast.count(), 1); + assert.match(await toast.innerText(), /Чат временно недоступен/); + await page.evaluate(() => document.fonts.ready); + await toast.screenshot({ path: path.join(directory, "unavailable-390.png") }); + await page.getByLabel("Закрыть уведомление").click(); + await toast.waitFor({ state: "hidden" }); + const attempts = await page.evaluate(() => window.qaChatSockets.length); + await page.waitForFunction(attempts => window.qaChatSockets.length > attempts, attempts, { + timeout: 10000, + }); + assert.equal(await toast.count(), 0, "Dismissed toast must not reappear during same outage"); + await page.evaluate(() => { + window.qaChatOffline = false; + }); + await page.waitForFunction(() => window.qaChatConnected, null, { timeout: 10000 }); + assert.equal(await toast.count(), 0); + await page.evaluate(() => { + window.qaChatOffline = true; + window.qaChatConnected = false; + window.qaChatSockets.at(-1).onerror(new Event("error")); + }); + await toast.waitFor({ state: "visible", timeout: 30000 }); + assert.equal(await toast.count(), 1, "New outage must produce one new notification"); + await page.evaluate(() => { + window.qaChatOffline = false; + }); + await page.waitForFunction(() => window.qaChatConnected, null, { timeout: 10000 }); + await toast.waitFor({ state: "hidden" }); + assert.equal(await toast.count(), 0, "Reconnect must dismiss notification automatically"); + assert.deepEqual(harness.errors, []); + fs.writeFileSync( + path.join(directory, "result.json"), + JSON.stringify( + { + width: 390, + syntheticWebSocket: true, + httpAvailable: true, + oneNotificationPerOutage: true, + manualDismiss: true, + noRedisplayAfterDismiss: true, + secondOutage: true, + autoDismissOnReconnect: true, + passed: true, + }, + null, + 2, + ) + "\n", + ); + console.log("Connection smoke: all scenarios passed"); + } finally { + await harness.browser.close(); + } +})().catch(error => { + console.error(error); + process.exitCode = 1; +}); diff --git a/docs/mobile-smoke-polish/desktop-comparison.json b/docs/mobile-smoke-polish/desktop-comparison.json new file mode 100644 index 000000000..5da5a0458 --- /dev/null +++ b/docs/mobile-smoke-polish/desktop-comparison.json @@ -0,0 +1,354 @@ +[ + { + "screen": "analytics-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "analytics-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "analytics-attention-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "analytics-attention-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "course-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "course-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "courses-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "courses-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "feed-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "feed-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "members-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "members-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "partners-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "partners-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "profile-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "profile-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "profile-edit-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "profile-edit-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "program-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "program-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "programs-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "programs-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "project-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "project-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "project-edit-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "project-edit-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "project-edit-fields-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "project-edit-fields-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "project-edit-goals-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "project-edit-goals-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "project-edit-new-goal-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "project-edit-new-goal-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "projects-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "projects-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "projects-invites-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "projects-invites-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "team-invite-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "team-invite-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "team-invite-selected-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "team-invite-selected-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "vacancies-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "vacancies-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "vacancy-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "vacancy-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + } +] diff --git a/docs/mobile-smoke-polish/preview.cjs b/docs/mobile-smoke-polish/preview.cjs new file mode 100644 index 000000000..edcd7079d --- /dev/null +++ b/docs/mobile-smoke-polish/preview.cjs @@ -0,0 +1,33 @@ +/** @format */ + +const fs = require("node:fs"); +const path = require("node:path"); + +// Optional direct delivery of unmodified build bytes avoids local HTTP/proxy failures. +module.exports = async function preview(context, baseUrl) { + if (!process.env.QA_BUILD_ROOT) return; + let root = path.resolve(process.env.QA_BUILD_ROOT); + if (fs.existsSync(path.join(root, "browser/index.html"))) root = path.join(root, "browser"); + const types = { + ".html": "text/html", + ".js": "text/javascript", + ".css": "text/css", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".json": "application/json", + }; + await context.route(baseUrl + "/**", async route => { + const url = new URL(route.request().url()); + let file = path.resolve(root, "." + decodeURIComponent(url.pathname)); + if (!file.startsWith(root + path.sep)) return route.abort(); + if (!fs.existsSync(file) || fs.statSync(file).isDirectory()) + file = path.join(root, "index.html"); + await route.fulfill({ + path: file, + contentType: types[path.extname(file)] || "application/octet-stream", + }); + }); +}; diff --git a/docs/mobile-smoke-polish/screenshots/analytics-attention-390.png b/docs/mobile-smoke-polish/screenshots/analytics-attention-390.png new file mode 100644 index 000000000..0f94fb20f Binary files /dev/null and b/docs/mobile-smoke-polish/screenshots/analytics-attention-390.png differ diff --git a/docs/mobile-smoke-polish/screenshots/partners-390.png b/docs/mobile-smoke-polish/screenshots/partners-390.png new file mode 100644 index 000000000..70425dd78 Binary files /dev/null and b/docs/mobile-smoke-polish/screenshots/partners-390.png differ diff --git a/docs/mobile-smoke-polish/screenshots/program-390.png b/docs/mobile-smoke-polish/screenshots/program-390.png new file mode 100644 index 000000000..a11c64d9b Binary files /dev/null and b/docs/mobile-smoke-polish/screenshots/program-390.png differ diff --git a/docs/mobile-smoke-polish/screenshots/project-edit-390.png b/docs/mobile-smoke-polish/screenshots/project-edit-390.png new file mode 100644 index 000000000..345e14c92 Binary files /dev/null and b/docs/mobile-smoke-polish/screenshots/project-edit-390.png differ diff --git a/docs/mobile-smoke-polish/screenshots/project-edit-fields-390.png b/docs/mobile-smoke-polish/screenshots/project-edit-fields-390.png new file mode 100644 index 000000000..684c6124e Binary files /dev/null and b/docs/mobile-smoke-polish/screenshots/project-edit-fields-390.png differ diff --git a/docs/mobile-smoke-polish/screenshots/project-edit-new-goal-390.png b/docs/mobile-smoke-polish/screenshots/project-edit-new-goal-390.png new file mode 100644 index 000000000..83ce03abf Binary files /dev/null and b/docs/mobile-smoke-polish/screenshots/project-edit-new-goal-390.png differ diff --git a/docs/mobile-smoke-polish/screenshots/projects-invites-390.png b/docs/mobile-smoke-polish/screenshots/projects-invites-390.png new file mode 100644 index 000000000..65061fa64 Binary files /dev/null and b/docs/mobile-smoke-polish/screenshots/projects-invites-390.png differ diff --git a/docs/mobile-smoke-polish/screenshots/team-invite-390.png b/docs/mobile-smoke-polish/screenshots/team-invite-390.png new file mode 100644 index 000000000..8773e5614 Binary files /dev/null and b/docs/mobile-smoke-polish/screenshots/team-invite-390.png differ diff --git a/docs/mobile-smoke-polish/screenshots/unavailable-390.png b/docs/mobile-smoke-polish/screenshots/unavailable-390.png new file mode 100644 index 000000000..132a87c42 Binary files /dev/null and b/docs/mobile-smoke-polish/screenshots/unavailable-390.png differ diff --git a/docs/mobile-smoke-polish/visual.cjs b/docs/mobile-smoke-polish/visual.cjs new file mode 100644 index 000000000..dea2cc660 --- /dev/null +++ b/docs/mobile-smoke-polish/visual.cjs @@ -0,0 +1,365 @@ +/** @format */ + +const fs = require("node:fs"); +const path = require("node:path"); +const assert = require("node:assert/strict"); +const { launch, measure, assertViewport } = require("../responsive/browser-harness.cjs"); +const fixtures = require("../mobile-polish/fixtures.cjs"); +const preview = require("./preview.cjs"); + +// Только синтетические данные: harness не отправляет запросы в live API. +const originalResponse = fixtures.response; +fixtures.overview.attention.participantsWithoutTeam = 6; +fixtures.overview.attention.projectsNotSubmitted.total = 1; +fixtures.response = (endpoint, method, query, state) => { + if (/\/invites\/$/.test(endpoint) && method === "GET" && !query.has("project")) { + return [ + { + id: 1, + user: fixtures.user, + project: fixtures.project, + role: "Разработчик", + isAccepted: null, + datetimeCreated: "2026-10-01", + }, + ]; + } + return originalResponse(endpoint, method, query, state); +}; + +const [phase, baseUrl, widthsArgument = "320,390,768,1440,1920", directoryArgument] = + process.argv.slice(2); +const output = path.resolve(directoryArgument || ".desktop-regression/mobile-smoke/screenshots"); +const widths = widthsArgument.split(",").map(Number); +const details = process.argv[6] === "details"; +const screens = [ + ["feed", "/office/feed", "app-open-vacancy"], + ["projects", "/office/projects/dashboard", "app-info-card"], + ["members", "/office/members", "app-member-card"], + ["programs", "/office/program/all", "app-program-card"], + ["program", "/office/program/1", "app-program-role-widget"], + ["profile", "/office/profile/1", "app-profile-left-side"], + ["project", "/office/projects/1", "app-projects-left-side"], + ["vacancies", "/office/vacancies/all", "app-project-vacancy-card"], + ["vacancy", "/office/vacancies/1", "app-vacancies-detail"], + ["courses", "/office/courses/all", "app-course"], + ["course", "/office/courses/1", "app-course-detail"], + ["profile-edit", "/office/profile/edit?editingStep=main", "app-profile-main-step"], + [ + "project-edit", + "/office/projects/1/edit?editingStep=main&programLinkId=1", + "app-project-main-step", + ], + [ + "partners", + "/office/projects/1/edit?editingStep=contacts&programLinkId=1", + "app-project-partner-resources-step", + ], + ["analytics", "/office/program/1/analytics", ".attention__list"], + [ + "team-invite", + "/office/projects/1/edit?editingStep=team&programLinkId=1", + "app-project-team-step", + ], +]; + +async function settle(page) { + await page.waitForLoadState("networkidle"); + await page.evaluate(() => { + for (const image of document.images) image.loading = "eager"; + }); + await page.waitForLoadState("networkidle"); + await page.evaluate(() => document.fonts.ready); + await page.waitForFunction(() => [...document.images].every(image => image.complete)); + await page.addStyleTag({ + content: + "*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important;}", + }); + await page.evaluate(() => { + window.scrollTo(0, 0); + for (const element of document.querySelectorAll("*")) { + if (["auto", "scroll"].includes(getComputedStyle(element).overflowY)) element.scrollTop = 0; + } + }); +} + +async function inspect(page, name) { + return page.evaluate(name => { + const box = element => { + const r = element.getBoundingClientRect(); + return { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: r.width }; + }; + const buttons = selector => [...document.querySelectorAll(selector)].map(box); + if (name === "projects") { + const list = document.querySelector(".page__invites--list"); + const card = list?.querySelector("app-info-card"); + return { list: list && box(list), card: card && box(card) }; + } + if (name === "project-edit") { + const region = document.querySelector(".region-select__control input"); + const industry = document.querySelector("#industry .field__input"); + const date = document.querySelector("#implementationDeadline .field__input"); + const hint = document.querySelector("#implementationDeadline .field__tooltip-wrapper"); + const avatar = document.querySelector(".project__image .control"); + const avatarHint = document.querySelector(".project__image .control__tooltip-wrapper"); + return { + actions: buttons(".project__save button"), + regionFont: getComputedStyle(region).fontSize, + industryFont: getComputedStyle(industry).fontSize, + date: box(date), + dateHint: box(hint), + datePadding: parseFloat(getComputedStyle(date).paddingRight), + avatar: box(avatar), + avatarHint: box(avatarHint), + goals: buttons( + ".project__options > app-button button, .project__link-options > app-button button", + ), + }; + } + if (name === "partners") + return { actions: buttons("app-project-partner-resources-step app-button button") }; + if (name === "program") + return { + emptyContacts: document.querySelectorAll(".info__contacts:has(button:disabled)").length, + }; + if (name === "analytics") + return { + backgrounds: [...document.querySelectorAll(".attention__action")].map( + button => getComputedStyle(button).backgroundColor, + ), + }; + if (name === "team-invite") { + const field = document.querySelector(".invite-search input"); + const footer = document.querySelector(".invite-actions"); + return { + inputBorder: getComputedStyle(field).borderTopWidth, + inputShadow: getComputedStyle(field).boxShadow, + inputOutline: getComputedStyle(field).outlineStyle, + actions: buttons(".invite-actions button"), + footer: box(footer), + }; + } + return {}; + }, name); +} + +function verify(name, layout) { + const gap = actions => { + for (let i = 1; i < actions.length; i++) + assert.ok( + actions[i].top - actions[i - 1].bottom >= 7, + `${name}: missing vertical button gap`, + ); + }; + if (name === "projects") + assert.ok( + Math.abs(layout.list.left + layout.list.right - (layout.card.left + layout.card.right)) < 2, + "Invitation card must be centered", + ); + if (name === "project-edit") { + gap(layout.actions); + gap(layout.goals); + assert.equal(layout.regionFont, layout.industryFont, "Region and select typography must match"); + assert.ok(layout.date.width > 150, "Date must fit full value"); + assert.ok( + layout.dateHint.left >= layout.date.right - layout.datePadding, + "Date hint overlaps text", + ); + assert.ok(layout.avatarHint.left > layout.avatar.right, "Avatar hint must be outside logo"); + } + if (name === "partners") gap(layout.actions); + if (name === "program") assert.equal(layout.emptyContacts, 0, "Empty contacts must be hidden"); + if (name === "analytics") + assert.ok( + layout.backgrounds.every(color => color === "rgba(0, 0, 0, 0)"), + "Attention rows must not resemble progress bars", + ); + if (name === "team-invite") { + assert.equal(layout.inputBorder, "0px", "Search must have one outer border"); + assert.equal(layout.inputShadow, "none"); + assert.equal(layout.inputOutline, "none", "Focus belongs to outer search field"); + gap(layout.actions); + for (const button of layout.actions) + assert.ok( + Math.abs(button.width - layout.footer.width) < 2, + "Dialog buttons must fill footer", + ); + } +} + +(async () => { + fs.mkdirSync(path.join(output, phase), { recursive: true }); + if (phase === "contacts") { + fixtures.program.links = ["https://t.me/procollab_fixture"]; + const harness = await launch(390, { baseUrl, isMobile: true, hasTouch: true }); + await preview(harness.context, baseUrl); + try { + const page = harness.page; + await page.goto(baseUrl + "/office/program/1"); + const contacts = page.locator(".info__contacts button"); + await contacts.waitFor({ state: "visible" }); + assert.equal(await contacts.isEnabled(), true); + await contacts.click(); + await page + .locator('.modal a[href="https://t.me/procollab_fixture"]') + .waitFor({ state: "visible" }); + await assertViewport(page); + assert.deepEqual(harness.errors, []); + console.log("Program contacts: existing link opens in mobile modal"); + } finally { + await harness.browser.close(); + } + return; + } + const rows = []; + for (const width of widths) { + const harness = await launch(width, { baseUrl, isMobile: width < 750, hasTouch: width < 750 }); + await preview(harness.context, baseUrl); + const page = harness.page; + const fixClock = () => { + const NativeDate = Date; + window.Date = class extends NativeDate { + constructor(...args) { + super(...(args.length ? args : [1791288000000])); + } + static now() { + return 1791288000000; + } + }; + }; + try { + await page.goto(baseUrl + "/office/feed"); + await page + .locator("app-open-vacancy") + .first() + .waitFor({ state: "attached" }) + .catch(async error => { + console.error( + JSON.stringify( + { + phase, + baseUrl, + width, + details, + url: page.url(), + errors: harness.errors, + requests: harness.requests, + html: (await page.content()).slice(-1600), + }, + null, + 2, + ), + ); + throw error; + }); + await page.evaluate(fixClock); + await settle(page); + for (const [name, route, selector] of screens) { + if (details && !["projects", "project-edit", "analytics", "team-invite"].includes(name)) + continue; + harness.errors.length = 0; + if (name !== "feed") + await page.evaluate(route => { + history.pushState({}, "", route); + dispatchEvent(new PopStateEvent("popstate")); + }, route); + await page + .locator(selector) + .first() + .waitFor({ state: "attached" }) + .catch(async error => { + console.error( + JSON.stringify( + { + name, + width, + text: await page.locator("body").innerText(), + errors: harness.errors, + requests: harness.requests.slice(-15), + }, + null, + 2, + ), + ); + throw error; + }); + await settle(page); + if (name === "team-invite") { + await page.locator(".invite__submit button").click(); + await page.locator(".project-invite[role=dialog]").waitFor({ state: "visible" }); + await settle(page); + } + const viewport = phase === "after" ? await assertViewport(page) : await measure(page); + const layout = await inspect(page, name); + if (phase === "after" && width < 1000) { + try { + verify(name, layout); + } catch (error) { + console.error(JSON.stringify({ name, width, layout })); + throw error; + } + } + assert.deepEqual(harness.errors, [], `${name} ${width} console errors`); + await page.screenshot({ + path: path.join(output, phase, `${name}-${width}.png`), + fullPage: true, + }); + rows.push({ name, width, layout, viewport, passed: true }); + console.log(`${phase}: ${name} ${width}`); + if (details) { + const screenshot = async (suffix, selector) => { + await page + .locator(selector) + .first() + .evaluate(element => element.scrollIntoView({ block: "center" })); + await page.screenshot({ + path: path.join(output, phase, `${suffix}-${width}.png`), + fullPage: true, + }); + console.log(`${phase}: ${suffix} ${width}`); + }; + if (name === "projects") await screenshot("projects-invites", ".page__invites--list"); + if (name === "project-edit") { + await screenshot("project-edit-fields", "#implementationDeadline"); + await screenshot("project-edit-goals", ".project__options"); + await page.locator(".project__options > app-button button").click(); + await screenshot("project-edit-new-goal", ".project__links--wrapper"); + if (phase === "after" && width < 1000) { + await assertViewport(page); + const date = page.locator(".project__links input").nth(1); + const box = await date.boundingBox(); + assert.ok(box.width > 150, "New goal date must fit its value"); + } + } + if (name === "analytics") await screenshot("analytics-attention", ".attention__list"); + if (name === "team-invite") { + await page.locator("#team-invite-search").fill("Кандидат"); + await page.locator(".invite-row[role=radio]").first().click(); + await page.locator(".invite-role input").fill("Разработчик"); + assert.equal( + await page + .getByRole("button", { name: "Отправить приглашение", exact: true }) + .isEnabled(), + true, + ); + await page.screenshot({ + path: path.join(output, phase, `team-invite-selected-${width}.png`), + fullPage: true, + }); + console.log(`${phase}: team-invite-selected ${width}`); + } + } + if (name === "team-invite") await page.getByLabel("Закрыть приглашение").click(); + } + } finally { + await harness.browser.close(); + } + } + fs.writeFileSync( + path.join(output, `${phase}${details ? "-details" : ""}.json`), + JSON.stringify(rows, null, 2) + "\n", + ); +})().catch(error => { + console.error(error); + process.exitCode = 1; +}); diff --git a/projects/core/src/lib/services/websockets/websocket.service.spec.ts b/projects/core/src/lib/services/websockets/websocket.service.spec.ts index 72f5ef351..bd3e371f7 100644 --- a/projects/core/src/lib/services/websockets/websocket.service.spec.ts +++ b/projects/core/src/lib/services/websockets/websocket.service.spec.ts @@ -4,6 +4,7 @@ import { TestBed } from "@angular/core/testing"; import { WebsocketService } from "./websocket.service"; import { TokenService } from "../tokens/token.service"; +import { environment } from "@environment"; describe("WebsocketService", () => { let service: WebsocketService; @@ -30,4 +31,90 @@ describe("WebsocketService", () => { it("should be created", () => { expect(service).toBeTruthy(); }); + + describe("восстановление соединения", () => { + let sockets: any[]; + + beforeEach(() => { + vi.useFakeTimers(); + sockets = []; + vi.stubGlobal( + "WebSocket", + class { + static OPEN = 1; + readyState = 0; + onopen: (() => void) | null = null; + onerror: (() => void) | null = null; + onclose: (() => void) | null = null; + onmessage: (() => void) | null = null; + send = vi.fn(); + close = vi.fn(() => { + this.readyState = 3; + }); + constructor() { + sockets.push(this); + } + }, + ); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("продолжает retry, сообщает один период недоступности и фиксирует восстановление", () => { + const states: string[] = []; + service.connectionStatus$.subscribe(state => states.push(state)); + const connection = service.connect("/chat/").subscribe(); + for (let i = 1; i <= environment.websocketReconnectionMaxAttempts + 2; i++) { + sockets.at(-1).onerror(); + vi.advanceTimersByTime( + i >= environment.websocketReconnectionMaxAttempts + ? Math.max(environment.websocketReconnectionInterval, 5000) + : environment.websocketReconnectionInterval, + ); + } + expect(states).toEqual(["idle", "unavailable"]); + sockets.at(-1).readyState = 1; + sockets.at(-1).onopen(); + expect(states).toEqual(["idle", "unavailable", "connected"]); + expect(service.isConnected).toBe(true); + connection.unsubscribe(); + expect(sockets.at(-1).close).toHaveBeenCalled(); + }); + + it("сохраняет буфер сообщений и отправляет его один раз после подключения", () => { + const connection = service.connect("/chat/").subscribe(); + service.send("new_message", { message: "Тест" }); + const socket = sockets[0]; + socket.readyState = 1; + socket.onopen(); + expect(socket.send).toHaveBeenCalledExactlyOnceWith( + JSON.stringify({ type: "new_message", content: { message: "Тест" } }), + ); + connection.unsubscribe(); + }); + + it("явное закрытие не запускает новую попытку подключения", () => { + const connection = service.connect("/chat/").subscribe(); + sockets[0].onopen(); + service.close(); + expect(sockets[0].onclose).toBeNull(); + vi.advanceTimersByTime(10000); + expect(sockets).toHaveLength(1); + expect(service.isConnected).toBe(false); + connection.unsubscribe(); + }); + + it("закрытие во время retry отменяет отложенное переподключение", () => { + const completed = vi.fn(); + service.connect("/chat/").subscribe({ complete: completed }); + sockets[0].onerror(); + service.close(); + vi.advanceTimersByTime(10000); + expect(sockets).toHaveLength(1); + expect(completed).toHaveBeenCalledOnce(); + }); + }); }); diff --git a/projects/core/src/lib/services/websockets/websocket.service.ts b/projects/core/src/lib/services/websockets/websocket.service.ts index 469315edb..cadd9ef1f 100644 --- a/projects/core/src/lib/services/websockets/websocket.service.ts +++ b/projects/core/src/lib/services/websockets/websocket.service.ts @@ -1,6 +1,17 @@ /** @format */ import { inject, Injectable } from "@angular/core"; -import { filter, map, Observable, Observer, retry, Subject, timer } from "rxjs"; +import { + BehaviorSubject, + distinctUntilChanged, + filter, + map, + Observable, + Observer, + retry, + Subject, + takeUntil, + timer, +} from "rxjs"; import { environment } from "@environment"; import snakecaseKeys from "snakecase-keys"; import camelcaseKeys from "camelcase-keys"; @@ -20,10 +31,18 @@ export class WebsocketService { private socket: WebSocket | null = null; /** Subject для обработки входящих сообщений */ private messages$ = new Subject(); + private readonly closed$ = new Subject(); /** Subject потери соединения (после исчерпания retry) — приватный, наружу только Observable */ private readonly _connectionLost$ = new Subject(); /** Сигнал потери WS-соединения. Потребитель отвечает за UX (toast/banner/etc). */ public readonly connectionLost$ = this._connectionLost$.asObservable(); + private readonly connectionStatusSubject = new BehaviorSubject< + "idle" | "connected" | "unavailable" + >("idle"); + /** Статус именно чата, а не доступности HTTP API или интернета в целом. */ + public readonly connectionStatus$ = this.connectionStatusSubject + .asObservable() + .pipe(distinctUntilChanged()); /** Буфер исходящих сообщений, накопленных пока сокет не OPEN — флашится в onopen. */ private outboundQueue: string[] = []; @@ -50,9 +69,11 @@ export class WebsocketService { const tokenAccess = tokens?.access ? tokens.access : ""; this.socket = new WebSocket(environment.websocketUrl + path, ["Bearer", tokenAccess]); + const socket = this.socket; this.socket.onopen = () => { this.isConnected = true; + this.connectionStatusSubject.next("connected"); // Отправляем всё, что накопилось пока сокет был CONNECTING. const flush = this.outboundQueue; this.outboundQueue = []; @@ -75,6 +96,18 @@ export class WebsocketService { this.socket.onmessage = message => { this.messages$.next(message); }; + + return () => { + socket.onopen = null; + socket.onerror = null; + socket.onclose = null; + socket.onmessage = null; + socket.close(); + if (this.socket === socket) { + this.socket = null; + this.isConnected = false; + } + }; }).pipe( // Resilient reconnect: НЕ сдаёмся насовсем по исчерпании попыток. // Первые maxAttempts — частые попытки (быстрое восстановление после idle-close сервера); @@ -83,6 +116,7 @@ export class WebsocketService { retry({ delay: (_error, retryCount) => { if (retryCount >= environment.websocketReconnectionMaxAttempts) { + this.connectionStatusSubject.next("unavailable"); this._connectionLost$.next(); return timer(Math.max(environment.websocketReconnectionInterval, 5000)); } @@ -90,6 +124,7 @@ export class WebsocketService { }, resetOnSuccess: true, }), + takeUntil(this.closed$), ); } @@ -141,10 +176,14 @@ export class WebsocketService { * Очищает ссылку на socket и сбрасывает флаг подключения */ public close(): void { + this.closed$.next(); if (this.socket) { + this.socket.onclose = null; + this.socket.onerror = null; this.socket.close(); this.socket = null; this.isConnected = false; } + this.connectionStatusSubject.next("idle"); } } diff --git a/projects/social_platform/src/app/api/connection-status/connection-status-toast.service.spec.ts b/projects/social_platform/src/app/api/connection-status/connection-status-toast.service.spec.ts new file mode 100644 index 000000000..cafd61093 --- /dev/null +++ b/projects/social_platform/src/app/api/connection-status/connection-status-toast.service.spec.ts @@ -0,0 +1,56 @@ +/** @format */ + +import { TestBed } from "@angular/core/testing"; +import { BehaviorSubject } from "rxjs"; +import { WebsocketService } from "@corelib"; +import { Snack } from "@domain/shared/snack.model"; +import { SnackbarService } from "@domain/shared/snackbar.service"; +import { ConnectionStatusToastService } from "./connection-status-toast.service"; + +describe("ConnectionStatusToastService", () => { + let status: BehaviorSubject<"idle" | "connected" | "unavailable">; + let snackbar: SnackbarService; + let snacks: Snack[]; + let dismissed: string[]; + + beforeEach(() => { + status = new BehaviorSubject<"idle" | "connected" | "unavailable">("idle"); + TestBed.configureTestingModule({ + providers: [ + { provide: WebsocketService, useValue: { connectionStatus$: status.asObservable() } }, + ], + }); + snackbar = TestBed.inject(SnackbarService); + snacks = []; + dismissed = []; + snackbar.snacks.subscribe(snack => snacks.push(snack)); + snackbar.dismissed$.subscribe(id => dismissed.push(id)); + TestBed.inject(ConnectionStatusToastService); + }); + + it("показывает одно закрываемое сообщение за весь период недоступности", () => { + status.next("connected"); + expect(snacks).toEqual([]); + status.next("unavailable"); + status.next("unavailable"); + status.next("unavailable"); + expect(snacks).toHaveLength(1); + expect(snacks[0]).toMatchObject({ type: "error", timeout: 0, dismissible: true }); + expect(snacks[0].text).toMatch(/Чат временно недоступен/); + }); + + it("убирает сообщение после восстановления и сообщает о следующем отдельном обрыве", () => { + status.next("unavailable"); + status.next("connected"); + expect(dismissed).toEqual([snacks[0].id]); + status.next("unavailable"); + expect(snacks).toHaveLength(2); + expect(snacks[1].id).not.toBe(snacks[0].id); + }); + + it("убирает сообщение при завершении сессии чата", () => { + status.next("unavailable"); + status.next("idle"); + expect(dismissed).toEqual([snacks[0].id]); + }); +}); diff --git a/projects/social_platform/src/app/api/connection-status/connection-status-toast.service.ts b/projects/social_platform/src/app/api/connection-status/connection-status-toast.service.ts index 1c5adcc6b..d1df7a825 100644 --- a/projects/social_platform/src/app/api/connection-status/connection-status-toast.service.ts +++ b/projects/social_platform/src/app/api/connection-status/connection-status-toast.service.ts @@ -3,16 +3,28 @@ import { inject, Injectable } from "@angular/core"; import { WebsocketService } from "@corelib"; import { SnackbarService } from "@domain/shared/snackbar.service"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -/** Слушает потерю WebSocket-соединения и показывает пользователю toast. */ +/** Одно закрываемое уведомление на период недоступности чата; скрывается после восстановления. */ @Injectable({ providedIn: "root" }) export class ConnectionStatusToastService { private readonly websocketService = inject(WebsocketService); private readonly snackbarService = inject(SnackbarService); + private notificationId?: string; constructor() { - this.websocketService.connectionLost$.subscribe(() => - this.snackbarService.error("Соединение с сервером потеряно. Проверьте интернет."), - ); + this.websocketService.connectionStatus$.pipe(takeUntilDestroyed()).subscribe(status => { + if (status === "unavailable") { + if (!this.notificationId) { + this.notificationId = this.snackbarService.error( + "Чат временно недоступен. Сообщения и статусы могут обновляться с задержкой. Подключаемся автоматически.", + { timeout: 0, dismissible: true }, + ); + } + } else if (this.notificationId) { + this.snackbarService.dismiss(this.notificationId); + this.notificationId = undefined; + } + }); } } diff --git a/projects/social_platform/src/app/domain/shared/snack.model.ts b/projects/social_platform/src/app/domain/shared/snack.model.ts index 1477ad623..2fcc8e36c 100644 --- a/projects/social_platform/src/app/domain/shared/snack.model.ts +++ b/projects/social_platform/src/app/domain/shared/snack.model.ts @@ -6,4 +6,5 @@ export class Snack { text!: string; timeout!: number; type!: "error" | "success" | "info"; + dismissible?: boolean; } diff --git a/projects/social_platform/src/app/domain/shared/snackbar.service.ts b/projects/social_platform/src/app/domain/shared/snackbar.service.ts index 88c87f9f6..ea27e596a 100644 --- a/projects/social_platform/src/app/domain/shared/snackbar.service.ts +++ b/projects/social_platform/src/app/domain/shared/snackbar.service.ts @@ -11,6 +11,8 @@ import { nanoid } from "nanoid"; }) export class SnackbarService { private readonly snacks$ = new Subject(); + private readonly dismissedSubject = new Subject(); + readonly dismissed$ = this.dismissedSubject.asObservable(); snacks = this.snacks$.asObservable().pipe(distinctUntilChanged()); @@ -18,8 +20,23 @@ export class SnackbarService { this.snacks$.next({ id: nanoid(), text, timeout: options.timeout, type: "success" }); } - error(text: string, options: { timeout: number } = { timeout: 5000 }): void { - this.snacks$.next({ id: nanoid(), text, timeout: options.timeout, type: "error" }); + error( + text: string, + options: { timeout: number; dismissible?: boolean } = { timeout: 5000 }, + ): string { + const id = nanoid(); + this.snacks$.next({ + id, + text, + timeout: options.timeout, + type: "error", + dismissible: options.dismissible, + }); + return id; + } + + dismiss(id: string): void { + this.dismissedSubject.next(id); } info(text: string, options: { timeout: number } = { timeout: 5000 }): void { diff --git a/projects/social_platform/src/app/infrastructure/repository/chat/chat-realtime.repository.spec.ts b/projects/social_platform/src/app/infrastructure/repository/chat/chat-realtime.repository.spec.ts index 4113524cc..23d03d8ea 100644 --- a/projects/social_platform/src/app/infrastructure/repository/chat/chat-realtime.repository.spec.ts +++ b/projects/social_platform/src/app/infrastructure/repository/chat/chat-realtime.repository.spec.ts @@ -69,6 +69,10 @@ describe("ChatRealtimeRepository", () => { expect(adapter.connect).toHaveBeenCalledExactlyOnceWith(); }); + it("не создаёт второе уведомление об обрыве из транспортного repository", () => { + expect(snackbarService.error).not.toHaveBeenCalled(); + }); + it("делегирует send/edit/delete/read/startTyping в WS adapter", () => { const sendPayload = {} as never; const editPayload = {} as never; diff --git a/projects/social_platform/src/app/infrastructure/repository/chat/chat-realtime.repository.ts b/projects/social_platform/src/app/infrastructure/repository/chat/chat-realtime.repository.ts index 754f6809d..a78ab8618 100644 --- a/projects/social_platform/src/app/infrastructure/repository/chat/chat-realtime.repository.ts +++ b/projects/social_platform/src/app/infrastructure/repository/chat/chat-realtime.repository.ts @@ -13,25 +13,12 @@ import { } from "@domain/chat/chat.model"; import { plainToInstance } from "class-transformer"; import { filter, map } from "rxjs"; -import { WebsocketService } from "@core/public-api"; -import { SnackbarService } from "@domain/shared/snackbar.service"; import { mapChatMessage } from "./chat.repository"; /** ChatRealtimePort поверх WebSocket-адаптера: команды проксируются в сокет, входящие события типизируются. */ @Injectable({ providedIn: "root" }) export class ChatRealtimeRepository implements ChatRealtimePort { private readonly chatWsAdapter = inject(ChatWsAdapter); - private readonly websocketService = inject(WebsocketService); - private readonly snackbarService = inject(SnackbarService); - - constructor() { - this.websocketService.connectionLost$.subscribe({ - next: () => { - this.snackbarService.error("Соединение потеряно"); - }, - }); - } - connect() { return this.chatWsAdapter.connect(); } diff --git a/projects/social_platform/src/app/ui/pages/office/snackbar/snackbar.component.html b/projects/social_platform/src/app/ui/pages/office/snackbar/snackbar.component.html index 088c67919..831756b1d 100644 --- a/projects/social_platform/src/app/ui/pages/office/snackbar/snackbar.component.html +++ b/projects/social_platform/src/app/ui/pages/office/snackbar/snackbar.component.html @@ -11,12 +11,23 @@ }" @slideInOut > - {{ snack.text }} - + {{ snack.text }} + @if (snack.dismissible) { + + } @else { + + } } diff --git a/projects/social_platform/src/app/ui/pages/office/snackbar/snackbar.component.scss b/projects/social_platform/src/app/ui/pages/office/snackbar/snackbar.component.scss index 60046c083..fa32c4235 100644 --- a/projects/social_platform/src/app/ui/pages/office/snackbar/snackbar.component.scss +++ b/projects/social_platform/src/app/ui/pages/office/snackbar/snackbar.component.scss @@ -34,6 +34,25 @@ padding: 20px; border-radius: 10px; + &__close { + display: grid; + flex: 0 0 44px; + place-items: center; + width: 44px; + height: 44px; + padding: 0; + color: inherit; + cursor: pointer; + background: transparent; + border: 0; + border-radius: 8px; + + &:focus-visible { + outline: 2px solid currentcolor; + outline-offset: 2px; + } + } + &--success { color: var(--white); background-color: var(--green); diff --git a/projects/social_platform/src/app/ui/pages/office/snackbar/snackbar.component.spec.ts b/projects/social_platform/src/app/ui/pages/office/snackbar/snackbar.component.spec.ts index 01b4bac10..e78a0c4ba 100644 --- a/projects/social_platform/src/app/ui/pages/office/snackbar/snackbar.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/office/snackbar/snackbar.component.spec.ts @@ -3,6 +3,8 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { SnackbarComponent } from "./snackbar.component"; +import { SnackbarService } from "@domain/shared/snackbar.service"; +import { provideNoopAnimations } from "@angular/platform-browser/animations"; describe("SnackbarComponent", () => { let component: SnackbarComponent; @@ -11,6 +13,7 @@ describe("SnackbarComponent", () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [SnackbarComponent], + providers: [provideNoopAnimations()], }).compileComponents(); }); @@ -23,4 +26,23 @@ describe("SnackbarComponent", () => { it("should create", () => { expect(component).toBeTruthy(); }); + + it("закрывает постоянное уведомление кнопкой и не удаляет соседние сообщения", () => { + const service = TestBed.inject(SnackbarService); + service.error("Чат недоступен", { timeout: 0, dismissible: true }); + service.info("Другое сообщение", { timeout: 0 }); + fixture.detectChanges(); + fixture.nativeElement.querySelector('[aria-label="Закрыть уведомление"]').click(); + fixture.detectChanges(); + expect(component.snacks.map(snack => snack.text)).toEqual(["Другое сообщение"]); + }); + + it("убирает нужное уведомление по восстановлению соединения", () => { + const service = TestBed.inject(SnackbarService); + const id = service.error("Чат недоступен", { timeout: 0, dismissible: true }); + service.info("Другое сообщение", { timeout: 0 }); + service.dismiss(id); + fixture.detectChanges(); + expect(component.snacks.map(snack => snack.text)).toEqual(["Другое сообщение"]); + }); }); diff --git a/projects/social_platform/src/app/ui/pages/office/snackbar/snackbar.component.ts b/projects/social_platform/src/app/ui/pages/office/snackbar/snackbar.component.ts index b9137500b..b537c59c2 100644 --- a/projects/social_platform/src/app/ui/pages/office/snackbar/snackbar.component.ts +++ b/projects/social_platform/src/app/ui/pages/office/snackbar/snackbar.component.ts @@ -61,6 +61,10 @@ export class SnackbarComponent implements OnInit, OnDestroy { this.snackbarService.snacks .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(snack => this.addNotification(snack)); + this.snackbarService.dismissed$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(id => { + this.snacks = this.snacks.filter(snack => snack.id !== id); + this.cdr.markForCheck(); + }); } /** Отписка от уведомлений при уничтожении */ diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.scss b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.scss index 91cb4e3be..513380f01 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.scss +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.scss @@ -983,3 +983,22 @@ background-position: -100% 0; } } + +@media screen and (max-width: 999px) { + .attention .attention__list .attention__action.ui-button { + width: 100%; + padding: 14px 12px; + font: inherit; + color: var(--black); + text-align: left; + background: transparent; + border: 0; + border-radius: 8px; + + &:hover:not(:disabled), + &:active:not(:disabled) { + background: transparent; + border-color: transparent; + } + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.html b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.html index cab18c3d4..cb0f9bedc 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.html +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.html @@ -455,7 +455,7 @@ -
+