add brandbook
This commit is contained in:
+4
-4
@@ -1,12 +1,12 @@
|
|||||||
# Вечерний детектив — брендбук
|
# Вечерний детектив — брендбук
|
||||||
|
|
||||||
Единый рабочий брендбук из трёх блоков: «Логотип», «Шрифты», «Цвета». Блок логотипа занимает две страницы, шрифты и цвета — по одной. Страницы белые; в основном документе оставлены только примеры и краткие правила. Технические сведения и источники сохранены в этом описании. В страницах оставлены логотипы, шрифты и палитра. Образцы диплома, бланков и фактур временно убраны из документа; исходные материалы сохранены в комплекте. Используется обновлённый логотип — детектив в шляпе с золотой круглой рамкой и прозрачным внешним фоном. Основная палитра: чёрный #111111, белый #FFFFFF и золотой #FCBF02. Бронза исключена, серый используется только как служебный цвет интерфейса; типографика закреплена за тремя гарнитурами с кириллицей и SIL OFL 1.1: Oswald 600 для логотипа и заголовков, Podkova 600 для других фирменных надписей, Noto Sans 400/700 для основного текста и выделений. Пробковая доска развивает оформление афиш и обложек. Сайт и приложения не изменены.
|
Единый рабочий брендбук из четырёх блоков: «Логотип», «Шрифты», «Цвета», «Тон общения». Документ открывает отдельная обложка. Блок логотипа занимает две страницы, шрифты, цвета и тон общения — по одной. Страницы белые; в основном документе оставлены только примеры и краткие правила. Технические сведения и источники сохранены в этом описании. На страницах представлены логотипы, шрифты, палитра и краткие правила общения с примерами. Образцы диплома, бланков и фактур временно убраны из документа; исходные материалы сохранены в комплекте. Используется обновлённый логотип — детектив в шляпе с золотой круглой рамкой и прозрачным внешним фоном. Основная палитра: чёрный #111111, белый #FFFFFF и золотой #FCBF02. Бронза исключена, серый используется только как служебный цвет интерфейса; типографика закреплена за тремя гарнитурами с кириллицей и SIL OFL 1.1: Oswald 600 для логотипа и заголовков, Podkova 600 для других фирменных надписей, Noto Sans 400/700 для основного текста и выделений. Пробковая доска развивает оформление афиш и обложек. Сайт и приложения не изменены.
|
||||||
|
|
||||||
Все изменения вносятся в этот комплект. PDF и `brandbook.zip` перезаписываются; старые версии и отдельные концепции не сохраняются.
|
Все изменения вносятся в этот комплект. PDF и `brandbook.zip` перезаписываются; старые версии и отдельные концепции не сохраняются.
|
||||||
|
|
||||||
## Комплект
|
## Комплект
|
||||||
|
|
||||||
- `brandbook.pdf` — 4 страницы A4 для просмотра и передачи.
|
- `brandbook.pdf` — 6 страниц A4 для просмотра и передачи.
|
||||||
- `index.html`, `brandbook.css` — редактируемый исходник без сборки и сетевых ресурсов.
|
- `index.html`, `brandbook.css` — редактируемый исходник без сборки и сетевых ресурсов.
|
||||||
- `assets/logos/logo.png` — обновлённая на основе исходной эмблемы растровая версия, 1254 × 1254 px, RGBA. Внешний фон прозрачный, белые области внутри знака сохранены. Изображение обновлено с помощью imagegen.
|
- `assets/logos/logo.png` — обновлённая на основе исходной эмблемы растровая версия, 1254 × 1254 px, RGBA. Внешний фон прозрачный, белые области внутри знака сохранены. Изображение обновлено с помощью imagegen.
|
||||||
- `assets/fonts/Podkova.ttf` — фирменный вариативный шрифт, насыщенности 400–800; `Podkova-OFL.txt` — его лицензия SIL OFL 1.1. Исходный файл Google Fonts скопирован без изменения байтов, с простым локальным именем.
|
- `assets/fonts/Podkova.ttf` — фирменный вариативный шрифт, насыщенности 400–800; `Podkova-OFL.txt` — его лицензия SIL OFL 1.1. Исходный файл Google Fonts скопирован без изменения байтов, с простым локальным именем.
|
||||||
@@ -36,11 +36,11 @@ pdfinfo brandbook.pdf
|
|||||||
pdftotext -layout brandbook.pdf -
|
pdftotext -layout brandbook.pdf -
|
||||||
```
|
```
|
||||||
|
|
||||||
При отсутствии соседнего проекта используйте ручную печать в браузере. Просмотр не требует Electron. Экспортёр проверяет загрузку шрифтов и изображений, отсутствие сетевых запросов, 4 HTML-страницы и отсутствие выхода содержимого за поля и границы примеров. После изменений нужен визуальный просмотр PDF.
|
При отсутствии соседнего проекта используйте ручную печать в браузере. Просмотр не требует Electron. Экспортёр проверяет загрузку шрифтов и изображений, отсутствие сетевых запросов, 6 HTML-страниц и отсутствие выхода содержимого за поля и границы примеров. После изменений нужен визуальный просмотр PDF.
|
||||||
|
|
||||||
## Проверки и ограничения
|
## Проверки и ограничения
|
||||||
|
|
||||||
Проверены три блока и 4 страницы A4, локальные ресурсы и прозрачность PNG эмблемы. Новая основная палитра закреплена в брендбуке; её перенос в `src/assets/base.css` пока не выполнен; текст правил очной игры — с актуальным `GAME_RULES_OFFLINE.md`. Шрифты встроены в PDF.
|
Проверены четыре блока и 6 страниц A4, локальные ресурсы и прозрачность PNG эмблемы. Новая основная палитра закреплена в брендбуке; её перенос в `src/assets/base.css` пока не выполнен; текст правил очной игры — с актуальным `GAME_RULES_OFFLINE.md`. Шрифты встроены в PDF.
|
||||||
|
|
||||||
Oswald, Podkova и Noto Sans разрешено использовать, встраивать и распространять по SIL OFL 1.1. При распространении шрифтов сохраняйте соответствующие лицензии и сведения авторства. При передаче файла шрифта сохраняйте `Podkova-OFL.txt` с copyright. Источник: https://github.com/google/fonts/tree/main/ofl/podkova ; файл: https://raw.githubusercontent.com/google/fonts/main/ofl/podkova/Podkova%5Bwght%5D.ttf . Файл `assets/fonts/LICENSE.txt` относится к Noto. Отдельные файлы a_OldTyper и его NOTICE удалены из комплекта.
|
Oswald, Podkova и Noto Sans разрешено использовать, встраивать и распространять по SIL OFL 1.1. При распространении шрифтов сохраняйте соответствующие лицензии и сведения авторства. При передаче файла шрифта сохраняйте `Podkova-OFL.txt` с copyright. Источник: https://github.com/google/fonts/tree/main/ofl/podkova ; файл: https://raw.githubusercontent.com/google/fonts/main/ofl/podkova/Podkova%5Bwght%5D.ttf . Файл `assets/fonts/LICENSE.txt` относится к Noto. Отдельные файлы a_OldTyper и его NOTICE удалены из комплекта.
|
||||||
|
|
||||||
|
|||||||
@@ -50,3 +50,9 @@ th{font-size:10px;letter-spacing:1px;text-transform:uppercase}
|
|||||||
@page{size:A4;margin:0}
|
@page{size:A4;margin:0}
|
||||||
@media print{body{background:transparent}.page{margin:0}*{-webkit-print-color-adjust:exact;print-color-adjust:exact}}
|
@media print{body{background:transparent}.page{margin:0}*{-webkit-print-color-adjust:exact;print-color-adjust:exact}}
|
||||||
@media screen and (max-width:820px){body{overflow-x:auto}.page{margin:0 0 16px}}
|
@media screen and (max-width:820px){body{overflow-x:auto}.page{margin:0 0 16px}}
|
||||||
|
|
||||||
|
.cover{text-align:center}
|
||||||
|
.cover-logo{display:block;width:480px;height:570px;object-fit:contain;margin:48px auto 24px}
|
||||||
|
.cover h1{font-size:64px;margin-bottom:14px}
|
||||||
|
.cover-caption{font-size:13px;letter-spacing:2px}
|
||||||
|
.cover .folio{text-align:left}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -32,8 +32,8 @@ app.whenReady().then(async () => {
|
|||||||
return { blocks: pages.map(page => page.id), pages: pages.length, overflow, familiesLoaded: ['Podkova', 'Oswald', 'Noto'].every(family => [...document.fonts].some(face => face.family === family && face.status === 'loaded')), fonts: ['14px Noto', '700 14px Noto', '600 48px Oswald', '600 32px Podkova'].every(font => document.fonts.check(font)),
|
return { blocks: pages.map(page => page.id), pages: pages.length, overflow, familiesLoaded: ['Podkova', 'Oswald', 'Noto'].every(family => [...document.fonts].some(face => face.family === family && face.status === 'loaded')), fonts: ['14px Noto', '700 14px Noto', '600 48px Oswald', '600 32px Podkova'].every(font => document.fonts.check(font)),
|
||||||
images: [...document.images].length, unloaded: [...document.images].filter(image => !image.naturalWidth).length };
|
images: [...document.images].length, unloaded: [...document.images].filter(image => !image.naturalWidth).length };
|
||||||
})()`);
|
})()`);
|
||||||
assert.equal(result.pages, 4, 'Expected four pages across three brandbook blocks');
|
assert.equal(result.pages, 6, 'Expected cover and five content pages');
|
||||||
assert.deepEqual(result.blocks, ['logo', 'logo-mono', 'fonts', 'colors']);
|
assert.deepEqual(result.blocks, ['cover', 'logo', 'logo-mono', 'fonts', 'colors', 'voice']);
|
||||||
assert.deepEqual(result.overflow, [], 'Content reaches the page footer');
|
assert.deepEqual(result.overflow, [], 'Content reaches the page footer');
|
||||||
assert.equal(result.fonts, true, 'Local fonts must load');
|
assert.equal(result.fonts, true, 'Local fonts must load');
|
||||||
assert.equal(result.familiesLoaded, true, 'All three bundled font families must load');
|
assert.equal(result.familiesLoaded, true, 'All three bundled font families must load');
|
||||||
|
|||||||
+33
-5
@@ -2,13 +2,20 @@
|
|||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Вечерний детектив — Брендбук</title><link rel="stylesheet" href="brandbook.css"></head>
|
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Вечерний детектив — Брендбук</title><link rel="stylesheet" href="brandbook.css"></head>
|
||||||
<body><main>
|
<body><main>
|
||||||
|
<section class="page cover" id="cover" aria-labelledby="cover-title">
|
||||||
|
<div class="eyebrow"><span>Вечерний детектив</span><span>Фирменный стиль</span></div>
|
||||||
|
<img class="cover-logo" src="assets/logos/vertical-color-light-transparent.png" alt="Вечерний детектив">
|
||||||
|
<h1 id="cover-title">Брендбук</h1>
|
||||||
|
<p class="cover-caption">Логотип · Шрифты · Цвета · Тон общения</p>
|
||||||
|
<footer class="folio"><span>ВЕЧЕРНИЙ ДЕТЕКТИВ</span><span>01 / 06</span></footer>
|
||||||
|
</section>
|
||||||
<section class="page" id="logo" aria-labelledby="logo-title">
|
<section class="page" id="logo" aria-labelledby="logo-title">
|
||||||
<div class="eyebrow"><span>Вечерний детектив / Брендбук</span><span>01 / Логотип</span></div>
|
<div class="eyebrow"><span>Вечерний детектив / Брендбук</span><span>01 / Логотип</span></div>
|
||||||
<h1 id="logo-title">Логотип и название</h1>
|
<h2 id="logo-title">Логотип и название</h2>
|
||||||
<p class="lead">Три композиции: знак, название справа и название снизу.</p>
|
<p class="lead">Три композиции: знак, название справа и название снизу.</p>
|
||||||
<div class="logo-grid"><figure class="logo-card mark"><a href="assets/logos/mark-color-light-background.png"><img src="assets/logos/mark-color-light-transparent.png" alt="Знак отдельно, светлый фон"></a><figcaption>Знак отдельно · светлый фон</figcaption></figure><figure class="logo-card mark"><a href="assets/logos/mark-color-dark-background.png"><img src="assets/logos/mark-color-dark-transparent.png" alt="Знак отдельно, тёмный фон"></a><figcaption>Знак отдельно · тёмный фон</figcaption></figure><figure class="logo-card horizontal"><a href="assets/logos/horizontal-color-light-background.png"><img src="assets/logos/horizontal-color-light-transparent.png" alt="Название справа, светлый фон"></a><figcaption>Название справа · светлый фон</figcaption></figure><figure class="logo-card horizontal"><a href="assets/logos/horizontal-color-dark-background.png"><img src="assets/logos/horizontal-color-dark-transparent.png" alt="Название справа, тёмный фон"></a><figcaption>Название справа · тёмный фон</figcaption></figure><figure class="logo-card vertical"><a href="assets/logos/vertical-color-light-background.png"><img src="assets/logos/vertical-color-light-transparent.png" alt="Название снизу, светлый фон"></a><figcaption>Название снизу · светлый фон</figcaption></figure><figure class="logo-card vertical"><a href="assets/logos/vertical-color-dark-background.png"><img src="assets/logos/vertical-color-dark-transparent.png" alt="Название снизу, тёмный фон"></a><figcaption>Название снизу · тёмный фон</figcaption></figure></div><div class="grid2 rule"><div><h3>Светлый фон</h3><p class="small">Чёрное название, золотые акценты.</p></div><div><h3>Тёмный фон</h3><p class="small">Белое название, золотые акценты.</p></div></div>
|
<div class="logo-grid"><figure class="logo-card mark"><a href="assets/logos/mark-color-light-background.png"><img src="assets/logos/mark-color-light-transparent.png" alt="Знак отдельно, светлый фон"></a><figcaption>Знак отдельно · светлый фон</figcaption></figure><figure class="logo-card mark"><a href="assets/logos/mark-color-dark-background.png"><img src="assets/logos/mark-color-dark-transparent.png" alt="Знак отдельно, тёмный фон"></a><figcaption>Знак отдельно · тёмный фон</figcaption></figure><figure class="logo-card horizontal"><a href="assets/logos/horizontal-color-light-background.png"><img src="assets/logos/horizontal-color-light-transparent.png" alt="Название справа, светлый фон"></a><figcaption>Название справа · светлый фон</figcaption></figure><figure class="logo-card horizontal"><a href="assets/logos/horizontal-color-dark-background.png"><img src="assets/logos/horizontal-color-dark-transparent.png" alt="Название справа, тёмный фон"></a><figcaption>Название справа · тёмный фон</figcaption></figure><figure class="logo-card vertical"><a href="assets/logos/vertical-color-light-background.png"><img src="assets/logos/vertical-color-light-transparent.png" alt="Название снизу, светлый фон"></a><figcaption>Название снизу · светлый фон</figcaption></figure><figure class="logo-card vertical"><a href="assets/logos/vertical-color-dark-background.png"><img src="assets/logos/vertical-color-dark-transparent.png" alt="Название снизу, тёмный фон"></a><figcaption>Название снизу · тёмный фон</figcaption></figure></div><div class="grid2 rule"><div><h3>Светлый фон</h3><p class="small">Чёрное название, золотые акценты.</p></div><div><h3>Тёмный фон</h3><p class="small">Белое название, золотые акценты.</p></div></div>
|
||||||
<p class="source-note">Используйте готовые композиции из комплекта.</p>
|
<p class="source-note">Используйте готовые композиции из комплекта.</p>
|
||||||
<footer class="folio"><span>ЛОГОТИП · ШРИФТЫ · ЦВЕТА</span><span>01 / 04</span></footer></section>
|
<footer class="folio"><span>ЛОГОТИП · ШРИФТЫ · ЦВЕТА · ТОН ОБЩЕНИЯ</span><span>02 / 06</span></footer></section>
|
||||||
<section class="page" id="logo-mono" aria-labelledby="logo-mono-title">
|
<section class="page" id="logo-mono" aria-labelledby="logo-mono-title">
|
||||||
<div class="eyebrow"><span>Вечерний детектив / Брендбук</span><span>01 / Логотип · продолжение</span></div>
|
<div class="eyebrow"><span>Вечерний детектив / Брендбук</span><span>01 / Логотип · продолжение</span></div>
|
||||||
<h2 id="logo-mono-title">Монохром и размещение</h2>
|
<h2 id="logo-mono-title">Монохром и размещение</h2>
|
||||||
@@ -16,7 +23,7 @@
|
|||||||
<div class="logo-grid"><figure class="logo-card mark"><a href="assets/logos/mark-black-background.png"><img src="assets/logos/mark-black-transparent.png" alt="Знак отдельно, светлый фон"></a><figcaption>Знак отдельно · светлый фон</figcaption></figure><figure class="logo-card mark"><a href="assets/logos/mark-white-background.png"><img src="assets/logos/mark-white-transparent.png" alt="Знак отдельно, тёмный фон"></a><figcaption>Знак отдельно · тёмный фон</figcaption></figure><figure class="logo-card horizontal"><a href="assets/logos/horizontal-black-background.png"><img src="assets/logos/horizontal-black-transparent.png" alt="Название справа, светлый фон"></a><figcaption>Название справа · светлый фон</figcaption></figure><figure class="logo-card horizontal"><a href="assets/logos/horizontal-white-background.png"><img src="assets/logos/horizontal-white-transparent.png" alt="Название справа, тёмный фон"></a><figcaption>Название справа · тёмный фон</figcaption></figure><figure class="logo-card vertical"><a href="assets/logos/vertical-black-background.png"><img src="assets/logos/vertical-black-transparent.png" alt="Название снизу, светлый фон"></a><figcaption>Название снизу · светлый фон</figcaption></figure><figure class="logo-card vertical"><a href="assets/logos/vertical-white-background.png"><img src="assets/logos/vertical-white-transparent.png" alt="Название снизу, тёмный фон"></a><figcaption>Название снизу · тёмный фон</figcaption></figure></div><div class="grid2 rule"><div><h3>Свободное поле</h3><p class="small">Не меньше ¼ диаметра знака. В готовых файлах поле уже включено.</p></div><div><h3>Размер и контраст</h3><p class="small">Знак — от 48 px. С названием — от 240 px по ширине. Фон спокойный, без пёстрого рисунка.</p></div></div>
|
<div class="logo-grid"><figure class="logo-card mark"><a href="assets/logos/mark-black-background.png"><img src="assets/logos/mark-black-transparent.png" alt="Знак отдельно, светлый фон"></a><figcaption>Знак отдельно · светлый фон</figcaption></figure><figure class="logo-card mark"><a href="assets/logos/mark-white-background.png"><img src="assets/logos/mark-white-transparent.png" alt="Знак отдельно, тёмный фон"></a><figcaption>Знак отдельно · тёмный фон</figcaption></figure><figure class="logo-card horizontal"><a href="assets/logos/horizontal-black-background.png"><img src="assets/logos/horizontal-black-transparent.png" alt="Название справа, светлый фон"></a><figcaption>Название справа · светлый фон</figcaption></figure><figure class="logo-card horizontal"><a href="assets/logos/horizontal-white-background.png"><img src="assets/logos/horizontal-white-transparent.png" alt="Название справа, тёмный фон"></a><figcaption>Название справа · тёмный фон</figcaption></figure><figure class="logo-card vertical"><a href="assets/logos/vertical-black-background.png"><img src="assets/logos/vertical-black-transparent.png" alt="Название снизу, светлый фон"></a><figcaption>Название снизу · светлый фон</figcaption></figure><figure class="logo-card vertical"><a href="assets/logos/vertical-white-background.png"><img src="assets/logos/vertical-white-transparent.png" alt="Название снизу, тёмный фон"></a><figcaption>Название снизу · тёмный фон</figcaption></figure></div><div class="grid2 rule"><div><h3>Свободное поле</h3><p class="small">Не меньше ¼ диаметра знака. В готовых файлах поле уже включено.</p></div><div><h3>Размер и контраст</h3><p class="small">Знак — от 48 px. С названием — от 240 px по ширине. Фон спокойный, без пёстрого рисунка.</p></div></div>
|
||||||
<p class="source-note">Не менять пропорции, цвета и расположение элементов. Не добавлять тени.</p>
|
<p class="source-note">Не менять пропорции, цвета и расположение элементов. Не добавлять тени.</p>
|
||||||
<p class="small"><a href="assets/logos/catalog.html">Все варианты логотипа →</a></p>
|
<p class="small"><a href="assets/logos/catalog.html">Все варианты логотипа →</a></p>
|
||||||
<footer class="folio"><span>ЛОГОТИП · ШРИФТЫ · ЦВЕТА</span><span>02 / 04</span></footer></section>
|
<footer class="folio"><span>ЛОГОТИП · ШРИФТЫ · ЦВЕТА · ТОН ОБЩЕНИЯ</span><span>03 / 06</span></footer></section>
|
||||||
<section class="page" id="fonts" aria-labelledby="fonts-title">
|
<section class="page" id="fonts" aria-labelledby="fonts-title">
|
||||||
<div class="eyebrow"><span>Вечерний детектив / Брендбук</span><span>02 / Шрифты</span></div>
|
<div class="eyebrow"><span>Вечерний детектив / Брендбук</span><span>02 / Шрифты</span></div>
|
||||||
<h2 id="fonts-title">Шрифты</h2>
|
<h2 id="fonts-title">Шрифты</h2>
|
||||||
@@ -35,7 +42,7 @@
|
|||||||
</tbody></table>
|
</tbody></table>
|
||||||
<p class="source-note">Шрифты включены в комплект. Лицензии SIL OFL 1.1: <a href="assets/fonts/Oswald-OFL.txt">Oswald</a>, <a href="assets/fonts/Podkova-OFL.txt">Podkova</a>, <a href="assets/fonts/LICENSE.txt">Noto Sans</a>. Передавайте шрифты вместе с лицензиями.</p>
|
<p class="source-note">Шрифты включены в комплект. Лицензии SIL OFL 1.1: <a href="assets/fonts/Oswald-OFL.txt">Oswald</a>, <a href="assets/fonts/Podkova-OFL.txt">Podkova</a>, <a href="assets/fonts/LICENSE.txt">Noto Sans</a>. Передавайте шрифты вместе с лицензиями.</p>
|
||||||
|
|
||||||
<footer class="folio"><span>ЛОГОТИП · ШРИФТЫ · ЦВЕТА</span><span>03 / 04</span></footer>
|
<footer class="folio"><span>ЛОГОТИП · ШРИФТЫ · ЦВЕТА · ТОН ОБЩЕНИЯ</span><span>04 / 06</span></footer>
|
||||||
</section>
|
</section>
|
||||||
<section class="page" id="colors" aria-labelledby="colors-title">
|
<section class="page" id="colors" aria-labelledby="colors-title">
|
||||||
<div class="eyebrow"><span>Вечерний детектив / Брендбук</span><span>03 / Цвета</span></div>
|
<div class="eyebrow"><span>Вечерний детектив / Брендбук</span><span>03 / Цвета</span></div>
|
||||||
@@ -49,6 +56,27 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="grid2 rule"><div><h3>Служебные цвета</h3><p class="small">Серый — для элементов интерфейса. Цвета статусов сопровождаются текстом.</p></div><div><h3>Контраст</h3><p class="small">Белый на чёрном; чёрный на белом и золотом. Не набирать мелкий текст золотым на белом.</p></div></div>
|
<div class="grid2 rule"><div><h3>Служебные цвета</h3><p class="small">Серый — для элементов интерфейса. Цвета статусов сопровождаются текстом.</p></div><div><h3>Контраст</h3><p class="small">Белый на чёрном; чёрный на белом и золотом. Не набирать мелкий текст золотым на белом.</p></div></div>
|
||||||
<p class="small">Для печати — цветопроба. <a href="README.md">Источники и лицензии →</a></p>
|
<p class="small">Для печати — цветопроба. <a href="README.md">Источники и лицензии →</a></p>
|
||||||
<footer class="folio"><span>ЛОГОТИП · ШРИФТЫ · ЦВЕТА</span><span>04 / 04</span></footer>
|
<footer class="folio"><span>ЛОГОТИП · ШРИФТЫ · ЦВЕТА · ТОН ОБЩЕНИЯ</span><span>05 / 06</span></footer>
|
||||||
|
</section>
|
||||||
|
<section class="page" id="voice" aria-labelledby="voice-title">
|
||||||
|
<div class="eyebrow"><span>Вечерний детектив / Брендбук</span><span>04 / Тон общения</span></div>
|
||||||
|
<h2 id="voice-title">Тон общения</h2>
|
||||||
|
<p class="lead">Приглашаем в расследование: дружелюбно, с интригой и уважением к игроку.</p>
|
||||||
|
<div class="grid2 rule">
|
||||||
|
<div><h3>На равных</h3><p>Обращаемся на «вы», со строчной буквы. Поддерживаем команду, не оцениваем способности игроков.</p></div>
|
||||||
|
<div><h3>Интрига без спойлеров</h3><p>Даём завязку и повод строить версии. Не раскрываем решения и ключевые улики.</p></div>
|
||||||
|
<div><h3>Ясно и по делу</h3><p>Короткие фразы и конкретные действия. Время, место, цена и правила — без загадок.</p></div>
|
||||||
|
<div><h3>Детективный характер</h3><p>«Дело», «улика», «версия», «расследование» — к месту. Без пафоса, давления и избытка восклицаний.</p></div>
|
||||||
|
</div>
|
||||||
|
<table><thead><tr><th>Ситуация</th><th>Как говорим</th></tr></thead><tbody>
|
||||||
|
<tr><td>Анонс</td><td>У каждого свидетеля своя версия. Соберите команду и выясните, что произошло.</td></tr>
|
||||||
|
<tr><td>Призыв</td><td>Выберите игру и зарегистрируйте команду.</td></tr>
|
||||||
|
<tr><td>Подсказка</td><td>Сравните время в показаниях. Все ли детали сходятся?</td></tr>
|
||||||
|
<tr><td>Ошибка</td><td>Не удалось отправить ответ. Попробуйте ещё раз.</td></tr>
|
||||||
|
<tr><td>Финал</td><td>Дело закрыто. Спасибо за ваши версии и командную работу!</td></tr>
|
||||||
|
</tbody></table>
|
||||||
|
<div class="grid2 rule"><div><h3>Избегаем</h3><p>«Только гений раскроет это дело!»<br>«Вы опять ошиблись».<br>«Срочно! Последний шанс!!!»</p></div><div><h3>Вместо этого</h3><p>«Внимание к деталям поможет найти ответ».<br>«Попробуйте проверить другую версию».<br>Указываем реальный срок регистрации.</p></div></div>
|
||||||
|
<p class="source-note">В анонсах создаём атмосферу. В правилах, оплате и поддержке прежде всего помогаем разобраться.</p>
|
||||||
|
<footer class="folio"><span>ЛОГОТИП · ШРИФТЫ · ЦВЕТА · ТОН ОБЩЕНИЯ</span><span>06 / 06</span></footer>
|
||||||
</section>
|
</section>
|
||||||
</main></body></html>
|
</main></body></html>
|
||||||
|
|||||||
@@ -2,6 +2,97 @@
|
|||||||
|
|
||||||
Архив задач, переведённых в статус `DONE`.
|
Архив задач, переведённых в статус `DONE`.
|
||||||
|
|
||||||
|
### ED-058 — Добавить карту и лист оплаты в материалы ведущего
|
||||||
|
|
||||||
|
- Статус: `DONE`
|
||||||
|
- Цель: добавить два согласованных исходных PNG в `Для ведущего/` каждого печатного ZIP.
|
||||||
|
- Решение: встроенные ассеты бэкенда, исходные имена и байты; существующие проверки отмены и размера архива.
|
||||||
|
- Критерии: оба файла есть в ZIP, в том числе без команд, без преобразования в PDF.
|
||||||
|
- Проверка: тесты наличия и точного совпадения байтов, `gofmt`, `go test ./...`, `git diff --check`, ревью Go-стандарта.
|
||||||
|
- Ограничения: без обработки изображений, изменений API/UI, физической печати, commit/push.
|
||||||
|
- Результат: оба исходных PNG добавляются в `Для ведущего/` с исходными именами для каждой игры. Тесты подтверждают наличие и точные байты файлов, включая игру без команд; копии ассетов сверены с исходниками. `gofmt`, `go test ./...` и `git diff --check` выполнены успешно; diff проверен по Go-стандарту.
|
||||||
|
- Ограничения проверки: физическая печать и рабочий сервер не проверялись. Commit/push не выполнялись.
|
||||||
|
|
||||||
|
|
||||||
|
### ED-057 — Заменить дипломы универсальным бланком
|
||||||
|
|
||||||
|
- Статус: `DONE`
|
||||||
|
- Цель: заменить три диплома одним пустым бланком из согласованного PNG.
|
||||||
|
- Решение: одностраничный A4 с исходной картинкой целиком, без обрезки и надписей; `Для ведущего/Дипломы/Диплом.pdf`.
|
||||||
|
- Критерии: один диплом в ZIP, включая игру без команд; все поля заполняются вручную.
|
||||||
|
- Проверка: тесты PDF и архива, визуальная проверка, `go test ./...`, Go-стандарт и `git diff --check`.
|
||||||
|
- Ограничения: без автозаполнения, новых зависимостей, изменений API/UI, физической печати, commit/push.
|
||||||
|
- Результат: три прежних диплома заменены одним пустым бланком; исходный PNG встроен без изменений. PDF визуально проверен: рамка целиком, поля пустые. `gofmt`, `go test ./...` и `git diff --check` выполнены успешно; diff проверен по Go-стандарту.
|
||||||
|
- Ограничения проверки: физическая печать и рабочий сервер не проверялись; исходник около 128 dpi на A4. Предпросмотр: `/tmp/ed057-diploma.pdf`. Commit/push не выполнялись.
|
||||||
|
|
||||||
|
|
||||||
|
### ED-056 — Редактор и офлайн-воспроизведение групп в screen
|
||||||
|
|
||||||
|
- Статус: `DONE`
|
||||||
|
- Репозиторий: `evening_detective_screen`.
|
||||||
|
- Цель: редактирование групп только в режиме редактирования, запуск групп в обычном режиме и полное воспроизведение подготовленных групп без сервера.
|
||||||
|
- Результат: в редактор добавлены создание, переименование, удаление без удаления треков, режим завершения, перестановка групп и треков внутри группы, перенос треков. Операции используют API ED-055; учитываются запреты активной/приостановленной группы. В обычном режиме — названия и режимы групп, текущая группа, «Играть группу» и возврат к целому плейлисту. Полей и кнопок изменения групп вне редактирования нет.
|
||||||
|
- Результат: черновики сохраняются при ошибках, потере связи, внешнем изменении и удалении группы; закрытие с изменениями требует явной отмены. Подтверждённое удаление собственной группы отменяет её черновик. Смена сервера сбрасывает контекст редактора.
|
||||||
|
- Результат: локальный пакет, каталог и IPC содержат группы и принадлежность треков; состояние канала хранит groupId. Формат пакета остаётся 1; отсутствующие новые поля не меняют старую форму данных и отпечатки. Метаданные групп входят в новые отпечатки и обнаружение обновлений материалов. Руководство пользователя обновлено.
|
||||||
|
- Результат: единая функция переходов используется контроллером, подготовкой следующего файла, командами и офлайн-продолжением. Поддержаны повтор одного/нескольких треков, остановка, следующая непустая группа и остановка в конце списка. При отсутствии файла или неподготовленной группе музыка безопасно останавливается с ошибкой; выход в общий список не выполняется. После восстановления связи применяется серверное состояние даже с прежней версией; офлайн-команды не отправляются задним числом.
|
||||||
|
- Результат: обработчики загрузки и завершения защищены от старых операций и сессий. Ожидающий ответ по старой группе не блокирует окончание новой короткой группы. Пауза, продолжение, громкость и ручной выбор сохраняют корректную группу и позицию.
|
||||||
|
- Проверено: `npm run type-check`, `npm run lint`, `npm run lint:css`, `npm test` (все 241 тест), `npm run build`, `git diff --check`; после сохранения исходной проверки отмены загрузки дополнительно пройден финальный online.test.js. Проверены парсер, переходы, сохранение/чтение пакетов, отпечатки, API редактора, предзагрузка, ошибки файлов, восстановление связи, устаревшие ошибки и конкурентные окончания.
|
||||||
|
- Проверено: Electron — редактор на 1280/375 px, отсутствие элементов редактирования в обычном режиме, запуск, запреты активной группы, режимы, порядок, ошибка создания и повтор, перенос трека, удаление группы с сохранением треков, сохранение черновика офлайн и при удалении извне, подтверждение отмены, офлайн-запуск и отсутствие горизонтального переполнения. Реальный короткий WAV через Controller + MediaPlayer без сервера: повтор, остановка, переход через пустую группу и остановка последней группы. Стенды и снимки находятся в `/tmp/ed056-ui`, аудиостенд — `/tmp/ed056-audio.cjs`.
|
||||||
|
- Проверено: ручное ревью целевого diff по существующим паттернам screen, границам IPC/API, сохранению данных и совместимости; независимое статическое ревью ядра, редактора и обработчиков ошибок. Обнаруженные гонки исправлены и покрыты тестами; блокирующих замечаний не осталось.
|
||||||
|
- Ограничения: редактирование требует сервера с API ED-055; офлайн используются подготовленные группы и файлы. Новые зависимости, backend API и миграции не добавлялись.
|
||||||
|
- Не проверено: звук на физическом оборудовании площадки и установленный релизный пакет. Развёртывание, commit и push не выполнялись; для использования изменений требуется обновить screen.
|
||||||
|
|
||||||
|
### ED-055 — Группы музыкального плейлиста
|
||||||
|
|
||||||
|
- Статус: `DONE`
|
||||||
|
- Репозитории: `evening_detective_server`, `evening_detective_frontend`; совместимость `evening_detective_screen`.
|
||||||
|
- Цель: именованные группы внутри плейлиста с порядком групп и треков, режимами повтор / остановка / следующая группа.
|
||||||
|
- Результат: группы создаются, переименовываются, переставляются и удаляются без удаления их треков; треки назначаются группе или остаются без группы. Новая группа повторяется по умолчанию. «Играть группу» сразу запускает первый трек; переход пропускает пустые группы и останавливается в конце списка. Старое воспроизведение всего плейлиста сохранено.
|
||||||
|
- Результат: сервер защищает активные и приостановленные группы во всех использующих их играх. Перестановка групп заблокирована до остановки плейлиста; переименование доступно. Выбор следующего трека выполняется под блокировками плейлиста и состояния, запоздавшие/повторные события окончания не перезаписывают новую команду. Переупорядочивание и удаление других треков сохраняют текущий файл и позицию.
|
||||||
|
- Результат: редактор сохраняет черновики при обновлении и удалении группы извне; исправлено пересоздание редакторов позиционными обёртками NSpace. Строковые int64 из protobuf JSON учитываются при сравнении версий и вычислении позиции паузы. Добавлены protobuf API и миграция `20261027120000_playlist_groups.sql`; группы, режимы, порядок и переназначенные ID переносятся экспортом/импортом. Старые пакеты без групп поддерживаются.
|
||||||
|
- Результат: нативный плеер сохраняет выбранную группу при паузе, продолжении, громкости и повторном запуске трека; повтор единственного трека перезапускает аудио. Управление треками и режимом группы выполняется через сайт. При потере связи выбранная группа останавливается вместо перехода в общий список; отдельный нативный редактор и автономный каталог групп не добавлялись.
|
||||||
|
- Проверено: генерация protobuf в трёх репозиториях; `gofmt`; полный `go test ./...` с отдельной PostgreSQL через `TEST_DATABASE_URL`, включая конкурентные окончания, права/ошибки, защиту редактирования, экспорт/импорт и старые пакеты; frontend `type-check`, `lint`, `lint:css`, `build`; native `type-check`, `lint`, `lint:css` и все 218 тестов; `git diff --check`.
|
||||||
|
- Проверено: Firefox на 1280/375 px — режим по умолчанию, запуск/пауза/продолжение, запреты редактирования, порядок групп, ошибка создания и повтор, перенос трека, сохранение удалённого извне черновика и отсутствие горизонтального переполнения. Реальный короткий WAV в браузере — повтор одного трека, остановка, переход через пустую группу, повтор запроса после временной ошибки; строковые версии 9→10 и 9007199254740995 передаются точно. Скриншоты и временные стенды оставлены в `/tmp`.
|
||||||
|
- Проверено: ручное ревью diff по `GO_STANDARD.md` и `FRONTEND_CODE_STANDARD.md`; независимое статическое ревью блокировок, согласованности каталога и сохранения черновиков. Блокирующих замечаний не осталось.
|
||||||
|
- Не проверено: звук на физическом оборудовании площадки и развёртывание. Production-миграция не выполнялась; для включения функции нужны миграция БД и обновление сервера/клиентов. Commit/push не выполнялись.
|
||||||
|
|
||||||
|
### ED-053 — Инструкция перед QR команды
|
||||||
|
|
||||||
|
- Статус: `DONE`
|
||||||
|
- Цель: в PDF и чеках сначала краткая инструкция Wi-Fi, затем QR, ниже название команды; предпросмотр соответствует чеку.
|
||||||
|
- Критерии: текст помещается при предельных длинах, QR сохраняет размер и читаемость; без Wi-Fi печатаются QR и название. API и настройки не меняются.
|
||||||
|
- Проверка: тесты рендеринга, просмотр образцов и декодирование QR; `gofmt`, `go test ./...`, ручное ревью по GO_STANDARD.md.
|
||||||
|
- Результат: общий текст сокращён до подключения к сети, пароля, напоминания остаться без интернета и сканирования QR. В PDF, чеках и предпросмотре инструкция сверху, затем QR, ниже название команды. При пустой сети — QR и название; размеры QR сохранены.
|
||||||
|
- Проверено: `gofmt`, `go test ./...`, целевые тесты печати (включая предельные длины и регрессию порядка QR/названия), `git diff --check`; ручное ревью diff по GO_STANDARD.md. Образцы PDF и чека просмотрены; три QR из PDF/PNG декодированы `zbarimg`.
|
||||||
|
- Не проверено: физическая печать и сканирование телефоном на площадке. Commit/push не выполнялись.
|
||||||
|
|
||||||
|
|
||||||
|
### ED-052 — Печатать инструкцию подключения к Wi-Fi рядом с QR команды
|
||||||
|
|
||||||
|
- Статус: `DONE`
|
||||||
|
- Цель: название сети и пароль задаются в настройках каждой игры и печатаются в PDF и чеках, включая предпросмотр.
|
||||||
|
- Решение: при пустом названии сети инструкция отсутствует; пустой пароль означает открытую сеть. Печать доступна онлайн и на площадке. Настройки переносятся в пакете игры и доступны для редактирования только управляющим игрой.
|
||||||
|
- Критерии: настройки сохраняются и очищаются; старые клиенты не стирают их; старые пакеты импортируются; текст помещается, QR читается. PDF с Wi-Fi содержит шесть карточек на листе.
|
||||||
|
- Проверка: тесты хранения, прав, экспорта/импорта, валидации и рендеринга; `go test ./...`; генерация protobuf; frontend type-check, lint, lint:css и build; ручное ревью стандартов.
|
||||||
|
- Ограничения: без Wi-Fi QR, новых зависимостей, commit и push. Проверка физического принтера и телефонов отдельно на площадке.
|
||||||
|
- Результат: добавлены поля игры, миграция `20261023120000_game_wifi.sql`, optional-поля обновления и форма с маскированным паролем. Параметры возвращаются только управляющим игрой; игрокам и в списки не выдаются. Старый клиент сохраняет настройки, явные пустые значения очищают их.
|
||||||
|
- Результат: PDF содержит 6 карточек при заданной сети, чек и его предпросмотр включают одинаковую инструкцию. Текст переносится, длинное название команды на чеке уменьшается при необходимости; размер QR сохранён. Пустая сеть отключает инструкцию, пустой пароль обозначает открытую сеть. Экспорт/импорт переносит параметры без изменения версии пакета; старые пакеты совместимы.
|
||||||
|
- Проверено: ручное ревью всего diff по `GO_STANDARD.md` и `FRONTEND_CODE_STANDARD.md`, `gofmt`, `git diff --check`; `make generate`, `npm run proto:gen`; `go test ./...` с отдельной PostgreSQL (включая реальные миграции, сохранение/очистку, старых клиентов и экспорт→импорт); `npm run type-check`, `npm run lint`, `npm run lint:css`, `npm run build` — успешно.
|
||||||
|
- Проверено: браузерный smoke на 390 и 1440 px — загрузка полей, маскировка пароля, валидация, сохранение пробелов пароля, ошибка сервера, защита от повторной отправки и очистка. Образцы PDF и чеков просмотрены, 8 QR из PDF и PNG успешно декодированы `zbarimg`; предельные длины покрыты тестами.
|
||||||
|
- Ограничения проверки: физический принтер, телефон и подключение к реальному Wi-Fi не проверялись. Рабочая БД и серверы не обновлялись; при запуске нужна миграция. Для изменения уже подготовленного офлайн-пакета требуется новый экспорт. Commit/push не выполнялись.
|
||||||
|
|
||||||
|
|
||||||
|
### ED-051 — Перенести идеи пульта во вкладку «Экраны»
|
||||||
|
|
||||||
|
- Статус: `DONE`
|
||||||
|
- Цель: перенести согласованные идеи `evening_detective_screen` на вкладку «Экраны» страницы игры.
|
||||||
|
- Результат: предпросмотр и подключение экранов расположены сверху; ниже адаптивные колонки плана экранов и музыки. Добавлены миниатюры всех шагов, выделение текущего шага, компактные кнопки и ползунки громкости; перемотка удалена.
|
||||||
|
- Результат: редактор музыки отделён от управления воспроизведением. Создание, переименование, загрузка MP3, удаление и перестановка треков используют ID редактируемого плейлиста. Назначение музыки игре выполняется явно; черновики сохраняются при поллинге и ошибках, конфликты внешних изменений показываются пользователю.
|
||||||
|
- Результат: пауза, продолжение и громкость основного медиа сохраняют позицию; повтор текущего музыкального трека обрабатывается проигрывателем. Устаревшие ответы поллинга музыки не перезаписывают команды. Добавлены проверки ввода таймера, текста и MP3, блокировка повторных запросов и сообщения об ошибках.
|
||||||
|
- Проверено: `node tests/screens-control.mjs`, `npm run type-check`, `npm run lint`, `npm run lint:css`, `npm run build-only`, `git diff --check` — успешно. Diff вручную проверен по FRONTEND_CODE_STANDARD.md.
|
||||||
|
- Проверено: Firefox на 1280 и 375 px, отсутствие горизонтального переполнения и автозапуска медиа миниатюрами, таймер и музыка, громкость мышью и клавиатурой, черновик плана после поллинга, независимый выбор плейлиста, ошибка сохранения порядка и повтор, переименование, отключение экрана и ошибка API. Браузерные проверки выполнены на локальных синтетических ответах API.
|
||||||
|
- Ограничения: сервер, protobuf, Electron и зависимости не изменялись. Реальный внешний дисплей и production не проверялись; предпросмотр отражает данные API, без точного прогресса и подтверждённого состояния медиапроигрывателя. Commit/push не выполнялись.
|
||||||
|
|
||||||
### ED-049 — Оптимизировать медиа пакета локальной игры
|
### ED-049 — Оптимизировать медиа пакета локальной игры
|
||||||
|
|
||||||
- Статус: `DONE`
|
- Статус: `DONE`
|
||||||
@@ -418,3 +509,36 @@
|
|||||||
- Автоматические проверки: `go test ./...`; фронтенд — `npm run type-check`, `npm run lint`, `npm run lint:css`, `npm run build`; приложение экрана — те же проверки, сборка и `npm test` (203/203). Миграция проверена дважды на временных таблицах PostgreSQL: надписи, порядок, текущий шаг, неизменность остальных шагов и повторный запуск корректны.
|
- Автоматические проверки: `go test ./...`; фронтенд — `npm run type-check`, `npm run lint`, `npm run lint:css`, `npm run build`; приложение экрана — те же проверки, сборка и `npm test` (203/203). Миграция проверена дважды на временных таблицах PostgreSQL: надписи, порядок, текущий шаг, неизменность остальных шагов и повторный запуск корректны.
|
||||||
- GUI: целевой проход восьми шагов Electron завершился `ED050_TEXT_SCREENS_PASS [1,2,8,4,8,6,7,8]`; обе надписи проверены в DOM. Общий smoke не завершился: `Timeout: volume slider keyboard update`; устаревшие ожидания автопаузы музыки в нём исправлены, код проигрывателя не менялся.
|
- GUI: целевой проход восьми шагов Electron завершился `ED050_TEXT_SCREENS_PASS [1,2,8,4,8,6,7,8]`; обе надписи проверены в DOM. Общий smoke не завершился: `Timeout: volume slider keyboard update`; устаревшие ожидания автопаузы музыки в нём исправлены, код проигрывателя не менялся.
|
||||||
- Ограничения: production-миграция не запускалась; сервер, сайт и приложение экрана требуют согласованного обновления. Коммиты и push не выполнялись.
|
- Ограничения: production-миграция не запускалась; сервер, сайт и приложение экрана требуют согласованного обновления. Коммиты и push не выполнялись.
|
||||||
|
|
||||||
|
### ED-054 — QR-экран, список команд и типы таймера
|
||||||
|
|
||||||
|
- Статус: `DONE`
|
||||||
|
- Результат: добавлены режимы QR (ссылка HTTP/HTTPS и необязательный текст) и списка команд; три типа таймера с исходной надписью и сохранением пользовательского текста при смене типа. Настройки доступны в веб-панели и приложении экрана, работают предпросмотры и браузерный вывод `/screen`.
|
||||||
|
- Команды: только названия активных регистраций, по русскому алфавиту; обновление каждые пять секунд, сохранение последнего снимка по ключу игры в приложении экрана, восстановление без сети после перезапуска. При ошибке предыдущий список сохраняется; при смене игры очищается.
|
||||||
|
- Совместимость: старые планы и хеши офлайн-пакетов сохранены, таймеры без типа означают «Перерыв» без замены собственного текста. Исправлена инициализация общего состояния таймера при первом подключении к шагу без длительности. Новый `GetScreenTeams` передаёт токен в теле POST; ответ содержит только ID и название.
|
||||||
|
- Миграция: `20261023130000_screen_qr_timer_type.sql` добавлена в сервер и `prod/migrations`; на рабочей БД не применялась.
|
||||||
|
- Проверки: штатная генерация protobuf во всех трёх приложениях; `go test ./...`; интеграционные тесты PostgreSQL для экранов, миграций и команд; 208 тестов приложения экрана; type-check, lint, lint:css и сборки обоих Vue-приложений; `node tests/screens-control.mjs`; Electron smoke предпросмотров 1280×720 / 1024×768 с QR и 32 длинными названиями; QR декодирован со снимка; Firefox smoke QR на ширинах 1280/375/160, списка и реального `/screen` с тестовыми API-ответами. Проверены ошибки сети, старые пакеты, сохранение снимка и изоляция игр. Diff вручную проверен по стандартам Go и фронтенда, `gofmt` и `git diff --check` — без замечаний.
|
||||||
|
- Ограничения: новые анимации отложены по согласованию; реальный проектор не проверялся. Публикация, commit и push не выполнялись.
|
||||||
|
|
||||||
|
### ED-059 — Отдельная страница плейлистов
|
||||||
|
|
||||||
|
- Статус: `DONE`
|
||||||
|
- Цель: `/playlists` для авторов и организаторов с текущими операциями; редактор в игре сохраняется и переиспользуется.
|
||||||
|
- Критерии: общий редактор и API-действия; защита черновиков; состояния загрузки, ошибки, пустого списка и отсутствия доступа; пункт меню.
|
||||||
|
- Проверка: type-check, lint, lint:css, build; smoke операций и регрессии музыки в игре.
|
||||||
|
- Ограничения: без новых зависимостей, API и предпросмотра звука; без commit/push.
|
||||||
|
- Результат: добавлены `/playlists` и пункт меню для авторов/организаторов; редактор и API-действия общие со страницей игры. Сохранены операции, MP3-валидация, блокировки, синхронизация и черновики; добавлено подтверждение ухода. API и зависимости не менялись.
|
||||||
|
- Проверено: `npm run type-check`, `npm run lint`, `npm run lint:css`, `npm run build`, `git diff --check`; ручное ревью diff по FRONTEND_CODE_STANDARD.md. `python3 tests/venue/playlists.py` прошёл: ошибка/повтор, пустой список, создание/выбор/переименование/удаление, пустой MP3 и загрузка, порядок и группы, отказ сохранения с сохранением черновика, роли, route guard, клавиатура, 1280/375 px. Отдельный браузерный smoke музыки в игре прошёл: play/pause/resume, активная группа, режимы, порядок, ошибки, черновик удалённой группы, 1280/375 px.
|
||||||
|
- Ограничения проверки: браузерные тесты используют изолированный тестовый API; рабочий сервер и реальное воспроизведение MP3 не проверялись. Commit/push не выполнялись.
|
||||||
|
|
||||||
|
### ED-060 — Загружать MP3 сразу в нужную группу
|
||||||
|
|
||||||
|
- Статус: `DONE`
|
||||||
|
- Цель: кнопка загрузки в каждой группе общего редактора; трек сразу сохраняется в выбранной группе.
|
||||||
|
- Критерии: единый запрос и транзакция вставки с groupID; проверка принадлежности и активности группы; совместимость старых запросов без groupID; обе страницы.
|
||||||
|
- Проверка: Go-тесты, интеграционная проверка PostgreSQL, генерация API, type-check, lint, lint:css, build и браузерный smoke.
|
||||||
|
- Ограничения: без новых зависимостей, миграций, пакетной загрузки и commit/push.
|
||||||
|
- Результат: «Добавить MP3» находится в каждой группе, включая «Без группы», на странице плейлистов и внутри игры. `groupID = 5` передаётся одним запросом; repository проверяет группу и её использование под существующей блокировкой и атомарно вставляет трек с группой. Запросы без поля сохраняют прежнюю загрузку без группы; очистка файла при отказе остаётся в существующем service flow. Обновлены все callers и generated API.
|
||||||
|
- Проверено: `make generate`, `npm run proto:gen`, `gofmt`, `go test ./...`; полный набор тестов `playlists_repo` с `TEST_DATABASE_URL` на отдельной локальной PostgreSQL прошёл, включая пустую/заполненную/чужую/удалённую/активную группу и отсутствие вставки при отказе. `npm run type-check`, `npm run lint`, `npm run lint:css`, `npm run build`, `git diff --check` прошли; diff проверен по Go- и frontend-стандартам.
|
||||||
|
- Браузер: `python3 tests/venue/playlists.py` — прямое назначение группы одним запросом, отказ сервера с сохранением черновика, операции редактора и 375/1280 px. Дополнительный игровой smoke `/tmp/ed060-game-upload-browser.py` — загрузка в группу через GameMusicPlanPanel, блокировка активной группы, доступность другой группы и регрессия воспроизведения/групп/черновиков.
|
||||||
|
- Ограничения проверки: браузер использует изолированный API; рабочее файловое хранилище и звучание MP3 не проверялись. Новому фронтенду для назначения группы нужен обновлённый бэкенд. Commit/push не выполнялись.
|
||||||
|
|||||||
+1
-1
Submodule evening_detective_screen updated: 06af1e047d...6e83c014e5
+1
-1
Submodule prod updated: f4ad26f7ff...34ad58a249
+1
-1
Submodule reproxy updated: 00bd14bac1...7aaa4245c8
@@ -223,3 +223,10 @@ Smoke проверяет три ширины, клавиатурные вкла
|
|||||||
`python3 tests/venue/clean.py` проверяет очистку venue с подставным Docker:
|
`python3 tests/venue/clean.py` проверяет очистку venue с подставным Docker:
|
||||||
порядок команд, повторный вызов, ошибки и сохранность настроек и посторонних томов.
|
порядок команд, повторный вызов, ошибки и сохранность настроек и посторонних томов.
|
||||||
Реальные данные не удаляются.
|
Реальные данные не удаляются.
|
||||||
|
|
||||||
|
### Страница плейлистов
|
||||||
|
|
||||||
|
После сборки фронтенда: `python3 tests/venue/playlists.py`. Нужен Firefox; тест
|
||||||
|
поднимает изолированный API, проверяет загрузку сразу в группу одним запросом, отказ загрузки без потери
|
||||||
|
черновика, операции редактора, роли,
|
||||||
|
клавиатуру и мобильную ширину. Рабочий сервер и настоящие MP3 не используются.
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"""Build the frontend, then run: python3 tests/venue/playlists.py. Requires Firefox."""
|
||||||
|
import base64, json, mimetypes, pathlib, socket, subprocess, tempfile, threading, time
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
DIST=pathlib.Path(__file__).resolve().parents[2]/'evening_detective_frontend/dist'
|
||||||
|
state={'playlists':[], 'calls':[], 'roles':['organizer'], 'failList':False, 'failGroup':False, 'failUpload':False}
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self,*args):pass
|
||||||
|
def respond(self,data):
|
||||||
|
self.send_response(200);self.send_header('Content-Type','application/json');self.end_headers();self.wfile.write(json.dumps(data,ensure_ascii=False).encode())
|
||||||
|
def do_GET(self):self.handle_request()
|
||||||
|
def do_POST(self):self.handle_request()
|
||||||
|
def do_PUT(self):self.handle_request()
|
||||||
|
def do_DELETE(self):self.handle_request()
|
||||||
|
def handle_request(self):
|
||||||
|
path=urlparse(self.path).path
|
||||||
|
if path.startswith('/__mock'):
|
||||||
|
path=path.removeprefix('/__mock');payload=json.loads(self.rfile.read(int(self.headers.get('Content-Length',0))) or '{}')
|
||||||
|
if self.command!='GET':state['calls'].append((path,payload))
|
||||||
|
if path=='/api/users/me':return self.respond({'user':{'ID':1,'username':'Тест','roles':state['roles']}})
|
||||||
|
if path=='/api/ui/permissions':return self.respond({'permissions':['games_page'],'environment':'prod'})
|
||||||
|
if path=='/api/playlists':
|
||||||
|
if self.command=='GET':return self.respond({'code':13,'message':'Ошибка списка'} if state['failList'] else {'playlists':state['playlists']})
|
||||||
|
p={'ID':max((p['ID'] for p in state['playlists']),default=0)+1,'name':payload['name'],'groups':[],'tracks':[]};state['playlists'].append(p)
|
||||||
|
return self.respond({'playlist':p})
|
||||||
|
if path.startswith('/api/playlists/'):
|
||||||
|
ident=int(path.split('/')[3]);p=next(p for p in state['playlists'] if p['ID']==ident)
|
||||||
|
if path.endswith('/tracks') and self.command=='POST':
|
||||||
|
assert payload['data'] and payload['filename'].endswith('.mp3')
|
||||||
|
if state['failUpload']:return self.respond({'code':9,'message':'Группа используется'})
|
||||||
|
if payload.get('groupID') and not any(g['ID']==payload['groupID'] for g in p['groups']):return self.respond({'code':5,'message':'Группа не найдена'})
|
||||||
|
p['tracks'].append({'ID':max((t['ID'] for t in p['tracks']),default=0)+1,'name':payload['name'],'filename':payload['filename'],'groupId':payload.get('groupID',0)})
|
||||||
|
elif '/tracks/' in path and self.command=='DELETE':p['tracks']=[t for t in p['tracks'] if t['ID']!=int(path.split('/')[-1])]
|
||||||
|
elif len(path.split('/'))==4 and self.command=='DELETE':state['playlists'].remove(p)
|
||||||
|
elif path.endswith('/groups/order'):
|
||||||
|
p['groups'].sort(key=lambda g:payload['groupIDs'].index(g['ID']))
|
||||||
|
elif path.endswith('/groups'):
|
||||||
|
if state['failGroup']:return self.respond({'code':13,'message':'Тест: группа не сохранена'})
|
||||||
|
if payload.get('groupID'):
|
||||||
|
g=next(g for g in p['groups'] if g['ID']==payload['groupID']);g.update(name=payload['name'])
|
||||||
|
else:
|
||||||
|
g={'ID':max((g['ID'] for g in p['groups']),default=0)+1,'name':payload['name'],'position':len(p['groups'])};p['groups'].append(g)
|
||||||
|
if payload.get('endMode') and payload['endMode']!='PLAYLIST_GROUP_END_MODE_REPEAT':g['endMode']=payload['endMode']
|
||||||
|
else:g.pop('endMode',None)
|
||||||
|
elif path.endswith('/group'):
|
||||||
|
t=next(t for t in p['tracks'] if t['ID']==int(path.split('/')[5]));t['groupId']=payload.get('groupID',0)
|
||||||
|
elif '/groups/' in path and self.command=='DELETE':
|
||||||
|
groupID=int(path.split('/')[-1]);p['groups']=[g for g in p['groups'] if g['ID']!=groupID]
|
||||||
|
for t in p['tracks']:
|
||||||
|
if t.get('groupId')==groupID:t['groupId']=0
|
||||||
|
elif 'trackIDs' in payload:
|
||||||
|
p['tracks'].sort(key=lambda t:payload['trackIDs'].index(t['ID']))
|
||||||
|
elif 'name' in payload:p['name']=payload['name']
|
||||||
|
return self.respond({'playlist':p})
|
||||||
|
return self.respond({})
|
||||||
|
f=DIST/path.lstrip('/')
|
||||||
|
if f.is_file():body=f.read_bytes();mime=mimetypes.guess_type(f.name)[0] or 'application/octet-stream'
|
||||||
|
else:
|
||||||
|
inject='''<script>localStorage.setItem('accessToken','fixture');localStorage.setItem('refreshToken','fixture');window.confirm=()=>window.allowDiscard!==false;window.testErrors=[];addEventListener('error',e=>testErrors.push(e.message));addEventListener('unhandledrejection',e=>testErrors.push(String(e.reason)));const originalFetch=fetch.bind(window);window.fetch=(u,o)=>{const p=new URL(u,location.href);return originalFetch(p.pathname.startsWith('/api/')?'/__mock'+p.pathname+p.search:u,o)};</script>'''
|
||||||
|
body=(DIST/'index.html').read_text().replace('<head>','<head>'+inject).encode();mime='text/html'
|
||||||
|
self.send_response(200);self.send_header('Content-Type',mime);self.end_headers();self.wfile.write(body)
|
||||||
|
server=ThreadingHTTPServer(('127.0.0.1',0),Handler);threading.Thread(target=server.serve_forever,daemon=True).start()
|
||||||
|
profile=pathlib.Path(tempfile.mkdtemp(prefix='ed059-playlists-'))
|
||||||
|
with socket.socket() as s:s.bind(('127.0.0.1',0));port=s.getsockname()[1]
|
||||||
|
(profile/'user.js').write_text(f'user_pref("marionette.port", {port});\nuser_pref("browser.shell.checkDefaultBrowser", false);')
|
||||||
|
log=(profile/'firefox.log').open('w');firefox=subprocess.Popen(['firefox','--headless','--no-remote','--profile',str(profile),'--marionette','--remote-allow-system-access','about:blank'],stdout=log,stderr=log);sock=None
|
||||||
|
try:
|
||||||
|
for _ in range(100):
|
||||||
|
try:sock=socket.create_connection(('127.0.0.1',port),timeout=1);break
|
||||||
|
except OSError:time.sleep(.1)
|
||||||
|
assert sock is not None,(profile/'firefox.log').read_text()
|
||||||
|
sock.settimeout(20)
|
||||||
|
def read():
|
||||||
|
size=b''
|
||||||
|
while not size.endswith(b':'):size+=sock.recv(1)
|
||||||
|
data=b''
|
||||||
|
while len(data)<int(size[:-1]):data+=sock.recv(int(size[:-1])-len(data))
|
||||||
|
return json.loads(data)
|
||||||
|
read();counter=0
|
||||||
|
def command(name,args=None):
|
||||||
|
global counter
|
||||||
|
counter+=1;p=json.dumps([0,counter,name,args or {}]).encode();sock.sendall(str(len(p)).encode()+b':'+p);r=read();assert not r[2],r[2];return r[3]
|
||||||
|
def js(script):
|
||||||
|
r=command('WebDriver:ExecuteScript',{'script':script,'args':[],'newSandbox':False,'sandbox':None});return r.get('value') if isinstance(r,dict) and 'value' in r else r
|
||||||
|
def wait(condition):
|
||||||
|
for _ in range(100):
|
||||||
|
if js('return '+condition):return
|
||||||
|
time.sleep(.1)
|
||||||
|
raise AssertionError(condition+'; body='+str(js('return document.body.innerText')))
|
||||||
|
def click(label,scope='document'):
|
||||||
|
code=f"const b=[...{scope}.querySelectorAll('button')].find(b=>b.textContent.trim()==={json.dumps(label)}||b.getAttribute('aria-label')==={json.dumps(label)});if(!b||b.disabled)return false;b.click();return true"
|
||||||
|
wait(f"[...{scope}.querySelectorAll('button')].some(b=>(b.textContent.trim()==={json.dumps(label)}||b.getAttribute('aria-label')==={json.dumps(label)})&&!b.disabled)")
|
||||||
|
assert js(code),label
|
||||||
|
time.sleep(.15)
|
||||||
|
def input_value(selector,value):
|
||||||
|
assert js(f'const e=document.querySelector({json.dumps(selector)});if(!e)return false;e.value={json.dumps(value)};e.dispatchEvent(new Event("input",{{bubbles:true}}));return true')
|
||||||
|
command('WebDriver:NewSession',{'capabilities':{'alwaysMatch':{}}});command('Marionette:SetContext',{'value':'chrome'});js('document.documentElement.style.minWidth="0";document.documentElement.setAttribute("minwidth","0");return true');command('Marionette:SetContext',{'value':'content'})
|
||||||
|
|
||||||
|
def choose(selector,label):
|
||||||
|
assert js(f'const e=document.querySelector({json.dumps(selector)});if(!e)return false;e.click();return true'),selector
|
||||||
|
wait('!!document.querySelector(".n-base-select-option")')
|
||||||
|
assert js(f'const e=[...document.querySelectorAll(".n-base-select-option")].find(e=>e.textContent.trim()==={json.dumps(label)});if(!e)return false;e.click();return true'),label
|
||||||
|
def navigate():
|
||||||
|
command('WebDriver:Navigate',{'url':f'http://127.0.0.1:{server.server_port}/playlists'})
|
||||||
|
def upload(name, content="fake-mp3", group="Без группы"):
|
||||||
|
assert js(f'const e=document.querySelector({json.dumps('section[aria-label="'+group+'"] input[type=file]')});const d=new DataTransfer();d.items.add(new File([{json.dumps(content)}],{json.dumps(name)},{{type:"audio/mpeg"}}));e.files=d.files;e.dispatchEvent(new Event("change",{{bubbles:true}}));return true')
|
||||||
|
command('WebDriver:SetWindowRect',{'width':1280,'height':1000})
|
||||||
|
state['failList']=True
|
||||||
|
navigate();wait('document.body.innerText.includes("Ошибка списка")')
|
||||||
|
state['failList']=False;click('Повторить загрузку')
|
||||||
|
wait('document.body.innerText.includes("Плейлистов пока нет")')
|
||||||
|
assert not js('return document.body.innerText.includes("Использовать в игре")')
|
||||||
|
input_value('input[placeholder="Новый плейлист"]','Первый')
|
||||||
|
click('Создать');wait('!!document.querySelector("#music-playlist-name")')
|
||||||
|
wait('document.querySelector("#music-playlist-name").value==="Первый"')
|
||||||
|
wait(r'document.querySelector("input[placeholder=\"Новый плейлист\"]").value===""')
|
||||||
|
input_value('#music-playlist-name','Переименованный');click('Сохранить название')
|
||||||
|
wait('!document.querySelector("#music-playlist-name").disabled')
|
||||||
|
assert state['playlists'][0]['name']=='Переименованный'
|
||||||
|
input_value('input[placeholder="Новая группа"]','Группа')
|
||||||
|
state['failGroup']=True;click('Добавить группу');wait('document.body.innerText.includes("Тест: группа не сохранена")')
|
||||||
|
assert js(r'return document.querySelector("input[placeholder=\"Новая группа\"]").value')=='Группа'
|
||||||
|
state['failGroup']=False;click('Добавить группу');wait('!!document.querySelector("section[aria-label=Группа]")')
|
||||||
|
wait('!document.querySelector("#music-playlist-name").disabled')
|
||||||
|
upload('empty.mp3','');wait('document.body.innerText.includes("Выберите непустой MP3 размером до 64 МиБ")')
|
||||||
|
assert not state['playlists'][0]['tracks']
|
||||||
|
input_value('#music-playlist-name','Черновик загрузки')
|
||||||
|
state['failUpload']=True;upload('rejected.mp3',group='Группа');wait('document.body.innerText.includes("Группа используется")')
|
||||||
|
assert js('return document.querySelector("#music-playlist-name").value')=='Черновик загрузки'
|
||||||
|
assert not state['playlists'][0]['tracks']
|
||||||
|
state['failUpload']=False;input_value('#music-playlist-name','Переименованный')
|
||||||
|
upload('grouped.mp3',group='Группа');wait('document.querySelectorAll(".track-row").length===1')
|
||||||
|
assert state['playlists'][0]['tracks'][0]['groupId']==1
|
||||||
|
assert not any(path.endswith('/group') for path,payload in state['calls']), 'upload must assign the group in one request'
|
||||||
|
wait('!document.querySelector("#music-playlist-name").disabled')
|
||||||
|
upload('first.mp3');wait('document.querySelectorAll(".track-row").length===2')
|
||||||
|
wait('!document.querySelector("#music-playlist-name").disabled')
|
||||||
|
upload('second.mp3');wait('document.querySelectorAll(".track-row").length===3')
|
||||||
|
wait('!document.querySelector("#music-playlist-name").disabled')
|
||||||
|
click('Поднять трек 2 в группе Без группы');click('Сохранить порядок')
|
||||||
|
wait('!document.querySelector("#music-playlist-name").disabled')
|
||||||
|
assert [t['name'] for t in state['playlists'][0]['tracks'] if not t['groupId']]==['second.mp3','first.mp3']
|
||||||
|
choose('section[aria-label="Без группы"] .track-row .n-base-selection','Группа')
|
||||||
|
wait('document.querySelector("section[aria-label=Группа] .track-row")!==null')
|
||||||
|
wait('!document.querySelector("#music-playlist-name").disabled')
|
||||||
|
assert state['playlists'][0]['tracks'][1]['groupId']==1
|
||||||
|
input_value('#music-playlist-name','Черновик')
|
||||||
|
js('window.allowDiscard=false;return true')
|
||||||
|
click('Главная');time.sleep(.2)
|
||||||
|
assert js('return location.pathname')=='/playlists'
|
||||||
|
assert js('return document.querySelector("#music-playlist-name").value')=='Черновик'
|
||||||
|
state['failList']=True
|
||||||
|
# A fresh visit tests the initial load failure again without altering production data.
|
||||||
|
js('window.allowDiscard=true;return true')
|
||||||
|
navigate();wait('document.body.innerText.includes("Ошибка списка")')
|
||||||
|
state['failList']=False;click('Повторить загрузку');wait('!!document.querySelector("#music-playlist-name")')
|
||||||
|
for width in [1280,375]:
|
||||||
|
command('WebDriver:SetWindowRect',{'width':width,'height':1000});time.sleep(.3)
|
||||||
|
assert js('return document.documentElement.scrollWidth<=innerWidth'), 'horizontal overflow'
|
||||||
|
image=command('WebDriver:TakeScreenshot',{})['value'];pathlib.Path(f'/tmp/ed059-playlists-{width}.png').write_bytes(base64.b64decode(image))
|
||||||
|
js('document.querySelector("#music-playlist-name").focus();return true')
|
||||||
|
command('WebDriver:PerformActions',{'actions':[{'type':'key','id':'keyboard','actions':[{'type':'keyDown','value':'\ue004'},{'type':'keyUp','value':'\ue004'}]}]})
|
||||||
|
assert js('return document.activeElement.tagName')=='BUTTON'
|
||||||
|
click('Удалить', 'document.querySelector(".track-row")');click('Подтвердить')
|
||||||
|
wait('document.querySelectorAll(".track-row").length===2')
|
||||||
|
click('Удалить группу');click('Подтвердить');wait('!document.querySelector("section[aria-label=Группа]")')
|
||||||
|
click('Удалить плейлист');click('Подтвердить');wait('document.body.innerText.includes("Плейлистов пока нет")')
|
||||||
|
state['roles']=['author'];navigate();wait('!!document.querySelector("[data-testid=music-editor]")')
|
||||||
|
state['roles']=['user'];navigate();wait('document.body.innerText.includes("Нет доступа к плейлистам")')
|
||||||
|
assert not js('return !!document.querySelector("[data-testid=music-editor]")')
|
||||||
|
assert js('return testErrors')==[],js('return testErrors')
|
||||||
|
print('PLAYLISTS_PASS: direct group upload and rejection, load error/retry, empty/create/select/rename, group error/retry, upload/order/membership/delete, dirty route guard, author/organizer access, keyboard, desktop/mobile')
|
||||||
|
finally:
|
||||||
|
if sock:sock.close()
|
||||||
|
firefox.terminate()
|
||||||
|
try:firefox.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:firefox.kill();firefox.wait()
|
||||||
|
log.close();server.shutdown();server.server_close()
|
||||||
Reference in New Issue
Block a user