'array']); } /** * One transaction, so the month list has an end to count back from. * * Not left to whatever the database happens to hold: under * DatabaseTransactions a clean test database has no transactions at all, * and the span assertion then measured nothing. */ private function seedOneTransaction(): void { $account = function (string $name) { $userId = DB::table('users')->insertGetId([ 'name' => $name, 'full_name' => $name, 'email' => $name . '@example.test', 'password' => bcrypt('secret'), 'created_at' => now()->subMonths(2), 'updated_at' => now(), ]); return DB::table('accounts')->insertGetId([ 'name' => 'current', 'limit_min' => 0, 'limit_max' => 0, 'accountable_type' => \App\Models\User::class, 'accountable_id' => $userId, 'created_at' => now(), 'updated_at' => now(), ]); }; $a = $account('net_render_a'); $b = $account('net_render_b'); $c = $account('net_render_c'); // Three profiles trading both ways, so the graph has real nodes and // edges: a single one-way transaction leaves nothing to draw. foreach ([[$a, $b], [$b, $a], [$a, $c], [$c, $b]] as [$from, $to]) { DB::table('transactions')->insert([ 'from_account_id' => $from, 'to_account_id' => $to, 'amount' => 60, 'transaction_type_id' => 1, 'description' => 'network render fixture', 'created_at' => now()->subMonth(), 'updated_at' => now()->subMonth(), ]); } } /** One more trade at a given moment, between the fixture's profiles. */ private function seedTransactionAt(\Carbon\Carbon $when): void { $accounts = DB::table('transactions') ->where('description', 'network render fixture') ->select('from_account_id', 'to_account_id') ->first(); DB::table('transactions')->insert([ 'from_account_id' => $accounts->from_account_id, 'to_account_id' => $accounts->to_account_id, 'amount' => 30, 'transaction_type_id' => 1, 'description' => 'network render fixture', 'created_at' => $when, 'updated_at' => $when, ]); // The trade range is cached; a new earliest trade must be seen. \Illuminate\Support\Facades\Cache::forget('stats:net:trade-range'); } private function actAsStatsAdmin(): void { $adminId = DB::table('admins')->insertGetId([ 'name' => 'stats_render_admin', 'full_name' => 'Stats Render Admin', 'email' => 'stats_render_admin@example.test', 'password' => bcrypt('secret'), 'created_at' => now(), 'updated_at' => now(), ]); $admin = Admin::find($adminId); $this->actingAs($admin, 'admin'); session([ 'activeProfileType' => Admin::class, 'activeProfileId' => $admin->id, 'active_guard' => 'admin', ]); // The component authorises through the Gate, so grant it there rather // than depending on whichever scoped Spatie roles this database holds. Gate::define('access stats', fn () => true); Gate::define('org stats', fn () => true); } public function test_stats_page_renders_with_the_network_time_lapse_off(): void { $this->actAsStatsAdmin(); Livewire::test(Stats::class) ->assertOk() ->assertSet('networkTimelapse', false); } public function test_stats_page_renders_with_the_network_time_lapse_on(): void { $this->actAsStatsAdmin(); $this->seedOneTransaction(); // Turning it on makes the view call networkTimelapseMonths(), which is // where the missing Carbon import threw. The controls only render once // the network section is actually open. $component = Livewire::test(Stats::class) ->set('showNetwork', true) ->set('networkTimelapse', true) ->assertOk(); // The graph markup itself depends on the fixture clearing the degree // threshold, which is StatsAggregationTest's subject, not this one. // What matters here is that rendering with the time-lapse on executes // networkTimelapseMonths() without throwing - and that it returns // usable frames. $months = $component->instance()->networkTimelapseMonths(); $this->assertNotEmpty($months); $this->assertArrayHasKey('key', $months[0]); $this->assertArrayHasKey('label', $months[0]); $this->assertMatchesRegularExpression('/^\d{4}-\d{2}$/', $months[0]['key']); } public function test_time_lapse_months_are_bounded_by_the_chosen_span(): void { $this->actAsStatsAdmin(); $this->seedOneTransaction(); $component = Livewire::test(Stats::class) ->set('networkTimelapse', true) ->set('networkTimelapseSpan', 24); $months = $component->instance()->networkTimelapseMonths(); // A monthly step is one real query per frame, so the span is what keeps // the live view affordable: the full range is 165 frames and ~77s cold. $this->assertCount(24, $months); $this->assertSame( array_values(array_unique(array_column($months, 'key'))), array_column($months, 'key'), 'Months must be distinct.' ); $keys = array_column($months, 'key'); $sorted = $keys; sort($sorted); $this->assertSame($sorted, $keys, 'Months must run oldest first.'); } public function test_playback_controls_sit_inside_the_graph(): void { $this->actAsStatsAdmin(); $this->seedOneTransaction(); $html = Livewire::test(Stats::class) ->set('showNetwork', true) ->set('networkTimelapse', true) ->html(); $controls = strpos($html, 'data-network-timelapse-controls'); $host = strpos($html, 'data-network-host'); $this->assertNotFalse($controls, 'Playback controls should render.'); $this->assertNotFalse($host, 'The graph host should render.'); // They steer what the canvas shows, so they belong over the picture // rather than up among the filters that rebuild it. $this->assertGreaterThan( $host, $controls, 'Playback controls must sit inside the graph container, after the host.' ); // Positioned bottom left, and above the canvas Cytoscape draws into. preg_match('/]*data-network-timelapse-controls[^>]*>/', $html, $m) ?: preg_match('/]*class="[^"]*"[^>]*data-network-timelapse-controls/', $html, $m); $this->assertNotEmpty($m); $this->assertStringContainsString('absolute', $m[0]); $this->assertStringContainsString('bottom-', $m[0]); $this->assertStringContainsString('left-', $m[0]); } public function test_time_lapse_toggle_sits_with_the_other_show_options(): void { $this->actAsStatsAdmin(); $this->seedOneTransaction(); $html = Livewire::test(Stats::class) ->set('showNetwork', true) ->set('networkTimelapse', true) ->html(); $inactive = strpos($html, 'wire:model.live="networkMarkInactive"'); $timelapse = strpos($html, 'wire:model.live="networkTimelapse"'); $playback = strpos($html, 'data-network-timelapse-controls'); $this->assertNotFalse($inactive); $this->assertNotFalse($timelapse); // It changes what is shown, so it belongs with the other show options // rather than among the filters. $this->assertGreaterThan( $inactive, $timelapse, 'The time-lapse toggle should follow "Highlight inactive profiles".' ); // Its settings stay in the controls; only playback moved to the graph. $this->assertGreaterThan($timelapse, $playback); } public function test_span_lives_in_the_playback_panel(): void { $this->actAsStatsAdmin(); $this->seedOneTransaction(); $html = Livewire::test(Stats::class) ->set('showNetwork', true) ->set('networkTimelapse', true) ->html(); $panel = strpos($html, 'data-network-timelapse-controls'); $span = strpos($html, 'wire:model.live="networkTimelapseSpan"'); $this->assertNotFalse($panel); $this->assertNotFalse($span); $this->assertGreaterThan($panel, $span, 'Span should sit in the playback panel.'); } public function test_play_button_uses_icons_with_an_accessible_name(): void { $this->actAsStatsAdmin(); $this->seedOneTransaction(); $html = Livewire::test(Stats::class) ->set('showNetwork', true) ->set('networkTimelapse', true) ->html(); // Both icons ship in the markup and the JS hides one, so no SVG is // written from JavaScript. $this->assertStringContainsString('data-timelapse-icon-play', $html); $this->assertStringContainsString('data-timelapse-icon-pause', $html); preg_match('/]*data-timelapse-play[^>]*>/', $html, $m); $this->assertNotEmpty($m, 'The play button should render.'); // An icon-only button is an unlabelled glyph without this. $this->assertStringContainsString('aria-label', $m[0]); } public function test_graph_header_lines_reserve_their_space(): void { $this->actAsStatsAdmin(); $this->seedOneTransaction(); $html = Livewire::test(Stats::class) ->set('showNetwork', true) ->set('networkTimelapse', true) ->html(); // These two lines change on every time-lapse frame. Marked `hidden` // they collapse, and the graph container below them resizes frame by // frame - so they must reserve their row instead. foreach (['data-network-stagnant', 'data-network-warning'] as $marker) { $this->assertStringContainsString($marker, $html); preg_match('/]*' . preg_quote($marker, '/') . '[^>]*>/', $html, $m); $this->assertNotEmpty($m, "Could not find the $marker element."); $this->assertStringNotContainsString( 'hidden', $m[0], "$marker must not be hidden: it reserves a line so the header keeps one height." ); $this->assertStringContainsString( 'min-h-', $m[0], "$marker must reserve a minimum height, or an empty line still collapses." ); } } public function test_time_lapse_offers_nothing_when_there_is_no_trade(): void { $this->actAsStatsAdmin(); // Deliberately no fixture. A platform with no transactions yet must // render the page and simply offer no frames, rather than throwing or // animating an empty graph. DB::table('transactions')->delete(); Livewire::test(Stats::class) ->set('showNetwork', true) ->set('networkTimelapse', true) ->assertOk(); $stats = Livewire::test(Stats::class)->instance(); $this->assertSame([], $stats->networkTimelapseMonths()); } public function test_frame_builder_is_callable_through_livewire(): void { $this->actAsStatsAdmin(); $this->seedOneTransaction(); // A public METHOD whose name collides with a public PROPERTY cannot be // dispatched by Livewire, and it fails silently: the graph draws, no // frames arrive, and the play button spins for ever. $networkTimelapse // is the property, so the method must not share its name. $this->assertTrue( property_exists(Stats::class, 'networkTimelapse'), 'The property this method must not collide with has moved.' ); $this->assertFalse( method_exists(Stats::class, 'networkTimelapse'), 'networkTimelapse() collides with $networkTimelapse and cannot be called.' ); Livewire::test(Stats::class) ->set('networkTimelapse', true) ->call('networkTimelapseData') ->assertOk(); } public function test_time_lapse_data_call_does_not_re_render(): void { $this->actAsStatsAdmin(); $this->seedOneTransaction(); $component = Livewire::test(Stats::class) ->set('showNetwork', true) ->set('networkTimelapse', true); // A control call that DOES render, for contrast: without it this test // could pass simply because the harness never reports html. $component->call('toggleNetwork')->call('toggleNetwork'); $this->assertArrayHasKey( 'html', $component->effects, 'A normal call should re-render; if not, this test proves nothing.' ); // The data call must not. A re-render morphs the page, the morph fires // the graph's rebuild hook, and the rebuild makes this call again - so // switching the time-lapse on redrew the graph over and over. $component->call('networkTimelapseData'); $this->assertArrayNotHasKey( 'html', $component->effects, 'networkTimelapseData() must be #[Renderless], or it re-triggers the rebuild that called it.' ); } public function test_end_date_is_filled_when_the_time_lapse_is_switched_on(): void { $this->actAsStatsAdmin(); $this->seedOneTransaction(); $component = Livewire::test(Stats::class) ->assertSet('networkTimelapseEnd', null) ->set('networkTimelapse', true); // Never blank: the picker shows what is being drawn from the start. $this->assertSame( now()->subMonth()->toDateString(), $component->get('networkTimelapseEnd'), 'Switching the time-lapse on should fill in the last trade date.' ); } public function test_end_date_is_clamped_to_real_trade(): void { $this->actAsStatsAdmin(); $this->seedOneTransaction(); $last = now()->subMonth()->toDateString(); // The value reaches a query, so anything outside real trade - or not a // date at all - snaps to a date the graph can actually be drawn for. foreach ([ now()->addYear()->toDateString() => $last, '1990-01-01' => $last, // before any trade: first trade, which is the same day here 'not-a-date' => $last, "2024-01-01'; DROP--" => $last, ] as $input => $expected) { $component = Livewire::test(Stats::class) ->set('networkTimelapse', true) ->set('networkTimelapseEnd', $input); $this->assertSame($expected, $component->get('networkTimelapseEnd'), "Input: $input"); } } public function test_span_counts_back_from_the_chosen_end_date(): void { $this->actAsStatsAdmin(); $this->seedOneTransaction(); // An earlier trade, so an end date before the last one is in range. $early = now()->subMonths(8); $this->seedTransactionAt($early); $end = now()->subMonths(4)->startOfMonth()->addDays(14); $stats = Livewire::test(Stats::class) ->set('networkTimelapse', true) ->set('networkTimelapseEnd', $end->toDateString()) ->set('networkTimelapseSpan', 12) ->instance(); $months = $stats->networkTimelapseMonths(); $this->assertCount(12, $months); $this->assertSame($end->format('Y-m'), end($months)['key'], 'The last frame is the chosen month.'); // The last frame stops ON the chosen date, not at the end of its month. $frames = Stats::decodeFrameDeltas($stats->networkTimelapseData()['frames']); $this->assertSame($end->toDateString(), end($frames)['till']); // And the lower bound, now distinct from the upper one: a date before // any trade snaps to the first trade, not to the last. $clamped = Livewire::test(Stats::class) ->set('networkTimelapse', true) ->set('networkTimelapseEnd', '1990-01-01') ->get('networkTimelapseEnd'); $this->assertSame($early->toDateString(), $clamped); } public function test_picker_replaces_the_period_dropdown_only_during_a_time_lapse(): void { $this->actAsStatsAdmin(); $this->seedOneTransaction(); $off = Livewire::test(Stats::class)->set('showNetwork', true)->html(); $this->assertStringContainsString('wire:model.live="networkPeriod"', $off); $this->assertStringNotContainsString('wire:model.live="networkTimelapseEnd"', $off); $on = Livewire::test(Stats::class) ->set('showNetwork', true) ->set('networkTimelapse', true) ->html(); $this->assertStringContainsString('wire:model.live="networkTimelapseEnd"', $on); $this->assertStringNotContainsString('wire:model.live="networkPeriod"', $on); } public function test_end_date_uses_the_same_flatpickr_as_the_page_period(): void { $this->actAsStatsAdmin(); $this->seedOneTransaction(); $html = Livewire::test(Stats::class) ->set('showNetwork', true) ->set('networkTimelapse', true) ->html(); preg_match('/]*id="network-timelapse-end"[^>]*>/', $html, $m); $this->assertNotEmpty($m, 'The end date should be a flatpickr input.'); $input = html_entity_decode($m[0]); // Bound directly: flatpickr fires 'input' on this element when a date // is picked, so no separate Update button is needed. $this->assertStringContainsString('wire:model.live="networkTimelapseEnd"', $input); $this->assertStringContainsString('flatpickr-input', $input); preg_match("/data-config='([^']*)'/", $input, $c); $config = json_decode($c[1] ?? '{}', true); // Same formats as the page's own period picker. $this->assertSame('Y-m-d', $config['dateFormat'] ?? null); $this->assertSame('d-m-Y', $config['altFormat'] ?? null); // Pre-filled, and bounded so an out-of-range date cannot be picked - // under wire:ignore the picker could not show a server-side snap. // Y/m/d, not Y-m-d: the project's flatpickr override hands dates over // that way on purpose, so the browser parses them as local dates // instead of shifting them a day in UTC+ timezones. $last = now()->subMonth()->format('Y/m/d'); $this->assertSame($last, $config['defaultDate'] ?? null); $this->assertSame($last, $config['maxDate'] ?? null); $this->assertArrayHasKey('minDate', $config); // Protected from Livewire morphs, like the page period picker. $this->assertMatchesRegularExpression('/wire:ignore[^>]*data-network-timelapse-end/', $html); } public function test_time_lapse_carries_dated_states_for_each_frame(): void { $this->actAsStatsAdmin(); $this->seedOneTransaction(); $this->seedTransactionAt(now()->subMonths(6)); // One fixture profile is marked inactive, and later removed - the // common real case, and the one that exposed "as of today" colouring. $payer = DB::table('transactions') ->where('description', 'network render fixture') ->join('accounts', 'accounts.id', '=', 'transactions.from_account_id') ->value('accounts.accountable_id'); $inactiveOn = now()->subMonths(3)->toDateString(); $removedOn = now()->subDays(10)->toDateString(); DB::table('users')->where('id', $payer)->update([ 'inactive_at' => $inactiveOn, 'deleted_at' => $removedOn, ]); $payload = Livewire::test(Stats::class) ->set('networkTimelapse', true) ->set('networkTimelapseSpan', 12) ->instance() ->networkTimelapseData(); $key = 'App\\Models\\User:' . $payer; $timeline = $payload['timeline']; // The dates, not "as of today" booleans: a frame decides for itself. $this->assertSame($inactiveOn, $timeline['inactive'][$key] ?? null); $this->assertSame($removedOn, $timeline['removed'][$key] ?? null); // Payment dates ascend and end at the latest payment, so the browser // can find "last paid as of a frame" with a simple search. $paid = $timeline['paid'][$key] ?? []; $this->assertNotEmpty($paid, 'The payer should have payment dates.'); $sorted = $paid; sort($sorted); $this->assertSame($sorted, $paid, 'Payment dates must ascend.'); $this->assertSame(now()->subMonth()->toDateString(), end($paid)); // Every frame carries its own date for that comparison. foreach ($payload['frames'] as $frame) { $this->assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}$/', $frame['till']); } } public function test_accumulate_is_on_by_default_and_off_shows_one_month_per_frame(): void { $this->actAsStatsAdmin(); $this->seedOneTransaction(); // one month ago, a/b/c $this->seedTransactionAt(now()->subMonths(4)); // four months ago, one pair $component = Livewire::test(Stats::class) ->assertSet('networkTimelapseCumulative', true) ->set('networkTimelapse', true) ->set('networkTimelapseSpan', 6); $frameAt = function (array $frames, \Carbon\Carbon $month): array { foreach ($frames as $f) { if ($f['label'] === $month->format('M Y')) { return $f; } } $this->fail('No frame for ' . $month->format('M Y')); }; $quiet = now()->subMonths(2); // no trade this month // Accumulating: a quiet month still shows everyone who traded before. $acc = Stats::decodeFrameDeltas($component->instance()->networkTimelapseData()['frames']); $this->assertNotEmpty($frameAt($acc, $quiet)['nodes']); $this->assertCount(3, $frameAt($acc, now()->subMonth())['nodes']); // Per month: a window exactly one frame long. $component->set('networkTimelapseCumulative', false); $per = Stats::decodeFrameDeltas($component->instance()->networkTimelapseData()['frames']); $this->assertSame([], $frameAt($per, $quiet)['nodes'], 'A month without trade is empty.'); $this->assertCount(2, $frameAt($per, now()->subMonths(4))['nodes'], 'Only that month\'s pair.'); $this->assertCount(3, $frameAt($per, now()->subMonth())['nodes']); } public function test_accumulate_sits_in_the_panel_and_triggers_a_rebuild(): void { $this->actAsStatsAdmin(); $this->seedOneTransaction(); $html = Livewire::test(Stats::class) ->set('showNetwork', true) ->set('networkTimelapse', true) ->html(); $panel = strpos($html, 'data-network-timelapse-controls'); $box = strpos($html, 'wire:model.live="networkTimelapseCumulative"'); $this->assertNotFalse($box); $this->assertGreaterThan($panel, $box, 'Accumulate belongs in the playback panel.'); // It changes every frame but not the month list, so the browser's // rebuild signature needs it, or toggling it would keep the old frames. $this->assertStringContainsString('data-network-timelapse-cumulative="1"', $html); } public function test_script_driven_playback_controls_are_protected_from_livewire(): void { $this->actAsStatsAdmin(); $this->seedOneTransaction(); $html = Livewire::test(Stats::class) ->set('showNetwork', true) ->set('networkTimelapse', true) ->html(); // The wire:ignore group, up to its closing tag. $start = strpos($html, 'data-timelapse-playback'); $this->assertNotFalse($start, 'The script-driven controls need their own wire:ignore group.'); $open = strrpos(substr($html, 0, $start), 'assertStringContainsString('wire:ignore', substr($html, $open, $start - $open + 40)); $depth = 0; preg_match_all('//', $html, $m, PREG_OFFSET_CAPTURE, $open); foreach ($m[0] as [$tag, $at]) { $depth += $tag === '' ? -1 : 1; if ($depth === 0) { $group = substr($html, $open, $at - $open); break; } } // Inside: everything only the script updates. Outside wire:ignore any // Livewire update put the server's version back - the spinner // returned and nothing restored the button. foreach (['data-timelapse-play', 'data-timelapse-scrubber', 'data-timelapse-year', 'data-timelapse-speed'] as $control) { $this->assertStringContainsString($control, $group, "$control must be under wire:ignore."); } // Outside: Livewire settings, which must keep their bindings live. foreach (['networkTimelapseCumulative', 'networkTimelapseSpan'] as $setting) { $this->assertStringNotContainsString($setting, $group, "$setting must stay outside wire:ignore."); } } public function test_every_layer_holds_exactly_the_trade_in_its_window(): void { $this->actAsStatsAdmin(); $this->seedOneTransaction(); // a, b and c, one month ago $this->seedTransactionAt(now()->subMonths(4)); // one pair, four months ago // Every fixture trade by day and profile, to work each window out by // hand rather than through the sliding windows under test. $trades = DB::table('transactions') ->where('description', 'network render fixture') ->join('accounts as f', 'f.id', '=', 'transactions.from_account_id') ->join('accounts as t', 't.id', '=', 'transactions.to_account_id') ->get(['transactions.created_at', 'f.accountable_id as payer', 't.accountable_id as payee']); $within = function (?string $from, string $till) use ($trades): array { $ids = []; foreach ($trades as $trade) { $day = substr((string) $trade->created_at, 0, 10); if (($from === null || $day >= $from) && $day <= $till) { $ids['App\\Models\\User:' . $trade->payer] = true; $ids['App\\Models\\User:' . $trade->payee] = true; } } $ids = array_keys($ids); sort($ids); return $ids; }; $sorted = function (array $ids): array { sort($ids); return $ids; }; $service = new \App\Services\StatsService(new \App\Services\Stats\StatsFilter( allTime: true, includeRemoved: true, markInactive: true )); $end = now()->endOfDay(); foreach (['month', 'week'] as $step) { foreach ([true, false] as $cumulative) { $lapse = $service->networkTimelapse( $end->copy()->startOfMonth()->subMonths(5), $end, $step, $cumulative ); $what = "$step steps, " . ($cumulative ? 'accumulating' : 'per window'); $current = Stats::decodeFrameDeltas($lapse['frames']); $ghosts = $cumulative ? null : Stats::decodeFrameDeltas($lapse['ghosts']); $history = $cumulative ? null : Stats::decodeFrameDeltas($lapse['history']); // Accumulated frames already hold everything behind them. $this->assertSame($cumulative, $lapse['ghosts'] === null, $what); $this->assertSame($cumulative, $lapse['history'] === null, $what); // The last step ends on the end date itself. $this->assertSame($end->toDateString(), end($lapse['frames'])['till'], $what); foreach ($lapse['frames'] as $i => $frame) { $till = \Carbon\Carbon::parse($frame['till']); if ($i > 0) { $gap = \Carbon\Carbon::parse($lapse['frames'][$i - 1]['till'])->diffInDays($till); $step === 'week' ? $this->assertSame(7, (int) $gap, "$what: weeks are 7 days apart") : $this->assertTrue( // The last month ends on the end date itself, // so it can be as short as one day (the 1st). $i === count($lapse['frames']) - 1 ? $gap >= 1 && $gap <= 31 : $gap >= 27 && $gap <= 31, "$what: months are a month apart" ); } // The layers stay month-sized whatever the step: current is // the month up to the step (its calendar month when stepping // by month), ghost the month before, history everything older. $from = $step === 'week' ? $till->copy()->subMonthNoOverflow()->addDay() : $till->copy()->startOfMonth(); $ghostFrom = $from->copy()->subMonthNoOverflow(); $this->assertSame( $within($cumulative ? null : $from->toDateString(), $till->toDateString()), $sorted($current[$i]['nodes']), "$what: current layer of {$frame['label']}" ); if (!$cumulative) { $this->assertSame( $within($ghostFrom->toDateString(), $from->copy()->subDay()->toDateString()), $sorted($ghosts[$i]['nodes']), "$what: ghost layer of {$frame['label']}" ); $this->assertSame( $within(null, $ghostFrom->copy()->subDay()->toDateString()), $sorted($history[$i]['nodes']), "$what: history layer of {$frame['label']}" ); } } } } } public function test_ten_years_can_be_chosen(): void { $this->actAsStatsAdmin(); $this->seedOneTransaction(); $component = Livewire::test(Stats::class) ->set('showNetwork', true) ->set('networkTimelapse', true); $this->assertStringContainsString('