Compare commits
2 Commits
add2e6aa46
...
8a110661bb
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a110661bb | |||
| b35141d1c7 |
11
.env.example
Normal file
11
.env.example
Normal file
@ -0,0 +1,11 @@
|
||||
# Copy this file to .env and fill in secrets. Do NOT commit .env
|
||||
# Example env file for local deployment
|
||||
PORT=8501
|
||||
IMAGE_NAME=wm2026_simulator:latest
|
||||
# Set a password used by your deployment or reverse proxy
|
||||
STREAMLIT_PASSWORD=changeme
|
||||
|
||||
# Optional: remote deploy settings (used by CI if provided as secrets)
|
||||
DEPLOY_HOST=example.com
|
||||
DEPLOY_USER=deploy
|
||||
DEPLOY_PASSWORD=supersecret
|
||||
55
.github/workflows/deploy.yml
vendored
Normal file
55
.github/workflows/deploy.yml
vendored
Normal file
@ -0,0 +1,55 @@
|
||||
name: Build and Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ghcr.io/${{ github.repository_owner }}/${{ github.repository }}:latest
|
||||
|
||||
optional-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-and-push
|
||||
if: ${{ secrets.DEPLOY_HOST && secrets.DEPLOY_USER && secrets.DEPLOY_PASSWORD }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install sshpass
|
||||
run: sudo apt-get update && sudo apt-get install -y sshpass
|
||||
|
||||
- name: Copy files and restart remote compose
|
||||
env:
|
||||
HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
USER: ${{ secrets.DEPLOY_USER }}
|
||||
PASS: ${{ secrets.DEPLOY_PASSWORD }}
|
||||
IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.repository }}:latest
|
||||
run: |
|
||||
sshpass -p "$PASS" ssh -o StrictHostKeyChecking=no $USER@$HOST "mkdir -p ~/wm2026_deploy && exit"
|
||||
sshpass -p "$PASS" scp -o StrictHostKeyChecking=no docker-compose.yml $USER@$HOST:~/wm2026_deploy/
|
||||
sshpass -p "$PASS" ssh -o StrictHostKeyChecking=no $USER@$HOST "cd ~/wm2026_deploy && docker pull $IMAGE || true && docker-compose up -d --build"
|
||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
.env
|
||||
__pycache__/
|
||||
.venv/
|
||||
*.pyc
|
||||
dist/
|
||||
build/
|
||||
/.pytest_cache
|
||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
@ -0,0 +1,17 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PORT=8501
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application files
|
||||
COPY . /app
|
||||
|
||||
EXPOSE ${PORT}
|
||||
|
||||
CMD streamlit run app.py --server.port $PORT --server.address 0.0.0.0
|
||||
Binary file not shown.
150
app.py
150
app.py
@ -128,6 +128,27 @@ TRANSLATIONS = {
|
||||
'es': 'Elige un rival',
|
||||
'ru': 'Выберите соперника',
|
||||
},
|
||||
'select_focus_team_label': {
|
||||
'de': 'Land für KO-Analyse wählen',
|
||||
'en': 'Choose country for KO analysis',
|
||||
'fr': 'Choisir le pays pour l’analyse KO',
|
||||
'es': 'Elige país para análisis KO',
|
||||
'ru': 'Выберите страну для анализа плей-офф',
|
||||
},
|
||||
'group_place_phrase': {
|
||||
'de': 'Gruppenplatz',
|
||||
'en': 'group place',
|
||||
'fr': 'place en groupe',
|
||||
'es': 'puesto de grupo',
|
||||
'ru': 'место в группе',
|
||||
},
|
||||
'path_phrase': {
|
||||
'de': 'Pfad',
|
||||
'en': 'path',
|
||||
'fr': 'parcours',
|
||||
'es': 'ruta',
|
||||
'ru': 'путь',
|
||||
},
|
||||
'germany_vs': {
|
||||
'de': '**Deutschland vs. {opponent}**',
|
||||
'en': '**Germany vs. {opponent}**',
|
||||
@ -637,7 +658,7 @@ def simulate_group(group_teams, rng, strengths):
|
||||
return teams_sorted, {'pts': pts, 'gd': gd, 'gf': gf, 'cards': cards, 'matches': matches}, match_records
|
||||
|
||||
|
||||
def run_single_tournament(groups, rng, strengths, annex_mapping=None, team_rankings=None):
|
||||
def run_single_tournament(groups, rng, strengths, annex_mapping=None, team_rankings=None, focal_team='Germany'):
|
||||
if team_rankings is None:
|
||||
team_rankings = strengths
|
||||
|
||||
@ -793,23 +814,23 @@ def run_single_tournament(groups, rng, strengths, annex_mapping=None, team_ranki
|
||||
|
||||
rounds = {}
|
||||
for a, b in r32_matches:
|
||||
if a == 'Germany' or b == 'Germany':
|
||||
opp = b if a == 'Germany' else a
|
||||
if a == focal_team or b == focal_team:
|
||||
opp = b if a == focal_team else a
|
||||
rounds['R32'] = opp
|
||||
for a, b in r16_matches:
|
||||
if a == 'Germany' or b == 'Germany':
|
||||
opp = b if a == 'Germany' else a
|
||||
if a == focal_team or b == focal_team:
|
||||
opp = b if a == focal_team else a
|
||||
rounds['R16'] = opp
|
||||
for a, b in qf_matches:
|
||||
if a == 'Germany' or b == 'Germany':
|
||||
opp = b if a == 'Germany' else a
|
||||
if a == focal_team or b == focal_team:
|
||||
opp = b if a == focal_team else a
|
||||
rounds['QF'] = opp
|
||||
for a, b in sf_matches:
|
||||
if a == 'Germany' or b == 'Germany':
|
||||
opp = b if a == 'Germany' else a
|
||||
if a == focal_team or b == focal_team:
|
||||
opp = b if a == focal_team else a
|
||||
rounds['SF'] = opp
|
||||
if 'Germany' in final_teams:
|
||||
opp = final_teams[1] if final_teams[0] == 'Germany' else final_teams[0] if final_teams[1] == 'Germany' else None
|
||||
if focal_team in final_teams:
|
||||
opp = final_teams[1] if final_teams[0] == focal_team else final_teams[0] if final_teams[1] == focal_team else None
|
||||
rounds['Final'] = opp
|
||||
|
||||
return {
|
||||
@ -843,7 +864,7 @@ def parse_group_place(route):
|
||||
return None
|
||||
|
||||
|
||||
def run_simulations(groups, n, seed=0, annex_mapping=None, team_rankings=None, strengths=None):
|
||||
def run_simulations(groups, n, seed=0, annex_mapping=None, team_rankings=None, strengths=None, focal_team='Germany'):
|
||||
rng = np.random.default_rng(seed)
|
||||
counters = {r: Counter() for r in ['R32', 'R16', 'QF', 'SF', 'Final']}
|
||||
teams = [t for team_list in groups.values() for t in team_list]
|
||||
@ -866,17 +887,17 @@ def run_simulations(groups, n, seed=0, annex_mapping=None, team_rankings=None, s
|
||||
}
|
||||
|
||||
for _ in range(n):
|
||||
result = run_single_tournament(groups, rng, strengths, annex_mapping=annex_mapping, team_rankings=team_rankings)
|
||||
result = run_single_tournament(groups, rng, strengths, annex_mapping=annex_mapping, team_rankings=team_rankings, focal_team=focal_team)
|
||||
for r, opp in result['rounds'].items():
|
||||
if opp is not None:
|
||||
counters[r][opp] += 1
|
||||
meeting_stats['Germany'][opp][r] += 1
|
||||
meeting_stats[opp]['Germany'][r] += 1
|
||||
if result['positions']['Germany'] == 1 and result['positions'][opp] == 1:
|
||||
meeting_stats[focal_team][opp][r] += 1
|
||||
meeting_stats[opp][focal_team][r] += 1
|
||||
if result['positions'][focal_team] == 1 and result['positions'][opp] == 1:
|
||||
both_group_winner_meetings[opp] += 1
|
||||
germany_route = summarize_meeting_route(result['team_info']['Germany'], r)
|
||||
focal_route = summarize_meeting_route(result['team_info'][focal_team], r)
|
||||
opp_route = summarize_meeting_route(result['team_info'][opp], r)
|
||||
meeting_paths[opp][r][(germany_route, opp_route)] += 1
|
||||
meeting_paths[opp][r][(focal_route, opp_route)] += 1
|
||||
for team, pos in result['positions'].items():
|
||||
team_stats[team]['group_positions'][pos] += 1
|
||||
for team, record in result['group_records'].items():
|
||||
@ -961,11 +982,21 @@ def main():
|
||||
list(LANGUAGES.keys()),
|
||||
format_func=lambda code: LANGUAGES[code],
|
||||
index=0,
|
||||
key='lang_select',
|
||||
)
|
||||
st.title(translate('title', lang))
|
||||
|
||||
teams = load_teams()
|
||||
strengths = compute_team_strengths(TEAM_STAGE_ODDS)
|
||||
# choose focal team for KO analysis (defaults to Germany if present)
|
||||
focus_default = 'Germany' if 'Germany' in teams else teams[0]
|
||||
focus_team = st.sidebar.selectbox(
|
||||
translate('select_focus_team_label', lang),
|
||||
teams,
|
||||
index=teams.index(focus_default),
|
||||
format_func=lambda t: display_team_name(t, lang),
|
||||
key=f'focus_team_{lang}',
|
||||
)
|
||||
|
||||
st.sidebar.header(translate('sidebar_header', lang))
|
||||
n = st.sidebar.slider(translate('sidebar_n', lang), min_value=100, max_value=20000, value=2000, step=100)
|
||||
@ -999,6 +1030,7 @@ def main():
|
||||
annex_mapping=None,
|
||||
team_rankings=strengths,
|
||||
strengths=strengths,
|
||||
focal_team=focus_team,
|
||||
)
|
||||
|
||||
if st.session_state.simulation_results is not None:
|
||||
@ -1006,25 +1038,26 @@ def main():
|
||||
st.success(translate('done_message', lang))
|
||||
|
||||
counters, team_stats, meeting_stats, meeting_paths, both_group_winner_meetings = st.session_state.simulation_results
|
||||
germany = team_stats['Germany']
|
||||
focal_stats = team_stats[focus_team]
|
||||
tab1, tab2 = st.tabs([translate('tab_opponent_details', lang), translate('tab_results', lang)])
|
||||
|
||||
with tab1:
|
||||
st.subheader(translate('opponent_details_header', lang))
|
||||
possible_opponents = sorted(
|
||||
[t for t in teams if t != 'Germany'],
|
||||
key=lambda x: sum(meeting_stats['Germany'][x].values()),
|
||||
[t for t in teams if t != focus_team],
|
||||
key=lambda x: sum(meeting_stats[focus_team][x].values()),
|
||||
reverse=True,
|
||||
)
|
||||
opponent_options = ['Germany'] + [t for t in sorted(teams) if t != 'Germany']
|
||||
opponent_options = [focus_team] + [t for t in sorted(teams) if t != focus_team]
|
||||
opponent = st.selectbox(
|
||||
translate('select_opponent', lang),
|
||||
opponent_options,
|
||||
format_func=lambda t: display_team_name(t, lang),
|
||||
key=f'opponent_select_{lang}',
|
||||
)
|
||||
|
||||
if opponent:
|
||||
meet = meeting_stats['Germany'][opponent]
|
||||
meet = meeting_stats[focus_team][opponent]
|
||||
total_meet = sum(meet.values())
|
||||
st.markdown(translate('germany_vs', lang, opponent=display_team_name(opponent, lang)))
|
||||
if total_meet == 0:
|
||||
@ -1061,20 +1094,20 @@ def main():
|
||||
if meet[r] == 0:
|
||||
continue
|
||||
for (germany_route, opp_route), path_count in meeting_paths[opponent][r].most_common():
|
||||
germany_winner = is_group_winner_route(germany_route)
|
||||
focal_winner = is_group_winner_route(germany_route)
|
||||
opp_winner = is_group_winner_route(opp_route)
|
||||
germany_place = parse_group_place(germany_route)
|
||||
focal_place = parse_group_place(germany_route)
|
||||
opp_place = parse_group_place(opp_route)
|
||||
placement_rows.append({
|
||||
'Round': r,
|
||||
'Germany group place': germany_place,
|
||||
f'{opponent} group place': opp_place,
|
||||
'Both group winners': germany_winner and opp_winner,
|
||||
'Focus group place': focal_place,
|
||||
'Opponent group place': opp_place,
|
||||
'Both group winners': focal_winner and opp_winner,
|
||||
'Count': path_count,
|
||||
'Share of round': f'{path_count / meet[r]:.1%}',
|
||||
'Share of sims': f'{path_count / n:.1%}',
|
||||
'Germany path': ' → '.join(germany_route),
|
||||
f'{opponent} path': ' → '.join(opp_route),
|
||||
'Focus path': ' → '.join(germany_route),
|
||||
'Opponent path': ' → '.join(opp_route),
|
||||
})
|
||||
|
||||
if placement_rows:
|
||||
@ -1086,14 +1119,14 @@ def main():
|
||||
[
|
||||
'Stage',
|
||||
'Round',
|
||||
'Germany group place',
|
||||
f'{opponent} group place',
|
||||
'Focus group place',
|
||||
'Opponent group place',
|
||||
'Both group winners',
|
||||
'Count',
|
||||
'Share of round',
|
||||
'Share of sims',
|
||||
'Germany path',
|
||||
f'{opponent} path',
|
||||
'Focus path',
|
||||
'Opponent path',
|
||||
'Round order',
|
||||
]
|
||||
]
|
||||
@ -1102,7 +1135,12 @@ def main():
|
||||
ascending=[False, True, False],
|
||||
)
|
||||
placement_df = placement_df.drop(columns=['Round order'])
|
||||
placement_df = placement_df.rename(columns=COLUMN_LABELS[lang])
|
||||
# prepare dynamic labels for the focus team
|
||||
col_map = COLUMN_LABELS.get(lang, {}).copy()
|
||||
# dynamic focus labels
|
||||
col_map['Focus group place'] = f"{display_team_name(focus_team, lang)} {translate('group_place_phrase', lang)}"
|
||||
col_map['Focus path'] = f"{display_team_name(focus_team, lang)} {translate('path_phrase', lang)}"
|
||||
placement_df = placement_df.rename(columns=col_map)
|
||||
st.markdown(translate('flows_first_both_winners', lang))
|
||||
def highlight_row(row):
|
||||
return [
|
||||
@ -1112,32 +1150,32 @@ def main():
|
||||
st.dataframe(placement_df.style.apply(highlight_row, axis=1))
|
||||
|
||||
grouping = pd.DataFrame(placement_rows).groupby(
|
||||
['Germany group place', f'{opponent} group place']
|
||||
['Focus group place', 'Opponent group place']
|
||||
)['Count'].sum().reset_index()
|
||||
grouping['Probability of meeting'] = grouping['Count'] / total_meet
|
||||
grouping = grouping.rename(
|
||||
columns={
|
||||
'Germany group place': label('Germany group place', lang),
|
||||
f'{opponent} group place': label(f'{opponent} group place', lang),
|
||||
'Focus group place': f"{display_team_name(focus_team, lang)} {translate('group_place_phrase', lang)}",
|
||||
'Opponent group place': label('Opponent group place', lang),
|
||||
'Count': label('Count', lang),
|
||||
'Probability of meeting': label('Probability of meeting', lang),
|
||||
}
|
||||
)
|
||||
st.subheader(translate('meeting_by_group_place', lang))
|
||||
grouping = grouping.sort_values(
|
||||
[label('Germany group place', lang), label(f'{opponent} group place', lang)]
|
||||
[f"{display_team_name(focus_team, lang)} {translate('group_place_phrase', lang)}", label('Opponent group place', lang)]
|
||||
)
|
||||
st.dataframe(grouping)
|
||||
|
||||
st.subheader(translate('stage_meeting_phase', lang))
|
||||
st.markdown(
|
||||
format_stage_line(
|
||||
display_team_name('Germany', lang),
|
||||
f'{team_stats['Germany']['R16'] / n:.1%}',
|
||||
f'{team_stats['Germany']['QF'] / n:.1%}',
|
||||
f'{team_stats['Germany']['SF'] / n:.1%}',
|
||||
f'{team_stats['Germany']['Final'] / n:.1%}',
|
||||
f'{team_stats['Germany']['Winner'] / n:.1%}',
|
||||
display_team_name(focus_team, lang),
|
||||
f"{team_stats[focus_team]['R16'] / n:.1%}",
|
||||
f"{team_stats[focus_team]['QF'] / n:.1%}",
|
||||
f"{team_stats[focus_team]['SF'] / n:.1%}",
|
||||
f"{team_stats[focus_team]['Final'] / n:.1%}",
|
||||
f"{team_stats[focus_team]['Winner'] / n:.1%}",
|
||||
lang=lang,
|
||||
)
|
||||
)
|
||||
@ -1145,11 +1183,11 @@ def main():
|
||||
st.markdown(
|
||||
format_stage_line(
|
||||
display_team_name(opponent, lang),
|
||||
f'{opp_stats['R16'] / n:.1%}',
|
||||
f'{opp_stats['QF'] / n:.1%}',
|
||||
f'{opp_stats['SF'] / n:.1%}',
|
||||
f'{opp_stats['Final'] / n:.1%}',
|
||||
f'{opp_stats['Winner'] / n:.1%}',
|
||||
f"{opp_stats['R16'] / n:.1%}",
|
||||
f"{opp_stats['QF'] / n:.1%}",
|
||||
f"{opp_stats['SF'] / n:.1%}",
|
||||
f"{opp_stats['Final'] / n:.1%}",
|
||||
f"{opp_stats['Winner'] / n:.1%}",
|
||||
lang=lang,
|
||||
)
|
||||
)
|
||||
@ -1162,13 +1200,13 @@ def main():
|
||||
translate(
|
||||
'germany_reaches',
|
||||
lang,
|
||||
team=display_team_name('Germany', lang),
|
||||
team=display_team_name(focus_team, lang),
|
||||
stage=stage_label(r, lang),
|
||||
value=f"{germany[r] / n:.1%}",
|
||||
value=f"{team_stats[focus_team][r] / n:.1%}",
|
||||
)
|
||||
)
|
||||
|
||||
st.subheader(translate('possible_opponents', lang, team=display_team_name('Germany', lang)))
|
||||
st.subheader(translate('possible_opponents', lang, team=display_team_name(focus_team, lang)))
|
||||
for r in ['R32', 'R16', 'QF', 'SF', 'Final']:
|
||||
cnt = counters[r]
|
||||
total = sum(cnt.values())
|
||||
@ -1215,11 +1253,12 @@ def main():
|
||||
summary_df = pd.DataFrame(team_rows).sort_values(label('R16', lang), ascending=False)
|
||||
st.dataframe(summary_df)
|
||||
|
||||
team_options = ['Germany'] + [t for t in sorted(teams) if t != 'Germany']
|
||||
team_options = [focus_team] + [t for t in sorted(teams) if t != focus_team]
|
||||
selected_team = st.selectbox(
|
||||
translate('select_team', lang),
|
||||
team_options,
|
||||
format_func=lambda t: display_team_name(t, lang),
|
||||
key=f'team_select_{lang}',
|
||||
)
|
||||
if selected_team:
|
||||
selected = team_stats[selected_team]
|
||||
@ -1264,6 +1303,3 @@ def main():
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
10
docker-compose.yml
Normal file
10
docker-compose.yml
Normal file
@ -0,0 +1,10 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
image: ${IMAGE_NAME:-wm2026_simulator:latest}
|
||||
ports:
|
||||
- "${PORT:-8501}:8501"
|
||||
environment:
|
||||
- PORT=${PORT:-8501}
|
||||
- STREAMLIT_PASSWORD=${STREAMLIT_PASSWORD}
|
||||
restart: unless-stopped
|
||||
73
gitea-deploy.sh
Executable file
73
gitea-deploy.sh
Executable file
@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Gitea / Git deployment runner script for Proxmox LXC container with Docker.
|
||||
#
|
||||
# Usage:
|
||||
# ./gitea-deploy.sh
|
||||
#
|
||||
# Optional environment variables:
|
||||
# REPO_DIR Path to the repository root (default: script directory)
|
||||
# DEPLOY_BRANCH Git branch to deploy (default: main)
|
||||
# COMPOSE_FILE Docker Compose file path (default: docker-compose.yml)
|
||||
# DOCKER_CMD Docker command to use (auto-detected)
|
||||
# GIT_CLEAN If set to 1, clean untracked files before deploy
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="${REPO_DIR:-$SCRIPT_DIR}"
|
||||
DEPLOY_BRANCH="${DEPLOY_BRANCH:-main}"
|
||||
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}"
|
||||
GIT_CLEAN="${GIT_CLEAN:-0}"
|
||||
|
||||
if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
|
||||
DOCKER_COMPOSE_CMD="docker compose"
|
||||
elif command -v docker-compose >/dev/null 2>&1; then
|
||||
DOCKER_COMPOSE_CMD="docker-compose"
|
||||
else
|
||||
echo "ERROR: docker compose command not found. Install Docker Compose or use Docker v20+ with built-in compose."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$REPO_DIR"
|
||||
|
||||
echo "Deploying $REPO_DIR"
|
||||
echo "Branch: $DEPLOY_BRANCH"
|
||||
echo "Compose file: $COMPOSE_FILE"
|
||||
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
echo "ERROR: git is not installed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$COMPOSE_FILE" ]; then
|
||||
echo "ERROR: Compose file '$COMPOSE_FILE' not found in $REPO_DIR."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure repository is on the correct branch and in sync with remote.
|
||||
|
||||
git fetch --all --prune
|
||||
|
||||
git checkout "$DEPLOY_BRANCH"
|
||||
git reset --hard "origin/$DEPLOY_BRANCH"
|
||||
|
||||
if [ "$GIT_CLEAN" = "1" ]; then
|
||||
echo "Cleaning untracked files..."
|
||||
git clean -fdx
|
||||
fi
|
||||
|
||||
# Build and deploy with Docker Compose.
|
||||
|
||||
echo "Stopping existing containers and removing orphans..."
|
||||
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" down --remove-orphans
|
||||
|
||||
echo "Building image..."
|
||||
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" build --no-cache
|
||||
|
||||
echo "Starting containers..."
|
||||
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" up -d
|
||||
|
||||
echo "Deployment finished. Current service status:"
|
||||
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" ps
|
||||
|
||||
exit 0
|
||||
@ -1,3 +1,4 @@
|
||||
streamlit
|
||||
pandas
|
||||
numpy
|
||||
pypdf
|
||||
|
||||
31
tests/test_i18n.py
Normal file
31
tests/test_i18n.py
Normal file
@ -0,0 +1,31 @@
|
||||
import pytest
|
||||
import os
|
||||
import sys
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
from app import translate, display_team_name, COLUMN_LABELS, TRANSLATIONS
|
||||
|
||||
|
||||
def test_display_team_name_localization():
|
||||
assert display_team_name('Germany', 'de') == 'Deutschland'
|
||||
assert display_team_name('Germany', 'en') == 'Germany'
|
||||
assert display_team_name('Spain', 'de') == 'Spain'
|
||||
|
||||
|
||||
def test_dynamic_focus_label_creation():
|
||||
focus = 'Spain'
|
||||
lang = 'en'
|
||||
col_map = COLUMN_LABELS.get(lang, {}).copy()
|
||||
col_map['Focus group place'] = f"{display_team_name(focus, lang)} {translate('group_place_phrase', lang)}"
|
||||
col_map['Focus path'] = f"{display_team_name(focus, lang)} {translate('path_phrase', lang)}"
|
||||
assert col_map['Focus group place'] == 'Spain group place'
|
||||
assert col_map['Focus path'] == 'Spain path'
|
||||
|
||||
|
||||
def test_no_untranslated_deutsch_in_non_de():
|
||||
# ensure no non-de translation contains the German word 'Deutschland'
|
||||
bad = []
|
||||
for key, d in TRANSLATIONS.items():
|
||||
for lang, text in d.items():
|
||||
if lang != 'de' and 'Deutschland' in text:
|
||||
bad.append((key, lang, text))
|
||||
assert not bad, f"Found German 'Deutschland' in non-de translations: {bad}"
|
||||
Loading…
x
Reference in New Issue
Block a user