Quelltext des aktuellen Standes
<?php
declare(strict_types=1);
use OpenNIT\Core\Database;
use OpenNIT\Tickets\TicketManager;
/**
* Deckt die Zeiterfassung eines Tickets ab.
*
* Erfasste Zeiten stammen aus zwei Quellen – der Stoppuhr und den an einer
* Antwort eingetragenen Minuten. Beide bleiben in ihrer Tabelle; die Karte im
* Ticket führt sie nur zusammen. Eine Korrektur schreibt deshalb immer in die
* Ursprungszeile, damit Karte, Antwort und Summe denselben Wert zeigen.
*/
return static function (): void {
/** Baut einen TicketManager auf einem Datenbank-Double. */
$manager = static function (array $timerRows, array $replyRows, ?object &$db = null): TicketManager {
$db = new class ($timerRows, $replyRows) extends Database {
/** @var array<int,array{table:string,data:array,where:string,params:array}> */
public array $updates = [];
public function __construct(private array $timerRows, private array $replyRows)
{
parent::__construct([]);
}
public function fetchAll(string $sql, array $params = []): array
{
return str_contains($sql, 'ticket_replies') ? $this->replyRows : $this->timerRows;
}
public function fetchOne(string $sql, array $params = []): ?array
{
$id = (int)($params[0] ?? 0);
$quelle = str_contains($sql, 'ticket_replies') ? $this->replyRows : $this->timerRows;
foreach ($quelle as $zeile) {
if ((int)$zeile['id'] === $id) {
return $zeile;
}
}
return null;
}
public function update(string $table, array $data, string $where, array $whereParams = []): int
{
$this->updates[] = ['table' => $table, 'data' => $data, 'where' => $where, 'params' => $whereParams];
return 1;
}
};
$manager = (new ReflectionClass(TicketManager::class))->newInstanceWithoutConstructor();
$eig = new ReflectionProperty(TicketManager::class, 'db');
$eig->setAccessible(true);
$eig->setValue($manager, $db);
return $manager;
};
// ----------------------------------------------------------------
// Effektive Dauer eines Stoppuhr-Eintrags
// ----------------------------------------------------------------
test('Ein manuell gesetzter Wert schlägt die Messung', function () {
assertSame(45, TicketManager::minutesOfTimeEntry([
'started_at' => '2026-09-01 09:00:00',
'stopped_at' => '2026-09-01 11:00:00',
'paused_total_seconds' => 0,
'minutes_override' => 45,
]));
});
test('Ohne Vorgabe zählt die Messung abzüglich der Pausen', function () {
assertSame(90, TicketManager::minutesOfTimeEntry([
'started_at' => '2026-09-01 09:00:00',
'stopped_at' => '2026-09-01 11:00:00',
'paused_total_seconds' => 1800,
'minutes_override' => null,
]));
});
test('Ein laufender Eintrag hat noch keine Dauer', function () {
assertSame(0, TicketManager::minutesOfTimeEntry([
'started_at' => '2026-09-01 09:00:00',
'stopped_at' => null,
'paused_total_seconds' => 0,
'minutes_override' => null,
]));
});
test('Längere Pausen als Laufzeit ergeben keine negative Dauer', function () {
assertSame(0, TicketManager::minutesOfTimeEntry([
'started_at' => '2026-09-01 09:00:00',
'stopped_at' => '2026-09-01 09:30:00',
'paused_total_seconds' => 7200,
'minutes_override' => null,
]));
});
// ----------------------------------------------------------------
// Zusammenführung beider Quellen
// ----------------------------------------------------------------
$timer = [
['id' => 7, 'ticket_id' => 1, 'user_name' => 'Ada', 'started_at' => '2026-09-01 09:00:00',
'stopped_at' => '2026-09-01 10:00:00', 'paused_total_seconds' => 0, 'minutes_override' => null, 'note' => 'Analyse'],
['id' => 8, 'ticket_id' => 1, 'user_name' => 'Ada', 'started_at' => '2026-09-03 08:00:00',
'stopped_at' => null, 'paused_total_seconds' => 0, 'minutes_override' => null, 'note' => null],
];
$replies = [
['id' => 20, 'ticket_id' => 1, 'created_at' => '2026-09-02 14:00:00', 'time_spent_minutes' => 25,
'author_name' => 'grace@example.test', 'user_name' => 'Grace'],
];
test('Beide Quellen erscheinen in einer Liste, neueste zuerst', function () use ($manager, $timer, $replies) {
$liste = $manager($timer, $replies)->getCombinedTimeEntriesForTicket(1);
assertSame(3, count($liste));
assertSame(['timer', 'reply', 'timer'], array_column($liste, 'source'));
assertSame([8, 20, 7], array_column($liste, 'id'));
});
test('Die Herkunft bleibt am Eintrag erkennbar', function () use ($manager, $timer, $replies) {
$liste = $manager($timer, $replies)->getCombinedTimeEntriesForTicket(1);
$nachId = array_column($liste, null, 'id');
assertSame('reply', $nachId[20]['source']);
assertSame(25, $nachId[20]['minutes']);
assertSame('Grace', $nachId[20]['user_name']);
// Eine Antwort trägt keine eigene Notiz – dort steht ihr Zeitpunkt.
assertTrue($nachId[20]['note'] === null);
assertSame('2026-09-02 14:00:00', $nachId[20]['occurred_at']);
assertSame('Analyse', $nachId[7]['note']);
assertSame(60, $nachId[7]['minutes']);
});
test('Ein laufender Timer zählt noch nicht mit', function () use ($manager, $timer, $replies) {
$m = $manager($timer, $replies);
$liste = array_column($m->getCombinedTimeEntriesForTicket(1), null, 'id');
assertTrue($liste[8]['running'], 'Der laufende Eintrag ist nicht als laufend markiert');
assertSame(0, $liste[8]['minutes']);
// 60 aus der Stoppuhr + 25 aus der Antwort; der laufende Timer bleibt außen vor.
assertSame(85, $m->getTotalMinutesForTicket(1));
});
// ----------------------------------------------------------------
// Korrektur eines Eintrags
// ----------------------------------------------------------------
test('Die Korrektur eines Stoppuhr-Eintrags setzt den manuellen Wert', function () use ($manager, $timer, $replies) {
$db = null;
$m = $manager($timer, $replies, $db);
$ergebnis = $m->updateTimeEntryMinutes('timer', 7, 30);
assertSame(1, $ergebnis['ticket_id']);
assertSame(60, $ergebnis['old_minutes']);
assertSame(30, $ergebnis['new_minutes']);
assertSame('ticket_time_entries', $db->updates[0]['table']);
assertSame(30, $db->updates[0]['data']['minutes_override']);
});
test('Die Korrektur einer Antwort schreibt in die Antwort selbst', function () use ($manager, $timer, $replies) {
// Dadurch stimmen Karte, Badge an der Antwort und Summe zwangsläufig überein.
$db = null;
$m = $manager($timer, $replies, $db);
$ergebnis = $m->updateTimeEntryMinutes('reply', 20, 40);
assertSame(25, $ergebnis['old_minutes']);
assertSame('ticket_replies', $db->updates[0]['table']);
assertSame(40, $db->updates[0]['data']['time_spent_minutes']);
});
test('Ein leerer Wert entfernt die Erfassung an einer Antwort', function () use ($manager, $timer, $replies) {
$db = null;
$m = $manager($timer, $replies, $db);
$m->updateTimeEntryMinutes('reply', 20, null);
assertTrue($db->updates[0]['data']['time_spent_minutes'] === null);
});
test('Ein laufender Timer lässt sich nicht korrigieren', function () use ($manager, $timer, $replies) {
// Seine Dauer steht erst mit dem Stoppen fest.
$m = $manager($timer, $replies);
$meldung = '';
try {
$m->updateTimeEntryMinutes('timer', 8, 10);
} catch (\RuntimeException $e) {
$meldung = $e->getMessage();
}
assertTrue(str_contains($meldung, 'laufender Timer'), 'Meldung: ' . $meldung);
});
test('Eine unbekannte Herkunft wird abgewiesen', function () use ($manager, $timer, $replies) {
$m = $manager($timer, $replies);
$abgewiesen = false;
try {
$m->updateTimeEntryMinutes('rechnung', 7, 10);
} catch (\RuntimeException) {
$abgewiesen = true;
}
assertTrue($abgewiesen);
});
test('Negative und absurd hohe Werte werden begrenzt', function () use ($manager, $timer, $replies) {
$db = null;
$m = $manager($timer, $replies, $db);
$m->updateTimeEntryMinutes('timer', 7, -5);
assertSame(0, $db->updates[0]['data']['minutes_override']);
$m->updateTimeEntryMinutes('timer', 7, 999999);
assertSame(100000, $db->updates[1]['data']['minutes_override']);
});
// ----------------------------------------------------------------
// Oberfläche
// ----------------------------------------------------------------
test('Die erfassten Zeiten bleiben auch bei geschlossenen Tickets sichtbar', function () {
// Die Karte hängt allein an der aktivierten Zeiterfassung, nicht am Status.
$sidebar = (string)file_get_contents(dirname(__DIR__) . '/templates/tickets/_sidebar.php');
assertTrue(
str_contains($sidebar, "if (\\OpenNIT\\Tickets\\TicketManager::isTimeTrackingEnabled()): ?>"),
'Die Karte ist an einen Ticket-Status gekoppelt'
);
// Die Beschriftung kann übersetzt sein; geprüft wird der Text, nicht seine Hülle.
assertTrue(
str_contains($sidebar, '<span>Zeiterfassung</span>')
|| str_contains($sidebar, "\$this->te('Zeiterfassung')"),
'Die Karte heißt nicht Zeiterfassung'
);
});
test('Das Ändern folgt dem Schalter für geschlossene Tickets', function () {
$sidebar = (string)file_get_contents(dirname(__DIR__) . '/templates/tickets/_sidebar.php');
assertTrue(str_contains($sidebar, 'editWhenClosedEnabled()'), 'Der Schalter wird nicht ausgewertet');
assertTrue(str_contains($sidebar, '$zeitBearbeitbar'), 'Das Formular hängt an keiner Bedingung');
});
test('Die Eingabe erscheint erst auf Klick', function () {
// Bei vielen Erfassungen soll die Liste je Zeile nur Person, Dauer und
// den Stift zeigen; das Feld gehört hinter den Klick.
$sidebar = (string)file_get_contents(dirname(__DIR__) . '/templates/tickets/_sidebar.php');
$css = (string)file_get_contents(dirname(__DIR__) . '/public/assets/css/tickets.css');
$js = (string)file_get_contents(dirname(__DIR__) . '/templates/tickets/_js_sidebar.php');
assertTrue(str_contains($sidebar, 'class="ts-te-edit-btn"'), 'Kein Stift an der Zeile');
assertTrue(str_contains($sidebar, 'class="ts-te-form"'), 'Das Formular trägt keine eigene Klasse');
assertTrue(str_contains($css, '.ts-te-form { display:none;'), 'Das Formular ist nicht vorab ausgeblendet');
assertTrue(str_contains($css, '.ts-te.is-editing .ts-te-form { display:flex; }'), 'Der Klick blendet nichts ein');
assertTrue(str_contains($js, "querySelector('.ts-te-list')"), 'Die Liste wird nicht verdrahtet');
assertTrue(str_contains($js, 'ts-te-cancel'), 'Kein Abbruch möglich');
});
test('Das Stift-Symbol ist der Emoji-Umschaltung bekannt', function () {
// Ein Icon ohne Zuordnung bliebe im Emoji-Modus als Font Awesome stehen.
$sidebar = (string)file_get_contents(dirname(__DIR__) . '/templates/tickets/_sidebar.php');
$map = (string)file_get_contents(dirname(__DIR__) . '/src/Core/IconMap.php');
assertTrue(str_contains($sidebar, '<i class="fas fa-pen"></i>'), 'Das Icon fehlt oder trägt kein Stil-Präfix');
assertTrue(str_contains($map, "'fa-pen'"), 'Das Icon ist nicht in der IconMap eingetragen');
});
test('Der Schalter ist ohne Konfiguration aus', function () {
// Ohne Zutun bleiben geschlossene Tickets unveränderlich.
$m = new ReflectionMethod(TicketManager::class, 'editWhenClosedEnabled');
assertTrue($m->isStatic());
$laden = new ReflectionMethod(TicketManager::class, 'loadRuntimeConfig');
$laden->setAccessible(true);
$cfg = $laden->invoke(null);
assertFalse((bool)($cfg['tickets']['edit_when_closed'] ?? false));
});
test('Die Korrektur-Route steht vor keiner kollidierenden Route', function () {
// /tickets/{id}/time/... darf nicht von der Timer-Route verdeckt werden.
$app = (string)file_get_contents(dirname(__DIR__) . '/src/Core/Application.php');
assertTrue(
str_contains($app, "'/tickets/{id}/time/{source}/{entry_id}'"),
'Die Route für die Korrektur fehlt'
);
assertTrue(
strpos($app, "'/tickets/{id}/timer/stop'") < strpos($app, "'/tickets/{id}/time/{source}/{entry_id}'"),
'Die Reihenfolge der Routen hat sich geändert'
);
});
};