The operator's daily clicks become durable assets:
Label export (GET /api/export/labels?days=N, JSONL download):
- Every 확인/오탐 click is a human verdict on a detection, and each verdict
carries a blurred 3-frame evidence timeline. The export turns that into a
dataset: one JSON line per event with type/severity/camera/zone/metrics/
spatial, label (confirmed | false_positive | unreviewed), labeled_by, and
snapshot uuids (images fetched via /snap, which re-checks session + site).
ergo_report rows are excluded - statistics are not labelable detections.
- Admin-only ('manage_compliance'): bulk export of detection history is a
compliance decision, not an operator convenience. Site scope applies.
- This is where the deferred P1-C evaluation set will come from for free.
Improvement actions (move_actions + GET/POST /api/actions):
- Record a site fix against a camera ("미끄럼 방지 매트 설치"); the report's
new card compares detection density over EQUAL windows before/after the
fix (capped 30d; under 3 days shows "측정 중" - two days of data is noise,
not a verdict). Improvement shows accent, worsening shows red - the card
reports honestly in both directions, which is exactly what makes the
improvement numbers credible.
- Future timestamps and >1y-old entries are rejected (the comparison could
not be fair). Recording needs edit_zones + CSRF.
Verified: PHPUnit 145 pass (5 new: export perm gate + line shape/labels/
snapshot join, action validation, equal-window effect math, pending state)
+ Chrome real-render (action card with better/worse/pending rows, export
link, no console errors).
119 lines
6.5 KiB
PHP
119 lines
6.5 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
namespace App\Tests;
|
|
|
|
use App\Http\Request;
|
|
use App\Auth\ArraySessionStore;
|
|
use App\Dashboard\{ExportController, ActionsController};
|
|
use App\Tests\Support\{InMemoryEventRepository, InMemorySnapshotRepository, InMemoryActionRepository};
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
final class ExportActionsTest extends TestCase
|
|
{
|
|
private function sess(string $role = 'admin', ?string $site = null): ArraySessionStore
|
|
{
|
|
$s = new ArraySessionStore();
|
|
$s->set('user_id', 1); $s->set('username', 'u'); $s->set('role', $role); $s->set('csrf', 'tok');
|
|
if ($site !== null) $s->set('site_scope', $site);
|
|
return $s;
|
|
}
|
|
|
|
// ---- 라벨 익스포트 ----------------------------------------------------------
|
|
|
|
public function testExportRequiresAdmin(): void
|
|
{
|
|
$c = new ExportController(new InMemoryEventRepository(), new InMemorySnapshotRepository(), $this->sess('operator'));
|
|
$this->assertSame(403, $c->labels(new Request('GET', '/api/export/labels', [], '', [], []))->status());
|
|
}
|
|
|
|
public function testExportEmitsLabeledJsonl(): void
|
|
{
|
|
$events = new InMemoryEventRepository();
|
|
$snaps = new InMemorySnapshotRepository();
|
|
$now = (float)time();
|
|
$a = $events->insert(['event_uuid' => str_repeat('a', 32), 'camera_id' => 'cam-1',
|
|
'event_type' => 'fall', 'severity' => 'high', 'occurred_at' => $now,
|
|
'metrics' => ['torso_angle' => 82.0], 'spatial_x' => 0.4, 'spatial_y' => 0.6]);
|
|
$events->acknowledge($a, 'op');
|
|
$snaps->add($a, 'uuid-1', 'h1'); $snaps->add($a, 'uuid-2', 'h2');
|
|
$b = $events->insert(['camera_id' => 'cam-1', 'event_type' => 'zone_warning',
|
|
'severity' => 'warning', 'occurred_at' => $now, 'metrics' => []]);
|
|
$events->markFalsePositive($b, 'op');
|
|
$events->insert(['camera_id' => 'cam-1', 'event_type' => 'near_fall',
|
|
'severity' => 'info', 'occurred_at' => $now, 'metrics' => []]); // unreviewed
|
|
$events->insert(['camera_id' => 'cam-1', 'event_type' => 'ergo_report',
|
|
'severity' => 'info', 'occurred_at' => $now, 'metrics' => []]); // 제외 대상
|
|
|
|
$c = new ExportController($events, $snaps, $this->sess('admin'));
|
|
$res = $c->labels(new Request('GET', '/api/export/labels', [], '', [], []));
|
|
$this->assertSame(200, $res->status());
|
|
$this->assertStringContainsString('application/x-ndjson', $res->headers()['Content-Type']);
|
|
$lines = array_values(array_filter(explode("\n", $res->body())));
|
|
$this->assertCount(3, $lines); // ergo_report 제외
|
|
$byType = [];
|
|
foreach ($lines as $l) { $o = json_decode($l, true); $byType[$o['event_type']] = $o; }
|
|
$this->assertSame('confirmed', $byType['fall']['label']);
|
|
$this->assertSame('op', $byType['fall']['labeled_by']);
|
|
$this->assertSame(['uuid-1', 'uuid-2'], $byType['fall']['snapshots']);
|
|
$this->assertEquals([0.4, 0.6], $byType['fall']['spatial']);
|
|
$this->assertSame('false_positive', $byType['zone_warning']['label']);
|
|
$this->assertSame('unreviewed', $byType['near_fall']['label']);
|
|
}
|
|
|
|
// ---- 개선 조치 --------------------------------------------------------------
|
|
|
|
private function actionReq(array $body): Request
|
|
{
|
|
return new Request('POST', '/api/actions', ['X-Csrf-Token' => 'tok'], json_encode($body), [], []);
|
|
}
|
|
|
|
public function testActionSaveValidation(): void
|
|
{
|
|
$c = new ActionsController(new InMemoryActionRepository(), new InMemoryEventRepository(), $this->sess('operator'));
|
|
$this->assertSame(201, $c->save($this->actionReq(['camera_id' => 'cam-1', 'note' => '매트 설치']))->status());
|
|
$this->assertSame(400, $c->save($this->actionReq(['camera_id' => '', 'note' => 'x']))->status());
|
|
$this->assertSame(400, $c->save($this->actionReq(['camera_id' => 'cam-1', 'note' => '']))->status());
|
|
$this->assertSame(400, $c->save($this->actionReq(['camera_id' => 'cam-1', 'note' => 'x',
|
|
'taken_at' => time() + 999]))->status()); // 미래 시각 거부
|
|
// viewer는 기록 불가
|
|
$cv = new ActionsController(new InMemoryActionRepository(), new InMemoryEventRepository(), $this->sess('viewer'));
|
|
$this->assertSame(403, $cv->save($this->actionReq(['camera_id' => 'cam-1', 'note' => 'x']))->status());
|
|
}
|
|
|
|
public function testActionEffectComparesEqualWindows(): void
|
|
{
|
|
$actions = new InMemoryActionRepository();
|
|
$events = new InMemoryEventRepository();
|
|
$now = time();
|
|
$takenAt = $now - 10 * 86400; // 10일 전 조치
|
|
$actions->insert(['camera_id' => 'cam-1', 'note' => '매트', 'taken_at' => $takenAt, 'created_by' => 'u']);
|
|
// 전 창(10일): 4건 / 후 창(10일): 1건
|
|
foreach ([1, 3, 5, 7] as $d) {
|
|
$events->insert(['camera_id' => 'cam-1', 'event_type' => 'near_fall', 'severity' => 'info',
|
|
'occurred_at' => (float)($takenAt - $d * 86400), 'metrics' => []]);
|
|
}
|
|
$events->insert(['camera_id' => 'cam-1', 'event_type' => 'near_fall', 'severity' => 'info',
|
|
'occurred_at' => (float)($takenAt + 2 * 86400), 'metrics' => []]);
|
|
// 다른 카메라·ergo는 집계 밖
|
|
$events->insert(['camera_id' => 'cam-9', 'event_type' => 'fall', 'severity' => 'high',
|
|
'occurred_at' => (float)($takenAt + 1), 'metrics' => []]);
|
|
$events->insert(['camera_id' => 'cam-1', 'event_type' => 'ergo_report', 'severity' => 'info',
|
|
'occurred_at' => (float)($takenAt + 1), 'metrics' => []]);
|
|
|
|
$c = new ActionsController($actions, $events, $this->sess('operator'));
|
|
$data = json_decode($c->json(new Request('GET', '/api/actions', [], '', [], []))->body(), true);
|
|
$e = $data['actions'][0]['effect'];
|
|
$this->assertSame(4, $e['before']);
|
|
$this->assertSame(1, $e['after']);
|
|
$this->assertSame(10, $e['window_days']);
|
|
}
|
|
|
|
public function testFreshActionReportsPendingEffect(): void
|
|
{
|
|
$actions = new InMemoryActionRepository();
|
|
$actions->insert(['camera_id' => 'cam-1', 'note' => '방금 조치', 'taken_at' => time() - 3600, 'created_by' => 'u']);
|
|
$c = new ActionsController($actions, new InMemoryEventRepository(), $this->sess('operator'));
|
|
$data = json_decode($c->json(new Request('GET', '/api/actions', [], '', [], []))->body(), true);
|
|
$this->assertNull($data['actions'][0]['effect']); // 3일 미만: 측정 중
|
|
}
|
|
}
|