Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85b6618952 |
No files matched your search
@@ -1,115 +0,0 @@
|
||||
# Postaví binárku a pověsí ji na release.
|
||||
#
|
||||
# Runner běží v host módu, takže tu není kontejner ani hotové akce
|
||||
# z marketplace — nic se nespoléhá na to, co má hostitel nainstalováno.
|
||||
# O starou glibc se stará build.sh přes samostatný CPython od uv.
|
||||
name: binárka
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
runs-on: homelab
|
||||
|
||||
steps:
|
||||
- name: objdump
|
||||
run: |
|
||||
# Jediné, co musí být na hostiteli — PyInstaller jím hledá, na
|
||||
# kterých knihovnách binárka visí. Zbytek si obstará uv.
|
||||
command -v objdump >/dev/null && exit 0
|
||||
sudo=""; [ "$(id -u)" -ne 0 ] && sudo=sudo
|
||||
if command -v apt-get >/dev/null; then
|
||||
$sudo apt-get update -qq
|
||||
$sudo apt-get install -y -qq binutils
|
||||
elif command -v dnf >/dev/null; then
|
||||
$sudo dnf install -y -q binutils
|
||||
else
|
||||
echo "Nainstaluj binutils, PyInstaller bez objdumpu nestaví." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Zdrojáky
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
git init -q .
|
||||
git remote remove origin 2>/dev/null || true
|
||||
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||
git config http.extraheader "Authorization: token ${TOKEN}"
|
||||
git fetch -q --depth 1 origin "${GITHUB_REF}"
|
||||
git config --unset http.extraheader
|
||||
git checkout -qf FETCH_HEAD
|
||||
git clean -qfd
|
||||
|
||||
- name: uv
|
||||
run: |
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
command -v uv >/dev/null || curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
uv --version
|
||||
|
||||
- name: Testy
|
||||
run: uv run --with-requirements requirements.txt --no-project pytest -q
|
||||
|
||||
- name: Binárka
|
||||
run: ./build.sh
|
||||
|
||||
- name: Zkouška
|
||||
run: |
|
||||
XDG_DATA_HOME=/tmp/zkouska DICE_PORT=8321 DICE_HOST=127.0.0.1 \
|
||||
./dist/kostky > /tmp/server.log 2>&1 &
|
||||
server=$!
|
||||
trap 'kill $server 2>/dev/null || true' EXIT
|
||||
|
||||
for _ in $(seq 30); do
|
||||
curl -sf -o /dev/null http://127.0.0.1:8321/ && break
|
||||
sleep 1
|
||||
done
|
||||
|
||||
curl -sf -o /dev/null http://127.0.0.1:8321/
|
||||
curl -sf -o /dev/null http://127.0.0.1:8321/static/style.css
|
||||
curl -sf -o /dev/null http://127.0.0.1:8321/board
|
||||
test -f /tmp/zkouska/kostky/dice.db
|
||||
cat /tmp/server.log
|
||||
|
||||
- name: Na release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ASSET: kostky-linux-x86_64
|
||||
run: |
|
||||
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
auth="Authorization: token ${TOKEN}"
|
||||
|
||||
# Release u tagu být může, ale nemusí — dá se založit i ručně.
|
||||
id=$(curl -s -H "$auth" "$api/releases/tags/${GITHUB_REF_NAME}" \
|
||||
| python3 -c 'import json,sys; print(json.load(sys.stdin).get("id",""))' || true)
|
||||
|
||||
if [ -z "$id" ]; then
|
||||
id=$(curl -sf -X POST -H "$auth" -H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"${GITHUB_REF_NAME}\",\"name\":\"${GITHUB_REF_NAME}\"}" \
|
||||
"$api/releases" \
|
||||
| python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
|
||||
fi
|
||||
|
||||
# Součet se počítá nad jménem, pod kterým se soubor stahuje, aby
|
||||
# šel ověřit rovnou: sha256sum -c kostky-linux-x86_64.sha256
|
||||
cp dist/kostky "$ASSET"
|
||||
sha256sum "$ASSET" | tee "$ASSET.sha256"
|
||||
|
||||
for file in "$ASSET" "$ASSET.sha256"; do
|
||||
# Aby se dal build zopakovat, stará příloha stejného jména jde pryč.
|
||||
curl -s -H "$auth" "$api/releases/$id/assets" \
|
||||
| python3 -c 'import json,sys; [print(a["id"]) for a in json.load(sys.stdin) if a["name"] == sys.argv[1]]' "$file" \
|
||||
| while read -r old; do
|
||||
curl -sf -X DELETE -H "$auth" "$api/releases/$id/assets/$old"
|
||||
done
|
||||
|
||||
curl -sf -o /dev/null -X POST -H "$auth" -F "attachment=@$file" \
|
||||
"$api/releases/$id/assets?name=$file"
|
||||
echo "$file pověšen na ${GITHUB_REF_NAME}"
|
||||
done
|
||||
@@ -1,69 +0,0 @@
|
||||
# Testy nad každým pull requestem a nad main.
|
||||
#
|
||||
# Runner běží v host módu, takže tu není kontejner ani hotové akce
|
||||
# z marketplace. Pythony obstará uv, na hostiteli nemusí být žádný.
|
||||
name: testy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: testy-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
pytest:
|
||||
runs-on: homelab
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
# Runner sdílí pracovní adresář mezi variantami, takže po jedné.
|
||||
max-parallel: 1
|
||||
matrix:
|
||||
python: ["3.11", "3.12", "3.13", "3.14"]
|
||||
|
||||
steps:
|
||||
- name: Zdrojáky
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
git init -q .
|
||||
git remote remove origin 2>/dev/null || true
|
||||
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||
git config http.extraheader "Authorization: token ${TOKEN}"
|
||||
git fetch -q --depth 1 origin "${GITHUB_REF}"
|
||||
git config --unset http.extraheader
|
||||
git checkout -qf FETCH_HEAD
|
||||
git clean -qfd
|
||||
|
||||
- name: uv
|
||||
run: |
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
command -v uv >/dev/null || curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
uv python install "${{ matrix.python }}"
|
||||
|
||||
- name: Testy
|
||||
run: |
|
||||
uv run --managed-python --python "${{ matrix.python }}" \
|
||||
--with-requirements requirements.txt --no-project \
|
||||
pytest -q
|
||||
|
||||
- name: Terminálová verze
|
||||
run: |
|
||||
# Pytest sahá na core.py, ne na prompty. Tohle projde celou partii
|
||||
# tak, jak ji odehraje člověk: dvě nuly, třetí vynuluje, výhra.
|
||||
out=$(printf 'A\nB\n\n1000\n\n200\n10\n0\n10\n0\n10\n0\n\n1000\n' \
|
||||
| TERM=dumb uv run --managed-python --python "${{ matrix.python }}" dice.py)
|
||||
|
||||
for expected in "200xx" "Po třech nulových hodech" "Vítězem se stává B"; do
|
||||
printf '%s' "$out" | grep -qF "$expected" || {
|
||||
echo "chybí: $expected"
|
||||
printf '%s\n' "$out"
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
echo "terminálová verze v pořádku"
|
||||
@@ -1,127 +0,0 @@
|
||||
# Binárky pro Windows a macOS.
|
||||
#
|
||||
# PyInstaller neumí křížovou kompilaci: binárku pro každý systém musí postavit
|
||||
# ten systém. Kupovat kvůli tomu Mac nebo krmit Windows VM by bylo přehnané,
|
||||
# tak si tyhle stroje půjčujeme od GitHub Actions.
|
||||
#
|
||||
# GitHub je tu jen půjčená dílna, ne druhý domov projektu: repozitář se sem
|
||||
# z Gitey zrcadlí a hotové soubory se posílají zpátky na tamní release přes
|
||||
# Gitea API. Jedna stránka s releasy, ta původní.
|
||||
#
|
||||
# Linux se tu schválně nestaví — o ten se stará vlastní runner
|
||||
# (.gitea/workflows/binarka.yml) a dělá to líp, proti glibc 2.17.
|
||||
name: binárky
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
GITEA: https://gitea.spacilovi.eu
|
||||
GITEA_REPO: david-spacil/dice-counter
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: windows-latest
|
||||
asset: kostky-windows-x86_64.exe
|
||||
built: dist/kostky.exe
|
||||
- os: macos-15-intel # Intel; macos-13 GitHub zrušil
|
||||
asset: kostky-macos-x86_64
|
||||
built: dist/kostky
|
||||
- os: macos-latest # Apple Silicon
|
||||
asset: kostky-macos-arm64
|
||||
built: dist/kostky
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
# Přesné verze, ne plovoucí `v7`. Ta u setup-uv nevede tam, kam by
|
||||
# člověk čekal — hlavní značky se přestaly vydávat po v7, takže `v10`
|
||||
# neexistuje, i když vydání v10.0.1 ano.
|
||||
- uses: actions/checkout@v7.0.1
|
||||
|
||||
- uses: astral-sh/setup-uv@v10.0.1
|
||||
|
||||
- name: Testy
|
||||
run: uv run --with-requirements requirements.txt --no-project pytest -q
|
||||
|
||||
- name: Binárka
|
||||
run: ./build.sh
|
||||
|
||||
- name: Zkouška
|
||||
run: |
|
||||
# Binárka se musí umět rozbalit, najít šablony a začít poslouchat.
|
||||
# Adresa se vnucuje, ať zkouška nezávisí na síti běhounu.
|
||||
export DICE_HOST=127.0.0.1 DICE_PORT=8123 DICE_DB=zkouska/dice.db
|
||||
"${{ matrix.built }}" &
|
||||
|
||||
for _ in $(seq 30); do
|
||||
curl -sf -o /dev/null "http://127.0.0.1:8123/" && break
|
||||
sleep 1
|
||||
done
|
||||
|
||||
curl -sf -o /dev/null "http://127.0.0.1:8123/" || {
|
||||
echo "Server se nerozeběhl." >&2; exit 1; }
|
||||
curl -sf -o /dev/null "http://127.0.0.1:8123/static/style.css"
|
||||
curl -sf "http://127.0.0.1:8123/board" | grep -q "127.0.0.1:8123"
|
||||
test -f zkouska/dice.db
|
||||
|
||||
echo "Binárka jede."
|
||||
|
||||
- name: Na release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
ASSET: ${{ matrix.asset }}
|
||||
run: |
|
||||
api="$GITEA/api/v1/repos/$GITEA_REPO"
|
||||
auth="Authorization: token $TOKEN"
|
||||
json() { uv run --no-project python -c "$@"; }
|
||||
|
||||
# Release u tagu být může, ale nemusí — dá se založit i ručně a
|
||||
# sahají na něj čtyři buildy naráz, tak se po neúspěchu ptáme znovu.
|
||||
for _ in 1 2 3; do
|
||||
id=$(curl -s -H "$auth" "$api/releases/tags/${GITHUB_REF_NAME}" \
|
||||
| json 'import json,sys; print(json.load(sys.stdin).get("id",""))')
|
||||
[ -n "$id" ] && break
|
||||
|
||||
curl -s -o /dev/null -X POST -H "$auth" -H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"${GITHUB_REF_NAME}\",\"name\":\"${GITHUB_REF_NAME}\"}" \
|
||||
"$api/releases"
|
||||
done
|
||||
[ -n "$id" ] || { echo "Release ${GITHUB_REF_NAME} se nepodařilo najít." >&2; exit 1; }
|
||||
|
||||
# macOS zná jen shasum, Git Bash na Windows jen sha256sum. Formát
|
||||
# výsledku je stejný, takže se dá ověřit kterýmkoli z nich.
|
||||
if command -v sha256sum >/dev/null; then
|
||||
soucet() { sha256sum "$@"; }
|
||||
else
|
||||
soucet() { shasum -a 256 "$@"; }
|
||||
fi
|
||||
|
||||
# Součet se počítá nad jménem, pod kterým se soubor stahuje, aby
|
||||
# šel ověřit rovnou: shasum -a 256 -c kostky-macos-arm64.sha256
|
||||
cp "${{ matrix.built }}" "$ASSET"
|
||||
soucet "$ASSET" | tee "$ASSET.sha256"
|
||||
|
||||
for file in "$ASSET" "$ASSET.sha256"; do
|
||||
# Aby se dal build zopakovat, stará příloha stejného jména jde pryč.
|
||||
curl -s -H "$auth" "$api/releases/$id/assets" \
|
||||
| json 'import json,sys; [print(a["id"]) for a in json.load(sys.stdin) if a["name"] == sys.argv[1]]' "$file" \
|
||||
| while read -r old; do
|
||||
curl -sf -X DELETE -H "$auth" "$api/releases/$id/assets/$old"
|
||||
done
|
||||
|
||||
curl -sf -o /dev/null -X POST -H "$auth" -F "attachment=@$file" \
|
||||
"$api/releases/$id/assets?name=$file"
|
||||
echo "$file pověšen na ${GITHUB_REF_NAME}"
|
||||
done
|
||||
@@ -4,4 +4,3 @@ __pycache__/
|
||||
.pytest_cache/
|
||||
dist/
|
||||
build/
|
||||
.build/
|
||||
@@ -1,4 +1,4 @@
|
||||
# Dice Counter
|
||||
# Počitadlo v kostkách
|
||||
|
||||
Doprovod k fyzickým kostkám: **telefon zapisuje skóre, notebook ukazuje
|
||||
zápisník** a hry se pamatují mezi večery.
|
||||
@@ -8,45 +8,17 @@ ten samý Adam jako z minulého měsíce. Žádné účty, žádná hesla.
|
||||
|
||||
## Spuštění
|
||||
|
||||
Stáhni binárku pro svůj systém z
|
||||
[releases](https://gitea.spacilovi.eu/david-spacil/dice-counter/releases):
|
||||
|
||||
| Systém | Soubor |
|
||||
|---|---|
|
||||
| Linux (x86-64) | `kostky-linux-x86_64` |
|
||||
| macOS (Apple Silicon) | `kostky-macos-arm64` |
|
||||
| macOS (Intel) | `kostky-macos-x86_64` |
|
||||
| Windows (x86-64) | `kostky-windows-x86_64.exe` |
|
||||
|
||||
Nic se neinstaluje — Python, Flask i šablony jsou uvnitř.
|
||||
|
||||
Na Linuxu a macOS:
|
||||
Stáhni binárku z [releases](https://gitea.spacilovi.eu/david-spacil/dice-counter/releases)
|
||||
a spusť ji:
|
||||
|
||||
```bash
|
||||
chmod +x kostky-*
|
||||
./kostky-linux-x86_64
|
||||
chmod +x kostky
|
||||
./kostky
|
||||
```
|
||||
|
||||
Na Windows stačí na `.exe` poklepat.
|
||||
|
||||
Linuxová binárka je postavená proti glibc 2.17, takže jede prakticky všude.
|
||||
|
||||
### Než to poprvé pustíš
|
||||
|
||||
Binárky nejsou podepsané — podpisové certifikáty stojí tisíce ročně a na
|
||||
počitadlo kostek by to byl nesmysl. Systémy si toho všimnou:
|
||||
|
||||
- **macOS** stažený soubor označí za karanténní a odmítne ho spustit. Buď
|
||||
značku sundej (`xattr -dr com.apple.quarantine kostky-macos-arm64`), nebo
|
||||
soubor stáhni rovnou z terminálu přes `curl -LO` — tudy se karanténa
|
||||
nenastavuje.
|
||||
- **Windows** ukáže modré okno SmartScreenu. *Další informace* →
|
||||
*Přesto spustit*. Občas si postěžuje i antivirus; u zabalených Python
|
||||
programů je to běžný falešný poplach. Při prvním spuštění se ještě zeptá
|
||||
brána firewall — bez povolení do místní sítě se telefon nepřipojí.
|
||||
|
||||
Kdo tomu nechce věřit, ověří si stažený soubor podle přiloženého součtu
|
||||
(níž) nebo si ho postaví sám — `./build.sh`, zdrojáky jsou tady celé.
|
||||
Nic se neinstaluje — Python, Flask i šablony jsou uvnitř. Zatím jen pro Linux
|
||||
na x86-64; postavené proti glibc 2.28, takže jede na všem od RHEL 8,
|
||||
Debianu 10 a Ubuntu 20.04 výš.
|
||||
|
||||
Ze zdrojáků to je stejně krátké:
|
||||
|
||||
@@ -66,8 +38,8 @@ Počitadlo je dostupné na:
|
||||
```
|
||||
|
||||
Na notebooku otevři `/board`, naskenuj QR kód telefonem a hraj. Tabule
|
||||
nabízí i zbylé adresy — když jedna nefunguje, klikni na jinou a QR kód se
|
||||
přepne na ni. Zastavuje se `Ctrl+C`.
|
||||
zobrazuje i zbylé adresy — když jedna nefunguje, zkus další. Zastavuje se
|
||||
`Ctrl+C`.
|
||||
|
||||
| Proměnná | K čemu je | Výchozí |
|
||||
|---|---|---|
|
||||
@@ -82,9 +54,7 @@ server vypíše při startu.
|
||||
|
||||
| Spuštěno | Databáze |
|
||||
|---|---|
|
||||
| binárkou na Linuxu | `~/.local/share/kostky/dice.db` |
|
||||
| binárkou na macOS | `~/Library/Application Support/kostky/dice.db` |
|
||||
| binárkou na Windows | `%LOCALAPPDATA%\kostky\dice.db` |
|
||||
| binárkou | `~/.local/share/kostky/dice.db` |
|
||||
| ze zdrojáků | `dice.db` v pracovním adresáři |
|
||||
|
||||
Binárka se při každém spuštění rozbaluje do dočasného adresáře a pouští se
|
||||
@@ -131,10 +101,7 @@ při remíze na prvním místě se hraje dál.
|
||||
| `stats.py` | síň slávy a kariérní statistiky |
|
||||
| `web.py` | Flask aplikace |
|
||||
| `dice.py` | totéž v terminálu, bez ukládání |
|
||||
| `console.py` | aby čeština prošla i windowsovou konzolí |
|
||||
| `build.sh`, `kostky.spec` | stavba binárky |
|
||||
| `.gitea/workflows/` | testy a linuxová binárka doma |
|
||||
| `.github/workflows/` | binárky pro Windows a macOS |
|
||||
|
||||
Terminálová verze zůstává funkční jako záloha:
|
||||
|
||||
@@ -165,49 +132,13 @@ python3 web.py
|
||||
./build.sh
|
||||
```
|
||||
|
||||
Staví se proti samostatnému CPythonu od `uv`, ne proti systémovému. Ten je
|
||||
slinkovaný s glibc 2.17; postavené proti Pythonu z Fedory 44 by to chtělo
|
||||
glibc 2.43 a nešlo by spustit skoro nikde — glibc drží zpětnou kompatibilitu,
|
||||
ne dopřednou. Nic z hostitele se do binárky nedostane, takže kontejner k tomu
|
||||
potřeba není. Výsledek je v `dist/`.
|
||||
|
||||
Stejný `build.sh` staví i na macOS a na Windows (v Git Bash) — jen tam bez
|
||||
té starosti o glibc. Křížem to nejde: PyInstaller balí do výsledku interpret
|
||||
a knihovny toho systému, na kterém běží, takže **binárku pro každý systém
|
||||
musí postavit ten systém.**
|
||||
Staví se v kontejneru (podman nebo docker) na AlmaLinux 8, ne na hostiteli.
|
||||
Binárka slinkovaná proti glibc z Fedory 44 by nešla spustit nikde se starším
|
||||
systémem — glibc drží zpětnou kompatibilitu, ne dopřednou. Výsledek je
|
||||
v `dist/kostky`.
|
||||
|
||||
Terminálová verze součástí binárky není; ta se pouští ze zdrojáků.
|
||||
|
||||
### Jak vznikají releasy
|
||||
|
||||
Otagovaný commit spustí stavbu na obou stranách:
|
||||
|
||||
| Kde | Co staví | Workflow |
|
||||
|---|---|---|
|
||||
| vlastní runner u Gitey | Linux | `.gitea/workflows/binarka.yml` |
|
||||
| GitHub Actions | Windows, macOS ×2 | `.github/workflows/binarky.yml` |
|
||||
|
||||
```bash
|
||||
git tag v1.0.0 && git push origin v1.0.0
|
||||
```
|
||||
|
||||
GitHub je tu jen půjčená dílna. Repozitář se tam z Gitey zrcadlí
|
||||
([mirror](https://github.com/david-spacil/dice-counter)), postavené soubory
|
||||
se posílají zpátky na zdejší release přes Gitea API a projekt má pořád jednu
|
||||
stránku s releasy — tuhle. Linux se na GitHubu schválně nestaví; doma to jde
|
||||
proti staré glibc, a když GitHub vypadne, release má aspoň tu platformu,
|
||||
na které to reálně poběží.
|
||||
|
||||
Každá stavba nejdřív projede testy a zkusí hotovou binárku nastartovat, než
|
||||
ji kamkoli pověsí. Vedle každé visí i `.sha256`, takže se stažený soubor dá
|
||||
ověřit:
|
||||
|
||||
```bash
|
||||
sha256sum -c kostky-linux-x86_64.sha256 # na macOS: shasum -a 256 -c
|
||||
```
|
||||
|
||||
Nad pull requesty se Linux staví taky, jen se nikam nevěší.
|
||||
|
||||
## Testy
|
||||
|
||||
```bash
|
||||
@@ -215,7 +146,3 @@ uv run --with-requirements requirements.txt --no-project pytest
|
||||
```
|
||||
|
||||
Nebo z připraveného prostředí prostě `pytest`.
|
||||
|
||||
Nad každým pull requestem a nad `main` běží `.gitea/workflows/testy.yml` —
|
||||
102 testů na Pythonu 3.11, 3.12, 3.13 i 3.14, a k tomu skriptovaná partie
|
||||
v terminálové verzi, na kterou pytest nesahá.
|
||||
@@ -1,35 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
# Postaví jednosouborovou binárku do dist/ — pro systém, na kterém běží.
|
||||
# Postaví jednosouborovou binárku pro Linux do dist/kostky.
|
||||
#
|
||||
# PyInstaller neumí křížovou kompilaci: binárku pro každý systém musí postavit
|
||||
# ten systém. Linux se staví doma na vlastním runneru, Windows a macOS na
|
||||
# půjčených strojích GitHub Actions (.github/workflows/binarky.yml).
|
||||
#
|
||||
# Staví se proti samostatnému CPythonu od uv, ne proti systémovému. Na Linuxu
|
||||
# je slinkovaný s glibc 2.17, takže binárka jede i na letitých systémech.
|
||||
# Postavená proti Pythonu z Fedory 44 by chtěla glibc 2.43 a nešla by spustit
|
||||
# skoro nikde — glibc drží zpětnou kompatibilitu, ne dopřednou. Nic z hostitele
|
||||
# se do ní nedostane, takže kontejner k tomu není potřeba.
|
||||
# Staví se v kontejneru s glibc 2.28 (AlmaLinux 8), ne na hostiteli. Binárka
|
||||
# slinkovaná proti glibc z Fedory 44 by nešla spustit nikde se starším
|
||||
# systémem — glibc drží zpětnou kompatibilitu, ne dopřednou. Postavené na
|
||||
# staré glibc poběží i na nové.
|
||||
set -euo pipefail
|
||||
|
||||
VERSION=${VERSION:-3.11}
|
||||
BUILD=${BUILD:-.build}
|
||||
IMAGE=${IMAGE:-docker.io/library/almalinux:8}
|
||||
PYTHON=${PYTHON:-python3.11}
|
||||
ENGINE=${ENGINE:-$(command -v podman || command -v docker)}
|
||||
|
||||
uv python install "$VERSION"
|
||||
uv venv --quiet --managed-python --python "$VERSION" "$BUILD"
|
||||
# Rootless podman mapuje roota v kontejneru na tebe, docker ne — tam by
|
||||
# dist/ zůstalo cizí.
|
||||
FIXUP=":"
|
||||
[[ "$ENGINE" == *docker ]] && FIXUP="chown -R $(id -u):$(id -g) dist"
|
||||
|
||||
# Windows dává spustitelné soubory venvu do Scripts/, zbytek světa do bin/.
|
||||
python="$BUILD/bin/python"
|
||||
pyinstaller="$BUILD/bin/pyinstaller"
|
||||
if [ -d "$BUILD/Scripts" ]; then
|
||||
python="$BUILD/Scripts/python.exe"
|
||||
pyinstaller="$BUILD/Scripts/pyinstaller.exe"
|
||||
fi
|
||||
|
||||
uv pip install --quiet --python "$python" -r requirements.txt pyinstaller
|
||||
|
||||
"$pyinstaller" --clean --noconfirm \
|
||||
--distpath dist --workpath "$BUILD/work" kostky.spec
|
||||
"$ENGINE" run --rm -v "$PWD:/src:z" -w /src "$IMAGE" bash -euc "
|
||||
dnf install -y -q python3.11 binutils
|
||||
$PYTHON -m venv /tmp/build
|
||||
/tmp/build/bin/pip install --quiet --upgrade pip
|
||||
/tmp/build/bin/pip install --quiet flask qrcode pyinstaller
|
||||
/tmp/build/bin/pyinstaller --clean --noconfirm \
|
||||
--distpath dist --workpath /tmp/work kostky.spec
|
||||
$FIXUP
|
||||
"
|
||||
|
||||
echo
|
||||
ls -lh dist/
|
||||
ls -lh dist/kostky
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
"""Aby čeština prošla i tam, kde konzole čeká něco jiného."""
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
def utf8() -> None:
|
||||
"""Přepne standardní výstup na UTF-8.
|
||||
|
||||
Windows sahá po historické kódové stránce (cp1252, cp852) všude, kde
|
||||
výstup není konzole — přesměrovaný do souboru, puštěný ze skriptu, ve
|
||||
frontě CI. První háček nad takovým proudem shodí celý program, takže
|
||||
binárka umřela dřív, než stihla vypsat adresu, na které poslouchá.
|
||||
|
||||
Na konzoli i na ostatních systémech je UTF-8 dávno výchozí a tohle je
|
||||
tam prázdná operace. `errors="replace"` je pojistka pro zbylé případy:
|
||||
rozsypaný háček je pořád lepší než spadlý server.
|
||||
"""
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
if hasattr(stream, "reconfigure"):
|
||||
stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
@@ -12,7 +12,6 @@ from os import name, system
|
||||
|
||||
from tabulate import tabulate
|
||||
|
||||
import console
|
||||
from core import FINAL_SCORE, Game
|
||||
|
||||
table_header = ["Hráč", "Skóre"]
|
||||
@@ -129,5 +128,4 @@ def main() -> None:
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
console.utf8()
|
||||
main()
|
||||
+1
-6
@@ -5,8 +5,6 @@ Staví se přes `./build.sh`, ne ručně — ta se stará o to, aby výsledek je
|
||||
i na starších systémech, než je ten tvůj.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
analysis = Analysis(
|
||||
["web.py"],
|
||||
pathex=["."],
|
||||
@@ -23,9 +21,6 @@ exe = EXE(
|
||||
analysis.datas,
|
||||
name="kostky",
|
||||
console=True,
|
||||
# Ořezání symbolů šetří pár megabajtů, ale jen na Linuxu. Na macOS by
|
||||
# rozbilo podpis, který si PyInstaller sám přidává a bez kterého se
|
||||
# binárka na Apple Silicon vůbec nespustí; na Windows nedělá nic.
|
||||
strip=sys.platform == "linux",
|
||||
strip=True,
|
||||
upx=False,
|
||||
)
|
||||
@@ -11,9 +11,7 @@ adresy a obecný typ rozhraní, ne to, co má kdo zrovna nainstalované.
|
||||
|
||||
import ipaddress
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
|
||||
# Rozsah, ze kterého adresy přiděluje Tailscale (RFC 6598, CGNAT).
|
||||
@@ -125,45 +123,8 @@ def _from_interfaces() -> list[tuple[str, str]]:
|
||||
return found
|
||||
|
||||
|
||||
def parse_ifconfig(text: str) -> list[tuple[str, str]]:
|
||||
"""Rozhraní a jejich IPv4 adresy z výstupu `ifconfig`.
|
||||
|
||||
Oddělená čistá funkce, ať se dá otestovat i bez macOS pod rukama.
|
||||
"""
|
||||
found = []
|
||||
name = ""
|
||||
|
||||
for line in text.splitlines():
|
||||
head = re.match(r"(\S+):", line)
|
||||
if head:
|
||||
name = head.group(1)
|
||||
continue
|
||||
|
||||
addr = re.search(r"\binet (\d+\.\d+\.\d+\.\d+)", line)
|
||||
if addr:
|
||||
found.append((name, addr.group(1)))
|
||||
|
||||
return found
|
||||
|
||||
|
||||
def _from_ifconfig() -> list[tuple[str, str]]:
|
||||
"""Adresy přes `ifconfig`. Záloha pro macOS, kde ioctl výše nesedí.
|
||||
|
||||
Spouští se jen tam, kde předchozí cesta nic nenašla, takže na Linuxu
|
||||
se neplatí nic. Trvá jednotky milisekund, cachovat to nemá cenu.
|
||||
"""
|
||||
try:
|
||||
done = subprocess.run(["ifconfig", "-a"], capture_output=True,
|
||||
text=True, timeout=5, check=True)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return []
|
||||
|
||||
return parse_ifconfig(done.stdout)
|
||||
|
||||
|
||||
def _from_hostname() -> list[tuple[str, str]]:
|
||||
"""Poslední záloha. Na Windows vrátí adresy všech rozhraní, jinde
|
||||
obvykle jen jednu — jména rozhraní se takhle nedozvíme."""
|
||||
"""Záloha pro systémy, kde předchozí cesta nefunguje."""
|
||||
try:
|
||||
infos = socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET)
|
||||
except OSError:
|
||||
@@ -183,7 +144,7 @@ def addresses() -> list[Address]:
|
||||
|
||||
primary = default_route_address()
|
||||
|
||||
found = _from_interfaces() or _from_ifconfig() or _from_hostname()
|
||||
found = _from_interfaces() or _from_hostname()
|
||||
if primary and primary not in [ip for _name, ip in found]:
|
||||
found.append(("", primary))
|
||||
|
||||
|
||||
+1
-21
@@ -373,7 +373,7 @@ button {
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.join .address .what {
|
||||
.join .address span {
|
||||
display: block;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.7rem;
|
||||
@@ -382,8 +382,6 @@ button {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.join .address .url { display: block; }
|
||||
|
||||
.qr { width: 5.5rem; height: 5.5rem; fill: var(--ink); }
|
||||
|
||||
.idle {
|
||||
@@ -482,24 +480,6 @@ 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;
|
||||
|
||||
+3
-13
@@ -16,30 +16,20 @@ from pathlib import Path
|
||||
|
||||
from core import FINAL_SCORE, Game, Turn
|
||||
|
||||
def data_home() -> Path:
|
||||
"""Adresář, kam podle zvyklostí systému patří data aplikací."""
|
||||
if sys.platform == "win32":
|
||||
local = os.environ.get("LOCALAPPDATA")
|
||||
return Path(local) if local else Path.home() / "AppData" / "Local"
|
||||
if sys.platform == "darwin":
|
||||
return Path.home() / "Library" / "Application Support"
|
||||
return Path(os.environ.get("XDG_DATA_HOME")
|
||||
or Path.home() / ".local" / "share")
|
||||
|
||||
|
||||
def default_db() -> Path:
|
||||
"""Kam se ukládá databáze.
|
||||
|
||||
Ze zdrojáků do pracovního adresáře — dá se tak mít víc rozehraných
|
||||
sad vedle sebe a je vidět, s čím se pracuje. Z binárky do datového
|
||||
adresáře podle systému: binárka se spouští odkudkoli a rozbaluje se do
|
||||
adresáře podle XDG: binárka se spouští odkudkoli a rozbaluje se do
|
||||
dočasného adresáře, takže relativní cesta by databázi rozsypala po disku.
|
||||
"""
|
||||
chosen = os.environ.get("DICE_DB")
|
||||
if chosen:
|
||||
return Path(chosen)
|
||||
if getattr(sys, "frozen", False):
|
||||
return data_home() / "kostky" / "dice.db"
|
||||
share = os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share"
|
||||
return Path(share) / "kostky" / "dice.db"
|
||||
return Path("dice.db")
|
||||
|
||||
|
||||
|
||||
+9
-45
@@ -1,58 +1,22 @@
|
||||
{# 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 %}
|
||||
{# Kudy se na počitadlo dostane telefon. Nabídneme víc cest, vybrat si může člověk. #}
|
||||
{% if primary %}
|
||||
<div class="join">
|
||||
<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>
|
||||
|
||||
{{ target|qr }}
|
||||
<div class="address">
|
||||
<span class="what">Zapisuje se z telefonu</span>
|
||||
<span class="url">{{ choices[0].url }}</span>
|
||||
|
||||
{% if choices|length > 1 %}
|
||||
<span>Zapisuje se z telefonu</span>
|
||||
{{ target }}
|
||||
{% if others %}
|
||||
<ul class="alt">
|
||||
{% 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>
|
||||
{% for address in others %}
|
||||
<li>{{ address.ip }}:{{ port }} <em>{{ address.label }}</em></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 class="what">Adresu se nepodařilo zjistit</span>
|
||||
<p class="address"><span>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="{{ address_key }}">{% include "_head.html" %}</div>
|
||||
<div id="head" data-address="{{ primary_ip }}">{% 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="{{ address_key }}">{% include "_head.html" %}</div>
|
||||
<div id="head" data-address="{{ primary_ip }}">{% include "_head.html" %}</div>
|
||||
{% include "_join.html" %}
|
||||
</header>
|
||||
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import io
|
||||
|
||||
import console
|
||||
|
||||
|
||||
class Proud:
|
||||
"""Náhrada za výstup, která si pamatuje, na co ji přepnuli."""
|
||||
|
||||
def __init__(self):
|
||||
self.encoding = "cp1252"
|
||||
self.errors = "strict"
|
||||
|
||||
def reconfigure(self, encoding, errors):
|
||||
self.encoding = encoding
|
||||
self.errors = errors
|
||||
|
||||
|
||||
def test_vystup_se_prepne_na_utf8(monkeypatch):
|
||||
out, err = Proud(), Proud()
|
||||
monkeypatch.setattr(console.sys, "stdout", out)
|
||||
monkeypatch.setattr(console.sys, "stderr", err)
|
||||
|
||||
console.utf8()
|
||||
|
||||
assert (out.encoding, out.errors) == ("utf-8", "replace")
|
||||
assert (err.encoding, err.errors) == ("utf-8", "replace")
|
||||
|
||||
|
||||
def test_cestina_projde_i_pres_cp1252(monkeypatch):
|
||||
"""Přesně to, na čem umřela první windowsová binárka."""
|
||||
surovy = io.BytesIO()
|
||||
proud = io.TextIOWrapper(surovy, encoding="cp1252")
|
||||
monkeypatch.setattr(console.sys, "stdout", proud)
|
||||
monkeypatch.setattr(console.sys, "stderr", proud)
|
||||
|
||||
console.utf8()
|
||||
print("Počitadlo je dostupné na:", file=console.sys.stdout)
|
||||
console.sys.stdout.flush()
|
||||
|
||||
assert "Počitadlo je dostupné" in surovy.getvalue().decode("utf-8")
|
||||
|
||||
|
||||
def test_proud_bez_reconfigure_se_prezije(monkeypatch):
|
||||
"""Zabalený výstup reconfigure mít nemusí; padat kvůli tomu nesmíme."""
|
||||
monkeypatch.setattr(console.sys, "stdout", object())
|
||||
monkeypatch.setattr(console.sys, "stderr", object())
|
||||
|
||||
console.utf8()
|
||||
@@ -90,50 +90,3 @@ def test_v_systemu_se_neco_najde():
|
||||
|
||||
assert all(net.classify(a.ip, a.interface) for a in found)
|
||||
assert len({a.ip for a in found}) == len(found)
|
||||
|
||||
|
||||
IFCONFIG_MACOS = """\
|
||||
lo0: flags=8049<UP,LOOPBACK,RUNNING,MULTICAST> mtu 16384
|
||||
\toptions=1203<RXCSUM,TXCSUM,TXSTATUS,SW_TIMESTAMP>
|
||||
\tinet 127.0.0.1 netmask 0xff000000
|
||||
\tinet6 ::1 prefixlen 128
|
||||
gif0: flags=8010<POINTOPOINT,MULTICAST> mtu 1280
|
||||
en0: flags=8863<UP,BROADCAST,SMART,RUNNING,SIMPLEX,MULTICAST> mtu 1500
|
||||
\tether 3c:22:fb:aa:bb:cc
|
||||
\tinet6 fe80::14b0:6b1a:9e2f:1a4c%en0 prefixlen 64 secured scopeid 0xb
|
||||
\tinet 192.168.0.42 netmask 0xffffff00 broadcast 192.168.0.255
|
||||
\tmedia: autoselect
|
||||
\tstatus: active
|
||||
utun4: flags=8051<UP,POINTOPOINT,RUNNING,MULTICAST> mtu 1280
|
||||
\tinet 100.99.98.97 --> 100.99.98.97 netmask 0xff000000
|
||||
"""
|
||||
|
||||
|
||||
def test_ifconfig_vytahne_rozhrani_a_adresy():
|
||||
"""macOS na ioctl z Linuxu neslyší, tak se čte výstup ifconfigu."""
|
||||
assert net.parse_ifconfig(IFCONFIG_MACOS) == [
|
||||
("lo0", "127.0.0.1"),
|
||||
("en0", "192.168.0.42"),
|
||||
("utun4", "100.99.98.97"),
|
||||
]
|
||||
|
||||
|
||||
def test_ifconfig_si_neplete_inet6_s_inet():
|
||||
"""`inet6` začíná stejně jako `inet`; IPv6 adresy tudy projít nesmí."""
|
||||
assert net.parse_ifconfig("en0: flags=1\n\tinet6 fe80::1 prefixlen 64\n") == []
|
||||
|
||||
|
||||
def test_adresy_z_ifconfigu_projdou_celym_retezem(monkeypatch):
|
||||
"""Bez linuxového ioctl se sáhne po ifconfigu, teprve pak po hostname."""
|
||||
monkeypatch.delenv("DICE_HOST", raising=False)
|
||||
monkeypatch.setattr(net, "_from_interfaces", lambda: [])
|
||||
monkeypatch.setattr(net, "_from_ifconfig",
|
||||
lambda: net.parse_ifconfig(IFCONFIG_MACOS))
|
||||
monkeypatch.setattr(net, "_from_hostname", lambda: pytest.fail("moc brzy"))
|
||||
monkeypatch.setattr(net, "default_route_address", lambda: "192.168.0.42")
|
||||
|
||||
found = net.addresses()
|
||||
|
||||
assert [a.ip for a in found] == ["192.168.0.42", "100.99.98.97"]
|
||||
assert found[0].primary and found[0].interface == "en0"
|
||||
assert found[1].kind == "tailnet"
|
||||
@@ -148,28 +148,11 @@ def test_umisteni_databaze_z_binarky(monkeypatch, tmp_path):
|
||||
"""Binárka se rozbaluje do dočasného adresáře, databáze tam nesmí."""
|
||||
monkeypatch.delenv("DICE_DB", raising=False)
|
||||
monkeypatch.setattr(storage.sys, "frozen", True, raising=False)
|
||||
monkeypatch.setattr(storage.sys, "platform", "linux")
|
||||
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
|
||||
|
||||
assert storage.default_db() == tmp_path / "kostky" / "dice.db"
|
||||
|
||||
|
||||
def test_data_home_podle_systemu(monkeypatch, tmp_path):
|
||||
"""Každý systém má svůj adresář na data; XDG je jen ten linuxový."""
|
||||
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path / "AppData" / "Local"))
|
||||
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path))
|
||||
|
||||
monkeypatch.setattr(storage.sys, "platform", "win32")
|
||||
assert storage.data_home() == tmp_path / "AppData" / "Local"
|
||||
|
||||
monkeypatch.setattr(storage.sys, "platform", "darwin")
|
||||
assert storage.data_home() == tmp_path / "Library" / "Application Support"
|
||||
|
||||
monkeypatch.setattr(storage.sys, "platform", "linux")
|
||||
monkeypatch.delenv("XDG_DATA_HOME", raising=False)
|
||||
assert storage.data_home() == tmp_path / ".local" / "share"
|
||||
|
||||
|
||||
def test_dice_db_prebije_oboje(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(storage.sys, "frozen", True, raising=False)
|
||||
monkeypatch.setenv("DICE_DB", str(tmp_path / "jinde.db"))
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
import net
|
||||
import storage
|
||||
import web
|
||||
|
||||
@@ -239,90 +236,3 @@ 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,9 +16,8 @@ from pathlib import Path
|
||||
import qrcode
|
||||
from flask import (Flask, abort, g, redirect, render_template, request,
|
||||
url_for)
|
||||
from markupsafe import Markup, escape
|
||||
from markupsafe import Markup
|
||||
|
||||
import console
|
||||
import net
|
||||
import stats
|
||||
import storage
|
||||
@@ -62,50 +61,31 @@ def close_db(_exception):
|
||||
|
||||
# --- adresa a QR kód --------------------------------------------------------
|
||||
|
||||
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 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 links(path: str = "/") -> dict:
|
||||
"""Adresy, na kterých je počitadlo dostupné, i s cíli pro QR kódy.
|
||||
"""Adresy, na kterých je počitadlo dostupné, i s cílem pro QR kód.
|
||||
|
||||
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. První v pořadí je ta nejnadějnější,
|
||||
zbytek si člověk může na tabuli přepnout.
|
||||
z domácí WiFi na hotspot a zpátky.
|
||||
"""
|
||||
found = net.addresses()
|
||||
primary = found[0] if found else None
|
||||
|
||||
return {
|
||||
"choices": [
|
||||
{"ip": address.ip, "label": address.label,
|
||||
"url": address.url(PORT) + path}
|
||||
for address in net.addresses()
|
||||
],
|
||||
"primary": primary,
|
||||
"others": found[1:],
|
||||
"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é z obdélníků — bez Pillow a bez práce s obrázky."""
|
||||
"""QR jako SVG složené ze čtvereč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)
|
||||
@@ -113,14 +93,14 @@ def qr_svg(data: str) -> Markup:
|
||||
size = len(matrix)
|
||||
|
||||
rects = [
|
||||
f'<rect x="{x}" y="{y}" width="{width}" height="1"/>'
|
||||
f'<rect x="{x}" y="{y}" width="1" height="1"/>'
|
||||
for y, row in enumerate(matrix)
|
||||
for x, width in runs(row)
|
||||
for x, dark in enumerate(row) if dark
|
||||
]
|
||||
|
||||
return Markup(
|
||||
f'<svg class="qr" viewBox="0 0 {size} {size}" role="img" '
|
||||
f'aria-label="QR kód s adresou {escape(data)}" '
|
||||
f'aria-label="QR kód s adresou pro telefon" '
|
||||
f'shape-rendering="crispEdges">{"".join(rects)}</svg>'
|
||||
)
|
||||
|
||||
@@ -204,7 +184,7 @@ def view(game: Game, game_id: int, row) -> dict:
|
||||
"standings": game.standings(),
|
||||
"winner": winner,
|
||||
"current": "" if winner else game.current_player,
|
||||
"address_key": address_key(),
|
||||
"primary_ip": primary_ip(),
|
||||
}
|
||||
|
||||
|
||||
@@ -321,8 +301,6 @@ def hall():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
console.utf8()
|
||||
|
||||
found = net.addresses()
|
||||
|
||||
if found:
|
||||
|
||||
Reference in new issue
Block a user