mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
test(sync-core): add port contract adapter coverage
This commit is contained in:
parent
1c337866d6
commit
bc437c7881
6 changed files with 229 additions and 2 deletions
|
|
@ -4,8 +4,9 @@
|
|||
> vector-clock ownership, full-state op classification config, and the PR 3b
|
||||
> pure helper slices are present on this branch. PR 4a port contracts have
|
||||
> started with the app-side replay/operation-store services explicitly satisfying
|
||||
> sync-core interfaces. Remaining cleanup is PR text alignment plus targeted
|
||||
> future `SyncLogger` routing for files as they move.**
|
||||
> sync-core interfaces and adapter contract specs covering the initial ports.
|
||||
> Remaining cleanup is PR text alignment plus targeted future `SyncLogger`
|
||||
> routing for files as they move.**
|
||||
|
||||
**Goal:** Carve the sync engine out of `src/app/op-log/` into a reusable,
|
||||
framework-agnostic, **domain-agnostic** `@sp/sync-core` package, plus a sibling
|
||||
|
|
@ -668,6 +669,20 @@ contracts, and these app services now explicitly satisfy them:
|
|||
This is contract-only: NgRx dispatch, hydration windows, archive IndexedDB
|
||||
handling, and deferred local action processing remain app-side.
|
||||
|
||||
App-side adapter specs now exercise the first port set through the sync-core
|
||||
types:
|
||||
|
||||
- `OperationApplyPort` and `ActionDispatchPort` coverage in
|
||||
`operation-applier.service.spec.ts`, including action/meta identity, bulk
|
||||
operation reference preservation, dispatch-yield-before-archive ordering,
|
||||
remote cooldown/end-window/deferred flush ordering, and local hydration
|
||||
close-window/deferred flush behavior.
|
||||
- `RemoteApplyWindowPort` coverage in `hydration-state.service.spec.ts`.
|
||||
- `ArchiveSideEffectPort` coverage in
|
||||
`archive-operation-handler.service.spec.ts`.
|
||||
- `DeferredLocalActionsPort` coverage in `operation-log.effects.spec.ts`.
|
||||
- `OperationStorePort` coverage in `operation-log-store.service.spec.ts`.
|
||||
|
||||
### Ports
|
||||
|
||||
- `OperationStorePort` - abstract over op-log persistence. Method names use
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { TestBed } from '@angular/core/testing';
|
||||
import type { ArchiveSideEffectPort } from '@sp/sync-core';
|
||||
import {
|
||||
ArchiveOperationHandler,
|
||||
isArchiveAffectingAction,
|
||||
|
|
@ -195,6 +196,29 @@ describe('ArchiveOperationHandler', () => {
|
|||
});
|
||||
|
||||
describe('handleOperation', () => {
|
||||
it('should expose archive side effects through ArchiveSideEffectPort without mutating action meta', async () => {
|
||||
const port: ArchiveSideEffectPort<PersistentAction> = service;
|
||||
const tasks = [createMockTaskWithSubTasks('task-1')];
|
||||
const meta: PersistentAction['meta'] = {
|
||||
isPersistent: true,
|
||||
isRemote: true,
|
||||
entityType: 'TASK',
|
||||
opType: OpType.Update,
|
||||
};
|
||||
const action = {
|
||||
type: TaskSharedActions.moveToArchive.type,
|
||||
tasks,
|
||||
meta,
|
||||
} as unknown as PersistentAction;
|
||||
|
||||
await port.handleOperation(action);
|
||||
|
||||
expect(action.meta).toBe(meta);
|
||||
expect(
|
||||
mockArchiveService.writeTasksToArchiveForRemoteSync,
|
||||
).toHaveBeenCalledOnceWith(tasks);
|
||||
});
|
||||
|
||||
describe('moveToArchive action', () => {
|
||||
it('should write tasks to archive for remote sync', async () => {
|
||||
const tasks = [
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
|
||||
import type { RemoteApplyWindowPort } from '@sp/sync-core';
|
||||
import { HydrationStateService } from './hydration-state.service';
|
||||
import { getIsApplyingRemoteOps } from '../capture/operation-capture.meta-reducer';
|
||||
|
||||
|
|
@ -23,6 +24,24 @@ describe('HydrationStateService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('RemoteApplyWindowPort contract', () => {
|
||||
it('should expose remote apply window state through the port methods', fakeAsync(() => {
|
||||
const port: RemoteApplyWindowPort = service;
|
||||
|
||||
port.startApplyingRemoteOps();
|
||||
expect(port.isApplyingRemoteOps?.()).toBeTrue();
|
||||
expect(service.isInSyncWindow()).toBeTrue();
|
||||
|
||||
port.startPostSyncCooldown(50);
|
||||
port.endApplyingRemoteOps();
|
||||
expect(port.isApplyingRemoteOps?.()).toBeFalse();
|
||||
expect(service.isInSyncWindow()).toBeTrue();
|
||||
|
||||
tick(100);
|
||||
expect(service.isInSyncWindow()).toBeFalse();
|
||||
}));
|
||||
});
|
||||
|
||||
describe('startApplyingRemoteOps', () => {
|
||||
it('should set isApplyingRemoteOps to true', () => {
|
||||
service.startApplyingRemoteOps();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import { TestBed } from '@angular/core/testing';
|
||||
import { Store } from '@ngrx/store';
|
||||
import type {
|
||||
ActionDispatchPort,
|
||||
OperationApplyPort,
|
||||
SyncActionLike,
|
||||
} from '@sp/sync-core';
|
||||
import { OperationApplierService } from './operation-applier.service';
|
||||
import { Operation, OpType, EntityType, ActionType } from '../core/operation.types';
|
||||
import { ArchiveOperationHandler } from './archive-operation-handler.service';
|
||||
|
|
@ -65,6 +70,36 @@ describe('OperationApplierService', () => {
|
|||
service = TestBed.inject(OperationApplierService);
|
||||
});
|
||||
|
||||
describe('port contracts', () => {
|
||||
it('should expose operation application through OperationApplyPort', async () => {
|
||||
const applyPort: OperationApplyPort<Operation> = service;
|
||||
const op = createMockOperation('op-1', 'TASK', OpType.Update, { title: 'Test' });
|
||||
|
||||
const result = await applyPort.applyOperations([op]);
|
||||
|
||||
expect(result.appliedOps).toEqual([op]);
|
||||
expect(mockStore.dispatch).toHaveBeenCalledOnceWith(
|
||||
bulkApplyOperations({ operations: [op] }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve action object and meta identity through ActionDispatchPort', () => {
|
||||
const dispatchPort: ActionDispatchPort<SyncActionLike> = mockStore;
|
||||
const meta = { source: 'remote-sync', nested: { marker: 'keep-reference' } };
|
||||
const action: SyncActionLike = {
|
||||
type: '[Test] Dispatch Port Action',
|
||||
meta,
|
||||
};
|
||||
|
||||
dispatchPort.dispatch(action);
|
||||
|
||||
const dispatchedAction = mockStore.dispatch.calls.first()
|
||||
.args[0] as unknown as SyncActionLike;
|
||||
expect(dispatchedAction).toBe(action);
|
||||
expect(dispatchedAction.meta).toBe(meta);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyOperations with bulk dispatch', () => {
|
||||
it('should dispatch bulkApplyOperations with single operation', async () => {
|
||||
const op = createMockOperation('op-1', 'TASK', OpType.Update, { title: 'Test' });
|
||||
|
|
@ -109,6 +144,23 @@ describe('OperationApplierService', () => {
|
|||
expect(result.failedOp).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should preserve the operation array and operation object references in the bulk action', async () => {
|
||||
const payload = { title: 'Reference payload' };
|
||||
const op = createMockOperation('op-1', 'TASK', OpType.Update, payload);
|
||||
const ops = [op];
|
||||
|
||||
await service.applyOperations(ops);
|
||||
|
||||
const dispatchedAction = mockStore.dispatch.calls.first().args[0] as unknown as {
|
||||
type: string;
|
||||
operations: Operation[];
|
||||
};
|
||||
expect(dispatchedAction.type).toBe(bulkApplyOperations.type);
|
||||
expect(dispatchedAction.operations).toBe(ops);
|
||||
expect(dispatchedAction.operations[0]).toBe(op);
|
||||
expect(dispatchedAction.operations[0].payload).toBe(payload);
|
||||
});
|
||||
|
||||
it('should handle empty operations array', async () => {
|
||||
const result = await service.applyOperations([]);
|
||||
|
||||
|
|
@ -256,6 +308,38 @@ describe('OperationApplierService', () => {
|
|||
|
||||
expect(mockHydrationState.startPostSyncCooldown).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should close the apply window and flush deferred actions for local hydration', async () => {
|
||||
const callOrder: string[] = [];
|
||||
const op = createMockOperation('op-1');
|
||||
|
||||
mockHydrationState.startApplyingRemoteOps.and.callFake(() => {
|
||||
callOrder.push('startApplyingRemoteOps');
|
||||
});
|
||||
|
||||
mockHydrationState.startPostSyncCooldown.and.callFake(() => {
|
||||
callOrder.push('startPostSyncCooldown');
|
||||
});
|
||||
|
||||
mockHydrationState.endApplyingRemoteOps.and.callFake(() => {
|
||||
callOrder.push('endApplyingRemoteOps');
|
||||
});
|
||||
|
||||
mockOperationLogEffects.processDeferredActions.and.callFake(() => {
|
||||
callOrder.push('processDeferredActions');
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
await service.applyOperations([op], { isLocalHydration: true });
|
||||
|
||||
expect(callOrder).toEqual([
|
||||
'startApplyingRemoteOps',
|
||||
'endApplyingRemoteOps',
|
||||
'processDeferredActions',
|
||||
]);
|
||||
expect(mockArchiveOperationHandler.handleOperation).not.toHaveBeenCalled();
|
||||
expect(mockHydrationState.startPostSyncCooldown).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('archive reload trigger', () => {
|
||||
|
|
@ -371,6 +455,43 @@ describe('OperationApplierService', () => {
|
|||
expect(setTimeoutCalledWithZero).toBe(true);
|
||||
});
|
||||
|
||||
it('should wait for the bulk dispatch yield before archive handling starts', async () => {
|
||||
const archiveCalls: string[] = [];
|
||||
const op = createMockOperation('op-1', 'TASK', OpType.Update, { title: 'Test' });
|
||||
let releaseDispatchYield: (() => void) | undefined;
|
||||
const originalSetTimeout = window.setTimeout.bind(window);
|
||||
|
||||
mockArchiveOperationHandler.handleOperation.and.callFake(() => {
|
||||
archiveCalls.push('archive');
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
spyOn(window, 'setTimeout').and.callFake(((fn: () => void, ms?: number) => {
|
||||
if (ms === 0 && !releaseDispatchYield) {
|
||||
releaseDispatchYield = fn;
|
||||
return 0;
|
||||
}
|
||||
return originalSetTimeout(fn, ms);
|
||||
}) as typeof window.setTimeout);
|
||||
|
||||
const applyPromise = service.applyOperations([op]);
|
||||
|
||||
expect(mockStore.dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(archiveCalls).toEqual([]);
|
||||
expect(mockArchiveOperationHandler.handleOperation).not.toHaveBeenCalled();
|
||||
expect(releaseDispatchYield).toBeDefined();
|
||||
|
||||
if (!releaseDispatchYield) {
|
||||
fail('Expected dispatch yield callback to be captured');
|
||||
return;
|
||||
}
|
||||
|
||||
releaseDispatchYield();
|
||||
await applyPromise;
|
||||
|
||||
expect(archiveCalls).toEqual(['archive']);
|
||||
});
|
||||
|
||||
it('should not yield to event loop when no operations are applied', async () => {
|
||||
let setTimeoutCalledWithZero = false;
|
||||
spyOn(window, 'setTimeout').and.callFake(((fn: () => void, ms?: number) => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { provideMockActions } from '@ngrx/effects/testing';
|
||||
import { Action, Store } from '@ngrx/store';
|
||||
import type { DeferredLocalActionsPort } from '@sp/sync-core';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { OperationLogEffects } from './operation-log.effects';
|
||||
import { OperationLogStoreService } from '../persistence/operation-log-store.service';
|
||||
|
|
@ -544,6 +545,22 @@ describe('OperationLogEffects', () => {
|
|||
* sync completes with fresh vector clocks.
|
||||
*/
|
||||
|
||||
it('should expose deferred action flushing through DeferredLocalActionsPort', async () => {
|
||||
const port: DeferredLocalActionsPort = effects;
|
||||
const action = createPersistentAction(ActionType.TASK_SHARED_UPDATE);
|
||||
bufferDeferredAction(action);
|
||||
|
||||
await port.processDeferredActions();
|
||||
|
||||
expect(mockOpLogStore.appendWithVectorClockUpdate).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({
|
||||
actionType: ActionType.TASK_SHARED_UPDATE,
|
||||
clientId: 'testClient',
|
||||
}),
|
||||
'local',
|
||||
);
|
||||
});
|
||||
|
||||
it('should do nothing when no deferred actions are buffered', async () => {
|
||||
await effects.processDeferredActions();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
|
||||
import type { OperationStorePort } from '@sp/sync-core';
|
||||
import { OperationLogStoreService } from './operation-log-store.service';
|
||||
import { VectorClockService } from '../sync/vector-clock.service';
|
||||
import {
|
||||
ActionType,
|
||||
Operation,
|
||||
OperationLogEntry,
|
||||
OpType,
|
||||
EntityType,
|
||||
VectorClock,
|
||||
|
|
@ -90,6 +92,35 @@ describe('OperationLogStoreService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('OperationStorePort contract', () => {
|
||||
it('should expose unsynced, synced, and rejected transitions through the port methods', async () => {
|
||||
const port: OperationStorePort<Operation, OperationLogEntry> = service;
|
||||
const syncedOp = createTestOperation({ entityId: 'synced-task' });
|
||||
const rejectedOp = createTestOperation({ entityId: 'rejected-task' });
|
||||
|
||||
await service.append(syncedOp);
|
||||
await service.append(rejectedOp);
|
||||
|
||||
const initialUnsynced = await port.getUnsynced();
|
||||
expect(initialUnsynced.map((entry) => entry.op.id)).toEqual([
|
||||
syncedOp.id,
|
||||
rejectedOp.id,
|
||||
]);
|
||||
|
||||
const syncedEntry = initialUnsynced.find((entry) => entry.op.id === syncedOp.id);
|
||||
expect(syncedEntry).toBeDefined();
|
||||
if (!syncedEntry) {
|
||||
fail('Expected synced operation entry to exist');
|
||||
return;
|
||||
}
|
||||
|
||||
await port.markSynced([syncedEntry.seq]);
|
||||
await port.markRejected([rejectedOp.id]);
|
||||
|
||||
expect(await port.getUnsynced()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('append', () => {
|
||||
it('should append operation to log', async () => {
|
||||
const op = createTestOperation();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue