c674db2b2f
Оценка времени работы над страницей как сессионизация истории page_history по паузам бездействия (WakaTime-подобно), а не span между крайними правками, плюс drill-down в суточный таймлайн 24ч×дни. Зависит от #374 (kind + idle-пульс). Server: - Чистая computeWorkTime(rows, config) (§5): нормализация+дедуп → коллапс агентских всплесков по aiChatId → ОДИН проход сессионизации (порог зависит от пары: оба agent → agentTGap, иначе tGap; последняя сессия обязательно закрывается) → класс готовой сессии (все agent → agent_only, иначе work) → добивка (много-сэмпл → [first−P_in, last+P_out], одиночный скаляр → [t−P_single, t]) → метрики = union wall-clock по классу. Детерминированная, без БД. - Чистая bucketByDay(sessions, tz) (§6.3): union work/agent_only РАЗДЕЛЬНО, разрез по полуночи tz через нативный Intl (DST-корректно, без «+24ч»). Инвариант Σ activeMs == workMs держится по построению. - PageHistoryRepo.findTimelineByPageId — лёгкая проекция без content, ASC. - PageHistoryService.computeWorkTime + POST /pages/history/time, гейт validateCanView как у /history; атрибуция человек/агент по lastUpdatedSource. Client: - usePageWorkTime(pageId) шлёт tz зрителя (Intl локаль). - Кликабельное число «≈ 4 ч 30 мин» в шапке страницы (порог в тултипе). - Модалка-punch-card: 24-часовые дорожки по дням, окна work/agent разным цветом, одиночные (P_single) приглушены, сумма за день, пустой день «—», сворачивание длинных серий пустых дней, подпись tz + T_gap. i18n ru-RU + en-US. Tests: 22 юнита на computeWorkTime/bucketByDay (§7-фикстура ≈1ч32м vs ≈60h наив, закрытие последней сессии, коллапс/разрыв всплеска, idle-пульс, DST 23/25ч, полуночный разрез, инвариант, union-без-задвоения); 5 юнитов гейта контроллера; int-spec на реальном pg (проекция + совпадение с ядром); 6 клиентских на формат. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
94 lines
3.2 KiB
TypeScript
94 lines
3.2 KiB
TypeScript
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
|
import { PageController } from './page.controller';
|
|
|
|
// Direct instantiation with stub deps. The Test.createTestingModule form failed
|
|
// to resolve PageService's injected tokens at compile(), and this smoke test only
|
|
// needs the controller to construct.
|
|
describe('PageController', () => {
|
|
let controller: PageController;
|
|
|
|
beforeEach(() => {
|
|
controller = new PageController(
|
|
{} as any, // pageService
|
|
{} as any, // pageRepo
|
|
{} as any, // pageHistoryService
|
|
{} as any, // spaceAbility
|
|
{} as any, // pageAccessService
|
|
{} as any, // backlinkService
|
|
{} as any, // labelService
|
|
{} as any, // auditService
|
|
);
|
|
});
|
|
|
|
it('should be defined', () => {
|
|
expect(controller).toBeDefined();
|
|
});
|
|
|
|
// #395 — the work-time endpoint must be gated exactly like /history.
|
|
describe('getPageWorkTime', () => {
|
|
const user = { id: 'u1' } as any;
|
|
|
|
function build(overrides: {
|
|
page?: any;
|
|
validate?: jest.Mock;
|
|
compute?: jest.Mock;
|
|
}) {
|
|
const pageRepo = { findById: jest.fn().mockResolvedValue(overrides.page) };
|
|
const pageAccessService = {
|
|
validateCanView: overrides.validate ?? jest.fn().mockResolvedValue(undefined),
|
|
};
|
|
const pageHistoryService = {
|
|
computeWorkTime:
|
|
overrides.compute ?? jest.fn().mockResolvedValue({ workMs: 0 }),
|
|
};
|
|
const c = new PageController(
|
|
{} as any,
|
|
pageRepo as any,
|
|
pageHistoryService as any,
|
|
{} as any,
|
|
pageAccessService as any,
|
|
{} as any,
|
|
{} as any,
|
|
{} as any,
|
|
);
|
|
return { c, pageRepo, pageAccessService, pageHistoryService };
|
|
}
|
|
|
|
it('404s when the page does not exist', async () => {
|
|
const { c } = build({ page: null });
|
|
await expect(
|
|
c.getPageWorkTime({ pageId: 'p1' } as any, user),
|
|
).rejects.toBeInstanceOf(NotFoundException);
|
|
});
|
|
|
|
it('enforces validateCanView before computing, then delegates with tz', async () => {
|
|
const validate = jest.fn().mockResolvedValue(undefined);
|
|
const compute = jest.fn().mockResolvedValue({ workMs: 42 });
|
|
const { c } = build({ page: { id: 'pg' }, validate, compute });
|
|
const out = await c.getPageWorkTime(
|
|
{ pageId: 'pg', tz: 'Europe/Moscow' } as any,
|
|
user,
|
|
);
|
|
expect(validate).toHaveBeenCalledWith({ id: 'pg' }, user);
|
|
expect(compute).toHaveBeenCalledWith('pg', 'Europe/Moscow');
|
|
expect(out).toEqual({ workMs: 42 });
|
|
});
|
|
|
|
it('maps an unknown-timezone RangeError to a 400', async () => {
|
|
const compute = jest.fn().mockRejectedValue(new RangeError('bad tz'));
|
|
const { c } = build({ page: { id: 'pg' }, compute });
|
|
await expect(
|
|
c.getPageWorkTime({ pageId: 'pg', tz: 'X/Y' } as any, user),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
});
|
|
|
|
it('does not swallow a non-RangeError from the service', async () => {
|
|
const compute = jest.fn().mockRejectedValue(new Error('db down'));
|
|
const { c } = build({ page: { id: 'pg' }, compute });
|
|
await expect(
|
|
c.getPageWorkTime({ pageId: 'pg' } as any, user),
|
|
).rejects.toThrow('db down');
|
|
});
|
|
});
|
|
});
|