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일 미만: 측정 중 } }