Per-project settings (owners/admins, «Configuración» tab):
- Statuses with category (TODO, IN_PROGRESS, QA, DONE, CANCELLED), colour and
"requires reason"; configurable transitions; ticket types (icon, colour) and
areas. Defaults are the previous fixed values; projects created before keep
them without migration.
- Values are archived, never deleted: tickets, history and statistics keep
referring to them. Validated in the domain (ProjectSettingsChangedEvent).
- Releases, board, tracking and "my work" use categories, so custom statuses
keep working. Status changes carry name/category metadata for history.
- GraphQL: status/type/area are strings (breaking for clients sending enum
literals); Project.settings, updateProjectSettings, statusCategory,
myIssues(categories). REST: PUT /projects/{project}/settings, category filters.
- Read model: statusCategory on issues (backfilled at startup).
Statistics («Estadísticas» tab):
- New issue_timeline projection; pure functions compute created vs resolved,
cumulative flow, cycle/lead time (avg + p85), throughput per person, QA
pass/fail and release burnup. GraphQL Project.analytics(days),
Release.burnup; REST /projects/{project}/analytics, /releases/{id}/burnup.
- Web: SVG charts with validated light/dark palette, crosshair tooltip,
keyboard navigation, legend and data table.
Home "Mi trabajo": my open tickets across projects grouped by category (QA
failures first), tickets waiting for QA, upcoming releases and "Ir a…".
Tests: domain/application specs, e2e scenario for settings and analytics,
facade tests. Docs, READMEs, schema.gql/openapi.json and Word/PDF updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
241 lines
7.3 KiB
TypeScript
241 lines
7.3 KiB
TypeScript
import { Component, inject, signal } from '@angular/core';
|
|
import { TestBed } from '@angular/core/testing';
|
|
import { Events } from '@ngrx/signals/events';
|
|
import { AuthFacade } from '@task-stream/web/identity/application';
|
|
import {
|
|
ActivityGateway,
|
|
CommentsGateway,
|
|
IssuesGateway,
|
|
Project,
|
|
ProjectRole,
|
|
ProjectsGateway,
|
|
ReleasesGateway,
|
|
TrackerRealtimeGateway,
|
|
UsersGateway,
|
|
} from '@task-stream/web/tracker/domain';
|
|
import { NEVER, of } from 'rxjs';
|
|
import { boardPageEvents } from '../events/board.events';
|
|
import { projectPageEvents } from '../events/project.events';
|
|
import { Notifications } from '../notifications';
|
|
import { BoardFacade, provideBoardFacade } from './board.facade';
|
|
import { HomeFacade } from './home.facade';
|
|
import { IssueFacade, provideIssueFacade } from './issue.facade';
|
|
import { ProjectFacade, provideProjectFacade } from './project.facade';
|
|
|
|
const ok = { id: 'x', key: null, aggregateVersion: 1 };
|
|
const project = { id: 'p1', key: 'WEB', members: [] } as unknown as Project;
|
|
const issue = { id: 'i1', key: 'WEB-1', links: [] };
|
|
const analytics = { from: 'a', to: 'b', qa: { failureRate: 0.25 } };
|
|
|
|
/** Every command succeeds, every query answers once, nothing arrives in real time. */
|
|
function gateways() {
|
|
return {
|
|
projects: {
|
|
project: vi.fn(() => of(project)),
|
|
stats: vi.fn(() => NEVER),
|
|
addMember: vi.fn(() => of(ok)),
|
|
updateSettings: vi.fn(() => of(ok)),
|
|
analytics: vi.fn(() => of(analytics)),
|
|
},
|
|
issues: {
|
|
board: vi.fn(() => of([])),
|
|
issue: vi.fn(() => of(issue)),
|
|
changeStatus: vi.fn(() => of(ok)),
|
|
},
|
|
list: { list: vi.fn(() => of([])) },
|
|
realtime: {
|
|
issueChanges: () => NEVER,
|
|
commentChanges: () => NEVER,
|
|
activity: () => NEVER,
|
|
releaseChanges: () => NEVER,
|
|
projectChanges: () => NEVER,
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Facades are scoped to a route component, like in the app. */
|
|
@Component({
|
|
template: '',
|
|
providers: [
|
|
provideProjectFacade(),
|
|
provideBoardFacade(),
|
|
provideIssueFacade(),
|
|
],
|
|
})
|
|
class Host {
|
|
readonly project = inject(ProjectFacade);
|
|
readonly board = inject(BoardFacade);
|
|
readonly issue = inject(IssueFacade);
|
|
}
|
|
|
|
function setup() {
|
|
const fakes = gateways();
|
|
TestBed.configureTestingModule({
|
|
providers: [
|
|
{ provide: ProjectsGateway, useValue: fakes.projects },
|
|
{ provide: IssuesGateway, useValue: fakes.issues },
|
|
{ provide: ReleasesGateway, useValue: fakes.list },
|
|
{ provide: ActivityGateway, useValue: fakes.list },
|
|
{ provide: CommentsGateway, useValue: fakes.list },
|
|
{ provide: UsersGateway, useValue: { search: () => of([]) } },
|
|
{ provide: TrackerRealtimeGateway, useValue: fakes.realtime },
|
|
{
|
|
provide: Notifications,
|
|
useValue: { success: vi.fn(), error: vi.fn() },
|
|
},
|
|
{ provide: AuthFacade, useValue: { user: signal({ id: 'me' }) } },
|
|
],
|
|
});
|
|
const host = TestBed.createComponent(Host).componentInstance;
|
|
return { ...host, fakes };
|
|
}
|
|
|
|
describe('BoardFacade', () => {
|
|
it('opens the board for a new project and refilters the same one', () => {
|
|
const { board, fakes } = setup();
|
|
const seen: string[] = [];
|
|
TestBed.inject(Events)
|
|
.on(boardPageEvents.opened, boardPageEvents.filterChanged)
|
|
.subscribe((event) => seen.push(event.type));
|
|
|
|
board.show({ projectId: 'p1' });
|
|
board.show({ projectId: 'p1', assigneeId: 'u2' });
|
|
board.show({ projectId: 'p2' });
|
|
|
|
expect(seen).toEqual([
|
|
boardPageEvents.opened.type,
|
|
boardPageEvents.filterChanged.type,
|
|
boardPageEvents.opened.type,
|
|
]);
|
|
expect(fakes.issues.board).toHaveBeenLastCalledWith({ projectId: 'p2' });
|
|
expect(board.filter()).toEqual({ projectId: 'p2' });
|
|
});
|
|
});
|
|
|
|
describe('ProjectFacade', () => {
|
|
it('ignores commands until a project is open, then sends its id', () => {
|
|
const { project, fakes } = setup();
|
|
|
|
project.addMember('u2');
|
|
expect(fakes.projects.addMember).not.toHaveBeenCalled();
|
|
|
|
project.open('WEB');
|
|
expect(project.project()?.id).toBe('p1');
|
|
|
|
project.addMember('u2', ProjectRole.VIEWER);
|
|
expect(fakes.projects.addMember).toHaveBeenCalledWith({
|
|
projectId: 'p1',
|
|
userId: 'u2',
|
|
role: ProjectRole.VIEWER,
|
|
});
|
|
});
|
|
|
|
it('saves settings and loads statistics of the open project', () => {
|
|
const { project, fakes } = setup();
|
|
project.open('WEB');
|
|
const settings = {
|
|
statuses: [],
|
|
transitions: [],
|
|
issueTypes: [],
|
|
areas: [],
|
|
};
|
|
|
|
project.saveSettings(settings);
|
|
expect(fakes.projects.updateSettings).toHaveBeenCalledWith('p1', settings);
|
|
expect(project.savingSettings()).toBe(false);
|
|
|
|
project.loadAnalytics(30);
|
|
expect(fakes.projects.analytics).toHaveBeenCalledWith('p1', 30);
|
|
expect(project.analyticsDays()).toBe(30);
|
|
expect(project.analytics()).toBe(analytics);
|
|
// Unknown ids still get a readable name.
|
|
expect(project.workflow().status('IN_REVIEW').name).toBe('In review');
|
|
});
|
|
|
|
it('searches the directory from two characters', () => {
|
|
const { project } = setup();
|
|
const seen: string[] = [];
|
|
TestBed.inject(Events)
|
|
.on(projectPageEvents.userSearchRequested)
|
|
.subscribe(({ payload }) => seen.push(payload));
|
|
|
|
project.searchUsers(' a ');
|
|
project.searchUsers(' an ');
|
|
|
|
expect(seen).toEqual(['an']);
|
|
});
|
|
});
|
|
|
|
describe('IssueFacade', () => {
|
|
it('acts on the open ticket', () => {
|
|
const { issue, fakes } = setup();
|
|
|
|
issue.changeStatus('IN_PROGRESS');
|
|
expect(fakes.issues.changeStatus).not.toHaveBeenCalled();
|
|
|
|
issue.open('WEB-1');
|
|
issue.changeStatus('QA_FAILED', 'Falla en Safari');
|
|
expect(fakes.issues.changeStatus).toHaveBeenCalledWith({
|
|
issueId: 'i1',
|
|
status: 'QA_FAILED',
|
|
reason: 'Falla en Safari',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('HomeFacade', () => {
|
|
const issue = (key: string, status: string, statusCategory: string) =>
|
|
({ id: key, key, status, statusCategory, title: key }) as never;
|
|
|
|
it('loads my work and groups it by category, QA failures first', () => {
|
|
const mine = vi.fn((filter: { assignedToMe: boolean }) =>
|
|
of(
|
|
filter.assignedToMe
|
|
? {
|
|
items: [
|
|
issue('WEB-1', 'IN_PROGRESS', 'IN_PROGRESS'),
|
|
issue('WEB-4', 'BACKLOG', 'TODO'),
|
|
issue('WEB-2', 'QA_FAILED', 'IN_PROGRESS'),
|
|
],
|
|
total: 3,
|
|
}
|
|
: { items: [issue('WEB-3', 'STAGING', 'QA')], total: 1 },
|
|
),
|
|
);
|
|
TestBed.configureTestingModule({
|
|
providers: [
|
|
{ provide: IssuesGateway, useValue: { mine } },
|
|
{
|
|
provide: ReleasesGateway,
|
|
useValue: { upcoming: () => of([{ id: 'r1', name: 'v1.4.0' }]) },
|
|
},
|
|
],
|
|
});
|
|
const home = TestBed.inject(HomeFacade);
|
|
|
|
home.load();
|
|
|
|
expect(mine).toHaveBeenCalledWith(
|
|
expect.objectContaining({ assignedToMe: true }),
|
|
);
|
|
expect(mine).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
assignedToMe: false,
|
|
categories: ['QA'],
|
|
}),
|
|
);
|
|
expect(home.loaded()).toBe(true);
|
|
expect(
|
|
home
|
|
.mineByCategory()
|
|
.map((g) => [g.category, g.issues.map((i) => i.key)]),
|
|
).toEqual([
|
|
['IN_PROGRESS', ['WEB-2', 'WEB-1']],
|
|
['TODO', ['WEB-4']],
|
|
]);
|
|
expect(home.qaFailed()).toBe(1);
|
|
expect(home.awaitingQaTotal()).toBe(1);
|
|
expect(home.releases().map((r) => r.name)).toEqual(['v1.4.0']);
|
|
});
|
|
});
|