diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml new file mode 100644 index 0000000..2977005 --- /dev/null +++ b/.gitea/workflows/test.yml @@ -0,0 +1,40 @@ +name: Tests + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +jobs: + frontend-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js 22 + uses: actions/setup-node@v3 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: | + npm config set fetch-retries 5 + npm config set fetch-retry-mintimeout 10000 + npm config set fetch-retry-maxtimeout 120000 + for i in 1 2 3; do + npm install --legacy-peer-deps && break + echo "Attempt $i failed, retrying in 20s..." + sleep 20 + done + + # heft test = сборка (sass + typescript + lint) и следом Jest. Это НЕ то же + # самое, что npm run build: там дополнительно пакуется .sppkg, а для проверки + # на PR упаковка не нужна. + - name: Run unit tests (Jest via heft) + run: npx heft test --clean diff --git a/src/webparts/requestPortal/features/myRequests/pageCache.test.ts b/src/webparts/requestPortal/features/myRequests/pageCache.test.ts new file mode 100644 index 0000000..729e595 --- /dev/null +++ b/src/webparts/requestPortal/features/myRequests/pageCache.test.ts @@ -0,0 +1,105 @@ +import { IMyRequest } from './models/IMyRequest'; +import { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache'; + +function request(over: Partial = {}): IMyRequest { + return { + id: 1, + title: 'Заявка №1', + status: 'Черновик', + authorEmail: 'ivanov@tnsu.kz', + authorName: 'Иванов Иван', + created: '2026-08-07T10:00:00Z', + requestTypeId: 1, + requestTypeName: 'Приём на работу', + section: 'Кадры', + formKey: 'hire', + kind: 'own', + ...over, + }; +} + +function page(over: Partial = {}): IPage { + return { rows: [request()], totalRows: 1, ...over }; +} + +describe('pageKey', () => { + it('склеивает раздел и части ключа через разделитель', () => { + expect(pageKey('own', [1, 'Черновик'])).toBe('own|1|Черновик'); + }); + + it('раздел всегда первый — ключи разных таблиц не пересекаются', () => { + expect(pageKey('own', [1])).not.toBe(pageKey('approvals', [1])); + }); + + it('разные части дают разные ключи', () => { + expect(pageKey('own', [1])).not.toBe(pageKey('own', [2])); + }); + + it('раздел без частей — просто раздел', () => { + expect(pageKey('search', [])).toBe('search'); + }); +}); + +describe('readPage / writePage', () => { + it('записанная страница читается по тому же ключу', () => { + const key = pageKey('own', ['read-write']); + const stored = page(); + writePage(key, stored); + expect(readPage(key)).toBe(stored); + }); + + it('по незнакомому ключу возвращается undefined', () => { + expect(readPage(pageKey('own', ['nothing-here']))).toBeUndefined(); + }); + + it('повторная запись по тому же ключу перезаписывает страницу', () => { + const key = pageKey('own', ['overwrite']); + writePage(key, page({ totalRows: 1 })); + writePage(key, page({ totalRows: 2 })); + expect(readPage(key)?.totalRows).toBe(2); + }); + + it('кеш переживает «размонтирование» — хранилище на уровне модуля, а не компонента', () => { + const key = pageKey('approvals', ['module-level']); + writePage(key, page({ totalRows: 7 })); + // Между записью и чтением компонент таблицы мог быть уничтожен целиком — + // на модульное хранилище это не влияет (ради этого оно и вынесено из useRef). + expect(readPage(key)?.totalRows).toBe(7); + }); +}); + +describe('pageSignature', () => { + it('одинаковое содержимое даёт одинаковую подпись (перерисовывать нечего)', () => { + expect(pageSignature(page())).toBe(pageSignature(page())); + }); + + it('изменение общего числа строк меняет подпись', () => { + expect(pageSignature(page({ totalRows: 1 }))).not.toBe(pageSignature(page({ totalRows: 2 }))); + }); + + it('изменение статуса заявки меняет подпись', () => { + const before = page({ rows: [request({ status: 'Черновик' })] }); + const after = page({ rows: [request({ status: 'На согласовании' })] }); + expect(pageSignature(before)).not.toBe(pageSignature(after)); + }); + + it('изменение порядка строк меняет подпись', () => { + const a = request({ id: 1 }); + const b = request({ id: 2 }); + expect(pageSignature(page({ rows: [a, b], totalRows: 2 }))).not.toBe( + pageSignature(page({ rows: [b, a], totalRows: 2 })) + ); + }); + + it('поля, которых нет в таблице, на подпись не влияют', () => { + // kind/section/formKey в колонках не показываются — их изменение не должно + // приводить к перерисовке (иначе таблица моргала бы на каждом фоновом обновлении). + const before = page({ rows: [request({ section: 'Кадры', kind: 'own' })] }); + const after = page({ rows: [request({ section: 'ИТ', kind: 'approval' })] }); + expect(pageSignature(before)).toBe(pageSignature(after)); + }); + + it('пустая страница имеет стабильную подпись', () => { + expect(pageSignature({ rows: [], totalRows: 0 })).toBe(pageSignature({ rows: [], totalRows: 0 })); + }); +}); diff --git a/src/webparts/requestPortal/shared/approval/models/statuses.test.ts b/src/webparts/requestPortal/shared/approval/models/statuses.test.ts new file mode 100644 index 0000000..30f8d72 --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/models/statuses.test.ts @@ -0,0 +1,66 @@ +import { + APPROVAL_PHASE, + APPROVAL_PHASE_CHOICES, + APPROVAL_ROW_STATUS_CHOICES, + EDITABLE_STATUSES, + REQUEST_STATUS, + REQUEST_STATUS_CHOICES, + isEditableStatus, +} from './statuses'; + +describe('isEditableStatus', () => { + it('новая заявка (статуса ещё нет) — редактируема', () => { + expect(isEditableStatus(undefined)).toBe(true); + expect(isEditableStatus('')).toBe(true); + }); + + it('черновик/возвращена/отклонена — редактируемы (можно доработать и переотправить)', () => { + expect(isEditableStatus(REQUEST_STATUS.draft)).toBe(true); + expect(isEditableStatus(REQUEST_STATUS.returned)).toBe(true); + expect(isEditableStatus(REQUEST_STATUS.rejected)).toBe(true); + }); + + it('заявка в работе или завершённая — только чтение', () => { + expect(isEditableStatus(REQUEST_STATUS.onApproval)).toBe(false); + expect(isEditableStatus(REQUEST_STATUS.approved)).toBe(false); + expect(isEditableStatus(REQUEST_STATUS.execution)).toBe(false); + expect(isEditableStatus(REQUEST_STATUS.closed)).toBe(false); + }); + + it('незнакомый статус считается нередактируемым (безопасный дефолт)', () => { + expect(isEditableStatus('какой-то новый статус')).toBe(false); + }); +}); + +// Наборы Choice-значений держатся в синхроне с provisioning.json списков — +// расхождение ломает запись (SharePoint отвергает значение вне Choice). +describe('наборы Choice-значений', () => { + it('все редактируемые статусы существуют в REQUEST_STATUS', () => { + const known = Object.keys(REQUEST_STATUS).map((k) => REQUEST_STATUS[k as keyof typeof REQUEST_STATUS]); + EDITABLE_STATUSES.forEach((s) => expect(known).toContain(s)); + }); + + it('редактируемые статусы — подмножество статусов ЗАЯВКИ', () => { + EDITABLE_STATUSES.forEach((s) => expect(REQUEST_STATUS_CHOICES).toContain(s)); + }); + + it('в статусах ЗАЯВКИ нет состояний отдельного шага маршрута', () => { + expect(REQUEST_STATUS_CHOICES).not.toContain(REQUEST_STATUS.queued); + expect(REQUEST_STATUS_CHOICES).not.toContain(REQUEST_STATUS.onExecution); + expect(REQUEST_STATUS_CHOICES).not.toContain(REQUEST_STATUS.executed); + }); + + it('в статусах СТРОКИ МАРШРУТА нет агрегатных состояний заявки', () => { + expect(APPROVAL_ROW_STATUS_CHOICES).not.toContain(REQUEST_STATUS.execution); + expect(APPROVAL_ROW_STATUS_CHOICES).not.toContain(REQUEST_STATUS.closed); + }); + + it('оба набора статусов без повторов', () => { + expect(new Set(REQUEST_STATUS_CHOICES).size).toBe(REQUEST_STATUS_CHOICES.length); + expect(new Set(APPROVAL_ROW_STATUS_CHOICES).size).toBe(APPROVAL_ROW_STATUS_CHOICES.length); + }); + + it('фазы маршрута перечислены полностью', () => { + expect(APPROVAL_PHASE_CHOICES).toEqual([APPROVAL_PHASE.approval, APPROVAL_PHASE.execution]); + }); +}); diff --git a/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx b/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx index 2ae9fd0..0ed724e 100644 --- a/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx +++ b/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx @@ -1,10 +1,11 @@ import * as React from 'react'; import styles from './approval.module.scss'; import { Icon } from '../../ui'; -import { IUseMatrixEditor } from '../useMatrixEditor'; +import { IUseMatrixEditor, matrixRowKeys } from '../useMatrixEditor'; import { ISiteUser } from '../models/ISiteUser'; import { StatusBadge } from './StatusBadge'; import { UserPicker } from './UserPicker'; +import { useFlipRows } from './useFlipRows'; export interface IApprovalMatrixProps { editor: IUseMatrixEditor; @@ -30,6 +31,17 @@ export const ApprovalMatrix: React.FC = ({ }) => { const { rows, loading, error, editable, blocks } = editor; + // Ключи строк и анимация их перестановки. Ключи обязаны быть устойчивыми + // (не индекс) — иначе React переписывает строки на месте, и «переезжать» + // при перестановке шага будет нечему (см. matrixRowKeys / useFlipRows). + const rowKeys = matrixRowKeys(rows); + // Шаг, который двигает пользователь: при обмене переезжают ОБА соседних шага, + // но подсветить и поднять надо только тот, по чьей стрелке кликнули, — иначе + // подсвечивается пол-таблицы и непонятно, что именно переехало. Запоминаем + // ключи блока ДО перестановки: после неё индексы уже другие. + const [activeKeys, setActiveKeys] = React.useState([]); + const registerRow = useFlipRows(activeKeys); + // Строка → шаг, которому она принадлежит. Нужно, чтобы отрисовать ячейку // «Шаг» один раз на весь шаг (rowSpan) и понять, можно ли двигать его выше/ниже. const blockByRow: Array<{ index: number; start: number; size: number }> = []; @@ -39,6 +51,31 @@ export const ApprovalMatrix: React.FC = ({ } }); + const moveBlock = (rowIndex: number, direction: 'up' | 'down'): void => { + const b = blockByRow[rowIndex]; + setActiveKeys(rowKeys.slice(b.start, b.start + b.size)); + if (direction === 'up') editor.moveBlockUp(rowIndex); + else editor.moveBlockDown(rowIndex); + }; + + // Остальные правки состава — не перестановка: сбрасываем «активный» шаг, + // иначе подъём и подсветка достались бы случайной строке, которая сдвинулась + // из-за перенумерации после удаления. + const removeRow = (rowIndex: number): void => { + setActiveKeys([]); + editor.remove(rowIndex); + }; + + const toggleParallel = (rowIndex: number): void => { + setActiveKeys([]); + editor.toggleParallel(rowIndex); + }; + + const addApprover = (user: ISiteUser): void => { + setActiveKeys([]); + editor.addApprover(user); + }; + // Пустые колонки не показываем — столбец из одних прочерков только шумит. const hasStatuses = rows.some((r) => r.status && r.status !== '—'); const hasDates = rows.some((r) => !!r.date); @@ -73,7 +110,11 @@ export const ApprovalMatrix: React.FC = ({ const block = blockByRow[i]; const isBlockStart = block.start === i; return ( - 1 ? styles.parallelRow : undefined}> + 1 ? styles.parallelRow : undefined} + > {isBlockStart && ( {r.order} @@ -104,7 +145,7 @@ export const ApprovalMatrix: React.FC = ({ type="button" className={styles.iconBtn} disabled={block.index === 0} - onClick={() => editor.moveBlockUp(i)} + onClick={() => moveBlock(i, 'up')} title="Шаг выше" > @@ -113,7 +154,7 @@ export const ApprovalMatrix: React.FC = ({ type="button" className={styles.iconBtn} disabled={block.index === blocks.length - 1} - onClick={() => editor.moveBlockDown(i)} + onClick={() => moveBlock(i, 'down')} title="Шаг ниже" > @@ -126,7 +167,7 @@ export const ApprovalMatrix: React.FC = ({ type="button" className={`${styles.iconBtn} ${r.parallelWithPrev ? styles.iconBtnActive : ''}`} disabled={i === 0} - onClick={() => editor.toggleParallel(i)} + onClick={() => toggleParallel(i)} title={ r.parallelWithPrev ? 'Отцепить: выполнять после предыдущего шага' @@ -138,7 +179,7 @@ export const ApprovalMatrix: React.FC = ({