mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-29 18:16:43 +00:00
fix: preserve i18n arrays and restore command descriptions
This commit is contained in:
@@ -4,10 +4,19 @@ Auto-translate missing keys in AppImage/messages/<locale>/common.json
|
||||
against the English source (AppImage/messages/en/common.json).
|
||||
|
||||
Guardrails:
|
||||
- Keys already translated in a target locale are PRESERVED. A key is
|
||||
considered "already translated" when the target value is non-empty
|
||||
AND differs from the English source. This protects human-curated
|
||||
locales (Vaso73's sk) from being overwritten.
|
||||
- Non-empty string translations are PRESERVED, including values equal
|
||||
to English. --refresh explicitly opts into replacing valid strings.
|
||||
- Arrays, objects and non-string leaves retain their JSON types. Array
|
||||
entries are translated individually, never as serialized Python text.
|
||||
- Preflight all locales; skip locales with existing type mismatches without
|
||||
provider calls or writes, including with --refresh. Valid locales proceed,
|
||||
but any skipped locale makes the run exit 1 (partial failure).
|
||||
The diagnostic identifies the locale
|
||||
and JSON Pointer requiring manual reconciliation. No automatic migration
|
||||
of serialized arrays is attempted: parseability or equal length cannot
|
||||
prove correspondence to current English indices. Recover human text only
|
||||
after checking its original source/order; do not delete it to force refill.
|
||||
Null string leaves remain eligible for filling, as in earlier versions.
|
||||
- `{placeholder}` tokens (next-intl style: `{vmid}`, `{appName}`, etc.)
|
||||
are extracted before translation and restored afterwards, so the
|
||||
interpolation contract stays intact regardless of what the
|
||||
@@ -16,6 +25,13 @@ Guardrails:
|
||||
Vaso73 has curated by hand; auto-translation only fills the keys
|
||||
that are still on the English fallback in sk.
|
||||
|
||||
Workflow limitation: build-i18n-messages.yml runs Commit + push only after
|
||||
successful generation. Exit 1 for skipped locales prevents that step, even
|
||||
when valid locales made local progress. This does not restore automatic
|
||||
publication for the default locale set until mismatches are reconciled or
|
||||
workflow policy is separately changed. Invalid JSON syntax still aborts
|
||||
preflight globally; per-locale skipping covers parsed catalog type mismatches.
|
||||
|
||||
Reuses the same translation providers as build_translation_cache.py so
|
||||
the CI environment (googletrans pinning, AppImage provider) stays
|
||||
identical.
|
||||
@@ -58,36 +74,38 @@ DEFAULT_CONTEXT = "Context: Technical UI text for a Proxmox management dashboard
|
||||
PLACEHOLDER_RE = re.compile(r"\{[A-Za-z_][A-Za-z0-9_]*\}")
|
||||
|
||||
|
||||
def flatten(node: dict, prefix: str = "") -> dict[str, str]:
|
||||
"""Depth-first flatten of a nested dict into ``{"a.b.c": "value"}``.
|
||||
Non-string leaves are coerced to str (should not happen in messages
|
||||
catalogs, but keeps the function total)."""
|
||||
out: dict[str, str] = {}
|
||||
for key, value in node.items():
|
||||
path = f"{prefix}{key}" if not prefix else f"{prefix}.{key}"
|
||||
if isinstance(value, dict):
|
||||
out.update(flatten(value, path))
|
||||
elif value is None:
|
||||
out[path] = ""
|
||||
else:
|
||||
out[path] = str(value)
|
||||
CatalogPath = tuple[str | int, ...]
|
||||
|
||||
|
||||
def flatten(node: object, prefix: CatalogPath = ()) -> dict[CatalogPath, object]:
|
||||
"""Keep typed path segments and empty container markers, without coercion."""
|
||||
if isinstance(node, dict):
|
||||
out = {prefix: {}}
|
||||
children = node.items()
|
||||
elif isinstance(node, list):
|
||||
out = {prefix: []}
|
||||
children = enumerate(node)
|
||||
else:
|
||||
return {prefix: node}
|
||||
for key, value in children:
|
||||
out.update(flatten(value, prefix + (key,)))
|
||||
return out
|
||||
|
||||
|
||||
def unflatten(flat: dict[str, str]) -> dict:
|
||||
"""Inverse of ``flatten``: rebuild nested structure from dotted keys."""
|
||||
out: dict = {}
|
||||
for path, value in flat.items():
|
||||
parts = path.split(".")
|
||||
cursor = out
|
||||
for part in parts[:-1]:
|
||||
existing = cursor.get(part)
|
||||
if not isinstance(existing, dict):
|
||||
existing = {}
|
||||
cursor[part] = existing
|
||||
cursor = existing
|
||||
cursor[parts[-1]] = value
|
||||
return out
|
||||
def unflatten(flat: dict[CatalogPath, object]) -> object:
|
||||
"""Rebuild a tree from typed paths; parents precede children by depth."""
|
||||
nodes = {}
|
||||
for path in sorted(flat, key=len):
|
||||
value = flat[path]
|
||||
nodes[path] = {} if isinstance(value, dict) else [] if isinstance(value, list) else value
|
||||
if path:
|
||||
parent = nodes[path[:-1]]
|
||||
key = path[-1]
|
||||
if isinstance(parent, list):
|
||||
while len(parent) <= key:
|
||||
parent.append(None)
|
||||
parent[key] = nodes[path]
|
||||
return nodes[()]
|
||||
|
||||
|
||||
def protect_placeholders(text: str) -> tuple[str, list[str]]:
|
||||
@@ -240,11 +258,38 @@ def main() -> int:
|
||||
print(f"Provider: {args.provider}", flush=True)
|
||||
print(f"Sleep between calls: {args.sleep}s", flush=True)
|
||||
|
||||
total_failures: list[tuple[str, str, str]] = []
|
||||
total_failures: list[tuple[str, CatalogPath, str]] = []
|
||||
|
||||
# Preflight every locale before any provider call or write. Serialized
|
||||
# arrays have no trustworthy index correspondence, even if JSON parses.
|
||||
# Never guess, evaluate Python repr, or discard human text on --refresh.
|
||||
targets = {}
|
||||
skipped = set()
|
||||
for lang in languages:
|
||||
locale_path = messages_dir / lang / "common.json"
|
||||
target_flat = flatten(read_json(locale_path))
|
||||
targets[lang] = target_flat
|
||||
for key, en_value in en_flat.items():
|
||||
if key not in target_flat:
|
||||
continue
|
||||
existing = target_flat[key]
|
||||
if isinstance(en_value, str) and existing is None:
|
||||
continue # Historical null string leaves are treated as empty.
|
||||
if type(existing) is not type(en_value):
|
||||
pointer = "".join("/" + str(part).replace("~", "~0").replace("/", "~1") for part in key)
|
||||
location = f"JSON Pointer {json.dumps(pointer)}" + (" (root)" if not key else "")
|
||||
print(
|
||||
f"{locale_path}: {location}: expected {type(en_value).__name__}, "
|
||||
f"found {type(existing).__name__}; manual reconciliation required. "
|
||||
"Locale skipped; its catalog will not be written.", file=sys.stderr,
|
||||
)
|
||||
skipped.add(lang)
|
||||
|
||||
for lang in languages:
|
||||
if lang in skipped:
|
||||
continue
|
||||
locale_path = messages_dir / lang / "common.json"
|
||||
target_flat = targets[lang]
|
||||
|
||||
# Decide what needs translating. Same rule as
|
||||
# build_translation_cache.py: only touch keys whose target
|
||||
@@ -263,9 +308,19 @@ def main() -> int:
|
||||
# steady-state, not a bug.
|
||||
# When someone genuinely wants to redo everything, --refresh
|
||||
# is still available (and is destructive by design).
|
||||
missing: list[str] = []
|
||||
# Seed structural nodes and non-text leaves before any checkpoint.
|
||||
# Only strings are sent to a translation provider.
|
||||
original_flat = target_flat.copy()
|
||||
for key, en_value in en_flat.items():
|
||||
if not en_value:
|
||||
if not isinstance(en_value, str) or not en_value:
|
||||
target_flat.setdefault(key, en_value)
|
||||
elif key and isinstance(key[-1], int):
|
||||
# Null keeps an untranslated array position addressable while
|
||||
# allowing the runtime English fallback and a later retry.
|
||||
target_flat.setdefault(key, None)
|
||||
missing: list[CatalogPath] = []
|
||||
for key, en_value in en_flat.items():
|
||||
if not isinstance(en_value, str) or not en_value:
|
||||
continue
|
||||
existing = target_flat.get(key, "")
|
||||
if args.refresh or not existing:
|
||||
@@ -276,10 +331,12 @@ def main() -> int:
|
||||
|
||||
print(f"\n=== {lang}: {len(missing)} keys to translate ===", flush=True)
|
||||
if not missing:
|
||||
print(f" {lang}: nothing to do", flush=True)
|
||||
if target_flat != original_flat:
|
||||
write_json(locale_path, unflatten(target_flat))
|
||||
print(f" {lang}: nothing to translate", flush=True)
|
||||
continue
|
||||
|
||||
failures_for_lang: list[tuple[str, str, str]] = []
|
||||
failures_for_lang: list[tuple[str, CatalogPath, str]] = []
|
||||
|
||||
for index, key in enumerate(missing, start=1):
|
||||
en_value = en_flat[key]
|
||||
@@ -336,7 +393,18 @@ def main() -> int:
|
||||
# Previously we returned 2, which failed the whole run and
|
||||
# discarded 36 out of 38 successful translations because
|
||||
# 2 googletrans timeouts hit sv at the start of the burst.
|
||||
return 0
|
||||
if not skipped:
|
||||
return 0
|
||||
|
||||
if skipped:
|
||||
names = ', '.join(dict.fromkeys(lang for lang in languages if lang in skipped))
|
||||
print(
|
||||
f"\nPartial failure: skipped {len(skipped)} locale(s): {names}. "
|
||||
"Valid locales processed; skipped catalogs unchanged. "
|
||||
"Manual reconciliation required; exiting 1.",
|
||||
file=sys.stderr, flush=True,
|
||||
)
|
||||
return 1
|
||||
|
||||
print("\ni18n messages generated successfully.", flush=True)
|
||||
return 0
|
||||
|
||||
@@ -0,0 +1,558 @@
|
||||
{
|
||||
"pin": "dede876f0b92f8a571325e96ed7591b30a85368e",
|
||||
"fallback_indices": [
|
||||
9,
|
||||
10,
|
||||
19,
|
||||
20,
|
||||
21,
|
||||
22,
|
||||
31,
|
||||
32,
|
||||
33,
|
||||
34,
|
||||
43,
|
||||
44,
|
||||
45,
|
||||
58,
|
||||
59
|
||||
],
|
||||
"locales": {
|
||||
"es": [
|
||||
"Listar todos los nodos Proxmox",
|
||||
"Listar VM en un nodo",
|
||||
"Listar contenedores LXC en un nodo",
|
||||
"Listar almacenamiento en un nodo",
|
||||
"Listar interfaces de red",
|
||||
"Listar todas las máquinas virtuales QEMU/KVM",
|
||||
"Iniciar una máquina virtual",
|
||||
"Detener una máquina virtual",
|
||||
"Apagar una máquina virtual de forma segura",
|
||||
"Mostrar estado de VM",
|
||||
"Mostrar configuración de VM",
|
||||
"Crear una instantánea de VM",
|
||||
"Listar todos los contenedores LXC",
|
||||
"Iniciar un contenedor LXC",
|
||||
"Detener un contenedor LXC",
|
||||
"Abrir una consola de contenedor LXC",
|
||||
"Mostrar configuración del contenedor",
|
||||
"Mostrar estado de almacenamiento",
|
||||
"Enumerar el contenido del almacenamiento",
|
||||
"Probar el rendimiento del sistema Proxmox",
|
||||
"Mostrar la versión de Proxmox VE",
|
||||
"Verificar el estado del clúster",
|
||||
"Mostrar el estado del clúster",
|
||||
"Enumerar los nodos del clúster",
|
||||
"Mostrar el estado del grupo ZFS",
|
||||
"Enumerar todos los grupos ZFS",
|
||||
"Enumerar todo Conjuntos de datos ZFS",
|
||||
"Enumerar todos los archivos con detalles",
|
||||
"Cambiar directorio",
|
||||
"Crear un directorio",
|
||||
"Eliminar un directorio de forma recursiva",
|
||||
"Copiar archivos o directorios",
|
||||
"Mover o cambiar el nombre de archivos",
|
||||
"Mostrar el contenido del archivo",
|
||||
"Buscar un patrón en un archivo",
|
||||
"Buscar archivos por nombre",
|
||||
"Cambiar permisos de archivos",
|
||||
"Cambiar el propietario del archivo",
|
||||
"Extraer un archivo tar.gz",
|
||||
"Crear un tar.gz archivo",
|
||||
"Mostrar uso del disco",
|
||||
"Mostrar tamaños de directorio",
|
||||
"Mostrar uso de memoria",
|
||||
"Mostrar procesos en ejecución",
|
||||
"Buscar un proceso en ejecución",
|
||||
"Forzar la eliminación de un proceso",
|
||||
"Verificar el estado del servicio",
|
||||
"Iniciar un servicio",
|
||||
"Detener un servicio",
|
||||
"Reiniciar un servicio",
|
||||
"Actualizar paquetes de Debian/Ubuntu",
|
||||
"Instalar un paquete de Debian/Ubuntu",
|
||||
"Eliminar un paquete",
|
||||
"Listar contenedores en ejecución",
|
||||
"Listar Docker imágenes",
|
||||
"Abrir un shell en un contenedor",
|
||||
"Mostrar direcciones IP",
|
||||
"Probar conectividad de red",
|
||||
"Obtener encabezados HTTP",
|
||||
"Descargar un archivo",
|
||||
"Conectarse a través de SSH",
|
||||
"Copiar un archivo a través de SSH",
|
||||
"Seguir un archivo de registro en tiempo real",
|
||||
"Mostrar historial de comandos",
|
||||
"Borrar la pantalla del terminal"
|
||||
],
|
||||
"de": [
|
||||
"Alle Proxmox-Knoten auflisten",
|
||||
"VMs auf einem Knoten auflisten",
|
||||
"LXC-Container auf einem Knoten auflisten",
|
||||
"Speicher auf einem Knoten auflisten",
|
||||
"Netzwerkschnittstellen auflisten",
|
||||
"Alle virtuellen QEMU/KVM-Maschinen auflisten",
|
||||
"Eine virtuelle Maschine starten",
|
||||
"Eine virtuelle Maschine stoppen",
|
||||
"Eine virtuelle Maschine sicher herunterfahren",
|
||||
"VM-Status anzeigen",
|
||||
"VM-Konfiguration anzeigen",
|
||||
"Einen VM-Snapshot erstellen",
|
||||
"Alle LXC-Container auflisten",
|
||||
"Einen LXC-Container starten",
|
||||
"Einen LXC-Container stoppen",
|
||||
"Eine LXC-Containerkonsole öffnen",
|
||||
"Containerkonfiguration anzeigen",
|
||||
"Speicherstatus anzeigen",
|
||||
"Speicherinhalte auflisten",
|
||||
"Proxmox-Systemleistung testen",
|
||||
"Proxmox VE-Version anzeigen",
|
||||
"Clusterstatus prüfen",
|
||||
"Clusterstatus anzeigen",
|
||||
"Clusterknoten auflisten",
|
||||
"ZFS-Poolstatus anzeigen",
|
||||
"Alle ZFS-Pools auflisten",
|
||||
"Alle ZFS-Datensätze auflisten",
|
||||
"Alle Dateien mit Details auflisten",
|
||||
"Verzeichnis ändern",
|
||||
"Verzeichnis erstellen",
|
||||
"Verzeichnis rekursiv entfernen",
|
||||
"Dateien oder Verzeichnisse kopieren",
|
||||
"Dateien verschieben oder umbenennen",
|
||||
"Dateiinhalte anzeigen",
|
||||
"Nach einem Muster in einer Datei suchen",
|
||||
"Dateien nach Namen suchen",
|
||||
"Dateiberechtigungen ändern",
|
||||
"Dateieigentümer ändern",
|
||||
"Tar.gz-Archiv extrahieren",
|
||||
"Tar.gz-Archiv erstellen",
|
||||
"Festplattennutzung anzeigen",
|
||||
"Verzeichnisgrößen anzeigen",
|
||||
"Speichernutzung anzeigen",
|
||||
"Laufende Prozesse anzeigen",
|
||||
"Laufenden Prozess suchen",
|
||||
"Prozess beenden",
|
||||
"Dienststatus prüfen",
|
||||
"Dienst starten",
|
||||
"Dienst stoppen",
|
||||
"Dienst neu starten",
|
||||
"Debian/Ubuntu-Pakete aktualisieren",
|
||||
"Debian/Ubuntu-Paket installieren",
|
||||
"Paket entfernen",
|
||||
"Laufende Container auflisten",
|
||||
"Docker-Images auflisten",
|
||||
"Eine Shell in einem Container öffnen",
|
||||
"IP-Adressen anzeigen",
|
||||
"Netzwerkkonnektivität testen",
|
||||
"HTTP-Header abrufen",
|
||||
"Eine Datei herunterladen",
|
||||
"Über SSH verbinden",
|
||||
"Eine Datei über SSH kopieren",
|
||||
"Eine Protokolldatei in Echtzeit verfolgen",
|
||||
"Befehlsverlauf anzeigen",
|
||||
"Terminalbildschirm löschen"
|
||||
],
|
||||
"fr": [
|
||||
"Liste de tous les nœuds Proxmox",
|
||||
"Liste des machines virtuelles sur un nœud",
|
||||
"Liste des conteneurs LXC sur un nœud",
|
||||
"Liste du stockage sur un nœud",
|
||||
"Liste des interfaces réseau",
|
||||
"Liste de toutes les machines virtuelles QEMU/KVM",
|
||||
"Démarrer une machine virtuelle",
|
||||
"Arrêter une machine virtuelle",
|
||||
"Arrêter une machine virtuelle en toute sécurité",
|
||||
"Afficher l'état de la VM",
|
||||
"Afficher la VM configuration",
|
||||
"Créer un instantané de VM",
|
||||
"Liste de tous les conteneurs LXC",
|
||||
"Démarrer un conteneur LXC",
|
||||
"Arrêter un conteneur LXC",
|
||||
"Ouvrir une console de conteneur LXC",
|
||||
"Afficher la configuration du conteneur",
|
||||
"Afficher l'état du stockage",
|
||||
"Liste du contenu du stockage",
|
||||
"Tester les performances du système Proxmox",
|
||||
"Afficher la version Proxmox VE",
|
||||
"Vérifier l'état du cluster",
|
||||
"Afficher l'état du cluster",
|
||||
"Liste des nœuds du cluster",
|
||||
"Afficher l'état du pool ZFS",
|
||||
"Liste de tous les pools ZFS",
|
||||
"Liste de tous les ensembles de données ZFS",
|
||||
"Liste de tous les fichiers avec détails",
|
||||
"Changer de répertoire",
|
||||
"Créer un répertoire",
|
||||
"Supprimer un répertoire de manière récursive",
|
||||
"Copier des fichiers ou des répertoires",
|
||||
"Déplacer ou renommer des fichiers",
|
||||
"Afficher le contenu du fichier",
|
||||
"Rechercher un modèle dans un fichier",
|
||||
"Rechercher des fichiers par nom",
|
||||
"Modifier les autorisations du fichier",
|
||||
"Modifier le propriétaire du fichier",
|
||||
"Extraire une archive tar.gz",
|
||||
"Créer une archive tar.gz",
|
||||
"Afficher l'utilisation du disque",
|
||||
"Afficher la taille des répertoires",
|
||||
"Afficher l'utilisation de la mémoire",
|
||||
"Afficher les processus en cours d'exécution",
|
||||
"Trouver un processus en cours d'exécution",
|
||||
"Forcer l'arrêt d'un processus",
|
||||
"Vérifier le service\". statut",
|
||||
"Démarrer un service",
|
||||
"Arrêter un service",
|
||||
"Redémarrer un service",
|
||||
"Mettre à jour les packages Debian/Ubuntu",
|
||||
"Installer un package Debian/Ubuntu",
|
||||
"Supprimer un package",
|
||||
"Liste des conteneurs en cours d'exécution",
|
||||
"Liste des images Docker",
|
||||
"Ouvrir un shell dans un conteneur",
|
||||
"Afficher les adresses IP",
|
||||
"Tester la connectivité réseau",
|
||||
"Obtenir les en-têtes HTTP",
|
||||
"Télécharger un fichier",
|
||||
"Connecter via SSH",
|
||||
"Copier un fichier via SSH",
|
||||
"Suivre un fichier journal en temps réel",
|
||||
"Afficher l'historique des commandes",
|
||||
"Effacer l'écran du terminal"
|
||||
],
|
||||
"it": [
|
||||
"Elenca tutti i nodi Proxmox",
|
||||
"Elenca VM su un nodo",
|
||||
"Elenca contenitori LXC su un nodo",
|
||||
"Elenca spazio di archiviazione su un nodo",
|
||||
"Elenca interfacce di rete",
|
||||
"Elenca tutte le macchine virtuali QEMU/KVM",
|
||||
"Avvia una macchina virtuale",
|
||||
"Arresta una macchina virtuale",
|
||||
"Spegni una macchina virtuale in modo sicuro",
|
||||
"Mostra stato VM",
|
||||
"Mostra configurazione VM",
|
||||
"Crea un'istantanea VM",
|
||||
"Elenca tutti i contenitori LXC",
|
||||
"Avvia un contenitore LXC",
|
||||
"Arresta un contenitore LXC",
|
||||
"Apri una console del contenitore LXC",
|
||||
"Mostra configurazione contenitore",
|
||||
"Mostra stato archiviazione",
|
||||
"Elenca contenuto archiviazione",
|
||||
"Verifica prestazioni del sistema Proxmox",
|
||||
"Mostra versione Proxmox VE",
|
||||
"Verifica stato cluster",
|
||||
"Mostra stato cluster",
|
||||
"Elenco nodi cluster",
|
||||
"Mostra stato pool ZFS",
|
||||
"Elenca tutti i pool ZFS",
|
||||
"Elenca tutti i set di dati ZFS",
|
||||
"Elenca tutti i file con dettagli",
|
||||
"Cambia directory",
|
||||
"Crea una directory",
|
||||
"Rimuovi una directory in modo ricorsivo",
|
||||
"Copia file o directory",
|
||||
"Sposta o rinomina file",
|
||||
"Visualizza contenuto file",
|
||||
"Cerca un modello in un file",
|
||||
"Trova file per nome",
|
||||
"Cambia permessi file",
|
||||
"Cambia proprietario del file",
|
||||
"Estrai un archivio tar.gz",
|
||||
"Crea un archivio tar.gz",
|
||||
"Mostra utilizzo disco",
|
||||
"Mostra dimensioni directory",
|
||||
"Mostra utilizzo memoria",
|
||||
"Mostra processi in esecuzione",
|
||||
"Trova un processo in esecuzione",
|
||||
"Termina forzatamente un processo",
|
||||
"Controlla stato del servizio",
|
||||
"Avvia un servizio",
|
||||
"Interrompi un servizio",
|
||||
"Riavvia un servizio",
|
||||
"Aggiorna pacchetti Debian/Ubuntu",
|
||||
"Installa un pacchetto Debian/Ubuntu",
|
||||
"Rimuovi un pacchetto",
|
||||
"Elenca contenitori in esecuzione",
|
||||
"Elenca immagini Docker",
|
||||
"Apri una shell in un contenitore",
|
||||
"Mostra indirizzi IP",
|
||||
"Verifica connettività di rete",
|
||||
"Ottieni intestazioni HTTP",
|
||||
"Scarica un file",
|
||||
"Connetti tramite SSH",
|
||||
"Copia un file tramite SSH",
|
||||
"Segui un file di registro in tempo reale",
|
||||
"Mostra cronologia comandi",
|
||||
"Cancella lo schermo del terminale"
|
||||
],
|
||||
"pt": [
|
||||
"Listar todos os nós Proxmox",
|
||||
"Listar VMs em um nó",
|
||||
"Listar contêineres LXC em um nó",
|
||||
"Listar armazenamento em um nó",
|
||||
"Listar interfaces de rede",
|
||||
"Listar todas as máquinas virtuais QEMU/KVM",
|
||||
"Iniciar uma máquina virtual",
|
||||
"Parar uma máquina virtual",
|
||||
"Desligar uma máquina virtual com segurança",
|
||||
"Mostrar status da VM",
|
||||
"Mostrar configuração da VM",
|
||||
"Criar um instantâneo de VM",
|
||||
"Listar todos os LXC containers",
|
||||
"Iniciar um contêiner LXC",
|
||||
"Parar um contêiner LXC",
|
||||
"Abrir um console de contêiner LXC",
|
||||
"Mostrar configuração do contêiner",
|
||||
"Mostrar status de armazenamento",
|
||||
"Listar conteúdo de armazenamento",
|
||||
"Testar desempenho do sistema Proxmox",
|
||||
"Mostrar a versão Proxmox VE",
|
||||
"Verificar status do cluster",
|
||||
"Mostrar status do cluster",
|
||||
"Listar nós do cluster",
|
||||
"Mostrar status do pool ZFS",
|
||||
"Listar todos os pools ZFS",
|
||||
"Listar todos os conjuntos de dados ZFS",
|
||||
"Listar todos os arquivos com detalhes",
|
||||
"Alterar diretório",
|
||||
"Criar um diretório",
|
||||
"Remover um diretório recursivamente",
|
||||
"Copiar arquivos ou diretórios",
|
||||
"Mover ou renomear arquivos",
|
||||
"Exibir conteúdo do arquivo",
|
||||
"Pesquisar um padrão em um arquivo",
|
||||
"Encontrar arquivos por nome",
|
||||
"Alterar permissões de arquivo",
|
||||
"Alterar o proprietário do arquivo",
|
||||
"Extrair um arquivo tar.gz",
|
||||
"Criar um arquivo tar.gz",
|
||||
"Mostrar uso de disco",
|
||||
"Mostrar tamanhos de diretório",
|
||||
"Mostrar uso de memória",
|
||||
"Mostrar processos em execução",
|
||||
"Encontrar um processo em execução",
|
||||
"Forçar a eliminação de um processo",
|
||||
"Verificar status do serviço",
|
||||
"Iniciar um serviço",
|
||||
"Parar um serviço",
|
||||
"Reiniciar um serviço",
|
||||
"Atualizar pacotes Debian/Ubuntu",
|
||||
"Instalar um pacote Debian/Ubuntu",
|
||||
"Remover um pacote",
|
||||
"Listar contêineres em execução",
|
||||
"Listar imagens do Docker",
|
||||
"Abrir um shell em um contêiner",
|
||||
"Mostrar endereços IP",
|
||||
"Testar conectividade de rede",
|
||||
"Obter cabeçalhos HTTP",
|
||||
"Baixar um arquivo",
|
||||
"Conectar através de SSH",
|
||||
"Copiar um arquivo através de SSH",
|
||||
"Seguir um arquivo de log em tempo real",
|
||||
"Mostrar histórico de comandos",
|
||||
"Limpar a tela do terminal"
|
||||
],
|
||||
"sv": [
|
||||
"Lista alla Proxmox-noder",
|
||||
"Lista virtuella datorer på en nod",
|
||||
"Lista LXC-behållare på en nod",
|
||||
"Lista lagringsutrymme på en nod",
|
||||
"Lista nätverksgränssnitt",
|
||||
"Lista alla virtuella QEMU/KVM-maskiner",
|
||||
"Starta en virtuell maskin",
|
||||
"Stoppa en virtuell maskin",
|
||||
"Stäng av en virtuell maskin på ett säkert sätt",
|
||||
"Show VM status",
|
||||
"Show VM configuration",
|
||||
"Skapa en VM Snapshot",
|
||||
"Lista alla LXC-behållare",
|
||||
"Starta en LXC-behållare",
|
||||
"Stoppa en LXC-behållare",
|
||||
"Öppna en LXC-containerkonsol",
|
||||
"Visa behållarkonfiguration",
|
||||
"Visa lagringsstatus",
|
||||
"Lista lagringsinnehåll",
|
||||
"Test Proxmox system performance",
|
||||
"Show the Proxmox VE version",
|
||||
"Check cluster status",
|
||||
"Show cluster status",
|
||||
"Lista klusternoder",
|
||||
"Visa ZFS-poolstatus",
|
||||
"Lista alla ZFS-pooler",
|
||||
"Lista alla ZFS-datauppsättningar",
|
||||
"Lista alla filer med detaljer",
|
||||
"Ändra katalog",
|
||||
"Skapa en katalog",
|
||||
"Ta bort en katalog rekursivt",
|
||||
"Copy files or directories",
|
||||
"Move or rename files",
|
||||
"Display file contents",
|
||||
"Search for a pattern in a file",
|
||||
"Hitta filer efter namn",
|
||||
"Ändra filbehörigheter",
|
||||
"Ändra filägare",
|
||||
"Extrahera ett tar.gz-arkiv",
|
||||
"Skapa ett tar.gz-arkiv",
|
||||
"Visa diskanvändning",
|
||||
"Visa katalogstorlekar",
|
||||
"Visa minnesanvändning",
|
||||
"Show running processes",
|
||||
"Find a running process",
|
||||
"Force-kill a process",
|
||||
"Kontrollera tjänsten status",
|
||||
"Starta en tjänst",
|
||||
"Stoppa en tjänst",
|
||||
"Starta om en tjänst",
|
||||
"Uppdatera Debian/Ubuntu-paket",
|
||||
"Installera ett Debian/Ubuntu-paket",
|
||||
"Ta bort ett paket",
|
||||
"Lista behållare som körs",
|
||||
"Lista Docker-avbildningar",
|
||||
"Öppna ett skal i en behållare",
|
||||
"Visa IP-adresser",
|
||||
"Testa en nätverksanslutning",
|
||||
"Get HTTP headers",
|
||||
"Download a file",
|
||||
"Anslut via SSH",
|
||||
"Kopiera en fil via SSH",
|
||||
"Följ en loggfil i realtid",
|
||||
"Visa kommandohistorik",
|
||||
"Rensa terminalskärmen"
|
||||
]
|
||||
},
|
||||
"english": [
|
||||
"List all Proxmox nodes",
|
||||
"List VMs on a node",
|
||||
"List LXC containers on a node",
|
||||
"List storage on a node",
|
||||
"List network interfaces",
|
||||
"List all QEMU/KVM virtual machines",
|
||||
"Start a virtual machine",
|
||||
"Stop a virtual machine",
|
||||
"Shut down a virtual machine safely",
|
||||
"Show VM status",
|
||||
"Show VM configuration",
|
||||
"Create a VM Snapshot",
|
||||
"List all LXC containers",
|
||||
"Start an LXC container",
|
||||
"Stop an LXC container",
|
||||
"Open an LXC container console",
|
||||
"Show container configuration",
|
||||
"Show storage status",
|
||||
"List storage contents",
|
||||
"Test Proxmox system performance",
|
||||
"Show the Proxmox VE version",
|
||||
"Check cluster status",
|
||||
"Show cluster status",
|
||||
"List cluster nodes",
|
||||
"Show ZFS pool status",
|
||||
"List all ZFS pools",
|
||||
"List all ZFS datasets",
|
||||
"List all files with details",
|
||||
"Change directory",
|
||||
"Create a directory",
|
||||
"Remove a directory recursively",
|
||||
"Copy files or directories",
|
||||
"Move or rename files",
|
||||
"Display file contents",
|
||||
"Search for a pattern in a file",
|
||||
"Find files by name",
|
||||
"Change file permissions",
|
||||
"Change the file owner",
|
||||
"Extract a tar.gz archive",
|
||||
"Create a tar.gz archive",
|
||||
"Show disk usage",
|
||||
"Show directory sizes",
|
||||
"Show memory usage",
|
||||
"Show running processes",
|
||||
"Find a running process",
|
||||
"Force-kill a process",
|
||||
"Check service status",
|
||||
"Start a service",
|
||||
"Stop a service",
|
||||
"Restart a service",
|
||||
"Update Debian/Ubuntu packages",
|
||||
"Install a Debian/Ubuntu package",
|
||||
"Remove a package",
|
||||
"List running containers",
|
||||
"List Docker images",
|
||||
"Open a shell in a container",
|
||||
"Show IP addresses",
|
||||
"Test network connectivity",
|
||||
"Get HTTP headers",
|
||||
"Download a file",
|
||||
"Connect through SSH",
|
||||
"Copy a file through SSH",
|
||||
"Follow a log file in real time",
|
||||
"Show command history",
|
||||
"Clear the terminal screen"
|
||||
],
|
||||
"commands": [
|
||||
"pvesh get /nodes",
|
||||
"pvesh get /nodes/{node}/qemu",
|
||||
"pvesh get /nodes/{node}/lxc",
|
||||
"pvesh get /nodes/{node}/storage",
|
||||
"pvesh get /nodes/{node}/network",
|
||||
"qm list",
|
||||
"qm start <vmid>",
|
||||
"qm stop <vmid>",
|
||||
"qm shutdown <vmid>",
|
||||
"qm status <vmid>",
|
||||
"qm config <vmid>",
|
||||
"qm snapshot <vmid> <snapname>",
|
||||
"pct list",
|
||||
"pct start <vmid>",
|
||||
"pct stop <vmid>",
|
||||
"pct enter <vmid>",
|
||||
"pct config <vmid>",
|
||||
"pvesm status",
|
||||
"pvesm list <storage>",
|
||||
"pveperf",
|
||||
"pveversion",
|
||||
"systemctl status pve-cluster",
|
||||
"pvecm status",
|
||||
"pvecm nodes",
|
||||
"zpool status",
|
||||
"zpool list",
|
||||
"zfs list",
|
||||
"ls -la",
|
||||
"cd /path/to/dir",
|
||||
"mkdir dirname",
|
||||
"rm -rf dirname",
|
||||
"cp source dest",
|
||||
"mv source dest",
|
||||
"cat filename",
|
||||
"grep 'pattern' file",
|
||||
"find . -name 'file'",
|
||||
"chmod 755 file",
|
||||
"chown user:group file",
|
||||
"tar -xzf file.tar.gz",
|
||||
"tar -czf archive.tar.gz dir/",
|
||||
"df -h",
|
||||
"du -sh *",
|
||||
"free -h",
|
||||
"top",
|
||||
"ps aux | grep process",
|
||||
"kill -9 PID",
|
||||
"systemctl status service",
|
||||
"systemctl start service",
|
||||
"systemctl stop service",
|
||||
"systemctl restart service",
|
||||
"apt update && apt upgrade",
|
||||
"apt install package",
|
||||
"apt remove package",
|
||||
"docker ps",
|
||||
"docker images",
|
||||
"docker exec -it container bash",
|
||||
"ip addr show",
|
||||
"ping host",
|
||||
"curl -I url",
|
||||
"wget url",
|
||||
"ssh user@host",
|
||||
"scp file user@host:/path",
|
||||
"tail -f /var/log/syslog",
|
||||
"history",
|
||||
"clear"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Offline regression tests for the Monitor catalog generator."""
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from contextlib import redirect_stdout, redirect_stderr
|
||||
|
||||
SCRIPT = Path(__file__).parents[1] / "build_i18n_messages.py"
|
||||
SPEC = importlib.util.spec_from_file_location("build_i18n_messages", SCRIPT)
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
class I18nMessagesTests(unittest.TestCase):
|
||||
def run_generator(self, source, target, *options, provider=None):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
en = root / "en" / "common.json"
|
||||
it = root / "it" / "common.json"
|
||||
en.parent.mkdir()
|
||||
it.parent.mkdir()
|
||||
en.write_text(json.dumps(source), encoding="utf-8")
|
||||
it.write_text(json.dumps(target), encoding="utf-8")
|
||||
before = it.read_bytes()
|
||||
argv = [str(SCRIPT), "--source", str(en), "--messages-dir", str(root),
|
||||
"--languages", "it", "--sleep", "0", *options]
|
||||
output = io.StringIO()
|
||||
with mock.patch.object(sys, "argv", argv), mock.patch.object(
|
||||
MODULE, "translate_one", side_effect=provider or (lambda text, *args: "IT " + text)
|
||||
) as translate, redirect_stdout(output), redirect_stderr(output):
|
||||
result = MODULE.main()
|
||||
return result, json.loads(it.read_text()), translate.call_args_list, output.getvalue(), before == it.read_bytes()
|
||||
|
||||
def test_generator_fills_array_leaves_preserving_existing_values(self):
|
||||
source = {"items": ["One", {"title": "Two", "children": ["Three"]}],
|
||||
"empty": [], "object": {}, "enabled": True, "count": 2, "none": None}
|
||||
target = {"items": ["Uno"], "extra": {"0": "Keep"}}
|
||||
result, actual, calls, _, _ = self.run_generator(source, target, "--save-every", "1")
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(actual, {**source, "items": ["Uno", {"title": "IT Two", "children": ["IT Three"]}],
|
||||
"extra": {"0": "Keep"}})
|
||||
self.assertEqual([call.args[0] for call in calls], ["Two", "Three"])
|
||||
|
||||
def test_wrong_types_defer_locale_without_calls_or_writes_even_on_refresh(self):
|
||||
source = {"new": "New", "terminal": {"commandDescriptions": ["One", "Two"]}}
|
||||
for malformed in ["['Uno', 'Due']", '["Uno", "Due"]', "ambiguous human text", {}, None, 7]:
|
||||
for options in [(), ("--refresh",)]:
|
||||
with self.subTest(malformed=malformed, options=options):
|
||||
target = {"terminal": {"commandDescriptions": malformed}, "human": "Keep"}
|
||||
result, actual, calls, output, unchanged = self.run_generator(source, target, *options)
|
||||
self.assertEqual(result, 1)
|
||||
self.assertEqual(actual, target)
|
||||
self.assertTrue(unchanged)
|
||||
self.assertEqual(calls, [])
|
||||
self.assertIn("/terminal/commandDescriptions", output)
|
||||
self.assertIn("manual", output)
|
||||
|
||||
def test_structure_only_catalog_is_written_without_translation(self):
|
||||
source = {"items": ["", [], {}, None, False, 0]}
|
||||
result, actual, calls, _, _ = self.run_generator(source, {})
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(actual, source)
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_limit_keeps_array_positions_as_null_fallbacks(self):
|
||||
result, actual, calls, _, _ = self.run_generator(
|
||||
{"items": ["One", "", "Three", "Four"]}, {}, "--limit", "1")
|
||||
self.assertEqual(actual, {"items": ["IT One", "", None, None]})
|
||||
self.assertEqual(len(calls), 1)
|
||||
|
||||
def test_existing_nonempty_including_english_and_stale_text_is_unchanged(self):
|
||||
source = {"items": ["Changed English", "CPU", "Three"]}
|
||||
target = {"items": ["Human translation", "CPU", "Tre", "Extra"]}
|
||||
result, actual, calls, _, unchanged = self.run_generator(source, target)
|
||||
self.assertEqual(actual, target)
|
||||
self.assertEqual(calls, [])
|
||||
self.assertTrue(unchanged)
|
||||
|
||||
def test_second_run_retries_null_placeholders_preserving_translations(self):
|
||||
source = {"items": ["One", "Two", "", "Three", "Four"]}
|
||||
target = {"items": ["Human"], "extra": "Keep"}
|
||||
result, first, calls, _, _ = self.run_generator(source, target, "--limit", "1")
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(first, {"items": ["Human", "IT Two", "", None, None], "extra": "Keep"})
|
||||
self.assertEqual([call.args[0] for call in calls], ["Two"])
|
||||
result, second, calls, _, _ = self.run_generator(source, first, "--save-every", "1")
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(second, {"items": ["Human", "IT Two", "", "IT Three", "IT Four"], "extra": "Keep"})
|
||||
self.assertEqual([call.args[0] for call in calls], ["Three", "Four"])
|
||||
|
||||
def test_refresh_and_provider_failure_keep_existing_fallback_semantics(self):
|
||||
def provider(text, *args):
|
||||
if text == "Two":
|
||||
raise RuntimeError("offline fixture failure")
|
||||
return "IT " + text
|
||||
result, actual, calls, output, _ = self.run_generator(
|
||||
{"items": ["One", "Two", "Three"]}, {"items": ["Uno", "Due"]},
|
||||
"--refresh", "--save-every", "1", provider=provider)
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(actual, {"items": ["IT One", "Due", "IT Three"]})
|
||||
self.assertEqual(len(calls), 3)
|
||||
self.assertIn("1 translation failures", output)
|
||||
|
||||
def test_null_and_empty_string_are_filled_and_tokens_restored(self):
|
||||
source = {"items": ["Hello {vmid}", "Use Proxmox"]}
|
||||
result, actual, calls, _, _ = self.run_generator(source, {"items": [None, ""]})
|
||||
self.assertEqual(actual, {"items": ["IT Hello {vmid}", "IT Use Proxmox"]})
|
||||
self.assertNotIn("{vmid}", calls[0].args[0])
|
||||
self.assertNotIn("Proxmox", calls[1].args[0])
|
||||
|
||||
def test_root_pointer_is_empty_not_empty_key_pointer(self):
|
||||
for source, target, pointer in [({"a": "Text"}, [], 'JSON Pointer "" (root)'),
|
||||
({"": []}, {"": "Text"}, 'JSON Pointer "/"'),
|
||||
({"a~/": []}, {"a~/": "Text"}, 'JSON Pointer "/a~0~1"')]:
|
||||
with self.subTest(pointer=pointer):
|
||||
result, _, calls, output, unchanged = self.run_generator(source, target)
|
||||
self.assertEqual(result, 1)
|
||||
self.assertEqual(calls, [])
|
||||
self.assertTrue(unchanged)
|
||||
self.assertIn(pointer + ": expected", output)
|
||||
|
||||
def test_wrong_scalar_and_nested_types_are_rejected(self):
|
||||
for source, target in [({"a": "Text"}, {"a": 42}),
|
||||
({"a": [{"b": "Text"}]}, {"a": ["human"]}),
|
||||
({"a": False}, {"a": 0}),
|
||||
({"a": {}}, {"a": []})]:
|
||||
with self.subTest(source=source):
|
||||
result, actual, calls, _, unchanged = self.run_generator(source, target)
|
||||
self.assertEqual(result, 1)
|
||||
self.assertTrue(unchanged)
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_real_english_array_round_trip_and_generation(self):
|
||||
catalog = json.loads((SCRIPT.parents[2] / "AppImage/messages/en/common.json").read_text())
|
||||
descriptions = catalog["terminal"]["commandDescriptions"]
|
||||
self.assertIsInstance(descriptions, list)
|
||||
self.assertTrue(descriptions)
|
||||
self.assertEqual(MODULE.unflatten(MODULE.flatten(catalog)), catalog)
|
||||
result, actual, calls, _, _ = self.run_generator({"terminal": {"commandDescriptions": descriptions}}, {})
|
||||
self.assertEqual(len(calls), sum(isinstance(text, str) and bool(text) for text in descriptions))
|
||||
self.assertEqual(actual["terminal"]["commandDescriptions"], ["IT " + text for text in descriptions])
|
||||
|
||||
def test_preflight_skips_invalid_locales_but_processes_valid_locales(self):
|
||||
for options in [(), ("--refresh",)]:
|
||||
with self.subTest(options=options), tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
catalogs = {"en": {"items": ["One", "Two"]},
|
||||
"sk": {"items": ["Human"]},
|
||||
"it": {"items": "Uno"}, "de": {"items": {}}}
|
||||
for lang, data in catalogs.items():
|
||||
path = root / lang / "common.json"
|
||||
path.parent.mkdir()
|
||||
path.write_text(json.dumps(data))
|
||||
before = {lang: (root / lang / "common.json").read_bytes() for lang in catalogs}
|
||||
reads = []
|
||||
real_read = MODULE.read_json
|
||||
|
||||
def read(path):
|
||||
reads.append(path.parent.name)
|
||||
return real_read(path)
|
||||
|
||||
def translate(text, lang, *args):
|
||||
self.assertEqual(reads, ["en", "sk", "it", "de"])
|
||||
self.assertEqual(lang, "sk")
|
||||
return "SK " + text
|
||||
|
||||
argv = [str(SCRIPT), "--source", str(root / "en/common.json"),
|
||||
"--messages-dir", str(root), "--languages", "sk,it,de",
|
||||
"--sleep", "0", "--save-every", "1", *options]
|
||||
output = io.StringIO()
|
||||
with mock.patch.object(sys, "argv", argv), \
|
||||
mock.patch.object(MODULE, "read_json", side_effect=read), \
|
||||
mock.patch.object(MODULE, "translate_one", side_effect=translate) as provider, \
|
||||
mock.patch.object(MODULE, "write_json", wraps=MODULE.write_json) as writer, \
|
||||
redirect_stderr(output), redirect_stdout(output):
|
||||
self.assertEqual(MODULE.main(), 1)
|
||||
self.assertEqual(json.loads((root / "sk/common.json").read_text()),
|
||||
{"items": ["SK One" if options else "Human", "SK Two"]})
|
||||
self.assertEqual(provider.call_count, 2 if options else 1)
|
||||
self.assertTrue(all(call.args[0].parent.name == "sk" for call in writer.call_args_list))
|
||||
for lang in ["en", "it", "de"]:
|
||||
self.assertEqual((root / lang / "common.json").read_bytes(), before[lang])
|
||||
self.assertIn("Partial failure: skipped 2 locale(s): it, de", output.getvalue())
|
||||
self.assertNotIn("generated successfully", output.getvalue())
|
||||
|
||||
def test_round_trip_preserves_json_types_and_literal_keys(self):
|
||||
catalog = {"terminal": {"commandDescriptions": ["One", {"nested": ["Two", [], {}]}]},
|
||||
"0": {"a.b": "literal", "": None}, "flags": [True, False, 2, 1.5]}
|
||||
self.assertEqual(MODULE.unflatten(MODULE.flatten(catalog)), catalog)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Frozen conservative recovery: update the fixture only after explicit review.
|
||||
|
||||
Run offline with Python unittest and Node >=22.14 on PATH (or NODE_BINARY).
|
||||
The consumer test executes only extracted pure lookup functions, not the UI.
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import redirect_stdout, redirect_stderr
|
||||
from unittest import mock
|
||||
|
||||
from test_build_i18n_messages import MODULE, SCRIPT
|
||||
|
||||
ROOT = SCRIPT.parents[2]
|
||||
FIXTURE = json.loads((Path(__file__).parent / "fixtures/command_descriptions.json").read_text())
|
||||
|
||||
|
||||
def require_node():
|
||||
diagnostic = "Node >=22.14 is required; install Node 22 LTS or set NODE_BINARY to its executable"
|
||||
node = os.environ.get("NODE_BINARY") or shutil.which("node")
|
||||
if not node:
|
||||
raise AssertionError(diagnostic)
|
||||
try:
|
||||
result = subprocess.run([node, "--version"], capture_output=True, text=True)
|
||||
except OSError as exc:
|
||||
raise AssertionError(f"{diagnostic}; cannot execute {node}: {exc}") from exc
|
||||
version = re.fullmatch(r"v(\d+)\.(\d+)\.(\d+)", result.stdout.strip())
|
||||
if result.returncode or not version or tuple(map(int, version.groups())) < (22, 14, 0):
|
||||
raise AssertionError(f"{diagnostic}; found {result.stdout.strip() or result.stderr.strip()!r}")
|
||||
return node
|
||||
|
||||
|
||||
def catalog(lang):
|
||||
return json.loads((ROOT / f"AppImage/messages/{lang}/common.json").read_text())
|
||||
|
||||
|
||||
class NodeRuntimeTests(unittest.TestCase):
|
||||
def test_missing_node_reports_setup_instructions(self):
|
||||
with mock.patch.dict(os.environ, {"PATH": "", "NODE_BINARY": ""}):
|
||||
with self.assertRaisesRegex(AssertionError, r"Node >=22\.14.*NODE_BINARY"):
|
||||
require_node()
|
||||
|
||||
def test_invalid_override_reports_setup_instructions(self):
|
||||
with mock.patch.dict(os.environ, {"NODE_BINARY": "/nonexistent/f05-node"}):
|
||||
with self.assertRaisesRegex(AssertionError, r"Node >=22\.14.*NODE_BINARY"):
|
||||
require_node()
|
||||
|
||||
def test_older_node_reports_detected_version(self):
|
||||
with mock.patch.dict(os.environ, {"NODE_BINARY": "node"}), \
|
||||
mock.patch.object(subprocess, "run", return_value=subprocess.CompletedProcess(
|
||||
["node", "--version"], 0, "v20.19.0\n", "")):
|
||||
with self.assertRaisesRegex(AssertionError, r"Node >=22\.14.*v20\.19\.0"):
|
||||
require_node()
|
||||
|
||||
|
||||
class CommandDescriptionsTests(unittest.TestCase):
|
||||
def test_real_catalogs_exact_indexed_recovery_and_source_order(self):
|
||||
self.assertEqual(catalog("en")["terminal"]["commandDescriptions"], FIXTURE["english"])
|
||||
consumer = (ROOT / "AppImage/components/terminal-panel.tsx").read_text()
|
||||
commands = re.search(r"const proxmoxCommands = (\[.*?\])", consumer, re.S).group(1)
|
||||
commands = json.loads(re.sub(r",\s*\]$", "]", commands))
|
||||
self.assertEqual(commands, FIXTURE["commands"])
|
||||
self.assertIn('desc: t(`terminal.commandDescriptions.${index}`)', consumer)
|
||||
for lang, expected in FIXTURE["locales"].items():
|
||||
with self.subTest(locale=lang):
|
||||
actual = catalog(lang)["terminal"]["commandDescriptions"]
|
||||
self.assertIsInstance(actual, list)
|
||||
self.assertEqual(len(actual), len(commands))
|
||||
self.assertEqual(actual, expected)
|
||||
self.assertTrue(all(isinstance(x, str) and x for x in actual))
|
||||
for index in FIXTURE["fallback_indices"]:
|
||||
self.assertEqual(catalog("sv")["terminal"]["commandDescriptions"][index], FIXTURE["english"][index])
|
||||
|
||||
def test_workflow_default_catalogs_rerun_without_provider_or_writes(self):
|
||||
workflow = (ROOT / ".github/workflows/build-i18n-messages.yml").read_text()
|
||||
languages = re.search(r'LANGS="\$\{LANGS:-(.*?)\}"', workflow).group(1)
|
||||
self.assertEqual(languages.split(","), list(MODULE.DEFAULT_LANGUAGES))
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
for lang in ["en", *languages.split(",")]:
|
||||
path = root / lang / "common.json"
|
||||
path.parent.mkdir()
|
||||
shutil.copyfile(ROOT / f"AppImage/messages/{lang}/common.json", path)
|
||||
before = {p: p.read_bytes() for p in root.glob("*/common.json")}
|
||||
argv = [str(SCRIPT), "--source", str(root / "en/common.json"),
|
||||
"--messages-dir", str(root), "--languages", languages, "--sleep", "0"]
|
||||
output = io.StringIO()
|
||||
with mock.patch.object(sys, "argv", argv), \
|
||||
mock.patch.object(MODULE, "translate_one", side_effect=AssertionError("provider forbidden")) as provider, \
|
||||
mock.patch.object(MODULE, "write_json", side_effect=AssertionError("writes forbidden")) as writer, \
|
||||
redirect_stdout(output), redirect_stderr(output):
|
||||
result = MODULE.main()
|
||||
self.assertEqual(result, 0, output.getvalue())
|
||||
provider.assert_not_called()
|
||||
writer.assert_not_called()
|
||||
self.assertEqual(before, {p: p.read_bytes() for p in before})
|
||||
for lang in languages.split(","):
|
||||
self.assertIn(f"{lang}: nothing to translate", output.getvalue())
|
||||
|
||||
def test_actual_consumer_lookup_null_fallback_and_explicit_english(self):
|
||||
node = require_node()
|
||||
source = (ROOT / "AppImage/lib/i18n/provider.tsx").read_text()
|
||||
functions = source[source.index("function getMessage("):source.index("export function I18nProvider(")]
|
||||
lookup = re.search(r" const localized =.*?return interpolate\(.*?\)\n", source, re.S).group(0)
|
||||
catalogs = {lang: catalog(lang) for lang in ["en", *FIXTURE["locales"]]}
|
||||
script = ('import assert from "node:assert/strict";\n'
|
||||
'type MessageTree = Record<string, unknown>;\n'
|
||||
'type TranslationParams = Record<string, string | number>;\n' + functions +
|
||||
'\nconst MESSAGE_CATALOG = ' + json.dumps(catalogs) + ';\n' +
|
||||
'function t(language: string, key: string, params?: TranslationParams) {\n' + lookup + '}\n' +
|
||||
'const fixture = ' + json.dumps(FIXTURE) + ';\n' + '''
|
||||
for (const [lang, texts] of Object.entries(fixture.locales)) {
|
||||
texts.forEach((text, i) => {
|
||||
const key = `terminal.commandDescriptions.${i}`;
|
||||
assert.equal(getMessage(MESSAGE_CATALOG[lang], key), text);
|
||||
assert.equal(t(lang, key), text);
|
||||
});
|
||||
}
|
||||
for (const i of fixture.fallback_indices) {
|
||||
const key = `terminal.commandDescriptions.${i}`;
|
||||
const selected = MESSAGE_CATALOG.sv.terminal.commandDescriptions[i];
|
||||
MESSAGE_CATALOG.en.terminal.commandDescriptions[i] = "CHANGED EN";
|
||||
assert.equal(t("sv", key), selected); // Explicit English is a locale value.
|
||||
MESSAGE_CATALOG.sv.terminal.commandDescriptions[i] = null;
|
||||
assert.equal(t("sv", key), "CHANGED EN"); // Null would dynamically fall back.
|
||||
MESSAGE_CATALOG.sv.terminal.commandDescriptions[i] = "";
|
||||
assert.equal(t("sv", key), ""); // Empty strings do NOT fall back.
|
||||
}
|
||||
assert.equal(t("sv", "missing.key"), "missing.key");
|
||||
console.log("390 indexed consumer lookups; 15 explicit-English/null/empty cases passed");
|
||||
''')
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "consumer.ts"
|
||||
path.write_text(script)
|
||||
result = subprocess.run([node, "--experimental-strip-types", str(path)],
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn("390 indexed consumer lookups", result.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,45 @@
|
||||
name: Offline i18n tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/scripts/**'
|
||||
- '.github/workflows/test-i18n.yml'
|
||||
- '.github/workflows/build-i18n-messages.yml'
|
||||
- 'AppImage/messages/**'
|
||||
- 'AppImage/lib/i18n/**'
|
||||
- 'AppImage/components/terminal-panel.tsx'
|
||||
- 'CONTRIBUTING.md'
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- '.github/scripts/**'
|
||||
- '.github/workflows/test-i18n.yml'
|
||||
- '.github/workflows/build-i18n-messages.yml'
|
||||
- 'AppImage/messages/**'
|
||||
- 'AppImage/lib/i18n/**'
|
||||
- 'AppImage/components/terminal-panel.tsx'
|
||||
- 'CONTRIBUTING.md'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
offline-i18n:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
- name: Run complete offline script test suite
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: python3 -m unittest discover -s .github/scripts/tests -v
|
||||
Reference in New Issue
Block a user