Compare commits
4 Commits
0.4
..
240306da20
| Author | SHA1 | Date | |
|---|---|---|---|
| 240306da20 | |||
| 3b4175aaff | |||
| a67c18d691 | |||
| 5715da67de |
Executable
+150
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env node
|
||||
// pack_nedb_to_leveldb.mjs
|
||||
// Przepakowuje kompendia z legacy NeDB (.db, jeden JSON/linia) do formatu LevelDB
|
||||
// (katalog), którego wymaga Foundry VTT v11+ (i v13/v14). Czyta listę paczek
|
||||
// wprost z module.json danego modułu, więc nie trzeba niczego wpisywać ręcznie.
|
||||
//
|
||||
// Wymaga jednorazowo: npm i -D @foundryvtt/foundryvtt-cli (lub -g)
|
||||
//
|
||||
// Użycie:
|
||||
// node help_scripts/pack_nedb_to_leveldb.mjs [--write] [--replace] [ścieżki/do/modułów...]
|
||||
//
|
||||
// (bez argumentów ścieżek) -> domyślnie 3 własne moduły w tym repo
|
||||
// --write faktycznie buduje katalogi LevelDB (bez tego: tylko dry-run/plan)
|
||||
// --replace po udanym zbudowaniu usuwa stary plik .db ORAZ podmienia ścieżkę
|
||||
// w module.json (packs[].path: "packs/foo.db" -> "packs/foo")
|
||||
//
|
||||
// Bezpiecznie: domyślnie nic nie nadpisuje. LevelDB trafia obok, do packs/<name>.
|
||||
// Stare .db zostają, dopóki nie podasz --replace.
|
||||
|
||||
import { readFile, writeFile, rm, mkdtemp, mkdir } from "node:fs/promises";
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO = path.resolve(HERE, "..");
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const WRITE = args.includes("--write");
|
||||
const REPLACE = args.includes("--replace");
|
||||
const moduleDirs = args.filter((a) => !a.startsWith("--"));
|
||||
|
||||
const DEFAULT_MODULES = [
|
||||
"wg-greyknights",
|
||||
"wg-voidships-builder",
|
||||
"wg-voidships-journal",
|
||||
].map((m) => path.join(REPO, m));
|
||||
|
||||
const targets = (moduleDirs.length ? moduleDirs : DEFAULT_MODULES).map((p) =>
|
||||
path.resolve(p)
|
||||
);
|
||||
|
||||
// Typ paczki z module.json -> documentType wymagany przez CLI dla źródła NeDB.
|
||||
const TYPE_OK = new Set([
|
||||
"Actor", "Item", "JournalEntry", "RollTable", "Macro",
|
||||
"Scene", "Playlist", "Cards", "Adventure",
|
||||
]);
|
||||
|
||||
async function loadCli() {
|
||||
try {
|
||||
return await import("@foundryvtt/foundryvtt-cli");
|
||||
} catch {
|
||||
console.error(
|
||||
"\n[!] Brak @foundryvtt/foundryvtt-cli. Zainstaluj jednorazowo:\n" +
|
||||
" npm i -D @foundryvtt/foundryvtt-cli\n"
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
async function processModule(cli, modDir) {
|
||||
const manifestPath = path.join(modDir, "module.json");
|
||||
if (!existsSync(manifestPath)) {
|
||||
console.warn(`- pomijam ${modDir} (brak module.json)`);
|
||||
return;
|
||||
}
|
||||
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
||||
const packs = manifest.packs ?? [];
|
||||
if (!packs.length) {
|
||||
console.log(`- ${manifest.id ?? path.basename(modDir)}: brak paczek`);
|
||||
return;
|
||||
}
|
||||
|
||||
let manifestDirty = false;
|
||||
console.log(`\n=== ${manifest.id ?? path.basename(modDir)} ===`);
|
||||
|
||||
for (const pack of packs) {
|
||||
const rel = pack.path; // np. "packs/wg-voidship-hulls.db" LUB "packs/gk-items"
|
||||
const newRel = rel.replace(/\.db$/, ""); // docelowa ścieżka LevelDB (bez .db)
|
||||
const outDir = path.join(modDir, newRel);
|
||||
// Plik NeDB może być tam, gdzie wskazuje manifest (….db) albo obok, gdy
|
||||
// manifest podaje ścieżkę bez rozszerzenia, a na dysku i tak leży <path>.db.
|
||||
const nedbAbs = rel.endsWith(".db")
|
||||
? path.join(modDir, rel)
|
||||
: path.join(modDir, rel + ".db");
|
||||
|
||||
if (existsSync(outDir) && statSync(outDir).isDirectory() && !existsSync(nedbAbs)) {
|
||||
console.log(` • ${pack.name}: już LevelDB (${newRel}/) — pomijam`);
|
||||
continue;
|
||||
}
|
||||
if (!existsSync(nedbAbs) || !statSync(nedbAbs).isFile()) {
|
||||
console.warn(` • ${pack.name}: nie znaleziono źródła NeDB (${path.relative(modDir, nedbAbs)}) — pomijam`);
|
||||
continue;
|
||||
}
|
||||
if (rel === newRel) {
|
||||
// manifest już wskazuje bez .db, ale plik to NeDB -> rozbieżność do naprawy
|
||||
console.log(` (uwaga: module.json wskazuje "${rel}", a na dysku jest NeDB "${path.basename(nedbAbs)}")`);
|
||||
}
|
||||
const type = pack.type;
|
||||
if (!TYPE_OK.has(type)) {
|
||||
console.warn(` • ${pack.name}: nieznany type="${type}" — pomijam`);
|
||||
continue;
|
||||
}
|
||||
const abs = nedbAbs;
|
||||
console.log(
|
||||
` • ${pack.name} [${type}] ${rel} -> ${newRel}/ ${WRITE ? "" : "(dry-run)"}`
|
||||
);
|
||||
if (!WRITE) continue;
|
||||
|
||||
// 1) NeDB -> katalog źródeł (.json z poprawnymi _key)
|
||||
const srcTmp = await mkdtemp(path.join(tmpdir(), "fvtt-src-"));
|
||||
await cli.extractPack(abs, srcTmp, {
|
||||
nedb: true,
|
||||
documentType: type,
|
||||
yaml: false,
|
||||
log: false,
|
||||
});
|
||||
// 2) źródła -> LevelDB
|
||||
await mkdir(outDir, { recursive: true });
|
||||
await cli.compilePack(srcTmp, outDir, { yaml: false, log: false });
|
||||
await rm(srcTmp, { recursive: true, force: true });
|
||||
console.log(` ✓ zbudowano LevelDB: ${newRel}/`);
|
||||
|
||||
if (REPLACE) {
|
||||
await rm(abs, { force: true });
|
||||
if (pack.path !== newRel) {
|
||||
pack.path = newRel;
|
||||
manifestDirty = true;
|
||||
}
|
||||
console.log(
|
||||
` ✓ usunięto ${path.basename(abs)}` +
|
||||
(manifestDirty ? ", zaktualizowano ścieżkę w module.json" : "")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (manifestDirty && WRITE && REPLACE) {
|
||||
await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
|
||||
}
|
||||
}
|
||||
|
||||
const cli = WRITE ? await loadCli() : null; // CLI potrzebny tylko do budowania
|
||||
console.log(
|
||||
WRITE
|
||||
? `Przepakowuję (${REPLACE ? "z podmianą .db i module.json" : "obok, .db zostają"})`
|
||||
: "DRY-RUN — plan (dodaj --write aby zbudować, --replace aby podmienić):"
|
||||
);
|
||||
for (const t of targets) await processModule(cli, t);
|
||||
console.log("\nGotowe.");
|
||||
@@ -0,0 +1,779 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
nas-dedup-copy.py
|
||||
Szybkie, deduplikujące kopiowanie z dysku źródłowego na NAS (przez zamontowany
|
||||
katalog docelowy, np. NFS z TrueNAS).
|
||||
|
||||
Realizuje:
|
||||
1. DEDUP W ŹRÓDLE: pliki grupowane po ROZMIARZE; hashujemy TYLKO te, których
|
||||
rozmiar z czymś koliduje. Identyczna treść (rozmiar+hash) kopiowana raz.
|
||||
Nazwy i ścieżki nie mają znaczenia — liczy się TREŚĆ.
|
||||
2. DEDUP WZGLĘDEM ISTNIEJĄCYCH DANYCH (--against DIR, można podać wiele razy):
|
||||
jeśli treść pliku ze źródła już leży w którymś ze wskazanych katalogów
|
||||
(np. na NAS-ie, pod dowolną nazwą), plik jest POMIJANY.
|
||||
3. FILTR: pomija pliki < MIN_SIZE (domyślnie 1 MiB), CHYBA że jpg/jpeg/png/gif.
|
||||
4. SPRAWDZENIE MIEJSCA: po dedup+filtrze liczy realny rozmiar do skopiowania
|
||||
i porównuje z wolnym miejscem na woluminie docelowym. Bez miejsca -> stop.
|
||||
5. HEARTBEAT: co --heartbeat sekund (domyślnie 120) wypisuje na stderr znak
|
||||
życia: etap, WSZYSTKIE równolegle przetwarzane pliki z postępem każdego,
|
||||
postęp całości etapu, tempo i ETA.
|
||||
6. WIELOWĄTKOWOŚĆ:
|
||||
--workers N (domyślnie 4) — wątki KOPIOWANIA i hashowania --against.
|
||||
--hash-workers M (domyślnie 1) — wątki hashowania ŹRÓDŁA.
|
||||
UWAGA: zwiększaj tylko gdy źródło to SSD. Na HDD
|
||||
równoległe odczyty = seek-thrashing = WOLNIEJ.
|
||||
7. PLIK STANU (--state stan.json, OPCJONALNY):
|
||||
- cache HASHY: każdy policzony hash ląduje w pliku stanu (klucz: ścieżka,
|
||||
walidacja: rozmiar + mtime). Po przerwaniu i restarcie pliki niezmienione
|
||||
NIE są hashowane ponownie — ani źródło, ani --against.
|
||||
- rejestr SKOPIOWANYCH: ukończone kopie są odnotowane; restart pomija je
|
||||
bez dotykania NFS (szybciej niż stat na tysiącach plików).
|
||||
- zapis ATOMOWY (tmp + rename) co --state-interval sekund (domyślnie 30),
|
||||
na końcu każdego etapu oraz przy SIGINT (Ctrl+C) i SIGTERM.
|
||||
- plik przerwany w połowie KOPIOWANIA nie jest w rejestrze -> po restarcie
|
||||
zostanie skopiowany od zera (niepełny plik w celu ma zły rozmiar, więc
|
||||
złapie go też --skip-existing). Przerwany w połowie HASH liczy się od zera.
|
||||
- stan jest wiązany z backendem hasha (xxh64/sha256); zmiana backendu
|
||||
unieważnia cache hashy (rejestr kopii zostaje).
|
||||
|
||||
Optymalizacja hashowania (kluczowa dla prędkości):
|
||||
- Plik ŹRÓDŁA hashujemy tylko, gdy jego rozmiar koliduje z innym plikiem
|
||||
źródła LUB występuje wśród istniejących danych (--against).
|
||||
- Plik ISTNIEJĄCY (--against) hashujemy tylko, gdy jego rozmiar występuje
|
||||
w zestawie do skopiowania. Reszty nie dotykamy (nie może być duplikatem).
|
||||
- Wynik dedupu jest DETERMINISTYCZNY niezależnie od liczby wątków.
|
||||
|
||||
Bezpieczeństwo:
|
||||
- Źródło i katalogi --against są tylko CZYTANE. Skrypt NICZEGO nie usuwa.
|
||||
- Domyślnie: analiza + raport, potem pyta o potwierdzenie (--yes pomija).
|
||||
- --dry-run: sama analiza. --skip-existing (domyślnie ON): wznawianie po przerwaniu.
|
||||
|
||||
Przykłady:
|
||||
# pełny zestaw na noc: dedup vs NAS, 6 wątków, stan zapisywany:
|
||||
./nas-dedup-copy.py --source /mnt/source --dest /mnt/pve/truenas-nfs/import \\
|
||||
--against /mnt/pve/truenas-nfs --yes --workers 6 \\
|
||||
--state /root/import-stan.json
|
||||
|
||||
# po przerwaniu: to samo polecenie — hashe i kopie wracają z pliku stanu.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import stat
|
||||
import shutil
|
||||
import hashlib
|
||||
import threading
|
||||
import time
|
||||
from collections import Counter, defaultdict
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
# --- backend hasha: xxhash jeśli dostępny (szybki), inaczej sha256 ---
|
||||
try:
|
||||
import xxhash
|
||||
def make_hasher():
|
||||
return xxhash.xxh64()
|
||||
HASH_NAME = "xxh64"
|
||||
except ImportError:
|
||||
def make_hasher():
|
||||
return hashlib.sha256()
|
||||
HASH_NAME = "sha256 (zainstaluj 'xxhash' dla szybszego dedupu)"
|
||||
|
||||
HASH_BACKEND = HASH_NAME.split()[0] # "xxh64" / "sha256"
|
||||
DEFAULT_KEEP_EXTS = {".jpg", ".jpeg", ".png", ".gif"}
|
||||
MIB = 1 << 20
|
||||
COPY_CHUNK = 4 * MIB
|
||||
HB_MAX_FILES = 4 # ile równoległych plików pokazywać w linii heartbeatu
|
||||
STATE_VERSION = 1
|
||||
|
||||
|
||||
def human(n):
|
||||
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
|
||||
if abs(n) < 1024.0:
|
||||
return f"{n:,.1f} {unit}"
|
||||
n /= 1024.0
|
||||
return f"{n:,.1f} PiB"
|
||||
|
||||
|
||||
# ====================== PLIK STANU ======================
|
||||
|
||||
class State:
|
||||
"""Trwały stan w JSON: cache hashy (walidacja size+mtime) i rejestr
|
||||
ukończonych kopii. Zapis atomowy (tmp + os.replace). Thread-safe.
|
||||
Gdy path=None, wszystkie operacje są no-op (tryb bez stanu)."""
|
||||
|
||||
def __init__(self, path):
|
||||
self.path = path
|
||||
self.lock = threading.Lock()
|
||||
self.dirty = False
|
||||
self.hash_hits = 0
|
||||
self.copy_hits = 0
|
||||
self.data = {
|
||||
"version": STATE_VERSION,
|
||||
"hash_backend": HASH_BACKEND,
|
||||
"hashes": {}, # abspath -> {"size": int, "mtime": float, "hash": str}
|
||||
"copied": {}, # relpath -> {"size": int}
|
||||
}
|
||||
if path and os.path.exists(path):
|
||||
self._load()
|
||||
|
||||
def _load(self):
|
||||
try:
|
||||
with open(self.path, "r", encoding="utf-8") as f:
|
||||
loaded = json.load(f)
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
print(f"UWAGA: plik stanu nieczytelny ({e}) — zaczynam z pustym stanem.",
|
||||
file=sys.stderr)
|
||||
return
|
||||
if loaded.get("version") != STATE_VERSION:
|
||||
print("UWAGA: niezgodna wersja pliku stanu — zaczynam z pustym stanem.",
|
||||
file=sys.stderr)
|
||||
return
|
||||
if loaded.get("hash_backend") != HASH_BACKEND:
|
||||
print(f"UWAGA: plik stanu ma hashe {loaded.get('hash_backend')}, "
|
||||
f"a działamy na {HASH_BACKEND} — cache hashy odrzucony, "
|
||||
f"rejestr kopii zachowany.", file=sys.stderr)
|
||||
loaded["hashes"] = {}
|
||||
loaded["hash_backend"] = HASH_BACKEND
|
||||
self.data = loaded
|
||||
self.data.setdefault("hashes", {})
|
||||
self.data.setdefault("copied", {})
|
||||
print(f"Stan wczytany: {len(self.data['hashes']):,} hashy w cache, "
|
||||
f"{len(self.data['copied']):,} plików odnotowanych jako skopiowane.",
|
||||
file=sys.stderr)
|
||||
|
||||
# --- hashe ---
|
||||
def get_hash(self, path, size, mtime):
|
||||
if not self.path:
|
||||
return None
|
||||
with self.lock:
|
||||
e = self.data["hashes"].get(path)
|
||||
if e and e["size"] == size and e["mtime"] == mtime:
|
||||
self.hash_hits += 1
|
||||
return e["hash"]
|
||||
return None
|
||||
|
||||
def put_hash(self, path, size, mtime, digest):
|
||||
if not self.path:
|
||||
return
|
||||
with self.lock:
|
||||
self.data["hashes"][path] = {"size": size, "mtime": mtime, "hash": digest}
|
||||
self.dirty = True
|
||||
|
||||
# --- kopie ---
|
||||
def is_copied(self, rel, size):
|
||||
if not self.path:
|
||||
return False
|
||||
with self.lock:
|
||||
e = self.data["copied"].get(rel)
|
||||
if e and e["size"] == size:
|
||||
self.copy_hits += 1
|
||||
return True
|
||||
return False
|
||||
|
||||
def put_copied(self, rel, size):
|
||||
if not self.path:
|
||||
return
|
||||
with self.lock:
|
||||
self.data["copied"][rel] = {"size": size}
|
||||
self.dirty = True
|
||||
|
||||
# --- zapis ---
|
||||
def save(self, force=False):
|
||||
if not self.path:
|
||||
return
|
||||
with self.lock:
|
||||
if not self.dirty and not force:
|
||||
return
|
||||
tmp = self.path + ".tmp"
|
||||
try:
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(self.data, f)
|
||||
os.replace(tmp, self.path) # atomowo
|
||||
self.dirty = False
|
||||
except OSError as e:
|
||||
print(f"UWAGA: nie mogę zapisać pliku stanu: {e}", file=sys.stderr)
|
||||
|
||||
|
||||
STATE = State(None) # podmieniane w main()
|
||||
|
||||
|
||||
def state_saver_loop(interval, stop_event):
|
||||
while not stop_event.wait(interval):
|
||||
STATE.save()
|
||||
|
||||
|
||||
# ====================== HEARTBEAT ======================
|
||||
|
||||
class Progress:
|
||||
"""Współdzielony stan postępu. Wiele wątków-workerów pisze (każdy o swoim
|
||||
pliku), wątek heartbeat czyta i drukuje zbiorczą linię."""
|
||||
|
||||
def __init__(self):
|
||||
self.lock = threading.Lock()
|
||||
self.phase = "start"
|
||||
self.detail = ""
|
||||
self.current = {} # path -> [done, total, op] — pliki w locie
|
||||
self.items_done = 0
|
||||
self.items_total = 0
|
||||
self.bytes_done = 0 # bajty UKOŃCZONYCH plików w etapie
|
||||
self.bytes_total = 0
|
||||
self.phase_start = time.monotonic()
|
||||
self._hb_bytes = 0
|
||||
self._hb_time = time.monotonic()
|
||||
|
||||
def set_phase(self, phase, items_total=0, bytes_total=0, detail=""):
|
||||
with self.lock:
|
||||
self.phase = phase
|
||||
self.detail = detail
|
||||
self.current = {}
|
||||
self.items_done = 0
|
||||
self.items_total = items_total
|
||||
self.bytes_done = 0
|
||||
self.bytes_total = bytes_total
|
||||
self.phase_start = time.monotonic()
|
||||
self._hb_bytes = 0
|
||||
self._hb_time = time.monotonic()
|
||||
|
||||
def start_file(self, path, size, op):
|
||||
with self.lock:
|
||||
self.current[path] = [0, size, op]
|
||||
|
||||
def advance(self, path, nbytes):
|
||||
with self.lock:
|
||||
cur = self.current.get(path)
|
||||
if cur:
|
||||
cur[0] += nbytes
|
||||
|
||||
def finish_file(self, path):
|
||||
with self.lock:
|
||||
cur = self.current.pop(path, None)
|
||||
if cur:
|
||||
self.bytes_done += cur[1]
|
||||
self.items_done += 1
|
||||
|
||||
def tick(self, items=1, add_bytes=0):
|
||||
"""Licznik dla pominięć/etapów bez czytania danych (np. cache hit)."""
|
||||
with self.lock:
|
||||
self.items_done += items
|
||||
self.bytes_done += add_bytes
|
||||
|
||||
def line(self):
|
||||
with self.lock:
|
||||
now = time.monotonic()
|
||||
inflight = sum(c[0] for c in self.current.values())
|
||||
done_now = self.bytes_done + inflight
|
||||
dt = max(now - self._hb_time, 1e-6)
|
||||
rate = (done_now - self._hb_bytes) / dt
|
||||
self._hb_bytes = done_now
|
||||
self._hb_time = now
|
||||
elapsed = int(now - self.phase_start)
|
||||
|
||||
parts = [f"etap: {self.phase}"]
|
||||
if self.detail:
|
||||
parts.append(self.detail)
|
||||
if self.items_total:
|
||||
parts.append(f"pliki {self.items_done:,}/{self.items_total:,}")
|
||||
elif self.items_done:
|
||||
parts.append(f"przetworzono {self.items_done:,} plików")
|
||||
if self.bytes_total:
|
||||
pct = 100.0 * done_now / self.bytes_total
|
||||
parts.append(f"dane {human(done_now)}/{human(self.bytes_total)} ({pct:.1f}%)")
|
||||
|
||||
shown = 0
|
||||
for path, (done, total, op) in list(self.current.items()):
|
||||
if shown >= HB_MAX_FILES:
|
||||
parts.append(f"(+{len(self.current) - shown} innych w locie)")
|
||||
break
|
||||
if total:
|
||||
fpct = 100.0 * done / total
|
||||
parts.append(f"{op}: {path} [{human(done)}/{human(total)} = {fpct:.1f}%]")
|
||||
else:
|
||||
parts.append(f"{op}: {path}")
|
||||
shown += 1
|
||||
|
||||
if rate > 1 and (self.current or self.bytes_total):
|
||||
parts.append(f"tempo {human(rate)}/s")
|
||||
if self.bytes_total and done_now < self.bytes_total:
|
||||
eta = (self.bytes_total - done_now) / rate
|
||||
parts.append(f"ETA etapu ~{int(eta // 60)}m{int(eta % 60):02d}s")
|
||||
parts.append(f"czas etapu {elapsed // 60}m{elapsed % 60:02d}s")
|
||||
return " | ".join(parts)
|
||||
|
||||
|
||||
PROGRESS = Progress()
|
||||
|
||||
|
||||
def heartbeat_loop(interval, stop_event):
|
||||
while not stop_event.wait(interval):
|
||||
ts = time.strftime("%H:%M:%S")
|
||||
print(f"[ŻYJĘ {ts}] {PROGRESS.line()}", file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
# ====================== LOGIKA ======================
|
||||
|
||||
def hash_file(path, chunk_size=MIB):
|
||||
h = make_hasher()
|
||||
with open(path, "rb") as f:
|
||||
for block in iter(lambda: f.read(chunk_size), b""):
|
||||
h.update(block)
|
||||
PROGRESS.advance(path, len(block))
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def cached_hash(path, size):
|
||||
"""Hash z cache stanu (walidacja size+mtime) albo policzony i zapisany."""
|
||||
try:
|
||||
mtime = os.stat(path).st_mtime
|
||||
except OSError:
|
||||
raise
|
||||
d = STATE.get_hash(path, size, mtime)
|
||||
if d is not None:
|
||||
PROGRESS.tick(items=0, add_bytes=size) # licz jako "zrobione bajty" bez czytania
|
||||
return d
|
||||
d = hash_file(path)
|
||||
STATE.put_hash(path, size, mtime, d)
|
||||
return d
|
||||
|
||||
|
||||
def parallel_hash(files, workers, progress_every=500, label="hash"):
|
||||
"""Hashuje listę (abspath, relpath, size) w `workers` wątkach.
|
||||
Zwraca dict abspath -> hexdigest (brak klucza = błąd odczytu).
|
||||
Postęp per-plik raportowany do PROGRESS; hashe idą przez cache stanu."""
|
||||
digests = {}
|
||||
done_counter = [0]
|
||||
counter_lock = threading.Lock()
|
||||
|
||||
def job(f):
|
||||
p, _rel, size = f
|
||||
PROGRESS.start_file(p, size, "hashuję")
|
||||
try:
|
||||
d = cached_hash(p, size)
|
||||
except OSError:
|
||||
d = None
|
||||
PROGRESS.finish_file(p)
|
||||
with counter_lock:
|
||||
done_counter[0] += 1
|
||||
if done_counter[0] % progress_every == 0:
|
||||
print(f" ... {label} {done_counter[0]}/{len(files)}", file=sys.stderr)
|
||||
return p, d
|
||||
|
||||
if workers <= 1:
|
||||
for f in files:
|
||||
p, d = job(f)
|
||||
if d is not None:
|
||||
digests[p] = d
|
||||
return digests
|
||||
|
||||
with ThreadPoolExecutor(max_workers=workers) as ex:
|
||||
for fut in as_completed(ex.submit(job, f) for f in files):
|
||||
p, d = fut.result()
|
||||
if d is not None:
|
||||
digests[p] = d
|
||||
return digests
|
||||
|
||||
|
||||
def scan(root_dir):
|
||||
"""Zwraca (abspath, relpath, size) dla zwykłych plików + liczbę błędów odczytu."""
|
||||
files = []
|
||||
errors = 0
|
||||
for root, _dirs, names in os.walk(root_dir, onerror=lambda e: None):
|
||||
for name in names:
|
||||
p = os.path.join(root, name)
|
||||
try:
|
||||
st = os.lstat(p)
|
||||
except OSError:
|
||||
errors += 1
|
||||
continue
|
||||
if not stat.S_ISREG(st.st_mode):
|
||||
continue # symlinki, urządzenia, potoki — pomijamy
|
||||
files.append((p, os.path.relpath(p, root_dir), st.st_size))
|
||||
PROGRESS.tick()
|
||||
return files, errors
|
||||
|
||||
|
||||
# ---- rejestr pominięć (thread-safe): kategoria -> lista (ścieżka, szczegół) ----
|
||||
_SKIP_LOCK = threading.Lock()
|
||||
_SKIPPED = defaultdict(list)
|
||||
|
||||
def add_skip(category, path, detail=""):
|
||||
with _SKIP_LOCK:
|
||||
_SKIPPED[category].append((path, detail))
|
||||
|
||||
def print_skipped(skipped_list_path=None):
|
||||
"""Wypisuje raport pominięć pogrupowany po kategorii.
|
||||
Bez skipped_list_path: pełna lista na stdout.
|
||||
Z skipped_list_path: pełna lista do pliku, na stdout tylko liczniki per kategoria."""
|
||||
with _SKIP_LOCK:
|
||||
if not _SKIPPED:
|
||||
return
|
||||
total = sum(len(v) for v in _SKIPPED.values())
|
||||
print(f"\n============ POMINIĘTE PLIKI ({total:,}) ============")
|
||||
if skipped_list_path:
|
||||
try:
|
||||
with open(skipped_list_path, "w", encoding="utf-8") as fh:
|
||||
for cat in sorted(_SKIPPED):
|
||||
items = _SKIPPED[cat]
|
||||
fh.write(f"# {cat} ({len(items)})\n")
|
||||
for path, detail in items:
|
||||
fh.write(f"{path}\t{detail}\n" if detail else f"{path}\n")
|
||||
fh.write("\n")
|
||||
for cat in sorted(_SKIPPED):
|
||||
print(f" {cat:<28}: {len(_SKIPPED[cat]):,}")
|
||||
print(f" (pełna lista zapisana do: {skipped_list_path})")
|
||||
except OSError as e:
|
||||
print(f" BŁĄD zapisu {skipped_list_path}: {e} — wypisuję na stdout:")
|
||||
skipped_list_path = None
|
||||
if not skipped_list_path:
|
||||
for cat in sorted(_SKIPPED):
|
||||
items = _SKIPPED[cat]
|
||||
print(f"\n {cat} ({len(items)}):")
|
||||
for path, detail in items:
|
||||
print(f" {path}" + (f" [{detail}]" if detail else ""))
|
||||
print("========================================")
|
||||
|
||||
|
||||
def apply_filter(files, min_size, keep_exts, only_exts=None):
|
||||
kept, skipped, skipped_bytes = [], 0, 0
|
||||
skipped_ext = 0
|
||||
for p, rel, size in files:
|
||||
ext = os.path.splitext(p)[1].lower()
|
||||
if only_exts is not None and ext not in only_exts:
|
||||
skipped_ext += 1
|
||||
add_skip("poza listą --only-exts", p, f"rozszerzenie '{ext or '(brak)'}'")
|
||||
continue
|
||||
if size < min_size and ext not in keep_exts:
|
||||
skipped += 1
|
||||
skipped_bytes += size
|
||||
add_skip("filtr rozmiaru", p, f"{human(size)} < próg {human(min_size)}")
|
||||
continue
|
||||
kept.append((p, rel, size))
|
||||
return kept, skipped, skipped_bytes, skipped_ext
|
||||
|
||||
|
||||
def build_existing_index(existing_files, source_sizes, workers):
|
||||
"""Index treści istniejących danych: {(size, hash)}.
|
||||
Hashuje tylko pliki, których rozmiar występuje w imporcie. Równolegle."""
|
||||
to_hash = [f for f in existing_files if f[2] in source_sizes]
|
||||
PROGRESS.set_phase("hash istniejących (--against)",
|
||||
items_total=len(to_hash),
|
||||
bytes_total=sum(f[2] for f in to_hash),
|
||||
detail=f"{workers} wątk.")
|
||||
if to_hash:
|
||||
print(f" Istniejące dane: {len(to_hash)} plików o pasującym rozmiarze do zahashowania",
|
||||
file=sys.stderr)
|
||||
digests = parallel_hash(to_hash, workers, label="hash istniejących")
|
||||
STATE.save()
|
||||
index = set()
|
||||
for p, _rel, size in to_hash:
|
||||
d = digests.get(p)
|
||||
if d is not None:
|
||||
index.add((size, d))
|
||||
return index
|
||||
|
||||
|
||||
def dedup(kept, existing_index, existing_sizes, hash_workers):
|
||||
"""Dedup źródła + odsianie treści już obecnych w existing_index.
|
||||
Hashe liczone RÓWNOLEGLE (przez cache stanu), selekcja SEKWENCYJNIE
|
||||
w kolejności skanu. Zwraca (unique, dup_internal, dup_existing, hashed_src)."""
|
||||
size_count = Counter(f[2] for f in kept)
|
||||
need_hash = [f for f in kept if size_count[f[2]] > 1 or f[2] in existing_sizes]
|
||||
|
||||
PROGRESS.set_phase("hash źródła (dedup)",
|
||||
items_total=len(need_hash),
|
||||
bytes_total=sum(f[2] for f in need_hash),
|
||||
detail=f"hash tylko dla kolizji rozmiaru, {hash_workers} wątk.")
|
||||
digests = parallel_hash(need_hash, hash_workers, label="hash źródła")
|
||||
STATE.save()
|
||||
|
||||
seen = set()
|
||||
unique = []
|
||||
dup_internal = 0
|
||||
dup_existing = 0
|
||||
hashed_src = 0
|
||||
|
||||
for p, rel, size in kept:
|
||||
needs = size_count[size] > 1 or size in existing_sizes
|
||||
if needs:
|
||||
hashed_src += 1
|
||||
d = digests.get(p)
|
||||
key = (size, d) if d is not None else None # nieczytelny -> unikat, nie gub
|
||||
else:
|
||||
key = (size, None) # unikalny rozmiar i brak w istniejących = na pewno unikat
|
||||
|
||||
if key is not None and key in existing_index:
|
||||
dup_existing += 1
|
||||
add_skip("już na NAS (--against)", p, f"treść identyczna, {human(size)}")
|
||||
continue
|
||||
if key is not None and key in seen:
|
||||
dup_internal += 1
|
||||
add_skip("duplikat w źródle", p, f"kopia wcześniejszego pliku, {human(size)}")
|
||||
continue
|
||||
if key is not None:
|
||||
seen.add(key)
|
||||
unique.append((p, rel, size))
|
||||
|
||||
return unique, dup_internal, dup_existing, hashed_src
|
||||
|
||||
|
||||
def copy_file_with_progress(src, dst, chunk_size=COPY_CHUNK):
|
||||
"""Jak shutil.copy2, ale chunkami z raportowaniem postępu bieżącego pliku."""
|
||||
with open(src, "rb") as fsrc, open(dst, "wb") as fdst:
|
||||
for block in iter(lambda: fsrc.read(chunk_size), b""):
|
||||
fdst.write(block)
|
||||
PROGRESS.advance(src, len(block))
|
||||
shutil.copystat(src, dst, follow_symlinks=False)
|
||||
|
||||
|
||||
def do_copy(unique, dest, skip_existing, workers):
|
||||
PROGRESS.set_phase("kopiowanie",
|
||||
items_total=len(unique),
|
||||
bytes_total=sum(f[2] for f in unique),
|
||||
detail=f"{workers} wątk.")
|
||||
n = len(unique)
|
||||
state_lock = threading.Lock()
|
||||
stats = {"copied": 0, "copied_bytes": 0, "skipped": 0, "done": 0}
|
||||
|
||||
def job(f):
|
||||
p, rel, size = f
|
||||
# 1) rejestr stanu — bez dotykania NFS
|
||||
if STATE.is_copied(rel, size):
|
||||
with state_lock:
|
||||
stats["skipped"] += 1
|
||||
stats["done"] += 1
|
||||
add_skip("już skopiowany (rejestr stanu)", p, f"rel={rel}")
|
||||
PROGRESS.tick(add_bytes=size)
|
||||
return
|
||||
dpath = os.path.join(dest, rel)
|
||||
os.makedirs(os.path.dirname(dpath), exist_ok=True)
|
||||
# 2) klasyczny skip-existing (stat na celu)
|
||||
if skip_existing and os.path.exists(dpath):
|
||||
try:
|
||||
if os.path.getsize(dpath) == size:
|
||||
STATE.put_copied(rel, size)
|
||||
with state_lock:
|
||||
stats["skipped"] += 1
|
||||
stats["done"] += 1
|
||||
add_skip("już w celu (ten sam rozmiar)", p, f"cel={dpath}")
|
||||
PROGRESS.tick(add_bytes=size)
|
||||
return
|
||||
except OSError:
|
||||
pass
|
||||
PROGRESS.start_file(p, size, "kopiuję")
|
||||
try:
|
||||
copy_file_with_progress(p, dpath)
|
||||
STATE.put_copied(rel, size)
|
||||
with state_lock:
|
||||
stats["copied"] += 1
|
||||
stats["copied_bytes"] += size
|
||||
except OSError as e:
|
||||
print(f" BŁĄD kopiowania {p}: {e}", file=sys.stderr)
|
||||
PROGRESS.finish_file(p)
|
||||
with state_lock:
|
||||
stats["done"] += 1
|
||||
if stats["done"] % 200 == 0:
|
||||
print(f" ... kopiuję {stats['done']}/{n} ({human(stats['copied_bytes'])})",
|
||||
file=sys.stderr)
|
||||
|
||||
if workers <= 1:
|
||||
for f in unique:
|
||||
job(f)
|
||||
else:
|
||||
with ThreadPoolExecutor(max_workers=workers) as ex:
|
||||
list(ex.map(job, unique))
|
||||
|
||||
STATE.save()
|
||||
return stats["copied"], stats["copied_bytes"], stats["skipped"]
|
||||
|
||||
|
||||
def install_signal_handlers():
|
||||
"""SIGINT/SIGTERM: zapisz stan i wyjdź. Wątki-workery są daemonami
|
||||
ThreadPoolExecutora tylko pośrednio, więc kończymy twardo — plik w połowie
|
||||
kopiowany zostanie dokończony przy następnym biegu (zły rozmiar w celu)."""
|
||||
def handler(signum, _frame):
|
||||
print(f"\nPrzerwano sygnałem {signal.Signals(signum).name} — zapisuję stan...",
|
||||
file=sys.stderr)
|
||||
STATE.save(force=True)
|
||||
print("Stan zapisany. Uruchom ponownie z tym samym --state, aby wznowić.",
|
||||
file=sys.stderr)
|
||||
os._exit(130)
|
||||
signal.signal(signal.SIGINT, handler)
|
||||
signal.signal(signal.SIGTERM, handler)
|
||||
|
||||
|
||||
def main():
|
||||
global STATE
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Deduplikujące kopiowanie na NAS z filtrem, dedupem względem istniejących danych, plikiem stanu i sprawdzeniem miejsca.")
|
||||
ap.add_argument("--source", required=True, help="Katalog źródłowy (dysk wpięty w host).")
|
||||
ap.add_argument("--dest", required=True, help="Katalog docelowy (np. /mnt/pve/truenas-nfs/import).")
|
||||
ap.add_argument("--against", action="append", default=[],
|
||||
help="Katalog z ISTNIEJĄCYMI danymi (np. cały mount NAS). Treść już tam obecna nie jest kopiowana. Można podać wiele razy.")
|
||||
ap.add_argument("--min-size", type=float, default=1.0,
|
||||
help="Próg w MiB; pliki mniejsze pomijane (domyślnie 1). Wyjątki: --keep-exts.")
|
||||
ap.add_argument("--only-exts", default=None, metavar="EXT,EXT",
|
||||
help="Kopiuj WYŁĄCZNIE pliki z jednym z podanych rozszerzeń (po przecinku, np. 'mkv,mp4,iso'). Reszta pomijana PRZED hashowaniem. Filtr --min-size dalej obowiązuje.")
|
||||
ap.add_argument("--keep-exts", default="jpg,jpeg,png,gif",
|
||||
help="Rozszerzenia kopiowane niezależnie od rozmiaru (po przecinku).")
|
||||
ap.add_argument("--skipped-list", default=None, metavar="PLIK",
|
||||
help="Zapisz pełną listę pominiętych plików do pliku; na stdout tylko liczniki per kategoria. Bez tej opcji pełna lista idzie na stdout.")
|
||||
ap.add_argument("--reserve", type=float, default=1.0,
|
||||
help="Margines wolnego miejsca w GiB do zostawienia na NAS (domyślnie 1).")
|
||||
ap.add_argument("--workers", type=int, default=4,
|
||||
help="Wątki kopiowania i hashowania --against (NFS). Domyślnie 4.")
|
||||
ap.add_argument("--hash-workers", type=int, default=1,
|
||||
help="Wątki hashowania ŹRÓDŁA. Domyślnie 1. Zwiększ tylko dla SSD; na HDD więcej = wolniej (seeki).")
|
||||
ap.add_argument("--state", default=None, metavar="PLIK.json",
|
||||
help="Plik stanu JSON: cache hashy + rejestr skopiowanych. Przerwany bieg wznawia się bez powtarzania pracy.")
|
||||
ap.add_argument("--state-interval", type=float, default=30.0,
|
||||
help="Co ile sekund autozapis pliku stanu (domyślnie 30).")
|
||||
ap.add_argument("--dry-run", action="store_true", help="Tylko analiza + raport, bez kopiowania.")
|
||||
ap.add_argument("--yes", action="store_true", help="Nie pytaj o potwierdzenie.")
|
||||
ap.add_argument("--no-skip-existing", action="store_true",
|
||||
help="Nie pomijaj plików już obecnych w celu po ścieżce (domyślnie pomija = wznawianie).")
|
||||
ap.add_argument("--heartbeat", type=float, default=120.0,
|
||||
help="Co ile sekund wypisywać znak życia na stderr (domyślnie 120; 0 = wyłącz).")
|
||||
args = ap.parse_args()
|
||||
|
||||
source = os.path.abspath(args.source)
|
||||
dest = os.path.abspath(args.dest)
|
||||
min_size = int(args.min_size * MIB)
|
||||
keep_exts = {("." + e.strip().lower().lstrip(".")) for e in args.keep_exts.split(",") if e.strip()}
|
||||
only_exts = None
|
||||
if args.only_exts:
|
||||
only_exts = {("." + e.strip().lower().lstrip(".")) for e in args.only_exts.split(",") if e.strip()}
|
||||
if not only_exts:
|
||||
sys.exit("BŁĄD: --only-exts podane, ale lista rozszerzeń pusta.")
|
||||
reserve = int(args.reserve * (1 << 30))
|
||||
skip_existing = not args.no_skip_existing
|
||||
workers = max(1, args.workers)
|
||||
hash_workers = max(1, args.hash_workers)
|
||||
|
||||
if not os.path.isdir(source):
|
||||
sys.exit(f"BŁĄD: źródło nie istnieje lub nie jest katalogiem: {source}")
|
||||
os.makedirs(dest, exist_ok=True)
|
||||
|
||||
STATE = State(os.path.abspath(args.state) if args.state else None)
|
||||
install_signal_handlers()
|
||||
|
||||
print(f"Źródło : {source}")
|
||||
print(f"Cel : {dest}")
|
||||
if args.against:
|
||||
print(f"Dedup vs : {', '.join(os.path.abspath(a) for a in args.against)}")
|
||||
if only_exts:
|
||||
print(f"Tylko ext : {sorted(only_exts)}")
|
||||
print(f"Filtr : pomijaj < {human(min_size)} poza {sorted(keep_exts)}")
|
||||
print(f"Hash : {HASH_NAME}")
|
||||
print(f"Wątki : kopiowanie/against={workers}, hash źródła={hash_workers}")
|
||||
print(f"Stan : {STATE.path or 'brak (bez wznawiania)'}")
|
||||
if args.heartbeat > 0:
|
||||
print(f"Puls : co {args.heartbeat:.0f} s na stderr\n")
|
||||
else:
|
||||
print("Puls : wyłączony\n")
|
||||
|
||||
stop_hb = threading.Event()
|
||||
if args.heartbeat > 0:
|
||||
threading.Thread(target=heartbeat_loop, args=(args.heartbeat, stop_hb), daemon=True).start()
|
||||
if STATE.path:
|
||||
threading.Thread(target=state_saver_loop,
|
||||
args=(max(5.0, args.state_interval), stop_hb), daemon=True).start()
|
||||
|
||||
try:
|
||||
print("[1/4] Skanuję źródło...", file=sys.stderr)
|
||||
PROGRESS.set_phase("skanowanie źródła", detail=source)
|
||||
all_files, scan_errors = scan(source)
|
||||
total_scanned = len(all_files)
|
||||
total_scanned_bytes = sum(f[2] for f in all_files)
|
||||
|
||||
print("[2/4] Filtruję...", file=sys.stderr)
|
||||
kept, skipped_small, skipped_bytes, skipped_ext = apply_filter(
|
||||
all_files, min_size, keep_exts, only_exts)
|
||||
source_sizes = set(f[2] for f in kept)
|
||||
|
||||
existing_index = set()
|
||||
existing_sizes = set()
|
||||
existing_total = 0
|
||||
if args.against:
|
||||
print("[3/4] Skanuję istniejące dane i buduję index treści...", file=sys.stderr)
|
||||
existing_files = []
|
||||
for d in args.against:
|
||||
PROGRESS.set_phase("skanowanie --against", detail=os.path.abspath(d))
|
||||
ef, _err = scan(os.path.abspath(d))
|
||||
existing_files.extend(ef)
|
||||
existing_total = len(existing_files)
|
||||
existing_sizes = set(f[2] for f in existing_files)
|
||||
existing_index = build_existing_index(existing_files, source_sizes, workers)
|
||||
else:
|
||||
print("[3/4] (pominięto — brak --against)", file=sys.stderr)
|
||||
|
||||
print("[4/4] Deduplikuję źródło i odsiewam treści już obecne...", file=sys.stderr)
|
||||
unique, dup_internal, dup_existing, hashed_src = dedup(
|
||||
kept, existing_index, existing_sizes, hash_workers)
|
||||
|
||||
PROGRESS.set_phase("raport / oczekiwanie")
|
||||
|
||||
to_copy_bytes = sum(f[2] for f in unique)
|
||||
usage = shutil.disk_usage(dest)
|
||||
fits = (to_copy_bytes + reserve) <= usage.free
|
||||
|
||||
# --- RAPORT ---
|
||||
print("\n================ RAPORT ================")
|
||||
print(f"Plików w źródle : {total_scanned:,} ({human(total_scanned_bytes)})")
|
||||
if only_exts:
|
||||
print(f"Pominięte (--only-exts) : {skipped_ext:,}")
|
||||
print(f"Pominięte filtrem (<{human(min_size)}) : {skipped_small:,} ({human(skipped_bytes)})")
|
||||
print(f"Po filtrze : {len(kept):,}")
|
||||
if args.against:
|
||||
print(f"Istniejące dane (skan) : {existing_total:,} plików")
|
||||
print(f"Odsiane (już na NAS) : {dup_existing:,}")
|
||||
print(f"Duplikaty w źródle : {dup_internal:,}")
|
||||
print(f"Zahashowano plików źródła : {hashed_src:,}")
|
||||
if STATE.path:
|
||||
print(f"Hashe z cache stanu : {STATE.hash_hits:,}")
|
||||
print(f"UNIKALNE do skopiowania : {len(unique):,} ({human(to_copy_bytes)})")
|
||||
if scan_errors:
|
||||
print(f"Błędy odczytu przy skanie : {scan_errors}")
|
||||
print("----------------------------------------")
|
||||
print(f"Wolne miejsce na celu : {human(usage.free)}")
|
||||
print(f"Potrzeba (+margines {human(reserve)}): {human(to_copy_bytes + reserve)}")
|
||||
print(f"MIEŚCI SIĘ : {'TAK' if fits else 'NIE'}")
|
||||
print("========================================\n")
|
||||
|
||||
if not fits:
|
||||
STATE.save(force=True)
|
||||
sys.exit("PRZERWANO: za mało miejsca na woluminie docelowym. "
|
||||
"Zwolnij miejsce, zmień --dest, albo zaostrz filtr (--min-size).")
|
||||
|
||||
if args.dry_run:
|
||||
STATE.save(force=True)
|
||||
print("Dry-run: nic nie skopiowano (hashe trafiły do pliku stanu).")
|
||||
print_skipped(args.skipped_list)
|
||||
return
|
||||
|
||||
if not args.yes:
|
||||
ans = input(f"Skopiować {len(unique):,} plików ({human(to_copy_bytes)}) do {dest}? [t/N] ").strip().lower()
|
||||
if ans not in ("t", "tak", "y", "yes"):
|
||||
STATE.save(force=True)
|
||||
print("Anulowano (stan zapisany).")
|
||||
return
|
||||
|
||||
print("\nKopiuję...", file=sys.stderr)
|
||||
copied, copied_bytes, skipped_ex = do_copy(unique, dest, skip_existing, workers)
|
||||
|
||||
print("\n================ KONIEC ================")
|
||||
print(f"Skopiowano : {copied:,} plików ({human(copied_bytes)})")
|
||||
if skip_existing:
|
||||
print(f"Pominięto (były) : {skipped_ex:,}")
|
||||
if STATE.path:
|
||||
print(f" w tym z rejestru stanu: {STATE.copy_hits:,}")
|
||||
print("========================================")
|
||||
print_skipped(args.skipped_list)
|
||||
finally:
|
||||
stop_hb.set()
|
||||
STATE.save(force=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,882 +0,0 @@
|
||||
{
|
||||
"host": "localhost",
|
||||
"port": 8785,
|
||||
"refresh_interval": 1.0,
|
||||
"variables_keys": [
|
||||
"condenser_volume",
|
||||
"coolant_core_flow_reached_speed",
|
||||
"coolant_core_flow_speed",
|
||||
"core_iodine_cumulative",
|
||||
"core_iodine_generation",
|
||||
"core_state_criticality",
|
||||
"core_temp",
|
||||
"core_xenon_cumulative",
|
||||
"core_xenon_generation",
|
||||
"generator_0_kw",
|
||||
"steam_turbine_0_pressure",
|
||||
"steam_turbine_0_rpm",
|
||||
"steam_turbine_0_temperature",
|
||||
"coolant_sec_0_liquid_volume",
|
||||
"coolant_sec_0_pressure",
|
||||
"coolant_sec_0_temperature",
|
||||
"coolant_sec_0_volume"
|
||||
],
|
||||
"vars": {
|
||||
"condenser_volume": {
|
||||
"display_name": "CONDENSER_VOLUME",
|
||||
"thresholds": {
|
||||
"dead_low": null,
|
||||
"low": null,
|
||||
"high": null,
|
||||
"extreme_high": null,
|
||||
"alarm_dead_low": true,
|
||||
"alarm_low": false,
|
||||
"alarm_high": false,
|
||||
"alarm_extreme_high": true,
|
||||
"action_dead_low": null,
|
||||
"value_dead_low": "1",
|
||||
"action_dead_low_interval": 1.0,
|
||||
"action_low": null,
|
||||
"value_low": "1",
|
||||
"action_low_interval": 1.0,
|
||||
"action_high": null,
|
||||
"value_high": "1",
|
||||
"action_high_interval": 1.0,
|
||||
"action_extreme_high": null,
|
||||
"value_extreme_high": "1",
|
||||
"action_extreme_high_interval": 1.0,
|
||||
"action_operating": null,
|
||||
"value_operating": "1",
|
||||
"action_operating_interval": 1.0,
|
||||
"expr_dead_low": null,
|
||||
"expr_low": null,
|
||||
"expr_operating": null,
|
||||
"expr_high": null,
|
||||
"expr_extreme_high": null,
|
||||
"expr_target_dead_low": null,
|
||||
"expr_target_low": null,
|
||||
"expr_target_operating": null,
|
||||
"expr_target_high": null,
|
||||
"expr_target_extreme_high": null,
|
||||
"expr_operating_interval": 1.0,
|
||||
"y_source": null,
|
||||
"z_source": null,
|
||||
"expr_x_source_dead_low": "raw",
|
||||
"expr_x_source_low": "raw",
|
||||
"expr_x_source_operating": "raw",
|
||||
"expr_x_source_high": "raw",
|
||||
"expr_x_source_extreme_high": "raw",
|
||||
"expr_thr_dead_low": null,
|
||||
"expr_thr_low": null,
|
||||
"expr_thr_high": null,
|
||||
"expr_thr_extreme_high": null
|
||||
}
|
||||
},
|
||||
"coolant_core_flow_reached_speed": {
|
||||
"display_name": "COOLANT_CORE_FLOW_REACHED_SPEED",
|
||||
"thresholds": {
|
||||
"dead_low": null,
|
||||
"low": null,
|
||||
"high": null,
|
||||
"extreme_high": null,
|
||||
"alarm_dead_low": true,
|
||||
"alarm_low": false,
|
||||
"alarm_high": false,
|
||||
"alarm_extreme_high": true,
|
||||
"action_dead_low": null,
|
||||
"value_dead_low": "1",
|
||||
"action_dead_low_interval": 1.0,
|
||||
"action_low": null,
|
||||
"value_low": "1",
|
||||
"action_low_interval": 1.0,
|
||||
"action_high": null,
|
||||
"value_high": "1",
|
||||
"action_high_interval": 1.0,
|
||||
"action_extreme_high": null,
|
||||
"value_extreme_high": "1",
|
||||
"action_extreme_high_interval": 1.0,
|
||||
"action_operating": null,
|
||||
"value_operating": "1",
|
||||
"action_operating_interval": 1.0,
|
||||
"expr_dead_low": null,
|
||||
"expr_low": null,
|
||||
"expr_operating": null,
|
||||
"expr_high": null,
|
||||
"expr_extreme_high": null,
|
||||
"expr_target_dead_low": null,
|
||||
"expr_target_low": null,
|
||||
"expr_target_operating": null,
|
||||
"expr_target_high": null,
|
||||
"expr_target_extreme_high": null,
|
||||
"expr_operating_interval": 1.0,
|
||||
"y_source": null,
|
||||
"z_source": null,
|
||||
"expr_x_source_dead_low": "raw",
|
||||
"expr_x_source_low": "raw",
|
||||
"expr_x_source_operating": "raw",
|
||||
"expr_x_source_high": "raw",
|
||||
"expr_x_source_extreme_high": "raw",
|
||||
"expr_thr_dead_low": null,
|
||||
"expr_thr_low": null,
|
||||
"expr_thr_high": null,
|
||||
"expr_thr_extreme_high": null
|
||||
}
|
||||
},
|
||||
"coolant_core_flow_speed": {
|
||||
"display_name": "COOLANT_CORE_FLOW_SPEED",
|
||||
"thresholds": {
|
||||
"dead_low": null,
|
||||
"low": null,
|
||||
"high": null,
|
||||
"extreme_high": null,
|
||||
"alarm_dead_low": true,
|
||||
"alarm_low": false,
|
||||
"alarm_high": false,
|
||||
"alarm_extreme_high": true,
|
||||
"action_dead_low": null,
|
||||
"value_dead_low": "1",
|
||||
"action_dead_low_interval": 1.0,
|
||||
"action_low": null,
|
||||
"value_low": "1",
|
||||
"action_low_interval": 1.0,
|
||||
"action_high": null,
|
||||
"value_high": "1",
|
||||
"action_high_interval": 1.0,
|
||||
"action_extreme_high": null,
|
||||
"value_extreme_high": "1",
|
||||
"action_extreme_high_interval": 1.0,
|
||||
"action_operating": null,
|
||||
"value_operating": "1",
|
||||
"action_operating_interval": 1.0,
|
||||
"expr_dead_low": null,
|
||||
"expr_low": null,
|
||||
"expr_operating": null,
|
||||
"expr_high": null,
|
||||
"expr_extreme_high": null,
|
||||
"expr_target_dead_low": null,
|
||||
"expr_target_low": null,
|
||||
"expr_target_operating": null,
|
||||
"expr_target_high": null,
|
||||
"expr_target_extreme_high": null,
|
||||
"expr_operating_interval": 1.0,
|
||||
"y_source": null,
|
||||
"z_source": null,
|
||||
"expr_x_source_dead_low": "raw",
|
||||
"expr_x_source_low": "raw",
|
||||
"expr_x_source_operating": "raw",
|
||||
"expr_x_source_high": "raw",
|
||||
"expr_x_source_extreme_high": "raw",
|
||||
"expr_thr_dead_low": null,
|
||||
"expr_thr_low": null,
|
||||
"expr_thr_high": null,
|
||||
"expr_thr_extreme_high": null
|
||||
}
|
||||
},
|
||||
"core_iodine_cumulative": {
|
||||
"display_name": "CORE_IODINE_CUMULATIVE",
|
||||
"thresholds": {
|
||||
"dead_low": null,
|
||||
"low": null,
|
||||
"high": null,
|
||||
"extreme_high": null,
|
||||
"alarm_dead_low": true,
|
||||
"alarm_low": false,
|
||||
"alarm_high": false,
|
||||
"alarm_extreme_high": true,
|
||||
"action_dead_low": null,
|
||||
"value_dead_low": "1",
|
||||
"action_dead_low_interval": 1.0,
|
||||
"action_low": null,
|
||||
"value_low": "1",
|
||||
"action_low_interval": 1.0,
|
||||
"action_high": null,
|
||||
"value_high": "1",
|
||||
"action_high_interval": 1.0,
|
||||
"action_extreme_high": null,
|
||||
"value_extreme_high": "1",
|
||||
"action_extreme_high_interval": 1.0,
|
||||
"action_operating": null,
|
||||
"value_operating": "1",
|
||||
"action_operating_interval": 1.0,
|
||||
"expr_dead_low": null,
|
||||
"expr_low": null,
|
||||
"expr_operating": null,
|
||||
"expr_high": null,
|
||||
"expr_extreme_high": null,
|
||||
"expr_target_dead_low": null,
|
||||
"expr_target_low": null,
|
||||
"expr_target_operating": null,
|
||||
"expr_target_high": null,
|
||||
"expr_target_extreme_high": null,
|
||||
"expr_operating_interval": 1.0,
|
||||
"y_source": null,
|
||||
"z_source": null,
|
||||
"expr_x_source_dead_low": "raw",
|
||||
"expr_x_source_low": "raw",
|
||||
"expr_x_source_operating": "raw",
|
||||
"expr_x_source_high": "raw",
|
||||
"expr_x_source_extreme_high": "raw",
|
||||
"expr_thr_dead_low": null,
|
||||
"expr_thr_low": null,
|
||||
"expr_thr_high": null,
|
||||
"expr_thr_extreme_high": null
|
||||
}
|
||||
},
|
||||
"core_iodine_generation": {
|
||||
"display_name": "CORE_IODINE_GENERATION",
|
||||
"thresholds": {
|
||||
"dead_low": null,
|
||||
"low": null,
|
||||
"high": null,
|
||||
"extreme_high": null,
|
||||
"alarm_dead_low": true,
|
||||
"alarm_low": false,
|
||||
"alarm_high": false,
|
||||
"alarm_extreme_high": true,
|
||||
"action_dead_low": null,
|
||||
"value_dead_low": "1",
|
||||
"action_dead_low_interval": 1.0,
|
||||
"action_low": null,
|
||||
"value_low": "1",
|
||||
"action_low_interval": 1.0,
|
||||
"action_high": null,
|
||||
"value_high": "1",
|
||||
"action_high_interval": 1.0,
|
||||
"action_extreme_high": null,
|
||||
"value_extreme_high": "1",
|
||||
"action_extreme_high_interval": 1.0,
|
||||
"action_operating": null,
|
||||
"value_operating": "1",
|
||||
"action_operating_interval": 1.0,
|
||||
"expr_dead_low": null,
|
||||
"expr_low": null,
|
||||
"expr_operating": null,
|
||||
"expr_high": null,
|
||||
"expr_extreme_high": null,
|
||||
"expr_target_dead_low": null,
|
||||
"expr_target_low": null,
|
||||
"expr_target_operating": null,
|
||||
"expr_target_high": null,
|
||||
"expr_target_extreme_high": null,
|
||||
"expr_operating_interval": 1.0,
|
||||
"y_source": null,
|
||||
"z_source": null,
|
||||
"expr_x_source_dead_low": "raw",
|
||||
"expr_x_source_low": "raw",
|
||||
"expr_x_source_operating": "raw",
|
||||
"expr_x_source_high": "raw",
|
||||
"expr_x_source_extreme_high": "raw",
|
||||
"expr_thr_dead_low": null,
|
||||
"expr_thr_low": null,
|
||||
"expr_thr_high": null,
|
||||
"expr_thr_extreme_high": null
|
||||
}
|
||||
},
|
||||
"core_state_criticality": {
|
||||
"display_name": "CORE_STATE_CRITICALITY",
|
||||
"thresholds": {
|
||||
"dead_low": null,
|
||||
"low": null,
|
||||
"high": null,
|
||||
"extreme_high": null,
|
||||
"alarm_dead_low": true,
|
||||
"alarm_low": false,
|
||||
"alarm_high": false,
|
||||
"alarm_extreme_high": true,
|
||||
"action_dead_low": null,
|
||||
"value_dead_low": "1",
|
||||
"action_dead_low_interval": 1.0,
|
||||
"action_low": null,
|
||||
"value_low": "1",
|
||||
"action_low_interval": 1.0,
|
||||
"action_high": null,
|
||||
"value_high": "1",
|
||||
"action_high_interval": 1.0,
|
||||
"action_extreme_high": null,
|
||||
"value_extreme_high": "1",
|
||||
"action_extreme_high_interval": 1.0,
|
||||
"action_operating": null,
|
||||
"value_operating": "1",
|
||||
"action_operating_interval": 1.0,
|
||||
"expr_dead_low": null,
|
||||
"expr_low": null,
|
||||
"expr_operating": null,
|
||||
"expr_high": null,
|
||||
"expr_extreme_high": null,
|
||||
"expr_target_dead_low": null,
|
||||
"expr_target_low": null,
|
||||
"expr_target_operating": null,
|
||||
"expr_target_high": null,
|
||||
"expr_target_extreme_high": null,
|
||||
"expr_operating_interval": 1.0,
|
||||
"y_source": null,
|
||||
"z_source": null,
|
||||
"expr_x_source_dead_low": "raw",
|
||||
"expr_x_source_low": "raw",
|
||||
"expr_x_source_operating": "raw",
|
||||
"expr_x_source_high": "raw",
|
||||
"expr_x_source_extreme_high": "raw",
|
||||
"expr_thr_dead_low": null,
|
||||
"expr_thr_low": null,
|
||||
"expr_thr_high": null,
|
||||
"expr_thr_extreme_high": null
|
||||
}
|
||||
},
|
||||
"core_temp": {
|
||||
"display_name": "CORE_TEMP",
|
||||
"thresholds": {
|
||||
"dead_low": null,
|
||||
"low": null,
|
||||
"high": null,
|
||||
"extreme_high": null,
|
||||
"alarm_dead_low": true,
|
||||
"alarm_low": false,
|
||||
"alarm_high": false,
|
||||
"alarm_extreme_high": true,
|
||||
"action_dead_low": null,
|
||||
"value_dead_low": "1",
|
||||
"action_dead_low_interval": 1.0,
|
||||
"action_low": null,
|
||||
"value_low": "1",
|
||||
"action_low_interval": 1.0,
|
||||
"action_high": null,
|
||||
"value_high": "1",
|
||||
"action_high_interval": 1.0,
|
||||
"action_extreme_high": null,
|
||||
"value_extreme_high": "1",
|
||||
"action_extreme_high_interval": 1.0,
|
||||
"action_operating": null,
|
||||
"value_operating": "1",
|
||||
"action_operating_interval": 1.0,
|
||||
"expr_dead_low": null,
|
||||
"expr_low": null,
|
||||
"expr_operating": null,
|
||||
"expr_high": null,
|
||||
"expr_extreme_high": null,
|
||||
"expr_target_dead_low": null,
|
||||
"expr_target_low": null,
|
||||
"expr_target_operating": null,
|
||||
"expr_target_high": null,
|
||||
"expr_target_extreme_high": null,
|
||||
"expr_operating_interval": 1.0,
|
||||
"y_source": null,
|
||||
"z_source": null,
|
||||
"expr_x_source_dead_low": "raw",
|
||||
"expr_x_source_low": "raw",
|
||||
"expr_x_source_operating": "raw",
|
||||
"expr_x_source_high": "raw",
|
||||
"expr_x_source_extreme_high": "raw",
|
||||
"expr_thr_dead_low": null,
|
||||
"expr_thr_low": null,
|
||||
"expr_thr_high": null,
|
||||
"expr_thr_extreme_high": null
|
||||
}
|
||||
},
|
||||
"core_xenon_cumulative": {
|
||||
"display_name": "CORE_XENON_CUMULATIVE",
|
||||
"thresholds": {
|
||||
"dead_low": null,
|
||||
"low": null,
|
||||
"high": null,
|
||||
"extreme_high": null,
|
||||
"alarm_dead_low": true,
|
||||
"alarm_low": false,
|
||||
"alarm_high": false,
|
||||
"alarm_extreme_high": true,
|
||||
"action_dead_low": null,
|
||||
"value_dead_low": "1",
|
||||
"action_dead_low_interval": 1.0,
|
||||
"action_low": null,
|
||||
"value_low": "1",
|
||||
"action_low_interval": 1.0,
|
||||
"action_high": null,
|
||||
"value_high": "1",
|
||||
"action_high_interval": 1.0,
|
||||
"action_extreme_high": null,
|
||||
"value_extreme_high": "1",
|
||||
"action_extreme_high_interval": 1.0,
|
||||
"action_operating": null,
|
||||
"value_operating": "1",
|
||||
"action_operating_interval": 1.0,
|
||||
"expr_dead_low": null,
|
||||
"expr_low": null,
|
||||
"expr_operating": null,
|
||||
"expr_high": null,
|
||||
"expr_extreme_high": null,
|
||||
"expr_target_dead_low": null,
|
||||
"expr_target_low": null,
|
||||
"expr_target_operating": null,
|
||||
"expr_target_high": null,
|
||||
"expr_target_extreme_high": null,
|
||||
"expr_operating_interval": 1.0,
|
||||
"y_source": null,
|
||||
"z_source": null,
|
||||
"expr_x_source_dead_low": "raw",
|
||||
"expr_x_source_low": "raw",
|
||||
"expr_x_source_operating": "raw",
|
||||
"expr_x_source_high": "raw",
|
||||
"expr_x_source_extreme_high": "raw",
|
||||
"expr_thr_dead_low": null,
|
||||
"expr_thr_low": null,
|
||||
"expr_thr_high": null,
|
||||
"expr_thr_extreme_high": null
|
||||
}
|
||||
},
|
||||
"core_xenon_generation": {
|
||||
"display_name": "CORE_XENON_GENERATION",
|
||||
"thresholds": {
|
||||
"dead_low": null,
|
||||
"low": null,
|
||||
"high": null,
|
||||
"extreme_high": null,
|
||||
"alarm_dead_low": true,
|
||||
"alarm_low": false,
|
||||
"alarm_high": false,
|
||||
"alarm_extreme_high": true,
|
||||
"action_dead_low": null,
|
||||
"value_dead_low": "1",
|
||||
"action_dead_low_interval": 1.0,
|
||||
"action_low": null,
|
||||
"value_low": "1",
|
||||
"action_low_interval": 1.0,
|
||||
"action_high": null,
|
||||
"value_high": "1",
|
||||
"action_high_interval": 1.0,
|
||||
"action_extreme_high": null,
|
||||
"value_extreme_high": "1",
|
||||
"action_extreme_high_interval": 1.0,
|
||||
"action_operating": null,
|
||||
"value_operating": "1",
|
||||
"action_operating_interval": 1.0,
|
||||
"expr_dead_low": null,
|
||||
"expr_low": null,
|
||||
"expr_operating": null,
|
||||
"expr_high": null,
|
||||
"expr_extreme_high": null,
|
||||
"expr_target_dead_low": null,
|
||||
"expr_target_low": null,
|
||||
"expr_target_operating": null,
|
||||
"expr_target_high": null,
|
||||
"expr_target_extreme_high": null,
|
||||
"expr_operating_interval": 1.0,
|
||||
"y_source": null,
|
||||
"z_source": null,
|
||||
"expr_x_source_dead_low": "raw",
|
||||
"expr_x_source_low": "raw",
|
||||
"expr_x_source_operating": "raw",
|
||||
"expr_x_source_high": "raw",
|
||||
"expr_x_source_extreme_high": "raw",
|
||||
"expr_thr_dead_low": null,
|
||||
"expr_thr_low": null,
|
||||
"expr_thr_high": null,
|
||||
"expr_thr_extreme_high": null
|
||||
}
|
||||
},
|
||||
"generator_0_kw": {
|
||||
"display_name": "GENERATOR_0_KW",
|
||||
"thresholds": {
|
||||
"dead_low": null,
|
||||
"low": null,
|
||||
"high": null,
|
||||
"extreme_high": null,
|
||||
"alarm_dead_low": true,
|
||||
"alarm_low": false,
|
||||
"alarm_high": false,
|
||||
"alarm_extreme_high": true,
|
||||
"action_dead_low": null,
|
||||
"value_dead_low": "1",
|
||||
"action_dead_low_interval": 1.0,
|
||||
"action_low": null,
|
||||
"value_low": "1",
|
||||
"action_low_interval": 1.0,
|
||||
"action_high": null,
|
||||
"value_high": "1",
|
||||
"action_high_interval": 1.0,
|
||||
"action_extreme_high": null,
|
||||
"value_extreme_high": "1",
|
||||
"action_extreme_high_interval": 1.0,
|
||||
"action_operating": null,
|
||||
"value_operating": "1",
|
||||
"action_operating_interval": 1.0,
|
||||
"expr_dead_low": null,
|
||||
"expr_low": null,
|
||||
"expr_operating": null,
|
||||
"expr_high": null,
|
||||
"expr_extreme_high": null,
|
||||
"expr_target_dead_low": null,
|
||||
"expr_target_low": null,
|
||||
"expr_target_operating": null,
|
||||
"expr_target_high": null,
|
||||
"expr_target_extreme_high": null,
|
||||
"expr_operating_interval": 1.0,
|
||||
"y_source": null,
|
||||
"z_source": null,
|
||||
"expr_x_source_dead_low": "raw",
|
||||
"expr_x_source_low": "raw",
|
||||
"expr_x_source_operating": "raw",
|
||||
"expr_x_source_high": "raw",
|
||||
"expr_x_source_extreme_high": "raw",
|
||||
"expr_thr_dead_low": null,
|
||||
"expr_thr_low": null,
|
||||
"expr_thr_high": null,
|
||||
"expr_thr_extreme_high": null
|
||||
}
|
||||
},
|
||||
"steam_turbine_0_pressure": {
|
||||
"display_name": "STEAM_TURBINE_0_PRESSURE",
|
||||
"thresholds": {
|
||||
"dead_low": null,
|
||||
"low": null,
|
||||
"high": null,
|
||||
"extreme_high": null,
|
||||
"alarm_dead_low": true,
|
||||
"alarm_low": false,
|
||||
"alarm_high": false,
|
||||
"alarm_extreme_high": true,
|
||||
"action_dead_low": null,
|
||||
"value_dead_low": "1",
|
||||
"action_dead_low_interval": 1.0,
|
||||
"action_low": null,
|
||||
"value_low": "1",
|
||||
"action_low_interval": 1.0,
|
||||
"action_high": null,
|
||||
"value_high": "1",
|
||||
"action_high_interval": 1.0,
|
||||
"action_extreme_high": null,
|
||||
"value_extreme_high": "1",
|
||||
"action_extreme_high_interval": 1.0,
|
||||
"action_operating": null,
|
||||
"value_operating": "1",
|
||||
"action_operating_interval": 1.0,
|
||||
"expr_dead_low": null,
|
||||
"expr_low": null,
|
||||
"expr_operating": null,
|
||||
"expr_high": null,
|
||||
"expr_extreme_high": null,
|
||||
"expr_target_dead_low": null,
|
||||
"expr_target_low": null,
|
||||
"expr_target_operating": null,
|
||||
"expr_target_high": null,
|
||||
"expr_target_extreme_high": null,
|
||||
"expr_operating_interval": 1.0,
|
||||
"y_source": null,
|
||||
"z_source": null,
|
||||
"expr_x_source_dead_low": "raw",
|
||||
"expr_x_source_low": "raw",
|
||||
"expr_x_source_operating": "raw",
|
||||
"expr_x_source_high": "raw",
|
||||
"expr_x_source_extreme_high": "raw",
|
||||
"expr_thr_dead_low": null,
|
||||
"expr_thr_low": null,
|
||||
"expr_thr_high": null,
|
||||
"expr_thr_extreme_high": null
|
||||
}
|
||||
},
|
||||
"steam_turbine_0_rpm": {
|
||||
"display_name": "STEAM_TURBINE_0_RPM",
|
||||
"thresholds": {
|
||||
"dead_low": null,
|
||||
"low": null,
|
||||
"high": null,
|
||||
"extreme_high": null,
|
||||
"alarm_dead_low": true,
|
||||
"alarm_low": false,
|
||||
"alarm_high": false,
|
||||
"alarm_extreme_high": true,
|
||||
"action_dead_low": null,
|
||||
"value_dead_low": "1",
|
||||
"action_dead_low_interval": 1.0,
|
||||
"action_low": null,
|
||||
"value_low": "1",
|
||||
"action_low_interval": 1.0,
|
||||
"action_high": null,
|
||||
"value_high": "1",
|
||||
"action_high_interval": 1.0,
|
||||
"action_extreme_high": null,
|
||||
"value_extreme_high": "1",
|
||||
"action_extreme_high_interval": 1.0,
|
||||
"action_operating": null,
|
||||
"value_operating": "1",
|
||||
"action_operating_interval": 1.0,
|
||||
"expr_dead_low": null,
|
||||
"expr_low": null,
|
||||
"expr_operating": null,
|
||||
"expr_high": null,
|
||||
"expr_extreme_high": null,
|
||||
"expr_target_dead_low": null,
|
||||
"expr_target_low": null,
|
||||
"expr_target_operating": null,
|
||||
"expr_target_high": null,
|
||||
"expr_target_extreme_high": null,
|
||||
"expr_operating_interval": 1.0,
|
||||
"y_source": null,
|
||||
"z_source": null,
|
||||
"expr_x_source_dead_low": "raw",
|
||||
"expr_x_source_low": "raw",
|
||||
"expr_x_source_operating": "raw",
|
||||
"expr_x_source_high": "raw",
|
||||
"expr_x_source_extreme_high": "raw",
|
||||
"expr_thr_dead_low": null,
|
||||
"expr_thr_low": null,
|
||||
"expr_thr_high": null,
|
||||
"expr_thr_extreme_high": null
|
||||
}
|
||||
},
|
||||
"steam_turbine_0_temperature": {
|
||||
"display_name": "STEAM_TURBINE_0_TEMPERATURE",
|
||||
"thresholds": {
|
||||
"dead_low": null,
|
||||
"low": null,
|
||||
"high": null,
|
||||
"extreme_high": null,
|
||||
"alarm_dead_low": true,
|
||||
"alarm_low": false,
|
||||
"alarm_high": false,
|
||||
"alarm_extreme_high": true,
|
||||
"action_dead_low": null,
|
||||
"value_dead_low": "1",
|
||||
"action_dead_low_interval": 1.0,
|
||||
"action_low": null,
|
||||
"value_low": "1",
|
||||
"action_low_interval": 1.0,
|
||||
"action_high": null,
|
||||
"value_high": "1",
|
||||
"action_high_interval": 1.0,
|
||||
"action_extreme_high": null,
|
||||
"value_extreme_high": "1",
|
||||
"action_extreme_high_interval": 1.0,
|
||||
"action_operating": null,
|
||||
"value_operating": "1",
|
||||
"action_operating_interval": 1.0,
|
||||
"expr_dead_low": null,
|
||||
"expr_low": null,
|
||||
"expr_operating": null,
|
||||
"expr_high": null,
|
||||
"expr_extreme_high": null,
|
||||
"expr_target_dead_low": null,
|
||||
"expr_target_low": null,
|
||||
"expr_target_operating": null,
|
||||
"expr_target_high": null,
|
||||
"expr_target_extreme_high": null,
|
||||
"expr_operating_interval": 1.0,
|
||||
"y_source": null,
|
||||
"z_source": null,
|
||||
"expr_x_source_dead_low": "raw",
|
||||
"expr_x_source_low": "raw",
|
||||
"expr_x_source_operating": "raw",
|
||||
"expr_x_source_high": "raw",
|
||||
"expr_x_source_extreme_high": "raw",
|
||||
"expr_thr_dead_low": null,
|
||||
"expr_thr_low": null,
|
||||
"expr_thr_high": null,
|
||||
"expr_thr_extreme_high": null
|
||||
}
|
||||
},
|
||||
"coolant_sec_0_liquid_volume": {
|
||||
"display_name": "COOLANT_SEC_0_LIQUID_VOLUME",
|
||||
"thresholds": {
|
||||
"dead_low": null,
|
||||
"low": null,
|
||||
"high": null,
|
||||
"extreme_high": null,
|
||||
"alarm_dead_low": true,
|
||||
"alarm_low": false,
|
||||
"alarm_high": false,
|
||||
"alarm_extreme_high": true,
|
||||
"action_dead_low": null,
|
||||
"value_dead_low": "1",
|
||||
"action_dead_low_interval": 1.0,
|
||||
"action_low": null,
|
||||
"value_low": "1",
|
||||
"action_low_interval": 1.0,
|
||||
"action_high": null,
|
||||
"value_high": "1",
|
||||
"action_high_interval": 1.0,
|
||||
"action_extreme_high": null,
|
||||
"value_extreme_high": "1",
|
||||
"action_extreme_high_interval": 1.0,
|
||||
"action_operating": null,
|
||||
"value_operating": "1",
|
||||
"action_operating_interval": 1.0,
|
||||
"expr_dead_low": null,
|
||||
"expr_low": null,
|
||||
"expr_operating": null,
|
||||
"expr_high": null,
|
||||
"expr_extreme_high": null,
|
||||
"expr_target_dead_low": null,
|
||||
"expr_target_low": null,
|
||||
"expr_target_operating": null,
|
||||
"expr_target_high": null,
|
||||
"expr_target_extreme_high": null,
|
||||
"expr_operating_interval": 1.0,
|
||||
"y_source": null,
|
||||
"z_source": null,
|
||||
"expr_x_source_dead_low": "raw",
|
||||
"expr_x_source_low": "raw",
|
||||
"expr_x_source_operating": "raw",
|
||||
"expr_x_source_high": "raw",
|
||||
"expr_x_source_extreme_high": "raw",
|
||||
"expr_thr_dead_low": null,
|
||||
"expr_thr_low": null,
|
||||
"expr_thr_high": null,
|
||||
"expr_thr_extreme_high": null
|
||||
}
|
||||
},
|
||||
"coolant_sec_0_pressure": {
|
||||
"display_name": "COOLANT_SEC_0_PRESSURE",
|
||||
"thresholds": {
|
||||
"dead_low": null,
|
||||
"low": null,
|
||||
"high": null,
|
||||
"extreme_high": null,
|
||||
"alarm_dead_low": true,
|
||||
"alarm_low": false,
|
||||
"alarm_high": false,
|
||||
"alarm_extreme_high": true,
|
||||
"action_dead_low": null,
|
||||
"value_dead_low": "1",
|
||||
"action_dead_low_interval": 1.0,
|
||||
"action_low": null,
|
||||
"value_low": "1",
|
||||
"action_low_interval": 1.0,
|
||||
"action_high": null,
|
||||
"value_high": "1",
|
||||
"action_high_interval": 1.0,
|
||||
"action_extreme_high": null,
|
||||
"value_extreme_high": "1",
|
||||
"action_extreme_high_interval": 1.0,
|
||||
"action_operating": null,
|
||||
"value_operating": "1",
|
||||
"action_operating_interval": 1.0,
|
||||
"expr_dead_low": null,
|
||||
"expr_low": null,
|
||||
"expr_operating": null,
|
||||
"expr_high": null,
|
||||
"expr_extreme_high": null,
|
||||
"expr_target_dead_low": null,
|
||||
"expr_target_low": null,
|
||||
"expr_target_operating": null,
|
||||
"expr_target_high": null,
|
||||
"expr_target_extreme_high": null,
|
||||
"expr_operating_interval": 1.0,
|
||||
"y_source": null,
|
||||
"z_source": null,
|
||||
"expr_x_source_dead_low": "raw",
|
||||
"expr_x_source_low": "raw",
|
||||
"expr_x_source_operating": "raw",
|
||||
"expr_x_source_high": "raw",
|
||||
"expr_x_source_extreme_high": "raw",
|
||||
"expr_thr_dead_low": null,
|
||||
"expr_thr_low": null,
|
||||
"expr_thr_high": null,
|
||||
"expr_thr_extreme_high": null
|
||||
}
|
||||
},
|
||||
"coolant_sec_0_temperature": {
|
||||
"display_name": "COOLANT_SEC_0_TEMPERATURE",
|
||||
"thresholds": {
|
||||
"dead_low": null,
|
||||
"low": null,
|
||||
"high": null,
|
||||
"extreme_high": null,
|
||||
"alarm_dead_low": true,
|
||||
"alarm_low": false,
|
||||
"alarm_high": false,
|
||||
"alarm_extreme_high": true,
|
||||
"action_dead_low": null,
|
||||
"value_dead_low": "1",
|
||||
"action_dead_low_interval": 1.0,
|
||||
"action_low": null,
|
||||
"value_low": "1",
|
||||
"action_low_interval": 1.0,
|
||||
"action_high": null,
|
||||
"value_high": "1",
|
||||
"action_high_interval": 1.0,
|
||||
"action_extreme_high": null,
|
||||
"value_extreme_high": "1",
|
||||
"action_extreme_high_interval": 1.0,
|
||||
"action_operating": null,
|
||||
"value_operating": "1",
|
||||
"action_operating_interval": 1.0,
|
||||
"expr_dead_low": null,
|
||||
"expr_low": null,
|
||||
"expr_operating": null,
|
||||
"expr_high": null,
|
||||
"expr_extreme_high": null,
|
||||
"expr_target_dead_low": null,
|
||||
"expr_target_low": null,
|
||||
"expr_target_operating": null,
|
||||
"expr_target_high": null,
|
||||
"expr_target_extreme_high": null,
|
||||
"expr_operating_interval": 1.0,
|
||||
"y_source": null,
|
||||
"z_source": null,
|
||||
"expr_x_source_dead_low": "raw",
|
||||
"expr_x_source_low": "raw",
|
||||
"expr_x_source_operating": "raw",
|
||||
"expr_x_source_high": "raw",
|
||||
"expr_x_source_extreme_high": "raw",
|
||||
"expr_thr_dead_low": null,
|
||||
"expr_thr_low": null,
|
||||
"expr_thr_high": null,
|
||||
"expr_thr_extreme_high": null
|
||||
}
|
||||
},
|
||||
"coolant_sec_0_volume": {
|
||||
"display_name": "COOLANT_SEC_0_VOLUME",
|
||||
"thresholds": {
|
||||
"dead_low": null,
|
||||
"low": null,
|
||||
"high": null,
|
||||
"extreme_high": null,
|
||||
"alarm_dead_low": true,
|
||||
"alarm_low": false,
|
||||
"alarm_high": false,
|
||||
"alarm_extreme_high": true,
|
||||
"action_dead_low": null,
|
||||
"value_dead_low": "1",
|
||||
"action_dead_low_interval": 1.0,
|
||||
"action_low": null,
|
||||
"value_low": "1",
|
||||
"action_low_interval": 1.0,
|
||||
"action_high": null,
|
||||
"value_high": "1",
|
||||
"action_high_interval": 1.0,
|
||||
"action_extreme_high": null,
|
||||
"value_extreme_high": "1",
|
||||
"action_extreme_high_interval": 1.0,
|
||||
"action_operating": null,
|
||||
"value_operating": "1",
|
||||
"action_operating_interval": 1.0,
|
||||
"expr_dead_low": null,
|
||||
"expr_low": null,
|
||||
"expr_operating": null,
|
||||
"expr_high": null,
|
||||
"expr_extreme_high": null,
|
||||
"expr_target_dead_low": null,
|
||||
"expr_target_low": null,
|
||||
"expr_target_operating": null,
|
||||
"expr_target_high": null,
|
||||
"expr_target_extreme_high": null,
|
||||
"expr_operating_interval": 1.0,
|
||||
"y_source": null,
|
||||
"z_source": null,
|
||||
"expr_x_source_dead_low": "raw",
|
||||
"expr_x_source_low": "raw",
|
||||
"expr_x_source_operating": "raw",
|
||||
"expr_x_source_high": "raw",
|
||||
"expr_x_source_extreme_high": "raw",
|
||||
"expr_thr_dead_low": null,
|
||||
"expr_thr_low": null,
|
||||
"expr_thr_high": null,
|
||||
"expr_thr_extreme_high": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"scheduled_tasks": [],
|
||||
"display_defaults": {
|
||||
"backend": "pyqtgraph",
|
||||
"samples": 200,
|
||||
"max_draw_pts": 400
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,140 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Quick test to verify the arrange_plot_windows function improvements.
|
||||
This script simulates the functionality without running the full nuclear monitor.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
def test_best_grid():
|
||||
"""Test the improved grid calculation"""
|
||||
def best_grid(n):
|
||||
if n <= 0:
|
||||
return (1, 1)
|
||||
# For small number of windows, prefer horizontal layout
|
||||
if n <= 3:
|
||||
return (1, n)
|
||||
if n == 4:
|
||||
return (2, 2)
|
||||
|
||||
# For larger numbers: approximate square, but prefer width
|
||||
cols = int(math.ceil(math.sqrt(n)))
|
||||
if cols * (cols - 1) >= n: # check if we can reduce rows
|
||||
cols -= 1
|
||||
rows = int(math.ceil(n / cols))
|
||||
return (rows, cols)
|
||||
|
||||
test_cases = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 16]
|
||||
|
||||
print("Grid layout tests:")
|
||||
print("Windows | Grid (rows x cols) | Layout")
|
||||
print("--------|-------------------|--------")
|
||||
|
||||
for n in test_cases:
|
||||
rows, cols = best_grid(n)
|
||||
layout = "x".join(["O"] * cols)
|
||||
layout = " | ".join([layout] * rows)
|
||||
print(f"{n:7d} | {rows:2d} x {cols:2d} | {layout}")
|
||||
|
||||
def test_window_padding():
|
||||
"""Test the window-specific padding"""
|
||||
def get_window_padding(kind):
|
||||
if kind == "qt":
|
||||
return (15, 40) # Qt needs more space for title bar
|
||||
else:
|
||||
return (8, 12) # Tk windows
|
||||
|
||||
print("\nWindow padding tests:")
|
||||
print("Type | Width Pad | Height Pad | Reason")
|
||||
print("-----|-----------|------------|--------")
|
||||
|
||||
for kind in ["tk", "qt"]:
|
||||
w_pad, h_pad = get_window_padding(kind)
|
||||
reason = "Qt title bar & frames" if kind == "qt" else "Tk decorations"
|
||||
print(f"{kind:4s} | {w_pad:9d} | {h_pad:10d} | {reason}")
|
||||
|
||||
def test_arrangement_calculation():
|
||||
"""Test the arrangement calculation for different scenarios"""
|
||||
print("\nArrangement calculation test:")
|
||||
|
||||
# Simulate monitor: 1920x1080
|
||||
mon_w, mon_h = 1920, 1080
|
||||
SIDE_MARGIN = 12
|
||||
TOP_MARGIN = 60
|
||||
BOTTOM_MARGIN = 12
|
||||
CELL_PAD = 10
|
||||
MAX_W, MAX_H = 380, 210 # Half of 760x420
|
||||
|
||||
test_scenarios = [
|
||||
(2, "Two windows - should be side by side"),
|
||||
(4, "Four windows - 2x2 grid"),
|
||||
(6, "Six windows - 2x3 or 3x2 grid"),
|
||||
(9, "Nine windows - full screen mode"),
|
||||
]
|
||||
|
||||
def best_grid(n):
|
||||
if n <= 0:
|
||||
return (1, 1)
|
||||
if n <= 3:
|
||||
return (1, n)
|
||||
if n == 4:
|
||||
return (2, 2)
|
||||
cols = int(math.ceil(math.sqrt(n)))
|
||||
if cols * (cols - 1) >= n:
|
||||
cols -= 1
|
||||
rows = int(math.ceil(n / cols))
|
||||
return (rows, cols)
|
||||
|
||||
for n_windows, description in test_scenarios:
|
||||
print(f"\n{description}")
|
||||
print(f"Windows: {n_windows}")
|
||||
|
||||
rows, cols = best_grid(n_windows)
|
||||
use_max_size = n_windows < 9
|
||||
|
||||
# Calculate available space
|
||||
available_w = mon_w - (cols + 1) * CELL_PAD - 2 * SIDE_MARGIN
|
||||
available_h = mon_h - (rows + 1) * CELL_PAD - TOP_MARGIN - BOTTOM_MARGIN
|
||||
|
||||
# Initial cell size
|
||||
cell_w = max(1, available_w // cols)
|
||||
cell_h = max(1, available_h // rows)
|
||||
|
||||
# Apply max size constraint for few windows
|
||||
if use_max_size:
|
||||
cell_w = min(MAX_W, cell_w)
|
||||
cell_h = min(MAX_H, cell_h)
|
||||
|
||||
print(f"Grid: {rows}x{cols}")
|
||||
print(f"Available space: {available_w}x{available_h}")
|
||||
print(f"Cell size: {cell_w}x{cell_h}")
|
||||
print(f"Max size applied: {use_max_size}")
|
||||
|
||||
# Test padding for different window types
|
||||
for win_type in ["tk", "qt"]:
|
||||
if win_type == "qt":
|
||||
w_pad, h_pad = (15, 40)
|
||||
else:
|
||||
w_pad, h_pad = (8, 12)
|
||||
|
||||
adj_w = max(50, cell_w - w_pad)
|
||||
adj_h = max(50, cell_h - h_pad)
|
||||
print(f" {win_type} windows: {adj_w}x{adj_h} (after padding)")
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Testing arrange_plot_windows improvements\n")
|
||||
print("=" * 50)
|
||||
|
||||
test_best_grid()
|
||||
test_window_padding()
|
||||
test_arrangement_calculation()
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print("All tests completed. The arrange_plot_windows function should now:")
|
||||
print("1. ✅ Properly detect both Qt and matplotlib windows")
|
||||
print("2. ✅ Use improved grid layout (prefer horizontal for few windows)")
|
||||
print("3. ✅ Apply appropriate padding for different window types")
|
||||
print("4. ✅ Arrange windows top-to-bottom, left-to-right")
|
||||
print("5. ✅ Handle multiple displays correctly")
|
||||
print("6. ✅ Scale down when windows don't fit")
|
||||
print("7. ✅ Sort windows alphabetically by display name")
|
||||
@@ -1,240 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script to verify the fixed arrange_plot_windows function.
|
||||
|
||||
This tests:
|
||||
1. Column-first arrangement (a-z in first column, then second column, etc.)
|
||||
2. Corrected padding (Qt gets less padding, matplotlib gets more padding)
|
||||
3. No double size calculation issues
|
||||
4. Proper window detection from unified storage
|
||||
"""
|
||||
|
||||
|
||||
# Mock window classes for testing
|
||||
class MockQtWindow:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
self.geometry_calls = []
|
||||
|
||||
def setGeometry(self, x, y, w, h):
|
||||
self.geometry_calls.append((x, y, w, h))
|
||||
print(f"Qt Window '{self.name}': setGeometry({x}, {y}, {w}, {h})")
|
||||
|
||||
|
||||
class MockTkWindow:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
self.geometry_calls = []
|
||||
|
||||
def geometry(self, geom_str):
|
||||
self.geometry_calls.append(geom_str)
|
||||
print(f"Tk Window '{self.name}': geometry('{geom_str}')")
|
||||
|
||||
|
||||
def test_arrangement_and_padding():
|
||||
"""Test the column-first arrangement and corrected padding values."""
|
||||
|
||||
print("=== Testing Column-First Arrangement and Corrected Padding ===\n")
|
||||
|
||||
# Test case: 6 windows arranged in a 3x2 grid (3 rows, 2 columns)
|
||||
windows = [
|
||||
("qt", MockQtWindow("PlotA"), "PlotA"),
|
||||
("tk", MockTkWindow("PlotB"), "PlotB"),
|
||||
("qt", MockQtWindow("PlotC"), "PlotC"),
|
||||
("tk", MockTkWindow("PlotD"), "PlotD"),
|
||||
("qt", MockQtWindow("PlotE"), "PlotE"),
|
||||
("tk", MockTkWindow("PlotF"), "PlotF"),
|
||||
]
|
||||
|
||||
# Grid parameters
|
||||
cols = 2
|
||||
rows = 3
|
||||
cell_w = 400
|
||||
cell_h = 300
|
||||
CELL_PAD = 10
|
||||
|
||||
# Mock monitor bounds
|
||||
L, T, R, B = 100, 100, 1500, 900
|
||||
SIDE_MARGIN = 20
|
||||
TOP_MARGIN = 50
|
||||
BOTTOM_MARGIN = 50
|
||||
|
||||
# Calculate grid layout (similar to real function)
|
||||
grid_w = cell_w * cols + (cols + 1) * CELL_PAD
|
||||
grid_h = cell_h * rows + (rows + 1) * CELL_PAD
|
||||
mon_w = R - L
|
||||
mon_h = B - T
|
||||
origin_x = L + SIDE_MARGIN + max(0, (mon_w - grid_w - 2 * SIDE_MARGIN) // 2)
|
||||
origin_y = (
|
||||
T + TOP_MARGIN + max(0, (mon_h - grid_h - TOP_MARGIN - BOTTOM_MARGIN) // 2)
|
||||
)
|
||||
|
||||
print(f"Grid: {cols}x{rows}, Cell: {cell_w}x{cell_h}")
|
||||
print(f"Monitor: ({L},{T}) to ({R},{B})")
|
||||
print(f"Grid origin: ({origin_x},{origin_y})")
|
||||
print("Expected arrangement (column-first):")
|
||||
print(" Column 1: PlotA(0,0), PlotB(0,1), PlotC(0,2)")
|
||||
print(" Column 2: PlotD(1,0), PlotE(1,1), PlotF(1,2)")
|
||||
print()
|
||||
|
||||
# Test the column-first arrangement with corrected padding
|
||||
arrangement_results = []
|
||||
i = 0
|
||||
for c in range(cols): # Column-first: iterate columns first
|
||||
for r in range(rows): # Then rows within each column
|
||||
if i >= len(windows):
|
||||
break
|
||||
kind, win, name = windows[i]
|
||||
|
||||
# Calculate position
|
||||
x = origin_x + CELL_PAD + c * (cell_w + CELL_PAD)
|
||||
y = origin_y + CELL_PAD + r * (cell_h + CELL_PAD)
|
||||
|
||||
# Apply corrected padding (Qt less, Tk/matplotlib more)
|
||||
if kind == "qt":
|
||||
w_pad, h_pad = (8, 15) # Qt: minimal frames, precise sizing
|
||||
else: # "tk" (matplotlib/canvas)
|
||||
w_pad, h_pad = (20, 50) # Tk/matplotlib: toolbar + larger decorations
|
||||
|
||||
adj_w = max(50, cell_w - w_pad)
|
||||
adj_h = max(50, cell_h - h_pad)
|
||||
|
||||
# Clamp to monitor bounds
|
||||
final_x = max(L + SIDE_MARGIN, min(x, R - SIDE_MARGIN - adj_w))
|
||||
final_y = max(T + TOP_MARGIN, min(y, B - BOTTOM_MARGIN - adj_h))
|
||||
|
||||
arrangement_results.append(
|
||||
{
|
||||
"name": name,
|
||||
"kind": kind,
|
||||
"column": c,
|
||||
"row": r,
|
||||
"padding": (w_pad, h_pad),
|
||||
"size": (adj_w, adj_h),
|
||||
"position": (final_x, final_y),
|
||||
}
|
||||
)
|
||||
|
||||
# Apply geometry
|
||||
if kind == "qt":
|
||||
win.setGeometry(final_x, final_y, adj_w, adj_h)
|
||||
else:
|
||||
win.geometry(f"{adj_w}x{adj_h}+{final_x}+{final_y}")
|
||||
|
||||
i += 1
|
||||
|
||||
print("\n=== Arrangement Results ===")
|
||||
for result in arrangement_results:
|
||||
name = result["name"]
|
||||
kind = result["kind"]
|
||||
col = result["column"]
|
||||
row = result["row"]
|
||||
w_pad, h_pad = result["padding"]
|
||||
adj_w, adj_h = result["size"]
|
||||
final_x, final_y = result["position"]
|
||||
|
||||
print(f"{name} ({kind}): Column {col}, Row {row}")
|
||||
print(
|
||||
f" Padding: {w_pad}x{h_pad} ({'minimal' if kind == 'qt' else 'toolbar+margins'})"
|
||||
)
|
||||
print(f" Final size: {adj_w}x{adj_h}")
|
||||
print(f" Position: ({final_x},{final_y})")
|
||||
print()
|
||||
|
||||
# Verify column-first arrangement
|
||||
print("=== Verification ===")
|
||||
expected_order = [
|
||||
("PlotA", 0, 0),
|
||||
("PlotB", 0, 1),
|
||||
("PlotC", 0, 2), # Column 1
|
||||
("PlotD", 1, 0),
|
||||
("PlotE", 1, 1),
|
||||
("PlotF", 1, 2), # Column 2
|
||||
]
|
||||
|
||||
success = True
|
||||
for i, (expected_name, expected_col, expected_row) in enumerate(expected_order):
|
||||
actual = arrangement_results[i]
|
||||
if (
|
||||
actual["name"] != expected_name
|
||||
or actual["column"] != expected_col
|
||||
or actual["row"] != expected_row
|
||||
):
|
||||
print(
|
||||
f"❌ Position {i}: Expected {expected_name} at ({expected_col},{expected_row}), "
|
||||
f"got {actual['name']} at ({actual['column']},{actual['row']})"
|
||||
)
|
||||
success = False
|
||||
|
||||
if success:
|
||||
print("✅ Column-first arrangement verified correctly!")
|
||||
|
||||
# Verify padding corrections
|
||||
qt_windows = [r for r in arrangement_results if r["kind"] == "qt"]
|
||||
tk_windows = [r for r in arrangement_results if r["kind"] == "tk"]
|
||||
|
||||
if qt_windows and tk_windows:
|
||||
qt_pad = qt_windows[0]["padding"]
|
||||
tk_pad = tk_windows[0]["padding"]
|
||||
|
||||
if qt_pad[0] < tk_pad[0] and qt_pad[1] < tk_pad[1]:
|
||||
print(
|
||||
"✅ Padding correction verified: Qt windows have less padding than Tk/matplotlib"
|
||||
)
|
||||
print(f" Qt padding: {qt_pad[0]}x{qt_pad[1]}")
|
||||
print(f" Tk padding: {tk_pad[0]}x{tk_pad[1]}")
|
||||
else:
|
||||
print(f"❌ Padding incorrect: Qt {qt_pad} should be less than Tk {tk_pad}")
|
||||
success = False
|
||||
|
||||
return success
|
||||
|
||||
|
||||
def test_grid_calculation():
|
||||
"""Test the grid calculation logic that prefers horizontal layouts."""
|
||||
|
||||
def best_grid(n):
|
||||
"""Find best grid dimensions preferring horizontal layouts for small n."""
|
||||
if n <= 0:
|
||||
return (0, 0)
|
||||
if n == 1:
|
||||
return (1, 1)
|
||||
if n <= 3:
|
||||
return (n, 1) # Horizontal preference for small counts
|
||||
|
||||
best_ratio = float("inf")
|
||||
best_cols, best_rows = 1, n
|
||||
|
||||
for cols in range(1, n + 1):
|
||||
rows = (n + cols - 1) // cols
|
||||
if cols * rows >= n:
|
||||
ratio = max(cols / rows, rows / cols)
|
||||
if ratio < best_ratio:
|
||||
best_ratio = ratio
|
||||
best_cols, best_rows = cols, rows
|
||||
|
||||
return (best_cols, best_rows)
|
||||
|
||||
print("\n=== Testing Grid Calculation ===")
|
||||
test_cases = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 15, 16]
|
||||
|
||||
for n in test_cases:
|
||||
cols, rows = best_grid(n)
|
||||
ratio = max(cols / rows, rows / cols) if rows > 0 else float("inf")
|
||||
print(f"Windows: {n:2d} → Grid: {cols}x{rows} (ratio: {ratio:.2f})")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Testing arrange_plot_windows fixes...\n")
|
||||
|
||||
result1 = test_arrangement_and_padding()
|
||||
result2 = test_grid_calculation()
|
||||
|
||||
if result1 and result2:
|
||||
print(
|
||||
"\n🎉 All tests passed! The arrangement and padding fixes are working correctly."
|
||||
)
|
||||
else:
|
||||
print("\n❌ Some tests failed. Check the implementation.")
|
||||
@@ -1,220 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test HIGH priority fixes from code review:
|
||||
1. Double cleanup prevention
|
||||
2. Input validation for host/port
|
||||
3. Qt window cleanup registration
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
# Add current directory to path for import
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
class TestHighPriorityFixes(unittest.TestCase):
|
||||
"""Test the HIGH priority code review fixes."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
# Mock pyqtgraph to avoid Qt dependencies in tests
|
||||
self.mock_pyqtgraph = Mock()
|
||||
self.mock_pyqtgraph.GraphicsLayoutWidget = Mock()
|
||||
self.mock_pyqtgraph.mkPen = Mock()
|
||||
self.mock_pyqtgraph.InfiniteLine = Mock()
|
||||
self.mock_pyqtgraph.TextItem = Mock()
|
||||
|
||||
with patch.dict('sys.modules', {'pyqtgraph': self.mock_pyqtgraph}):
|
||||
from nucleares_monitor.control_board_monitor import App
|
||||
self.App = App
|
||||
|
||||
def test_double_cleanup_prevention(self):
|
||||
"""Test that on_close prevents double cleanup calls."""
|
||||
with patch('tkinter.Tk'):
|
||||
app = self.App()
|
||||
|
||||
# Mock cleanup methods to track calls
|
||||
app._cleanup_qt_windows = Mock()
|
||||
app._cleanup_poller = Mock()
|
||||
app._cleanup_scheduler = Mock()
|
||||
app.destroy = Mock()
|
||||
|
||||
# First call should work normally
|
||||
app.on_close()
|
||||
|
||||
# Verify cleanup methods were called
|
||||
app._cleanup_qt_windows.assert_called_once()
|
||||
app._cleanup_poller.assert_called_once()
|
||||
app._cleanup_scheduler.assert_called_once()
|
||||
app.destroy.assert_called_once()
|
||||
|
||||
# Reset mocks
|
||||
app._cleanup_qt_windows.reset_mock()
|
||||
app._cleanup_poller.reset_mock()
|
||||
app._cleanup_scheduler.reset_mock()
|
||||
app.destroy.reset_mock()
|
||||
|
||||
# Second call should be ignored (idempotent)
|
||||
app.on_close()
|
||||
|
||||
# Verify no methods were called again
|
||||
app._cleanup_qt_windows.assert_not_called()
|
||||
app._cleanup_poller.assert_not_called()
|
||||
app._cleanup_scheduler.assert_not_called()
|
||||
app.destroy.assert_not_called()
|
||||
|
||||
def test_host_validation(self):
|
||||
"""Test safe host validation with fallbacks."""
|
||||
with patch('tkinter.Tk'):
|
||||
app = self.App()
|
||||
|
||||
# Test valid host
|
||||
app.host_var.set("example.com")
|
||||
result = app._get_validated_host()
|
||||
self.assertEqual(result, "example.com")
|
||||
|
||||
# Test empty host
|
||||
app.host_var.set("")
|
||||
result = app._get_validated_host("fallback.com")
|
||||
self.assertEqual(result, "fallback.com")
|
||||
|
||||
# Test host with whitespace
|
||||
app.host_var.set(" test.com ")
|
||||
result = app._get_validated_host()
|
||||
self.assertEqual(result, "test.com")
|
||||
|
||||
def test_port_validation(self):
|
||||
"""Test safe port validation with fallbacks."""
|
||||
with patch('tkinter.Tk'):
|
||||
app = self.App()
|
||||
|
||||
# Test valid port
|
||||
app.port_var.set("8080")
|
||||
result = app._get_validated_port()
|
||||
self.assertEqual(result, 8080)
|
||||
|
||||
# Test invalid port (non-numeric)
|
||||
app.port_var.set("abc")
|
||||
result = app._get_validated_port(9000)
|
||||
self.assertEqual(result, 9000)
|
||||
|
||||
# Test port out of range
|
||||
app.port_var.set("70000")
|
||||
result = app._get_validated_port(8080)
|
||||
self.assertEqual(result, 8080)
|
||||
|
||||
# Test empty port
|
||||
app.port_var.set("")
|
||||
result = app._get_validated_port(3000)
|
||||
self.assertEqual(result, 3000)
|
||||
|
||||
# Test port caching
|
||||
app.port_var.set("8080")
|
||||
app._get_validated_port() # This should cache 8080
|
||||
app.port_var.set("invalid")
|
||||
result = app._get_validated_port(9000)
|
||||
self.assertEqual(result, 8080) # Should return cached value
|
||||
|
||||
def test_base_url_validation(self):
|
||||
"""Test safe base URL generation."""
|
||||
with patch('tkinter.Tk'):
|
||||
app = self.App()
|
||||
|
||||
# Test normal case
|
||||
app.host_var.set("localhost")
|
||||
app.port_var.set("8080")
|
||||
result = app._get_base_url_validated()
|
||||
self.assertTrue(result.startswith("http://localhost:8080"))
|
||||
|
||||
# Test with invalid port (should use fallback)
|
||||
app.host_var.set("test.com")
|
||||
app.port_var.set("invalid")
|
||||
result = app._get_base_url_validated()
|
||||
# Should not crash and return a valid URL
|
||||
self.assertTrue(result.startswith("http://"))
|
||||
|
||||
def test_qt_window_registration(self):
|
||||
"""Test that Qt windows are properly registered for cleanup."""
|
||||
with patch('tkinter.Tk'), \
|
||||
patch('nucleares_monitor.control_board_monitor._pyqtgraph_available', True), \
|
||||
patch('nucleares_monitor.control_board_monitor.pg') as mock_pg, \
|
||||
patch.object(self.App, '_qt_ensure_app'), \
|
||||
patch.object(self.App, '_ensure_qt_pump'), \
|
||||
patch.object(self.App, '_ensure_plot_timer'):
|
||||
|
||||
# Mock PyQtGraph components
|
||||
mock_widget = Mock()
|
||||
mock_pg.GraphicsLayoutWidget.return_value = mock_widget
|
||||
mock_widget.addPlot.return_value = Mock()
|
||||
mock_widget.resize = Mock()
|
||||
mock_widget.setWindowTitle = Mock()
|
||||
mock_pg.mkPen.return_value = Mock()
|
||||
mock_pg.InfiniteLine.return_value = Mock()
|
||||
mock_pg.TextItem.return_value = Mock()
|
||||
|
||||
app = self.App()
|
||||
app._qt_app = Mock()
|
||||
|
||||
# Mock variable info
|
||||
app.vars = {"test_var": Mock(display_name="Test Variable")}
|
||||
|
||||
# Create a Qt window
|
||||
app._open_pyqtgraph_window("test_var")
|
||||
|
||||
# Verify window is registered in both tracking dictionaries
|
||||
self.assertIn("test_var", app._plot_windows)
|
||||
self.assertIn("test_var", app._qt_windows)
|
||||
|
||||
# Verify _qt_windows contains proper tuple
|
||||
win, timer = app._qt_windows["test_var"]
|
||||
self.assertIsNotNone(win)
|
||||
self.assertIsNone(timer) # PyQtGraph windows don't have timers
|
||||
|
||||
def test_qt_cleanup_with_no_timer(self):
|
||||
"""Test that Qt cleanup handles windows without timers."""
|
||||
with patch('tkinter.Tk'):
|
||||
app = self.App()
|
||||
|
||||
# Create mock Qt window without timer
|
||||
mock_win = Mock()
|
||||
app._qt_windows = {"test": (mock_win, None)}
|
||||
|
||||
# Run cleanup
|
||||
app._cleanup_qt_windows()
|
||||
|
||||
# Verify window close was called
|
||||
mock_win.close.assert_called_once()
|
||||
|
||||
# Verify tracking dict was cleared
|
||||
self.assertEqual(len(app._qt_windows), 0)
|
||||
|
||||
|
||||
def run_tests():
|
||||
"""Run the test suite."""
|
||||
print("Testing HIGH priority fixes...")
|
||||
print("=" * 50)
|
||||
|
||||
# Run tests
|
||||
suite = unittest.TestLoader().loadTestsFromTestCase(TestHighPriorityFixes)
|
||||
runner = unittest.TextTestRunner(verbosity=2)
|
||||
result = runner.run(suite)
|
||||
|
||||
# Summary
|
||||
print("\n" + "=" * 50)
|
||||
if result.wasSuccessful():
|
||||
print("✅ All HIGH priority fixes working correctly!")
|
||||
print(f"✅ Ran {result.testsRun} tests successfully")
|
||||
else:
|
||||
print("❌ Some tests failed:")
|
||||
print(f"❌ {len(result.failures)} failures")
|
||||
print(f"❌ {len(result.errors)} errors")
|
||||
for test, traceback in result.failures + result.errors:
|
||||
print(f" - {test}: {traceback.splitlines()[-1]}")
|
||||
|
||||
return result.wasSuccessful()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_tests()
|
||||
@@ -1,127 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script to validate the LOW priority scheduler optimization for reducing redundant
|
||||
get_value_cb calls in ActionScheduler.run_task_once().
|
||||
|
||||
The optimization caches get_stats_for() results within a single task execution to avoid:
|
||||
1. Redundant calls when the same source is used for multiple axes (x_src == y_src)
|
||||
2. Unnecessary calls for None sources
|
||||
3. Multiple lock acquisitions and deque copying for the same data
|
||||
|
||||
Performance improvement: Reduces scheduler overhead by 33-66% for common scenarios.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
|
||||
# Add the monitor directory to path
|
||||
sys.path.insert(0, 'nucleares_monitor')
|
||||
|
||||
def test_scheduler_optimization():
|
||||
"""Test that the scheduler optimization reduces get_value_cb calls."""
|
||||
from control_board_monitor import ActionScheduler
|
||||
|
||||
print("Testing ActionScheduler call optimization...")
|
||||
|
||||
class MockTask:
|
||||
def __init__(self, x_src='var1', y_src='var1', z_src='var2', expr='x + y + z'):
|
||||
self.x_src = x_src
|
||||
self.y_src = y_src
|
||||
self.z_src = z_src
|
||||
self.expr = expr
|
||||
self.value = '0'
|
||||
self.name = 'test'
|
||||
self.x_mode = 'raw'
|
||||
self.y_mode = 'raw'
|
||||
self.z_mode = 'raw'
|
||||
|
||||
call_count = 0
|
||||
call_log = []
|
||||
|
||||
def mock_get_value_cb(src):
|
||||
nonlocal call_count, call_log
|
||||
call_count += 1
|
||||
call_log.append(src)
|
||||
# Simulate some work (lock acquisition, deque copying, etc.)
|
||||
time.sleep(0.001) # 1ms per call
|
||||
return {'x': 10.0, 'x_avg': 8.0, 'dx': 1.0, 'dx_avg': 0.5}
|
||||
|
||||
def mock_get_base_url():
|
||||
return 'http://localhost:8080'
|
||||
|
||||
scheduler = ActionScheduler(mock_get_base_url, mock_get_value_cb)
|
||||
|
||||
test_cases = [
|
||||
("Same x and y source", MockTask('var1', 'var1', 'var2'), 2),
|
||||
("All same source", MockTask('var1', 'var1', 'var1'), 1),
|
||||
("One None source", MockTask('var1', None, 'var2'), 2),
|
||||
("All different sources", MockTask('var1', 'var2', 'var3'), 3),
|
||||
("Two None sources", MockTask('var1', None, None), 1),
|
||||
]
|
||||
|
||||
results = []
|
||||
|
||||
for description, task, expected_calls in test_cases:
|
||||
call_count = 0
|
||||
call_log = []
|
||||
|
||||
start_time = time.time()
|
||||
try:
|
||||
scheduler.run_task_once(task)
|
||||
except Exception:
|
||||
pass # Expected due to HTTP/expression errors in test
|
||||
end_time = time.time()
|
||||
|
||||
execution_time = (end_time - start_time) * 1000 # Convert to ms
|
||||
|
||||
results.append({
|
||||
'description': description,
|
||||
'expected_calls': expected_calls,
|
||||
'actual_calls': call_count,
|
||||
'call_log': call_log,
|
||||
'execution_time_ms': execution_time,
|
||||
'optimized': call_count == expected_calls
|
||||
})
|
||||
|
||||
print(f"\n{description}:")
|
||||
print(f" Sources: x={task.x_src}, y={task.y_src}, z={task.z_src}")
|
||||
print(f" Expected calls: {expected_calls}")
|
||||
print(f" Actual calls: {call_count}")
|
||||
print(f" Called for: {call_log}")
|
||||
print(f" Execution time: {execution_time:.1f}ms")
|
||||
print(" ✅ Optimized" if call_count == expected_calls else " ❌ Not optimized")
|
||||
|
||||
# Summary
|
||||
print(f"\n{'='*60}")
|
||||
print("OPTIMIZATION RESULTS:")
|
||||
print(f"{'='*60}")
|
||||
|
||||
optimized_count = sum(1 for r in results if r['optimized'])
|
||||
total_count = len(results)
|
||||
|
||||
print(f"Tests passed: {optimized_count}/{total_count}")
|
||||
|
||||
if optimized_count == total_count:
|
||||
print("✅ ALL TESTS PASSED - Scheduler optimization working correctly!")
|
||||
|
||||
# Calculate potential savings
|
||||
unoptimized_calls = sum(3 for _ in results) # Old version always called 3 times
|
||||
optimized_calls = sum(r['actual_calls'] for r in results)
|
||||
savings_percent = ((unoptimized_calls - optimized_calls) / unoptimized_calls) * 100
|
||||
|
||||
print("\nPerformance improvement:")
|
||||
print(f" Old version: {unoptimized_calls} total calls")
|
||||
print(f" Optimized version: {optimized_calls} total calls")
|
||||
print(f" Savings: {savings_percent:.1f}% reduction in get_value_cb calls")
|
||||
|
||||
else:
|
||||
print("❌ Some tests failed - optimization needs review")
|
||||
for r in results:
|
||||
if not r['optimized']:
|
||||
print(f" Failed: {r['description']}")
|
||||
|
||||
return optimized_count == total_count
|
||||
|
||||
if __name__ == '__main__':
|
||||
success = test_scheduler_optimization()
|
||||
sys.exit(0 if success else 1)
|
||||
@@ -1,201 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Simple test for HIGH priority fixes - focused validation tests only.
|
||||
"""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import Mock
|
||||
|
||||
# Test the validation functions in isolation
|
||||
class TestValidationFixes(unittest.TestCase):
|
||||
"""Test validation fixes in isolation."""
|
||||
|
||||
def test_double_cleanup_prevention_logic(self):
|
||||
"""Test the idempotent logic without full App initialization."""
|
||||
print("\n✅ Testing double cleanup prevention logic...")
|
||||
|
||||
# Simulate the idempotent behavior
|
||||
cleanup_called = []
|
||||
|
||||
def mock_on_close(already_closing_flag=None):
|
||||
if already_closing_flag is None:
|
||||
already_closing_flag = [False]
|
||||
if already_closing_flag[0]:
|
||||
return # Prevent double cleanup
|
||||
already_closing_flag[0] = True
|
||||
cleanup_called.append(True)
|
||||
|
||||
# First call should work
|
||||
mock_on_close()
|
||||
self.assertEqual(len(cleanup_called), 1)
|
||||
|
||||
# Second call should be ignored
|
||||
mock_on_close()
|
||||
self.assertEqual(len(cleanup_called), 1) # Still just 1
|
||||
|
||||
print(" ✅ Double cleanup prevention works correctly")
|
||||
|
||||
def test_port_validation_logic(self):
|
||||
"""Test port validation logic without Tkinter dependencies."""
|
||||
print("\n✅ Testing port validation logic...")
|
||||
|
||||
def validate_port(port_input, fallback=8080, cache=None):
|
||||
if cache is None:
|
||||
cache = {}
|
||||
"""Simplified version of port validation logic."""
|
||||
try:
|
||||
# Handle both string and int inputs
|
||||
if isinstance(port_input, int):
|
||||
port = port_input
|
||||
else:
|
||||
port_str = str(port_input).strip()
|
||||
if not port_str:
|
||||
return fallback
|
||||
port = int(port_str)
|
||||
|
||||
if not (1 <= port <= 65535):
|
||||
return cache.get('port', fallback)
|
||||
|
||||
cache['port'] = port
|
||||
return port
|
||||
except ValueError:
|
||||
return cache.get('port', fallback)
|
||||
except Exception:
|
||||
return cache.get('port', fallback)
|
||||
|
||||
# Test valid port
|
||||
result = validate_port("8080")
|
||||
self.assertEqual(result, 8080)
|
||||
print(" ✅ Valid port string handled correctly")
|
||||
|
||||
# Test integer input
|
||||
result = validate_port(9000)
|
||||
self.assertEqual(result, 9000)
|
||||
print(" ✅ Integer input handled correctly")
|
||||
|
||||
# Test invalid port
|
||||
cache = {'port': 8080} # Pre-cached value
|
||||
result = validate_port("abc", fallback=3000, cache=cache)
|
||||
self.assertEqual(result, 8080) # Should return cached value
|
||||
print(" ✅ Invalid port returns cached value")
|
||||
|
||||
# Test empty port
|
||||
result = validate_port("", fallback=5000)
|
||||
self.assertEqual(result, 5000)
|
||||
print(" ✅ Empty port returns fallback")
|
||||
|
||||
# Test out of range
|
||||
result = validate_port("70000", fallback=8080)
|
||||
self.assertEqual(result, 8080)
|
||||
print(" ✅ Out of range port returns fallback")
|
||||
|
||||
def test_host_validation_logic(self):
|
||||
"""Test host validation logic."""
|
||||
print("\n✅ Testing host validation logic...")
|
||||
|
||||
def validate_host(host_input, fallback="localhost", cache=None):
|
||||
"""Simplified version of host validation logic."""
|
||||
if cache is None:
|
||||
cache = {}
|
||||
try:
|
||||
host = str(host_input).strip()
|
||||
if not host:
|
||||
return fallback
|
||||
cache['host'] = host
|
||||
return host
|
||||
except Exception:
|
||||
return cache.get('host', fallback)
|
||||
|
||||
# Test valid host
|
||||
result = validate_host("example.com")
|
||||
self.assertEqual(result, "example.com")
|
||||
print(" ✅ Valid host handled correctly")
|
||||
|
||||
# Test empty host
|
||||
result = validate_host("", fallback="test.com")
|
||||
self.assertEqual(result, "test.com")
|
||||
print(" ✅ Empty host returns fallback")
|
||||
|
||||
# Test host with whitespace
|
||||
result = validate_host(" test.com ")
|
||||
self.assertEqual(result, "test.com")
|
||||
print(" ✅ Host whitespace trimmed correctly")
|
||||
|
||||
def test_qt_window_cleanup_logic(self):
|
||||
"""Test Qt window cleanup logic."""
|
||||
print("\n✅ Testing Qt window cleanup logic...")
|
||||
|
||||
def cleanup_qt_windows(qt_windows):
|
||||
"""Simplified Qt cleanup logic."""
|
||||
for _, tup in list(qt_windows.items()):
|
||||
try:
|
||||
if isinstance(tup, tuple) and len(tup) >= 2:
|
||||
win, timer = tup[:2]
|
||||
if timer:
|
||||
timer.stop() # Would call stop() if timer exists
|
||||
if win:
|
||||
win.close() # Would call close() if window exists
|
||||
except Exception:
|
||||
pass # Error isolation
|
||||
qt_windows.clear()
|
||||
|
||||
# Test with window and timer
|
||||
mock_win = Mock()
|
||||
mock_timer = Mock()
|
||||
qt_windows = {"test1": (mock_win, mock_timer)}
|
||||
|
||||
cleanup_qt_windows(qt_windows)
|
||||
|
||||
mock_timer.stop.assert_called_once()
|
||||
mock_win.close.assert_called_once()
|
||||
self.assertEqual(len(qt_windows), 0)
|
||||
print(" ✅ Qt window with timer cleaned up correctly")
|
||||
|
||||
# Test with window but no timer (PyQtGraph case)
|
||||
mock_win2 = Mock()
|
||||
qt_windows = {"test2": (mock_win2, None)}
|
||||
|
||||
cleanup_qt_windows(qt_windows)
|
||||
|
||||
mock_win2.close.assert_called_once()
|
||||
self.assertEqual(len(qt_windows), 0)
|
||||
print(" ✅ Qt window without timer cleaned up correctly")
|
||||
|
||||
|
||||
def run_validation_tests():
|
||||
"""Run the isolated validation tests."""
|
||||
print("Testing HIGH priority fixes - Validation Logic")
|
||||
print("=" * 60)
|
||||
|
||||
# Run tests
|
||||
suite = unittest.TestLoader().loadTestsFromTestCase(TestValidationFixes)
|
||||
runner = unittest.TextTestRunner(verbosity=0, stream=open(os.devnull, 'w'))
|
||||
result = runner.run(suite)
|
||||
|
||||
# Summary
|
||||
print("\n" + "=" * 60)
|
||||
if result.wasSuccessful():
|
||||
print("✅ All HIGH priority validation fixes working correctly!")
|
||||
print(f"✅ Ran {result.testsRun} validation tests successfully")
|
||||
print("\n🎯 KEY FIXES VERIFIED:")
|
||||
print(" 1. ✅ Double cleanup prevention (idempotent on_close)")
|
||||
print(" 2. ✅ Safe port validation with fallbacks and caching")
|
||||
print(" 3. ✅ Safe host validation with fallbacks")
|
||||
print(" 4. ✅ Qt window cleanup with optional timers")
|
||||
print("\n📝 IMPLEMENTATION STATUS:")
|
||||
print(" • Removed atexit registration to prevent double cleanup")
|
||||
print(" • Added _already_closing flag for idempotent behavior")
|
||||
print(" • Centralized input validation with caching")
|
||||
print(" • Qt windows registered in both _plot_windows and _qt_windows")
|
||||
print(" • Cleanup methods handle missing timers gracefully")
|
||||
else:
|
||||
print("❌ Some validation tests failed:")
|
||||
print(f"❌ {len(result.failures)} failures")
|
||||
print(f"❌ {len(result.errors)} errors")
|
||||
|
||||
return result.wasSuccessful()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_validation_tests()
|
||||
@@ -1,236 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Direct test of HIGH priority fixes - verification script.
|
||||
"""
|
||||
|
||||
def test_double_cleanup_logic():
|
||||
"""Test double cleanup prevention logic."""
|
||||
print("🔍 Testing Double Cleanup Prevention...")
|
||||
|
||||
cleanup_calls = []
|
||||
already_closing = [False]
|
||||
|
||||
def on_close():
|
||||
# Implementation matches the actual code
|
||||
if already_closing[0]:
|
||||
return # Idempotent - prevent double cleanup
|
||||
already_closing[0] = True
|
||||
cleanup_calls.append("cleanup")
|
||||
|
||||
# First call should work
|
||||
on_close()
|
||||
assert len(cleanup_calls) == 1, f"Expected 1 cleanup call, got {len(cleanup_calls)}"
|
||||
|
||||
# Second call should be ignored
|
||||
on_close()
|
||||
assert len(cleanup_calls) == 1, f"Expected 1 cleanup call after double call, got {len(cleanup_calls)}"
|
||||
|
||||
print(" ✅ Double cleanup prevention working correctly")
|
||||
return True
|
||||
|
||||
def test_port_validation_logic():
|
||||
"""Test port validation with various inputs."""
|
||||
print("🔍 Testing Port Validation...")
|
||||
|
||||
cache = {}
|
||||
|
||||
def validate_port(port_input, fallback=8080):
|
||||
try:
|
||||
# Handle both string and int inputs (for testing)
|
||||
if isinstance(port_input, int):
|
||||
port = port_input
|
||||
else:
|
||||
port_str = str(port_input).strip()
|
||||
if not port_str:
|
||||
return fallback
|
||||
port = int(port_str)
|
||||
|
||||
if not (1 <= port <= 65535):
|
||||
return cache.get('port', fallback)
|
||||
|
||||
# Cache valid port
|
||||
cache['port'] = port
|
||||
return port
|
||||
except ValueError:
|
||||
return cache.get('port', fallback)
|
||||
except Exception:
|
||||
return cache.get('port', fallback)
|
||||
|
||||
# Test valid inputs first
|
||||
result = validate_port("8080")
|
||||
assert result == 8080, f"Valid string port: Expected 8080, got {result}"
|
||||
print(f" ✅ Valid string port: 8080 -> {result}")
|
||||
|
||||
result = validate_port(9000)
|
||||
assert result == 9000, f"Valid integer port: Expected 9000, got {result}"
|
||||
print(f" ✅ Valid integer port: 9000 -> {result}")
|
||||
|
||||
# Test empty string
|
||||
result = validate_port("", fallback=3000)
|
||||
assert result == 3000, f"Empty string: Expected 3000, got {result}"
|
||||
print(f" ✅ Empty string with fallback: '' -> {result}")
|
||||
|
||||
# Set up cache with known value for invalid tests
|
||||
validate_port("8080") # This caches 8080
|
||||
|
||||
# Test invalid string (should return cached value)
|
||||
result = validate_port("abc")
|
||||
expected = cache.get('port', 8080) # Should get cached value
|
||||
assert result == expected, f"Invalid string: Expected {expected}, got {result}"
|
||||
print(f" ✅ Invalid string (cached): 'abc' -> {result}")
|
||||
|
||||
# Test out of range (should return cached value)
|
||||
result = validate_port("70000")
|
||||
expected = cache.get('port', 8080) # Should get cached value
|
||||
assert result == expected, f"Out of range: Expected {expected}, got {result}"
|
||||
print(f" ✅ Out of range port (cached): '70000' -> {result}")
|
||||
|
||||
# Test string with whitespace
|
||||
result = validate_port(" 8080 ")
|
||||
assert result == 8080, f"Whitespace string: Expected 8080, got {result}"
|
||||
print(f" ✅ String with whitespace: ' 8080 ' -> {result}")
|
||||
|
||||
return True
|
||||
|
||||
def test_qt_cleanup_logic():
|
||||
"""Test Qt window cleanup logic."""
|
||||
print("🔍 Testing Qt Window Cleanup...")
|
||||
|
||||
class MockWindow:
|
||||
def __init__(self):
|
||||
self.closed = False
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
class MockTimer:
|
||||
def __init__(self):
|
||||
self.stopped = False
|
||||
def stop(self):
|
||||
self.stopped = True
|
||||
|
||||
def cleanup_qt_windows(qt_windows):
|
||||
"""Simplified Qt cleanup matching actual implementation."""
|
||||
for _, tup in list(qt_windows.items()):
|
||||
try:
|
||||
if isinstance(tup, tuple) and len(tup) >= 2:
|
||||
win, timer = tup[:2]
|
||||
if timer:
|
||||
timer.stop()
|
||||
if win:
|
||||
win.close()
|
||||
except Exception:
|
||||
pass # Error isolation
|
||||
qt_windows.clear()
|
||||
|
||||
# Test with timer
|
||||
win1 = MockWindow()
|
||||
timer1 = MockTimer()
|
||||
qt_windows = {"test1": (win1, timer1)}
|
||||
|
||||
cleanup_qt_windows(qt_windows)
|
||||
|
||||
assert win1.closed, "Window should be closed"
|
||||
assert timer1.stopped, "Timer should be stopped"
|
||||
assert len(qt_windows) == 0, "Dict should be cleared"
|
||||
print(" ✅ Qt window with timer cleaned up correctly")
|
||||
|
||||
# Test without timer (PyQtGraph case)
|
||||
win2 = MockWindow()
|
||||
qt_windows = {"test2": (win2, None)}
|
||||
|
||||
cleanup_qt_windows(qt_windows)
|
||||
|
||||
assert win2.closed, "Window should be closed"
|
||||
assert len(qt_windows) == 0, "Dict should be cleared"
|
||||
print(" ✅ Qt window without timer cleaned up correctly")
|
||||
|
||||
return True
|
||||
|
||||
def test_host_validation_logic():
|
||||
"""Test host validation logic."""
|
||||
print("🔍 Testing Host Validation...")
|
||||
|
||||
cache = {}
|
||||
|
||||
def validate_host(host_input, fallback="localhost"):
|
||||
try:
|
||||
host = str(host_input).strip()
|
||||
if not host:
|
||||
return fallback
|
||||
cache['host'] = host
|
||||
return host
|
||||
except Exception:
|
||||
return cache.get('host', fallback)
|
||||
|
||||
# Test cases
|
||||
tests = [
|
||||
("example.com", "example.com", "Valid host"),
|
||||
("", "fallback.com", "Empty host"),
|
||||
(" test.com ", "test.com", "Host with whitespace"),
|
||||
("localhost", "localhost", "Localhost"),
|
||||
]
|
||||
|
||||
for input_val, expected, description in tests:
|
||||
if input_val == "":
|
||||
result = validate_host(input_val, fallback="fallback.com")
|
||||
else:
|
||||
result = validate_host(input_val)
|
||||
assert result == expected, f"{description}: Expected {expected}, got {result}"
|
||||
print(f" ✅ {description}: '{input_val}' -> '{result}'")
|
||||
|
||||
return True
|
||||
|
||||
def main():
|
||||
"""Run all HIGH priority fix tests."""
|
||||
print("🧪 HIGH PRIORITY FIXES VERIFICATION")
|
||||
print("=" * 50)
|
||||
|
||||
all_passed = True
|
||||
|
||||
try:
|
||||
test_double_cleanup_logic()
|
||||
except Exception as e:
|
||||
print(f" ❌ Double cleanup test failed: {e}")
|
||||
all_passed = False
|
||||
|
||||
try:
|
||||
test_port_validation_logic()
|
||||
except Exception as e:
|
||||
print(f" ❌ Port validation test failed: {e}")
|
||||
all_passed = False
|
||||
|
||||
try:
|
||||
test_host_validation_logic()
|
||||
except Exception as e:
|
||||
print(f" ❌ Host validation test failed: {e}")
|
||||
all_passed = False
|
||||
|
||||
try:
|
||||
test_qt_cleanup_logic()
|
||||
except Exception as e:
|
||||
print(f" ❌ Qt cleanup test failed: {e}")
|
||||
all_passed = False
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
if all_passed:
|
||||
print("🎉 ALL HIGH PRIORITY FIXES VERIFIED SUCCESSFULLY!")
|
||||
print("\n📋 SUMMARY OF FIXES IMPLEMENTED:")
|
||||
print(" 1. ✅ Fixed double cleanup registration")
|
||||
print(" • Removed atexit.register to avoid conflict with WM_DELETE_WINDOW")
|
||||
print(" • Added _already_closing flag for idempotent on_close")
|
||||
print(" 2. ✅ Centralized input validation for host/port")
|
||||
print(" • Added _get_validated_host() with fallback handling")
|
||||
print(" • Added _get_validated_port() with range checking and caching")
|
||||
print(" • Replaced all unsafe int(self.port_var.get()) calls")
|
||||
print(" 3. ✅ Completed Qt window cleanup registration")
|
||||
print(" • PyQtGraph windows now registered in both _plot_windows and _qt_windows")
|
||||
print(" • Cleanup handles optional timers (None for PyQtGraph)")
|
||||
print(" • Added close event handlers for user-initiated window closes")
|
||||
print("\n🚀 Ready for MEDIUM priority fixes!")
|
||||
else:
|
||||
print("❌ Some tests failed - check implementation!")
|
||||
|
||||
return all_passed
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1 +0,0 @@
|
||||
1.17
|
||||
+4
-20
@@ -1,23 +1,7 @@
|
||||
cffi==2.0.0
|
||||
contourpy==1.3.3
|
||||
cycler==0.12.1
|
||||
fonttools==4.60.1
|
||||
kiwisolver==1.4.9
|
||||
matplotlib==3.10.7
|
||||
# Zależności vtt_work — wyłącznie dla help_scripts/sound.py (generator dźwięków).
|
||||
# Pozostałe skrypty Foundry używają czystego stdlib.
|
||||
numpy==2.3.3
|
||||
packaging==25.0
|
||||
pillow==12.0.0
|
||||
pycparser==2.23
|
||||
pyparsing==3.2.5
|
||||
PyQt5==5.15.11
|
||||
PyQt5-Qt5==5.15.2
|
||||
PyQt5_sip==12.17.1
|
||||
pyqtgraph==0.13.7
|
||||
PySide6==6.10.0
|
||||
PySide6_Addons==6.10.0
|
||||
PySide6_Essentials==6.10.0
|
||||
python-dateutil==2.9.0.post0
|
||||
scipy==1.16.2
|
||||
shiboken6==6.10.0
|
||||
six==1.17.0
|
||||
soundfile==0.13.1
|
||||
cffi==2.0.0
|
||||
pycparser==2.23
|
||||
|
||||
@@ -54,7 +54,8 @@ Hooks.once("ready", () => {
|
||||
window._gkAutoLoadoutHookId = Hooks.on("createItem", async (item, opts, userId) => {
|
||||
try {
|
||||
if (item?.type !== "archetype") return;
|
||||
const src = item?.flags?.core?.sourceId || "";
|
||||
// v12+: pochodzenie z paczki przeniesione do _stats.compendiumSource (flags.core.sourceId deprecated)
|
||||
const src = item?._stats?.compendiumSource || item?.flags?.core?.sourceId || "";
|
||||
const isFromOurPack = src.startsWith(PACK_SRC_PREFIX);
|
||||
const isOurGKByName = item?.name === "Grey Knight (Archetype)";
|
||||
if (!isFromOurPack && !isOurGKByName) return;
|
||||
|
||||
@@ -21,13 +21,13 @@
|
||||
luminosity:0.5, attenuation:0.35, saturation:0, contrast:0, shadows:0,
|
||||
animation:{ type:"pulse", speed:3, intensity:4, reverse:false } }
|
||||
});
|
||||
await canvas.perception.update({ lighting: { refresh: true } });
|
||||
await canvas.perception.update({ refreshLighting: true });
|
||||
};
|
||||
const setGlowOff = async () => {
|
||||
const prev = token.document.getFlag(FLAG_SCOPE, FLAG_KEY);
|
||||
if (prev) { await token.document.update({ light: prev }); await token.document.unsetFlag(FLAG_SCOPE, FLAG_KEY); }
|
||||
else { await token.document.update({ light: { dim:0, bright:0, color:null } }); }
|
||||
await canvas.perception.update({ lighting: { refresh: true } });
|
||||
await canvas.perception.update({ refreshLighting: true });
|
||||
};
|
||||
if (wasDisabled) { await setGlowOn(); ChatMessage.create({ content: `<b>Aegis:</b> Warding Runes <span style="color:#66ccff">ENGAGED</span>. (Akcja)` }); }
|
||||
else { await setGlowOff(); ChatMessage.create({ content: `<b>Aegis:</b> Warding Runes DISENGAGED.` }); }
|
||||
|
||||
@@ -52,7 +52,9 @@ async function gkResolveActorFromMessage(doc) {
|
||||
}
|
||||
async function gkIsPsychicMessage(doc) {
|
||||
try {
|
||||
const raw = `${doc.flavor || ""} ${TextEditor?.stripHTML(doc.content || "") || ""}`.toLowerCase();
|
||||
// v13: TextEditor przeniesiony do foundry.applications.ux (global deprecated) — z fallbackiem na v11/v12
|
||||
const _TE = foundry.applications?.ux?.TextEditor?.implementation ?? globalThis.TextEditor;
|
||||
const raw = `${doc.flavor || ""} ${_TE?.stripHTML?.(doc.content || "") || ""}`.toLowerCase();
|
||||
const keys = ["psychic","psychic mastery","psychic power","moc psioniczna","smite","hammerhand","sanctuary","shrouding","might of titan"];
|
||||
if (keys.some(k=>raw.includes(k))) return true;
|
||||
const flagsStr = JSON.stringify(doc.flags || {}).toLowerCase();
|
||||
@@ -105,7 +107,8 @@ Hooks.once("ready", () => {
|
||||
const ok = await ensureHasItemByName(actor, "Smite", "psychicPower");
|
||||
if (!ok) {
|
||||
ui.notifications?.warn("Smite not found in available packs. Please add a Librarius power manually.");
|
||||
game.ui?.sidebar?.tabs?.compendium?.activate();
|
||||
// globalny obiekt to `ui`, nie `game.ui`; activateTab działa w v12/v13
|
||||
ui.sidebar?.activateTab?.("compendium");
|
||||
}
|
||||
}
|
||||
} catch (e) { console.error("gk-greyknight — Brotherhood prompt:", e); }
|
||||
|
||||
@@ -188,7 +188,7 @@ Hooks.on("getSceneControlButtons", (controls) => {
|
||||
button: true,
|
||||
visible: true,
|
||||
order: 999,
|
||||
onClick: () => GKFocusPanel.toggle()
|
||||
onChange: () => GKFocusPanel.toggle()
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
{"name":"Lunar-class Cruiser","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Lunar-class Cruiser","size":"Cruiser","slots":{"prow":1,"dorsal":2,"port":1,"starboard":1,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":12,"integrity":40,"manoeuvre":-1,"detection":1,"speed":8,"space":70,"power":80},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":-1,"notes":"Archetypal line cruiser—balanced, dependable, easily refitted. Backbone of many squadrons.","combat":{"size":"colossal","speed":8,"wounds":{"value":0,"bonus":0,"max":40},"resilience":{"bonus":0,"total":12},"defence":{"bonus":2},"fly":0},"source":"Battlefleet Koronus, p. 24"}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Lunar-class Cruiser","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"0b59c38ddda63f42","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
|
||||
{"name":"Secutor-class Monitor-Cruiser","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Secutor-class Monitor-Cruiser","size":"Cruiser","slots":{"prow":1,"dorsal":3,"port":0,"starboard":0,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":12,"integrity":40,"manoeuvre":-1,"detection":1,"speed":8,"space":68,"power":82},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":-1,"notes":"Mechanicus monitor with powerful dorsals and redundant shielding; anchors actions.","combat":{"size":"colossal","speed":8,"wounds":{"value":0,"bonus":0,"max":40},"resilience":{"bonus":0,"total":12},"defence":{"bonus":2},"fly":0},"source":"Into the Storm, p. 151"}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Secutor-class Monitor-Cruiser","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"0eb071148d375807","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
|
||||
{"name":"Lathe-class Monitor-Cruiser","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Lathe-class Monitor-Cruiser","size":"Cruiser","slots":{"prow":1,"dorsal":3,"port":0,"starboard":0,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":12,"integrity":40,"manoeuvre":-1,"detection":1,"speed":8,"space":68,"power":82},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":-1,"notes":"Lathes-forged variant emphasising resilient sanctified systems.","combat":{"size":"colossal","speed":8,"wounds":{"value":0,"bonus":0,"max":40},"resilience":{"bonus":0,"total":12},"defence":{"bonus":2},"fly":0},"source":"Into the Storm, p. 152"}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Lathe-class Monitor-Cruiser","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"104965869ffa36bb","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
|
||||
{"name":"Frigate Hull (Empty)","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Frigate Hull (Empty)","size":"Frigate","slots":{"prow":1,"dorsal":1,"port":0,"starboard":0,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":10,"integrity":24,"manoeuvre":1,"detection":1,"speed":10,"space":40,"power":45},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":1,"notes":"Empty hull. Add Components from Items pack.","combat":{"size":"colossal","speed":10,"wounds":{"value":0,"bonus":0,"max":24},"resilience":{"bonus":0,"total":10},"defence":{"bonus":3},"fly":0}}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Frigate Hull (Empty)","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"1f954166473bedf3","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
|
||||
{"name":"Conquest-class Star Galleon","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Conquest-class Star Galleon","size":"Cruiser","slots":{"prow":1,"dorsal":1,"port":1,"starboard":1,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":12,"integrity":36,"manoeuvre":-1,"detection":1,"speed":8,"space":75,"power":70},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":-1,"notes":"Heavily built merchant galleon with strong batteries and hull.","combat":{"size":"colossal","speed":8,"wounds":{"value":0,"bonus":0,"max":36},"resilience":{"bonus":0,"total":12},"defence":{"bonus":2},"fly":0},"source":"Battlefleet Koronus, p. 25"}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Conquest-class Star Galleon","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"287a7cd15c95031f","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
|
||||
{"name":"Goliath-class Factory Ship","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Goliath-class Factory Ship","size":"Cruiser","slots":{"prow":1,"dorsal":1,"port":0,"starboard":0,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":12,"integrity":36,"manoeuvre":-1,"detection":1,"speed":8,"space":75,"power":70},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":-1,"notes":"Roving manufactorum and refinery; long autonomous deployments.","combat":{"size":"colossal","speed":8,"wounds":{"value":0,"bonus":0,"max":36},"resilience":{"bonus":0,"total":12},"defence":{"bonus":2},"fly":0},"source":"Battlefleet Koronus, p. 30"}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Goliath-class Factory Ship","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"2c612305a7bb853f","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
|
||||
{"name":"Escort Hull (Empty)","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Escort Hull (Empty)","size":"Escort","slots":{"prow":1,"dorsal":0,"port":0,"starboard":0,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":9,"integrity":18,"manoeuvre":2,"detection":1,"speed":10,"space":35,"power":40},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":2,"notes":"Empty hull. Add Components from Items pack.","combat":{"size":"colossal","speed":10,"wounds":{"value":0,"bonus":0,"max":18},"resilience":{"bonus":0,"total":9},"defence":{"bonus":3},"fly":0}}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Escort Hull (Empty)","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"4412029d26021383","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
|
||||
{"name":"Hazeroth-class Privateer","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Hazeroth-class Privateer","size":"Frigate","slots":{"prow":1,"dorsal":1,"port":0,"starboard":0,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":9,"integrity":22,"manoeuvre":2,"detection":1,"speed":10,"space":38,"power":45},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":2,"notes":"Knife-fast raider with reinforced internals and stripped armour. Favoured for ambushes and commerce raiding.","combat":{"size":"colossal","speed":10,"wounds":{"value":0,"bonus":0,"max":22},"resilience":{"bonus":0,"total":9},"defence":{"bonus":3},"fly":0},"source":"Rogue Trader Core Rulebook, p. 195"}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Hazeroth-class Privateer","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"521f6e630bd813a4","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
|
||||
{"name":"Tyrant-class Cruiser","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Tyrant-class Cruiser","size":"Cruiser","slots":{"prow":1,"dorsal":2,"port":1,"starboard":1,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":12,"integrity":40,"manoeuvre":-1,"detection":1,"speed":8,"space":70,"power":80},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":-1,"notes":"Macrobattery-focused cruiser optimised for crushing broadsides.","combat":{"size":"colossal","speed":8,"wounds":{"value":0,"bonus":0,"max":40},"resilience":{"bonus":0,"total":12},"defence":{"bonus":2},"fly":0},"source":"Into the Storm, p. 153"}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Tyrant-class Cruiser","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"6774443321f24e87","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
|
||||
{"name":"Universe-class Mass Conveyor","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Universe-class Mass Conveyor","size":"Cruiser","slots":{"prow":1,"dorsal":1,"port":0,"starboard":0,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"},{"tag":"cargo-bay","min":4,"label":"Cargo Holds"},{"tag":"aux-plasma","min":1,"label":"Auxiliary Plasma Generator"}]},"base":{"armour":12,"integrity":36,"manoeuvre":-1,"detection":1,"speed":8,"space":80,"power":60},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":-1,"notes":"Super-massive hauler for colony loads and crusade materiel; logistics linchpin.","combat":{"size":"colossal","speed":8,"wounds":{"value":0,"bonus":0,"max":36},"resilience":{"bonus":0,"total":12},"defence":{"bonus":2},"fly":0},"source":"Battlefleet Koronus, p. 31"}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Universe-class Mass Conveyor","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"6836e238cce8d9fc","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
|
||||
{"name":"Cobra-class Destroyer (Renegade)","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Cobra-class Destroyer (Renegade)","size":"Escort","slots":{"prow":1,"dorsal":1,"port":0,"starboard":0,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":9,"integrity":22,"manoeuvre":2,"detection":1,"speed":10,"space":36,"power":42},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":2,"notes":"Ex-Navy Cobra in privateer hands with non-standard prow refits.","combat":{"size":"colossal","speed":10,"wounds":{"value":0,"bonus":0,"max":22},"resilience":{"bonus":0,"total":9},"defence":{"bonus":3},"fly":0},"source":"Battlefleet Koronus, p. 66"}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Cobra-class Destroyer (Renegade)","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"6da4418bdbd2206c","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
|
||||
{"name":"Defiant-class Light Cruiser (Carrier)","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Defiant-class Light Cruiser (Carrier)","size":"Light Cruiser","slots":{"prow":1,"dorsal":1,"port":1,"starboard":1,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":11,"integrity":32,"manoeuvre":0,"detection":1,"speed":9,"space":60,"power":70},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":0,"notes":"Light carrier with significant attack craft; excellent for recon and strikes.","combat":{"size":"colossal","speed":9,"wounds":{"value":0,"bonus":0,"max":32},"resilience":{"bonus":0,"total":11},"defence":{"bonus":2},"fly":0}}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Defiant-class Light Cruiser (Carrier)","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"73eed7c83ea60340","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
|
||||
{"name":"Orion-class Star Clipper","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Orion-class Star Clipper","size":"Cruiser","slots":{"prow":1,"dorsal":1,"port":0,"starboard":0,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":12,"integrity":36,"manoeuvre":-1,"detection":1,"speed":8,"space":70,"power":70},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":-1,"notes":"Fast passenger/light-cargo clipper for courier missions and risky runs.","combat":{"size":"colossal","speed":8,"wounds":{"value":0,"bonus":0,"max":36},"resilience":{"bonus":0,"total":12},"defence":{"bonus":2},"fly":0},"source":"Into the Storm, p. 150"}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Orion-class Star Clipper","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"750ee1998f698e08","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
|
||||
{"name":"Vagabond-class Merchant Trader","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Vagabond-class Merchant Trader","size":"Cruiser","slots":{"prow":1,"dorsal":1,"port":0,"starboard":0,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":12,"integrity":36,"manoeuvre":-1,"detection":1,"speed":8,"space":72,"power":68},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":-1,"notes":"Modular cargobays and adaptable fittings; a chartist workhorse. Simple to keep running in frontier yards; heavily customised.","combat":{"size":"colossal","speed":8,"wounds":{"value":0,"bonus":0,"max":36},"resilience":{"bonus":0,"total":12},"defence":{"bonus":2},"fly":0},"source":"Rogue Trader Core Rulebook, p. 195"}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Vagabond-class Merchant Trader","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"95fcf7b99d705bd1","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
|
||||
{"name":"Cruiser Hull (Empty)","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Cruiser Hull (Empty)","size":"Cruiser","slots":{"prow":1,"dorsal":2,"port":1,"starboard":1,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":12,"integrity":40,"manoeuvre":-1,"detection":1,"speed":8,"space":70,"power":80},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":-1,"notes":"Empty hull. Add Components from Items pack.","combat":{"size":"colossal","speed":8,"wounds":{"value":0,"bonus":0,"max":40},"resilience":{"bonus":0,"total":12},"defence":{"bonus":2},"fly":0}}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Cruiser Hull (Empty)","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"96a0dacd77d672bb","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
|
||||
{"name":"Jericho-class Pilgrim Vessel","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Jericho-class Pilgrim Vessel","size":"Cruiser","slots":{"prow":1,"dorsal":1,"port":0,"starboard":0,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":12,"integrity":36,"manoeuvre":-1,"detection":1,"speed":8,"space":75,"power":68},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":-1,"notes":"Immense conversions for pilgrim passages and reliquary cargo. Cavernous holds and chapels; range and capacity over armour and speed.","combat":{"size":"colossal","speed":8,"wounds":{"value":0,"bonus":0,"max":36},"resilience":{"bonus":0,"total":12},"defence":{"bonus":2},"fly":0},"source":"Rogue Trader Core Rulebook, p. 195"}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Jericho-class Pilgrim Vessel","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"a08b1d9ef251ec0b","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
|
||||
{"name":"Cobra-class Destroyer","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Cobra-class Destroyer","size":"Escort","slots":{"prow":1,"dorsal":1,"port":0,"starboard":0,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":9,"integrity":22,"manoeuvre":2,"detection":1,"speed":10,"space":36,"power":42},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":2,"notes":"Torpedo raider hunting in packs; strikes, then disengages to reload.","combat":{"size":"colossal","speed":10,"wounds":{"value":0,"bonus":0,"max":22},"resilience":{"bonus":0,"total":9},"defence":{"bonus":3},"fly":0},"source":"Battlefleet Koronus, p. 66"}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Cobra-class Destroyer","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"b518e981d69324aa","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
|
||||
{"name":"Havoc-class Merchant Raider","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Havoc-class Merchant Raider","size":"Frigate","slots":{"prow":1,"dorsal":1,"port":0,"starboard":0,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":9,"integrity":22,"manoeuvre":2,"detection":1,"speed":10,"space":38,"power":45},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":2,"notes":"Hybrid carrier-raider balancing battery weight with cargo. Less protected than Navy hulls but brutal on traffic.","combat":{"size":"colossal","speed":10,"wounds":{"value":0,"bonus":0,"max":22},"resilience":{"bonus":0,"total":9},"defence":{"bonus":3},"fly":0},"source":"Rogue Trader Core Rulebook, p. 196"}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Havoc-class Merchant Raider","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"c4c69cf1b895c0e1","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
|
||||
{"name":"Light Cruiser Hull (Empty)","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Light Cruiser Hull (Empty)","size":"Light Cruiser","slots":{"prow":1,"dorsal":1,"port":1,"starboard":1,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":11,"integrity":32,"manoeuvre":0,"detection":1,"speed":9,"space":60,"power":70},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":0,"notes":"Empty hull. Add Components from Items pack.","combat":{"size":"colossal","speed":9,"wounds":{"value":0,"bonus":0,"max":32},"resilience":{"bonus":0,"total":11},"defence":{"bonus":2},"fly":0}}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Light Cruiser Hull (Empty)","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"c5ca1fc87803b111","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
|
||||
{"name":"Dauntless-class Light Cruiser","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Dauntless-class Light Cruiser","size":"Light Cruiser","slots":{"prow":1,"dorsal":2,"port":1,"starboard":1,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":11,"integrity":32,"manoeuvre":0,"detection":1,"speed":9,"space":62,"power":72},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":0,"notes":"Versatile patrol cruiser—good acceleration and flexible refits. Often leads Passage Watch patrols.","combat":{"size":"colossal","speed":9,"wounds":{"value":0,"bonus":0,"max":32},"resilience":{"bonus":0,"total":11},"defence":{"bonus":2},"fly":0},"source":"Battlefleet Koronus, p. 36"}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Dauntless-class Light Cruiser","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"c8975bb7181d8e12","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
|
||||
{"name":"Falchion-class Frigate","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Falchion-class Frigate","size":"Frigate","slots":{"prow":1,"dorsal":1,"port":0,"starboard":0,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":10,"integrity":28,"manoeuvre":1,"detection":1,"speed":10,"space":42,"power":48},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":1,"notes":"Modernised frigate with improved drives for frontier scouting.","combat":{"size":"colossal","speed":10,"wounds":{"value":0,"bonus":0,"max":28},"resilience":{"bonus":0,"total":10},"defence":{"bonus":3},"fly":0},"source":"Battlefleet Koronus, p. 28"}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Falchion-class Frigate","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"d3853b0270deaf3f","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
|
||||
{"name":"Sword-class Frigate","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Sword-class Frigate","size":"Frigate","slots":{"prow":1,"dorsal":1,"port":0,"starboard":0,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":10,"integrity":28,"manoeuvre":1,"detection":1,"speed":10,"space":42,"power":48},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":1,"notes":"Reliable escort with accurate dorsal lasers and stout drives. Squadrons screen convoys and prosecute pirates.","combat":{"size":"colossal","speed":10,"wounds":{"value":0,"bonus":0,"max":28},"resilience":{"bonus":0,"total":10},"defence":{"bonus":3},"fly":0},"source":"Battlefleet Koronus, p. 28"}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Sword-class Frigate","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"ec53303961ef4fd4","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
|
||||
{"name":"Turbulent-class Heavy Frigate","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Turbulent-class Heavy Frigate","size":"Frigate","slots":{"prow":1,"dorsal":1,"port":0,"starboard":0,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":10,"integrity":28,"manoeuvre":1,"detection":1,"speed":10,"space":42,"power":48},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":1,"notes":"Strengthened frame and broader arcs; trades agility for staying power.","combat":{"size":"colossal","speed":10,"wounds":{"value":0,"bonus":0,"max":28},"resilience":{"bonus":0,"total":10},"defence":{"bonus":3},"fly":0},"source":"Battlefleet Koronus, p. 28"}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Turbulent-class Heavy Frigate","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"f1ea3416a6d0d27e","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
|
||||
{"name":"Tempest-class Strike Frigate","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Tempest-class Strike Frigate","size":"Frigate","slots":{"prow":1,"dorsal":1,"port":0,"starboard":0,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":10,"integrity":28,"manoeuvre":1,"detection":1,"speed":10,"space":42,"power":48},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":1,"notes":"Close-action brawler for boardings and short-range gunnery. Reinforced prow and over-tuned drives.","combat":{"size":"colossal","speed":10,"wounds":{"value":0,"bonus":0,"max":28},"resilience":{"bonus":0,"total":10},"defence":{"bonus":3},"fly":0},"source":"Rogue Trader Core Rulebook, p. 196"}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Tempest-class Strike Frigate","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"fb874877eacc7a08","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
|
||||
@@ -1,10 +0,0 @@
|
||||
{"_id":"BRIDGECLV0000001","name":"Command Bridge (Civilian)","type":"gear","img":"icons/svg/book.svg","flags":{"wg-voidships-builder":{"component":{"kind":"bridge","subtype":"civilian","tags":["bridge","internal"]},"voidOnly":true,"voidship":{"slot":"Internal","use":{"space":2,"power":2},"mods":{"armour":0,"integrity":0,"manoeuvre":1,"detection":1,"speed":0,"space":0,"power":0}}}},"system":{"quantity":1,"rarity":"Common","description":"Standard command bridge and cogitators.","value":0,"keywords":[],"effect":"","test":""},"effects":[]}
|
||||
{"_id":"REACTORPLSM00001","name":"Plasma Reactor (Civilian)","type":"gear","img":"icons/svg/energy.svg","flags":{"wg-voidships-builder":{"component":{"kind":"reactor","subtype":"plasma","tags":["reactor","internal"]},"voidOnly":true,"voidship":{"slot":"Internal","use":{"space":4,"power":-10},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":0,"power":10}}}},"system":{"quantity":1,"rarity":"Common","description":"Primary plasma reactor for voidship operations.","value":0,"keywords":[],"effect":"","test":""},"effects":[]}
|
||||
{"_id":"GELLARSTD0000001","name":"Gellar Field Projector","type":"gear","img":"icons/svg/temple.svg","flags":{"wg-voidships-builder":{"component":{"kind":"gellar","subtype":"standard","tags":["gellar","internal"]},"voidOnly":true,"voidship":{"slot":"Internal","use":{"space":2,"power":3},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":0,"power":0}}}},"system":{"quantity":1,"rarity":"Common","description":"Essential protection in the Immaterium.","value":0,"keywords":[],"effect":"","test":""},"effects":[]}
|
||||
{"_id":"WARPDRVMILO00001","name":"Miloslav Warp Drive (Civilian)","type":"gear","img":"icons/svg/wing.svg","flags":{"wg-voidships-builder":{"component":{"kind":"drive","subtype":"warp","tags":["drive","warp","internal"]},"voidOnly":true,"voidship":{"slot":"Internal","use":{"space":3,"power":6},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":0,"power":0}}}},"system":{"quantity":1,"rarity":"Common","description":"Reliable civilian warp drive.","value":0,"keywords":[],"effect":"","test":""},"effects":[]}
|
||||
{"_id":"AUGURARRAY000001","name":"Augur Array (Civilian)","type":"gear","img":"icons/svg/mystery-man.svg","flags":{"wg-voidships-builder":{"component":{"kind":"sensor","subtype":"augur","tags":["sensor","auspex","aetherics"]},"voidOnly":true,"voidship":{"slot":"Aetherics","use":{"space":1,"power":2},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":2,"speed":0,"space":0,"power":0}}}},"system":{"quantity":1,"rarity":"Common","description":"Standard augur/sensor package.","value":0,"keywords":[],"effect":"","test":""},"effects":[]}
|
||||
{"_id":"HULLPLATE0000001","name":"Reinforced Hull Plating","type":"gear","img":"icons/svg/armor.svg","flags":{"wg-voidships-builder":{"component":{"kind":"armour","subtype":"plating","tags":["hull","armour","internal"]},"voidOnly":true,"voidship":{"slot":"Internal","use":{"space":1,"power":0},"mods":{"armour":2,"integrity":4,"manoeuvre":-1,"detection":0,"speed":0,"space":0,"power":0}}}},"system":{"quantity":1,"rarity":"Common","description":"Additional armour plates and bracing.","value":0,"keywords":[],"effect":"","test":""},"effects":[]}
|
||||
{"_id":"CARGOHOLD0000001","name":"Cargo Hold & Lighter Bay","type":"gear","img":"icons/svg/crate.svg","flags":{"wg-voidships-builder":{"component":{"kind":"cargo-bay","subtype":"standard","tags":["cargo-bay","internal"]},"voidOnly":true,"voidship":{"slot":"Internal","use":{"space":-5,"power":1},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":-5,"power":0}}}},"system":{"quantity":1,"rarity":"Common","description":"General-purpose cargo hold with lighter handling.","value":0,"keywords":[],"effect":"","test":""},"effects":[]}
|
||||
{"_id":"AUXPLASMA0000001","name":"Auxiliary Plasma Generator","type":"gear","img":"icons/svg/energy.svg","flags":{"wg-voidships-builder":{"component":{"kind":"plasma","subtype":"aux-generator","tags":["aux-plasma","internal"]},"voidOnly":true,"voidship":{"slot":"Internal","use":{"space":2,"power":-6},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":0,"power":6}}}},"system":{"quantity":1,"rarity":"Common","description":"Auxiliary plasma reactor increasing available power.","value":0,"keywords":[],"effect":"","test":""},"effects":[]}
|
||||
{"_id":"MACROPROW0000001","name":"Macrobattery (Prow)","type":"gear","img":"icons/svg/sword.svg","flags":{"wg-voidships-builder":{"component":{"kind":"weapon","subtype":"macrobattery","tags":["weapon","prow"]},"voidOnly":true,"voidship":{"slot":"Prow","use":{"space":2,"power":2},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":0,"power":0}}}},"system":{"quantity":1,"rarity":"Common","description":"Prow macrobattery.","value":0,"keywords":[],"effect":"","test":""},"effects":[]}
|
||||
{"_id":"MACRODORS0000001","name":"Macrobattery (Dorsal)","type":"gear","img":"icons/svg/sword.svg","flags":{"wg-voidships-builder":{"component":{"kind":"weapon","subtype":"macrobattery","tags":["weapon","dorsal"]},"voidOnly":true,"voidship":{"slot":"Dorsal","use":{"space":2,"power":2},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":0,"power":0}}}},"system":{"quantity":1,"rarity":"Common","description":"Dorsal macrobattery.","value":0,"keywords":[],"effect":"","test":""},"effects":[]}
|
||||
@@ -385,7 +385,8 @@ async _onDropItem(event, data) {
|
||||
|
||||
// Register the sheet
|
||||
Hooks.once("init", () => {
|
||||
Actors.registerSheet(VB_MOD, VoidshipSheet, {
|
||||
// v13: kolekcje dokumentów pod foundry.documents.collections (global Actors deprecated) — fallback na v12
|
||||
(foundry.documents?.collections?.Actors ?? Actors).registerSheet(VB_MOD, VoidshipSheet, {
|
||||
types: ["vehicle"],
|
||||
makeDefault: false,
|
||||
label: "Voidship (W&G)"
|
||||
|
||||
@@ -3,7 +3,9 @@ Hooks.once('init', async () => {
|
||||
if (typeof Handlebars?.registerHelper === "function") {
|
||||
Handlebars.registerHelper("json", (ctx) => JSON.stringify(ctx ?? {}, null, 2));
|
||||
}
|
||||
await loadTemplates(["modules/wg-voidships-builder/templates/voidship-sheet.hbs"]);
|
||||
// v13: loadTemplates przeniesiony do foundry.applications.handlebars (global deprecated) — fallback na v12
|
||||
const _loadTemplates = foundry.applications?.handlebars?.loadTemplates ?? loadTemplates;
|
||||
await _loadTemplates(["modules/wg-voidships-builder/templates/voidship-sheet.hbs"]);
|
||||
console.debug("[VB] templates preloaded");
|
||||
} catch (e) {
|
||||
console.warn("wg-voidships-builder: init failed", e);
|
||||
|
||||
Reference in New Issue
Block a user