diff --git a/.circleci/circle_envs b/.ci_env similarity index 97% rename from .circleci/circle_envs rename to .ci_env index 30e40fa938..a4608b0e3d 100644 --- a/.circleci/circle_envs +++ b/.ci_env @@ -11,3 +11,4 @@ CODECLIMATE_REPO_TOKEN=2216bf71b977a85ed8da497942c34a503dbedd77da1b6b97c33551ba0 COVERALLS_REPO_TOKEN=lEX6nkql7y2YFCcIXVq5ORvdvMtYzfZdG CODECOV_TOKEN=c0fe1e65-d284-4d58-a742-4088e88be35d RAILS_ENV=test +MAX_POOL_SIZE=2 diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 84d5090eb8..0000000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,428 +0,0 @@ -version: 2.1 - -executors: - build-executor: - machine: - image: ubuntu-2404:current - resource_class: xlarge - working_directory: /home/circleci/project - -commands: - start-commands: - steps: - - send-notification: - message: "CI run started" - - send-notification: - message: "<${CIRCLE_BUILD_URL}|logs>" - - run: - name: Create compare link - command: | - DEPLOYS_URL_API="https://api.github.com/repos/Farmbot/Farmbot-Web-App/deployments" - COMPARE_URL_WEB="https://github.com/Farmbot/Farmbot-Web-App/compare/" - data=$(curl -fsS "$DEPLOYS_URL_API") || data="[]" - last_sha=$(printf "%s" "$data" | python -c 'import json,sys; data=json.loads(sys.stdin.read() or "[]"); deploy_index=0; sha=(data[deploy_index] or {}).get("sha"); print(sha or "")') - compare_url="$COMPARE_URL_WEB$last_sha...${CIRCLE_SHA1}" - echo "$compare_url" - echo "export COMPARE_URL='$compare_url'" >> "$BASH_ENV" - - send-notification: - message: "<$COMPARE_URL|diff>" - send-notification: - parameters: - message: - type: string - when: - type: enum - enum: ["on_success", "on_fail", "always"] - default: "always" - steps: - - when: - condition: - equal: [staging, << pipeline.git.branch >>] - steps: - - run: - name: "Send notification: << parameters.message >>" - when: << parameters.when >> - command: | - if [ -n "$SLACK_WEBHOOK_URL" ]; then - curl -fsS -X POST -H "Content-Type: application/json" \ - --data "{\"text\":\"<< parameters.message >>\",\"channel\":\"#software\"}" \ - "$SLACK_WEBHOOK_URL" || true - fi - build-commands: - steps: - - run: - name: Build and Install Deps - command: | - mv .circleci/circle_envs .env - echo -e '\ndocker_volumes/db/pg_wal/*' >> .dockerignore - sudo docker compose build web - if sudo docker compose run --rm web bash -lc '\ - gem install bundler && \ - bundle install && \ - bun install \ - '; then - echo "export SETUP_OK=true" >> "$BASH_ENV" - else - echo "export SETUP_OK=false" >> "$BASH_ENV" - exit 1 - fi - - run: - name: Run setup tasks - command: | - if [ "$SETUP_OK" != "true" ]; then - echo "export SETUP_OK=false" >> "$BASH_ENV" - exit 1 - fi - - if sudo docker compose run --rm web bash -lc '\ - bundle exec rails db:create && \ - bundle exec rails db:migrate && \ - bundle exec rake keys:generate \ - '; then - echo "export SETUP_OK=true" >> "$BASH_ENV" - else - echo "export SETUP_OK=false" >> "$BASH_ENV" - exit 1 - fi - rspec-commands: - steps: - - run: - name: Run Ruby Tests - command: | - sudo docker compose run --rm web bundle exec rspec spec --format progress --format RspecJunitFormatter --out test-results/rspec.xml - - run: - name: Check app coverage status - command: | - if [ "$SETUP_OK" != "true" ]; then - echo "skipping" - exit 0 - fi - sudo docker compose run --rm web bundle exec rake check_file_coverage:api || [ $CIRCLE_BRANCH == "staging" ] - when: always - - run: - name: Upload app coverage to Codecov - command: | - curl https://keybase.io/codecovsecurity/pgp_keys.asc | gpg --no-default-keyring --keyring trustedkeys.gpg --import - curl -Os https://uploader.codecov.io/latest/linux/codecov - curl -Os https://uploader.codecov.io/latest/linux/codecov.SHA256SUM - curl -Os https://uploader.codecov.io/latest/linux/codecov.SHA256SUM.sig - gpgv codecov.SHA256SUM.sig codecov.SHA256SUM - shasum -a 256 -c codecov.SHA256SUM - chmod +x codecov - ./codecov -t $CODECOV_TOKEN -f coverage_api/coverage.xml - js-test-commands: - steps: - - run: - name: Run JS tests - when: always - command: | - if [ "$SETUP_OK" != "true" ]; then - echo "skipping" - exit 0 - fi - sudo docker compose run --rm web bun test --reporter=junit --reporter-outfile=test-results/junit.xml - echo 'export COVERAGE_AVAILABLE=true' >> $BASH_ENV - lint-commands: - steps: - - run: - name: Run Ruby Linters - command: | - if [ "$SETUP_OK" != "true" ]; then - echo "skipping" - exit 0 - fi - sudo docker compose run --rm web bun run ruby-check - when: always - - run: - name: Run JS Linters - command: | - if [ "$SETUP_OK" != "true" ]; then - echo "skipping" - exit 0 - fi - sudo docker compose run --rm web bun run linters - when: always - bun-build-commands: - steps: - - run: - name: Check bun build - command: | - if [ "$SETUP_OK" != "true" ]; then - echo "skipping" - exit 0 - fi - sudo docker compose run --rm web bundle exec rake assets:precompile 2>&1 | tee assets_precompile.log - printf -- '-%.0s' {1..50}; printf '\n' - if grep -Ei '^(warn|error):' assets_precompile.log; then - exit 1 - fi - when: always - coverage-commands: - steps: - - run: - name: Check frontend coverage status - command: | - if [ "$SETUP_OK" != "true" ]; then - echo "skipping" - exit 0 - fi - sudo docker compose run --rm -e CIRCLE_SHA1="$CIRCLE_SHA1" -e CIRCLE_BRANCH="$CIRCLE_BRANCH" -e CIRCLE_PULL_REQUEST="$CIRCLE_PULL_REQUEST" web bundle exec rake coverage:run || [ $CIRCLE_BRANCH == "staging" ] - when: always - - run: - name: Check frontend file coverage status - command: | - if [ "$SETUP_OK" != "true" ]; then - echo "skipping" - exit 0 - fi - changed=$(git diff --name-only staging...HEAD | tr '\n' ',' | sed 's/,$//') || true - sudo docker compose run --rm -e CHANGED_FILES="$changed" web bundle exec rake check_file_coverage:fe || true - when: always - - run: - name: Report frontend coverage to Coveralls - command: | - if [ "$CIRCLE_BRANCH" == "staging" ]; then set +eo pipefail; fi - if [ "$COVERAGE_AVAILABLE" ] && [ "$COVERALLS_REPO_TOKEN" ] - then - curl -sLO https://github.com/coverallsapp/coverage-reporter/releases/latest/download/coveralls-linux.tar.gz - curl -sLO https://github.com/coverallsapp/coverage-reporter/releases/latest/download/coveralls-checksums.txt - cat coveralls-checksums.txt | grep coveralls-linux.tar.gz | sha256sum --check - tar -xzf coveralls-linux.tar.gz - ./coveralls report coverage_fe/lcov.info - fi - if [ "$CIRCLE_BRANCH" == "staging" ]; then echo; fi - when: always # change to `on_success` for a stricter comparison - - restore_cache: - keys: - - fe-coverage-staging- - - run: - name: Get current frontend coverage value - when: always - command: | - if [ -z "$COVERAGE_AVAILABLE" ]; then - echo "skipping" - exit 0 - fi - eval "$(awk -F: '/^LH:/ { covered += $2 } /^LF:/ { total += $2 } END { printf "COVERED=%d\nTOTAL=%d\n", covered, total }' coverage_fe/lcov.info)" - FE_VALUE=$(awk "BEGIN { printf \"%.2f\", ($COVERED / $TOTAL) * 100 }") - echo "Covered lines: $COVERED, Total lines: $TOTAL, Coverage: $FE_VALUE%" - echo "export COVERED=$COVERED" >> "$BASH_ENV" - echo "export TOTAL=$TOTAL" >> "$BASH_ENV" - echo "export FE_VALUE=$FE_VALUE" >> "$BASH_ENV" - - run: - name: Load previous frontend coverage value - when: always - command: | - if [ -f fe_coverage.csv ]; then - PREV_FE=$(python -c 'import csv; f=open("fe_coverage.csv", newline=""); r=csv.reader(f); next(r, None); rows=[float(row[0]) for row in r if row and row[0]]; print(f"{rows[-1]:.2f}" if rows else "98.7")') - else - PREV_FE=98.7 - fi - echo "Previous value: $PREV_FE%" - echo "export PREV_FE_VALUE=$PREV_FE" >> $BASH_ENV - - run: - name: Compare frontend coverage value - when: always - command: | - if [ -z "$COVERAGE_AVAILABLE" ]; then - echo "skipping" - exit 0 - fi - percent_change=$(python -c 'import os; fe=float(os.environ.get("FE_VALUE","0") or 0); prev=float(os.environ.get("PREV_FE_VALUE","0") or 0); print("n/a" if prev==0 else f"{((fe-prev)/prev)*100:.2f}")') - echo "$FE_VALUE% ($percent_change% change)" - echo "export PERCENT_FE_CHANGE=$percent_change" >> "$BASH_ENV" - - run: - name: Save new frontend coverage value - when: always - command: | - if [ -z "$COVERAGE_AVAILABLE" ]; then - echo "skipping" - exit 0 - fi - echo "$FE_VALUE" - if [ ! -f fe_coverage.csv ]; then - printf '%s\n' "percent,covered lines,total lines,percent change" > fe_coverage.csv - fi - printf '%s\n' "$FE_VALUE,$COVERED,$TOTAL,$PERCENT_FE_CHANGE" >> fe_coverage.csv - cat fe_coverage.csv - - save_cache: - key: fe-coverage-{{ .Branch }}-{{ epoch }} - paths: - - fe_coverage.csv - when: always - - store_artifacts: - path: fe_coverage.csv - destination: fe_coverage.csv - render-commands: - steps: - - run: - name: Start services - when: always - command: | - if [ "$SETUP_OK" != "true" ]; then - echo "skipping" - exit 0 - fi - sudo docker compose up -d - sudo docker compose ps - - run: - name: Install playwright - when: always - command: | - if [ "$SETUP_OK" != "true" ]; then - echo "skipping" - exit 0 - fi - sudo docker compose exec -e PLAYWRIGHT_BROWSERS_PATH=0 web bunx playwright install chromium --with-deps - - run: - name: Wait for load - when: always - command: | - if [ "$SETUP_OK" != "true" ]; then - echo "skipping" - exit 0 - fi - for i in $(seq 1 90); do - if curl -fsS "http://127.0.0.1:3000/promo" >/dev/null; then - echo "web is up" - exit 0 - fi - sleep 2 - done - - echo "timeout" - sudo docker compose logs --no-color --tail=300 web - exit 1 - - run: - name: Run playwright - when: always - command: | - if [ "$SETUP_OK" != "true" ]; then - echo "skipping" - exit 0 - fi - attempts=2 - for _ in $(seq 1 "$attempts"); do - url="http://localhost:3000/promo?sizePreset=Genesis+XL&promoSpread=true" - echo "Attempting FPS check via ${url}" - if ! sudo docker compose exec web curl -I "${url}" >/dev/null; then - continue - fi - - if ! fps_output=$(sudo docker compose exec -e PLAYWRIGHT_BROWSERS_PATH=0 web bun scripts/fps.js "${url}" "tmp/promo.png"); then - echo "${fps_output}" - continue - fi - echo "${fps_output}" - - fps_value=$(echo "${fps_output}" | awk -F= '/^FPS_VALUE=/{print $2; exit}') - scene_metrics=$(echo "${fps_output}" | awk -F= '/^SCENE_METRICS=/{print $2; exit}') - echo "export FPS_VALUE=${fps_value}" >> "$BASH_ENV" - echo "export SCENE_METRICS=\"${scene_metrics}\"" >> "$BASH_ENV" - - exit 0 - done - - echo "FPS check failed for all URLs" - exit 1 - - restore_cache: - keys: - - fps_value-staging- - - run: - name: Load previous fps value - when: always - command: | - if [ -f fps_value.txt ]; then - PREV=$(cat fps_value.txt) - else - PREV=0.75 - fi - echo "Previous value: $PREV fps" - echo "export PREV_VALUE=$PREV" >> $BASH_ENV - - run: - name: Compare fps value - when: always - command: | - percent_change=$(python -c 'import os; fps=float(os.environ.get("FPS_VALUE","0") or 0); prev=float(os.environ.get("PREV_VALUE","0") or 0); print("n/a" if prev==0 else f"{((fps-prev)/prev)*100:.2f}")') - echo "$FPS_VALUE fps ($percent_change% change)" - echo "export PERCENT_CHANGE=$percent_change" >> "$BASH_ENV" - - run: - name: Save new fps value - command: | - echo "$FPS_VALUE" - echo "$FPS_VALUE" > fps_value.txt - if [ ! -f scene_metrics.csv ]; then - printf '%s\n' "epoch, FPS, Calls, Triangles, Points, Lines, Geometries, Textures, Objects, Meshes, Instanced meshes" > scene_metrics.csv - fi - printf '%s\n' "$SCENE_METRICS" >> scene_metrics.csv - cat scene_metrics.csv - - save_cache: - key: fps_value-{{ .Branch }}-{{ epoch }} - paths: - - fps_value.txt - - scene_metrics.csv - - send-notification: - message: "$FPS_VALUE fps (${PERCENT_CHANGE}% change)" - - run: - name: On failure - when: on_fail - command: | - if [ "$SETUP_OK" != "true" ]; then - echo "setup failed" - exit 0 - fi - sudo docker compose ps - sudo docker compose logs --no-color --tail=500 - - store_artifacts: - path: tmp/promo.png - destination: promo_xl.png - - store_artifacts: - path: scene_metrics.csv - destination: scene_metrics.csv - end-commands: - steps: - - run: - name: Fetch image artifact URL - command: | - project_slug="gh/${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}" - artifacts_json=$(curl -fsS \ - -H "Accept: application/json" \ - "https://circleci.com/api/v2/project/${project_slug}/${CIRCLE_BUILD_NUM}/artifacts") - artifact_url=$(printf "%s" "$artifacts_json" | python -c 'import json,sys; data=json.load(sys.stdin); items=data.get("items") or []; print(next((item.get("url","") for item in items if item.get("url","").endswith(".png")), ""))') - echo "export ARTIFACT_URL=$artifact_url" >> "$BASH_ENV" - - send-notification: - message: "CI run succeeded" - when: on_success - - send-notification: - message: "<$ARTIFACT_URL|screenshot>" - when: on_success - - send-notification: - message: "CI run failed" - when: on_fail - - - -workflows: - version: 2 - build_and_test: - # max_auto_reruns: 1 - jobs: - - all - -jobs: - all: - executor: build-executor - steps: - - start-commands - - checkout - - run: mkdir -p test-results - - build-commands - - rspec-commands - - lint-commands - - bun-build-commands - - js-test-commands - - store_test_results: - path: test-results - - coverage-commands - - render-commands - - end-commands diff --git a/.github/actions/setup-ci/action.yml b/.github/actions/setup-ci/action.yml new file mode 100644 index 0000000000..59166c5c23 --- /dev/null +++ b/.github/actions/setup-ci/action.yml @@ -0,0 +1,73 @@ +name: Set up CI +description: Prepare the FarmBot web Docker image and dependencies. +inputs: + save-docker-cache: + description: Save Docker build layers to the GitHub Actions cache. + required: false + default: "false" + rails-env: + description: Rails environment to write to .env. + required: true +runs: + using: composite + steps: + - name: Set up environment file + shell: bash + run: | + mv .ci_env .env + sed -i "s/^RAILS_ENV=.*/RAILS_ENV=${{ inputs.rails-env }}/" .env + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Build web image + if: inputs.save-docker-cache != 'true' + uses: docker/build-push-action@v7 + with: + context: . + file: docker_configs/api.Dockerfile + load: true + tags: farmbot_web:latest + cache-from: type=gha,scope=farmbot-web + + - name: Build web image and save Docker cache + if: inputs.save-docker-cache == 'true' + uses: docker/build-push-action@v7 + with: + context: . + file: docker_configs/api.Dockerfile + load: true + tags: farmbot_web:latest + cache-from: type=gha,scope=farmbot-web + cache-to: type=gha,mode=max,scope=farmbot-web + + - name: Cache bundle + uses: actions/cache@v5 + with: + path: docker_volumes/bundle_cache + key: bundle-${{ runner.os }}-${{ hashFiles('Gemfile.lock') }} + + - name: Cache node modules + uses: actions/cache@v5 + with: + path: node_modules + key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }} + + - name: Install deps + shell: bash + run: | + sudo docker compose run --rm web bash -lc ' + gem install bundler && + bundle install && + bun install + ' + + - name: Run setup tasks + shell: bash + run: | + sudo docker compose run --rm web bash -lc ' + bundle exec rails db:create && + bundle exec rails db:migrate && + bundle exec rake keys:generate + ' + mkdir -p test-results diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000000..53b2b086a4 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,26 @@ +name: build + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test: + uses: ./.github/workflows/test.yml + permissions: + contents: read + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + + render: + needs: test + if: github.ref_name == 'staging' || github.event_name != 'push' || !contains(github.event.head_commit.message, '[skip render]') + uses: ./.github/workflows/render.yml + permissions: + contents: write + secrets: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/.github/workflows/render.yml b/.github/workflows/render.yml new file mode 100644 index 0000000000..6e8e19fd8a --- /dev/null +++ b/.github/workflows/render.yml @@ -0,0 +1,196 @@ +name: render + +on: + workflow_call: + secrets: + SLACK_WEBHOOK_URL: + required: false + +env: + NOTIFY: ${{ github.ref_name == 'staging' }} + STAGING: ${{ github.ref_name == 'staging' }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + CACHE_KEY_SUFFIX: ${{ github.run_id }}-${{ github.run_attempt }} + FPS_METRICS_CACHE_KEY: fps-metrics-${{ github.ref_name }} + FE_COVERAGE_CACHE_KEY: fe-coverage-p-${{ github.ref_name }} + +jobs: + render: + runs-on: gpu + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set render env + run: scripts/ci/export-render-env + + - name: Set up CI + uses: ./.github/actions/setup-ci + with: + rails-env: production + + - name: Set render service hosts + run: | + runner_ip=$(ip route get 1.1.1.1 | awk '{for (i=1; i<=NF; i++) if ($i == "src") {print $(i+1); exit}}') + echo "Runner IP: $runner_ip" + sed -i "s/^API_HOST=.*/API_HOST=$runner_ip/" .env + sed -i "s/^MQTT_HOST=.*/MQTT_HOST=$runner_ip/" .env + sudo docker compose run --rm web bundle exec rails runner RmqConfigWriter.render + + - name: Start services + run: | + sudo docker compose up -d + sudo docker compose ps + + - name: Install playwright + run: | + curl -fsSL https://bun.sh/install | bash + export BUN_INSTALL="$HOME/.bun" + export PATH="$BUN_INSTALL/bin:$PATH" + export PLAYWRIGHT_BROWSERS_PATH=/tmp/playwright-browsers + echo "BUN_INSTALL=$BUN_INSTALL" >> "$GITHUB_ENV" + echo "$BUN_INSTALL/bin" >> "$GITHUB_PATH" + echo "PLAYWRIGHT_BROWSERS_PATH=$PLAYWRIGHT_BROWSERS_PATH" >> "$GITHUB_ENV" + bunx playwright install chromium --with-deps + + - name: Set up GPU + run: | + sudo modprobe nvidia || true + sudo modprobe nvidia_uvm || true + ls -la /dev/nvidia* || true + nvidia-smi + + - name: Wait for load + run: | + for i in $(seq 1 90); do + if curl -fsS "http://127.0.0.1:3000/promo" >/dev/null; then + echo "web is up" + exit 0 + fi + sleep 2 + done + echo "web did not become ready" + exit 1 + + - name: Wait for MQTT auth + run: | + admin_password=$(awk -F= '/^ADMIN_PASSWORD=/{print $2; exit}' .env) + for i in $(seq 1 90); do + if timeout 5s sudo docker compose exec -T mqtt rabbitmqctl authenticate_user admin "$admin_password" >/dev/null; then + echo "mqtt auth is up" + exit 0 + fi + sleep 2 + done + echo "mqtt auth did not become ready" + exit 1 + + - name: Restore metric history + id: restore_metric_history + run: | + scripts/ci/git-restore-artifact-branch-file "$SCENE_METRICS_NAME*.csv" + if compgen -G "/tmp/$SCENE_METRICS_NAME*.csv" >/dev/null; then + echo "scene_metrics_found=true" >> "$GITHUB_OUTPUT" + fi + + - name: Restore FPS metrics + if: steps.restore_metric_history.outputs.scene_metrics_found != 'true' + uses: actions/cache/restore@v5 + with: + path: | + /tmp/${{ env.SCENE_METRICS_NAME }}*.csv + key: ${{ env.FPS_METRICS_CACHE_KEY }}-${{ env.CACHE_KEY_SUFFIX }} + restore-keys: | + ${{ env.FPS_METRICS_CACHE_KEY }}- + + - name: Restore frontend coverage value + if: always() + uses: actions/cache/restore@v5 + with: + path: /tmp/${{ env.FE_COVERAGE_NAME }}.csv + key: ${{ env.FE_COVERAGE_CACHE_KEY }}-${{ env.CACHE_KEY_SUFFIX }} + restore-keys: | + ${{ env.FE_COVERAGE_CACHE_KEY }}- + + - name: Restore frontend coverage history fallback + if: always() + run: | + [ -f "/tmp/$FE_COVERAGE_NAME.csv" ] && exit 0 + + scripts/ci/git-restore-artifact-branch-file "$FE_COVERAGE_NAME.csv" + + - name: Run playwright + run: scripts/ci/run-playwright-render + + - name: Track FPS value + run: | + PREV=$(scripts/ci/previous-fps-value) + echo "Previous value: $PREV fps" + percent_change=$(scripts/ci/percent-change --new "$FPS_VALUE" --old "$PREV") + echo "$FPS_VALUE fps ($percent_change% change)" + echo "PERCENT_CHANGE=$percent_change" >> "$GITHUB_ENV" + + - name: Plot metric CSVs + if: always() + run: | + for csv in /tmp/*.csv; do + [ -f "$csv" ] || continue + bun scripts/metric_plot.js "$csv" + done + + - name: Save FPS metrics + uses: actions/cache/save@v5 + with: + path: | + /tmp/${{ env.SCENE_METRICS_NAME }}*.csv + key: ${{ env.FPS_METRICS_CACHE_KEY }}-${{ env.CACHE_KEY_SUFFIX }} + + - name: View values + if: always() + run: | + echo $FPS_VALUE + echo $SCENE_METRICS + + - name: Send notification + if: env.NOTIFY == 'true' + run: scripts/ci/send-notification "${FPS_VALUE} fps (${PERCENT_CHANGE}% change)" + + - name: Upload image artifacts + if: always() + uses: actions/upload-artifact@v7 + with: + name: images + path: | + /tmp/*.png + /tmp/${{ env.FE_COVERAGE_NAME }}.png + if-no-files-found: warn + + - name: Upload csv artifacts + if: always() + uses: actions/upload-artifact@v7 + with: + name: scene-metrics-csv + path: | + /tmp/*.csv + if-no-files-found: warn + + - name: On failure + if: failure() + run: | + sudo docker compose ps || true + sudo docker compose logs --no-color --tail=500 || true + + - name: Publish image links + if: success() && env.STAGING == 'true' + run: scripts/ci/git-publish-render-artifacts + + - name: Send notification + if: success() && env.NOTIFY == 'true' + run: | + RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + for MESSAGE in \ + "<${RENDER_SUMMARY_URL:-$RUN_URL}|render summary>" + do + scripts/ci/send-notification "$MESSAGE" + done diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000000..d993e6b54f --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,181 @@ +name: test + +on: + workflow_call: + secrets: + CODECOV_TOKEN: + required: false + SLACK_WEBHOOK_URL: + required: false + +env: + NOTIFY: ${{ github.ref_name == 'staging' }} + STAGING: ${{ github.ref_name == 'staging' }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + CACHE_KEY_SUFFIX: ${{ github.run_id }}-${{ github.run_attempt }} + FE_COVERAGE_CACHE_KEY: fe-coverage-p-${{ github.ref_name }} + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 100 + + - name: Set render env + run: scripts/ci/export-render-env + + - name: Run CI script tests + run: python3 -m unittest scripts/ci/test_python_scripts.py + + - name: Send notification + if: env.NOTIFY == 'true' + run: scripts/ci/send-notification "CI run started" + + - name: Send notification + if: env.NOTIFY == 'true' + run: scripts/ci/send-notification "<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|logs>" + + - name: Create compare link + if: env.NOTIFY == 'true' + run: | + compare_url=$(scripts/ci/create-compare-link) + echo "$compare_url" + echo "COMPARE_URL=$compare_url" >> "$GITHUB_ENV" + + - name: Send notification + if: env.NOTIFY == 'true' + run: scripts/ci/send-notification "<${COMPARE_URL}|diff>" + + - name: Set up CI + uses: ./.github/actions/setup-ci + with: + rails-env: test + save-docker-cache: "true" + + - name: Run Ruby linters + run: sudo docker compose run --rm web bun run ruby-check + + - name: Run Ruby tests + run: sudo docker compose run --rm web bundle exec rspec spec --format progress --format RspecJunitFormatter --out test-results/rspec.xml + + - name: Check app coverage status + if: ${{ always() && hashFiles('coverage_api/index.html') != '' }} + run: sudo docker compose run --rm web bundle exec rake check_file_coverage:api || [ "$STAGING" = "true" ] + + - name: Upload app coverage to Codecov + if: ${{ hashFiles('coverage_api/coverage.xml') != '' }} + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage_api/coverage.xml + fail_ci_if_error: false + + - name: Run JS linters + run: sudo docker compose run --rm web bun run linters + + - name: Check bun build + run: | + sudo docker compose run --rm web bundle exec rake assets:precompile 2>&1 | tee assets_precompile.log + printf -- '-%.0s' {1..50}; printf '\n' + if grep -Ei '^(warn|error):' assets_precompile.log; then + exit 1 + fi + + - name: Run JS tests + run: | + set +e + sudo docker compose run -T --rm web bun test --reporter=junit --reporter-outfile=test-results/junit.xml + status=$? + if [ "$status" -eq 0 ]; then + echo "COVERAGE_AVAILABLE=true" >> "$GITHUB_ENV" + fi + exit $status + + - name: Check frontend coverage status + if: ${{ always() && (hashFiles('coverage_fe/index.html') != '' || hashFiles('coverage_fe/coverage-final.json') != '' || hashFiles('coverage_fe/lcov.info') != '') }} + env: + GITHUB_PULL_REQUEST: ${{ github.event.pull_request.html_url || '/0' }} + run: sudo docker compose run --rm -e GITHUB_SHA="$GITHUB_SHA" -e GITHUB_REF_NAME="$GITHUB_REF_NAME" -e GITHUB_PULL_REQUEST="$GITHUB_PULL_REQUEST" web bundle exec rake coverage:run || [ "$STAGING" = "true" ] + + - name: Check frontend file coverage status + if: always() + run: | + changed=$(git diff --name-only staging...HEAD | tr '\n' ',' | sed 's/,$//') || true + sudo docker compose run --rm -e CHANGED_FILES="$changed" web bundle exec rake check_file_coverage:fe || true + + - name: Report frontend coverage to Coveralls + if: env.COVERAGE_AVAILABLE == 'true' + uses: coverallsapp/github-action@v2 + with: + github-token: ${{ github.token }} + file: coverage_fe/lcov.info + format: lcov + fail-on-error: false + + - name: Restore frontend coverage history + id: restore_fe_coverage_history + if: always() + run: | + scripts/ci/git-restore-artifact-branch-file "$FE_COVERAGE_NAME.csv" + if [ -f "/tmp/$FE_COVERAGE_NAME.csv" ]; then + echo "fe_coverage_found=true" >> "$GITHUB_OUTPUT" + fi + + - name: Restore frontend coverage cache fallback + if: ${{ always() && steps.restore_fe_coverage_history.outputs.fe_coverage_found != 'true' }} + uses: actions/cache/restore@v5 + with: + path: /tmp/${{ env.FE_COVERAGE_NAME }}.csv + key: ${{ env.FE_COVERAGE_CACHE_KEY }}-${{ env.CACHE_KEY_SUFFIX }} + restore-keys: | + ${{ env.FE_COVERAGE_CACHE_KEY }}- + + - name: Track frontend coverage value + if: always() + run: | + if [ "$COVERAGE_AVAILABLE" != "true" ]; then + echo "skipping" + exit 0 + fi + scripts/ci/track-fe-coverage + + - name: Save frontend coverage value + if: ${{ always() && env.COVERAGE_AVAILABLE == 'true' }} + uses: actions/cache/save@v5 + with: + path: /tmp/${{ env.FE_COVERAGE_NAME }}.csv + key: ${{ env.FE_COVERAGE_CACHE_KEY }}-${{ env.CACHE_KEY_SUFFIX }} + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v7 + with: + name: test-results + path: test-results + if-no-files-found: warn + + - name: Upload coverage artifacts + if: always() + uses: actions/upload-artifact@v7 + with: + name: fe-coverage-csv + path: /tmp/${{ env.FE_COVERAGE_NAME }}.csv + if-no-files-found: warn + + - name: On failure + if: failure() + run: | + sudo docker compose ps || true + sudo docker compose logs --no-color --tail=500 || true + + - name: Send notification + if: success() && env.NOTIFY == 'true' + run: scripts/ci/send-notification "CI run succeeded" + + - name: Send notification + if: failure() && env.NOTIFY == 'true' + run: scripts/ci/send-notification "CI run failed" diff --git a/.gitignore b/.gitignore index 5513db4f0a..a0f87065dd 100755 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ upgrade_deps.sh storage/* tmp module_graph.* +scripts/ci/__pycache__ diff --git a/.ruby-version b/.ruby-version index 4d54daddb6..c4e41f9459 100755 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -4.0.2 +4.0.3 diff --git a/Gemfile b/Gemfile index 567affe78b..4980dcebdf 100755 --- a/Gemfile +++ b/Gemfile @@ -1,5 +1,5 @@ source "https://rubygems.org" -ruby "~> 4.0.2" +ruby "~> 4.0.3" gem "rails", "~> 8" gem "active_model_serializers" diff --git a/Gemfile.lock b/Gemfile.lock index 9df3375765..371ebbd0eb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ GEM remote: https://rubygems.org/ specs: - action_text-trix (2.1.18) + action_text-trix (2.1.19) railties actioncable (8.1.3) actionpack (= 8.1.3) @@ -82,7 +82,7 @@ GEM uri (>= 0.13.1) addressable (2.9.0) public_suffix (>= 2.0.2, < 8.0) - amq-protocol (2.7.0) + amq-protocol (2.8.0) ast (2.4.3) base64 (0.3.0) bcrypt (3.1.22) @@ -119,7 +119,7 @@ GEM delayed_job_active_record (4.1.11) activerecord (>= 3.0, < 9.0) delayed_job (>= 3.0, < 5) - devise (5.0.3) + devise (5.0.4) bcrypt (~> 3.0) orm_adapter (~> 0.1) railties (>= 7.0) @@ -135,20 +135,20 @@ GEM e2mmap (0.1.0) erb (6.0.4) erubi (1.13.1) - factory_bot (6.5.6) + factory_bot (6.6.0) activesupport (>= 6.1.0) factory_bot_rails (6.5.1) factory_bot (~> 6.5) railties (>= 6.1.0) faker (3.8.0) i18n (>= 1.8.11, < 2) - faraday (2.14.1) + faraday (2.14.2) faraday-net_http (>= 2.0, < 3.5) json logger faraday-follow_redirects (0.5.0) faraday (>= 1, < 3) - faraday-net_http (3.4.2) + faraday-net_http (3.4.3) net-http (~> 0.5) globalid (1.3.0) activesupport (>= 6.1) @@ -160,9 +160,9 @@ GEM mini_mime (~> 1.1) representable (~> 3.0) retriable (~> 3.1) - google-apis-iamcredentials_v1 (0.26.0) + google-apis-iamcredentials_v1 (0.27.0) google-apis-core (>= 0.15.0, < 2.a) - google-apis-storage_v1 (0.61.0) + google-apis-storage_v1 (0.62.0) google-apis-core (>= 0.15.0, < 2.a) google-cloud-core (1.8.0) google-cloud-env (>= 1.0, < 3.a) @@ -171,7 +171,7 @@ GEM base64 (~> 0.2) faraday (>= 1.0, < 3.a) google-cloud-errors (1.6.0) - google-cloud-storage (1.59.0) + google-cloud-storage (1.60.0) addressable (~> 2.8) digest-crc (~> 0.4) google-apis-core (>= 0.18, < 2) @@ -195,14 +195,14 @@ GEM i18n (1.14.8) concurrent-ruby (~> 1.0) io-console (0.8.2) - irb (1.17.0) + irb (1.18.0) pp (>= 0.6.0) prism (>= 1.3.0) rdoc (>= 4.0.0) reline (>= 0.4.2) - json (2.19.4) + json (2.19.5) jsonapi-renderer (0.2.2) - jwt (3.1.2) + jwt (3.2.0) base64 kaminari (1.2.2) activesupport (>= 4.1.0) @@ -233,19 +233,19 @@ GEM net-imap net-pop net-smtp - marcel (1.1.0) + marcel (1.2.1) method_source (1.1.0) mini_mime (1.1.5) - minitest (6.0.5) + minitest (6.0.6) drb (~> 2.0) prism (~> 1.5) - multi_json (1.20.1) + multi_json (1.21.1) mutations (0.9.2) activesupport mutex_m (0.3.0) net-http (0.9.1) uri (>= 0.11.1) - net-imap (0.6.3) + net-imap (0.6.4) date net-protocol net-pop (0.1.2) @@ -255,18 +255,18 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.5) - nokogiri (1.19.2-aarch64-linux-gnu) + nokogiri (1.19.3-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.2-x86_64-linux-gnu) + nokogiri (1.19.3-x86_64-linux-gnu) racc (~> 1.4) orm_adapter (0.5.0) os (1.1.4) ostruct (0.6.3) - parallel (2.0.1) + parallel (2.1.0) parser (3.3.11.1) ast (~> 2.4.1) racc - passenger (6.1.2) + passenger (6.1.3) logger (>= 1.7.0) rack (>= 1.6.13) rackup (>= 1.0.1) @@ -357,9 +357,9 @@ GEM responders (3.2.0) actionpack (>= 7.0) railties (>= 7.0) - retriable (3.4.1) + retriable (3.5.0) rexml (3.4.4) - rollbar (3.7.0) + rollbar (3.8.0) rspec (3.13.2) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) @@ -383,7 +383,7 @@ GEM rspec-support (3.13.7) rspec_junit_formatter (0.6.0) rspec-core (>= 2, < 4, != 2.12.0) - rubocop (1.86.1) + rubocop (1.86.2) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -397,7 +397,7 @@ GEM rubocop-ast (1.49.1) parser (>= 3.3.7.2) prism (~> 1.7) - rubocop-rails (2.34.3) + rubocop-rails (2.35.2) activesupport (>= 4.2.0) lint_roller (~> 1.1) rack (>= 1.1) @@ -435,7 +435,7 @@ GEM tsort (0.2.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - tzinfo-data (1.2026.1) + tzinfo-data (1.2026.2) tzinfo (>= 1.0.0) uber (0.1.0) unicode-display_width (3.2.0) @@ -453,7 +453,7 @@ GEM base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) - zeitwerk (2.7.5) + zeitwerk (2.8.2) PLATFORMS aarch64-linux @@ -511,7 +511,7 @@ DEPENDENCIES webmock RUBY VERSION - ruby 4.0.2 + ruby 4.0.3 BUNDLED WITH - 4.0.9 + 4.0.10 diff --git a/app/controllers/api/ais_controller.rb b/app/controllers/api/ais_controller.rb index d44288a0aa..6f6188d1b7 100644 --- a/app/controllers/api/ais_controller.rb +++ b/app/controllers/api/ais_controller.rb @@ -240,18 +240,18 @@ def named_resources "nothing for now", ] + DEV_DOCS_API_REF_URL = "https://raw.githubusercontent.com/FarmBot-Docs/farmbot-developers/refs/heads/main/v15/docs/web-app/api-docs.md" + SRC_CODE_BOT_STATE_URL = "https://raw.githubusercontent.com/FarmBot/farmbot-js/refs/heads/main/src/interfaces.ts" + def page_url(page_name) "https://raw.githubusercontent.com/FarmBot-Docs/farmbot-developers/main/" \ "v15/lua/functions/#{page_name}.md" end - def get_page_data(page_name) - url = page_url(page_name) - begin - URI.parse(url).open.read - rescue SocketError => exception - puts "AI Lua docs fetch error: #{exception.message}" unless Rails.env.test? - end + def get_page_data(url) + URI.parse(url).open.read + rescue SocketError => exception + puts "AI Lua docs fetch error: #{exception.message}" unless Rails.env.test? end def shorten_docs(docs) @@ -275,18 +275,30 @@ def shorten_docs(docs) keep.join("\n") end + def process_dev_docs_page_data(url) + page_data = get_page_data(url) + if page_data.nil? + return "" + end + + content = page_data.split("---").slice(2, page_data.length) + if content.nil? + return "" + end + + content.join("---") + end + def get_docs function_docs = "" PAGE_NAMES.each do |page_name| - page_data = get_page_data(page_name) - if page_data.nil? - return "" - end - - page_content = page_data.split("---").slice(2, page_data.length).join("---") - function_docs += page_content + url = page_url(page_name) + function_docs += process_dev_docs_page_data(url) end short_docs = shorten_docs(function_docs) + short_docs += get_page_data(SRC_CODE_BOT_STATE_URL) || "" + short_docs += process_dev_docs_page_data(DEV_DOCS_API_REF_URL) + puts "AI Lua docs fetched: #{short_docs.length} characters" unless Rails.env.test? short_docs end diff --git a/app/controllers/api/alerts_controller.rb b/app/controllers/api/alerts_controller.rb index 41298b2a23..9fbd2cbee0 100644 --- a/app/controllers/api/alerts_controller.rb +++ b/app/controllers/api/alerts_controller.rb @@ -11,7 +11,7 @@ def destroy private def alert - @alert ||= current_device.alerts.find(params[:id]) + @alert ||= current_device.alerts.find(params.expect(:id)) end end end diff --git a/app/controllers/api/curves_controller.rb b/app/controllers/api/curves_controller.rb index 9e8ea776b0..9fe6d36ea9 100644 --- a/app/controllers/api/curves_controller.rb +++ b/app/controllers/api/curves_controller.rb @@ -23,7 +23,7 @@ def destroy private def curve - @curve ||= current_device.curves.find(params[:id]) + @curve ||= current_device.curves.find(params.expect(:id)) end end end diff --git a/app/controllers/api/farm_events_controller.rb b/app/controllers/api/farm_events_controller.rb index 280efc2f3a..6d045e4c48 100644 --- a/app/controllers/api/farm_events_controller.rb +++ b/app/controllers/api/farm_events_controller.rb @@ -7,7 +7,7 @@ def index end def show - render json: current_device.farm_events.find(params[:id]) + render json: current_device.farm_events.find(params.expect(:id)) end def create @@ -31,7 +31,7 @@ def destroy private def farm_event - @farm_event ||= FarmEvent.find(params[:id]) + @farm_event ||= FarmEvent.find(params.expect(:id)) end end end diff --git a/app/controllers/api/farmware_envs_controller.rb b/app/controllers/api/farmware_envs_controller.rb index d26c394488..d2f0c48d7b 100644 --- a/app/controllers/api/farmware_envs_controller.rb +++ b/app/controllers/api/farmware_envs_controller.rb @@ -31,7 +31,7 @@ def destroy private def farmware_env - farmware_envs.find(params[:id]) + farmware_envs.find(params.expect(:id)) end def farmware_envs diff --git a/app/controllers/api/farmware_installations_controller.rb b/app/controllers/api/farmware_installations_controller.rb index ef511f04e0..9542a0c513 100644 --- a/app/controllers/api/farmware_installations_controller.rb +++ b/app/controllers/api/farmware_installations_controller.rb @@ -31,7 +31,7 @@ def farmware_installations end def farmware_installation - @farmware_installation ||= farmware_installations.find(params[:id]) + @farmware_installation ||= farmware_installations.find(params.expect(:id)) end end end diff --git a/app/controllers/api/folders_controller.rb b/app/controllers/api/folders_controller.rb index 68756d9235..61f2b4f4ea 100644 --- a/app/controllers/api/folders_controller.rb +++ b/app/controllers/api/folders_controller.rb @@ -25,7 +25,7 @@ def destroy private def folder - folders.find(params[:id]) + folders.find(params.expect(:id)) end def folders diff --git a/app/controllers/api/images_controller.rb b/app/controllers/api/images_controller.rb index 209f7b10f6..72a0d36c92 100644 --- a/app/controllers/api/images_controller.rb +++ b/app/controllers/api/images_controller.rb @@ -44,7 +44,7 @@ def policy_class end def image - @image ||= Image.where(device: current_device).find(params[:id]) + @image ||= Image.where(device: current_device).find(params.expect(:id)) end end end diff --git a/app/controllers/api/logs_controller.rb b/app/controllers/api/logs_controller.rb index c54a568a02..fcd0227bfc 100644 --- a/app/controllers/api/logs_controller.rb +++ b/app/controllers/api/logs_controller.rb @@ -49,7 +49,7 @@ def destroy if params[:id] == "all" render json: (current_device.logs.destroy_all && "") else - render json: (current_device.logs.find(params[:id]).destroy! && "") + render json: (current_device.logs.find(params.expect(:id)).destroy! && "") end end diff --git a/app/controllers/api/peripherals_controller.rb b/app/controllers/api/peripherals_controller.rb index d9ed7e98e9..f7b2730848 100644 --- a/app/controllers/api/peripherals_controller.rb +++ b/app/controllers/api/peripherals_controller.rb @@ -25,7 +25,7 @@ def destroy private def peripheral - @peripheral ||= current_device.peripherals.find(params[:id]) + @peripheral ||= current_device.peripherals.find(params.expect(:id)) end end end diff --git a/app/controllers/api/pin_bindings_controller.rb b/app/controllers/api/pin_bindings_controller.rb index 79d630e036..b6e24985de 100644 --- a/app/controllers/api/pin_bindings_controller.rb +++ b/app/controllers/api/pin_bindings_controller.rb @@ -31,7 +31,7 @@ def pin_bindings end def pin_binding - @pin_binding ||= pin_bindings.find(params[:id]) + @pin_binding ||= pin_bindings.find(params.expect(:id)) end end end diff --git a/app/controllers/api/plant_templates_controller.rb b/app/controllers/api/plant_templates_controller.rb index 0618edcc9a..2991139e38 100644 --- a/app/controllers/api/plant_templates_controller.rb +++ b/app/controllers/api/plant_templates_controller.rb @@ -25,7 +25,7 @@ def plant_templates end def plant_template - @plant_template ||= plant_templates.find(params[:id]) + @plant_template ||= plant_templates.find(params.expect(:id)) end end end diff --git a/app/controllers/api/point_groups_controller.rb b/app/controllers/api/point_groups_controller.rb index bd45be617a..24e2987e9d 100644 --- a/app/controllers/api/point_groups_controller.rb +++ b/app/controllers/api/point_groups_controller.rb @@ -25,7 +25,7 @@ def destroy private def the_point_group - your_point_groups.find(params[:id]) + your_point_groups.find(params.expect(:id)) end def your_point_groups diff --git a/app/controllers/api/points_controller.rb b/app/controllers/api/points_controller.rb index 9e785c5c9d..4097271e6d 100644 --- a/app/controllers/api/points_controller.rb +++ b/app/controllers/api/points_controller.rb @@ -21,7 +21,7 @@ def index end def show - render json: points.find(params[:id]) + render json: points.find(params.expect(:id)) end def search @@ -39,14 +39,14 @@ def update def destroy # TODO: We don't need to do batch requests like this any more. # This should be removed when possible. -RC 1 AUG 2018 - ids = params[:id].to_s.split(",").map(&:to_i) + ids = params.expect(:id).to_s.split(",").map(&:to_i) mutate Points::Destroy.run({ point_ids: ids }, device_params) end private def point - @point ||= points.find(params[:id]) + @point ||= points.find(params.expect(:id)) end def points(filter = params[:filter]) diff --git a/app/controllers/api/regimens_controller.rb b/app/controllers/api/regimens_controller.rb index 2f3405781a..ac589029ee 100644 --- a/app/controllers/api/regimens_controller.rb +++ b/app/controllers/api/regimens_controller.rb @@ -25,7 +25,7 @@ def destroy private def the_regimen - your_regimens.find(params[:id]) + your_regimens.find(params.expect(:id)) end def your_regimens diff --git a/app/controllers/api/saved_gardens_controller.rb b/app/controllers/api/saved_gardens_controller.rb index d0c77894a6..813f2e53d1 100644 --- a/app/controllers/api/saved_gardens_controller.rb +++ b/app/controllers/api/saved_gardens_controller.rb @@ -36,7 +36,7 @@ def gardens end def garden - @garden ||= gardens.find(params[:id]) + @garden ||= gardens.find(params.expect(:id)) end end end diff --git a/app/controllers/api/sensor_readings_controller.rb b/app/controllers/api/sensor_readings_controller.rb index 7e6f050e40..645cf6c8dd 100644 --- a/app/controllers/api/sensor_readings_controller.rb +++ b/app/controllers/api/sensor_readings_controller.rb @@ -33,7 +33,7 @@ def readings end def reading - @reading ||= readings.find(params[:id]) + @reading ||= readings.find(params.expect(:id)) end end end diff --git a/app/controllers/api/sensors_controller.rb b/app/controllers/api/sensors_controller.rb index aff19605e0..7d401a1dd7 100644 --- a/app/controllers/api/sensors_controller.rb +++ b/app/controllers/api/sensors_controller.rb @@ -23,7 +23,7 @@ def destroy private def sensor - @sensor ||= current_device.sensors.find(params[:id]) + @sensor ||= current_device.sensors.find(params.expect(:id)) end end end diff --git a/app/controllers/api/sequence_versions_controller.rb b/app/controllers/api/sequence_versions_controller.rb index e9d0ffdc23..5b91b6b03e 100644 --- a/app/controllers/api/sequence_versions_controller.rb +++ b/app/controllers/api/sequence_versions_controller.rb @@ -24,7 +24,7 @@ def version_meta end def sequence_version - @sequence_version ||= SequenceVersion.find(params[:id]) + @sequence_version ||= SequenceVersion.find(params.expect(:id)) end end end diff --git a/app/controllers/api/sequences_controller.rb b/app/controllers/api/sequences_controller.rb index bb147cbadd..bd9e46ee5c 100644 --- a/app/controllers/api/sequences_controller.rb +++ b/app/controllers/api/sequences_controller.rb @@ -75,7 +75,7 @@ def upgrade private def sequence_version - @sequence_version ||= SequenceVersion.find(params[:sequence_version_id]) + @sequence_version ||= SequenceVersion.find(params.expect(:sequence_version_id)) end def sequence_params @@ -84,7 +84,7 @@ def sequence_params # Retrieve a single sequence record directly associated with the current device def sequence - @sequence ||= Sequence.with_usage_reports.find_by!(id: params[:id], device: current_device) + @sequence ||= Sequence.with_usage_reports.find_by!(id: params.expect(:id), device: current_device) end end end diff --git a/app/controllers/api/telemetries_controller.rb b/app/controllers/api/telemetries_controller.rb index b774ab6535..154ec7f8a2 100644 --- a/app/controllers/api/telemetries_controller.rb +++ b/app/controllers/api/telemetries_controller.rb @@ -14,7 +14,7 @@ def destroy if params[:id] == "all" render json: (current_device.telemetries.destroy_all && "") else - render json: (current_device.telemetries.find(params[:id]).destroy! && "") + render json: (current_device.telemetries.find(params.expect(:id)).destroy! && "") end end diff --git a/app/controllers/api/tools_controller.rb b/app/controllers/api/tools_controller.rb index b7812759f1..4abd130b38 100644 --- a/app/controllers/api/tools_controller.rb +++ b/app/controllers/api/tools_controller.rb @@ -33,7 +33,7 @@ def tools end def tool - @tool ||= Tool.join_tool_slot_and_find_by_id(params[:id].to_i) + @tool ||= Tool.join_tool_slot_and_find_by_id(params.expect(:id).to_i) end end end diff --git a/app/controllers/api/webcam_feeds_controller.rb b/app/controllers/api/webcam_feeds_controller.rb index 82ae43f80a..ee20954cf1 100644 --- a/app/controllers/api/webcam_feeds_controller.rb +++ b/app/controllers/api/webcam_feeds_controller.rb @@ -25,7 +25,7 @@ def destroy private def webcam - webcams.find(params[:id]) + webcams.find(params.expect(:id)) end def webcams diff --git a/app/controllers/api/wizard_step_results_controller.rb b/app/controllers/api/wizard_step_results_controller.rb index 8f752bb31c..0b5a77a9f1 100644 --- a/app/controllers/api/wizard_step_results_controller.rb +++ b/app/controllers/api/wizard_step_results_controller.rb @@ -25,7 +25,7 @@ def update_params end def wizard_step_result - @wizard_step_result ||= wizard_step_results.find(params[:id]) + @wizard_step_result ||= wizard_step_results.find(params.expect(:id)) end def wizard_step_results diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index da3fff1dbd..c956a72b3d 100755 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,6 +1,14 @@ class ApplicationController < ActionController::Base # For APIs, you may want to use :null_session instead. protect_from_forgery with: :null_session + # class static initialization block support + allow_browser versions: { + chrome: 94, + firefox: 93, + edge: 94, + ie: false + } + after_action :unset_current_device def unset_current_device diff --git a/app/mutations/devices/create_seed_data.rb b/app/mutations/devices/create_seed_data.rb index 2ef30c595f..a5e8da45aa 100644 --- a/app/mutations/devices/create_seed_data.rb +++ b/app/mutations/devices/create_seed_data.rb @@ -1,5 +1,11 @@ module Devices class CreateSeedData < Mutations::Command + STRESS_DEMO_ONLY = + "Stress product lines are only available for demo accounts." + STRESS_PRODUCT_LINES = + Devices::Seeders::StressData::PRODUCT_LINES + .transform_values { Devices::Seeders::GenesisXlOneEight } + PRODUCT_LINES = { "express_1.0" => Devices::Seeders::ExpressOneZero, "express_1.1" => Devices::Seeders::ExpressOneOne, @@ -22,7 +28,7 @@ class CreateSeedData < Mutations::Command "genesis_xl_1.8" => Devices::Seeders::GenesisXlOneEight, "none" => Devices::Seeders::None, - } + }.merge(STRESS_PRODUCT_LINES) required do model :device @@ -33,6 +39,12 @@ class CreateSeedData < Mutations::Command boolean :demo end + def validate + if Devices::Seeders::StressData.stress?(product_line) && !demo + add_error(:product_line, :demo_only, STRESS_DEMO_ONLY) + end + end + def execute if device.account_seeded_at return { done: "Device already has seed data." } diff --git a/app/mutations/devices/seeders/demo_account_seeder.rb b/app/mutations/devices/seeders/demo_account_seeder.rb index 960f4caf90..f09063a11a 100644 --- a/app/mutations/devices/seeders/demo_account_seeder.rb +++ b/app/mutations/devices/seeders/demo_account_seeder.rb @@ -187,8 +187,13 @@ def before_product_line_seeder def after_product_line_seeder(product_line) create_webcam_feed(product_line) - add_plants(product_line) - add_soil_height_points(product_line) + stress_count = Devices::Seeders::StressData.count_for(product_line) + if stress_count + Devices::Seeders::StressData.new(device, stress_count).seed! + else + add_plants(product_line) + add_soil_height_points(product_line) + end add_point_groups tool = device.tools.find_by(name: ToolNames::WATERING_NOZZLE) Tools::Update.run(tool: tool, flow_rate_ml_per_s: 100) if tool diff --git a/app/mutations/devices/seeders/stress_data.rb b/app/mutations/devices/seeders/stress_data.rb new file mode 100644 index 0000000000..2ffd43fa94 --- /dev/null +++ b/app/mutations/devices/seeders/stress_data.rb @@ -0,0 +1,220 @@ +module Devices + module Seeders + class StressData + include Constants + + PRODUCT_LINES = { + "genesis_xl_1.8_stress_250" => 250, + "genesis_xl_1.8_stress_500" => 500, + "genesis_xl_1.8_stress_750" => 750, + "genesis_xl_1.8_stress_1000" => 1_000, + }.freeze + + CROP_DATA = [ + %w[Spinach spinach], + %w[Broccoli broccoli], + %w[Beet beet], + ].freeze + + IMAGE_PATH = Rails.public_path.join("soil.png") + + attr_reader :device, :count + + def self.count_for(product_line) + PRODUCT_LINES[product_line] + end + + def self.stress?(product_line) + count_for(product_line).present? + end + + def initialize(device, count) + @device = device + @count = count + end + + def seed! + [plant_rows, soil_height_rows, weed_rows].each do |rows| + Point.insert_all!(rows) + end + Image.insert_all!(image_rows, returning: %w[id]).then do |result| + attach_images(result.rows.flatten) + end + SensorReading.insert_all!(sensor_reading_rows) + update_demo_settings + end + + private + + def plant_rows + count.times.map do |i| + crop_name, slug = CROP_DATA[i % CROP_DATA.length] + x, y = coordinate(i) + timestamp = timestamp(i) + { + created_at: timestamp, + updated_at: timestamp, + device_id: device.id, + pointer_type: "Plant", + name: crop_name, + openfarm_slug: slug, + plant_stage: i.even? ? "planted" : "planned", + planted_at: timestamp, + radius: 25, + depth: 5, + x: x, + y: y, + z: 0, + meta: {}, + } + end + end + + def soil_height_rows + count.times.map do |i| + x, y = coordinate(i, x_offset: 35, y_offset: 20) + timestamp = timestamp(i) + { + created_at: timestamp, + updated_at: timestamp, + device_id: device.id, + pointer_type: "GenericPointer", + name: "Soil Height", + radius: 0, + x: x, + y: y, + z: -550 + (i % 101), + meta: { color: "gray", at_soil_level: "true" }, + } + end + end + + def weed_rows + count.times.map do |i| + x, y = coordinate(i, x_offset: 70, y_offset: 40) + timestamp = timestamp(i) + { + created_at: timestamp, + updated_at: timestamp, + device_id: device.id, + pointer_type: "Weed", + name: "Stress Weed #{i + 1}", + plant_stage: "active", + radius: 20 + (i % 6), + x: x, + y: y, + z: 0, + meta: { color: i.even? ? "red" : "yellow" }, + } + end + end + + def image_rows + count.times.map do |i| + x, y = coordinate(i, x_offset: 105, y_offset: 60) + timestamp = timestamp(i) + { + created_at: timestamp, + updated_at: timestamp, + device_id: device.id, + attachment_processed_at: timestamp, + meta: { x: x, y: y, z: 0, name: "Stress Image #{i + 1}" }.to_yaml, + } + end + end + + def sensor_reading_rows + count.times.map do |i| + x, y = coordinate(i, x_offset: 140, y_offset: 80) + timestamp = timestamp(i) + { + created_at: timestamp, + updated_at: timestamp, + read_at: timestamp, + device_id: device.id, + pin: 59, + mode: ANALOG, + value: 300 + ((i * 37) % 600), + x: x, + y: y, + z: 0, + } + end + end + + def attach_images(image_ids) + blob = File.open(IMAGE_PATH) do |file| + ActiveStorage::Blob.create_and_upload!( + io: file, + filename: "stress-soil.png", + content_type: "image/png", + ) + end + rows = image_ids.map do |image_id| + { + name: "attachment", + record_type: "Image", + record_id: image_id, + blob_id: blob.id, + created_at: Time.now, + } + end + ActiveStorage::Attachment.insert_all!(rows) + end + + def update_demo_settings + device.update!(max_images_count: count) + device.web_app_config.update!( + show_images: true, + show_points: true, + show_plants: true, + show_sensor_readings: true, + show_moisture_interpolation_map: true, + show_weeds: true, + show_spread: true, + three_d_garden: true, + ) + end + + def coordinate(index, x_offset: 0, y_offset: 0) + col_count = Math.sqrt(count).ceil + row_count = (count.to_f / col_count).ceil + col = index % col_count + row = index / col_count + [ + clamp(100 + col * x_spacing(col_count) + x_offset, map_size_x), + clamp(100 + row * y_spacing(row_count) + y_offset, map_size_y), + ].map(&:round) + end + + def x_spacing(col_count) + span(map_size_x).fdiv([col_count - 1, 1].max) + end + + def y_spacing(row_count) + span(map_size_y).fdiv([row_count - 1, 1].max) + end + + def span(axis_length) + [axis_length - 200, 1].max + end + + def clamp(value, max) + value.clamp(0, max) + end + + def map_size_x + @map_size_x ||= device.web_app_config.map_size_x + end + + def map_size_y + @map_size_y ||= device.web_app_config.map_size_y + end + + def timestamp(index) + @timestamp ||= Time.now + @timestamp - index.seconds + end + end + end +end diff --git a/app/mutations/points/destroy.rb b/app/mutations/points/destroy.rb index 8a2c413a1b..e3ec3c5699 100644 --- a/app/mutations/points/destroy.rb +++ b/app/mutations/points/destroy.rb @@ -1,9 +1,7 @@ module Points class Destroy < Mutations::Command - STILL_IN_USE = "Could not delete the following item(s): %s. Item(s) are " \ - "in use by the following sequence(s): %s." - JUST_ONE = "Could not delete %s. Item is in use by the following " \ - "sequence(s): %s." + STILL_IN_USE = "Could not delete %s. Items are in use by %s." + JUST_ONE = "Could not delete %s. Item is in use by %s." required do model :device, class: Device @@ -19,21 +17,18 @@ class Destroy < Mutations::Command def validate maybe_wrap_ids - # Collect names of sequences that still use this point. - problems = (tool_seq + point_seq + resource_update_seq) - .group_by(&:sequence_name) - .to_a - .each_with_object({ S => [], P => [] }) do |(seq_name, data), total| - total[S].push(seq_name) - total[P].push(*(data || []).map(&:fancy_name)) + problems = point_usage.each_with_object({ S => [], P => [] }) do |usage, total| + owner, point = usage + total[S].push(owner_name(owner)) + total[P].push(point.fancy_name) end p = problems[P].sort.uniq.join(", ") if p.present? - sequences = problems[S].sort.uniq.join(", ") + owners = problems[S].sort.uniq.join(", ") message = point_ids.many? ? STILL_IN_USE : JUST_ONE - problems = format(message, p, sequences) + problems = format(message, p, owners) add_error :whoops, :in_use, problems end @@ -109,6 +104,68 @@ def tool_seq .to_a end + def point_usage + @point_usage ||= sequence_point_usage + fragment_point_usage + end + + def sequence_point_usage + @sequence_point_usage ||= sequence_usage + .group_by(&:sequence_id) + .to_a + .flat_map do |(sequence_id, data)| + sequence = sequence_owners[sequence_id] + next [] unless sequence + + (data || []).map { |point| [sequence, point] } + end + end + + def sequence_usage + @sequence_usage ||= tool_seq + point_seq + resource_update_seq + end + + def sequence_owners + @sequence_owners ||= Sequence + .where(id: sequence_usage.map(&:sequence_id).uniq) + .index_by(&:id) + end + + def fragment_point_usage + @fragment_point_usage ||= begin + primitives = point_ids.flat_map do |point_id| + Primitive + .where(fragment_id: fragment_owner_ids) + .where(value: point_id) + end + pairs = PrimitivePair + .includes(:primitive) + .where(arg_name_id: ArgName.find_or_create_by!(value: "pointer_id").id) + .where(primitive_id: primitives.map(&:id)) + owners = Fragment + .includes(:owner) + .where(id: pairs.map(&:fragment_id).uniq) + .index_by(&:id) + points_by_id = points.index_by(&:id) + + pairs.each_with_object([]) do |pair, result| + owner = owners[pair.fragment_id]&.owner + point = points_by_id[pair.primitive.value] + result.push([owner, point]) if owner && point + end + end + end + + def fragment_owner_ids + @fragment_owner_ids ||= Fragment + .where(device_id: device.id, + owner_type: [FarmEvent.name, Regimen.name]) + .pluck(:id) + end + + def owner_name(owner) + "#{owner.class.name} '#{owner.fancy_name}'" + end + def maybe_wrap_ids raise "NO" unless (point || point_ids) diff --git a/app/views/dashboard/promo.html.erb b/app/views/dashboard/promo.html.erb index 97f76e5473..5722dd921b 100644 --- a/app/views/dashboard/promo.html.erb +++ b/app/views/dashboard/promo.html.erb @@ -7,7 +7,3 @@ margin: 0; } -
- Loading 3D interactive experience... -

Loading interactive 3D FarmBot...

-
diff --git a/bun.lock b/bun.lock index edd6ff89b2..c25d3eeb78 100644 --- a/bun.lock +++ b/bun.lock @@ -5,27 +5,27 @@ "": { "name": "farmbot-web-frontend", "dependencies": { - "@blueprintjs/core": "6.12.0", - "@blueprintjs/select": "6.1.9", + "@blueprintjs/core": "6.15.0", + "@blueprintjs/select": "6.2.1", "@monaco-editor/react": "4.7.0", - "@react-spring/three": "10.0.3", + "@react-spring/three": "10.1.0", "@react-three/drei": "10.7.7", - "@react-three/fiber": "9.6.0", + "@react-three/fiber": "9.6.1", "@rollbar/react": "1.0.0", - "@types/bun": "1.3.13", + "@types/bun": "1.3.14", "@types/lodash": "4.17.24", "@types/markdown-it": "14.1.2", "@types/markdown-it-emoji": "3.0.1", "@types/promise-timeout": "1.3.3", - "@types/react": "19.2.14", + "@types/react": "19.2.15", "@types/react-color": "3.0.13", "@types/react-dom": "19.2.3", "@types/react-test-renderer": "19.1.0", "@types/redux-immutable-state-invariant": "2.1.4", - "@types/three": "0.184.0", + "@types/three": "0.184.1", "@types/ws": "8.18.1", "@xterm/xterm": "6.0.0", - "axios": "1.15.2", + "axios": "1.16.1", "bowser": "2.14.1", "browser-speech": "1.1.1", "delaunator": "5.1.0", @@ -33,9 +33,9 @@ "farmbot": "15.9.3", "fengari": "0.1.5", "fengari-web": "0.1.4", - "i18next": "26.0.6", + "i18next": "26.2.0", "lodash": "4.18.1", - "markdown-it": "14.1.1", + "markdown-it": "14.2.0", "markdown-it-emoji": "3.0.0", "moment": "2.30.1", "monaco-editor": "0.55.1", @@ -44,11 +44,11 @@ "promise-timeout": "1.3.0", "punycode": "2.3.1", "querystring-es3": "0.2.1", - "react": "19.2.5", + "react": "19.2.6", "react-color": "2.19.3", - "react-dom": "19.2.5", - "react-redux": "9.2.0", - "react-router": "7.14.2", + "react-dom": "19.2.6", + "react-redux": "9.3.0", + "react-router": "7.15.1", "redux": "5.0.1", "redux-immutable-state-invariant": "2.1.0", "redux-thunk": "3.1.0", @@ -71,36 +71,36 @@ "@types/jest": "30.0.0", "@types/readable-stream": "4.0.23", "@types/suncalc": "1.9.2", - "@typescript-eslint/eslint-plugin": "8.59.0", - "@typescript-eslint/parser": "8.59.0", - "eslint": "10.2.1", + "@typescript-eslint/eslint-plugin": "8.60.0", + "@typescript-eslint/parser": "8.60.0", + "eslint": "10.4.0", "eslint-plugin-eslint-comments": "3.2.0", "eslint-plugin-import": "2.32.0", "eslint-plugin-jest": "29.15.2", "eslint-plugin-no-null": "1.0.2", - "eslint-plugin-promise": "7.2.1", + "eslint-plugin-promise": "7.3.0", "eslint-plugin-react": "7.37.5", "eslint-plugin-react-hooks": "7.1.1", "happy-dom": "20.9.0", - "jest": "30.3.0", + "jest": "30.4.2", "jest-canvas-mock": "2.5.2", - "jest-cli": "30.3.0", - "jest-environment-jsdom": "30.3.0", - "jest-junit": "16.0.0", + "jest-cli": "30.4.2", + "jest-environment-jsdom": "30.4.1", + "jest-junit": "17.0.0", "jest-skipped-reporter": "0.0.5", "jshint": "2.13.6", "madge": "8.0.0", "path-browserify": "1.0.1", - "playwright": "1.59.1", - "postcss": "8.5.10", + "playwright": "1.60.0", + "postcss": "8.5.15", "postcss-scss": "4.0.9", "raf": "3.4.1", - "react-test-renderer": "19.2.5", - "sass": "1.99.0", + "react-test-renderer": "19.2.6", + "sass": "1.100.0", "sass-lint": "1.13.1", - "stylelint": "17.8.0", + "stylelint": "17.12.0", "stylelint-config-standard-scss": "17.0.0", - "ts-jest": "29.4.9", + "ts-jest": "29.4.11", "tslint": "6.1.3", }, }, @@ -186,11 +186,11 @@ "@blueprintjs/colors": ["@blueprintjs/colors@5.1.16", "", { "dependencies": { "tslib": "~2.6.2" } }, "sha512-P9uX0Aj2TP9+6aUcori1iPl4snxM/Vgq0LZbhUl1l5bHTgNxxwm/0+IoS/SlQg93HBRl8KTAM1evEqtPbwV10A=="], - "@blueprintjs/core": ["@blueprintjs/core@6.12.0", "", { "dependencies": { "@blueprintjs/colors": "^5.1.16", "@blueprintjs/icons": "^6.9.0", "@floating-ui/react": "^0.27.13", "@popperjs/core": "^2.11.8", "classnames": "^2.3.1", "normalize.css": "^8.0.1", "react-popper": "^2.3.0", "react-transition-group": "^4.4.5", "tslib": "~2.6.2", "use-sync-external-store": "^1.2.0" }, "peerDependencies": { "@types/react": "18", "react": "18", "react-dom": "18" }, "optionalPeers": ["@types/react"], "bin": { "upgrade-blueprint-2.0.0-rename": "scripts/upgrade-blueprint-2.0.0-rename.sh", "upgrade-blueprint-3.0.0-rename": "scripts/upgrade-blueprint-3.0.0-rename.sh" } }, "sha512-huBwfAU0/n4XG33C1xl4cWd/f+POtU11AL1wjTG/zWqyV4HRd57TE92z+SdioBbvCm6fmwEjNfjKAu1P1ojWxw=="], + "@blueprintjs/core": ["@blueprintjs/core@6.15.0", "", { "dependencies": { "@blueprintjs/colors": "^5.1.16", "@blueprintjs/icons": "^6.10.0", "@floating-ui/react": "^0.27.13", "@popperjs/core": "^2.11.8", "classnames": "^2.3.1", "normalize.css": "^8.0.1", "react-popper": "^2.3.0", "react-transition-group": "^4.4.5", "tslib": "~2.6.2" }, "peerDependencies": { "@types/react": "18", "react": "18", "react-dom": "18" }, "optionalPeers": ["@types/react"], "bin": { "upgrade-blueprint-2.0.0-rename": "scripts/upgrade-blueprint-2.0.0-rename.sh", "upgrade-blueprint-3.0.0-rename": "scripts/upgrade-blueprint-3.0.0-rename.sh" } }, "sha512-g0CcgoZltOHd142EJfLkLpsgQ5/JjFaJmMkTclef1Z5bZUrkJl8pLS8GZfjdhLp/ZGWqFUZRXvKV2jEnbd/W1Q=="], - "@blueprintjs/icons": ["@blueprintjs/icons@6.9.0", "", { "dependencies": { "change-case": "^4.1.2", "classnames": "^2.3.1", "tslib": "~2.6.2" }, "peerDependencies": { "@types/react": "18", "react": "18", "react-dom": "18" }, "optionalPeers": ["@types/react"] }, "sha512-pnwnw6dPARk7Q4CZ9ZKeVe9C8PO1OE9cv9DO4mPgNqJBIOrI9aVAWbHsfjqpbOPXx82/O65kNOQweP8foAzPRA=="], + "@blueprintjs/icons": ["@blueprintjs/icons@6.10.0", "", { "dependencies": { "change-case": "^4.1.2", "classnames": "^2.3.1", "tslib": "~2.6.2" }, "peerDependencies": { "@types/react": "18", "react": "18", "react-dom": "18" }, "optionalPeers": ["@types/react"] }, "sha512-KjNviCpxjJOcIVgNUOlw2FCGp8T5o0SZW56alL6veDHyHnMP48jaW8cVCRvKvZqGmUL+d9faLis1CJzI20E+kg=="], - "@blueprintjs/select": ["@blueprintjs/select@6.1.9", "", { "dependencies": { "@blueprintjs/colors": "^5.1.16", "@blueprintjs/core": "^6.12.0", "@blueprintjs/icons": "^6.9.0", "classnames": "^2.3.1", "tslib": "~2.6.2" }, "peerDependencies": { "@types/react": "18", "react": "18", "react-dom": "18" }, "optionalPeers": ["@types/react"] }, "sha512-PtHQl0MWr606/PGxvN3xjYYvMc+gLyeHy/FdzK0/SXK4s9dBCBXGbdJEo8yVtDo71jyrJXZfjUkJlf+KpqRibQ=="], + "@blueprintjs/select": ["@blueprintjs/select@6.2.1", "", { "dependencies": { "@blueprintjs/colors": "^5.1.16", "@blueprintjs/core": "^6.15.0", "@blueprintjs/icons": "^6.10.0", "classnames": "^2.3.1", "tslib": "~2.6.2" }, "peerDependencies": { "@types/react": "18", "react": "18", "react-dom": "18" }, "optionalPeers": ["@types/react"] }, "sha512-NBMxdAR7PE4Oyn5tN9AfKVzFpCRoQHaIuhNCCGFFjt22efgQscQF+ppHPe1+pUy4YIWB2KBwfBUT5bpIvNFPfA=="], "@cacheable/memory": ["@cacheable/memory@2.0.8", "", { "dependencies": { "@cacheable/utils": "^2.4.0", "@keyv/bigmap": "^1.3.1", "hookified": "^1.15.1", "keyv": "^5.6.0" } }, "sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw=="], @@ -198,13 +198,13 @@ "@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="], - "@csstools/css-calc": ["@csstools/css-calc@3.1.1", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ=="], + "@csstools/css-calc": ["@csstools/css-calc@3.2.0", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w=="], "@csstools/css-color-parser": ["@csstools/css-color-parser@3.1.0", "", { "dependencies": { "@csstools/color-helpers": "^5.1.0", "@csstools/css-calc": "^2.1.4" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA=="], "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="], - "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.2", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA=="], + "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.3", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg=="], "@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="], @@ -230,7 +230,7 @@ "@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="], - "@eslint/config-helpers": ["@eslint/config-helpers@0.5.5", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w=="], + "@eslint/config-helpers": ["@eslint/config-helpers@0.6.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA=="], "@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="], @@ -274,43 +274,43 @@ "@istanbuljs/schema": ["@istanbuljs/schema@0.1.3", "", {}, "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA=="], - "@jest/console": ["@jest/console@30.3.0", "", { "dependencies": { "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "jest-message-util": "30.3.0", "jest-util": "30.3.0", "slash": "^3.0.0" } }, "sha512-PAwCvFJ4696XP2qZj+LAn1BWjZaJ6RjG6c7/lkMaUJnkyMS34ucuIsfqYvfskVNvUI27R/u4P1HMYFnlVXG/Ww=="], + "@jest/console": ["@jest/console@30.4.1", "", { "dependencies": { "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "jest-message-util": "30.4.1", "jest-util": "30.4.1", "slash": "^3.0.0" } }, "sha512-v3bhyxUh9Hgmo5p6hAOXe14/R3ZxZDOsvHleh4B07z3m/x4/ngPUXEm9XwK4sF4u+f+P2ORb0Ge+MgpaqRMVDA=="], - "@jest/core": ["@jest/core@30.3.0", "", { "dependencies": { "@jest/console": "30.3.0", "@jest/pattern": "30.0.1", "@jest/reporters": "30.3.0", "@jest/test-result": "30.3.0", "@jest/transform": "30.3.0", "@jest/types": "30.3.0", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "ci-info": "^4.2.0", "exit-x": "^0.2.2", "graceful-fs": "^4.2.11", "jest-changed-files": "30.3.0", "jest-config": "30.3.0", "jest-haste-map": "30.3.0", "jest-message-util": "30.3.0", "jest-regex-util": "30.0.1", "jest-resolve": "30.3.0", "jest-resolve-dependencies": "30.3.0", "jest-runner": "30.3.0", "jest-runtime": "30.3.0", "jest-snapshot": "30.3.0", "jest-util": "30.3.0", "jest-validate": "30.3.0", "jest-watcher": "30.3.0", "pretty-format": "30.3.0", "slash": "^3.0.0" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, "optionalPeers": ["node-notifier"] }, "sha512-U5mVPsBxLSO6xYbf+tgkymLx+iAhvZX43/xI1+ej2ZOPnPdkdO1CzDmFKh2mZBn2s4XZixszHeQnzp1gm/DIxw=="], + "@jest/core": ["@jest/core@30.4.2", "", { "dependencies": { "@jest/console": "30.4.1", "@jest/pattern": "30.4.0", "@jest/reporters": "30.4.1", "@jest/test-result": "30.4.1", "@jest/transform": "30.4.1", "@jest/types": "30.4.1", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "ci-info": "^4.2.0", "exit-x": "^0.2.2", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", "jest-changed-files": "30.4.1", "jest-config": "30.4.2", "jest-haste-map": "30.4.1", "jest-message-util": "30.4.1", "jest-regex-util": "30.4.0", "jest-resolve": "30.4.1", "jest-resolve-dependencies": "30.4.2", "jest-runner": "30.4.2", "jest-runtime": "30.4.2", "jest-snapshot": "30.4.1", "jest-util": "30.4.1", "jest-validate": "30.4.1", "jest-watcher": "30.4.1", "pretty-format": "30.4.1", "slash": "^3.0.0" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, "optionalPeers": ["node-notifier"] }, "sha512-TZJA6cPJUFxoWhxaLo8t0VX/MZX2wPWr0uIDvLSHIvN4gu9h02vSzqI2kBADG1ExqQlC+cY09xKMSreivvrChQ=="], "@jest/diff-sequences": ["@jest/diff-sequences@30.0.1", "", {}, "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw=="], - "@jest/environment": ["@jest/environment@30.3.0", "", { "dependencies": { "@jest/fake-timers": "30.3.0", "@jest/types": "30.3.0", "@types/node": "*", "jest-mock": "30.3.0" } }, "sha512-SlLSF4Be735yQXyh2+mctBOzNDx5s5uLv88/j8Qn1wH679PDcwy67+YdADn8NJnGjzlXtN62asGH/T4vWOkfaw=="], + "@jest/environment": ["@jest/environment@30.4.1", "", { "dependencies": { "@jest/fake-timers": "30.4.1", "@jest/types": "30.4.1", "@types/node": "*", "jest-mock": "30.4.1" } }, "sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w=="], - "@jest/environment-jsdom-abstract": ["@jest/environment-jsdom-abstract@30.3.0", "", { "dependencies": { "@jest/environment": "30.3.0", "@jest/fake-timers": "30.3.0", "@jest/types": "30.3.0", "@types/jsdom": "^21.1.7", "@types/node": "*", "jest-mock": "30.3.0", "jest-util": "30.3.0" }, "peerDependencies": { "canvas": "^3.0.0", "jsdom": "*" }, "optionalPeers": ["canvas"] }, "sha512-0hNFs5N6We3DMCwobzI0ydhkY10sT1tZSC0AAiy+0g2Dt/qEWgrcV5BrMxPczhe41cxW4qm6X+jqZaUdpZIajA=="], + "@jest/environment-jsdom-abstract": ["@jest/environment-jsdom-abstract@30.4.1", "", { "dependencies": { "@jest/environment": "30.4.1", "@jest/fake-timers": "30.4.1", "@jest/types": "30.4.1", "@types/jsdom": "^21.1.7", "@types/node": "*", "jest-mock": "30.4.1", "jest-util": "30.4.1" }, "peerDependencies": { "canvas": "^3.0.0", "jsdom": "*" }, "optionalPeers": ["canvas"] }, "sha512-dSlKrqug3siYNHVnjwIldShY12wAH3spwRltO/+8VOjg0X+xEq7vOs3DbBs4LRKsu7OH+NUb9kuZUNBF9Ho3TA=="], - "@jest/expect": ["@jest/expect@30.3.0", "", { "dependencies": { "expect": "30.3.0", "jest-snapshot": "30.3.0" } }, "sha512-76Nlh4xJxk2D/9URCn3wFi98d2hb19uWE1idLsTt2ywhvdOldbw3S570hBgn25P4ICUZ/cBjybrBex2g17IDbg=="], + "@jest/expect": ["@jest/expect@30.4.1", "", { "dependencies": { "expect": "30.4.1", "jest-snapshot": "30.4.1" } }, "sha512-ginrj6TMgh2GshLUGCjO94Ptx9HhdZA/I6A9iUfyeLKFtdAjnKzHDgzgP9HYQgbxM1lbXScQ2eUBz2lGeVDPWA=="], "@jest/expect-utils": ["@jest/expect-utils@30.0.5", "", { "dependencies": { "@jest/get-type": "30.0.1" } }, "sha512-F3lmTT7CXWYywoVUGTCmom0vXq3HTTkaZyTAzIy+bXSBizB7o5qzlC9VCtq0arOa8GqmNsbg/cE9C6HLn7Szew=="], - "@jest/fake-timers": ["@jest/fake-timers@30.3.0", "", { "dependencies": { "@jest/types": "30.3.0", "@sinonjs/fake-timers": "^15.0.0", "@types/node": "*", "jest-message-util": "30.3.0", "jest-mock": "30.3.0", "jest-util": "30.3.0" } }, "sha512-WUQDs8SOP9URStX1DzhD425CqbN/HxUYCTwVrT8sTVBfMvFqYt/s61EK5T05qnHu0po6RitXIvP9otZxYDzTGQ=="], + "@jest/fake-timers": ["@jest/fake-timers@30.4.1", "", { "dependencies": { "@jest/types": "30.4.1", "@sinonjs/fake-timers": "^15.4.0", "@types/node": "*", "jest-message-util": "30.4.1", "jest-mock": "30.4.1", "jest-util": "30.4.1" } }, "sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ=="], "@jest/get-type": ["@jest/get-type@30.0.1", "", {}, "sha512-AyYdemXCptSRFirI5EPazNxyPwAL0jXt3zceFjaj8NFiKP9pOi0bfXonf6qkf82z2t3QWPeLCWWw4stPBzctLw=="], - "@jest/globals": ["@jest/globals@30.3.0", "", { "dependencies": { "@jest/environment": "30.3.0", "@jest/expect": "30.3.0", "@jest/types": "30.3.0", "jest-mock": "30.3.0" } }, "sha512-+owLCBBdfpgL3HU+BD5etr1SvbXpSitJK0is1kiYjJxAAJggYMRQz5hSdd5pq1sSggfxPbw2ld71pt4x5wwViA=="], + "@jest/globals": ["@jest/globals@30.4.1", "", { "dependencies": { "@jest/environment": "30.4.1", "@jest/expect": "30.4.1", "@jest/types": "30.4.1", "jest-mock": "30.4.1" } }, "sha512-ZbuY4cmXC8DkxYjfvT2DbcHWL2T6vmsMhXCDcmTB2T0y0gaezBI77ufq5ZAIdcRkYZ7NEQEDg1xFeKbxUJ5v5Q=="], - "@jest/pattern": ["@jest/pattern@30.0.1", "", { "dependencies": { "@types/node": "*", "jest-regex-util": "30.0.1" } }, "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA=="], + "@jest/pattern": ["@jest/pattern@30.4.0", "", { "dependencies": { "@types/node": "*", "jest-regex-util": "30.4.0" } }, "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg=="], - "@jest/reporters": ["@jest/reporters@30.3.0", "", { "dependencies": { "@bcoe/v8-coverage": "^0.2.3", "@jest/console": "30.3.0", "@jest/test-result": "30.3.0", "@jest/transform": "30.3.0", "@jest/types": "30.3.0", "@jridgewell/trace-mapping": "^0.3.25", "@types/node": "*", "chalk": "^4.1.2", "collect-v8-coverage": "^1.0.2", "exit-x": "^0.2.2", "glob": "^10.5.0", "graceful-fs": "^4.2.11", "istanbul-lib-coverage": "^3.0.0", "istanbul-lib-instrument": "^6.0.0", "istanbul-lib-report": "^3.0.0", "istanbul-lib-source-maps": "^5.0.0", "istanbul-reports": "^3.1.3", "jest-message-util": "30.3.0", "jest-util": "30.3.0", "jest-worker": "30.3.0", "slash": "^3.0.0", "string-length": "^4.0.2", "v8-to-istanbul": "^9.0.1" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, "optionalPeers": ["node-notifier"] }, "sha512-a09z89S+PkQnL055bVj8+pe2Caed2PBOaczHcXCykW5ngxX9EWx/1uAwncxc/HiU0oZqfwseMjyhxgRjS49qPw=="], + "@jest/reporters": ["@jest/reporters@30.4.1", "", { "dependencies": { "@bcoe/v8-coverage": "^0.2.3", "@jest/console": "30.4.1", "@jest/test-result": "30.4.1", "@jest/transform": "30.4.1", "@jest/types": "30.4.1", "@jridgewell/trace-mapping": "^0.3.25", "@types/node": "*", "chalk": "^4.1.2", "collect-v8-coverage": "^1.0.2", "exit-x": "^0.2.2", "glob": "^10.5.0", "graceful-fs": "^4.2.11", "istanbul-lib-coverage": "^3.0.0", "istanbul-lib-instrument": "^6.0.0", "istanbul-lib-report": "^3.0.0", "istanbul-lib-source-maps": "^5.0.0", "istanbul-reports": "^3.1.3", "jest-message-util": "30.4.1", "jest-util": "30.4.1", "jest-worker": "30.4.1", "slash": "^3.0.0", "string-length": "^4.0.2", "v8-to-istanbul": "^9.0.1" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, "optionalPeers": ["node-notifier"] }, "sha512-/SnkPCzEQpUaBH81kjdEdDdo2WZl5hxw+BmLDGWjRkm8o7XlhjwsU36cqwe5PGBE5WYpBvDzRSdXx9rbGuJtNA=="], "@jest/schemas": ["@jest/schemas@30.0.5", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA=="], - "@jest/snapshot-utils": ["@jest/snapshot-utils@30.3.0", "", { "dependencies": { "@jest/types": "30.3.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "natural-compare": "^1.4.0" } }, "sha512-ORbRN9sf5PP82v3FXNSwmO1OTDR2vzR2YTaR+E3VkSBZ8zadQE6IqYdYEeFH1NIkeB2HIGdF02dapb6K0Mj05g=="], + "@jest/snapshot-utils": ["@jest/snapshot-utils@30.4.1", "", { "dependencies": { "@jest/types": "30.4.1", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "natural-compare": "^1.4.0" } }, "sha512-ObY4ljvQ95mt6iwKtVLetR/4yXiAgl3H4nJxhztr0MTjrN97TwDYrnCp/kF60Ec9HdhkWTHSu+Hg05aXfngpOA=="], "@jest/source-map": ["@jest/source-map@24.9.0", "", { "dependencies": { "callsites": "^3.0.0", "graceful-fs": "^4.1.15", "source-map": "^0.6.0" } }, "sha512-/Xw7xGlsZb4MJzNDgB7PW5crou5JqWiBQaz6xyPd3ArOg2nfn/PunV8+olXbbEZzNl591o5rWKE9BRDaFAuIBg=="], - "@jest/test-result": ["@jest/test-result@30.3.0", "", { "dependencies": { "@jest/console": "30.3.0", "@jest/types": "30.3.0", "@types/istanbul-lib-coverage": "^2.0.6", "collect-v8-coverage": "^1.0.2" } }, "sha512-e/52nJGuD74AKTSe0P4y5wFRlaXP0qmrS17rqOMHeSwm278VyNyXE3gFO/4DTGF9w+65ra3lo3VKj0LBrzmgdQ=="], + "@jest/test-result": ["@jest/test-result@30.4.1", "", { "dependencies": { "@jest/console": "30.4.1", "@jest/types": "30.4.1", "@types/istanbul-lib-coverage": "^2.0.6", "collect-v8-coverage": "^1.0.2" } }, "sha512-/ZG7pgEiOmmWkN9TplKbOu4id2N5lh7FHwRwlkgBVAzGdRH+OkkQ8wX/kIxg4zmd3ZQvAL1RwL2yWsvNYYECTw=="], - "@jest/test-sequencer": ["@jest/test-sequencer@30.3.0", "", { "dependencies": { "@jest/test-result": "30.3.0", "graceful-fs": "^4.2.11", "jest-haste-map": "30.3.0", "slash": "^3.0.0" } }, "sha512-dgbWy9b8QDlQeRZcv7LNF+/jFiiYHTKho1xirauZ7kVwY7avjFF6uTT0RqlgudB5OuIPagFdVtfFMosjVbk1eA=="], + "@jest/test-sequencer": ["@jest/test-sequencer@30.4.1", "", { "dependencies": { "@jest/test-result": "30.4.1", "graceful-fs": "^4.2.11", "jest-haste-map": "30.4.1", "slash": "^3.0.0" } }, "sha512-PeYE+4td5rKjoRPxztObrXU+H8hsjZfxKMXOcmrr34JerSyB/ROOxbbicz8B7A5j9R9VayDnVPvBmedqCsFCdw=="], - "@jest/transform": ["@jest/transform@30.3.0", "", { "dependencies": { "@babel/core": "^7.27.4", "@jest/types": "30.3.0", "@jridgewell/trace-mapping": "^0.3.25", "babel-plugin-istanbul": "^7.0.1", "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", "jest-haste-map": "30.3.0", "jest-regex-util": "30.0.1", "jest-util": "30.3.0", "pirates": "^4.0.7", "slash": "^3.0.0", "write-file-atomic": "^5.0.1" } }, "sha512-TLKY33fSLVd/lKB2YI1pH69ijyUblO/BQvCj566YvnwuzoTNr648iE0j22vRvVNk2HsPwByPxATg3MleS3gf5A=="], + "@jest/transform": ["@jest/transform@30.4.1", "", { "dependencies": { "@babel/core": "^7.27.4", "@jest/types": "30.4.1", "@jridgewell/trace-mapping": "^0.3.25", "babel-plugin-istanbul": "^7.0.1", "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", "jest-haste-map": "30.4.1", "jest-regex-util": "30.4.0", "jest-util": "30.4.1", "pirates": "^4.0.7", "slash": "^3.0.0", "write-file-atomic": "^5.0.1" } }, "sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ=="], - "@jest/types": ["@jest/types@30.3.0", "", { "dependencies": { "@jest/pattern": "30.0.1", "@jest/schemas": "30.0.5", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", "@types/node": "*", "@types/yargs": "^17.0.33", "chalk": "^4.1.2" } }, "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw=="], + "@jest/types": ["@jest/types@30.4.1", "", { "dependencies": { "@jest/pattern": "30.4.0", "@jest/schemas": "30.4.1", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", "@types/node": "*", "@types/yargs": "^17.0.33", "chalk": "^4.1.2" } }, "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], @@ -378,23 +378,23 @@ "@popperjs/core": ["@popperjs/core@2.11.8", "", {}, "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="], - "@react-spring/animated": ["@react-spring/animated@10.0.3", "", { "dependencies": { "@react-spring/shared": "~10.0.3", "@react-spring/types": "~10.0.3" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-7MrxADV3vaUADn2V9iYhaIL6iOWRx9nCJjYrsk2AHD2kwPr6fg7Pt0v+deX5RnCDmCKNnD6W5fasiyM8D+wzJQ=="], + "@react-spring/animated": ["@react-spring/animated@10.1.0", "", { "dependencies": { "@react-spring/shared": "~10.1.0", "@react-spring/types": "~10.1.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dvGVSfNYhbkzTAnyB1S1940ZiKp6/Ey+b2vQc70dJ5k6gzH/GdlfeDzCh65V94b6OPqF7Xl4VUICVUQpc07iUg=="], - "@react-spring/core": ["@react-spring/core@10.0.3", "", { "dependencies": { "@react-spring/animated": "~10.0.3", "@react-spring/shared": "~10.0.3", "@react-spring/types": "~10.0.3" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-D4DwNO68oohDf/0HG2G0Uragzb9IA1oXblxrd6MZAcBcUQG2EHUWXewjdECMPLNmQvlYVyyBRH6gPxXM5DX7DQ=="], + "@react-spring/core": ["@react-spring/core@10.1.0", "", { "dependencies": { "@react-spring/animated": "~10.1.0", "@react-spring/shared": "~10.1.0", "@react-spring/types": "~10.1.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-XUG3UCCCxay6lYjBU2KOKzEgC1sx/w44ouB5gRh2Ex6rVJOdPcGVg7JJUIOAQo7uhKGfQdCQU4qUZaQnmInZPQ=="], - "@react-spring/rafz": ["@react-spring/rafz@10.0.3", "", {}, "sha512-Ri2/xqt8OnQ2iFKkxKMSF4Nqv0LSWnxXT4jXFzBDsHgeeH/cHxTLupAWUwmV9hAGgmEhBmh5aONtj3J6R/18wg=="], + "@react-spring/rafz": ["@react-spring/rafz@10.1.0", "", {}, "sha512-e/LRnNmvmg8Agl4j3MGcjHuSTSBcCrxr3xeoWdodxpLWeTHY1pHl8PJDOCEJ2Pj6BMdEBaWFRAX7uxRo1FLzCg=="], - "@react-spring/shared": ["@react-spring/shared@10.0.3", "", { "dependencies": { "@react-spring/rafz": "~10.0.3", "@react-spring/types": "~10.0.3" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-geCal66nrkaQzUVhPkGomylo+Jpd5VPK8tPMEDevQEfNSWAQP15swHm+MCRG4wVQrQlTi9lOzKzpRoTL3CA84Q=="], + "@react-spring/shared": ["@react-spring/shared@10.1.0", "", { "dependencies": { "@react-spring/rafz": "~10.1.0", "@react-spring/types": "~10.1.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-ogIUujWxdwcsQ2Vp2hZs+KehvH8tKeWGHsFb1eRBYoePzhKS541Qg1JfFlQ7ursBUwwPDBQ5Wpwn+Cwx6WV1PQ=="], - "@react-spring/three": ["@react-spring/three@10.0.3", "", { "dependencies": { "@react-spring/animated": "~10.0.3", "@react-spring/core": "~10.0.3", "@react-spring/shared": "~10.0.3", "@react-spring/types": "~10.0.3" }, "peerDependencies": { "@react-three/fiber": ">=6.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "three": ">=0.126" } }, "sha512-hZP7ChF/EwnWn+H2xuzAsRRfQdhquoBTI1HKgO6X9V8tcVCuR69qJmsA9N00CA4Nzx0bo/zwBtqONmi55Ffm5w=="], + "@react-spring/three": ["@react-spring/three@10.1.0", "", { "dependencies": { "@react-spring/animated": "~10.1.0", "@react-spring/core": "~10.1.0", "@react-spring/shared": "~10.1.0", "@react-spring/types": "~10.1.0" }, "peerDependencies": { "@react-three/fiber": ">=6.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "three": ">=0.126" } }, "sha512-/XhDlpfVfsmIBMzK38M999oexmeyt+wlTLc66XTJKR/XlRpo9dpCGA+6viS+XjwEfD/S9/GlDrsTBy6vKiuxZA=="], - "@react-spring/types": ["@react-spring/types@10.0.3", "", {}, "sha512-H5Ixkd2OuSIgHtxuHLTt7aJYfhMXKXT/rK32HPD/kSrOB6q6ooeiWAXkBy7L8F3ZxdkBb9ini9zP9UwnEFzWgQ=="], + "@react-spring/types": ["@react-spring/types@10.1.0", "", {}, "sha512-RkJAlkAZ5yZSRBMOSidamioBHLjf8wGKbtr3pZ5iKoHFNQuNM2k6DqJDIEUcNQlWRZXny6Eqys2c9NdugdbdyQ=="], "@react-three/drei": ["@react-three/drei@10.7.7", "", { "dependencies": { "@babel/runtime": "^7.26.0", "@mediapipe/tasks-vision": "0.10.17", "@monogrid/gainmap-js": "^3.0.6", "@use-gesture/react": "^10.3.1", "camera-controls": "^3.1.0", "cross-env": "^7.0.3", "detect-gpu": "^5.0.56", "glsl-noise": "^0.0.0", "hls.js": "^1.5.17", "maath": "^0.10.8", "meshline": "^3.3.1", "stats-gl": "^2.2.8", "stats.js": "^0.17.0", "suspend-react": "^0.1.3", "three-mesh-bvh": "^0.8.3", "three-stdlib": "^2.35.6", "troika-three-text": "^0.52.4", "tunnel-rat": "^0.1.2", "use-sync-external-store": "^1.4.0", "utility-types": "^3.11.0", "zustand": "^5.0.1" }, "peerDependencies": { "@react-three/fiber": "^9.0.0", "react": "^19", "react-dom": "^19", "three": ">=0.159" }, "optionalPeers": ["react-dom"] }, "sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ=="], "@react-three/eslint-plugin": ["@react-three/eslint-plugin@0.1.2", "", { "dependencies": { "@babel/runtime": "^7.17.8", "eslint": "^8.12.0" } }, "sha512-jenNIhvt+/1fb3NDr3M5vwF06U9euX6kI2SuAFltVKdQP2nzUPY+zdati2Rd67ewAKn0jfljQWT7DWIe6siChg=="], - "@react-three/fiber": ["@react-three/fiber@9.6.0", "", { "dependencies": { "@babel/runtime": "^7.17.8", "@types/webxr": "*", "base64-js": "^1.5.1", "buffer": "^6.0.3", "its-fine": "^2.0.0", "react-use-measure": "^2.1.7", "scheduler": "^0.27.0", "suspend-react": "^0.1.3", "use-sync-external-store": "^1.4.0", "zustand": "^5.0.3" }, "peerDependencies": { "expo": ">=43.0", "expo-asset": ">=8.4", "expo-file-system": ">=11.0", "expo-gl": ">=11.0", "react": ">=19 <19.3", "react-dom": ">=19 <19.3", "react-native": ">=0.78", "three": ">=0.156" }, "optionalPeers": ["expo", "expo-asset", "expo-file-system", "expo-gl", "react-dom", "react-native"] }, "sha512-90abYK2q5/qDM+GACs9zRvc5KhEEpEWqWlHSd64zTPNxg+9wCJvTfyD9x2so7hlQhjRYO1Fa6flR3BC/kpTFkA=="], + "@react-three/fiber": ["@react-three/fiber@9.6.1", "", { "dependencies": { "@babel/runtime": "^7.17.8", "@types/webxr": "*", "base64-js": "^1.5.1", "buffer": "^6.0.3", "its-fine": "^2.0.0", "react-use-measure": "^2.1.7", "scheduler": "^0.27.0", "suspend-react": "^0.1.3", "use-sync-external-store": "^1.4.0", "zustand": "^5.0.3" }, "peerDependencies": { "expo": ">=43.0", "expo-asset": ">=8.4", "expo-file-system": ">=11.0", "expo-gl": ">=11.0", "react": ">=19 <19.3", "react-dom": ">=19 <19.3", "react-native": ">=0.78", "three": ">=0.156" }, "optionalPeers": ["expo", "expo-asset", "expo-file-system", "expo-gl", "react-dom", "react-native"] }, "sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg=="], "@rollbar/react": ["@rollbar/react@1.0.0", "", { "dependencies": { "tiny-invariant": "^1.1.0" }, "peerDependencies": { "prop-types": "^15.7.2", "react": "16.x || 17.x || 18.x || 19.x", "rollbar": "^2.26.4 || ^3.0.0-alpha.3" } }, "sha512-e3S9K9k1BLNuqAFA/AD8XH4kcypClYWoMBuW5LO7fnu5Jy1/qiRFIgS5cFZpAFWQqJaSPQAqrLP6n41sH08X9A=="], @@ -412,7 +412,7 @@ "@sinonjs/commons": ["@sinonjs/commons@3.0.1", "", { "dependencies": { "type-detect": "4.0.8" } }, "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ=="], - "@sinonjs/fake-timers": ["@sinonjs/fake-timers@15.1.1", "", { "dependencies": { "@sinonjs/commons": "^3.0.1" } }, "sha512-cO5W33JgAPbOh07tvZjUOJ7oWhtaqGHiZw+11DPbyqh2kHTBc3eF/CjJDeQ4205RLQsX6rxCuYOroFQwl7JDRw=="], + "@sinonjs/fake-timers": ["@sinonjs/fake-timers@15.4.0", "", { "dependencies": { "@sinonjs/commons": "^3.0.1" } }, "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA=="], "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], @@ -444,7 +444,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.20.6", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg=="], - "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/css-font-loading-module": ["@types/css-font-loading-module@0.0.7", "", {}, "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q=="], @@ -486,7 +486,7 @@ "@types/promise-timeout": ["@types/promise-timeout@1.3.3", "", {}, "sha512-gqmIw/4R1F1bqY5hWWZP0YE66iy6KkIu0tICpOLdXBuyHOAaSy9bNvwWHTJxyYHLozkieHM3Ej9GrYA6nuQPMA=="], - "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + "@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="], "@types/react-color": ["@types/react-color@3.0.13", "", { "dependencies": { "@types/reactcss": "*" }, "peerDependencies": { "@types/react": "*" } }, "sha512-2c/9FZ4ixC5T3JzN0LP5Cke2Mf0MKOP2Eh0NPDPWmuVH3NjPyhEjqNMQpN1Phr5m74egAy+p2lYNAFrX1z9Yrg=="], @@ -508,7 +508,7 @@ "@types/suncalc": ["@types/suncalc@1.9.2", "", {}, "sha512-ATAGBHHfA1TlE2tjfidLyTcysjoT2JHHEAmWRULh73SU9UTn++j5fqHEW16X6Y/2Li87jEQXzgu4R/OOdlDqzw=="], - "@types/three": ["@types/three@0.184.0", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "fflate": "~0.8.2", "meshoptimizer": "~1.1.1" } }, "sha512-4mY2tZAu0y0B0567w7013BBXSpsP0+Z48NJvmNo4Y/Pf76yCyz6Jw4P3tUVs10WuYNXXZ+wmHyGWpCek3amJxA=="], + "@types/three": ["@types/three@0.184.1", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "fflate": "~0.8.2", "meshoptimizer": "~1.1.1" } }, "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA=="], "@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="], @@ -526,25 +526,25 @@ "@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/type-utils": "8.59.0", "@typescript-eslint/utils": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.60.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.60.0", "@typescript-eslint/type-utils": "8.60.0", "@typescript-eslint/utils": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.60.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw=="], - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/types": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.60.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.60.0", "@typescript-eslint/types": "8.60.0", "@typescript-eslint/typescript-estree": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.0", "@typescript-eslint/types": "^8.59.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.60.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.60.0", "@typescript-eslint/types": "^8.60.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.0", "", { "dependencies": { "@typescript-eslint/types": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0" } }, "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg=="], + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.60.0", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0" } }, "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.60.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ=="], - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.0", "", { "dependencies": { "@typescript-eslint/types": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0", "@typescript-eslint/utils": "8.59.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg=="], + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.60.0", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "@typescript-eslint/typescript-estree": "8.60.0", "@typescript-eslint/utils": "8.60.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.59.0", "", {}, "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A=="], + "@typescript-eslint/types": ["@typescript-eslint/types@8.60.0", "", {}, "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.0", "@typescript-eslint/tsconfig-utils": "8.59.0", "@typescript-eslint/types": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.60.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.60.0", "@typescript-eslint/tsconfig-utils": "8.60.0", "@typescript-eslint/types": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/types": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g=="], + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.60.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.60.0", "@typescript-eslint/types": "8.60.0", "@typescript-eslint/typescript-estree": "8.60.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.0", "", { "dependencies": { "@typescript-eslint/types": "8.59.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.60.0", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], @@ -612,7 +612,7 @@ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], - "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], @@ -676,17 +676,17 @@ "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], - "axios": ["axios@1.15.2", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A=="], + "axios": ["axios@1.16.1", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A=="], - "babel-jest": ["babel-jest@30.3.0", "", { "dependencies": { "@jest/transform": "30.3.0", "@types/babel__core": "^7.20.5", "babel-plugin-istanbul": "^7.0.1", "babel-preset-jest": "30.3.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "slash": "^3.0.0" }, "peerDependencies": { "@babel/core": "^7.11.0 || ^8.0.0-0" } }, "sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ=="], + "babel-jest": ["babel-jest@30.4.1", "", { "dependencies": { "@jest/transform": "30.4.1", "@types/babel__core": "^7.20.5", "babel-plugin-istanbul": "^7.0.1", "babel-preset-jest": "30.4.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "slash": "^3.0.0" }, "peerDependencies": { "@babel/core": "^7.11.0 || ^8.0.0-0" } }, "sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw=="], "babel-plugin-istanbul": ["babel-plugin-istanbul@7.0.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "@istanbuljs/load-nyc-config": "^1.0.0", "@istanbuljs/schema": "^0.1.3", "istanbul-lib-instrument": "^6.0.2", "test-exclude": "^6.0.0" } }, "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA=="], - "babel-plugin-jest-hoist": ["babel-plugin-jest-hoist@30.3.0", "", { "dependencies": { "@types/babel__core": "^7.20.5" } }, "sha512-+TRkByhsws6sfPjVaitzadk1I0F5sPvOVUH5tyTSzhePpsGIVrdeunHSw/C36QeocS95OOk8lunc4rlu5Anwsg=="], + "babel-plugin-jest-hoist": ["babel-plugin-jest-hoist@30.4.0", "", { "dependencies": { "@types/babel__core": "^7.20.5" } }, "sha512-9EdtWM/sSfXLOGLwSn+GS6pIXyBnL07/8gyJlwFXjWy4DxMOyItqyUT29d4lQiS380EZwYlX7/At4PgBS+m2aA=="], "babel-preset-current-node-syntax": ["babel-preset-current-node-syntax@1.2.0", "", { "dependencies": { "@babel/plugin-syntax-async-generators": "^7.8.4", "@babel/plugin-syntax-bigint": "^7.8.3", "@babel/plugin-syntax-class-properties": "^7.12.13", "@babel/plugin-syntax-class-static-block": "^7.14.5", "@babel/plugin-syntax-import-attributes": "^7.24.7", "@babel/plugin-syntax-import-meta": "^7.10.4", "@babel/plugin-syntax-json-strings": "^7.8.3", "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-syntax-numeric-separator": "^7.10.4", "@babel/plugin-syntax-object-rest-spread": "^7.8.3", "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", "@babel/plugin-syntax-private-property-in-object": "^7.14.5", "@babel/plugin-syntax-top-level-await": "^7.14.5" }, "peerDependencies": { "@babel/core": "^7.0.0 || ^8.0.0-0" } }, "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg=="], - "babel-preset-jest": ["babel-preset-jest@30.3.0", "", { "dependencies": { "babel-plugin-jest-hoist": "30.3.0", "babel-preset-current-node-syntax": "^1.2.0" }, "peerDependencies": { "@babel/core": "^7.11.0 || ^8.0.0-beta.1" } }, "sha512-6ZcUbWHC+dMz2vfzdNwi87Z1gQsLNK2uLuK1Q89R11xdvejcivlYYwDlEv0FHX3VwEXpbBQ9uufB/MUNpZGfhQ=="], + "babel-preset-jest": ["babel-preset-jest@30.4.0", "", { "dependencies": { "babel-plugin-jest-hoist": "30.4.0", "babel-preset-current-node-syntax": "^1.2.0" }, "peerDependencies": { "@babel/core": "^7.11.0 || ^8.0.0-beta.1" } }, "sha512-lBY4jxsNmCnSiu7kquw8ZC9F4+XLMOKypT3RnNHPvU2Kpd4W0xaPuLr5ZkRyOsvLYAY4yaW1ZwTW4xB7NIiZzg=="], "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], @@ -722,7 +722,7 @@ "builtin-modules": ["builtin-modules@1.1.1", "", {}, "sha512-wxXCdllwGhI2kCC0MnvTGYTMvnVZTvqgypkiTI8Pa5tcz2i6VqsqwYGgqwXji+4RgCzms6EajE4IxiUH6HH8nQ=="], - "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "cache-base": ["cache-base@1.0.1", "", { "dependencies": { "collection-visit": "^1.0.0", "component-emitter": "^1.2.1", "get-value": "^2.0.6", "has-value": "^1.0.0", "isobject": "^3.0.1", "set-value": "^2.0.0", "to-object-path": "^0.3.0", "union-value": "^1.0.0", "unset-value": "^1.0.0" } }, "sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ=="], @@ -754,7 +754,7 @@ "char-regex": ["char-regex@1.0.2", "", {}, "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw=="], - "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], "ci-info": ["ci-info@4.3.0", "", {}, "sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ=="], @@ -984,7 +984,7 @@ "escope": ["escope@3.6.0", "", { "dependencies": { "es6-map": "^0.1.3", "es6-weak-map": "^2.0.1", "esrecurse": "^4.1.0", "estraverse": "^4.1.1" } }, "sha512-75IUQsusDdalQEW/G/2esa87J7raqdJF+Ca0/Xm5C3Q58Nr4yVYjZGp/P1+2xiEVgXRrA39dpRb8LcshajbqDQ=="], - "eslint": ["eslint@10.2.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.5.5", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q=="], + "eslint": ["eslint@10.4.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ=="], "eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.9", "", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.13.0", "resolve": "^1.22.4" } }, "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g=="], @@ -998,7 +998,7 @@ "eslint-plugin-no-null": ["eslint-plugin-no-null@1.0.2", "", { "peerDependencies": { "eslint": ">=3.0.0" } }, "sha512-uRDiz88zCO/2rzGfgG15DBjNsgwWtWiSo4Ezy7zzajUgpnFIqd1TjepKeRmJZHEfBGu58o2a8S0D7vglvvhkVA=="], - "eslint-plugin-promise": ["eslint-plugin-promise@7.2.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.4.0" }, "peerDependencies": { "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" } }, "sha512-SWKjd+EuvWkYaS+uN2csvj0KoP43YTu7+phKQ5v+xw6+A0gutVX2yqCeCkC3uLCJFiPfR2dD8Es5L7yUsmvEaA=="], + "eslint-plugin-promise": ["eslint-plugin-promise@7.3.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.4.0" }, "peerDependencies": { "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0" } }, "sha512-6uGiOR0INuujr6PEQmeSSP7GbIMJ/ebEXXiEzb/nOj68LknH5Pxzb/AbZivmr6VE6TkTE8rTjRK9zhKpK6HsRA=="], "eslint-plugin-react": ["eslint-plugin-react@7.37.5", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="], @@ -1092,7 +1092,7 @@ "flatted": ["flatted@3.3.2", "", {}, "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA=="], - "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], + "follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], @@ -1222,11 +1222,11 @@ "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], - "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], "human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], - "i18next": ["i18next@26.0.6", "", { "dependencies": { "@babel/runtime": "^7.29.2" }, "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-A4U6eCXodIbrhf8EarRurB9/4ebyaurH4+fu4gig9bqxmpSt+fCAFm/GpRQDcN1Xzu/LdFCx4nYHsnM1edIIbg=="], + "i18next": ["i18next@26.2.0", "", { "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-zwBHldHdTmwN7r6UNc7lC6GWNN+YYg3DrRSeHR5PRRBf5QnJZcYHrQc0uaU26qZeYxR7iFZD+Y315dPnKP47wA=="], "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], @@ -1384,33 +1384,33 @@ "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], - "jest": ["jest@30.3.0", "", { "dependencies": { "@jest/core": "30.3.0", "@jest/types": "30.3.0", "import-local": "^3.2.0", "jest-cli": "30.3.0" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, "optionalPeers": ["node-notifier"], "bin": "./bin/jest.js" }, "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg=="], + "jest": ["jest@30.4.2", "", { "dependencies": { "@jest/core": "30.4.2", "@jest/types": "30.4.1", "import-local": "^3.2.0", "jest-cli": "30.4.2" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, "optionalPeers": ["node-notifier"], "bin": "./bin/jest.js" }, "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ=="], "jest-canvas-mock": ["jest-canvas-mock@2.5.2", "", { "dependencies": { "cssfontparser": "^1.2.1", "moo-color": "^1.0.2" } }, "sha512-vgnpPupjOL6+L5oJXzxTxFrlGEIbHdZqFU+LFNdtLxZ3lRDCl17FlTMM7IatoRQkrcyOTMlDinjUguqmQ6bR2A=="], - "jest-changed-files": ["jest-changed-files@30.3.0", "", { "dependencies": { "execa": "^5.1.1", "jest-util": "30.3.0", "p-limit": "^3.1.0" } }, "sha512-B/7Cny6cV5At6M25EWDgf9S617lHivamL8vl6KEpJqkStauzcG4e+WPfDgMMF+H4FVH4A2PLRyvgDJan4441QA=="], + "jest-changed-files": ["jest-changed-files@30.4.1", "", { "dependencies": { "execa": "^5.1.1", "jest-util": "30.4.1", "p-limit": "^3.1.0" } }, "sha512-IuctmYrxi21iOSOaIXpJWalHyPAsVv0GeBHKDn8C1CA4W5htHn7INL+wdnL4Bo0+olEndvAFkmb++tIQJG+vvg=="], - "jest-circus": ["jest-circus@30.3.0", "", { "dependencies": { "@jest/environment": "30.3.0", "@jest/expect": "30.3.0", "@jest/test-result": "30.3.0", "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "co": "^4.6.0", "dedent": "^1.6.0", "is-generator-fn": "^2.1.0", "jest-each": "30.3.0", "jest-matcher-utils": "30.3.0", "jest-message-util": "30.3.0", "jest-runtime": "30.3.0", "jest-snapshot": "30.3.0", "jest-util": "30.3.0", "p-limit": "^3.1.0", "pretty-format": "30.3.0", "pure-rand": "^7.0.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-PyXq5szeSfR/4f1lYqCmmQjh0vqDkURUYi9N6whnHjlRz4IUQfMcXkGLeEoiJtxtyPqgUaUUfyQlApXWBSN1RA=="], + "jest-circus": ["jest-circus@30.4.2", "", { "dependencies": { "@jest/environment": "30.4.1", "@jest/expect": "30.4.1", "@jest/test-result": "30.4.1", "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "co": "^4.6.0", "dedent": "^1.6.0", "is-generator-fn": "^2.1.0", "jest-each": "30.4.1", "jest-matcher-utils": "30.4.1", "jest-message-util": "30.4.1", "jest-runtime": "30.4.2", "jest-snapshot": "30.4.1", "jest-util": "30.4.1", "p-limit": "^3.1.0", "pretty-format": "30.4.1", "pure-rand": "^7.0.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-rvHH7VlY6LgbJXJTQ87GW62g1FntOtbhh0zT+v04kC+pgL6aBKyYINXxWukCpj3dcIBMw5/XUbtDS9dU9JTXeQ=="], - "jest-cli": ["jest-cli@30.3.0", "", { "dependencies": { "@jest/core": "30.3.0", "@jest/test-result": "30.3.0", "@jest/types": "30.3.0", "chalk": "^4.1.2", "exit-x": "^0.2.2", "import-local": "^3.2.0", "jest-config": "30.3.0", "jest-util": "30.3.0", "jest-validate": "30.3.0", "yargs": "^17.7.2" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, "optionalPeers": ["node-notifier"], "bin": { "jest": "./bin/jest.js" } }, "sha512-l6Tqx+j1fDXJEW5bqYykDQQ7mQg+9mhWXtnj+tQZrTWYHyHoi6Be8HPumDSA+UiX2/2buEgjA58iJzdj146uCw=="], + "jest-cli": ["jest-cli@30.4.2", "", { "dependencies": { "@jest/core": "30.4.2", "@jest/test-result": "30.4.1", "@jest/types": "30.4.1", "chalk": "^4.1.2", "exit-x": "^0.2.2", "import-local": "^3.2.0", "jest-config": "30.4.2", "jest-util": "30.4.1", "jest-validate": "30.4.1", "yargs": "^17.7.2" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, "optionalPeers": ["node-notifier"], "bin": { "jest": "./bin/jest.js" } }, "sha512-jfA2ocvVHMXS2QijrJ0d31ektP+d/W0T5RpcTX2Pq+3sVqHlsXVCM2+FmwpL+bdY8OfHpIg9xMxLF17Zg0U49Q=="], - "jest-config": ["jest-config@30.3.0", "", { "dependencies": { "@babel/core": "^7.27.4", "@jest/get-type": "30.1.0", "@jest/pattern": "30.0.1", "@jest/test-sequencer": "30.3.0", "@jest/types": "30.3.0", "babel-jest": "30.3.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "deepmerge": "^4.3.1", "glob": "^10.5.0", "graceful-fs": "^4.2.11", "jest-circus": "30.3.0", "jest-docblock": "30.2.0", "jest-environment-node": "30.3.0", "jest-regex-util": "30.0.1", "jest-resolve": "30.3.0", "jest-runner": "30.3.0", "jest-util": "30.3.0", "jest-validate": "30.3.0", "parse-json": "^5.2.0", "pretty-format": "30.3.0", "slash": "^3.0.0", "strip-json-comments": "^3.1.1" }, "peerDependencies": { "@types/node": "*", "esbuild-register": ">=3.4.0", "ts-node": ">=9.0.0" }, "optionalPeers": ["@types/node", "esbuild-register", "ts-node"] }, "sha512-WPMAkMAtNDY9P/oKObtsRG/6KTrhtgPJoBTmk20uDn4Uy6/3EJnnaZJre/FMT1KVRx8cve1r7/FlMIOfRVWL4w=="], + "jest-config": ["jest-config@30.4.2", "", { "dependencies": { "@babel/core": "^7.27.4", "@jest/get-type": "30.1.0", "@jest/pattern": "30.4.0", "@jest/test-sequencer": "30.4.1", "@jest/types": "30.4.1", "babel-jest": "30.4.1", "chalk": "^4.1.2", "ci-info": "^4.2.0", "deepmerge": "^4.3.1", "glob": "^10.5.0", "graceful-fs": "^4.2.11", "jest-circus": "30.4.2", "jest-docblock": "30.4.0", "jest-environment-node": "30.4.1", "jest-regex-util": "30.4.0", "jest-resolve": "30.4.1", "jest-runner": "30.4.2", "jest-util": "30.4.1", "jest-validate": "30.4.1", "parse-json": "^5.2.0", "pretty-format": "30.4.1", "slash": "^3.0.0", "strip-json-comments": "^3.1.1" }, "peerDependencies": { "@types/node": "*", "esbuild-register": ">=3.4.0", "ts-node": ">=9.0.0" }, "optionalPeers": ["@types/node", "esbuild-register", "ts-node"] }, "sha512-rNHAShJQqQwFNoL0hbf3BphSBOWnpOUAKvidLS/AjNVLPfoj5mSf4jQMfW3cYOs6hXeZC7nF7mDHaBnbxELOzg=="], "jest-diff": ["jest-diff@30.0.5", "", { "dependencies": { "@jest/diff-sequences": "30.0.1", "@jest/get-type": "30.0.1", "chalk": "^4.1.2", "pretty-format": "30.0.5" } }, "sha512-1UIqE9PoEKaHcIKvq2vbibrCog4Y8G0zmOxgQUVEiTqwR5hJVMCoDsN1vFvI5JvwD37hjueZ1C4l2FyGnfpE0A=="], - "jest-docblock": ["jest-docblock@30.2.0", "", { "dependencies": { "detect-newline": "^3.1.0" } }, "sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA=="], + "jest-docblock": ["jest-docblock@30.4.0", "", { "dependencies": { "detect-newline": "^3.1.0" } }, "sha512-ZPMabUZCx5MpbZ2eBYSvZ0J8fvo3dR9oM+eeUpb3aKNQFuS2tu3Duw1TNlMoP8k3WQgKGJuhcMFvwcVuq6T7oA=="], - "jest-each": ["jest-each@30.3.0", "", { "dependencies": { "@jest/get-type": "30.1.0", "@jest/types": "30.3.0", "chalk": "^4.1.2", "jest-util": "30.3.0", "pretty-format": "30.3.0" } }, "sha512-V8eMndg/aZ+3LnCJgSm13IxS5XSBM22QSZc9BtPK8Dek6pm+hfUNfwBdvsB3d342bo1q7wnSkC38zjX259qZNA=="], + "jest-each": ["jest-each@30.4.1", "", { "dependencies": { "@jest/get-type": "30.1.0", "@jest/types": "30.4.1", "chalk": "^4.1.2", "jest-util": "30.4.1", "pretty-format": "30.4.1" } }, "sha512-/8MJbH6fuj48TstjrMf+u/pd06Qezz5xOXvZA6442heNOWr8bdeoGZX2d9fCn028CoMgYmroH9//zky5GfyYmA=="], - "jest-environment-jsdom": ["jest-environment-jsdom@30.3.0", "", { "dependencies": { "@jest/environment": "30.3.0", "@jest/environment-jsdom-abstract": "30.3.0", "jsdom": "^26.1.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-RLEOJy6ip1lpw0yqJ8tB3i88FC7VBz7i00Zvl2qF71IdxjS98gC9/0SPWYIBVXHm5hgCYK0PAlSlnHGGy9RoMg=="], + "jest-environment-jsdom": ["jest-environment-jsdom@30.4.1", "", { "dependencies": { "@jest/environment": "30.4.1", "@jest/environment-jsdom-abstract": "30.4.1", "jsdom": "^26.1.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-o3nfaN4zej7qgk2X0j8Jhq/S9nAVKs2xK3QeQxeHVvpkEPxaA1yxDGydR+iVI7zPy7Cp62Aq2h3Ja46QvfWHGA=="], - "jest-environment-node": ["jest-environment-node@30.3.0", "", { "dependencies": { "@jest/environment": "30.3.0", "@jest/fake-timers": "30.3.0", "@jest/types": "30.3.0", "@types/node": "*", "jest-mock": "30.3.0", "jest-util": "30.3.0", "jest-validate": "30.3.0" } }, "sha512-4i6HItw/JSiJVsC5q0hnKIe/hbYfZLVG9YJ/0pU9Hz2n/9qZe3Rhn5s5CUZA5ORZlcdT/vmAXRMyONXJwPrmYQ=="], + "jest-environment-node": ["jest-environment-node@30.4.1", "", { "dependencies": { "@jest/environment": "30.4.1", "@jest/fake-timers": "30.4.1", "@jest/types": "30.4.1", "@types/node": "*", "jest-mock": "30.4.1", "jest-util": "30.4.1", "jest-validate": "30.4.1" } }, "sha512-4FZYVOk85hz2AyT6BbarKy9u37g6DbrDyCdFhsnDdXqyrueYQvB+0zO4f/kqLCRD0BsPRXPMNJeQwihKZV8naw=="], - "jest-haste-map": ["jest-haste-map@30.3.0", "", { "dependencies": { "@jest/types": "30.3.0", "@types/node": "*", "anymatch": "^3.1.3", "fb-watchman": "^2.0.2", "graceful-fs": "^4.2.11", "jest-regex-util": "30.0.1", "jest-util": "30.3.0", "jest-worker": "30.3.0", "picomatch": "^4.0.3", "walker": "^1.0.8" }, "optionalDependencies": { "fsevents": "^2.3.3" } }, "sha512-mMi2oqG4KRU0R9QEtscl87JzMXfUhbKaFqOxmjb2CKcbHcUGFrJCBWHmnTiUqi6JcnzoBlO4rWfpdl2k/RfLCA=="], + "jest-haste-map": ["jest-haste-map@30.4.1", "", { "dependencies": { "@jest/types": "30.4.1", "@types/node": "*", "anymatch": "^3.1.3", "fb-watchman": "^2.0.2", "graceful-fs": "^4.2.11", "jest-regex-util": "30.4.0", "jest-util": "30.4.1", "jest-worker": "30.4.1", "picomatch": "^4.0.3", "walker": "^1.0.8" }, "optionalDependencies": { "fsevents": "^2.3.3" } }, "sha512-rFrcONd8jeFsyw+Z9CrScJgglRf2+NFmNam8dKu7n+SoHqNYT47mn0DdEcVUZJpvh7Iz6/si7f7yUH7GJHVgnw=="], - "jest-junit": ["jest-junit@16.0.0", "", { "dependencies": { "mkdirp": "^1.0.4", "strip-ansi": "^6.0.1", "uuid": "^8.3.2", "xml": "^1.0.1" } }, "sha512-A94mmw6NfJab4Fg/BlvVOUXzXgF0XIH6EmTgJ5NDPp4xoKq0Kr7sErb+4Xs9nZvu58pJojz5RFGpqnZYJTrRfQ=="], + "jest-junit": ["jest-junit@17.0.0", "", { "dependencies": { "mkdirp": "^1.0.4", "strip-ansi": "^6.0.1", "uuid": "^14.0.0", "xml": "^1.0.1" } }, "sha512-RYWCkq4j59gUXj5DsgbIE7xFBZzu1gtibPhyjSjMmGaOTLnqlXhg7x9zuGCwgbCuMAyoyvk0Mi8wSrRR5uOeLA=="], - "jest-leak-detector": ["jest-leak-detector@30.3.0", "", { "dependencies": { "@jest/get-type": "30.1.0", "pretty-format": "30.3.0" } }, "sha512-cuKmUUGIjfXZAiGJ7TbEMx0bcqNdPPI6P1V+7aF+m/FUJqFDxkFR4JqkTu8ZOiU5AaX/x0hZ20KaaIPXQzbMGQ=="], + "jest-leak-detector": ["jest-leak-detector@30.4.1", "", { "dependencies": { "@jest/get-type": "30.1.0", "pretty-format": "30.4.1" } }, "sha512-IpmyiioeHxiWDhesHnUFmOxcTzwCwKpgACgWajtAP+nYQXiY7DakTxB6Bx9JFiRMljr0AX1PvnQdaU1KFoz6NQ=="], "jest-matcher-utils": ["jest-matcher-utils@30.0.5", "", { "dependencies": { "@jest/get-type": "30.0.1", "chalk": "^4.1.2", "jest-diff": "30.0.5", "pretty-format": "30.0.5" } }, "sha512-uQgGWt7GOrRLP1P7IwNWwK1WAQbq+m//ZY0yXygyfWp0rJlksMSLQAA4wYQC3b6wl3zfnchyTx+k3HZ5aPtCbQ=="], @@ -1420,27 +1420,27 @@ "jest-pnp-resolver": ["jest-pnp-resolver@1.2.3", "", { "peerDependencies": { "jest-resolve": "*" } }, "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w=="], - "jest-regex-util": ["jest-regex-util@30.0.1", "", {}, "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA=="], + "jest-regex-util": ["jest-regex-util@30.4.0", "", {}, "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg=="], - "jest-resolve": ["jest-resolve@30.3.0", "", { "dependencies": { "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "jest-haste-map": "30.3.0", "jest-pnp-resolver": "^1.2.3", "jest-util": "30.3.0", "jest-validate": "30.3.0", "slash": "^3.0.0", "unrs-resolver": "^1.7.11" } }, "sha512-NRtTAHQlpd15F9rUR36jqwelbrDV/dY4vzNte3S2kxCKUJRYNd5/6nTSbYiak1VX5g8IoFF23Uj5TURkUW8O5g=="], + "jest-resolve": ["jest-resolve@30.4.1", "", { "dependencies": { "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "jest-haste-map": "30.4.1", "jest-pnp-resolver": "^1.2.3", "jest-util": "30.4.1", "jest-validate": "30.4.1", "slash": "^3.0.0", "unrs-resolver": "^1.7.11" } }, "sha512-Zry8Yq/yJcNAZ7dJ5F2heic8AheXvbFZ7XI5V+h28nrYZ7Qoyy4dItq8OodjnYD270mvX+ZudmrNV9cysqhW5Q=="], - "jest-resolve-dependencies": ["jest-resolve-dependencies@30.3.0", "", { "dependencies": { "jest-regex-util": "30.0.1", "jest-snapshot": "30.3.0" } }, "sha512-9ev8s3YN6Hsyz9LV75XUwkCVFlwPbaFn6Wp75qnI0wzAINYWY8Fb3+6y59Rwd3QaS3kKXffHXsZMziMavfz/nw=="], + "jest-resolve-dependencies": ["jest-resolve-dependencies@30.4.2", "", { "dependencies": { "jest-regex-util": "30.4.0", "jest-snapshot": "30.4.1" } }, "sha512-gDiVh1I+GxYzz9oXlyw+1wv6VOYX1WYxMOfjsA3iGKePV2oxmbHhwxfkALxNxYy1ciw6APWwkW2zZONwP97aEQ=="], - "jest-runner": ["jest-runner@30.3.0", "", { "dependencies": { "@jest/console": "30.3.0", "@jest/environment": "30.3.0", "@jest/test-result": "30.3.0", "@jest/transform": "30.3.0", "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "emittery": "^0.13.1", "exit-x": "^0.2.2", "graceful-fs": "^4.2.11", "jest-docblock": "30.2.0", "jest-environment-node": "30.3.0", "jest-haste-map": "30.3.0", "jest-leak-detector": "30.3.0", "jest-message-util": "30.3.0", "jest-resolve": "30.3.0", "jest-runtime": "30.3.0", "jest-util": "30.3.0", "jest-watcher": "30.3.0", "jest-worker": "30.3.0", "p-limit": "^3.1.0", "source-map-support": "0.5.13" } }, "sha512-gDv6C9LGKWDPLia9TSzZwf4h3kMQCqyTpq+95PODnTRDO0g9os48XIYYkS6D236vjpBir2fF63YmJFtqkS5Duw=="], + "jest-runner": ["jest-runner@30.4.2", "", { "dependencies": { "@jest/console": "30.4.1", "@jest/environment": "30.4.1", "@jest/test-result": "30.4.1", "@jest/transform": "30.4.1", "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "emittery": "^0.13.1", "exit-x": "^0.2.2", "graceful-fs": "^4.2.11", "jest-docblock": "30.4.0", "jest-environment-node": "30.4.1", "jest-haste-map": "30.4.1", "jest-leak-detector": "30.4.1", "jest-message-util": "30.4.1", "jest-resolve": "30.4.1", "jest-runtime": "30.4.2", "jest-util": "30.4.1", "jest-watcher": "30.4.1", "jest-worker": "30.4.1", "p-limit": "^3.1.0", "source-map-support": "0.5.13" } }, "sha512-2dw0PslVYXxffXGpLo+Ejad+KcI1Qkjn7f4X4619gf21oCUmL+SPfjqIa/losUem3yEOvfNZe/F1HWUcNpODcg=="], - "jest-runtime": ["jest-runtime@30.3.0", "", { "dependencies": { "@jest/environment": "30.3.0", "@jest/fake-timers": "30.3.0", "@jest/globals": "30.3.0", "@jest/source-map": "30.0.1", "@jest/test-result": "30.3.0", "@jest/transform": "30.3.0", "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "cjs-module-lexer": "^2.1.0", "collect-v8-coverage": "^1.0.2", "glob": "^10.5.0", "graceful-fs": "^4.2.11", "jest-haste-map": "30.3.0", "jest-message-util": "30.3.0", "jest-mock": "30.3.0", "jest-regex-util": "30.0.1", "jest-resolve": "30.3.0", "jest-snapshot": "30.3.0", "jest-util": "30.3.0", "slash": "^3.0.0", "strip-bom": "^4.0.0" } }, "sha512-CgC+hIBJbuh78HEffkhNKcbXAytQViplcl8xupqeIWyKQF50kCQA8J7GeJCkjisC6hpnC9Muf8jV5RdtdFbGng=="], + "jest-runtime": ["jest-runtime@30.4.2", "", { "dependencies": { "@jest/environment": "30.4.1", "@jest/fake-timers": "30.4.1", "@jest/globals": "30.4.1", "@jest/source-map": "30.0.1", "@jest/test-result": "30.4.1", "@jest/transform": "30.4.1", "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "cjs-module-lexer": "^2.1.0", "collect-v8-coverage": "^1.0.2", "glob": "^10.5.0", "graceful-fs": "^4.2.11", "jest-haste-map": "30.4.1", "jest-message-util": "30.4.1", "jest-mock": "30.4.1", "jest-regex-util": "30.4.0", "jest-resolve": "30.4.1", "jest-snapshot": "30.4.1", "jest-util": "30.4.1", "slash": "^3.0.0", "strip-bom": "^4.0.0" } }, "sha512-3/5e8iPz2k/VLqlr8DgTftYyLUv8Su3FkCAO2/Od81UsUTpSxOrS6O5x5KkoQwyUjmpYyDJKeyAvg2T2nvpNkQ=="], "jest-skipped-reporter": ["jest-skipped-reporter@0.0.5", "", { "dependencies": { "jest-util": "^24.9.0" } }, "sha512-cjbwbH4mrPUf0JGqOTzgNzB8j+bw72qLFlj4oinxCSBNMAP/DiVYveTl4ZyTiWQ4oBm0gelcfJMDX7SoIaIgeg=="], - "jest-snapshot": ["jest-snapshot@30.3.0", "", { "dependencies": { "@babel/core": "^7.27.4", "@babel/generator": "^7.27.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/types": "^7.27.3", "@jest/expect-utils": "30.3.0", "@jest/get-type": "30.1.0", "@jest/snapshot-utils": "30.3.0", "@jest/transform": "30.3.0", "@jest/types": "30.3.0", "babel-preset-current-node-syntax": "^1.2.0", "chalk": "^4.1.2", "expect": "30.3.0", "graceful-fs": "^4.2.11", "jest-diff": "30.3.0", "jest-matcher-utils": "30.3.0", "jest-message-util": "30.3.0", "jest-util": "30.3.0", "pretty-format": "30.3.0", "semver": "^7.7.2", "synckit": "^0.11.8" } }, "sha512-f14c7atpb4O2DeNhwcvS810Y63wEn8O1HqK/luJ4F6M4NjvxmAKQwBUWjbExUtMxWJQ0wVgmCKymeJK6NZMnfQ=="], + "jest-snapshot": ["jest-snapshot@30.4.1", "", { "dependencies": { "@babel/core": "^7.27.4", "@babel/generator": "^7.27.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/types": "^7.27.3", "@jest/expect-utils": "30.4.1", "@jest/get-type": "30.1.0", "@jest/snapshot-utils": "30.4.1", "@jest/transform": "30.4.1", "@jest/types": "30.4.1", "babel-preset-current-node-syntax": "^1.2.0", "chalk": "^4.1.2", "expect": "30.4.1", "graceful-fs": "^4.2.11", "jest-diff": "30.4.1", "jest-matcher-utils": "30.4.1", "jest-message-util": "30.4.1", "jest-util": "30.4.1", "pretty-format": "30.4.1", "semver": "^7.7.2", "synckit": "^0.11.8" } }, "sha512-tEOkkfOMppUyeiHwjZswOQ3lcnoTnws/q5FnGIaeIh/jmoU0ZlgMYRR8sTlTj+nNGCoJ0RDq6SfxGxCsyMTPmw=="], - "jest-util": ["jest-util@30.3.0", "", { "dependencies": { "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", "picomatch": "^4.0.3" } }, "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg=="], + "jest-util": ["jest-util@30.4.1", "", { "dependencies": { "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", "picomatch": "^4.0.3" } }, "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw=="], - "jest-validate": ["jest-validate@30.3.0", "", { "dependencies": { "@jest/get-type": "30.1.0", "@jest/types": "30.3.0", "camelcase": "^6.3.0", "chalk": "^4.1.2", "leven": "^3.1.0", "pretty-format": "30.3.0" } }, "sha512-I/xzC8h5G+SHCb2P2gWkJYrNiTbeL47KvKeW5EzplkyxzBRBw1ssSHlI/jXec0ukH2q7x2zAWQm7015iusg62Q=="], + "jest-validate": ["jest-validate@30.4.1", "", { "dependencies": { "@jest/get-type": "30.1.0", "@jest/types": "30.4.1", "camelcase": "^6.3.0", "chalk": "^4.1.2", "leven": "^3.1.0", "pretty-format": "30.4.1" } }, "sha512-PDWi4SOwLnwqNDfHZjOcsEFyZ4fc/2W2gVL3DEoyqnB6jCQMLRtfBong8s6omIw3lI0HWOus12xfnFmQtjW3fw=="], - "jest-watcher": ["jest-watcher@30.3.0", "", { "dependencies": { "@jest/test-result": "30.3.0", "@jest/types": "30.3.0", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "emittery": "^0.13.1", "jest-util": "30.3.0", "string-length": "^4.0.2" } }, "sha512-PJ1d9ThtTR8aMiBWUdcownq9mDdLXsQzJayTk4kmaBRHKvwNQn+ANveuhEBUyNI2hR1TVhvQ8D5kHubbzBHR/w=="], + "jest-watcher": ["jest-watcher@30.4.1", "", { "dependencies": { "@jest/test-result": "30.4.1", "@jest/types": "30.4.1", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "emittery": "^0.13.1", "jest-util": "30.4.1", "string-length": "^4.0.2" } }, "sha512-/l9UonmvCwjHH7d2h3iAwIloLc1H0S8mJZ/LNK3i86hqwPAz8otUJjP9MfYtz9Tt77Su5FD2xGjZn8d31IZHlw=="], - "jest-worker": ["jest-worker@30.3.0", "", { "dependencies": { "@types/node": "*", "@ungap/structured-clone": "^1.3.0", "jest-util": "30.3.0", "merge-stream": "^2.0.0", "supports-color": "^8.1.1" } }, "sha512-DrCKkaQwHexjRUFTmPzs7sHQe0TSj9nvDALKGdwmK5mW9v7j90BudWirKAJHt3QQ9Dhrg1F7DogPzhChppkJpQ=="], + "jest-worker": ["jest-worker@30.4.1", "", { "dependencies": { "@types/node": "*", "@ungap/structured-clone": "^1.3.0", "jest-util": "30.4.1", "merge-stream": "^2.0.0", "supports-color": "^8.1.1" } }, "sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g=="], "js-sdsl": ["js-sdsl@4.3.0", "", {}, "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ=="], @@ -1490,7 +1490,7 @@ "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], - "linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="], + "linkify-it": ["linkify-it@5.0.1", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], @@ -1534,7 +1534,7 @@ "map-visit": ["map-visit@1.0.0", "", { "dependencies": { "object-visit": "^1.0.0" } }, "sha512-4y7uGv8bd2WdM9vpQsiQNo41Ln1NvhvDRuVt0k2JZQ+ezN2uaQes7lZeZ+QQUHOLQAtDaBJ+7wCbi+ab/KFs+w=="], - "markdown-it": ["markdown-it@14.1.1", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA=="], + "markdown-it": ["markdown-it@14.2.0", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.1", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ=="], "markdown-it-emoji": ["markdown-it-emoji@3.0.0", "", {}, "sha512-+rUD93bXHubA4arpEZO3q80so0qgoFJEKRkRbjKX8RTdca89v2kfyF+xR3i2sQTwql9tpPZPOQN5B+PunspXRg=="], @@ -1602,7 +1602,7 @@ "mute-stream": ["mute-stream@0.0.5", "", {}, "sha512-EbrziT4s8cWPmzr47eYVW3wimS4HsvlnV5ri1xw1aR6JQo/OrJX5rkl32K/QQHdxeabJETtfeaROGhd8W7uBgg=="], - "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], "nanomatch": ["nanomatch@1.2.13", "", { "dependencies": { "arr-diff": "^4.0.0", "array-unique": "^0.3.2", "define-property": "^2.0.2", "extend-shallow": "^3.0.2", "fragment-cache": "^0.2.1", "is-windows": "^1.0.2", "kind-of": "^6.0.2", "object.pick": "^1.3.0", "regex-not": "^1.0.0", "snapdragon": "^0.8.1", "to-regex": "^3.0.1" } }, "sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA=="], @@ -1720,9 +1720,9 @@ "pkg-dir": ["pkg-dir@4.2.0", "", { "dependencies": { "find-up": "^4.0.0" } }, "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ=="], - "playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="], + "playwright": ["playwright@1.60.0", "", { "dependencies": { "playwright-core": "1.60.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA=="], - "playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="], + "playwright-core": ["playwright-core@1.60.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA=="], "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], @@ -1730,7 +1730,7 @@ "possible-typed-array-names": ["possible-typed-array-names@1.0.0", "", {}, "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q=="], - "postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="], + "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], "postcss-media-query-parser": ["postcss-media-query-parser@0.2.3", "", {}, "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig=="], @@ -1790,23 +1790,27 @@ "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": "cli.js" }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], - "react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="], + "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], "react-color": ["react-color@2.19.3", "", { "dependencies": { "@icons/material": "^0.2.4", "lodash": "^4.17.15", "lodash-es": "^4.17.15", "material-colors": "^1.2.1", "prop-types": "^15.5.10", "reactcss": "^1.2.0", "tinycolor2": "^1.4.1" }, "peerDependencies": { "react": "*" } }, "sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA=="], - "react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="], + "react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="], "react-fast-compare": ["react-fast-compare@3.2.2", "", {}, "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="], - "react-is": ["react-is@19.2.5", "", {}, "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ=="], + "react-is": ["react-is@19.2.6", "", {}, "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw=="], + + "react-is-18": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + + "react-is-19": ["react-is@19.2.6", "", {}, "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw=="], "react-popper": ["react-popper@2.3.0", "", { "dependencies": { "react-fast-compare": "^3.0.1", "warning": "^4.0.2" }, "peerDependencies": { "@popperjs/core": "^2.0.0", "react": "^16.8.0 || ^17 || ^18", "react-dom": "^16.8.0 || ^17 || ^18" } }, "sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q=="], - "react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" } }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="], + "react-redux": ["react-redux@9.3.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g=="], - "react-router": ["react-router@7.14.2", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw=="], + "react-router": ["react-router@7.15.1", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A=="], - "react-test-renderer": ["react-test-renderer@19.2.5", "", { "dependencies": { "react-is": "^19.2.5", "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-kwViRpdISMTpcpy5B6TSewfJzRjnajihRaj57ZmOWKD+SPN6k9LUM13O0pfOuW8ir6B6OOiAXwCRqOoVxRNykA=="], + "react-test-renderer": ["react-test-renderer@19.2.6", "", { "dependencies": { "react-is": "^19.2.6", "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-GbS6V23YduFTPiWJ5xICbKEjRcqx1Z90js/V5miqhz7qp/d6xSe9Dd6NjSQODFRdzdsqRMPW82E/sFpPRbY5Mw=="], "react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="], @@ -1816,7 +1820,7 @@ "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], - "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], "readline-sync": ["readline-sync@1.4.10", "", {}, "sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw=="], @@ -1902,7 +1906,7 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - "sass": ["sass@1.99.0", "", { "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.1.5", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q=="], + "sass": ["sass@1.100.0", "", { "dependencies": { "chokidar": "^5.0.0", "immutable": "^5.1.5", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-B5j0rYMlinhhOo9tjQebMVVn0TfyXAF+wB3b2ggZUuJ/is/Y+7+JGjirAMxHZ9Z3hIP98NPfamlAkBHa1lAaXQ=="], "sass-lint": ["sass-lint@1.13.1", "", { "dependencies": { "commander": "^2.8.1", "eslint": "^2.7.0", "front-matter": "2.1.2", "fs-extra": "^3.0.1", "glob": "^7.0.0", "globule": "^1.0.0", "gonzales-pe-sl": "^4.2.3", "js-yaml": "^3.5.4", "known-css-properties": "^0.3.0", "lodash.capitalize": "^4.1.0", "lodash.kebabcase": "^4.0.0", "merge": "^1.2.0", "path-is-absolute": "^1.0.0", "util": "^0.10.3" }, "bin": "bin/sass-lint.js" }, "sha512-DSyah8/MyjzW2BWYmQWekYEKir44BpLqrCFsgs9iaWiVTcwZfwXHF586hh3D1n+/9ihUNMfd8iHAyb9KkGgs7Q=="], @@ -1990,7 +1994,7 @@ "string-length": ["string-length@4.0.2", "", { "dependencies": { "char-regex": "^1.0.2", "strip-ansi": "^6.0.0" } }, "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ=="], - "string-width": ["string-width@8.2.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="], + "string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -2020,7 +2024,7 @@ "strip-json-comments": ["strip-json-comments@1.0.4", "", { "bin": "cli.js" }, "sha512-AOPG8EBc5wAikaG1/7uFCNFJwnKOuQwFTpYBdTW6OvWHeZBQBrAA/amefHGrEiOnCPcLFZK6FUPtWVKpQVIRgg=="], - "stylelint": ["stylelint@17.8.0", "", { "dependencies": { "@csstools/css-calc": "^3.1.1", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-syntax-patches-for-csstree": "^1.1.2", "@csstools/css-tokenizer": "^4.0.0", "@csstools/media-query-list-parser": "^5.0.0", "@csstools/selector-resolve-nested": "^4.0.0", "@csstools/selector-specificity": "^6.0.0", "colord": "^2.9.3", "cosmiconfig": "^9.0.1", "css-functions-list": "^3.3.3", "css-tree": "^3.2.1", "debug": "^4.4.3", "fast-glob": "^3.3.3", "fastest-levenshtein": "^1.0.16", "file-entry-cache": "^11.1.2", "global-modules": "^2.0.0", "globby": "^16.2.0", "globjoin": "^0.1.4", "html-tags": "^5.1.0", "ignore": "^7.0.5", "import-meta-resolve": "^4.2.0", "is-plain-object": "^5.0.0", "mathml-tag-names": "^4.0.0", "meow": "^14.1.0", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.5.9", "postcss-safe-parser": "^7.0.1", "postcss-selector-parser": "^7.1.1", "postcss-value-parser": "^4.2.0", "string-width": "^8.2.0", "supports-hyperlinks": "^4.4.0", "svg-tags": "^1.0.0", "table": "^6.9.0", "write-file-atomic": "^7.0.1" }, "bin": { "stylelint": "bin/stylelint.mjs" } }, "sha512-oHkld9T60LDSaUQ4CSVc+tlt9eUoDlxhaGWShsUCKyIL14boZfmK5bSphZqx64aiC5tCqX+BsQMTMoSz8D1zIg=="], + "stylelint": ["stylelint@17.12.0", "", { "dependencies": { "@csstools/css-calc": "^3.2.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@csstools/css-tokenizer": "^4.0.0", "@csstools/media-query-list-parser": "^5.0.0", "@csstools/selector-resolve-nested": "^4.0.0", "@csstools/selector-specificity": "^6.0.0", "colord": "^2.9.3", "cosmiconfig": "^9.0.1", "css-functions-list": "^3.3.3", "css-tree": "^3.2.1", "debug": "^4.4.3", "fast-glob": "^3.3.3", "fastest-levenshtein": "^1.0.16", "file-entry-cache": "^11.1.3", "global-modules": "^2.0.0", "globby": "^16.2.0", "globjoin": "^0.1.4", "html-tags": "^5.1.0", "ignore": "^7.0.5", "import-meta-resolve": "^4.2.0", "mathml-tag-names": "^4.0.0", "meow": "^14.1.0", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.5.14", "postcss-safe-parser": "^7.0.1", "postcss-selector-parser": "^7.1.1", "postcss-value-parser": "^4.2.0", "string-width": "^8.2.1", "supports-hyperlinks": "^4.4.0", "svg-tags": "^1.0.0", "table": "^6.9.0", "write-file-atomic": "^7.0.1" }, "bin": { "stylelint": "bin/stylelint.mjs" } }, "sha512-KIlzWXMHUvgfPUR0R7TK3H80yCIi0uoivUwf+6Az4yrHJD1Q3c1qIkh/H5Z0i/K3QXgtq/UMEkWyBUSUwnpnOg=="], "stylelint-config-recommended": ["stylelint-config-recommended@18.0.0", "", { "peerDependencies": { "stylelint": "^17.0.0" } }, "sha512-mxgT2XY6YZ3HWWe3Di8umG6aBmWmHTblTgu/f10rqFXnyWxjKWwNdjSWkgkwCtxIKnqjSJzvFmPT5yabVIRxZg=="], @@ -2104,7 +2108,7 @@ "ts-graphviz": ["ts-graphviz@2.1.6", "", { "dependencies": { "@ts-graphviz/adapter": "^2.0.6", "@ts-graphviz/ast": "^2.0.7", "@ts-graphviz/common": "^2.1.5", "@ts-graphviz/core": "^2.0.7" } }, "sha512-XyLVuhBVvdJTJr2FJJV2L1pc4MwSjMhcunRVgDE9k4wbb2ee7ORYnPewxMWUav12vxyfUM686MSGsqnVRIInuw=="], - "ts-jest": ["ts-jest@29.4.9", "", { "dependencies": { "bs-logger": "^0.2.6", "fast-json-stable-stringify": "^2.1.0", "handlebars": "^4.7.9", "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", "semver": "^7.7.4", "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, "peerDependencies": { "@babel/core": ">=7.0.0-beta.0 <8", "@jest/transform": "^29.0.0 || ^30.0.0", "@jest/types": "^29.0.0 || ^30.0.0", "babel-jest": "^29.0.0 || ^30.0.0", "jest": "^29.0.0 || ^30.0.0", "jest-util": "^29.0.0 || ^30.0.0", "typescript": ">=4.3 <7" }, "optionalPeers": ["@babel/core", "@jest/transform", "@jest/types", "babel-jest", "jest-util"], "bin": { "ts-jest": "cli.js" } }, "sha512-LTb9496gYPMCqjeDLdPrKuXtncudeV1yRZnF4Wo5l3SFi0RYEnYRNgMrFIdg+FHvfzjCyQk1cLncWVqiSX+EvQ=="], + "ts-jest": ["ts-jest@29.4.11", "", { "dependencies": { "bs-logger": "^0.2.6", "fast-json-stable-stringify": "^2.1.0", "handlebars": "^4.7.9", "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", "semver": "^7.8.0", "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, "peerDependencies": { "@babel/core": ">=7.0.0-beta.0 <8", "@jest/transform": "^29.0.0 || ^30.0.0", "@jest/types": "^29.0.0 || ^30.0.0", "babel-jest": "^29.0.0 || ^30.0.0", "jest": "^29.0.0 || ^30.0.0", "jest-util": "^29.0.0 || ^30.0.0", "typescript": ">=4.3 <7" }, "optionalPeers": ["@babel/core", "@jest/transform", "@jest/types", "babel-jest", "jest-util"], "bin": { "ts-jest": "cli.js" } }, "sha512-IrFl7l9AuB/qrNw5quqvAv/hmKMb8dhWOH4jQOGo0Oq8tCeo1O86/iTFG1FaRimgUkF13l4PcepO8ATFT6Ns4g=="], "tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="], @@ -2178,7 +2182,7 @@ "utility-types": ["utility-types@3.11.0", "", {}, "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw=="], - "uuid": ["uuid@8.3.2", "", { "bin": "dist/bin/uuid" }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], + "uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="], "v8-to-istanbul": ["v8-to-istanbul@9.3.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.12", "@types/istanbul-lib-coverage": "^2.0.1", "convert-source-map": "^2.0.0" } }, "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA=="], @@ -2346,32 +2350,34 @@ "@istanbuljs/load-nyc-config/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], - "@jest/console/jest-message-util": ["jest-message-util@30.3.0", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.3.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "picomatch": "^4.0.3", "pretty-format": "30.3.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw=="], + "@jest/console/jest-message-util": ["jest-message-util@30.4.1", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.4.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "jest-util": "30.4.1", "picomatch": "^4.0.3", "pretty-format": "30.4.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ=="], - "@jest/core/jest-message-util": ["jest-message-util@30.3.0", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.3.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "picomatch": "^4.0.3", "pretty-format": "30.3.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw=="], + "@jest/core/jest-message-util": ["jest-message-util@30.4.1", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.4.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "jest-util": "30.4.1", "picomatch": "^4.0.3", "pretty-format": "30.4.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ=="], - "@jest/core/pretty-format": ["pretty-format@30.3.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ=="], + "@jest/core/pretty-format": ["pretty-format@30.4.1", "", { "dependencies": { "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", "react-is-18": "npm:react-is@^18.3.1", "react-is-19": "npm:react-is@^19.2.5" } }, "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw=="], - "@jest/environment/jest-mock": ["jest-mock@30.3.0", "", { "dependencies": { "@jest/types": "30.3.0", "@types/node": "*", "jest-util": "30.3.0" } }, "sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog=="], + "@jest/environment/jest-mock": ["jest-mock@30.4.1", "", { "dependencies": { "@jest/types": "30.4.1", "@types/node": "*", "jest-util": "30.4.1" } }, "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw=="], - "@jest/environment-jsdom-abstract/jest-mock": ["jest-mock@30.3.0", "", { "dependencies": { "@jest/types": "30.3.0", "@types/node": "*", "jest-util": "30.3.0" } }, "sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog=="], + "@jest/environment-jsdom-abstract/jest-mock": ["jest-mock@30.4.1", "", { "dependencies": { "@jest/types": "30.4.1", "@types/node": "*", "jest-util": "30.4.1" } }, "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw=="], - "@jest/expect/expect": ["expect@30.3.0", "", { "dependencies": { "@jest/expect-utils": "30.3.0", "@jest/get-type": "30.1.0", "jest-matcher-utils": "30.3.0", "jest-message-util": "30.3.0", "jest-mock": "30.3.0", "jest-util": "30.3.0" } }, "sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q=="], + "@jest/expect/expect": ["expect@30.4.1", "", { "dependencies": { "@jest/expect-utils": "30.4.1", "@jest/get-type": "30.1.0", "jest-matcher-utils": "30.4.1", "jest-message-util": "30.4.1", "jest-mock": "30.4.1", "jest-util": "30.4.1" } }, "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA=="], - "@jest/fake-timers/jest-message-util": ["jest-message-util@30.3.0", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.3.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "picomatch": "^4.0.3", "pretty-format": "30.3.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw=="], + "@jest/fake-timers/jest-message-util": ["jest-message-util@30.4.1", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.4.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "jest-util": "30.4.1", "picomatch": "^4.0.3", "pretty-format": "30.4.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ=="], - "@jest/fake-timers/jest-mock": ["jest-mock@30.3.0", "", { "dependencies": { "@jest/types": "30.3.0", "@types/node": "*", "jest-util": "30.3.0" } }, "sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog=="], + "@jest/fake-timers/jest-mock": ["jest-mock@30.4.1", "", { "dependencies": { "@jest/types": "30.4.1", "@types/node": "*", "jest-util": "30.4.1" } }, "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw=="], - "@jest/globals/jest-mock": ["jest-mock@30.3.0", "", { "dependencies": { "@jest/types": "30.3.0", "@types/node": "*", "jest-util": "30.3.0" } }, "sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog=="], + "@jest/globals/jest-mock": ["jest-mock@30.4.1", "", { "dependencies": { "@jest/types": "30.4.1", "@types/node": "*", "jest-util": "30.4.1" } }, "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw=="], "@jest/reporters/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], - "@jest/reporters/jest-message-util": ["jest-message-util@30.3.0", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.3.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "picomatch": "^4.0.3", "pretty-format": "30.3.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw=="], + "@jest/reporters/jest-message-util": ["jest-message-util@30.4.1", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.4.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "jest-util": "30.4.1", "picomatch": "^4.0.3", "pretty-format": "30.4.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ=="], "@jest/source-map/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "@jest/transform/write-file-atomic": ["write-file-atomic@5.0.1", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" } }, "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw=="], + "@jest/types/@jest/schemas": ["@jest/schemas@30.4.1", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q=="], + "@jridgewell/remapping/@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="], "@jridgewell/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="], @@ -2400,9 +2406,11 @@ "@types/jest/pretty-format": ["pretty-format@30.0.5", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw=="], + "@types/react-test-renderer/@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + "@types/redux-immutable-state-invariant/redux": ["redux@4.2.1", "", { "dependencies": { "@babel/runtime": "^7.9.2" } }, "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w=="], - "@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@typescript-eslint/typescript-estree/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], "@vue/compiler-core/@babel/parser": ["@babel/parser@7.26.7", "", { "dependencies": { "@babel/types": "^7.26.7" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-kEvgGGgEjRUutvdVvZhbn/BxVt+5VSpwXz1j3WYXQbXDo8KzFOPNG2GQbdAiNq8g6wn1yKk7C/qrke03a84V+w=="], @@ -2482,8 +2490,6 @@ "eslint-plugin-jest/@typescript-eslint/utils": ["@typescript-eslint/utils@8.58.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.58.0", "@typescript-eslint/types": "8.58.0", "@typescript-eslint/typescript-estree": "8.58.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA=="], - "eslint-plugin-promise/@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="], - "eslint-plugin-react/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], @@ -2548,11 +2554,9 @@ "htmlparser2/readable-stream": ["readable-stream@1.1.14", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.1", "isarray": "0.0.1", "string_decoder": "~0.10.x" } }, "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ=="], - "http-proxy-agent/debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], - - "https-proxy-agent/debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], + "http-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "i18next/@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + "http-proxy-agent/debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], "import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], @@ -2584,17 +2588,17 @@ "istanbul-lib-source-maps/debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], - "jest-circus/jest-matcher-utils": ["jest-matcher-utils@30.3.0", "", { "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "jest-diff": "30.3.0", "pretty-format": "30.3.0" } }, "sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA=="], + "jest-circus/jest-matcher-utils": ["jest-matcher-utils@30.4.1", "", { "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "jest-diff": "30.4.1", "pretty-format": "30.4.1" } }, "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A=="], - "jest-circus/jest-message-util": ["jest-message-util@30.3.0", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.3.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "picomatch": "^4.0.3", "pretty-format": "30.3.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw=="], + "jest-circus/jest-message-util": ["jest-message-util@30.4.1", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.4.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "jest-util": "30.4.1", "picomatch": "^4.0.3", "pretty-format": "30.4.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ=="], - "jest-circus/pretty-format": ["pretty-format@30.3.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ=="], + "jest-circus/pretty-format": ["pretty-format@30.4.1", "", { "dependencies": { "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", "react-is-18": "npm:react-is@^18.3.1", "react-is-19": "npm:react-is@^19.2.5" } }, "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw=="], "jest-config/@jest/get-type": ["@jest/get-type@30.1.0", "", {}, "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA=="], "jest-config/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], - "jest-config/pretty-format": ["pretty-format@30.3.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ=="], + "jest-config/pretty-format": ["pretty-format@30.4.1", "", { "dependencies": { "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", "react-is-18": "npm:react-is@^18.3.1", "react-is-19": "npm:react-is@^19.2.5" } }, "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw=="], "jest-config/strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], @@ -2602,15 +2606,15 @@ "jest-each/@jest/get-type": ["@jest/get-type@30.1.0", "", {}, "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA=="], - "jest-each/pretty-format": ["pretty-format@30.3.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ=="], + "jest-each/pretty-format": ["pretty-format@30.4.1", "", { "dependencies": { "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", "react-is-18": "npm:react-is@^18.3.1", "react-is-19": "npm:react-is@^19.2.5" } }, "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw=="], - "jest-environment-node/jest-mock": ["jest-mock@30.3.0", "", { "dependencies": { "@jest/types": "30.3.0", "@types/node": "*", "jest-util": "30.3.0" } }, "sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog=="], + "jest-environment-node/jest-mock": ["jest-mock@30.4.1", "", { "dependencies": { "@jest/types": "30.4.1", "@types/node": "*", "jest-util": "30.4.1" } }, "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw=="], "jest-haste-map/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "jest-leak-detector/@jest/get-type": ["@jest/get-type@30.1.0", "", {}, "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA=="], - "jest-leak-detector/pretty-format": ["pretty-format@30.3.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ=="], + "jest-leak-detector/pretty-format": ["pretty-format@30.4.1", "", { "dependencies": { "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", "react-is-18": "npm:react-is@^18.3.1", "react-is-19": "npm:react-is@^19.2.5" } }, "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw=="], "jest-matcher-utils/pretty-format": ["pretty-format@30.0.5", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw=="], @@ -2622,44 +2626,46 @@ "jest-mock/jest-util": ["jest-util@30.0.5", "", { "dependencies": { "@jest/types": "30.0.5", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", "picomatch": "^4.0.2" } }, "sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g=="], - "jest-runner/jest-message-util": ["jest-message-util@30.3.0", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.3.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "picomatch": "^4.0.3", "pretty-format": "30.3.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw=="], + "jest-runner/jest-message-util": ["jest-message-util@30.4.1", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.4.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "jest-util": "30.4.1", "picomatch": "^4.0.3", "pretty-format": "30.4.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ=="], "jest-runtime/@jest/source-map": ["@jest/source-map@30.0.1", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "callsites": "^3.1.0", "graceful-fs": "^4.2.11" } }, "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg=="], "jest-runtime/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], - "jest-runtime/jest-message-util": ["jest-message-util@30.3.0", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.3.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "picomatch": "^4.0.3", "pretty-format": "30.3.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw=="], + "jest-runtime/jest-message-util": ["jest-message-util@30.4.1", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.4.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "jest-util": "30.4.1", "picomatch": "^4.0.3", "pretty-format": "30.4.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ=="], - "jest-runtime/jest-mock": ["jest-mock@30.3.0", "", { "dependencies": { "@jest/types": "30.3.0", "@types/node": "*", "jest-util": "30.3.0" } }, "sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog=="], + "jest-runtime/jest-mock": ["jest-mock@30.4.1", "", { "dependencies": { "@jest/types": "30.4.1", "@types/node": "*", "jest-util": "30.4.1" } }, "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw=="], "jest-runtime/strip-bom": ["strip-bom@4.0.0", "", {}, "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w=="], "jest-skipped-reporter/jest-util": ["jest-util@24.9.0", "", { "dependencies": { "@jest/console": "^24.9.0", "@jest/fake-timers": "^24.9.0", "@jest/source-map": "^24.9.0", "@jest/test-result": "^24.9.0", "@jest/types": "^24.9.0", "callsites": "^3.0.0", "chalk": "^2.0.1", "graceful-fs": "^4.1.15", "is-ci": "^2.0.0", "mkdirp": "^0.5.1", "slash": "^2.0.0", "source-map": "^0.6.0" } }, "sha512-x+cZU8VRmOJxbA1K5oDBdxQmdq0OIdADarLxk0Mq+3XS4jgvhG/oKGWcIDCtPG0HgjxOYvF+ilPJQsAyXfbNOg=="], - "jest-snapshot/@jest/expect-utils": ["@jest/expect-utils@30.3.0", "", { "dependencies": { "@jest/get-type": "30.1.0" } }, "sha512-j0+W5iQQ8hBh7tHZkTQv3q2Fh/M7Je72cIsYqC4OaktgtO7v1So9UTjp6uPBHIaB6beoF/RRsCgMJKvti0wADA=="], + "jest-snapshot/@jest/expect-utils": ["@jest/expect-utils@30.4.1", "", { "dependencies": { "@jest/get-type": "30.1.0" } }, "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ=="], "jest-snapshot/@jest/get-type": ["@jest/get-type@30.1.0", "", {}, "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA=="], - "jest-snapshot/expect": ["expect@30.3.0", "", { "dependencies": { "@jest/expect-utils": "30.3.0", "@jest/get-type": "30.1.0", "jest-matcher-utils": "30.3.0", "jest-message-util": "30.3.0", "jest-mock": "30.3.0", "jest-util": "30.3.0" } }, "sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q=="], + "jest-snapshot/expect": ["expect@30.4.1", "", { "dependencies": { "@jest/expect-utils": "30.4.1", "@jest/get-type": "30.1.0", "jest-matcher-utils": "30.4.1", "jest-message-util": "30.4.1", "jest-mock": "30.4.1", "jest-util": "30.4.1" } }, "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA=="], - "jest-snapshot/jest-diff": ["jest-diff@30.3.0", "", { "dependencies": { "@jest/diff-sequences": "30.3.0", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "pretty-format": "30.3.0" } }, "sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ=="], + "jest-snapshot/jest-diff": ["jest-diff@30.4.1", "", { "dependencies": { "@jest/diff-sequences": "30.4.0", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "pretty-format": "30.4.1" } }, "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA=="], - "jest-snapshot/jest-matcher-utils": ["jest-matcher-utils@30.3.0", "", { "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "jest-diff": "30.3.0", "pretty-format": "30.3.0" } }, "sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA=="], + "jest-snapshot/jest-matcher-utils": ["jest-matcher-utils@30.4.1", "", { "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "jest-diff": "30.4.1", "pretty-format": "30.4.1" } }, "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A=="], - "jest-snapshot/jest-message-util": ["jest-message-util@30.3.0", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.3.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "picomatch": "^4.0.3", "pretty-format": "30.3.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw=="], + "jest-snapshot/jest-message-util": ["jest-message-util@30.4.1", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.4.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "jest-util": "30.4.1", "picomatch": "^4.0.3", "pretty-format": "30.4.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ=="], - "jest-snapshot/pretty-format": ["pretty-format@30.3.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ=="], + "jest-snapshot/pretty-format": ["pretty-format@30.4.1", "", { "dependencies": { "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", "react-is-18": "npm:react-is@^18.3.1", "react-is-19": "npm:react-is@^19.2.5" } }, "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw=="], - "jest-snapshot/semver": ["semver@7.7.3", "", { "bin": "bin/semver.js" }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "jest-snapshot/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "jest-validate/@jest/get-type": ["@jest/get-type@30.1.0", "", {}, "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA=="], - "jest-validate/pretty-format": ["pretty-format@30.3.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ=="], + "jest-validate/pretty-format": ["pretty-format@30.4.1", "", { "dependencies": { "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", "react-is-18": "npm:react-is@^18.3.1", "react-is-19": "npm:react-is@^19.2.5" } }, "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw=="], "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], "js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "jsdom/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "jsdom/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], "jshint/lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], @@ -2770,7 +2776,7 @@ "string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - "stylelint/file-entry-cache": ["file-entry-cache@11.1.2", "", { "dependencies": { "flat-cache": "^6.1.20" } }, "sha512-N2WFfK12gmrK1c1GXOqiAJ1tc5YE+R53zvQ+t5P8S5XhnmKYVB5eZEiLNZKDSmoG8wqqbF9EXYBBW/nef19log=="], + "stylelint/file-entry-cache": ["file-entry-cache@11.1.3", "", { "dependencies": { "flat-cache": "^6.1.22" } }, "sha512-oMbq0PD6VIiIwMF6LIa7MEwd/l9huKwmqRKXqmrkqIZv8CvRbfowL+L0ryAl8h//HfAS0zS+4SbYoRyAoA6BJA=="], "stylelint-scss/known-css-properties": ["known-css-properties@0.37.0", "", {}, "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ=="], @@ -2790,7 +2796,7 @@ "to-object-path/kind-of": ["kind-of@3.2.2", "", { "dependencies": { "is-buffer": "^1.1.5" } }, "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ=="], - "ts-jest/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "ts-jest/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], "tsconfig-paths/json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": "lib/cli.js" }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], @@ -2868,31 +2874,31 @@ "@jest/console/jest-message-util/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - "@jest/console/jest-message-util/pretty-format": ["pretty-format@30.3.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ=="], + "@jest/console/jest-message-util/pretty-format": ["pretty-format@30.4.1", "", { "dependencies": { "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", "react-is-18": "npm:react-is@^18.3.1", "react-is-19": "npm:react-is@^19.2.5" } }, "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw=="], "@jest/core/jest-message-util/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - "@jest/core/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "@jest/core/pretty-format/@jest/schemas": ["@jest/schemas@30.4.1", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q=="], - "@jest/expect/expect/@jest/expect-utils": ["@jest/expect-utils@30.3.0", "", { "dependencies": { "@jest/get-type": "30.1.0" } }, "sha512-j0+W5iQQ8hBh7tHZkTQv3q2Fh/M7Je72cIsYqC4OaktgtO7v1So9UTjp6uPBHIaB6beoF/RRsCgMJKvti0wADA=="], + "@jest/expect/expect/@jest/expect-utils": ["@jest/expect-utils@30.4.1", "", { "dependencies": { "@jest/get-type": "30.1.0" } }, "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ=="], "@jest/expect/expect/@jest/get-type": ["@jest/get-type@30.1.0", "", {}, "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA=="], - "@jest/expect/expect/jest-matcher-utils": ["jest-matcher-utils@30.3.0", "", { "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "jest-diff": "30.3.0", "pretty-format": "30.3.0" } }, "sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA=="], + "@jest/expect/expect/jest-matcher-utils": ["jest-matcher-utils@30.4.1", "", { "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "jest-diff": "30.4.1", "pretty-format": "30.4.1" } }, "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A=="], - "@jest/expect/expect/jest-message-util": ["jest-message-util@30.3.0", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.3.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "picomatch": "^4.0.3", "pretty-format": "30.3.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw=="], + "@jest/expect/expect/jest-message-util": ["jest-message-util@30.4.1", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.4.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "jest-util": "30.4.1", "picomatch": "^4.0.3", "pretty-format": "30.4.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ=="], - "@jest/expect/expect/jest-mock": ["jest-mock@30.3.0", "", { "dependencies": { "@jest/types": "30.3.0", "@types/node": "*", "jest-util": "30.3.0" } }, "sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog=="], + "@jest/expect/expect/jest-mock": ["jest-mock@30.4.1", "", { "dependencies": { "@jest/types": "30.4.1", "@types/node": "*", "jest-util": "30.4.1" } }, "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw=="], "@jest/fake-timers/jest-message-util/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - "@jest/fake-timers/jest-message-util/pretty-format": ["pretty-format@30.3.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ=="], + "@jest/fake-timers/jest-message-util/pretty-format": ["pretty-format@30.4.1", "", { "dependencies": { "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", "react-is-18": "npm:react-is@^18.3.1", "react-is-19": "npm:react-is@^19.2.5" } }, "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw=="], "@jest/reporters/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "@jest/reporters/jest-message-util/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - "@jest/reporters/jest-message-util/pretty-format": ["pretty-format@30.3.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ=="], + "@jest/reporters/jest-message-util/pretty-format": ["pretty-format@30.4.1", "", { "dependencies": { "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", "react-is-18": "npm:react-is@^18.3.1", "react-is-19": "npm:react-is@^19.2.5" } }, "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw=="], "@react-three/eslint-plugin/eslint/@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="], @@ -2962,8 +2968,6 @@ "eslint-plugin-jest/@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.58.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.58.0", "@typescript-eslint/tsconfig-utils": "8.58.0", "@typescript-eslint/types": "8.58.0", "@typescript-eslint/visitor-keys": "8.58.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA=="], - "eslint-plugin-promise/@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], - "eslint-plugin-react/minimatch/brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="], "expand-brackets/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], @@ -3022,35 +3026,39 @@ "jest-circus/jest-matcher-utils/@jest/get-type": ["@jest/get-type@30.1.0", "", {}, "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA=="], - "jest-circus/jest-matcher-utils/jest-diff": ["jest-diff@30.3.0", "", { "dependencies": { "@jest/diff-sequences": "30.3.0", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "pretty-format": "30.3.0" } }, "sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ=="], + "jest-circus/jest-matcher-utils/jest-diff": ["jest-diff@30.4.1", "", { "dependencies": { "@jest/diff-sequences": "30.4.0", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "pretty-format": "30.4.1" } }, "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA=="], "jest-circus/jest-message-util/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - "jest-circus/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "jest-circus/pretty-format/@jest/schemas": ["@jest/schemas@30.4.1", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q=="], "jest-config/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], - "jest-config/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "jest-config/pretty-format/@jest/schemas": ["@jest/schemas@30.4.1", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q=="], "jest-diff/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], - "jest-each/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "jest-each/pretty-format/@jest/schemas": ["@jest/schemas@30.4.1", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q=="], - "jest-leak-detector/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "jest-leak-detector/pretty-format/@jest/schemas": ["@jest/schemas@30.4.1", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q=="], "jest-matcher-utils/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "jest-message-util/@jest/types/@jest/pattern": ["@jest/pattern@30.0.1", "", { "dependencies": { "@types/node": "*", "jest-regex-util": "30.0.1" } }, "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA=="], + "jest-message-util/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "jest-mock/@jest/types/@jest/pattern": ["@jest/pattern@30.0.1", "", { "dependencies": { "@types/node": "*", "jest-regex-util": "30.0.1" } }, "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA=="], + "jest-runner/jest-message-util/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - "jest-runner/jest-message-util/pretty-format": ["pretty-format@30.3.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ=="], + "jest-runner/jest-message-util/pretty-format": ["pretty-format@30.4.1", "", { "dependencies": { "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", "react-is-18": "npm:react-is@^18.3.1", "react-is-19": "npm:react-is@^19.2.5" } }, "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw=="], "jest-runtime/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "jest-runtime/jest-message-util/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - "jest-runtime/jest-message-util/pretty-format": ["pretty-format@30.3.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ=="], + "jest-runtime/jest-message-util/pretty-format": ["pretty-format@30.4.1", "", { "dependencies": { "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", "react-is-18": "npm:react-is@^18.3.1", "react-is-19": "npm:react-is@^19.2.5" } }, "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw=="], "jest-skipped-reporter/jest-util/@jest/console": ["@jest/console@24.9.0", "", { "dependencies": { "@jest/source-map": "^24.9.0", "chalk": "^2.0.1", "slash": "^2.0.0" } }, "sha512-Zuj6b8TnKXi3q4ymac8EQfc3ea/uhLeCGThFqXeC8H9/raaH8ARPUTdId+XyGd03Z4In0/VjD2OYFcBF09fNLQ=="], @@ -3068,20 +3076,24 @@ "jest-skipped-reporter/jest-util/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - "jest-snapshot/expect/jest-mock": ["jest-mock@30.3.0", "", { "dependencies": { "@jest/types": "30.3.0", "@types/node": "*", "jest-util": "30.3.0" } }, "sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog=="], + "jest-snapshot/expect/jest-mock": ["jest-mock@30.4.1", "", { "dependencies": { "@jest/types": "30.4.1", "@types/node": "*", "jest-util": "30.4.1" } }, "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw=="], - "jest-snapshot/jest-diff/@jest/diff-sequences": ["@jest/diff-sequences@30.3.0", "", {}, "sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA=="], + "jest-snapshot/jest-diff/@jest/diff-sequences": ["@jest/diff-sequences@30.4.0", "", {}, "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g=="], "jest-snapshot/jest-message-util/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - "jest-snapshot/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "jest-snapshot/pretty-format/@jest/schemas": ["@jest/schemas@30.4.1", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q=="], - "jest-validate/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "jest-validate/pretty-format/@jest/schemas": ["@jest/schemas@30.4.1", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q=="], "jest-worker/supports-color/has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "js-yaml/argparse/sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], + "jsdom/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "jsdom/https-proxy-agent/debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], + "jshint/minimatch/brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="], "node-source-walk/@babel/parser/@babel/types": ["@babel/types@7.26.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9" } }, "sha512-t8kDRGrKXyp6+tjUh7hw2RLyclsW4TRoRvRHtSyAX9Bb5ldlFh+90YAYY6awRXrlB4G5G2izNeGySpATlFzmOg=="], @@ -3164,27 +3176,27 @@ "@jest/console/jest-message-util/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - "@jest/console/jest-message-util/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "@jest/console/jest-message-util/pretty-format/@jest/schemas": ["@jest/schemas@30.4.1", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q=="], "@jest/core/jest-message-util/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - "@jest/expect/expect/jest-matcher-utils/jest-diff": ["jest-diff@30.3.0", "", { "dependencies": { "@jest/diff-sequences": "30.3.0", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "pretty-format": "30.3.0" } }, "sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ=="], + "@jest/expect/expect/jest-matcher-utils/jest-diff": ["jest-diff@30.4.1", "", { "dependencies": { "@jest/diff-sequences": "30.4.0", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "pretty-format": "30.4.1" } }, "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA=="], - "@jest/expect/expect/jest-matcher-utils/pretty-format": ["pretty-format@30.3.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ=="], + "@jest/expect/expect/jest-matcher-utils/pretty-format": ["pretty-format@30.4.1", "", { "dependencies": { "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", "react-is-18": "npm:react-is@^18.3.1", "react-is-19": "npm:react-is@^19.2.5" } }, "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw=="], "@jest/expect/expect/jest-message-util/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - "@jest/expect/expect/jest-message-util/pretty-format": ["pretty-format@30.3.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ=="], + "@jest/expect/expect/jest-message-util/pretty-format": ["pretty-format@30.4.1", "", { "dependencies": { "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", "react-is-18": "npm:react-is@^18.3.1", "react-is-19": "npm:react-is@^19.2.5" } }, "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw=="], "@jest/fake-timers/jest-message-util/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - "@jest/fake-timers/jest-message-util/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "@jest/fake-timers/jest-message-util/pretty-format/@jest/schemas": ["@jest/schemas@30.4.1", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q=="], "@jest/reporters/glob/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], "@jest/reporters/jest-message-util/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - "@jest/reporters/jest-message-util/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "@jest/reporters/jest-message-util/pretty-format/@jest/schemas": ["@jest/schemas@30.4.1", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q=="], "@react-three/eslint-plugin/eslint/espree/acorn": ["acorn@8.14.0", "", { "bin": "bin/acorn" }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], @@ -3216,6 +3228,8 @@ "eslint-plugin-react/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "expect/jest-util/@jest/types/@jest/pattern": ["@jest/pattern@30.0.1", "", { "dependencies": { "@types/node": "*", "jest-regex-util": "30.0.1" } }, "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA=="], + "farmbot/mqtt/worker-timers/tslib": ["tslib@2.6.3", "", {}, "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ=="], "farmbot/mqtt/worker-timers/worker-timers-broker": ["worker-timers-broker@6.1.8", "", { "dependencies": { "@babel/runtime": "^7.24.5", "fast-unique-numbers": "^8.0.13", "tslib": "^2.6.2", "worker-timers-worker": "^7.0.71" } }, "sha512-FUCJu9jlK3A8WqLTKXM9E6kAmI/dR1vAJ8dHYLMisLNB/n3GuaFIjJ7pn16ZcD1zCOf7P6H62lWIEBi+yz/zQQ=="], @@ -3246,21 +3260,25 @@ "istanbul-lib-instrument/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.25.9", "", {}, "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA=="], - "jest-circus/jest-matcher-utils/jest-diff/@jest/diff-sequences": ["@jest/diff-sequences@30.3.0", "", {}, "sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA=="], + "jest-circus/jest-matcher-utils/jest-diff/@jest/diff-sequences": ["@jest/diff-sequences@30.4.0", "", {}, "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g=="], "jest-circus/jest-message-util/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], "jest-config/glob/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], + "jest-message-util/@jest/types/@jest/pattern/jest-regex-util": ["jest-regex-util@30.0.1", "", {}, "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA=="], + + "jest-mock/@jest/types/@jest/pattern/jest-regex-util": ["jest-regex-util@30.0.1", "", {}, "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA=="], + "jest-runner/jest-message-util/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - "jest-runner/jest-message-util/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "jest-runner/jest-message-util/pretty-format/@jest/schemas": ["@jest/schemas@30.4.1", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q=="], "jest-runtime/glob/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], "jest-runtime/jest-message-util/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - "jest-runtime/jest-message-util/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "jest-runtime/jest-message-util/pretty-format/@jest/schemas": ["@jest/schemas@30.4.1", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q=="], "jest-skipped-reporter/jest-util/@jest/fake-timers/jest-message-util": ["jest-message-util@24.9.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "@jest/test-result": "^24.9.0", "@jest/types": "^24.9.0", "@types/stack-utils": "^1.0.1", "chalk": "^2.0.1", "micromatch": "^3.1.10", "slash": "^2.0.0", "stack-utils": "^1.0.1" } }, "sha512-oCj8FiZ3U0hTP4aSui87P4L4jC37BtQwUMqk+zk/b11FR19BJDeZsZAvIHutWnmtw7r85UmR3CEWZ0HWU2mAlw=="], @@ -3332,13 +3350,13 @@ "@istanbuljs/load-nyc-config/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], - "@jest/expect/expect/jest-matcher-utils/jest-diff/@jest/diff-sequences": ["@jest/diff-sequences@30.3.0", "", {}, "sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA=="], + "@jest/expect/expect/jest-matcher-utils/jest-diff/@jest/diff-sequences": ["@jest/diff-sequences@30.4.0", "", {}, "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g=="], - "@jest/expect/expect/jest-matcher-utils/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "@jest/expect/expect/jest-matcher-utils/pretty-format/@jest/schemas": ["@jest/schemas@30.4.1", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q=="], "@jest/expect/expect/jest-message-util/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - "@jest/expect/expect/jest-message-util/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "@jest/expect/expect/jest-message-util/pretty-format/@jest/schemas": ["@jest/schemas@30.4.1", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q=="], "@jest/reporters/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], @@ -3346,6 +3364,8 @@ "detective-typescript/@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "expect/jest-util/@jest/types/@jest/pattern/jest-regex-util": ["jest-regex-util@30.0.1", "", {}, "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA=="], + "farmbot/mqtt/worker-timers/worker-timers-broker/fast-unique-numbers": ["fast-unique-numbers@8.0.13", "", { "dependencies": { "@babel/runtime": "^7.23.8", "tslib": "^2.6.2" } }, "sha512-7OnTFAVPefgw2eBJ1xj2PGGR9FwYzSUso9decayHgCDX4sJkHLdcsYTytTg+tYv+wKF3U8gJuSBz2jJpQV4u/g=="], "jest-config/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], diff --git a/bunfig.toml b/bunfig.toml index 0458dc7807..a2f23dfad2 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -3,7 +3,7 @@ preload = [ "./frontend/__test_support__/happydom.ts", "./frontend/__test_support__/bun_test_setup.ts" ] -coverageReporter = ["text", "lcov"] +coverageReporter = ["lcov"] coverageDir = "coverage_fe" coverage = true onlyFailures = true @@ -13,7 +13,7 @@ coveragePathIgnorePatterns = [ ] [test.reporter] -dots = true +dots = false [env] file = false diff --git a/docker_configs/api.Dockerfile b/docker_configs/api.Dockerfile index b1837ff564..a8a4c93802 100644 --- a/docker_configs/api.Dockerfile +++ b/docker_configs/api.Dockerfile @@ -1,4 +1,4 @@ -FROM ruby:4.0.2 +FROM ruby:4.0.3 RUN curl https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor | tee /etc/apt/trusted.gpg.d/apt.postgresql.org.gpg > /dev/null RUN sh -c '. /etc/os-release; echo $VERSION_CODENAME; echo "deb http://apt.postgresql.org/pub/repos/apt/ $VERSION_CODENAME-pgdg main" >> /etc/apt/sources.list.d/pgdg.list' RUN apt-get update -qq && apt-get install -y build-essential libpq-dev postgresql postgresql-contrib lcov diff --git a/example.env b/example.env index 995dca6818..fbe6c27733 100644 --- a/example.env +++ b/example.env @@ -132,10 +132,10 @@ ROLLBAR_ENV= # Do not add this key if you do not use DataDog on your server. DATADOG_CLIENT_TOKEN=?? -# Set by CircleCI, used by CI test coverage check -CIRCLE_SHA1= -CIRCLE_BRANCH= -CIRCLE_PULL_REQUEST= +# Set by GitHub Actions, used by CI test coverage check +GITHUB_SHA= +GITHUB_REF_NAME= +GITHUB_PULL_REQUEST= # Can be deleted unless you are using codecov. CODECOV_TOKEN= diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 629461d6b1..33909064ff 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -10,6 +10,7 @@ Follow existing codebase conventions and style, for example: ## Testing Instructions - Jest mocks already exist for many modules in `frontend/__test_support__`. - New tests should be written using the `@testing-library/react` library. +- Use only test-level mocks, avoid module-level mocks. - Do not terminate test commands before they are completed. ### For the files you change diff --git a/frontend/__test_support__/react_element_search.tsx b/frontend/__test_support__/react_element_search.tsx new file mode 100644 index 0000000000..6969dd0a5c --- /dev/null +++ b/frontend/__test_support__/react_element_search.tsx @@ -0,0 +1,34 @@ +import React from "react"; + +// eslint-disable-next-line comma-spacing +export const findElement = ( + node: React.ReactNode, + matcher: (type: React.ElementType) => boolean, +): React.ReactElement

| undefined => { + if (!node || typeof node === "string" || typeof node === "number") { + return undefined; + } + if (Array.isArray(node)) { + const children: React.ReactNode[] = node; + for (const child of children) { + const found = findElement

(child, matcher); + if (found) { return found; } + } + return undefined; + } + if (React.isValidElement<{ children?: React.ReactNode }>(node)) { + if (matcher(node.type as React.ElementType)) { + return node as React.ReactElement

; + } + return findElement

(node.props.children, matcher); + } + return undefined; +}; + +export const findElementByType = + // eslint-disable-next-line comma-spacing + ( + node: React.ReactNode, + component: React.ElementType, + ): React.ReactElement

| undefined => + findElement

(node, type => type === component); diff --git a/frontend/__test_support__/three_d_mocks.tsx b/frontend/__test_support__/three_d_mocks.tsx index 6ec691e255..f33f4d7b6e 100644 --- a/frontend/__test_support__/three_d_mocks.tsx +++ b/frontend/__test_support__/three_d_mocks.tsx @@ -229,15 +229,21 @@ jest.mock("@react-three/fiber", () => ({ jest.mock("@react-spring/three", () => ({ useSpring: (props: UseSpringProps) => { - if (typeof props == "function") { (props as Function)(); } + const springProps = typeof props == "function" + ? (props as Function)() + : props; + const onRest = springProps.onRest; + React.useEffect(() => { + onRest?.(); + }, [onRest]); const resolvedTo = - props.to && typeof props.to == "object" - ? props.to + springProps.to && typeof springProps.to == "object" + ? springProps.to : {}; const api = { start: jest.fn(() => Promise.resolve()), }; - return [{ ...props, ...props.from, ...resolvedTo }, api]; + return [{ ...springProps, ...springProps.from, ...resolvedTo }, api]; }, // mocks for ` {

{children}
, Detailed: ({ children }: { children: ReactNode }) =>
{children}
, - Html: ({ children }: { children: ReactNode }) => -
{children}
, + Html: ({ children, style }: { + children: ReactNode, + style?: React.CSSProperties, + }) => +
{children}
, PerspectiveCamera: ({ name }: { name: string }) =>
{name}
, useCursor: jest.fn(), @@ -803,8 +812,12 @@ jest.mock("@react-three/drei", () => { }; return makeTexture(); }), - RenderTexture: ({ children }: { children: ReactNode }) => -
{children}
, + RenderTexture: ( + props: { children: ReactNode, frames?: number }, + ) => +
+ {props.children} +
, GizmoHelper: ({ name }: { name: string }) =>
{name}
, GizmoViewcube: ({ name }: { name: string }) => diff --git a/frontend/__tests__/hotkeys_test.tsx b/frontend/__tests__/hotkeys_test.tsx index 449c8fdd9c..8f82ae435f 100644 --- a/frontend/__tests__/hotkeys_test.tsx +++ b/frontend/__tests__/hotkeys_test.tsx @@ -3,6 +3,7 @@ const mockState = fakeState(); import React from "react"; import { render } from "@testing-library/react"; +import { HotkeysProvider } from "@blueprintjs/core"; import { HotKey, HotKeys, HotKeysProps, hotkeysWithActions, HotkeysWithActionsProps, toggleHotkeyHelpOverlay, @@ -128,16 +129,18 @@ describe("", () => { dispatch: jest.fn(), designer: fakeDesignerState(), }); + const renderHotKeys = (props: HotKeysProps) => + render(); it("renders", () => { location.pathname = Path.mock(Path.designer("nope")); - const { container } = render(); + const { container } = renderHotKeys(fakeProps()); expect(container.querySelectorAll("div").length).toEqual(1); }); it("renders default", () => { location.pathname = Path.mock(Path.designer()); - const { container } = render(); + const { container } = renderHotKeys(fakeProps()); expect(container.querySelectorAll("div").length).toEqual(1); }); }); diff --git a/frontend/api/__tests__/delete_points_test.ts b/frontend/api/__tests__/delete_points_test.ts index 84f5202ffb..65c8564726 100644 --- a/frontend/api/__tests__/delete_points_test.ts +++ b/frontend/api/__tests__/delete_points_test.ts @@ -64,7 +64,28 @@ describe("deletePoints()", () => { expect(error).toHaveBeenCalledWith(expect.stringContaining( "Some weeds failed to delete.")); expect(error).toHaveBeenCalledWith(expect.stringContaining( - "Are they in use by sequences?")); + "Are they in use by sequences, regimens, or events?")); + }); + + it("shows response errors when points can't be deleted", async () => { + mockDelete = Promise.reject({ + response: { + data: { + whoops: "Could not delete point. Item is in use by the following: X.", + }, + }, + }); + mockData = [{ id: 1 }, { id: 2 }, { id: 3 }]; + const dispatch = jest.fn(); + const progressCb = jest.fn(); + const query = { meta: { created_by: "plant-detection" } }; + await actualDeletePoints().deletePoints("weeds", query, progressCb)( + dispatch, jest.fn()); + await Promise.resolve(); + expect(error).toHaveBeenCalledWith(expect.stringContaining( + "Some weeds failed to delete.")); + expect(error).toHaveBeenCalledWith( + "Whoops: Could not delete point. Item is in use by the following: X."); }); it("chunks points", async () => { @@ -114,7 +135,23 @@ describe("deletePointsByIds()", () => { expect(error).toHaveBeenCalledWith(expect.stringContaining( "Some points failed to delete.")); expect(error).toHaveBeenCalledWith(expect.stringContaining( - "Are they in use by sequences?")); + "Are they in use by sequences, regimens, or events?")); + expect(success).not.toHaveBeenCalled(); + }); + + it("shows response errors when points can't be deleted", async () => { + mockDelete = Promise.reject({ + response: { + data: { + whoops: "Could not delete point. Item is in use by the following: X.", + }, + }, + }); + await actualDeletePoints().deletePointsByIds("points", [1, 2, 3]); + expect(error).toHaveBeenCalledWith(expect.stringContaining( + "Some points failed to delete.")); + expect(error).toHaveBeenCalledWith( + "Whoops: Could not delete point. Item is in use by the following: X."); expect(success).not.toHaveBeenCalled(); }); }); diff --git a/frontend/api/delete_points.ts b/frontend/api/delete_points.ts index 988ded7970..164eee9ef2 100644 --- a/frontend/api/delete_points.ts +++ b/frontend/api/delete_points.ts @@ -7,6 +7,16 @@ import { noop, chunk } from "lodash"; import { Point } from "farmbot/dist/resources/api_resources"; import { Actions } from "../constants"; import { t } from "../i18next_wrapper"; +import { + AxiosErrorResponse, + prettyPrintApiErrors, +} from "../util/errors"; + +const toastResponseErrors = (err: AxiosErrorResponse) => { + if (err.response?.data) { + error(prettyPrintApiErrors(err)); + } +}; export function deletePoints( pointName: string, @@ -30,7 +40,7 @@ export function deletePoints( return x; }); }); - Promise + return Promise .all(promises) .then(function () { dispatch({ @@ -42,10 +52,11 @@ export function deletePoints( })); prog.finish(); }) - .catch(function () { + .catch(function (err: AxiosErrorResponse) { error(trim(`${t("Some {{points}} failed to delete.", { points: pointName })} - ${t("Are they in use by sequences?")}`)); + ${t("Are they in use by sequences, regimens, or events?")}`)); + toastResponseErrors(err); prog.finish(); }); }; @@ -59,6 +70,8 @@ export const deletePointsByIds = (pointName: string, ids: number[]) => success(t("Deleted {{num}} {{points}}", { num: ids.length, points: pointName, }))) - .catch(() => + .catch((err: AxiosErrorResponse) => { error(trim(`${t("Some {{points}} failed to delete.", { points: pointName })} - ${t("Are they in use by sequences?")}`))); + ${t("Are they in use by sequences, regimens, or events?")}`)); + toastResponseErrors(err); + }); diff --git a/frontend/app.tsx b/frontend/app.tsx index bd76aa6af3..6e367f3780 100644 --- a/frontend/app.tsx +++ b/frontend/app.tsx @@ -29,6 +29,7 @@ import { AppState } from "./reducer"; import { Navigate, Outlet } from "react-router"; import { ErrorBoundary } from "./error_boundary"; import { DesignerState } from "./farm_designer/interfaces"; +import { setPanelOpen } from "./farm_designer/panel_header"; export interface AppProps { dispatch: Function; @@ -81,27 +82,45 @@ const MUST_LOAD: ResourceName[] = [ export class RawApp extends React.Component { private _isMounted = false; + private mapLandingPanelClosed = false; private get isLoaded() { return (MUST_LOAD.length === intersection(this.props.loaded, MUST_LOAD).length); } + closePanelForMapPage = () => { + const landingPage = this.props.getConfigValue(StringSetting.landing_page); + if (!this.mapLandingPanelClosed && + (Path.equals(Path.designer()) || + ((Path.equals("") || Path.equals(Path.app())) && + landingPage == "map"))) { + this.props.dispatch(setPanelOpen(false)); + this.mapLandingPanelClosed = true; + } + }; + /** * If the sync object takes more than 10s to load, the user will be granted * access into the app, but still warned. */ componentDidMount() { this._isMounted = true; + this.closePanelForMapPage(); setTimeout(() => { if (this._isMounted && !this.isLoaded) { error(t(Content.APP_LOAD_TIMEOUT_MESSAGE), { title: t("Warning") }); } }, LOAD_TIME_FAILURE_MS); const browser = Bowser.getParser(window.navigator.userAgent); - !browser.satisfies({ chrome: ">85", firefox: ">75", edge: ">85" }) && + /** css :has() support */ + !browser.satisfies({ chrome: ">=105", firefox: ">=121", edge: ">=105" }) && warning(t(Content.UNSUPPORTED_BROWSER)); } + componentDidUpdate() { + this.closePanelForMapPage(); + } + componentWillUnmount() { this._isMounted = false; } diff --git a/frontend/constants.ts b/frontend/constants.ts index c4b9f8c3e8..9b32a1cbcb 100644 --- a/frontend/constants.ts +++ b/frontend/constants.ts @@ -2511,6 +2511,7 @@ export enum Actions { DEMO_WRITE_PIN = "DEMO_WRITE_PIN", DEMO_SET_POSITION = "DEMO_SET_POSITION", DEMO_SET_JOB_PROGRESS = "DEMO_SET_JOB_PROGRESS", + DEMO_SET_STATE = "DEMO_SET_STATE", DEMO_SET_ESTOP = "DEMO_SET_ESTOP", DEMO_SET_MOUNTED_TOOL_ID = "DEMO_SET_MOUNTED_TOOL_ID", DEMO_SET_QUEUE_LENGTH = "DEMO_SET_QUEUE_LENGTH", diff --git a/frontend/css/app/navbar.scss b/frontend/css/app/navbar.scss index e9851bec2f..f390fbcdf1 100644 --- a/frontend/css/app/navbar.scss +++ b/frontend/css/app/navbar.scss @@ -10,6 +10,23 @@ background: linear-gradient(0deg, transparent, $translucent3 60%, $translucent5), linear-gradient(0deg, transparent 30%, $translucent3); } +@keyframes nav-slide-in { + from { + opacity: 0; + transform: translateY(-100%); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: no-preference) { + .nav-load-in { + animation: nav-slide-in 320ms ease-out both; + } +} + .skip-nav-link { display: none; } diff --git a/frontend/css/farm_designer/farm_designer.scss b/frontend/css/farm_designer/farm_designer.scss index 537e7a44cb..1582593e8f 100644 --- a/frontend/css/farm_designer/farm_designer.scss +++ b/frontend/css/farm_designer/farm_designer.scss @@ -537,7 +537,7 @@ } .garden-map-legend { - position: absolute; + position: fixed; top: 7.5rem; right: -155px; z-index: 3; diff --git a/frontend/css/farm_designer/three_d_garden.scss b/frontend/css/farm_designer/three_d_garden.scss index afa64fabf0..195c79c1c0 100644 --- a/frontend/css/farm_designer/three_d_garden.scss +++ b/frontend/css/farm_designer/three_d_garden.scss @@ -2,44 +2,6 @@ @use "sass:color"; @use "../global/fonts" as *; -.three-d-garden-loading-container, -.promo-loading-container { - display: grid; - align-content: center; - justify-content: center; - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - z-index: -9999999; -} - -.three-d-garden-loading-container { - z-index: unset; - width: 100vw; - transform: translateX(22.5rem); -} - -.promo-loading-image { - height: 100vh; -} - -.three-d-garden-loading-text, -.promo-loading-text { - font-family: $inknut; - font-size: 2rem; - font-weight: bold; - color: $off_white; - text-shadow: 0 0 3.5rem #000, 0 0 1rem #000; - text-align: center; - margin: 0; - position: absolute; - width: 100vw; - height: 100vh; - align-content: center; -} - .promo { &.three-d-garden { .garden-bed-3d-model { @@ -81,6 +43,45 @@ } } + .three-d-load-progress { + position: absolute; + left: 50%; + bottom: 6rem; + transform: translateX(-50%); + width: min(36rem, calc(100vw - 3rem)); + pointer-events: none; + color: $off_white; + text-align: center; + text-shadow: 0 0 1rem $black; + opacity: 1; + transition: opacity 300ms ease; + + &.three-d-load-progress-complete { + opacity: 0; + } + + .three-d-load-progress-bar { + height: 0.7rem; + overflow: hidden; + border-radius: 999px; + background: $translucent4; + box-shadow: 0 0 0 1px $translucent2_white; + } + + .three-d-load-progress-fill { + height: 100%; + border-radius: inherit; + background: rgba(255, 255, 255, 0.75); + transition: width 180ms ease; + } + + p { + margin-top: 0.8rem; + font-size: 1.3rem; + font-weight: bold; + } + } + .gear { position: absolute; top: 1rem; @@ -121,6 +122,23 @@ background: linear-gradient(0deg, $translucent5, transparent); scrollbar-width: none; + .settings-bar-content { + display: flex; + gap: 1.25rem; + opacity: 0; + transform: translateY(1.5rem); + transition: + opacity 450ms ease, + transform 450ms ease; + } + + &.settings-bar-loaded { + .settings-bar-content { + opacity: 1; + transform: translateY(0); + } + } + @media screen and (max-width: 768px) { justify-content: left; pointer-events: all; @@ -141,17 +159,29 @@ .row { display: flex; + position: relative; margin: 0; - background: rgba(255, 255, 255, 0.6); + overflow: hidden; + background: transparent; box-shadow: $translucent2 0px 0px 1rem; border-radius: 2.5rem; height: 3.1rem; padding: 0.3rem; justify-content: space-evenly; - backdrop-filter: blur(5px); gap: 0.5rem; - &:after, - &:before { + + &::before { + content: ""; + position: absolute; + inset: 0; + z-index: 0; + border-radius: inherit; + pointer-events: none; + background: rgba(255, 255, 255, 0.6); + backdrop-filter: blur(5px); + } + + &:after { content: unset; display: unset; clear: unset; @@ -159,6 +189,8 @@ } button { + position: relative; + z-index: 1; padding: 0 0.85rem; border-radius: 2.5rem; font-weight: bold; @@ -235,6 +267,7 @@ .all-configs { position: absolute; + z-index: 2; top: 1rem; right: 1rem; width: 22rem; @@ -265,25 +298,29 @@ margin-top: 1rem; } - details { - color: $off_white; + .config-search { + width: 100%; + height: 2rem; + margin-bottom: 0.5rem; + padding: 0.2rem 0.5rem; + font-size: 0.85rem; + box-shadow: none; + } - label { - font-weight: bold; - color: $off_white; - text-transform: none; - font-size: 0.9rem; - } + .config-title { + color: $off_white; + font-weight: bold; } - summary { - margin: -1rem; - cursor: pointer; - padding: 1rem; + label { + font-weight: bold; + color: $off_white; + text-transform: none; + font-size: 0.9rem; + } - &:hover { - background: $dark_gray; - } + .config-section:not(:has(.config-row)) { + display: none; } .config-row { @@ -358,6 +395,41 @@ } } + .focus-transition-dom { + opacity: 1; + transform: scale(1); + transition: + opacity 900ms cubic-bezier(0.65, 0, 0.35, 1), + transform 900ms cubic-bezier(0.65, 0, 0.35, 1); + will-change: opacity, transform; + + &.focus-transition-hidden { + opacity: 0; + pointer-events: none; + transform: scale(0.98); + } + + &.focus-transition-visible { + opacity: 1; + transform: scale(1); + } + } + + .focus-transition-opacity { + opacity: 1; + transition: opacity 900ms cubic-bezier(0.65, 0, 0.35, 1); + will-change: opacity; + + &.focus-transition-hidden { + opacity: 0; + pointer-events: none; + } + + &.focus-transition-visible { + opacity: 1; + } + } + .promo-info { display: grid; position: absolute; @@ -370,6 +442,25 @@ gap: 1rem; justify-items: right; + .title, + .description { + opacity: 0; + animation: promo-info-load-in 500ms ease forwards; + } + + .title, + .description { + transform: translateY(1rem); + } + + .title { + animation-delay: 0ms; + } + + .description { + animation-delay: 150ms; + } + .title { margin: 0; font-family: $inknut; @@ -400,11 +491,12 @@ .buy-button { display: flex; + position: relative; + overflow: hidden; pointer-events: all; - background: #00a579e0; + background: transparent; border-radius: 7px; box-shadow: $translucent2 0px 0px 10px; - backdrop-filter: blur(5px); padding: 0.1rem 1.75rem; text-shadow: none; text-transform: uppercase; @@ -412,7 +504,23 @@ gap: 0.4rem; align-items: center; + &::before { + content: ""; + position: absolute; + inset: 0; + z-index: 0; + border-radius: inherit; + pointer-events: none; + background: #00a579e0; + backdrop-filter: blur(5px); + animation: buy-button-backdrop-load-in 500ms ease 275ms both; + } + p { + position: relative; + z-index: 1; + opacity: 0; + animation: promo-info-fade-in 500ms ease 275ms forwards; margin: 0; color: $off_white; font-size: 1.5rem; @@ -434,21 +542,54 @@ } &:hover { - background: #00bb89e5; + &::before { + background: #00bb89e5; + } } } } + .settings-bar.focus-transition-dom { + transform-origin: 50% 100%; + transform: none; + transition: opacity 900ms cubic-bezier(0.65, 0, 0.35, 1); + will-change: auto; + + &.focus-transition-hidden { + transform: none; + } + + &.focus-transition-visible { + transform: none; + } + } + + .promo-info.focus-transition-dom { + transform-origin: 100% 0; + transform: none; + transition: opacity 900ms cubic-bezier(0.65, 0, 0.35, 1); + will-change: auto; + + &.focus-transition-hidden { + transform: none; + } + + &.focus-transition-visible { + transform: none; + } + } + .beacon-info { width: 34rem; - background: $translucent8_white; + background: rgba(255, 255, 255, 0.6); color: $black; box-shadow: $translucent2 0px 0px 10px; border-radius: 0.5rem; padding: 1.5rem; - backdrop-filter: blur(5px); + backdrop-filter: blur(20px); text-align: left; - user-select: none; + cursor: default; + user-select: text; p { margin-bottom: 0; margin-top: 1rem; @@ -492,6 +633,7 @@ text-align: center; background: rgba(255, 255, 255, 0.4); box-shadow: $translucent2 0px 0px 0.75rem; + user-select: none; &:hover { cursor: pointer; background: rgba(255, 255, 255, 0.6); @@ -500,6 +642,10 @@ } } + .beacon-info.focus-transition-dom { + transform-origin: 50% 50%; + } + @media screen and (max-width: 768px) { .beacon-info-wrapper { display: grid; @@ -585,3 +731,35 @@ position: absolute; top: 3rem; } + +@keyframes promo-info-load-in { + from { + opacity: 0; + transform: translateY(1rem); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes promo-info-fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes buy-button-backdrop-load-in { + from { + background: rgba(0, 165, 121, 0); + } + + to { + background: #00a579e0; + } +} diff --git a/frontend/css/panels/sequences.scss b/frontend/css/panels/sequences.scss index 12b7be3c08..ef40e27a9d 100644 --- a/frontend/css/panels/sequences.scss +++ b/frontend/css/panels/sequences.scss @@ -1094,6 +1094,7 @@ } .add-command-button-container { + position: relative; .bp6-collapse { transition-duration: 0ms !important; } diff --git a/frontend/demo/__tests__/demo_iframe_test.tsx b/frontend/demo/__tests__/demo_iframe_test.tsx index 765c27e9c7..89aedaa69e 100644 --- a/frontend/demo/__tests__/demo_iframe_test.tsx +++ b/frontend/demo/__tests__/demo_iframe_test.tsx @@ -40,6 +40,7 @@ describe("", () => { .mockImplementation(((props: FBSelectProps) => + + + + + + ; + }; + + it("marks ready steps and hides the progress bar when complete", () => { + jest.useFakeTimers(); + const consoleLog = jest.spyOn(console, "log").mockImplementation(jest.fn()); + render(); + + expect(screen.getByTestId("current-step").textContent) + .toEqual("environment"); + expect(screen.getByTestId("bed-allowed").textContent).toEqual("false"); + expect(screen.getByTestId("grid-allowed").textContent).toEqual("false"); + expect(screen.getByTestId("plants-allowed").textContent).toEqual("false"); + expect(screen.getByTestId("weeds-allowed").textContent).toEqual("false"); + expect(screen.getByTestId("points-allowed").textContent).toEqual("false"); + expect(screen.getByTestId("farmbot-allowed").textContent).toEqual("false"); + expect(screen.getByTestId("details-allowed").textContent).toEqual("false"); + expect(document.querySelector(".three-d-load-progress")).toBeTruthy(); + THREE_D_LOAD_STEPS.forEach(step => { + expect(screen.getByTestId("current-step").textContent).toEqual(step.id); + fireEvent.click(screen.getByText("advance")); + }); + + expect(screen.getByTestId("current-step").textContent).toEqual("complete"); + expect(screen.getByText("Enjoy!")).toBeTruthy(); + expect(document.querySelector(".three-d-load-progress-complete")) + .toBeTruthy(); + act(() => { + jest.advanceTimersByTime(THREE_D_LOAD_PROGRESS_FADE_MS); + }); + expect(document.querySelector(".three-d-load-progress")).toBeFalsy(); + expect(consoleLog).toHaveBeenCalledWith(expect.stringContaining("Total")); + consoleLog.mockRestore(); + jest.useRealTimers(); + }); + + it("allows steps as soon as their dependencies are ready", () => { + render(); + + fireEvent.click(screen.getByText("mark environment")); + expect(screen.getByTestId("bed-allowed").textContent).toEqual("true"); + expect(screen.getByTestId("grid-allowed").textContent).toEqual("false"); + expect(screen.getByTestId("plants-allowed").textContent).toEqual("false"); + expect(screen.getByTestId("weeds-allowed").textContent).toEqual("false"); + expect(screen.getByTestId("points-allowed").textContent).toEqual("false"); + expect(screen.getByTestId("farmbot-allowed").textContent).toEqual("false"); + + fireEvent.click(screen.getByText("mark bed")); + expect(screen.getByTestId("grid-allowed").textContent).toEqual("true"); + expect(screen.getByTestId("plants-allowed").textContent).toEqual("false"); + expect(screen.getByTestId("weeds-allowed").textContent).toEqual("false"); + expect(screen.getByTestId("points-allowed").textContent).toEqual("false"); + expect(screen.getByTestId("farmbot-allowed").textContent).toEqual("false"); + + fireEvent.click(screen.getByText("mark grid")); + expect(screen.getByTestId("plants-allowed").textContent).toEqual("true"); + expect(screen.getByTestId("weeds-allowed").textContent).toEqual("true"); + expect(screen.getByTestId("points-allowed").textContent).toEqual("true"); + expect(screen.getByTestId("farmbot-allowed").textContent).toEqual("true"); + expect(screen.getByTestId("details-allowed").textContent).toEqual("false"); + + fireEvent.click(screen.getByText("mark plants")); + expect(screen.getByTestId("details-allowed").textContent).toEqual("false"); + + fireEvent.click(screen.getByText("mark FarmBot")); + expect(screen.getByTestId("details-allowed").textContent).toEqual("true"); + }); + + it("marks one step ready", () => { + const markStep = jest.fn(); + render(); + expect(markStep).toHaveBeenCalledWith("plants"); + }); + + it("can hide before all progress steps are ready", () => { + jest.useFakeTimers(); + const CompleteHarness = ({ complete }: { complete: boolean }) => { + const progress = useThreeDLoadProgress(); + return ; + }; + + const { rerender } = render(); + expect(document.querySelector(".three-d-load-progress")).toBeTruthy(); + + rerender(); + + expect(screen.getByText("Enjoy!")).toBeTruthy(); + expect(document.querySelector(".three-d-load-progress-complete")) + .toBeTruthy(); + act(() => { + jest.advanceTimersByTime(THREE_D_LOAD_PROGRESS_FADE_MS); + }); + expect(document.querySelector(".three-d-load-progress")).toBeFalsy(); + jest.useRealTimers(); + }); + + it("doesn't block clicks outside the progress bar", () => { + render(); + + const html = document.querySelector(".html") as HTMLElement; + expect(html.style.pointerEvents).toEqual("none"); + }); +}); diff --git a/frontend/three_d_garden/__tests__/texture_variants_test.ts b/frontend/three_d_garden/__tests__/texture_variants_test.ts new file mode 100644 index 0000000000..56813922d5 --- /dev/null +++ b/frontend/three_d_garden/__tests__/texture_variants_test.ts @@ -0,0 +1,69 @@ +import { RepeatWrapping, Texture } from "three"; +import { renderHook } from "@testing-library/react"; +import { useTexture } from "@react-three/drei"; +import { + getTextureVariant, textureVariantKey, useTextureVariant, +} from "../texture_variants"; + +describe("texture variants", () => { + it("builds stable keys", () => { + expect(textureVariantKey({ + wrapS: RepeatWrapping, + wrapT: RepeatWrapping, + repeat: [0.02, 0.05], + offset: [0.25, 0.5], + rotation: Math.PI / 2, + })).toEqual("1000|1000|0.02,0.05|0.25,0.5|1.5707963267948966"); + }); + + it("reuses identical variants for the same base texture", () => { + const baseTexture = new Texture(); + const options = { + wrapS: RepeatWrapping, + wrapT: RepeatWrapping, + repeat: [0.02, 0.05] as [number, number], + }; + + const first = getTextureVariant(baseTexture, options); + const second = getTextureVariant(baseTexture, options); + + expect(first).toBe(second); + expect(first).not.toBe(baseTexture); + expect(first.wrapS).toEqual(RepeatWrapping); + expect(first.wrapT).toEqual(RepeatWrapping); + expect(first.repeat.x).toEqual(0.02); + expect(first.repeat.y).toEqual(0.05); + }); + + it("keeps different variant options isolated", () => { + const baseTexture = new Texture(); + + const first = getTextureVariant(baseTexture, { + wrapS: RepeatWrapping, + repeat: [0.02, 0.05], + }); + const second = getTextureVariant(baseTexture, { + wrapS: RepeatWrapping, + repeat: [0.3, 0.3], + }); + + expect(first).not.toBe(second); + expect(first.repeat.x).toEqual(0.02); + expect(second.repeat.x).toEqual(0.3); + }); + + it("loads a base texture and returns a cached variant", () => { + (useTexture as unknown as jest.Mock).mockClear(); + + const { result } = renderHook(() => + useTextureVariant("/3D/textures/wood.avif", { + wrapS: RepeatWrapping, + wrapT: RepeatWrapping, + repeat: [0.02, 0.05], + })); + + expect(useTexture).toHaveBeenCalledWith("/3D/textures/wood.avif"); + expect(result.current.wrapS).toEqual(RepeatWrapping); + expect(result.current.wrapT).toEqual(RepeatWrapping); + }); +}); diff --git a/frontend/three_d_garden/__tests__/triangle_functions_test.ts b/frontend/three_d_garden/__tests__/triangle_functions_test.ts index 1c3332409e..54899e17d4 100644 --- a/frontend/three_d_garden/__tests__/triangle_functions_test.ts +++ b/frontend/three_d_garden/__tests__/triangle_functions_test.ts @@ -1,4 +1,6 @@ -import { getZFunc, precomputeTriangles } from "../triangle_functions"; +import { + getZFunc, parseStoredTriangles, precomputeTriangles, serializeTriangles, +} from "../triangle_functions"; describe("precomputeTriangles()", () => { it("computes triangles: zero", () => { @@ -19,6 +21,10 @@ describe("precomputeTriangles()", () => { b: [4, 1, 0], c: [2, 3, 0], det: 6, + maxX: 4, + maxY: 3, + minX: 1, + minY: 1, x1: 1, x2: 4, x3: 2, @@ -40,6 +46,10 @@ describe("getZFunc()", () => { b: [2, 0, 20], c: [0, 2, 30], det: 4, + maxX: 2, + maxY: 2, + minX: 0, + minY: 0, x1: 0, x2: 2, x3: 0, @@ -48,4 +58,102 @@ describe("getZFunc()", () => { y3: 2, }], -100)(1, 1)).toEqual(25); }); + + it("caches Z by coordinate and invalidates with a new function", () => { + const triangle = { + a: [0, 0, 10] as [number, number, number], + b: [2, 0, 20] as [number, number, number], + c: [0, 2, 30] as [number, number, number], + det: 4, + maxX: 2, + maxY: 2, + minX: 0, + minY: 0, + x1: 0, + x2: 2, + x3: 0, + y1: 0, + y2: 0, + y3: 2, + }; + const getZ = getZFunc([triangle], -100); + expect(getZ(1, 1)).toEqual(25); + triangle.c[2] = 300; + expect(getZ(1, 1)).toEqual(25); + expect(getZFunc([triangle], -100)(1, 1)).toEqual(160); + }); + + it("gets Z through the spatial index", () => { + const triangles = precomputeTriangles([ + [0, 0, 10], + [10, 0, 20], + [0, 10, 30], + [20, 0, 40], + [30, 0, 50], + [20, 10, 60], + [0, 20, 70], + [10, 20, 80], + [0, 30, 90], + [20, 20, 100], + [30, 20, 110], + [20, 30, 120], + ], [ + 0, 1, 2, + 3, 4, 5, + 6, 7, 8, + 9, 10, 11, + ]); + expect(getZFunc(triangles, -100)(25, 25)).toEqual(115); + expect(getZFunc(triangles, -100)(15, 15)).toEqual(-100); + }); + + it("keeps original triangle priority in indexed buckets", () => { + const triangles = precomputeTriangles([ + [0, 0, 10], + [10, 0, 10], + [0, 10, 10], + [0, 0, 20], + [10, 0, 20], + [0, 10, 20], + [20, 0, 30], + [30, 0, 30], + [20, 10, 30], + [0, 20, 40], + [10, 20, 40], + [0, 30, 40], + ], [ + 0, 1, 2, + 3, 4, 5, + 6, 7, 8, + 9, 10, 11, + ]); + expect(getZFunc(triangles, -100)(1, 1)).toEqual(10); + }); +}); + +describe("stored triangles", () => { + it("serializes compact triangles", () => { + const triangles = precomputeTriangles([ + [0, 0, 10], + [2, 0, 20], + [0, 2, 30], + ], [0, 1, 2]); + expect(parseStoredTriangles(serializeTriangles(triangles))) + .toEqual(triangles); + }); + + it("parses legacy triangle objects", () => { + const triangles = precomputeTriangles([ + [0, 0, 10], + [2, 0, 20], + [0, 2, 30], + ], [0, 1, 2]); + expect(parseStoredTriangles(JSON.stringify(triangles))) + .toEqual(triangles); + }); + + it("ignores invalid stored triangles", () => { + expect(parseStoredTriangles("[\"foo\"]")).toEqual([]); + expect(parseStoredTriangles("not json")).toEqual([]); + }); }); diff --git a/frontend/three_d_garden/__tests__/visualization_test.tsx b/frontend/three_d_garden/__tests__/visualization_test.tsx index 2b880c4645..9c6f04f523 100644 --- a/frontend/three_d_garden/__tests__/visualization_test.tsx +++ b/frontend/three_d_garden/__tests__/visualization_test.tsx @@ -19,6 +19,7 @@ let originalGetState: typeof store.getState; describe("", () => { beforeEach(() => { + console.log = jest.fn(); originalGetState = store.getState; (store as unknown as { getState: () => { resources: typeof mockResources } }) .getState = () => ({ resources: mockResources }); diff --git a/frontend/three_d_garden/__tests__/zoom_beacons_constants_test.tsx b/frontend/three_d_garden/__tests__/zoom_beacons_constants_test.tsx index 06099b9c6c..6f7d0e610d 100644 --- a/frontend/three_d_garden/__tests__/zoom_beacons_constants_test.tsx +++ b/frontend/three_d_garden/__tests__/zoom_beacons_constants_test.tsx @@ -55,7 +55,10 @@ describe("getCameraOffset()", () => { const config = clone(INITIAL); const configPosition = clone(INITIAL_POSITION); const focus = FOCI(config, configPosition)[0]; - expect(getCameraOffset(focus).position[1]).toEqual(0); + expect(getCameraOffset(focus).position[1]) + .toEqual(getCameraOffset(focus).target[1] - 25); + expect(getCameraOffset(focus).position[0]) + .toEqual(getCameraOffset(focus).target[0]); }); it("returns camera offset: narrow", () => { @@ -63,7 +66,10 @@ describe("getCameraOffset()", () => { const config = clone(INITIAL); const configPosition = clone(INITIAL_POSITION); const focus = FOCI(config, configPosition)[0]; - expect(getCameraOffset(focus).position[1]).toEqual(-1000); + expect(getCameraOffset(focus).position[1]) + .toEqual(getCameraOffset(focus).target[1] - 25); + expect(getCameraOffset(focus).position[0]) + .toEqual(getCameraOffset(focus).target[0]); }); }); @@ -75,8 +81,13 @@ describe("getCamera()", () => { position: [0, 0, 0], target: [0, 0, 0], }; - expect(getCamera(config, configPosition, "What you can grow", fallback).position[0]) - .toEqual(0); + const camera = getCamera( + config, + configPosition, + "What you can grow", + fallback, + ); + expect(camera.position[1]).not.toEqual(camera.target[1]); }); }); @@ -99,14 +110,14 @@ describe("setUrlParam()", () => { }); it("sets URL param", () => { - history.replaceState(undefined, "", "/app/designer"); + window.location.search = ""; setUrlParam("focus", "What you can grow"); const url = getPushedUrl(); expect(url.searchParams.get("focus")).toEqual("What you can grow"); }); it("removes URL param", () => { - history.replaceState(undefined, "", "/app/designer?focus=What+you+can+grow"); + window.location.search = "?focus=What+you+can+grow"; setUrlParam("focus", ""); const url = getPushedUrl(); expect(url.searchParams.get("focus")).toBeNull(); diff --git a/frontend/three_d_garden/bed/__tests__/bed_test.tsx b/frontend/three_d_garden/bed/__tests__/bed_test.tsx index 119f5523c6..a0e8c5956b 100644 --- a/frontend/three_d_garden/bed/__tests__/bed_test.tsx +++ b/frontend/three_d_garden/bed/__tests__/bed_test.tsx @@ -132,7 +132,7 @@ describe("", () => { afterEach(() => { requestAnimationFrameSpy.mockRestore(); - history.replaceState(undefined, "", originalPathname); + location.pathname = originalPathname; }); const fakeProps = (): BedProps => ({ @@ -169,7 +169,7 @@ describe("", () => { ["renders", SpecialStatus.SAVED], ])("%s pointer point", (title, gridPointSpecialStatus) => { getModeSpy.mockReturnValue(Mode.createPoint); - history.replaceState(undefined, "", Path.points("add")); + location.pathname = Path.mock(Path.points("add")); mockIsMobile = false; const p = fakeProps(); p.addPlantProps = fakeAddPlantProps(); @@ -194,7 +194,7 @@ describe("", () => { it("adds a plant", () => { getModeSpy.mockReturnValue(Mode.clickToAdd); - history.replaceState(undefined, "", Path.cropSearch("mint")); + location.pathname = Path.mock(Path.cropSearch("mint")); const p = fakeProps(); p.addPlantProps = fakeAddPlantProps(); const { container } = render(); @@ -207,7 +207,7 @@ describe("", () => { it("doesn't add a drawn point", () => { getModeSpy.mockReturnValue(Mode.createPoint); - history.replaceState(undefined, "", Path.points("add")); + location.pathname = Path.mock(Path.points("add")); const p = fakeProps(); const addPlantProps = fakeAddPlantProps(); addPlantProps.designer.drawnPoint = undefined; @@ -220,7 +220,7 @@ describe("", () => { it("adds a drawn point: xy", () => { getModeSpy.mockReturnValue(Mode.createPoint); - history.replaceState(undefined, "", Path.points("add")); + location.pathname = Path.mock(Path.points("add")); mockPlantRef.current = { position: { set: mockSetPlantPosition } }; const p = fakeProps(); const addPlantProps = fakeAddPlantProps(); @@ -242,7 +242,7 @@ describe("", () => { it("adds a drawn point: radius", () => { getModeSpy.mockReturnValue(Mode.createPoint); - history.replaceState(undefined, "", Path.points("add")); + location.pathname = Path.mock(Path.points("add")); mockPlantRef.current = { position: { set: mockSetPlantPosition } }; const p = fakeProps(); const addPlantProps = fakeAddPlantProps(); @@ -264,7 +264,7 @@ describe("", () => { it("updates pointer plant position", () => { getModeSpy.mockReturnValue(Mode.clickToAdd); - history.replaceState(undefined, "", Path.mock(Path.cropSearch("mint"))); + location.pathname = Path.mock(Path.cropSearch("mint")); mockIsMobile = false; mockPlantRef.current = { position: { set: mockSetPlantPosition } }; mockXCrosshairRef.current = { position: { set: mockSetXCrosshairPosition } }; @@ -281,7 +281,7 @@ describe("", () => { it("handles missing ref", () => { getModeSpy.mockReturnValue(Mode.clickToAdd); - history.replaceState(undefined, "", Path.mock(Path.cropSearch("mint"))); + location.pathname = Path.mock(Path.cropSearch("mint")); mockIsMobile = false; mockPlantRef.current = undefined; mockXCrosshairRef.current = undefined; @@ -297,7 +297,7 @@ describe("", () => { it("handles missing crosshair refs", () => { getModeSpy.mockReturnValue(Mode.clickToAdd); - history.replaceState(undefined, "", Path.mock(Path.cropSearch("mint"))); + location.pathname = Path.mock(Path.cropSearch("mint")); mockIsMobile = false; mockPlantRef.current = { position: { set: mockSetPlantPosition } }; mockXCrosshairRef.current = undefined; @@ -314,7 +314,7 @@ describe("", () => { it("doesn't update pointer plant position: mobile", () => { getModeSpy.mockReturnValue(Mode.clickToAdd); - history.replaceState(undefined, "", Path.mock(Path.cropSearch("mint"))); + location.pathname = Path.mock(Path.cropSearch("mint")); mockIsMobile = true; mockPlantRef.current = { position: { set: mockSetPlantPosition } }; mockXCrosshairRef.current = { position: { set: mockSetXCrosshairPosition } }; @@ -329,7 +329,7 @@ describe("", () => { it("doesn't update pointer point position", () => { getModeSpy.mockReturnValue(Mode.createPoint); - history.replaceState(undefined, "", Path.mock(Path.points("add"))); + location.pathname = Path.mock(Path.points("add")); mockIsMobile = false; mockPlantRef.current = { position: { set: mockSetPlantPosition } }; mockXCrosshairRef.current = { position: { set: mockSetXCrosshairPosition } }; @@ -345,7 +345,7 @@ describe("", () => { it("updates pointer point position", () => { getModeSpy.mockReturnValue(Mode.createPoint); - history.replaceState(undefined, "", Path.mock(Path.points("add"))); + location.pathname = Path.mock(Path.points("add")); mockIsMobile = false; mockPlantRef.current = { position: { set: mockSetPlantPosition } }; mockXCrosshairRef.current = { position: { set: mockSetXCrosshairPosition } }; @@ -367,7 +367,7 @@ describe("", () => { it("updates pointer point radius", () => { getModeSpy.mockReturnValue(Mode.createPoint); - history.replaceState(undefined, "", Path.mock(Path.points("add"))); + location.pathname = Path.mock(Path.points("add")); mockIsMobile = false; mockPlantRef.current = { position: { set: mockSetPlantPosition } }; mockRadiusRef.current = { scale: { set: mockSetRadiusScale } }; @@ -393,7 +393,7 @@ describe("", () => { it("doesn't update pointer point radius: no ref", () => { getModeSpy.mockReturnValue(Mode.createPoint); - history.replaceState(undefined, "", Path.mock(Path.points("add"))); + location.pathname = Path.mock(Path.points("add")); mockIsMobile = false; mockPlantRef.current = { position: { set: mockSetPlantPosition } }; mockRadiusRef.current = undefined; @@ -419,7 +419,7 @@ describe("", () => { it("doesn't update pointer point radius: already set", () => { getModeSpy.mockReturnValue(Mode.createPoint); - history.replaceState(undefined, "", Path.mock(Path.points("add"))); + location.pathname = Path.mock(Path.points("add")); mockIsMobile = false; mockPlantRef.current = { position: { set: mockSetPlantPosition } }; mockRadiusRef.current = { scale: { set: mockSetRadiusScale } }; diff --git a/frontend/three_d_garden/bed/bed.tsx b/frontend/three_d_garden/bed/bed.tsx index 5a6b47bd3b..4f1eda4664 100644 --- a/frontend/three_d_garden/bed/bed.tsx +++ b/frontend/three_d_garden/bed/bed.tsx @@ -1,6 +1,6 @@ import React from "react"; import { - Box, Detailed, Extrude, Plane, useHelper, useTexture, + Box, Detailed, Extrude, Plane, useHelper, } from "@react-three/drei"; import { DoubleSide, @@ -46,6 +46,8 @@ import { VertexNormalsHelper } from "three/examples/jsm/Addons.js"; import { MoistureSurface } from "../garden/moisture_texture"; import { HeightMaterial } from "../garden/height_material"; import { soilSurfaceExtents } from "../triangles"; +import { FocusVisibilityGroup } from "../focus_transition"; +import { useTextureVariant } from "../texture_variants"; const soil = ( Type: typeof LinePath | typeof Shape, @@ -205,22 +207,16 @@ export const Bed = (props: BedProps) => { ]; const casterHeight = legSize * 1.375; - const bedWoodTextureBase = useTexture(ASSETS.textures.wood + "?=bedWood"); - const bedWoodTexture = React.useMemo(() => { - const texture = bedWoodTextureBase.clone(); - texture.wrapS = RepeatWrapping; - texture.wrapT = RepeatWrapping; - texture.repeat.set(0.0003, 0.003); - return texture; - }, [bedWoodTextureBase]); - const legWoodTextureBase = useTexture(ASSETS.textures.wood + "?=legWood"); - const legWoodTexture = React.useMemo(() => { - const texture = legWoodTextureBase.clone(); - texture.wrapS = RepeatWrapping; - texture.wrapT = RepeatWrapping; - texture.repeat.set(0.02, 0.05); - return texture; - }, [legWoodTextureBase]); + const bedWoodTexture = useTextureVariant(ASSETS.textures.wood, { + wrapS: RepeatWrapping, + wrapT: RepeatWrapping, + repeat: [0.0003, 0.003], + }); + const legWoodTexture = useTextureVariant(ASSETS.textures.wood, { + wrapS: RepeatWrapping, + wrapT: RepeatWrapping, + repeat: [0.02, 0.05], + }); // eslint-disable-next-line no-null/no-null const pointerPlantRef: PointerPlantRef = React.useRef(null); @@ -382,7 +378,8 @@ export const Bed = (props: BedProps) => { ]}> - { y: threeSpace(bedWidthOuter, bedWidthOuter), z: groundZ, }} /> - + { gardenCoords: { x: 1360, y: 660 }, })); }); + + it("doesn't create a plant after a drag", () => { + location.pathname = Path.mock(Path.cropSearch("mint")); + mockIsMobile = false; + const p = fakeProps(); + const e = { + stopPropagation: jest.fn(), + point: { x: 1, y: 2 }, + delta: 3, + } as unknown as ThreeEvent; + soilClick(p)(e); + expect(e.stopPropagation).toHaveBeenCalled(); + expect(dropPlantSpy).not.toHaveBeenCalled(); + }); + + it.each([ + ["point", Path.points("add")], + ["weed", Path.weeds("add")], + ])("doesn't set %s location after a drag", (_label, path) => { + location.pathname = Path.mock(path); + mockIsMobile = false; + const p = fakeProps(); + const point = fakeDrawnPoint(); + point.cx = undefined; + point.cy = undefined; + point.r = 0; + p.addPlantProps.designer.drawnPoint = point; + const e = { + stopPropagation: jest.fn(), + point: { x: 1, y: 2 }, + delta: 3, + } as unknown as ThreeEvent; + soilClick(p)(e); + expect(e.stopPropagation).toHaveBeenCalled(); + expect(p.addPlantProps.dispatch).not.toHaveBeenCalled(); + }); }); describe("soilPointerMove()", () => { diff --git a/frontend/three_d_garden/bed/objects/pointer_objects.tsx b/frontend/three_d_garden/bed/objects/pointer_objects.tsx index 2993f9048f..e856b368ee 100644 --- a/frontend/three_d_garden/bed/objects/pointer_objects.tsx +++ b/frontend/three_d_garden/bed/objects/pointer_objects.tsx @@ -41,6 +41,7 @@ import { getPlantIconTexture, getPlantIconTextureUrl, } from "../../garden/plant_icon_atlas"; +import { clickWasDragged } from "../../click_event"; export type PointerPlantRef = React.RefObject; export type RadiusRef = React.RefObject; @@ -183,6 +184,7 @@ export const soilClick = (props: SoilClickProps) => const { config, navigate, addPlantProps, pointerPlantRef } = props; const getGardenPosition = getGardenPositionFunc(config); e.stopPropagation(); + if (clickWasDragged(e)) { return; } if (addPlantProps) { if (getMode() == Mode.clickToAdd) { dropPlant({ diff --git a/frontend/three_d_garden/bed/objects/utilities_post.tsx b/frontend/three_d_garden/bed/objects/utilities_post.tsx index 686ae32759..f4da9248b3 100644 --- a/frontend/three_d_garden/bed/objects/utilities_post.tsx +++ b/frontend/three_d_garden/bed/objects/utilities_post.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Box, Cylinder, RoundedBox, Tube, useTexture } from "@react-three/drei"; +import { Box, Cylinder, RoundedBox, Tube } from "@react-three/drei"; import { RepeatWrapping } from "three"; import { ASSETS } from "../../constants"; import { Config } from "../../config"; @@ -9,6 +9,8 @@ import { import { outletDepth } from "../../bot"; import * as THREE from "three"; import { Group, MeshPhongMaterial } from "../../components"; +import { FocusVisibilityGroup } from "../../focus_transition"; +import { useTextureVariant } from "../../texture_variants"; export interface UtilitiesPostProps { config: Config; @@ -41,17 +43,15 @@ export const UtilitiesPost = (props: UtilitiesPostProps) => { new THREE.Vector3(barbX, barbY, barbZ), ); - const postWoodTextureBase = useTexture(ASSETS.textures.wood + "?=post"); - const postWoodTexture = React.useMemo(() => { - const texture = postWoodTextureBase.clone(); - texture.wrapS = RepeatWrapping; - texture.wrapT = RepeatWrapping; - texture.repeat.set(0.02, 0.05); - return texture; - }, [postWoodTextureBase]); + const postWoodTexture = useTextureVariant(ASSETS.textures.wood, { + wrapS: RepeatWrapping, + wrapT: RepeatWrapping, + repeat: [0.02, 0.05], + }); - return { - ; + ; }; diff --git a/frontend/three_d_garden/bot/__tests__/bot_test.tsx b/frontend/three_d_garden/bot/__tests__/bot_test.tsx index 6f388a1c69..a4d9d85707 100644 --- a/frontend/three_d_garden/bot/__tests__/bot_test.tsx +++ b/frontend/three_d_garden/bot/__tests__/bot_test.tsx @@ -4,6 +4,10 @@ import { Bot, FarmbotModelProps } from "../bot"; import { INITIAL, INITIAL_POSITION } from "../../config"; import { clone } from "lodash"; import { SVGLoader } from "three/examples/jsm/Addons.js"; +import { + createRenderer, + unmountRenderer, +} from "../../../__test_support__/test_renderer"; describe("", () => { afterEach(() => { @@ -65,6 +69,15 @@ describe("", () => { expect(container.querySelectorAll("[name='button-group']").length).toEqual(3); }); + it("hides FarmBot in Planter bed focus", () => { + const p = fakeProps(); + p.activeFocus = "Planter bed"; + const wrapper = createRenderer(); + const bot = wrapper.root.findAll(node => node.props.name == "bot")[0]; + expect(bot.props.visible).toEqual(false); + unmountRenderer(wrapper); + }); + it("renders watering animation", () => { const p = fakeProps(); p.config.waterFlow = true; diff --git a/frontend/three_d_garden/bot/bot.tsx b/frontend/three_d_garden/bot/bot.tsx index 527fee6841..b3d7b68482 100644 --- a/frontend/three_d_garden/bot/bot.tsx +++ b/frontend/three_d_garden/bot/bot.tsx @@ -2,9 +2,11 @@ import React, { useEffect, useState } from "react"; import * as THREE from "three"; import { - Cylinder, Extrude, Trail, Tube, useGLTF, useTexture, + Cylinder, Extrude, Trail, Tube, useGLTF, } from "@react-three/drei"; -import { DoubleSide, Shape, RepeatWrapping } from "three"; +import { + DoubleSide, ExtrudeGeometryOptions, Shape, RepeatWrapping, +} from "three"; import { easyCubicBezierCurve3, get3DPositionNoMirrorFunc, zDir as zDirFunc, @@ -34,6 +36,8 @@ import { } from "./components"; import { SlotWithTool } from "../../resources/interfaces"; import { WateringAnimations } from "./components/watering_animations"; +import { FocusVisibilityGroup } from "../focus_transition"; +import { useTextureVariant } from "../texture_variants"; export const extrusionWidth = 20; const utmRadius = 35; @@ -91,13 +95,12 @@ type XAxisCCMount = GLTF & { materials: never; } -Object.values(ASSETS.models).map(model => useGLTF.preload(model, LIB_DIR)); - export interface FarmbotModelProps { config: Config; configPosition: PositionConfig; activeFocus: string; getZ(x: number, y: number): number; + trailReady?: boolean; toolSlots?: SlotWithTool[]; mountedToolName?: string | undefined; dispatch?: Function; @@ -182,16 +185,13 @@ export const Bot = (props: FarmbotModelProps) => { }); } }); - const aluminumTextureBase = useTexture(ASSETS.textures.aluminum + "?=bot"); - const aluminumTexture = React.useMemo(() => { - const texture = aluminumTextureBase.clone(); - texture.wrapS = RepeatWrapping; - texture.wrapT = RepeatWrapping; - texture.repeat.set(0.01, 0.0003); - return texture; - }, [aluminumTextureBase]); + const aluminumTexture = useTextureVariant(ASSETS.textures.aluminum, { + wrapS: RepeatWrapping, + wrapT: RepeatWrapping, + repeat: [0.01, 0.0003], + }); - const yBeltPath = () => { + const yBeltPath = React.useCallback(() => { const radius = 12; const path = new Shape(); path.moveTo(0, 0); @@ -211,10 +211,15 @@ export const Bot = (props: FarmbotModelProps) => { path.arc(-2, -radius, radius, Math.PI / 2, Math.PI); path.lineTo(-2, 0); return path; - }; + }, [botSizeY, y]); + const yBeltArgs = React.useMemo(() => [ + yBeltPath(), + { steps: 1, depth: 6, bevelEnabled: false }, + ] as [Shape, ExtrudeGeometryOptions], [yBeltPath]); const distanceToSoil = -props.getZ(x, y) - zDir * z; const defaultTrailWidth = config.perspective ? 500 : 0.1; + const trailReady = props.trailReady !== false; const airTubeEndPosition = (kitVersion: string): [number, number, number] => { switch (kitVersion) { @@ -250,8 +255,22 @@ export const Bot = (props: FarmbotModelProps) => { ...gardenXY(x + cameraMountOffset.x, y + cameraMountOffset.y), zZero - zDir * z - 140 + zGantryOffset + 20, ); + const utmComponent = + + ; - return {[0 - extrusionWidth, bedWidthOuter].map((y, index) => { const bedColumnYOffset = @@ -566,29 +585,19 @@ export const Bot = (props: FarmbotModelProps) => { configPosition={props.configPosition} cameraMountPosition={cameraMountPosition} distanceToSoil={distanceToSoil} /> - Math.pow(t, 3)} - color={"red"} - length={100} - decay={0.5} - local={false} - stride={0} - interval={1}> - - - - + {trailReady && trail + ? Math.pow(t, 3)} + color={"red"} + length={100} + decay={0.5} + local={false} + stride={0} + interval={1}> + {utmComponent} + + : utmComponent} { { - ; + ; }; diff --git a/frontend/three_d_garden/bot/components/cable_carriers.tsx b/frontend/three_d_garden/bot/components/cable_carriers.tsx index f7639b5264..52346b33d0 100644 --- a/frontend/three_d_garden/bot/components/cable_carriers.tsx +++ b/frontend/three_d_garden/bot/components/cable_carriers.tsx @@ -71,15 +71,16 @@ export const CableCarrierX = (props: CableCarrierXProps) => { x: botSizeX / 2, y: (tracks ? 0 : extrusionWidth) - 15 - bedYOffset, }); + const args = React.useMemo(() => [ + ccPath( + botSizeX / 2, botSizeX / 2 - x + 20, + bedCCSupportHeight - 40, + true), + { steps: 1, depth: 22, bevelEnabled: false }, + ] as [Shape, THREE.ExtrudeGeometryOptions], [bedCCSupportHeight, botSizeX, x]); return { const position = get3DPosition({ x: x - 28, y: 20 }); return [position.x, position.y, columnLength + 150]; }; + const args = React.useMemo(() => [ + ccPath(botSizeY, y + 40, 70), + { steps: 1, depth: ccDepth(kitVersion), bevelEnabled: false }, + ] as [Shape, THREE.ExtrudeGeometryOptions], [botSizeY, kitVersion, y]); return @@ -140,12 +142,13 @@ export const CableCarrierZ = (props: CableCarrierZProps) => { const zDir = zDirFunc(props.config); const get3DPosition = get3DPositionNoMirrorFunc(props.config); const position = get3DPosition({ x: x - 41, y: y - 25 }); + const args = React.useMemo(() => [ + ccPath(botSizeZ + zGantryOffset - 100, zDir * z + zGantryOffset - 15, 87), + { steps: 1, depth: 60, bevelEnabled: false }, + ] as [Shape, THREE.ExtrudeGeometryOptions], [botSizeZ, z, zDir, zGantryOffset]); return range((zAxisLength - 350) / 200), [zAxisLength]); const verticalRef = React.useRef(undefined); + const verticalGeometry = React.useMemo(() => { + const shape = new THREE.Shape(); + shape.moveTo(0, 0); + shape.lineTo(0, 20); + shape.lineTo(15, 20); + shape.lineTo(20, 1.5); + shape.lineTo(28.5, 1.5); + shape.lineTo(28.5, -61); + shape.lineTo(24, -63); + shape.lineTo(24, -61.5); + shape.lineTo(27, -60); + shape.lineTo(27, 0); + shape.lineTo(0, 0); + return new THREE.ExtrudeGeometry(shape, { + depth: zAxisLength - 350, + bevelEnabled: false, + }); + }, [zAxisLength]); + React.useEffect(() => () => verticalGeometry.dispose(), [verticalGeometry]); React.useEffect(() => { if (!verticalRef.current || verticalInstances.length === 0) { return; } const temp = new THREE.Object3D(); @@ -222,27 +244,7 @@ export const CableCarrierSupportVertical = { - const shape = new THREE.Shape(); - shape.moveTo(0, 0); - shape.lineTo(0, 20); - shape.lineTo(15, 20); - shape.lineTo(20, 1.5); - shape.lineTo(28.5, 1.5); - shape.lineTo(28.5, -61); - shape.lineTo(24, -63); - shape.lineTo(24, -61.5); - shape.lineTo(27, -60); - shape.lineTo(27, 0); - shape.lineTo(0, 0); - return shape; - })(), - { - depth: zAxisLength - 350, - bevelEnabled: false, - }, - )}> + geometry={verticalGeometry}> @@ -268,6 +270,23 @@ export const CableCarrierSupportHorizontal = const horizontalInstances = React.useMemo(() => range((botSizeY - 10) / 300), [botSizeY]); const horizontalRef = React.useRef(undefined); + const horizontalGeometry = React.useMemo(() => { + const shape = new THREE.Shape(); + shape.moveTo(0, 0); + shape.lineTo(0, 20); + shape.lineTo(-40, 20); + shape.lineTo(-41, 22.5); + shape.lineTo(-42.5, 22.5); + shape.lineTo(-41.5, 18.5); + shape.lineTo(-30, 18.5); + shape.lineTo(-25, 0); + shape.lineTo(0, 0); + return new THREE.ExtrudeGeometry(shape, { + depth: botSizeY - 30, + bevelEnabled: false, + }); + }, [botSizeY]); + React.useEffect(() => () => horizontalGeometry.dispose(), [horizontalGeometry]); React.useEffect(() => { if (!horizontalRef.current || horizontalInstances.length === 0) { return; } const temp = new THREE.Object3D(); @@ -313,26 +332,7 @@ export const CableCarrierSupportHorizontal = columnLength + 60, ]} rotation={[Math.PI / 2, 0, 0]} - geometry={new THREE.ExtrudeGeometry( - (() => { - const shape = new THREE.Shape(); - - shape.moveTo(0, 0); - shape.lineTo(0, 20); - shape.lineTo(-40, 20); - shape.lineTo(-41, 22.5); - shape.lineTo(-42.5, 22.5); - shape.lineTo(-41.5, 18.5); - shape.lineTo(-30, 18.5); - shape.lineTo(-25, 0); - shape.lineTo(0, 0); - return shape; - })(), - { - depth: botSizeY - 30, - bevelEnabled: false, - }, - )}> + geometry={horizontalGeometry}> ", () => { const Component = CrossSlide(model); const { container } = render(); expect(container.innerHTML).toContain("name"); - expect(container.innerHTML).toContain("instancedmesh"); + expect(container.querySelector("mesh, instancedmesh")).toBeTruthy(); }); }); diff --git a/frontend/three_d_garden/bot/parts/__tests__/gantry_wheel_plate_test.tsx b/frontend/three_d_garden/bot/parts/__tests__/gantry_wheel_plate_test.tsx index 1459198c1b..34cd164b0c 100644 --- a/frontend/three_d_garden/bot/parts/__tests__/gantry_wheel_plate_test.tsx +++ b/frontend/three_d_garden/bot/parts/__tests__/gantry_wheel_plate_test.tsx @@ -10,6 +10,6 @@ describe("", () => { const Component = GantryWheelPlate(model); const { container } = render(); expect(container.innerHTML).toContain("name"); - expect(container.innerHTML).toContain("instancedmesh"); + expect(container.querySelector("mesh, instancedmesh")).toBeTruthy(); }); }); diff --git a/frontend/three_d_garden/bot/parts/__tests__/merged_instanced_geometry_test.ts b/frontend/three_d_garden/bot/parts/__tests__/merged_instanced_geometry_test.ts new file mode 100644 index 0000000000..586c69b48c --- /dev/null +++ b/frontend/three_d_garden/bot/parts/__tests__/merged_instanced_geometry_test.ts @@ -0,0 +1,45 @@ +import * as THREE from "three"; +import { mergedInstancedGeometry } from "../merged_instanced_geometry"; + +describe("mergedInstancedGeometry", () => { + it("bakes instanced matrices into one geometry", () => { + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute( + new Float32Array([ + 0, 0, 0, + 1, 0, 0, + 0, 1, 0, + ]), + 3, + )); + const matrices = new Float32Array(32); + new THREE.Matrix4().identity().toArray(matrices, 0); + new THREE.Matrix4().makeTranslation(10, 0, 0).toArray(matrices, 16); + const model = { + nodes: { + mesh0_mesh: { + geometry, + instanceMatrix: new THREE.InstancedBufferAttribute(matrices, 16), + } as THREE.Mesh & { instanceMatrix: THREE.InstancedBufferAttribute }, + ignored: { geometry } as THREE.Mesh, + }, + }; + + const merged = mergedInstancedGeometry(model, /^mesh/); + + expect(merged?.getAttribute("position").count).toEqual(6); + const positions = merged?.getAttribute("position").array; + expect(Array.from(positions || []).slice(9, 12)).toEqual([10, 0, 0]); + expect(mergedInstancedGeometry(model, /^mesh/)).toBe(merged); + }); + + it("returns undefined without matching instanced nodes", () => { + const model = { + nodes: { + other: { geometry: new THREE.BufferGeometry() } as THREE.Mesh, + }, + }; + + expect(mergedInstancedGeometry(model, /^mesh/)).toBeUndefined(); + }); +}); diff --git a/frontend/three_d_garden/bot/parts/cross_slide.tsx b/frontend/three_d_garden/bot/parts/cross_slide.tsx index c648a3a657..fea0f75970 100644 --- a/frontend/three_d_garden/bot/parts/cross_slide.tsx +++ b/frontend/three_d_garden/bot/parts/cross_slide.tsx @@ -5,6 +5,7 @@ import { InstancedBufferAttribute } from "three"; import type { GLTF } from "three-stdlib"; import { Group, Mesh as MeshComponent, InstancedMesh } from "../../components"; import { ThreeElements } from "@react-three/fiber"; +import { mergedInstancedGeometry } from "./merged_instanced_geometry"; type Mesh = THREE.Mesh & { instanceMatrix: InstancedBufferAttribute | undefined }; @@ -153,6 +154,19 @@ interface CrossSlideProps extends Omit { export const CrossSlideModel = (props: CrossSlideProps) => { const { model, ...groupProps } = props; const { nodes, materials } = model; + const mergedGeometry = mergedInstancedGeometry(model, /^mesh/); + if (mergedGeometry) { + return + + + ; + } return (props: Omit) => { const { nodes, materials } = model; + const mergedGeometry = mergedInstancedGeometry(model, /^mesh/); + if (mergedGeometry) { + return + + + ; + } return ; +}; + +const geometryCache = new WeakMap(); + +export const mergedInstancedGeometry = ( + model: InstancedModel, + keyPattern: RegExp, +) => { + const cached = geometryCache.get(model); + if (cached) { return cached; } + const matrix = new THREE.Matrix4(); + const geometries: THREE.BufferGeometry[] = []; + Object.entries(model.nodes) + .filter(([key, node]) => keyPattern.test(key) && node.instanceMatrix) + .forEach(([, node]) => { + const instanceMatrix = node.instanceMatrix; + if (!instanceMatrix) { return; } + for (let i = 0; i < instanceMatrix.count; i++) { + matrix.fromArray(instanceMatrix.array, i * instanceMatrix.itemSize); + const geometry = node.geometry.clone(); + geometry.applyMatrix4(matrix); + geometries.push(geometry); + } + }); + if (geometries.length == 0) { return undefined; } + const merged = mergeGeometries(geometries, false); + geometries.forEach(geometry => geometry.dispose()); + if (!merged) { return undefined; } + geometryCache.set(model, merged); + return merged; +}; diff --git a/frontend/three_d_garden/bot/power_supply.tsx b/frontend/three_d_garden/bot/power_supply.tsx index 9813f4809c..855af14bec 100644 --- a/frontend/three_d_garden/bot/power_supply.tsx +++ b/frontend/three_d_garden/bot/power_supply.tsx @@ -2,11 +2,12 @@ import React from "react"; import { RepeatWrapping } from "three"; import * as THREE from "three"; -import { Box, Tube, useTexture } from "@react-three/drei"; +import { Box, Tube } from "@react-three/drei"; import { ASSETS } from "../constants"; import { threeSpace, easyCubicBezierCurve3 } from "../helpers"; import { Config } from "../config"; import { Group, MeshPhongMaterial } from "../components"; +import { useTextureVariant } from "../texture_variants"; export interface PowerSupplyProps { config: Config; @@ -30,14 +31,11 @@ export const PowerSupply = (props: PowerSupplyProps) => { } = props.config; const zGround = -bedHeight - bedZOffset; - const powerSupplyTextureBase = useTexture(ASSETS.textures.aluminum + "?=powerSupply"); - const powerSupplyTexture = React.useMemo(() => { - const texture = powerSupplyTextureBase.clone(); - texture.wrapS = RepeatWrapping; - texture.wrapT = RepeatWrapping; - texture.repeat.set(0.01, 0.003); - return texture; - }, [powerSupplyTextureBase]); + const powerSupplyTexture = useTextureVariant(ASSETS.textures.aluminum, { + wrapS: RepeatWrapping, + wrapT: RepeatWrapping, + repeat: [0.01, 0.003], + }); const combinedCablePath = new THREE.CurvePath(); diff --git a/frontend/three_d_garden/click_event.ts b/frontend/three_d_garden/click_event.ts new file mode 100644 index 0000000000..2abec2af7a --- /dev/null +++ b/frontend/three_d_garden/click_event.ts @@ -0,0 +1,7 @@ +import type { ThreeEvent } from "@react-three/fiber"; + +export const MAX_POINTER_CLICK_DELTA = 1; + +export const clickWasDragged = ( + event: Pick, "delta">, +) => (event.delta || 0) > MAX_POINTER_CLICK_DELTA; diff --git a/frontend/three_d_garden/config.ts b/frontend/three_d_garden/config.ts index 84b538c40d..e403c67819 100644 --- a/frontend/three_d_garden/config.ts +++ b/frontend/three_d_garden/config.ts @@ -158,7 +158,7 @@ export const INITIAL: ConfigWithPosition = { showSoilPoints: false, plants: "Spring", labels: false, - labelsOnHover: false, + labelsOnHover: true, ground: true, grid: true, axes: false, @@ -578,10 +578,10 @@ type SeasonProperties = { cloudOpacity: number; }; const SEASON_PROPERTIES: Record = { - Winter: { sunIntensity: 4 / 4, sunColor: "#A0C4FF", cloudOpacity: 0.85 }, - Spring: { sunIntensity: 7 / 4, sunColor: "#BDE0FE", cloudOpacity: 0.2 }, - Summer: { sunIntensity: 9 / 4, sunColor: "#FFFFFF", cloudOpacity: 0 }, - Fall: { sunIntensity: 5.5 / 4, sunColor: "#FFD6BC", cloudOpacity: 0.3 }, + Winter: { sunIntensity: 4, sunColor: "#A0C4FF", cloudOpacity: 0.75 }, + Spring: { sunIntensity: 7, sunColor: "#BDE0FE", cloudOpacity: 0.2 }, + Summer: { sunIntensity: 9, sunColor: "#FFFFFF", cloudOpacity: 0 }, + Fall: { sunIntensity: 5.5, sunColor: "#FFD6BC", cloudOpacity: 0.3 }, }; export const getSeasonProperties = ( config: Config, diff --git a/frontend/three_d_garden/config_overlays.tsx b/frontend/three_d_garden/config_overlays.tsx index 9c19cfd961..6d373ed908 100644 --- a/frontend/three_d_garden/config_overlays.tsx +++ b/frontend/three_d_garden/config_overlays.tsx @@ -2,6 +2,7 @@ import React from "react"; import { ConfigWithPosition, modifyConfig } from "./config"; import { setUrlParam } from "./zoom_beacons_constants"; import { ExternalUrl } from "../external_urls"; +import { FocusVisibilityDiv } from "./focus_transition"; export interface ToolTip { timeoutId: number; @@ -15,6 +16,7 @@ export interface OverlayProps { setToolTip(tooltip: ToolTip): void; activeFocus: string; setActiveFocus(focus: string): void; + loadComplete?: boolean; startTimeRef?: React.RefObject; } @@ -74,10 +76,16 @@ const PublicOverlaySection = (props: SectionProps) => { export const PublicOverlay = (props: OverlayProps) => { const { config, setConfig, toolTip, setToolTip } = props; const commonSectionProps = { config, setConfig, toolTip, setToolTip }; + const settingsBarClassName = [ + "settings-bar", + props.loadComplete ? "settings-bar-loaded" : "", + ].join(" "); return
- {config.settingsBar && !props.activeFocus && -
+ +
{ "lab": "Lab", "greenhouse": "Greenhouse", }} /> -
} - {config.promoInfo && !props.activeFocus && +
+ + } + kitVersion={config.kitVersion} /> +
; }; @@ -129,7 +141,7 @@ interface PromoInfoProps { const PromoInfo = (props: PromoInfoProps) => { const { isGenesis, kitVersion } = props; - return
+ return

Explore our models

{isGenesis ?
@@ -157,29 +169,35 @@ const PromoInfo = (props: PromoInfoProps) => { universities, and commercial research facilities.

} - -

Order Genesis

-

- XL -

-

{kitVersion}

-
-
; + + ; }; interface ConfigRowProps { configKey: keyof ConfigWithPosition; children: React.ReactNode; addLabel?: string; + searchTerms?: string[]; } +const ConfigSearchContext = React.createContext(""); + const ConfigRow = (props: ConfigRowProps) => { const { configKey } = props; + const search = React.useContext(ConfigSearchContext).trim().toLowerCase(); const urlHasParam = (key: keyof ConfigWithPosition) => !!(new URLSearchParams(window.location.search)).get(key); const removeParam = () => { @@ -195,6 +213,10 @@ const ConfigRow = (props: ConfigRowProps) => { if (props.addLabel) { label += ` (${props.addLabel})`; } + const searchText = [label, ...(props.searchTerms || [])] + .join(" ") + .toLowerCase(); + if (search && !searchText.includes(search)) { return ; } return
{hasParam &&

x

} {label} @@ -275,7 +297,10 @@ const Radio = (props: RadioProps) => { setConfig(modifyConfig(config, update)); maybeAddParam(config.urlParamAutoAdd, configKey, "" + newValue); }; - return + return
{options.map(value =>
@@ -297,145 +322,183 @@ export const PrivateOverlay = (props: OverlayProps) => { const bedMin = props.config.bedWallThickness * 2; const { config, setConfig } = props; const common = { ...props }; - return
-
- - {"Configs"} -

setConfig(modifyConfig(config, { config: false }))}> - X -

-
-
+ const [search, setSearch] = React.useState(""); + // eslint-disable-next-line no-null/no-null + const searchInputRef = React.useRef(null); + const closeConfig = React.useCallback(() => + setConfig(modifyConfig(config, { config: false })), [config, setConfig]); + React.useEffect(() => { + searchInputRef.current?.focus(); + }, []); + return
e.key == "Escape" && closeConfig()}> +
+ {"Configs"} +

+ X +

+
+
+ setSearch(e.target.value)} + /> + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+ + + + + + +
+
+ + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + + +
+
+ + + + + + + + + + + + +
+
+ + + + + + + + + + +
+
+ + + + + + + +
+
+ + + + + + + + + +
+
+ + + + + + + + + + + +
+
+ + + + + + + +
+
+ + + + + + + + +
+
; }; diff --git a/frontend/three_d_garden/focus_transition.tsx b/frontend/three_d_garden/focus_transition.tsx new file mode 100644 index 0000000000..9504176d93 --- /dev/null +++ b/frontend/three_d_garden/focus_transition.tsx @@ -0,0 +1,596 @@ +import React from "react"; +import { useSpring } from "@react-spring/three"; +import { Material, Object3D } from "three"; +import { Group } from "./components"; +import { Camera, VectorXyz } from "./zoom_beacons_constants"; + +export const FOCUS_TRANSITION_MS = 900; + +export const easeInOutCubic = (t: number) => + t < 0.5 + ? 4 * t * t * t + : 1 - Math.pow(-2 * t + 2, 3) / 2; + +interface FocusTransitionContextValue { + enabled: boolean; + duration: number; +} + +const FocusTransitionContext = + React.createContext({ + enabled: false, + duration: FOCUS_TRANSITION_MS, + }); + +export interface FocusTransitionProviderProps { + enabled: boolean; + duration?: number; + children: React.ReactNode; +} + +export const FocusTransitionProvider = + (props: FocusTransitionProviderProps) => { + const parent = React.useContext(FocusTransitionContext); + const value = React.useMemo(() => ({ + enabled: props.enabled, + duration: props.duration || parent.duration || FOCUS_TRANSITION_MS, + }), [parent.duration, props.duration, props.enabled]); + return + {props.children} + ; + }; + +export const useFocusTransition = () => + React.useContext(FocusTransitionContext); + +interface MaterialState { + opacity: number; + transparent: boolean; + depthWrite: boolean; +} + +type MaterialSlot = Material | Material[]; + +interface MaterialOwner extends Object3D { + material?: MaterialSlot; +} + +interface MaterialRecord { + owner: MaterialOwner; + original: MaterialSlot; + clones: MaterialSlot; + states: MaterialState[]; +} + +const isMaterial = (value: unknown): value is Material => + !!value + && typeof value == "object" + && typeof (value as Material).clone == "function"; + +const materialState = (material: Material): MaterialState => ({ + opacity: material.opacity, + transparent: material.transparent, + depthWrite: material.depthWrite, +}); + +const cloneMaterial = (material: Material) => { + const clone = material.clone(); + clone.onBeforeCompile = material.onBeforeCompile; + return clone; +}; + +const cloneSlot = (slot: MaterialSlot): { + clones: MaterialSlot; + states: MaterialState[]; +} | undefined => { + if (Array.isArray(slot)) { + const clones = slot.map(cloneMaterial); + return { clones, states: clones.map(materialState) }; + } + if (!isMaterial(slot)) { return undefined; } + const clone = cloneMaterial(slot); + return { clones: clone, states: [materialState(clone)] }; +}; + +const forEachMaterial = + (slot: MaterialSlot, callback: (material: Material, index: number) => void) => { + if (Array.isArray(slot)) { + slot.map(callback); + } else if (isMaterial(slot)) { + callback(slot, 0); + } + }; + +export const applyFocusMaterialOpacity = ( + material: Material, + state: MaterialState, + opacity: number, + preserveDepthWrite = false, +) => { + material.opacity = state.opacity * opacity; + material.transparent = state.transparent || opacity < 1; + material.depthWrite = preserveDepthWrite || opacity >= 1 + ? state.depthWrite + : false; + material.needsUpdate = true; +}; + +interface CreateFocusMaterialBindingOptions { + preserveDepthWrite?: boolean; +} + +export const createFocusMaterialBinding = ( + root: Object3D, + options: CreateFocusMaterialBindingOptions = {}, +) => { + const records: MaterialRecord[] = []; + root.traverse(child => { + const owner = child as MaterialOwner; + if (!owner.material) { return; } + const clone = cloneSlot(owner.material); + if (!clone) { return; } + records.push({ + owner, + original: owner.material, + clones: clone.clones, + states: clone.states, + }); + owner.material = clone.clones; + }); + + return { + apply: (opacity: number) => { + records.map(record => + forEachMaterial(record.clones, (material, index) => + applyFocusMaterialOpacity( + material, + record.states[index], + opacity, + !!options.preserveDepthWrite, + ))); + }, + restore: () => { + records.map(record => { + record.owner.material = record.original; + forEachMaterial(record.clones, material => material.dispose()); + }); + }, + }; +}; + +const canTraverse = (value: unknown): value is Object3D => + !!value + && typeof value == "object" + && typeof (value as Object3D).traverse == "function"; + +const setVector = ( + vector: { set(x: number, y: number, z: number): void }, + values: VectorXyz, +) => vector.set(values[0], values[1], values[2]); + +export type FocusVisibilityGroupProps = + React.ComponentProps & { + visible: boolean; + keepMounted?: boolean; + materialBindingKey?: React.Key; + preserveDepthWrite?: boolean; + }; + +export const shouldUnmountFocusVisibilityGroup = ( + rendered: boolean, + visible: boolean, + keepMounted?: boolean, +) => !rendered && !visible && !keepMounted; + +export const FocusVisibilityGroup = + React.forwardRef((props, forwardedRef) => { + const { + visible, keepMounted, materialBindingKey, preserveDepthWrite, children, + ...groupProps + } = props; + const transition = useFocusTransition(); + const enabled = transition.enabled; + const [rendered, setRendered] = React.useState(visible || !!keepMounted); + const [groupVisible, setGroupVisible] = React.useState(visible); + const groupRef = React.useRef(undefined); + const opacityRef = React.useRef(visible ? 1 : 0); + const materialBinding = React.useRef | undefined>(undefined); + + const restoreMaterialBinding = React.useCallback(() => { + materialBinding.current?.restore(); + materialBinding.current = undefined; + }, []); + + const refreshMaterialBinding = React.useCallback(() => { + if (!enabled || !canTraverse(groupRef.current)) { return; } + restoreMaterialBinding(); + materialBinding.current = createFocusMaterialBinding(groupRef.current, { + preserveDepthWrite, + }); + materialBinding.current.apply(opacityRef.current); + }, [enabled, preserveDepthWrite, restoreMaterialBinding]); + + const setRef = React.useCallback((value: Object3D | null) => { + if (!value) { + groupRef.current = undefined; + restoreMaterialBinding(); + return; + } + groupRef.current = value; + if (enabled && canTraverse(value)) { + materialBinding.current ||= createFocusMaterialBinding(value, { + preserveDepthWrite, + }); + materialBinding.current.apply(opacityRef.current); + } + if (typeof forwardedRef == "function") { + forwardedRef(value); + } else if (forwardedRef) { + forwardedRef.current = value; + } + }, [enabled, forwardedRef, preserveDepthWrite, restoreMaterialBinding]); + + const applyOpacity = React.useCallback((opacity: number) => { + opacityRef.current = opacity; + if (!enabled || !canTraverse(groupRef.current)) { return; } + materialBinding.current ||= createFocusMaterialBinding(groupRef.current, { + preserveDepthWrite, + }); + materialBinding.current.apply(opacity); + }, [enabled, preserveDepthWrite]); + + React.useEffect(() => { + if (enabled && visible) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setRendered(true); + // eslint-disable-next-line react-hooks/set-state-in-effect + setGroupVisible(true); + } + }, [enabled, visible]); + + React.useEffect(() => { + if (materialBindingKey === undefined) { return; } + refreshMaterialBinding(); + }, [materialBindingKey, refreshMaterialBinding]); + + useSpring({ + opacity: visible ? 1 : 0, + immediate: !enabled, + config: { + duration: transition.duration, + easing: easeInOutCubic, + }, + onChange: result => { + const value = result.value as { opacity?: number }; + applyOpacity(value.opacity ?? (visible ? 1 : 0)); + }, + onRest: () => { + applyOpacity(visible ? 1 : 0); + if (enabled && !visible) { + if (keepMounted) { + setGroupVisible(false); + } else { + setRendered(false); + } + } + }, + }); + + React.useEffect(() => restoreMaterialBinding, [restoreMaterialBinding]); + + if (!enabled) { + return + {children} + ; + } + + if (shouldUnmountFocusVisibilityGroup(rendered, visible, keepMounted)) { + return undefined; + } + + return + {children} + ; + }); + +export const useFocusTransitionMount = (visible: boolean) => { + const transition = useFocusTransition(); + const [mounted, setMounted] = React.useState(visible); + + React.useEffect(() => { + if (transition.enabled && visible) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setMounted(true); + return; + } + if (!transition.enabled) { return; } + const timeout = window.setTimeout(() => + setMounted(false), transition.duration); + return () => window.clearTimeout(timeout); + }, [transition.duration, transition.enabled, visible]); + + return { + ...transition, + mounted: transition.enabled ? mounted || visible : visible, + }; +}; + +export const useFocusVisibilityClass = (visible: boolean) => { + const transition = useFocusTransitionMount(visible); + const [shown, setShown] = React.useState(false); + + React.useEffect(() => { + if (!transition.enabled) { return; } + if (!transition.mounted) { return; } + if (!visible) { + const frame = window.requestAnimationFrame(() => setShown(false)); + return () => window.cancelAnimationFrame(frame); + } + let secondFrame = 0; + const firstFrame = window.requestAnimationFrame(() => { + secondFrame = window.requestAnimationFrame(() => setShown(true)); + }); + return () => { + window.cancelAnimationFrame(firstFrame); + window.cancelAnimationFrame(secondFrame); + }; + }, [ + transition.enabled, + transition.mounted, + visible, + ]); + + const visibleClass = transition.enabled + ? visible && shown + : visible; + return { + ...transition, + className: visibleClass + ? "focus-transition-visible" + : "focus-transition-hidden", + }; +}; + +export interface FocusVisibilityDivProps + extends React.HTMLAttributes { + visible: boolean; + className: string; + children: React.ReactNode; +} + +export const FocusVisibilityDiv = (props: FocusVisibilityDivProps) => { + const { + visible, className: incomingClassName, children, ...divProps + } = props; + const transition = useFocusVisibilityClass(visible); + + if (!transition.mounted) { return undefined; } + const className = [ + incomingClassName, + "focus-transition-dom", + transition.className, + ].join(" "); + return
+ {children} +
; +}; + +export interface SmoothCameraState extends Camera { + zoom: number; +} + +const vectorFromSpring = (value: unknown, fallback: VectorXyz): VectorXyz => + Array.isArray(value) && value.length == 3 + ? [Number(value[0]), Number(value[1]), Number(value[2])] + : fallback; + +export const cameraTransitionValue = ( + value: Partial>, + fallback: SmoothCameraState, +): SmoothCameraState => ({ + position: vectorFromSpring(value.position, fallback.position), + target: vectorFromSpring(value.target, fallback.target), + zoom: typeof value.zoom == "number" ? value.zoom : fallback.zoom, +}); + +export const interpolateCameraState = ( + from: SmoothCameraState, + to: SmoothCameraState, + progress: number, +): SmoothCameraState => { + const lerp = (start: number, end: number) => + start + (end - start) * progress; + return { + position: [ + lerp(from.position[0], to.position[0]), + lerp(from.position[1], to.position[1]), + lerp(from.position[2], to.position[2]), + ], + target: [ + lerp(from.target[0], to.target[0]), + lerp(from.target[1], to.target[1]), + lerp(from.target[2], to.target[2]), + ], + zoom: lerp(from.zoom, to.zoom), + }; +}; + +const cameraKey = (state: SmoothCameraState) => + [ + ...state.position, + ...state.target, + state.zoom, + ].join(","); + +export interface SmoothCameraObject { + position: { + x?: number; + y?: number; + z?: number; + set(x: number, y: number, z: number): void; + toArray?(): number[]; + }; + zoom: number; + lookAt?(x: number, y: number, z: number): void; + updateProjectionMatrix?(): void; +} + +export interface SmoothCameraControls { + target: { + x?: number; + y?: number; + z?: number; + set(x: number, y: number, z: number): void; + toArray?(): number[]; + }; + update?(): void; +} + +const readVector = ( + vector: SmoothCameraObject["position"] | SmoothCameraControls["target"] | undefined, + fallback: VectorXyz, +): VectorXyz => { + const values = vector?.toArray?.(); + if (values && values.length >= 3) { + return [values[0], values[1], values[2]]; + } + if ( + typeof vector?.x == "number" + && typeof vector.y == "number" + && typeof vector.z == "number" + ) { + return [vector.x, vector.y, vector.z]; + } + return fallback; +}; + +export const readSmoothCameraState = ( + fallback: SmoothCameraState, + cameraObject?: SmoothCameraObject | null, + controls?: SmoothCameraControls | null, +): SmoothCameraState => ({ + position: readVector(cameraObject?.position, fallback.position), + target: readVector(controls?.target, fallback.target), + zoom: typeof cameraObject?.zoom == "number" + ? cameraObject.zoom + : fallback.zoom, +}); + +export const applySmoothCameraState = ( + state: SmoothCameraState, + cameraObject?: SmoothCameraObject | null, + controls?: SmoothCameraControls | null, +) => { + if (cameraObject && typeof cameraObject.position?.set == "function") { + setVector(cameraObject.position, state.position); + cameraObject.zoom = state.zoom; + cameraObject.updateProjectionMatrix?.(); + cameraObject.lookAt?.(...state.target); + } + if (controls && typeof controls.target?.set == "function") { + setVector(controls.target, state.target); + controls.update?.(); + } +}; + +export interface UseSmoothCameraProps { + camera: Camera; + zoom: number; + enabled: boolean; + cameraObject?: SmoothCameraObject | null; + controls?: SmoothCameraControls | null; +} + +export const useSmoothCamera = (props: UseSmoothCameraProps) => { + const transition = useFocusTransition(); + const [positionX, positionY, positionZ] = props.camera.position; + const [targetX, targetY, targetZ] = props.camera.target; + const target = React.useMemo(() => ({ + position: [positionX, positionY, positionZ], + target: [targetX, targetY, targetZ], + zoom: props.zoom, + }), [ + positionX, + positionY, + positionZ, + props.zoom, + targetX, + targetY, + targetZ, + ]); + const [displayCamera, setDisplayCamera] = React.useState(target); + const displayRef = React.useRef(displayCamera); + const key = cameraKey(target); + + React.useEffect(() => { + displayRef.current = displayCamera; + }, [displayCamera]); + + const appliedCamera = props.enabled ? displayCamera : target; + React.useLayoutEffect(() => { + applySmoothCameraState( + appliedCamera, + props.cameraObject, + props.controls, + ); + }, [ + appliedCamera, + props.cameraObject, + props.controls, + ]); + + React.useEffect(() => { + if (!props.enabled) { + displayRef.current = target; + applySmoothCameraState( + target, + props.cameraObject, + props.controls, + ); + return; + } + const from = readSmoothCameraState( + displayRef.current, + props.cameraObject, + props.controls, + ); + const startedAt = performance.now(); + let frame = 0; + const tick = () => { + const elapsed = performance.now() - startedAt; + const progress = Math.min(elapsed / transition.duration, 1); + const next = interpolateCameraState( + from, + target, + easeInOutCubic(progress), + ); + displayRef.current = next; + setDisplayCamera(next); + applySmoothCameraState(next, props.cameraObject, props.controls); + if (progress < 1) { + frame = window.requestAnimationFrame(tick); + } else { + displayRef.current = target; + setDisplayCamera(target); + applySmoothCameraState( + target, + props.cameraObject, + props.controls, + ); + } + }; + frame = window.requestAnimationFrame(tick); + return () => window.cancelAnimationFrame(frame); + }, [ + key, + props.cameraObject, + props.controls, + props.enabled, + target, + transition.duration, + ]); + + return props.enabled ? displayCamera : target; +}; diff --git a/frontend/three_d_garden/fps_probe.tsx b/frontend/three_d_garden/fps_probe.tsx index 5c1c0de2f7..b25d7bb933 100644 --- a/frontend/three_d_garden/fps_probe.tsx +++ b/frontend/three_d_garden/fps_probe.tsx @@ -1,5 +1,6 @@ import React from "react"; import { useFrame, useThree } from "@react-three/fiber"; +import { perfSample } from "../performance/perf"; type SceneObject = { isMesh?: boolean; @@ -86,6 +87,8 @@ export const FPSProbe = () => { const { geometries, textures } = gl.info.memory; const sceneCounts = countSceneObjects(scene); window.__fps = fps; + perfSample("fps", fps); + perfSample("frame_ms", 1000 / fps); const linesToLogObj: Record = { epoch: Date.now(), FPS: fps.toFixed(2), diff --git a/frontend/three_d_garden/garden/__tests__/clouds_test.tsx b/frontend/three_d_garden/garden/__tests__/clouds_test.tsx index d2884a48c1..dde827c525 100644 --- a/frontend/three_d_garden/garden/__tests__/clouds_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/clouds_test.tsx @@ -13,4 +13,11 @@ describe("", () => { const { container } = render(); expect(container).toContainHTML("clouds"); }); + + it("doesn't mount zero-opacity clouds", () => { + const p = fakeProps(); + p.config.plants = "Summer"; + const { container } = render(); + expect(container).not.toContainHTML("clouds"); + }); }); diff --git a/frontend/three_d_garden/garden/__tests__/grid_test.tsx b/frontend/three_d_garden/garden/__tests__/grid_test.tsx index 4b6556407f..9064c63628 100644 --- a/frontend/three_d_garden/garden/__tests__/grid_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/grid_test.tsx @@ -1,8 +1,13 @@ import React from "react"; import { render } from "@testing-library/react"; import { Grid, gridLineOffsets, GridProps } from "../grid"; -import { INITIAL } from "../../config"; +import { INITIAL, PRESETS } from "../../config"; import { clone } from "lodash"; +import { + actRenderer, + createRenderer, + unmountRenderer, +} from "../../../__test_support__/test_renderer"; describe("gridLineOffsets()", () => { it("calculates offsets", () => { @@ -25,4 +30,20 @@ describe("", () => { const { container } = render(); expect(container).toContainHTML("grid"); }); + + it("refreshes focus material binding when grid dimensions change", () => { + const p = fakeProps(); + p.config.grid = true; + const wrapper = createRenderer(); + const findGridGroup = () => wrapper.root.findAll(node => + node.props.name == "garden-grid")[0]; + const genesisBindingKey = findGridGroup().props.materialBindingKey; + p.config = { ...p.config, ...PRESETS["Genesis XL"] }; + actRenderer(() => { + wrapper.update(); + }); + expect(findGridGroup().props.materialBindingKey) + .not.toEqual(genesisBindingKey); + unmountRenderer(wrapper); + }); }); diff --git a/frontend/three_d_garden/garden/__tests__/images_test.tsx b/frontend/three_d_garden/garden/__tests__/images_test.tsx index b09bb4a218..ad697a6c4c 100644 --- a/frontend/three_d_garden/garden/__tests__/images_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/images_test.tsx @@ -2,13 +2,13 @@ let mockDemo = false; import React from "react"; import { render, screen } from "@testing-library/react"; import { - extraRotation, getImagePosition, getMirrorTextureProps, + extraRotation, getImagePosition, getImageTextureKey, getMirrorTextureProps, ImageTexture, ImageTextureProps, } from "../images"; import { INITIAL } from "../../config"; import { clone } from "lodash"; import { - fakeImage, fakeWebAppConfig, + fakeImage, fakeSensor, fakeSensorReading, fakeWebAppConfig, } from "../../../__test_support__/fake_state/resources"; import { fakeAddPlantProps } from "../../../__test_support__/fake_props"; import * as mustBeOnline from "../../../devices/must_be_online"; @@ -61,6 +61,8 @@ describe("", () => { p.addPlantProps = apProps; render(); expect(screen.getAllByText("image").length).toEqual(3); + expect(document.querySelector(".render-texture")) + .toHaveAttribute("data-frames", "1"); }); it("renders when images missing", () => { @@ -157,6 +159,31 @@ describe("", () => { render(); expect(screen.queryAllByText("image").length).toEqual(1); }); + + it("changes texture key when moisture visibility changes", () => { + const p = fakeProps(); + const key = getImageTextureKey(p); + p.showMoistureMap = false; + expect(getImageTextureKey(p)).not.toEqual(key); + }); + + it("changes texture key when moisture data changes", () => { + const p = fakeProps(); + const reading = fakeSensorReading(); + p.sensorReadings = [reading]; + const key = getImageTextureKey(p); + reading.body.value = 800; + expect(getImageTextureKey(p)).not.toEqual(key); + }); + + it("changes texture key when moisture sensor metadata changes", () => { + const p = fakeProps(); + const sensor = fakeSensor(); + p.sensors = [sensor]; + const key = getImageTextureKey(p); + sensor.body.pin = (sensor.body.pin || 0) + 1; + expect(getImageTextureKey(p)).not.toEqual(key); + }); }); describe("extraRotation()", () => { diff --git a/frontend/three_d_garden/garden/__tests__/moisture_texture_test.tsx b/frontend/three_d_garden/garden/__tests__/moisture_texture_test.tsx index 2910dd0211..67051f0506 100644 --- a/frontend/three_d_garden/garden/__tests__/moisture_texture_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/moisture_texture_test.tsx @@ -6,6 +6,8 @@ import { INITIAL } from "../../config"; import { fakeSensor, fakeSensorReading, } from "../../../__test_support__/fake_state/resources"; +import * as interpolationMap from + "../../../farm_designer/map/layers/points/interpolation_map"; describe("", () => { const fakeProps = (): MoistureSurfaceProps => ({ @@ -40,4 +42,19 @@ describe("", () => { const { container } = render(); expect(container).toContainHTML("moisture-layer"); }); + + it("renders the moisture map with a native instanced mesh", () => { + const { container } = render(); + expect(container.querySelector("instancedmesh")).toBeTruthy(); + expect(container.querySelector(".instances")).toBeFalsy(); + expect(container.querySelector(".instance")).toBeFalsy(); + }); + + it("skips interpolation when the moisture map is hidden", () => { + const generateData = jest.spyOn(interpolationMap, "generateData"); + const p = fakeProps(); + p.showMoistureMap = false; + render(); + expect(generateData).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx b/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx index 75a4b4a518..6873f958a7 100644 --- a/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx @@ -22,7 +22,12 @@ import { fireEvent, render } from "@testing-library/react"; import { clone } from "lodash"; import { useFrame } from "@react-three/fiber"; import { useTexture } from "@react-three/drei"; -import { Quaternion } from "three"; +import { + InstancedMesh as ThreeInstancedMesh, + Quaternion, + type Intersection, + type Raycaster, +} from "three"; import { fakePlant } from "../../../__test_support__/fake_state/resources"; import { INITIAL } from "../../config"; import { @@ -38,6 +43,11 @@ import { setMockInstanceId } from "../../../__test_support__/three_d_mocks"; import { PLANT_ICON_ATLAS } from "../plant_icon_atlas"; import { Mode } from "../../../farm_designer/map/interfaces"; import * as mapUtil from "../../../farm_designer/map/util"; +import * as meshKey from "../instanced_mesh_key"; +import { + createRenderer, + unmountRenderer, +} from "../../../__test_support__/test_renderer"; describe("", () => { let reactUseRefSpy: jest.SpyInstance; @@ -99,6 +109,46 @@ describe("", () => { expect(meshes.length).toBe(2); }); + it("uses reserved icon capacity while rendering only active plants", () => { + const p = fakeProps(); + p.plants = [p.plants[0]]; + p.iconCapacities = { [p.plants[0].icon]: 10 }; + const { container } = render(); + const mesh = container.querySelector("instancedmesh"); + expect(mesh?.getAttribute("args")).toContain("10"); + expect(mesh?.getAttribute("count")).toEqual("1"); + }); + + it("keeps reserved icon meshes mounted without active plants", () => { + const p = fakeProps(); + p.plants = [p.plants[0]]; + p.iconCapacities = { + [p.plants[0].icon]: 10, + "https://example.com/inactive-icon.avif": 5, + }; + const { container } = render(); + const meshes = container.querySelectorAll("instancedmesh"); + expect(meshes.length).toBe(2); + expect(meshes.item(1).getAttribute("args")).toContain("5"); + expect(meshes.item(1).getAttribute("count")).toEqual("0"); + expect(useTexture).toHaveBeenCalledWith("https://example.com/inactive-icon.avif"); + }); + + it("disables frustum culling for billboarded plant icons", () => { + const wrapper = createRenderer(); + const mesh = wrapper.root.findAll(node => + (node.type as string) == "instancedMesh")[0]; + expect(mesh.props.frustumCulled).toEqual(false); + unmountRenderer(wrapper); + }); + + it("doesn't build per-plant mesh keys while rendering", () => { + const keySpy = jest.spyOn(meshKey, "instancedMeshKey"); + render(); + expect(keySpy).not.toHaveBeenCalled(); + keySpy.mockRestore(); + }); + it("loads the atlas texture when an icon is mapped", () => { PLANT_ICON_ATLAS["/crops/icons/beet.avif"] = { atlasUrl: "/crops/icons/atlas.avif", @@ -135,6 +185,19 @@ describe("", () => { expect(mockNavigate).toHaveBeenCalledWith(Path.plants("1")); }); + it("doesn't navigate after orbiting over a plant icon", () => { + const p = fakeProps(); + const dispatch = jest.fn(); + p.dispatch = mockDispatch(dispatch); + const wrapper = createRenderer(); + const mesh = wrapper.root.findAll(node => + (node.type as string) == "instancedMesh")[0]; + mesh.props.onClick({ instanceId: 0, delta: 3 }); + unmountRenderer(wrapper); + expect(dispatch).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + it("doesn't navigate without dispatch", () => { setMockInstanceId(0); const p = fakeProps(); @@ -157,6 +220,50 @@ describe("", () => { expect(mockNavigate).not.toHaveBeenCalled(); }); + const iconRaycast = (p = fakeProps()) => { + const wrapper = createRenderer(); + const mesh = wrapper.root.findAll(node => + (node.type as string) == "instancedMesh")[0]; + const raycast = mesh.props.raycast as ( + this: ThreeInstancedMesh, + raycaster: Raycaster, + intersects: Intersection[], + ) => void; + unmountRenderer(wrapper); + return raycast; + }; + + it.each([ + Mode.clickToAdd, + Mode.createPoint, + Mode.createWeed, + ])("allows %s raycasts through plant icons", mode => { + getModeSpy.mockReturnValue(mode); + const defaultRaycast = jest.spyOn( + ThreeInstancedMesh.prototype, + "raycast", + ); + const intersects: Intersection[] = []; + const raycaster = {} as Raycaster; + iconRaycast().call({} as ThreeInstancedMesh, raycaster, intersects); + expect(defaultRaycast).not.toHaveBeenCalled(); + expect(intersects).toEqual([]); + defaultRaycast.mockRestore(); + }); + + it("keeps plant icon raycasts outside placement modes", () => { + getModeSpy.mockReturnValue(Mode.none); + const defaultRaycast = jest.spyOn( + ThreeInstancedMesh.prototype, + "raycast", + ).mockImplementation(() => undefined); + const intersects: Intersection[] = []; + const raycaster = {} as Raycaster; + iconRaycast().call({} as ThreeInstancedMesh, raycaster, intersects); + expect(defaultRaycast).toHaveBeenCalledWith(raycaster, intersects); + defaultRaycast.mockRestore(); + }); + it("doesn't navigate with missing instanceId", () => { setMockInstanceId(undefined); const p = fakeProps(); @@ -223,6 +330,24 @@ describe("", () => { expect(matrix.elements[13]).toBeCloseTo(460); }); + it("skips repeated icon matrix updates until camera changes", () => { + const p = fakeProps(); + p.plants = [p.plants[0]]; + render(); + const frameFn = (useFrame as jest.Mock).mock.calls[0][0]; + const instancedRef = allRefs.find(ref => !!ref.current?.setMatrixAt); + const setMatrixAt = instancedRef?.current?.setMatrixAt as jest.Mock; + const state = { camera: { quaternion: new Quaternion() } }; + frameFn(state); + setMatrixAt.mockClear(); + frameFn(state); + expect(setMatrixAt).not.toHaveBeenCalled(); + frameFn({ + camera: { quaternion: new Quaternion(0, 0, 0.1, 1).normalize() }, + }); + expect(setMatrixAt).toHaveBeenCalled(); + }); + it("updates material brightness when changed", () => { const setScalar = jest.fn(); const instancedRef = { diff --git a/frontend/three_d_garden/garden/__tests__/plants_test.tsx b/frontend/three_d_garden/garden/__tests__/plants_test.tsx index 3b1c48e7db..cb24893b58 100644 --- a/frontend/three_d_garden/garden/__tests__/plants_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/plants_test.tsx @@ -16,9 +16,20 @@ import { Actions } from "../../../constants"; import { convertPlants } from "../../../farm_designer/three_d_garden_map"; import { setMockInstanceId } from "../../../__test_support__/three_d_mocks"; import { useFrame } from "@react-three/fiber"; -import { Quaternion, WebGLProgramParametersWithUniforms } from "three"; +import { + InstancedMesh as ThreeInstancedMesh, + Quaternion, + WebGLProgramParametersWithUniforms, + type Intersection, + type Raycaster, +} from "three"; import { Mode } from "../../../farm_designer/map/interfaces"; import * as mapUtil from "../../../farm_designer/map/util"; +import * as meshKey from "../instanced_mesh_key"; +import { + createRenderer, + unmountRenderer, +} from "../../../__test_support__/test_renderer"; interface MockRef { current: { @@ -190,6 +201,25 @@ describe("", () => { expect(container.querySelectorAll("instancedmesh").length).toBe(1); }); + it("uses reserved spread capacity while rendering active plants", () => { + location.pathname = Path.mock(Path.cropSearch("mint")); + queueMeshRef(); + const p = fakeProps(); + p.instanceCapacity = 10; + const { container } = render(); + const mesh = container.querySelector("instancedmesh"); + expect(mesh?.getAttribute("args")).toContain("10"); + expect(mesh?.getAttribute("count")).toEqual("2"); + }); + + it("doesn't build per-plant spread mesh keys while rendering", () => { + const keySpy = jest.spyOn(meshKey, "instancedMeshKey"); + queueMeshRef(); + render(); + expect(keySpy).not.toHaveBeenCalled(); + keySpy.mockRestore(); + }); + it("renders spread: edit plant mode", () => { location.pathname = Path.mock(Path.plants("1")); queueMeshRef(); @@ -223,6 +253,20 @@ describe("", () => { expect(mockNavigate).toHaveBeenCalledWith(Path.plants("1")); }); + it("doesn't navigate after orbiting over a spread sphere", () => { + queueMeshRef(); + const p = fakeProps(); + const dispatch = jest.fn(); + p.dispatch = mockDispatch(dispatch); + const wrapper = createRenderer(); + const mesh = wrapper.root.findAll(node => + (node.type as string) == "instancedMesh")[0]; + mesh.props.onClick({ instanceId: 0, delta: 3 }); + unmountRenderer(wrapper); + expect(dispatch).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + it("doesn't navigate on spread click in camera selection mode", () => { setMockInstanceId(0); getModeSpy.mockReturnValue(Mode.cameraSelection); @@ -237,6 +281,51 @@ describe("", () => { expect(mockNavigate).not.toHaveBeenCalled(); }); + const spreadRaycast = (p = fakeProps()) => { + queueMeshRef(); + const wrapper = createRenderer(); + const mesh = wrapper.root.findAll(node => + (node.type as string) == "instancedMesh")[0]; + const raycast = mesh.props.raycast as ( + this: ThreeInstancedMesh, + raycaster: Raycaster, + intersects: Intersection[], + ) => void; + unmountRenderer(wrapper); + return raycast; + }; + + it.each([ + Mode.clickToAdd, + Mode.createPoint, + Mode.createWeed, + ])("allows %s raycasts through spread spheres", mode => { + getModeSpy.mockReturnValue(mode); + const defaultRaycast = jest.spyOn( + ThreeInstancedMesh.prototype, + "raycast", + ); + const intersects: Intersection[] = []; + const raycaster = {} as Raycaster; + spreadRaycast().call({} as ThreeInstancedMesh, raycaster, intersects); + expect(defaultRaycast).not.toHaveBeenCalled(); + expect(intersects).toEqual([]); + defaultRaycast.mockRestore(); + }); + + it("keeps spread sphere raycasts outside placement modes", () => { + getModeSpy.mockReturnValue(Mode.none); + const defaultRaycast = jest.spyOn( + ThreeInstancedMesh.prototype, + "raycast", + ).mockImplementation(() => undefined); + const intersects: Intersection[] = []; + const raycaster = {} as Raycaster; + spreadRaycast().call({} as ThreeInstancedMesh, raycaster, intersects); + expect(defaultRaycast).toHaveBeenCalledWith(raycaster, intersects); + defaultRaycast.mockRestore(); + }); + it("updates instance colors on frame", () => { queueMeshRef(); const p = fakeProps(); @@ -270,6 +359,59 @@ describe("", () => { expect(meshRef?.current?.instanceMatrix?.needsUpdate).toBeFalsy(); }); + it("skips repeated inactive spread frame updates", () => { + queueMeshRef(); + const p = fakeProps(); + p.spreadVisible = false; + render(); + const meshRef = allRefs[0]; + meshRef.current = buildMeshRef(); + const mesh = meshRef.current; + const setMatrixAt = mesh?.setMatrixAt as jest.Mock; + const frameFn = (useFrame as jest.Mock).mock.calls[0][0]; + const state = { camera: { quaternion: new Quaternion() } }; + frameFn(state); + setMatrixAt.mockClear(); + frameFn(state); + expect(setMatrixAt).not.toHaveBeenCalled(); + }); + + it("skips repeated visible spread frame updates", () => { + queueMeshRef(); + const p = fakeProps(); + p.spreadVisible = true; + render(); + const meshRef = allRefs[0]; + meshRef.current = buildMeshRef(); + const mesh = meshRef.current; + const setMatrixAt = mesh?.setMatrixAt as jest.Mock; + const frameFn = (useFrame as jest.Mock).mock.calls[0][0]; + const state = { camera: { quaternion: new Quaternion() } }; + frameFn(state); + setMatrixAt.mockClear(); + frameFn(state); + expect(setMatrixAt).not.toHaveBeenCalled(); + }); + + it("updates click-to-add spread when active position changes", () => { + queueMeshRef(); + const p = fakeProps(); + p.spreadVisible = true; + getModeSpy.mockReturnValue(Mode.clickToAdd); + render(); + const meshRef = allRefs[0]; + meshRef.current = buildMeshRef(); + const mesh = meshRef.current; + const setMatrixAt = mesh?.setMatrixAt as jest.Mock; + const frameFn = (useFrame as jest.Mock).mock.calls[0][0]; + const state = { camera: { quaternion: new Quaternion() } }; + frameFn(state); + setMatrixAt.mockClear(); + p.activePositionRef.current = { x: 100, y: 100 }; + frameFn(state); + expect(setMatrixAt).toHaveBeenCalled(); + }); + it("handles missing mesh in layout effect", () => { reactUseImperativeHandleSpy.mockImplementation(() => undefined); reactUseRefSpy.mockImplementation(() => ({ current: undefined }) as never); @@ -288,7 +430,9 @@ describe("", () => { { current: undefined as unknown as { x: number; y: number } }; location.pathname = Path.mock(Path.plants("1")); render(); - const mesh = getMeshRef()?.current as + const meshRef = allRefs[0]; + meshRef.current = buildMeshRef(); + const mesh = meshRef.current as (MockRef["current"] & { material: { needsUpdate: boolean } }) | undefined; if (mesh) { mesh.instanceColor = { needsUpdate: false, count: 0 }; diff --git a/frontend/three_d_garden/garden/__tests__/point_test.tsx b/frontend/three_d_garden/garden/__tests__/point_test.tsx index 7326c664bc..e354275c95 100644 --- a/frontend/three_d_garden/garden/__tests__/point_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/point_test.tsx @@ -1,6 +1,9 @@ import React from "react"; import { fireEvent, render } from "@testing-library/react"; -import { DrawnPoint, DrawnPointProps, Point, PointProps } from "../point"; +import { + DrawnPoint, DrawnPointProps, Point, PointInstances, PointInstancesProps, + PointProps, +} from "../point"; import { INITIAL } from "../../config"; import { clone } from "lodash"; import { fakePoint } from "../../../__test_support__/fake_state/resources"; @@ -11,12 +14,23 @@ import { fakeDesignerState, fakeDrawnPoint, } from "../../../__test_support__/fake_designer_state"; import { SpecialStatus } from "farmbot"; +import { + createRenderer, + unmountRenderer, +} from "../../../__test_support__/test_renderer"; describe("", () => { + const mountedWrappers: ReturnType[] = []; + beforeEach(() => { location.pathname = Path.mock(Path.points()); }); + afterEach(() => { + mountedWrappers.splice(0).forEach(wrapper => + unmountRenderer(wrapper)); + }); + const fakeProps = (): PointProps => ({ config: clone(INITIAL), point: fakePoint(), @@ -64,6 +78,20 @@ describe("", () => { expect(mockNavigate).toHaveBeenCalledWith(Path.points("1")); }); + it("doesn't navigate after orbiting over a point", () => { + const p = fakeProps(); + const dispatch = jest.fn(); + p.dispatch = mockDispatch(dispatch); + p.point.body.id = 1; + const wrapper = createRenderer(); + mountedWrappers.push(wrapper); + const point = wrapper.root + .findAll(node => node.props.name == "marker")[0]; + point.props.onClick({ delta: 3 }); + expect(dispatch).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + it("doesn't navigate to point info", () => { const p = fakeProps(); p.dispatch = undefined; @@ -73,6 +101,52 @@ describe("", () => { point && fireEvent.click(point); expect(mockNavigate).not.toHaveBeenCalled(); }); + + const fakeInstanceProps = (): PointInstancesProps => ({ + config: clone(INITIAL), + points: [fakePoint(), fakePoint()], + visible: true, + getZ: () => 0, + }); + + it("renders instanced point markers", () => { + const wrapper = createRenderer(); + mountedWrappers.push(wrapper); + const meshes = wrapper.root.findAll(node => + (node.type as string) == "instancedMesh"); + expect(meshes.length).toEqual(3); + expect(meshes[0].props.args[2]).toEqual(2); + }); + + it("navigates from a point instance", () => { + const p = fakeInstanceProps(); + const dispatch = jest.fn(); + p.dispatch = mockDispatch(dispatch); + p.points[0].body.id = 1; + const wrapper = createRenderer(); + mountedWrappers.push(wrapper); + const marker = wrapper.root + .findAll(node => node.props.name == "marker")[0]; + marker.props.onClick({ instanceId: 0 }); + expect(dispatch).toHaveBeenCalledWith({ + type: Actions.SET_PANEL_OPEN, payload: true, + }); + expect(mockNavigate).toHaveBeenCalledWith(Path.points("1")); + }); + + it("doesn't navigate after orbiting over a point instance", () => { + const p = fakeInstanceProps(); + const dispatch = jest.fn(); + p.dispatch = mockDispatch(dispatch); + p.points[0].body.id = 1; + const wrapper = createRenderer(); + mountedWrappers.push(wrapper); + const marker = wrapper.root + .findAll(node => node.props.name == "marker")[0]; + marker.props.onClick({ instanceId: 0, delta: 3 }); + expect(dispatch).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + }); }); describe("", () => { @@ -95,6 +169,17 @@ describe("", () => { expect(container).toContainHTML("position=\"0,0,0\""); }); + it("draws point radius preview", () => { + location.pathname = Path.mock(Path.points("add")); + const p = fakeProps(); + const point = fakeDrawnPoint(); + point.r = 0; + p.designer.drawnPoint = point; + p.torusRef = { current: undefined as never }; + const { container } = render(); + expect(container.querySelector(".torus")).not.toBeNull(); + }); + it("doesn't draw point", () => { location.pathname = Path.mock(Path.points("add")); const p = fakeProps(); diff --git a/frontend/three_d_garden/garden/__tests__/solar_test.tsx b/frontend/three_d_garden/garden/__tests__/solar_test.tsx index aec76f6bb8..5780d9e749 100644 --- a/frontend/three_d_garden/garden/__tests__/solar_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/solar_test.tsx @@ -3,6 +3,13 @@ import { render } from "@testing-library/react"; import { Solar, SolarProps } from "../solar"; import { INITIAL } from "../../config"; import { clone } from "lodash"; +import { FocusTransitionProvider } from "../../focus_transition"; +import { + createRenderer, + unmountRenderer, +} from "../../../__test_support__/test_renderer"; +import { RenderOrder } from "../../constants"; +import { DoubleSide } from "three"; describe("", () => { const fakeProps = (): SolarProps => ({ @@ -16,4 +23,45 @@ describe("", () => { const { container } = render(); expect(container).toContainHTML("solar"); }); + + it("keeps solar mounted during focus transitions", () => { + const { container } = render( + + + , + ); + expect(container).toContainHTML("solar-wiring"); + }); + + it("doesn't cull instanced solar cells", () => { + const p = fakeProps(); + p.config.solar = true; + const wrapper = createRenderer(); + const solarCells = wrapper.root.findAll(node => + (node.type as string) == "instancedMesh"); + expect(solarCells[0].props.frustumCulled).toEqual(false); + expect(solarCells[0].props.renderOrder).toEqual(RenderOrder.one + 1); + unmountRenderer(wrapper); + }); + + it("renders solar cells above panels and wiring", () => { + const p = fakeProps(); + p.config.solar = true; + const wrapper = createRenderer(); + const wiring = wrapper.root.findAll(node => + node.props.name == "solar-wiring")[0]; + const panel = wrapper.root.findAll(node => + (node.type as string) == "mesh" + && node.props.renderOrder == RenderOrder.one)[0]; + const cells = wrapper.root.findAll(node => + (node.type as string) == "instancedMesh")[0]; + const cellMaterial = wrapper.root.findAll(node => + node.props.side == DoubleSide)[0]; + expect(wiring.props.renderOrder).toEqual(RenderOrder.default); + expect(panel.props.renderOrder).toEqual(RenderOrder.one); + expect(Number(cells.props.renderOrder)) + .toBeGreaterThan(Number(panel.props.renderOrder)); + expect(cellMaterial.props.side).toEqual(DoubleSide); + unmountRenderer(wrapper); + }); }); diff --git a/frontend/three_d_garden/garden/__tests__/sun_test.tsx b/frontend/three_d_garden/garden/__tests__/sun_test.tsx index 60c221874d..5d687e4528 100644 --- a/frontend/three_d_garden/garden/__tests__/sun_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/sun_test.tsx @@ -1,26 +1,9 @@ -interface Mock0Ref { - current: number; -} -const mock0Ref: Mock0Ref = { - current: 0, -}; interface Mock1Ref { current: { position: { set: Function; }; } | undefined; } const mock1Ref: Mock1Ref = { current: { position: { set: jest.fn() } } }; -interface Mock4Ref { - current: { position: { set: Function; }; }[] | undefined; -} -const mock4Ref: Mock4Ref = { - current: [ - { position: { set: jest.fn() } }, - { position: { set: jest.fn() } }, - { position: { set: jest.fn() } }, - { position: { set: jest.fn() } }, - ] -}; interface MockMaterialRef { current: { opacity: number; } | undefined; } @@ -33,7 +16,7 @@ import { render } from "@testing-library/react"; import { calcSunI, getCycleLength, skyColor, Sun, SunProps } from "../sun"; import { INITIAL } from "../../config"; import { clone } from "lodash"; -import { MeshBasicMaterial } from "three"; +import { MeshBasicMaterial, Vector3 } from "three"; beforeEach(() => { jest.clearAllMocks(); @@ -97,6 +80,14 @@ describe("", () => { expect(left).toBeLessThanOrEqual(-minBound); }); + it("disables shadows in low-detail mode", () => { + const p = fakeProps(); + p.config.lowDetail = true; + const { container } = render(); + const light = container.querySelector("directionallight"); + expect(light?.getAttribute("castshadow")).not.toEqual("true"); + }); + it("renders animated without ref", () => { const p = fakeProps(); p.config.animateSeasons = true; @@ -110,14 +101,13 @@ describe("", () => { it("renders animated", () => { jest.spyOn(React, "useRef") - .mockImplementationOnce(() => mock4Ref) - .mockImplementationOnce(() => mock4Ref) .mockImplementationOnce(() => mock1Ref) .mockImplementationOnce(() => mock1Ref) .mockImplementationOnce(() => mock1Ref) - .mockImplementationOnce(() => mock0Ref) + .mockImplementationOnce(() => mock1Ref) + .mockImplementationOnce(() => mock1Ref) .mockImplementationOnce(() => mockMaterialRef); - jest.spyOn(React, "useState").mockReturnValue([[], jest.fn()]); + jest.spyOn(React, "useState").mockReturnValue([new Vector3(), jest.fn()]); const p = fakeProps(); p.config.animateSeasons = true; p.startTimeRef = { current: 0 }; diff --git a/frontend/three_d_garden/garden/__tests__/weed_test.tsx b/frontend/three_d_garden/garden/__tests__/weed_test.tsx index d321f52d97..7046275197 100644 --- a/frontend/three_d_garden/garden/__tests__/weed_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/weed_test.tsx @@ -1,6 +1,6 @@ import React from "react"; import { fireEvent, render } from "@testing-library/react"; -import { Weed, WeedProps } from "../weed"; +import { Weed, WeedInstances, WeedInstancesProps, WeedProps } from "../weed"; import { INITIAL } from "../../config"; import { clone } from "lodash"; import { fakeWeed } from "../../../__test_support__/fake_state/resources"; @@ -9,16 +9,29 @@ import { Actions } from "../../../constants"; import { mockDispatch } from "../../../__test_support__/fake_dispatch"; import * as mapUtil from "../../../farm_designer/map/util"; import { Mode } from "../../../farm_designer/map/interfaces"; +import { useFrame } from "@react-three/fiber"; +import { Quaternion } from "three"; +import { + createRenderer, + unmountRenderer, +} from "../../../__test_support__/test_renderer"; describe("", () => { let getModeSpy: jest.SpyInstance; + let reactUseRefSpy: jest.SpyInstance | undefined; + const mountedWrappers: ReturnType[] = []; beforeEach(() => { getModeSpy = jest.spyOn(mapUtil, "getMode").mockReturnValue(Mode.none); + (useFrame as jest.Mock).mockClear(); }); afterEach(() => { + mountedWrappers.splice(0).forEach(wrapper => + unmountRenderer(wrapper)); getModeSpy.mockRestore(); + reactUseRefSpy?.mockRestore(); + reactUseRefSpy = undefined; }); const fakeProps = (): WeedProps => ({ @@ -59,6 +72,20 @@ describe("", () => { expect(mockNavigate).toHaveBeenCalledWith(Path.weeds("1")); }); + it("doesn't navigate after orbiting over a weed", () => { + const p = fakeProps(); + const dispatch = jest.fn(); + p.dispatch = mockDispatch(dispatch); + p.weed.body.id = 1; + const wrapper = createRenderer(); + mountedWrappers.push(wrapper); + const weed = wrapper.root + .findAll(node => node.props.name == "weed-1")[0]; + weed.props.onClick({ delta: 3 }); + expect(dispatch).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + it("doesn't navigate to weed info", () => { const p = fakeProps(); p.dispatch = undefined; @@ -68,4 +95,97 @@ describe("", () => { weed && fireEvent.click(weed); expect(mockNavigate).not.toHaveBeenCalled(); }); + + const fakeInstanceProps = (): WeedInstancesProps => ({ + config: clone(INITIAL), + weeds: [fakeWeed(), fakeWeed()], + visible: true, + getZ: () => 0, + }); + + it("renders instanced weeds", () => { + const wrapper = createRenderer(); + mountedWrappers.push(wrapper); + const meshes = wrapper.root.findAll(node => + (node.type as string) == "instancedMesh"); + expect(meshes.length).toEqual(2); + expect(meshes[0].props.name).toEqual("weed-icons"); + expect(meshes[1].props.name).toEqual("weed-radius"); + }); + + it("navigates from a weed instance", () => { + const p = fakeInstanceProps(); + const dispatch = jest.fn(); + p.dispatch = mockDispatch(dispatch); + p.weeds[0].body.id = 1; + const wrapper = createRenderer(); + mountedWrappers.push(wrapper); + const weedIcons = wrapper.root + .findAll(node => node.props.name == "weed-icons")[0]; + weedIcons.props.onClick({ instanceId: 0 }); + expect(dispatch).toHaveBeenCalledWith({ + type: Actions.SET_PANEL_OPEN, payload: true, + }); + expect(mockNavigate).toHaveBeenCalledWith(Path.weeds("1")); + }); + + it("navigates from a weed radius instance", () => { + const p = fakeInstanceProps(); + const dispatch = jest.fn(); + p.dispatch = mockDispatch(dispatch); + p.weeds[0].body.id = 1; + const wrapper = createRenderer(); + mountedWrappers.push(wrapper); + const weedRadius = wrapper.root + .findAll(node => node.props.name == "weed-radius")[0]; + weedRadius.props.onClick({ instanceId: 0 }); + expect(dispatch).toHaveBeenCalledWith({ + type: Actions.SET_PANEL_OPEN, payload: true, + }); + expect(mockNavigate).toHaveBeenCalledWith(Path.weeds("1")); + }); + + it("doesn't navigate after orbiting over weed instances", () => { + const p = fakeInstanceProps(); + const dispatch = jest.fn(); + p.dispatch = mockDispatch(dispatch); + p.weeds[0].body.id = 1; + const wrapper = createRenderer(); + mountedWrappers.push(wrapper); + const weedIcons = wrapper.root + .findAll(node => node.props.name == "weed-icons")[0]; + const weedRadius = wrapper.root + .findAll(node => node.props.name == "weed-radius")[0]; + weedIcons.props.onClick({ instanceId: 0, delta: 3 }); + weedRadius.props.onClick({ instanceId: 0, delta: 3 }); + expect(dispatch).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it("updates weed icon matrices on frame", () => { + const iconRef = { + current: { + setMatrixAt: jest.fn(), + instanceMatrix: { needsUpdate: false }, + }, + }; + const radiusRef = { + current: { + setMatrixAt: jest.fn(), + instanceMatrix: { needsUpdate: false }, + }, + }; + const updateStateRef = { current: {} }; + reactUseRefSpy = jest.spyOn(React, "useRef") + .mockImplementationOnce(() => iconRef) + .mockImplementationOnce(() => updateStateRef) + .mockImplementationOnce(() => radiusRef) + .mockImplementation(value => ({ current: value })); + const wrapper = createRenderer(); + mountedWrappers.push(wrapper); + const frameFn = (useFrame as jest.Mock).mock.calls[0][0]; + frameFn({ camera: { quaternion: new Quaternion() } }); + expect(iconRef.current.setMatrixAt).toHaveBeenCalled(); + expect(iconRef.current.instanceMatrix.needsUpdate).toEqual(true); + }); }); diff --git a/frontend/three_d_garden/garden/__tests__/zoom_beacons_test.tsx b/frontend/three_d_garden/garden/__tests__/zoom_beacons_test.tsx index abec987e83..af8dfe290d 100644 --- a/frontend/three_d_garden/garden/__tests__/zoom_beacons_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/zoom_beacons_test.tsx @@ -11,6 +11,8 @@ import { createRenderer, unmountRenderer, } from "../../../__test_support__/test_renderer"; +import { FocusTransitionProvider } from "../../focus_transition"; +import { RenderOrder } from "../../constants"; const originalDocumentQuerySelector = document.querySelector.bind(document); let isDesktopSpy: jest.SpyInstance; @@ -78,7 +80,7 @@ describe("", () => { unmountRenderer(wrapper); }); - it("changes cursor", () => { + it("hides beacon while focused", () => { const element = document.createElement("div"); Object.defineProperty(document, "querySelector", { value: () => element, @@ -89,19 +91,50 @@ describe("", () => { p.config.animate = false; const wrapper = createRenderer(); const sphere = wrapper.root.findAll(node => node.props.name == "beacon-sphere")[0]; - actRenderer(() => { - sphere?.props.onPointerEnter(); - }); - expect(element.style.cursor).toEqual("zoom-out"); - actRenderer(() => { - sphere?.props.onPointerLeave(); - }); - expect(element.style.cursor).toEqual(""); - actRenderer(() => { - sphere?.props.onClick(); - }); + expect(sphere).toBeUndefined(); expect(element.style.cursor).toEqual(""); - expect(p.setActiveFocus).toHaveBeenCalledWith(""); + expect(p.setActiveFocus).not.toHaveBeenCalled(); + unmountRenderer(wrapper); + }); + + it("renders visible beacons without writing depth", () => { + const p = fakeProps(); + p.config.animate = false; + const wrapper = createRenderer(); + const sphere = wrapper.root.findAll(node => + node.props.name == "beacon-sphere")[0]; + const materials = wrapper.root.findAll(node => + node.props.depthWrite === false); + expect(sphere.props.renderOrder).toEqual(RenderOrder.beacons); + expect(materials[0].props.depthTest).toBeUndefined(); + expect(materials[0].props.depthWrite).toEqual(false); + unmountRenderer(wrapper); + }); + + it("applies load-in scale on each anchored beacon visual", () => { + const p = fakeProps(); + p.config.animate = false; + const wrapper = createRenderer(); + const beacon = wrapper.root.findAll(node => + node.props.name == "zoom-beacon")[0]; + const visual = wrapper.root.findAll(node => + node.props.name == "beacon-visual")[0]; + expect(beacon.props.position).toBeTruthy(); + expect(visual.props.scale).toEqual(0.35); + unmountRenderer(wrapper); + }); + + it("doesn't mount stable focused beacon visuals", () => { + const p = fakeProps(); + p.activeFocus = "What you can grow"; + p.config.animate = false; + const wrapper = createRenderer( + + + , + ); + expect(wrapper.root.findAll(node => + node.props.name == "beacon-sphere").length).toEqual(0); unmountRenderer(wrapper); }); @@ -129,7 +162,8 @@ describe("", () => { p.config.animate = false; const wrapper = createRenderer(); const e = { stopPropagation: jest.fn() }; - const info = wrapper.root.findAll(node => node.props.className == "beacon-info")[0]; + const info = wrapper.root.findAll(node => + (node.props.className || "").includes("beacon-info"))[0]; actRenderer(() => { info?.props.onPointerDown(e); info?.props.onPointerMove(e); diff --git a/frontend/three_d_garden/garden/clouds.tsx b/frontend/three_d_garden/garden/clouds.tsx index 0a4fd62d99..3f24373fab 100644 --- a/frontend/three_d_garden/garden/clouds.tsx +++ b/frontend/three_d_garden/garden/clouds.tsx @@ -2,18 +2,29 @@ import React from "react"; import { Config, getSeasonProperties } from "../config"; import { Cloud, Clouds as DreiClouds } from "@react-three/drei"; import { ASSETS, RenderOrder } from "../constants"; +import { animated, useSpring } from "@react-spring/three"; export interface CloudsProps { config: Config; } +const AnimatedCloud = animated(Cloud); + export const Clouds = (props: CloudsProps) => { const { config } = props; const sunParams = getSeasonProperties(config, "Summer"); + const targetOpacity = sunParams.cloudOpacity; + const { opacity } = useSpring({ + from: { opacity: 0 }, + to: { opacity: targetOpacity }, + immediate: !config.animate, + config: { duration: 600 }, + }); + if (!config.clouds || targetOpacity <= 0) { return undefined; } return - { color="#ccc" growth={400} speed={.1} - opacity={sunParams.cloudOpacity} + opacity={opacity} fade={5000} /> ; }; diff --git a/frontend/three_d_garden/garden/grid.tsx b/frontend/three_d_garden/garden/grid.tsx index a853aace2e..06a6c912b8 100644 --- a/frontend/three_d_garden/garden/grid.tsx +++ b/frontend/three_d_garden/garden/grid.tsx @@ -1,6 +1,6 @@ import React from "react"; import { Config } from "../config"; -import { Group, Primitive } from "../components"; +import { Primitive } from "../components"; import { get3DPositionFunc, zero as zeroFunc, } from "../helpers"; @@ -13,6 +13,7 @@ import { LineSegmentsGeometry, } from "three/examples/jsm/lines/LineSegmentsGeometry.js"; import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js"; +import { FocusVisibilityGroup } from "../focus_transition"; export const gridLineOffsets = (botDimension: number): number[] => { const lastRegularOffset = floor(botDimension, -2); @@ -132,8 +133,17 @@ export const Grid = (props: GridProps) => { }); return result; }, [config, props.getZ]); - return { color={"white"} opacity={0.5} linewidth={2} /> - ; + ; }; diff --git a/frontend/three_d_garden/garden/ground.tsx b/frontend/three_d_garden/garden/ground.tsx index 4050b93408..8dceff03d8 100644 --- a/frontend/three_d_garden/garden/ground.tsx +++ b/frontend/three_d_garden/garden/ground.tsx @@ -1,9 +1,10 @@ import React from "react"; import { Config, detailLevels } from "../config"; -import { Detailed, useTexture } from "@react-three/drei"; +import { Detailed } from "@react-three/drei"; import { Mesh, MeshPhongMaterial } from "../components"; import { ASSETS, BigDistance } from "../constants"; import { CircleGeometry, Float32BufferAttribute, RepeatWrapping } from "three"; +import { useTextureVariant } from "../texture_variants"; export interface GroundProps { config: Config; @@ -44,30 +45,21 @@ export const Ground = (props: GroundProps) => { const { config } = props; const groundZ = config.bedZOffset + config.bedHeight; - const grassTextureBase = useTexture(ASSETS.textures.grass + "?=grass"); - const grassTexture = React.useMemo(() => { - const texture = grassTextureBase.clone(); - texture.wrapS = RepeatWrapping; - texture.wrapT = RepeatWrapping; - texture.repeat.set(24, 24); - return texture; - }, [grassTextureBase]); - const labFloorTextureBase = useTexture(ASSETS.textures.concrete + "?=labFloor"); - const labFloorTexture = React.useMemo(() => { - const texture = labFloorTextureBase.clone(); - texture.wrapS = RepeatWrapping; - texture.wrapT = RepeatWrapping; - texture.repeat.set(16, 24); - return texture; - }, [labFloorTextureBase]); - const brickTextureBase = useTexture(ASSETS.textures.bricks + "?=bricks"); - const brickTexture = React.useMemo(() => { - const texture = brickTextureBase.clone(); - texture.wrapS = RepeatWrapping; - texture.wrapT = RepeatWrapping; - texture.repeat.set(30, 30); - return texture; - }, [brickTextureBase]); + const grassTexture = useTextureVariant(ASSETS.textures.grass, { + wrapS: RepeatWrapping, + wrapT: RepeatWrapping, + repeat: [24, 24], + }); + const labFloorTexture = useTextureVariant(ASSETS.textures.concrete, { + wrapS: RepeatWrapping, + wrapT: RepeatWrapping, + repeat: [16, 24], + }); + const brickTexture = useTextureVariant(ASSETS.textures.bricks, { + wrapS: RepeatWrapping, + wrapT: RepeatWrapping, + repeat: [30, 30], + }); const getGroundProperties = (sceneName: string) => { switch (sceneName) { diff --git a/frontend/three_d_garden/garden/images.tsx b/frontend/three_d_garden/garden/images.tsx index f33acd3a3e..0e2f37f427 100644 --- a/frontend/three_d_garden/garden/images.tsx +++ b/frontend/three_d_garden/garden/images.tsx @@ -20,6 +20,7 @@ import { } from "../../farm_designer/map/layers/images/map_image"; import { forceOnline } from "../../devices/must_be_online"; import { MoistureSurface } from "./moisture_texture"; +import { perfCount, perfMeasure } from "../../performance/perf"; interface BaseProps { config: Config; @@ -39,6 +40,7 @@ interface PlaneWrapperProps { const PlaneWrapper = (props: PlaneWrapperProps) => perfCount("soilTextureRenders")} position={[ props.bedWallThickness + props.width / 2, props.bedWallThickness + props.height / 2, @@ -87,6 +89,38 @@ export interface ImageTextureProps extends BaseProps { showMoistureMap: boolean; } +const getSensorKey = (sensors: TaggedSensor[]) => { + let key = ""; + sensors.map(sensor => { + key += `${sensor.uuid},${sensor.body.label},`; + key += `${sensor.body.mode},${sensor.body.pin}|`; + }); + return key; +}; + +const getSensorReadingKey = (readings: TaggedSensorReading[]) => { + let key = ""; + readings.map(reading => { + key += `${reading.uuid},${reading.body.x},${reading.body.y},`; + key += `${reading.body.z},${reading.body.value},`; + key += `${reading.body.mode},${reading.body.pin},${reading.body.read_at}|`; + }); + return key; +}; + +export const getImageTextureKey = (props: ImageTextureProps) => { + const extents = soilSurfaceExtents(props.config); + const moistureVisible = props.showMoistureMap || props.showMoistureReadings; + return [ + extents.x.min, extents.x.max, + extents.y.min, extents.y.max, + props.showMoistureMap, + props.showMoistureReadings, + moistureVisible && getSensorKey(props.sensors), + moistureVisible && getSensorReadingKey(props.sensorReadings), + ].join(":"); +}; + export const ImageTexture = (props: ImageTextureProps) => { const extents = soilSurfaceExtents(props.config); const width = extents.x.max - extents.x.min; @@ -98,10 +132,8 @@ export const ImageTexture = (props: ImageTextureProps) => { const textureHeight = height >= width ? textureSize : Math.max(1, Math.round(textureSize * height / width)); - const textureKey = [ - extents.x.min, extents.x.max, - extents.y.min, extents.y.max, - ].join(":"); + const textureKey = perfMeasure("imageTextureSetupMs", () => + getImageTextureKey(props)); const { bedXOffset, bedYOffset, bedWallThickness } = props.config; const soilTexture = useTexture(ASSETS.textures.soil + "?=soilT"); const color = getColorFromBrightness(props.config.soilBrightness); @@ -109,21 +141,27 @@ export const ImageTexture = (props: ImageTextureProps) => { const designer = addPlantProps?.designer; const getConfigValue = addPlantProps?.getConfigValue; const visible = !!addPlantProps?.getConfigValue(BooleanSetting.show_images); - const filteredImages = filterImages({ - visible, - designer, - images, - getConfigValue, - calibrationZ: "" + props.config.imgCalZ, - }); - const imageArray = filteredImages.filter(img => !img.highlighted); - const lastImageArray = filteredImages.filter(img => img.highlighted); + const { imageArray, lastImageArray } = + perfMeasure("imageTextureSetupMs", () => { + const filteredImages = filterImages({ + visible, + designer, + images, + getConfigValue, + calibrationZ: "" + props.config.imgCalZ, + }); + return { + imageArray: filteredImages.filter(img => !img.highlighted), + lastImageArray: filteredImages.filter(img => img.highlighted), + }; + }); const highlightActive = lastImageArray[0]?.highlighted; const commonProps = { width, height, bedWallThickness }; const mirrorTextureProps = getMirrorTextureProps(props.config); return { const texture = useTexture(url); const i = (texture.source?.data ?? texture.image) as HTMLImageElement | undefined; if (!i) { return; } - const aspect = i.width / i.height; - const height = i.height * config.imgScale; - const width = height * aspect; - if (!props.image.highlighted && - !imageSizeCheck({ width: i.width, height: i.height }, - { x: "" + config.imgCenterX, y: "" + config.imgCenterY })) { return; } - - const alreadyRotated = isRotated(props.image.body.meta.name); - const initialRotation = alreadyRotated ? 0 : config.imgRotation; - const rotation = (initialRotation + extraRotation(config)) * Math.PI / 180; - - return ; + return perfMeasure("imageWrapperSetupMs", () => { + const aspect = i.width / i.height; + const height = i.height * config.imgScale; + const width = height * aspect; + if (!props.image.highlighted && + !imageSizeCheck({ width: i.width, height: i.height }, + { x: "" + config.imgCenterX, y: "" + config.imgCenterY })) { return; } + + const alreadyRotated = isRotated(props.image.body.meta.name); + const initialRotation = alreadyRotated ? 0 : config.imgRotation; + const rotation = (initialRotation + extraRotation(config)) * Math.PI / 180; + + return ; + }); }; export const extraRotation = (config: Config) => { diff --git a/frontend/three_d_garden/garden/moisture_texture.tsx b/frontend/three_d_garden/garden/moisture_texture.tsx index e75e333aac..56a5ed277b 100644 --- a/frontend/three_d_garden/garden/moisture_texture.tsx +++ b/frontend/three_d_garden/garden/moisture_texture.tsx @@ -1,7 +1,10 @@ import React from "react"; import { Config } from "../config"; -import { Instance, Instances, Sphere } from "@react-three/drei"; -import { BoxGeometry, Group, MeshBasicMaterial } from "../components"; +import { Sphere } from "@react-three/drei"; +import { + BoxGeometry, Group, InstancedMesh as InstancedMeshComponent, + MeshBasicMaterial, +} from "../components"; import { TaggedSensor, TaggedSensorReading } from "farmbot"; import { threeSpace, zZero } from "../helpers"; import { @@ -10,7 +13,8 @@ import { import { filterMoistureReadings, getMoistureColor, } from "../../farm_designer/map/layers/sensor_readings/sensor_readings_layer"; -import { InstancedBufferAttribute, InstancedMesh } from "three"; +import { Color, Matrix4 } from "three"; +import { perfMeasure } from "../../performance/perf"; export interface MoistureSurfaceProps { position: [number, number, number]; @@ -24,32 +28,68 @@ export interface MoistureSurfaceProps { showMoistureMap: boolean; } +interface MoistureInstanceBuffers { + matrices: Float32Array; + colors: Float32Array; + opacities: Float32Array; +} + export const MoistureSurface = (props: MoistureSurfaceProps) => { - const { readings: moistureReadings } = - filterMoistureReadings(props.sensorReadings, props.sensors); - const options = { - stepSize: props.config.interpolationStepSize, - useNearest: props.config.interpolationUseNearest, - power: props.config.interpolationPower, - }; - generateData({ - kind: "SensorReading", - points: moistureReadings, - gridSize: { x: props.config.bedLengthOuter, y: props.config.bedWidthOuter }, - getColor: getMoistureColor, - options, - }); - const data = getInterpolationData("SensorReading"); - // eslint-disable-next-line no-null/no-null - const ref = React.useRef(null); - React.useEffect(() => { - const opacities = new Float32Array(data.length); - data.map((d, i) => { - opacities[i] = getMoistureColor(d.z).a; + const { + interpolationStepSize, + interpolationUseNearest, + interpolationPower, + bedLengthOuter, + bedWidthOuter, + } = props.config; + const options = React.useMemo(() => ({ + stepSize: interpolationStepSize, + useNearest: interpolationUseNearest, + power: interpolationPower, + }), [ + interpolationPower, + interpolationStepSize, + interpolationUseNearest, + ]); + const data = React.useMemo(() => perfMeasure("moistureSurfaceMs", () => { + if (!props.showMoistureMap) { return []; } + const { readings: moistureReadings } = + filterMoistureReadings(props.sensorReadings, props.sensors); + generateData({ + kind: "SensorReading", + points: moistureReadings, + gridSize: { + x: bedLengthOuter, + y: bedWidthOuter, + }, + getColor: getMoistureColor, + options, }); - ref.current?.geometry?.setAttribute("instanceOpacity", - new InstancedBufferAttribute(opacities, 1)); - }, [data]); + return getInterpolationData("SensorReading"); + }), [ + bedLengthOuter, + bedWidthOuter, + options, + props.sensorReadings, + props.sensors, + props.showMoistureMap, + ]); + const buffers = React.useMemo(() => + perfMeasure("moistureInstanceNodesMs", () => { + const matrices = new Float32Array(data.length * 16); + const colors = new Float32Array(data.length * 3); + const opacities = new Float32Array(data.length); + const matrix = new Matrix4(); + const instanceColor = new Color(); + data.map((d, i) => { + const color = getMoistureColor(d.z); + matrix.identity().setPosition(d.x, d.y, d.z / 2); + matrix.toArray(matrices, i * 16); + instanceColor.set(color.rgb).toArray(colors, i * 3); + opacities[i] = color.a; + }); + return { matrices, colors, opacities }; + }), [data]); return {props.showMoistureReadings && { readingZOverride={props.readingZOverride} readings={props.sensorReadings} />} {props.showMoistureMap && - + + + + args={[options.stepSize, options.stepSize, options.stepSize]}> + + { shader.vertexShader = ` @@ -81,14 +133,7 @@ export const MoistureSurface = (props: MoistureSurfaceProps) => { "vec4 diffuseColor = vec4( diffuse, opacity );", "vec4 diffuseColor = vec4( diffuse, opacity * vInstanceOpacity );"); }} /> - {data.map(p => { - const { x, y, z } = p; - return ; - })} - } + } ; }; diff --git a/frontend/three_d_garden/garden/plant_instances.tsx b/frontend/three_d_garden/garden/plant_instances.tsx index 74762020cb..618133b7db 100644 --- a/frontend/three_d_garden/garden/plant_instances.tsx +++ b/frontend/three_d_garden/garden/plant_instances.tsx @@ -1,10 +1,12 @@ import React from "react"; import { - InstancedMesh as InstancedMeshType, + InstancedMesh as ThreeInstancedMesh, Matrix4, Quaternion, Vector3, MeshBasicMaterial as ThreeMeshBasicMaterial, + type Intersection, + type Raycaster, } from "three"; import { ThreeEvent, useFrame } from "@react-three/fiber"; import { useNavigate } from "react-router"; @@ -24,9 +26,10 @@ import { getPlantIconTextureUrl, } from "./plant_icon_atlas"; import { Mode } from "../../farm_designer/map/interfaces"; -import moment from "moment"; -import { calcSunCoordinate, calcSunI, getCycleLength } from "./sun"; -import { instancedMeshKey } from "./instanced_mesh_key"; +import { + calcSunCoordinate, calcSunI, getAnimatedSeasonDate, +} from "./sun"; +import { clickWasDragged } from "../click_event"; export interface PlantInstancesProps { plants: ThreeDGardenPlant[]; @@ -35,17 +38,40 @@ export interface PlantInstancesProps { visible?: boolean; startTimeRef?: React.RefObject; dispatch?: Function; + iconCapacities?: Record; } interface PlantIconInstancesProps extends PlantInstancesProps { icon: string; plants: ThreeDGardenPlant[]; plantIndexes: number[]; + capacity: number; +} + +interface PlantIconUpdateState { + lastCameraQuaternion: Quaternion; + hasCameraQuaternion: boolean; + needsMatrixUpdate: boolean; } +const newPlantIconUpdateState = (): PlantIconUpdateState => ({ + lastCameraQuaternion: new Quaternion(), + hasCameraQuaternion: false, + needsMatrixUpdate: true, +}); + export const plantIconBrightness = (sunFactor?: number) => Math.max(0.25, sunFactor ?? 1); +const plantIconRaycast = function ( + this: ThreeInstancedMesh, + raycaster: Raycaster, + intersects: Intersection[], +) { + if (HOVER_OBJECT_MODES.includes(getMode())) { return; } + ThreeInstancedMesh.prototype.raycast.call(this, raycaster, intersects); +}; + const PlantIconInstances = (props: PlantIconInstancesProps) => { const { config, plants, icon, visible, startTimeRef, dispatch, getZ, plantIndexes, @@ -57,10 +83,20 @@ const PlantIconInstances = (props: PlantIconInstancesProps) => { () => getPlantIconTexture(baseTexture, icon), [baseTexture, icon]); // eslint-disable-next-line no-null/no-null - const instancedRef = React.useRef(null); + const instancedRef = React.useRef(null); // eslint-disable-next-line no-null/no-null const materialRef = React.useRef(null); const lastBrightness = React.useRef(undefined); + const updateStateRef = + React.useRef(newPlantIconUpdateState()); + const getUpdateState = () => { + const current = + updateStateRef.current as Partial | undefined; + if (!current?.lastCameraQuaternion) { + updateStateRef.current = newPlantIconUpdateState(); + } + return updateStateRef.current; + }; const tempMatrix = React.useMemo(() => new Matrix4(), []); const tempPosition = React.useMemo(() => new Vector3(), []); const tempScale = React.useMemo(() => new Vector3(), []); @@ -71,16 +107,26 @@ const PlantIconInstances = (props: PlantIconInstancesProps) => { + getZ(plant.x, plant.y) + size / 2, [config, getZ]); + React.useEffect(() => { + const updateState = getUpdateState(); + updateState.needsMatrixUpdate = true; + lastBrightness.current = undefined; + }, [config, getZ, plants, startTimeRef]); + + // eslint-disable-next-line complexity useFrame(state => { const mesh = instancedRef.current; - if (!mesh) { return; } + if (!mesh || visible === false) { return; } + if (plants.length == 0) { return; } + const updateState = getUpdateState(); + const seasonAnimating = !!(config.animateSeasons && startTimeRef); + const cameraChanged = !updateState.hasCameraQuaternion + || !updateState.lastCameraQuaternion.equals(state.camera.quaternion); let sunFactor = calcSunI(config.sunInclination); - if (config.animateSeasons && startTimeRef) { - const totalCycle = getCycleLength(config.plants); + if (seasonAnimating) { const currentTime = performance.now() / 1000; const t = currentTime - (startTimeRef.current || 0); - const timeOffset = Math.min(t / totalCycle, 1) * 24 * 60 * 60; - const date = moment().utc().startOf("day").add(timeOffset, "seconds").toDate(); + const date = getAnimatedSeasonDate(config.plants, t); sunFactor = calcSunI(calcSunCoordinate(date, 0, 52, 0).inclination); } const brightness = plantIconBrightness(sunFactor); @@ -90,6 +136,9 @@ const PlantIconInstances = (props: PlantIconInstancesProps) => { materialRef.current.color.setScalar(brightness); lastBrightness.current = brightness; } + if (!updateState.needsMatrixUpdate && !seasonAnimating && !cameraChanged) { + return; + } tempQuaternion.copy(state.camera.quaternion); const currentTime = performance.now() / 1000; const t = startTimeRef ? currentTime - (startTimeRef.current || 0) : 0; @@ -108,9 +157,13 @@ const PlantIconInstances = (props: PlantIconInstancesProps) => { mesh.setMatrixAt(index, tempMatrix); }); mesh.instanceMatrix.needsUpdate = true; + updateState.lastCameraQuaternion.copy(state.camera.quaternion); + updateState.hasCameraQuaternion = true; + updateState.needsMatrixUpdate = false; }); const onClick = (event: ThreeEvent) => { + if (clickWasDragged(event)) { return; } const instanceId = event.instanceId; if (isUndefined(instanceId)) { return; } const plant = plants[instanceId]; @@ -122,11 +175,13 @@ const PlantIconInstances = (props: PlantIconInstancesProps) => { }; return @@ -138,9 +193,22 @@ const PlantIconInstances = (props: PlantIconInstancesProps) => { ; }; -export const PlantInstances = (props: PlantInstancesProps) => { +export const PlantInstances = React.memo((props: PlantInstancesProps) => { const instances = React.useMemo(() => { const iconInstances: Record = {}; + Object.entries(props.iconCapacities || {}).map(([icon, capacity]) => { + iconInstances[icon] = { + config: props.config, + dispatch: props.dispatch, + getZ: props.getZ, + icon, + plants: [], + plantIndexes: [], + capacity, + startTimeRef: props.startTimeRef, + visible: props.visible, + }; + }); props.plants.forEach((plant, index) => { const instance = iconInstances[plant.icon]; if (instance) { @@ -148,20 +216,39 @@ export const PlantInstances = (props: PlantInstancesProps) => { instance.plantIndexes.push(index); } else { iconInstances[plant.icon] = { - ...props, + config: props.config, + dispatch: props.dispatch, + getZ: props.getZ, icon: plant.icon, plants: [plant], plantIndexes: [index], + capacity: 0, + startTimeRef: props.startTimeRef, + visible: props.visible, }; } }); - return Object.values(iconInstances); - }, [props]); + return Object.values(iconInstances).map(instance => ({ + ...instance, + capacity: Math.max( + instance.plants.length, + props.iconCapacities?.[instance.icon] || 0, + ), + })); + }, [ + props.config, + props.dispatch, + props.getZ, + props.iconCapacities, + props.plants, + props.startTimeRef, + props.visible, + ]); return <> {instances.map(instance => )} ; -}; +}); diff --git a/frontend/three_d_garden/garden/plants.tsx b/frontend/three_d_garden/garden/plants.tsx index 34c9527ee3..37f5438b5b 100644 --- a/frontend/three_d_garden/garden/plants.tsx +++ b/frontend/three_d_garden/garden/plants.tsx @@ -7,10 +7,12 @@ import { Group as GroupType, Color, WebGLProgramParametersWithUniforms, - InstancedMesh as InstancedMeshType, + InstancedMesh as ThreeInstancedMesh, Matrix4, Quaternion, InstancedBufferAttribute, + type Raycaster, + type Intersection, } from "three"; import { getGardenPositionFunc, @@ -32,7 +34,8 @@ import { import { ActivePositionRef } from "../bed/objects/pointer_objects"; import { Mode } from "../../farm_designer/map/interfaces"; import { findCrop } from "../../crops/find"; -import { instancedMeshKey } from "./instanced_mesh_key"; +import { perfMeasure } from "../../performance/perf"; +import { clickWasDragged } from "../click_event"; export interface ThreeDGardenPlant { id?: number | undefined; @@ -102,20 +105,52 @@ export interface PlantSpreadInstancesProps { dispatch?: Function; activePositionRef: ActivePositionRef; spreadVisible: boolean; + instanceCapacity?: number; } -export const PlantSpreadInstances = (props: PlantSpreadInstancesProps) => { +interface PlantSpreadUpdateState { + needsInstanceUpdate: boolean; + lastUpdateKey: string; +} + +const plantSpreadRaycast = function ( + this: ThreeInstancedMesh, + raycaster: Raycaster, + intersects: Intersection[], +) { + if (HOVER_OBJECT_MODES.includes(getMode())) { return; } + ThreeInstancedMesh.prototype.raycast.call(this, raycaster, intersects); +}; + +const newPlantSpreadUpdateState = (): PlantSpreadUpdateState => ({ + needsInstanceUpdate: true, + lastUpdateKey: "", +}); + +export const PlantSpreadInstances = React.memo((props: PlantSpreadInstancesProps) => { const { config, plants, getZ, visible, dispatch, activePositionRef, spreadVisible, } = props; + const instanceCapacity = Math.max(props.instanceCapacity || 0, plants.length); const navigate = useNavigate(); // eslint-disable-next-line no-null/no-null - const instancedRef = React.useRef(null); + const instancedRef = React.useRef(null); const tempMatrix = React.useMemo(() => new Matrix4(), []); const tempPosition = React.useMemo(() => new Vector3(), []); const tempScale = React.useMemo(() => new Vector3(), []); const tempQuaternion = React.useMemo(() => new Quaternion(), []); const tempColor = React.useMemo(() => new Color(), []); + const updateStateRef = + React.useRef(newPlantSpreadUpdateState()); + const getUpdateState = () => { + const current = + updateStateRef.current as Partial | undefined; + if (typeof current?.needsInstanceUpdate != "boolean" || + typeof current?.lastUpdateKey != "string") { + updateStateRef.current = newPlantSpreadUpdateState(); + } + return updateStateRef.current; + }; const get3DPosition = React.useMemo(() => get3DPositionFunc(config), [config]); // eslint-disable-next-line react-hooks/exhaustive-deps, react-hooks/use-memo const boundsCenter = React.useMemo(getBoundsCenter(config), []); @@ -135,12 +170,14 @@ export const PlantSpreadInstances = (props: PlantSpreadInstancesProps) => { const activeDragSpread = editPlantMode ? currentPlant?.spread : findCrop(Path.getCropSlug()).spread; + const hasTransientPlant = React.useMemo(() => + plants.some(plant => !plant.id), [plants]); - const ensureInstanceColor = React.useCallback((mesh: InstancedMeshType) => { + const ensureInstanceColor = React.useCallback((mesh: ThreeInstancedMesh) => { const needsResize = !mesh.instanceColor - || mesh.instanceColor.count != plants.length; + || mesh.instanceColor.count != instanceCapacity; if (needsResize) { - const colors = new Float32Array(plants.length * 3); + const colors = new Float32Array(instanceCapacity * 3); colors.fill(1); mesh.instanceColor = new InstancedBufferAttribute(colors, 3); if (mesh.geometry) { @@ -154,7 +191,7 @@ export const PlantSpreadInstances = (props: PlantSpreadInstancesProps) => { material.needsUpdate = true; } } - }, [plants.length]); + }, [instanceCapacity]); React.useLayoutEffect(() => { const mesh = instancedRef.current; @@ -162,69 +199,95 @@ export const PlantSpreadInstances = (props: PlantSpreadInstancesProps) => { ensureInstanceColor(mesh); }, [ensureInstanceColor]); + React.useEffect(() => { + const updateState = getUpdateState(); + updateState.needsInstanceUpdate = true; + }, [activeDragSpread, config, getZ, plants]); + + // eslint-disable-next-line complexity useFrame(state => { const mesh = instancedRef.current; if (!mesh || visible === false) { return; } + const updateState = getUpdateState(); + const clickToAddMode = getMode() == Mode.clickToAdd; + const spreadActive = + spreadVisible || editPlantMode || clickToAddMode || hasTransientPlant; + if (!spreadActive && !updateState.needsInstanceUpdate) { return; } ensureInstanceColor(mesh); tempQuaternion.copy(state.camera.quaternion); - const worldPos = activePositionRef.current || { x: -10000, y: -10000 }; - const activePointer = getGardenPositionFunc(config)(worldPos); const active = editPlantMode ? { x: currentPlant?.x || -10000, y: currentPlant?.y || -10000, } - : { - x: activePointer.x, - y: activePointer.y, - }; - const clickToAddMode = getMode() == Mode.clickToAdd; - plants.forEach((plant, index) => { - const spreadRadii = getSpreadRadii({ - activeDragSpread, - inactiveSpread: plant.spread, - radius: plant.size / 2, - }); - const scale = (spreadVisible || !plant.id || editPlantMode) - ? spreadRadii.inactive - : 0; - const position = get3DPosition({ x: plant.x, y: plant.y }); - tempPosition.set( - position.x, - position.y, - getPlantZ(plant.size, plant), - ); - tempScale.set(scale, scale, scale); - tempMatrix.compose(tempPosition, tempQuaternion, tempScale); - mesh.setMatrixAt(index, tempMatrix); - if (mesh.setColorAt) { - const overlap = getSpreadOverlap({ - spreadRadii, - activeDragXY: { - x: round(active.x), - y: round(active.y), - z: 0, - }, - plantXY: { - x: round(plant.x), - y: round(plant.y), - z: 0, - }, + : getGardenPositionFunc(config)( + activePositionRef.current || { x: -10000, y: -10000 }); + const activeKey = (clickToAddMode || editPlantMode) + ? `${round(active.x)}:${round(active.y)}` + : ""; + const updateKey = [ + spreadVisible, + editPlantMode, + clickToAddMode, + hasTransientPlant, + plantId, + activeDragSpread || "", + activeKey, + ].join(":"); + if (!updateState.needsInstanceUpdate && + updateState.lastUpdateKey == updateKey) { return; } + perfMeasure("spreadFrameUpdateMs", () => { + plants.forEach((plant, index) => { + const spreadRadii = getSpreadRadii({ + activeDragSpread, + inactiveSpread: plant.spread, + radius: plant.size / 2, }); - const color = (plant.id && (plantId != plant.id)) - ? overlap.color.rgb - : [1, 1, 1]; - const insideColor = - (clickToAddMode || editPlantMode) ? color : [0, 1, 0]; - tempColor.setRGB(insideColor[0], insideColor[1], insideColor[2]); - mesh.setColorAt(index, tempColor); - } + const scale = (spreadVisible || !plant.id || editPlantMode) + ? spreadRadii.inactive + : 0; + const position = get3DPosition({ x: plant.x, y: plant.y }); + tempPosition.set( + position.x, + position.y, + getPlantZ(plant.size, plant), + ); + tempScale.set(scale, scale, scale); + tempMatrix.compose(tempPosition, tempQuaternion, tempScale); + mesh.setMatrixAt(index, tempMatrix); + if (mesh.setColorAt) { + let insideColor = [0, 1, 0]; + if (clickToAddMode || editPlantMode) { + const overlap = getSpreadOverlap({ + spreadRadii, + activeDragXY: { + x: round(active.x), + y: round(active.y), + z: 0, + }, + plantXY: { + x: round(plant.x), + y: round(plant.y), + z: 0, + }, + }); + insideColor = (plant.id && (plantId != plant.id)) + ? overlap.color.rgb + : [1, 1, 1]; + } + tempColor.setRGB(insideColor[0], insideColor[1], insideColor[2]); + mesh.setColorAt(index, tempColor); + } + }); + mesh.instanceMatrix.needsUpdate = true; + if (mesh.instanceColor) { mesh.instanceColor.needsUpdate = true; } }); - mesh.instanceMatrix.needsUpdate = true; - if (mesh.instanceColor) { mesh.instanceColor.needsUpdate = true; } + updateState.needsInstanceUpdate = false; + updateState.lastUpdateKey = updateKey; }); const onClick = (event: ThreeEvent) => { + if (clickWasDragged(event)) { return; } const instanceId = event.instanceId; if (isUndefined(instanceId)) { return; } const plant = plants[instanceId]; @@ -236,11 +299,13 @@ export const PlantSpreadInstances = (props: PlantSpreadInstancesProps) => { }; return { }} depthWrite={false} /> ; -}; +}); export const getBoundsCenter = (config: Config) => () => diff --git a/frontend/three_d_garden/garden/point.tsx b/frontend/three_d_garden/garden/point.tsx index d60892698d..3243026010 100644 --- a/frontend/three_d_garden/garden/point.tsx +++ b/frontend/three_d_garden/garden/point.tsx @@ -1,9 +1,20 @@ import React from "react"; import { SpecialStatus, TaggedGenericPointer, Xyz } from "farmbot"; import { Config } from "../config"; -import { Group, MeshPhongMaterial } from "../components"; +import { + Group, InstancedMesh, MeshPhongMaterial, +} from "../components"; import { Cylinder, Sphere, Torus } from "@react-three/drei"; -import { DoubleSide } from "three"; +import { + DoubleSide, + Euler, + InstancedMesh as InstancedMeshType, + Matrix4, + Mesh as ThreeMesh, + Quaternion, + Vector3, +} from "three"; +import { ThreeEvent } from "@react-three/fiber"; import { getWorldPositionFunc } from "../helpers"; import { useNavigate } from "react-router"; import { Path } from "../../internal_urls"; @@ -17,6 +28,7 @@ import { HOVER_OBJECT_MODES, RenderOrder } from "../constants"; import { BillboardRef, ImageRef, RadiusRef, TorusRef, } from "../bed/objects/pointer_objects"; +import { clickWasDragged } from "../click_event"; const POINT_PIN_RADIUS = 12.5; const POINT_PIN_HEIGHT = 50; @@ -47,7 +59,8 @@ export const Point = (props: PointProps) => { y: point.body.y, z: props.getZ(point.body.x, point.body.y), }} - onClick={() => { + onClick={(event) => { + if (clickWasDragged(event)) { return; } if (point.body.id && !isUndefined(props.dispatch) && props.visible && !HOVER_OBJECT_MODES.includes(getMode())) { props.dispatch(setPanelOpen(true)); @@ -60,6 +73,193 @@ export const Point = (props: PointProps) => { />; }; +interface PointInstance { + point: TaggedGenericPointer; + position: [number, number, number]; + radius: number; +} + +interface PointInstanceBucket { + color: string | undefined; + alpha: number; + points: PointInstance[]; + ringPoints: PointInstance[]; +} + +export interface PointInstancesProps { + points: TaggedGenericPointer[]; + config: Config; + dispatch?: Function; + visible: boolean; + getZ(x: number, y: number): number; +} + +const pointAlpha = (point: TaggedGenericPointer) => + point.specialStatus !== SpecialStatus.SAVED ? 0.5 : 1; + +const pointBucketKey = (point: TaggedGenericPointer) => + `${point.body.meta.color || ""}-${pointAlpha(point)}`; + +const getPointInstanceBuckets = ( + points: TaggedGenericPointer[], + config: Config, + getZ: (x: number, y: number) => number, +) => { + const getWorldPosition = getWorldPositionFunc(config); + const buckets: Record = {}; + points.forEach(point => { + const alpha = pointAlpha(point); + const key = pointBucketKey(point); + const instance = { + point, + position: getWorldPosition({ + x: point.body.x, + y: point.body.y, + z: getZ(point.body.x, point.body.y), + }), + radius: point.body.radius, + }; + buckets[key] ||= { + color: point.body.meta.color, + alpha, + points: [], + ringPoints: [], + }; + buckets[key].points.push(instance); + if (point.body.radius > 0) { buckets[key].ringPoints.push(instance); } + }); + return Object.values(buckets); +}; + +interface PointInstanceBucketProps extends PointInstancesProps { + bucket: PointInstanceBucket; +} + +const PointBucketInstances = (props: PointInstanceBucketProps) => { + const { bucket, dispatch, visible } = props; + const navigate = useNavigate(); + // eslint-disable-next-line no-null/no-null + const pinRef = React.useRef(null); + // eslint-disable-next-line no-null/no-null + const sphereRef = React.useRef(null); + // eslint-disable-next-line no-null/no-null + const ringRef = React.useRef(null); + const tempMatrix = React.useMemo(() => new Matrix4(), []); + const tempPosition = React.useMemo(() => new Vector3(), []); + const pinRotation = React.useMemo(() => + new Quaternion().setFromEuler(new Euler(Math.PI / 2, 0, 0)), []); + const noRotation = React.useMemo(() => new Quaternion(), []); + const noScale = React.useMemo(() => new Vector3(1, 1, 1), []); + const ringScale = React.useMemo(() => new Vector3(), []); + + React.useEffect(() => { + const pinMesh = pinRef.current; + const sphereMesh = sphereRef.current; + if (!pinMesh?.setMatrixAt || !sphereMesh?.setMatrixAt) { return; } + bucket.points.forEach((instance, index) => { + const [x, y, z] = instance.position; + tempPosition.set(x, y, z + POINT_PIN_HEIGHT / 2); + tempMatrix.compose(tempPosition, pinRotation, noScale); + pinMesh.setMatrixAt(index, tempMatrix); + tempPosition.set(x, y, z + POINT_PIN_HEIGHT); + tempMatrix.compose(tempPosition, noRotation, noScale); + sphereMesh.setMatrixAt(index, tempMatrix); + }); + pinMesh.instanceMatrix.needsUpdate = true; + sphereMesh.instanceMatrix.needsUpdate = true; + }, [bucket.points, noRotation, noScale, pinRotation, tempMatrix, tempPosition]); + + React.useEffect(() => { + const ringMesh = ringRef.current; + if (!ringMesh?.setMatrixAt) { return; } + bucket.ringPoints.forEach((instance, index) => { + const [x, y, z] = instance.position; + tempPosition.set(x, y, z); + ringScale.set( + instance.radius, + instance.radius, + POINT_CYLINDER_SCALE_FACTOR, + ); + tempMatrix.compose(tempPosition, noRotation, ringScale); + ringMesh.setMatrixAt(index, tempMatrix); + }); + ringMesh.instanceMatrix.needsUpdate = true; + }, [bucket.ringPoints, noRotation, ringScale, tempMatrix, tempPosition]); + + const onClick = (instances: PointInstance[]) => + (event: ThreeEvent) => { + if (clickWasDragged(event)) { return; } + const instanceId = event.instanceId; + if (isUndefined(instanceId)) { return; } + const point = instances[instanceId]?.point; + if (point?.body.id && dispatch && visible && + !HOVER_OBJECT_MODES.includes(getMode())) { + dispatch(setPanelOpen(true)); + navigate(Path.points(point.body.id)); + } + }; + + return <> + + + + + + + + + {bucket.ringPoints.length > 0 && + + + + } + ; +}; + +export const PointInstances = React.memo((props: PointInstancesProps) => { + const buckets = React.useMemo( + () => getPointInstanceBuckets(props.points, props.config, props.getZ), + [props.points, props.config, props.getZ]); + return <> + {buckets.map(bucket => + )} + ; +}); + export interface DrawnPointProps { designer: DesignerState; usePosition: boolean; @@ -95,7 +295,7 @@ export const DrawnPoint = (props: DrawnPointProps) => { interface PointBaseProps { pointName: string; position?: Record; - onClick?: () => void; + onClick?: (event: ThreeEvent) => void; color: string | undefined; radius: number; alpha: number; @@ -138,7 +338,7 @@ const PointBase = (props: PointBaseProps) => { opacity={1 * alpha} /> - {radius > 0 && + {(radius > 0 || torusRef) && { + (torusRef as React.MutableRefObject).current = node; +}; + const HollowCylinder = ( { radius, color, alpha, torusRef }: HollowCylinderProps, ) => { + const setTorusRef = React.useCallback((node: ThreeMesh | null) => { + if (!torusRef) { return; } + const maybeMesh = node as Partial | null; + if (!node || maybeMesh?.scale) { + setTorusRefCurrent(torusRef, node); + } + }, [torusRef]); return torusRef ? diff --git a/frontend/three_d_garden/garden/solar.tsx b/frontend/three_d_garden/garden/solar.tsx index be72a50ddf..3a10432670 100644 --- a/frontend/three_d_garden/garden/solar.tsx +++ b/frontend/three_d_garden/garden/solar.tsx @@ -1,9 +1,18 @@ import React from "react"; -import { Shape } from "three"; -import { Extrude, Line } from "@react-three/drei"; +import { + DoubleSide, ExtrudeGeometry, InstancedMesh as ThreeInstancedMesh, + Object3D, Shape, +} from "three"; +import { Line } from "@react-three/drei"; +import { animated, useSpring } from "@react-spring/three"; +import { SpringValue } from "@react-spring/core"; import { threeSpace } from "../helpers"; import { Config } from "../config"; -import { Group, Mesh, BoxGeometry, MeshPhongMaterial } from "../components"; +import { + Group, Mesh, BoxGeometry, MeshPhongMaterial, InstancedMesh, +} from "../components"; +import { easeInOutCubic, useFocusTransition } from "../focus_transition"; +import { RenderOrder } from "../constants"; export interface SolarProps { config: Config; @@ -12,6 +21,11 @@ export interface SolarProps { const panelWidth = 540; const panelLength = 1040; +const panelDepth = 30; +const cellDepth = 2; +const cellZ = panelDepth / 2 + cellDepth + 1; +const AnimatedMeshPhongMaterial = animated(MeshPhongMaterial); +const AnimatedLine = animated(Line); const cell2D = () => { const cellSize = 95; @@ -28,8 +42,8 @@ const cell2D = () => { return path; }; -const cellArray = () => { - const cells = []; +const cellPositions = () => { + const positions: [number, number, number][] = []; const cellSize = 100; const cellsWide = Math.floor(panelWidth / cellSize); const cellsLong = Math.floor(panelLength / cellSize); @@ -38,32 +52,85 @@ const cellArray = () => { for (let y = 0; y < cellsLong; y++) { const xPos = x * cellSize - (panelWidth / 2) + 20 + 2.5; const yPos = y * cellSize - (panelLength / 2) + 20 + 2.5; - cells.push( - - - - - ); + positions.push([xPos, yPos, cellZ]); } } - return cells; + return positions; }; -const SolarPanel = () => { +const CELL_POSITIONS = cellPositions(); + +interface SolarMaterialProps { + opacity: number | SpringValue; + color: string; + side?: typeof DoubleSide; +} + +const SolarMaterial = (props: SolarMaterialProps) => + ; + +const SolarCells = (props: { opacity: SolarMaterialProps["opacity"] }) => { + const geometry = React.useMemo( + () => new ExtrudeGeometry(cell2D(), { + steps: 1, + depth: cellDepth, + bevelEnabled: false, + }), + [], + ); + const setRef = React.useCallback((mesh: ThreeInstancedMesh | null) => { + if (!mesh || typeof mesh.setMatrixAt != "function") { return; } + const dummy = new Object3D(); + CELL_POSITIONS.map((position, index) => { + dummy.position.set(...position); + dummy.updateMatrix(); + mesh.setMatrixAt(index, dummy.matrix); + }); + mesh.instanceMatrix.needsUpdate = true; + }, []); + + return + + ; +}; + +const SolarPanel = (props: { opacity: SolarMaterialProps["opacity"] }) => { return - - - + + + - {cellArray()} + ; }; export const Solar = (props: SolarProps) => { const { config } = props; const zGround = -config.bedZOffset - config.bedHeight; - return + const transition = useFocusTransition(); + const visible = config.solar || props.activeFocus == "What you need to provide"; + const { opacity } = useSpring({ + opacity: visible ? 1 : 0, + immediate: !transition.enabled, + config: { + duration: transition.duration, + easing: easeInOutCubic, + }, + }); + const rendered = transition.enabled || visible; + if (!rendered) { return undefined; } + + return { ]} rotation={[0, 0, Math.PI]}> - + - + - { zGround + 20, ]]} color={"yellow"} + transparent={true} + opacity={opacity} lineWidth={5} /> ; }; diff --git a/frontend/three_d_garden/garden/sun.tsx b/frontend/three_d_garden/garden/sun.tsx index 7ea48ccfc9..a27634294a 100644 --- a/frontend/three_d_garden/garden/sun.tsx +++ b/frontend/three_d_garden/garden/sun.tsx @@ -15,7 +15,7 @@ import { useFrame } from "@react-three/fiber"; import SunCalc from "suncalc"; import { range } from "lodash"; import moment from "moment"; -import { SEASON_DURATIONS } from "../../promo/constants"; +import { Season, SEASON_DURATIONS } from "../../promo/constants"; import { Line2 } from "three/examples/jsm/lines/Line2.js"; import { ASSETS, BigDistance } from "../constants"; @@ -23,32 +23,33 @@ const shadowBias = -0.0005; const shadowRadius = 8; const shadowBlurSamples = 8; const shadowBuffer = 1000; -const SUN_COLOR = ["#FFD700", "#FFEA00", "#FFF700", "#FFE066"]; +const SUN_COLOR = "#FFD700"; +const DAY_SECONDS = 24 * 60 * 60; +const SUN_TIME_STEP_SECONDS = 60; +const BELOW_HORIZON_SUN_SPEED = 10; +const BELOW_HORIZON_SPEED_INCLINATION = -10; +const sunAnimationCache: Record = {}; +const SEASON_SUN_DATES: Record = { + [Season.Spring]: [2, 20], + [Season.Summer]: [5, 21], + [Season.Fall]: [8, 22], + [Season.Winter]: [11, 21], +}; export const getCycleLength = (season: string) => SEASON_DURATIONS[season] || 20; +interface SunAnimationSample { + animationSeconds: number; + sunSeconds: number; +} + export interface SunProps { config: Config; startTimeRef?: React.RefObject; skyRef: React.RefObject; } -const SUN_COUNT = 1; -const offset = 50; -const SUN_OFFSETS: [number, number][] = [ - [0, 0], - [0, offset], - [offset, offset], - [offset, 0], -]; - -const offsetSunPos = - (sunPos: Vector3, index: number): [number, number, number] => { - const offset = SUN_OFFSETS[index]; - return [sunPos.x + offset[0], sunPos.y + offset[1], sunPos.z]; - }; - export const calcSunCoordinate = ( date: Date, heading: number, @@ -63,6 +64,51 @@ export const calcSunCoordinate = ( }; }; +export const getAnimatedSeasonDate = ( + season: string, + elapsedSeconds: number, + dayStart = moment().utc().startOf("day").toDate(), +) => { + const totalCycle = getCycleLength(season); + const clampedElapsed = Math.min(Math.max(elapsedSeconds, 0), totalCycle); + const seasonDayStart = getSeasonDayStart(season, dayStart); + const samples = getSunAnimationSamples(seasonDayStart); + const totalAnimationSeconds = samples[samples.length - 1].animationSeconds; + const targetAnimationSeconds = + clampedElapsed / totalCycle * totalAnimationSeconds; + const sample = samples.find(({ animationSeconds }) => + animationSeconds >= targetAnimationSeconds) || samples[samples.length - 1]; + const date = new Date(seasonDayStart.getTime() + sample.sunSeconds * 1000); + return date; +}; + +const getSeasonDayStart = (season: string, dayStart: Date) => { + const seasonDate = SEASON_SUN_DATES[season]; + if (!seasonDate) { return dayStart; } + const [month, day] = seasonDate; + return new Date(Date.UTC(2016, month, day)); +}; + +const getSunAnimationSamples = (dayStart: Date): SunAnimationSample[] => { + const cacheKey = dayStart.toISOString().slice(0, 10); + const cachedSamples = sunAnimationCache[cacheKey]; + if (cachedSamples) { return cachedSamples; } + const samples: SunAnimationSample[] = []; + let animationSeconds = 0; + for (let sunSeconds = 0; sunSeconds <= DAY_SECONDS; + sunSeconds += SUN_TIME_STEP_SECONDS) { + samples.push({ animationSeconds, sunSeconds }); + const date = new Date(dayStart.getTime() + sunSeconds * 1000); + const { inclination } = calcSunCoordinate(date, 0, 35, 0); + const speed = inclination < BELOW_HORIZON_SPEED_INCLINATION + ? BELOW_HORIZON_SUN_SPEED + : 1; + animationSeconds += SUN_TIME_STEP_SECONDS / speed; + } + sunAnimationCache[cacheKey] = samples; + return samples; +}; + const toRad = (degrees: number) => degrees * Math.PI / 180; const polarToCartesian = ( radius: number, @@ -140,17 +186,17 @@ export const Sun = (props: SunProps) => { config.sunAzimuth, BigDistance.sunActual); - const lightRefs = React.useRef<(ThreeDirectionalLight | null)[]>([]); - const sphereRefs = React.useRef<(Mesh | null)[]>([]); + // eslint-disable-next-line no-null/no-null + const lightRef = React.useRef(null); + // eslint-disable-next-line no-null/no-null + const debugSunRef = React.useRef(null); // eslint-disable-next-line no-null/no-null const sunRef = React.useRef(null); // eslint-disable-next-line no-null/no-null const sunFlatRef = React.useRef(null); // eslint-disable-next-line no-null/no-null const lineRef = React.useRef(null); - const [points, setPoints] = React.useState( - range(SUN_COUNT).map(index => new Vector3(...offsetSunPos(sunPos, index))), - ); + const [point, setPoint] = React.useState(sunPos); // eslint-disable-next-line no-null/no-null const starsRef = React.useRef(null); const origin = new Vector3(0, 0, 0); @@ -192,33 +238,22 @@ export const Sun = (props: SunProps) => { useFrame(() => { if (!config.animateSeasons || !props.startTimeRef) { return; } - const totalCycle = getCycleLength(config.plants); const currentTime = performance.now() / 1000; const t = currentTime - props.startTimeRef.current; - const timeOffset = Math.min(t / totalCycle, 1) * 24 * 60 * 60; - const date = moment().utc().startOf("day").add(timeOffset, "seconds").toDate(); - const { azimuth, inclination } = calcSunCoordinate(date, 0, 52, 0); + const date = getAnimatedSeasonDate(config.plants, t); + const { azimuth, inclination } = calcSunCoordinate(date, 0, 35, 0); const sunFactor = calcSunI(inclination); - const position = (index: number) => { - const sunPos = sunPosition(inclination, azimuth, BigDistance.sunActual); - return offsetSunPos(sunPos, index); - }; + const position = sunPosition(inclination, azimuth, BigDistance.sunActual); setSunSky(sunFactor, config.sun); - lightRefs.current.forEach((light, index) => { - if (light) { - light.position?.set(...position(index)); - light.intensity = - sunIntensity * config.sun / 100 * sunFactor; - } - }); + if (lightRef.current) { + lightRef.current.position?.set(position.x, position.y, position.z); + lightRef.current.intensity = + sunIntensity * config.sun / 100 * sunFactor; + } - sphereRefs.current.forEach((sphere, index) => { - if (sphere) { - sphere.position.set(...position(index)); - } - }); + debugSunRef.current?.position.set(position.x, position.y, position.z); const visualPos = sunPosition(inclination, azimuth, BigDistance.sunVisual); sunRef.current?.position?.set(visualPos.x, visualPos.y, visualPos.z); @@ -226,52 +261,40 @@ export const Sun = (props: SunProps) => { sunFlatRef.current?.position?.set(flatPos.x, flatPos.y, flatPos.z); if (lineRef.current) { - const newPoints = range(SUN_COUNT) - // eslint-disable-next-line @react-three/no-new-in-loop - .map(index => new Vector3(...position(index))); - setPoints(newPoints); + setPoint(position); } }); return - {range(SUN_COUNT).map(index => { - const position = offsetSunPos(sunPos, index); - const color = SUN_COLOR[index]; - const intensity = sunIntensity * config.sun / 100 * renderedSunFactor; - return - { - if (el) { lightRefs.current[index] = el; } - }} - intensity={intensity * 4 / SUN_COUNT} - color={sunColor} - castShadow={true} - shadow-bias={shadowBias} - shadow-radius={shadowRadius} - shadow-blurSamples={shadowBlurSamples} - shadow-mapSize-width={1024} - shadow-mapSize-height={1024} - shadow-camera-near={1} - shadow-camera-far={BigDistance.sunAffect} - shadow-camera-left={-shadowBounds} - shadow-camera-right={shadowBounds} - shadow-camera-top={shadowBounds} - shadow-camera-bottom={-shadowBounds} - position={position} - /> - {config.lightsDebug && - } - {config.lightsDebug && - t}> - { if (el) { sphereRefs.current[index] = el; } }} - args={[500, 16, 16]} - position={position}> - - - } - ; - })} + + {config.lightsDebug && + } + {config.lightsDebug && + t}> + + + + } { config.sunInclination, config.sunAzimuth, BigDistance.sunVisual)}> - + {config.lightsDebug && } @@ -287,7 +310,7 @@ export const Sun = (props: SunProps) => { ref={sunFlatRef} args={[500, 8, 8]} position={sunPosition(0, config.sunAzimuth, BigDistance.ground)}> - + } ; }; diff --git a/frontend/three_d_garden/garden/weed.tsx b/frontend/three_d_garden/garden/weed.tsx index fc8bc6b67a..78c8a0c31b 100644 --- a/frontend/three_d_garden/garden/weed.tsx +++ b/frontend/three_d_garden/garden/weed.tsx @@ -2,8 +2,17 @@ import React from "react"; import { TaggedWeedPointer, Xyz } from "farmbot"; import { Config } from "../config"; import { ASSETS, HOVER_OBJECT_MODES, RenderOrder } from "../constants"; -import { Group, MeshPhongMaterial } from "../components"; -import { Image, Billboard, Sphere } from "@react-three/drei"; +import { + Group, InstancedMesh, MeshBasicMaterial, MeshPhongMaterial, PlaneGeometry, +} from "../components"; +import { Image, Billboard, Sphere, useTexture } from "@react-three/drei"; +import { + InstancedMesh as InstancedMeshType, + Matrix4, + Quaternion, + Vector3, +} from "three"; +import { ThreeEvent, useFrame } from "@react-three/fiber"; import { getWorldPositionFunc } from "../helpers"; import { useNavigate } from "react-router"; import { Path } from "../../internal_urls"; @@ -11,6 +20,7 @@ import { isUndefined } from "lodash"; import { setPanelOpen } from "../../farm_designer/panel_header"; import { getMode } from "../../farm_designer/map/util"; import { RadiusRef, BillboardRef, ImageRef } from "../bed/objects/pointer_objects"; +import { clickWasDragged } from "../click_event"; export const WEED_IMG_SIZE_FRACTION = 0.89; @@ -28,7 +38,8 @@ export const Weed = (props: WeedProps) => { return { + onClick={(event) => { + if (clickWasDragged(event)) { return; } if (weed.body.id && !isUndefined(props.dispatch) && props.visible && !HOVER_OBJECT_MODES.includes(getMode())) { props.dispatch(setPanelOpen(true)); @@ -48,7 +59,7 @@ export const Weed = (props: WeedProps) => { interface WeedBaseProps { pointName: string; position?: Record; - onClick?: () => void; + onClick?: (event: ThreeEvent) => void; color: string | undefined; radius: number; alpha: number; @@ -99,3 +110,223 @@ export const WeedBase = (props: WeedBaseProps) => { ; }; + +interface WeedInstance { + weed: TaggedWeedPointer; + position: [number, number, number]; + weedSize: number; + iconSize: number; +} + +interface WeedColorBucket { + color: string | undefined; + weeds: WeedInstance[]; +} + +export interface WeedInstancesProps { + weeds: TaggedWeedPointer[]; + config: Config; + dispatch?: Function; + visible: boolean; + getZ(x: number, y: number): number; +} + +const weedSize = (weed: TaggedWeedPointer) => + weed.body.radius == 0 ? 50 : weed.body.radius; + +const getWeedInstances = ( + weeds: TaggedWeedPointer[], + config: Config, + getZ: (x: number, y: number) => number, +) => { + const getWorldPosition = getWorldPositionFunc(config); + return weeds.map(weed => { + const size = weedSize(weed); + return { + weed, + position: getWorldPosition({ + x: weed.body.x, + y: weed.body.y, + z: getZ(weed.body.x, weed.body.y), + }), + weedSize: size, + iconSize: size * WEED_IMG_SIZE_FRACTION, + }; + }); +}; + +const getWeedColorBuckets = (weeds: WeedInstance[]) => { + const buckets: Record = {}; + weeds.forEach(weed => { + const color = weed.weed.body.meta.color; + const key = color || ""; + buckets[key] ||= { color, weeds: [] }; + buckets[key].weeds.push(weed); + }); + return Object.values(buckets); +}; + +interface WeedIconUpdateState { + lastCameraQuaternion: Quaternion; + hasCameraQuaternion: boolean; + needsMatrixUpdate: boolean; +} + +const newWeedIconUpdateState = (): WeedIconUpdateState => ({ + lastCameraQuaternion: new Quaternion(), + hasCameraQuaternion: false, + needsMatrixUpdate: true, +}); + +const useNavigateToWeed = ( + dispatch: Function | undefined, + visible: boolean, +) => { + const navigate = useNavigate(); + return (weed: TaggedWeedPointer | undefined) => { + if (weed?.body.id && dispatch && visible && + !HOVER_OBJECT_MODES.includes(getMode())) { + dispatch(setPanelOpen(true)); + navigate(Path.weeds(weed.body.id)); + } + }; +}; + +interface WeedIconInstancesProps extends WeedInstancesProps { + weedInstances: WeedInstance[]; +} + +const WeedIconInstances = (props: WeedIconInstancesProps) => { + const { weedInstances, dispatch, visible } = props; + const texture = useTexture(ASSETS.other.weed); + const navigateToWeed = useNavigateToWeed(dispatch, visible); + // eslint-disable-next-line no-null/no-null + const instancedRef = React.useRef(null); + const updateStateRef = + React.useRef(newWeedIconUpdateState()); + const getUpdateState = () => { + const current = + updateStateRef.current as Partial | undefined; + if (!current?.lastCameraQuaternion) { + updateStateRef.current = newWeedIconUpdateState(); + } + return updateStateRef.current; + }; + const tempMatrix = React.useMemo(() => new Matrix4(), []); + const tempPosition = React.useMemo(() => new Vector3(), []); + const tempScale = React.useMemo(() => new Vector3(), []); + const tempQuaternion = React.useMemo(() => new Quaternion(), []); + + React.useEffect(() => { + getUpdateState().needsMatrixUpdate = true; + }, [weedInstances]); + + useFrame(state => { + const mesh = instancedRef.current; + if (!mesh?.setMatrixAt || visible === false) { return; } + const updateState = getUpdateState(); + const cameraChanged = !updateState.hasCameraQuaternion + || !updateState.lastCameraQuaternion.equals(state.camera.quaternion); + if (!updateState.needsMatrixUpdate && !cameraChanged) { return; } + tempQuaternion.copy(state.camera.quaternion); + weedInstances.forEach((weed, index) => { + const [x, y, z] = weed.position; + tempPosition.set(x, y, z + weed.iconSize / 2); + tempScale.set(weed.iconSize, weed.iconSize, 1); + tempMatrix.compose(tempPosition, tempQuaternion, tempScale); + mesh.setMatrixAt(index, tempMatrix); + }); + mesh.instanceMatrix.needsUpdate = true; + updateState.lastCameraQuaternion.copy(state.camera.quaternion); + updateState.hasCameraQuaternion = true; + updateState.needsMatrixUpdate = false; + }); + + const onClick = (event: ThreeEvent) => { + if (clickWasDragged(event)) { return; } + const instanceId = event.instanceId; + if (isUndefined(instanceId)) { return; } + navigateToWeed(weedInstances[instanceId]?.weed); + }; + + return + + + ; +}; + +interface WeedRadiusInstancesProps extends WeedInstancesProps { + bucket: WeedColorBucket; +} + +const WeedRadiusInstances = (props: WeedRadiusInstancesProps) => { + const { bucket, dispatch, visible } = props; + const navigateToWeed = useNavigateToWeed(dispatch, visible); + // eslint-disable-next-line no-null/no-null + const instancedRef = React.useRef(null); + const tempMatrix = React.useMemo(() => new Matrix4(), []); + const tempPosition = React.useMemo(() => new Vector3(), []); + const noRotation = React.useMemo(() => new Quaternion(), []); + const tempScale = React.useMemo(() => new Vector3(), []); + + React.useEffect(() => { + const mesh = instancedRef.current; + if (!mesh?.setMatrixAt) { return; } + bucket.weeds.forEach((weed, index) => { + const [x, y, z] = weed.position; + tempPosition.set(x, y, z + weed.iconSize / 2); + tempScale.set(weed.weedSize, weed.weedSize, weed.weedSize); + tempMatrix.compose(tempPosition, noRotation, tempScale); + mesh.setMatrixAt(index, tempMatrix); + }); + mesh.instanceMatrix.needsUpdate = true; + }, [bucket.weeds, noRotation, tempMatrix, tempPosition, tempScale]); + + const onClick = (event: ThreeEvent) => { + if (clickWasDragged(event)) { return; } + const instanceId = event.instanceId; + if (isUndefined(instanceId)) { return; } + navigateToWeed(bucket.weeds[instanceId]?.weed); + }; + + return + + + ; +}; + +export const WeedInstances = React.memo((props: WeedInstancesProps) => { + const weedInstances = React.useMemo( + () => getWeedInstances(props.weeds, props.config, props.getZ), + [props.weeds, props.config, props.getZ]); + const buckets = React.useMemo( + () => getWeedColorBuckets(weedInstances), + [weedInstances]); + return <> + + {buckets.map(bucket => + )} + ; +}); diff --git a/frontend/three_d_garden/garden/zoom_beacons.tsx b/frontend/three_d_garden/garden/zoom_beacons.tsx index 0556064838..bbde52f670 100644 --- a/frontend/three_d_garden/garden/zoom_beacons.tsx +++ b/frontend/three_d_garden/garden/zoom_beacons.tsx @@ -2,30 +2,39 @@ import { Sphere, Html, Line } from "@react-three/drei"; import React from "react"; import { Config, PositionConfig } from "../config"; import { FOCI, getCameraOffset, setUrlParam } from "../zoom_beacons_constants"; -import { useSpring, animated } from "@react-spring/three"; -import { Group, Mesh, MeshPhongMaterial } from "../components"; +import { animated, useSpring } from "@react-spring/three"; +import { SpringValue, to } from "@react-spring/core"; +import { Group, MeshPhongMaterial, Mesh } from "../components"; import { isDesktop } from "../../screen_size"; import { RenderOrder } from "../constants"; +import { + easeInOutCubic, useFocusTransition, useFocusVisibilityClass, +} from "../focus_transition"; const beaconColor = "#0266b5"; const AnimatedMesh = animated(Mesh); +const AnimatedGroup = animated(Group); const AnimatedMeshPhongMaterial = animated(MeshPhongMaterial); +type Focus = ReturnType[number]; export interface ZoomBeaconsProps { config: Config; configPosition: PositionConfig; activeFocus: string; setActiveFocus(focus: string): void; + loadInOpacity?: SpringValue; + loadInScale?: SpringValue | number; } interface BeaconPulseProps { beaconSize: number; animate: boolean; + parentOpacity: SpringValue; } const BeaconPulse = (props: BeaconPulseProps) => { - const { beaconSize, animate } = props; + const { beaconSize, animate, parentOpacity } = props; const { scale, opacity } = useSpring({ from: { scale: 1, opacity: 0.75 }, to: async (next) => { @@ -43,12 +52,122 @@ const BeaconPulse = (props: BeaconPulseProps) => { renderOrder={RenderOrder.beacons}> + pulse * parent)} + depthWrite={false} transparent={true} /> ; }; +interface BeaconVisualProps { + activeFocus: string; + beaconSize: number; + hovered: boolean; + onClick(): void; + onPointerEnter(): void; + onPointerLeave(): void; + config: Config; + loadInOpacity?: SpringValue; + loadInScale?: SpringValue | number; +} + +const BeaconVisual = (props: BeaconVisualProps) => { + const transition = useFocusTransition(); + const visible = !props.activeFocus; + const [rendered, setRendered] = React.useState(visible); + const { opacity } = useSpring({ + opacity: visible ? 1 : 0, + immediate: !transition.enabled, + config: { + duration: transition.duration, + easing: easeInOutCubic, + }, + onRest: () => { + if (transition.enabled && !visible) { + setRendered(false); + } + }, + }); + + React.useEffect(() => { + if (transition.enabled && visible) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setRendered(true); + } + }, [transition.enabled, visible]); + + if (!rendered && !visible) { return undefined; } + const beaconOpacity = props.loadInOpacity + ? to([opacity, props.loadInOpacity], (focus, load) => focus * load) + : opacity; + + return + + + + } /> + ; +}; + +interface BeaconInfoProps { + focus: Focus; + active: boolean; + onExit(): void; +} + +const BeaconInfo = (props: BeaconInfoProps) => { + const transition = useFocusVisibilityClass(props.active); + if (!transition.mounted) { return undefined; } + const className = [ + "beacon-info", + "focus-transition-opacity", + transition.className, + ].join(" "); + return +
e.stopPropagation()} + onPointerMove={e => e.stopPropagation()}> +
+

{props.focus.label}

+
+ ❌ +
+
+ {props.focus.info.description} +
+ ; +}; + export const ZoomBeacons = (props: ZoomBeaconsProps) => { const [hoveredFocus, setHoveredFocus] = React.useState(""); const { activeFocus, setActiveFocus } = props; @@ -60,6 +179,19 @@ export const ZoomBeacons = (props: ZoomBeaconsProps) => { return {FOCI(props.config, props.configPosition).map(focus => { const camera = getCameraOffset(focus); + const exitFocus = () => { + setActiveFocus(""); + setUrlParam("focus", ""); + }; + const enterFocus = () => { + if (activeFocus) { return; } + setActiveFocus(focus.label); + setUrlParam("focus", focus.label); + setHoveredFocus(""); + if (gardenBedDiv) { + gardenBedDiv.style.cursor = ""; + } + }; return {props.config.zoomBeaconDebug && @@ -71,19 +203,19 @@ export const ZoomBeacons = (props: ZoomBeaconsProps) => { } - { - setActiveFocus(activeFocus ? "" : focus.label); - setUrlParam("focus", focus.label); - setHoveredFocus(""); - if (gardenBedDiv) { - gardenBedDiv.style.cursor = ""; - } - }} + { + if (activeFocus) { return; } setHoveredFocus(focus.label); if (gardenBedDiv) { - gardenBedDiv.style.cursor = activeFocus ? "zoom-out" : "zoom-in"; + gardenBedDiv.style.cursor = "zoom-in"; } }} onPointerLeave={() => { @@ -91,44 +223,11 @@ export const ZoomBeacons = (props: ZoomBeaconsProps) => { if (gardenBedDiv) { gardenBedDiv.style.cursor = ""; } - }} - receiveShadow={false} - castShadow={false} - visible={!activeFocus} - args={[ - beaconSize - * (hoveredFocus == focus.label ? 1.5 : 1) - * ((!activeFocus && props.config.sizePreset == "Genesis XL") ? 1.5 : 1), - 12, - 12, - ]}> - - - {!activeFocus && - } - {activeFocus == focus.label && - -
e.stopPropagation()} - onPointerMove={e => e.stopPropagation()}> -
-

{focus.label}

-
{ - setActiveFocus(""); - setUrlParam("focus", ""); - }}> - ❌ -
-
- {focus.info.description} -
- } + }} /> +
; })} ; diff --git a/frontend/three_d_garden/garden_model.tsx b/frontend/three_d_garden/garden_model.tsx index c508c6a54b..dabc2eb724 100644 --- a/frontend/three_d_garden/garden_model.tsx +++ b/frontend/three_d_garden/garden_model.tsx @@ -13,17 +13,17 @@ import { OrthographicCamera as ThreeOrthographicCamera, PerspectiveCamera as ThreePerspectiveCamera, } from "three"; -import { Bot } from "./bot"; import { AddPlantProps, Bed } from "./bed"; import { Sky, Solar, Sun, sunPosition, ZoomBeacons, PlantInstances, PlantSpreadInstances, - Point, Grid, Clouds, Ground, Weed, + PointInstances, Grid, Clouds, Ground, WeedInstances, ThreeDGardenPlant, NorthArrow, skyColor, ThreeDPlantLabel, + ZoomBeaconsProps, } from "./garden"; import { Config, PositionConfig } from "./config"; import { useSpring, animated } from "@react-spring/three"; @@ -44,14 +44,85 @@ import { SlotWithTool } from "../resources/interfaces"; import { cameraInit } from "./camera"; import { filterSoilPoints, getSurface } from "./triangles"; import { BigDistance } from "./constants"; -import { getZFunc } from "./triangle_functions"; +import { getZFunc, serializeTriangles } from "./triangle_functions"; import { Visualization } from "./visualization"; import { GroupOrderVisual } from "./group_order_visual"; import { MoistureReadings } from "./garden/moisture_texture"; import { FPSProbe } from "./fps_probe"; import { CameraSelectionUI } from "./camera_selection_ui"; +import { + PerfMark, perfMark, perfMeasure, usePerfRenderCount, +} from "../performance/perf"; +import { + botLoadInConfig, FallInGroup, GridRevealGroup, LoadStepReady, PopInGroup, + ThreeDLoadProgress, ThreeDLoadProgressOverlay, ThreeDLoadStepId, + useThreeDLoadProgress, +} from "./progressive_load"; +import { + FocusTransitionProvider, FocusVisibilityGroup, SmoothCameraControls, + useSmoothCamera, +} from "./focus_transition"; const AnimatedGroup = animated(Group); +const LazyBot = React.lazy(() => + import("./bot").then(module => ({ default: module.Bot }))); +export const SMOOTH_XL_CAMERA_BED_SCALE = 1.9; +export const SMOOTH_XL_CAMERA_HEIGHT_SCALE = 1.45; + +interface ZoomBeaconsLoadInProps extends ZoomBeaconsProps { + reveal?: boolean; + onRest?: () => void; +} + +const ZoomBeaconsLoadIn = (props: ZoomBeaconsLoadInProps) => { + const { onRest, reveal: revealProp, ...zoomBeaconProps } = props; + const reveal = revealProp !== false; + const { scale, opacity } = useSpring({ + from: { scale: 0.35, opacity: 0 }, + to: { + scale: reveal ? 1 : 0.35, + opacity: reveal ? 1 : 0, + }, + immediate: !reveal, + onRest: () => reveal && onRest?.(), + config: { + tension: 220, + friction: 26, + }, + }); + + return + + ; +}; + +interface SceneBoundaryProps { + markName?: string; + loadProgress?: ThreeDLoadProgress; + loadStep?: ThreeDLoadStepId; + reveal?: boolean; + markReadyOnMount?: boolean; + children: React.ReactNode; +} + +const SceneBoundary = (props: SceneBoundaryProps) => { + const reveal = props.reveal !== false; + const markReadyOnMount = props.markReadyOnMount !== false; + return + + {props.children} + + {reveal && markReadyOnMount && props.loadStep && props.loadProgress && + } + {reveal && props.markName && } + ; +}; export interface GardenModelProps { config: Config; @@ -70,11 +141,19 @@ export interface GardenModelProps { images?: TaggedImage[]; sensorReadings?: TaggedSensorReading[]; sensors?: TaggedSensor[]; + smoothFocusTransitions?: boolean; + plantIconCapacities?: Record; + plantInstanceCapacity?: number; + onDetailsRevealStart?(): void; + onLoadComplete?(): void; } // eslint-disable-next-line complexity export const GardenModel = (props: GardenModelProps) => { - const { config, addPlantProps, threeDPlants } = props; + usePerfRenderCount("GardenModel"); + const { + config, addPlantProps, onDetailsRevealStart, onLoadComplete, threeDPlants, + } = props; const dispatch = addPlantProps?.dispatch; const Camera = config.perspective ? PerspectiveCamera : OrthographicCamera; @@ -106,8 +185,13 @@ export const GardenModel = (props: GardenModelProps) => { }; const isXL = config.sizePreset == "Genesis XL"; + let modelScale = 1; + if (!props.smoothFocusTransitions && isXL) { + modelScale = 1.75; + } const { scale } = useSpring({ - scale: isXL ? 1.75 : 1, + scale: modelScale, + immediate: props.smoothFocusTransitions && !config.animate, config: { tension: 300, friction: 40, @@ -119,18 +203,81 @@ export const GardenModel = (props: GardenModelProps) => { const topDownCameraAngle = config.topDown ? baseAngle + heading * Math.PI / 180 : undefined; - const camera = getCamera( - config, - props.configPosition, - props.activeFocus, - cameraInit({ - topDown: config.topDown, - viewpointHeading: config.viewpointHeading, - bedSize: { x: config.bedLengthOuter, y: config.bedWidthOuter }, - })); + const cameraBedScale = props.smoothFocusTransitions && isXL + ? SMOOTH_XL_CAMERA_BED_SCALE + : 1; + const cameraBedSize = React.useMemo(() => ({ + x: config.bedLengthOuter * cameraBedScale, + y: config.bedWidthOuter * cameraBedScale, + }), [ + config.bedLengthOuter, + config.bedWidthOuter, + cameraBedScale, + ]); + const defaultCamera = React.useMemo( + () => { + const nextCamera = cameraInit({ + topDown: config.topDown, + viewpointHeading: config.viewpointHeading, + bedSize: cameraBedSize, + }); + return props.smoothFocusTransitions && isXL + ? { + ...nextCamera, + position: [ + nextCamera.position[0], + nextCamera.position[1], + nextCamera.position[2] * SMOOTH_XL_CAMERA_HEIGHT_SCALE, + ] as typeof nextCamera.position, + } + : nextCamera; + }, [ + cameraBedSize, + config.topDown, + config.viewpointHeading, + isXL, + props.smoothFocusTransitions, + ]); + const camera = props.activeFocus + ? getCamera(config, props.configPosition, props.activeFocus, defaultCamera) + : defaultCamera; const [controlsCamera, setControlsCamera] = // eslint-disable-next-line no-null/no-null React.useState(null); + const [controls, setControls] = + // eslint-disable-next-line no-null/no-null + React.useState(null); + const loadProgress = useThreeDLoadProgress(); + const environmentReveal = loadProgress.isStepAllowed("environment"); + const bedReveal = loadProgress.isStepAllowed("bed"); + const gridReveal = loadProgress.isStepAllowed("grid"); + const plantsReveal = loadProgress.isStepAllowed("plants"); + const weedsReveal = loadProgress.isStepAllowed("weeds"); + const pointsReveal = loadProgress.isStepAllowed("points"); + const farmbotReveal = loadProgress.isStepAllowed("farmbot"); + const detailsReveal = loadProgress.isStepAllowed("details"); + const detailsRevealNotified = React.useRef(false); + const loadCompleteNotified = React.useRef(false); + const markLoadStep = loadProgress.markStep; + const markDetailsLoaded = React.useCallback(() => { + markLoadStep("details"); + }, [markLoadStep]); + + React.useEffect(() => { + perfMark("garden_model_mounted"); + }, []); + + React.useEffect(() => { + if (!detailsReveal || detailsRevealNotified.current) { return; } + detailsRevealNotified.current = true; + onDetailsRevealStart?.(); + }, [detailsReveal, onDetailsRevealStart]); + + React.useEffect(() => { + if (!loadProgress.complete || loadCompleteNotified.current) { return; } + loadCompleteNotified.current = true; + onLoadComplete?.(); + }, [loadProgress.complete, onLoadComplete]); const showPlants = !addPlantProps || !!addPlantProps.getConfigValue(BooleanSetting.show_plants); @@ -143,13 +290,16 @@ export const GardenModel = (props: GardenModelProps) => { const showSpread = !!addPlantProps?.getConfigValue(BooleanSetting.show_spread); const soilPoints = React.useMemo( - () => filterSoilPoints({ points: props.mapPoints, config }), + () => perfMeasure("soilPointFilterMs", () => + filterSoilPoints({ points: props.mapPoints, config })), [props.mapPoints, config]); const soilSurface = React.useMemo(() => - getSurface(soilPoints), [soilPoints]); + perfMeasure("soilSurfaceMs", () => getSurface(soilPoints)), [soilPoints]); React.useEffect(() => { - sessionStorage.setItem("soilSurfaceTriangles", - JSON.stringify(soilSurface.triangles)); + perfMeasure("soilStorageMs", () => { + sessionStorage.setItem("soilSurfaceTriangles", + serializeTriangles(soilSurface.triangles)); + }); }, [soilSurface.triangles]); const getZ = React.useMemo( () => getZFunc(soilSurface.triangles, -config.soilHeight), @@ -159,10 +309,23 @@ export const GardenModel = (props: GardenModelProps) => { BooleanSetting.show_moisture_interpolation_map); const showMoistureReadings = !!props.addPlantProps?.getConfigValue( BooleanSetting.show_sensor_readings); + const sceneDetailsLoadIn = + config.scene == "Lab" || config.scene == "Greenhouse"; + const animatedDetailsLoadIn = sceneDetailsLoadIn || config.zoomBeacons; const topDownAtStart = !!props.addPlantProps?.getConfigValue( BooleanSetting.top_down_view); const topDownZoomLevel = 0.25 * 3000 / config.bedLengthOuter; + const targetZoom = config.topDown ? topDownZoomLevel : 1; + const focusTransitionsEnabled = + !!props.smoothFocusTransitions && config.animate; + const renderedCamera = useSmoothCamera({ + camera, + zoom: targetZoom, + enabled: focusTransitionsEnabled, + cameraObject: controlsCamera, + controls, + }); // eslint-disable-next-line no-null/no-null const skyRef = React.useRef(null); @@ -171,6 +334,7 @@ export const GardenModel = (props: GardenModelProps) => { const plantLabelNodes = React.useMemo( () => { + if (!config.labels && !config.labelsOnHover) { return undefined; } if (config.labelsOnHover) { if (hoveredPlant === undefined) { return undefined; } const plant = threeDPlants[hoveredPlant]; @@ -190,60 +354,40 @@ export const GardenModel = (props: GardenModelProps) => { }, [threeDPlants, config, getZ, hoveredPlant]); - const pointNodes = React.useMemo( - () => props.mapPoints?.map(point => - ), - [props.mapPoints, showPoints, config, getZ, dispatch]); + const plantInstancesVisible = props.smoothFocusTransitions + ? showPlants + : plantsVisible; + let cameraScale: number | typeof scale = scale; + if (props.smoothFocusTransitions || props.activeFocus) { + cameraScale = 1; + } + const cameraProps = focusTransitionsEnabled + ? {} + : { position: renderedCamera.position, zoom: renderedCamera.zoom }; + const orbitControlProps = focusTransitionsEnabled + ? {} + : { target: renderedCamera.target }; - const weedNodes = React.useMemo( - () => props.weeds?.map(weed => - ), - [props.weeds, showWeeds, config, getZ, dispatch]); - - // eslint-disable-next-line no-null/no-null - return console.log(e.intersections.map(x => x.object.name)) - : undefined}> - - {config.stats && } - {config.stats && } - {config.zoomBeacons && } - - - - - - - - {controlsCamera && + return + {/* eslint-disable-next-line no-null/no-null */} + console.log(e.intersections.map(x => x.object.name)) + : undefined}> + + + + + + {controlsCamera && { enableZoom={config.zoom} enablePan={config.pan} dampingFactor={0.2} - target={camera.target} + {...orbitControlProps} minZoom={config.lightsDebug ? 0 : 0.05} maxZoom={10} minDistance={config.lightsDebug ? 50 : 500} maxDistance={config.lightsDebug ? BigDistance.devZoom : BigDistance.zoom} />} - - {config.viewCube && } - - - - - - - {showMoistureMap && props.config.moistureDebug && - } - {showFarmbot && - } - - {plantLabelNodes} - - - - - - - - {pointNodes} - - - {weedNodes} + + + + + + + + + + + + + loadProgress.markStep("bed")} + distance={config.bedHeight + config.bedZOffset}> + + + + + loadProgress.markStep("grid")}> + + + + + loadProgress.markStep("plants")} + distance={200}> + + {plantLabelNodes} + + + + + + + + + loadProgress.markStep("weeds")} + distance={200}> + + {(props.weeds?.length || 0) > 0 && + } + + + + + loadProgress.markStep("points")} + distance={config.columnLength + 1000}> + + {(props.mapPoints?.length || 0) > 0 && + } + + + + + {showFarmbot && + loadProgress.markStep("farmbot")} + config={botLoadInConfig} + distance={config.columnLength + 1500} + fadeIn={true} + preserveDepthWrite={true}> + + } + + + {config.stats && } + {config.stats && } + {config.zoomBeacons && + } + + {config.viewCube && } + + {showMoistureMap && props.config.moistureDebug && + } + + {props.addPlantProps?.designer.visualizedSequence && + } + + + + {config.cameraSelectionView && + } + {detailsReveal && !animatedDetailsLoadIn && + } + - - - - - - {config.cameraSelectionView && - } - ; + ; }; diff --git a/frontend/three_d_garden/index.tsx b/frontend/three_d_garden/index.tsx index 722dec76cd..7aeb769d2e 100644 --- a/frontend/three_d_garden/index.tsx +++ b/frontend/three_d_garden/index.tsx @@ -12,19 +12,22 @@ import { } from "farmbot"; import { SlotWithTool } from "../resources/interfaces"; import { NavigateFunction } from "react-router"; -import { FilePath, Path } from "../internal_urls"; +import { Path } from "../internal_urls"; import { t } from "../i18next_wrapper"; import { Actions, Content, DeviceSetting } from "../constants"; import { isMobile } from "../screen_size"; import { Help } from "../ui"; import { BooleanSetting } from "../session_keys"; import { LayerToggle } from "../farm_designer/map/legend/layer_toggle"; -import { GetWebAppConfigValue, setWebAppConfigValue } from "../config_storage/actions"; +import { + GetWebAppConfigValue, setWebAppConfigValue, +} from "../config_storage/actions"; import { DesignerState } from "../farm_designer/interfaces"; import { setPanelOpen } from "../farm_designer/panel_header"; import { ThreeDGardenPlant } from "./garden"; import { DeviceAccountSettings } from "farmbot/dist/resources/api_resources"; import { isTopDown } from "./helpers"; +import { perfMark, usePerfRenderCount } from "../performance/perf"; export interface ThreeDGardenProps { config: Config; @@ -43,41 +46,35 @@ export interface ThreeDGardenProps { } export const ThreeDGarden = (props: ThreeDGardenProps) => { + usePerfRenderCount("ThreeDGarden"); + React.useEffect(() => { + perfMark("three_d_garden_mounted"); + }, []); return
- - {"Loading -

- {t("Loading interactive 3D FarmBot...")} -

-
}> - { - gl.localClippingEnabled = true; - }}> - - - + { + gl.localClippingEnabled = true; + perfMark("canvas_created"); + }}> + +
; }; diff --git a/frontend/three_d_garden/progressive_load.tsx b/frontend/three_d_garden/progressive_load.tsx new file mode 100644 index 0000000000..2890819cbb --- /dev/null +++ b/frontend/three_d_garden/progressive_load.tsx @@ -0,0 +1,328 @@ +import React from "react"; +import { animated, useSpring } from "@react-spring/three"; +import { Html } from "@react-three/drei"; +import { Group } from "./components"; +import { Object3D } from "three"; +import { createFocusMaterialBinding } from "./focus_transition"; + +const AnimatedGroup = animated(Group); + +export const THREE_D_LOAD_STEPS = [ + { id: "environment", label: "Loading environment" }, + { id: "bed", label: "Loading bed and soil" }, + { id: "grid", label: "Loading grid" }, + { id: "plants", label: "Loading plants" }, + { id: "weeds", label: "Loading weeds" }, + { id: "points", label: "Loading points" }, + { id: "farmbot", label: "Loading FarmBot" }, + { id: "details", label: "Loading scene details" }, +] as const; + +export const THREE_D_LOAD_PROGRESS_FADE_MS = 300; + +export type ThreeDLoadStepId = typeof THREE_D_LOAD_STEPS[number]["id"]; +type ReadyStepTimes = Partial>; +type StepDependencies = Record; + +export const THREE_D_LOAD_STEP_DEPENDENCIES: StepDependencies = { + environment: [], + bed: ["environment"], + grid: ["bed"], + plants: ["grid"], + weeds: ["grid"], + points: ["grid"], + farmbot: ["grid"], + details: ["farmbot"], +}; + +interface ReadyAction { + step: ThreeDLoadStepId; + elapsed: number; +} + +const readyStepReducer = + (state: ReadyStepTimes, action: ReadyAction): ReadyStepTimes => + state[action.step] === undefined + ? { ...state, [action.step]: action.elapsed } + : state; + +const now = () => + typeof performance == "undefined" ? Date.now() : performance.now(); + +export interface ThreeDLoadProgress { + readyStepTimes: ReadyStepTimes; + currentStep: typeof THREE_D_LOAD_STEPS[number] | undefined; + progress: number; + complete: boolean; + markStep(step: ThreeDLoadStepId): void; + isStepAllowed(step: ThreeDLoadStepId): boolean; +} + +const rounded = (value: number | undefined) => Math.round(value || 0); + +export const useThreeDLoadProgress = (): ThreeDLoadProgress => { + const startTimeRef = React.useRef(now()); + const loggedRef = React.useRef(false); + const [readyStepTimes, dispatch] = + React.useReducer(readyStepReducer, {} as ReadyStepTimes); + + const markStep = React.useCallback((step: ThreeDLoadStepId) => { + dispatch({ step, elapsed: now() - startTimeRef.current }); + }, []); + + const isStepAllowed = React.useCallback((step: ThreeDLoadStepId) => { + return THREE_D_LOAD_STEP_DEPENDENCIES[step] + .every(stepId => readyStepTimes[stepId] !== undefined); + }, [readyStepTimes]); + + const readyStepCount = + THREE_D_LOAD_STEPS.filter(step => readyStepTimes[step.id] !== undefined) + .length; + const currentStep = + THREE_D_LOAD_STEPS.find(step => readyStepTimes[step.id] === undefined); + const complete = readyStepCount == THREE_D_LOAD_STEPS.length; + const progress = readyStepCount / THREE_D_LOAD_STEPS.length * 100; + + React.useEffect(() => { + if (!complete || loggedRef.current) { return; } + loggedRef.current = true; + let totalElapsed = 0; + THREE_D_LOAD_STEPS.forEach(step => { + const elapsed = readyStepTimes[step.id] || 0; + totalElapsed = Math.max(totalElapsed, elapsed); + console.log(`[3D load] ${step.label}: ${rounded(elapsed)}ms`); + }); + console.log(`[3D load] Total: ${rounded(totalElapsed)}ms`); + }, [complete, readyStepTimes]); + + return React.useMemo(() => ({ + readyStepTimes, + currentStep, + progress, + complete, + markStep, + isStepAllowed, + }), [ + complete, + currentStep, + isStepAllowed, + markStep, + progress, + readyStepTimes, + ]); +}; + +interface LoadStepReadyProps { + step: ThreeDLoadStepId; + markStep(step: ThreeDLoadStepId): void; +} + +export const LoadStepReady = (props: LoadStepReadyProps) => { + const { markStep, step } = props; + React.useEffect(() => { + markStep(step); + }, [markStep, step]); + return undefined; +}; + +interface ThreeDLoadProgressOverlayProps { + progress: ThreeDLoadProgress; + complete?: boolean; +} + +export const ThreeDLoadProgressOverlay = + (props: ThreeDLoadProgressOverlayProps) => { + const complete = props.complete || props.progress.complete; + const [mounted, setMounted] = React.useState(!complete); + + React.useEffect(() => { + if (!complete) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setMounted(true); + return; + } + const timeout = window.setTimeout(() => + setMounted(false), THREE_D_LOAD_PROGRESS_FADE_MS); + return () => window.clearTimeout(timeout); + }, [complete]); + + if (!mounted) { return undefined; } + const className = [ + "three-d-load-progress", + complete ? "three-d-load-progress-complete" : "", + ].join(" "); + return +
+
+
+
+

{complete ? "Enjoy!" : props.progress.currentStep?.label}

+
+ ; + }; + +const loadInConfig = { + tension: 240, + friction: 30, +}; + +export const botLoadInConfig = { + tension: 240, + friction: 30, + clamp: true, +}; + +type LoadInSpringConfig = typeof loadInConfig & { + clamp?: boolean; +}; + +const canTraverse = (value: unknown): value is Object3D => + !!value + && typeof value == "object" + && typeof (value as Object3D).traverse == "function"; + +interface LoadInGroupProps { + name: string; + children: React.ReactNode; + reveal?: boolean; + onRest?: () => void; + config?: LoadInSpringConfig; + fromPosition?: [number, number, number]; + toPosition?: [number, number, number]; + fromScale?: number | [number, number, number]; + toScale?: number | [number, number, number]; + fadeIn?: boolean; + preserveDepthWrite?: boolean; +} + +export const LoadInGroup = (props: LoadInGroupProps) => { + const reveal = props.reveal !== false; + const fromPosition = props.fromPosition || [0, 0, 0]; + const toPosition = props.toPosition || [0, 0, 0]; + const fromScale = props.fromScale || 1; + const toScale = props.toScale || 1; + const fromOpacity = props.fadeIn ? 0 : 1; + const groupRef = React.useRef(undefined); + const opacityRef = React.useRef(fromOpacity); + const materialBinding = React.useRef | undefined>(undefined); + const applyOpacity = React.useCallback((opacity: number) => { + opacityRef.current = opacity; + if (!props.fadeIn || !canTraverse(groupRef.current)) { return; } + materialBinding.current ||= createFocusMaterialBinding(groupRef.current, { + preserveDepthWrite: props.preserveDepthWrite, + }); + materialBinding.current.apply(opacity); + }, [props.fadeIn, props.preserveDepthWrite]); + const setRef = React.useCallback((value: Object3D | null) => { + groupRef.current = value || undefined; + }, []); + const restoreMaterialBinding = React.useCallback(() => { + materialBinding.current?.restore(); + materialBinding.current = undefined; + }, []); + + const { position, scale } = useSpring({ + from: { + position: fromPosition, + scale: fromScale, + opacity: fromOpacity, + }, + to: { + position: reveal ? toPosition : fromPosition, + scale: reveal ? toScale : fromScale, + opacity: reveal ? 1 : fromOpacity, + }, + immediate: !reveal, + onChange: result => { + const value = result.value as { opacity?: number }; + applyOpacity(value.opacity ?? 1); + }, + onRest: () => { + if (!reveal) { return; } + applyOpacity(1); + restoreMaterialBinding(); + props.onRest?.(); + }, + config: props.config || loadInConfig, + }); + + React.useLayoutEffect(() => { + if (!props.fadeIn) { return; } + applyOpacity(opacityRef.current); + }, [applyOpacity, props.fadeIn]); + + React.useEffect(() => restoreMaterialBinding, [restoreMaterialBinding]); + + return + {props.children} + ; +}; + +interface PopInGroupProps { + name: string; + children: React.ReactNode; + reveal?: boolean; + onRest?: () => void; + distance?: number; +} + +export const PopInGroup = (props: PopInGroupProps) => + + {props.children} + ; + +interface FallInGroupProps { + name: string; + children: React.ReactNode; + reveal?: boolean; + onRest?: () => void; + config?: LoadInSpringConfig; + distance?: number; + fadeIn?: boolean; + preserveDepthWrite?: boolean; +} + +export const FallInGroup = (props: FallInGroupProps) => + + {props.children} + ; + +interface GridRevealGroupProps { + name: string; + children: React.ReactNode; + reveal?: boolean; + onRest?: () => void; +} + +export const GridRevealGroup = (props: GridRevealGroupProps) => + + {props.children} + ; diff --git a/frontend/three_d_garden/scenes/__tests__/greenhouse_test.tsx b/frontend/three_d_garden/scenes/__tests__/greenhouse_test.tsx index 4a834e2e66..c6fcd81cee 100644 --- a/frontend/three_d_garden/scenes/__tests__/greenhouse_test.tsx +++ b/frontend/three_d_garden/scenes/__tests__/greenhouse_test.tsx @@ -18,8 +18,9 @@ describe("", () => { const { container } = render(); expect(container).toContainHTML("greenhouse-environment"); expect(container).not.toContainHTML("people"); - expect(container).toContainHTML("starter-tray-1"); - expect(container).toContainHTML("starter-tray-2"); + expect(container).toContainHTML("starter-trays"); + expect(container).toContainHTML("starter-tray-bases"); + expect(container).toContainHTML("starter-tray-seedlings"); expect(container).toContainHTML("left-greenhouse-wall"); expect(container).toContainHTML("right-greenhouse-wall"); expect(container).toContainHTML("potted-plant"); @@ -51,4 +52,14 @@ describe("", () => { expect(container).toContainHTML("greenhouse-environment"); expect(container).not.toContainHTML("people"); }); + + it("animates scene details in", () => { + const p = fakeProps(); + const onDetailsLoadInRest = jest.fn(); + p.config.scene = "Greenhouse"; + const { container } = render(); + expect(container).toContainHTML("greenhouse-scene-details-load-in"); + expect(onDetailsLoadInRest).toHaveBeenCalled(); + }); }); diff --git a/frontend/three_d_garden/scenes/__tests__/lab_test.tsx b/frontend/three_d_garden/scenes/__tests__/lab_test.tsx index d64521afe1..31d7c6345f 100644 --- a/frontend/three_d_garden/scenes/__tests__/lab_test.tsx +++ b/frontend/three_d_garden/scenes/__tests__/lab_test.tsx @@ -40,4 +40,14 @@ describe("", () => { expect(container).toContainHTML("shelf"); expect(container).toContainHTML("people"); }); + + it("animates scene details in", () => { + const p = fakeProps(); + const onDetailsLoadInRest = jest.fn(); + p.config.scene = "Lab"; + const { container } = render(); + expect(container).toContainHTML("lab-scene-details-load-in"); + expect(onDetailsLoadInRest).toHaveBeenCalled(); + }); }); diff --git a/frontend/three_d_garden/scenes/greenhouse.tsx b/frontend/three_d_garden/scenes/greenhouse.tsx index 16676b2a44..f823f86634 100644 --- a/frontend/three_d_garden/scenes/greenhouse.tsx +++ b/frontend/three_d_garden/scenes/greenhouse.tsx @@ -1,15 +1,20 @@ import React from "react"; -import { Box, useTexture } from "@react-three/drei"; +import { Box } from "@react-three/drei"; import { DoubleSide, RepeatWrapping } from "three"; import { ASSETS } from "../constants"; import { threeSpace } from "../helpers"; import { Config } from "../config"; import { Group, MeshPhongMaterial } from "../components"; -import { StarterTray, PottedPlant, GreenhouseWall, People } from "./props"; +import { StarterTrays, PottedPlant, GreenhouseWall, People } from "./props"; +import { PopInGroup } from "../progressive_load"; +import { FocusVisibilityGroup } from "../focus_transition"; +import { useTextureVariant } from "../texture_variants"; export interface GreenhouseProps { config: Config; activeFocus: string; + reveal?: boolean; + onDetailsLoadInRest?(): void; } const wallLength = 10000; @@ -22,84 +27,83 @@ export const Greenhouse = (props: GreenhouseProps) => { const { config } = props; const groundZ = -config.bedZOffset - config.bedHeight; - const shelfWoodTextureBase = useTexture(ASSETS.textures.wood + "?=shelf"); - const shelfWoodTexture = React.useMemo(() => { - const texture = shelfWoodTextureBase.clone(); - texture.wrapS = RepeatWrapping; - texture.wrapT = RepeatWrapping; - texture.repeat.set(0.3, 0.3); - return texture; - }, [shelfWoodTextureBase]); + const shelfWoodTexture = useTextureVariant(ASSETS.textures.wood, { + wrapS: RepeatWrapping, + wrapT: RepeatWrapping, + repeat: [0.3, 0.3], + }); return + - - - - - - - + + + + + + - - - - - - - + + + - + - - - + + + + ; }; diff --git a/frontend/three_d_garden/scenes/lab.tsx b/frontend/three_d_garden/scenes/lab.tsx index 399816b49a..ed4ae7449c 100644 --- a/frontend/three_d_garden/scenes/lab.tsx +++ b/frontend/three_d_garden/scenes/lab.tsx @@ -1,15 +1,19 @@ import React from "react"; -import { Box, Extrude, useTexture } from "@react-three/drei"; +import { Box, Extrude } from "@react-three/drei"; import { DoubleSide, Shape, RepeatWrapping } from "three"; import { ASSETS } from "../constants"; import { threeSpace } from "../helpers"; import { Config } from "../config"; import { Desk, People } from "./props"; import { Group, MeshPhongMaterial } from "../components"; +import { PopInGroup } from "../progressive_load"; +import { useTextureVariant } from "../texture_variants"; export interface LabProps { config: Config; activeFocus: string; + reveal?: boolean; + onDetailsLoadInRest?(): void; } const wallLength = 10000; @@ -37,65 +41,68 @@ export const Lab = (props: LabProps) => { const { config } = props; const groundZ = -config.bedZOffset - config.bedHeight; - const shelfWoodTextureBase = useTexture(ASSETS.textures.wood + "?=shelf"); - const shelfWoodTexture = React.useMemo(() => { - const texture = shelfWoodTextureBase.clone(); - texture.wrapS = RepeatWrapping; - texture.wrapT = RepeatWrapping; - texture.repeat.set(0.3, 0.3); - return texture; - }, [shelfWoodTextureBase]); + const shelfWoodTexture = useTextureVariant(ASSETS.textures.wood, { + wrapS: RepeatWrapping, + wrapT: RepeatWrapping, + repeat: [0.3, 0.3], + }); return - - + - - - {[wallHeight / 2, wallHeight / 3].map((shelfHeight, index) => ( - - - - ))} - - - + + + {[wallHeight / 2, wallHeight / 3].map((shelfHeight, index) => ( + + + + ))} + + + + ; }; diff --git a/frontend/three_d_garden/scenes/props/__tests__/starter_tray_test.tsx b/frontend/three_d_garden/scenes/props/__tests__/starter_tray_test.tsx index 12b115fca6..a437cf37f0 100644 --- a/frontend/three_d_garden/scenes/props/__tests__/starter_tray_test.tsx +++ b/frontend/three_d_garden/scenes/props/__tests__/starter_tray_test.tsx @@ -1,17 +1,60 @@ import React from "react"; import { render } from "@testing-library/react"; -import { Desk, DeskProps } from "../desk"; -import { clone } from "lodash"; -import { INITIAL } from "../../../config"; - -describe("", () => { - const fakeProps = (): DeskProps => ({ - config: clone(INITIAL), - activeFocus: "", +import { StarterTray, StarterTrays } from "../starter_tray"; +import { InstancedMesh } from "three"; + +const mockMesh = () => ({ + setMatrixAt: jest.fn(), + instanceMatrix: { needsUpdate: false }, +}) as unknown as InstancedMesh; + +describe("", () => { + it("renders a single starter tray", () => { + const { container } = render(); + + expect(container).toContainHTML("starter-tray"); + expect(container).toContainHTML("starter-trays"); + expect(container.querySelectorAll("instancedmesh").length).toEqual(2); }); +}); + +describe("", () => { + it("renders instanced starter trays and seedlings", () => { + const { container } = render(); + + expect(container).toContainHTML("starter-tray-bases"); + expect(container).toContainHTML("starter-tray-seedlings"); + expect(container.querySelectorAll("instancedmesh").length).toEqual(2); + expect(container.querySelectorAll(".billboard").length).toEqual(0); + expect(container.querySelectorAll(".image").length).toEqual(0); + }); + + it("updates tray and seedling instance matrices", () => { + const trayMesh = mockMesh(); + const seedlingMesh = mockMesh(); + const useRef = React.useRef; + const useEffectSpy = jest.spyOn(React, "useEffect") + .mockImplementationOnce(effect => { + effect(); + }); + const useRefSpy = jest.spyOn(React, "useRef") + .mockImplementationOnce(() => ({ current: trayMesh })) + .mockImplementationOnce(() => ({ current: seedlingMesh })) + .mockImplementation(useRef); + + render(); - it("renders", () => { - const { container } = render(); - expect(container).toContainHTML("desk"); + expect(trayMesh.setMatrixAt).toHaveBeenCalledTimes(2); + expect(seedlingMesh.setMatrixAt).toHaveBeenCalledTimes(140); + expect(trayMesh.instanceMatrix.needsUpdate).toBeTruthy(); + expect(seedlingMesh.instanceMatrix.needsUpdate).toBeTruthy(); + useRefSpy.mockRestore(); + useEffectSpy.mockRestore(); }); }); diff --git a/frontend/three_d_garden/scenes/props/desk.tsx b/frontend/three_d_garden/scenes/props/desk.tsx index 9916c95aec..669aed284f 100644 --- a/frontend/three_d_garden/scenes/props/desk.tsx +++ b/frontend/three_d_garden/scenes/props/desk.tsx @@ -1,10 +1,12 @@ import React from "react"; import { RepeatWrapping } from "three"; -import { Box, useTexture } from "@react-three/drei"; +import { Box } from "@react-three/drei"; import { ASSETS } from "../../constants"; import { threeSpace } from "../../helpers"; import { Config } from "../../config"; import { Group, MeshPhongMaterial } from "../../components"; +import { FocusVisibilityGroup } from "../../focus_transition"; +import { useTextureVariant } from "../../texture_variants"; export interface DeskProps { config: Config; @@ -21,22 +23,16 @@ const deskWoodDarkness = "#666"; export const Desk = (props: DeskProps) => { const { config } = props; const zGround = -config.bedZOffset - config.bedHeight; - const deskWoodTextureBase = useTexture(ASSETS.textures.wood + "?=desk"); - const deskWoodTexture = React.useMemo(() => { - const texture = deskWoodTextureBase.clone(); - texture.wrapS = RepeatWrapping; - texture.wrapT = RepeatWrapping; - texture.repeat.set(0.3, 0.3); - return texture; - }, [deskWoodTextureBase]); - const screenTextureBase = useTexture(ASSETS.textures.screen + "?=screen"); - const screenTexture = React.useMemo(() => { - const texture = screenTextureBase.clone(); - texture.rotation = Math.PI / 2; - texture.wrapT = RepeatWrapping; - return texture; - }, [screenTextureBase]); - return { - ; + ; }; diff --git a/frontend/three_d_garden/scenes/props/people.tsx b/frontend/three_d_garden/scenes/props/people.tsx index eb4fd22cde..329994d237 100644 --- a/frontend/three_d_garden/scenes/props/people.tsx +++ b/frontend/three_d_garden/scenes/props/people.tsx @@ -5,6 +5,7 @@ import { Config } from "../../config"; import { threeSpace } from "../../helpers"; import { Vector3, DoubleSide } from "three"; import { ASSETS, RenderOrder } from "../../constants"; +import { FocusVisibilityGroup } from "../../focus_transition"; export interface PeopleProps { config: Config; @@ -15,7 +16,7 @@ export interface PeopleProps { export const People = (props: PeopleProps) => { const { people, config } = props; const groundZ = -config.bedZOffset - config.bedHeight; - return {people.map((person, i) => { const offset = new Vector3(...person.offset); @@ -28,7 +29,7 @@ export const People = (props: PeopleProps) => { ; })} - ; + ; }; interface DataRecord { diff --git a/frontend/three_d_garden/scenes/props/starter_tray.tsx b/frontend/three_d_garden/scenes/props/starter_tray.tsx index 34c62fe266..2b88f5f2ce 100644 --- a/frontend/three_d_garden/scenes/props/starter_tray.tsx +++ b/frontend/three_d_garden/scenes/props/starter_tray.tsx @@ -1,8 +1,22 @@ import React from "react"; -import { Box, Billboard, Image } from "@react-three/drei"; -import { DoubleSide } from "three"; +import { useTexture } from "@react-three/drei"; +import { useFrame } from "@react-three/fiber"; +import { + DoubleSide, + InstancedMesh as InstancedMeshType, + Matrix4, + Quaternion, + Vector3, +} from "three"; import { ASSETS, RenderOrder } from "../../constants"; -import { Group, MeshPhongMaterial } from "../../components"; +import { + BoxGeometry, + Group, + InstancedMesh, + MeshBasicMaterial, + MeshPhongMaterial, + PlaneGeometry, +} from "../../components"; import { range } from "lodash"; const length = 250; @@ -10,31 +24,95 @@ const width = 700; const height = 50; const cellSize = 50; const seedlingSize = 40; +const trayCells = range(5).flatMap(row => + range(14).map(col => ({ + x: -width / 2 + cellSize / 2 + col * cellSize, + y: -length / 2 + cellSize / 2 + row * cellSize, + }))); -export const StarterTray = () => { +export interface StarterTraysProps { + positions: [number, number, number][]; +} - return - { + // eslint-disable-next-line no-null/no-null + const trayRef = React.useRef(null); + // eslint-disable-next-line no-null/no-null + const seedlingRef = React.useRef(null); + const plantTexture = useTexture(ASSETS.other.plant); + const matrix = React.useMemo(() => new Matrix4(), []); + const position = React.useMemo(() => new Vector3(), []); + const scale = React.useMemo(() => new Vector3(), []); + const trayQuaternion = React.useMemo(() => new Quaternion(), []); + const seedlingQuaternion = React.useMemo(() => new Quaternion(), []); + + React.useEffect(() => { + const mesh = trayRef.current; + if (!mesh) { return; } + props.positions.forEach((trayPosition, index) => { + position.set( + trayPosition[0], + trayPosition[1], + trayPosition[2] + height / 2, + ); + scale.set(1, 1, 1); + matrix.compose(position, trayQuaternion, scale); + mesh.setMatrixAt(index, matrix); + }); + mesh.instanceMatrix.needsUpdate = true; + }, [matrix, position, props.positions, scale, trayQuaternion]); + + useFrame(state => { + const mesh = seedlingRef.current; + if (!mesh) { return; } + seedlingQuaternion.copy(state.camera.quaternion); + scale.set(seedlingSize, seedlingSize, seedlingSize); + props.positions.forEach((trayPosition, trayIndex) => { + trayCells.forEach((cell, cellIndex) => { + const index = trayIndex * trayCells.length + cellIndex; + position.set( + trayPosition[0] + cell.x, + trayPosition[1] + cell.y, + trayPosition[2] + height + seedlingSize / 2, + ); + matrix.compose(position, seedlingQuaternion, scale); + mesh.setMatrixAt(index, matrix); + }); + }); + mesh.instanceMatrix.needsUpdate = true; + }); + + return + + frustumCulled={false}> + - - {range(5).map(row => - range(14).map(col => { - const x = -width / 2 + cellSize / 2 + col * cellSize; - const y = -length / 2 + cellSize / 2 + row * cellSize; - return - - ; - }), - )} + + + + + + ; +}; + +export const StarterTray = () => { + + return + ; }; diff --git a/frontend/three_d_garden/texture_variants.ts b/frontend/three_d_garden/texture_variants.ts new file mode 100644 index 0000000000..38f174fe46 --- /dev/null +++ b/frontend/three_d_garden/texture_variants.ts @@ -0,0 +1,51 @@ +import { useTexture } from "@react-three/drei"; +import { Texture, type Wrapping } from "three"; + +export interface TextureVariantOptions { + wrapS?: Wrapping; + wrapT?: Wrapping; + repeat?: [number, number]; + offset?: [number, number]; + rotation?: number; +} + +const textureVariantCache = new WeakMap>(); + +export const textureVariantKey = (options: TextureVariantOptions): string => + [ + options.wrapS ?? "", + options.wrapT ?? "", + options.repeat?.join(",") ?? "", + options.offset?.join(",") ?? "", + options.rotation ?? "", + ].join("|"); + +export const getTextureVariant = ( + baseTexture: Texture, + options: TextureVariantOptions, +): Texture => { + const key = textureVariantKey(options); + const variants = textureVariantCache.get(baseTexture) || new Map(); + const existing = variants.get(key); + if (existing) { return existing; } + + const texture = baseTexture.clone(); + if (options.wrapS != undefined) { texture.wrapS = options.wrapS; } + if (options.wrapT != undefined) { texture.wrapT = options.wrapT; } + if (options.repeat) { texture.repeat.set(...options.repeat); } + if (options.offset) { texture.offset.set(...options.offset); } + if (options.rotation != undefined) { texture.rotation = options.rotation; } + texture.needsUpdate = true; + + variants.set(key, texture); + textureVariantCache.set(baseTexture, variants); + return texture; +}; + +export const useTextureVariant = ( + url: string, + options: TextureVariantOptions, +): Texture => { + const baseTexture = useTexture(url); + return getTextureVariant(baseTexture, options); +}; diff --git a/frontend/three_d_garden/triangle_functions.ts b/frontend/three_d_garden/triangle_functions.ts index 1ce0864d72..6bef57faea 100644 --- a/frontend/three_d_garden/triangle_functions.ts +++ b/frontend/three_d_garden/triangle_functions.ts @@ -1,7 +1,13 @@ +import { perfEnabled, perfSample } from "../performance/perf"; + export interface TriangleData { a: [number, number, number]; b: [number, number, number]; c: [number, number, number]; + minX: number; + maxX: number; + minY: number; + maxY: number; x1: number; y1: number; x2: number; @@ -11,6 +17,49 @@ export interface TriangleData { det: number; } +interface TriangleIndex { + minX: number; + maxX: number; + minY: number; + maxY: number; + columns: number; + rows: number; + cellWidth: number; + cellHeight: number; + buckets: TriangleData[][]; +} + +const MAX_BUCKETS_PER_AXIS = 64; +const MIN_INDEXED_TRIANGLES = 4; + +const triangleData = ( + a: [number, number, number], + b: [number, number, number], + c: [number, number, number], +) => { + const [x1, y1] = [a[0], a[1]]; + const [x2, y2] = [b[0], b[1]]; + const [x3, y3] = [c[0], c[1]]; + const det = (y2 - y3) * (x1 - x3) + (x3 - x2) * (y1 - y3); + if (Math.abs(det) < 1e-10) { return undefined; } + return { + a, + b, + c, + minX: Math.min(x1, x2, x3), + maxX: Math.max(x1, x2, x3), + minY: Math.min(y1, y2, y3), + maxY: Math.max(y1, y2, y3), + x1, + y1, + x2, + y2, + x3, + y3, + det, + }; +}; + export const precomputeTriangles = ( vertices: [number, number, number][], faces: number[], @@ -21,33 +70,155 @@ export const precomputeTriangles = ( const a = vertices[faces[i]]; const b = vertices[faces[i + 1]]; const c = vertices[faces[i + 2]]; + const triangle = triangleData(a, b, c); + triangle && triangles.push(triangle); + } - const [x1, y1] = [a[0], a[1]]; - const [x2, y2] = [b[0], b[1]]; - const [x3, y3] = [c[0], c[1]]; + return triangles; +}; + +export const serializeTriangles = (triangles: TriangleData[]) => + JSON.stringify(triangles.map(({ a, b, c }) => [ + a[0], a[1], a[2], + b[0], b[1], b[2], + c[0], c[1], c[2], + ])); + +const pointFromArray = ( + values: unknown[], + offset: number, +): [number, number, number] | undefined => { + const point = values.slice(offset, offset + 3); + if (point.length != 3 || !point.every(Number.isFinite)) { + return undefined; + } + return point as [number, number, number]; +}; - const det = (y2 - y3) * (x1 - x3) + (x3 - x2) * (y1 - y3); - if (Math.abs(det) < 1e-10) { continue; } - triangles.push({ a, b, c, x1, y1, x2, y2, x3, y3, det }); +const parseStoredTriangle = (value: unknown): TriangleData | undefined => { + if (Array.isArray(value)) { + const a = pointFromArray(value, 0); + const b = pointFromArray(value, 3); + const c = pointFromArray(value, 6); + return a && b && c ? triangleData(a, b, c) : undefined; } + if (!value || typeof value != "object") { return undefined; } + const { a, b, c } = value as Partial; + return Array.isArray(a) && Array.isArray(b) && Array.isArray(c) + ? triangleData(a, b, c) + : undefined; +}; - return triangles; +export const parseStoredTriangles = (storedTriangles: string | null) => { + try { + const parsed = JSON.parse(storedTriangles || "[]") as unknown; + if (!Array.isArray(parsed)) { return []; } + return parsed + .map(parseStoredTriangle) + .filter((triangle): triangle is TriangleData => !!triangle); + } catch { + return []; + } +}; + +const clampCell = (value: number, min: number, size: number, count: number) => + Math.min(count - 1, Math.max(0, Math.floor((value - min) / size))); + +const bucketIndex = (index: TriangleIndex, column: number, row: number) => + row * index.columns + column; + +const buildTriangleIndex = (triangles: TriangleData[]) => { + if (triangles.length < MIN_INDEXED_TRIANGLES) { return undefined; } + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + for (const triangle of triangles) { + minX = Math.min(minX, triangle.minX); + maxX = Math.max(maxX, triangle.maxX); + minY = Math.min(minY, triangle.minY); + maxY = Math.max(maxY, triangle.maxY); + } + if (minX == maxX || minY == maxY) { return undefined; } + const bucketCount = Math.min( + MAX_BUCKETS_PER_AXIS, + Math.max(1, Math.ceil(Math.sqrt(triangles.length))), + ); + const index: TriangleIndex = { + minX, + maxX, + minY, + maxY, + columns: bucketCount, + rows: bucketCount, + cellWidth: (maxX - minX) / bucketCount, + cellHeight: (maxY - minY) / bucketCount, + buckets: Array.from({ length: bucketCount * bucketCount }, () => []), + }; + for (const triangle of triangles) { + const minColumn = clampCell(triangle.minX, minX, index.cellWidth, + index.columns); + const maxColumn = clampCell(triangle.maxX, minX, index.cellWidth, + index.columns); + const minRow = clampCell(triangle.minY, minY, index.cellHeight, + index.rows); + const maxRow = clampCell(triangle.maxY, minY, index.cellHeight, + index.rows); + for (let row = minRow; row <= maxRow; row++) { + for (let column = minColumn; column <= maxColumn; column++) { + index.buckets[bucketIndex(index, column, row)].push(triangle); + } + } + } + return index; +}; + +const indexedTrianglesForPoint = ( + index: TriangleIndex | undefined, + triangles: TriangleData[], + x: number, + y: number, +) => { + if (!index) { return triangles; } + if ( + x < index.minX || x > index.maxX || + y < index.minY || y > index.maxY + ) { + return []; + } + const column = clampCell(x, index.minX, index.cellWidth, index.columns); + const row = clampCell(y, index.minY, index.cellHeight, index.rows); + return index.buckets[bucketIndex(index, column, row)]; }; export const getZFunc = ( triangles: TriangleData[], fallback: number, -) => - (x: number, y: number) => { - for (const t of triangles) { +) => { + const cache: Record = {}; + const measure = perfEnabled(); + const indexStartedAt = measure ? performance.now() : 0; + const index = buildTriangleIndex(triangles); + measure && perfSample("getZIndexMs", performance.now() - indexStartedAt); + return (x: number, y: number) => { + const key = `${x},${y}`; + const cached = cache[key]; + if (cached !== undefined) { return cached; } + const startedAt = measure ? performance.now() : 0; + for (const t of indexedTrianglesForPoint(index, triangles, x, y)) { const { a, b, c, x1, y1, x2, y2, x3, y3, det } = t; const l1 = ((y2 - y3) * (x - x3) + (x3 - x2) * (y - y3)) / det; const l2 = ((y3 - y1) * (x - x3) + (x1 - x3) * (y - y3)) / det; const l3 = 1 - l1 - l2; if (l1 >= 0 && l2 >= 0 && l3 >= 0) { - return l1 * a[2] + l2 * b[2] + l3 * c[2]; + cache[key] = l1 * a[2] + l2 * b[2] + l3 * c[2]; + measure && perfSample("getZMs", performance.now() - startedAt); + return cache[key]; } } - return fallback; + cache[key] = fallback; + measure && perfSample("getZMs", performance.now() - startedAt); + return cache[key]; }; +}; diff --git a/frontend/three_d_garden/zoom_beacons_constants.tsx b/frontend/three_d_garden/zoom_beacons_constants.tsx index 7054ba254c..90d5bc942d 100644 --- a/frontend/three_d_garden/zoom_beacons_constants.tsx +++ b/frontend/three_d_garden/zoom_beacons_constants.tsx @@ -12,6 +12,8 @@ export interface Camera { target: VectorXyz; } +const TOP_DOWN_FOCUS_AZIMUTH_OFFSET = 25; + interface Focus { label: string; info: { @@ -70,7 +72,7 @@ export const FOCI = (config: Config, configPosition: PositionConfig): Focus[] => narrow: { position: [ 0, - -1000, + -1000 - TOP_DOWN_FOCUS_AZIMUTH_OFFSET, config.sizePreset == "Genesis XL" ? 16000 : 8000, ], target: [ @@ -82,7 +84,7 @@ export const FOCI = (config: Config, configPosition: PositionConfig): Focus[] => wide: { position: [ 0, - 0, + -TOP_DOWN_FOCUS_AZIMUTH_OFFSET, config.sizePreset == "Genesis XL" ? 10000 : 5000, ], target: [ diff --git a/frontend/tools/__tests__/edit_tool_slot_test.tsx b/frontend/tools/__tests__/edit_tool_slot_test.tsx index 481ba81199..6bd24e4e3e 100644 --- a/frontend/tools/__tests__/edit_tool_slot_test.tsx +++ b/frontend/tools/__tests__/edit_tool_slot_test.tsx @@ -10,7 +10,6 @@ import { } from "../../__test_support__/resource_index_builder"; import * as crud from "../../api/crud"; import { mapStateToPropsEdit } from "../state_to_props"; -import { SlotEditRows } from "../tool_slot_edit_components"; import { fakeToolTransformProps } from "../../__test_support__/fake_tool_info"; import { EditToolSlotProps } from "../interfaces"; import * as toolGraphics from "../../farm_designer/map/layers/tool_slots/tool_graphics"; @@ -157,11 +156,19 @@ describe("", () => { it("finds tool", () => { const p = fakeProps(); const toolSlot = fakeToolSlot(); - p.findToolSlot = () => toolSlot; + toolSlot.body.tool_id = 1; + const tool = fakeTool(); + tool.body.id = 1; + + p.findToolSlot = () => toolSlot; p.findTool = () => tool; - const wrapper = createWrapper(p); - expect(wrapper.root.findAllByType(SlotEditRows)[0]?.props.tool).toEqual(tool); + p.tools = [tool]; + + const { getByText } = render(); + + expect(getByText("Tool or Seed Container")).toBeTruthy(); + expect(getByText(tool.body.name as string)).toBeTruthy(); }); }); diff --git a/frontend/tools/__tests__/index_test.tsx b/frontend/tools/__tests__/index_test.tsx index 8e23b00802..5927561f26 100644 --- a/frontend/tools/__tests__/index_test.tsx +++ b/frontend/tools/__tests__/index_test.tsx @@ -2,7 +2,6 @@ const mockDevice = { readPin: jest.fn((_) => Promise.resolve()) }; import React from "react"; import { act, fireEvent, render } from "@testing-library/react"; -import type { ReactTestInstance } from "react-test-renderer"; import { RawTools as Tools, ToolSlotInventoryItem, @@ -17,7 +16,9 @@ import { Content, Actions } from "../../constants"; import * as crud from "../../api/crud"; import { ToolSelection } from "../tool_slot_edit_components"; import { fakeToolTransformProps } from "../../__test_support__/fake_tool_info"; -import { ToolsProps, ToolSlotInventoryItemProps } from "../interfaces"; +import { + ToolsProps, ToolSelectionProps, ToolSlotInventoryItemProps, +} from "../interfaces"; import * as mapActions from "../../farm_designer/map/actions"; import * as mapUtil from "../../farm_designer/map/util"; import { Mode } from "../../farm_designer/map/interfaces"; @@ -26,11 +27,8 @@ import { DEFAULT_CRITERIA } from "../../point_groups/criteria/interfaces"; import { Path } from "../../internal_urls"; import * as deviceModule from "../../device"; import { NavigationContext } from "../../routes_helpers"; -import { FBSelect } from "../../ui/new_fb_select"; -import { - createRenderer, - unmountRenderer, -} from "../../__test_support__/test_renderer"; +import * as toolSlotEditComponents from "../tool_slot_edit_components"; +import { findElement } from "../../__test_support__/react_element_search"; const originalPathname = location.pathname; @@ -41,44 +39,9 @@ const renderWithContext = (element: React.ReactElement) => , ); -const findNodeByType = ( - node: React.ReactNode, - matcher: (type: React.ElementType) => boolean, -): ReactTestInstance | undefined => { - if (!node || typeof node === "string" || typeof node === "number") { - return undefined; - } - if (Array.isArray(node)) { - for (const child of node as React.ReactNode[]) { - const found = findNodeByType(child, matcher); - if (found) { - return found; - } - } - return undefined; - } - if (React.isValidElement(node)) { - const element = node as React.ReactElement<{ children?: React.ReactNode }>; - if (matcher(element.type as React.ElementType)) { - return createRenderer(node as React.ReactElement).root; - } - let found: ReactTestInstance | undefined; - React.Children.forEach(element.props.children, (child: React.ReactNode) => { - if (found) { - return; - } - found = findNodeByType(child, matcher); - }); - if (found) { - return found; - } - } - return undefined; -}; - describe("", () => { afterEach(() => { - history.replaceState(undefined, "", Path.mock(originalPathname)); + location.pathname = originalPathname; jest.useRealTimers(); }); @@ -256,7 +219,7 @@ describe("", () => { const ref = React.createRef(); renderWithContext(); const mountedTool = ref.current?.MountedToolInfo(); - const toolSelection = findNodeByType( + const toolSelection = findElement( mountedTool, type => type === ToolSelection); act(() => { toolSelection?.props.onChange({ tool_id: 123 }); @@ -358,7 +321,7 @@ describe("", () => { beforeEach(() => { jest.clearAllMocks(); - history.replaceState(undefined, "", Path.mock(originalPathname)); + location.pathname = originalPathname; jest.useRealTimers(); jest.spyOn(crud, "edit").mockImplementation(jest.fn()); jest.spyOn(crud, "save").mockImplementation(jest.fn()); @@ -385,19 +348,17 @@ describe("", () => { it("changes tool", () => { const p = fakeProps(); - let wrapper: ReturnType; - act(() => { - wrapper = createRenderer(); - }); - act(() => { - wrapper.root.findByType(FBSelect).props.onChange({ value: "1" }); - }); + const toolSelectionSpy = jest.spyOn(toolSlotEditComponents, "ToolSelection") + .mockImplementation((props: ToolSelectionProps) => +
; -}; +}); + +WeedInventoryItem.displayName = "WeedInventoryItem"; diff --git a/frontend/wizard/__tests__/checks_test.tsx b/frontend/wizard/__tests__/checks_test.tsx index f1a851b6a8..2fcfe5c5f0 100644 --- a/frontend/wizard/__tests__/checks_test.tsx +++ b/frontend/wizard/__tests__/checks_test.tsx @@ -13,7 +13,6 @@ const mockDevice = { import React from "react"; import { fireEvent, render } from "@testing-library/react"; -import type { ReactTestInstance } from "react-test-renderer"; import { bot } from "../../__test_support__/fake_state/bot"; import { buildResourceIndex, fakeDevice, @@ -90,7 +89,6 @@ import * as messageCards from "../../messages/cards"; import * as bootSequenceSelector from "../../settings/fbos_settings/boot_sequence_selector"; import * as messageActions from "../../messages/actions"; import * as deviceActions from "../../devices/actions"; -import { DropdownConfig } from "../../photos/camera_calibration/config"; import { PLACEHOLDER_FARMBOT } from "../../photos/images/image_flipper"; import { createRenderer } from "../../__test_support__/test_renderer"; @@ -114,22 +112,6 @@ let emergencyUnlockSpy: jest.SpyInstance; let findHomeSpy: jest.SpyInstance; let findAxisLengthSpy: jest.SpyInstance; -const findNodeByType = ( - node: React.ReactNode, - matcher: (type: unknown) => boolean, -): ReactTestInstance | undefined => { - if (!node || !React.isValidElement(node)) { - return undefined; - } - try { - return createRenderer(node).root - .findAll(element => matcher(element.type)) - .filter(item => matcher(item.type))[0]; - } catch { - return undefined; - } -}; - beforeEach(() => { jest.clearAllMocks(); jest.useRealTimers(); @@ -951,10 +933,14 @@ describe("", () => { it("changes origin", () => { const p = fakeProps(); - const origin = findNodeByType(, - type => type === DropdownConfig); - origin?.props.onChange( - "CAMERA_CALIBRATION_image_bot_origin_location", 2); + const fbSelectProps: ui.FBSelectProps[] = []; + fbSelectSpy = jest.spyOn(ui, "FBSelect") + .mockImplementation(((props: ui.FBSelectProps) => { + fbSelectProps.push(props); + return
; + }) as never); + render(); + fbSelectProps[0].onChange({ label: "Top left", value: 2 }); expect(initSave).toHaveBeenCalledWith("FarmwareEnv", { key: "CAMERA_CALIBRATION_image_bot_origin_location", value: "\"TOP_LEFT\"", diff --git a/lib/tasks/coverage.rake b/lib/tasks/coverage.rake index 099892a257..4ceb6a8441 100644 --- a/lib/tasks/coverage.rake +++ b/lib/tasks/coverage.rake @@ -3,15 +3,15 @@ require "find" COVERAGE_FILE_PATH = "./coverage_fe/index.html" JSON_COVERAGE_FILE_PATH = "./coverage_fe/coverage-final.json" LCOV_FILE_PATH = "./coverage_fe/lcov.info" -THRESHOLD = 0.001 +THRESHOLD = 0.05 REPO_URL = "https://api.github.com/repos/Farmbot/Farmbot-Web-App" LATEST_COV_URL = "https://coveralls.io/github/FarmBot/Farmbot-Web-App.json" COV_API_BUILDS_PER_PAGE = 5 COV_BUILDS_TO_FETCH = 20 -PULL_REQUEST = ENV.fetch("CIRCLE_PULL_REQUEST", "/0") -CURRENT_BRANCH = ENV.fetch("CIRCLE_BRANCH", "staging") # "staging" or "pull/11" +PULL_REQUEST = ENV.fetch("GITHUB_PULL_REQUEST", "/0") +CURRENT_BRANCH = ENV.fetch("GITHUB_REF_NAME", "staging") # "staging" or "pull/11" BASE_BRANCHES = ["main", "staging"] -CURRENT_COMMIT = ENV.fetch("CIRCLE_SHA1", "") +CURRENT_COMMIT = ENV.fetch("GITHUB_SHA", "") CSS_SELECTOR = ".fraction" FRACTION_DELIM = "/" REMOTE_COVERAGE_OVERRIDE = ENV.fetch('REMOTE_COVERAGE_OVERRIDE', '0').to_f diff --git a/package.json b/package.json index 88ed025076..7d68d6835c 100644 --- a/package.json +++ b/package.json @@ -38,27 +38,27 @@ "mqtt": "mqtt/dist/mqtt.esm.js" }, "dependencies": { - "@blueprintjs/core": "6.12.0", - "@blueprintjs/select": "6.1.9", + "@blueprintjs/core": "6.15.0", + "@blueprintjs/select": "6.2.1", "@monaco-editor/react": "4.7.0", - "@react-spring/three": "10.0.3", + "@react-spring/three": "10.1.0", "@react-three/drei": "10.7.7", - "@react-three/fiber": "9.6.0", + "@react-three/fiber": "9.6.1", "@rollbar/react": "1.0.0", - "@types/bun": "1.3.13", + "@types/bun": "1.3.14", "@types/lodash": "4.17.24", "@types/markdown-it": "14.1.2", "@types/markdown-it-emoji": "3.0.1", "@types/promise-timeout": "1.3.3", - "@types/react": "19.2.14", + "@types/react": "19.2.15", "@types/react-color": "3.0.13", "@types/react-dom": "19.2.3", "@types/react-test-renderer": "19.1.0", "@types/redux-immutable-state-invariant": "2.1.4", - "@types/three": "0.184.0", + "@types/three": "0.184.1", "@types/ws": "8.18.1", "@xterm/xterm": "6.0.0", - "axios": "1.15.2", + "axios": "1.16.1", "bowser": "2.14.1", "browser-speech": "1.1.1", "delaunator": "5.1.0", @@ -66,9 +66,9 @@ "farmbot": "15.9.3", "fengari": "0.1.5", "fengari-web": "0.1.4", - "i18next": "26.0.6", + "i18next": "26.2.0", "lodash": "4.18.1", - "markdown-it": "14.1.1", + "markdown-it": "14.2.0", "markdown-it-emoji": "3.0.0", "moment": "2.30.1", "monaco-editor": "0.55.1", @@ -77,11 +77,11 @@ "promise-timeout": "1.3.0", "punycode": "2.3.1", "querystring-es3": "0.2.1", - "react": "19.2.5", + "react": "19.2.6", "react-color": "2.19.3", - "react-dom": "19.2.5", - "react-redux": "9.2.0", - "react-router": "7.14.2", + "react-dom": "19.2.6", + "react-redux": "9.3.0", + "react-router": "7.15.1", "redux": "5.0.1", "redux-immutable-state-invariant": "2.1.0", "redux-thunk": "3.1.0", @@ -104,36 +104,36 @@ "@types/jest": "30.0.0", "@types/readable-stream": "4.0.23", "@types/suncalc": "1.9.2", - "@typescript-eslint/eslint-plugin": "8.59.0", - "@typescript-eslint/parser": "8.59.0", - "eslint": "10.2.1", + "@typescript-eslint/eslint-plugin": "8.60.0", + "@typescript-eslint/parser": "8.60.0", + "eslint": "10.4.0", "eslint-plugin-eslint-comments": "3.2.0", "eslint-plugin-import": "2.32.0", "eslint-plugin-jest": "29.15.2", "eslint-plugin-no-null": "1.0.2", - "eslint-plugin-promise": "7.2.1", + "eslint-plugin-promise": "7.3.0", "eslint-plugin-react": "7.37.5", "eslint-plugin-react-hooks": "7.1.1", "happy-dom": "20.9.0", - "jest": "30.3.0", + "jest": "30.4.2", "jest-canvas-mock": "2.5.2", - "jest-cli": "30.3.0", - "jest-environment-jsdom": "30.3.0", - "jest-junit": "16.0.0", + "jest-cli": "30.4.2", + "jest-environment-jsdom": "30.4.1", + "jest-junit": "17.0.0", "jest-skipped-reporter": "0.0.5", "jshint": "2.13.6", "madge": "8.0.0", "path-browserify": "1.0.1", - "playwright": "1.59.1", - "postcss": "8.5.10", + "playwright": "1.60.0", + "postcss": "8.5.15", "postcss-scss": "4.0.9", "raf": "3.4.1", - "react-test-renderer": "19.2.5", - "sass": "1.99.0", + "react-test-renderer": "19.2.6", + "sass": "1.100.0", "sass-lint": "1.13.1", - "stylelint": "17.8.0", + "stylelint": "17.12.0", "stylelint-config-standard-scss": "17.0.0", - "ts-jest": "29.4.9", + "ts-jest": "29.4.11", "tslint": "6.1.3" } } diff --git a/public/400.html b/public/400.html index 39f442cfa4..fc1e2a5d79 100644 --- a/public/400.html +++ b/public/400.html @@ -1,31 +1,27 @@ - - + - - The server cannot process the request due to a client error (400 Bad Request) - - - - - - - + Bad Request (400) + + + + - -
-
-

- The server cannot process the request due to a client error. - Please check the request and try again. If you're the application owner check the logs for more information. -

-
-
- +
+ + FarmBot + +

+ 400 +

+

+ The server cannot process the request due to a client error.

+ Please check the request and try again. +

+
diff --git a/public/404.html b/public/404.html index e6419c7b81..be2eb73aad 100755 --- a/public/404.html +++ b/public/404.html @@ -4,83 +4,13 @@ - +
- - - - - - - - - - - - - - - - + FarmBot

404 diff --git a/public/405.html b/public/405.html new file mode 100644 index 0000000000..ffd0e72144 --- /dev/null +++ b/public/405.html @@ -0,0 +1,26 @@ + + + + + Method Not Allowed (405) + + + + + + +
+ + FarmBot + +

+ 405 +

+

+ The request method is not allowed.

+ Try going back. +

+
+ + + diff --git a/public/406-unsupported-browser.html b/public/406-unsupported-browser.html index fa1aa430f5..2c14a06030 100644 --- a/public/406-unsupported-browser.html +++ b/public/406-unsupported-browser.html @@ -1,20 +1,26 @@ - + - Your browser is not supported (406) - - + Your browser is not supported (406) + + + -
-
-

Your browser is not supported.

-

Please upgrade your browser to continue.

+
+ + FarmBot + +

+ 406 +

+

+ Your browser is not supported.

+ Please upgrade your browser to continue. +

-
diff --git a/public/406.html b/public/406.html new file mode 100644 index 0000000000..26ef88afe7 --- /dev/null +++ b/public/406.html @@ -0,0 +1,26 @@ + + + + + Not Acceptable (406) + + + + + + +
+ + FarmBot + +

+ 406 +

+

+ The requested format is not acceptable.

+ Try going back. +

+
+ + + diff --git a/public/422.html b/public/422.html index 7b5a47a458..bef7004b40 100755 --- a/public/422.html +++ b/public/422.html @@ -1,58 +1,26 @@ - - The change you wanted was rejected (422) - + + The change you wanted was rejected (422) + + + - -
-

The change you wanted was rejected.

-

Maybe you tried to change something you didn't have access to.

-
-

If you are the application owner check the logs for more information.

+
+ + FarmBot + +

+ 422 +

+

+ The change you wanted was rejected.

+ Maybe you tried to change something you didn't have access to. +

+
+ diff --git a/public/429.html b/public/429.html new file mode 100644 index 0000000000..75f9bae357 --- /dev/null +++ b/public/429.html @@ -0,0 +1,26 @@ + + + + + Too Many Requests (429) + + + + + + +
+ + FarmBot + +

+ 429 +

+

+ Too many requests were sent.

+ Please wait a moment and try again. +

+
+ + + diff --git a/public/500.html b/public/500.html index 90318ed0a9..8a17910654 100755 --- a/public/500.html +++ b/public/500.html @@ -4,91 +4,20 @@ - +
- - - - - - - - - - - - - - - - - + FarmBot

- Sorry, something went wrong. + 500

+ Sorry, something went wrong.

You can try going back, but if the problem persists, please let us know. -

diff --git a/public/501.html b/public/501.html new file mode 100644 index 0000000000..e49121b005 --- /dev/null +++ b/public/501.html @@ -0,0 +1,26 @@ + + + + + Not Implemented (501) + + + + + + +
+ + FarmBot + +

+ 501 +

+

+ The requested feature is not implemented.

+ Try going back. +

+
+ + + diff --git a/public/app-resources/languages/zh.json b/public/app-resources/languages/zh.json index 649226cf01..25f99b128f 100644 --- a/public/app-resources/languages/zh.json +++ b/public/app-resources/languages/zh.json @@ -1052,7 +1052,6 @@ "live chat": "在线聊天", "Load (%)": "负载 (%)", "Loading": "加载中", - "Loading interactive 3D FarmBot...": "正在加载交互式3D FarmBot...", "Loading slots": "加载槽位", "Loading...": "加载中...", "Local IP": "本地 IP", diff --git a/public/error-pages.css b/public/error-pages.css new file mode 100644 index 0000000000..0039a8ca34 --- /dev/null +++ b/public/error-pages.css @@ -0,0 +1,49 @@ +body { + min-width: 100vw; + min-height: 100vh; + background: linear-gradient(-135deg, #089460, #159ACC); + background-size: 100% 100%; + overflow: hidden; + text-shadow: 0px 1px 1px #555; +} + +img { + display: block; + margin: 0 auto 2rem; + max-width: 20rem; +} + +h1, +h4, +a { + font-family: "Cabin", "Cabin Fallback", sans-serif; + text-align: center; + color: #fff; +} + +h1 { + font-weight: 600; + font-size: 4rem; + margin-bottom: 1rem; +} + +h4, +a { + font-size: 1.2rem; + font-weight: 400; +} + +a { + text-decoration: underline; + cursor: pointer; +} + +a:active, +a:visited { + color: #fff; +} + +.content { + padding: 0 2rem; + margin: 4rem auto 0; +} diff --git a/public/promo_loading_image.avif b/public/promo_loading_image.avif deleted file mode 100644 index f6c7cb8f2a..0000000000 Binary files a/public/promo_loading_image.avif and /dev/null differ diff --git a/public/promo_loading_image_full_bot.avif b/public/promo_loading_image_full_bot.avif deleted file mode 100644 index 49813140ac..0000000000 Binary files a/public/promo_loading_image_full_bot.avif and /dev/null differ diff --git a/scripts/ci/combine-render-images b/scripts/ci/combine-render-images new file mode 100755 index 0000000000..be6820d6f9 --- /dev/null +++ b/scripts/ci/combine-render-images @@ -0,0 +1,151 @@ +#!/usr/bin/env bun +const fs = require('fs'); +const path = require('path'); +const { chromium } = require('playwright'); + +const requiredEnv = name => { + const value = process.env[name]; + if (!value) { + console.error(`${name} is required`); + process.exit(1); + } + return value; +}; + +const output = `/tmp/${requiredEnv('COMBINED_RENDER_IMAGE')}.png`; +const screenshotName = requiredEnv('SCREENSHOT_NAME'); +const fpsSamplesName = requiredEnv('FPS_SAMPLES_NAME'); +const sceneMetricsName = requiredEnv('SCENE_METRICS_NAME'); +const feCoverageName = requiredEnv('FE_COVERAGE_NAME'); +const imagePath = name => `/tmp/${name}.png`; +const imageData = filePath => { + const data = fs.readFileSync(filePath).toString('base64'); + return `data:image/png;base64,${data}`; +}; + +const metricRow = name => [ + [`${name} screenshot`, imagePath(`${screenshotName}_${name}`)], + [`${name} fps`, imagePath(`${fpsSamplesName}_${name}`)], + [`${name} metrics`, imagePath(`${sceneMetricsName}_${name}`)], +]; +const screenshotCell = name => [ + name.replace(/_/g, ' '), + imagePath(`${screenshotName}_${name}`), +]; +const rows = [ + metricRow('promo_xl'), + metricRow('app'), + metricRow('stress'), + metricRow('water'), + [ + screenshotCell('login'), + screenshotCell('os'), + screenshotCell('demo'), + ], + [ + screenshotCell('password_reset'), + screenshotCell('404'), + ['fe coverage', imagePath(feCoverageName)], + ], +]; + +const escapeHtml = value => String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +const cell = ([title, filePath]) => { + if (!fs.existsSync(filePath)) { + return ` +
+
${escapeHtml(title)}
+
Missing ${escapeHtml(path.basename(filePath))}
+
`; + } + return ` +
+
${escapeHtml(title)}
+ +
`; +}; +const rowHtml = rows + .map(row => `
${row.map(cell).join('')}
`) + .join(''); +const html = ` + + + + + + + ${rowHtml} + +`; + +async function main() { + fs.mkdirSync(path.dirname(output), { recursive: true }); + const browser = await chromium.launch({ + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + ], + }); + try { + const page = await browser.newPage({ viewport: { width: 1800, height: 1200 } }); + await page.setContent(html, { waitUntil: 'load' }); + await page.screenshot({ path: output, fullPage: true }); + console.log(`COMBINED_RENDER_IMAGE=${output}`); + } finally { + await browser.close(); + } +} + +main().catch(error => { + console.error('Failed to combine render images:', error.message || error); + process.exitCode = 1; +}); diff --git a/scripts/ci/create-compare-link b/scripts/ci/create-compare-link new file mode 100755 index 0000000000..499d8e0364 --- /dev/null +++ b/scripts/ci/create-compare-link @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +import json +import os +import sys +import urllib.request + +deploys_url = "https://api.github.com/repos/Farmbot/Farmbot-Web-App/deployments" +compare_url_base = "https://github.com/Farmbot/Farmbot-Web-App/compare/" +github_sha = os.environ["GITHUB_SHA"] + +try: + with urllib.request.urlopen(deploys_url, timeout=30) as response: + deployments = json.load(response) +except Exception as error: + print(f"Failed to read deployments: {error}", file=sys.stderr) + deployments = [] + +last_sha = deployments[0].get("sha", "") if deployments else "" +print(f"{compare_url_base}{last_sha}...{github_sha}") diff --git a/scripts/ci/export-render-env b/scripts/ci/export-render-env new file mode 100755 index 0000000000..d466e8fa0e --- /dev/null +++ b/scripts/ci/export-render-env @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +render_env="scripts/ci/render-env" +github_env="${GITHUB_ENV:?GITHUB_ENV is required}" + +set -a +# shellcheck disable=SC1091 +. "$render_env" +set +a + +while IFS= read -r line; do + case "$line" in + ': "${'*':='*'}"') + name="${line#*: \"\$\{}" + name="${name%%:=*}" + echo "$name=${!name}" >> "$github_env" + ;; + esac +done < "$render_env" diff --git a/scripts/ci/git-publish-render-artifacts b/scripts/ci/git-publish-render-artifacts new file mode 100755 index 0000000000..a62b5d7ebd --- /dev/null +++ b/scripts/ci/git-publish-render-artifacts @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${ARTIFACT_BRANCH:?ARTIFACT_BRANCH is required}" +: "${COMBINED_RENDER_IMAGE:?COMBINED_RENDER_IMAGE is required}" +: "${CHOSEN_FPS:?CHOSEN_FPS is required}" +: "${SCENE_METRICS_NAME:?SCENE_METRICS_NAME is required}" +: "${FE_COVERAGE_NAME:?FE_COVERAGE_NAME is required}" +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +: "${GITHUB_RUN_ID:?GITHUB_RUN_ID is required}" +: "${GITHUB_ENV:?GITHUB_ENV is required}" + +worktree=$(mktemp -d) +git config user.name "github-actions[bot]" +git config user.email "41898282+github-actions[bot]@users.noreply.github.com" +if git ls-remote --exit-code --heads origin "$ARTIFACT_BRANCH" >/dev/null; then + git fetch origin "$ARTIFACT_BRANCH" + git worktree add "$worktree" "origin/$ARTIFACT_BRANCH" +else + git worktree add --detach "$worktree" + git -C "$worktree" switch --orphan "$ARTIFACT_BRANCH" +fi + +bun scripts/ci/combine-render-images + +mkdir -p "$worktree/build/latest" "$worktree/build/${GITHUB_RUN_ID}" +for image in "$COMBINED_RENDER_IMAGE"; do + cp "/tmp/$image.png" "$worktree/build/latest/$image.png" + cp "/tmp/$image.png" "$worktree/build/${GITHUB_RUN_ID}/$image.png" +done +cp "/tmp/$CHOSEN_FPS.csv" "$worktree/build/latest/$CHOSEN_FPS.csv" +cp "/tmp/$CHOSEN_FPS.csv" "$worktree/build/${GITHUB_RUN_ID}/$CHOSEN_FPS.csv" +for metric in "/tmp/$SCENE_METRICS_NAME"*.csv "/tmp/$FE_COVERAGE_NAME.csv"; do + [ -f "$metric" ] && cp "$metric" "$worktree/$(basename "$metric")" +done +git -C "$worktree" add build +for artifact in "$worktree/$SCENE_METRICS_NAME"*.csv "$worktree/$FE_COVERAGE_NAME.csv"; do + [ -f "$artifact" ] && git -C "$worktree" add "$(basename "$artifact")" +done +git -C "$worktree" commit -m "Update CI image artifacts for ${GITHUB_RUN_ID}" || true +git -C "$worktree" push origin "HEAD:$ARTIFACT_BRANCH" + +raw_base="https://raw.githubusercontent.com/${GITHUB_REPOSITORY}/${ARTIFACT_BRANCH}/build/${GITHUB_RUN_ID}" +render_summary_url="${raw_base}/${COMBINED_RENDER_IMAGE}.png" +echo "RENDER_SUMMARY_URL=${render_summary_url}" +echo "RENDER_SUMMARY_URL=${render_summary_url}" >> "$GITHUB_ENV" diff --git a/scripts/ci/git-restore-artifact-branch-file b/scripts/ci/git-restore-artifact-branch-file new file mode 100755 index 0000000000..13de65686b --- /dev/null +++ b/scripts/ci/git-restore-artifact-branch-file @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +source_pattern="${1:?source path is required}" +: "${ARTIFACT_BRANCH:?ARTIFACT_BRANCH is required}" + +if ! git ls-remote --exit-code --heads origin "$ARTIFACT_BRANCH" >/dev/null; then + exit 0 +fi + +git fetch origin "$ARTIFACT_BRANCH" + +git ls-tree -r --name-only "origin/$ARTIFACT_BRANCH" | while IFS= read -r source_path; do + case "$source_path" in + $source_pattern) + destination_path="/tmp/$source_path" + mkdir -p "$(dirname "$destination_path")" + git show "origin/$ARTIFACT_BRANCH:$source_path" > "$destination_path" + ;; + esac +done diff --git a/scripts/ci/percent-change b/scripts/ci/percent-change new file mode 100755 index 0000000000..55647cdb52 --- /dev/null +++ b/scripts/ci/percent-change @@ -0,0 +1,9 @@ +#!/usr/bin/env python3 +import argparse + +parser = argparse.ArgumentParser() +parser.add_argument("--new", required=True, type=float) +parser.add_argument("--old", required=True, type=float) +args = parser.parse_args() + +print("n/a" if args.old == 0 else f"{((args.new - args.old) / args.old) * 100:.2f}") diff --git a/scripts/ci/previous-fps-value b/scripts/ci/previous-fps-value new file mode 100755 index 0000000000..21a6f88026 --- /dev/null +++ b/scripts/ci/previous-fps-value @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +import csv +import os +import sys + +def required_env(name): + value = os.environ.get(name) + if value is None: + print(f"{name} is required", file=sys.stderr) + raise SystemExit(1) + return value + +chosen_metrics = required_env("CHOSEN_METRICS") +fallback = required_env("FALLBACK_FPS_VALUE") +path = f"/tmp/{chosen_metrics}.csv" + +try: + with open(path, newline="") as f: + rows = list(csv.DictReader(f)) +except FileNotFoundError: + rows = [] + +values = [] +for row in rows[:-1]: + try: + values.append(float(row["FPS"])) + except (KeyError, TypeError, ValueError): + pass + +print(f"{values[-1]:.2f}" if values else fallback) diff --git a/scripts/ci/render-env b/scripts/ci/render-env new file mode 100644 index 0000000000..e915a0078f --- /dev/null +++ b/scripts/ci/render-env @@ -0,0 +1,13 @@ +: "${CHOSEN_NAME:=promo_xl}" +: "${SCREENSHOT_NAME:=screenshot}" +: "${CHOSEN_IMAGE:=${SCREENSHOT_NAME}_${CHOSEN_NAME}}" +: "${FPS_SAMPLES_NAME:=fps_samples}" +: "${CHOSEN_FPS:=${FPS_SAMPLES_NAME}_${CHOSEN_NAME}}" +: "${SCENE_METRICS_NAME:=scene_metrics}" +: "${CHOSEN_METRICS:=${SCENE_METRICS_NAME}_${CHOSEN_NAME}}" +: "${COMBINED_RENDER_IMAGE:=render_summary}" +: "${FE_COVERAGE_NAME:=fe_coverage}" +: "${SCENE_METRICS_HEADER:=epoch, FPS, Calls, Triangles, Points, Lines, Geometries, Textures, Objects, Meshes, Instanced meshes}" +: "${ARTIFACT_BRANCH:=ci-artifacts}" +: "${FALLBACK_FPS_VALUE:=100}" +: "${FALLBACK_FE_COVERAGE_VALUE:=99.4}" diff --git a/scripts/ci/render-url-records b/scripts/ci/render-url-records new file mode 100755 index 0000000000..0b5f4d9992 --- /dev/null +++ b/scripts/ci/render-url-records @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as f: + entries = json.load(f) + +for entry in entries: + print("\034".join([ + entry["name"], + entry["mode"], + entry["url"], + entry.get("click", ""), + entry.get("state", ""), + ])) diff --git a/scripts/ci/render-urls.json b/scripts/ci/render-urls.json new file mode 100644 index 0000000000..aa0924e64b --- /dev/null +++ b/scripts/ci/render-urls.json @@ -0,0 +1,49 @@ +[ + { + "name": "promo_xl", + "mode": "fps", + "url": "http://localhost:3000/promo?sizePreset=Genesis+XL&promoSpread=true" + }, + { + "name": "login", + "mode": "screenshot", + "url": "http://localhost:3000/" + }, + { + "name": "os", + "mode": "screenshot", + "url": "http://localhost:3000/os" + }, + { + "name": "demo", + "mode": "screenshot", + "url": "http://localhost:3000/demo" + }, + { + "name": "password_reset", + "mode": "screenshot", + "url": "http://localhost:3000/password_reset/abc" + }, + { + "name": "404", + "mode": "screenshot", + "url": "http://localhost:3000/404" + }, + { + "name": "app", + "mode": "fps", + "url": "http://localhost:3000/try_farmbot?productLine=genesis_1.8" + }, + { + "name": "stress", + "mode": "fps", + "url": "http://localhost:3000/try_farmbot?productLine=genesis_xl_1.8_stress_1000" + }, + { + "name": "water", + "mode": "fps", + "url": "http://localhost:3000/app/designer/sequences/Water_all", + "click": "Run", + "state": "app" + } +] diff --git a/scripts/ci/run-playwright-render b/scripts/ci/run-playwright-render new file mode 100755 index 0000000000..1270244039 --- /dev/null +++ b/scripts/ci/run-playwright-render @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ -f scripts/ci/render-env ]; then + set -a + # shellcheck disable=SC1091 + . scripts/ci/render-env + set +a +fi + +if [ -z "${GITHUB_ACTIONS:-}" ]; then + export OPEN_WINDOW="${OPEN_WINDOW:-1}" +fi + +attempts="${ATTEMPTS:-2}" +: "${CHOSEN_NAME:?CHOSEN_NAME is required}" +: "${SCREENSHOT_NAME:?SCREENSHOT_NAME is required}" +: "${FPS_SAMPLES_NAME:?FPS_SAMPLES_NAME is required}" +: "${SCENE_METRICS_NAME:?SCENE_METRICS_NAME is required}" +: "${SCENE_METRICS_HEADER:?SCENE_METRICS_HEADER is required}" +GITHUB_ENV="${GITHUB_ENV:-/tmp/render-github-env}" +URLS_JSON="${URLS_JSON:-scripts/ci/render-urls.json}" +chosen_seen=false + +while IFS=$'\034' read -r -u 3 name mode url click state; do + screenshot_path="/tmp/${SCREENSHOT_NAME}_${name}.png" + fps_samples_path="/tmp/${FPS_SAMPLES_NAME}_${name}.csv" + scene_metrics_path="/tmp/${SCENE_METRICS_NAME}_${name}.csv" + + for _ in $(seq 1 "$attempts"); do + fps_args=( + --name "$name" + --url "$url" + --screenshot-path "$screenshot_path" + --fps-samples-path "$fps_samples_path" + ) + [ -n "$click" ] && fps_args+=(--click "$click") + [ -n "$state" ] && fps_args+=(--state "$state") + + echo -e "\n\n\n" + if [ "$mode" = "screenshot" ]; then + echo "Attempting screenshot via ${url}" + else + echo "Attempting FPS check via ${url}" + fi + if ! sudo docker compose exec -T web curl -I "${url}" >/dev/null; then + continue + fi + + if [ "$mode" = "screenshot" ]; then + if ! screenshot_output=$(bun scripts/fps.js --screenshot-only "${fps_args[@]}"); then + echo "${screenshot_output}" + continue + fi + echo "${screenshot_output}" + continue 2 + fi + + if ! fps_output=$(bun scripts/fps.js "${fps_args[@]}"); then + echo "${fps_output}" + continue + fi + echo "${fps_output}" + + fps_value=$(echo "${fps_output}" | awk -F= '/^FPS_VALUE=/{print $2; exit}') + scene_metrics=$(echo "${fps_output}" | awk -F= '/^SCENE_METRICS=/{print $2; exit}') + if [ ! -f "$scene_metrics_path" ]; then + printf '%s\n' "$SCENE_METRICS_HEADER" > "$scene_metrics_path" + fi + printf '%s\n' "$scene_metrics" >> "$scene_metrics_path" + echo "SCENE_METRICS=${scene_metrics_path}" + + if [ "$name" = "${CHOSEN_NAME}" ]; then + echo "FPS_VALUE=${fps_value}" >> "$GITHUB_ENV" + echo "SCENE_METRICS=${scene_metrics}" >> "$GITHUB_ENV" + chosen_seen=true + fi + + continue 2 + done + + echo "FPS check failed for ${url}" + exit 1 +done 3< <(scripts/ci/render-url-records "$URLS_JSON") + +if [ "$chosen_seen" != "true" ]; then + echo "Chosen FPS scenario '${CHOSEN_NAME}' was not found in ${URLS_JSON}" + exit 1 +fi + +if [ -z "${GITHUB_ACTIONS:-}" ]; then + echo -e "\n\n\n" + echo "Plotting metrics..." + for csv in /tmp/*.csv; do + [ -f "$csv" ] || continue + bun scripts/metric_plot.js "$csv" + done + echo -e "\n\n\n" +fi diff --git a/scripts/ci/send-notification b/scripts/ci/send-notification new file mode 100755 index 0000000000..faaee307e1 --- /dev/null +++ b/scripts/ci/send-notification @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +import json +import os +import sys +import urllib.request + +webhook_url = os.environ.get("SLACK_WEBHOOK_URL") +if not webhook_url: + raise SystemExit(0) + +payload = json.dumps({ + "text": " ".join(sys.argv[1:]), + "channel": "#software", +}).encode() +request = urllib.request.Request( + webhook_url, + data=payload, + headers={"Content-Type": "application/json"}, + method="POST", +) + +try: + urllib.request.urlopen(request, timeout=30) +except Exception as error: + print(f"Failed to send notification: {error}", file=sys.stderr) diff --git a/scripts/ci/test_python_scripts.py b/scripts/ci/test_python_scripts.py new file mode 100644 index 0000000000..c62c7f035d --- /dev/null +++ b/scripts/ci/test_python_scripts.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +import contextlib +import io +import json +import os +from pathlib import Path +import runpy +import sys +import tempfile +import unittest +from unittest.mock import patch + + +ROOT = Path(__file__).resolve().parents[2] +CI = ROOT / "scripts" / "ci" + + +class FakeResponse: + def __init__(self, body): + self.body = body + + def __enter__(self): + return self + + def __exit__(self, _exc_type, _exc_value, _traceback): + return False + + def read(self): + return self.body.encode() + + +def run_script(name, argv=None, env=None): + stdout = io.StringIO() + stderr = io.StringIO() + script = CI / name + with patch.object(sys, "argv", [str(script), *(argv or [])]): + with patch.dict(os.environ, env or {}, clear=True): + with contextlib.redirect_stdout(stdout): + with contextlib.redirect_stderr(stderr): + try: + runpy.run_path(str(script), run_name="__main__") + except SystemExit as error: + code = error.code if isinstance(error.code, int) else 1 + else: + code = 0 + return code, stdout.getvalue(), stderr.getvalue() + + +class CiPythonScriptTest(unittest.TestCase): + def test_percent_change_reports_percent(self): + code, stdout, stderr = run_script("percent-change", [ + "--new", "110", + "--old", "100", + ]) + self.assertEqual(code, 0) + self.assertEqual(stdout, "10.00\n") + self.assertEqual(stderr, "") + + def test_percent_change_handles_zero_old_value(self): + code, stdout, _stderr = run_script("percent-change", [ + "--new", "110", + "--old", "0", + ]) + self.assertEqual(code, 0) + self.assertEqual(stdout, "n/a\n") + + def test_previous_fps_value_reads_previous_row(self): + metrics_name = f"scene_metrics_test_{os.getpid()}" + path = Path("/tmp") / f"{metrics_name}.csv" + path.write_text( + "epoch,FPS\n" + "1,80\n" + "2,90\n" + "3,100\n", + ) + try: + code, stdout, stderr = run_script("previous-fps-value", env={ + "CHOSEN_METRICS": metrics_name, + "FALLBACK_FPS_VALUE": "100", + }) + finally: + path.unlink(missing_ok=True) + + self.assertEqual(code, 0) + self.assertEqual(stdout, "90.00\n") + self.assertEqual(stderr, "") + + def test_previous_fps_value_uses_fallback_without_history(self): + code, stdout, _stderr = run_script("previous-fps-value", env={ + "CHOSEN_METRICS": f"missing_metrics_{os.getpid()}", + "FALLBACK_FPS_VALUE": "100", + }) + self.assertEqual(code, 0) + self.assertEqual(stdout, "100\n") + + def test_render_url_records_outputs_field_separated_records(self): + with tempfile.NamedTemporaryFile("w", delete=False) as file: + json.dump([{ + "name": "promo", + "mode": "fps", + "url": "http://localhost:3000/promo", + "click": "Run", + "state": "app", + }], file) + file_path = file.name + try: + code, stdout, stderr = run_script( + "render-url-records", [file_path]) + finally: + Path(file_path).unlink(missing_ok=True) + + self.assertEqual(code, 0) + self.assertEqual( + stdout, "promo\034fps\034http://localhost:3000/promo\034Run\034app\n") + self.assertEqual(stderr, "") + + def test_create_compare_link_uses_latest_deployment_sha(self): + deployments = json.dumps([{"sha": "old123"}]) + with patch("urllib.request.urlopen", return_value=FakeResponse(deployments)): + code, stdout, stderr = run_script("create-compare-link", env={ + "GITHUB_SHA": "new456", + }) + + self.assertEqual(code, 0) + self.assertEqual( + stdout, "https://github.com/Farmbot/Farmbot-Web-App/compare/old123...new456\n") + self.assertEqual(stderr, "") + + def test_send_notification_skips_without_webhook(self): + code, stdout, stderr = run_script("send-notification", ["hello"]) + self.assertEqual(code, 0) + self.assertEqual(stdout, "") + self.assertEqual(stderr, "") + + def test_send_notification_posts_payload(self): + calls = [] + + def fake_urlopen(request, timeout): + calls.append((request, timeout)) + return object() + + with patch("urllib.request.urlopen", side_effect=fake_urlopen): + code, stdout, stderr = run_script("send-notification", ["hello", "world"], env={ + "SLACK_WEBHOOK_URL": "https://example.test/webhook", + }) + + self.assertEqual(code, 0) + self.assertEqual(stdout, "") + self.assertEqual(stderr, "") + self.assertEqual(len(calls), 1) + request, timeout = calls[0] + self.assertEqual(timeout, 30) + self.assertEqual(request.full_url, "https://example.test/webhook") + self.assertEqual(request.get_method(), "POST") + self.assertEqual(json.loads(request.data.decode()), { + "text": "hello world", + "channel": "#software", + }) + + def test_track_fe_coverage_appends_csv(self): + coverage_name = f"fe_coverage_test_{os.getpid()}" + csv_path = Path("/tmp") / f"{coverage_name}.csv" + with tempfile.NamedTemporaryFile("w", delete=False) as lcov: + lcov.write("LF:10\nLH:8\nLF:5\nLH:4\n") + lcov_path = lcov.name + + try: + code, stdout, stderr = run_script("track-fe-coverage", env={ + "FE_COVERAGE_NAME": coverage_name, + "FALLBACK_FE_COVERAGE_VALUE": "75", + "FE_LCOV_PATH": lcov_path, + "PATH": os.environ["PATH"], + }) + finally: + Path(lcov_path).unlink(missing_ok=True) + csv_path.unlink(missing_ok=True) + + self.assertEqual(code, 0) + self.assertIn( + "Covered lines: 12, Total lines: 15, Coverage: 80.00%", stdout) + self.assertIn("80.00% (6.67% change)", stdout) + self.assertIn( + "percent,covered lines,total lines,percent change\n80.00,12,15,6.67\n", stdout) + self.assertEqual(stderr, "") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/ci/track-fe-coverage b/scripts/ci/track-fe-coverage new file mode 100755 index 0000000000..4c4600511c --- /dev/null +++ b/scripts/ci/track-fe-coverage @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +import csv +import os +from pathlib import Path +import subprocess + +fe_coverage_name = os.environ["FE_COVERAGE_NAME"] +fallback = os.environ["FALLBACK_FE_COVERAGE_VALUE"] +lcov_path = Path(os.environ.get("FE_LCOV_PATH", "coverage_fe/lcov.info")) +csv_path = Path("/tmp") / f"{fe_coverage_name}.csv" + +covered = 0 +total = 0 +with lcov_path.open() as lcov: + for line in lcov: + if line.startswith("LH:"): + covered += int(line.split(":", 1)[1]) + elif line.startswith("LF:"): + total += int(line.split(":", 1)[1]) + +value = f"{(covered / total) * 100:.2f}" + +previous = fallback +if csv_path.exists(): + with csv_path.open(newline="") as csv_file: + reader = csv.reader(csv_file) + next(reader, None) + values = [float(row[0]) for row in reader if row and row[0]] + if values: + previous = f"{values[-1]:.2f}" + +percent_change = subprocess.check_output([ + "scripts/ci/percent-change", + "--new", + value, + "--old", + previous, +], text=True).strip() + +print(f"Covered lines: {covered}, Total lines: {total}, Coverage: {value}%") +print(f"{value}% ({percent_change}% change)") + +if not csv_path.exists(): + csv_path.write_text("percent,covered lines,total lines,percent change\n") + +with csv_path.open("a", newline="") as csv_file: + writer = csv.writer(csv_file) + writer.writerow([value, covered, total, percent_change]) + +print(csv_path.read_text(), end="") diff --git a/scripts/fps.js b/scripts/fps.js index b503de4dca..6ba48751ae 100644 --- a/scripts/fps.js +++ b/scripts/fps.js @@ -1,45 +1,240 @@ const { chromium } = require('playwright'); const fs = require('fs'); const path = require('path'); +const { prepareStressResources } = require('./fps_stress_resources'); -const url = process.argv[2]; -const screenshotPath = process.argv[3] || 'tmp/fps.png'; +function parseArgs(argv) { + const options = { + screenshotPath: '/tmp/fps.png', + samplesCsvPath: '/tmp/fps_samples.csv', + screenshotOnly: false, + }; + const valueOptions = { + '--name': 'name', + '--url': 'url', + '--screenshot-path': 'screenshotPath', + '--fps-samples-path': 'samplesCsvPath', + '--click': 'click', + '--state': 'state', + }; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '-h' || arg === '--help') { + options.help = true; + continue; + } + if (arg === '--screenshot-only') { + options.screenshotOnly = true; + continue; + } + const optionName = valueOptions[arg]; + if (!optionName) { throw new Error(`Unknown argument: ${arg}`); } + const value = argv[i + 1]; + if (!value || value.startsWith('--')) { + throw new Error(`Missing value for ${arg}`); + } + options[optionName] = value; + i++; + } + + return options; +} + +const options = parseArgs(process.argv.slice(2)); +const name = options.name; +const url = options.url; +const screenshotPath = options.screenshotPath; +const samplesCsvPath = options.samplesCsvPath; +const maxLoadingSamples = 240; +const postLoadSamples = 10; +const sampleIntervalMs = 1000; +const ci = Boolean(process.env.CI); +const openWindow = Boolean(process.env.DISPLAY && process.env.OPEN_WINDOW); +const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; +const screenshotOnly = options.screenshotOnly; +const click = options.click; +const state = options.state ? path.join('/tmp', `${options.state}.json`) : undefined; +const saveState = path.join('/tmp', `${name}.json`); +const chromiumArgs = [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + '--ignore-gpu-blocklist', + '--disable-frame-rate-limit', + '--disable-gpu-vsync', + '--enable-features=Vulkan', + '--use-gl=angle', + '--enable-gpu-rasterization', + '--enable-zero-copy', + ...((openWindow && !ci) ? [] : ['--use-angle=vulkan']), +]; + +const pageIsLoading = page => + page.evaluate(() => Boolean(document.querySelector('.three-d-load-progress'))); + +const webglRenderer = page => + page.evaluate(() => { + const canvas = document.createElement('canvas'); + const gl = canvas.getContext('webgl2') || canvas.getContext('webgl'); + if (!gl) { return { status: 'unavailable' }; } + + const debugInfo = gl.getExtension('WEBGL_debug_renderer_info'); + if (!debugInfo) { return { status: 'available' }; } + + return { + status: 'available', + vendor: gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL), + renderer: gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL), + }; + }); + +function printUsage() { + console.log([ + 'Usage: bun scripts/fps.js --name --url [options]', + '', + 'Options:', + ' --name Scenario name used for storage state output.', + ' --url Page URL that exposes window.__fps and window.__scene_metrics.', + ' --screenshot-path Full-page screenshot PNG path. Default: /tmp/fps.png', + ' --fps-samples-path FPS samples CSV path. Default: /tmp/fps_samples.csv', + ' --screenshot-only Take a screenshot without FPS metrics.', + ' --click Click an element by title after page load.', + ' --state <name> Load cookies and localStorage from /tmp/<name>.json.', + '', + 'Environment:', + ' CI Use CI browser launch behavior.', + ' DISPLAY Display server used for headed local runs.', + ' OPEN_WINDOW Open a visible browser window when DISPLAY is set.', + ' PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH Path to a Chrome/Chromium binary.', + ].join('\n')); +} + +const csvField = value => { + const stringValue = String(value); + return /[",\n\r]/.test(stringValue) + ? `"${stringValue.replace(/"/g, '""')}"` + : stringValue; +}; + +function saveFpsSamplesCsv(samples, destination) { + fs.mkdirSync(path.dirname(destination), { recursive: true }); + const rows = [ + ['elapsed seconds', 'fps', 'loading', 'averaged'], + ...samples.map(sample => [ + Number(sample.elapsedSeconds).toFixed(3), + Number(sample.fps).toFixed(2), + sample.loading ? 'true' : 'false', + sample.averaged ? 'true' : 'false', + ]), + ]; + fs.writeFileSync(destination, `${rows + .map(row => row.map(csvField).join(',')) + .join('\n')}\n`); +} + +async function saveStorage(page) { + fs.mkdirSync(path.dirname(saveState), { recursive: true }); + await page.context().storageState({ path: saveState }); + console.log(`SAVE_STATE=${saveState}`); +} async function main() { + console.log(`Launching Chromium with args:\n ${chromiumArgs.join('\n ')}\n`); + if (executablePath) { + console.log(`CHROMIUM_EXECUTABLE_PATH=${executablePath}`); + } const browser = await chromium.launch({ - headless: true, - args: [ - '--no-sandbox', - '--disable-setuid-sandbox', - '--disable-dev-shm-usage', - '--enable-gpu', - ], + headless: !openWindow, + executablePath, + args: chromiumArgs, }); - const page = await browser.newPage(); - page.setDefaultTimeout(120_000); + const contextOptions = state ? { storageState: state } : {}; + if (state) { + console.log(`STATE=${state}`); + } + const context = await browser.newContext(contextOptions); + const page = await context.newPage(); + page.setDefaultTimeout(60_000); try { await page.goto(url, { waitUntil: 'domcontentloaded' }); - await page.waitForFunction(() => { - const canvas = document.querySelector('.garden-bed-3d-model canvas'); - return Boolean(canvas && typeof canvas.dataset.engine === 'string'); - }); + await prepareStressResources(page, url); + if (click) { + await page.getByTitle(click).click(); + console.log(`CLICK=${click}`); + } + if (screenshotOnly) { + await page.waitForTimeout(1000); + fs.mkdirSync(path.dirname(screenshotPath), { recursive: true }); + await page.screenshot({ + path: screenshotPath, + fullPage: true, + timeout: 60_000, + }); + console.log(`SCREENSHOT=${screenshotPath}`); + return; + } + const renderer = await webglRenderer(page); + console.log(`WEBGL_STATUS=${renderer.status}`); + if (renderer.vendor) { console.log(`WEBGL_VENDOR=${renderer.vendor}`); } + if (renderer.renderer) { console.log(`WEBGL_RENDERER=${renderer.renderer}`); } await page.waitForFunction(() => typeof window.__fps !== 'undefined'); - const samples = 10; - const takeSample = 5; - let lastSample = 0; - let validCount = 0; - for (let i = 0; i < samples; i++) { + let averagePostLoadSample = 0; + let loadingSampleCount = 0; + let postLoadCount = 0; + const postLoadValues = []; + const sampleValues = []; + let loading = true; + const maxSamples = maxLoadingSamples + postLoadSamples; + const startMs = Date.now(); + let loadedSeen = false; + for (let i = 0; i < maxSamples; i++) { const v = await page.evaluate(() => window.__fps); const n = Number(v); - if (Number.isFinite(n) && validCount <= takeSample) { - lastSample = n; - validCount++; + const elapsedSeconds = (Date.now() - startMs) / 1000; + const pageLoading = await pageIsLoading(page); + if (pageLoading) { + loadedSeen = false; + loading = true; + } else { + loading = !loadedSeen; + loadedSeen = true; } - console.log(`Sample ${i + 1}/${samples}: ${n}`); - await page.waitForTimeout(1000); + let averaged = false; + if (loading) { + loadingSampleCount++; + } else { + postLoadCount++; + postLoadValues.push(n); + averaged = true; + } + const sample = { + fps: n, + elapsedSeconds, + loading, + averaged, + }; + sampleValues.push(sample); + const status = loading + ? 'loading' + : `loaded ${postLoadCount}/${postLoadSamples}`; + const averagedMarker = averaged ? ' <---' : ''; + console.log(`Sample ${i + 1} (${status}, ${elapsedSeconds.toFixed(2)}s): ${n.toFixed(2)}fps${averagedMarker}`); + if (postLoadCount >= postLoadSamples) { break; } + if (loadingSampleCount >= maxLoadingSamples) { break; } + await page.waitForTimeout(sampleIntervalMs); + } + if (loading) { + throw new Error(`3D load did not finish after ${sampleValues.length} samples`); } - console.log(`FPS_VALUE=${lastSample.toFixed(2)}`); + averagePostLoadSample = + postLoadValues.reduce((total, value) => total + value, 0) + / postLoadValues.length; + if (!Number.isFinite(averagePostLoadSample)) { + throw new Error('Average post-load FPS was not a valid value'); + } + console.log(`FPS_VALUE=${averagePostLoadSample.toFixed(2)}`); const data = await page.evaluate(() => window.__scene_metrics); console.log(`SCENE_METRICS=${data}`); fs.mkdirSync(path.dirname(screenshotPath), { recursive: true }); @@ -49,6 +244,9 @@ async function main() { timeout: 60_000, }); console.log(`FPS_SCREENSHOT=${screenshotPath}`); + saveFpsSamplesCsv(sampleValues, samplesCsvPath); + console.log(`FPS_SAMPLES_CSV=${samplesCsvPath}`); + await saveStorage(page); } catch (err) { console.error('Failed to read window.__fps:', err.message || err); process.exitCode = 1; @@ -57,7 +255,15 @@ async function main() { } } -main().catch((err) => { - console.error('Unexpected error:', err); +if (options.help) { + printUsage(); + process.exitCode = 0; +} else if (!name || !url) { + printUsage(); process.exitCode = 1; -}); +} else { + main().catch((err) => { + console.error('Unexpected error:', err); + process.exitCode = 1; + }); +} diff --git a/scripts/fps_stress_resources.js b/scripts/fps_stress_resources.js new file mode 100644 index 0000000000..7d64deb95f --- /dev/null +++ b/scripts/fps_stress_resources.js @@ -0,0 +1,62 @@ +const stressResourceTimeoutMs = 180_000; +const stressResourceSettleMs = 5_000; + +const productLineFromUrl = value => new URL(value).searchParams.get('productLine'); + +const stressResourceCountFromProductLine = productLine => { + const match = productLine && productLine.match(/_stress_(\d+)$/); + return match ? Number(match[1]) : undefined; +}; + +const stressResourceCountFromUrl = value => + stressResourceCountFromProductLine(productLineFromUrl(value)); + +const isStressTryFarmbotUrl = value => + new URL(value).pathname.includes('/try_farmbot') + && Number.isFinite(stressResourceCountFromUrl(value)); + +const appUrlFor = value => new URL('/app/designer/plants', value).toString(); + +const waitForStressResources = async (page, stressResourceCount) => { + await page.waitForFunction(() => localStorage.getItem('session'), { + timeout: stressResourceTimeoutMs, + }); + await page.waitForFunction(async expected => { + const session = JSON.parse(localStorage.getItem('session')); + const headers = { Authorization: session.token.encoded }; + const json = path => fetch(path, { headers }).then(response => { + if (!response.ok) { throw new Error(`${response.status} ${path}`); } + return response.json(); + }); + const [points, images, sensorReadings] = await Promise.all([ + json('/api/points'), + json('/api/images'), + json('/api/sensor_readings'), + ]); + const plants = points.filter(point => point.pointer_type == 'Plant').length; + const weeds = points.filter(point => point.pointer_type == 'Weed').length; + const soilHeightPoints = points + .filter(point => point.name == 'Soil Height').length; + return plants >= expected + && weeds >= expected + && soilHeightPoints >= expected + && images.length >= expected + && sensorReadings.length >= expected; + }, stressResourceCount, { timeout: stressResourceTimeoutMs }); +}; + +const prepareStressResources = async (page, url) => { + if (!isStressTryFarmbotUrl(url)) { return; } + const stressResourceCount = stressResourceCountFromUrl(url); + console.log(`Waiting for ${stressResourceCount} stress resources...`); + await waitForStressResources(page, stressResourceCount); + console.log(`Stress resources found. Waiting ${stressResourceSettleMs}ms...`); + await page.waitForTimeout(stressResourceSettleMs); + const appUrl = appUrlFor(url); + console.log(`Stress resources loaded. Navigating to ${appUrl}`); + await page.goto(appUrl, { waitUntil: 'domcontentloaded' }); +}; + +module.exports = { + prepareStressResources, +}; diff --git a/scripts/metric_plot.js b/scripts/metric_plot.js new file mode 100644 index 0000000000..157b6d6952 --- /dev/null +++ b/scripts/metric_plot.js @@ -0,0 +1,402 @@ +const fs = require('fs'); +const path = require('path'); + +const escapeSvgText = value => String(value) + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"'); +const formatStat = value => value.toFixed(2); +const formatPoint = value => Number(value.toFixed(2)); +const seriesColors = [ + '#0969da', + '#1a7f37', + '#cf222e', + '#8250df', + '#bf8700', + '#0a7ea4', + '#d12470', + '#57606a', + '#953800', + '#116329', +]; +const normalizeMetricSamples = (samples, valueKey = 'value') => samples + .map((sample, index) => { + const value = typeof sample === 'object' ? sample[valueKey] : sample; + const x = typeof sample === 'object' + ? Number(sample.x ?? sample.elapsedSeconds ?? index) + : index; + return { + value: Number(value), + x, + index, + loading: typeof sample === 'object' ? sample.loading : undefined, + averaged: typeof sample === 'object' ? sample.averaged : undefined, + }; + }) + .filter(({ value, x }) => Number.isFinite(value) && Number.isFinite(x)); + +const normalizeSeries = (series, valueKey) => series + .map((item, index) => ({ + name: item.name || `Series ${index + 1}`, + color: item.color || seriesColors[index % seriesColors.length], + samples: normalizeMetricSamples(item.samples || [], valueKey), + })) + .filter(item => item.samples.length); + +function buildMetricPlotSvg(samples, options = {}) { + const inputSamples = samples || []; + const width = options.width || 640; + const height = options.height || 320; + const highlightIndex = options.highlightIndex; + const title = escapeSvgText(options.title || 'Metric samples'); + const xLabel = escapeSvgText(options.xLabel || 'Samples'); + const valueKey = options.valueKey || 'value'; + const series = normalizeSeries(options.series || [{ + name: options.seriesName || title, + samples: inputSamples, + }], valueKey); + const multiSeries = series.length > 1; + const margin = { + top: multiSeries ? 76 : 52, + right: 24, + bottom: multiSeries ? 52 : 44, + left: 54, + }; + const plotWidth = width - margin.left - margin.right; + const plotHeight = height - margin.top - margin.bottom; + const finite = series.flatMap(item => item.samples); + const values = finite.map(({ value }) => value); + const statSamples = options.statsAfterLoaded + ? finite.filter(({ loading }) => loading === false) + : finite; + const statValues = statSamples.map(({ value }) => value); + const xValues = finite + .map(({ x }) => x) + .filter(value => Number.isFinite(value)); + const minSample = statValues.length ? Math.min(...statValues) : 0; + const maxSample = statValues.length ? Math.max(...statValues) : 0; + const avgSample = statValues.length + ? statValues.reduce((total, value) => total + value, 0) / statValues.length + : 0; + const lastSample = statValues.length ? statValues[statValues.length - 1] : 0; + const yMinSample = values.length ? Math.min(...values) : 0; + const minValue = values.length ? yMinSample : 0; + const yMaxSample = values.length > 1 + ? [...values].sort((a, b) => b - a)[1] + : maxSample; + const maxValue = Math.max(1, yMaxSample); + const valueRange = maxValue - minValue || 1; + const minX = Math.min(0, xValues.length ? Math.min(...xValues) : 0); + const maxX = Math.max(1, xValues.length ? Math.max(...xValues) : inputSamples.length - 1); + const xRange = maxX - minX || 1; + const xFor = x => margin.left + ( + (x - minX) / xRange + ) * plotWidth; + const yFor = value => { + const y = margin.top + ((maxValue - value) / valueRange) * plotHeight; + return Math.max(margin.top, Math.min(height - margin.bottom, y)); + }; + const lines = series + .map(item => { + const points = item.samples + .map(({ value, x }) => + `${formatPoint(xFor(x))},${formatPoint(yFor(value))}`) + .join(' '); + return points + ? `<polyline fill="none" stroke="${item.color}" stroke-width="${multiSeries ? 2 : 3}" stroke-linecap="round" stroke-linejoin="round" points="${points}" />` + : ''; + }) + .join(''); + const circles = multiSeries + ? '' + : (series[0]?.samples || []) + .map(({ value, x, index }) => { + const highlighted = index === highlightIndex; + return [ + `<circle cx="${formatPoint(xFor(x))}" cy="${formatPoint(yFor(value))}"`, + ` r="${highlighted ? 5 : 3}" fill="${highlighted ? '#f97316' : series[0].color}" />`, + ].join(''); + }) + .join(''); + const gridValues = [maxValue, (maxValue + minValue) / 2, minValue]; + const grid = gridValues + .map(value => { + const y = formatPoint(yFor(value)); + return [ + `<line x1="${margin.left}" y1="${y}" x2="${width - margin.right}" y2="${y}" stroke="#d0d7de" />`, + `<text x="${margin.left - 10}" y="${formatPoint(y + 4)}" text-anchor="end" fill="#57606a" font-size="12">${formatStat(value)}</text>`, + ].join(''); + }) + .join(''); + const tickInterval = maxX <= 10 ? 1 : maxX <= 100 ? 10 : 100; + const xTicks = Array.from({ length: Math.floor(maxX / tickInterval) + 1 }, + (_value, index) => { + const tickValue = index * tickInterval; + const x = formatPoint(xFor(tickValue)); + return [ + `<line x1="${x}" y1="${height - margin.bottom}" x2="${x}" y2="${height - margin.bottom + 5}" stroke="#8c959f" />`, + `<text x="${x}" y="${height - 22}" text-anchor="middle" fill="#57606a" font-family="Arial, sans-serif" font-size="10">${tickValue}</text>`, + ].join(''); + }) + .join(''); + const firstLoaded = series[0]?.samples.find(({ loading }) => loading === false); + const loadedMarker = firstLoaded + ? [ + `<line x1="${formatPoint(xFor(firstLoaded.x))}" y1="${margin.top}" x2="${formatPoint(xFor(firstLoaded.x))}" y2="${height - margin.bottom}" stroke="#f97316" stroke-width="2" stroke-dasharray="5 4" />`, + `<text x="${formatPoint(xFor(firstLoaded.x) + 6)}" y="${margin.top + 16}" fill="#c2410c" font-family="Arial, sans-serif" font-size="12" font-weight="700">Loaded</text>`, + ].join('') + : ''; + const averageValue = Number(options.averageValue); + const averageLine = Number.isFinite(averageValue) + ? [ + `<line x1="${formatPoint(firstLoaded ? xFor(firstLoaded.x) : margin.left)}" y1="${formatPoint(yFor(averageValue))}" x2="${width - margin.right}" y2="${formatPoint(yFor(averageValue))}" stroke="#1a7f37" stroke-width="2" stroke-dasharray="4 4" />`, + `<text x="${width - margin.right - 6}" y="${formatPoint(yFor(averageValue) - 6)}" text-anchor="end" fill="#1a7f37" font-family="Arial, sans-serif" font-size="12" font-weight="700">avg ${formatStat(averageValue)}</text>`, + ].join('') + : ''; + const displayedAverage = Number.isFinite(averageValue) + ? averageValue + : avgSample; + const stats = options.hideStats + ? '' + : escapeSvgText(statValues.length + ? `min ${formatStat(minSample)} avg ${formatStat(displayedAverage)} max ${formatStat(maxSample)} last ${formatStat(lastSample)}` + : 'No valid samples'); + const legend = multiSeries + ? series + .map((item, index) => { + const x = margin.left + (index % 5) * 112; + const y = 48 + Math.floor(index / 5) * 16; + return [ + `<line x1="${x}" y1="${y - 4}" x2="${x + 16}" y2="${y - 4}" stroke="${item.color}" stroke-width="3" />`, + `<text x="${x + 21}" y="${y}" fill="#57606a" font-family="Arial, sans-serif" font-size="10">${escapeSvgText(item.name)}</text>`, + ].join(''); + }) + .join('') + : ''; + + return `<!doctype html> +<html> +<head> + <meta charset="utf-8"> + <style>body { margin: 0; background: #ffffff; }</style> +</head> +<body> + <svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"> + <rect width="${width}" height="${height}" fill="#ffffff" /> + <text x="${margin.left}" y="28" fill="#24292f" font-family="Arial, sans-serif" font-size="20" font-weight="700">${title}</text> + <text x="${width - margin.right}" y="28" text-anchor="end" fill="#57606a" font-family="Arial, sans-serif" font-size="12">${stats}</text> + ${legend} + ${grid} + <line x1="${margin.left}" y1="${margin.top}" x2="${margin.left}" y2="${height - margin.bottom}" stroke="#8c959f" /> + <line x1="${margin.left}" y1="${height - margin.bottom}" x2="${width - margin.right}" y2="${height - margin.bottom}" stroke="#8c959f" /> + ${xTicks} + ${loadedMarker} + ${averageLine} + ${lines} + ${circles} + <text x="${(margin.left + width - margin.right) / 2}" y="${height - 8}" text-anchor="middle" fill="#57606a" font-family="Arial, sans-serif" font-size="12">${xLabel}</text> + </svg> +</body> +</html>`; +} + +async function saveMetricPlot(browser, samples, destination, options = {}) { + fs.mkdirSync(path.dirname(destination), { recursive: true }); + const plotPage = await browser.newPage({ viewport: { width: 640, height: 320 } }); + try { + await plotPage.setContent(buildMetricPlotSvg(samples, options), { waitUntil: 'load' }); + await plotPage.locator('svg').screenshot({ + path: destination, + timeout: 60_000, + }); + } finally { + await plotPage.close(); + } +} + +const parseCsvLine = line => { + const fields = []; + let field = ''; + let quoted = false; + for (let i = 0; i < line.length; i++) { + const char = line[i]; + if (char === '"' && quoted && line[i + 1] === '"') { + field += '"'; + i++; + } else if (char === '"') { + quoted = !quoted; + } else if (char === ',' && !quoted) { + fields.push(field.trim()); + field = ''; + } else { + field += char; + } + } + fields.push(field.trim()); + return fields; +}; + +const parseCsv = content => { + const lines = content.split(/\r?\n/).filter(line => line.trim()); + const headers = lines.length ? parseCsvLine(lines[0]) : []; + const rows = lines.slice(1).map(line => { + const fields = parseCsvLine(line); + return Object.fromEntries(headers.map((header, index) => [header, fields[index]])); + }); + return { headers, rows }; +}; + +const title = (basename, prefix) => { + const name = basename.replace(/\.csv$/, '').replace(/_/g, ' '); + const suffix = name.replace(prefix.toLowerCase(), '').trim(); + return suffix ? `${prefix}: ${suffix}` : prefix; +} + +const inferCsvPlot = ({ headers, rows }, filename = '') => { + const basename = path.basename(filename); + const isSceneMetricsCsv = /^scene_metrics(?:_[^/]+)?\.csv$/.test(basename); + const firstHeader = headers[0]; + const xHeader = headers.find(header => + ['elapsed seconds', 'elapsedSeconds'].includes(header)); + const sceneMetricExcludedHeaders = ['epoch', 'Points', 'Lines']; + const sceneMetricValueFor = (row, header) => { + const value = Number(row[header]); + if (header === 'FPS') { return value ? 1000 / value : NaN; } + return header === 'Triangles' ? value / 1000 : value; + }; + const valueFor = (row, header) => Number(row[header]); + const sceneMetricLabelFor = header => { + if (header === 'FPS') { return 'Frame time (ms)'; } + return header === 'Triangles' ? 'Triangles (k)' : header; + }; + const sampleFor = (row, index, valueHeader) => ({ + x: xHeader ? row[xHeader] : index, + value: valueFor(row, valueHeader), + loading: row.loading === undefined ? undefined : row.loading === 'true', + averaged: row.averaged === undefined ? undefined : row.averaged === 'true', + }); + const plottableHeaders = headers + .filter(header => !sceneMetricExcludedHeaders.includes(header)) + .filter(header => rows.some(row => Number.isFinite(sceneMetricValueFor(row, header)))); + + if (headers.includes('percent')) { + return { + title: 'Frontend coverage', + xLabel: 'Runs', + samples: rows.map((row, index) => sampleFor(row, index, 'percent')), + }; + } + if (headers.includes('FPS')) { + if (isSceneMetricsCsv) { + return { + title: title(basename, 'Scene metrics'), + xLabel: 'Runs', + hideStats: true, + series: plottableHeaders.map(header => ({ + name: sceneMetricLabelFor(header), + samples: rows.map((row, index) => ({ + x: index, + value: sceneMetricValueFor(row, header), + })), + })), + }; + } + return { + title: title(basename, 'FPS samples'), + xLabel: 'Runs', + samples: rows.map((row, index) => sampleFor(row, index, 'FPS')), + }; + } + if (headers.includes('fps')) { + const averagedValues = rows + .filter(row => row.averaged === 'true') + .map(row => Number(row.fps)) + .filter(Number.isFinite); + const averageValue = averagedValues.length + ? averagedValues.reduce((total, value) => total + value, 0) + / averagedValues.length + : undefined; + const highlightIndex = rows.findIndex(row => row.chosen === 'true'); + return { + title: title(basename, 'FPS samples'), + xLabel: xHeader ? 'Seconds' : 'Samples', + samples: rows.map((row, index) => sampleFor(row, index, 'fps')), + statsAfterLoaded: true, + ...(Number.isFinite(averageValue) ? { averageValue } : {}), + ...(highlightIndex >= 0 ? { highlightIndex } : {}), + }; + } + throw new Error(`No plottable metric found in ${basename || firstHeader || 'CSV'}`); +}; + +const buildCsvPlotSvg = (content, options = {}) => { + const inferred = inferCsvPlot(parseCsv(content), options.filename); + return buildMetricPlotSvg(inferred.samples, { ...inferred, ...options }); +}; + +async function saveCsvPlot(browser, csvPath, destination, options = {}) { + const content = fs.readFileSync(csvPath, 'utf8'); + const inferred = inferCsvPlot(parseCsv(content), csvPath); + return saveMetricPlot(browser, inferred.samples, destination, { + ...inferred, + ...options, + }); +} + +function printUsage() { + console.log([ + 'Usage: bun scripts/metric_plot.js <csv_path> [plot_path]', + '', + 'Supported CSV inputs:', + ' fps_samples.csv Plots the fps column against elapsed seconds.', + ' fe_coverage.csv Plots the percent column.', + ' scene_metrics.csv Plots numeric columns.', + '', + 'Arguments:', + ' csv_path CSV file to plot.', + ' plot_path Optional PNG output path. Default: /tmp/<csv basename>.png', + ].join('\n')); +} + +async function main() { + const csvPath = process.argv[2]; + if (!csvPath || csvPath === '-h' || csvPath === '--help') { + printUsage(); + process.exitCode = csvPath ? 0 : 1; + return; + } + + const destination = process.argv[3] + || path.join('/tmp', `${path.basename(csvPath, '.csv')}.png`); + const { chromium } = require('playwright'); + const browser = await chromium.launch({ + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + ], + }); + try { + await saveCsvPlot(browser, csvPath, destination); + console.log(`CSV_PLOT=${destination}`); + } finally { + await browser.close(); + } +} + +module.exports = { + buildCsvPlotSvg, + buildMetricPlotSvg, + escapeSvgText, + inferCsvPlot, + parseCsv, + saveCsvPlot, + saveMetricPlot, +}; + +if (require.main === module) { + main().catch((err) => { + console.error('Failed to plot CSV:', err.message || err); + process.exitCode = 1; + }); +} diff --git a/scripts/perf/stress_1000_3d.js b/scripts/perf/stress_1000_3d.js new file mode 100644 index 0000000000..f422d26cd9 --- /dev/null +++ b/scripts/perf/stress_1000_3d.js @@ -0,0 +1,711 @@ +const { chromium } = require("playwright"); +const crypto = require("crypto"); +const fs = require("fs"); +const path = require("path"); + +const DEFAULT_URL = "http://localhost:3000"; +const PRODUCT_LINE = "genesis_xl_1.8_stress_1000"; +const DEMO_USER = "farmbot_demo"; +const TIMEOUT = 180_000; +const DEFAULT_VIEWPORT = { width: 3840, height: 2160 }; +const DEFAULT_SAMPLE_MS = 12_000; + +const parseArgs = () => { + const [command = "run", ...rest] = process.argv.slice(2); + const args = { command }; + for (let i = 0; i < rest.length; i += 2) { + args[rest[i].replace(/^--/, "")] = rest[i + 1]; + } + return args; +}; + +const median = values => { + const sorted = values.filter(Number.isFinite).sort((a, b) => a - b); + if (sorted.length == 0) { return undefined; } + return sorted[Math.floor(sorted.length / 2)]; +}; + +const percentile = (values, p) => { + const sorted = values.filter(Number.isFinite).sort((a, b) => a - b); + if (sorted.length == 0) { return undefined; } + return sorted[Math.ceil((p / 100) * sorted.length) - 1]; +}; + +const summary = runs => { + const metric = key => median(runs.map(run => run[key])); + return { + pageReadyMs: metric("pageReadyMs"), + coreReadyMs: metric("coreReadyMs"), + fullReadyMs: metric("fullReadyMs"), + fpsMedian: metric("fpsMedian"), + frameP95Ms: metric("frameP95Ms"), + navPlantMs: metric("navPlantMs"), + navPointMs: metric("navPointMs"), + navWeedMs: metric("navWeedMs"), + togglePlantsMs: metric("togglePlantsMs"), + togglePointsMs: metric("togglePointsMs"), + toggleWeedsMs: metric("toggleWeedsMs"), + toggleSpreadMs: metric("toggleSpreadMs"), + toggleFarmbotMs: metric("toggleFarmbotMs"), + jsEncodedBytes: metric("jsEncodedBytes"), + jsTransferBytes: metric("jsTransferBytes"), + jsResourceCount: metric("jsResourceCount"), + modelEncodedBytes: metric("modelEncodedBytes"), + modelTransferBytes: metric("modelTransferBytes"), + modelResourceCount: metric("modelResourceCount"), + threeDGardenMapRenders: metric("threeDGardenMapRenders"), + gardenModelRenders: metric("gardenModelRenders"), + threeDGardenRenders: metric("threeDGardenRenders"), + plantInventoryItemRenders: metric("plantInventoryItemRenders"), + drawCalls: metric("drawCalls"), + triangles: metric("triangles"), + webglGeometries: metric("webglGeometries"), + webglTextures: metric("webglTextures"), + sceneObjects: metric("sceneObjects"), + sceneMeshes: metric("sceneMeshes"), + sceneInstancedMeshes: metric("sceneInstancedMeshes"), + usedJSHeapSize: metric("usedJSHeapSize"), + getZBatchMs: metric("getZBatchMs"), + getZCalls: metric("getZCalls"), + getZIndexMs: metric("getZIndexMs"), + getZP95Ms: metric("getZP95Ms"), + soilPointFilterMs: metric("soilPointFilterMs"), + soilSurfaceMs: metric("soilSurfaceMs"), + soilStorageMs: metric("soilStorageMs"), + soilStorageCalls: metric("soilStorageCalls"), + imageTextureSetupMs: metric("imageTextureSetupMs"), + imageWrapperSetupMs: metric("imageWrapperSetupMs"), + soilTextureRenders: metric("soilTextureRenders"), + spreadFrameUpdateMs: metric("spreadFrameUpdateMs"), + moistureSurfaceMs: metric("moistureSurfaceMs"), + moistureInstanceNodesMs: metric("moistureInstanceNodesMs"), + }; +}; + +const firstMark = (marks, ...names) => { + for (const name of names) { + const value = marks[name]?.[0]; + if (Number.isFinite(value)) { return value; } + } +}; + +const maxMark = (marks, names) => { + const values = names + .map(name => marks[name]?.[0]) + .filter(Number.isFinite); + return values.length > 0 ? Math.max(...values) : undefined; +}; + +const nextPaint = page => + page.evaluate(() => new Promise(resolve => + requestAnimationFrame(() => requestAnimationFrame(resolve)))); + +const resourceSummary = async page => page.evaluate(() => { + const jsResources = performance.getEntriesByType("resource") + .filter(entry => entry.name.match(/\.js(\?|$)/)); + const modelResources = performance.getEntriesByType("resource") + .filter(entry => entry.name.match(/\.(glb|gltf)(\?|$)/)); + const sum = key => jsResources + .reduce((total, entry) => total + (entry[key] || 0), 0); + const modelSum = key => modelResources + .reduce((total, entry) => total + (entry[key] || 0), 0); + const largestModels = modelResources + .map(entry => ({ + name: entry.name.split("/").pop(), + transferSize: entry.transferSize || 0, + encodedBodySize: entry.encodedBodySize || 0, + decodedBodySize: entry.decodedBodySize || 0, + duration: entry.duration || 0, + })) + .sort((a, b) => b.encodedBodySize - a.encodedBodySize) + .slice(0, 10); + const largestJs = jsResources + .map(entry => ({ + name: entry.name.split("/").pop(), + transferSize: entry.transferSize || 0, + encodedBodySize: entry.encodedBodySize || 0, + decodedBodySize: entry.decodedBodySize || 0, + duration: entry.duration || 0, + })) + .sort((a, b) => b.encodedBodySize - a.encodedBodySize) + .slice(0, 10); + return { + jsResourceCount: jsResources.length, + jsTransferBytes: sum("transferSize"), + jsEncodedBytes: sum("encodedBodySize"), + jsDecodedBytes: sum("decodedBodySize"), + largestJs, + modelResourceCount: modelResources.length, + modelTransferBytes: modelSum("transferSize"), + modelEncodedBytes: modelSum("encodedBodySize"), + modelDecodedBytes: modelSum("decodedBodySize"), + largestModels, + }; +}); + +const runtimeSummary = async page => page.evaluate(() => { + const parseLegacySceneMetrics = () => { + const values = (window.__scene_metrics || "") + .split(",") + .map(value => Number(value.trim())); + return { + calls: values[2], + triangles: values[3], + geometries: values[6], + textures: values[7], + total: values[8], + meshes: values[9], + instancedMeshes: values[10], + }; + }; + const legacy = parseLegacySceneMetrics(); + const render = window.__threeDRenderMetrics || {}; + const scene = window.__collectThreeDSceneMetrics?.() || {}; + const memory = performance.memory || {}; + return { + drawCalls: render.calls ?? legacy.calls, + triangles: render.triangles ?? legacy.triangles, + webglGeometries: render.geometries ?? legacy.geometries, + webglTextures: render.textures ?? legacy.textures, + sceneObjects: scene.total ?? legacy.total, + sceneMeshes: scene.meshes ?? legacy.meshes, + sceneInstancedMeshes: scene.instancedMeshes ?? legacy.instancedMeshes, + usedJSHeapSize: memory.usedJSHeapSize, + totalJSHeapSize: memory.totalJSHeapSize, + jsHeapSizeLimit: memory.jsHeapSizeLimit, + }; +}); + +const createDemoSession = async (browser, baseUrl) => { + const secret = crypto.randomUUID().replaceAll("-", ""); + const page = await browser.newPage(); + await page.goto(`${baseUrl}/demo`, { waitUntil: "domcontentloaded" }); + await page.addScriptTag({ path: require.resolve("mqtt/dist/mqtt.min.js") }); + const session = await page.evaluate(async ({ demoUser, line, value }) => { + const configResponse = await fetch("/api/global_config"); + const config = await configResponse.json(); + const topic = `demos/${value}`; + const client = window.mqtt.connect(config.MQTT_WS, { + username: demoUser, + password: "required, but not used.", + }); + const tokenPromise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + client.end(true); + reject(new Error("Timed out waiting for demo token over MQTT.")); + }, 180_000); + client.on("connect", () => { + client.subscribe(topic, error => error && reject(error)); + }); + client.on("message", (_topic, buffer) => { + clearTimeout(timeout); + client.end(true); + resolve(buffer.toString()); + }); + client.on("error", reject); + }); + const response = await fetch("/api/demo_account", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ secret: value, product_line: line }), + }); + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}`); + } + return tokenPromise; + }, { + demoUser: DEMO_USER, + line: PRODUCT_LINE, + value: secret, + }); + await page.close(); + return session; +}; + +const authHeader = session => JSON.parse(session).token.encoded; + +const apiJson = async (baseUrl, session, endpoint, options = {}) => { + const response = await fetch(`${baseUrl}${endpoint}`, { + ...options, + headers: { + Authorization: authHeader(session), + "Content-Type": "application/json", + ...(options.headers || {}), + }, + }); + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}: ${endpoint}`); + } + return response.json(); +}; + +const setFarmwareEnv = async (baseUrl, session, key, value) => { + const envs = await apiJson(baseUrl, session, "/api/farmware_envs"); + const existing = envs.find(env => env.key == key); + if (existing) { + await apiJson(baseUrl, session, `/api/farmware_envs/${existing.id}`, { + method: "PUT", + body: JSON.stringify({ value }), + }); + } else { + await apiJson(baseUrl, session, "/api/farmware_envs", { + method: "POST", + body: JSON.stringify({ key, value }), + }); + } +}; + +const waitFor3D = async page => { + await page.waitForFunction(() => { + const canvas = document.querySelector(".garden-bed-3d-model canvas"); + return Boolean(canvas && typeof canvas.dataset.engine == "string"); + }, { timeout: TIMEOUT }); + await page.waitForFunction(() => typeof window.__fps == "number", { + timeout: TIMEOUT, + }); +}; + +const waitForGardenRender = async (page, beforeRenderCount) => { + await page.waitForFunction(before => { + const count = window.__fbPerf?.counts?.["render.GardenModel"] || 0; + return count > before; + }, beforeRenderCount, { timeout: 10_000 }).catch(() => undefined); + await nextPaint(page); +}; + +const openSoilHeightSection = async page => { + const section = page.locator(".points-section-header") + .filter({ hasText: "Soil Height" }) + .first(); + if (await section.count() == 0) { return; } + await section.click(); + await page.locator(".point-search-item").first() + .waitFor({ timeout: 10_000 }).catch(() => undefined); +}; + +const clickAndMeasure = async ( + page, + route, + itemSelector, + panelSelector, + prepare, +) => { + await page.goto(route, { waitUntil: "domcontentloaded" }); + await waitFor3D(page); + const item = page.locator(itemSelector).first(); + let count = await item.count(); + if (count == 0 && prepare) { + await prepare(page); + count = await item.count(); + } + if (count == 0) { return undefined; } + if (!await item.isVisible()) { return undefined; } + const startedAt = await page.evaluate(() => performance.now()); + await item.click(); + await page.waitForSelector(panelSelector, { timeout: TIMEOUT }); + return page.evaluate(start => performance.now() - start, startedAt); +}; + +const measureLayerToggle = async (page, labelText) => { + const toggle = page.locator("fieldset") + .filter({ hasText: labelText }) + .locator(".fb-layer-toggle") + .first(); + const count = await toggle.count(); + if (count == 0) { return undefined; } + if (!await toggle.isVisible()) { return undefined; } + const beforeRenderCount = await page.evaluate(() => + window.__fbPerf?.counts?.["render.GardenModel"] || 0); + const startedAt = await page.evaluate(() => performance.now()); + await toggle.click(); + await waitForGardenRender(page, beforeRenderCount); + const elapsed = await page.evaluate(start => performance.now() - start, + startedAt); + const beforeRestoreRenderCount = await page.evaluate(() => + window.__fbPerf?.counts?.["render.GardenModel"] || 0); + await toggle.click(); + await waitForGardenRender(page, beforeRestoreRenderCount); + return elapsed; +}; + +const ensureLayerVisible = async (page, labelText) => { + const toggle = page.locator("fieldset") + .filter({ hasText: labelText }) + .locator(".fb-layer-toggle") + .first(); + const count = await toggle.count(); + if (count == 0) { return; } + if (!await toggle.isVisible()) { return; } + const className = await toggle.getAttribute("class"); + if (className?.includes("green")) { return; } + const beforeRenderCount = await page.evaluate(() => + window.__fbPerf?.counts?.["render.GardenModel"] || 0); + await toggle.click(); + await waitForGardenRender(page, beforeRenderCount); +}; + +const collectRun = async (browser, baseUrl, session, runIndex, options) => { + const context = await browser.newContext({ + viewport: options.viewport, + }); + await context.addInitScript(value => { + window.localStorage.setItem("session", value.session); + window.localStorage.setItem("FB_PERF_BENCHMARK", "true"); + window.localStorage.setItem("FPS_LOGS", "false"); + }, { session }); + const page = await context.newPage(); + page.on("pageerror", error => console.error("pageerror", error)); + page.on("console", message => { + if (message.type() == "error") { + console.error("console", message.text()); + } + }); + page.setDefaultTimeout(TIMEOUT); + const appUrl = `${baseUrl}/app/designer/plants?fb_perf=1`; + await page.goto(appUrl, { waitUntil: "domcontentloaded" }); + await waitFor3D(page); + if (options.moistureMap) { + await ensureLayerVisible(page, "Moisture"); + } + await nextPaint(page); + const resources = await resourceSummary(page); + await page.waitForTimeout(options.sampleMs); + const runtime = await runtimeSummary(page); + const perf = await page.evaluate(() => window.__fbPerf); + const marks = perf?.marks || {}; + const samples = perf?.samples || {}; + const counts = perf?.counts || {}; + const fpsSamples = samples.fps || []; + const frameSamples = samples.frame_ms || []; + const getZSamples = samples.getZMs || []; + const getZIndexSamples = samples.getZIndexMs || []; + const soilPointFilterSamples = samples.soilPointFilterMs || []; + const soilSurfaceSamples = samples.soilSurfaceMs || []; + const soilStorageSamples = samples.soilStorageMs || []; + const imageTextureSetupSamples = samples.imageTextureSetupMs || []; + const imageWrapperSetupSamples = samples.imageWrapperSetupMs || []; + const spreadFrameUpdateSamples = samples.spreadFrameUpdateMs || []; + const moistureSurfaceSamples = samples.moistureSurfaceMs || []; + const moistureInstanceNodeSamples = samples.moistureInstanceNodesMs || []; + const togglePlantsMs = await measureLayerToggle(page, "Plants"); + const togglePointsMs = await measureLayerToggle(page, "Points"); + const toggleWeedsMs = await measureLayerToggle(page, "Weeds"); + const toggleSpreadMs = await measureLayerToggle(page, "Spread"); + const toggleFarmbotMs = await measureLayerToggle(page, "FarmBot"); + const navPlantMs = await clickAndMeasure( + page, + `${baseUrl}/app/designer/plants?fb_perf=1`, + ".plant-search-item", + ".plant-info-panel", + ); + const navPointMs = await clickAndMeasure( + page, + `${baseUrl}/app/designer/points?fb_perf=1`, + ".point-search-item", + ".point-info-panel", + openSoilHeightSection, + ); + const navWeedMs = await clickAndMeasure( + page, + `${baseUrl}/app/designer/weeds?fb_perf=1`, + ".weed-search-item", + ".weed-info-panel", + ); + await context.close(); + return { + runIndex, + pageReadyMs: firstMark( + marks, + "three_d_map_mounted", + "three_d_garden_mounted", + ), + coreReadyMs: firstMark( + marks, + "three_d_core_ready", + "garden_model_rendered", + ), + fullReadyMs: maxMark(marks, [ + "three_d_bot_ready", + "three_d_bed_ready", + "three_d_grid_ready", + "three_d_core_ready", + "three_d_decorations_ready", + "three_d_details_ready", + "three_d_visualizations_ready", + "three_d_camera_ui_ready", + "three_d_debug_ready", + "three_d_ground_ready", + "three_d_moisture_debug_ready", + "three_d_points_ready", + "three_d_weeds_ready", + ]) || marks.garden_model_mounted?.[0], + fpsMedian: median(fpsSamples), + frameP95Ms: percentile(frameSamples, 95), + getZBatchMs: getZSamples.reduce((total, value) => total + value, 0), + getZCalls: getZSamples.length, + getZIndexMs: getZIndexSamples + .reduce((total, value) => total + value, 0), + getZP95Ms: percentile(getZSamples, 95), + soilPointFilterMs: soilPointFilterSamples + .reduce((total, value) => total + value, 0), + soilSurfaceMs: soilSurfaceSamples + .reduce((total, value) => total + value, 0), + soilStorageMs: soilStorageSamples + .reduce((total, value) => total + value, 0), + soilStorageCalls: soilStorageSamples.length, + imageTextureSetupMs: imageTextureSetupSamples + .reduce((total, value) => total + value, 0), + imageWrapperSetupMs: imageWrapperSetupSamples + .reduce((total, value) => total + value, 0), + soilTextureRenders: counts.soilTextureRenders || 0, + spreadFrameUpdateMs: spreadFrameUpdateSamples + .reduce((total, value) => total + value, 0), + moistureSurfaceMs: moistureSurfaceSamples + .reduce((total, value) => total + value, 0), + moistureInstanceNodesMs: moistureInstanceNodeSamples + .reduce((total, value) => total + value, 0), + navPlantMs, + navPointMs, + navWeedMs, + togglePlantsMs, + togglePointsMs, + toggleWeedsMs, + toggleSpreadMs, + toggleFarmbotMs, + ...resources, + ...runtime, + threeDGardenMapRenders: counts["render.ThreeDGardenMap"], + gardenModelRenders: counts["render.GardenModel"], + threeDGardenRenders: counts["render.ThreeDGarden"], + plantInventoryItemRenders: counts["render.PlantInventoryItem"], + }; +}; + +const runBenchmark = async args => { + const baseUrl = args["base-url"] || DEFAULT_URL; + const runs = Number(args.runs || 5); + const warmups = Number(args.warmups || 1); + const out = args.out || "tmp/perf/stress_1000_3d.json"; + const lowDetail = ["1", "true"].includes(args["low-detail"]); + const viewport = { + width: Number(args.width || DEFAULT_VIEWPORT.width), + height: Number(args.height || DEFAULT_VIEWPORT.height), + }; + const sampleMs = Number(args["sample-ms"] || DEFAULT_SAMPLE_MS); + const browser = await chromium.launch({ + headless: true, + args: [ + "--no-sandbox", + "--disable-setuid-sandbox", + "--disable-dev-shm-usage", + "--disable-frame-rate-limit", + "--disable-gpu-vsync", + "--enable-gpu", + ], + }); + try { + const session = await createDemoSession(browser, baseUrl); + if (lowDetail) { + await setFarmwareEnv(baseUrl, session, "3D_lowDetail", "1"); + } + if (["1", "true"].includes(args["moisture-map"])) { + await apiJson(baseUrl, session, "/api/web_app_config/", { + method: "PUT", + body: JSON.stringify({ + show_sensor_readings: true, + show_moisture_interpolation_map: true, + }), + }); + } + const measuredRuns = []; + for (let i = 0; i < warmups + runs; i++) { + const run = await collectRun(browser, baseUrl, session, i, { + viewport, + sampleMs, + moistureMap: ["1", "true"].includes(args["moisture-map"]), + }); + console.log(`${i < warmups ? "warmup" : "run"} ${i + 1}`, run); + if (i >= warmups) { measuredRuns.push(run); } + } + const result = { + productLine: PRODUCT_LINE, + createdAt: new Date().toISOString(), + viewport, + sampleMs, + runs: measuredRuns, + summary: summary(measuredRuns), + }; + fs.mkdirSync(path.dirname(out), { recursive: true }); + fs.writeFileSync(out, `${JSON.stringify(result, undefined, 2)}\n`); + console.log(`Wrote ${out}`); + console.log(result.summary); + } finally { + await browser.close(); + } +}; + +const getSession = async (browser, baseUrl, sessionFile) => { + if (sessionFile && fs.existsSync(sessionFile)) { + return fs.readFileSync(sessionFile, "utf8"); + } + const session = await createDemoSession(browser, baseUrl); + if (sessionFile) { + fs.mkdirSync(path.dirname(sessionFile), { recursive: true }); + fs.writeFileSync(sessionFile, session); + } + return session; +}; + +const screenshot = async args => { + const baseUrl = args["base-url"] || DEFAULT_URL; + const out = args.out || "tmp/perf/three_d_garden.png"; + const viewport = { + width: Number(args.width || DEFAULT_VIEWPORT.width), + height: Number(args.height || DEFAULT_VIEWPORT.height), + }; + const browser = await chromium.launch({ + headless: true, + args: [ + "--no-sandbox", + "--disable-setuid-sandbox", + "--disable-dev-shm-usage", + "--enable-gpu", + ], + }); + try { + const session = await getSession(browser, baseUrl, args["session-file"]); + const context = await browser.newContext({ viewport }); + await context.addInitScript(value => { + window.localStorage.setItem("session", value.session); + window.localStorage.setItem("FB_PERF_BENCHMARK", "true"); + window.localStorage.setItem("FPS_LOGS", "false"); + }, { session }); + const page = await context.newPage(); + page.setDefaultTimeout(TIMEOUT); + await page.goto(`${baseUrl}/app/designer/plants?fb_perf=1`, { + waitUntil: "domcontentloaded", + }); + await waitFor3D(page); + await page.waitForTimeout(Number(args["settle-ms"] || 3_000)); + await nextPaint(page); + const canvas = page.locator(".garden-bed-3d-model canvas").first(); + fs.mkdirSync(path.dirname(out), { recursive: true }); + await canvas.screenshot({ path: out }); + await context.close(); + console.log(`Wrote ${out}`); + } finally { + await browser.close(); + } +}; + +const imageDiff = async args => { + const before = fs.readFileSync(args.before, "base64"); + const after = fs.readFileSync(args.after, "base64"); + const threshold = Number(args.threshold || 3); + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage(); + const result = await page.evaluate(async ({ before, after, threshold }) => { + const load = data => new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = reject; + image.src = `data:image/png;base64,${data}`; + }); + const [beforeImage, afterImage] = + await Promise.all([load(before), load(after)]); + const width = beforeImage.width; + const height = beforeImage.height; + if (width != afterImage.width || height != afterImage.height) { + throw new Error("Image dimensions differ."); + } + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext("2d"); + context.drawImage(beforeImage, 0, 0); + const beforePixels = + context.getImageData(0, 0, width, height).data; + context.clearRect(0, 0, width, height); + context.drawImage(afterImage, 0, 0); + const afterPixels = + context.getImageData(0, 0, width, height).data; + let diffPixels = 0; + let maxDelta = 0; + let totalDelta = 0; + for (let i = 0; i < beforePixels.length; i += 4) { + const delta = Math.max( + Math.abs(beforePixels[i] - afterPixels[i]), + Math.abs(beforePixels[i + 1] - afterPixels[i + 1]), + Math.abs(beforePixels[i + 2] - afterPixels[i + 2]), + Math.abs(beforePixels[i + 3] - afterPixels[i + 3]), + ); + maxDelta = Math.max(maxDelta, delta); + totalDelta += delta; + if (delta > threshold) { diffPixels++; } + } + const pixels = width * height; + return { + width, + height, + pixels, + diffPixels, + diffRatio: diffPixels / pixels, + maxDelta, + avgDelta: totalDelta / pixels, + }; + }, { before, after, threshold }); + console.log(result); + if (result.diffPixels > 0) { process.exitCode = 1; } + } finally { + await browser.close(); + } +}; + +const readJson = file => JSON.parse(fs.readFileSync(file, "utf8")); + +const compare = args => { + const before = readJson(args.before); + const after = readJson(args.after); + const metric = args.metric; + const direction = args.direction || ( + ["fpsMedian"].includes(metric) ? "up" : "down"); + const threshold = Number(args.threshold || 10); + const beforeValue = before.summary[metric]; + const afterValue = after.summary[metric]; + if (!Number.isFinite(beforeValue) || !Number.isFinite(afterValue)) { + throw new Error(`Metric ${metric} is missing from benchmark results.`); + } + const improvement = direction == "up" + ? 100 * (afterValue - beforeValue) / beforeValue + : 100 * (beforeValue - afterValue) / beforeValue; + console.log({ + metric, + direction, + before: beforeValue, + after: afterValue, + improvement: `${improvement.toFixed(2)}%`, + threshold: `${threshold}%`, + }); + if (improvement < threshold) { + process.exitCode = 1; + } +}; + +const main = async () => { + const args = parseArgs(); + if (args.command == "compare") { + compare(args); + } else if (args.command == "screenshot") { + await screenshot(args); + } else if (args.command == "image-diff") { + await imageDiff(args); + } else { + await runBenchmark(args); + } +}; + +main().catch(error => { + console.error(error); + process.exitCode = 1; +}); diff --git a/spec/controllers/api/ai/ai_controller_spec.rb b/spec/controllers/api/ai/ai_controller_spec.rb index 3eaebaec00..8b0e81be3d 100644 --- a/spec/controllers/api/ai/ai_controller_spec.rb +++ b/spec/controllers/api/ai/ai_controller_spec.rb @@ -121,6 +121,14 @@ def chunk(content, done=nil) expect(response.body).to eq("red") end + it "handles docs without front matter" do + allow(controller).to receive(:get_page_data).and_return("content") + + result = controller.send(:process_dev_docs_page_data, "url") + + expect(result).to eq("") + end + it "throttles requests" do sign_in user payload = { diff --git a/spec/mutations/devices/create_seed_data_spec.rb b/spec/mutations/devices/create_seed_data_spec.rb index 53a2e04620..f2a18371e4 100644 --- a/spec/mutations/devices/create_seed_data_spec.rb +++ b/spec/mutations/devices/create_seed_data_spec.rb @@ -1,6 +1,24 @@ require "spec_helper" describe Devices::CreateSeedData do + it "accepts stress demo product lines" do + expect(described_class::PRODUCT_LINES.fetch("genesis_xl_1.8_stress_250")) + .to eq(Devices::Seeders::GenesisXlOneEight) + expect(described_class::PRODUCT_LINES.fetch("genesis_xl_1.8_stress_1000")) + .to eq(Devices::Seeders::GenesisXlOneEight) + end + + it "rejects stress product lines outside of demo accounts" do + result = described_class.run( + device: FactoryBot.create(:device), + product_line: "genesis_xl_1.8_stress_250", + ) + + expect(result.success?).to be(false) + expect(result.errors.message_list) + .to include(described_class::STRESS_DEMO_ONLY) + end + it "passes `none`" do device = FactoryBot.create(:device) previous_peripherals_count = device.peripherals.count diff --git a/spec/mutations/devices/seeders/demo_account_seeder_spec.rb b/spec/mutations/devices/seeders/demo_account_seeder_spec.rb new file mode 100644 index 0000000000..f558836aea --- /dev/null +++ b/spec/mutations/devices/seeders/demo_account_seeder_spec.rb @@ -0,0 +1,19 @@ +require "spec_helper" + +describe Devices::Seeders::DemoAccountSeeder do + let(:device) { FactoryBot.create(:device) } + + it "uses stress data for stress demo product lines" do + stress_data = instance_double(Devices::Seeders::StressData) + + expect(Devices::Seeders::StressData) + .to receive(:new) + .with(device, 250) + .and_return(stress_data) + expect(stress_data).to receive(:seed!) + + described_class + .new(device) + .after_product_line_seeder("genesis_xl_1.8_stress_250") + end +end diff --git a/spec/mutations/devices/seeders/stress_data_spec.rb b/spec/mutations/devices/seeders/stress_data_spec.rb new file mode 100644 index 0000000000..3ea157970a --- /dev/null +++ b/spec/mutations/devices/seeders/stress_data_spec.rb @@ -0,0 +1,33 @@ +require "spec_helper" + +describe Devices::Seeders::StressData do + let(:device) { FactoryBot.create(:device) } + + it "maps stress product lines to row counts" do + expect(described_class.count_for("genesis_xl_1.8_stress_250")).to eq(250) + expect(described_class.count_for("genesis_xl_1.8_stress_500")).to eq(500) + expect(described_class.count_for("genesis_xl_1.8_stress_750")).to eq(750) + expect(described_class.count_for("genesis_xl_1.8_stress_1000")).to eq(1_000) + expect(described_class.count_for("genesis_xl_1.8")).to be_nil + end + + it "adds stress resources and display settings", :slow do + device.web_app_config.update!(map_size_x: 5_900, map_size_y: 2_730) + + described_class.new(device, 3).seed! + + expect(device.plants.count).to eq(3) + expect(device.generic_pointers.where(name: "Soil Height").count).to eq(3) + expect(device.points.where(pointer_type: "Weed").count).to eq(3) + expect(device.images.count).to eq(3) + expect(device.images.all? { |image| image.attachment.attached? }).to be(true) + expect(device.sensor_readings.count).to eq(3) + expect(device.sensor_readings.pluck(:pin).uniq).to eq([59]) + expect(device.sensor_readings.pluck(:mode).uniq) + .to eq([CeleryScriptSettingsBag::ANALOG]) + expect(device.reload.max_images_count).to eq(3) + expect(device.web_app_config.show_sensor_readings).to be(true) + expect(device.web_app_config.show_moisture_interpolation_map).to be(true) + expect(device.web_app_config.three_d_garden).to be(true) + end +end diff --git a/spec/mutations/points/destroy_edge_cases_spec.rb b/spec/mutations/points/destroy_edge_cases_spec.rb index 273f38f632..5f4a3c537b 100644 --- a/spec/mutations/points/destroy_edge_cases_spec.rb +++ b/spec/mutations/points/destroy_edge_cases_spec.rb @@ -26,8 +26,8 @@ }]) result = Points::Destroy.run(point_ids: [tool_slot.id], device: device) errors = result.errors.message_list - expected = "Could not delete foo tool. Item is in use by the following "\ - "sequence(s): sequence." + expected = "Could not delete foo tool. Item is in use by " \ + "Sequence 'sequence'." expect(errors).to include(expected) end end diff --git a/spec/mutations/points/destroy_spec.rb b/spec/mutations/points/destroy_spec.rb index edce38ac07..ead324dc3b 100644 --- a/spec/mutations/points/destroy_spec.rb +++ b/spec/mutations/points/destroy_spec.rb @@ -63,8 +63,8 @@ expect(result.errors.message_list.count).to eq(1) expect(result.errors.message_list.first).to include(params[:name]) coords = [:x, :y, :z].map { |c| points.first[c] }.join(", ") - expected = "Could not delete the following item(s): point at (#{coords})." \ - " Item(s) are in use by the following sequence(s): Test Case I." + expected = "Could not delete point at (#{coords}). Items are in use " \ + "by Sequence 'Test Case I'." expect(result.errors.message_list.first).to include(expected) end @@ -73,11 +73,147 @@ point_ids = [s.tool_slot.id] result = Points::Destroy.run(point_ids: point_ids, device: s.device) expect(result.success?).to be(false) - expected = "Could not delete Scenario Tool. Item is in use by the " \ - "following sequence(s): Scenario Sequence." + expected = "Could not delete Scenario Tool. Item is in use by " \ + "Sequence 'Scenario Sequence'." expect(result.errors.message_list).to include(expected) end + it "prevents deletion of points used by farm event fragments" do + point = FactoryBot.create(:generic_pointer, + device: device, + x: 4, + y: 5, + z: 6) + farm_event = FactoryBot.create(:farm_event, + device: device, + start_time: Time.now + 1.day) + Fragment.from_celery(device: device, + owner: farm_event, + kind: "internal_farm_event", + args: {}, + body: [ + { + kind: "parameter_application", + args: { + label: "location", + data_value: { + kind: "point", + args: { + pointer_type: "GenericPointer", + pointer_id: point.id, + }, + }, + }, + }, + ]) + + result = Points::Destroy.run(point_ids: [point.id], device: device) + + expect(result.success?).to be(false) + expected = "Could not delete point at (4.0, 5.0, 6.0). Item is in use " \ + "by FarmEvent '#{farm_event.fancy_name}'." + expect(result.errors.message[:whoops]).to eq(expected) + expect(Point.exists?(point.id)).to be(true) + end + + it "prevents deletion of points used by regimen fragments" do + point = FactoryBot.create(:generic_pointer, + device: device, + x: 4, + y: 5, + z: 6) + regimen = FactoryBot.create(:regimen, + device: device, + name: "Point user") + Fragment.from_celery(device: device, + owner: regimen, + kind: "internal_regimen", + args: {}, + body: [ + { + kind: "parameter_application", + args: { + label: "location", + data_value: { + kind: "point", + args: { + pointer_type: "GenericPointer", + pointer_id: point.id, + }, + }, + }, + }, + ]) + + result = Points::Destroy.run(point_ids: [point.id], device: device) + + expect(result.success?).to be(false) + expected = "Could not delete point at (4.0, 5.0, 6.0). Item is in use " \ + "by Regimen 'Point user'." + expect(result.errors.message[:whoops]).to eq(expected) + expect(Point.exists?(point.id)).to be(true) + end + + it "reports sequence and fragment point usage in one error" do + point = FactoryBot.create(:generic_pointer, + device: device, + x: 4, + y: 5, + z: 6) + Sequences::Create.run!(device: device, + name: "Sequence user", + body: [ + { + kind: "move_absolute", + args: { + location: { + kind: "point", + args: { + pointer_type: "GenericPointer", + pointer_id: point.id, + }, + }, + speed: 100, + offset: { + kind: "coordinate", + args: { x: 0, y: 0, z: 0 }, + }, + }, + }, + ]) + farm_event = FactoryBot.create(:farm_event, + device: device, + start_time: Time.now + 1.day) + Fragment.from_celery(device: device, + owner: farm_event, + kind: "internal_farm_event", + args: {}, + body: [ + { + kind: "parameter_application", + args: { + label: "location", + data_value: { + kind: "point", + args: { + pointer_type: "GenericPointer", + pointer_id: point.id, + }, + }, + }, + }, + ]) + + result = Points::Destroy.run(point_ids: [point.id], device: device) + + expect(result.success?).to be(false) + expect(result.errors.message_list.count).to eq(1) + expected = "Could not delete point at (4.0, 5.0, 6.0). Item is in use " \ + "by FarmEvent '#{farm_event.fancy_name}', " \ + "Sequence 'Sequence user'." + expect(result.errors.message[:whoops]).to eq(expected) + end + it "handles multiple sequence dep tracking issues at deletion time" do point = FactoryBot.create(:generic_pointer, device: device, x: 4, y: 5, z: 6) plant = FactoryBot.create(:plant, device: device, x: 0, y: 1, z: 0) @@ -152,9 +288,8 @@ .run(point_ids: [point.id, plant.id], device: device) .errors .message - expected = "Could not delete the following item(s): plant at (0.0, 1.0," \ - " 0.0). Item(s) are in use by the following sequence(s): " \ - "Sequence A, Sequence B." + expected = "Could not delete plant at (0.0, 1.0, 0.0). Items are " \ + "in use by Sequence 'Sequence A', Sequence 'Sequence B'." expect(result[:whoops]).to eq(expected) end diff --git a/spec/mutations/sequences/mark_as_spec.rb b/spec/mutations/sequences/mark_as_spec.rb index e280174f8d..908bd928ec 100644 --- a/spec/mutations/sequences/mark_as_spec.rb +++ b/spec/mutations/sequences/mark_as_spec.rb @@ -63,7 +63,7 @@ def sequence(body, locals = nil) expect(weed_result.success?).to be false error = weed_result.errors.message_list.first expect(error).to include("Could not delete weed") - expect(error).to include("in use by the following sequence(s): #{s.name}") + expect(error).to include("in use by Sequence '#{s.name}'") end end @@ -94,7 +94,7 @@ def sequence(body, locals = nil) expect(plant_result.success?).to be false error = plant_result.errors.message_list.first expect(error).to include("Could not delete plant") - expect(error).to include("in use by the following sequence(s): #{s.name}") + expect(error).to include("in use by Sequence '#{s.name}'") end end