ThinkAdmin/plugin/think-library/tests/ComposerDependencyBoundaryTest.php
Anyon e634118a22 refactor(plugin): 迁移 v8 插件化组件体系
将 v6 中直接放在本地 app 的后台与微信能力迁移为 v8 插件组件,并把运行时基础能力沉淀到独立插件包。

主要内容:

- 新增 think-library、system、worker、static、install 等基础插件包。

- 新增 account、payment、wechat-client、wechat-service、wemall、wuma 等业务插件包。

- 移除 v6 的 app/admin 与 app/wechat 本地应用实现,改由插件分发接管。

- 将 Helper 能力彻底并入 System,统一为 plugin\system\helper\* 命名空间。

- 同步插件迁移发布清单与根 route 占位,保证安装发布流程可复现。
2026-05-08 15:30:46 +08:00

282 lines
9.9 KiB
PHP

<?php
declare(strict_types=1);
/**
* +----------------------------------------------------------------------
* | ThinkAdmin Plugin for ThinkAdminDeveloper
* +----------------------------------------------------------------------
* | Copyright (c) 2014~2026 ThinkAdmin [ thinkadmin.top ]
* +----------------------------------------------------------------------
* | Official Website: https://thinkadmin.top
* +----------------------------------------------------------------------
* | Licensed: https://mit-license.org
* | Disclaimer: https://thinkadmin.top/disclaimer
* | Vip Rights: https://thinkadmin.top/vip-introduce
* +----------------------------------------------------------------------
* | Gitee Repository: https://gitee.com/zoujingli/ThinkAdmin
* | Github Repository: https://github.com/zoujingli/ThinkAdmin
* +----------------------------------------------------------------------
*/
namespace think\admin\tests;
use PHPUnit\Framework\TestCase;
/**
* @internal
* @coversNothing
*/
class ComposerDependencyBoundaryTest extends TestCase
{
private string $projectRoot;
/**
* @var array<string, array{name:string, path:string, require:array<string, string>}>
*/
private array $packages = [];
protected function setUp(): void
{
parent::setUp();
$this->projectRoot = TEST_PROJECT_ROOT;
$this->packages = $this->loadPackages();
}
public function testLocalPluginDependencyGraphIsAcyclic(): void
{
$graph = $this->localGraph();
$state = [];
$stack = [];
$cycle = [];
$visit = function (string $package) use (&$visit, &$graph, &$state, &$stack, &$cycle): void {
if ($cycle !== []) {
return;
}
$state[$package] = 1;
$stack[] = $package;
foreach ($graph[$package] ?? [] as $dependency) {
$depState = $state[$dependency] ?? 0;
if ($depState === 0) {
$visit($dependency);
if ($cycle !== []) {
return;
}
continue;
}
if ($depState === 1) {
$offset = array_search($dependency, $stack, true);
$cycle = array_slice($stack, $offset === false ? 0 : $offset);
$cycle[] = $dependency;
return;
}
}
array_pop($stack);
$state[$package] = 2;
};
foreach (array_keys($graph) as $package) {
if (($state[$package] ?? 0) === 0) {
$visit($package);
}
}
$this->assertSame([], $cycle, 'Local composer dependency cycle detected: ' . implode(' -> ', $cycle));
}
public function testBasePackagesStayAtTheBottomOfDependencyGraph(): void
{
$this->assertSame([], $this->localRequires('zoujingli/think-library'));
$this->assertSame([
'zoujingli/think-library',
'zoujingli/think-plugs-static',
'zoujingli/think-plugs-worker',
], $this->localRequires('zoujingli/think-plugs-system'));
$this->assertSame(['zoujingli/think-library'], $this->localRequires('zoujingli/think-plugs-worker'));
}
public function testSystemDoesNotReintroduceLegacyStoragePackage(): void
{
$system = $this->localRequires('zoujingli/think-plugs-system');
$this->assertNotContains('zoujingli/think-plugs-storage', $system);
}
public function testHelperPackageStaysMergedIntoSystem(): void
{
$this->assertDirectoryDoesNotExist($this->path('plugin/think-plugs-helper'));
$this->assertFileExists($this->path('plugin/think-plugs-system/src/helper/Service.php'));
$this->assertFileExists($this->path('plugin/think-plugs-system/src/helper/command/project/PublishCommand.php'));
$legacyHelperNamespace = 'plugin' . '\\helper\\';
$systemComposer = $this->jsonFile($this->path('plugin/think-plugs-system/composer.json'));
$systemPsr4 = is_array($systemComposer['autoload']['psr-4'] ?? null) ? $systemComposer['autoload']['psr-4'] : [];
$systemServices = is_array($systemComposer['extra']['think']['services'] ?? null) ? $systemComposer['extra']['think']['services'] : [];
$this->assertArrayNotHasKey($legacyHelperNamespace, $systemPsr4);
$this->assertContains('plugin\system\helper\Service', $systemServices);
$this->assertNotContains($legacyHelperNamespace . 'Service', $systemServices);
$violations = [];
foreach ($this->composerFiles() as $file) {
$json = $this->jsonFile($file);
if (str_ends_with($file, '/composer.lock')) {
$packages = array_merge(
is_array($json['packages'] ?? null) ? $json['packages'] : [],
is_array($json['packages-dev'] ?? null) ? $json['packages-dev'] : []
);
foreach ($packages as $package) {
if (!is_array($package)) {
continue;
}
if (($package['name'] ?? null) === 'zoujingli/think-plugs-helper') {
$violations[] = [$file, 'package'];
}
foreach (['require', 'require-dev'] as $section) {
if (isset($package[$section]['zoujingli/think-plugs-helper'])) {
$violations[] = [$file, $section, $package['name'] ?? 'unknown'];
}
}
}
continue;
}
foreach (['require', 'require-dev'] as $section) {
if (isset($json[$section]['zoujingli/think-plugs-helper'])) {
$violations[] = [$file, $section];
}
}
}
$this->assertSame([], $violations, 'Legacy helper package dependencies found: ' . json_encode($violations, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
}
public function testLegacyViewPackagesStayRemoved(): void
{
$legacyPackages = [
'zoujingli/think-plugs-view' => 'plugin/think-plugs-view',
'zoujingli/think-plugs-system-view' => 'plugin/think-plugs-system-view',
'zoujingli/think-plugs-wechat-client-view' => 'plugin/think-plugs-wechat-client-view',
'zoujingli/think-plugs-wechat-service-view' => 'plugin/think-plugs-wechat-service-view',
];
foreach ($legacyPackages as $package => $directory) {
$this->assertDirectoryDoesNotExist($this->path($directory), $package . ' directory should stay removed');
}
$violations = [];
foreach ($this->composerFiles() as $file) {
$content = file_get_contents($file) ?: '';
foreach (array_keys($legacyPackages) as $package) {
if (strpos($content, $package) !== false) {
$violations[] = [$package, $file];
}
}
}
$this->assertSame([], $violations, 'Legacy view package references found: ' . json_encode($violations, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
}
public function testEveryLocalDependencyPointsToAnExistingWorkspacePackage(): void
{
$known = array_keys($this->packages);
$missing = [];
foreach ($this->packages as $name => $package) {
foreach ($this->localRequires($name) as $dependency) {
if (!in_array($dependency, $known, true)) {
$missing[] = [$name, $dependency];
}
}
}
$this->assertSame([], $missing, 'Unknown local package dependencies found: ' . json_encode($missing, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
}
/**
* @return array<string, array{name:string, path:string, require:array<string, string>}>
*/
private function loadPackages(): array
{
$items = [];
foreach (glob($this->path('plugin/*/composer.json')) ?: [] as $file) {
$json = json_decode(file_get_contents($file) ?: '', true);
if (!is_array($json) || empty($json['name'])) {
continue;
}
$items[strval($json['name'])] = [
'name' => strval($json['name']),
'path' => $file,
'require' => is_array($json['require'] ?? null) ? $json['require'] : [],
];
}
ksort($items);
return $items;
}
/**
* @return array<string, list<string>>
*/
private function localGraph(): array
{
$graph = [];
foreach (array_keys($this->packages) as $name) {
$graph[$name] = $this->localRequires($name);
}
return $graph;
}
/**
* @return list<string>
*/
private function localRequires(string $package): array
{
$requires = array_keys($this->packages[$package]['require'] ?? []);
$locals = array_values(array_filter($requires, fn (string $name): bool => isset($this->packages[$name])));
sort($locals);
return $locals;
}
/**
* @return list<string>
*/
private function composerFiles(): array
{
$files = [];
foreach (['composer.json', 'composer.lock'] as $name) {
$file = $this->path($name);
if (is_file($file)) {
$files[] = str_replace('\\', '/', $file);
}
}
foreach (glob($this->path('plugin/*/composer.json')) ?: [] as $file) {
$files[] = str_replace('\\', '/', $file);
}
sort($files);
return $files;
}
/**
* @return array<string, mixed>
*/
private function jsonFile(string $file): array
{
$json = json_decode(file_get_contents($file) ?: '', true);
return is_array($json) ? $json : [];
}
private function path(string $relative): string
{
return $this->projectRoot . '/' . ltrim($relative, '/');
}
}