QR kód se dá přepnout na jinou adresu
binárka / linux (pull_request) Successful in 10s
testy / pytest (3.11) (pull_request) Successful in 1s
testy / pytest (3.12) (pull_request) Successful in 1s
testy / pytest (3.13) (pull_request) Successful in 1s
testy / pytest (3.14) (pull_request) Successful in 0s
binárka / linux (pull_request) Successful in 10s
testy / pytest (3.11) (pull_request) Successful in 1s
testy / pytest (3.12) (pull_request) Successful in 1s
testy / pytest (3.13) (pull_request) Successful in 1s
testy / pytest (3.14) (pull_request) Successful in 0s
Tabule dosud zbylé adresy jen vypsala, ale naskenovat se dala pouze ta první. Teď se na ně dá kliknout: vybraná se přesune nahoru velkým písmem, QR kód se přepne na ni a ta předchozí se objeví v seznamu, takže se dá kroužit dokola. Kódy jsou vykreslené všechny předem a přepíná se jen ten viditelný — žádné doptávání serveru, žádná QR knihovna na klientovi. Aby to stránku nenafouklo, skládá se SVG ze souvislých úseků místo jednotlivých čtverečků; tabule se třemi adresami má 23 kB. Otisk v data-address zahrnuje všechny adresy, ne jen tu první. Když se změní kterákoli z nich, nabídka i QR kódy jsou k ničemu a tabule se načte znovu. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ca97857ca0
commit
84db0c0380
7 files changed
+197
-32
No files matched your search
@@ -37,8 +37,8 @@ Počitadlo je dostupné na:
|
||||
```
|
||||
|
||||
Na notebooku otevři `/board`, naskenuj QR kód telefonem a hraj. Tabule
|
||||
zobrazuje i zbylé adresy — když jedna nefunguje, zkus další. Zastavuje se
|
||||
`Ctrl+C`.
|
||||
nabízí i zbylé adresy — když jedna nefunguje, klikni na jinou a QR kód se
|
||||
přepne na ni. Zastavuje se `Ctrl+C`.
|
||||
|
||||
| Proměnná | K čemu je | Výchozí |
|
||||
|---|---|---|
|
||||
|
||||
+21
-1
@@ -373,7 +373,7 @@ button {
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.join .address span {
|
||||
.join .address .what {
|
||||
display: block;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.7rem;
|
||||
@@ -382,6 +382,8 @@ button {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.join .address .url { display: block; }
|
||||
|
||||
.qr { width: 5.5rem; height: 5.5rem; fill: var(--ink); }
|
||||
|
||||
.idle {
|
||||
@@ -480,6 +482,24 @@ button {
|
||||
|
||||
.alt li { white-space: nowrap; }
|
||||
|
||||
/* Adresa je tlačítko, ale nemá vypadat jako tlačítko — jen dát najevo,
|
||||
že se na ni dá kliknout. */
|
||||
.alt button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: none;
|
||||
border: 0;
|
||||
border-bottom: 1px dotted var(--rule);
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.alt button:hover,
|
||||
.alt button:focus-visible {
|
||||
color: var(--ink);
|
||||
border-bottom-color: currentColor;
|
||||
}
|
||||
|
||||
.alt em {
|
||||
font-family: var(--font-ui);
|
||||
font-style: normal;
|
||||
|
||||
+45
-9
@@ -1,22 +1,58 @@
|
||||
{# Kudy se na počitadlo dostane telefon. Nabídneme víc cest, vybrat si může člověk. #}
|
||||
{% if primary %}
|
||||
{# Kudy se na počitadlo dostane telefon. Nabídneme víc cest, vybrat si může
|
||||
člověk — QR kódy jsou vykreslené všechny, přepíná se jen ten viditelný. #}
|
||||
{% if choices %}
|
||||
<div class="join">
|
||||
{{ target|qr }}
|
||||
<div class="qr-stack">
|
||||
{% for choice in choices %}
|
||||
<div class="qr-slot" data-ip="{{ choice.ip }}"{% if not loop.first %} hidden{% endif %}>
|
||||
{{ choice.url|qr }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="address">
|
||||
<span>Zapisuje se z telefonu</span>
|
||||
{{ target }}
|
||||
{% if others %}
|
||||
<span class="what">Zapisuje se z telefonu</span>
|
||||
<span class="url">{{ choices[0].url }}</span>
|
||||
|
||||
{% if choices|length > 1 %}
|
||||
<ul class="alt">
|
||||
{% for address in others %}
|
||||
<li>{{ address.ip }}:{{ port }} <em>{{ address.label }}</em></li>
|
||||
{% for choice in choices %}
|
||||
<li data-ip="{{ choice.ip }}"{% if loop.first %} hidden{% endif %}>
|
||||
<button type="button" data-ip="{{ choice.ip }}" data-url="{{ choice.url }}"
|
||||
aria-label="Ukázat QR kód pro {{ choice.url }}">
|
||||
{{ choice.ip }}:{{ port }} <em>{{ choice.label }}</em>
|
||||
</button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Klik na jinou adresu jen prohodí, který QR kód a který řádek je vidět.
|
||||
// Vybraná adresa ze seznamu zmizí a ta předchozí se v něm objeví, takže
|
||||
// se dá kroužit dokola.
|
||||
{
|
||||
const join = document.currentScript.previousElementSibling;
|
||||
const shown = join.querySelector(".address .url");
|
||||
|
||||
for (const button of join.querySelectorAll(".alt button")) {
|
||||
button.addEventListener("click", () => {
|
||||
for (const slot of join.querySelectorAll(".qr-slot")) {
|
||||
slot.hidden = slot.dataset.ip !== button.dataset.ip;
|
||||
}
|
||||
for (const item of join.querySelectorAll(".alt li")) {
|
||||
item.hidden = item.dataset.ip === button.dataset.ip;
|
||||
}
|
||||
shown.textContent = button.dataset.url;
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% else %}
|
||||
<div class="join">
|
||||
<p class="address"><span>Adresu se nepodařilo zjistit</span>
|
||||
<p class="address"><span class="what">Adresu se nepodařilo zjistit</span>
|
||||
Spusť server s <code>DICE_HOST=<adresa></code>.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -1,3 +1,3 @@
|
||||
{# Fragment pro tabuli: obě části, které se během hry mění. #}
|
||||
<div id="head" data-address="{{ primary_ip }}">{% include "_head.html" %}</div>
|
||||
<div id="head" data-address="{{ address_key }}">{% include "_head.html" %}</div>
|
||||
<div id="sheet">{% include "_sheet.html" %}</div>
|
||||
@@ -4,7 +4,7 @@
|
||||
{% block body %}
|
||||
<div class="board">
|
||||
<header class="masthead">
|
||||
<div id="head" data-address="{{ primary_ip }}">{% include "_head.html" %}</div>
|
||||
<div id="head" data-address="{{ address_key }}">{% include "_head.html" %}</div>
|
||||
{% include "_join.html" %}
|
||||
</header>
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
import net
|
||||
import storage
|
||||
import web
|
||||
|
||||
@@ -236,3 +239,90 @@ def test_kratka_hra_nic_neuseka(client):
|
||||
])
|
||||
def test_sklonovani_kol(count, expected):
|
||||
assert web.kola(count) == expected
|
||||
|
||||
|
||||
def offer(monkeypatch, *ips):
|
||||
"""Server nabídne přesně tyhle adresy, v tomhle pořadí."""
|
||||
found = [net.Address(ip=ip, interface="test", kind="lan", primary=not i)
|
||||
for i, ip in enumerate(ips)]
|
||||
monkeypatch.setattr(web.net, "addresses", lambda: found)
|
||||
|
||||
|
||||
def test_tabule_nabidne_qr_ke_kazde_adrese(client, monkeypatch):
|
||||
"""Kódy jsou vykreslené předem, přepíná se jen ten viditelný."""
|
||||
offer(monkeypatch, "192.168.0.10", "100.91.0.24", "172.17.0.1")
|
||||
game_id = start_game(client, "Adam", "Eva")
|
||||
|
||||
page = text(client.get(f"/board/{game_id}"))
|
||||
|
||||
assert page.count('<svg class="qr"') == 3
|
||||
for ip in ("192.168.0.10", "100.91.0.24", "172.17.0.1"):
|
||||
assert f'data-url="http://{ip}:8000/game/{game_id}"' in page
|
||||
|
||||
|
||||
def test_vybrana_adresa_ze_seznamu_zmizi(client, monkeypatch):
|
||||
"""Velkým písmem i v seznamu zároveň by byla dvakrát."""
|
||||
offer(monkeypatch, "192.168.0.10", "100.91.0.24")
|
||||
|
||||
page = text(client.get("/board"))
|
||||
|
||||
assert '<li data-ip="192.168.0.10" hidden>' in page
|
||||
assert '<li data-ip="100.91.0.24">' in page
|
||||
|
||||
|
||||
def test_jedina_adresa_nema_co_prepinat(client, monkeypatch):
|
||||
offer(monkeypatch, "192.168.0.10")
|
||||
|
||||
page = text(client.get("/board"))
|
||||
|
||||
assert '<ul class="alt">' not in page
|
||||
assert page.count('<svg class="qr"') == 1
|
||||
|
||||
|
||||
def test_otisk_hlida_vsechny_adresy(client, monkeypatch):
|
||||
"""Tabule se načte znovu i když se změní jen ta druhá v pořadí —
|
||||
nabídnuté QR kódy už by neplatily."""
|
||||
game_id = start_game(client, "Adam", "Eva")
|
||||
|
||||
offer(monkeypatch, "192.168.0.10", "100.91.0.24")
|
||||
before = text(client.get(f"/board/{game_id}"))
|
||||
|
||||
offer(monkeypatch, "192.168.0.10", "100.91.0.99")
|
||||
after = text(client.get(f"/board/{game_id}"))
|
||||
|
||||
assert 'data-address="192.168.0.10,100.91.0.24"' in before
|
||||
assert 'data-address="192.168.0.10,100.91.0.99"' in after
|
||||
|
||||
|
||||
@pytest.mark.parametrize("row, expected", [
|
||||
([], []),
|
||||
([False, False], []),
|
||||
([True], [(0, 1)]),
|
||||
([True, True], [(0, 2)]),
|
||||
([False, True, True], [(1, 2)]),
|
||||
([True, True, False, True], [(0, 2), (3, 1)]),
|
||||
])
|
||||
def test_souvisle_useky(row, expected):
|
||||
assert web.runs(row) == expected
|
||||
|
||||
|
||||
def test_qr_obdelniky_kryji_stejne_moduly():
|
||||
"""Slučování čtverečků do širších obdélníků nesmí kód změnit."""
|
||||
import qrcode
|
||||
|
||||
data = "http://192.168.0.10:8000/"
|
||||
code = qrcode.QRCode(box_size=1, border=2)
|
||||
code.add_data(data)
|
||||
code.make(fit=True)
|
||||
matrix = code.get_matrix()
|
||||
|
||||
svg = str(web.qr_svg(data))
|
||||
painted = set()
|
||||
for x, y, width in re.findall(r'<rect x="(\d+)" y="(\d+)" width="(\d+)"', svg):
|
||||
painted.update((int(x) + step, int(y)) for step in range(int(width)))
|
||||
|
||||
expected = {(x, y) for y, row in enumerate(matrix)
|
||||
for x, dark in enumerate(row) if dark}
|
||||
|
||||
assert painted == expected
|
||||
assert svg.count("<rect") < len(expected)
|
||||
@@ -16,7 +16,7 @@ from pathlib import Path
|
||||
import qrcode
|
||||
from flask import (Flask, abort, g, redirect, render_template, request,
|
||||
url_for)
|
||||
from markupsafe import Markup
|
||||
from markupsafe import Markup, escape
|
||||
|
||||
import net
|
||||
import stats
|
||||
@@ -61,31 +61,50 @@ def close_db(_exception):
|
||||
|
||||
# --- adresa a QR kód --------------------------------------------------------
|
||||
|
||||
def primary_ip() -> str:
|
||||
"""Adresa, na kterou tabule ukazuje. Prázdno, když se nedá zjistit."""
|
||||
found = net.addresses()
|
||||
return found[0].ip if found else ""
|
||||
def address_key() -> str:
|
||||
"""Otisk všech adres. Tabule ho hlídá: jakmile se změní, načte se znovu,
|
||||
protože nabídnuté adresy i QR kódy už neplatí."""
|
||||
return ",".join(address.ip for address in net.addresses())
|
||||
|
||||
|
||||
def links(path: str = "/") -> dict:
|
||||
"""Adresy, na kterých je počitadlo dostupné, i s cílem pro QR kód.
|
||||
"""Adresy, na kterých je počitadlo dostupné, i s cíli pro QR kódy.
|
||||
|
||||
Hledá se při každém načtení — notebook se během večera může přesunout
|
||||
z domácí WiFi na hotspot a zpátky.
|
||||
z domácí WiFi na hotspot a zpátky. První v pořadí je ta nejnadějnější,
|
||||
zbytek si člověk může na tabuli přepnout.
|
||||
"""
|
||||
found = net.addresses()
|
||||
primary = found[0] if found else None
|
||||
|
||||
return {
|
||||
"primary": primary,
|
||||
"others": found[1:],
|
||||
"choices": [
|
||||
{"ip": address.ip, "label": address.label,
|
||||
"url": address.url(PORT) + path}
|
||||
for address in net.addresses()
|
||||
],
|
||||
"port": PORT,
|
||||
"target": primary.url(PORT) + path if primary else "",
|
||||
}
|
||||
|
||||
|
||||
def runs(row: list[bool]) -> list[tuple[int, int]]:
|
||||
"""Souvislé tmavé úseky řádku jako (začátek, délka).
|
||||
|
||||
Jeden široký obdélník místo řady čtverečků. Na tabuli visí QR kód pro
|
||||
každou adresu zvlášť, takže se to na velikosti stránky pozná.
|
||||
"""
|
||||
found = []
|
||||
start = None
|
||||
|
||||
for x, dark in enumerate([*row, False]):
|
||||
if dark and start is None:
|
||||
start = x
|
||||
elif not dark and start is not None:
|
||||
found.append((start, x - start))
|
||||
start = None
|
||||
|
||||
return found
|
||||
|
||||
|
||||
def qr_svg(data: str) -> Markup:
|
||||
"""QR jako SVG složené ze čtverečků — bez Pillow a bez práce s obrázky."""
|
||||
"""QR jako SVG složené z obdélníků — bez Pillow a bez práce s obrázky."""
|
||||
code = qrcode.QRCode(box_size=1, border=2)
|
||||
code.add_data(data)
|
||||
code.make(fit=True)
|
||||
@@ -93,14 +112,14 @@ def qr_svg(data: str) -> Markup:
|
||||
size = len(matrix)
|
||||
|
||||
rects = [
|
||||
f'<rect x="{x}" y="{y}" width="1" height="1"/>'
|
||||
f'<rect x="{x}" y="{y}" width="{width}" height="1"/>'
|
||||
for y, row in enumerate(matrix)
|
||||
for x, dark in enumerate(row) if dark
|
||||
for x, width in runs(row)
|
||||
]
|
||||
|
||||
return Markup(
|
||||
f'<svg class="qr" viewBox="0 0 {size} {size}" role="img" '
|
||||
f'aria-label="QR kód s adresou pro telefon" '
|
||||
f'aria-label="QR kód s adresou {escape(data)}" '
|
||||
f'shape-rendering="crispEdges">{"".join(rects)}</svg>'
|
||||
)
|
||||
|
||||
@@ -184,7 +203,7 @@ def view(game: Game, game_id: int, row) -> dict:
|
||||
"standings": game.standings(),
|
||||
"winner": winner,
|
||||
"current": "" if winner else game.current_player,
|
||||
"primary_ip": primary_ip(),
|
||||
"address_key": address_key(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user