1193 lines
49 KiB
PHP
1193 lines
49 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Http\Livewire\Admin\Stats;
|
|
use App\Models\Admin;
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Gate;
|
|
use Livewire\Livewire;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* Renders the stats component for real.
|
|
*
|
|
* StatsAggregationTest covers the numbers and StatsAccessTest covers who may
|
|
* enter, but nothing rendered the view - so a method body referring to a class
|
|
* the component never imported passed every test and still threw a 500 on the
|
|
* page ("Class App\Http\Livewire\Admin\Carbon not found", 2026-09-28). A
|
|
* signature check cannot catch that; only executing the body can.
|
|
*/
|
|
class StatsNetworkRenderTest extends TestCase
|
|
{
|
|
use DatabaseTransactions;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
// An in-memory cache of our own, not the Redis store phpunit.xml
|
|
// configures - which is the dev site's store too.
|
|
//
|
|
// The database rolls back after every test, but Redis does not. Graphs
|
|
// are cached on their filter settings, identical from run to run, so a
|
|
// test could be handed a graph built from an EARLIER run's fixture:
|
|
// profiles that no longer exist, while its own were missing. Two traps
|
|
// this also closes: a warm trade-range cache let the closure that
|
|
// parses dates go unexecuted, which once hid a missing Carbon import;
|
|
// and clearing Redis instead would wipe the dev site's cache too.
|
|
config(['cache.default' => '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('/<div[^>]*data-network-timelapse-controls[^>]*>/', $html, $m)
|
|
?: preg_match('/<div[^>]*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('/<button[^>]*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('/<p[^>]*' . 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('/<input[^>]*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), '<div');
|
|
$this->assertStringContainsString('wire:ignore', substr($html, $open, $start - $open + 40));
|
|
|
|
$depth = 0;
|
|
preg_match_all('/<div\b|<\/div>/', $html, $m, PREG_OFFSET_CAPTURE, $open);
|
|
foreach ($m[0] as [$tag, $at]) {
|
|
$depth += $tag === '</div>' ? -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('<option value="120">', $component->html());
|
|
|
|
// And the cap lets it through rather than clipping it back to five.
|
|
$months = $component->set('networkTimelapseSpan', 120)->instance()->networkTimelapseMonths();
|
|
|
|
$this->assertCount(120, $months);
|
|
}
|
|
|
|
public function test_all_time_starts_at_the_first_trade(): void
|
|
{
|
|
$this->actAsStatsAdmin();
|
|
$this->seedOneTransaction();
|
|
// Further back than the ten-year cap, so All time must go past it.
|
|
$this->seedTransactionAt(now()->subYears(12));
|
|
|
|
$component = Livewire::test(Stats::class)
|
|
->set('showNetwork', true)
|
|
->set('networkTimelapse', true);
|
|
|
|
$this->assertStringContainsString('<option value="0">', $component->html());
|
|
|
|
$months = $component->set('networkTimelapseSpan', Stats::TIMELAPSE_ALL_TIME)
|
|
->instance()->networkTimelapseMonths();
|
|
|
|
$this->assertSame(now()->subYears(12)->format('Y-m'), $months[0]['key']);
|
|
$this->assertSame(now()->subMonth()->format('Y-m'), end($months)['key']);
|
|
|
|
// And the frames follow the same range.
|
|
$frames = $component->instance()->networkTimelapseData()['frames'];
|
|
|
|
$this->assertSame(now()->subYears(12)->format('M Y'), $frames[0]['label']);
|
|
}
|
|
|
|
public function test_node_volumes_grow_step_by_step_to_the_graph_total(): void
|
|
{
|
|
$this->actAsStatsAdmin();
|
|
$this->seedOneTransaction(); // 60 min each, one month ago
|
|
$this->seedTransactionAt(now()->subMonths(4)); // 30 min, four months ago
|
|
|
|
$payload = Livewire::test(Stats::class)
|
|
->set('networkTimelapse', true)
|
|
->set('networkTimelapseSpan', 6)
|
|
->instance()
|
|
->networkTimelapseData();
|
|
|
|
// One entry per step: only the profiles whose running total changed.
|
|
$this->assertCount(count($payload['frames']), $payload['volumes']);
|
|
|
|
$running = [];
|
|
$grew = false;
|
|
|
|
foreach ($payload['volumes'] as $changes) {
|
|
foreach ((array) $changes as $id => $total) {
|
|
$this->assertGreaterThan($running[$id] ?? 0, $total, 'A running total only grows.');
|
|
$grew = $grew || isset($running[$id]);
|
|
$running[$id] = $total;
|
|
}
|
|
}
|
|
|
|
$this->assertTrue($grew, 'Some profile traded in two different steps.');
|
|
|
|
// The last step's totals are the static graph's own: no jump at the end.
|
|
foreach ($payload['graph']['nodes'] as $node) {
|
|
$this->assertSame($node['volume'], $running[$node['id']] ?? 0, $node['id']);
|
|
}
|
|
}
|
|
|
|
public function test_full_screen_button_sits_right_of_spread_and_targets_the_graph_box(): void
|
|
{
|
|
$this->actAsStatsAdmin();
|
|
$this->seedOneTransaction();
|
|
|
|
$html = Livewire::test(Stats::class)
|
|
->set('showNetwork', true)
|
|
->set('networkTimelapse', true)
|
|
->html();
|
|
|
|
$spread = strpos($html, 'data-network-spread');
|
|
$button = strpos($html, 'data-network-fullscreen');
|
|
$this->assertNotFalse($button);
|
|
$this->assertGreaterThan($spread, $button, 'Right of the spread button.');
|
|
|
|
// The box that goes full screen holds the canvas and the playback
|
|
// panel, and nothing above it (filters, header).
|
|
$frame = strpos($html, 'data-network-frame');
|
|
$this->assertNotFalse($frame);
|
|
$this->assertGreaterThan($frame, strpos($html, 'data-network-host'));
|
|
$this->assertGreaterThan($frame, strpos($html, 'data-network-timelapse-controls'));
|
|
$this->assertLessThan($frame, strpos($html, 'data-network-stagnant'));
|
|
|
|
// Full-screen sizing is CSS on :fullscreen, never an inline height or
|
|
// a script-set class: the morph after a wire:model.live time-lapse
|
|
// setting resets both while the box stays full screen.
|
|
$this->assertMatchesRegularExpression(
|
|
'/<div class="[^"]*\bgroup-fullscreen:h-full\b[^"]*" data-network-frame>/',
|
|
$html
|
|
);
|
|
$this->assertStringNotContainsString('is-fullscreen', $html);
|
|
}
|
|
|
|
public function test_monthly_ghost_and_history_are_the_months_behind_each_frame(): void
|
|
{
|
|
$this->actAsStatsAdmin();
|
|
$this->seedOneTransaction(); // one month ago
|
|
$this->seedTransactionAt(now()->subMonths(4)); // four months ago
|
|
|
|
$component = Livewire::test(Stats::class)
|
|
->set('networkTimelapse', true)
|
|
->set('networkTimelapseSpan', 6);
|
|
|
|
$accumulated = Stats::decodeFrameDeltas($component->instance()->networkTimelapseData()['frames']);
|
|
|
|
$component->set('networkTimelapseCumulative', false);
|
|
$payload = $component->instance()->networkTimelapseData();
|
|
$per = Stats::decodeFrameDeltas($payload['frames']);
|
|
$ghosts = Stats::decodeFrameDeltas($payload['ghosts']);
|
|
$history = Stats::decodeFrameDeltas($payload['history']);
|
|
|
|
$nodes = function (array $frame): array {
|
|
$ids = $frame['nodes'];
|
|
sort($ids);
|
|
|
|
return $ids;
|
|
};
|
|
|
|
foreach ($per as $i => $frame) {
|
|
// The ghost is last month's frame...
|
|
if ($i >= 1) {
|
|
$this->assertSame($nodes($per[$i - 1]), $nodes($ghosts[$i]), 'Ghost behind ' . $frame['label']);
|
|
}
|
|
|
|
// ...and the history everything up to the month before that.
|
|
if ($i >= 2) {
|
|
$this->assertSame($nodes($accumulated[$i - 2]), $nodes($history[$i]), 'History behind ' . $frame['label']);
|
|
}
|
|
}
|
|
}
|
|
|
|
public function test_time_lapse_can_step_by_week(): void
|
|
{
|
|
$this->actAsStatsAdmin();
|
|
$this->seedOneTransaction();
|
|
|
|
$component = Livewire::test(Stats::class)
|
|
->set('showNetwork', true)
|
|
->set('networkTimelapse', true)
|
|
->set('networkTimelapseSpan', 3)
|
|
->assertSet('networkTimelapseStep', 'month');
|
|
|
|
$html = $component->html();
|
|
$this->assertStringContainsString('wire:model.live="networkTimelapseStep"', $html);
|
|
$this->assertStringContainsString('data-network-timelapse-step="month"', $html);
|
|
|
|
$months = $component->instance()->networkTimelapseData()['frames'];
|
|
|
|
$component->set('networkTimelapseStep', 'week');
|
|
// The browser rebuilds on this, or switching would keep the old steps.
|
|
$this->assertStringContainsString('data-network-timelapse-step="week"', $component->html());
|
|
|
|
$weeks = $component->instance()->networkTimelapseData()['frames'];
|
|
|
|
// Weekly steps count back from the end date to the start of the first
|
|
// month, each labelled with its day.
|
|
$start = \Carbon\Carbon::parse($months[0]['till'])->startOfMonth();
|
|
$days = (int) $start->diffInDays(\Carbon\Carbon::parse(end($months)['till']));
|
|
$this->assertCount(intdiv($days, 7) + 1, $weeks);
|
|
$this->assertMatchesRegularExpression('/^\d{1,2} \w{3} \d{4}$/', end($weeks)['label']);
|
|
$this->assertSame(end($months)['till'], end($weeks)['till'], 'Both end on the same day.');
|
|
|
|
// Anything else falls back to months.
|
|
$component->set('networkTimelapseStep', 'day')->assertSet('networkTimelapseStep', 'month');
|
|
}
|
|
|
|
public function test_circulation_legends_use_the_colours_the_graph_draws(): void
|
|
{
|
|
// The graph colours circulation with FLOW_COLORS in stats-network.js;
|
|
// the legends are written in PHP, so nothing kept them in step. The
|
|
// bank report's legend had drifted to a four-colour ramp the graph
|
|
// never drew. Read the palette from the script itself.
|
|
$js = file_get_contents(resource_path('js/stats-network.js'));
|
|
|
|
$this->assertMatchesRegularExpression('/const FLOW_COLORS = \{(.*?)\};/s', $js);
|
|
preg_match('/const FLOW_COLORS = \{(.*?)\};/s', $js, $block);
|
|
preg_match_all("/(\w+):\s*'(#[0-9a-fA-F]{6})'/", $block[1], $pairs, PREG_SET_ORDER);
|
|
|
|
$palette = [];
|
|
foreach ($pairs as [, $state, $hex]) {
|
|
$palette[$state] = strtolower($hex);
|
|
}
|
|
|
|
$this->assertSame(['flowing', 'stalled', 'neverPaid'], array_keys($palette));
|
|
|
|
foreach ([\App\Http\Livewire\Admin\Stats::class, \App\Http\Livewire\SingleReport::class] as $class) {
|
|
$component = new $class();
|
|
$component->networkView = 'circulation';
|
|
|
|
$legend = $component->networkLegend(['nodes' => [], 'edges' => [], 'stats' => []]);
|
|
$colours = array_map('strtolower', array_column($legend['colors'], 'color'));
|
|
|
|
$this->assertSame(
|
|
array_values($palette),
|
|
$colours,
|
|
class_basename($class) . ' legend must show exactly FLOW_COLORS, in order.'
|
|
);
|
|
}
|
|
}
|
|
|
|
public function test_svg_export_gets_the_theme_logo(): void
|
|
{
|
|
$logo = Stats::networkExportLogo();
|
|
|
|
// The theme's own inline SVG, so every white-label installation
|
|
// exports its own logo, with the colour set explicitly: a standalone
|
|
// SVG file has no stylesheet for the view's CSS class.
|
|
$this->assertNotNull($logo, 'The active theme should provide a logo.');
|
|
$this->assertMatchesRegularExpression('/<svg\b[^>]*viewBox="[^"]+"/', $logo['svg']);
|
|
$this->assertStringNotContainsString('{{', $logo['svg'], 'The logo view must be rendered, not raw Blade.');
|
|
$this->assertSame(theme_color('logo'), $logo['color']);
|
|
|
|
// And it reaches the export's metadata on the stats page.
|
|
$this->actAsStatsAdmin();
|
|
$this->seedOneTransaction();
|
|
|
|
$html = Livewire::test(Stats::class)->set('showNetwork', true)->html();
|
|
|
|
preg_match('/data-network-meta="([^"]*)"/', $html, $m);
|
|
$meta = json_decode(html_entity_decode($m[1] ?? ''), true);
|
|
|
|
$this->assertIsArray($meta);
|
|
$this->assertSame($logo['svg'], $meta['logo']['svg'] ?? null);
|
|
}
|
|
|
|
public function test_a_theme_without_a_logo_view_exports_without_one(): void
|
|
{
|
|
// Decoration must never break an export.
|
|
config(['themes.themes.' . config('themes.active', env('TIMEBANK_THEME', 'timebank_cc')) . '.logos.svg_inline' => 'logos.does_not_exist']);
|
|
|
|
$this->assertNull(Stats::networkExportLogo());
|
|
}
|
|
|
|
public function test_svg_export_is_dated_instead_of_all_time(): void
|
|
{
|
|
// Only "All time" is replaced; a period that already names its span
|
|
// is left alone.
|
|
$this->assertSame('Up to 22-09-2026', Stats::exportPeriodLabel(__('All time'), '2026-09-22'));
|
|
$this->assertSame(__('Previous year'), Stats::exportPeriodLabel(__('Previous year'), '2026-09-22'));
|
|
|
|
$this->actAsStatsAdmin();
|
|
$this->seedOneTransaction(); // one month ago
|
|
$this->seedTransactionAt(now()->subMonths(4)); // an older one
|
|
|
|
$component = Livewire::test(Stats::class)->set('showNetwork', true);
|
|
|
|
// The graph knows its most recent drawn trade.
|
|
$this->assertSame('all_time', $component->get('networkPeriod'));
|
|
|
|
$html = $component->html();
|
|
preg_match('/data-network-meta="([^"]*)"/', $html, $m);
|
|
$meta = json_decode(html_entity_decode($m[1] ?? ''), true);
|
|
|
|
$this->assertNotSame(__('All time'), $meta['period'] ?? null, 'An export must never say only "All time".');
|
|
$this->assertSame('Up to ' . now()->subMonth()->format('d-m-Y'), $meta['period']);
|
|
}
|
|
|
|
public function test_currency_flow_has_a_question_title_and_its_explanation_in_the_legend(): void
|
|
{
|
|
$this->actAsStatsAdmin();
|
|
$this->seedOneTransaction();
|
|
|
|
$component = Livewire::test(Stats::class)
|
|
->set('showNetwork', true)
|
|
->call('setNetworkView', 'flow');
|
|
|
|
// A question like the other views, in the platform's currency name,
|
|
// lower case mid-sentence.
|
|
$title = Stats::networkViewCaption('flow');
|
|
$this->assertSame('Which way do the hours flow?', $title);
|
|
|
|
// The explanation lives in the legend now, not under the title.
|
|
$explanation = Stats::networkViewHint('flow');
|
|
$this->assertStringStartsWith('Dot size is the number of trading partners.', $explanation);
|
|
$this->assertStringContainsString('showing the hours of that type', $explanation);
|
|
|
|
$html = $component->html();
|
|
|
|
// The header on screen: the title's paragraph, up to the counts line.
|
|
// (The first match for the title is the SVG metadata attribute on the
|
|
// graph wrapper, so look for the rendered paragraph itself.)
|
|
$titleAt = strpos($html, '>' . PHP_EOL . ' ' . e($title));
|
|
$titleAt = $titleAt === false ? strpos($html, e($title) . PHP_EOL) : $titleAt;
|
|
$countsAt = strpos($html, 'data-network-stagnant', (int) $titleAt);
|
|
|
|
$this->assertNotFalse($titleAt, 'The title is in the header.');
|
|
$this->assertNotFalse($countsAt);
|
|
$this->assertStringNotContainsString(
|
|
e($explanation),
|
|
substr($html, $titleAt, $countsAt - $titleAt),
|
|
'Header is title then counts: the explanation no longer sits under the title.'
|
|
);
|
|
|
|
// ...and it is in the legend, after the header.
|
|
$this->assertNotFalse(
|
|
strpos($html, e($explanation), $countsAt),
|
|
'The explanation is in the legend on the page.'
|
|
);
|
|
|
|
// SVG: the title in its header, the explanation first in its legend.
|
|
preg_match('/data-network-meta="([^"]*)"/', $html, $m);
|
|
$meta = json_decode(html_entity_decode($m[1] ?? ''), true);
|
|
|
|
$this->assertSame($title, $meta['caption'] ?? null);
|
|
$this->assertArrayNotHasKey('description', $meta);
|
|
$this->assertSame(__('Currency flow') . ': ' . $explanation, $meta['hint'] ?? null);
|
|
|
|
// PDF: printed from the same legend.
|
|
$legend = $component->instance()->networkLegend(['nodes' => [], 'edges' => [], 'stats' => []]);
|
|
|
|
$this->assertSame($explanation, $legend['view_hint']);
|
|
$this->assertArrayNotHasKey('view_description', $legend);
|
|
}
|
|
|
|
public function test_currency_flow_title_follows_the_platform_currency_name(): void
|
|
{
|
|
// A white-label platform with its own currency.
|
|
config(['timebank_cc.platform_translations.en.platform_currency_name_plural' => 'Beans']);
|
|
config(['timebank_cc.platform_translations.de.platform_currency_name_plural' => 'Bohnen']);
|
|
|
|
$this->assertSame('Which way do the beans flow?', Stats::networkViewCaption('flow'));
|
|
|
|
// German keeps its capital: every noun has one.
|
|
app()->setLocale('de');
|
|
$this->assertSame('In welche Richtung fließen die Bohnen?', Stats::networkViewCaption('flow'));
|
|
}
|
|
|
|
public function test_time_lapse_builds_every_frame_in_one_call(): void
|
|
{
|
|
$this->actAsStatsAdmin();
|
|
$this->seedOneTransaction();
|
|
|
|
// Through Livewire's own dispatch, NOT ->instance()->method().
|
|
// Calling it on the instance bypasses the name resolution that
|
|
// actually broke: a public method colliding with a public property
|
|
// cannot be called, and this test passed while the page was broken.
|
|
$component = Livewire::test(Stats::class)
|
|
->set('networkTimelapse', true)
|
|
->call('networkTimelapseData');
|
|
|
|
$payload = $component->instance()->networkTimelapseData();
|
|
// Frames travel as changes; the assertions below are about full frames.
|
|
$payload['frames'] = Stats::decodeFrameDeltas($payload['frames']);
|
|
|
|
$this->assertArrayHasKey('graph', $payload);
|
|
$this->assertArrayHasKey('frames', $payload);
|
|
|
|
// Frames carry membership only; the elements live in `graph`, which is
|
|
// what makes one call affordable. Per-frame calls each ran a full
|
|
// all-time query: ~1.1s x 36 = 42.5s before anything appeared.
|
|
foreach ($payload['frames'] as $frame) {
|
|
$this->assertArrayHasKey('label', $frame);
|
|
|
|
foreach ($frame['nodes'] as $id) {
|
|
$this->assertIsString($id, 'A frame should carry node ids, not node objects.');
|
|
}
|
|
}
|
|
|
|
// Cumulative: a frame can never be smaller than the one before it.
|
|
$previous = 0;
|
|
|
|
foreach ($payload['frames'] as $frame) {
|
|
$this->assertGreaterThanOrEqual(
|
|
$previous,
|
|
count($frame['nodes']),
|
|
'A cumulative frame cannot shrink.'
|
|
);
|
|
$previous = count($frame['nodes']);
|
|
}
|
|
}
|
|
|
|
}
|