Selkie.git | t/ | 45-plot-ticks.rakutest


use Test;
use lib 'lib';

use Selkie::Plot::Ticks;

plan 14;

subtest "construction with valid args" => {
	plan 3;
	my $t = Selkie::Plot::Ticks.nice(min => 0, max => 100, count => 5);
	isa-ok $t, Selkie::Plot::Ticks;
	is $t.min,    0,   "min stored";
	is $t.max,    100, "max stored";
};

subtest "rejects count < 2" => {
	plan 2;
	dies-ok { Selkie::Plot::Ticks.nice(min => 0, max => 1, count => 1) },
		"count = 1 is rejected";
	dies-ok { Selkie::Plot::Ticks.nice(min => 0, max => 1, count => 0) },
		"count = 0 is rejected";
};

subtest "rejects min > max" => {
	plan 1;
	dies-ok { Selkie::Plot::Ticks.nice(min => 10, max => 5, count => 5) },
		"min > max is rejected";
};

subtest "degenerate range (min == max) returns single tick" => {
	plan 3;
	my $t = Selkie::Plot::Ticks.nice(min => 7, max => 7, count => 5);
	is $t.values.elems,  1,  "exactly one tick";
	is $t.values[0],     7,  "tick is at the degenerate value";
	is $t.step,          0,  "step is zero (no spacing)";
};

subtest "(0, 100, 5) produces classic round ticks" => {
	plan 3;
	my $t = Selkie::Plot::Ticks.nice(min => 0, max => 100, count => 5);
	# Heckbert with the {1, 2, 5} set lands on step=20 here (the
	# nearest "nice" multiplier; step=25 isn't in the set). That
	# yields six ticks instead of five — Heckbert prefers nice
	# spacing over hitting the requested count exactly.
	is $t.values.list, (0, 20, 40, 60, 80, 100),
		"ticks at 0, 20, 40, 60, 80, 100";
	is $t.step,    20, "step is 20";
	is $t.labels.list, ("0", "20", "40", "60", "80", "100"),
		"labels are integer-formatted";
};

subtest "(-50, 50, 5) handles symmetric negative range" => {
	plan 2;
	my $t = Selkie::Plot::Ticks.nice(min => -50, max => 50, count => 5);
	# step=20 by the same logic as (0, 100, 5).
	is $t.values.list, (-60, -40, -20, 0, 20, 40, 60),
		"ticks span the symmetric range with nice spacing";
	is $t.step, 20, "step is 20";
};

subtest "(7, 93, 5) extends to nice round endpoints" => {
	plan 2;
	# Heckbert pads outward to nice numbers — the data range is 7..93
	# but the axis labels go 0..100 because those are the nice round
	# numbers nearest the data extents.
	my $t = Selkie::Plot::Ticks.nice(min => 7, max => 93, count => 5);
	is $t.values.list, (0, 20, 40, 60, 80, 100),
		"ticks extend to nice round endpoints";
	is $t.step, 20, "step is 20";
};

subtest "sub-unit range produces sub-unit step" => {
	plan 2;
	my $t = Selkie::Plot::Ticks.nice(min => 0, max => 1, count => 5);
	# Mirror of (0, 100, 5): step=0.2, six ticks.
	is-approx $t.step, 0.2, "step is 0.2";
	is $t.values.elems, 6,  "six ticks (Heckbert prefers nice spacing)";
};

subtest "sub-unit labels carry consistent decimal precision" => {
	plan 6;
	my $t = Selkie::Plot::Ticks.nice(min => 0, max => 0.1, count => 5);
	# Step is 0.02; labels should all carry 2 decimal places so they
	# align (no "0" mixed with "0.02" mixed with "0.06").
	for $t.labels -> $label {
		ok $label.contains('.'), "label '$label' carries decimal";
	}
};

subtest "(0.001, 0.009, 4) — Heckbert prefers nice spacing over exact count" => {
	plan 3;
	my $t = Selkie::Plot::Ticks.nice(min => 0.001, max => 0.009, count => 4);
	# Heckbert gives step = 0.005, ticks = (0, 0.005, 0.01).
	# Three ticks despite requesting four — that's correct behaviour;
	# you trade exact count for round spacing.
	is-approx $t.step, 0.005, "step is 0.005";
	ok $t.values.elems >= 2,  "at least two ticks";
	ok $t.values.elems <= 6,  "tick count is reasonable";
};

subtest "all ticks are multiples of step (within FP tolerance)" => {
	plan 1;
	my $t = Selkie::Plot::Ticks.nice(min => -7.5, max => 42.3, count => 7);
	my $step = $t.step;
	my $all-multiples = True;
	for $t.values -> $v {
		my $ratio = $v / $step;
		my $rounded = $ratio.round;
		if ($ratio - $rounded).abs > 1e-9 {
			$all-multiples = False;
			last;
		}
	}
	ok $all-multiples, "every tick is a multiple of step";
};

subtest "step is always 1, 2, or 5 times a power of 10" => {
	plan 6;
	# Sample a range of input domains and verify step is a member of
	# {1, 2, 5} × 10^n.
	my @cases = (
		(0, 100, 5),
		(0, 0.01, 5),
		(-1000, 1000, 10),
		(0.5, 7.3, 5),
		(0, 1_000_000, 4),
		(-0.0001, 0.0009, 5),
	);
	for @cases -> ($mn, $mx, $cnt) {
		my $t = Selkie::Plot::Ticks.nice(min => $mn, max => $mx, count => $cnt);
		my $step = $t.step;
		my $exp = $step.abs.log(10).floor;
		my $leading = ($step.abs / 10 ** $exp).round(0.001);
		my $is-nice = $leading == 1 || $leading == 2 || $leading == 5;
		ok $is-nice, "step $step (leading $leading) is nice";
	}
};

subtest "ticks span the data range" => {
	plan 3;
	my $t = Selkie::Plot::Ticks.nice(min => 7, max => 93, count => 5);
	ok $t.values[0] <= $t.min,
		"first tick at or below min ({$t.values[0]} <= {$t.min})";
	ok $t.values[*-1] >= $t.max,
		"last tick at or above max ({$t.values[*-1]} >= {$t.max})";
	# Adjacent ticks differ by exactly step.
	for ^($t.values.elems - 1) -> $i {
		# Single check — break out on first failure
		last if ($t.values[$i + 1] - $t.values[$i] - $t.step).abs > 1e-9;
	}
	pass "adjacent ticks are step-spaced";
};

subtest "labels round-trip parsing" => {
	plan 1;
	my $t = Selkie::Plot::Ticks.nice(min => -3.5, max => 7.5, count => 6);
	my @parsed = $t.labels.map(*.Num);
	my @vals   = $t.values.map(*.Num);
	is @parsed, @vals, "labels parse back to the tick values";
};