OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
@@ -0,0 +1,392 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unit\app\Plugins\PgmPro\Domain\Resources\Services;
|
||||
|
||||
use Leantime\Core\Db\Db;
|
||||
use Leantime\Domain\Projects\Services\Projects as ProjectService;
|
||||
use Leantime\Plugins\PgmPro\Domain\Resources\Repositories\ResourceStructureRepository;
|
||||
use Leantime\Plugins\PgmPro\Domain\Resources\Services\ResourceStructureService;
|
||||
use Leantime\Plugins\PgmPro\Services\Programs;
|
||||
use Unit\TestCase;
|
||||
|
||||
/**
|
||||
* Behaviors under test:
|
||||
*
|
||||
* getForProjects — the ResourcesGateway contract:
|
||||
* 1. Empty projectIds → empty ResourceSummary (no walk).
|
||||
* 2. Projects with no resource canvas → empty ResourceSummary (honest
|
||||
* "resources not authored here" state, not an error).
|
||||
* 3. status='stub' PEOPLE are excluded from totals — matches the tab's
|
||||
* teamStats split. A seeded stub defaults to capacity:40, and counting
|
||||
* it would make the report disagree with the tab the moment anyone
|
||||
* uses LM-Inputs seeding.
|
||||
* 4. status='stub' BUDGET LINES are excluded from totals AND from the
|
||||
* budget[] array — stubs are 0/0 so they don't move totals, but they
|
||||
* inflate `count(budget)` and can flip `isEmpty()`.
|
||||
*
|
||||
* Seeders — the "skip already present" contract:
|
||||
* 5. seedPeopleFromChildProjects is idempotent by userId — a second run
|
||||
* with the same source data adds zero rows and reports the correct
|
||||
* skipped count.
|
||||
* 6. seedBudgetFromChildProjects is idempotent by projectId — same.
|
||||
*
|
||||
* All tests avoid touching the DB via a testable subclass of the SUT that
|
||||
* overrides `findResourceCanvasIds()`; the repository and the two service
|
||||
* collaborators are stubbed with anonymous classes so the test asserts on
|
||||
* the exact call surface.
|
||||
*/
|
||||
class ResourceStructureServiceTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
// The OSS CI checkout has an empty app/Plugins (private submodule) — the classes
|
||||
// under test only exist when the plugins are present. Skip, don't error.
|
||||
if (! class_exists(ResourceStructureService::class)) {
|
||||
$this->markTestSkipped('PgmPro plugin is not present in this checkout.');
|
||||
}
|
||||
|
||||
parent::setUp();
|
||||
}
|
||||
|
||||
public function test_get_for_projects_returns_empty_summary_when_project_ids_is_empty(): void
|
||||
{
|
||||
$svc = $this->makeService(canvasIds: [1], items: []);
|
||||
$summary = $svc->getForProjects([]);
|
||||
|
||||
$this->assertSame([], $summary->projectIds);
|
||||
$this->assertTrue($summary->isEmpty());
|
||||
}
|
||||
|
||||
public function test_get_for_projects_returns_empty_summary_when_no_resource_canvas(): void
|
||||
{
|
||||
$svc = $this->makeService(canvasIds: [], items: []);
|
||||
$summary = $svc->getForProjects([1, 2]);
|
||||
|
||||
$this->assertSame([1, 2], $summary->projectIds);
|
||||
$this->assertTrue($summary->isEmpty());
|
||||
$this->assertSame(0.0, $summary->totalCapacity);
|
||||
}
|
||||
|
||||
public function test_get_for_projects_excludes_stub_people_from_totals(): void
|
||||
{
|
||||
// Active person: capacity 40, allocated 30. Stub person: default 40.
|
||||
// The report and the tab must agree — the tab excludes stubs from
|
||||
// teamStats, so the gateway must too. Otherwise a seeded-but-unlinked
|
||||
// person shows 40h in the report and 0h in the tab.
|
||||
$svc = $this->makeService(
|
||||
canvasIds: [100],
|
||||
items: [
|
||||
100 => [
|
||||
'people' => [
|
||||
[
|
||||
'id' => 1,
|
||||
'description' => 'Sarah Chen',
|
||||
'status' => 'active',
|
||||
'parsedData' => ['userId' => 1, 'capacity' => 40, 'allocations' => ['7' => 30]],
|
||||
],
|
||||
[
|
||||
'id' => 2,
|
||||
'description' => 'Unlinked seed',
|
||||
'status' => 'stub',
|
||||
'parsedData' => ['capacity' => 40, 'allocations' => []],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$summary = $svc->getForProjects([7]);
|
||||
|
||||
$this->assertCount(1, $summary->people, 'Stubs must not appear in the people array');
|
||||
$this->assertSame(40.0, $summary->totalCapacity, 'Stub 40h capacity must not be counted');
|
||||
$this->assertSame(30.0, $summary->totalAllocated);
|
||||
}
|
||||
|
||||
public function test_get_for_projects_excludes_stub_budget_lines_from_array_and_totals(): void
|
||||
{
|
||||
$svc = $this->makeService(
|
||||
canvasIds: [100],
|
||||
items: [
|
||||
100 => [
|
||||
'budget' => [
|
||||
[
|
||||
'id' => 10,
|
||||
'description' => 'Community Health Fairs',
|
||||
'status' => 'active',
|
||||
'parsedData' => ['projectId' => 7, 'budgeted' => 10000, 'spent' => 2000],
|
||||
],
|
||||
[
|
||||
'id' => 11,
|
||||
'description' => '',
|
||||
'status' => 'stub',
|
||||
'parsedData' => ['projectId' => 8, 'budgeted' => 0, 'spent' => 0],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$summary = $svc->getForProjects([7, 8]);
|
||||
|
||||
$this->assertCount(1, $summary->budget, 'Stub budget rows must not inflate the array count (would flip isEmpty)');
|
||||
$this->assertSame(10000.0, $summary->totalBudgeted);
|
||||
$this->assertSame(2000.0, $summary->totalSpent);
|
||||
}
|
||||
|
||||
public function test_get_for_projects_aggregates_across_multiple_canvases(): void
|
||||
{
|
||||
// A strategy with two programs, each with its own resource canvas.
|
||||
// The gateway must sum across both.
|
||||
$svc = $this->makeService(
|
||||
canvasIds: [100, 200],
|
||||
items: [
|
||||
100 => [
|
||||
'people' => [
|
||||
['id' => 1, 'description' => 'A', 'status' => 'active',
|
||||
'parsedData' => ['userId' => 1, 'capacity' => 40, 'allocations' => ['7' => 20]]],
|
||||
],
|
||||
],
|
||||
200 => [
|
||||
'people' => [
|
||||
['id' => 2, 'description' => 'B', 'status' => 'active',
|
||||
'parsedData' => ['userId' => 2, 'capacity' => 30, 'allocations' => ['9' => 15]]],
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$summary = $svc->getForProjects([2, 15, 7, 9]);
|
||||
|
||||
$this->assertCount(2, $summary->people);
|
||||
$this->assertSame(70.0, $summary->totalCapacity);
|
||||
$this->assertSame(35.0, $summary->totalAllocated);
|
||||
}
|
||||
|
||||
public function test_seed_people_from_child_projects_is_idempotent_by_user_id(): void
|
||||
{
|
||||
// Two child projects; user 5 assigned to both, user 7 to one.
|
||||
// First run: 2 people added (5 and 7). Second run: 0 added, 2 skipped
|
||||
// per encounter — the "skip already present" contract is what makes
|
||||
// the seeder safe to re-run on demand from the UI.
|
||||
$existingItems = [];
|
||||
$addCalls = [];
|
||||
$repo = $this->makeRepo(
|
||||
canvasId: 500,
|
||||
itemsByBoxProvider: function (int $canvasId, string $box) use (&$existingItems) {
|
||||
return $existingItems;
|
||||
},
|
||||
onAddItem: function (int $canvasId, array $values) use (&$existingItems, &$addCalls): int {
|
||||
$addCalls[] = $values;
|
||||
$existingItems[] = [
|
||||
'id' => count($existingItems) + 1,
|
||||
'description' => $values['description'],
|
||||
'status' => $values['status'],
|
||||
'parsedData' => $values['data'],
|
||||
];
|
||||
|
||||
return count($existingItems);
|
||||
},
|
||||
);
|
||||
|
||||
$programs = $this->makePrograms(childProjects: [
|
||||
['id' => 7, 'name' => 'Project A'],
|
||||
['id' => 8, 'name' => 'Project B'],
|
||||
]);
|
||||
|
||||
$projects = $this->makeProjects(usersByProject: [
|
||||
7 => [['id' => 5, 'firstname' => 'Sarah', 'lastname' => 'Chen', 'jobTitle' => 'PM']],
|
||||
8 => [
|
||||
['id' => 5, 'firstname' => 'Sarah', 'lastname' => 'Chen', 'jobTitle' => 'PM'],
|
||||
['id' => 7, 'firstname' => 'Aisha', 'lastname' => 'Patel', 'jobTitle' => 'Dev'],
|
||||
],
|
||||
]);
|
||||
|
||||
$svc = new ResourceStructureService($repo, $this->makeDb(), $projects, $programs);
|
||||
|
||||
$first = $svc->seedPeopleFromChildProjects(500);
|
||||
$this->assertSame(2, $first['added'], 'First run adds the two distinct users');
|
||||
$this->assertSame(1, $first['skipped'], 'Sarah appearing on the second project is skipped');
|
||||
$this->assertSame(500, $first['canvasId']);
|
||||
|
||||
$second = $svc->seedPeopleFromChildProjects(500);
|
||||
$this->assertSame(0, $second['added'], 'Second run must add nothing (idempotent by userId)');
|
||||
$this->assertSame(3, $second['skipped'], 'Every source-user encounter is a skip on re-run');
|
||||
|
||||
// Also validates the add-payload shape: userId is captured, status is 'active' (not 'stub').
|
||||
$this->assertCount(2, $addCalls);
|
||||
$this->assertSame('active', $addCalls[0]['status']);
|
||||
$this->assertSame(5, $addCalls[0]['data']['userId']);
|
||||
}
|
||||
|
||||
public function test_get_for_projects_hydrates_a_dependency_with_none_of_the_optional_fields(): void
|
||||
{
|
||||
// Back-compat guarantee for pre-existing dependency canvas items —
|
||||
// owner/dueDate/notes/lastModified were added later. An item authored
|
||||
// before those existed must hydrate cleanly (no undefined-index
|
||||
// warning, all four optional slots null) so Page 3 renders the empty
|
||||
// state instead of crashing or flashing blank cells.
|
||||
$svc = $this->makeService(
|
||||
canvasIds: [100],
|
||||
items: [
|
||||
100 => [
|
||||
'dependency' => [
|
||||
[
|
||||
'id' => 42,
|
||||
'description' => 'Community Health Fair Partner',
|
||||
'status' => 'active',
|
||||
'parsedData' => [
|
||||
'partnerName' => 'City Health Dept',
|
||||
'type' => 'partnership',
|
||||
'confirmed' => true,
|
||||
// owner, dueDate, notes intentionally absent.
|
||||
],
|
||||
// 'modified' intentionally absent too — pre-hydrated rows
|
||||
// that predate lastModified capture.
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$summary = $svc->getForProjects([7]);
|
||||
|
||||
$this->assertCount(1, $summary->dependencies);
|
||||
$dep = $summary->dependencies[0];
|
||||
$this->assertSame(42, $dep->itemId);
|
||||
$this->assertSame('City Health Dept', $dep->partnerName);
|
||||
$this->assertSame('partnership', $dep->type);
|
||||
$this->assertTrue($dep->confirmed);
|
||||
$this->assertNull($dep->owner);
|
||||
$this->assertNull($dep->dueDate);
|
||||
$this->assertNull($dep->notes);
|
||||
$this->assertNull($dep->lastModified);
|
||||
}
|
||||
|
||||
public function test_seed_budget_from_child_projects_is_idempotent_by_project_id(): void
|
||||
{
|
||||
// Same skip-when-present contract, keyed on projectId. A child with
|
||||
// dollarBudget=0 is skipped entirely (no line to seed).
|
||||
$existingItems = [];
|
||||
$addCalls = [];
|
||||
$repo = $this->makeRepo(
|
||||
canvasId: 500,
|
||||
itemsByBoxProvider: function (int $canvasId, string $box) use (&$existingItems) {
|
||||
return $existingItems;
|
||||
},
|
||||
onAddItem: function (int $canvasId, array $values) use (&$existingItems, &$addCalls): int {
|
||||
$addCalls[] = $values;
|
||||
$existingItems[] = [
|
||||
'id' => count($existingItems) + 1,
|
||||
'description' => $values['description'],
|
||||
'status' => $values['status'],
|
||||
'parsedData' => $values['data'],
|
||||
];
|
||||
|
||||
return count($existingItems);
|
||||
},
|
||||
);
|
||||
|
||||
$programs = $this->makePrograms(childProjects: [
|
||||
['id' => 7, 'name' => 'Health Fairs', 'dollarBudget' => 45000, 'color' => '#3E937A'],
|
||||
['id' => 8, 'name' => 'Zero-budget project', 'dollarBudget' => 0, 'color' => '#000'],
|
||||
['id' => 9, 'name' => 'Walk-in Days', 'dollarBudget' => 12000, 'color' => '#C09035'],
|
||||
]);
|
||||
|
||||
$svc = new ResourceStructureService($repo, $this->makeDb(), $this->makeProjects(), $programs);
|
||||
|
||||
$first = $svc->seedBudgetFromChildProjects(500);
|
||||
$this->assertSame(2, $first['added'], 'The zero-budget project is skipped (no line to seed)');
|
||||
$this->assertSame(0, $first['skipped']);
|
||||
|
||||
$second = $svc->seedBudgetFromChildProjects(500);
|
||||
$this->assertSame(0, $second['added'], 'Re-run must add nothing');
|
||||
$this->assertSame(2, $second['skipped'], 'Both real-budget projects skip on re-run');
|
||||
}
|
||||
|
||||
// ─── Test doubles ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Testable subclass of the SUT that overrides `findResourceCanvasIds()`
|
||||
* with a canned list, so the test doesn't need to mock a Db query chain.
|
||||
* `getItemsByBox()` returns items keyed by `[canvasId][box]`.
|
||||
*
|
||||
* @param int[] $canvasIds
|
||||
* @param array<int, array<string, array<int, array<string, mixed>>>> $items
|
||||
*/
|
||||
private function makeService(array $canvasIds, array $items): ResourceStructureService
|
||||
{
|
||||
$repo = $this->makeRepo(
|
||||
canvasId: 0,
|
||||
itemsByBoxProvider: fn (int $canvasId, string $box) => $items[$canvasId][$box] ?? [],
|
||||
);
|
||||
|
||||
return new class($repo, $this->makeDb(), $this->makeProjects(), $this->makePrograms(), $canvasIds) extends ResourceStructureService
|
||||
{
|
||||
/** @param int[] $canvasIds */
|
||||
public function __construct(
|
||||
ResourceStructureRepository $repo,
|
||||
Db $dbCore,
|
||||
ProjectService $projectService,
|
||||
Programs $programService,
|
||||
private array $canvasIds,
|
||||
) {
|
||||
parent::__construct($repo, $dbCore, $projectService, $programService);
|
||||
}
|
||||
|
||||
protected function findResourceCanvasIds(array $projectIds): array
|
||||
{
|
||||
return $this->canvasIds;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private function makeRepo(
|
||||
int $canvasId = 0,
|
||||
?\Closure $itemsByBoxProvider = null,
|
||||
?\Closure $onAddItem = null,
|
||||
): ResourceStructureRepository {
|
||||
$repo = $this->createMock(ResourceStructureRepository::class);
|
||||
$repo->method('getOrCreateResourceCanvas')->willReturn($canvasId);
|
||||
$repo->method('getItemsByBox')->willReturnCallback(
|
||||
$itemsByBoxProvider ?? fn (int $canvasId, string $box) => []
|
||||
);
|
||||
if ($onAddItem !== null) {
|
||||
$repo->method('addItem')->willReturnCallback($onAddItem);
|
||||
}
|
||||
|
||||
return $repo;
|
||||
}
|
||||
|
||||
private function makeDb(): Db
|
||||
{
|
||||
// Not used by the SUT paths under test — `findResourceCanvasIds` is
|
||||
// overridden in makeService(), and seeders don't touch dbCore.
|
||||
return $this->createMock(Db::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<int, array<string, mixed>>> $usersByProject projectId => user rows
|
||||
*/
|
||||
private function makeProjects(array $usersByProject = []): ProjectService
|
||||
{
|
||||
$projects = $this->createMock(ProjectService::class);
|
||||
$projects->method('getUsersAssignedToProject')->willReturnCallback(
|
||||
fn (int $projectId) => $usersByProject[$projectId] ?? []
|
||||
);
|
||||
|
||||
return $projects;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $childProjects
|
||||
*/
|
||||
private function makePrograms(array $childProjects = []): Programs
|
||||
{
|
||||
$programs = $this->createMock(Programs::class);
|
||||
$programs->method('getColoredProgramProjects')->willReturn($childProjects);
|
||||
|
||||
return $programs;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user