move_detect/server/tests/FpStatsTest.php
유창욱 47ab1143e1 feat(fp): operator false-positive tag + per-camera FP-rate report
The feedback loop, per product decision (option a): tags feed STATISTICS only.
A human reads the rate and re-tunes a camera deliberately - the tag never
adjusts edge sensitivity on its own (no silent behavior change in a safety
system).

- POST /events/{id}/false-positive: same RBAC ('acknowledge') + CSRF + site
  scope as ack. Tagging also acknowledges (an FP is a handled event - leaving
  it unacknowledged would keep it alarming). COALESCE keeps the FIRST tagger:
  a second tag never rewrites who made the original audit-relevant call.
- move_events.false_positive_by/at (migration included), audit-logged.
- GET /api/fp-stats?days=N: per-(camera, event_type) totals + FP counts,
  FP>0 groups only, fp-desc, fail-closed site scoping.
- Console: each open row gets a visually-secondary 오탐 button beside 확인
  (can't be fat-fingered as the default); resolved rows show a mono verdict
  ("오탐 (user)" struck through vs "확인됨 (user)"). New rail panel "오탐율 ·
  30일" lists top offenders - 30%+ highlighted as re-tuning candidates -
  refreshed on a slow 60s cadence plus immediately after a tag. Detail view
  shows the tag.

Verified: PHPUnit 135 pass / pytest unchanged + Chrome real-render (tag ->
row flips to struck-through verdict + drops out of unacked KPI/tree counts ->
FP panel updates immediately; no console errors).
2026-08-06 20:38:36 +09:00

62 lines
2.7 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests;
use App\Http\Request;
use App\Auth\ArraySessionStore;
use App\Dashboard\FpStatsController;
use App\Tests\Support\InMemoryEventRepository;
use PHPUnit\Framework\TestCase;
final class FpStatsTest extends TestCase
{
private function sess(?string $site = null): ArraySessionStore
{
$s = new ArraySessionStore();
$s->set('user_id', 1);
if ($site !== null) $s->set('site_scope', $site);
return $s;
}
private function repo(): InMemoryEventRepository
{
$r = new InMemoryEventRepository();
$now = (float)time();
// cam-1 zone_warning: 3 events, 2 FP-tagged
$a = $r->insert(['camera_id' => 'cam-1', 'event_type' => 'zone_warning', 'severity' => 'warning', 'site' => 'a', 'occurred_at' => $now]);
$b = $r->insert(['camera_id' => 'cam-1', 'event_type' => 'zone_warning', 'severity' => 'warning', 'site' => 'a', 'occurred_at' => $now]);
$r->insert(['camera_id' => 'cam-1', 'event_type' => 'zone_warning', 'severity' => 'warning', 'site' => 'a', 'occurred_at' => $now]);
$r->markFalsePositive($a, 'u');
$r->markFalsePositive($b, 'u');
// cam-2 fall on another site: 1 event, 1 FP
$c = $r->insert(['camera_id' => 'cam-2', 'event_type' => 'fall', 'severity' => 'high', 'site' => 'b', 'occurred_at' => $now]);
$r->markFalsePositive($c, 'u');
// cam-3: events but no FP tags -> not in the report
$r->insert(['camera_id' => 'cam-3', 'event_type' => 'fall', 'severity' => 'high', 'site' => 'a', 'occurred_at' => $now]);
return $r;
}
public function testRequiresLogin(): void
{
$c = new FpStatsController($this->repo(), new ArraySessionStore());
$this->assertSame(401, $c->json(new Request('GET', '/api/fp-stats', [], '', [], []))->status());
}
public function testAggregatesPerCameraAndType(): void
{
$c = new FpStatsController($this->repo(), $this->sess());
$data = json_decode($c->json(new Request('GET', '/api/fp-stats', [], '', [], []))->body(), true);
$this->assertSame(30, $data['days']);
$this->assertCount(2, $data['stats']); // cam-3 (no FP) excluded
$this->assertSame(['camera_id' => 'cam-1', 'event_type' => 'zone_warning', 'total' => 3, 'fp' => 2],
$data['stats'][0]); // sorted by fp desc
}
public function testSiteScopeFiltersStats(): void
{
$c = new FpStatsController($this->repo(), $this->sess('a'));
$data = json_decode($c->json(new Request('GET', '/api/fp-stats', [], '', [], []))->body(), true);
$this->assertCount(1, $data['stats']);
$this->assertSame('cam-1', $data['stats'][0]['camera_id']);
}
}