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).
62 lines
2.7 KiB
PHP
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']);
|
|
}
|
|
}
|