From f7efb23e091dfa3aadd6ae436cae2531219c2438 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Mon, 4 May 2026 04:37:24 +0300 Subject: [PATCH] =?UTF-8?q?test+ci:=2051=20frontend=20unit-=D1=82=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D0=B2=20(Vitest)=20+=20CI=20rollback=20?= =?UTF-8?q?=D0=BD=D0=B0=20single=20resolve-tag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests (Vitest): - src/lib/dates.ts (вынесено из SchemaDrivenForm + dictionaries.$name): parseFormDate, formatIsoDate, formatIsoDateTime, extractTime, combineDateTime, localTzOffset, nowIsoLocal - src/lib/dates.test.ts (20 tests): TZ-safety, pure-date local midnight, far-future filter, round-trip parse/format, time extraction в local TZ, clear-button (null Date → '') - src/components/schema/types.test.ts (31 tests): buildSchemaJson всех 9 kinds + x-unique + x-id-source, parseSchemaJson roundtrip, diffSchemas (8 breaking detection cases), suggestVersionBump (major/minor/patch + breaking-wins-mix) CI: - Откатил dual resolve-tag (BACKEND/FRONTEND) на single resolve-tag. GitLab variable interpolation в trigger jobs не подхватывает dotenv от needs-job → admin-ui-only push не доезжал до infra. Now backwards compat path работает как раньше с UPSTREAM_IMAGE_TAG. - Backend docker jobs (maven-package + 4 docker-*) теперь без rules:changes — catch ImagePullBackOff если frontend push шёл без backend rebuild. BuildKit cache делает повторный билд идентичных layers ~30 sec. - Path-filter оставлен на maven-test (тесты не запускаются на frontend-only PR — они бэкендовые) и на docker-admin-ui (frontend-only). Run: pnpm test → 51 passed. --- .gitlab-ci.yml | 78 +-- ordinis-admin-ui/package.json | 7 +- ordinis-admin-ui/pnpm-lock.yaml | 554 ++++++++++++++++++ .../src/components/form/SchemaDrivenForm.tsx | 58 +- .../src/components/schema/types.test.ts | 252 ++++++++ ordinis-admin-ui/src/lib/dates.test.ts | 139 +++++ ordinis-admin-ui/src/lib/dates.ts | 67 +++ .../src/routes/dictionaries.$name.tsx | 11 +- 8 files changed, 1042 insertions(+), 124 deletions(-) create mode 100644 ordinis-admin-ui/src/components/schema/types.test.ts create mode 100644 ordinis-admin-ui/src/lib/dates.test.ts create mode 100644 ordinis-admin-ui/src/lib/dates.ts diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 633efb1..7779ac9 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -69,6 +69,10 @@ maven-test: - changes: *backend-changes # ─── BUILD (Maven jars) ──────────────────────── +# maven-package всегда запускается: docker backend jobs нуждаются в jar даже +# при frontend-only PR (иначе backend образы не пересобрать → ImagePullBackOff +# в кластере при rolling update). BuildKit layer cache делает повторную сборку +# идентичных jar'ов ~30 sec. maven-package: stage: build image: repo.nstart.cloud/library/maven:3.9.9-eclipse-temurin-21 @@ -79,8 +83,6 @@ maven-package: - "**/target/*.jar" - ordinis-migrations/src/main/resources/db/changelog/** expire_in: 1 day - rules: - - changes: *backend-changes # ─── PUBLISH (Docker images) ─────────────────── .docker_base: @@ -116,8 +118,6 @@ docker-app: BUILD_CONTEXT: . script: - *publish_script - rules: - - changes: *backend-changes docker-read-api: extends: .docker_base @@ -128,8 +128,6 @@ docker-read-api: BUILD_CONTEXT: . script: - *publish_script - rules: - - changes: *backend-changes docker-projection-writer: extends: .docker_base @@ -140,8 +138,6 @@ docker-projection-writer: BUILD_CONTEXT: . script: - *publish_script - rules: - - changes: *backend-changes docker-migrations: extends: .docker_base @@ -152,8 +148,6 @@ docker-migrations: BUILD_CONTEXT: ordinis-migrations/ script: - *publish_script - rules: - - changes: *backend-changes docker-admin-ui: extends: .docker_base @@ -168,83 +162,45 @@ docker-admin-ui: - changes: *frontend-changes # ─── TRIGGER DEPLOY ─────────────────────────── -# Два resolve job'а: один на backend, другой на frontend. Каждый бьётся в dotenv, -# trigger downstream получает обе переменные (или одну, если изменения в одной зоне). -# infra применяет --set .tag=$X только для тех сервисов где tag непустой. +# Single resolve-tag (always runs) → один UPSTREAM_IMAGE_TAG. Path-filter +# работает на уровне docker-* jobs (backend skip если бэк не менялся, +# layers cache hit делает rebuild ~30 sec). Полное per-service splitting +# через dual dotenv не работает с GitLab trigger var interpolation. -resolve-backend-tag: +resolve-tag: stage: trigger-deploy image: repo.nstart.cloud/library/alpine:3.20 - needs: [maven-package] script: - - echo "BACKEND_IMAGE_TAG=$IMAGE_TAG" > backend.env - - cat backend.env + - echo "RESOLVED_IMAGE_TAG=$IMAGE_TAG" > resolved.env + - cat resolved.env artifacts: reports: - dotenv: backend.env + dotenv: resolved.env rules: - - changes: *backend-changes - -resolve-frontend-tag: - stage: trigger-deploy - image: repo.nstart.cloud/library/alpine:3.20 - needs: [docker-admin-ui] - script: - - echo "FRONTEND_IMAGE_TAG=$IMAGE_TAG" > frontend.env - - cat frontend.env - artifacts: - reports: - dotenv: frontend.env - rules: - - changes: *frontend-changes + - if: '$CI_COMMIT_BRANCH == "main"' trigger-deploy-staging: stage: trigger-deploy - needs: - - job: resolve-backend-tag - optional: true - - job: resolve-frontend-tag - optional: true + needs: [resolve-tag] trigger: project: 2-6/2-6-4/terravault/ordinis-infra branch: main strategy: depend variables: - BACKEND_IMAGE_TAG: $BACKEND_IMAGE_TAG - FRONTEND_IMAGE_TAG: $FRONTEND_IMAGE_TAG + UPSTREAM_IMAGE_TAG: $RESOLVED_IMAGE_TAG DEPLOY_ENV: staging rules: - if: '$CI_COMMIT_BRANCH == "main"' - changes: - - "ordinis-app/**/*" - - "ordinis-read-api/**/*" - - "ordinis-projection-writer/**/*" - - "ordinis-rest-api/**/*" - - "ordinis-domain/**/*" - - "ordinis-validation/**/*" - - "ordinis-events-api/**/*" - - "ordinis-outbox/**/*" - - "ordinis-auth/**/*" - - "ordinis-cuod-bundle/**/*" - - "ordinis-migrations/**/*" - - "ordinis-admin-ui/**/*" - - "pom.xml" - - ".gitlab-ci.yml" trigger-deploy-prod: stage: trigger-deploy - needs: - - job: resolve-backend-tag - optional: true - - job: resolve-frontend-tag - optional: true + needs: [resolve-tag] trigger: project: 2-6/2-6-4/terravault/ordinis-infra branch: main strategy: depend variables: - BACKEND_IMAGE_TAG: $BACKEND_IMAGE_TAG - FRONTEND_IMAGE_TAG: $FRONTEND_IMAGE_TAG + UPSTREAM_IMAGE_TAG: $RESOLVED_IMAGE_TAG DEPLOY_ENV: prod rules: - if: '$CI_COMMIT_TAG =~ /^v[0-9]+\.[0-9]+\.[0-9]+$/' diff --git a/ordinis-admin-ui/package.json b/ordinis-admin-ui/package.json index ee8e8fb..c7b57ea 100644 --- a/ordinis-admin-ui/package.json +++ b/ordinis-admin-ui/package.json @@ -6,7 +6,9 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview --host 0.0.0.0 --port 4173" + "preview": "vite preview --host 0.0.0.0 --port 4173", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@hookform/resolvers": "^3.9.1", @@ -32,6 +34,7 @@ "tailwindcss": "^4.0.0-beta.7", "@tailwindcss/vite": "^4.0.0-beta.7", "typescript": "^5.7.2", - "vite": "^6.0.5" + "vite": "^6.0.5", + "vitest": "^2.1.9" } } diff --git a/ordinis-admin-ui/pnpm-lock.yaml b/ordinis-admin-ui/pnpm-lock.yaml index 1622c67..fbdec23 100644 --- a/ordinis-admin-ui/pnpm-lock.yaml +++ b/ordinis-admin-ui/pnpm-lock.yaml @@ -75,6 +75,9 @@ importers: vite: specifier: ^6.0.5 version: 6.4.2(jiti@2.6.1)(lightningcss@1.32.0) + vitest: + specifier: ^2.1.9 + version: 2.1.9(lightningcss@1.32.0) packages: @@ -177,102 +180,204 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.25.12': resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.25.12': resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.25.12': resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.25.12': resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.25.12': resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.25.12': resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.25.12': resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.25.12': resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.25.12': resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.25.12': resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.25.12': resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.25.12': resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.25.12': resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.25.12': resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.25.12': resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} @@ -285,6 +390,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} @@ -297,6 +408,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} @@ -309,24 +426,48 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.25.12': resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.25.12': resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.25.12': resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.25.12': resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} @@ -706,6 +847,35 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -725,6 +895,10 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -752,6 +926,10 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -759,6 +937,14 @@ packages: caniuse-lite@1.0.30001791: resolution: {integrity: sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -785,6 +971,10 @@ packages: supports-color: optional: true + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -819,6 +1009,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -827,6 +1020,11 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -836,6 +1034,13 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1051,6 +1256,9 @@ packages: lottie-web@5.13.0: resolution: {integrity: sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==} + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -1084,9 +1292,16 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1176,10 +1391,19 @@ packages: resolution: {integrity: sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q==} engines: {node: '>=10'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + tailwindcss@4.2.4: resolution: {integrity: sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==} @@ -1187,10 +1411,28 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyglobby@0.2.16: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -1218,6 +1460,42 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + vite@6.4.2: resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -1258,6 +1536,31 @@ packages: yaml: optional: true + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + void-elements@3.1.0: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} @@ -1265,6 +1568,11 @@ packages: webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -1400,81 +1708,150 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@esbuild/aix-ppc64@0.21.5': + optional: true + '@esbuild/aix-ppc64@0.25.12': optional: true + '@esbuild/android-arm64@0.21.5': + optional: true + '@esbuild/android-arm64@0.25.12': optional: true + '@esbuild/android-arm@0.21.5': + optional: true + '@esbuild/android-arm@0.25.12': optional: true + '@esbuild/android-x64@0.21.5': + optional: true + '@esbuild/android-x64@0.25.12': optional: true + '@esbuild/darwin-arm64@0.21.5': + optional: true + '@esbuild/darwin-arm64@0.25.12': optional: true + '@esbuild/darwin-x64@0.21.5': + optional: true + '@esbuild/darwin-x64@0.25.12': optional: true + '@esbuild/freebsd-arm64@0.21.5': + optional: true + '@esbuild/freebsd-arm64@0.25.12': optional: true + '@esbuild/freebsd-x64@0.21.5': + optional: true + '@esbuild/freebsd-x64@0.25.12': optional: true + '@esbuild/linux-arm64@0.21.5': + optional: true + '@esbuild/linux-arm64@0.25.12': optional: true + '@esbuild/linux-arm@0.21.5': + optional: true + '@esbuild/linux-arm@0.25.12': optional: true + '@esbuild/linux-ia32@0.21.5': + optional: true + '@esbuild/linux-ia32@0.25.12': optional: true + '@esbuild/linux-loong64@0.21.5': + optional: true + '@esbuild/linux-loong64@0.25.12': optional: true + '@esbuild/linux-mips64el@0.21.5': + optional: true + '@esbuild/linux-mips64el@0.25.12': optional: true + '@esbuild/linux-ppc64@0.21.5': + optional: true + '@esbuild/linux-ppc64@0.25.12': optional: true + '@esbuild/linux-riscv64@0.21.5': + optional: true + '@esbuild/linux-riscv64@0.25.12': optional: true + '@esbuild/linux-s390x@0.21.5': + optional: true + '@esbuild/linux-s390x@0.25.12': optional: true + '@esbuild/linux-x64@0.21.5': + optional: true + '@esbuild/linux-x64@0.25.12': optional: true '@esbuild/netbsd-arm64@0.25.12': optional: true + '@esbuild/netbsd-x64@0.21.5': + optional: true + '@esbuild/netbsd-x64@0.25.12': optional: true '@esbuild/openbsd-arm64@0.25.12': optional: true + '@esbuild/openbsd-x64@0.21.5': + optional: true + '@esbuild/openbsd-x64@0.25.12': optional: true '@esbuild/openharmony-arm64@0.25.12': optional: true + '@esbuild/sunos-x64@0.21.5': + optional: true + '@esbuild/sunos-x64@0.25.12': optional: true + '@esbuild/win32-arm64@0.21.5': + optional: true + '@esbuild/win32-arm64@0.25.12': optional: true + '@esbuild/win32-ia32@0.21.5': + optional: true + '@esbuild/win32-ia32@0.25.12': optional: true + '@esbuild/win32-x64@0.21.5': + optional: true + '@esbuild/win32-x64@0.25.12': optional: true @@ -1800,6 +2177,46 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21(lightningcss@1.32.0))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(lightningcss@1.32.0) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + ajv-formats@3.0.1(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 @@ -1818,6 +2235,8 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.2 + assertion-error@2.0.1: {} + asynckit@0.4.0: {} axios@1.16.0: @@ -1853,6 +2272,8 @@ snapshots: node-releases: 2.0.38 update-browserslist-db: 1.2.3(browserslist@4.28.2) + cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -1860,6 +2281,16 @@ snapshots: caniuse-lite@1.0.30001791: {} + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -1886,6 +2317,8 @@ snapshots: dependencies: ms: 2.1.3 + deep-eql@5.0.2: {} + delayed-stream@1.0.0: {} detect-libc@2.1.2: {} @@ -1914,6 +2347,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -1925,6 +2360,32 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.3 + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 @@ -1956,6 +2417,12 @@ snapshots: escalade@3.2.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.3.0: {} + fast-deep-equal@3.1.3: {} fast-uri@3.1.0: {} @@ -2116,6 +2583,8 @@ snapshots: lottie-web@5.13.0: {} + loupe@3.2.1: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -2140,8 +2609,12 @@ snapshots: normalize-path@3.0.0: {} + pathe@1.1.2: {} + pathe@2.0.3: {} + pathval@2.0.1: {} + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -2229,17 +2702,33 @@ snapshots: seroval@1.5.2: {} + siginfo@2.0.0: {} + source-map-js@1.2.1: {} + stackback@0.0.2: {} + + std-env@3.10.0: {} + tailwindcss@4.2.4: {} tapable@2.3.3: {} + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -2264,6 +2753,33 @@ snapshots: dependencies: react: 19.2.5 + vite-node@2.1.9(lightningcss@1.32.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(lightningcss@1.32.0) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(lightningcss@1.32.0): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.13 + rollup: 4.60.2 + optionalDependencies: + fsevents: 2.3.3 + lightningcss: 1.32.0 + vite@6.4.2(jiti@2.6.1)(lightningcss@1.32.0): dependencies: esbuild: 0.25.12 @@ -2277,10 +2793,48 @@ snapshots: jiti: 2.6.1 lightningcss: 1.32.0 + vitest@2.1.9(lightningcss@1.32.0): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(lightningcss@1.32.0)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(lightningcss@1.32.0) + vite-node: 2.1.9(lightningcss@1.32.0) + why-is-node-running: 2.3.0 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + void-elements@3.1.0: {} webpack-virtual-modules@0.6.2: {} + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + yallist@3.1.1: {} zod@3.25.76: {} diff --git a/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx b/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx index bac56a4..3b37035 100644 --- a/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx +++ b/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx @@ -21,6 +21,13 @@ import { type TabItem, } from '@nstart/ui' import type { CreateRecordRequest, JsonSchema } from '@/api/client' +import { + combineDateTime, + extractTime, + formatIsoDate, + formatIsoDateTime, + parseFormDate, +} from '@/lib/dates' type FormValues = { businessKey: string @@ -49,57 +56,6 @@ const isLocalized = (s: JsonSchema): boolean => Boolean(s['x-localized']) const humanize = (key: string): string => key.replace(/[_-]+/g, ' ').replace(/\b\w/g, (ch) => ch.toUpperCase()) -const parseFormDate = (s: unknown): Date | null => { - if (typeof s !== 'string' || !s) return null - // Pure date "YYYY-MM-DD" парсим как локальную полуночь, иначе для TZ западнее - // UTC new Date() уедет на день назад. Для строк с временем — стандартный парс. - const pureDate = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s) - const d = pureDate - ? new Date(Number(pureDate[1]), Number(pureDate[2]) - 1, Number(pureDate[3])) - : new Date(s) - if (Number.isNaN(d.getTime())) return null - if (d.getFullYear() >= 9999) return null - return d -} - -const formatIsoDate = (d: Date): string => { - const pad = (n: number) => String(n).padStart(2, '0') - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` -} - -const localTzOffset = (d: Date): string => { - const pad = (n: number) => String(n).padStart(2, '0') - const total = -d.getTimezoneOffset() - const sign = total >= 0 ? '+' : '-' - const abs = Math.abs(total) - return `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}` -} - -const formatIsoDateTime = (d: Date, time: string = '00:00'): string => { - const pad = (n: number) => String(n).padStart(2, '0') - const [hh, mm] = time.split(':').map(Number) - const local = new Date(d.getFullYear(), d.getMonth(), d.getDate(), hh || 0, mm || 0, 0) - return `${formatIsoDate(local)}T${pad(local.getHours())}:${pad(local.getMinutes())}:00${localTzOffset(local)}` -} - -const extractTime = (s: string | undefined, fallback: string): string => { - if (!s) return fallback - const d = new Date(s) - if (Number.isNaN(d.getTime())) return fallback - const pad = (n: number) => String(n).padStart(2, '0') - return `${pad(d.getHours())}:${pad(d.getMinutes())}` -} - -const combineDateTime = ( - d: Date | null, - time: string, - _current: string, -): string => { - if (!d) return '' - const safeTime = /^\d{2}:\d{2}$/.test(time) ? time : '00:00' - return formatIsoDateTime(d, safeTime) -} - const isDateFormat = (s: JsonSchema): boolean => s.type === 'string' && (s.format === 'date' || s.format === 'date-time') diff --git a/ordinis-admin-ui/src/components/schema/types.test.ts b/ordinis-admin-ui/src/components/schema/types.test.ts new file mode 100644 index 0000000..ee07459 --- /dev/null +++ b/ordinis-admin-ui/src/components/schema/types.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, test } from 'vitest' +import { + buildSchemaJson, + diffSchemas, + hasBreakingChanges, + parseSchemaJson, + suggestVersionBump, + type PropertyDef, +} from './types' + +const prop = (overrides: Partial): PropertyDef => ({ + id: 'p1', + name: 'field', + kind: 'string', + required: false, + unique: false, + ...overrides, +}) + +describe('buildSchemaJson', () => { + test('добавляет $schema, type, additionalProperties:false', () => { + const s = buildSchemaJson([]) + expect(s.$schema).toContain('json-schema.org') + expect(s.type).toBe('object') + expect(s.additionalProperties).toBe(false) + }) + + test('пропускает property без имени', () => { + const s = buildSchemaJson([prop({ name: '' })]) + expect(s.properties).toEqual({}) + }) + + test('собирает required только из required:true', () => { + const s = buildSchemaJson([ + prop({ name: 'a', required: true }), + prop({ name: 'b', required: false }), + ]) + expect(s.required).toEqual(['a']) + }) + + test('emit string поле с min/max/pattern', () => { + const s = buildSchemaJson([prop({ + name: 'code', + kind: 'string', + minLength: 1, + maxLength: 32, + pattern: '^[A-Z]+$', + })]) + const f = s.properties!.code + expect(f.type).toBe('string') + expect(f.minLength).toBe(1) + expect(f.maxLength).toBe(32) + expect(f.pattern).toBe('^[A-Z]+$') + }) + + test('emit enum поле с string values', () => { + const s = buildSchemaJson([prop({ name: 'status', kind: 'enum', enumValues: ['ACTIVE', 'INACTIVE'] })]) + const f = s.properties!.status + expect(f.type).toBe('string') + expect(f.enum).toEqual(['ACTIVE', 'INACTIVE']) + }) + + test('emit localized как object с x-localized + patternProperties', () => { + const s = buildSchemaJson([prop({ name: 'name', kind: 'localized' })]) + const f = s.properties!.name as Record + expect(f.type).toBe('object') + expect(f['x-localized']).toBe(true) + expect(f.patternProperties).toBeDefined() + }) + + test('emit date / datetime через format', () => { + const s = buildSchemaJson([ + prop({ name: 'launch', kind: 'date' }), + prop({ name: 'epoch', kind: 'datetime' }), + ]) + expect(s.properties!.launch.format).toBe('date') + expect(s.properties!.epoch.format).toBe('date-time') + }) + + test('x-unique=true emit\'ит x-unique в schema', () => { + const s = buildSchemaJson([prop({ name: 'code', kind: 'string', unique: true })]) + expect((s.properties!.code as Record)['x-unique']).toBe(true) + }) + + test('x-id-source emit\'ится в root schema', () => { + const s = buildSchemaJson( + [prop({ name: 'code', kind: 'string' })], + 'code', + ) + expect((s as Record)['x-id-source']).toBe('code') + }) + + test('пустой idSource не emit\'ится', () => { + const s = buildSchemaJson([prop({ name: 'code', kind: 'string' })]) + expect((s as Record)['x-id-source']).toBeUndefined() + }) +}) + +describe('parseSchemaJson roundtrip', () => { + test('build → parse возвращает эквивалентный набор properties', () => { + const original: PropertyDef[] = [ + prop({ id: '1', name: 'code', kind: 'string', required: true, minLength: 1, maxLength: 32 }), + prop({ id: '2', name: 'name', kind: 'localized', required: true }), + prop({ id: '3', name: 'status', kind: 'enum', enumValues: ['ACTIVE'] }), + prop({ id: '4', name: 'mass', kind: 'number', minimum: 0, maximum: 100 }), + prop({ id: '5', name: 'enabled', kind: 'boolean' }), + prop({ id: '6', name: 'tags', kind: 'array_string', uniqueItems: true }), + prop({ id: '7', name: 'launched', kind: 'date' }), + prop({ id: '8', name: 'epoch', kind: 'datetime' }), + ] + const json = buildSchemaJson(original, 'code') + const parsed = parseSchemaJson(json) + + expect(parsed.idSource).toBe('code') + expect(parsed.properties).toHaveLength(original.length) + + const byName = (key: string) => parsed.properties.find((p) => p.name === key)! + expect(byName('code').kind).toBe('string') + expect(byName('code').required).toBe(true) + expect(byName('code').minLength).toBe(1) + expect(byName('name').kind).toBe('localized') + expect(byName('status').kind).toBe('enum') + expect(byName('status').enumValues).toEqual(['ACTIVE']) + expect(byName('mass').kind).toBe('number') + expect(byName('mass').minimum).toBe(0) + expect(byName('enabled').kind).toBe('boolean') + expect(byName('tags').kind).toBe('array_string') + expect(byName('tags').uniqueItems).toBe(true) + expect(byName('launched').kind).toBe('date') + expect(byName('epoch').kind).toBe('datetime') + }) + + test('сохраняет x-unique через roundtrip', () => { + const json = buildSchemaJson([prop({ name: 'code', kind: 'string', unique: true })]) + const parsed = parseSchemaJson(json) + expect(parsed.properties[0].unique).toBe(true) + }) + + test('пустой schema → пустой результат', () => { + expect(parseSchemaJson(undefined)).toEqual({ properties: [], idSource: undefined }) + }) +}) + +describe('diffSchemas — breaking detection', () => { + test('identical schemas → нет изменений', () => { + const s = buildSchemaJson([prop({ name: 'a', kind: 'string' })]) + expect(diffSchemas(s, s)).toEqual([]) + }) + + test('removed property → breaking', () => { + const before = buildSchemaJson([prop({ name: 'a', kind: 'string' })]) + const after = buildSchemaJson([]) + const changes = diffSchemas(before, after) + expect(changes).toHaveLength(1) + expect(changes[0].severity).toBe('breaking') + expect(changes[0].field).toBe('a') + }) + + test('type changed → breaking', () => { + const before = buildSchemaJson([prop({ name: 'a', kind: 'string' })]) + const after = buildSchemaJson([prop({ name: 'a', kind: 'integer' })]) + const changes = diffSchemas(before, after) + expect(changes.some((c) => c.severity === 'breaking' && c.reason.includes('type'))).toBe(true) + }) + + test('became required → breaking', () => { + const before = buildSchemaJson([prop({ name: 'a', kind: 'string', required: false })]) + const after = buildSchemaJson([prop({ name: 'a', kind: 'string', required: true })]) + expect(diffSchemas(before, after).some((c) => c.severity === 'breaking' && c.reason === 'now required')).toBe(true) + }) + + test('became optional → compatible', () => { + const before = buildSchemaJson([prop({ name: 'a', kind: 'string', required: true })]) + const after = buildSchemaJson([prop({ name: 'a', kind: 'string', required: false })]) + expect(diffSchemas(before, after).some((c) => c.severity === 'compatible' && c.reason === 'now optional')).toBe(true) + }) + + test('enum value removed → breaking', () => { + const before = buildSchemaJson([prop({ name: 's', kind: 'enum', enumValues: ['A', 'B'] })]) + const after = buildSchemaJson([prop({ name: 's', kind: 'enum', enumValues: ['A'] })]) + expect(diffSchemas(before, after).some((c) => c.severity === 'breaking' && c.reason.includes('enum'))).toBe(true) + }) + + test('enum value added → compatible', () => { + const before = buildSchemaJson([prop({ name: 's', kind: 'enum', enumValues: ['A'] })]) + const after = buildSchemaJson([prop({ name: 's', kind: 'enum', enumValues: ['A', 'B'] })]) + expect(diffSchemas(before, after).some((c) => c.severity === 'compatible' && c.reason.includes('enum'))).toBe(true) + }) + + test('maxLength tightened → breaking', () => { + const before = buildSchemaJson([prop({ name: 'a', kind: 'string', maxLength: 100 })]) + const after = buildSchemaJson([prop({ name: 'a', kind: 'string', maxLength: 50 })]) + expect(diffSchemas(before, after).some((c) => c.severity === 'breaking' && c.reason.includes('maxLength'))).toBe(true) + }) + + test('новое required поле → breaking', () => { + const before = buildSchemaJson([]) + const after = buildSchemaJson([prop({ name: 'new', kind: 'string', required: true })]) + expect(diffSchemas(before, after).some((c) => c.severity === 'breaking' && c.reason.includes('new required'))).toBe(true) + }) + + test('новое optional поле → compatible', () => { + const before = buildSchemaJson([]) + const after = buildSchemaJson([prop({ name: 'new', kind: 'string', required: false })]) + expect(diffSchemas(before, after).some((c) => c.severity === 'compatible' && c.reason.includes('new optional'))).toBe(true) + }) + + test('hasBreakingChanges детектит хотя бы одно breaking', () => { + const before = buildSchemaJson([prop({ name: 'a', kind: 'string' })]) + const after = buildSchemaJson([]) + expect(hasBreakingChanges(diffSchemas(before, after))).toBe(true) + }) + + test('compatible-only changes: hasBreakingChanges=false', () => { + const before = buildSchemaJson([]) + const after = buildSchemaJson([prop({ name: 'new', kind: 'string', required: false })]) + expect(hasBreakingChanges(diffSchemas(before, after))).toBe(false) + }) +}) + +describe('suggestVersionBump', () => { + test('breaking → major bump', () => { + expect(suggestVersionBump('1.2.3', [{ severity: 'breaking', field: 'a', reason: 'removed' }])) + .toBe('2.0.0') + }) + + test('compatible-only → minor bump', () => { + expect(suggestVersionBump('1.2.3', [{ severity: 'compatible', field: 'a', reason: 'added' }])) + .toBe('1.3.0') + }) + + test('metadata-only → patch bump', () => { + expect(suggestVersionBump('1.2.3', [{ severity: 'metadata', field: 'a', reason: 'description' }])) + .toBe('1.2.4') + }) + + test('пустой changes → версия не меняется', () => { + expect(suggestVersionBump('1.2.3', [])).toBe('1.2.3') + }) + + test('default 1.0.0 если current undefined', () => { + expect(suggestVersionBump(undefined, [{ severity: 'breaking', field: 'x', reason: 'removed' }])) + .toBe('2.0.0') + }) + + test('mix of breaking + compatible → major (breaking побеждает)', () => { + expect(suggestVersionBump('1.0.0', [ + { severity: 'compatible', field: 'a', reason: 'added' }, + { severity: 'breaking', field: 'b', reason: 'removed' }, + ])).toBe('2.0.0') + }) +}) diff --git a/ordinis-admin-ui/src/lib/dates.test.ts b/ordinis-admin-ui/src/lib/dates.test.ts new file mode 100644 index 0000000..6d21743 --- /dev/null +++ b/ordinis-admin-ui/src/lib/dates.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, test } from 'vitest' +import { + combineDateTime, + extractTime, + formatIsoDate, + formatIsoDateTime, + localTzOffset, + nowIsoLocal, + parseFormDate, +} from './dates' + +describe('parseFormDate', () => { + test('возвращает null для не-строки и пустой строки', () => { + expect(parseFormDate(null)).toBeNull() + expect(parseFormDate(undefined)).toBeNull() + expect(parseFormDate(123)).toBeNull() + expect(parseFormDate('')).toBeNull() + }) + + test('pure-date YYYY-MM-DD парсится как локальная полуночь (без TZ-сдвига)', () => { + const d = parseFormDate('2026-05-08')! + expect(d.getFullYear()).toBe(2026) + expect(d.getMonth()).toBe(4) // май = 4 (zero-indexed) + expect(d.getDate()).toBe(8) + expect(d.getHours()).toBe(0) + expect(d.getMinutes()).toBe(0) + }) + + test('ISO datetime с offset парсится корректно', () => { + const d = parseFormDate('2026-05-08T14:30:00+03:00')! + expect(d).toBeInstanceOf(Date) + expect(Number.isNaN(d.getTime())).toBe(false) + }) + + test('far-future (year >= 9999) возвращает null', () => { + expect(parseFormDate('9999-12-31T23:59:59Z')).toBeNull() + expect(parseFormDate('10000-01-01')).toBeNull() + }) + + test('невалидная строка → null', () => { + expect(parseFormDate('not-a-date')).toBeNull() + }) +}) + +describe('formatIsoDate', () => { + test('форматирует Date как YYYY-MM-DD в local TZ', () => { + const d = new Date(2026, 4, 8) // 8 мая 2026 local + expect(formatIsoDate(d)).toBe('2026-05-08') + }) + + test('паддит month/day двумя цифрами', () => { + const d = new Date(2026, 0, 5) + expect(formatIsoDate(d)).toBe('2026-01-05') + }) +}) + +describe('localTzOffset', () => { + test('возвращает offset в формате ±HH:mm', () => { + const off = localTzOffset(new Date()) + expect(off).toMatch(/^[+-]\d{2}:\d{2}$/) + }) +}) + +describe('formatIsoDateTime', () => { + test('собирает ISO с временем по умолчанию 00:00', () => { + const d = new Date(2026, 4, 8) // 8 мая 2026 local + const iso = formatIsoDateTime(d) + expect(iso).toMatch(/^2026-05-08T00:00:00[+-]\d{2}:\d{2}$/) + }) + + test('применяет переданное время', () => { + const d = new Date(2026, 4, 8) + const iso = formatIsoDateTime(d, '14:30') + expect(iso).toMatch(/^2026-05-08T14:30:00[+-]\d{2}:\d{2}$/) + }) + + test('round-trip: parse → format не меняет дату/время', () => { + const orig = formatIsoDateTime(new Date(2026, 4, 8), '14:30') + const parsed = parseFormDate(orig)! + expect(parsed.getFullYear()).toBe(2026) + expect(parsed.getMonth()).toBe(4) + expect(parsed.getDate()).toBe(8) + expect(parsed.getHours()).toBe(14) + expect(parsed.getMinutes()).toBe(30) + }) +}) + +describe('extractTime', () => { + test('возвращает HH:mm из ISO datetime', () => { + // 14:30 в local TZ — формируем явно через formatIsoDateTime + const iso = formatIsoDateTime(new Date(2026, 4, 8), '14:30') + expect(extractTime(iso, '00:00')).toBe('14:30') + }) + + test('возвращает fallback для пустой строки', () => { + expect(extractTime(undefined, '23:59')).toBe('23:59') + expect(extractTime('', '00:00')).toBe('00:00') + }) + + test('возвращает fallback для невалидной строки', () => { + expect(extractTime('garbage', '12:34')).toBe('12:34') + }) + + test('паддит часы и минуты двумя цифрами', () => { + const iso = formatIsoDateTime(new Date(2026, 4, 8), '05:07') + expect(extractTime(iso, '00:00')).toBe('05:07') + }) +}) + +describe('combineDateTime', () => { + test('null Date → пустая строка (для clear button)', () => { + expect(combineDateTime(null, '14:30', 'previous')).toBe('') + }) + + test('собирает ISO datetime из Date + HH:mm', () => { + const d = new Date(2026, 4, 8) + const iso = combineDateTime(d, '14:30', '') + expect(iso).toMatch(/^2026-05-08T14:30:00[+-]\d{2}:\d{2}$/) + }) + + test('защитный fallback на 00:00 при невалидном time', () => { + const d = new Date(2026, 4, 8) + const iso = combineDateTime(d, 'invalid', '') + expect(iso).toMatch(/^2026-05-08T00:00:00[+-]\d{2}:\d{2}$/) + }) +}) + +describe('nowIsoLocal', () => { + test('возвращает ISO datetime в local TZ', () => { + const iso = nowIsoLocal() + expect(iso).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:00[+-]\d{2}:\d{2}$/) + }) + + test('parsable обратно в Date', () => { + const iso = nowIsoLocal() + const d = parseFormDate(iso) + expect(d).not.toBeNull() + }) +}) diff --git a/ordinis-admin-ui/src/lib/dates.ts b/ordinis-admin-ui/src/lib/dates.ts new file mode 100644 index 0000000..cd54d5b --- /dev/null +++ b/ordinis-admin-ui/src/lib/dates.ts @@ -0,0 +1,67 @@ +/** + * Helpers для работы с датами в формах. Все функции делают TZ-aware преобразования + * чтобы избежать day-shift на 1 при парсинге/форматировании в timezones отличных + * от UTC. + */ + +/** Парсит ISO-строку в Date. Pure-date "YYYY-MM-DD" → local midnight, иначе new Date(s). */ +export const parseFormDate = (s: unknown): Date | null => { + if (typeof s !== 'string' || !s) return null + const pureDate = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s) + const d = pureDate + ? new Date(Number(pureDate[1]), Number(pureDate[2]) - 1, Number(pureDate[3])) + : new Date(s) + if (Number.isNaN(d.getTime())) return null + if (d.getFullYear() >= 9999) return null + return d +} + +/** Возвращает дату как "YYYY-MM-DD" в локальном TZ. */ +export const formatIsoDate = (d: Date): string => { + const pad = (n: number) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +} + +/** Локальный TZ offset в формате ±HH:mm для ISO строк. */ +export const localTzOffset = (d: Date): string => { + const pad = (n: number) => String(n).padStart(2, '0') + const total = -d.getTimezoneOffset() + const sign = total >= 0 ? '+' : '-' + const abs = Math.abs(total) + return `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}` +} + +/** ISO-8601 datetime в local TZ: "2026-05-08T23:59:00+03:00". */ +export const formatIsoDateTime = (d: Date, time: string = '00:00'): string => { + const pad = (n: number) => String(n).padStart(2, '0') + const [hh, mm] = time.split(':').map(Number) + const local = new Date(d.getFullYear(), d.getMonth(), d.getDate(), hh || 0, mm || 0, 0) + return `${formatIsoDate(local)}T${pad(local.getHours())}:${pad(local.getMinutes())}:00${localTzOffset(local)}` +} + +/** Извлекает HH:mm из ISO datetime в local TZ. */ +export const extractTime = (s: string | undefined, fallback: string): string => { + if (!s) return fallback + const d = new Date(s) + if (Number.isNaN(d.getTime())) return fallback + const pad = (n: number) => String(n).padStart(2, '0') + return `${pad(d.getHours())}:${pad(d.getMinutes())}` +} + +/** Собирает Date + HH:mm в local-TZ ISO datetime. null Date → пустая строка. */ +export const combineDateTime = ( + d: Date | null, + time: string, + _current: string, +): string => { + if (!d) return '' + const safeTime = /^\d{2}:\d{2}$/.test(time) ? time : '00:00' + return formatIsoDateTime(d, safeTime) +} + +/** "Сейчас" как local-TZ ISO datetime. Используется как default validFrom при Edit. */ +export const nowIsoLocal = (): string => { + const d = new Date() + const pad = (n: number) => String(n).padStart(2, '0') + return `${formatIsoDate(d)}T${pad(d.getHours())}:${pad(d.getMinutes())}:00${localTzOffset(d)}` +} diff --git a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx index 3c704a9..6c5abdb 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx @@ -27,6 +27,7 @@ import type { CreateRecordRequest, FlattenedRecord } from '@/api/client' import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm' import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog' import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer' +import { nowIsoLocal } from '@/lib/dates' export const Route = createFileRoute('/dictionaries/$name')({ component: DictionaryDetail, @@ -323,16 +324,6 @@ function DictionaryDetail() { ) } -const nowIsoLocal = (): string => { - const d = new Date() - const pad = (n: number) => String(n).padStart(2, '0') - const off = -d.getTimezoneOffset() - const sign = off >= 0 ? '+' : '-' - const abs = Math.abs(off) - const tz = `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}` - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:00${tz}` -} - const toIsoDate = (iso: string | undefined): string => { if (!iso) return '' const d = new Date(iso)