From 072d246eedc99a17b96e0f4a138b32b9014f933e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Fri, 16 Oct 2026 10:08:07 +0500 Subject: [PATCH] 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) --- .../stroyBot/app/SectionPlaceholder.tsx | 8 - .../report/dataverseReportRepository.test.ts | 204 ++++++++++++++++++ .../pages/report/reportCollections.test.ts | 50 +++++ .../pages/report/reportPayload.test.ts | 51 +++++ .../stroyBot/pages/report/reportRules.test.ts | 136 ++++++++++++ .../stroyBot/pages/report/reportTestData.ts | 56 +++++ .../pages/report/reportValidation.test.ts | 43 ++++ .../report/sharePointReportRepository.test.ts | 106 +++++++++ .../stroyBot/shared/testing/fakeHttpClient.ts | 14 +- 9 files changed, 657 insertions(+), 11 deletions(-) delete mode 100644 src/webparts/stroyBot/app/SectionPlaceholder.tsx create mode 100644 src/webparts/stroyBot/pages/report/dataverseReportRepository.test.ts create mode 100644 src/webparts/stroyBot/pages/report/reportCollections.test.ts create mode 100644 src/webparts/stroyBot/pages/report/reportPayload.test.ts create mode 100644 src/webparts/stroyBot/pages/report/reportRules.test.ts create mode 100644 src/webparts/stroyBot/pages/report/reportTestData.ts create mode 100644 src/webparts/stroyBot/pages/report/reportValidation.test.ts create mode 100644 src/webparts/stroyBot/pages/report/sharePointReportRepository.test.ts diff --git a/src/webparts/stroyBot/app/SectionPlaceholder.tsx b/src/webparts/stroyBot/app/SectionPlaceholder.tsx deleted file mode 100644 index d2cbe7e..0000000 --- a/src/webparts/stroyBot/app/SectionPlaceholder.tsx +++ /dev/null @@ -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 = () => ( - -); diff --git a/src/webparts/stroyBot/pages/report/dataverseReportRepository.test.ts b/src/webparts/stroyBot/pages/report/dataverseReportRepository.test.ts new file mode 100644 index 0000000..786e321 --- /dev/null +++ b/src/webparts/stroyBot/pages/report/dataverseReportRepository.test.ts @@ -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)' + }); + }); +}); diff --git a/src/webparts/stroyBot/pages/report/reportCollections.test.ts b/src/webparts/stroyBot/pages/report/reportCollections.test.ts new file mode 100644 index 0000000..16d576b --- /dev/null +++ b/src/webparts/stroyBot/pages/report/reportCollections.test.ts @@ -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: 'Часы работы' }) + ]); + }); +}); diff --git a/src/webparts/stroyBot/pages/report/reportPayload.test.ts b/src/webparts/stroyBot/pages/report/reportPayload.test.ts new file mode 100644 index 0000000..efe5e62 --- /dev/null +++ b/src/webparts/stroyBot/pages/report/reportPayload.test.ts @@ -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: 'Часы работы' } } + ]); + }); +}); diff --git a/src/webparts/stroyBot/pages/report/reportRules.test.ts b/src/webparts/stroyBot/pages/report/reportRules.test.ts new file mode 100644 index 0000000..bcb0879 --- /dev/null +++ b/src/webparts/stroyBot/pages/report/reportRules.test.ts @@ -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(['Бульдозер', 'Экскаватор', 'Автокран']); + }); +}); diff --git a/src/webparts/stroyBot/pages/report/reportTestData.ts b/src/webparts/stroyBot/pages/report/reportTestData.ts new file mode 100644 index 0000000..9c2b199 --- /dev/null +++ b/src/webparts/stroyBot/pages/report/reportTestData.ts @@ -0,0 +1,56 @@ +import { IReportAccess, IReportFormState, IResourceItem, IWorkItem } from './reportTypes'; + +// Заготовки данных для тестов формы отчёта (используются только в *.test.ts). + +export const access = (overrides: Partial = {}): IReportAccess => ({ + id: 1, + userId: 42, + project: '5', + read: false, + edit: false, + aproove: false, + ...overrides +}); + +export const resource = (overrides: Partial = {}): IResourceItem => ({ + id: 'r1', + resourceId: 7, + title: 'Сварщик', + count: 2, + hours: 8, + hoursType: 'Часы работы', + comment: '', + ...overrides +}); + +export const workItem = (overrides: Partial = {}): 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 => ({ + objectId: 41, + objectPath: 'Пятно / Раздел', + companyId: 3, + companyName: 'ТОО Строй', + shiftId: 1, + shiftName: 'Дневная', + reportDate: '2026-10-16', + reportNumber: 0, + comment: '', + works: [], + professions: [], + technics: [], + photos: [], + photoFolderUrl: undefined, + ...overrides +}); diff --git a/src/webparts/stroyBot/pages/report/reportValidation.test.ts b/src/webparts/stroyBot/pages/report/reportValidation.test.ts new file mode 100644 index 0000000..771d372 --- /dev/null +++ b/src/webparts/stroyBot/pages/report/reportValidation.test.ts @@ -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([]); + }); +}); diff --git a/src/webparts/stroyBot/pages/report/sharePointReportRepository.test.ts b/src/webparts/stroyBot/pages/report/sharePointReportRepository.test.ts new file mode 100644 index 0000000..cbab647 --- /dev/null +++ b/src/webparts/stroyBot/pages/report/sharePointReportRepository.test.ts @@ -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; + repository: ReturnType; +} => { + 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); + }); +}); diff --git a/src/webparts/stroyBot/shared/testing/fakeHttpClient.ts b/src/webparts/stroyBot/shared/testing/fakeHttpClient.ts index 6fb67d0..d5857b4 100644 --- a/src/webparts/stroyBot/shared/testing/fakeHttpClient.ts +++ b/src/webparts/stroyBot/shared/testing/fakeHttpClient.ts @@ -13,10 +13,15 @@ export interface IFakeResponse { body?: unknown; } -/** Правило ответа: если URL содержит `match` — вернуть `response`. Проверяются по порядку. */ +/** + * Правило ответа: если URL содержит `match` — вернуть `response`. Правила проверяются по порядку. + * Вместо `response` можно передать `respond(callNumber)` — ответ, который зависит от номера вызова + * этого правила (1, 2, 3…), например чтобы каждая созданная строка получала свой id. + */ export interface IFakeRoute { match: string; - response: IFakeResponse; + response?: IFakeResponse; + respond?: (callNumber: number) => IFakeResponse; } export interface IFakeHttpClient extends IHttpClient { @@ -37,6 +42,7 @@ function toResponse({ status = 200, body }: IFakeResponse): IHttpResponse { export function createFakeHttpClient(routes: IFakeRoute[] = []): IFakeHttpClient { const requests: IRecordedRequest[] = []; + const callCounts = new Map(); return { requests, fetch: async (url: string, request: IHttpRequest = {}): Promise => { @@ -45,7 +51,9 @@ export function createFakeHttpClient(routes: IFakeRoute[] = []): IFakeHttpClient if (!route) { 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 ?? {}); } }; }