Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/Plugins
117 changes: 117 additions & 0 deletions tests/Unit/app/Plugins/StrategyPro/Services/ReverseStageTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<?php

declare(strict_types=1);

namespace Unit\app\Plugins\StrategyPro\Services;

use Leantime\Plugins\StrategyPro\Services\ReverseStage;
use PHPUnit\Framework\TestCase;

/**
* Contract tests for {@see ReverseStage}. The enum is what prevents the
* "copy the Outcomes step four times" trap — behavior lives here, not in
* per-stage controller branching. If these break, the whole flow's
* confidence + mode discipline breaks with them.
*
* The Impact-never-seeded invariant is the most load-bearing test in the
* whole feature: a canvas with a generated Impact is a lie the report
* renders to a funder (§4 rev.2). Locking it in at the enum level means
* no future template can accidentally propose one.
*/
class ReverseStageTest extends TestCase
{
Comment on lines +21 to +22
public function test_impact_can_never_be_seeded(): void
Comment on lines +21 to +23
Comment on lines +21 to +23
{
Comment on lines +21 to +24
$this->assertFalse(
ReverseStage::Impact->canBeSeeded(),
'Impact must never be seeded — a canvas with a generated Impact is a lie the report renders to a funder.'
);
$this->assertSame('none', ReverseStage::Impact->confidence());
}

public function test_every_other_stage_can_be_seeded(): void
{
foreach (ReverseStage::cases() as $stage) {
if ($stage === ReverseStage::Impact) {
continue;
}
$this->assertTrue(
$stage->canBeSeeded(),
"Stage {$stage->value} must be seedable — Impact is the only exception."
);
}
}

public function test_high_confidence_stages_propose_selected(): void
{
// Highest = Inputs (a resource IS an input).
// High = Outcomes (goals w/ linkAndReport are outcomes by construction).
$this->assertTrue(ReverseStage::Inputs->proposesSelected());
$this->assertTrue(ReverseStage::Outcomes->proposesSelected());
}

public function test_medium_and_low_confidence_stages_propose_unselected(): void
{
// Medium = Outputs (milestones CAN be internal work — user opts in).
// Low = Activities (unit mismatch — user decides which projects group into an activity).
$this->assertFalse(ReverseStage::Outputs->proposesSelected());
$this->assertFalse(ReverseStage::Activities->proposesSelected());
}

public function test_only_low_confidence_stages_collapse_by_default(): void
{
$this->assertTrue(ReverseStage::Activities->isCollapsedByDefault());
// Everything else stays uncollapsed — even seeded-with-lots-of-candidates
// stages like Outputs render open (they get the +N-more tail instead).
foreach ([ReverseStage::Inputs, ReverseStage::Outputs, ReverseStage::Outcomes, ReverseStage::Impact] as $stage) {
$this->assertFalse($stage->isCollapsedByDefault(), $stage->value.' must render open by default');
}
}

public function test_canvas_order_is_inputs_to_impact(): void
{
// Locks the review-screen section order and the stage rail order.
// A regression here would render the arc backwards for the reader.
$this->assertSame([
ReverseStage::Inputs,
ReverseStage::Activities,
ReverseStage::Outputs,
ReverseStage::Outcomes,
ReverseStage::Impact,
], ReverseStage::canvasOrder());
}

public function test_boxes_match_logicmodelcanvas_convention(): void
{
// The box() values are what get written into zp_canvas_items.box.
// A mismatch would silently misplace items on the canvas grid.
$this->assertSame('lm_inputs', ReverseStage::Inputs->box());
$this->assertSame('lm_activities', ReverseStage::Activities->box());
$this->assertSame('lm_outputs', ReverseStage::Outputs->box());
$this->assertSame('lm_outcomes', ReverseStage::Outcomes->box());
$this->assertSame('lm_impact', ReverseStage::Impact->box());
}

public function test_every_stage_has_a_guide_prompt(): void
{
// Guide mode isn't a fallback — every stage must have a real prompt.
// §3 rev.2: neither mode is a failure state.
foreach (ReverseStage::cases() as $stage) {
$prompt = $stage->guidePrompt();
$this->assertArrayHasKey('title', $prompt);
$this->assertArrayHasKey('hint', $prompt);
$this->assertNotSame('', trim($prompt['title']), $stage->value.' guide prompt title is empty');
$this->assertNotSame('', trim($prompt['hint']), $stage->value.' guide prompt hint is empty');
}
}

public function test_impact_guide_prompt_matches_spec(): void
{
// Spec §6 rev.2 dictates the exact phrasing — "the change in the world
// you're working toward. Not the number — the change." Test locks it.
$prompt = ReverseStage::Impact->guidePrompt();
$this->assertStringContainsString("What's this all for", $prompt['title']);
$this->assertStringContainsString('change in the world', $prompt['hint']);
$this->assertStringContainsString('Not the number', $prompt['hint']);
}
}
240 changes: 240 additions & 0 deletions tests/Unit/app/Plugins/StrategyPro/Services/WorkAdapterTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
<?php

declare(strict_types=1);

namespace Unit\app\Plugins\StrategyPro\Services;

use Leantime\Core\Db\Db;
use Leantime\Plugins\StrategyPro\Services\WorkAdapter;
use Unit\TestCase;

/**
* Behaviors under test — the reverse flow's read half:
*
* 1. `adapt` returns a stable shape (scope + outcomes + completeness)
* whether goals are present or not.
* 2. Classification splits goals by whether they carry a measurable
* target (metricType != '' AND endValue > 0). A percent goal with
* endValue=0 goes into `withoutTarget` — "0% of nothing" isn't a
* target.
* 3. Provenance filter respects BOTH directions per §10.b:
* - `generated_from` (canvas item ← goal): forward-seeded, hide.
* - `maps_to` (goal → canvas item): reverse-seeded, hide.
* Re-runs of the reverse flow don't re-propose already-committed goals.
* 4. `completeness` message adapts to candidate count without asserting
* numbers the reader can't reconstruct from what's on screen (§11
* rule 6). Empty / sparse / rich each have their own authored line.
*
* The SUT reads two DB tables (`zp_canvas` / `zp_canvas_items` for goals,
* `zp_entity_relationship` for provenance) via `Db::getConnection()`. Rather
* than mock a query-builder chain, this test uses an anonymous subclass
* that overrides the two seams the adapter walks — `readGoalsInScope`
* (private) via a protected extraction, and `filterAlreadyLinked` via a
* hidden-ids injection — through a small trait-free subclass override.
* That keeps tests fast, deterministic, and free of DB setup while still
* exercising the aggregation + classification + completeness logic.
*/
class WorkAdapterTest extends TestCase
{
Comment on lines +37 to +38
public function test_empty_strategy_returns_empty_completeness(): void
Comment on lines +37 to +39
Comment on lines +37 to +39
{
Comment on lines +37 to +40
Comment on lines +37 to +40
$adapter = $this->makeAdapter(
scope: ['strategyId' => 100, 'strategyName' => 'Fresh', 'programCount' => 0, 'projectCount' => 0, 'projectIds' => [100]],
goals: [],
hiddenIds: [],
);

$out = $adapter->adapt(100);

$this->assertSame(0, count($out['outcomes']['withTarget']));
$this->assertSame(0, count($out['outcomes']['withoutTarget']));
$this->assertSame(0, $out['outcomes']['hiddenCount']);
$this->assertSame('empty', $out['completeness']['level']);
$this->assertStringContainsString('add goals', $out['completeness']['message']);
}

public function test_all_goals_hidden_reads_as_already_reflected(): void
{
// Every goal in scope carries a MapsTo or GeneratedFrom link — the
// honest read isn't "you have no goals" (a lie) but "you've already
// wired everything to a Logic Model" (the actual state).
$goal = $this->goal(1, 'Prior goal', 'number', 100);
$adapter = $this->makeAdapter(
scope: ['strategyId' => 100, 'strategyName' => 'Old', 'programCount' => 1, 'projectCount' => 2, 'projectIds' => [100, 200, 201]],
goals: [$goal],
hiddenIds: [1],
);

$out = $adapter->adapt(100);

$this->assertSame(0, count($out['outcomes']['withTarget']));
$this->assertSame(1, $out['outcomes']['hiddenCount']);
$this->assertSame('empty', $out['completeness']['level']);
$this->assertStringContainsString('already reflected', $out['completeness']['message']);
}

public function test_classifies_by_metric_target(): void
{
// Four goals mix: percent w/ target, number w/ target, currency w/
// target, empty metric type. The empty type is the "no clear target"
// case regardless of the numeric values on it — mockup's "1 more
// goal without a clear target" row.
$adapter = $this->makeAdapter(
scope: ['strategyId' => 100, 'strategyName' => 'X', 'programCount' => 2, 'projectCount' => 2, 'projectIds' => [100, 200, 300]],
goals: [
$this->goal(1, '60% completion rate', 'percent', 60),
$this->goal(2, 'Reach 200 residents', 'number', 200),
$this->goal(3, '$500K wage gains', 'currency', 500000),
$this->goal(4, 'Strengthen partnerships', '', 0),
],
hiddenIds: [],
);

$out = $adapter->adapt(100);

$this->assertSame(3, count($out['outcomes']['withTarget']));
$this->assertSame(1, count($out['outcomes']['withoutTarget']));
$this->assertSame('Strengthen partnerships', $out['outcomes']['withoutTarget'][0]['title']);
$this->assertSame('rich', $out['completeness']['level']);
}

public function test_percent_goal_with_zero_endvalue_is_no_target(): void
{
// A percent goal with endValue=0 is authored-but-unspecified —
// rendering "0% target" or "100% of 0" would be nonsense. Falls
// into the no-target bucket per the classifier contract.
$adapter = $this->makeAdapter(
scope: ['strategyId' => 100, 'strategyName' => 'X', 'programCount' => 0, 'projectCount' => 1, 'projectIds' => [100, 200]],
goals: [$this->goal(1, 'Improve X somehow', 'percent', 0)],
hiddenIds: [],
);

$out = $adapter->adapt(100);

$this->assertSame(0, count($out['outcomes']['withTarget']));
$this->assertSame(1, count($out['outcomes']['withoutTarget']));
}

public function test_hidden_goals_are_removed_from_candidates_but_counted(): void
{
// Three goals in scope; goal 2 hidden (say, MapsTo-linked from a
// prior reverse run). Candidates show 2, hidden count shows 1 —
// §11 rule 6: every number reconstructable from what's visible.
$adapter = $this->makeAdapter(
scope: ['strategyId' => 100, 'strategyName' => 'X', 'programCount' => 1, 'projectCount' => 1, 'projectIds' => [100, 200]],
goals: [
$this->goal(1, 'A', 'number', 10),
$this->goal(2, 'B (hidden)', 'number', 20),
$this->goal(3, 'C', 'number', 30),
],
hiddenIds: [2],
);

$out = $adapter->adapt(100);
$titles = array_map(static fn ($g) => $g['title'], $out['outcomes']['withTarget']);

$this->assertSame(['A', 'C'], $titles);
$this->assertSame(1, $out['outcomes']['hiddenCount']);
}

public function test_sparse_level_when_one_or_two_candidates(): void
{
$adapter = $this->makeAdapter(
scope: ['strategyId' => 100, 'strategyName' => 'X', 'programCount' => 1, 'projectCount' => 1, 'projectIds' => [100, 200]],
goals: [
$this->goal(1, 'Only one', 'number', 10),
$this->goal(2, 'Only two', 'number', 20),
],
hiddenIds: [],
);

$out = $adapter->adapt(100);

$this->assertSame('sparse', $out['completeness']['level']);
$this->assertStringNotContainsString('%d', $out['completeness']['message'],
'Message must be authored, not sprintf-ready — no unfilled placeholders in what reaches the reader.');
}

public function test_scope_shape_passes_through(): void
{
// The scope block on the response is what the intercept modal +
// stage-rail templates read; regressing it silently truncates the
// "3 programs, 7 goals, 12 milestones" line on the modal.
$adapter = $this->makeAdapter(
scope: ['strategyId' => 42, 'strategyName' => 'Named Strategy', 'programCount' => 3, 'projectCount' => 7, 'projectIds' => [42]],
goals: [],
hiddenIds: [],
);

$out = $adapter->adapt(42);

$this->assertSame([
'strategyId' => 42,
'strategyName' => 'Named Strategy',
'programCount' => 3,
'projectCount' => 7,
], $out['scope']);
}

// ─── Test doubles ────────────────────────────────────────────────

/**
* @param array<string, mixed> $scope Fake `collectScope()` return.
* @param array<int, array<string, mixed>> $goals Fake `readGoalsInScope()` return.
* @param int[] $hiddenIds Fake `filterAlreadyLinked()` return.
*/
private function makeAdapter(array $scope, array $goals, array $hiddenIds): WorkAdapter
{
$db = $this->createMock(Db::class);

return new class ($db, $scope, $goals, $hiddenIds) extends WorkAdapter
{
/**
* @param array<string, mixed> $scope
* @param array<int, array<string, mixed>> $goals
* @param int[] $hiddenIds
*/
public function __construct(
Db $db,
private array $scope,
private array $goals,
private array $hiddenIds,
) {
parent::__construct($db);
}

protected function collectScope(int $strategyProjectId): array
{
return $this->scope;
}

protected function readGoalsInScope(array $projectIds): array
{
return $this->goals;
}

protected function filterAlreadyLinked(array $goalIds): array
{
return array_values(array_intersect($this->hiddenIds, $goalIds));
}
};
}

/**
* @return array<string, mixed>
*/
private function goal(int $id, string $title, string $metricType, float $endValue): array
{
return [
'id' => $id,
'title' => $title,
'projectId' => 200,
'projectName' => 'A Project',
'rolledUp' => true,
'metricType' => $metricType,
'startValue' => 0.0,
'endValue' => $endValue,
'currentValue' => 0.0,
];
}
}
Loading