test: cover report form rules, save order and SharePoint calls
Checked line by line against stroy-bot-report: form modes, validation texts and order, payloads, delete-children-first then recreate save sequence, photo upload, access lookup and dictionary fallbacks. The fake HTTP client can now answer per call so created rows get distinct ids. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +0,0 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import { EmptyState } from '../shared/components/EmptyState';
|
|
||||||
import { APP_TEXTS } from './texts';
|
|
||||||
|
|
||||||
/** Временная заглушка для разделов, которые ещё не перенесены. Удаляется вместе с последним разделом. */
|
|
||||||
export const SectionPlaceholder: React.FC = () => (
|
|
||||||
<EmptyState icon="refresh" title={APP_TEXTS.sectionInProgress} text={APP_TEXTS.sectionInProgressHint} />
|
|
||||||
);
|
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import {
|
||||||
|
buildReportDataPayload,
|
||||||
|
createDataverseReportRepository,
|
||||||
|
DV_PATHS,
|
||||||
|
orderRowsForDeletion
|
||||||
|
} from './dataverseReportRepository';
|
||||||
|
import { createFakeHttpClient, IFakeRoute } from '../../shared/testing/fakeHttpClient';
|
||||||
|
import { IReportPayload } from './reportTypes';
|
||||||
|
|
||||||
|
const DV = 'https://tansu.crm5.dynamics.com';
|
||||||
|
const API = `${DV}/api/data/v9.2/`;
|
||||||
|
|
||||||
|
const REPORT: IReportPayload = {
|
||||||
|
cree9_objectid: 41,
|
||||||
|
cree9_companyid: 3,
|
||||||
|
cree9_shiftid: 1,
|
||||||
|
cree9_report_date: '2026-10-16',
|
||||||
|
cree9_number: 8,
|
||||||
|
cree9_projectid: 5,
|
||||||
|
cree9_comment: undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Тело запроса как объект — чтобы сравнивать без зависимости от порядка ключей. */
|
||||||
|
const bodyOf = (body: string | ArrayBuffer | undefined): unknown => JSON.parse(String(body));
|
||||||
|
|
||||||
|
describe('адреса Dataverse (1:1 со stroy-bot-report)', () => {
|
||||||
|
it('отчёт, счётчик, строки, факт', () => {
|
||||||
|
expect(DV_PATHS.report('r1')).toEqual('cree9_reports(r1)');
|
||||||
|
expect(DV_PATHS.reportCount(5)).toEqual(
|
||||||
|
'cree9_reports?$filter=cree9_projectid eq 5 and cree9_deleted ne true&$count=true&$top=1&$select=cree9_number'
|
||||||
|
);
|
||||||
|
expect(DV_PATHS.reportData('r1')).toEqual('cree9_report_datas?$filter=_cree9_column1_value eq r1');
|
||||||
|
expect(DV_PATHS.jobTotalFact('j1')).toEqual('cree9_report_datas?$filter=_cree9_jobid_value eq j1&$select=cree9_value');
|
||||||
|
expect(DV_PATHS.jobsByObject(41)).toEqual(
|
||||||
|
"cree9_tables?$filter=cree9_objectid eq '41'" +
|
||||||
|
'&$select=cree9_tableid,cree9_title,cree9_unit,cree9_objectid,cree9_volume,_cree9_parentid_value'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildReportDataPayload', () => {
|
||||||
|
it('работа — через cree9_jobId@odata.bind, всегда привязка к отчёту, пустые поля пропускаются', () => {
|
||||||
|
expect(buildReportDataPayload({ _cree9_jobid_value: 'j1', cree9_value: 2, cree9_comment: undefined }, 'r1')).toEqual({
|
||||||
|
'cree9_jobId@odata.bind': '/cree9_tables(j1)',
|
||||||
|
cree9_value: 2,
|
||||||
|
'cree9_Column1@odata.bind': '/cree9_reports(r1)'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('строка на работе привязана к строке-работе через cree9_parentId', () => {
|
||||||
|
expect(buildReportDataPayload({ cree9_professionid: 7, cree9_count: 2 }, 'r1', 'row-w')).toEqual({
|
||||||
|
cree9_professionid: 7,
|
||||||
|
cree9_count: 2,
|
||||||
|
'cree9_Column1@odata.bind': '/cree9_reports(r1)',
|
||||||
|
'cree9_parentId@odata.bind': '/cree9_report_datas(row-w)'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('orderRowsForDeletion', () => {
|
||||||
|
it('сначала дочерние (с родителем), потом остальные', () => {
|
||||||
|
const ordered = orderRowsForDeletion([
|
||||||
|
{ cree9_report_dataid: 'parent' },
|
||||||
|
{ cree9_report_dataid: 'child', _cree9_parentid_value: 'parent' },
|
||||||
|
{ cree9_report_dataid: 'general' }
|
||||||
|
]);
|
||||||
|
expect(ordered.map((row) => row.cree9_report_dataid)).toEqual(['child', 'parent', 'general']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('saveReportWithData', () => {
|
||||||
|
const nodes = [
|
||||||
|
{ data: { _cree9_jobid_value: 'j1', cree9_value: 2 }, children: [{ cree9_professionid: 7, cree9_count: 1 }] },
|
||||||
|
{ data: { cree9_technicid: 9, cree9_count: 1 } }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Каждая созданная строка отчёта получает свой id: row-1, row-2, … */
|
||||||
|
const createdRows: IFakeRoute = {
|
||||||
|
match: 'cree9_report_datas',
|
||||||
|
respond: (callNumber) => ({ status: 201, body: { cree9_report_dataid: `row-${callNumber}` } })
|
||||||
|
};
|
||||||
|
|
||||||
|
it('новый отчёт: POST отчёта, затем строки по порядку, дети — к строке своей работы', async () => {
|
||||||
|
const client = createFakeHttpClient([
|
||||||
|
createdRows,
|
||||||
|
{ match: 'cree9_reports', response: { status: 201, body: { cree9_reportid: 'r-new' } } }
|
||||||
|
]);
|
||||||
|
const repository = createDataverseReportRepository(client, DV);
|
||||||
|
|
||||||
|
expect(await repository.saveReportWithData(REPORT, nodes)).toEqual('r-new');
|
||||||
|
|
||||||
|
const writes = client.requests;
|
||||||
|
expect(writes.map((r) => r.url)).toEqual([
|
||||||
|
`${API}cree9_reports`,
|
||||||
|
`${API}cree9_report_datas`,
|
||||||
|
`${API}cree9_report_datas`,
|
||||||
|
`${API}cree9_report_datas`
|
||||||
|
]);
|
||||||
|
expect(bodyOf(writes[0].request.body)).toEqual({
|
||||||
|
cree9_objectid: 41,
|
||||||
|
cree9_companyid: 3,
|
||||||
|
cree9_shiftid: 1,
|
||||||
|
cree9_report_date: '2026-10-16',
|
||||||
|
cree9_number: 8,
|
||||||
|
cree9_projectid: 5
|
||||||
|
});
|
||||||
|
// Ребёнок работы (второй POST строки) привязан к строке работы row-1.
|
||||||
|
expect(bodyOf(writes[2].request.body)).toEqual(
|
||||||
|
expect.objectContaining({ 'cree9_parentId@odata.bind': '/cree9_report_datas(row-1)' })
|
||||||
|
);
|
||||||
|
// Общая техника — без родителя.
|
||||||
|
expect(bodyOf(writes[3].request.body)).not.toHaveProperty('cree9_parentId@odata.bind');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('правка: PATCH отчёта → удаление старых строк (дети первыми) → создание новых', async () => {
|
||||||
|
const client = createFakeHttpClient([
|
||||||
|
{
|
||||||
|
match: '?$filter=_cree9_column1_value eq r1',
|
||||||
|
response: {
|
||||||
|
body: {
|
||||||
|
value: [
|
||||||
|
{ cree9_report_dataid: 'old-work' },
|
||||||
|
{ cree9_report_dataid: 'old-child', _cree9_parentid_value: 'old-work' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ match: 'cree9_report_datas(', response: { status: 204 } },
|
||||||
|
createdRows,
|
||||||
|
{ match: 'cree9_reports(r1)', response: { status: 204 } }
|
||||||
|
]);
|
||||||
|
const repository = createDataverseReportRepository(client, DV);
|
||||||
|
|
||||||
|
expect(await repository.saveReportWithData(REPORT, nodes, 'r1')).toEqual('r1');
|
||||||
|
|
||||||
|
const sequence = client.requests.map((r) => `${r.request.method} ${r.url.replace(API, '')}`);
|
||||||
|
expect(sequence).toEqual([
|
||||||
|
'PATCH cree9_reports(r1)',
|
||||||
|
'GET cree9_report_datas?$filter=_cree9_column1_value eq r1',
|
||||||
|
'DELETE cree9_report_datas(old-child)',
|
||||||
|
'DELETE cree9_report_datas(old-work)',
|
||||||
|
'POST cree9_report_datas',
|
||||||
|
'POST cree9_report_datas',
|
||||||
|
'POST cree9_report_datas'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('если сервер не вернул id отчёта — ошибка, строки не создаются', async () => {
|
||||||
|
const client = createFakeHttpClient([{ match: 'cree9_reports', response: { status: 201, body: {} } }]);
|
||||||
|
const repository = createDataverseReportRepository(client, DV);
|
||||||
|
await expect(repository.saveReportWithData(REPORT, nodes)).rejects.toThrow('reportId is empty');
|
||||||
|
expect(client.requests).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ошибка удаления старой строки прерывает сохранение', async () => {
|
||||||
|
const client = createFakeHttpClient([
|
||||||
|
{ match: '_cree9_column1_value', response: { body: { value: [{ cree9_report_dataid: 'old' }] } } },
|
||||||
|
{ match: 'cree9_report_datas(old)', response: { status: 403 } },
|
||||||
|
{ match: 'cree9_reports(r1)', response: { status: 204 } }
|
||||||
|
]);
|
||||||
|
const repository = createDataverseReportRepository(client, DV);
|
||||||
|
await expect(repository.saveReportWithData(REPORT, nodes, 'r1')).rejects.toThrow('403');
|
||||||
|
expect(client.requests.some((r) => r.request.method === 'POST')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('прочие операции', () => {
|
||||||
|
it('номер нового отчёта = количество неудалённых + 1', async () => {
|
||||||
|
const client = createFakeHttpClient([{ match: '$count=true', response: { body: { '@odata.count': 7, value: [] } } }]);
|
||||||
|
expect(await createDataverseReportRepository(client, DV).getNextReportNumber(5)).toEqual(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('утверждение — PATCH cree9_aprooved: true', async () => {
|
||||||
|
const client = createFakeHttpClient([{ match: 'cree9_reports(r1)', response: { status: 204 } }]);
|
||||||
|
await createDataverseReportRepository(client, DV).approveReport('r1');
|
||||||
|
expect(client.requests[0].request.method).toEqual('PATCH');
|
||||||
|
expect(bodyOf(client.requests[0].request.body)).toEqual({ cree9_aprooved: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('итоговый факт — сумма cree9_value по всем строкам работы', async () => {
|
||||||
|
const client = createFakeHttpClient([
|
||||||
|
{ match: '_cree9_jobid_value', response: { body: { value: [{ cree9_value: 2 }, {}, { cree9_value: 1.5 }] } } }
|
||||||
|
]);
|
||||||
|
expect(await createDataverseReportRepository(client, DV).getJobTotalFact('j1')).toEqual(3.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('новая работа: объём только если > 0, родитель — через найденное навигационное свойство', async () => {
|
||||||
|
const client = createFakeHttpClient([
|
||||||
|
{ match: 'EntityDefinitions', response: { body: { value: [{ ReferencingEntityNavigationPropertyName: 'cree9_ParentId' }] } } },
|
||||||
|
{ match: 'cree9_tables', response: { status: 201, body: { cree9_tableid: 'new-job' } } }
|
||||||
|
]);
|
||||||
|
const repository = createDataverseReportRepository(client, DV);
|
||||||
|
|
||||||
|
const job = await repository.createJob({ title: 'Бетон', unit: 'м3', objectId: 41, volume: 0, parentId: 'a' });
|
||||||
|
|
||||||
|
expect(job.cree9_tableid).toEqual('new-job');
|
||||||
|
expect(bodyOf(client.requests[1].request.body)).toEqual({
|
||||||
|
cree9_title: 'Бетон',
|
||||||
|
cree9_unit: 'м3',
|
||||||
|
cree9_objectid: '41',
|
||||||
|
'cree9_ParentId@odata.bind': '/cree9_tables(a)'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { buildFormCollections } from './reportCollections';
|
||||||
|
import { IReportServices } from './reportServices';
|
||||||
|
import { IReportDataRow } from './reportTypes';
|
||||||
|
|
||||||
|
/** Сервисы-заглушки: только то, что использует разбор строк. */
|
||||||
|
function fakeServices(): IReportServices {
|
||||||
|
const sp = {
|
||||||
|
getProfessionById: async (id: number) => ({ Id: id, Title: id === 7 ? 'Сварщик' : 'Монтажник' }),
|
||||||
|
getTechnicById: async (id: number) => {
|
||||||
|
if (id === 99) throw new Error('удалена');
|
||||||
|
return { Id: id, Title: 'Кран' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const dv = {
|
||||||
|
getJobsByObjectId: async () => [
|
||||||
|
{ cree9_tableid: 'j1', cree9_title: 'Бетон', cree9_unit: 'м3', cree9_objectid: '41', cree9_volume: 10, fullPath: 'Бетон' }
|
||||||
|
],
|
||||||
|
getJobTotalFact: async () => 6
|
||||||
|
};
|
||||||
|
return { sp, dv, siteUrl: '' } as unknown as IReportServices;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('buildFormCollections (как buildFormCollections старой формы)', () => {
|
||||||
|
const rows: IReportDataRow[] = [
|
||||||
|
// Ребёнок раньше своей работы — всё равно прикрепляется к ней.
|
||||||
|
{ cree9_report_dataid: 'c1', _cree9_parentid_value: 'w1', cree9_professionid: 7, cree9_count: 2, cree9_hours: 8 },
|
||||||
|
{ cree9_report_dataid: 'w1', _cree9_jobid_value: 'j1', cree9_value: 3, cree9_comment: 'залили' },
|
||||||
|
// Старый бот писал название в комментарий — такой комментарий не показывается.
|
||||||
|
{ cree9_report_dataid: 'p1', cree9_professionid: 8, cree9_comment: 'Монтажник', cree9_hourstype: 'Часы простоя' },
|
||||||
|
// Элемент справочника удалён — без названия, без падения.
|
||||||
|
{ cree9_report_dataid: 't1', cree9_technicid: 99, cree9_comment: 'сломался' }
|
||||||
|
];
|
||||||
|
|
||||||
|
it('работы, дети работ и общие секции', async () => {
|
||||||
|
const result = await buildFormCollections(fakeServices(), rows, 41);
|
||||||
|
|
||||||
|
expect(result.works).toHaveLength(1);
|
||||||
|
expect(result.works[0]).toEqual(
|
||||||
|
expect.objectContaining({ jobId: 'j1', jobTitle: 'Бетон', jobUnit: 'м3', value: 3, plan: 10, totalFact: 6, comment: 'залили' })
|
||||||
|
);
|
||||||
|
expect(result.works[0].professions.map((p) => p.title)).toEqual(['Сварщик']);
|
||||||
|
|
||||||
|
expect(result.professions).toEqual([
|
||||||
|
expect.objectContaining({ resourceId: 8, title: 'Монтажник', comment: '', hoursType: 'Часы простоя' })
|
||||||
|
]);
|
||||||
|
expect(result.technics).toEqual([
|
||||||
|
expect.objectContaining({ resourceId: 99, title: '', comment: 'сломался', hoursType: 'Часы работы' })
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { buildReportNodes, buildReportPayload } from './reportPayload';
|
||||||
|
import { reportForm, resource, workItem } from './reportTestData';
|
||||||
|
|
||||||
|
describe('buildReportPayload', () => {
|
||||||
|
it('поля отчёта как в handleSave, пустой комментарий не отправляется', () => {
|
||||||
|
expect(buildReportPayload(reportForm(), 8, 5)).toEqual({
|
||||||
|
cree9_objectid: 41,
|
||||||
|
cree9_companyid: 3,
|
||||||
|
cree9_shiftid: 1,
|
||||||
|
cree9_report_date: '2026-10-16',
|
||||||
|
cree9_number: 8,
|
||||||
|
cree9_projectid: 5,
|
||||||
|
cree9_comment: undefined
|
||||||
|
});
|
||||||
|
expect(buildReportPayload(reportForm({ comment: 'Дождь' }), 8, 5).cree9_comment).toEqual('Дождь');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildReportNodes (порядок строк как в handleSave)', () => {
|
||||||
|
it('работы с детьми → общий персонал → общая техника', () => {
|
||||||
|
const form = reportForm({
|
||||||
|
works: [
|
||||||
|
workItem({
|
||||||
|
comment: 'залили',
|
||||||
|
professions: [resource({ resourceId: 7, hoursType: 'Часы простоя', comment: 'x' })],
|
||||||
|
technics: [resource({ resourceId: 9, count: 1, hours: 4 })]
|
||||||
|
}),
|
||||||
|
workItem({ id: 'w2', jobId: 'job-2', value: 1 })
|
||||||
|
],
|
||||||
|
professions: [resource({ resourceId: 11, hoursType: 'Часы простоя', comment: 'ждали' })],
|
||||||
|
technics: [resource({ resourceId: 12, hoursType: '' })]
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(buildReportNodes(form)).toEqual([
|
||||||
|
{
|
||||||
|
data: { _cree9_jobid_value: 'job-1', cree9_value: 3.5, cree9_comment: 'залили' },
|
||||||
|
children: [
|
||||||
|
// На работе тип часов всегда «Часы работы», даже если в элементе другой.
|
||||||
|
{ cree9_professionid: 7, cree9_count: 2, cree9_hours: 8, cree9_comment: 'x', cree9_hourstype: 'Часы работы' },
|
||||||
|
{ cree9_technicid: 9, cree9_count: 1, cree9_hours: 4, cree9_comment: undefined, cree9_hourstype: 'Часы работы' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ data: { _cree9_jobid_value: 'job-2', cree9_value: 1, cree9_comment: undefined }, children: undefined },
|
||||||
|
{
|
||||||
|
data: { cree9_professionid: 11, cree9_count: 2, cree9_hours: 8, cree9_comment: 'ждали', cree9_hourstype: 'Часы простоя' }
|
||||||
|
},
|
||||||
|
// Пустой тип часов — «Часы работы».
|
||||||
|
{ data: { cree9_technicid: 12, cree9_count: 2, cree9_hours: 8, cree9_comment: undefined, cree9_hourstype: 'Часы работы' } }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import {
|
||||||
|
buildJobPaths,
|
||||||
|
buildObjectPaths,
|
||||||
|
canShowBackAfterSave,
|
||||||
|
canShowSaveButton,
|
||||||
|
daysSinceReport,
|
||||||
|
decideExistingReportMode,
|
||||||
|
getStatusBadge,
|
||||||
|
hasAnyAccess,
|
||||||
|
isFormReadOnly,
|
||||||
|
newJobPath,
|
||||||
|
parseReportRoute,
|
||||||
|
sortTechnics,
|
||||||
|
todayIsoDate
|
||||||
|
} from './reportRules';
|
||||||
|
import { access } from './reportTestData';
|
||||||
|
|
||||||
|
const DAY = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
describe('parseReportRoute', () => {
|
||||||
|
it('projectId через parseInt, reportId «null» — нет отчёта', () => {
|
||||||
|
expect(parseReportRoute({ page: 'report', projectId: '12', reportId: 'null' })).toEqual({
|
||||||
|
projectId: 12,
|
||||||
|
reportId: undefined,
|
||||||
|
copyFromId: ''
|
||||||
|
});
|
||||||
|
expect(parseReportRoute({ page: 'report', projectId: '12abc', copyFromId: 'c1' }).projectId).toEqual(12);
|
||||||
|
expect(parseReportRoute({ page: 'report' }).projectId).toEqual(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('decideExistingReportMode (как loadExistingReport)', () => {
|
||||||
|
it('утверждающий + неутверждённый — утверждение, даже если есть право правки', () => {
|
||||||
|
expect(decideExistingReportMode(access({ aproove: true, edit: true }), false, 0, 5)).toEqual('approve');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('редактор + неутверждённый + меньше editDays дней — правка', () => {
|
||||||
|
expect(decideExistingReportMode(access({ edit: true }), false, 4, 5)).toEqual('edit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('прошло editDays дней — только просмотр', () => {
|
||||||
|
expect(decideExistingReportMode(access({ edit: true }), false, 5, 5)).toEqual('readonly');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('утверждённый отчёт — просмотр для всех', () => {
|
||||||
|
expect(decideExistingReportMode(access({ edit: true, aproove: true }), true, 0, 5)).toEqual('readonly');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('только чтение — просмотр', () => {
|
||||||
|
expect(decideExistingReportMode(access({ read: true }), false, 0, 5)).toEqual('readonly');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('доступ и кнопки', () => {
|
||||||
|
it('нет записи или все права false — нет доступа', () => {
|
||||||
|
expect(hasAnyAccess(undefined)).toBe(false);
|
||||||
|
expect(hasAnyAccess(access())).toBe(false);
|
||||||
|
expect(hasAnyAccess(access({ read: true }))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('просмотр и утверждение — форма только для чтения', () => {
|
||||||
|
expect(isFormReadOnly('readonly')).toBe(true);
|
||||||
|
expect(isFormReadOnly('approve')).toBe(true);
|
||||||
|
expect(isFormReadOnly('edit')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('бейдж: у нового нет, утверждён, на утверждении — только у утверждающего', () => {
|
||||||
|
expect(getStatusBadge('create', true, access({ aproove: true }))).toBeUndefined();
|
||||||
|
expect(getStatusBadge('readonly', true, access())).toEqual('approved');
|
||||||
|
expect(getStatusBadge('approve', false, access({ aproove: true }))).toEqual('pending');
|
||||||
|
expect(getStatusBadge('edit', false, access({ edit: true }))).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('«Сохранить»: 🐞 новый отчёт без строки «Доступы» сохранить можно, edit=false — нельзя', () => {
|
||||||
|
expect(canShowSaveButton('create', false, undefined)).toBe(true);
|
||||||
|
expect(canShowSaveButton('create', false, access({ edit: false }))).toBe(false);
|
||||||
|
expect(canShowSaveButton('edit', false, access({ edit: true }))).toBe(true);
|
||||||
|
expect(canShowSaveButton('edit', true, access({ edit: true }))).toBe(false);
|
||||||
|
expect(canShowSaveButton('readonly', false, access({ edit: true }))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('после сохранения — «Вернуться назад»', () => {
|
||||||
|
expect(canShowBackAfterSave('create', true)).toBe(true);
|
||||||
|
expect(canShowBackAfterSave('approve', true)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('даты', () => {
|
||||||
|
it('сегодня — по UTC, как в старом коде', () => {
|
||||||
|
expect(todayIsoDate(new Date('2026-10-16T23:30:00Z'))).toEqual('2026-10-16');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('полные сутки с даты отчёта', () => {
|
||||||
|
const reportDate = '2026-10-10T00:00:00Z';
|
||||||
|
expect(daysSinceReport(reportDate, new Date(reportDate).getTime() + 5 * DAY - 1)).toEqual(4);
|
||||||
|
expect(daysSinceReport(reportDate, new Date(reportDate).getTime() + 5 * DAY)).toEqual(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('пути', () => {
|
||||||
|
it('объекты: «Пятно / Раздел», сортировка по пути', () => {
|
||||||
|
const paths = buildObjectPaths([
|
||||||
|
{ Id: 2, Title: 'Раздел', parentId: 1 },
|
||||||
|
{ Id: 1, Title: 'Пятно' },
|
||||||
|
{ Id: 3, Title: 'Арка' }
|
||||||
|
]);
|
||||||
|
expect(paths.map((p) => p.fullPath)).toEqual(['Арка', 'Пятно', 'Пятно / Раздел']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('работы: путь через родителей, цикл не зацикливает', () => {
|
||||||
|
const paths = buildJobPaths([
|
||||||
|
{ cree9_tableid: 'a', cree9_title: 'Фундамент', cree9_unit: '', cree9_objectid: '41' },
|
||||||
|
{ cree9_tableid: 'b', cree9_title: 'Бетон', cree9_unit: 'м3', cree9_objectid: '41', _cree9_parentid_value: 'a' },
|
||||||
|
{ cree9_tableid: 'x', cree9_title: 'X', cree9_unit: '', cree9_objectid: '41', _cree9_parentid_value: 'y' },
|
||||||
|
{ cree9_tableid: 'y', cree9_title: 'Y', cree9_unit: '', cree9_objectid: '41', _cree9_parentid_value: 'x' }
|
||||||
|
]);
|
||||||
|
expect(paths.filter((p) => p.cree9_tableid === 'b')[0].fullPath).toEqual('Фундамент / Бетон');
|
||||||
|
expect(paths.filter((p) => p.cree9_tableid === 'x')[0].fullPath).toEqual('Y / X');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('путь новой работы — от найденного родителя', () => {
|
||||||
|
const jobs = buildJobPaths([{ cree9_tableid: 'a', cree9_title: 'Фундамент', cree9_unit: '', cree9_objectid: '41' }]);
|
||||||
|
expect(newJobPath('Бетон', 'a', jobs)).toEqual('Фундамент / Бетон');
|
||||||
|
expect(newJobPath('Бетон', 'нет', jobs)).toEqual('Бетон');
|
||||||
|
expect(newJobPath('Бетон', undefined, jobs)).toEqual('Бетон');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('техника: по группе, затем по названию', () => {
|
||||||
|
const sorted = sortTechnics([
|
||||||
|
{ Id: 1, Title: 'Экскаватор', group: 'Земляные' },
|
||||||
|
{ Id: 2, Title: 'Автокран', group: 'Краны' },
|
||||||
|
{ Id: 3, Title: 'Бульдозер', group: 'Земляные' }
|
||||||
|
]);
|
||||||
|
expect(sorted.map((t) => t.Title)).toEqual(['Бульдозер', 'Экскаватор', 'Автокран']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { IReportAccess, IReportFormState, IResourceItem, IWorkItem } from './reportTypes';
|
||||||
|
|
||||||
|
// Заготовки данных для тестов формы отчёта (используются только в *.test.ts).
|
||||||
|
|
||||||
|
export const access = (overrides: Partial<IReportAccess> = {}): IReportAccess => ({
|
||||||
|
id: 1,
|
||||||
|
userId: 42,
|
||||||
|
project: '5',
|
||||||
|
read: false,
|
||||||
|
edit: false,
|
||||||
|
aproove: false,
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
export const resource = (overrides: Partial<IResourceItem> = {}): IResourceItem => ({
|
||||||
|
id: 'r1',
|
||||||
|
resourceId: 7,
|
||||||
|
title: 'Сварщик',
|
||||||
|
count: 2,
|
||||||
|
hours: 8,
|
||||||
|
hoursType: 'Часы работы',
|
||||||
|
comment: '',
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
export const workItem = (overrides: Partial<IWorkItem> = {}): IWorkItem => ({
|
||||||
|
id: 'w1',
|
||||||
|
jobId: 'job-1',
|
||||||
|
jobTitle: 'Бетон',
|
||||||
|
jobUnit: 'м3',
|
||||||
|
value: 3.5,
|
||||||
|
plan: 10,
|
||||||
|
totalFact: 4,
|
||||||
|
comment: '',
|
||||||
|
professions: [],
|
||||||
|
technics: [],
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
export const reportForm = (overrides: Partial<IReportFormState> = {}): IReportFormState => ({
|
||||||
|
objectId: 41,
|
||||||
|
objectPath: 'Пятно / Раздел',
|
||||||
|
companyId: 3,
|
||||||
|
companyName: 'ТОО Строй',
|
||||||
|
shiftId: 1,
|
||||||
|
shiftName: 'Дневная',
|
||||||
|
reportDate: '2026-10-16',
|
||||||
|
reportNumber: 0,
|
||||||
|
comment: '',
|
||||||
|
works: [],
|
||||||
|
professions: [],
|
||||||
|
technics: [],
|
||||||
|
photos: [],
|
||||||
|
photoFolderUrl: undefined,
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { validateReportForm } from './reportValidation';
|
||||||
|
import { reportForm, resource, workItem } from './reportTestData';
|
||||||
|
|
||||||
|
describe('validateReportForm (тексты и порядок как в старом validate)', () => {
|
||||||
|
it('пустая форма — все обязательные поля по порядку', () => {
|
||||||
|
expect(
|
||||||
|
validateReportForm(reportForm({ objectId: undefined, companyId: undefined, shiftId: undefined, reportDate: '' }))
|
||||||
|
).toEqual([
|
||||||
|
'Выберите объект',
|
||||||
|
'Выберите компанию',
|
||||||
|
'Выберите смену',
|
||||||
|
'Укажите дату отчета',
|
||||||
|
'Добавьте хотя бы одну работу, профессию или технику'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('достаточно одной работы', () => {
|
||||||
|
expect(validateReportForm(reportForm({ works: [workItem()] }))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('простой персонала без комментария', () => {
|
||||||
|
const form = reportForm({ professions: [resource({ hoursType: 'Часы простоя', comment: ' ' })] });
|
||||||
|
expect(validateReportForm(form)).toEqual(['Персонал #1 (Сварщик): укажите комментарий для типа "Часы простоя"']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('простой и ремонт техники без комментария, номер — позиция в списке', () => {
|
||||||
|
const form = reportForm({
|
||||||
|
technics: [
|
||||||
|
resource({ title: 'Кран', hoursType: 'Часы работы' }),
|
||||||
|
resource({ title: 'Экскаватор', hoursType: 'Часы ремонта' }),
|
||||||
|
resource({ title: 'Бульдозер', hoursType: 'Часы простоя', comment: 'ждали бетон' })
|
||||||
|
]
|
||||||
|
});
|
||||||
|
expect(validateReportForm(form)).toEqual(['Техника #2 (Экскаватор): укажите комментарий для типа "Часы ремонта"']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('персонал и техника внутри работы не проверяются (у них нет типа часов)', () => {
|
||||||
|
const form = reportForm({
|
||||||
|
works: [workItem({ professions: [resource({ hoursType: 'Часы простоя' })] })]
|
||||||
|
});
|
||||||
|
expect(validateReportForm(form)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import {
|
||||||
|
createSharePointReportRepository,
|
||||||
|
fileAddUrl,
|
||||||
|
itemsQueryUrl,
|
||||||
|
photoFolderServerRelativeUrl,
|
||||||
|
pickAccess
|
||||||
|
} from './sharePointReportRepository';
|
||||||
|
import { createFakeHttpClient, IFakeRoute } from '../../shared/testing/fakeHttpClient';
|
||||||
|
|
||||||
|
const SITE = 'https://tnsukz.sharepoint.com/sites/stroy';
|
||||||
|
|
||||||
|
const repositoryWith = (routes: IFakeRoute[]): {
|
||||||
|
client: ReturnType<typeof createFakeHttpClient>;
|
||||||
|
repository: ReturnType<typeof createSharePointReportRepository>;
|
||||||
|
} => {
|
||||||
|
const client = createFakeHttpClient(routes);
|
||||||
|
const repository = createSharePointReportRepository({
|
||||||
|
client,
|
||||||
|
siteUrl: SITE,
|
||||||
|
userId: 42,
|
||||||
|
webServerRelativeUrl: '/sites/stroy/'
|
||||||
|
});
|
||||||
|
return { client, repository };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('адреса SharePoint (1:1 со stroy-bot-report)', () => {
|
||||||
|
it('$top=5000 первым, затем $filter и $select', () => {
|
||||||
|
expect(itemsQueryUrl(SITE, 'Объекты', { filter: "project eq 'Сункар'", select: 'Id,Title,parentId' })).toEqual(
|
||||||
|
`${SITE}/_api/web/lists/getbytitle('Объекты')/items?$top=5000&$filter=project eq 'Сункар'&$select=Id,Title,parentId`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('папка фото DocLib/{projectId}_{номер}, апостроф в имени файла удваивается', () => {
|
||||||
|
const folder = photoFolderServerRelativeUrl('/sites/stroy/', 5, 8);
|
||||||
|
expect(folder).toEqual('/sites/stroy/DocLib/5_8');
|
||||||
|
expect(fileAddUrl(SITE, folder, "it's.jpg")).toEqual(
|
||||||
|
`${SITE}/_api/web/GetFolderByServerRelativeUrl('/sites/stroy/DocLib/5_8')/Files/add(url='it''s.jpg',overwrite=true)`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pickAccess', () => {
|
||||||
|
const row = { Id: 1, projectId: 5, read: true, edit0: false, aproove: false };
|
||||||
|
|
||||||
|
it('поле пользователя ищется под разными именами', () => {
|
||||||
|
expect(pickAccess([{ ...row, userIdId: 42 }], 42)?.read).toBe(true);
|
||||||
|
expect(pickAccess([{ ...row, UserId: 42 }], 42)?.read).toBe(true);
|
||||||
|
expect(pickAccess([{ ...row, userId: 7 }], 42)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('edit0 → edit', () => {
|
||||||
|
expect(pickAccess([{ ...row, userId: 42, edit0: true }], 42)).toEqual({
|
||||||
|
id: 1,
|
||||||
|
userId: 42,
|
||||||
|
project: '5',
|
||||||
|
read: true,
|
||||||
|
edit: true,
|
||||||
|
aproove: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('справочники «по проекту»', () => {
|
||||||
|
const allCompanies = { match: "getbytitle('Компании')", response: { body: { value: [{ Id: 1, Title: 'А' }, { Id: 2, Title: 'Б' }] } } };
|
||||||
|
|
||||||
|
it('есть привязки — только привязанные компании', async () => {
|
||||||
|
const { repository } = repositoryWith([
|
||||||
|
{ match: 'Компании по проекту', response: { body: { value: [{ companyIdId: 2 }] } } },
|
||||||
|
allCompanies
|
||||||
|
]);
|
||||||
|
expect((await repository.getCompaniesWithFallback(5)).map((c) => c.Id)).toEqual([2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('привязок нет или список недоступен — все компании', async () => {
|
||||||
|
const empty = repositoryWith([{ match: 'Компании по проекту', response: { body: { value: [] } } }, allCompanies]);
|
||||||
|
const broken = repositoryWith([{ match: 'Компании по проекту', response: { status: 404 } }, allCompanies]);
|
||||||
|
expect(await empty.repository.getCompaniesWithFallback(5)).toHaveLength(2);
|
||||||
|
expect(await broken.repository.getCompaniesWithFallback(5)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('uploadPhotos', () => {
|
||||||
|
const photo = { name: 'a.jpg', preview: '', buffer: new ArrayBuffer(4) };
|
||||||
|
|
||||||
|
it('папки нет — создаётся, затем файлы; возвращается ссылка на папку', async () => {
|
||||||
|
const { client, repository } = repositoryWith([
|
||||||
|
{ match: '/Files/add', response: { status: 200 } },
|
||||||
|
{ match: 'GetFolderByServerRelativeUrl', response: { status: 404 } },
|
||||||
|
{ match: '/_api/web/folders', response: { status: 201 } }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(await repository.uploadPhotos(5, 8, [photo])).toEqual(`${SITE}/DocLib/5_8`);
|
||||||
|
expect(client.requests.map((r) => r.request.method ?? 'GET')).toEqual(['GET', 'POST', 'POST']);
|
||||||
|
expect(JSON.parse(String(client.requests[1].request.body))).toEqual({ ServerRelativeUrl: '/sites/stroy/DocLib/5_8' });
|
||||||
|
expect(client.requests[2].request.headers).toEqual(expect.objectContaining({ 'Content-Type': 'application/octet-stream' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('папка уже есть — не создаётся', async () => {
|
||||||
|
const { client, repository } = repositoryWith([
|
||||||
|
{ match: '/Files/add', response: { status: 200 } },
|
||||||
|
{ match: 'GetFolderByServerRelativeUrl', response: { status: 200 } }
|
||||||
|
]);
|
||||||
|
await repository.uploadPhotos(5, 8, [photo]);
|
||||||
|
expect(client.requests.some((r) => r.url.endsWith('/_api/web/folders'))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,10 +13,15 @@ export interface IFakeResponse {
|
|||||||
body?: unknown;
|
body?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Правило ответа: если URL содержит `match` — вернуть `response`. Проверяются по порядку. */
|
/**
|
||||||
|
* Правило ответа: если URL содержит `match` — вернуть `response`. Правила проверяются по порядку.
|
||||||
|
* Вместо `response` можно передать `respond(callNumber)` — ответ, который зависит от номера вызова
|
||||||
|
* этого правила (1, 2, 3…), например чтобы каждая созданная строка получала свой id.
|
||||||
|
*/
|
||||||
export interface IFakeRoute {
|
export interface IFakeRoute {
|
||||||
match: string;
|
match: string;
|
||||||
response: IFakeResponse;
|
response?: IFakeResponse;
|
||||||
|
respond?: (callNumber: number) => IFakeResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IFakeHttpClient extends IHttpClient {
|
export interface IFakeHttpClient extends IHttpClient {
|
||||||
@@ -37,6 +42,7 @@ function toResponse({ status = 200, body }: IFakeResponse): IHttpResponse {
|
|||||||
|
|
||||||
export function createFakeHttpClient(routes: IFakeRoute[] = []): IFakeHttpClient {
|
export function createFakeHttpClient(routes: IFakeRoute[] = []): IFakeHttpClient {
|
||||||
const requests: IRecordedRequest[] = [];
|
const requests: IRecordedRequest[] = [];
|
||||||
|
const callCounts = new Map<IFakeRoute, number>();
|
||||||
return {
|
return {
|
||||||
requests,
|
requests,
|
||||||
fetch: async (url: string, request: IHttpRequest = {}): Promise<IHttpResponse> => {
|
fetch: async (url: string, request: IHttpRequest = {}): Promise<IHttpResponse> => {
|
||||||
@@ -45,7 +51,9 @@ export function createFakeHttpClient(routes: IFakeRoute[] = []): IFakeHttpClient
|
|||||||
if (!route) {
|
if (!route) {
|
||||||
throw new Error(`fakeHttpClient: нет ответа для ${url}`);
|
throw new Error(`fakeHttpClient: нет ответа для ${url}`);
|
||||||
}
|
}
|
||||||
return toResponse(route.response);
|
const callNumber = (callCounts.get(route) ?? 0) + 1;
|
||||||
|
callCounts.set(route, callNumber);
|
||||||
|
return toResponse(route.respond ? route.respond(callNumber) : route.response ?? {});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user