feat(studio): Einstellungen-Reiter je Fall, KI-Weg wählbar und sichtbar

Das Studio konnte Fälle bisher nur anlegen, nicht bearbeiten. Es gab kein
Einstellungs-Panel, alle Felder waren nach dem Anlegen eingefroren. Wer etwas
ändern wollte, musste zurück in die klassische Ansicht. Für die geplante
Ablösung der klassischen Ansicht ist das der erste Blocker.

Neuer Reiter Einstellungen in der Fallnavigation, unter Export. Er führt alle
Felder des klassischen Bearbeiten-Modals: Titel, Beschreibung, die drei
Quellenschalter, Sichtbarkeit, KI-Verarbeitung, Aktualisierung mit Intervall
und Startzeit, Aufbewahrung und die drei E-Mail-Benachrichtigungen.

Gespeichert wird nur, was sich geändert hat. Zwei Gründe. Der Server
aktualisiert feldweise, unverändert zurückgereichte Werte würden trotzdem
geschrieben. Und eine unbeabsichtigte Änderung am Intervall kann einen
laufenden Zeitplan verstellen.

Die Wahl des KI-Wegs, also Anthropic gegen die EU-Fassung über Bedrock
Frankfurt, fehlte im Studio vollständig. Sie steht jetzt im Reiter und im
Anlege-Dialog, wortgleich zur klassischen Ansicht. Beim Umstellen erscheint
ein Hinweis, dass bereits erzeugte Inhalte auf dem bisherigen Weg entstanden
sind und die Änderung ab dem nächsten Lauf greift. Läuft gerade ein Schritt,
sagt der Reiter das ebenfalls.

Dazu die Kennzeichen EU und Anthropic in Fallliste und Kopfzeile. Sie
erscheinen nur, wenn der Fall ausdrücklich auf einen Weg gestellt ist. Ohne
Angabe gilt die Vorgabe der Organisation, dann bleibt es unbeschriftet.

Zwei Feinheiten. Das Intervall wird in der größten glatt aufgehenden Einheit
gezeigt, 1440 Minuten also als ein Tag. Und der X-Schalter bleibt bedienbar,
wenn der Fall X bereits nutzt, sonst ließe er sich nach dem Wegfall des
letzten Zugangs nicht mehr abschalten.

Prüfungen ohne Browser und ohne Netzzugriff. 57 Fälle für den Reiter, dazu 29
für die Struktur beider Oberflächen, die prüfen, dass jedes onclick auf eine
vorhandene Methode zeigt, dass keine ID doppelt vergeben ist und dass jedes
Design-Token ohne Fallback auch definiert ist.

Cache-Buster auf 20260802b.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
claude-dev
2026-08-02 18:30:27 +00:00
Ursprung c09c2a49b9
Commit 4ba66cb510
7 geänderte Dateien mit 985 neuen und 9 gelöschten Zeilen

Datei anzeigen

@@ -136,7 +136,7 @@ src/:
static/: static/:
index.html: "Login-Seite (Magic-Link), JS inline" index.html: "Login-Seite (Magic-Link), JS inline"
dashboard.html: "Hauptdashboard (Sidebar + Reiter-Ansicht + 8 Modals + Chat-Widget)" dashboard.html: "Hauptdashboard (Sidebar + Reiter-Ansicht + 8 Modals + Chat-Widget)"
studio.html: "Studio (3-Spalten-Werkstatt, online nur fuer info@ sichtbar - Gating rein clientseitig)" studio.html: "Studio (3-Spalten-Werkstatt, online nur fuer info@ sichtbar - Gating rein clientseitig). Reiter Einstellungen ersetzt das Bearbeiten-Modal der klassischen Ansicht und enthaelt die Wahl des KI-Wegs je Fall."
css/: css/:
style.css: "AegisSight Design System (Dark/Light Theme, alle Komponenten)" style.css: "AegisSight Design System (Dark/Light Theme, alle Komponenten)"
studio.css: "Studio-spezifische Styles" studio.css: "Studio-spezifische Styles"
@@ -144,7 +144,7 @@ src/:
api.js: "REST-API-Client (fetch, Auth-Header, 30s Timeout, 403 -> Nur-Lese-Modus)" api.js: "REST-API-Client (fetch, Auth-Header, 30s Timeout, 403 -> Nur-Lese-Modus)"
app.js: "Hauptlogik: ThemeManager, NotificationCenter, App-Objekt (181 KB)" app.js: "Hauptlogik: ThemeManager, NotificationCenter, App-Objekt (181 KB)"
components.js: "UI-Rendering: Sidebar, Faktenchecks, Toasts, Progress, Karte (geteilt mit Studio)" components.js: "UI-Rendering: Sidebar, Faktenchecks, Toasts, Progress, Karte (geteilt mit Studio)"
studio.js: "Kompletter Studio-Controller (109 KB)" studio.js: "Kompletter Studio-Controller (116 KB), enthaelt auch den Einstellungen-Reiter je Fall"
chat.js: "Chat-Assistent Widget (Bedienfragen)" chat.js: "Chat-Assistent Widget (Bedienfragen)"
pipeline.js: "Grafische Analysepipeline (11 Schritte, Live-Animation)" pipeline.js: "Grafische Analysepipeline (11 Schritte, Live-Animation)"
layout.js: "Reiter-Umschalter je Lage (merkt letzten Reiter; enthaelt noch gridstack-Legacy-Stubs)" layout.js: "Reiter-Umschalter je Lage (merkt letzten Reiter; enthaelt noch gridstack-Legacy-Stubs)"
@@ -168,6 +168,9 @@ tests/:
test_faktencheck_belegtiefe.py: "Belegzaehlung nach Medienhaus, Nachbedingung fuer bestaetigte Fakten, Sperre gegen das Hochstufen von developing, getrennte Bezeichnungen fuer Widerspruch und Widerlegung" test_faktencheck_belegtiefe.py: "Belegzaehlung nach Medienhaus, Nachbedingung fuer bestaetigte Fakten, Sperre gegen das Hochstufen von developing, getrennte Bezeichnungen fuer Widerspruch und Widerlegung"
test_rss_treffer.py: "Keyword-Matching der RSS-Auswertung: Mehrwort-Begriffe treffen wortweise, Auftrag verlangt Einzelbegriffe und Eigennamen" test_rss_treffer.py: "Keyword-Matching der RSS-Auswertung: Mehrwort-Begriffe treffen wortweise, Auftrag verlangt Einzelbegriffe und Eigennamen"
test_bedrock_wiederholung.py: "Wiederholung des EU-Wegs bei Kapazitaets- und Kontingentfehlern, Staffelung, Zeit- und Wartebudget, Sichtbarkeit im Refresh-Protokoll" test_bedrock_wiederholung.py: "Wiederholung des EU-Wegs bei Kapazitaets- und Kontingentfehlern, Staffelung, Zeit- und Wartebudget, Sichtbarkeit im Refresh-Protokoll"
test_sammelaktionen.js: "Sammelaktionen der Seitenleiste im klassischen Dashboard, Auswahlmodus, Filterbeachtung, Loeschbestaetigung (Aufruf mit node)"
test_falleinstellungen.js: "Einstellungen-Reiter im Studio, Formular fuellen, nur Geaendertes speichern, KI-Weg umstellen samt Warnhinweis, Abo nachladen (Aufruf mit node)"
test_studio_struktur.py: "Strukturpruefung beider Oberflaechen ohne Browser, eindeutige IDs, jedes onclick zeigt auf eine vorhandene Methode, Cache-Buster, Design-Tokens"
test_quellenausgabe.py: "Ausgabetreue des Quellenverzeichnisses: Nummern aus dem Lagebild statt Zeilennummern, keine Kappung, ein Verlag ein Name, Statistik deckungsgleich mit dem Verzeichnis" test_quellenausgabe.py: "Ausgabetreue des Quellenverzeichnisses: Nummern aus dem Lagebild statt Zeilennummern, keine Kappung, ein Verlag ein Name, Statistik deckungsgleich mit dem Verzeichnis"
``` ```

Datei anzeigen

@@ -1377,3 +1377,112 @@
.chat-offer-focus{font-size:12px;opacity:.7;margin-bottom:8px;} .chat-offer-focus{font-size:12px;opacity:.7;margin-bottom:8px;}
.chat-offer-btn{cursor:pointer;} .chat-offer-btn{cursor:pointer;}
.chat-offer-btn:disabled{opacity:.6;cursor:default;} .chat-offer-btn:disabled{opacity:.6;cursor:default;}
/* === Einstellungen des Falls ========================================== */
/* Ersetzt das Bearbeiten-Modal der klassischen Ansicht. Eine Spalte mit
begrenzter Breite, damit die Felder auf breiten Schirmen nicht auseinander
laufen. */
.settings-panel {
max-width: 560px;
padding: 4px 2px 16px;
}
.settings-panel .form-group {
margin-bottom: 16px;
}
.settings-panel label {
display: block;
margin-bottom: 6px;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
}
.settings-panel .toggle-label {
display: flex;
margin-bottom: 0;
}
.settings-panel input[type="text"],
.settings-panel input[type="number"],
.settings-panel input[type="time"],
.settings-panel select,
.settings-panel textarea {
width: 100%;
}
.settings-panel textarea {
min-height: 72px;
resize: vertical;
}
/* Hinweis auf einen gerade laufenden Schritt */
.settings-note {
margin-bottom: 16px;
padding: 10px 12px;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg-elevated);
font-size: 12px;
color: var(--text-secondary);
}
.settings-note[hidden] {
display: none;
}
/* Warnung, sobald der KI-Weg vom gespeicherten Stand abweicht */
.settings-warn {
margin-top: 8px;
padding: 8px 10px;
border-left: 2px solid var(--warning);
background: var(--tint-warning);
color: var(--text-secondary);
}
.settings-warn[hidden] {
display: none;
}
.settings-foot {
display: flex;
align-items: center;
gap: 8px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
/* Der Zustandstext schiebt die Knoepfe nach rechts */
.settings-state {
flex: 1;
min-width: 0;
font-size: 12px;
color: var(--text-tertiary);
}
.settings-state.ok {
color: var(--success);
}
.settings-state.fehler {
color: var(--error);
}
/* Kennzeichen des KI-Wegs in der Fallliste. Schrumpft nicht, der Titel
kuerzt sich stattdessen. */
.case-ai-tag {
flex-shrink: 0;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.case-ai-tag.ai-eu {
color: #10B981;
}
.case-ai-tag.ai-anthropic {
color: var(--text-disabled);
}

Datei anzeigen

@@ -13,7 +13,7 @@
<link rel="stylesheet" href="/static/vendor/leaflet.css"> <link rel="stylesheet" href="/static/vendor/leaflet.css">
<link rel="stylesheet" href="/static/vendor/MarkerCluster.css"> <link rel="stylesheet" href="/static/vendor/MarkerCluster.css">
<link rel="stylesheet" href="/static/vendor/MarkerCluster.Default.css"> <link rel="stylesheet" href="/static/vendor/MarkerCluster.Default.css">
<link rel="stylesheet" href="/static/css/style.css?v=20260802a"> <link rel="stylesheet" href="/static/css/style.css?v=20260802b">
<style> <style>
/* Export Modal Radio */ /* Export Modal Radio */
.export-radio { display:flex; align-items:center; gap:10px; padding:8px 12px; cursor:pointer; border-radius:var(--radius-sm); transition:background 0.15s; border:1px solid transparent; margin-bottom:4px; } .export-radio { display:flex; align-items:center; gap:10px; padding:8px 12px; cursor:pointer; border-radius:var(--radius-sm); transition:background 0.15s; border:1px solid transparent; margin-bottom:4px; }
@@ -858,11 +858,11 @@
<script src="/static/js/i18n.js?v=20260513a"></script> <script src="/static/js/i18n.js?v=20260513a"></script>
<script src="/static/js/api.js?v=20260725b"></script> <script src="/static/js/api.js?v=20260725b"></script>
<script src="/static/js/ws.js?v=20260316b"></script> <script src="/static/js/ws.js?v=20260316b"></script>
<script src="/static/js/components.js?v=20260802a"></script> <script src="/static/js/components.js?v=20260802b"></script>
<script src="/static/js/layout.js?v=20260513f"></script> <script src="/static/js/layout.js?v=20260513f"></script>
<script src="/static/js/pipeline.js?v=20260513d"></script> <script src="/static/js/pipeline.js?v=20260513d"></script>
<script src="/static/js/a11y.js?v=20260725a"></script> <script src="/static/js/a11y.js?v=20260725a"></script>
<script src="/static/js/app.js?v=20260802a"></script> <script src="/static/js/app.js?v=20260802b"></script>
<script src="/static/js/cluster-data.js?v=20260322f"></script> <script src="/static/js/cluster-data.js?v=20260322f"></script>
<script src="/static/js/tutorial.js?v=20260316z"></script> <script src="/static/js/tutorial.js?v=20260316z"></script>
<script src="/static/js/chat.js?v=20260514e"></script> <script src="/static/js/chat.js?v=20260514e"></script>

Datei anzeigen

@@ -260,6 +260,7 @@ const Studio = {
</label>` : ''} </label>` : ''}
<button class="case-open" onclick="${sel ? `Studio.toggleCaseSel(${i.id}, !Studio.caseSel.has(${i.id}))` : `Studio.selectIncident(${i.id})`}" title="${UI.escape(i.title)}"> <button class="case-open" onclick="${sel ? `Studio.toggleCaseSel(${i.id}, !Studio.caseSel.has(${i.id}))` : `Studio.selectIncident(${i.id})`}" title="${UI.escape(i.title)}">
<span class="case-title">${UI.escape(i.title)}</span> <span class="case-title">${UI.escape(i.title)}</span>
${Studio._aiTag(i, 'case-ai-tag')}
<span class="case-count">${i.article_count}</span> <span class="case-count">${i.article_count}</span>
</button> </button>
${sel ? '' : `<button class="case-menu-btn" onclick="Studio.caseMenu(event, ${i.id})" title="Mehr" aria-label="Mehr"> ${sel ? '' : `<button class="case-menu-btn" onclick="Studio.caseMenu(event, ${i.id})" title="Mehr" aria-label="Mehr">
@@ -691,6 +692,10 @@ const Studio = {
badge.textContent = research ? 'Analyse' : 'Live'; badge.textContent = research ? 'Analyse' : 'Live';
badge.className = 'studio-type-badge' + (research ? ' type-research' : ''); badge.className = 'studio-type-badge' + (research ? ' type-research' : '');
badge.style.display = ''; badge.style.display = '';
// KI-Weg nur zeigen, wenn der Fall ausdruecklich darauf steht. Ohne
// Angabe gilt die Vorgabe der Organisation, dann bleibt es unbeschriftet.
const aiBadge = document.getElementById('studio-ai-badge');
if (aiBadge) aiBadge.innerHTML = this._aiTag(inc, 'incident-ai-badge');
document.getElementById('art-summary-title').textContent = research ? 'Recherchebericht' : 'Lagebild'; document.getElementById('art-summary-title').textContent = research ? 'Recherchebericht' : 'Lagebild';
document.getElementById('art-latest-title').textContent = research ? 'Zusammenfassung' : 'Neueste Entwicklungen'; document.getElementById('art-latest-title').textContent = research ? 'Zusammenfassung' : 'Neueste Entwicklungen';
}, },
@@ -1333,6 +1338,233 @@ const Studio = {
}, },
// === Studio-Tabs in der Mitte === // === Studio-Tabs in der Mitte ===
// === Einstellungen des Falls =========================================
// Der Reiter ersetzt das Bearbeiten-Modal der klassischen Ansicht. Er
// schickt beim Speichern nur, was sich wirklich geaendert hat. Zwei Gruende.
// Der Server aktualisiert feldweise, alles Mitgeschickte wuerde sonst
// ueberschrieben, auch wenn es nur unveraendert zurueckgereicht wird. Und
// eine unbeabsichtigte Aenderung am Intervall kann einen laufenden Zeitplan
// verstellen.
_setEl(id) { return document.getElementById(id); },
/** Liest das Formular. Der Rueckgabewert ist mit dem Schnappschuss vergleichbar. */
_settingsWerte() {
const g = (id) => this._setEl(id);
const einheit = parseInt(g('set-refresh-unit').value) || 1;
const zahl = parseInt(g('set-refresh-value').value) || 15;
const auto = g('set-refresh-mode').value === 'auto';
const werte = {
title: g('set-title').value.trim(),
description: g('set-description').value.trim(),
international_sources: g('set-international').checked,
include_telegram: g('set-telegram').checked,
include_x: g('set-x').checked,
visibility: g('set-visibility').checked ? 'public' : 'private',
ai_backend: g('set-ai-backend').value || '',
refresh_mode: g('set-refresh-mode').value,
refresh_interval: Math.max(10, Math.min(10080, zahl * einheit)),
retention_days: Math.max(0, Math.min(999, parseInt(g('set-retention').value) || 0)),
};
// Die Startzeit gehoert nur zur automatischen Aktualisierung. Bei
// manueller Aktualisierung bleibt der gespeicherte Wert unangetastet,
// ausgewertet wird er dort ohnehin nicht.
if (auto) werte.refresh_start_time = g('set-refresh-starttime').value || '07:00';
return werte;
},
_settingsAbo() {
return {
notify_email_summary: this._setEl('set-notify-summary').checked,
notify_email_new_articles: this._setEl('set-notify-new-articles').checked,
notify_email_status_change: this._setEl('set-notify-status-change').checked,
};
},
/** Fuellt das Formular aus dem offenen Fall. Dient auch als Verwerfen. */
async loadSettings() {
const inc = this.incident;
if (!inc || !this._setEl('set-title')) return;
const g = (id) => this._setEl(id);
g('set-title').value = inc.title || '';
g('set-description').value = inc.description || '';
g('set-international').checked = !!inc.international_sources;
g('set-telegram').checked = !!inc.include_telegram;
g('set-x').checked = !!inc.include_x;
g('set-visibility').checked = (inc.visibility || 'public') === 'public';
g('set-ai-backend').value = inc.ai_backend || '';
g('set-refresh-mode').value = inc.refresh_mode === 'auto' ? 'auto' : 'manual';
g('set-refresh-starttime').value = inc.refresh_start_time || '07:00';
g('set-retention').value = (inc.retention_days === null || inc.retention_days === undefined)
? 0 : inc.retention_days;
// Intervall in die groesste Einheit zeigen, die glatt aufgeht. 1440
// Minuten liest sich als 1 Tag besser denn als 1440 Minuten.
const iv = parseInt(inc.refresh_interval) || 15;
let einheit = 1;
for (const u of [10080, 1440, 60]) { if (iv % u === 0) { einheit = u; break; } }
g('set-refresh-unit').value = String(einheit);
g('set-refresh-value').value = String(Math.round(iv / einheit));
this.settingsRefreshToggle();
this.settingsIntervalMin();
this.settingsVisibilityHint();
this._settingsRunningNote();
// Der Schnappschuss ist die Vergleichsgrundlage fuers Speichern. Er
// wird vor dem Abo-Nachladen gesetzt, damit ein langsamer Abruf das
// Formular nicht in einem halben Zustand zuruecklaesst.
this._settingsAlt = this._settingsWerte();
this.settingsAiChanged();
this._settingsState('');
try {
const abo = await API.getSubscription(inc.id);
g('set-notify-summary').checked = !!(abo && abo.notify_email_summary);
g('set-notify-new-articles').checked = !!(abo && abo.notify_email_new_articles);
g('set-notify-status-change').checked = !!(abo && abo.notify_email_status_change);
} catch (e) {
// Ohne Abo-Stand bleiben die Haken aus. Das ist der Serverzustand
// fuer einen Fall ohne Abo.
g('set-notify-summary').checked = false;
g('set-notify-new-articles').checked = false;
g('set-notify-status-change').checked = false;
}
this._settingsAboAlt = this._settingsAbo();
// Bewusst abgewartet. Erst danach ist das Formular vollstaendig,
// vorher stuende der X-Schalter noch auf dem Ausgangswert.
await this._settingsXHint();
},
settingsRefreshToggle() {
const auto = this._setEl('set-refresh-mode').value === 'auto';
this._setEl('set-interval-field').classList.toggle('visible', auto);
this._setEl('set-starttime-field').classList.toggle('visible', auto);
},
settingsIntervalMin() {
const einheit = parseInt(this._setEl('set-refresh-unit').value);
const feld = this._setEl('set-refresh-value');
const min = einheit === 1 ? 10 : 1;
feld.min = min;
if (parseInt(feld.value) < min) feld.value = min;
},
settingsVisibilityHint() {
const oeffentlich = this._setEl('set-visibility').checked;
this._setEl('set-visibility-text').textContent = oeffentlich
? 'Öffentlich, für alle Nutzer sichtbar'
: 'Privat, nur für dich sichtbar';
},
/** Warnt, sobald der KI-Weg vom gespeicherten Stand abweicht. */
settingsAiChanged() {
const w = this._setEl('set-ai-warn');
if (!w) return;
const jetzt = this._setEl('set-ai-backend').value || '';
const alt = (this._settingsAlt && this._settingsAlt.ai_backend) || '';
w.hidden = jetzt === alt;
},
_settingsRunningNote() {
const n = this._setEl('set-running-note');
if (n) n.hidden = !this._runningStage;
},
_settingsState(text, art) {
const s = this._setEl('set-state');
if (!s) return;
s.textContent = text || '';
s.className = 'settings-state' + (art ? ' ' + art : '');
},
/** X laesst sich nur einschalten, wenn ein Zugang hinterlegt ist. */
async _settingsXHint() {
const cb = this._setEl('set-x');
const hint = this._setEl('set-x-hint');
if (!cb) return;
let aktiv = 0;
try {
const konten = await API.listXAccounts();
aktiv = (konten || []).filter(a => a.active).length;
} catch (e) {
aktiv = 0; // im Zweifel sperren
}
// Nutzt der Fall X bereits, bleibt der Schalter bedienbar. Sonst koennte
// man ihn nach dem Wegfall des letzten Zugangs nicht mehr abschalten.
cb.disabled = aktiv === 0 && !cb.checked;
if (hint) hint.hidden = aktiv !== 0;
},
async saveSettings() {
const inc = this.incident;
if (!inc || !this._setEl('set-title')) return;
const knopf = this._setEl('set-save');
const jetzt = this._settingsWerte();
if (!jetzt.title) {
UI.showToast('Ohne Titel geht es nicht', 'error');
this._setEl('set-title').focus();
return;
}
const alt = this._settingsAlt || {};
const patch = {};
Object.keys(jetzt).forEach(k => {
if (jetzt[k] !== alt[k]) patch[k] = jetzt[k];
});
const abo = this._settingsAbo();
const aboAlt = this._settingsAboAlt || {};
const aboGeaendert = Object.keys(abo).some(k => abo[k] !== aboAlt[k]);
if (!Object.keys(patch).length && !aboGeaendert) {
this._settingsState('Nichts geändert');
return;
}
knopf.disabled = true;
this._settingsState('Wird gespeichert …');
try {
if (Object.keys(patch).length) {
const neu = await API.updateIncident(inc.id, patch);
// Der Server antwortet mit der angereicherten Lage. Sie ist die
// verlaessliche Quelle, das Formular koennte abweichen.
this.incident = (neu && neu.id) ? neu : Object.assign({}, inc, patch);
const i = this.incidents.findIndex(x => x.id === inc.id);
if (i >= 0) this.incidents[i] = Object.assign({}, this.incidents[i], this.incident);
}
if (aboGeaendert) await API.updateSubscription(inc.id, abo);
this._settingsAlt = this._settingsWerte();
this._settingsAboAlt = abo;
this.settingsAiChanged();
this.renderHeader(this.incident);
this.renderCases();
this._settingsState('Gespeichert', 'ok');
UI.showToast('Einstellungen gespeichert', 'success');
} catch (e) {
this._settingsState('Nicht gespeichert', 'fehler');
UI.showToast((e && e.message) || 'Einstellungen konnten nicht gespeichert werden', 'error');
} finally {
knopf.disabled = false;
}
},
/** Kennzeichen des KI-Wegs. Leer, solange die Org-Vorgabe gilt. */
_aiTag(inc, klasse) {
const b = inc && inc.ai_backend;
if (b === 'bedrock') {
return `<span class="${klasse} ai-eu" title="KI-Verarbeitung in der EU (AWS Bedrock, Frankfurt), Recherche über den europäischen Suchindex staan">EU</span>`;
}
if (b === 'cli') {
return `<span class="${klasse} ai-anthropic" title="KI-Verarbeitung über Anthropic mit eingebauter Websuche (heutiger Weg)">Anthropic</span>`;
}
return '';
},
_tabTitle(key) { _tabTitle(key) {
const el = document.querySelector(`#studio-menu .studio-menu-item[data-art="${CSS.escape(key)}"] .mi-title`); const el = document.querySelector(`#studio-menu .studio-menu-item[data-art="${CSS.escape(key)}"] .mi-title`);
return el ? el.textContent.trim() : key; return el ? el.textContent.trim() : key;
@@ -1395,6 +1627,7 @@ const Studio = {
} }
if (key === 'snapshots') this.loadSnapshots(); if (key === 'snapshots') this.loadSnapshots();
if (key === 'timeline') this.loadTimeline(); if (key === 'timeline') this.loadTimeline();
if (key === 'settings') this.loadSettings();
}, },
closeTab(key, ev) { closeTab(key, ev) {
@@ -2088,6 +2321,7 @@ const Studio = {
include_telegram: document.getElementById('inc-telegram').checked, include_telegram: document.getElementById('inc-telegram').checked,
include_x: document.getElementById('inc-x').checked, include_x: document.getElementById('inc-x').checked,
visibility: document.getElementById('inc-visibility').checked ? 'public' : 'private', visibility: document.getElementById('inc-visibility').checked ? 'public' : 'private',
ai_backend: (document.getElementById('inc-ai-backend') || {}).value || '',
}; };
}, },

Datei anzeigen

@@ -12,8 +12,8 @@
<link rel="stylesheet" href="/static/vendor/leaflet.css"> <link rel="stylesheet" href="/static/vendor/leaflet.css">
<link rel="stylesheet" href="/static/vendor/MarkerCluster.css"> <link rel="stylesheet" href="/static/vendor/MarkerCluster.css">
<link rel="stylesheet" href="/static/vendor/MarkerCluster.Default.css"> <link rel="stylesheet" href="/static/vendor/MarkerCluster.Default.css">
<link rel="stylesheet" href="/static/css/style.css?v=20260802a"> <link rel="stylesheet" href="/static/css/style.css?v=20260802b">
<link rel="stylesheet" href="/static/css/studio.css?v=20260802a"> <link rel="stylesheet" href="/static/css/studio.css?v=20260802b">
</head> </head>
<body> <body>
<div class="studio"> <div class="studio">
@@ -30,6 +30,7 @@
<span class="studio-incident-title" id="incident-title"></span> <span class="studio-incident-title" id="incident-title"></span>
<span class="studio-type-badge" id="type-badge" style="display:none;"></span> <span class="studio-type-badge" id="type-badge" style="display:none;"></span>
<span id="studio-ai-badge"></span>
<div class="studio-status" id="live-status"> <div class="studio-status" id="live-status">
<span class="mini-spinner"></span> <span class="mini-spinner"></span>
@@ -245,6 +246,124 @@
</div> </div>
<div class="tab-panel" data-art="timeline"><div id="art-tl-body"></div></div> <div class="tab-panel" data-art="timeline"><div id="art-tl-body"></div></div>
<div class="tab-panel" data-art="snapshots"><div id="art-snap-body"></div></div> <div class="tab-panel" data-art="snapshots"><div id="art-snap-body"></div></div>
<div class="tab-panel" data-art="settings">
<div class="settings-panel">
<div class="settings-note" id="set-running-note" hidden>
Gerade läuft ein Schritt. Änderungen werden gespeichert, wirken aber erst beim nächsten Lauf danach.
</div>
<div class="form-group">
<label for="set-ai-backend">KI-Verarbeitung <span class="info-icon tooltip-below" data-tooltip="Steuert, wo die KI-Verarbeitung dieser Lage stattfindet.&#10;&#10;Standard: es gilt die Vorgabe der Organisation.&#10;Anthropic: heutiger Weg mit eingebauter Websuche.&#10;EU: Verarbeitung ueber AWS Bedrock in Frankfurt, Recherche ueber den europaeischen Suchindex staan. Prompts und Suchanfragen verlassen den europaeischen Rechtsraum nicht."><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg></span></label>
<select id="set-ai-backend" onchange="Studio.settingsAiChanged()">
<option value="">Standard (Vorgabe der Organisation)</option>
<option value="cli">Anthropic (heutiger Weg)</option>
<option value="bedrock">EU (Frankfurt, DSGVO-Fassung)</option>
</select>
<div class="form-hint settings-warn" id="set-ai-warn" hidden>
Der neue Weg wirkt ab dem nächsten Lauf. Was dieser Fall bisher erzeugt hat, ist auf dem bisherigen Weg entstanden und bleibt unverändert.
</div>
</div>
<div class="form-group">
<label for="set-title">Titel des Vorfalls</label>
<input type="text" id="set-title" required aria-required="true">
</div>
<div class="form-group">
<label for="set-description">Beschreibung / Kontext</label>
<textarea id="set-description" placeholder="Weitere Details zum Vorfall (optional)"></textarea>
</div>
<div class="form-group">
<label>Quellen</label>
<div class="toggle-group">
<label class="toggle-label">
<input type="checkbox" id="set-international">
<span class="toggle-switch"></span>
<span class="toggle-text">Internationale Quellen einbeziehen</span>
</label>
</div>
<div class="toggle-group" style="margin-top: 8px;">
<label class="toggle-label">
<input type="checkbox" id="set-telegram">
<span class="toggle-switch"></span>
<span class="toggle-text">Telegram-Kanäle einbeziehen</span>
</label>
</div>
<div class="toggle-group" style="margin-top: 8px;">
<label class="toggle-label">
<input type="checkbox" id="set-x">
<span class="toggle-switch"></span>
<span class="toggle-text">X (Twitter) einbeziehen</span>
</label>
<div class="form-hint" id="set-x-hint" hidden>Erst einen X-Zugang hinterlegen (klassische Ansicht &rarr; „X-Zugänge").</div>
</div>
</div>
<div class="form-group">
<label>Sichtbarkeit</label>
<div class="toggle-group">
<label class="toggle-label">
<input type="checkbox" id="set-visibility" onchange="Studio.settingsVisibilityHint()">
<span class="toggle-switch"></span>
<span class="toggle-text" id="set-visibility-text">Öffentlich, für alle Nutzer sichtbar</span>
</label>
</div>
</div>
<div class="form-group">
<label for="set-refresh-mode">Aktualisierung</label>
<select id="set-refresh-mode" onchange="Studio.settingsRefreshToggle()">
<option value="manual">Manuell</option>
<option value="auto">Automatisch</option>
</select>
</div>
<div class="form-group conditional-field" id="set-interval-field">
<label for="set-refresh-value">Intervall</label>
<div class="interval-input-group">
<input type="number" id="set-refresh-value" min="10" value="15">
<select id="set-refresh-unit" onchange="Studio.settingsIntervalMin()">
<option value="1" selected>Minuten</option>
<option value="60">Stunden</option>
<option value="1440">Tage</option>
<option value="10080">Wochen</option>
</select>
</div>
</div>
<div class="form-group conditional-field" id="set-starttime-field">
<label for="set-refresh-starttime">Erste Aktualisierung um</label>
<input type="time" id="set-refresh-starttime" value="07:00">
</div>
<div class="form-group">
<label for="set-retention">Aufbewahrung (Tage)</label>
<input type="number" id="set-retention" min="0" max="999" placeholder="0 = Unbegrenzt">
</div>
<div class="form-group">
<label>E-Mail-Benachrichtigungen</label>
<div class="form-hint" style="margin-bottom: 8px;">Per E-Mail benachrichtigen bei</div>
<div class="toggle-group">
<label class="toggle-label">
<input type="checkbox" id="set-notify-summary">
<span class="toggle-switch"></span>
<span class="toggle-text">Neues Lagebild</span>
</label>
</div>
<div class="toggle-group" style="margin-top: 8px;">
<label class="toggle-label">
<input type="checkbox" id="set-notify-new-articles">
<span class="toggle-switch"></span>
<span class="toggle-text">Neue Artikel</span>
</label>
</div>
<div class="toggle-group" style="margin-top: 8px;">
<label class="toggle-label">
<input type="checkbox" id="set-notify-status-change">
<span class="toggle-switch"></span>
<span class="toggle-text">Statusänderung Faktencheck</span>
</label>
</div>
</div>
<div class="settings-foot">
<span class="settings-state" id="set-state"></span>
<button type="button" class="btn btn-secondary btn-small" onclick="Studio.loadSettings()">Verwerfen</button>
<button type="button" class="btn btn-primary btn-small" id="set-save" onclick="Studio.saveSettings()">Speichern</button>
</div>
</div>
</div>
<div class="tab-panel" data-art="export"> <div class="tab-panel" data-art="export">
<div class="export-row"> <div class="export-row">
<select id="export-format"> <select id="export-format">
@@ -389,6 +508,10 @@
<!-- Export ist eine Ausgabe-Aktion, kein Artefakt -> Fusszeile --> <!-- Export ist eine Ausgabe-Aktion, kein Artefakt -> Fusszeile -->
<div class="art-foot"> <div class="art-foot">
<button class="studio-menu-item art-export" data-art="settings" onclick="Studio.openTab('settings')">
<span class="mi-icon"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg></span>
<span class="mi-title">Einstellungen</span>
</button>
<button class="studio-menu-item art-export" data-art="export" onclick="Studio.openTab('export')"> <button class="studio-menu-item art-export" data-art="export" onclick="Studio.openTab('export')">
<span class="mi-icon"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></span> <span class="mi-icon"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></span>
<span class="mi-title">Bericht exportieren</span> <span class="mi-title">Bericht exportieren</span>
@@ -493,6 +616,14 @@
</label> </label>
</div> </div>
</div> </div>
<div class="form-group">
<label for="inc-ai-backend">KI-Verarbeitung <span class="info-icon tooltip-below" data-tooltip="Steuert, wo die KI-Verarbeitung dieser Lage stattfindet.&#10;&#10;Standard: es gilt die Vorgabe der Organisation.&#10;Anthropic: heutiger Weg mit eingebauter Websuche.&#10;EU: Verarbeitung ueber AWS Bedrock in Frankfurt, Recherche ueber den europaeischen Suchindex staan. Prompts und Suchanfragen verlassen den europaeischen Rechtsraum nicht."><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg></span></label>
<select id="inc-ai-backend">
<option value="">Standard (Vorgabe der Organisation)</option>
<option value="cli">Anthropic (heutiger Weg)</option>
<option value="bedrock">EU (Frankfurt, DSGVO-Fassung)</option>
</select>
</div>
<div class="form-group"> <div class="form-group">
<label for="inc-refresh-mode">Aktualisierung</label> <label for="inc-refresh-mode">Aktualisierung</label>
<select id="inc-refresh-mode" onchange="Studio.incRefreshToggle()"> <select id="inc-refresh-mode" onchange="Studio.incRefreshToggle()">
@@ -563,9 +694,9 @@
<script src="/static/js/i18n.js?v=20260513a"></script> <script src="/static/js/i18n.js?v=20260513a"></script>
<script src="/static/js/api.js?v=20260725b"></script> <script src="/static/js/api.js?v=20260725b"></script>
<script src="/static/js/ws.js?v=20260316b"></script> <script src="/static/js/ws.js?v=20260316b"></script>
<script src="/static/js/components.js?v=20260802a"></script> <script src="/static/js/components.js?v=20260802b"></script>
<script src="/static/js/a11y.js?v=20260725a"></script> <script src="/static/js/a11y.js?v=20260725a"></script>
<script src="/static/js/ai-disclaimer.js?v=20260725a"></script> <script src="/static/js/ai-disclaimer.js?v=20260725a"></script>
<script src="/static/js/studio.js?v=20260802a"></script> <script src="/static/js/studio.js?v=20260802b"></script>
</body> </body>
</html> </html>

Datei anzeigen

@@ -0,0 +1,359 @@
/**
* Testet den Einstellungen-Reiter des Studios ohne Browser.
*
* Die Methoden werden aus studio.js herausgeloest und gegen ein schlankes
* DOM-Double laufen gelassen. Kein Netzzugriff, keine Kosten.
*
* Aufruf aus dem Projektstamm:
* node tests/test_falleinstellungen.js
*/
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
let ok = 0;
let fail = 0;
function pruefe(name, bedingung, extra) {
if (bedingung) {
ok++;
console.log(' OK ' + name);
} else {
fail++;
console.log(' FEHL ' + name + ' '
+ (extra === undefined ? '' : JSON.stringify(extra)));
}
}
// --- DOM-Double ------------------------------------------------------------
function element(id) {
const klassen = new Set();
return {
id: id,
value: '',
checked: false,
textContent: '',
innerHTML: '',
hidden: false,
disabled: false,
min: 0,
_fokus: 0,
focus() { this._fokus++; },
classList: {
toggle(name, an) { if (an) klassen.add(name); else klassen.delete(name); },
contains(name) { return klassen.has(name); },
},
};
}
const FELDER = [
'set-title', 'set-description', 'set-international', 'set-telegram', 'set-x',
'set-x-hint', 'set-visibility', 'set-visibility-text', 'set-ai-backend',
'set-ai-warn', 'set-refresh-mode', 'set-refresh-value', 'set-refresh-unit',
'set-interval-field', 'set-starttime-field', 'set-refresh-starttime',
'set-retention', 'set-notify-summary', 'set-notify-new-articles',
'set-notify-status-change', 'set-save', 'set-state', 'set-running-note',
];
const elemente = {};
const meldungen = [];
const aufrufe = { update: [], abo: [] };
let aboAntwort = null;
let aboWirft = false;
let updateWirft = false;
let xKonten = [{ active: true }];
const umgebung = {
document: { getElementById: (id) => elemente[id] || null },
UI: { showToast: (text, art) => meldungen.push({ text, art }) },
API: {
getSubscription: async () => {
if (aboWirft) throw new Error('kein Abo');
return aboAntwort;
},
updateIncident: async (id, daten) => {
aufrufe.update.push({ id, daten });
if (updateWirft) throw new Error('keine Berechtigung');
return Object.assign({ id }, daten);
},
updateSubscription: async (id, daten) => { aufrufe.abo.push({ id, daten }); },
listXAccounts: async () => xKonten,
},
console: console,
};
umgebung.window = umgebung;
// --- Die Methoden aus studio.js herausloesen -------------------------------
const quelle = fs.readFileSync(
path.join(__dirname, '..', 'src', 'static', 'js', 'studio.js'), 'utf8');
const von = quelle.indexOf(' // === Einstellungen des Falls ===');
const bis = quelle.indexOf(' _tabTitle(key) {', von);
if (von === -1 || bis === -1) {
console.log('FEHLER: Einstellungen-Block in studio.js nicht gefunden');
process.exit(1);
}
const kontext = vm.createContext(umgebung);
vm.runInContext('var Studio = {\n' + quelle.slice(von, bis) + '\n'
+ ' incident: null, incidents: [], _runningStage: null,\n'
+ ' _kopf: 0, _liste: 0,\n'
+ ' renderHeader() { this._kopf++; },\n'
+ ' renderCases() { this._liste++; },\n'
+ '};', kontext);
const Studio = kontext.Studio;
const LAGE = {
id: 7,
title: 'Ceuta',
description: 'Grenzsituation',
type: 'adhoc',
status: 'active',
visibility: 'public',
international_sources: true,
include_telegram: false,
include_x: false,
ai_backend: 'bedrock',
refresh_mode: 'auto',
refresh_interval: 1440,
refresh_start_time: '06:30',
retention_days: 30,
};
function neu(ueber) {
for (const id of FELDER) elemente[id] = element(id);
Studio.incident = Object.assign({}, LAGE, ueber || {});
Studio.incidents = [Object.assign({}, Studio.incident)];
Studio._runningStage = null;
Studio._settingsAlt = null;
Studio._settingsAboAlt = null;
Studio._kopf = 0;
Studio._liste = 0;
aufrufe.update.length = 0;
aufrufe.abo.length = 0;
meldungen.length = 0;
aboAntwort = null;
aboWirft = false;
updateWirft = false;
xKonten = [{ active: true }];
}
const g = (id) => elemente[id];
async function main() {
console.log('\nA) Das Formular wird aus dem Fall gefüllt');
neu();
await Studio.loadSettings();
pruefe('A1 Titel', g('set-title').value === 'Ceuta');
pruefe('A2 Beschreibung', g('set-description').value === 'Grenzsituation');
pruefe('A3 internationale Quellen an', g('set-international').checked === true);
pruefe('A4 Telegram aus', g('set-telegram').checked === false);
pruefe('A5 öffentlich', g('set-visibility').checked === true);
pruefe('A6 KI-Weg steht auf EU', g('set-ai-backend').value === 'bedrock');
pruefe('A7 Aufbewahrung', String(g('set-retention').value) === '30');
pruefe('A8 Startzeit', g('set-refresh-starttime').value === '06:30');
console.log('\nB) Das Intervall wird in der größten glatten Einheit gezeigt');
pruefe('B1 1440 Minuten werden zu 1 Tag',
g('set-refresh-unit').value === '1440' && String(g('set-refresh-value').value) === '1',
[g('set-refresh-unit').value, g('set-refresh-value').value]);
neu({ refresh_interval: 90 });
await Studio.loadSettings();
pruefe('B2 90 Minuten bleiben Minuten',
g('set-refresh-unit').value === '1' && String(g('set-refresh-value').value) === '90',
[g('set-refresh-unit').value, g('set-refresh-value').value]);
neu({ refresh_interval: 120 });
await Studio.loadSettings();
pruefe('B3 120 Minuten werden zu 2 Stunden',
g('set-refresh-unit').value === '60' && String(g('set-refresh-value').value) === '2');
neu({ refresh_interval: 10080 });
await Studio.loadSettings();
pruefe('B4 10080 Minuten werden zu 1 Woche',
g('set-refresh-unit').value === '10080' && String(g('set-refresh-value').value) === '1');
console.log('\nC) Intervall und Startzeit nur bei automatischer Aktualisierung');
neu({ refresh_mode: 'manual' });
await Studio.loadSettings();
pruefe('C1 bei manuell verborgen',
g('set-interval-field').classList.contains('visible') === false
&& g('set-starttime-field').classList.contains('visible') === false);
g('set-refresh-mode').value = 'auto';
Studio.settingsRefreshToggle();
pruefe('C2 bei automatisch sichtbar',
g('set-interval-field').classList.contains('visible') === true
&& g('set-starttime-field').classList.contains('visible') === true);
console.log('\nD) Gespeichert wird nur, was sich geändert hat');
neu();
await Studio.loadSettings();
await Studio.saveSettings();
pruefe('D1 ohne Änderung kein Aufruf', aufrufe.update.length === 0, aufrufe.update);
pruefe('D2 der Zustand sagt es', g('set-state').textContent === 'Nichts geändert',
g('set-state').textContent);
g('set-title').value = 'Ceuta und Melilla';
await Studio.saveSettings();
pruefe('D3 genau ein Aufruf', aufrufe.update.length === 1);
pruefe('D4 nur der Titel im Patch',
JSON.stringify(Object.keys(aufrufe.update[0].daten)) === '["title"]',
aufrufe.update[0].daten);
pruefe('D5 richtige Lage', aufrufe.update[0].id === 7);
pruefe('D6 Liste und Kopfzeile neu gezeichnet',
Studio._kopf === 1 && Studio._liste === 1);
pruefe('D7 Rückmeldung an den Nutzer',
meldungen.length === 1 && meldungen[0].art === 'success', meldungen);
await Studio.saveSettings();
pruefe('D8 direkt danach ist wieder nichts offen', aufrufe.update.length === 1,
aufrufe.update);
console.log('\nE) Der KI-Weg lässt sich umstellen');
neu();
await Studio.loadSettings();
g('set-ai-backend').value = 'cli';
await Studio.saveSettings();
pruefe('E1 nur der KI-Weg im Patch',
JSON.stringify(aufrufe.update[0].daten) === '{"ai_backend":"cli"}',
aufrufe.update[0].daten);
neu();
await Studio.loadSettings();
g('set-ai-backend').value = '';
await Studio.saveSettings();
pruefe('E2 Zurücksetzen auf die Org-Vorgabe schickt den Leerstring',
aufrufe.update.length === 1 && aufrufe.update[0].daten.ai_backend === '',
aufrufe.update[0] && aufrufe.update[0].daten);
console.log('\nF) Der Warnhinweis folgt der Änderung');
neu();
await Studio.loadSettings();
pruefe('F1 zunächst verborgen', g('set-ai-warn').hidden === true);
g('set-ai-backend').value = 'cli';
Studio.settingsAiChanged();
pruefe('F2 nach der Umstellung sichtbar', g('set-ai-warn').hidden === false);
g('set-ai-backend').value = 'bedrock';
Studio.settingsAiChanged();
pruefe('F3 zurückgestellt wieder verborgen', g('set-ai-warn').hidden === true);
g('set-ai-backend').value = 'cli';
Studio.settingsAiChanged();
await Studio.saveSettings();
pruefe('F4 nach dem Speichern ist der neue Weg der Normalfall',
g('set-ai-warn').hidden === true);
console.log('\nG) Der Hinweis auf einen laufenden Schritt');
neu();
Studio._runningStage = 'collect';
await Studio.loadSettings();
pruefe('G1 bei laufendem Schritt sichtbar', g('set-running-note').hidden === false);
neu();
await Studio.loadSettings();
pruefe('G2 sonst verborgen', g('set-running-note').hidden === true);
console.log('\nH) E-Mail-Benachrichtigungen');
neu();
aboAntwort = { notify_email_summary: true, notify_email_new_articles: false,
notify_email_status_change: true };
await Studio.loadSettings();
pruefe('H1 Stand wird geladen',
g('set-notify-summary').checked === true
&& g('set-notify-new-articles').checked === false
&& g('set-notify-status-change').checked === true);
await Studio.saveSettings();
pruefe('H2 ohne Änderung kein Abo-Aufruf', aufrufe.abo.length === 0);
g('set-notify-new-articles').checked = true;
await Studio.saveSettings();
pruefe('H3 Änderung wird geschickt', aufrufe.abo.length === 1);
pruefe('H4 alle drei Werte im Aufruf',
aufrufe.abo[0].daten.notify_email_new_articles === true
&& aufrufe.abo[0].daten.notify_email_summary === true,
aufrufe.abo[0].daten);
pruefe('H5 die Lage selbst wurde nicht angefasst', aufrufe.update.length === 0);
neu();
aboWirft = true;
await Studio.loadSettings();
pruefe('H6 ohne Abo bleiben die Haken aus',
g('set-notify-summary').checked === false
&& g('set-notify-status-change').checked === false);
console.log('\nI) Randfälle');
neu();
await Studio.loadSettings();
g('set-title').value = ' ';
await Studio.saveSettings();
pruefe('I1 ohne Titel wird nicht gespeichert', aufrufe.update.length === 0);
pruefe('I2 Fehlermeldung erscheint',
meldungen.length === 1 && meldungen[0].art === 'error', meldungen);
pruefe('I3 das Titelfeld bekommt den Fokus', g('set-title')._fokus === 1);
neu();
await Studio.loadSettings();
updateWirft = true;
g('set-title').value = 'Anderer Titel';
await Studio.saveSettings();
pruefe('I4 Fehler beim Speichern wird gemeldet',
meldungen.some(m => m.art === 'error'), meldungen);
pruefe('I5 der Zustand sagt es auch', g('set-state').textContent === 'Nicht gespeichert',
g('set-state').textContent);
pruefe('I6 der Knopf ist wieder bedienbar', g('set-save').disabled === false);
neu();
Studio.incident = null;
await Studio.loadSettings();
await Studio.saveSettings();
pruefe('I7 ohne offenen Fall passiert nichts', aufrufe.update.length === 0);
console.log('\nJ) Der X-Schalter hängt an den hinterlegten Zugängen');
neu();
xKonten = [];
await Studio.loadSettings();
pruefe('J1 ohne Zugang gesperrt', g('set-x').disabled === true);
pruefe('J2 mit Hinweis', g('set-x-hint').hidden === false);
neu({ include_x: true });
xKonten = [];
await Studio.loadSettings();
pruefe('J3 nutzt der Fall X bereits, bleibt er bedienbar', g('set-x').disabled === false);
neu();
xKonten = [{ active: true }];
await Studio.loadSettings();
pruefe('J4 mit Zugang frei und ohne Hinweis',
g('set-x').disabled === false && g('set-x-hint').hidden === true);
console.log('\nK) Die Sichtbarkeit beschriftet sich selbst');
neu();
await Studio.loadSettings();
pruefe('K1 öffentlich', g('set-visibility-text').textContent.startsWith('Öffentlich'),
g('set-visibility-text').textContent);
g('set-visibility').checked = false;
Studio.settingsVisibilityHint();
pruefe('K2 privat', g('set-visibility-text').textContent.startsWith('Privat'),
g('set-visibility-text').textContent);
console.log('\nL) Das Kennzeichen des KI-Wegs');
pruefe('L1 EU', Studio._aiTag({ ai_backend: 'bedrock' }, 'x').includes('>EU<'));
pruefe('L2 EU trägt die Klasse', Studio._aiTag({ ai_backend: 'bedrock' }, 'x').includes('x ai-eu'));
pruefe('L3 Anthropic', Studio._aiTag({ ai_backend: 'cli' }, 'x').includes('>Anthropic<'));
pruefe('L4 ohne Angabe bleibt es leer', Studio._aiTag({ ai_backend: null }, 'x') === '');
pruefe('L5 auch ohne Lage', Studio._aiTag(null, 'x') === '');
pruefe('L6 unbekannter Wert erzeugt nichts',
Studio._aiTag({ ai_backend: 'irgendwas' }, 'x') === '');
console.log('\nM) Die Untergrenze des Intervalls');
neu();
await Studio.loadSettings();
g('set-refresh-unit').value = '1';
g('set-refresh-value').value = '3';
Studio.settingsIntervalMin();
pruefe('M1 in Minuten sind 10 das Minimum', String(g('set-refresh-value').value) === '10',
g('set-refresh-value').value);
g('set-refresh-unit').value = '60';
g('set-refresh-value').value = '1';
Studio.settingsIntervalMin();
pruefe('M2 in Stunden ist 1 erlaubt', String(g('set-refresh-value').value) === '1');
console.log('\nErgebnis: ' + ok + ' bestanden, ' + fail + ' fehlgeschlagen');
process.exit(fail ? 1 : 0);
}
main();

140
tests/test_studio_struktur.py Normale Datei
Datei anzeigen

@@ -0,0 +1,140 @@
"""Prueft die Struktur der Oberflaechen ohne Browser.
Faengt genau die Fehler, die eine Syntaxpruefung durchlaesst. Ein onclick, das
auf eine nicht vorhandene Methode zeigt. Ein Feld, das das Skript sucht, das
im Dokument aber fehlt. Eine ID, die zweimal vergeben ist. Ein vergessener
Cache-Buster.
Aufruf aus dem Projektstamm:
python3 tests/test_studio_struktur.py
"""
import os
import re
import sys
STAMM = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STATIC = os.path.join(STAMM, "src", "static")
ok = 0
fehl = 0
def pruefe(name, bedingung, extra=None):
global ok, fehl
if bedingung:
ok += 1
print(f" OK {name}")
else:
fehl += 1
print(f" FEHL {name}" + (f" {extra}" if extra is not None else ""))
def lies(*teile):
with open(os.path.join(STATIC, *teile), encoding="utf-8") as f:
return f.read()
studio_html = lies("studio.html")
studio_js = lies("js", "studio.js")
dash_html = lies("dashboard.html")
app_js = lies("js", "app.js")
comp_js = lies("js", "components.js")
def ids(html):
return re.findall(r'\bid="([^"]+)"', html)
def doppelte(liste):
gesehen, doppelt = set(), []
for x in liste:
if x in gesehen:
doppelt.append(x)
gesehen.add(x)
return doppelt
print("\nA) IDs sind eindeutig")
for name, html in [("studio.html", studio_html), ("dashboard.html", dash_html)]:
d = doppelte(ids(html))
pruefe(f"A keine doppelte ID in {name}", not d, d)
print("\nB) Der Einstellungen-Reiter ist vollstaendig verdrahtet")
# Alle Felder, die die Logik anfasst, muessen im Dokument stehen.
gesucht = sorted(set(re.findall(r"_setEl\('([^']+)'\)", studio_js)))
vorhanden = set(ids(studio_html))
fehlend = [f for f in gesucht if f not in vorhanden]
pruefe("B1 jedes angesprochene Feld existiert", not fehlend, fehlend)
pruefe("B2 es sind auch wirklich welche geprueft", len(gesucht) >= 15, len(gesucht))
# Panel und Navigationsknopf haengen am selben Schluessel.
pruefe("B3 Navigationsknopf vorhanden",
'data-art="settings" onclick="Studio.openTab(\'settings\')"' in studio_html)
pruefe("B4 Panel vorhanden", '<div class="tab-panel" data-art="settings">' in studio_html)
pruefe("B5 activateTab kennt den Reiter",
"if (key === 'settings') this.loadSettings();" in studio_js)
print("\nC) Jedes onclick zeigt auf eine vorhandene Methode")
for name, html, js, praefix in [
("studio", studio_html, studio_js, "Studio"),
("dashboard", dash_html, app_js + comp_js, "App"),
]:
aufgerufen = sorted(set(re.findall(praefix + r"\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\(", html)))
fehlend = []
for m in aufgerufen:
# Methoden stehen als " name(" oder " async name(" im Objektliteral
if not re.search(r"^\s+(async\s+)?" + re.escape(m) + r"\s*\(", js, re.M):
fehlend.append(m)
pruefe(f"C {name}.html ruft nur vorhandene Methoden ({len(aufgerufen)} geprueft)",
not fehlend, fehlend)
print("\nD) Der KI-Weg ist in beiden Ansichten gleich angeboten")
for name, html, feld in [("studio.html", studio_html, "set-ai-backend"),
("studio.html anlegen", studio_html, "inc-ai-backend"),
("dashboard.html", dash_html, "inc-ai-backend")]:
block = re.search(r'<select id="' + feld + r'".*?</select>', html, re.S)
pruefe(f"D {name} bietet drei Werte an",
bool(block) and block.group(0).count("<option") == 3)
if block:
werte = re.findall(r'value="([^"]*)"', block.group(0))
pruefe(f"D {name} genau die erlaubten Werte", werte == ["", "cli", "bedrock"], werte)
print("\nE) Cache-Buster sind einheitlich")
for name, html in [("studio.html", studio_html), ("dashboard.html", dash_html)]:
stempel = set(re.findall(r"/static/(?:js|css)/[a-z0-9_.-]+\?v=([0-9a-z]+)", html))
# Fremde Bausteine (i18n, api, ws) haben eigene Staende, das ist in Ordnung.
# Geprueft wird nur, dass die in diesem Zug geaenderten Dateien mitgezogen sind.
eigene = re.findall(r"/static/(?:js|css)/(studio|app|style|components|studio)\.(?:js|css)\?v=([0-9a-z]+)", html)
pruefe(f"E {name} nennt Stempel", bool(stempel), stempel)
pruefe(f"E {name} geaenderte Dateien tragen 20260802b",
all(v == "20260802b" for _, v in eigene), eigene)
print("\nF) Tags im neuen Panel sind ausgeglichen")
i = studio_html.index('<div class="tab-panel" data-art="settings">')
j = studio_html.index('<div class="tab-panel" data-art="export">')
teil = studio_html[i:j]
for tag in ["div", "select", "label", "textarea"]:
auf = len(re.findall(r"<" + tag + r"[\s>]", teil))
zu = len(re.findall(r"</" + tag + r">", teil))
pruefe(f"F {tag} ausgeglichen ({auf})", auf == zu, (auf, zu))
print("\nG) Das Kennzeichen nutzt vorhandene Stile")
style = lies("css", "style.css")
studio_css = lies("css", "studio.css")
pruefe("G1 incident-ai-badge ist definiert", ".incident-ai-badge" in style)
pruefe("G2 case-ai-tag ist definiert", ".case-ai-tag" in studio_css)
pruefe("G3 studio.html bindet style.css ein", "/static/css/style.css" in studio_html)
pruefe("G4 Kennzeichenplatz in der Kopfzeile", 'id="studio-ai-badge"' in studio_html)
print("\nH) Keine unbekannten Design-Tokens")
# var(--x, wert) ist auch ohne Definition gueltig, der Fallback greift.
# Geprueft wird deshalb nur, was ohne Fallback benutzt wird.
ohne_fallback = set(re.findall(r"var\((--[a-z0-9-]+)\s*\)", studio_css))
definiert = set(re.findall(r"^\s*(--[a-z0-9-]+)\s*:", style, re.M))
definiert |= set(re.findall(r"^\s*(--[a-z0-9-]+)\s*:", studio_css, re.M))
unbekannt = sorted(ohne_fallback - definiert)
pruefe("H1 jedes Token ohne Fallback ist definiert", not unbekannt, unbekannt)
pruefe("H2 es sind auch wirklich welche geprueft", len(ohne_fallback) >= 10, len(ohne_fallback))
print(f"\nErgebnis: {ok} bestanden, {fehl} fehlgeschlagen")
sys.exit(1 if fehl else 0)