From 2e205ef7922688e7f256109770cf324774923737 Mon Sep 17 00:00:00 2001 From: PeterChrz Date: Fri, 18 Sep 2026 20:12:30 -0400 Subject: [PATCH] v2.1: session-grouped summary home page (GPU/model/quant/runner bands, per-suite pass/fail, avg + weighted tok/s), flat task table moved to /results, summary command + tables on schedule and after import --- app/Console/Commands/FestbenchImport.php | 4 + app/Console/Commands/SessionSummary.php | 186 +++++++++++++++++ ..._09_18_000003_create_session_summaries.php | 61 ++++++ .../components/layouts/app/sidebar.blade.php | 2 +- resources/views/flux/icon/cpu.blade.php | 56 +++++ .../views/livewire/results/index.blade.php | 3 +- resources/views/livewire/runs/show.blade.php | 2 +- .../views/livewire/summary/index.blade.php | 197 ++++++++++++++++++ routes/console.php | 5 +- routes/web.php | 3 +- tests/Feature/HomeSnapshotTest.php | 8 +- tests/Feature/ResultsPageTest.php | 8 +- tests/Feature/SessionSummaryTest.php | 117 +++++++++++ 13 files changed, 637 insertions(+), 15 deletions(-) create mode 100644 app/Console/Commands/SessionSummary.php create mode 100644 database/migrations/2026_09_18_000003_create_session_summaries.php create mode 100644 resources/views/flux/icon/cpu.blade.php create mode 100644 resources/views/livewire/summary/index.blade.php create mode 100644 tests/Feature/SessionSummaryTest.php diff --git a/app/Console/Commands/FestbenchImport.php b/app/Console/Commands/FestbenchImport.php index bc9565a..6b5fc35 100644 --- a/app/Console/Commands/FestbenchImport.php +++ b/app/Console/Commands/FestbenchImport.php @@ -89,6 +89,10 @@ public function handle(): int $this->warn("{$unknownSuites} rows were stored with fallback routing — check the suite registry."); } + // Keep the precomputed summary + snapshot in step with the new data. + $this->call('festbench:summary'); + $this->call('festbench:snapshot'); + return self::SUCCESS; } diff --git a/app/Console/Commands/SessionSummary.php b/app/Console/Commands/SessionSummary.php new file mode 100644 index 0000000..98516b7 --- /dev/null +++ b/app/Console/Commands/SessionSummary.php @@ -0,0 +1,186 @@ +leftJoin('users', 'users.id', '=', 'benchmark_runs.user_id') + ->orderByDesc('ran_at') + ->orderByDesc('benchmark_runs.id') + ->get([ + 'benchmark_runs.*', + 'users.name as runner_name', + ]); + + foreach ($runs as $run) { + // Visibility participates in the grouping key so private runs + // form their own band — the guest view can then filter bands by + // visibility without any per-request recomputation. + $key = implode('|', [ + $run->label ?? '-', + $run->model_id ?? '-', + $run->model_quant ?? '-', + $run->gpu ?? '-', + $run->gpu_count ?? '-', + $run->user_id ?? '-', + $run->visibility, + ]); + + $sessions[$key] ??= [ + 'key' => $key, + 'label' => $run->label, + 'model_id' => $run->model_id, + 'model_quant' => $run->model_quant, + 'gpu' => $run->gpu, + 'gpu_count' => $run->gpu_count, + 'runner_name' => $run->runner_name, + 'user_id' => $run->user_id, + 'visibility' => $run->visibility, + 'ran_at' => $run->ran_at, + 'passed' => 0, + 'failed' => 0, + 'tokens' => 0, + 'runs' => collect(), + ]; + + $agg = $this->aggregateRun($run); + + $sessions[$key]['passed'] += $agg['passed']; + $sessions[$key]['failed'] += $agg['failed']; + $sessions[$key]['tokens'] += $agg['total_tokens']; + $sessions[$key]['runs']->push($agg + ['suite' => $run->suite, 'level' => $agg['level']]); + } + + DB::transaction(function () use ($sessions) { + DB::table('session_summaries')->truncate(); + + foreach (array_values($sessions) as $bandIndex => $band) { + $summaryId = DB::table('session_summaries')->insertGetId([ + 'session_key' => $band['key'], + 'label' => $band['label'], + 'model_id' => $band['model_id'], + 'model_quant' => $band['model_quant'], + 'gpu' => $band['gpu'], + 'gpu_count' => $band['gpu_count'], + 'runner_name' => $band['runner_name'], + 'user_id' => $band['user_id'], + 'visibility' => $band['visibility'], + 'ran_at' => $band['ran_at'], + 'total_passed' => $band['passed'], + 'total_failed' => $band['failed'], + 'total_tokens' => $band['tokens'], + 'sequence' => $bandIndex, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + foreach ($band['runs'] as $suiteIndex => $row) { + DB::table('session_suite_rows')->insert([ + 'session_summary_id' => $summaryId, + 'benchmark_run_id' => $row['run_pk'], + 'run_id' => $row['run_id'], + 'suite' => $row['suite'], + 'level' => $row['level'], + 'passed' => $row['passed'], + 'failed' => $row['failed'], + 'avg_user_tps' => $row['avg_user_tps'], + 'weighted_user_tps' => $row['weighted_user_tps'], + 'total_tokens' => $row['total_tokens'], + 'sequence' => $suiteIndex, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + }); + + $this->info('session summaries rebuilt: '.count($sessions).' session(s).'); + + return self::SUCCESS; + } + + /** + * Aggregate one run's suite table: pass/fail counts, simple + weighted + * user tok/s, generated tokens, and the run's thinking level. + */ + private function aggregateRun(object $run): array + { + $model = Suites::modelFor($run->suite); + + if ($model === null) { + return [ + 'run_pk' => $run->id, + 'run_id' => $run->run_id, + 'level' => null, + 'passed' => 0, + 'failed' => 0, + 'avg_user_tps' => null, + 'weighted_user_tps' => null, + 'total_tokens' => 0, + ]; + } + + $table = $model::make()->getTable(); + + if ($run->suite === 'scenariobench') { + $agg = DB::table($table)->where('benchmark_run_id', $run->id) + ->selectRaw('count(*) filter (where ok) as passed') + ->selectRaw('count(*) filter (where not ok) as failed') + ->selectRaw('sum(gen_tokens) as total_tokens') + ->first(); + + return [ + 'run_pk' => $run->id, + 'run_id' => $run->run_id, + 'level' => $this->runLevel($table, $run->id), + 'passed' => (int) ($agg->passed ?? 0), + 'failed' => (int) ($agg->failed ?? 0), + 'avg_user_tps' => null, + 'weighted_user_tps' => null, + 'total_tokens' => (int) ($agg->total_tokens ?? 0), + ]; + } + + $agg = DB::table($table)->where('benchmark_run_id', $run->id) + ->selectRaw('count(*) filter (where ok) as passed') + ->selectRaw('count(*) filter (where not ok) as failed') + ->selectRaw('avg(user_tps) as avg_user_tps') + ->selectRaw('sum(user_tps * total) filter (where total > 0) / nullif(sum(total) filter (where total > 0), 0) as weighted_user_tps') + ->selectRaw('sum(total) as total_tokens') + ->first(); + + return [ + 'run_pk' => $run->id, + 'run_id' => $run->run_id, + 'level' => $this->runLevel($table, $run->id), + 'passed' => (int) ($agg->passed ?? 0), + 'failed' => (int) ($agg->failed ?? 0), + 'avg_user_tps' => $agg->avg_user_tps !== null ? round((float) $agg->avg_user_tps, 1) : null, + 'weighted_user_tps' => $agg->weighted_user_tps !== null ? round((float) $agg->weighted_user_tps, 1) : null, + 'total_tokens' => (int) ($agg->total_tokens ?? 0), + ]; + } + + /** + * The run's thinking level: the distinct level across its result rows + * (a run is single-level; falls back to null when mixed or empty). + */ + private function runLevel(string $table, int $runPk): ?string + { + $level = DB::table($table)->where('benchmark_run_id', $runPk) + ->distinct()->pluck('level')->filter()->values(); + + return $level->count() === 1 ? $level->first() : null; + } +} diff --git a/database/migrations/2026_09_18_000003_create_session_summaries.php b/database/migrations/2026_09_18_000003_create_session_summaries.php new file mode 100644 index 0000000..68f1150 --- /dev/null +++ b/database/migrations/2026_09_18_000003_create_session_summaries.php @@ -0,0 +1,61 @@ +id(); + $table->string('session_key', 255)->unique(); + $table->string('label', 255)->nullable(); + $table->string('model_id', 255)->nullable(); + $table->string('model_quant', 64)->nullable(); + $table->string('gpu', 128)->nullable(); + $table->unsignedInteger('gpu_count')->nullable(); + $table->string('runner_name', 255)->nullable(); + $table->foreignId('user_id')->nullable(); + $table->string('visibility', 16)->default('public'); + $table->timestamp('ran_at')->nullable(); + $table->unsignedInteger('total_passed')->default(0); + $table->unsignedInteger('total_failed')->default(0); + $table->unsignedInteger('total_tokens')->default(0); + $table->unsignedInteger('sequence')->index()->comment('band order, newest first'); + $table->timestamps(); + }); + + Schema::create('session_suite_rows', function (Blueprint $table) { + $table->id(); + $table->foreignId('session_summary_id')->constrained('session_summaries')->cascadeOnDelete(); + $table->foreignId('benchmark_run_id')->constrained()->cascadeOnDelete(); + $table->string('run_id', 32); + $table->string('suite', 32); + $table->string('level', 16)->nullable(); + $table->unsignedInteger('passed')->default(0); + $table->unsignedInteger('failed')->default(0); + $table->double('avg_user_tps')->nullable(); + $table->double('weighted_user_tps')->nullable(); + $table->unsignedInteger('total_tokens')->default(0); + $table->unsignedInteger('sequence')->comment('suite order within band'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('session_suite_rows'); + Schema::dropIfExists('session_summaries'); + } +}; diff --git a/resources/views/components/layouts/app/sidebar.blade.php b/resources/views/components/layouts/app/sidebar.blade.php index 25d6fc2..473f9fa 100644 --- a/resources/views/components/layouts/app/sidebar.blade.php +++ b/resources/views/components/layouts/app/sidebar.blade.php @@ -13,7 +13,7 @@ - Results + Sessions @auth My Runs Dashboard diff --git a/resources/views/flux/icon/cpu.blade.php b/resources/views/flux/icon/cpu.blade.php new file mode 100644 index 0000000..69b57a0 --- /dev/null +++ b/resources/views/flux/icon/cpu.blade.php @@ -0,0 +1,56 @@ +@blaze(fold: true) + +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); +} + +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + + + + + + + + + + + + + diff --git a/resources/views/livewire/results/index.blade.php b/resources/views/livewire/results/index.blade.php index 309e0b6..8a43c51 100644 --- a/resources/views/livewire/results/index.blade.php +++ b/resources/views/livewire/results/index.blade.php @@ -150,9 +150,10 @@ private function applyFilters($query)
- {{ __('Benchmark Results') }} + {{ __('All Task Rows') }} {{ __('Every datapoint festbench captures — sortable.') }}
+ {{ __('Session summary') }}
diff --git a/resources/views/livewire/runs/show.blade.php b/resources/views/livewire/runs/show.blade.php index 27595f2..393db72 100644 --- a/resources/views/livewire/runs/show.blade.php +++ b/resources/views/livewire/runs/show.blade.php @@ -80,7 +80,7 @@ private function authorizeOwner(): void
- {{ __('Back') }} + {{ __('Sessions') }} @if ($this->run->isOwnedBy(auth()->user())) null, + 'model' => null, + 'suite' => null, + ]; + + public function resetFilters(): void + { + $this->filters = ['search' => null, 'model' => null, 'suite' => null]; + $this->resetPage(); + } + + #[Computed] + public function sessions() + { + $ttl = (int) config('festbench.home_cache_ttl', 300); + $user = auth()->user(); + // Guest views are shared-cacheable; logged-in views may contain the + // user's private bands, so they get a user-scoped key. + $cacheKey = 'summary:'.($user?->id ?? 'guest').':'.md5(json_encode([$this->filters, $this->getPage()])); + + return Cache::remember($cacheKey, $ttl, function () use ($user) { + $bands = $this->bandQuery($user)->paginate(10); + + $runIds = $bands->getCollection()->map(fn ($b) => $b->id)->all(); + $suiteRows = $runIds === [] + ? collect() + : \Illuminate\Support\Facades\DB::table('session_suite_rows') + ->whereIn('session_summary_id', $runIds) + ->orderBy('sequence') + ->get() + ->groupBy('session_summary_id'); + + $bands->getCollection()->transform(fn ($band) => (object) [ + 'band' => $band, + 'suites' => $suiteRows->get($band->id, collect()), + ]); + + return $bands; + }); + } + + private function bandQuery($user) + { + return \Illuminate\Support\Facades\DB::table('session_summaries') + ->where(function ($q) use ($user) { + $q->where('visibility', 'public'); + if ($user !== null) { + $q->orWhere(function ($own) use ($user) { + $own->where('visibility', 'private')->where('user_id', $user->id); + }); + } + }) + ->when($this->filters['search'], fn ($q, $search) => $q->where(function ($q) use ($search) { + $q->where('label', 'like', "%{$search}%") + ->orWhere('model_id', 'like', "%{$search}%") + ->orWhere('gpu', 'like', "%{$search}%") + ->orWhere('runner_name', 'like', "%{$search}%"); + })) + ->when($this->filters['model'], fn ($q, $model) => $q->where('model_id', $model)) + // Suite filter: keep bands containing the chosen suite + ->when($this->filters['suite'], fn ($q, $suite) => $q->whereExists(function ($sub) use ($suite) { + $sub->select(\Illuminate\Support\Facades\DB::raw(1)) + ->from('session_suite_rows') + ->whereColumn('session_suite_rows.session_summary_id', 'session_summaries.id') + ->where('session_suite_rows.suite', $suite); + })) + ->orderByDesc('ran_at') + ->orderBy('sequence'); + } + + #[Computed] + public function models(): array + { + return Cache::remember('summary:models', 300, function () { + return \Illuminate\Support\Facades\DB::table('session_summaries') + ->where('visibility', 'public') + ->distinct()->orderBy('model_id')->pluck('model_id')->all(); + }); + } + + #[Computed] + public function suites(): array + { + return Cache::remember('summary:suites', 300, function () { + return \Illuminate\Support\Facades\DB::table('session_suite_rows') + ->join('session_summaries', 'session_summaries.id', '=', 'session_suite_rows.session_summary_id') + ->where('session_summaries.visibility', 'public') + ->distinct()->orderBy('suite')->pluck('suite')->all(); + }); + } +}; +?> + +
+
+
+ {{ __('Benchmark Sessions') }} + {{ __('GPU, model, suite scores — click a suite line for per-task detail.') }} +
+ {{ __('All task rows') }} +
+ +
+ + + + @foreach ($this->models as $model) + {{ $model }} + @endforeach + + + + @foreach ($this->suites as $suite) + {{ $suite }} + @endforeach + + + {{ __('Clear') }} +
+ + @if ($this->sessions->count() > 0) +
+ @foreach ($this->sessions as $session) + +
+ + {{ $session->band->gpu ?? 'unknown GPU' }} + @if ($session->band->gpu_count) + ×{{ $session->band->gpu_count }} + @endif + {{ $session->band->model_id ?? 'unknown model' }} + @if ($session->band->model_quant) + {{ $session->band->model_quant }} + @endif + + {{ $session->band->runner_name ?? 'anonymous' }} + {{ optional($session->band->ran_at)->format('M j Y') }} +
+ @if ($session->band->label) +
{{ $session->band->label }}
+ @endif + +
+ @endforeach +
+ +
+ {{ $this->sessions->links() }} +
+ @else +
+ + {{ __('No sessions') }} + {{ __('No benchmark sessions match the current filters.') }} + +
+ @endif +
\ No newline at end of file diff --git a/routes/console.php b/routes/console.php index 48081ed..e04a5d8 100644 --- a/routes/console.php +++ b/routes/console.php @@ -6,6 +6,5 @@ // request path never runs the UNION. Cheap on this data volume. Schedule::command('festbench:snapshot')->everyFiveMinutes(); -// Ensure next month's Postgres partitions exist before inserts need them. -// (Created by the migration for +/-1 year; this keeps future months coming.) -// Placeholder command added when partition maintenance is wired up. +// Rebuild the session-grouped home summary (bands + per-suite rows). +Schedule::command('festbench:summary')->everyFiveMinutes(); diff --git a/routes/web.php b/routes/web.php index e28214e..0c6bb19 100644 --- a/routes/web.php +++ b/routes/web.php @@ -3,7 +3,8 @@ use Illuminate\Support\Facades\Route; use Livewire\Volt\Volt; -Route::livewire('/', 'results.index')->name('home'); +Route::livewire('/', 'summary.index')->name('home'); +Route::livewire('results', 'results.index')->name('results'); Route::livewire('runs/{run}', 'runs.show')->name('runs.show'); diff --git a/tests/Feature/HomeSnapshotTest.php b/tests/Feature/HomeSnapshotTest.php index 149b23c..dc0a695 100644 --- a/tests/Feature/HomeSnapshotTest.php +++ b/tests/Feature/HomeSnapshotTest.php @@ -20,23 +20,23 @@ ->and($rows[0]->run_id)->toBe($run->run_id); }); -test('guest default home view reads the snapshot, no union needed', function () { +test('guest default results view reads the snapshot, no union needed', function () { $run = BenchmarkRun::factory()->create(['suite' => 'softbench', 'visibility' => 'public']); SoftbenchResult::factory()->create(['benchmark_run_id' => $run->id, 'task' => 'snapshotted-task']); Artisan::call('festbench:snapshot'); // The snapshot is the only path that knows this task name; seeing it // proves the default view served from home_snapshot. - $this->get(route('home'))->assertOk()->assertSee('snapshotted-task'); + $this->get(route('results'))->assertOk()->assertSee('snapshotted-task'); }); test('guest pages get public cache headers, authenticated pages do not', function () { - $guest = $this->get(route('home')); + $guest = $this->get(route('results')); expect($guest->headers->get('Cache-Control'))->toContain('public') ->and($guest->headers->get('Cache-Control'))->toContain('s-maxage'); $this->actingAs(User::factory()->create()); - $auth = $this->get(route('home')); + $auth = $this->get(route('results')); expect($auth->headers->get('Cache-Control'))->toContain('private') ->and($auth->headers->get('Cache-Control'))->not->toContain('public'); diff --git a/tests/Feature/ResultsPageTest.php b/tests/Feature/ResultsPageTest.php index e85aef5..75f376f 100644 --- a/tests/Feature/ResultsPageTest.php +++ b/tests/Feature/ResultsPageTest.php @@ -6,26 +6,26 @@ use App\Models\User; use Livewire\Volt\Volt; -test('guests see public task results on the home page', function () { +test('guests see public task results on the results page', function () { $publicRun = BenchmarkRun::factory()->create(['model_id' => 'public/model', 'suite' => 'softbench']); ProsebenchResult::factory()->count(3)->create(['benchmark_run_id' => $publicRun->id]); BenchmarkRun::factory()->private()->create(['model_id' => 'private/model', 'suite' => 'softbench']); - $response = $this->get(route('home')); + $response = $this->get(route('results')); $response->assertOk() ->assertSee('public/model') ->assertDontSee('private/model'); }); -test('authenticated users see their private runs mixed in', function () { +test('authenticated users see their private runs mixed in on results', function () { $me = User::factory()->create(); BenchmarkRun::factory()->private()->create(['model_id' => 'my-private/model', 'user_id' => $me->id, 'suite' => 'softbench']); BenchmarkRun::factory()->private()->create(['model_id' => 'other-private/model', 'suite' => 'softbench']); $this->actingAs($me); - $this->get(route('home'))->assertOk() + $this->get(route('results'))->assertOk() ->assertSee('my-private/model') ->assertDontSee('other-private/model'); }); diff --git a/tests/Feature/SessionSummaryTest.php b/tests/Feature/SessionSummaryTest.php new file mode 100644 index 0000000..f8b24d4 --- /dev/null +++ b/tests/Feature/SessionSummaryTest.php @@ -0,0 +1,117 @@ +create(['name' => 'peterZ']); + + // One session: two softbench runs sharing label/model/gpu/user, plus a + // different-session run (different label). + $a = BenchmarkRun::factory()->create([ + 'suite' => 'softbench', 'label' => 'session A', 'model_id' => 'm', 'model_quant' => 'Q5_K_M', + 'gpu' => 'AMD R9700', 'gpu_count' => 1, 'user_id' => $me->id, 'visibility' => 'public', + 'ran_at' => '2026-09-03 12:00:00', + ]); + SoftbenchResult::factory()->create(['benchmark_run_id' => $a->id, 'ok' => true, 'user_tps' => 60.0, 'total' => 100, 'level' => 'off']); + SoftbenchResult::factory()->create(['benchmark_run_id' => $a->id, 'ok' => false, 'user_tps' => 20.0, 'total' => 300, 'level' => 'off']); + + $b = BenchmarkRun::factory()->create([ + 'suite' => 'infrabench', 'label' => 'session A', 'model_id' => 'm', 'model_quant' => 'Q5_K_M', + 'gpu' => 'AMD R9700', 'gpu_count' => 1, 'user_id' => $me->id, 'visibility' => 'public', + 'ran_at' => '2026-09-03 12:05:00', + ]); + InfrabenchResult::factory()->create(['benchmark_run_id' => $b->id, 'ok' => true, 'user_tps' => 40.0, 'total' => 200, 'level' => 'low']); + + $c = BenchmarkRun::factory()->create([ + 'suite' => 'softbench', 'label' => 'session B', 'model_id' => 'm', 'model_quant' => 'Q5_K_M', + 'gpu' => 'AMD R9700', 'gpu_count' => 1, 'user_id' => $me->id, 'visibility' => 'public', + 'ran_at' => '2026-09-04 12:00:00', + ]); + SoftbenchResult::factory()->create(['benchmark_run_id' => $c->id, 'ok' => true, 'user_tps' => 50.0, 'total' => 50, 'level' => 'medium']); + + Artisan::call('festbench:summary'); + + $bands = DB::table('session_summaries')->orderBy('sequence')->get(); + expect($bands)->toHaveCount(2); + + $bandA = $bands->firstWhere('label', 'session A'); + expect($bandA->runner_name)->toBe('peterZ') + ->and($bandA->gpu)->toBe('AMD R9700') + ->and($bandA->gpu_count)->toBe(1) + ->and($bandA->total_passed)->toBe(2) + ->and($bandA->total_failed)->toBe(1) + ->and($bandA->total_tokens)->toBe(600); + + $rowsA = DB::table('session_suite_rows')->where('session_summary_id', $bandA->id)->orderBy('sequence')->get(); + expect($rowsA)->toHaveCount(2); + + $soft = $rowsA->firstWhere('suite', 'softbench'); + // simple avg of (60, 20) = 40; weighted = (60*100 + 20*300)/400 = 30 + expect($soft->passed)->toBe(1) + ->and($soft->failed)->toBe(1) + ->and($soft->avg_user_tps)->toBe(40.0) + ->and($soft->weighted_user_tps)->toBe(30.0) + ->and($soft->total_tokens)->toBe(400) + ->and($soft->level)->toBe('off'); + + $infra = $rowsA->firstWhere('suite', 'infrabench'); + expect($infra->passed)->toBe(1)->and($infra->level)->toBe('low'); + + // Newest session first + expect($bands->first()->label)->toBe('session B'); +}); + +test('private runs form their own band and are hidden from guests', function () { + $me = User::factory()->create(); + BenchmarkRun::factory()->create([ + 'suite' => 'softbench', 'label' => 'pub', 'model_id' => 'm', 'visibility' => 'public', + 'gpu' => 'g', 'gpu_count' => 1, 'user_id' => $me->id, + ]); + SoftbenchResult::factory()->create(['benchmark_run_id' => BenchmarkRun::latest('id')->first()->id]); + + BenchmarkRun::factory()->create([ + 'suite' => 'softbench', 'label' => 'pub', 'model_id' => 'm', 'visibility' => 'private', + 'gpu' => 'g', 'gpu_count' => 1, 'user_id' => $me->id, + ]); + + Artisan::call('festbench:summary'); + + expect(DB::table('session_summaries')->count())->toBe(2) + ->and(DB::table('session_summaries')->where('visibility', 'private')->count())->toBe(1); + + // Guest home shows the public band only; owner sees both + $this->get(route('home'))->assertOk()->assertSee('pub'); + + $bands = DB::table('session_summaries')->pluck('visibility'); + expect($bands)->toContain('public')->toContain('private'); +}); + +test('home renders the session summary and links into per-task detail', function () { + $run = BenchmarkRun::factory()->create([ + 'suite' => 'softbench', 'label' => 'rendered session', 'visibility' => 'public', + 'model_id' => 'render/model', 'gpu' => 'AMD R9700', 'gpu_count' => 2, + ]); + SoftbenchResult::factory()->create(['benchmark_run_id' => $run->id, 'ok' => true, 'level' => 'off']); + + Artisan::call('festbench:summary'); + + $this->get(route('home'))->assertOk() + ->assertSee('rendered session') + ->assertSee('render/model') + ->assertSee('AMD R9700') + ->assertSee(route('runs.show', $run->run_id)) + ->assertSee('All task rows'); +}); + +test('results page still lists flat task rows', function () { + $run = BenchmarkRun::factory()->create(['suite' => 'softbench', 'visibility' => 'public']); + SoftbenchResult::factory()->create(['benchmark_run_id' => $run->id, 'task' => 'flat-task']); + + $this->get(route('results'))->assertOk() + ->assertSee('All Task Rows') + ->assertSee('flat-task'); +});