Quelltext des aktuellen Standes
<?php
declare(strict_types=1);
use OpenNIT\Assets\AssetHistory;
/**
* Deckt den Lebenslauf eines Geräts ab: dass alle Quellen in einer Reihe
* landen, dass Werte lesbar herauskommen und dass kein Rohwert der Datenbank
* in der Oberfläche auftaucht.
*/
/** Alle Font-Awesome-Klassen, die der Lebenslauf verwendet. */
function symbole(): array
{
$wurzel = dirname(__DIR__);
$quellen = (string)file_get_contents($wurzel . '/src/Assets/AssetHistory.php');
// Das Symbol des Leerzustands steht in der Vorlage, nicht in der Klasse.
$vorlage = (string)file_get_contents($wurzel . '/templates/assets/show.php');
$von = strpos($vorlage, 'id="tab-changes"');
$bis = strpos($vorlage, 'close tab-changes pane', (int)$von);
$quellen .= substr($vorlage, (int)$von, (int)$bis - (int)$von);
preg_match_all("/\bfa-[a-z0-9-]+/", $quellen, $treffer);
$symbole = array_values(array_unique($treffer[0]));
if (count($symbole) < 6) {
throw new RuntimeException('Symbole des Lebenslaufs nicht gefunden');
}
return $symbole;
}
return static function (): void {
/** Ein Audit-Eintrag, wie ihn die Detailseite lädt. */
$audit = static function (string $ts, string $aktion, ?array $alt = null, ?array $neu = null, ?string $details = null): array {
return [
'event_timestamp' => $ts,
'action_type' => $aktion,
'user_name' => 'Theo Test',
'old_value_json' => $alt === null ? null : json_encode($alt),
'new_value_json' => $neu === null ? null : json_encode($neu),
'details_json' => $details,
];
};
// ----------------------------------------------------------------
// Zusammenführung
// ----------------------------------------------------------------
test('Alle Quellen landen in einer nach Zeit sortierten Reihe', function () use ($audit) {
$eintraege = AssetHistory::build(
[$audit('2026-01-02 10:00:00', 'asset_created')],
[['created_at' => '2026-01-04 08:00:00', 'type' => 'handout', 'person_name' => 'Anna', 'created_by_name' => 'Theo']],
[['status' => 'returned', 'borrower_name' => 'Bea', 'actual_start' => '2026-01-01 09:00:00', 'actual_end' => '2026-01-05 09:00:00']],
[['id' => 7, 'title' => 'RAM getauscht', 'type' => 'hardware', 'status' => 'done', 'changed_at' => '2026-01-03 12:00:00']]
);
$zeiten = array_column($eintraege, 'ts');
$sortiert = $zeiten;
rsort($sortiert);
assertSame($sortiert, $zeiten, 'Reihenfolge ist nicht absteigend');
assertSame(
['loan', 'handover', 'configlog', 'audit', 'loan'],
array_column($eintraege, 'kind')
);
});
test('Die Zählung je Art passt zur Filterleiste', function () use ($audit) {
$eintraege = AssetHistory::build(
[$audit('2026-01-02 10:00:00', 'asset_created'), $audit('2026-01-03 10:00:00', 'asset_updated')],
[['created_at' => '2026-01-04 08:00:00', 'type' => 'return']]
);
$zahlen = AssetHistory::zaehleArten($eintraege);
assertSame(2, $zahlen['audit']);
assertSame(1, $zahlen['handover']);
assertSame(0, $zahlen['loan']);
assertSame(0, $zahlen['configlog']);
// Jede Art aus ARTEN ist gezählt – die Leiste fragt alle ab.
assertSame(array_keys(AssetHistory::ARTEN), array_keys($zahlen));
});
// ----------------------------------------------------------------
// Lesbarkeit der Werte
// ----------------------------------------------------------------
test('Nur tatsächlich geänderte Felder erscheinen', function () use ($audit) {
$eintraege = AssetHistory::build([$audit(
'2026-01-02 10:00:00',
'asset_updated',
['Name' => 'Laptop 1', 'Status' => 'Aktiv'],
['Name' => 'Laptop 1', 'Status' => 'Lager']
)]);
$aenderungen = $eintraege[0]['changes'];
assertSame(1, count($aenderungen));
assertSame('Status', $aenderungen[0]['field']);
assertSame('Aktiv', $aenderungen[0]['from']);
assertSame('Lager', $aenderungen[0]['to']);
});
test('Ein geleertes Feld bleibt sichtbar', function () use ($audit) {
// Die schreibende Seite filtert leere Werte heraus; der Schlüssel fehlt
// dann auf der neuen Seite und dürfte sonst spurlos verschwinden.
$eintraege = AssetHistory::build([$audit(
'2026-01-02 10:00:00',
'asset_updated',
['Notizen' => 'Altgerät'],
[]
)]);
$aenderung = $eintraege[0]['changes'][0];
assertSame('Notizen', $aenderung['field']);
assertSame('Altgerät', $aenderung['from']);
assertSame(null, $aenderung['to']);
});
test('Ein neu gesetztes Feld ebenso', function () use ($audit) {
$eintraege = AssetHistory::build([$audit(
'2026-01-02 10:00:00',
'asset_updated',
[],
['Standort' => 'Serverraum']
)]);
$aenderung = $eintraege[0]['changes'][0];
assertSame(null, $aenderung['from']);
assertSame('Serverraum', $aenderung['to']);
});
test('Spaltennamen aus Altbeständen bekommen eine Beschriftung', function () use ($audit) {
$eintraege = AssetHistory::build([$audit(
'2026-01-02 10:00:00',
'asset_updated',
['serial_number' => 'ABC'],
['serial_number' => 'XYZ']
)]);
assertSame('Seriennummer', $eintraege[0]['changes'][0]['field']);
});
test('Ein unbekannter Schlüssel wird lesbar gemacht statt roh gezeigt', function () use ($audit) {
$eintraege = AssetHistory::build([$audit(
'2026-01-02 10:00:00',
'asset_updated',
['irgendein_feld' => 'a'],
['irgendein_feld' => 'b']
)]);
assertSame('Irgendein Feld', $eintraege[0]['changes'][0]['field']);
});
test('Ein bereits beschrifteter Schlüssel wird nicht umgeformt', function () use ($audit) {
// Die schreibende Seite legt deutsche Beschriftungen ab; aus
// „Garantie bis" darf kein „Garantie Bis" werden.
$eintraege = AssetHistory::build([$audit(
'2026-01-02 10:00:00',
'asset_updated',
[],
['Garantie bis' => '2027-04-30', 'Zugewiesen an' => 'Anna']
)]);
assertSame(['Garantie bis', 'Zugewiesen an'], array_column($eintraege[0]['changes'], 'field'));
});
test('Eine Änderung ohne aufgezeichnete Werte sagt das auch', function () use ($audit) {
// Das Audit-Log führt nicht jedes Feld mit Werten – Zusatzfelder etwa
// nicht. „Geändert" ohne jede Zeile ließe den Leser ratlos zurück.
$eintrag = AssetHistory::build([$audit('2026-01-02 10:00:00', 'asset_updated', [], [])])[0];
assertSame([], $eintrag['changes']);
assertSame('Keine Feldänderung aufgezeichnet.', $eintrag['detail']);
});
test('Eine eigene Meldung hat Vorrang vor dem Ersatzsatz', function () use ($audit) {
$eintrag = AssetHistory::build([$audit(
'2026-01-02 10:00:00',
'asset_updated',
[],
[],
json_encode(['message' => 'Aus dem Import übernommen'])
)])[0];
assertSame('Aus dem Import übernommen', $eintrag['detail']);
});
test('Ereignisse ohne erwartbare Werte bekommen keinen Ersatzsatz', function () use ($audit) {
// „Angelegt" oder „Anhang hinzugefügt" führen naturgemäß keine
// Wertänderungen – dort wäre der Hinweis sinnlos.
foreach (['asset_created', 'asset_attachment_added'] as $aktion) {
$eintrag = AssetHistory::build([$audit('2026-01-02 10:00:00', $aktion)])[0];
assertSame(null, $eintrag['detail'], "Ersatzsatz bei {$aktion}");
}
});
test('Datum und Zeitstempel erscheinen in deutscher Schreibweise', function () use ($audit) {
$eintraege = AssetHistory::build([$audit(
'2026-01-02 10:00:00',
'asset_updated',
['Kaufdatum' => '2024-03-07'],
['Kaufdatum' => '2025-11-30']
)]);
assertSame('07.03.2024', $eintraege[0]['changes'][0]['from']);
assertSame('30.11.2025', $eintraege[0]['changes'][0]['to']);
});
test('Ein zusammengesetzter Wert wird nicht als JSON ausgegeben', function () use ($audit) {
$eintraege = AssetHistory::build([$audit(
'2026-01-02 10:00:00',
'asset_updated',
[],
['Hardware-Daten' => ['8 GB', 'i5']]
)]);
assertSame('8 GB, i5', $eintraege[0]['changes'][0]['to']);
});
// ----------------------------------------------------------------
// Beschriftung der Ereignisse
// ----------------------------------------------------------------
test('Jede Audit-Aktion hat eine deutsche Bezeichnung und ein Symbol', function () use ($audit) {
foreach (['asset_created', 'asset_updated', 'asset_deleted', 'asset_attachment_added', 'asset_warranty_synced'] as $aktion) {
$eintrag = AssetHistory::build([$audit('2026-01-02 10:00:00', $aktion)])[0];
assertFalse(str_contains($eintrag['title'], '_'), "Rohwert als Titel: {$aktion}");
assertTrue(str_starts_with($eintrag['icon'], 'fa-'), "Kein Symbol: {$aktion}");
}
});
test('Eine unbekannte Aktion bricht nichts und zeigt keinen Rohwert', function () use ($audit) {
$eintrag = AssetHistory::build([$audit('2026-01-02 10:00:00', 'asset_irgendwas_neues')])[0];
assertSame('Asset Irgendwas Neues', $eintrag['title']);
assertSame('fa-info-circle', $eintrag['icon']);
});
test('Eine Übergabe nennt Richtung, Person und Unterschrift', function () {
$aus = AssetHistory::build([], [[
'created_at' => '2026-01-04 08:00:00', 'type' => 'handout',
'person_name' => 'Anna Beispiel', 'signature_obtained_at' => '2026-01-04 08:05:00',
'created_by_name' => 'Theo Test',
]])[0];
assertSame('Ausgegeben', $aus['title']);
assertSame('Theo Test', $aus['actor']);
assertTrue(str_contains($aus['detail'], 'an Anna Beispiel'));
assertTrue(str_contains($aus['detail'], 'Unterschrift erhalten'));
$ohne = AssetHistory::build([], [[
'created_at' => '2026-01-04 08:00:00', 'type' => 'handout', 'person_name' => 'Anna',
]])[0];
assertTrue(str_contains($ohne['detail'], 'ohne Unterschrift'));
});
// ----------------------------------------------------------------
// Leihvorgänge
// ----------------------------------------------------------------
test('Ein abgeschlossener Leihvorgang ergibt Ausgabe und Rückgabe', function () {
$eintraege = AssetHistory::build([], [], [[
'status' => 'returned', 'borrower_name' => 'Bea',
'actual_start' => '2026-02-01 09:00:00', 'actual_end' => '2026-02-08 17:00:00',
]]);
assertSame(2, count($eintraege));
assertSame('Aus Leihe zurück', $eintraege[0]['title']);
assertSame('Verliehen', $eintraege[1]['title']);
});
test('Eine Reservierung erscheint mit ihrem geplanten Beginn', function () {
$eintrag = AssetHistory::build([], [], [[
'status' => 'reserved', 'borrower_name' => 'Bea', 'planned_start' => '2026-03-01',
]])[0];
assertSame('Reserviert', $eintrag['title']);
assertSame('Geplant', $eintrag['badge']['label']);
});
test('Ein abgesagter Leihvorgang taucht nicht auf', function () {
$eintraege = AssetHistory::build([], [], [[
'status' => 'cancelled', 'borrower_name' => 'Bea', 'planned_start' => '2026-03-01',
]]);
assertSame(0, count($eintraege));
});
// ----------------------------------------------------------------
// Änderungsprotokoll
// ----------------------------------------------------------------
test('Ein Protokoll-Eintrag verlinkt und trägt seinen Status', function () {
$eintrag = AssetHistory::build([], [], [], [[
'id' => 12, 'title' => 'BIOS aktualisiert', 'type' => 'firmware',
'status' => 'failed', 'changed_at' => '2026-01-09 14:00:00',
]], ['firmware' => 'Firmware'])[0];
assertSame('BIOS aktualisiert', $eintrag['title']);
assertSame('/configlog/12', $eintrag['link']);
assertSame('Fehlgeschlagen', $eintrag['badge']['label']);
assertSame('danger', $eintrag['badge']['tone']);
assertSame('Firmware', $eintrag['detail']);
});
// ----------------------------------------------------------------
// Form der Einträge
// ----------------------------------------------------------------
test('Jeder Eintrag hat dieselben Felder', function () use ($audit) {
$eintraege = AssetHistory::build(
[$audit('2026-01-02 10:00:00', 'asset_created')],
[['created_at' => '2026-01-04 08:00:00', 'type' => 'return']],
[['status' => 'active', 'actual_start' => '2026-01-01 09:00:00']],
[['id' => 1, 'title' => 'X', 'status' => 'done', 'changed_at' => '2026-01-03 12:00:00']]
);
$erwartet = ['ts', 'kind', 'title', 'icon', 'tone', 'actor', 'detail', 'link', 'badge', 'changes'];
foreach ($eintraege as $eintrag) {
assertSame($erwartet, array_keys($eintrag), 'Abweichende Felder bei ' . $eintrag['kind']);
assertTrue(array_key_exists($eintrag['kind'], AssetHistory::ARTEN));
}
});
// ----------------------------------------------------------------
// Verdrahtung in der Oberfläche
// ----------------------------------------------------------------
test('Die Detailseite trägt keinen eigenen Verlauf mehr', function () {
// Der Verlauf lag in einem Modal hinter der Uhr in der Kopfleiste;
// er gehört jetzt allein in den Tab.
$tpl = (string)file_get_contents(dirname(__DIR__) . '/templates/assets/show.php');
assertFalse(str_contains($tpl, 'activity-modal'), 'Verlauf-Modal noch vorhanden');
assertFalse(str_contains($tpl, 'openActivityModal'), 'Uhr-Schaltfläche noch vorhanden');
assertTrue(str_contains($tpl, 'id="asset-timeline"'), 'Zeitleiste fehlt im Tab');
});
test('Der Lebenslauf-Tab hängt nicht am Änderungsprotokoll-Modul', function () {
// Audit-Verlauf und Übergaben gibt es auch ohne das Modul.
$tpl = (string)file_get_contents(dirname(__DIR__) . '/templates/assets/show.php');
assertFalse(str_contains($tpl, "if (!empty(\$configlog_enabled)) \$tabs[] = 'changes';"));
assertTrue(str_contains($tpl, "\$tabs[] = 'changes';"));
});
test('Die Zeitleiste kommt ohne Farbliterale aus', function () {
// Styleguide: Farben ausschließlich über Design-Tokens.
$css = (string)file_get_contents(dirname(__DIR__) . '/public/assets/css/app.css');
$von = strpos($css, 'Zeitleiste (Verläufe');
$bis = strpos($css, 'Badges', (int)$von);
$block = substr($css, (int)$von, (int)$bis - (int)$von);
assertTrue(str_contains($block, '.timeline-item'), 'Komponente nicht gefunden');
assertFalse((bool)preg_match('/#[0-9a-fA-F]{3,8}\b/', $block), 'Hex-Farbe in der Zeitleiste');
});
test('Jedes Symbol der Zeitleiste gibt es in der mitgelieferten Schrift', function () {
// Ein Name, den die ausgelieferte Font-Awesome-Fassung nicht kennt,
// hinterlässt einen leeren Kreis – sichtbar erst im Browser.
$wurzel = dirname(__DIR__);
$css = '';
foreach (glob($wurzel . '/public/assets/fontawesome/css/*.css') ?: [] as $datei) {
$css .= (string)file_get_contents($datei);
}
assertTrue($css !== '', 'Font Awesome nicht gefunden');
foreach (symbole() as $symbol) {
assertTrue(str_contains($css, ".{$symbol}:before"), "Kein Zeichen in Font Awesome: {$symbol}");
}
});
test('Jedes Symbol der Zeitleiste steht in der Icon-Zuordnung', function () {
// Sonst bliebe es im Emoji-Modus monochrom.
$map = (string)file_get_contents(dirname(__DIR__) . '/src/Core/IconMap.php');
foreach (symbole() as $symbol) {
assertTrue(str_contains($map, "'{$symbol}'"), "Nicht in IconMap: {$symbol}");
}
});
};