OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)

This commit is contained in:
wangruiguo
2026-09-03 18:49:20 +08:00
commit d647428529
3501 changed files with 1988906 additions and 0 deletions

View File

@@ -0,0 +1,395 @@
<?php
namespace Unit\app\Core\Events;
use Leantime\Core\Events\Concerns\InteractsWithEvents;
use Leantime\Core\Events\Concerns\InteractsWithFilters;
use Leantime\Core\Events\Contracts\LeantimeEvent;
use Leantime\Core\Events\Contracts\LeantimeFilter;
use Leantime\Core\Events\EventDispatcher;
use Unit\TestCase;
/**
* Fixture event mirroring a migrated domain event: typed payload plus the
* `legacyHook: __FUNCTION__` discriminator pattern — each dispatch rebuilds the single
* historical name of its emit site (never a static list of all sites).
*/
class FixtureThingUpdated implements LeantimeEvent
{
use InteractsWithEvents;
public function __construct(
public readonly int $thingId,
private readonly ?string $legacyHook = null,
) {}
public function legacyHooks(): array
{
if ($this->legacyHook === null) {
return [];
}
return ['leantime.domain.things.services.things.'.$this->legacyHook.'.thing_updated'];
}
}
/**
* Fixture event without legacy hooks (an event introduced after the class-based system).
*/
class FixtureThingCreated implements LeantimeEvent
{
use InteractsWithEvents;
public function __construct(public readonly int $thingId) {}
}
/**
* Fixture class-based listener (resolved through the container, handle() receives the
* typed event object).
*/
class FixtureThingListener
{
public static array $received = [];
public function handle(FixtureThingUpdated $event): void
{
self::$received[] = $event;
}
}
/**
* Fixture invokable listener (no handle() method) to cover the __invoke fallback for
* array-form registrations like [FixtureInvokableListener::class].
*/
class FixtureInvokableListener
{
public static ?object $received = null;
public function __invoke(FixtureThingCreated $event): void
{
self::$received = $event;
}
}
/**
* Fixture filter mirroring a migrated domain filter: payload plus typed context.
*/
class FixtureThingsFilter implements LeantimeFilter
{
use InteractsWithFilters;
public function __construct(public array $things, public readonly int $userId) {}
public function payload(): mixed
{
return $this->things;
}
public function legacyHooks(): array
{
return [
'leantime.domain.things.services.things.getThings.filterThings',
];
}
}
class ClassEventDispatchTest extends TestCase
{
private array $staticSnapshot = [];
private const STATIC_PROPS = [
'eventRegistry',
'filterRegistry',
'available_hooks',
'patternMatchCache',
'compiledPatternCache',
'eventRegistryVersion',
'filterRegistryVersion',
];
protected function setUp(): void
{
parent::setUp();
$reflection = new \ReflectionClass(EventDispatcher::class);
foreach (self::STATIC_PROPS as $prop) {
$property = $reflection->getProperty($prop);
$this->staticSnapshot[$prop] = $property->getValue();
}
FixtureThingListener::$received = [];
}
protected function tearDown(): void
{
$reflection = new \ReflectionClass(EventDispatcher::class);
foreach ($this->staticSnapshot as $prop => $value) {
$property = $reflection->getProperty($prop);
$property->setValue(null, $value);
}
parent::tearDown();
}
/**
* A closure listener registered on the FQCN receives the bare typed event object.
*/
public function test_fqcn_closure_listener_receives_typed_event_object(): void
{
$received = null;
EventDispatcher::add_event_listener(FixtureThingUpdated::class, function ($event) use (&$received) {
$received = $event;
});
FixtureThingUpdated::dispatch(thingId: 42);
$this->assertInstanceOf(FixtureThingUpdated::class, $received);
$this->assertSame(42, $received->thingId);
}
/**
* A class-string listener registered on the FQCN is container-resolved and its
* handle() method receives the typed event object. This is the cacheable
* registration style new code should use (no closures).
*/
public function test_fqcn_class_listener_handle_receives_typed_event_object(): void
{
EventDispatcher::add_event_listener(FixtureThingUpdated::class, FixtureThingListener::class);
FixtureThingUpdated::dispatch(thingId: 7);
$this->assertCount(1, FixtureThingListener::$received);
$this->assertSame(7, FixtureThingListener::$received[0]->thingId);
}
/**
* An invokable listener registered in array form ([Class::class], no handle()
* method) falls back to __invoke() — same as the string registration form.
*/
public function test_array_form_invokable_listener_falls_back_to_invoke(): void
{
FixtureInvokableListener::$received = null;
EventDispatcher::add_event_listener(FixtureThingCreated::class, [FixtureInvokableListener::class]);
FixtureThingCreated::dispatch(thingId: 11);
$this->assertInstanceOf(FixtureThingCreated::class, FixtureInvokableListener::$received);
$this->assertSame(11, FixtureInvokableListener::$received->thingId);
}
/**
* BACKWARDS COMPATIBILITY: a listener registered on the exact historical string name
* fires and receives today's array payload (event properties + current_route +
* currentEvent) — NOT the event object. Existing plugins keep working unchanged.
*/
public function test_legacy_string_listener_receives_legacy_array_payload(): void
{
$received = null;
EventDispatcher::add_event_listener(
'leantime.domain.things.services.things.updateThing.thing_updated',
function ($params) use (&$received) {
$received = $params;
}
);
FixtureThingUpdated::dispatch(thingId: 42, legacyHook: 'updateThing');
$this->assertIsArray($received);
$this->assertSame(42, $received['thingId']);
$this->assertSame(
'leantime.domain.things.services.things.updateThing.thing_updated',
$received['currentEvent']
);
$this->assertArrayHasKey('current_route', $received);
}
/**
* BACKWARDS COMPATIBILITY: plugin wildcard subscriptions (leantime.domain.*.services.*)
* match the legacy name of a class-based event — exactly ONCE per dispatch, because
* each emit site contributes only its own historical name via the legacyHook
* discriminator. Both historical names stay reachable from their respective sites.
*/
public function test_wildcard_listener_fires_once_per_dispatch_for_legacy_hook(): void
{
$called = 0;
EventDispatcher::add_event_listener('leantime.domain.*.services.*', function () use (&$called) {
$called++;
});
FixtureThingUpdated::dispatch(thingId: 1, legacyHook: 'updateThing');
$this->assertSame(1, $called);
FixtureThingUpdated::dispatch(thingId: 1, legacyHook: 'patchThing');
$this->assertSame(2, $called);
}
/**
* BACKWARDS COMPATIBILITY: an exact subscriber to one historical site's name does
* NOT fire when a different site emits the same logical event — per-site semantics
* are preserved through the migration window.
*/
public function test_exact_legacy_listener_keeps_per_site_semantics(): void
{
$called = 0;
EventDispatcher::add_event_listener(
'leantime.domain.things.services.things.patchThing.thing_updated',
function () use (&$called) {
$called++;
}
);
FixtureThingUpdated::dispatch(thingId: 1, legacyHook: 'updateThing');
$this->assertSame(0, $called);
FixtureThingUpdated::dispatch(thingId: 1, legacyHook: 'patchThing');
$this->assertSame(1, $called);
}
/**
* Wildcard string listeners do NOT accidentally match the FQCN (backslashes and
* case don't fit the dotted lowercase patterns).
*/
public function test_wildcard_listener_does_not_match_fqcn(): void
{
$called = 0;
EventDispatcher::add_event_listener('leantime.*', function () use (&$called) {
$called++;
});
FixtureThingCreated::dispatch(thingId: 1);
$this->assertSame(0, $called);
}
/**
* An event with no legacy hooks only reaches FQCN listeners.
*/
public function test_event_without_legacy_hooks_fires_fqcn_listener_only(): void
{
$received = null;
EventDispatcher::add_event_listener(FixtureThingCreated::class, function ($event) use (&$received) {
$received = $event;
});
FixtureThingCreated::dispatch(thingId: 9);
$this->assertInstanceOf(FixtureThingCreated::class, $received);
$this->assertContains(FixtureThingCreated::class, EventDispatcher::get_available_hooks()['events']);
}
/**
* FQCN listeners run in priority order, lower number first.
*/
public function test_fqcn_listeners_run_in_priority_order(): void
{
$order = [];
EventDispatcher::add_event_listener(FixtureThingCreated::class, function () use (&$order) {
$order[] = 30;
}, 30);
EventDispatcher::add_event_listener(FixtureThingCreated::class, function () use (&$order) {
$order[] = 10;
}, 10);
FixtureThingCreated::dispatch(thingId: 1);
$this->assertSame([10, 30], $order);
}
/**
* Class filter: FQCN listeners thread the payload and receive the filter object as
* typed context; the final payload is returned.
*/
public function test_class_filter_threads_payload_through_fqcn_listeners(): void
{
$receivedFilter = null;
EventDispatcher::add_filter_listener(FixtureThingsFilter::class, function ($things, $filter) use (&$receivedFilter) {
$receivedFilter = $filter;
$things[] = 'added-by-listener';
return $things;
});
$result = FixtureThingsFilter::dispatch(things: ['original'], userId: 5);
$this->assertSame(['original', 'added-by-listener'], $result);
$this->assertInstanceOf(FixtureThingsFilter::class, $receivedFilter);
$this->assertSame(5, $receivedFilter->userId);
}
/**
* BACKWARDS COMPATIBILITY: a filter listener on the historical string name receives
* today's ($payload, $availableParams) signature — params include the filter's
* public properties plus current_route/currentEvent — and its return value threads
* into the final result, after FQCN listeners.
*/
public function test_class_filter_threads_payload_through_legacy_listeners(): void
{
$receivedParams = null;
EventDispatcher::add_filter_listener(FixtureThingsFilter::class, function ($things, $filter) {
$things[] = 'fqcn';
return $things;
});
EventDispatcher::add_filter_listener(
'leantime.domain.things.services.things.getThings.filterThings',
function ($things, $params) use (&$receivedParams) {
$receivedParams = $params;
$things[] = 'legacy';
return $things;
}
);
$result = FixtureThingsFilter::dispatch(things: ['original'], userId: 5);
// FQCN group runs first, then the legacy group threads its output.
$this->assertSame(['original', 'fqcn', 'legacy'], $result);
$this->assertSame(5, $receivedParams['userId']);
$this->assertArrayHasKey('current_route', $receivedParams);
}
/**
* A filter with no listeners at all returns the payload unchanged.
*/
public function test_class_filter_without_listeners_returns_payload_unchanged(): void
{
$result = FixtureThingsFilter::dispatch(things: ['untouched'], userId: 1);
$this->assertSame(['untouched'], $result);
}
/**
* The instance apply() ergonomic returns the filtered payload too.
*/
public function test_class_filter_apply_instance_method(): void
{
EventDispatcher::add_filter_listener(FixtureThingsFilter::class, function ($things) {
$things[] = 'applied';
return $things;
});
$filter = new FixtureThingsFilter(things: ['a'], userId: 2);
$this->assertSame(['a', 'applied'], $filter->apply());
}
/**
* Class events route correctly through Laravel's event() helper / the instance
* dispatch() of the Dispatcher interface as well.
*/
public function test_class_event_routes_through_laravel_event_helper(): void
{
$received = null;
EventDispatcher::add_event_listener(FixtureThingCreated::class, function ($event) use (&$received) {
$received = $event;
});
event(new FixtureThingCreated(thingId: 3));
$this->assertInstanceOf(FixtureThingCreated::class, $received);
$this->assertSame(3, $received->thingId);
}
}

View File

@@ -0,0 +1,218 @@
<?php
namespace Unit\app\Core\Events;
use Leantime\Core\Events\DispatchesEvents;
use Leantime\Core\Events\EventDispatcher;
use Leantime\Core\WorkStructure\Events\StructureRegistered;
use Unit\TestCase;
/**
* Fixture emitter that dispatches through the DispatchesEvents trait exactly like a
* domain service does, so the auto-generated event names (lowercased FQCN + method +
* raw hook) match the real runtime format.
*/
class CharacterizationEmitter
{
use DispatchesEvents;
public function updateThing(): void
{
self::dispatchEvent('thing_updated', ['thingId' => 7]);
}
public function filterThing(int $payload): mixed
{
return self::dispatchFilter('thing_filter', $payload, ['mode' => 'strict']);
}
}
/**
* Characterization tests locking the CURRENT EventDispatcher behavior before the
* class-based event bridge is added. These tests document the string-event contract
* that existing plugins rely on; they must keep passing unchanged.
*/
class EventDispatcherCharacterizationTest extends TestCase
{
private array $staticSnapshot = [];
private const STATIC_PROPS = [
'eventRegistry',
'filterRegistry',
'available_hooks',
'patternMatchCache',
'compiledPatternCache',
'eventRegistryVersion',
'filterRegistryVersion',
];
protected function setUp(): void
{
parent::setUp();
$reflection = new \ReflectionClass(EventDispatcher::class);
foreach (self::STATIC_PROPS as $prop) {
$property = $reflection->getProperty($prop);
$this->staticSnapshot[$prop] = $property->getValue();
}
}
protected function tearDown(): void
{
$reflection = new \ReflectionClass(EventDispatcher::class);
foreach ($this->staticSnapshot as $prop => $value) {
$property = $reflection->getProperty($prop);
$property->setValue(null, $value);
}
parent::tearDown();
}
/**
* The DispatchesEvents trait builds the full event name as
* strtolower(FQCN with \ -> .) + '.' + emitting method + '.' + raw hook.
* Plugins subscribe to exactly these strings — the format must not drift.
*/
public function test_trait_builds_full_event_name_from_class_and_method(): void
{
(new CharacterizationEmitter)->updateThing();
$this->assertContains(
'unit.app.core.events.characterizationemitter.updateThing.thing_updated',
EventDispatcher::get_available_hooks()['events']
);
}
/**
* A listener registered on the full string name receives a SINGLE array argument:
* the dispatched payload merged with current_route and currentEvent.
*/
public function test_string_event_listener_receives_define_params_array(): void
{
$received = null;
EventDispatcher::add_event_listener(
'unit.app.core.events.characterizationemitter.updateThing.thing_updated',
function ($params) use (&$received) {
$received = $params;
}
);
(new CharacterizationEmitter)->updateThing();
$this->assertIsArray($received);
$this->assertSame(7, $received['thingId']);
$this->assertSame(
'unit.app.core.events.characterizationemitter.updateThing.thing_updated',
$received['currentEvent']
);
$this->assertArrayHasKey('current_route', $received);
}
/**
* Filter listeners receive ($payload, $availableParams) where availableParams is the
* emitter-provided context merged with current_route/currentEvent, and the payload is
* threaded through listeners in priority order (lower priority number runs first).
*/
public function test_filter_threads_payload_in_priority_order_and_passes_params(): void
{
$fullName = 'unit.app.core.events.characterizationemitter.filterThing.thing_filter';
$receivedParams = null;
EventDispatcher::add_filter_listener($fullName, function ($payload, $params) use (&$receivedParams) {
$receivedParams = $params;
return $payload + 1;
}, 20);
EventDispatcher::add_filter_listener($fullName, function ($payload, $params) {
return $payload * 2;
}, 10);
$result = (new CharacterizationEmitter)->filterThing(5);
// priority 10 runs first: 5 * 2 = 10, then priority 20: 10 + 1 = 11
$this->assertSame(11, $result);
$this->assertSame('strict', $receivedParams['mode']);
$this->assertSame($fullName, $receivedParams['currentEvent']);
$this->assertArrayHasKey('current_route', $receivedParams);
}
/**
* Plugins rely on wildcard subscriptions (e.g. leantime.domain.*.services.*) matching
* the auto-generated full names. The * wildcard must keep matching.
*/
public function test_wildcard_listener_matches_full_event_name(): void
{
$called = 0;
EventDispatcher::add_event_listener('leantime.domain.*.services.*', function () use (&$called) {
$called++;
});
EventDispatcher::dispatch_event('leantime.domain.faux.services.faux.doIt.did_it', ['x' => 1], '');
$this->assertSame(1, $called);
}
/**
* Event listeners for one hook run in priority order, lower number first.
*/
public function test_event_listeners_run_in_priority_order(): void
{
$order = [];
EventDispatcher::add_event_listener('char.priority.event', function () use (&$order) {
$order[] = 30;
}, 30);
EventDispatcher::add_event_listener('char.priority.event', function () use (&$order) {
$order[] = 10;
}, 10);
EventDispatcher::add_event_listener('char.priority.event', function () use (&$order) {
$order[] = 20;
}, 20);
EventDispatcher::dispatch_event('char.priority.event', [], '');
$this->assertSame([10, 20, 30], $order);
}
/**
* Current behavior for plain object events (Laravel Dispatchable path, e.g. the
* WorkStructure events): the object resolves to its FQCN as the listener name and a
* 'leantime' source listener receives the defineParams array with the object at [0].
*/
public function test_plain_object_event_fires_fqcn_string_listener(): void
{
$received = null;
EventDispatcher::add_event_listener(StructureRegistered::class, function ($params) use (&$received) {
$received = $params;
});
StructureRegistered::dispatch(1, 'My Structure', 'system');
$this->assertIsArray($received);
$this->assertInstanceOf(StructureRegistered::class, $received[0]);
$this->assertSame(1, $received[0]->structureId);
}
/**
* The pattern-match cache is invalidated when a listener is added (version counter),
* so listeners registered after a first dispatch still fire on later dispatches.
*/
public function test_pattern_cache_busts_when_listener_added_after_dispatch(): void
{
$first = 0;
$second = 0;
EventDispatcher::add_event_listener('char.cache.*', function () use (&$first) {
$first++;
});
EventDispatcher::dispatch_event('char.cache.bust', [], '');
EventDispatcher::add_event_listener('char.cache.*', function () use (&$second) {
$second++;
});
EventDispatcher::dispatch_event('char.cache.bust', [], '');
$this->assertSame(2, $first);
$this->assertSame(1, $second);
}
}

View File

@@ -0,0 +1,104 @@
<?php
namespace Unit\app\Core\Events;
use Codeception\Test\Unit;
use Leantime\Core\Events\Contracts\LeantimeEvent;
use Leantime\Core\Events\Contracts\LeantimeFilter;
use Leantime\Core\Events\EventVerb;
/**
* Enforces the shared event vocabulary across all domains:
*
* - event classes are named {Entity}{Verb} with the verb from the central EventVerb
* enum (TicketCreated, MilestoneDeleted — never TicketChanged/TicketEdited)
* - filter classes are named {Thing}Filter (TodoWidgetTasksFilter)
*
* Scans every class in app/Domain/* /Events and app/Core/* /Events that implements
* LeantimeEvent or LeantimeFilter. Failing this test means a synonym crept in — use an
* existing verb or (rarely) add one to EventVerb.
*/
class EventVocabularyTest extends Unit
{
public function test_event_class_names_end_with_central_vocabulary_verb(): void
{
$discovered = $this->discoverEventClasses();
// Guard against a vacuous pass: if discovery silently finds nothing (e.g. a
// broken base path), the loop below would assert nothing. The pilot ships ten
// contract classes in Tickets, so discovery must find them.
$this->assertContains(
\Leantime\Domain\Tickets\Events\TicketUpdated::class,
$discovered,
'Event class discovery found nothing — the vocabulary check would pass vacuously.'
);
$violations = [];
foreach ($discovered as $class) {
$implements = class_implements($class);
$shortName = substr($class, strrpos($class, '\\') + 1);
if (in_array(LeantimeFilter::class, $implements, true)) {
if (! str_ends_with($shortName, 'Filter')) {
$violations[] = "$class — filter classes must be named {Thing}Filter";
}
continue;
}
if (in_array(LeantimeEvent::class, $implements, true)) {
$endsWithVerb = false;
foreach (EventVerb::cases() as $verb) {
if (str_ends_with($shortName, $verb->name)) {
$endsWithVerb = true;
break;
}
}
if (! $endsWithVerb) {
$violations[] = "$class — event classes must be named {Entity}{Verb} with a verb from EventVerb";
}
}
}
$this->assertSame([], $violations, "Event vocabulary violations:\n".implode("\n", $violations));
}
/**
* Finds all classes in Domain and Core Events/ folders that implement one of the
* class-based hook contracts.
*
* @return array<int, class-string>
*/
private function discoverEventClasses(): array
{
// Anchor on the canonical app-root constant rather than a brittle relative
// dirname() hop, so the scan can't silently miss the Events folders.
$appRoot = defined('APP_ROOT') ? APP_ROOT : dirname(__DIR__, 5);
$files = array_merge(
glob($appRoot.'/app/Domain/*/Events/*.php') ?: [],
glob($appRoot.'/app/Core/*/Events/*.php') ?: [],
);
$classes = [];
foreach ($files as $file) {
$relative = str_replace([$appRoot.'/app/', '/', '.php'], ['', '\\', ''], $file);
$class = 'Leantime\\'.$relative;
if (! class_exists($class)) {
continue;
}
$implements = class_implements($class) ?: [];
if (in_array(LeantimeEvent::class, $implements, true)
|| in_array(LeantimeFilter::class, $implements, true)) {
$classes[] = $class;
}
}
return $classes;
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace Unit\app\Core\Events;
use Codeception\Test\Unit;
use Leantime\Core\Events\EventDispatcher;
class EventsTest extends Unit
{
/**
* This test will check the dispatch_event method of the EventDispatcher class.
* It will dispatch an event and assert if it is added to the available_hooks array.
*/
public function test_dispatch_event()
{
$eventName = 'test.event.name';
$payload = ['testKey' => 'testValue'];
$context = 'testContext';
// Dispatch event
EventDispatcher::dispatch_event($eventName, $payload, $context);
// Get all available hooks
$available_hooks = EventDispatcher::get_available_hooks();
// Test that the dispatched event has been registered in available_hooks
$this->assertContains("$context.$eventName", $available_hooks['events']);
}
/**
* This test will check the findEventListeners method of the EventDispatcher class.
*/
public function test_find_event_listeners()
{
$eventName = 'test.event.name';
$listenerName = 'test.listener';
$payload = ['testKey' => 'testValue'];
$context = 'testContext';
$eventListeners = [$listenerName => [$payload]];
EventDispatcher::add_event_listener($listenerName, function () {}, 10);
// Test that the event listener has been found
$this->assertEquals([$payload], EventDispatcher::findEventListeners($listenerName, $eventListeners));
}
/**
* This test will check the get_registries method of the EventDispatcher class.
* It will add new event listener and a new filter listener and check both listeners
* are in the registry arrays.
*/
public function test_get_registries()
{
$eventName = 'event.test.name';
$filterName = 'filter.test.name';
// Add an event listener
EventDispatcher::add_event_listener($eventName, function () {}, 10);
// Add a filter listener
EventDispatcher::add_filter_listener($filterName, function () {}, 10);
// Get registries
$registries = EventDispatcher::get_registries();
// Check registries
$this->assertContains($eventName, $registries['events']);
$this->assertContains($filterName, $registries['filters']);
}
}