mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
Compare commits
26 commits
master
...
ai-preview
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b3c6510b4 | ||
|
|
168f194512 | ||
|
|
1b172ddda5 | ||
|
|
e4d996013a | ||
|
|
85ce1d0172 | ||
|
|
c5ca3dae78 | ||
|
|
6cbc48c78e | ||
|
|
6b6f057c86 | ||
|
|
6e8e11b2b9 | ||
|
|
40b63b6a28 | ||
|
|
ced78e181f | ||
|
|
82e11cbaba | ||
|
|
642cfa29ee | ||
|
|
6ca2fc954c | ||
|
|
9cb1414d7a | ||
|
|
bf52b24b98 | ||
|
|
ebbea75e26 | ||
|
|
4a37d95fab | ||
|
|
0347c2901b | ||
|
|
f86058ee8a | ||
|
|
485a9703eb | ||
|
|
41bd8f7c45 | ||
|
|
c6e0f18d5e | ||
|
|
85604c80fe | ||
|
|
b07672b976 | ||
|
|
d77eaa9ecd |
380 changed files with 10818 additions and 32453 deletions
253
.circleci/config.yml
Normal file
253
.circleci/config.yml
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
# Use the latest 2.1 version of CircleCI pipeline process engine.
|
||||
# See: https://circleci.com/docs/2.0/configuration-reference
|
||||
version: 2.1
|
||||
|
||||
# Define a job to be invoked later in a workflow.
|
||||
# See: https://circleci.com/docs/2.0/configuration-reference/#jobs
|
||||
jobs:
|
||||
api-tests:
|
||||
docker:
|
||||
- image: cimg/python:3.13
|
||||
# `large` (4 vCPU, 8 GB) so pytest-xdist's `-nauto` has room to scale.
|
||||
resource_class: large
|
||||
steps:
|
||||
- checkout
|
||||
- run: sudo apt-get update
|
||||
- run: sudo apt-get install -y ffmpeg catdoc
|
||||
- restore_cache:
|
||||
key: deps-3.13-{{ checksum "requirements.txt" }}-6
|
||||
- run:
|
||||
name: Install Requirements
|
||||
command: |
|
||||
# A restored venv points at the exact interpreter that built it. CircleCI's
|
||||
# cimg/python:3.13 gets patch bumps, and this cache key does not name the
|
||||
# patch, so a venv cached before a bump has a dangling bin/python3 -- and
|
||||
# `python3 -m venv` over it fails with `[Errno 2]` instead of repairing it,
|
||||
# which broke every Python job. Rebuild when it cannot run: that costs a
|
||||
# reinstall only on the first run after a bump.
|
||||
if ! ./venv/bin/python3 --version >/dev/null 2>&1; then rm -rf venv; fi
|
||||
python3 -m venv venv
|
||||
. venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
- save_cache:
|
||||
key: deps-3.13-{{ checksum "requirements.txt" }}-6
|
||||
paths:
|
||||
- "venv"
|
||||
# `-nauto` parallelizes across all available cores via pytest-xdist.
|
||||
# `-svv` was dumping the full test stdout to CircleCI logs on every run,
|
||||
# which inflated wall time and produced multi-MB log artifacts; dropped.
|
||||
- run:
|
||||
command: './venv/bin/pytest -nauto --junitxml=test-results.xml'
|
||||
- store_test_results:
|
||||
path: test-results.xml
|
||||
# Do the API and the frontend's test fixtures still agree?
|
||||
#
|
||||
# `api-tests` runs these too, as part of the whole suite. This job exists for the
|
||||
# signal: fixture drift is silent by nature -- the frontend suite passes against a body the
|
||||
# API stopped sending -- so it is worth a check whose name says what broke, failing in under a
|
||||
# minute rather than somewhere inside seventeen hundred results.
|
||||
#
|
||||
# It needs no ffmpeg or catdoc (verified), and shares the dependency cache key with the other
|
||||
# Python jobs, so it costs a venv restore and one test file.
|
||||
api-contract-tests:
|
||||
docker:
|
||||
- image: cimg/python:3.13
|
||||
resource_class: medium
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: deps-3.13-{{ checksum "requirements.txt" }}-6
|
||||
- run:
|
||||
name: Install Requirements
|
||||
command: |
|
||||
# A restored venv points at the exact interpreter that built it. CircleCI's
|
||||
# cimg/python:3.13 gets patch bumps, and this cache key does not name the
|
||||
# patch, so a venv cached before a bump has a dangling bin/python3 -- and
|
||||
# `python3 -m venv` over it fails with `[Errno 2]` instead of repairing it,
|
||||
# which broke every Python job. Rebuild when it cannot run: that costs a
|
||||
# reinstall only on the first run after a bump.
|
||||
if ! ./venv/bin/python3 --version >/dev/null 2>&1; then rm -rf venv; fi
|
||||
python3 -m venv venv
|
||||
. venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
- run:
|
||||
name: Fixtures match the OpenAPI spec, and the spec matches the handlers
|
||||
command: './venv/bin/pytest wrolpi/test/test_api_fixtures.py -v'
|
||||
|
||||
api-alembic-migrations:
|
||||
docker:
|
||||
- image: cimg/python:3.13
|
||||
resource_class: medium
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: deps-3.13-{{ checksum "requirements.txt" }}-6
|
||||
- run:
|
||||
name: Install Requirements
|
||||
command: |
|
||||
# A restored venv points at the exact interpreter that built it. CircleCI's
|
||||
# cimg/python:3.13 gets patch bumps, and this cache key does not name the
|
||||
# patch, so a venv cached before a bump has a dangling bin/python3 -- and
|
||||
# `python3 -m venv` over it fails with `[Errno 2]` instead of repairing it,
|
||||
# which broke every Python job. Rebuild when it cannot run: that costs a
|
||||
# reinstall only on the first run after a bump.
|
||||
if ! ./venv/bin/python3 --version >/dev/null 2>&1; then rm -rf venv; fi
|
||||
python3 -m venv venv
|
||||
. venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
- run:
|
||||
name: Create Media Directory
|
||||
command: mkdir -p /tmp/wrolpi-media/config
|
||||
- run:
|
||||
name: Run Alembic Migrations
|
||||
command: MEDIA_DIRECTORY=/tmp/wrolpi-media ./venv/bin/python ./main.py db upgrade
|
||||
- run:
|
||||
name: Check for Missing Migrations
|
||||
command: MEDIA_DIRECTORY=/tmp/wrolpi-media ./venv/bin/python ./main.py db check
|
||||
sanic-api-start:
|
||||
docker:
|
||||
- image: cimg/python:3.13
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: deps-3.13-{{ checksum "requirements.txt" }}-6
|
||||
- run:
|
||||
name: Install dependencies
|
||||
command: |
|
||||
# A restored venv points at the exact interpreter that built it. CircleCI's
|
||||
# cimg/python:3.13 gets patch bumps, and this cache key does not name the
|
||||
# patch, so a venv cached before a bump has a dangling bin/python3 -- and
|
||||
# `python3 -m venv` over it fails with `[Errno 2]` instead of repairing it,
|
||||
# which broke every Python job. Rebuild when it cannot run: that costs a
|
||||
# reinstall only on the first run after a bump.
|
||||
if ! ./venv/bin/python3 --version >/dev/null 2>&1; then rm -rf venv; fi
|
||||
python -m venv venv
|
||||
. venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
sudo mkdir -p /media/wrolpi
|
||||
- run:
|
||||
name: Start Sanic API
|
||||
command: |
|
||||
. venv/bin/activate
|
||||
sanic main:api_app -H 0.0.0.0 -p 8081
|
||||
background: true
|
||||
- run:
|
||||
name: Test Sanic API
|
||||
command: |
|
||||
sleep 20
|
||||
curl http://0.0.0.0:8081/api/echo
|
||||
- run:
|
||||
name: Stop Sanic API
|
||||
command: pkill -f sanic
|
||||
react-app-start:
|
||||
docker:
|
||||
- image: cimg/node:20.19
|
||||
resource_class: large
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: app-{{ checksum "app/package-lock.json" }}
|
||||
- run: cd app && npm install
|
||||
- save_cache:
|
||||
key: app-{{ checksum "app/package-lock.json" }}
|
||||
paths:
|
||||
- ./app/node_modules
|
||||
- run:
|
||||
name: Build React App
|
||||
command: cd app && DISABLE_ESLINT_PLUGIN=true npm run build
|
||||
- run:
|
||||
name: Start React App
|
||||
command: cd app && DISABLE_ESLINT_PLUGIN=true npm run start
|
||||
background: true
|
||||
- run:
|
||||
name: Test React App
|
||||
command: |
|
||||
sleep 20
|
||||
curl http://0.0.0.0:3000/manifest.json
|
||||
- run:
|
||||
name: Stop React App
|
||||
command: pkill -f npm
|
||||
# Jest tests moved to GitHub Actions (.github/workflows/jest-tests.yml)
|
||||
# Component specs live under `src/`, so they need `cypress run --component`; the e2e job's
|
||||
# `cypress run` will not match them. Cypress starts its own dev server, so no `npm start`.
|
||||
cypress-component-tests:
|
||||
docker:
|
||||
- image: cimg/node:20.19-browsers
|
||||
resource_class: large
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: app-browsers-{{ checksum "app/package-lock.json" }}
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: cd app && npm install
|
||||
- run:
|
||||
name: Install Cypress Binary
|
||||
command: cd app && npx cypress install
|
||||
- run:
|
||||
name: Run Cypress Component Tests
|
||||
command: cd app && CI=true npx cypress run --component
|
||||
- store_artifacts:
|
||||
path: app/cypress/screenshots
|
||||
cypress-e2e-tests:
|
||||
docker:
|
||||
- image: cimg/node:20.19-browsers
|
||||
resource_class: large
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: app-browsers-{{ checksum "app/package-lock.json" }}
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: cd app && npm install
|
||||
# The Cypress binary lives outside node_modules, so caching only
|
||||
# node_modules lets the two drift apart: a restored cache satisfies npm,
|
||||
# npm skips the postinstall download, and the binary for the new version
|
||||
# is never fetched. Cache both, and install explicitly (a no-op when the
|
||||
# binary is already present).
|
||||
- run:
|
||||
name: Install Cypress Binary
|
||||
command: cd app && npx cypress install
|
||||
- save_cache:
|
||||
key: app-browsers-{{ checksum "app/package-lock.json" }}
|
||||
paths:
|
||||
- ./app/node_modules
|
||||
- ~/.cache/Cypress
|
||||
- run:
|
||||
name: Start React App in Background
|
||||
command: cd app && DISABLE_ESLINT_PLUGIN=true npm start
|
||||
background: true
|
||||
- run:
|
||||
name: Wait for React App
|
||||
command: |
|
||||
for i in {1..60}; do
|
||||
if curl -s http://localhost:3000 > /dev/null; then
|
||||
echo "React app is ready!"
|
||||
exit 0
|
||||
fi
|
||||
echo "Waiting for React app... ($i/60)"
|
||||
sleep 2
|
||||
done
|
||||
echo "React app failed to start"
|
||||
exit 1
|
||||
- run:
|
||||
name: Run Cypress Tests
|
||||
command: cd app && CI=true npx cypress run
|
||||
- store_artifacts:
|
||||
path: app/cypress/videos
|
||||
- store_artifacts:
|
||||
path: app/cypress/screenshots
|
||||
|
||||
workflows:
|
||||
wrolpi-api-tests:
|
||||
jobs:
|
||||
- sanic-api-start
|
||||
- api-tests
|
||||
- api-contract-tests
|
||||
- api-alembic-migrations
|
||||
wrolpi-app-test:
|
||||
jobs:
|
||||
- react-app-start
|
||||
# jest-tests moved to GitHub Actions
|
||||
- cypress-e2e-tests
|
||||
- cypress-component-tests
|
||||
41
.github/dependabot.yml
vendored
41
.github/dependabot.yml
vendored
|
|
@ -7,11 +7,6 @@ updates:
|
|||
interval: "monthly"
|
||||
target-branch: "master"
|
||||
open-pull-requests-limit: 5
|
||||
ignore:
|
||||
# SQLAlchemy 2.1 is a minor release with its own deprecations; upgrade it
|
||||
# deliberately rather than by an automatic bump.
|
||||
- dependency-name: "SQLAlchemy"
|
||||
versions: [">=2.1"]
|
||||
groups:
|
||||
python-dependencies:
|
||||
patterns:
|
||||
|
|
@ -42,42 +37,6 @@ updates:
|
|||
interval: "monthly"
|
||||
target-branch: "master"
|
||||
open-pull-requests-limit: 5
|
||||
ignore:
|
||||
# Major upgrades below need their own migration branch, not a bump PR.
|
||||
# maplibre-gl 6: /map build depends on pinned babel/terser handling.
|
||||
- dependency-name: "maplibre-gl"
|
||||
versions: [">=6"]
|
||||
# react-dropzone 15+ changed the accept API; 20 requires Node >=22.
|
||||
- dependency-name: "react-dropzone"
|
||||
versions: [">=15"]
|
||||
# React 19: @react-three/fiber 8 pins react <19.
|
||||
- dependency-name: "react"
|
||||
update-types: ["version-update:semver-major"]
|
||||
- dependency-name: "react-dom"
|
||||
update-types: ["version-update:semver-major"]
|
||||
- dependency-name: "@types/react"
|
||||
update-types: ["version-update:semver-major"]
|
||||
- dependency-name: "@types/react-dom"
|
||||
update-types: ["version-update:semver-major"]
|
||||
# TypeScript 6+/7 breaks the react-scripts toolchain.
|
||||
- dependency-name: "typescript"
|
||||
versions: [">=6"]
|
||||
# Mantine 9: all @mantine packages move together with app code changes.
|
||||
- dependency-name: "@mantine/*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
# Cypress 16 requires Node >=22; CI and the app image run Node 20.
|
||||
- dependency-name: "cypress"
|
||||
versions: [">=16"]
|
||||
# react-router 8 is a routing migration across ~40 files.
|
||||
- dependency-name: "react-router"
|
||||
update-types: ["version-update:semver-major"]
|
||||
# @testing-library/react 14+ async act semantics break menu/popover tests.
|
||||
- dependency-name: "@testing-library/react"
|
||||
update-types: ["version-update:semver-major"]
|
||||
# Cypress component testing resolves the top-level webpack-dev-server and only
|
||||
# accepts major 5; 6 crashes cypress.config.js ("Unexpected major version").
|
||||
- dependency-name: "webpack-dev-server"
|
||||
versions: [">=6"]
|
||||
groups:
|
||||
npm-dependencies:
|
||||
patterns:
|
||||
|
|
|
|||
145
.github/workflows/backend.yml
vendored
145
.github/workflows/backend.yml
vendored
|
|
@ -1,145 +0,0 @@
|
|||
name: Backend Tests
|
||||
|
||||
# Same triggers as jest-tests.yml: `pull_request` is unfiltered so pull requests into feature
|
||||
# branches are checked too, not only those into master.
|
||||
on:
|
||||
push:
|
||||
branches: [master, feature/*]
|
||||
pull_request:
|
||||
|
||||
# A new push to a pull request makes the run for the previous push worthless; cancel it so it
|
||||
# does not hold runners (the free plan allows 20 concurrent jobs). Runs on master always finish.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
# The suite is split by directory so it finishes in a few minutes, and so a check's name says
|
||||
# which area broke. The groups are balanced by test time; `rest` runs everything the others do
|
||||
# not, so a new directory is tested before anyone assigns it. A path added to any other group
|
||||
# must also be added to `rest` as an --ignore, or its tests run twice.
|
||||
api-tests:
|
||||
name: api-tests (${{ matrix.name }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: core
|
||||
paths: wrolpi/test
|
||||
- name: media
|
||||
paths: modules/videos modules/zim modules/docs modules/map modules/inventory modules/flasher
|
||||
- name: files
|
||||
paths: wrolpi/files wrolpi/collections
|
||||
- name: rest
|
||||
paths: >-
|
||||
--ignore=wrolpi/test
|
||||
--ignore=modules/videos --ignore=modules/zim --ignore=modules/docs --ignore=modules/map
|
||||
--ignore=modules/inventory --ignore=modules/flasher
|
||||
--ignore=wrolpi/files --ignore=wrolpi/collections
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install system packages
|
||||
run: sudo apt-get update && sudo apt-get install -y ffmpeg catdoc
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
# Trixie ships 3.13.
|
||||
python-version: '3.13'
|
||||
cache: 'pip'
|
||||
cache-dependency-path: requirements.txt
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements.txt
|
||||
|
||||
# `logical`, not `auto`: with psutil installed, `auto` counts physical cores, which is two on
|
||||
# the four-vCPU runner. No `-v`: the full test output inflates wall time and logs.
|
||||
- name: Run pytest
|
||||
env:
|
||||
PATHS: ${{ matrix.paths }}
|
||||
run: pytest -n logical $PATHS --junitxml=test-results.xml
|
||||
|
||||
- name: Upload test results
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: api-test-results-${{ matrix.name }}
|
||||
path: test-results.xml
|
||||
retention-days: 30
|
||||
|
||||
# Do the API and the frontend's test fixtures still agree?
|
||||
#
|
||||
# `api-tests` runs these too, as part of the whole suite. This job exists for the signal:
|
||||
# fixture drift is silent by nature -- the frontend suite passes against a body the API stopped
|
||||
# sending -- so it is worth a check whose name says what broke, failing in under a minute rather
|
||||
# than somewhere inside seventeen hundred results. It needs no ffmpeg or catdoc.
|
||||
api-contract-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
cache: 'pip'
|
||||
cache-dependency-path: requirements.txt
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements.txt
|
||||
|
||||
- name: Fixtures match the OpenAPI spec, and the spec matches the handlers
|
||||
run: pytest wrolpi/test/test_api_fixtures.py -v
|
||||
|
||||
# The migrations apply to an empty database, the models match them, and the API starts.
|
||||
api-migrations-and-start:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
cache: 'pip'
|
||||
cache-dependency-path: requirements.txt
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements.txt
|
||||
|
||||
- name: Run Alembic migrations
|
||||
env:
|
||||
MEDIA_DIRECTORY: ${{ runner.temp }}/wrolpi-media
|
||||
run: |
|
||||
mkdir -p "$MEDIA_DIRECTORY/config"
|
||||
python ./main.py db upgrade
|
||||
|
||||
- name: Check for missing migrations
|
||||
env:
|
||||
MEDIA_DIRECTORY: ${{ runner.temp }}/wrolpi-media
|
||||
run: python ./main.py db check
|
||||
|
||||
- name: Start Sanic API
|
||||
run: |
|
||||
sudo mkdir -p /media/wrolpi
|
||||
nohup sanic main:api_app -H 0.0.0.0 -p 8081 > sanic.log 2>&1 &
|
||||
for i in $(seq 1 60); do
|
||||
if curl -sf http://localhost:8081/api/echo; then
|
||||
echo
|
||||
echo "Sanic API is up"
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "Sanic API failed to start"
|
||||
cat sanic.log
|
||||
exit 1
|
||||
|
||||
- name: Stop Sanic API
|
||||
if: ${{ always() }}
|
||||
run: pkill -f sanic || true
|
||||
121
.github/workflows/cypress.yml
vendored
121
.github/workflows/cypress.yml
vendored
|
|
@ -1,121 +0,0 @@
|
|||
name: Cypress Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master, feature/*]
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
# Component specs live under `src/`, so they need `cypress run --component`; the e2e job's
|
||||
# `cypress run` will not match them. Cypress starts its own dev server, so no `npm start`.
|
||||
cypress-component-tests:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: app
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.19'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: app/package-lock.json
|
||||
|
||||
# The Cypress binary lives outside node_modules, and setup-node only caches ~/.npm. Key it
|
||||
# on the lockfile so a Cypress bump fetches the matching binary.
|
||||
- name: Cache Cypress binary
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/Cypress
|
||||
key: cypress-${{ runner.os }}-${{ hashFiles('app/package-lock.json') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
# A no-op when the cached binary matches.
|
||||
- name: Install Cypress binary
|
||||
run: npx cypress install
|
||||
|
||||
- name: Run Cypress component tests
|
||||
run: npx cypress run --component
|
||||
|
||||
- name: Upload screenshots
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cypress-component-screenshots
|
||||
path: app/cypress/screenshots
|
||||
retention-days: 14
|
||||
|
||||
# Waiting for `npm start` to serve the app also checks that the dev server starts.
|
||||
cypress-e2e-tests:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: app
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.19'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: app/package-lock.json
|
||||
|
||||
- name: Cache Cypress binary
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/Cypress
|
||||
key: cypress-${{ runner.os }}-${{ hashFiles('app/package-lock.json') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Install Cypress binary
|
||||
run: npx cypress install
|
||||
|
||||
# GitHub Actions sets `CI=true`, which makes cypress.config.js use http://localhost:3000 as
|
||||
# the base URL.
|
||||
- name: Start React app
|
||||
env:
|
||||
DISABLE_ESLINT_PLUGIN: 'true'
|
||||
BROWSER: none
|
||||
run: |
|
||||
nohup npm start > react.log 2>&1 &
|
||||
for i in $(seq 1 90); do
|
||||
if curl -sf http://localhost:3000/manifest.json > /dev/null; then
|
||||
echo "React app is ready"
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "React app failed to start"
|
||||
cat react.log
|
||||
exit 1
|
||||
|
||||
- name: Run Cypress e2e tests
|
||||
run: npx cypress run
|
||||
|
||||
- name: Upload videos and screenshots
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cypress-e2e-media
|
||||
path: |
|
||||
app/cypress/videos
|
||||
app/cypress/screenshots
|
||||
retention-days: 14
|
||||
if-no-files-found: ignore
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -150,6 +150,3 @@ app/cypress/videos
|
|||
release/latest.json
|
||||
perf_results/
|
||||
scripts/api_perf_hosts.json
|
||||
|
||||
# Tile cache for scripts/build_solar_climatology.py
|
||||
/scripts/.solar_climatology_cache/
|
||||
|
|
|
|||
67
INSTALL.md
67
INSTALL.md
|
|
@ -3,10 +3,8 @@
|
|||
There are several ways to run WROLPi. Pick the one that matches your hardware:
|
||||
|
||||
* [WROLPi Portable (Live USB)](#wrolpi-portable-live-usb) — boot a full WROLPi from a USB stick on any x86 PC or laptop, no installation required.
|
||||
* [Debian 13 Install](#debian-13-install) — install WROLPi onto an x86 PC or laptop.
|
||||
* [Debian 12 Install](#debian-12-install) — install WROLPi onto an x86 PC or laptop.
|
||||
* [Raspberry Pi Install](#raspberry-pi-install) — install WROLPi onto a Raspberry Pi with an external drive.
|
||||
|
||||
After installing, follow [First Boot](#first-boot) to set up your drive.
|
||||
* [Docker](docker/README.md) — run the WROLPi containers on an existing Linux host (best for trying it out or development).
|
||||
|
||||
See [UPGRADE.md](UPGRADE.md) for upgrading an existing WROLPi.
|
||||
|
|
@ -38,7 +36,6 @@ Steps:
|
|||
minute or two; progress is shown on screen.
|
||||
5. Once the desktop loads, browse to https://wrolpi.local or the IP address of
|
||||
your WROLPi. Wi-Fi hotspot and all other features work as usual.
|
||||
6. To use a terminal remotely: `ssh user@wrolpi.local`, password `live`.
|
||||
|
||||
Notes:
|
||||
|
||||
|
|
@ -55,50 +52,30 @@ Notes:
|
|||
* **Upgrading.** See [Upgrading a WROLPi Portable USB](UPGRADE.md#upgrading-a-wrolpi-portable-usb)
|
||||
to move to a newer ISO without losing your library.
|
||||
|
||||
## Debian 13 Install
|
||||
## Debian 12 Install
|
||||
|
||||
Install WROLPi onto the internal disk of an x86 PC or laptop. This uses the same
|
||||
`WROLPi-v*-amd64.iso` as WROLPi Portable.
|
||||
Steps necessary to initialize your WROLPi after installing the Debian image from wrolpi.org
|
||||
|
||||
1. Download the latest `WROLPi-v*-amd64.iso` from [wrolpi.org](https://wrolpi.org) and flash it to a USB drive
|
||||
(see [WROLPi Portable](#wrolpi-portable-live-usb) step 2).
|
||||
2. Boot the PC from the USB drive. At the boot menu, choose **Install** or **Graphical Install**.
|
||||
3. Install Debian 13 as you would like.
|
||||
1. It is recommended to use the hostname **wrolpi**.
|
||||
2. WROLPi is installed along with Debian; there is nothing extra to select.
|
||||
4. Unplug the USB drive after the installation has completed, and reboot.
|
||||
5. Continue with [First Boot](#first-boot).
|
||||
1. Download and copy a pre-built Debian image from https://wrolpi.org onto a USB thumb-drive (USB 2 recommended).
|
||||
2. Insert the thumb-drive into the laptop, boot to the thumb-drive.
|
||||
1. Select "Start Installer".
|
||||
2. Install Debian 12 as you would like.
|
||||
1. It is recommended to use the hostname **wrolpi**.
|
||||
2. (WROLPi will be installed during the installation without your intervention.)
|
||||
3. Unplug the thumb-drive after the installation has completed.
|
||||
4. Login as the user _you_ created during installation.
|
||||
5. Initialize the WROLPi databases using the repair script: `/opt/wrolpi/repair.sh`
|
||||
6. Reboot: `reboot`
|
||||
7. Browse to https://wrolpi.local or the IP address of your WROLPi!
|
||||
|
||||
## Raspberry Pi Install
|
||||
|
||||
Install WROLPi onto a Raspberry Pi. An external drive is recommended for your library.
|
||||
Steps necessary to initialize your WROLPi after installing the Raspberry Pi image from wrolpi.org
|
||||
|
||||
1. Download the latest `WROLPi-v*-aarch64-desktop.img.xz` from [wrolpi.org](https://wrolpi.org).
|
||||
2. Flash it to an SD card using Raspberry Pi Imager.
|
||||
3. Plug in your external drive, insert the SD card, and boot the Raspberry Pi.
|
||||
4. On the first boot, Raspberry Pi OS asks you to create your own user.
|
||||
5. Continue with [First Boot](#first-boot).
|
||||
|
||||
## First Boot
|
||||
|
||||
These steps are the same for every install.
|
||||
|
||||
1. Join the WROLPi hotspot (network **WROLPi**, password **wrolpi hotspot**; it starts on every boot), or connect
|
||||
your WROLPi to your network, then browse to https://wrolpi.local (or the IP address of your WROLPi).
|
||||
2. **Choose your drive.**
|
||||
* **Raspberry Pi and Debian:** until a drive is set up, you will see the **Controller** with a *Welcome to WROLPi*
|
||||
wizard (also at http://wrolpi.local). Select the drive which will hold your library; WROLPi mounts it
|
||||
at `/media/wrolpi`, adds it to `/etc/fstab`, and runs the repair script for you. If the drive already holds a
|
||||
WROLPi library (from another WROLPi, or a backup), its configuration is used. No external drive? Choose **Run
|
||||
repair without external drive** to keep your library on the internal disk or SD card.
|
||||
* **WROLPi Portable:** nothing to do; your library lives in the persistence partition on the USB drive.
|
||||
3. **Trust the certificate.** Your browser will warn about the HTTPS certificate because it was made by your WROLPi,
|
||||
not by a public authority. Download https://wrolpi.local/ca.crt and trust it on each device you use; see
|
||||
*Help > Certificates* on your WROLPi for per-device instructions.
|
||||
4. **Wait for the first refresh.** WROLPi refreshes all your files when it first starts so it can find anything already
|
||||
on the drive. Your library looks empty, and Download and Upload are disabled, until the refresh completes.
|
||||
5. Start adding content: download a video channel, archive a web page, or add a Zim (Wikipedia) file. The *Help* page
|
||||
on your WROLPi explains each module.
|
||||
|
||||
You do not need to edit `/etc/fstab` or run `repair.sh` by hand. `sudo /opt/wrolpi/repair.sh` is still available if
|
||||
your WROLPi ever needs repairing.
|
||||
1. Download and copy a pre-built image from https://wrolpi.org onto an SD card.
|
||||
2. Boot the Raspberry Pi, login with username pi and password wrolpi.
|
||||
3. Modify fstab to mount your external drive to /media/wrolpi (modify this command to match your system).
|
||||
* `echo '/dev/sda1 /media/wrolpi auto defaults,nofail 0 0' | sudo tee -a /etc/fstab`
|
||||
4. Initialize the WROLPi databases using the repair script: `sudo /opt/wrolpi/repair.sh`
|
||||
5. Reboot `sudo reboot`
|
||||
6. Join the Hotspot or browse to https://wrolpi.local or the IP address of your WROLPi!
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# WROLPi
|
||||
|
||||
[](https://github.com/lrnselfreliance/wrolpi/actions/workflows/backend.yml?query=branch%3Amaster)
|
||||
[](https://app.circleci.com/pipelines/github/lrnselfreliance/wrolpi?branch=release&filter=all)
|
||||
[](https://github.com/lrnselfreliance/wrolpi/blob/master/LICENSE)
|
||||
[](https://discord.gg/HrwFk7nqA2)
|
||||
[](https://gitpod.io/#https://github.com/lrnselfreliance/wrolpi)
|
||||
|
|
@ -93,7 +93,7 @@ More Docker documentation is available in [docker/README.md](docker/README.md)
|
|||
Full installation instructions are in [INSTALL.md](INSTALL.md). Pick the option that matches your hardware:
|
||||
|
||||
* **[WROLPi Portable (Live USB)](INSTALL.md#wrolpi-portable-live-usb)** — boot a complete WROLPi from a USB stick on any x86 PC or laptop. Nothing is installed to the computer; the first boot creates a persistence partition on the USB so your library, database, and configuration survive reboots.
|
||||
* **[Debian 13](INSTALL.md#debian-13-install)** — install WROLPi onto an x86 PC or laptop.
|
||||
* **[Debian 12](INSTALL.md#debian-12-install)** — install WROLPi onto an x86 PC or laptop.
|
||||
* **[Raspberry Pi](INSTALL.md#raspberry-pi-install)** — install WROLPi onto a Raspberry Pi with an external drive.
|
||||
* **[Docker](docker/README.md)** — run the WROLPi containers on an existing Linux host.
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
# Table of Contents
|
||||
|
||||
* [Upgrading Docker containers](#upgrading-docker-containers)
|
||||
* [Upgrading Raspberry Pi or Debian Installation](#upgrading-raspberry-pi-or-debian-installation)
|
||||
* [Upgrading Raspberry Pi or Debian 12 Installation](#upgrading-raspberry-pi-or-debian-12-installation)
|
||||
* [Upgrading a WROLPi Portable USB](#upgrading-a-wrolpi-portable-usb)
|
||||
|
||||
## Upgrading Docker containers
|
||||
|
|
@ -24,9 +24,7 @@
|
|||
7. Start all docker containers
|
||||
* `docker-compose up -d`
|
||||
|
||||
## Upgrading Raspberry Pi or Debian Installation
|
||||
|
||||
A Debian 12 (bookworm) WROLPi stays on the final version for Debian 12; reinstall on Debian 13 to get newer versions.
|
||||
## Upgrading Raspberry Pi or Debian 12 Installation
|
||||
|
||||
1. Run the upgrade script
|
||||
* `sudo /bin/bash /opt/wrolpi/upgrade.sh`
|
||||
|
|
|
|||
|
|
@ -6,10 +6,9 @@ from alembic import context
|
|||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Configure logging only for the alembic command line. WROLPi also migrates in-process at startup
|
||||
# (db_bootstrap), where fileConfig would replace the API's handlers and disable its existing loggers.
|
||||
if config.cmd_opts is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
|
|
@ -24,7 +23,6 @@ from wrolpi.collections.models import Collection, CollectionItem # noqa: F401
|
|||
from modules.videos.models import Video, Channel # noqa: F401
|
||||
from modules.archive.models import Archive # noqa: F401
|
||||
from modules.zim.models import Zim, TagZimEntry, ZimSubscription # noqa: F401
|
||||
from modules.repos.models import Repository # noqa: F401
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
|
@ -98,10 +96,7 @@ def run_migrations_online():
|
|||
|
||||
connectable = create_wrolpi_engine(get_url())
|
||||
|
||||
# Autocommit: each migration statement is its own transaction. A migration may call WROLPi
|
||||
# functions which write on their own connection; a migration connection holding an open read
|
||||
# transaction could not then upgrade to a writer ("database is locked").
|
||||
with connectable.connect().execution_options(isolation_level='AUTOCOMMIT') as connection:
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
"""Maintain collection.item_count/total_size for Domain Collections with triggers.
|
||||
|
||||
Listing Domain Collections summed file_group.size through an archive join on every request,
|
||||
one random page read per archive. The new triggers keep the summary columns current
|
||||
(as the channel triggers already do for Channels); this migration installs them and
|
||||
backfills existing Domain Collections.
|
||||
|
||||
Revision ID: 2026_09_27_1200
|
||||
Revises: 2026_09_20_1200
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '2026_09_27_1200'
|
||||
down_revision = '2026_09_20_1200'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
TRIGGERS = (
|
||||
'archive_insert_collection_summary',
|
||||
'archive_delete_collection_summary',
|
||||
'archive_update_collection_summary',
|
||||
'file_group_size_collection_summary',
|
||||
)
|
||||
|
||||
|
||||
def upgrade():
|
||||
from wrolpi.schema_ddl import install_raw_ddl
|
||||
|
||||
# Idempotent: only the new triggers are created.
|
||||
install_raw_ddl(op.get_bind())
|
||||
|
||||
op.execute('''
|
||||
UPDATE collection SET
|
||||
item_count = (SELECT COUNT(*) FROM archive WHERE collection_id = collection.id),
|
||||
total_size = (SELECT COALESCE(SUM(fg.size), 0) FROM archive a
|
||||
LEFT JOIN file_group fg ON a.file_group_id = fg.id
|
||||
WHERE a.collection_id = collection.id)
|
||||
WHERE kind = 'domain'
|
||||
''')
|
||||
|
||||
|
||||
def downgrade():
|
||||
for name in TRIGGERS:
|
||||
op.execute(f'DROP TRIGGER IF EXISTS {name}')
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
"""Covering indexes for the Collection listing.
|
||||
|
||||
Listing Collections read every channel row (for video_count/total_size) and every download
|
||||
row of those Collections (for the minimum frequency). Both tables store yt-dlp info_json,
|
||||
so their rows are tens of KB and each sits on its own scattered page; from a cold disk cache
|
||||
that was 10+ seconds. These indexes answer both queries without touching a row.
|
||||
|
||||
Revision ID: 2026_09_28_1200
|
||||
Revises: 2026_09_27_1200
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '2026_09_28_1200'
|
||||
down_revision = '2026_09_27_1200'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.execute('CREATE INDEX IF NOT EXISTS channel_collection_summary_idx'
|
||||
' ON channel (collection_id, video_count, total_size)')
|
||||
op.execute('CREATE INDEX IF NOT EXISTS download_collection_frequency_idx'
|
||||
' ON download (collection_id, frequency)')
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.execute('DROP INDEX IF EXISTS channel_collection_summary_idx')
|
||||
op.execute('DROP INDEX IF EXISTS download_collection_frequency_idx')
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
"""Retag existing markdown file groups as text/markdown.
|
||||
|
||||
A refresh skips a group whose files have not changed, so markdown indexed before text/markdown
|
||||
existed would keep the type libmagic gave it (text/plain, or a programming language when the file
|
||||
holds code) and the indexer that type chose. Marking the rows unindexed lets apply_indexers
|
||||
re-index them with TextIndexer. SQLite's LIKE is case-insensitive for ASCII, so `.MD` matches.
|
||||
|
||||
Revision ID: 2026_10_05_1200
|
||||
Revises: 2026_09_28_1200
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '2026_10_05_1200'
|
||||
down_revision = '2026_09_28_1200'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.execute("""
|
||||
UPDATE file_group SET mimetype = 'text/markdown', "indexed" = 0
|
||||
WHERE (primary_path LIKE '%.md' OR primary_path LIKE '%.markdown')
|
||||
AND mimetype LIKE 'text/%'
|
||||
AND mimetype != 'text/markdown'
|
||||
""")
|
||||
|
||||
|
||||
def downgrade():
|
||||
# The original types are not recorded; text/plain is what libmagic reports for most markdown.
|
||||
op.execute("""UPDATE file_group SET mimetype = 'text/plain', "indexed" = 0 WHERE mimetype = 'text/markdown'""")
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
"""Add the repository table for git Repos (Collections with kind='repo').
|
||||
|
||||
Revision ID: 2026_10_06_1200
|
||||
Revises: 2026_10_05_1200
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
import wrolpi.dates
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '2026_10_06_1200'
|
||||
down_revision = '2026_10_05_1200'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
'repository',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('url', sa.String(), nullable=False),
|
||||
sa.Column('host', sa.String(), nullable=True),
|
||||
sa.Column('owner', sa.String(), nullable=True),
|
||||
sa.Column('mode', sa.String(), nullable=False),
|
||||
sa.Column('branch', sa.String(), nullable=True),
|
||||
sa.Column('default_branch', sa.String(), nullable=True),
|
||||
sa.Column('head_sha', sa.String(), nullable=True),
|
||||
sa.Column('head_date', wrolpi.dates.TZDateTime(), nullable=True),
|
||||
sa.Column('head_message', sa.Text(), nullable=True),
|
||||
sa.Column('last_fetch', wrolpi.dates.TZDateTime(), nullable=True),
|
||||
sa.Column('size', sa.BigInteger(), nullable=True),
|
||||
sa.Column('readme_path', sa.String(), nullable=True),
|
||||
sa.Column('collection_id', sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['collection_id'], ['collection.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('collection_id'),
|
||||
sa.UniqueConstraint('url'),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table('repository')
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
"""Search git Repos by name and README: repository.search_name/readme_text and repository_fts.
|
||||
|
||||
Revision ID: 2026_10_06_1300
|
||||
Revises: 2026_10_06_1200
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '2026_10_06_1300'
|
||||
down_revision = '2026_10_06_1200'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
from wrolpi.schema_ddl import install_raw_ddl
|
||||
|
||||
op.add_column('repository', sa.Column('search_name', sa.String(), nullable=True))
|
||||
op.add_column('repository', sa.Column('readme_text', sa.Text(), nullable=True))
|
||||
op.execute('''
|
||||
UPDATE repository SET search_name = TRIM(
|
||||
COALESCE((SELECT name FROM collection WHERE collection.id = repository.collection_id), '')
|
||||
|| ' ' || COALESCE(owner, ''))
|
||||
''')
|
||||
|
||||
# Creates repository_fts and its triggers now that the columns exist. The README text is filled by the next
|
||||
# update of each Repo.
|
||||
install_raw_ddl(op.get_bind())
|
||||
op.execute("INSERT INTO repository_fts(repository_fts) VALUES('rebuild')")
|
||||
|
||||
|
||||
def downgrade():
|
||||
for name in ('repository_fts_ai', 'repository_fts_ad', 'repository_fts_au'):
|
||||
op.execute(f'DROP TRIGGER IF EXISTS {name}')
|
||||
op.execute('DROP TABLE IF EXISTS repository_fts')
|
||||
with op.batch_alter_table('repository') as batch_op:
|
||||
batch_op.drop_column('readme_text')
|
||||
batch_op.drop_column('search_name')
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
"""A git Repo can also download its submodules: repository.submodules.
|
||||
|
||||
Revision ID: 2026_10_06_1400
|
||||
Revises: 2026_10_06_1300
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '2026_10_06_1400'
|
||||
down_revision = '2026_10_06_1300'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column('repository', sa.Column('submodules', sa.Boolean(), server_default=sa.false(), nullable=False))
|
||||
|
||||
|
||||
def downgrade():
|
||||
with op.batch_alter_table('repository') as batch_op:
|
||||
batch_op.drop_column('submodules')
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
"""A git Repo's clone holds a random token: repository.clone_token.
|
||||
|
||||
Revision ID: 2026_10_06_1500
|
||||
Revises: 2026_10_06_1400
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '2026_10_06_1500'
|
||||
down_revision = '2026_10_06_1400'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column('repository', sa.Column('clone_token', sa.String(), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
with op.batch_alter_table('repository') as batch_op:
|
||||
batch_op.drop_column('clone_token')
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
"""A FileGroup records how far the user is through it: file_group.progress and file_group.position.
|
||||
|
||||
Revision ID: 2026_10_08_1200
|
||||
Revises: 2026_10_06_1500
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '2026_10_08_1200'
|
||||
down_revision = '2026_10_06_1500'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column('file_group', sa.Column('progress', sa.Float(), nullable=True))
|
||||
op.add_column('file_group', sa.Column('position', sa.JSON(), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Not batch_alter_table: rebuilding file_group drops its FTS triggers (see 2026_09_20_1200).
|
||||
op.execute('ALTER TABLE file_group DROP COLUMN position')
|
||||
op.execute('ALTER TABLE file_group DROP COLUMN progress')
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
"""Each database config's version, so an import can tell whether the database or the file is newer
|
||||
(see wrolpi.config_version).
|
||||
|
||||
Revision ID: 2026_10_24_1200
|
||||
Revises: 2026_10_08_1200
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '2026_10_24_1200'
|
||||
down_revision = '2026_10_08_1200'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# No rows: a config's next import (without deletes) records its version.
|
||||
op.create_table(
|
||||
'config_version',
|
||||
sa.Column('name', sa.String(), primary_key=True),
|
||||
sa.Column('version', sa.Integer(), nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table('config_version')
|
||||
|
|
@ -1,175 +0,0 @@
|
|||
"""Tag names have no leading or trailing whitespace.
|
||||
|
||||
A Tag whose name had whitespace is renamed. Tags whose names are the same once stripped are merged into one: it
|
||||
keeps every tagged file, Zim entry and Collection, and the first color (by Tag id) of the merged Tags. Downloads
|
||||
and the config files that name Tags are rewritten the same way, so the next import finds them in step with the
|
||||
database (an import of the old names would create the padded Tags again, or delete the stripped ones).
|
||||
|
||||
Revision ID: 2026_10_25_1200
|
||||
Revises: 2026_10_24_1200
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
|
||||
import yaml
|
||||
from alembic import op
|
||||
|
||||
from wrolpi.common import get_media_directory, write_config_data
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '2026_10_25_1200'
|
||||
down_revision = '2026_10_24_1200'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# The configs which name Tags.
|
||||
CONFIG_FILES = ('tags.yaml', 'download_manager.yaml', 'channels.yaml', 'domains.yaml', 'playlists.yaml',
|
||||
'repos.yaml', 'videos_downloader.yaml', 'archives_downloader.yaml')
|
||||
|
||||
|
||||
def _strip(name):
|
||||
"""A Tag name without its whitespace; a name that would be empty is kept (it cannot be renamed)."""
|
||||
if isinstance(name, str) and name.strip():
|
||||
return name.strip()
|
||||
return name
|
||||
|
||||
|
||||
def _strip_names(names: list) -> list:
|
||||
stripped = list()
|
||||
for name in map(_strip, names):
|
||||
if name not in stripped:
|
||||
stripped.append(name)
|
||||
return stripped
|
||||
|
||||
|
||||
def _strip_nested(data):
|
||||
"""Strip the Tag names in a config's (or a Download's settings') `tag_name`, `tag_names` and
|
||||
`channel_tag_name` values, at any depth."""
|
||||
if isinstance(data, list):
|
||||
return [_strip_nested(i) for i in data]
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
result = dict()
|
||||
for key, value in data.items():
|
||||
if key == 'tag_name':
|
||||
value = _strip(value)
|
||||
elif key in ('tag_names', 'channel_tag_name') and isinstance(value, list):
|
||||
value = _strip_names(value)
|
||||
else:
|
||||
value = _strip_nested(value)
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _strip_rows(rows: list, key_length: int) -> list:
|
||||
"""tags.yaml's tag_files or tag_zims, each row keyed by its first `key_length` values (the Tag name first)."""
|
||||
result, seen = list(), set()
|
||||
for row in rows:
|
||||
if isinstance(row, list) and row:
|
||||
row = [_strip(row[0]), *row[1:]]
|
||||
key = json.dumps(row[:key_length], default=str)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
result.append(row)
|
||||
return result
|
||||
|
||||
|
||||
def _strip_tags_config(config: dict, colors: dict) -> dict:
|
||||
"""`colors`: each renamed Tag's color in the database, which the file must match (an import copies the file's
|
||||
color into the database)."""
|
||||
config = dict(config)
|
||||
if isinstance(config.get('tags'), dict):
|
||||
tags = dict()
|
||||
for name, attrs in config['tags'].items():
|
||||
stripped = _strip(name)
|
||||
if stripped not in tags:
|
||||
tags[stripped] = attrs
|
||||
elif isinstance(attrs, dict) and attrs.get('color') and not (tags[stripped] or dict()).get('color'):
|
||||
# The first color of the merged Tags.
|
||||
tags[stripped] = {**(tags[stripped] or dict()), 'color': attrs['color']}
|
||||
for name in tags.keys() & colors.keys():
|
||||
tags[name] = {**(tags[name] or dict()), 'color': colors[name]}
|
||||
config['tags'] = tags
|
||||
if isinstance(config.get('tag_files'), list):
|
||||
config['tag_files'] = _strip_rows(config['tag_files'], 2)
|
||||
if isinstance(config.get('tag_zims'), list):
|
||||
config['tag_zims'] = _strip_rows(config['tag_zims'], 3)
|
||||
return config
|
||||
|
||||
|
||||
def strip_tag_names_in_configs(colors: dict):
|
||||
config_directory = get_media_directory() / 'config'
|
||||
for file_name in CONFIG_FILES:
|
||||
file = config_directory / file_name
|
||||
if not file.is_file():
|
||||
continue
|
||||
try:
|
||||
with file.open('rt') as fh:
|
||||
config = yaml.load(fh, Loader=yaml.CSafeLoader)
|
||||
except Exception as e:
|
||||
logger.error(f'Cannot strip Tag names of invalid config: {file}', exc_info=e)
|
||||
continue
|
||||
if not isinstance(config, dict):
|
||||
continue
|
||||
stripped = _strip_tags_config(config, colors) if file_name == 'tags.yaml' else config
|
||||
stripped = _strip_nested(stripped)
|
||||
if stripped != config:
|
||||
logger.warning(f'Stripping whitespace from Tag names in {file}')
|
||||
write_config_data(stripped, file, width=500 if file_name == 'tags.yaml' else 90)
|
||||
|
||||
|
||||
def strip_tag_names_in_db(connection) -> dict:
|
||||
"""Returns the color of each renamed Tag, by its new name."""
|
||||
groups, colors = dict(), dict()
|
||||
for id_, name, color in connection.exec_driver_sql('SELECT id, name, color FROM tag ORDER BY id').fetchall():
|
||||
groups.setdefault(_strip(name), list()).append((id_, name, color))
|
||||
|
||||
for stripped, tags in groups.items():
|
||||
if all(name == stripped for _, name, _ in tags):
|
||||
continue
|
||||
# Keep the Tag already named this, if any, otherwise the oldest.
|
||||
survivor_id = next((id_ for id_, name, _ in tags if name == stripped), tags[0][0])
|
||||
color = colors[stripped] = next((color for _, _, color in tags if color), None)
|
||||
for id_, name, _ in tags:
|
||||
if id_ == survivor_id:
|
||||
continue
|
||||
logger.warning(f'Merging Tag {name!r} into {stripped!r}')
|
||||
connection.exec_driver_sql(
|
||||
'INSERT OR IGNORE INTO tag_file (tag_id, file_group_id, created_at)'
|
||||
' SELECT ?, file_group_id, created_at FROM tag_file WHERE tag_id = ?', (survivor_id, id_))
|
||||
connection.exec_driver_sql(
|
||||
'INSERT OR IGNORE INTO tag_zim (tag_id, zim_id, zim_entry, created_at)'
|
||||
' SELECT ?, zim_id, zim_entry, created_at FROM tag_zim WHERE tag_id = ?', (survivor_id, id_))
|
||||
connection.exec_driver_sql('DELETE FROM tag_file WHERE tag_id = ?', (id_,))
|
||||
connection.exec_driver_sql('DELETE FROM tag_zim WHERE tag_id = ?', (id_,))
|
||||
connection.exec_driver_sql('UPDATE collection SET tag_id = ? WHERE tag_id = ?', (survivor_id, id_))
|
||||
connection.exec_driver_sql('DELETE FROM tag WHERE id = ?', (id_,))
|
||||
logger.warning(f'Renaming Tag to {stripped!r}')
|
||||
connection.exec_driver_sql('UPDATE tag SET name = ?, color = ? WHERE id = ?', (stripped, color, survivor_id))
|
||||
|
||||
# The Downloads which apply them (download_manager.yaml is stripped the same way).
|
||||
downloads = connection.exec_driver_sql(
|
||||
'SELECT id, tag_names, settings FROM download WHERE tag_names IS NOT NULL OR settings IS NOT NULL').fetchall()
|
||||
for id_, tag_names, settings in downloads:
|
||||
tag_names, settings = json.loads(tag_names) if tag_names else None, json.loads(settings) if settings else None
|
||||
new_tag_names = _strip_names(tag_names) if isinstance(tag_names, list) else tag_names
|
||||
new_settings = _strip_nested(settings)
|
||||
if new_tag_names != tag_names or new_settings != settings:
|
||||
connection.exec_driver_sql(
|
||||
'UPDATE download SET tag_names = ?, settings = ? WHERE id = ?',
|
||||
(json.dumps(new_tag_names) if new_tag_names is not None else None,
|
||||
json.dumps(new_settings) if new_settings is not None else None, id_))
|
||||
return colors
|
||||
|
||||
|
||||
def upgrade():
|
||||
colors = strip_tag_names_in_db(op.get_bind())
|
||||
strip_tag_names_in_configs(colors)
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Whitespace that was stripped is not restored.
|
||||
pass
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* The six Special Directories fields on /admin/settings.
|
||||
* The five Special Directories fields on /admin/settings.
|
||||
*
|
||||
* These are the fields that shipped printing their own prefix over their own value, and the
|
||||
* ones whose visible labels were never associated with their inputs. Both faults were
|
||||
|
|
@ -19,7 +19,6 @@ describe('Special Directories settings', () => {
|
|||
['Map Directory', 'map'],
|
||||
['Zims Directory', 'zims'],
|
||||
['Playlists Directory', 'playlists'],
|
||||
['Repos Directory', 'repos/%(repo_tag)s/%(repo_name)s'],
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -39,8 +38,8 @@ describe('Special Directories settings', () => {
|
|||
|
||||
it('names every directory field, so they can be told apart', () => {
|
||||
/*
|
||||
* All six fields sit in one grid, hold similar-looking relative paths, and share the
|
||||
* same prefix. Without a name attached to the input, a screen reader user hears six
|
||||
* All five fields sit in one grid, hold similar-looking relative paths, and share the
|
||||
* same prefix. Without a name attached to the input, a screen reader user hears five
|
||||
* fields described only as "/media/wrolpi/" and cannot tell which one saves videos.
|
||||
*/
|
||||
directories.forEach(([label, value]) => {
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ import React from "react";
|
|||
|
||||
Cypress.on('uncaught:exception', (err, runnable) => {
|
||||
if (err.message.includes('ChunkLoadError')) {
|
||||
return false; // Ignore this error which happens after CI tests.
|
||||
return false; // Ignore this error which happens after CircleCI tests.
|
||||
}
|
||||
// Otherwise, fail like normal
|
||||
return true;
|
||||
|
|
|
|||
654
app/package-lock.json
generated
654
app/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -9,30 +9,28 @@
|
|||
"@mantine/core": "^8.3.18",
|
||||
"@mantine/hooks": "^8.3.18",
|
||||
"@mantine/notifications": "^8.3.18",
|
||||
"@tabler/icons-react": "^3.49.0",
|
||||
"@tabler/icons-react": "^3.46.0",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^14.6.7",
|
||||
"@testing-library/user-event": "^14.6.6",
|
||||
"cypress": "^15.21.1",
|
||||
"dompurify": "^3.4.17",
|
||||
"esptool-js": "^0.7.0",
|
||||
"esptool-js": "^0.6.1",
|
||||
"lodash": "^4.18.1",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"markdown-it": "^15.0.3",
|
||||
"mathjs": "^15.2.0",
|
||||
"pmtiles": "^4.5.0",
|
||||
"protomaps-themes-base": "^4.5.0",
|
||||
"query-string": "^9.5.1",
|
||||
"react": "^18.3.1",
|
||||
"react-colorful": "^5.8.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-dropzone": "^14.4.1",
|
||||
"react": "^18.2.0",
|
||||
"react-colorful": "^5.8.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-hotkeys-hook": "^5.3.3",
|
||||
"react-qr-code": "^2.2.0",
|
||||
"react-router": "^7.18.4",
|
||||
"react-router": "^7.18.2",
|
||||
"react-scripts": "5.0.1",
|
||||
"react-stl-viewer": "^2.5.0",
|
||||
"three": "^0.186",
|
||||
"web-vitals": "^6.2.3"
|
||||
"three": "^0.185",
|
||||
"web-vitals": "^5.3.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
|
|
@ -60,10 +58,10 @@
|
|||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/dom": "^10.4.2",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@types/lodash": "^4.17.25",
|
||||
"@types/node": "^26.6.4",
|
||||
"@types/node": "^26.4.0",
|
||||
"@types/react": "^18.3.17",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"typescript": "^5.9.3",
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
Solar climatology for the WROLPi Solar calculator
|
||||
|
||||
Source: NASA POWER, https://power.larc.nasa.gov/
|
||||
Data: 2001-2020 monthly climatology of ALLSKY_SFC_SW_DWN, ALLSKY_SFC_SW_DIFF, ALLSKY_SRF_ALB, T2M, on a 1° grid.
|
||||
The data was obtained from the POWER Project's POWER Climatology API v2.10.0 version on 2026/10/04.
|
||||
|
||||
The data was obtained from National Aeronautics and Space Administration (NASA) Langley Research Center's Prediction Of Worldwide Energy Resources (POWER) project funded through the NASA Earth Science Division.
|
||||
|
||||
License: Creative Commons Attribution 4.0 International (CC BY 4.0), https://creativecommons.org/licenses/by/4.0/
|
||||
|
||||
Changes: WROLPi modified the data: values are rounded to fit one byte each (0.04 kWh/m²/day for irradiance, 0.004 for albedo, 0.5 °C for temperature), temperatures are averaged from NASA's 0.5° x 0.625° grid into 1° cells, and the calculator interpolates between cells.
|
||||
Binary file not shown.
|
|
@ -242,8 +242,6 @@ either expressed or implied, of the FreeBSD Project. -->
|
|||
// Optional search term passed in from a search-result deep link. If present we'll
|
||||
// search the spine item at `loc` and jump to + highlight the first match.
|
||||
let searchQuery = (params && params.get("q")) ? params.get("q") : null;
|
||||
// Where the reader left off (an EPUB CFI). A search result's `loc` wins over it.
|
||||
let startCfi = (params && params.get("cfi")) ? params.get("cfi") : null;
|
||||
|
||||
let book = ePub(url);
|
||||
let rendition = book.renderTo("viewer", {
|
||||
|
|
@ -252,33 +250,7 @@ either expressed or implied, of the FreeBSD Project. -->
|
|||
height: "100%"
|
||||
});
|
||||
|
||||
rendition.display(currentSectionIndex !== undefined ? currentSectionIndex : (startCfi || undefined));
|
||||
|
||||
// How far through the book `location` is, from its chapter and its page within that chapter. Cheaper
|
||||
// than `book.locations.generate()`, which stalls a Raspberry Pi on a large book.
|
||||
function readingProgress(location) {
|
||||
if (location.atEnd) {
|
||||
return 1;
|
||||
}
|
||||
const start = location.start;
|
||||
const sections = book.spine.length || 1;
|
||||
const displayed = start.displayed || {page: 1, total: 1};
|
||||
const withinSection = (displayed.page - 1) / Math.max(displayed.total, 1);
|
||||
return Math.min((start.index + withinSection) / sections, 1);
|
||||
}
|
||||
|
||||
// Tell the WROLPi page that embeds this viewer where the reader is, so it can save their place. Nothing is
|
||||
// posted at the start of the book: that is where a finished book opens, and saving it would un-finish it.
|
||||
function postLocation(location) {
|
||||
if (window.parent === window || !location || !location.start || location.atStart) {
|
||||
return;
|
||||
}
|
||||
window.parent.postMessage({
|
||||
type: "wrolpi:epub-location",
|
||||
cfi: location.start.cfi,
|
||||
progress: readingProgress(location),
|
||||
}, window.location.origin);
|
||||
}
|
||||
rendition.display(currentSectionIndex);
|
||||
|
||||
// --- In-book search ---------------------------------------------------
|
||||
// State is module-level so the toolbar buttons can drive it. `hits` is the
|
||||
|
|
@ -544,7 +516,6 @@ either expressed or implied, of the FreeBSD Project. -->
|
|||
prev.style.visibility = "visible";
|
||||
}
|
||||
|
||||
postLocation(location);
|
||||
});
|
||||
|
||||
rendition.on("layout", function (layout) {
|
||||
|
|
|
|||
116
app/src/App.css
116
app/src/App.css
|
|
@ -316,90 +316,6 @@ pre.wrap-text {
|
|||
height: 100%;
|
||||
}
|
||||
|
||||
/* A markdown file preview, formatted or raw. Scrolls rather than fitting like .preview-fit, because
|
||||
it is a document to read, not media to frame. Colors come from the theme tokens. */
|
||||
.markdown-preview {
|
||||
height: calc(100dvh - 21rem);
|
||||
min-height: 15rem;
|
||||
overflow: auto;
|
||||
margin-top: 0.5em;
|
||||
padding: 0 0.25em;
|
||||
}
|
||||
|
||||
/* A repo's README is part of its page, not a modal; it grows with its content. */
|
||||
.repo-readme .markdown-preview {
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.markdown-raw {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.markdown-body {
|
||||
max-width: 50rem;
|
||||
line-height: 1.6;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.markdown-body > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.markdown-body h1, .markdown-body h2 {
|
||||
padding-bottom: 0.25em;
|
||||
border-bottom: 1px solid var(--table-line);
|
||||
}
|
||||
|
||||
.markdown-body img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.markdown-body blockquote {
|
||||
margin: 0 0 1em;
|
||||
padding: 0 1em;
|
||||
color: var(--muted);
|
||||
border-left: 0.25em solid var(--border);
|
||||
}
|
||||
|
||||
.markdown-body code {
|
||||
padding: 0.1em 0.3em;
|
||||
border-radius: 0.25em;
|
||||
background-color: var(--head);
|
||||
}
|
||||
|
||||
.markdown-body pre {
|
||||
padding: 0.75em 1em;
|
||||
overflow-x: auto;
|
||||
border-radius: 0.25em;
|
||||
background-color: var(--head);
|
||||
}
|
||||
|
||||
.markdown-body pre code {
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.markdown-body table {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.markdown-body th, .markdown-body td {
|
||||
padding: 0.3em 0.75em;
|
||||
border: 1px solid var(--table-line);
|
||||
}
|
||||
|
||||
.markdown-body hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
td.file-path {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
|
@ -809,35 +725,3 @@ tr.drag-deselecting td {
|
|||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* Map popups (POI details and pins) follow the active theme's tokens. */
|
||||
/* Scoped under .maplibregl-popup to outrank maplibre-gl.css, which loads after this file. */
|
||||
.maplibregl-popup .maplibregl-popup-content {
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.maplibregl-popup .maplibregl-popup-close-button {
|
||||
color: var(--muted);
|
||||
}
|
||||
.maplibregl-popup .maplibregl-popup-close-button:hover {
|
||||
background: var(--head);
|
||||
color: var(--text);
|
||||
}
|
||||
.maplibregl-popup.maplibregl-popup-anchor-top .maplibregl-popup-tip,
|
||||
.maplibregl-popup.maplibregl-popup-anchor-top-left .maplibregl-popup-tip,
|
||||
.maplibregl-popup.maplibregl-popup-anchor-top-right .maplibregl-popup-tip {
|
||||
border-bottom-color: var(--panel);
|
||||
}
|
||||
.maplibregl-popup.maplibregl-popup-anchor-bottom .maplibregl-popup-tip,
|
||||
.maplibregl-popup.maplibregl-popup-anchor-bottom-left .maplibregl-popup-tip,
|
||||
.maplibregl-popup.maplibregl-popup-anchor-bottom-right .maplibregl-popup-tip {
|
||||
border-top-color: var(--panel);
|
||||
}
|
||||
.maplibregl-popup.maplibregl-popup-anchor-left .maplibregl-popup-tip {
|
||||
border-right-color: var(--panel);
|
||||
}
|
||||
.maplibregl-popup.maplibregl-popup-anchor-right .maplibregl-popup-tip {
|
||||
border-left-color: var(--panel);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ import {ArchiveRoute} from "./components/Archive";
|
|||
import {FilesRoute} from "./components/Files";
|
||||
import {QueryProvider, StatusProvider} from "./hooks/customHooks";
|
||||
import {FileWorkerStatusProvider} from "./contexts/FileWorkerStatusContext";
|
||||
import {BookmarksProvider} from "./contexts/BookmarksContext";
|
||||
import {ChannelEditPage, ChannelNewPage, ChannelsPage} from "./components/Channels";
|
||||
import {MapRoute} from "./components/Map";
|
||||
import {MediaContextProvider, mediaStyles, StatusContext} from "./contexts/contexts";
|
||||
|
|
@ -30,9 +29,9 @@ import {FilePreviewProvider} from "./components/FilePreview";
|
|||
import {TagsProvider} from "./Tags";
|
||||
import {ZimRoute} from "./components/Zim";
|
||||
import {PlaylistsRoute} from "./components/Playlists";
|
||||
import {ReposRoute} from "./components/Repos";
|
||||
import {DocsRoute} from "./components/Docs";
|
||||
import {FlasherRoute} from "./components/Flasher";
|
||||
import {AiRoute} from "./components/Ai";
|
||||
import ErrorBoundary from "./components/ErrorBoundary";
|
||||
import {KeyboardShortcutsProvider} from "./components/KeyboardShortcutsProvider";
|
||||
|
||||
|
|
@ -71,15 +70,13 @@ function Root() {
|
|||
<TagsProvider>
|
||||
<KeyboardShortcutsProvider>
|
||||
<FilePreviewProvider>
|
||||
<BookmarksProvider>
|
||||
<header>
|
||||
<NavBar/>
|
||||
</header>
|
||||
<ErrorBoundary>
|
||||
<Outlet/>
|
||||
</ErrorBoundary>
|
||||
<Footer/>
|
||||
</BookmarksProvider>
|
||||
<header>
|
||||
<NavBar/>
|
||||
</header>
|
||||
<ErrorBoundary>
|
||||
<Outlet/>
|
||||
</ErrorBoundary>
|
||||
<Footer/>
|
||||
</FilePreviewProvider>
|
||||
</KeyboardShortcutsProvider>
|
||||
</TagsProvider>
|
||||
|
|
@ -87,12 +84,10 @@ function Root() {
|
|||
</QueryProvider>
|
||||
}
|
||||
|
||||
export const routes = createRoutesFromElements(<Route
|
||||
const router = createBrowserRouter(createRoutesFromElements(<Route
|
||||
path='/'
|
||||
element={<Root/>}
|
||||
// Root (and its ThemeProvider) is not rendered when Root itself fails, so the error page
|
||||
// brings its own theme; Mantine components cannot render without one.
|
||||
errorElement={<ThemeProvider><PageNotFound/></ThemeProvider>}
|
||||
errorElement={<PageNotFound/>}
|
||||
>
|
||||
<Route index element={<ErrorBoundary><DashboardPage/></ErrorBoundary>}/>
|
||||
<Route path='search/*' element={<ErrorBoundary><DashboardPage/></ErrorBoundary>}/>
|
||||
|
|
@ -120,14 +115,10 @@ export const routes = createRoutesFromElements(<Route
|
|||
<Route path='map/*' element={<ErrorBoundary><MapRoute/></ErrorBoundary>}/>
|
||||
<Route path='zim/*' element={<ErrorBoundary><ZimRoute/></ErrorBoundary>}/>
|
||||
<Route path='playlists/*' element={<ErrorBoundary><PlaylistsRoute/></ErrorBoundary>}/>
|
||||
<Route path='repos/*' element={<ErrorBoundary><ReposRoute/></ErrorBoundary>}/>
|
||||
<Route path='files/*' element={<ErrorBoundary><FilesRoute/></ErrorBoundary>}/>
|
||||
<Route path='flasher/*' element={<ErrorBoundary><FlasherRoute/></ErrorBoundary>}/>
|
||||
{/* Unknown URLs render inside Root, with the navigation bar and theme. */}
|
||||
<Route path='*' element={<PageNotFound/>}/>
|
||||
</Route>);
|
||||
|
||||
const router = createBrowserRouter(routes);
|
||||
<Route path='ai/*' element={<ErrorBoundary><AiRoute/></ErrorBoundary>}/>
|
||||
</Route>));
|
||||
|
||||
export default function App() {
|
||||
useEventsInterval();
|
||||
|
|
|
|||
|
|
@ -1,52 +0,0 @@
|
|||
import React from 'react';
|
||||
import {render, screen} from '@testing-library/react';
|
||||
import {createMemoryRouter, RouterProvider} from 'react-router';
|
||||
import {routes} from './App';
|
||||
import {StatusProvider} from './hooks/customHooks';
|
||||
import {FileWorkerStatusProvider} from './contexts/FileWorkerStatusContext';
|
||||
import {MediaContextProvider} from './contexts/contexts';
|
||||
|
||||
// Jest does not transform CSS inside node_modules (see transformIgnorePatterns).
|
||||
jest.mock('@mantine/core/styles.css', () => ({}));
|
||||
jest.mock('@mantine/notifications/styles.css', () => ({}));
|
||||
// Routes this test does not visit, whose libraries jsdom/Jest cannot load (maplibre needs
|
||||
// browser APIs; esptool-js is ESM-only).
|
||||
jest.mock('./components/Map', () => ({MapRoute: () => null}));
|
||||
jest.mock('./components/Flasher', () => ({FlasherRoute: () => null}));
|
||||
|
||||
describe('unknown routes', () => {
|
||||
beforeEach(() => {
|
||||
// resetMocks clears setupTests' implementation; the theme and media providers read it.
|
||||
window.matchMedia.mockImplementation(query => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
}));
|
||||
global.fetch = jest.fn(() => Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: {get: () => 'application/json'},
|
||||
json: () => Promise.resolve({tags: []}),
|
||||
text: () => Promise.resolve('{}'),
|
||||
}));
|
||||
});
|
||||
|
||||
it('renders Page Not Found inside the app layout', async () => {
|
||||
const router = createMemoryRouter(routes, {initialEntries: ['/no-such-page']});
|
||||
// The providers App wraps around the router.
|
||||
render(<StatusProvider>
|
||||
<FileWorkerStatusProvider>
|
||||
<MediaContextProvider>
|
||||
<RouterProvider router={router}/>
|
||||
</MediaContextProvider>
|
||||
</FileWorkerStatusProvider>
|
||||
</StatusProvider>);
|
||||
|
||||
expect(await screen.findByText('Page Not Found!')).toBeInTheDocument();
|
||||
// Rendered inside Root's layout (the footer comes from Root), not as a bare error page.
|
||||
expect(screen.getByText('Donate')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -5,7 +5,6 @@ import {
|
|||
PageContainer,
|
||||
RefreshHeader,
|
||||
SearchResultsInput,
|
||||
useLocalStorage,
|
||||
useTitle
|
||||
} from "./components/Common";
|
||||
import React, {useContext, useState} from "react";
|
||||
|
|
@ -15,7 +14,6 @@ import {DownloadMenu} from "./components/Download";
|
|||
import {
|
||||
ActionInput,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Divider,
|
||||
Header,
|
||||
Icon,
|
||||
|
|
@ -252,8 +250,6 @@ export function Getters() {
|
|||
</div>
|
||||
<span className='wrolpi-or-label'>Or</span>
|
||||
</div>
|
||||
{status?.flags && gettersDisabled &&
|
||||
<p>Download and Upload are available after your files are refreshed.</p>}
|
||||
</Panel>;
|
||||
|
||||
let getterModal;
|
||||
|
|
@ -355,14 +351,7 @@ export function DashboardStatus() {
|
|||
}
|
||||
|
||||
function DashboardRecentFiles() {
|
||||
// Show only the files the user is part way through.
|
||||
const [continueOnly, setContinueOnly] = useLocalStorage('dashboard_recent_continue', false);
|
||||
const {searchFiles, loading, fetchFiles} = useSearchRecentFiles(continueOnly);
|
||||
|
||||
let files = <FileCards files={searchFiles} loading={loading}/>;
|
||||
if (continueOnly && searchFiles && searchFiles.length === 0) {
|
||||
files = <p>Nothing in progress. A video, audio file, ebook or comic you stop part way through will be here.</p>;
|
||||
}
|
||||
const {searchFiles, loading, fetchFiles} = useSearchRecentFiles();
|
||||
|
||||
return <Panel>
|
||||
<RefreshHeader
|
||||
|
|
@ -370,15 +359,7 @@ function DashboardRecentFiles() {
|
|||
popupContents='Fetch the most recent files again'
|
||||
onRefresh={fetchFiles}
|
||||
/>
|
||||
<ButtonGroup>
|
||||
<Button size='small' variant={continueOnly ? 'default' : 'filled'} onClick={() => setContinueOnly(false)}>
|
||||
All
|
||||
</Button>
|
||||
<Button size='small' variant={continueOnly ? 'filled' : 'default'} onClick={() => setContinueOnly(true)}>
|
||||
Continue
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<div style={{marginTop: '1em'}}>{files}</div>
|
||||
<FileCards files={searchFiles} loading={loading}/>
|
||||
</Panel>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,11 +21,11 @@ jest.mock('./hooks/customHooks', () => ({
|
|||
useWROLMode: () => false,
|
||||
}));
|
||||
|
||||
function renderAtUrl(url, flags = {refresh_complete: true}) {
|
||||
function renderAtUrl(url) {
|
||||
// test-utils' render() wraps content in a BrowserRouter (not MemoryRouter),
|
||||
// so point the jsdom location at the desired URL before rendering.
|
||||
window.history.pushState({}, '', url);
|
||||
const status = {flags};
|
||||
const status = {flags: {refresh_complete: true}};
|
||||
return render(
|
||||
<StatusContext.Provider value={{status}}>
|
||||
<Getters/>
|
||||
|
|
@ -67,25 +67,6 @@ describe('DashboardPage Getters deep-link', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('DashboardPage Getters before the first refresh', () => {
|
||||
const note = /available after your files are refreshed/i;
|
||||
|
||||
it('explains why Download and Upload are disabled', () => {
|
||||
renderAtUrl('/', {refresh_complete: false});
|
||||
expect(screen.getByText(note)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('says nothing once the refresh has completed', () => {
|
||||
renderAtUrl('/');
|
||||
expect(screen.queryByText(note)).toBeNull();
|
||||
});
|
||||
|
||||
it('says nothing before the status has loaded', () => {
|
||||
renderAtUrl('/', null);
|
||||
expect(screen.queryByText(note)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// The DownloadMenu's seed-object construction (joining initialUrls with
|
||||
// newlines into the `download` prop's `urls` field) is exercised in the
|
||||
// browser via manual verification, not unit-tested here — reaching into the
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ function eventToast(title, description, type = 'info', time = 5000, onClick = nu
|
|||
toast({type: type, title: title, description: description, time: time, onClick: onClick});
|
||||
}
|
||||
|
||||
export function handleEvents(events) {
|
||||
function handleEvents(events) {
|
||||
const newestEvents = {};
|
||||
|
||||
events.forEach(e => {
|
||||
|
|
@ -63,17 +63,13 @@ export function handleEvents(events) {
|
|||
}
|
||||
|
||||
if (event === 'user_notify_message') {
|
||||
// Only a shared page carries a URL; other notifications (jobs, transcodes) are text.
|
||||
if (url) {
|
||||
eventToast(
|
||||
message,
|
||||
'Click here to view the shared page',
|
||||
'info',
|
||||
10_000,
|
||||
() => window.open(url, '_self'));
|
||||
} else {
|
||||
eventToast(message, null, 'info', 10_000);
|
||||
}
|
||||
console.log(message, url);
|
||||
eventToast(
|
||||
message,
|
||||
'Click here to view the shared page',
|
||||
'info',
|
||||
10_000,
|
||||
() => window.open(url, '_self'));
|
||||
}
|
||||
|
||||
if (event === 'deleted') {
|
||||
|
|
|
|||
|
|
@ -1,33 +0,0 @@
|
|||
import {handleEvents} from './Events';
|
||||
import {toast} from './components/ui';
|
||||
|
||||
jest.mock('./components/ui', () => ({toast: jest.fn()}));
|
||||
|
||||
const notify = (url) => ({event: 'user_notify_message', message: 'Transcoding video.mp4', dt: '2026-10-12', url});
|
||||
|
||||
afterEach(() => {
|
||||
toast.mockClear();
|
||||
});
|
||||
|
||||
describe('user_notify_message', () => {
|
||||
it('a notification without a URL is plain text, not a link', () => {
|
||||
handleEvents([notify(null)]);
|
||||
|
||||
expect(toast).toHaveBeenCalledTimes(1);
|
||||
const options = toast.mock.calls[0][0];
|
||||
expect(options.title).toBe('Transcoding video.mp4');
|
||||
expect(options.description).toBeFalsy();
|
||||
expect(options.onClick).toBeFalsy();
|
||||
});
|
||||
|
||||
it('a shared page opens its URL when clicked', () => {
|
||||
const open = jest.spyOn(window, 'open').mockImplementation(() => null);
|
||||
handleEvents([notify('/archive/1')]);
|
||||
|
||||
const options = toast.mock.calls[0][0];
|
||||
expect(options.description).toBe('Click here to view the shared page');
|
||||
options.onClick();
|
||||
expect(open).toHaveBeenCalledWith('/archive/1', '_self');
|
||||
open.mockRestore();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
import {
|
||||
flasherSearch, getConfigBackups, getDownloaders, getInventoryBackups, getMapSearchStatus, searchMap,
|
||||
} from './api';
|
||||
|
||||
// A helper that fails must say so. These used to turn a non-OK response into an empty value
|
||||
// ([], {dates: []}, {results: []}, ...), so every consumer rendered "nothing here" for a server
|
||||
// error. They now return undefined; an empty value only ever comes from the server.
|
||||
|
||||
jest.mock('./components/ui', () => ({
|
||||
...jest.requireActual('./components/ui'),
|
||||
toast: jest.fn(),
|
||||
}));
|
||||
|
||||
const response = (ok, body = {}) => {
|
||||
const r = {
|
||||
ok,
|
||||
status: ok ? 200 : 500,
|
||||
json: async () => body,
|
||||
text: async () => JSON.stringify(body),
|
||||
};
|
||||
r.clone = () => r;
|
||||
return r;
|
||||
};
|
||||
|
||||
let originalFetch;
|
||||
beforeEach(() => {
|
||||
originalFetch = global.fetch;
|
||||
});
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
const failWith = () => {
|
||||
global.fetch = jest.fn(() => Promise.resolve(response(false, {error: 'boom'})));
|
||||
};
|
||||
const succeedWith = (body) => {
|
||||
global.fetch = jest.fn(() => Promise.resolve(response(true, body)));
|
||||
};
|
||||
|
||||
describe('on a non-OK response, the helper returns undefined', () => {
|
||||
test.each([
|
||||
['getConfigBackups', () => getConfigBackups('wrolpi.yaml')],
|
||||
['getInventoryBackups', () => getInventoryBackups('food')],
|
||||
['getDownloaders', () => getDownloaders()],
|
||||
['searchMap', () => searchMap('camp')],
|
||||
['getMapSearchStatus', () => getMapSearchStatus()],
|
||||
['flasherSearch', () => flasherSearch(null, null)],
|
||||
])('%s', async (_name, call) => {
|
||||
failWith();
|
||||
expect(await call()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('a confirmed empty result is still empty', () => {
|
||||
test('getConfigBackups', async () => {
|
||||
succeedWith({dates: []});
|
||||
expect(await getConfigBackups('wrolpi.yaml')).toEqual({dates: []});
|
||||
});
|
||||
|
||||
test('getInventoryBackups', async () => {
|
||||
succeedWith({dates: []});
|
||||
expect(await getInventoryBackups('food')).toEqual([]);
|
||||
});
|
||||
|
||||
test('getDownloaders', async () => {
|
||||
succeedWith({downloaders: []});
|
||||
expect(await getDownloaders()).toEqual({downloaders: []});
|
||||
});
|
||||
|
||||
test('searchMap', async () => {
|
||||
succeedWith({results: [], total: 0});
|
||||
expect(await searchMap('camp')).toEqual({results: [], total: 0});
|
||||
});
|
||||
|
||||
test('getMapSearchStatus', async () => {
|
||||
succeedWith({indexed: [], missing: []});
|
||||
expect(await getMapSearchStatus()).toEqual({indexed: [], missing: []});
|
||||
});
|
||||
|
||||
test('flasherSearch', async () => {
|
||||
succeedWith({file_groups: [], totals: {file_groups: 0}});
|
||||
expect(await flasherSearch(null, null)).toEqual([[], 0]);
|
||||
});
|
||||
});
|
||||
222
app/src/api.js
222
app/src/api.js
|
|
@ -7,7 +7,6 @@ import {
|
|||
COLLECTIONS_API,
|
||||
DEFAULT_LIMIT,
|
||||
Downloaders,
|
||||
REPOS_API,
|
||||
VIDEOS_API,
|
||||
ZIM_API
|
||||
} from "./components/Vars";
|
||||
|
|
@ -407,7 +406,6 @@ export async function getSettings() {
|
|||
nav_color: content.nav_color,
|
||||
media_directory: content.media_directory,
|
||||
playlists_destination: content.playlists_destination,
|
||||
repos_destination: content.repos_destination,
|
||||
require_cookies_unlocked: content.require_cookies_unlocked,
|
||||
require_media_mounted: content.require_media_mounted,
|
||||
save_ffprobe_json: content.save_ffprobe_json,
|
||||
|
|
@ -524,7 +522,7 @@ export async function getConfigBackups(fileName) {
|
|||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
// Undefined, not {dates: []}, so the modal can tell a failed fetch from having no backups.
|
||||
return {dates: []};
|
||||
}
|
||||
|
||||
export async function postConfigBackupPreview(fileName, backupDate, mode) {
|
||||
|
|
@ -722,7 +720,7 @@ export async function getInventoryBackups(slug) {
|
|||
if (response.ok) {
|
||||
return (await response.json())['dates'];
|
||||
}
|
||||
// Undefined, not [], so the modal can tell a failed fetch from having no backups.
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function postInventoryRestorePreview(slug, backupDate, mode) {
|
||||
|
|
@ -929,21 +927,6 @@ export async function getCollectionTagInfo(collectionId, tagName) {
|
|||
}
|
||||
}
|
||||
|
||||
// Tag any Collection (and optionally move its directory).
|
||||
export async function tagCollection(collectionId, tagName, directory) {
|
||||
const body = {tag_name: tagName};
|
||||
if (directory) {
|
||||
body['directory'] = directory;
|
||||
}
|
||||
const response = await apiPost(`${COLLECTIONS_API}/${collectionId}/tag`, body);
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
const message = await getErrorMessage(response, 'Unable to tag. See server logs.');
|
||||
toast({type: 'error', title: 'Tag Error', description: message, time: 5000});
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
export async function tagDomain(domainId, tagName, directory) {
|
||||
const body = {
|
||||
tag_name: tagName,
|
||||
|
|
@ -1276,15 +1259,12 @@ export async function startDownloads() {
|
|||
}
|
||||
|
||||
export async function getDownloaders() {
|
||||
// Not toasting because this will happen often. Undefined on any failure, not an empty list, so
|
||||
// the form can say the downloaders could not be fetched.
|
||||
try {
|
||||
const response = await apiGet(`${API_URI}/downloaders`);
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
// Not toasting because this will happen often.
|
||||
return await response.json();
|
||||
} catch (e) {
|
||||
// The API is down; reported as undefined below.
|
||||
return {downloaders: []};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1305,7 +1285,7 @@ export async function deleteDownload(downloadId) {
|
|||
}
|
||||
}
|
||||
|
||||
export async function filesSearch(offset, limit, searchStr, mimetypes, model, tagNames, headline, months, fromYear, toYear, anyTag, order, suffix, path, deep, inProgress) {
|
||||
export async function filesSearch(offset, limit, searchStr, mimetypes, model, tagNames, headline, months, fromYear, toYear, anyTag, order, suffix, path, deep) {
|
||||
const body = {search_str: searchStr, offset: parseInt(offset), limit: parseInt(limit), any_tag: anyTag};
|
||||
if (suffix) {
|
||||
body['suffix'] = suffix;
|
||||
|
|
@ -1340,9 +1320,6 @@ export async function filesSearch(offset, limit, searchStr, mimetypes, model, ta
|
|||
if (deep) {
|
||||
body['deep'] = true;
|
||||
}
|
||||
if (inProgress) {
|
||||
body['in_progress'] = true;
|
||||
}
|
||||
console.info('searching files', body);
|
||||
const response = await apiPost(`${API_URI}/files/search`, body);
|
||||
|
||||
|
|
@ -1374,7 +1351,7 @@ export async function flasherSearch(chip, path) {
|
|||
}
|
||||
const message = await getErrorMessage(response, 'Cannot search firmware. See server logs.');
|
||||
toast({type: 'error', title: 'Unable to search firmware', description: message, time: 5000});
|
||||
// Undefined, not [null, null], so the picker can tell a failed search from finding nothing.
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
export async function getFlasherConfigs() {
|
||||
|
|
@ -1384,9 +1361,7 @@ export async function getFlasherConfigs() {
|
|||
const data = await response.json();
|
||||
return data['configurations'] || [];
|
||||
}
|
||||
// Undefined, not [], so the page can tell a failed fetch from an empty list.
|
||||
const message = await getErrorMessage(response, 'Could not get saved firmware configurations');
|
||||
toast({type: 'error', title: 'Flasher Error', description: message, time: 5000});
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function saveFlasherConfig(name, files, eraseAll) {
|
||||
|
|
@ -1681,8 +1656,7 @@ export async function searchMap(query, limit = 12, offset = 0, lat = null, lon =
|
|||
}
|
||||
const response = await apiGet(url);
|
||||
if (!response.ok) {
|
||||
// Undefined, not {results: []}, so a failed search is not shown as finding nothing.
|
||||
return undefined;
|
||||
return {results: [], total: 0};
|
||||
}
|
||||
return await response.json();
|
||||
}
|
||||
|
|
@ -1690,8 +1664,7 @@ export async function searchMap(query, limit = 12, offset = 0, lat = null, lon =
|
|||
export async function getMapSearchStatus() {
|
||||
const response = await apiGet(`${API_URI}/map/search/status`);
|
||||
if (!response.ok) {
|
||||
// Undefined, not {indexed: [], missing: []}, which read as "no maps are installed".
|
||||
return undefined;
|
||||
return {indexed: [], missing: []};
|
||||
}
|
||||
return await response.json();
|
||||
}
|
||||
|
|
@ -2071,7 +2044,6 @@ export async function searchSuggestions(search_str) {
|
|||
domains: content.domains,
|
||||
authors: content.authors || [],
|
||||
subjects: content.subjects || [],
|
||||
repos: content.repos || [],
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to get file search suggestions!');
|
||||
|
|
@ -2110,8 +2082,8 @@ export async function searchEstimateZims(search_str, tagNames) {
|
|||
}
|
||||
}
|
||||
|
||||
export async function searchEstimateOthers(tagNames, searchStr) {
|
||||
const body = {tag_names: tagNames, search_str: searchStr || null};
|
||||
export async function searchEstimateOthers(tagNames) {
|
||||
const body = {tag_names: tagNames};
|
||||
const response = await apiPost(`${API_URI}/search_other_estimates`, body);
|
||||
if (response.ok) {
|
||||
const content = await response.json();
|
||||
|
|
@ -2121,8 +2093,8 @@ export async function searchEstimateOthers(tagNames, searchStr) {
|
|||
}
|
||||
}
|
||||
|
||||
export async function searchChannels(tagNames, searchStr) {
|
||||
const body = {tag_names: tagNames, search_str: searchStr};
|
||||
export async function searchChannels(tagNames) {
|
||||
const body = {tag_names: tagNames};
|
||||
const response = await apiPost(`${API_URI}/videos/channels/search`, body);
|
||||
if (response.ok) {
|
||||
const content = await response.json();
|
||||
|
|
@ -2132,16 +2104,6 @@ export async function searchChannels(tagNames, searchStr) {
|
|||
}
|
||||
}
|
||||
|
||||
// Collections of `kind` whose name matches `searchStr` and/or which have any of the tags.
|
||||
export async function searchCollections(kind, tagNames, searchStr) {
|
||||
const body = {kind, tag_names: tagNames, search_str: searchStr};
|
||||
const response = await apiPost(`${COLLECTIONS_API}/search`, body);
|
||||
if (response.ok) {
|
||||
const content = await response.json();
|
||||
return {collections: content.collections};
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOutdatedZims() {
|
||||
const response = await apiGet(`${API_URI}/zim/outdated`);
|
||||
if (response.status === 200) {
|
||||
|
|
@ -2477,154 +2439,32 @@ export async function setPlaylistTag(playlistId, tagName, directory) {
|
|||
throw Error(message);
|
||||
}
|
||||
|
||||
// Bookmarks: a tree of nodes, a node with `children` being a directory.
|
||||
export async function getBookmarks() {
|
||||
const response = await apiGet(`${API_URI}/bookmarks`);
|
||||
// --- AI assistant ---
|
||||
|
||||
export async function getAiCatalog() {
|
||||
const response = await apiGet(`${API_URI}/ai/manage/catalog`);
|
||||
if (response.ok) {
|
||||
return (await response.json())['bookmarks'];
|
||||
return await response.json();
|
||||
}
|
||||
throw new Error('Could not fetch bookmarks');
|
||||
const message = await getErrorMessage(response, 'Failed to fetch AI catalog.');
|
||||
toast({type: 'error', title: 'Error', description: message, time: 5000});
|
||||
}
|
||||
|
||||
async function bookmarksModify(request, fallbackMessage) {
|
||||
const response = await request;
|
||||
export async function postAiSettings(settings) {
|
||||
const response = await apiPost(`${API_URI}/ai/manage/settings`, settings);
|
||||
if (response.ok) {
|
||||
return response.status === 204 ? null : await response.json();
|
||||
return await response.json();
|
||||
}
|
||||
const message = await getErrorMessage(response, fallbackMessage);
|
||||
toast({type: 'error', title: 'Bookmarks Error', description: message, time: 5000});
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
export async function addBookmark({name, url, new_tab = false, parent_id = null, position = null}) {
|
||||
const body = {name, url, new_tab, parent_id, position};
|
||||
return await bookmarksModify(apiPost(`${API_URI}/bookmarks`, body), 'Could not add bookmark');
|
||||
}
|
||||
|
||||
export async function addBookmarkDirectory({name, parent_id = null, position = null}) {
|
||||
const body = {name, parent_id, position};
|
||||
return await bookmarksModify(apiPost(`${API_URI}/bookmarks/directory`, body), 'Could not add directory');
|
||||
}
|
||||
|
||||
export async function updateBookmark(nodeId, {name, url, new_tab}) {
|
||||
const body = {name, url, new_tab};
|
||||
return await bookmarksModify(apiPut(`${API_URI}/bookmarks/${nodeId}`, body), 'Could not update bookmark');
|
||||
}
|
||||
|
||||
export async function moveBookmark(nodeId, parent_id, position = null) {
|
||||
const body = {parent_id, position};
|
||||
return await bookmarksModify(apiPost(`${API_URI}/bookmarks/${nodeId}/move`, body), 'Could not move bookmark');
|
||||
}
|
||||
|
||||
export async function deleteBookmark(nodeId) {
|
||||
return await bookmarksModify(apiDelete(`${API_URI}/bookmarks/${nodeId}`), 'Could not delete bookmark');
|
||||
}
|
||||
|
||||
async function reposError(response, fallbackMessage) {
|
||||
const message = await getErrorMessage(response, fallbackMessage);
|
||||
toast({type: 'error', title: 'Repos Error', description: message, time: 5000});
|
||||
const message = await getErrorMessage(response, 'Failed to save AI settings.');
|
||||
toast({type: 'error', title: 'Error', description: message, time: 5000});
|
||||
throw Error(message);
|
||||
}
|
||||
|
||||
export async function fetchRepos() {
|
||||
const response = await apiGet(REPOS_API);
|
||||
export async function getChatModes() {
|
||||
const response = await apiGet(`${API_URI}/chat/modes`);
|
||||
if (response.ok) {
|
||||
return (await response.json())['repos'];
|
||||
return (await response.json())['modes'];
|
||||
}
|
||||
await reposError(response, 'Failed to fetch repos.');
|
||||
}
|
||||
|
||||
export async function getRepo(repoId) {
|
||||
const response = await apiGet(`${REPOS_API}/${repoId}`);
|
||||
if (response.ok) {
|
||||
return (await response.json())['repo'];
|
||||
}
|
||||
await reposError(response, 'Failed to fetch repo.');
|
||||
}
|
||||
|
||||
// `repo`: {url, directory?, name?, tag_name?, frequency?, mode?, branch?, description?}
|
||||
export async function createRepo(repo) {
|
||||
const response = await apiPost(REPOS_API, repo);
|
||||
if (response.ok) {
|
||||
return (await response.json())['repo'];
|
||||
}
|
||||
await reposError(response, 'Failed to add repo.');
|
||||
}
|
||||
|
||||
// `changes`: {description?, frequency?, mode?, branch?}. An empty branch follows the remote's default branch.
|
||||
export async function updateRepo(repoId, changes) {
|
||||
const response = await apiPut(`${REPOS_API}/${repoId}`, changes);
|
||||
if (response.ok) {
|
||||
return (await response.json())['repo'];
|
||||
}
|
||||
await reposError(response, 'Failed to update repo.');
|
||||
}
|
||||
|
||||
export async function deleteRepo(repoId, deleteFiles = false) {
|
||||
const response = await apiDelete(`${REPOS_API}/${repoId}?delete_files=${deleteFiles ? 'true' : 'false'}`);
|
||||
if (!response.ok) {
|
||||
await reposError(response, 'Failed to delete repo.');
|
||||
}
|
||||
}
|
||||
|
||||
// Download the repo's updates now.
|
||||
export async function updateRepoNow(repoId) {
|
||||
const response = await apiPost(`${REPOS_API}/${repoId}/update`);
|
||||
if (!response.ok) {
|
||||
await reposError(response, 'Failed to start the repo update.');
|
||||
}
|
||||
}
|
||||
|
||||
// One directory of a repo's files: {path, entries: [{name, path, is_dir, size}], readme_path}.
|
||||
export async function getRepoTree(repoId, path) {
|
||||
const query = path ? `?path=${encodeURIComponent(path)}` : '';
|
||||
const response = await apiGet(`${REPOS_API}/${repoId}/tree${query}`);
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
await reposError(response, 'Failed to list the repo files.');
|
||||
}
|
||||
|
||||
// Repos matching the search (name and README) and/or the tags. Each has an escaped `readme_headline`.
|
||||
export async function searchRepos(searchStr, tagNames) {
|
||||
const body = {search_str: searchStr || null, tag_names: tagNames || []};
|
||||
const response = await apiPost(`${REPOS_API}/search`, body);
|
||||
if (response.ok) {
|
||||
const content = await response.json();
|
||||
return {repos: content.repos, total: content.totals.repos};
|
||||
}
|
||||
await reposError(response, 'Failed to search repos.');
|
||||
}
|
||||
|
||||
// The commits of a repo's checked out branch, newest first: {commits: [{sha, author, date, message}], total}.
|
||||
export async function getRepoLog(repoId, offset = 0, limit = 50) {
|
||||
const response = await apiGet(`${REPOS_API}/${repoId}/log?limit=${limit}&offset=${offset}`);
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
await reposError(response, 'Failed to fetch the repo history.');
|
||||
}
|
||||
|
||||
// A ZIP of a repo's checked out files; the browser downloads it.
|
||||
export function repoArchiveURL(repoId) {
|
||||
return `${REPOS_API}/${repoId}/archive.zip`;
|
||||
}
|
||||
|
||||
// What importing the git clone in `directory` (relative to the media directory) would do. Changes nothing.
|
||||
export async function inspectRepoImport(directory, name, tagName, url) {
|
||||
const body = {directory, name: name || null, tag_name: tagName || null, url: url || null};
|
||||
const response = await apiPost(`${REPOS_API}/import/inspect`, body);
|
||||
if (response.ok) {
|
||||
return {inspection: (await response.json())['inspection']};
|
||||
}
|
||||
return {error: await getErrorMessage(response, 'Cannot import this directory.')};
|
||||
}
|
||||
|
||||
// Import an existing git clone as a Repo; it stays where it is. `repo`: {directory, confirm, url?, name?, ...}
|
||||
export async function importRepo(repo) {
|
||||
const response = await apiPost(`${REPOS_API}/import`, repo);
|
||||
if (response.ok) {
|
||||
return (await response.json())['repo'];
|
||||
}
|
||||
await reposError(response, 'Failed to import the repo.');
|
||||
const message = await getErrorMessage(response, 'Failed to fetch chat modes.');
|
||||
toast({type: 'error', title: 'Error', description: message, time: 5000});
|
||||
}
|
||||
|
|
|
|||
216
app/src/components/Ai.js
Normal file
216
app/src/components/Ai.js
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import React, {useContext, useEffect, useState} from 'react';
|
||||
import {Route, Routes} from 'react-router';
|
||||
|
||||
import {getAiCatalog, postAiSettings, postDownload} from '../api';
|
||||
import {getServiceStatus, startService, stopService} from '../api/controller';
|
||||
import {AiChatProvider} from '../contexts/AiChatContext';
|
||||
import {StatusContext} from '../contexts/contexts';
|
||||
import {AiChat} from './AiChat';
|
||||
import {APIButton, humanFileSize, InfoMessage, PageContainer, TabLinks, useTitle, WROLModeMessage} from './Common';
|
||||
import {SortableTable} from './SortableTable';
|
||||
import {Button, Divider, Group, Header, NumberInput, Select, Table, Text, Toggle, toast} from './ui';
|
||||
import {Downloaders} from './Vars';
|
||||
|
||||
// The Controller's name for llama-server: the compose service in docker, the systemd unit natively.
|
||||
export const llmServiceName = (dockerized) => dockerized ? 'llm' : 'wrolpi-llm';
|
||||
|
||||
export function AiModelRow({model, onDownload}) {
|
||||
const {name, tier, size, description, downloaded, active} = model;
|
||||
|
||||
return <Table.Row>
|
||||
<Table.Cell>
|
||||
<Text fw={active ? 700 : undefined}>{name}{active ? ' (active)' : ''}</Text>
|
||||
{description && <Text size='sm' c='dimmed'>{description}</Text>}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{tier}</Table.Cell>
|
||||
<Table.Cell>{humanFileSize(size)}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{downloaded
|
||||
? <Button size='xs' icon='check' disabled role='save'>
|
||||
{tier === 'custom' ? 'On disk' : 'Downloaded'}
|
||||
</Button>
|
||||
: <APIButton size='xs' icon='download' color='violet' onClick={() => onDownload(model)}>
|
||||
Download
|
||||
</APIButton>}
|
||||
</Table.Cell>
|
||||
</Table.Row>;
|
||||
}
|
||||
|
||||
export function ManageAi() {
|
||||
const [catalog, setCatalog] = useState(null);
|
||||
const [serviceStatus, setServiceStatus] = useState(null);
|
||||
const [pendingSave, setPendingSave] = useState(false);
|
||||
const {status} = useContext(StatusContext);
|
||||
const serviceName = llmServiceName(status?.dockerized);
|
||||
|
||||
const fetchCatalog = async () => {
|
||||
try {
|
||||
const data = await getAiCatalog();
|
||||
if (data) {
|
||||
setCatalog(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchService = async () => {
|
||||
try {
|
||||
setServiceStatus(await getServiceStatus(serviceName));
|
||||
} catch (e) {
|
||||
// The service is unknown until AI is installed; not an error worth toasting.
|
||||
setServiceStatus(null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchCatalog();
|
||||
fetchService();
|
||||
const interval = setInterval(() => {
|
||||
fetchCatalog();
|
||||
fetchService();
|
||||
}, 30000);
|
||||
return () => clearInterval(interval);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [serviceName]);
|
||||
|
||||
const handleDownload = async (model) => {
|
||||
await postDownload({
|
||||
urls: [model.url],
|
||||
downloader: Downloaders.AiModel,
|
||||
destination: 'ai/models',
|
||||
});
|
||||
toast({
|
||||
type: 'success',
|
||||
title: 'Download started',
|
||||
description: 'Monitor progress on the Downloads page.',
|
||||
time: 5000,
|
||||
});
|
||||
};
|
||||
|
||||
const saveSettings = async (settings) => {
|
||||
setPendingSave(true);
|
||||
try {
|
||||
await postAiSettings(settings);
|
||||
await fetchCatalog();
|
||||
} finally {
|
||||
setPendingSave(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleServiceButton = async () => {
|
||||
if (serviceStatus?.status === 'running') {
|
||||
await stopService(serviceName);
|
||||
} else {
|
||||
await startService(serviceName);
|
||||
}
|
||||
await fetchService();
|
||||
};
|
||||
|
||||
if (!catalog) {
|
||||
return <WROLModeMessage content='Cannot modify AI'/>;
|
||||
}
|
||||
|
||||
const {models, recommended_tier, total_ram, slow_hardware, disk_usage, enabled, active_model} = catalog;
|
||||
const downloadedModels = models.filter(i => i.downloaded);
|
||||
|
||||
const modelHeaders = [
|
||||
{key: 'name', text: 'Model', sortBy: i => i.name.toLowerCase()},
|
||||
{key: 'tier', text: 'Tier', sortBy: i => i.tier},
|
||||
{key: 'size', text: 'Size', sortBy: i => i.size || 0},
|
||||
{key: 'actions', text: '', sortBy: null},
|
||||
];
|
||||
|
||||
return <>
|
||||
<WROLModeMessage content='Cannot modify AI'/>
|
||||
|
||||
{slow_hardware && <InfoMessage storageName='hint_ai_slow_hardware'>
|
||||
This hardware will run the assistant slowly. Answers may take minutes; the smallest
|
||||
model is recommended.
|
||||
</InfoMessage>}
|
||||
|
||||
<Header as='h3'>AI Assistant</Header>
|
||||
<Group gap='lg'>
|
||||
<Toggle
|
||||
label='Enabled'
|
||||
checked={enabled === true}
|
||||
disabled={pendingSave || !active_model}
|
||||
onChange={e => saveSettings({enabled: e.target.checked})}
|
||||
/>
|
||||
<Select
|
||||
label='Active Model'
|
||||
data={downloadedModels.map(i => ({value: i.name, label: i.name}))}
|
||||
value={active_model || null}
|
||||
placeholder={downloadedModels.length ? 'Select a model' : 'Download a model below'}
|
||||
disabled={pendingSave || !downloadedModels.length}
|
||||
onChange={value => value && saveSettings({active_model: value})}
|
||||
/>
|
||||
<NumberInput
|
||||
label='Idle unload (minutes)'
|
||||
min={1}
|
||||
value={catalog.idle_unload_minutes}
|
||||
disabled={pendingSave}
|
||||
onChange={value => value && saveSettings({idle_unload_minutes: value})}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{enabled && <Group gap='xs' style={{marginTop: '1em'}}>
|
||||
<Text>Service: {serviceStatus?.status || 'unknown'}</Text>
|
||||
<APIButton
|
||||
size='xs'
|
||||
color={serviceStatus?.status === 'running' ? undefined : 'violet'}
|
||||
role={serviceStatus?.status === 'running' ? 'danger' : undefined}
|
||||
onClick={handleServiceButton}
|
||||
>
|
||||
{serviceStatus?.status === 'running' ? 'Stop' : 'Start'}
|
||||
</APIButton>
|
||||
</Group>}
|
||||
|
||||
<Divider/>
|
||||
|
||||
<Header as='h3'>Model Catalog</Header>
|
||||
<Text size='sm'>
|
||||
This device has {humanFileSize(total_ram)} of RAM;
|
||||
the <b>{recommended_tier}</b> tier is recommended.
|
||||
Models use {humanFileSize(disk_usage)} in <b>ai/models/</b>.
|
||||
</Text>
|
||||
<SortableTable
|
||||
tableHeaders={modelHeaders}
|
||||
data={models}
|
||||
rowFunc={(model) => <AiModelRow key={model.name} model={model} onDownload={handleDownload}/>}
|
||||
rowKey='name'
|
||||
defaultSortColumn='size'
|
||||
/>
|
||||
|
||||
<InfoMessage storageName='hint_ai_models'>
|
||||
<p>The AI assistant runs entirely on this WROLPi — no internet is needed after a model
|
||||
is downloaded. Download a model, select it, then enable the assistant. Monitor
|
||||
download progress on the <b>Downloads</b> page.</p>
|
||||
<p>You can also copy your own GGUF file into <b>ai/models/</b>; it will appear here as a
|
||||
<b>custom</b> model the next time this page loads. Use a plain ASCII file name without
|
||||
<b>#</b>, quotes, or <b>..</b>. The model must support tool calling (Qwen3, Llama 3.x,
|
||||
and Mistral instruct models do). Selecting a custom model resets the context size to the
|
||||
default; set <b>context_size</b> in <b>ai.yaml</b> to override it.</p>
|
||||
</InfoMessage>
|
||||
</>;
|
||||
}
|
||||
|
||||
export function AiRoute() {
|
||||
useTitle('AI');
|
||||
|
||||
const links = [
|
||||
{text: 'Chat', to: '/ai', key: 'chat', end: true},
|
||||
{text: 'Manage', to: '/ai/manage', key: 'manage'},
|
||||
];
|
||||
|
||||
// The provider wraps both tabs so the conversation survives visiting Manage.
|
||||
return <AiChatProvider>
|
||||
<PageContainer>
|
||||
<TabLinks links={links}/>
|
||||
<Routes>
|
||||
<Route path='/' exact element={<AiChat/>}/>
|
||||
<Route path='manage' exact element={<ManageAi/>}/>
|
||||
</Routes>
|
||||
</PageContainer>
|
||||
</AiChatProvider>;
|
||||
}
|
||||
130
app/src/components/Ai.test.js
Normal file
130
app/src/components/Ai.test.js
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import React from 'react';
|
||||
import {fireEvent, screen, waitFor} from '@testing-library/react';
|
||||
|
||||
import {render} from '../test-utils';
|
||||
import {AiModelRow, ManageAi, llmServiceName} from './Ai';
|
||||
import {Table} from './ui';
|
||||
|
||||
jest.mock('../api', () => ({
|
||||
getAiCatalog: jest.fn(),
|
||||
postAiSettings: jest.fn().mockResolvedValue({}),
|
||||
postDownload: jest.fn().mockResolvedValue({}),
|
||||
}));
|
||||
jest.mock('../api/controller', () => ({
|
||||
getServiceStatus: jest.fn().mockResolvedValue({name: 'wrolpi-llm', status: 'stopped'}),
|
||||
startService: jest.fn().mockResolvedValue({}),
|
||||
stopService: jest.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
const {getAiCatalog, postAiSettings, postDownload} = require('../api');
|
||||
|
||||
const catalogFixture = (overrides = {}) => ({
|
||||
models: [
|
||||
{
|
||||
name: 'Qwen3-1.7B-Q4_K_M.gguf', tier: 'small', size: 1_200_000_000,
|
||||
url: 'https://cdn.example.com/ai/Qwen3-1.7B-Q4_K_M.gguf',
|
||||
description: 'Small model.', downloaded: true, active: true,
|
||||
},
|
||||
{
|
||||
name: 'Qwen3-4B-Instruct-2507-Q4_K_M.gguf', tier: 'medium', size: 2_600_000_000,
|
||||
url: 'https://cdn.example.com/ai/Qwen3-4B-Instruct-2507-Q4_K_M.gguf',
|
||||
description: 'Medium model.', downloaded: false, active: false,
|
||||
},
|
||||
],
|
||||
catalog_source: 'bundled',
|
||||
total_ram: 8 * 1024 ** 3,
|
||||
recommended_tier: 'medium',
|
||||
slow_hardware: false,
|
||||
models_directory: '/media/wrolpi/ai/models',
|
||||
disk_usage: 1_200_000_000,
|
||||
enabled: false,
|
||||
active_model: 'Qwen3-1.7B-Q4_K_M.gguf',
|
||||
idle_unload_minutes: 15,
|
||||
context_size: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('llmServiceName', () => {
|
||||
it('is the compose service in docker, the systemd unit natively', () => {
|
||||
expect(llmServiceName(true)).toBe('llm');
|
||||
expect(llmServiceName(false)).toBe('wrolpi-llm');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AiModelRow', () => {
|
||||
const renderRow = (model, onDownload = jest.fn()) => render(
|
||||
<Table><Table.Body><AiModelRow model={model} onDownload={onDownload}/></Table.Body></Table>);
|
||||
|
||||
it('shows a downloaded, active model without a download button', () => {
|
||||
renderRow(catalogFixture().models[0]);
|
||||
expect(screen.getByText(/Qwen3-1.7B-Q4_K_M.gguf \(active\)/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Downloaded')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Download')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers a download for a model that is not downloaded', async () => {
|
||||
const onDownload = jest.fn();
|
||||
renderRow(catalogFixture().models[1], onDownload);
|
||||
fireEvent.click(screen.getByText('Download'));
|
||||
await waitFor(() => expect(onDownload).toHaveBeenCalled());
|
||||
expect(onDownload.mock.calls[0][0].name).toBe('Qwen3-4B-Instruct-2507-Q4_K_M.gguf');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ManageAi', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
getAiCatalog.mockResolvedValue(catalogFixture());
|
||||
});
|
||||
|
||||
it('renders the catalog with the RAM recommendation', async () => {
|
||||
render(<ManageAi/>);
|
||||
await waitFor(() => expect(screen.getByText(/tier is recommended/)).toBeInTheDocument());
|
||||
// The active model appears in both the Select and its catalog row.
|
||||
expect(screen.getAllByText(/Qwen3-1.7B-Q4_K_M.gguf/).length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText(/of RAM/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('starts a model download with the ai_model downloader', async () => {
|
||||
render(<ManageAi/>);
|
||||
await waitFor(() => expect(screen.getByText('Download')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByText('Download'));
|
||||
await waitFor(() => expect(postDownload).toHaveBeenCalled());
|
||||
expect(postDownload).toHaveBeenCalledWith({
|
||||
urls: ['https://cdn.example.com/ai/Qwen3-4B-Instruct-2507-Q4_K_M.gguf'],
|
||||
downloader: 'ai_model',
|
||||
destination: 'ai/models',
|
||||
});
|
||||
});
|
||||
|
||||
it('saves the enabled toggle', async () => {
|
||||
render(<ManageAi/>);
|
||||
await waitFor(() => expect(screen.getByLabelText('Enabled')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByLabelText('Enabled'));
|
||||
await waitFor(() => expect(postAiSettings).toHaveBeenCalledWith({enabled: true}));
|
||||
});
|
||||
|
||||
it('lists a custom GGUF from ai/models in the Active Model dropdown', async () => {
|
||||
const custom = {
|
||||
name: 'My-Custom-Model-Q8_0.gguf', tier: 'custom', size: 3_000_000_000, url: null,
|
||||
description: 'Custom model found in ai/models/. Not from the WROLPi catalog.',
|
||||
downloaded: true, active: false,
|
||||
};
|
||||
const fixture = catalogFixture();
|
||||
getAiCatalog.mockResolvedValue({...fixture, models: [...fixture.models, custom]});
|
||||
render(<ManageAi/>);
|
||||
await waitFor(() => expect(screen.getByText('On disk')).toBeInTheDocument());
|
||||
expect(screen.getByRole('cell', {name: 'custom'})).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('textbox', {name: 'Active Model'}));
|
||||
const option = await screen.findByRole('option', {name: 'My-Custom-Model-Q8_0.gguf'});
|
||||
fireEvent.click(option);
|
||||
await waitFor(() => expect(postAiSettings).toHaveBeenCalledWith({active_model: 'My-Custom-Model-Q8_0.gguf'}));
|
||||
});
|
||||
|
||||
it('shows the slow-hardware warning on a Pi 4', async () => {
|
||||
getAiCatalog.mockResolvedValue(catalogFixture({slow_hardware: true}));
|
||||
render(<ManageAi/>);
|
||||
await waitFor(() => expect(screen.getByText(/run the assistant slowly/)).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
154
app/src/components/AiChat.js
Normal file
154
app/src/components/AiChat.js
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import React, {useEffect, useRef, useState} from 'react';
|
||||
import {Link} from 'react-router';
|
||||
|
||||
import {getAiCatalog, getChatModes} from '../api';
|
||||
import {useAiChat} from '../contexts/AiChatContext';
|
||||
import {InfoMessage} from './Common';
|
||||
import {Button, Group, Icon, Loader, Text, TextInput} from './ui';
|
||||
|
||||
const MODE_LABELS = {help: 'Help', research: 'Research', system: 'System'};
|
||||
const MODE_DESCRIPTIONS = {
|
||||
help: 'Learn how to use WROLPi',
|
||||
research: 'Find and summarize library content',
|
||||
system: 'Troubleshoot this WROLPi',
|
||||
};
|
||||
|
||||
// Render an assistant message with WROLPi links clickable. The model only ever returns
|
||||
// relative links (/videos/123), which the server computed — never external URLs.
|
||||
export function LinkifiedText({content}) {
|
||||
const parts = String(content || '').split(/(\/[a-zA-Z0-9_\-./?=&%#]+)/g);
|
||||
return <span style={{whiteSpace: 'pre-wrap', overflowWrap: 'anywhere'}}>
|
||||
{parts.map((part, i) => part.startsWith('/') && part.length > 1
|
||||
? <Link key={i} to={part}>{part}</Link>
|
||||
: part)}
|
||||
</span>;
|
||||
}
|
||||
|
||||
function ToolActivity({activity}) {
|
||||
if (!activity || !activity.length) {
|
||||
return null;
|
||||
}
|
||||
return <div>
|
||||
{activity.map((item, i) => <Text key={i} size='sm' c='dimmed'>
|
||||
{item.pending
|
||||
? <Loader size='xs'/>
|
||||
: <Icon name={item.success ? 'check' : 'warning circle'}/>}
|
||||
{' '}using {String(item.tool || '').replace(/_/g, ' ')}
|
||||
</Text>)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function ChatMessage({message}) {
|
||||
if (message.role === 'user') {
|
||||
return <div style={{textAlign: 'right', margin: '0.5em 0'}}>
|
||||
<span style={{
|
||||
display: 'inline-block', padding: '0.5em 0.8em', borderRadius: 8,
|
||||
background: 'var(--panel)', maxWidth: '85%', textAlign: 'left',
|
||||
}}>{message.content}</span>
|
||||
</div>;
|
||||
}
|
||||
return <div style={{margin: '0.5em 0'}}>
|
||||
{message.status && <Text size='sm' c='dimmed'>{message.status}</Text>}
|
||||
<ToolActivity activity={message.activity}/>
|
||||
{message.content && <LinkifiedText content={message.content}/>}
|
||||
{message.streaming && !message.content && !message.activity?.length && <Loader size='xs'/>}
|
||||
{message.error && <Text style={{color: 'var(--danger)'}}>{message.error}</Text>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function AiChat() {
|
||||
const {mode, setMode, messages, sending, send, stop, disabledCode} = useAiChat();
|
||||
const [modes, setModes] = useState([]);
|
||||
const [slowHardware, setSlowHardware] = useState(false);
|
||||
const [aiEnabled, setAiEnabled] = useState(null);
|
||||
const [input, setInput] = useState('');
|
||||
const bottomRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
getChatModes().then(i => i && setModes(i)).catch(console.error);
|
||||
getAiCatalog().then(catalog => {
|
||||
if (catalog) {
|
||||
setSlowHardware(catalog.slow_hardware === true);
|
||||
setAiEnabled(Boolean(catalog.enabled && catalog.active_model));
|
||||
}
|
||||
}).catch(console.error);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView?.({behavior: 'smooth'});
|
||||
}, [messages]);
|
||||
|
||||
const handleSend = async (content) => {
|
||||
setInput('');
|
||||
await send(content);
|
||||
};
|
||||
|
||||
const notReady = aiEnabled === false || disabledCode === 'AI_DISABLED';
|
||||
if (notReady) {
|
||||
return <InfoMessage>
|
||||
The AI assistant is not ready. Download a model and enable it on
|
||||
the <Link to='/ai/manage'>Manage</Link> tab.
|
||||
</InfoMessage>;
|
||||
}
|
||||
|
||||
const suggestions = (modes.find(i => i.name === mode) || {}).suggestions || [];
|
||||
|
||||
return <>
|
||||
{slowHardware && <InfoMessage storageName='hint_ai_chat_slow'>
|
||||
This hardware runs the assistant slowly — answers can take minutes.
|
||||
</InfoMessage>}
|
||||
|
||||
<Group gap='xs'>
|
||||
{(modes.length ? modes.map(i => i.name) : Object.keys(MODE_LABELS)).map(name =>
|
||||
<Button
|
||||
key={name}
|
||||
size='sm'
|
||||
color={mode === name ? 'violet' : undefined}
|
||||
aria-pressed={mode === name}
|
||||
onClick={() => setMode(name)}
|
||||
>
|
||||
{MODE_LABELS[name] || name}
|
||||
</Button>)}
|
||||
{mode && <Text size='sm' c='dimmed'>{MODE_DESCRIPTIONS[mode]}</Text>}
|
||||
</Group>
|
||||
|
||||
{!mode && <Text style={{marginTop: '1em'}}>
|
||||
Pick a mode to start: <b>Help</b> teaches WROLPi, <b>Research</b> searches your
|
||||
library, <b>System</b> troubleshoots this device.
|
||||
</Text>}
|
||||
|
||||
{mode && <>
|
||||
<div style={{minHeight: '40vh', margin: '1em 0'}}>
|
||||
{messages.length === 0 && <Group gap='xs'>
|
||||
{suggestions.map(suggestion =>
|
||||
<Button key={suggestion} size='xs' onClick={() => handleSend(suggestion)}>
|
||||
{suggestion}
|
||||
</Button>)}
|
||||
</Group>}
|
||||
{messages.map((message, i) => <ChatMessage key={i} message={message}/>)}
|
||||
<div ref={bottomRef}/>
|
||||
</div>
|
||||
|
||||
<form onSubmit={e => {
|
||||
e.preventDefault();
|
||||
if (!sending) {
|
||||
handleSend(input.trim());
|
||||
}
|
||||
}}>
|
||||
<Group gap='xs' wrap='nowrap'>
|
||||
<TextInput
|
||||
style={{flexGrow: 1}}
|
||||
placeholder='Ask the assistant…'
|
||||
value={input}
|
||||
disabled={sending}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
aria-label='Chat message'
|
||||
/>
|
||||
{sending
|
||||
? <Button role='cancel' onClick={stop}>Stop</Button>
|
||||
: <Button type='submit' color='violet' disabled={!input.trim()}>Send</Button>}
|
||||
</Group>
|
||||
</form>
|
||||
</>}
|
||||
</>;
|
||||
}
|
||||
121
app/src/components/AiChat.test.js
Normal file
121
app/src/components/AiChat.test.js
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import React from 'react';
|
||||
import {fireEvent, screen, waitFor} from '@testing-library/react';
|
||||
|
||||
import {render} from '../test-utils';
|
||||
import {AiChatProvider} from '../contexts/AiChatContext';
|
||||
import {parseSSEBlock} from '../sse';
|
||||
import {AiChat, LinkifiedText} from './AiChat';
|
||||
|
||||
jest.mock('../api', () => ({
|
||||
getChatModes: jest.fn().mockResolvedValue([
|
||||
{name: 'help', suggestions: ['How do I download videos?']},
|
||||
{name: 'research', suggestions: ['Find videos about canning']},
|
||||
{name: 'system', suggestions: ['Why is my drive full?']},
|
||||
]),
|
||||
getAiCatalog: jest.fn().mockResolvedValue({
|
||||
enabled: true, active_model: 'test.gguf', slow_hardware: false,
|
||||
}),
|
||||
}));
|
||||
jest.mock('../sse', () => ({
|
||||
...jest.requireActual('../sse'),
|
||||
streamChat: jest.fn(),
|
||||
}));
|
||||
|
||||
const {getAiCatalog, getChatModes} = require('../api');
|
||||
const {streamChat} = require('../sse');
|
||||
|
||||
const renderChat = () => render(<AiChatProvider><AiChat/></AiChatProvider>);
|
||||
|
||||
describe('parseSSEBlock', () => {
|
||||
it('parses an event and its JSON data', () => {
|
||||
expect(parseSSEBlock('event: token\ndata: {"content": "hi"}'))
|
||||
.toEqual({event: 'token', data: {content: 'hi'}});
|
||||
expect(parseSSEBlock('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('LinkifiedText', () => {
|
||||
it('makes relative WROLPi links clickable', () => {
|
||||
render(<LinkifiedText content='See /videos/123 for details'/>);
|
||||
const link = screen.getByRole('link', {name: '/videos/123'});
|
||||
expect(link).toHaveAttribute('href', '/videos/123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AiChat', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
getChatModes.mockResolvedValue([
|
||||
{name: 'help', suggestions: ['How do I download videos?']},
|
||||
{name: 'research', suggestions: ['Find videos about canning']},
|
||||
{name: 'system', suggestions: ['Why is my drive full?']},
|
||||
]);
|
||||
getAiCatalog.mockResolvedValue({enabled: true, active_model: 'test.gguf', slow_hardware: false});
|
||||
});
|
||||
|
||||
it('starts with the mode picker; picking a mode shows its suggestion chips', async () => {
|
||||
renderChat();
|
||||
await waitFor(() => expect(screen.getByRole('button', {name: 'Research'})).toBeInTheDocument());
|
||||
expect(screen.getByText(/Pick a mode/)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {name: 'Research'}));
|
||||
await waitFor(() => expect(screen.getByText('Find videos about canning')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('streams tokens and tool activity into the conversation', async () => {
|
||||
streamChat.mockImplementation(async ({mode, messages}, onEvent) => {
|
||||
expect(mode).toBe('research');
|
||||
expect(messages[messages.length - 1]).toEqual({role: 'user', content: 'find canning'});
|
||||
onEvent('tool_call', {tool: 'search_files', args: {search_str: 'canning', kind: 'video'}});
|
||||
onEvent('tool_result', {tool: 'search_files', success: true});
|
||||
onEvent('token', {content: 'One video: '});
|
||||
onEvent('token', {content: '/videos/123'});
|
||||
onEvent('done', {content: 'One video: /videos/123'});
|
||||
});
|
||||
|
||||
renderChat();
|
||||
await waitFor(() => expect(screen.getByRole('button', {name: 'Research'})).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole('button', {name: 'Research'}));
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Chat message'), {target: {value: 'find canning'}});
|
||||
fireEvent.click(screen.getByText('Send'));
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/using search files/)).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByRole('link', {name: '/videos/123'})).toBeInTheDocument());
|
||||
expect(streamChat).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('switching modes clears the conversation', async () => {
|
||||
streamChat.mockImplementation(async (_, onEvent) => {
|
||||
onEvent('done', {content: 'An answer.'});
|
||||
});
|
||||
renderChat();
|
||||
await waitFor(() => expect(screen.getByRole('button', {name: 'Help'})).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole('button', {name: 'Help'}));
|
||||
fireEvent.change(screen.getByLabelText('Chat message'), {target: {value: 'hello'}});
|
||||
fireEvent.click(screen.getByText('Send'));
|
||||
await waitFor(() => expect(screen.getByText('An answer.')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {name: 'System'}));
|
||||
await waitFor(() => expect(screen.queryByText('An answer.')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows an error event without crashing the conversation', async () => {
|
||||
streamChat.mockImplementation(async (_, onEvent) => {
|
||||
onEvent('error', {message: 'The AI failed to answer.'});
|
||||
});
|
||||
renderChat();
|
||||
await waitFor(() => expect(screen.getByRole('button', {name: 'Help'})).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole('button', {name: 'Help'}));
|
||||
fireEvent.change(screen.getByLabelText('Chat message'), {target: {value: 'hello'}});
|
||||
fireEvent.click(screen.getByText('Send'));
|
||||
await waitFor(() => expect(screen.getByText('The AI failed to answer.')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('points at the Manage tab when AI is not enabled', async () => {
|
||||
getAiCatalog.mockResolvedValue({enabled: false, active_model: '', slow_hardware: false});
|
||||
renderChat();
|
||||
await waitFor(() => expect(screen.getByText(/not ready/)).toBeInTheDocument());
|
||||
expect(screen.getByRole('link', {name: 'Manage'})).toHaveAttribute('href', '/ai/manage');
|
||||
});
|
||||
});
|
||||
|
|
@ -12,10 +12,8 @@ import {
|
|||
import {Header, Loading, Panel, Statistic, StatisticGroup} from "./ui";
|
||||
import {useStatistics} from "../hooks/customHooks";
|
||||
import {CalculatorsPage} from "./Calculators";
|
||||
import {BookmarksPage} from "./Bookmarks";
|
||||
|
||||
// `statistics` follows useStatistics: null = pending, undefined = fetch failed.
|
||||
export function StatisticsPage() {
|
||||
function StatisticsPage() {
|
||||
useTitle('Statistics');
|
||||
|
||||
const {statistics} = useStatistics();
|
||||
|
|
@ -27,7 +25,7 @@ export function StatisticsPage() {
|
|||
</>
|
||||
}
|
||||
|
||||
if (statistics && statistics['global_statistics']) {
|
||||
if (statistics['global_statistics']) {
|
||||
let {global_statistics, file_statistics} = statistics;
|
||||
const {
|
||||
archive_count,
|
||||
|
|
@ -106,7 +104,6 @@ export function MoreRoute(props) {
|
|||
<Routes>
|
||||
<Route path='calculators' element={<CalculatorsPage/>}/>
|
||||
<Route path='statistics' exact element={<StatisticsPage/>}/>
|
||||
<Route path='bookmarks' element={<BookmarksPage/>}/>
|
||||
</Routes>
|
||||
</PageContainer>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,453 +0,0 @@
|
|||
import React, {useState} from 'react';
|
||||
import {Link} from 'react-router';
|
||||
import {IconBookmark} from '@tabler/icons-react';
|
||||
import {Button, Group, Header, Icon, Menu, Message, Modal, Panel, Select, Table, Text, TextInput, Toggle} from './ui';
|
||||
import {APIButton, PageContainer, useTitle} from './Common';
|
||||
import {isBookmarkDirectory, resolveBookmarkUrl, useBookmarks} from '../contexts/BookmarksContext';
|
||||
import {addBookmark, addBookmarkDirectory, deleteBookmark, moveBookmark, updateBookmark} from '../api';
|
||||
|
||||
export const EDIT_BOOKMARKS_PATH = '/more/bookmarks';
|
||||
|
||||
/**
|
||||
* The props that make a Menu.Item open a bookmark: an in-app Link for a path, a plain
|
||||
* anchor for everything else, and a new tab when the bookmark asks for one. A URL that
|
||||
* does not resolve (see resolveBookmarkUrl) gets a disabled item, never a raw href.
|
||||
*/
|
||||
function bookmarkItemProps(node) {
|
||||
const {internal, href} = resolveBookmarkUrl(node.url);
|
||||
if (!href) {
|
||||
return {disabled: true};
|
||||
}
|
||||
const target = node.new_tab ? {target: '_blank', rel: 'noopener noreferrer'} : {};
|
||||
if (internal) {
|
||||
return {component: Link, to: href, ...target};
|
||||
}
|
||||
return {component: 'a', href, ...target};
|
||||
}
|
||||
|
||||
/**
|
||||
* The items of a bookmarks menu.
|
||||
*
|
||||
* `nested` draws a directory as a flyout (Menu.Sub), which opens beside the menu on hover.
|
||||
* Without it a directory is a labelled, indented run of items instead: the mobile
|
||||
* hamburger is the one place bookmarks are shown where a flyout cannot be hovered. The
|
||||
* indent is in rem: a label is set smaller than an item, so an em indent would put a
|
||||
* directory's label a few pixels left of the items beside it.
|
||||
*/
|
||||
export function BookmarkMenuItems({nodes, nested = true, depth = 0}) {
|
||||
if (!nodes || nodes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return nodes.map(node => {
|
||||
if (isBookmarkDirectory(node)) {
|
||||
if (nested) {
|
||||
return <Menu.Sub key={node.id}>
|
||||
<Menu.Sub.Target>
|
||||
<Menu.Sub.Item leftSection={<Icon name='folder' size='small'/>}>{node.name}</Menu.Sub.Item>
|
||||
</Menu.Sub.Target>
|
||||
<Menu.Sub.Dropdown>
|
||||
{node.children.length > 0
|
||||
? <BookmarkMenuItems nodes={node.children} nested={nested}/>
|
||||
: <Menu.Item disabled>Empty</Menu.Item>}
|
||||
</Menu.Sub.Dropdown>
|
||||
</Menu.Sub>;
|
||||
}
|
||||
return <React.Fragment key={node.id}>
|
||||
<Menu.Label style={{paddingLeft: `${0.75 + depth}rem`}}>{node.name}</Menu.Label>
|
||||
<BookmarkMenuItems nodes={node.children} nested={false} depth={depth + 1}/>
|
||||
</React.Fragment>;
|
||||
}
|
||||
return <Menu.Item
|
||||
key={node.id}
|
||||
{...bookmarkItemProps(node)}
|
||||
style={nested ? undefined : {paddingLeft: `${0.75 + depth}rem`}}
|
||||
rightSection={node.new_tab ? <Icon name='external' size='small'/> : undefined}
|
||||
>
|
||||
{node.name}
|
||||
</Menu.Item>;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything inside a bookmarks dropdown: the bookmarks, then the link to edit them. The
|
||||
* edit link is always there, so a user with no bookmarks yet can find where to add one.
|
||||
*/
|
||||
export function BookmarksMenuContents({nested = true}) {
|
||||
const {bookmarks} = useBookmarks();
|
||||
const nodes = bookmarks || [];
|
||||
return <>
|
||||
<BookmarkMenuItems nodes={nodes} nested={nested}/>
|
||||
{nodes.length > 0 && <Menu.Divider/>}
|
||||
<Menu.Item component={Link} to={EDIT_BOOKMARKS_PATH} leftSection={<Icon name='edit' size='small'/>}>
|
||||
Edit bookmarks
|
||||
</Menu.Item>
|
||||
</>;
|
||||
}
|
||||
|
||||
/** Every directory in the tree with its full path, for the "Location" picker. */
|
||||
export function directoryOptions(nodes, prefix = '') {
|
||||
const options = [];
|
||||
for (const node of nodes || []) {
|
||||
if (isBookmarkDirectory(node)) {
|
||||
const label = `${prefix}${node.name}`;
|
||||
options.push({value: String(node.id), label, id: node.id});
|
||||
options.push(...directoryOptions(node.children, `${label} / `));
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/** The ids of `node` and everything beneath it. */
|
||||
const subtreeIds = (node) => {
|
||||
const ids = [node.id];
|
||||
if (isBookmarkDirectory(node)) {
|
||||
node.children.forEach(child => ids.push(...subtreeIds(child)));
|
||||
}
|
||||
return ids;
|
||||
};
|
||||
|
||||
const ROOT = 'root';
|
||||
|
||||
/** The places a node may live: the top level, and every directory not inside `node`. */
|
||||
export function locationOptions(nodes, node = null) {
|
||||
// A directory cannot be moved into itself or its own children.
|
||||
const excluded = node ? new Set(subtreeIds(node)) : new Set();
|
||||
return [
|
||||
{value: ROOT, label: 'Top level'},
|
||||
...directoryOptions(nodes).filter(i => !excluded.has(i.id)).map(({value, label}) => ({value, label})),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The form for one node. `node` is null when adding; `directory` says which kind.
|
||||
* `parentId` is where a new node goes, or where an edited one currently lives.
|
||||
*/
|
||||
export function BookmarkNodeModal({open, node, directory, parentId, nodes, onSave, onClose}) {
|
||||
const [name, setName] = useState(node?.name || '');
|
||||
const [url, setUrl] = useState(node?.url || '');
|
||||
const [newTab, setNewTab] = useState(Boolean(node?.new_tab));
|
||||
const [location, setLocation] = useState(parentId === null || parentId === undefined ? ROOT : String(parentId));
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const locations = locationOptions(nodes, node);
|
||||
|
||||
const valid = name.trim() && (directory || url.trim());
|
||||
const title = node ? (directory ? 'Edit directory' : 'Edit bookmark') : (directory ? 'New directory' : 'New bookmark');
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!valid) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave({
|
||||
name: name.trim(),
|
||||
url: url.trim(),
|
||||
new_tab: newTab,
|
||||
parent_id: location === ROOT ? null : Number(location),
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return <Modal open={open} onClose={onClose} size='tiny'>
|
||||
<Modal.Header>{title}</Modal.Header>
|
||||
<Modal.Content>
|
||||
<form onSubmit={e => {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}}>
|
||||
<TextInput
|
||||
autoFocus
|
||||
label='Name'
|
||||
value={name}
|
||||
onChange={e => setName(e.currentTarget.value)}
|
||||
/>
|
||||
{!directory && <TextInput
|
||||
label='URL'
|
||||
style={{marginTop: '1em'}}
|
||||
value={url}
|
||||
onChange={e => setUrl(e.currentTarget.value)}
|
||||
placeholder='/videos, :8096/, or https://example.com'
|
||||
description='A path on this WROLPi (/videos), a port on this host (:8096/ or http://:8096/), or a full URL.'
|
||||
/>}
|
||||
<Select
|
||||
label='Location'
|
||||
style={{marginTop: '1em'}}
|
||||
data={locations}
|
||||
value={location}
|
||||
onChange={value => setLocation(value || ROOT)}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
{!directory && <Toggle
|
||||
style={{marginTop: '1em'}}
|
||||
label='Open in a new tab'
|
||||
checked={newTab}
|
||||
onChange={e => setNewTab(e.currentTarget.checked)}
|
||||
/>}
|
||||
</form>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button role='cancel' onClick={onClose}>Cancel</Button>
|
||||
<Button role='save' disabled={!valid || saving} onClick={handleSave}>Save</Button>
|
||||
</Modal.Actions>
|
||||
</Modal>;
|
||||
}
|
||||
|
||||
/** The rows of the editor, one per node, children beneath their directory and indented. */
|
||||
function BookmarkTableRows({nodes, parentId, depth, editable, actions}) {
|
||||
return nodes.map((node, index) => {
|
||||
const directory = isBookmarkDirectory(node);
|
||||
const {href} = directory ? {} : resolveBookmarkUrl(node.url);
|
||||
return <React.Fragment key={node.id}>
|
||||
<Table.Row>
|
||||
<Table.Cell>{String(index + 1).padStart(2, '0')}</Table.Cell>
|
||||
<Table.Cell style={{width: '90px', textAlign: 'center'}}>
|
||||
{directory
|
||||
? <Icon name='folder' size='large'/>
|
||||
: <Icon name={node.new_tab ? 'external' : 'linkify'} size='large'/>}
|
||||
</Table.Cell>
|
||||
<Table.Cell style={{paddingLeft: depth ? `calc(0.75em + ${depth * 1.5}em)` : undefined}}>
|
||||
{directory && <b>{node.name}</b>}
|
||||
{!directory && (href
|
||||
? <a href={href} target={node.new_tab ? '_blank' : undefined} rel='noopener noreferrer'>
|
||||
{node.name}
|
||||
</a>
|
||||
: <span>{node.name}</span>)}
|
||||
{!directory && <Text size='xs' c='dimmed' truncate='end'>{node.url}</Text>}
|
||||
</Table.Cell>
|
||||
{editable && <Table.Cell>
|
||||
<div className='wrolpi-button-row' style={{justifyContent: 'flex-end'}}>
|
||||
<Button icon='arrow up' size='xs' aria-label={`Move ${node.name} up`} disabled={index === 0}
|
||||
onClick={() => actions.move(node, parentId, index - 1)}/>
|
||||
<Button icon='arrow down' size='xs' aria-label={`Move ${node.name} down`}
|
||||
disabled={index === nodes.length - 1}
|
||||
onClick={() => actions.move(node, parentId, index + 1)}/>
|
||||
<Button icon='edit' size='xs' aria-label={`Edit ${node.name}`}
|
||||
onClick={() => actions.edit(node, parentId)}/>
|
||||
{directory
|
||||
? <APIButton
|
||||
role='danger' icon='trash' size='xs' aria-label={`Delete ${node.name}`}
|
||||
confirmContent={`Delete the directory "${node.name}" and everything in it?`}
|
||||
confirmButton='Delete'
|
||||
onClick={() => actions.remove(node)}/>
|
||||
: <Button role='danger' icon='trash' size='xs' aria-label={`Delete ${node.name}`}
|
||||
onClick={() => actions.remove(node)}/>}
|
||||
</div>
|
||||
</Table.Cell>}
|
||||
</Table.Row>
|
||||
{directory && <BookmarkTableRows nodes={node.children} parentId={node.id} depth={depth + 1}
|
||||
editable={editable} actions={actions}/>}
|
||||
</React.Fragment>;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The bookmarks table, in the shape of the playlist items table. `actions` carries
|
||||
* edit/move/remove so the gallery can render it with none of them wired.
|
||||
*/
|
||||
export function BookmarkTable({nodes, editable = true, actions}) {
|
||||
return <Table>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>#</Table.HeaderCell>
|
||||
<Table.HeaderCell>Kind</Table.HeaderCell>
|
||||
<Table.HeaderCell>Bookmark</Table.HeaderCell>
|
||||
{editable && <Table.HeaderCell/>}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
<BookmarkTableRows nodes={nodes} parentId={null} depth={0} editable={editable} actions={actions}/>
|
||||
</Table.Body>
|
||||
</Table>;
|
||||
}
|
||||
|
||||
/** The "Add a bookmark" and "Add a directory" forms beneath the table. */
|
||||
export function BookmarkAddForms({nodes, onAddBookmark, onAddDirectory}) {
|
||||
const [url, setUrl] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [newTab, setNewTab] = useState(false);
|
||||
const [location, setLocation] = useState(ROOT);
|
||||
const [directoryName, setDirectoryName] = useState('');
|
||||
const [directoryLocation, setDirectoryLocation] = useState(ROOT);
|
||||
const locations = locationOptions(nodes);
|
||||
const parentOf = (value) => value === ROOT ? null : Number(value);
|
||||
|
||||
// The form is cleared only once the server has accepted the node. A refused add has
|
||||
// already shown its toast; the typed URL stays so the user can correct it.
|
||||
const handleAddBookmark = async () => {
|
||||
if (!url.trim() || !name.trim()) return;
|
||||
try {
|
||||
await onAddBookmark({name: name.trim(), url: url.trim(), new_tab: newTab, parent_id: parentOf(location)});
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
setUrl('');
|
||||
setName('');
|
||||
};
|
||||
|
||||
const handleAddDirectory = async () => {
|
||||
if (!directoryName.trim()) return;
|
||||
try {
|
||||
await onAddDirectory({name: directoryName.trim(), parent_id: parentOf(directoryLocation)});
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
setDirectoryName('');
|
||||
};
|
||||
|
||||
return <>
|
||||
<Header as='h4'>Add a bookmark</Header>
|
||||
<form onSubmit={e => {
|
||||
e.preventDefault();
|
||||
handleAddBookmark();
|
||||
}}>
|
||||
<Group align='flex-end' wrap='wrap'>
|
||||
<TextInput
|
||||
style={{flex: '2 1 300px'}}
|
||||
aria-label='Bookmark URL'
|
||||
placeholder='URL (e.g. /videos, :8096/, or https://example.com)'
|
||||
value={url}
|
||||
onChange={e => setUrl(e.currentTarget.value)}/>
|
||||
<TextInput
|
||||
style={{flex: '1 1 200px'}}
|
||||
aria-label='Bookmark name'
|
||||
placeholder='Name'
|
||||
value={name}
|
||||
onChange={e => setName(e.currentTarget.value)}/>
|
||||
<Select
|
||||
style={{flex: '1 1 160px'}}
|
||||
aria-label='Bookmark location'
|
||||
data={locations}
|
||||
value={location}
|
||||
onChange={value => setLocation(value || ROOT)}
|
||||
allowDeselect={false}/>
|
||||
<Toggle
|
||||
label='New tab'
|
||||
checked={newTab}
|
||||
onChange={e => setNewTab(e.currentTarget.checked)}/>
|
||||
<Button role='primary' type='submit' disabled={!url.trim() || !name.trim()}>Add</Button>
|
||||
</Group>
|
||||
</form>
|
||||
<p style={{marginTop: '0.5em'}}>
|
||||
<Text size='xs' c='dimmed' component='span'>
|
||||
A URL is a path on this WROLPi (/videos), a port on this host (:8096/ or http://:8096/) that
|
||||
works whichever address you reach it by, or a full URL.
|
||||
</Text>
|
||||
</p>
|
||||
|
||||
<Header as='h4'>Add a directory</Header>
|
||||
<form onSubmit={e => {
|
||||
e.preventDefault();
|
||||
handleAddDirectory();
|
||||
}}>
|
||||
<Group align='flex-end' wrap='wrap'>
|
||||
<TextInput
|
||||
style={{flex: '2 1 300px'}}
|
||||
aria-label='Directory name'
|
||||
placeholder='Directory name'
|
||||
value={directoryName}
|
||||
onChange={e => setDirectoryName(e.currentTarget.value)}/>
|
||||
<Select
|
||||
style={{flex: '1 1 160px'}}
|
||||
aria-label='Directory location'
|
||||
data={locations}
|
||||
value={directoryLocation}
|
||||
onChange={value => setDirectoryLocation(value || ROOT)}
|
||||
allowDeselect={false}/>
|
||||
<Button role='primary' type='submit' disabled={!directoryName.trim()}>Add</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</>;
|
||||
}
|
||||
|
||||
export function BookmarksPage() {
|
||||
useTitle('Bookmarks');
|
||||
const {bookmarks, refresh} = useBookmarks();
|
||||
// {node, directory, parentId} while the edit modal is open; a `key` remounts the form fresh.
|
||||
const [modal, setModal] = useState(null);
|
||||
const [modalKey, setModalKey] = useState(0);
|
||||
const nodes = bookmarks || [];
|
||||
|
||||
const actions = {
|
||||
edit: (node, parentId) => {
|
||||
setModalKey(k => k + 1);
|
||||
setModal({node, directory: isBookmarkDirectory(node), parentId});
|
||||
},
|
||||
move: async (node, parentId, position) => {
|
||||
try {
|
||||
await moveBookmark(node.id, parentId, position);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
// Error toast already shown by the API client.
|
||||
}
|
||||
},
|
||||
remove: async (node) => {
|
||||
try {
|
||||
await deleteBookmark(node.id);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// These reject on failure (the API client has already shown the toast) so the add
|
||||
// forms know to keep what was typed.
|
||||
const handleAddBookmark = async (bookmark) => {
|
||||
await addBookmark(bookmark);
|
||||
await refresh();
|
||||
};
|
||||
|
||||
const handleAddDirectory = async (directory) => {
|
||||
await addBookmarkDirectory(directory);
|
||||
await refresh();
|
||||
};
|
||||
|
||||
const handleEditSave = async ({name, url, new_tab, parent_id}) => {
|
||||
const {node, directory, parentId} = modal;
|
||||
try {
|
||||
await updateBookmark(node.id, directory ? {name} : {name, url, new_tab});
|
||||
} catch (e) {
|
||||
return; // Nothing changed; the modal stays open with the toast beside it.
|
||||
}
|
||||
// The rename is committed even if the move is refused, so the nav must show it
|
||||
// either way; only a fully successful edit closes the modal.
|
||||
let moved = true;
|
||||
if (parent_id !== parentId) {
|
||||
try {
|
||||
await moveBookmark(node.id, parent_id);
|
||||
} catch (e) {
|
||||
moved = false;
|
||||
}
|
||||
}
|
||||
await refresh();
|
||||
if (moved) {
|
||||
setModal(null);
|
||||
}
|
||||
};
|
||||
|
||||
return <PageContainer>
|
||||
<Panel>
|
||||
<Header as='h1' icon={IconBookmark}>Bookmarks</Header>
|
||||
<p>
|
||||
Bookmarks appear in the Bookmarks menu of the navigation bar. They can point at pages on this
|
||||
WROLPi, at other services running beside it, or anywhere on the web.
|
||||
</p>
|
||||
|
||||
{nodes.length === 0
|
||||
? <Message title='There are no bookmarks yet'>Add a bookmark below.</Message>
|
||||
: <BookmarkTable nodes={nodes} actions={actions}/>}
|
||||
|
||||
<BookmarkAddForms nodes={nodes} onAddBookmark={handleAddBookmark} onAddDirectory={handleAddDirectory}/>
|
||||
</Panel>
|
||||
{modal && <BookmarkNodeModal
|
||||
key={modalKey}
|
||||
open
|
||||
node={modal.node}
|
||||
directory={modal.directory}
|
||||
parentId={modal.parentId}
|
||||
nodes={nodes}
|
||||
onSave={handleEditSave}
|
||||
onClose={() => setModal(null)}
|
||||
/>}
|
||||
</PageContainer>;
|
||||
}
|
||||
|
|
@ -1,199 +0,0 @@
|
|||
import React from 'react';
|
||||
import {screen} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import {render} from '../test-utils';
|
||||
import {BookmarkAddForms, BookmarkNodeModal, BookmarkTable, directoryOptions, locationOptions} from './Bookmarks';
|
||||
import {resolveBookmarkUrl} from '../contexts/BookmarksContext';
|
||||
|
||||
const tree = [
|
||||
{id: 1, name: 'Channels', url: '/videos/channel', new_tab: false},
|
||||
{
|
||||
id: 2, name: 'Services', children: [
|
||||
{id: 3, name: 'Jellyfin', url: ':8096/', new_tab: true},
|
||||
{id: 4, name: 'Radio', children: []},
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
describe('resolveBookmarkUrl', () => {
|
||||
const location = {protocol: 'https:', hostname: '10.0.0.2', host: '10.0.0.2'};
|
||||
|
||||
it.each([
|
||||
['/videos', {internal: true, href: '/videos'}],
|
||||
[' /videos/channel/3 ', {internal: true, href: '/videos/channel/3'}],
|
||||
// A port on this host takes the page's scheme, or its own when it names one.
|
||||
[':8096/web', {internal: false, href: 'https://10.0.0.2:8096/web'}],
|
||||
[':8096', {internal: false, href: 'https://10.0.0.2:8096'}],
|
||||
['http://:8096/web', {internal: false, href: 'http://10.0.0.2:8096/web'}],
|
||||
['HTTPS://:8085', {internal: false, href: 'https://10.0.0.2:8085'}],
|
||||
['https://example.com/a?b=c', {internal: false, href: 'https://example.com/a?b=c'}],
|
||||
])('resolves %s', (url, expected) => {
|
||||
expect(resolveBookmarkUrl(url, location)).toEqual(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
// Paths a browser takes off this host: network paths, backslashes, and control
|
||||
// characters a URL parser strips before deciding.
|
||||
'//evil.com', '///evil.com', '/\\evil.com', '/\\@evil.com', '/\t/evil.com', '/\n/evil.com',
|
||||
// Port forms where the host would become credentials.
|
||||
':8096@evil.com', 'http://:8096@evil.com', ':8096\t@evil.com', ':8096/x@evil.com', ':8096\\evil.com',
|
||||
// Not http(s).
|
||||
'javascript:alert(1)', 'data:text/html,hi', 'ftp://example.com', 'videos', '',
|
||||
])('refuses %j rather than guessing', (url) => {
|
||||
expect(resolveBookmarkUrl(url, location)).toEqual({internal: false, href: null});
|
||||
});
|
||||
|
||||
it('brackets an IPv6 host for a port bookmark', () => {
|
||||
// The browser reports an IPv6 hostname bracketed already.
|
||||
const v6 = {protocol: 'https:', hostname: '[::1]', host: '[::1]'};
|
||||
expect(resolveBookmarkUrl(':8096/web', v6)).toEqual({internal: false, href: 'https://[::1]:8096/web'});
|
||||
expect(resolveBookmarkUrl(':8096', {protocol: 'http:', hostname: '::1', host: '[::1]'}))
|
||||
.toEqual({internal: false, href: 'http://[::1]:8096'});
|
||||
});
|
||||
});
|
||||
|
||||
describe('directoryOptions', () => {
|
||||
it('lists every directory with its path, and no bookmarks', () => {
|
||||
expect(directoryOptions(tree)).toEqual([
|
||||
{value: '2', label: 'Services', id: 2},
|
||||
{value: '4', label: 'Services / Radio', id: 4},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BookmarkTable', () => {
|
||||
const actions = () => ({edit: jest.fn(), move: jest.fn(), remove: jest.fn()});
|
||||
|
||||
it('renders the tree with resolved links, numbered within each directory', () => {
|
||||
render(<BookmarkTable nodes={tree} actions={actions()}/>);
|
||||
const channels = screen.getByRole('link', {name: 'Channels'});
|
||||
expect(channels).toHaveAttribute('href', '/videos/channel');
|
||||
expect(channels).not.toHaveAttribute('target');
|
||||
const jellyfin = screen.getByRole('link', {name: 'Jellyfin'});
|
||||
expect(jellyfin).toHaveAttribute('href', `${window.location.protocol}//${window.location.hostname}:8096/`);
|
||||
expect(jellyfin).toHaveAttribute('target', '_blank');
|
||||
expect(screen.getByText('Radio')).toBeInTheDocument();
|
||||
// Channels, Services, then Services' two children.
|
||||
const numbers = screen.getAllByRole('row').slice(1).map(row => row.firstChild.textContent);
|
||||
expect(numbers).toEqual(['01', '02', '01', '02']);
|
||||
});
|
||||
|
||||
it('moves a node among its siblings and only within bounds', async () => {
|
||||
const a = actions();
|
||||
render(<BookmarkTable nodes={tree} actions={a}/>);
|
||||
// The first top-level node cannot move up; the last cannot move down.
|
||||
expect(screen.getByRole('button', {name: 'Move Channels up'})).toBeDisabled();
|
||||
expect(screen.getByRole('button', {name: 'Move Services down'})).toBeDisabled();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', {name: 'Move Channels down'}));
|
||||
expect(a.move).toHaveBeenCalledWith(tree[0], null, 1);
|
||||
// A child moves within its directory: parent 2, from index 1 to 0.
|
||||
await userEvent.click(screen.getByRole('button', {name: 'Move Radio up'}));
|
||||
expect(a.move).toHaveBeenCalledWith(tree[1].children[1], 2, 0);
|
||||
});
|
||||
|
||||
it('shows a bookmark whose URL does not resolve as text, not a link', () => {
|
||||
const nodes = [{id: 1, name: 'Bad', url: '//evil.com', new_tab: false}];
|
||||
render(<BookmarkTable nodes={nodes} actions={actions()}/>);
|
||||
expect(screen.queryByRole('link', {name: 'Bad'})).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Bad')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('edits with the parent the node lives in, and deletes a bookmark without asking', async () => {
|
||||
const a = actions();
|
||||
render(<BookmarkTable nodes={tree} actions={a}/>);
|
||||
await userEvent.click(screen.getByRole('button', {name: 'Edit Jellyfin'}));
|
||||
expect(a.edit).toHaveBeenCalledWith(tree[1].children[0], 2);
|
||||
await userEvent.click(screen.getByRole('button', {name: 'Delete Channels'}));
|
||||
expect(a.remove).toHaveBeenCalledWith(tree[0]);
|
||||
});
|
||||
|
||||
it('asks before deleting a directory, since its contents go with it', async () => {
|
||||
const a = actions();
|
||||
render(<BookmarkTable nodes={tree} actions={a}/>);
|
||||
await userEvent.click(screen.getByRole('button', {name: 'Delete Services'}));
|
||||
expect(a.remove).not.toHaveBeenCalled();
|
||||
await userEvent.click(await screen.findByRole('button', {name: 'Delete'}));
|
||||
expect(a.remove).toHaveBeenCalledWith(tree[1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BookmarkAddForms', () => {
|
||||
it('adds a bookmark at the top level and clears the form', async () => {
|
||||
const onAddBookmark = jest.fn();
|
||||
render(<BookmarkAddForms nodes={tree} onAddBookmark={onAddBookmark} onAddDirectory={jest.fn()}/>);
|
||||
const adds = screen.getAllByRole('button', {name: 'Add'});
|
||||
expect(adds[0]).toBeDisabled();
|
||||
|
||||
await userEvent.type(screen.getByLabelText('Bookmark URL'), ':8123/');
|
||||
expect(adds[0]).toBeDisabled();
|
||||
await userEvent.type(screen.getByLabelText('Bookmark name'), 'Home Assistant');
|
||||
await userEvent.click(screen.getByLabelText('New tab'));
|
||||
await userEvent.click(adds[0]);
|
||||
|
||||
expect(onAddBookmark).toHaveBeenCalledWith({name: 'Home Assistant', url: ':8123/', new_tab: true, parent_id: null});
|
||||
expect(screen.getByLabelText('Bookmark URL')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('keeps what was typed when the server refuses the bookmark', async () => {
|
||||
const onAddBookmark = jest.fn().mockRejectedValue(new Error('refused'));
|
||||
render(<BookmarkAddForms nodes={tree} onAddBookmark={onAddBookmark} onAddDirectory={jest.fn()}/>);
|
||||
await userEvent.type(screen.getByLabelText('Bookmark URL'), '//evil.com');
|
||||
await userEvent.type(screen.getByLabelText('Bookmark name'), 'Oops');
|
||||
await userEvent.click(screen.getAllByRole('button', {name: 'Add'})[0]);
|
||||
|
||||
expect(onAddBookmark).toHaveBeenCalled();
|
||||
expect(screen.getByLabelText('Bookmark URL')).toHaveValue('//evil.com');
|
||||
expect(screen.getByLabelText('Bookmark name')).toHaveValue('Oops');
|
||||
});
|
||||
|
||||
it('adds a directory', async () => {
|
||||
const onAddDirectory = jest.fn();
|
||||
render(<BookmarkAddForms nodes={tree} onAddBookmark={jest.fn()} onAddDirectory={onAddDirectory}/>);
|
||||
await userEvent.type(screen.getByLabelText('Directory name'), 'Tools');
|
||||
await userEvent.click(screen.getAllByRole('button', {name: 'Add'})[1]);
|
||||
expect(onAddDirectory).toHaveBeenCalledWith({name: 'Tools', parent_id: null});
|
||||
});
|
||||
});
|
||||
|
||||
describe('BookmarkNodeModal', () => {
|
||||
// Mantine's Select scrolls the highlighted option into view; jsdom has no layout.
|
||||
beforeAll(() => {
|
||||
Element.prototype.scrollIntoView = jest.fn();
|
||||
});
|
||||
|
||||
it('saves a new bookmark with its location and new-tab choice', async () => {
|
||||
const onSave = jest.fn();
|
||||
render(<BookmarkNodeModal open node={null} directory={false} parentId={2} nodes={tree}
|
||||
onSave={onSave} onClose={jest.fn()}/>);
|
||||
const save = screen.getByRole('button', {name: 'Save'});
|
||||
expect(save).toBeDisabled();
|
||||
|
||||
await userEvent.type(screen.getByLabelText('Name'), 'Home Assistant');
|
||||
expect(save).toBeDisabled();
|
||||
await userEvent.type(screen.getByLabelText('URL'), ':8123/');
|
||||
await userEvent.click(screen.getByLabelText('Open in a new tab'));
|
||||
await userEvent.click(save);
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith({name: 'Home Assistant', url: ':8123/', new_tab: true, parent_id: 2});
|
||||
});
|
||||
|
||||
it('a directory has no URL or new-tab choice', () => {
|
||||
render(<BookmarkNodeModal open node={tree[1]} directory parentId={null} nodes={tree}
|
||||
onSave={jest.fn()} onClose={jest.fn()}/>);
|
||||
expect(screen.getByLabelText('Name')).toHaveValue('Services');
|
||||
expect(screen.queryByLabelText('URL')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Open in a new tab')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('locationOptions', () => {
|
||||
it('offers the top level and every directory to a new node', () => {
|
||||
expect(locationOptions(tree).map(o => o.label)).toEqual(['Top level', 'Services', 'Services / Radio']);
|
||||
});
|
||||
|
||||
it('never offers a directory itself, or its children', () => {
|
||||
expect(locationOptions(tree, tree[1]).map(o => o.label)).toEqual(['Top level']);
|
||||
expect(locationOptions(tree, tree[1].children[1]).map(o => o.label)).toEqual(['Top level', 'Services']);
|
||||
});
|
||||
});
|
||||
|
|
@ -14,7 +14,6 @@ import {FoodStorageCalculator} from "./calculators/FoodStorageCalculator";
|
|||
import {OneTimePadCalculator} from "./calculators/OneTimePadCalculator";
|
||||
import {VinDecoderCalculator} from "./calculators/VinDecoderCalculator";
|
||||
import {SpeedTestCalculator} from "./calculators/SpeedTestCalculator";
|
||||
import {SolarCalculator} from "./calculators/SolarCalculator";
|
||||
|
||||
// Calculators are shown in these titled groups, in this order. Each calculator carries a
|
||||
// `group` matching one of these names; any calculator whose group is missing/unknown falls
|
||||
|
|
@ -36,7 +35,6 @@ export const useCalculators = () => {
|
|||
{key: 'ratio', icon: 'th large', button: 'Ratio', group: 'Engineering', contents: <RatioCalculators/>},
|
||||
{key: 'electrical', icon: 'lightning', button: 'Electrical', group: 'Engineering', contents: <ElectricalCalculators/>},
|
||||
{key: 'drive', icon: 'cogs', button: 'Drive Ratio', group: 'Engineering', contents: <DriveCalculator/>},
|
||||
{key: 'solar', icon: 'sun', button: 'Solar', group: 'Engineering', contents: <SolarCalculator/>},
|
||||
{key: 'water', icon: 'tint', button: 'Water Storage', group: 'Preparedness & Storage', contents: <WaterCalculator/>},
|
||||
{key: 'ration', icon: 'food', button: 'Food Storage', group: 'Preparedness & Storage', contents: <FoodStorageCalculator/>},
|
||||
{key: 'antenna', icon: 'signal', button: 'Antenna', group: 'Radio & Communications', contents: <DipoleAntennaCalculator/>},
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import {IconArrowLeft, IconArrowRight} from '@tabler/icons-react';
|
|||
import {getArchiveContents, getArchiveMemberUrl} from '../api';
|
||||
import {IconButton, Label, Loading, Message} from './ui';
|
||||
import {useLocalStorage} from './Common';
|
||||
import {usePageProgress} from '../hooks/useFileProgress';
|
||||
|
||||
const IMAGE_EXTENSIONS = /\.(jpe?g|png|gif|webp|bmp|tiff?)$/i;
|
||||
|
||||
|
|
@ -23,20 +22,9 @@ function flattenEntries(entries, prefix = '') {
|
|||
return paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param path The comic book file.
|
||||
* @param progressPath The FileGroup's primary path, to resume and save the reader's page; omit to not track it.
|
||||
* @param fileGroup The FileGroup JSON, which holds the saved page.
|
||||
*/
|
||||
export function CbzViewer({path, progressPath = null, fileGroup = null}) {
|
||||
export function CbzViewer({path}) {
|
||||
const [pages, setPages] = useState(null);
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const {initialPage, onPageChange} = usePageProgress(progressPath, fileGroup, {enabled: !!progressPath});
|
||||
// Read when the pages load; a later save must not move the reader.
|
||||
const initialPageRef = useRef(initialPage);
|
||||
initialPageRef.current = initialPage;
|
||||
// The page shown on opening is where the reader already is (or the cover of a finished comic); it is not saved.
|
||||
const justOpenedRef = useRef(true);
|
||||
const [rtl, setRtl] = useLocalStorage('cbzViewerRtl', false);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
|
@ -53,9 +41,8 @@ export function CbzViewer({path, progressPath = null, fileGroup = null}) {
|
|||
.filter(name => IMAGE_EXTENSIONS.test(name))
|
||||
.sort();
|
||||
const urls = images.map(member => getArchiveMemberUrl(path, member));
|
||||
justOpenedRef.current = true;
|
||||
setPages(urls);
|
||||
setCurrentPage(Math.min(initialPageRef.current, Math.max(urls.length - 1, 0)));
|
||||
setCurrentPage(0);
|
||||
} catch (e) {
|
||||
setError(e.message || 'Could not read archive contents.');
|
||||
}
|
||||
|
|
@ -63,17 +50,6 @@ export function CbzViewer({path, progressPath = null, fileGroup = null}) {
|
|||
load();
|
||||
}, [path]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pages || pages.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (justOpenedRef.current) {
|
||||
justOpenedRef.current = false;
|
||||
return;
|
||||
}
|
||||
onPageChange(currentPage, pages.length);
|
||||
}, [currentPage, pages, onPageChange]);
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
setCurrentPage(p => Math.min(p + 1, (pages?.length || 1) - 1));
|
||||
}, [pages]);
|
||||
|
|
|
|||
|
|
@ -1,84 +0,0 @@
|
|||
import React from 'react';
|
||||
import {act, fireEvent, screen} from '@testing-library/react';
|
||||
import {renderWithProviders} from '../test-utils';
|
||||
import {CbzViewer} from './CbzViewer';
|
||||
import {getArchiveContents} from '../api';
|
||||
|
||||
jest.mock('../api', () => ({
|
||||
...jest.requireActual('../api'),
|
||||
getArchiveContents: jest.fn(),
|
||||
getArchiveMemberUrl: (path, member) => `/member/${member}`,
|
||||
}));
|
||||
|
||||
const PATH = 'comics/issue.cbz';
|
||||
const PAGES = ['01.jpg', '02.jpg', '03.jpg', '04.jpg'];
|
||||
|
||||
const {fetch: realFetch} = global;
|
||||
const realSendBeacon = navigator.sendBeacon;
|
||||
|
||||
beforeEach(() => {
|
||||
window.sessionStorage.clear();
|
||||
getArchiveContents.mockResolvedValue({entries: PAGES.map(name => ({name, path: name, is_dir: false}))});
|
||||
navigator.sendBeacon = jest.fn(() => false);
|
||||
global.fetch = jest.fn(() => Promise.resolve({ok: true}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = realFetch;
|
||||
navigator.sendBeacon = realSendBeacon;
|
||||
});
|
||||
|
||||
const saves = () => global.fetch.mock.calls.map(([, init]) => JSON.parse(init.body));
|
||||
|
||||
describe('CbzViewer progress', () => {
|
||||
test('opens at the saved page, and saves the page the reader leaves on', async () => {
|
||||
const fileGroup = {progress: 0.75, position: {kind: 'page', page: 2, updated_at: 1000}};
|
||||
const {unmount} = renderWithProviders(
|
||||
<CbzViewer path={PATH} progressPath={PATH} fileGroup={fileGroup}/>);
|
||||
|
||||
expect(await screen.findByText('3 / 4')).toBeInTheDocument();
|
||||
expect(screen.getByAltText('Page 3')).toHaveAttribute('src', '/member/03.jpg');
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Next page'));
|
||||
expect(screen.getByText('4 / 4')).toBeInTheDocument();
|
||||
|
||||
unmount();
|
||||
expect(saves()).toEqual([expect.objectContaining({
|
||||
file: PATH, progress: 1, final: true, position: expect.objectContaining({kind: 'page', page: 3}),
|
||||
})]);
|
||||
});
|
||||
|
||||
test('a saved page past the end opens the last page', async () => {
|
||||
const fileGroup = {progress: 0.5, position: {kind: 'page', page: 40, updated_at: 1000}};
|
||||
renderWithProviders(<CbzViewer path={PATH} progressPath={PATH} fileGroup={fileGroup}/>);
|
||||
expect(await screen.findByText('4 / 4')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('without a progress path it opens at the first page and saves nothing', async () => {
|
||||
const fileGroup = {progress: 0.75, position: {kind: 'page', page: 2, updated_at: 1000}};
|
||||
const {unmount} = renderWithProviders(<CbzViewer path={PATH} fileGroup={fileGroup}/>);
|
||||
expect(await screen.findByText('1 / 4')).toBeInTheDocument();
|
||||
await act(async () => fireEvent.click(screen.getByLabelText('Next page')));
|
||||
unmount();
|
||||
expect(saves()).toEqual([]);
|
||||
});
|
||||
|
||||
test('opening a comic saves nothing, so a finished comic stays finished', async () => {
|
||||
const fileGroup = {progress: 1, position: null, viewed: new Date(9000).toISOString()};
|
||||
const {unmount} = renderWithProviders(<CbzViewer path={PATH} progressPath={PATH} fileGroup={fileGroup}/>);
|
||||
expect(await screen.findByText('1 / 4')).toBeInTheDocument();
|
||||
unmount();
|
||||
expect(saves()).toEqual([]);
|
||||
});
|
||||
|
||||
test('peeking at the cover keeps the reader\'s place', async () => {
|
||||
const fileGroup = {progress: 0.75, position: {kind: 'page', page: 2, updated_at: 1000}};
|
||||
const {unmount} = renderWithProviders(<CbzViewer path={PATH} progressPath={PATH} fileGroup={fileGroup}/>);
|
||||
expect(await screen.findByText('3 / 4')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByLabelText('Previous page'));
|
||||
fireEvent.click(screen.getByLabelText('Previous page'));
|
||||
expect(screen.getByText('1 / 4')).toBeInTheDocument();
|
||||
unmount();
|
||||
expect(saves()).toEqual([expect.objectContaining({position: expect.objectContaining({page: 1})})]);
|
||||
});
|
||||
});
|
||||
|
|
@ -49,7 +49,7 @@ describe('Channels Page', () => {
|
|||
{initialEntries: ['/videos/channel/1/edit']}
|
||||
);
|
||||
cy.wait('@channelFetch');
|
||||
cy.wait('@searchDirectories'); // Needed in CI.
|
||||
cy.wait('@searchDirectories'); // Needed for CircleCi.
|
||||
cy.wait(500);
|
||||
cy.get('input[name="name"]').should('be.visible');
|
||||
cy.get('input[name="name"]').should('have.value', 'Editing Channel');
|
||||
|
|
|
|||
|
|
@ -757,18 +757,6 @@ export function findPosterPath(file) {
|
|||
}
|
||||
}
|
||||
|
||||
/** How far the user is through a file, as a bar along the bottom of its poster. A full bar is finished. */
|
||||
export function ViewProgress({progress}) {
|
||||
if (!(progress > 0)) {
|
||||
return null;
|
||||
}
|
||||
const percent = Math.round(Math.min(progress, 1) * 100);
|
||||
return <div className='wrolpi-view-progress' role='progressbar' aria-label='Viewing progress'
|
||||
aria-valuemin={0} aria-valuemax={100} aria-valuenow={percent}>
|
||||
<div className='wrolpi-view-progress-bar' style={{width: `${percent}%`}}/>
|
||||
</div>
|
||||
}
|
||||
|
||||
export function CardPoster({to, file, overlay}) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
|
@ -797,7 +785,6 @@ export function CardPoster({to, file, overlay}) {
|
|||
{imageLabel}
|
||||
<img alt='poster' src={posterPath}/>
|
||||
{overlay}
|
||||
<ViewProgress progress={file.progress}/>
|
||||
</>;
|
||||
|
||||
return <div className='wrolpi-card-poster'>
|
||||
|
|
@ -815,7 +802,6 @@ export function CardPoster({to, file, overlay}) {
|
|||
<div className='wrolpi-card-icon'>
|
||||
{imageLabel}
|
||||
<FileIcon file={file}/>
|
||||
<ViewProgress progress={file.progress}/>
|
||||
</div>
|
||||
</PreviewLink>
|
||||
} else if (!posterPath && to) {
|
||||
|
|
@ -824,7 +810,6 @@ export function CardPoster({to, file, overlay}) {
|
|||
<div className='wrolpi-card-icon' onClick={() => navigate(to)}>
|
||||
{imageLabel}
|
||||
<FileIcon file={file}/>
|
||||
<ViewProgress progress={file.progress}/>
|
||||
</div>
|
||||
</Link>
|
||||
}
|
||||
|
|
@ -943,10 +928,7 @@ export function HelpHeader({
|
|||
// why it cannot be used. `confirmStop` asks before switching off.
|
||||
function SubsystemToggle({label, on, onChange, unsupportedMessage, info = null, disabled = null, confirmStop = null}) {
|
||||
const [confirmOpen, setConfirmOpen] = React.useState(false);
|
||||
// null = the status has loaded without this subsystem; undefined = the status has not loaded
|
||||
// yet (or a call is in flight). Both disable the toggle; only the first claims unsupported.
|
||||
const unsupported = on === null;
|
||||
const unknown = on === undefined;
|
||||
|
||||
const handleChange = (checked) => {
|
||||
if (!checked && confirmStop) {
|
||||
|
|
@ -979,11 +961,11 @@ function SubsystemToggle({label, on, onChange, unsupportedMessage, info = null,
|
|||
</Confirm>}
|
||||
<Toggle
|
||||
label={label}
|
||||
disabled={disabled === null ? (unsupported || unknown) : disabled}
|
||||
disabled={disabled === null ? unsupported : disabled}
|
||||
checked={on === true}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
{popup && <span data-testid='subsystem-popup'><InfoPopup content={popup}/></span>}
|
||||
{popup && <InfoPopup content={popup}/>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
|
|
@ -1083,10 +1065,9 @@ export function Toggle({label, checked, disabled, onChange, icon, popupContent =
|
|||
|
||||
export function DisableDownloadsToggle() {
|
||||
const [pending, setPending] = React.useState(false);
|
||||
const {status, fetchStatus, loaded} = React.useContext(StatusContext);
|
||||
const {status, fetchStatus} = React.useContext(StatusContext);
|
||||
|
||||
// Until the first status poll lands there is no downloads state to show, only a disabled toggle.
|
||||
const downloads = loaded ? status?.downloads : null;
|
||||
const {downloads} = status ? status : {downloads: null};
|
||||
const wrolModeEnabled = useWROLMode();
|
||||
|
||||
const setDownloads = async (enable) => {
|
||||
|
|
@ -1101,9 +1082,8 @@ export function DisableDownloadsToggle() {
|
|||
}
|
||||
|
||||
const on = downloads && downloads['disabled'] === false && downloads['stopped'] === false;
|
||||
const label = downloads === null ? 'Downloading' : on === true ? 'Downloading Enabled' : 'Downloading Disabled';
|
||||
return <Toggle
|
||||
label={label}
|
||||
label={on === true ? 'Downloading Enabled' : 'Downloading Disabled'}
|
||||
disabled={wrolModeEnabled || pending || downloads === null}
|
||||
checked={on === true}
|
||||
onChange={setDownloads}
|
||||
|
|
@ -1180,7 +1160,7 @@ export function mimetypeIconName(mimetype, lowerPath = '') {
|
|||
return 'file code';
|
||||
} else if (mimetype.startsWith('application/pdf')) {
|
||||
return 'file pdf';
|
||||
} else if (mimetype.startsWith('text/plain') || mimetype.startsWith('text/markdown')) {
|
||||
} else if (mimetype.startsWith('text/plain')) {
|
||||
return 'file text';
|
||||
} else if (mimetype.startsWith('image/')) {
|
||||
return 'image';
|
||||
|
|
|
|||
|
|
@ -1121,7 +1121,7 @@ describe('a row of buttons', () => {
|
|||
* the two had been inline before.
|
||||
*
|
||||
* A row fixes it without touching the stack: the row is the stretched child, and it lays
|
||||
* its buttons out at their own width. Thirteen sites, so this scan rather than thirteen
|
||||
* its buttons out at their own width. Nine sites, so this scan rather than nine
|
||||
* assertions -- and a scan because a stretched button is still a working button.
|
||||
*/
|
||||
const offenders = [];
|
||||
|
|
@ -1140,8 +1140,8 @@ describe('a row of buttons', () => {
|
|||
}
|
||||
}
|
||||
|
||||
// Thirteen renders. Common.js declares it, and a declaration is not a `<BackButton`.
|
||||
expect(found).toBe(13);
|
||||
// Nine renders. Common.js declares it, and a declaration is not a `<BackButton`.
|
||||
expect(found).toBe(9);
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -38,7 +38,6 @@ import {toast} from "./ui";
|
|||
import {CollectionTable} from "./collections/CollectionTable";
|
||||
import {AddToPlaylistButton} from "./AddToPlaylist";
|
||||
import {CbzViewer} from "./CbzViewer";
|
||||
import {useEpubProgress} from "../hooks/useFileProgress";
|
||||
import _ from "lodash";
|
||||
|
||||
function DocsPage() {
|
||||
|
|
@ -166,23 +165,6 @@ function DocsPage() {
|
|||
</>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The embedded viewer (the EPUB viewer, or the browser's PDF viewer). An EPUB opens where the user left off,
|
||||
* unless a search result says where (`deepLinked`), and saves their place as they read.
|
||||
*
|
||||
* @param epubProgressPath The FileGroup's primary path when an EPUB is shown and tracked, otherwise null.
|
||||
*/
|
||||
function DocViewerFrame({url, epubProgressPath, fileGroup, deepLinked, title}) {
|
||||
const {iframeRef, resumeCfi} = useEpubProgress(epubProgressPath, fileGroup, {enabled: !!epubProgressPath});
|
||||
const src = resumeCfi && !deepLinked ? `${url}&cfi=${encodeURIComponent(resumeCfi)}` : url;
|
||||
return <iframe
|
||||
ref={iframeRef}
|
||||
src={src}
|
||||
title={title}
|
||||
style={{width: '100%', height: '80vh', border: '1px solid var(--border)'}}
|
||||
/>
|
||||
}
|
||||
|
||||
function DocPage() {
|
||||
const {fileGroupId} = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -263,8 +245,6 @@ function DocPage() {
|
|||
? `/media/${encodeMediaPath(activePath)}${pageParam ? `#page=${encodeURIComponent(pageParam)}` : ''}`
|
||||
: null;
|
||||
const embedUrl = isEpub ? viewerUrl : pdfSrc;
|
||||
// Progress belongs to the FileGroup; another format of the same book (a PDF beside the EPUB) is not tracked.
|
||||
const trackedPath = String(activePath) === String(docFile.primary_path) ? docFile.primary_path : null;
|
||||
|
||||
const handleDelete = async (force = false) => {
|
||||
if (doc && doc.id) {
|
||||
|
|
@ -374,7 +354,7 @@ function DocPage() {
|
|||
return <>
|
||||
<div className='wrolpi-button-row'><BackButton/></div>
|
||||
|
||||
{isCbz && activePath && <CbzViewer path={activePath} progressPath={trackedPath} fileGroup={docFile}/>}
|
||||
{isCbz && activePath && <CbzViewer path={activePath}/>}
|
||||
|
||||
{canEmbed && embedUrl && !isCbz && <div style={{marginBottom: '1em'}}>
|
||||
{/*
|
||||
|
|
@ -384,8 +364,11 @@ function DocPage() {
|
|||
* re-gates rather than inheriting the last one's reveal.
|
||||
*/}
|
||||
<MediaGate key={activePath} gated={isPdf && mediaFilterEnabled}>
|
||||
<DocViewerFrame url={embedUrl} epubProgressPath={isEpub ? trackedPath : null} fileGroup={docFile}
|
||||
deepLinked={!!locParam} title={docFile.title || docFile.name}/>
|
||||
<iframe
|
||||
src={embedUrl}
|
||||
title={docFile.title || docFile.name}
|
||||
style={{width: '100%', height: '80vh', border: '1px solid var(--border)'}}
|
||||
/>
|
||||
</MediaGate>
|
||||
</div>}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,15 +19,10 @@ const source = fs.readFileSync(path.join(__dirname, 'Docs.js'), 'utf8');
|
|||
|
||||
describe('DocPage embed gating (source contract)', () => {
|
||||
it('wraps the embedded viewer in a MediaGate', () => {
|
||||
// The iframe must not be reachable without passing through the gate. The gate's only child is the
|
||||
// viewer frame, and the frame is just the iframe.
|
||||
const embed = source.match(/<MediaGate[^>]*>\s*<DocViewerFrame/);
|
||||
expect(embed).not.toBeNull();
|
||||
// The iframe must not be reachable without passing through the gate.
|
||||
const embed = source.match(/<MediaGate[^>]*>\s*<iframe/);
|
||||
|
||||
const frame = source.match(/function DocViewerFrame[\s\S]*?\n}\n/);
|
||||
expect(frame).not.toBeNull();
|
||||
expect(frame[0]).toMatch(/return <iframe/);
|
||||
expect(source.match(/<iframe/g)).toHaveLength(1);
|
||||
expect(embed).not.toBeNull();
|
||||
});
|
||||
|
||||
it('gates on the live filter state, not on the theme name', () => {
|
||||
|
|
|
|||
|
|
@ -127,15 +127,6 @@ describe('<ChannelDownloadForm/>', () => {
|
|||
shouldBeInvalid('#url_input');
|
||||
});
|
||||
|
||||
it('can be submitted with a frequency of Once', () => {
|
||||
cy.get('#url_input').type('https://example.com/playlist').wait(500);
|
||||
cy.get('#frequency_select').click();
|
||||
cy.contains('[role="option"]:visible', 'Once').should('be.visible').click({force: true});
|
||||
cy.get('#frequency_select').should('have.value', 'Once');
|
||||
|
||||
cy.get('#download_form_download_button').should('not.be.disabled');
|
||||
});
|
||||
|
||||
it('can choose different options', () => {
|
||||
/*
|
||||
* `#download_frequency_selector` and `.item` named the old dropdown and its menu rows.
|
||||
|
|
|
|||
|
|
@ -247,38 +247,28 @@ export function DownloadFrequencySelector({
|
|||
}
|
||||
|
||||
export function DownloaderSelector({form, name = 'sub_downloader', path = 'sub_downloader'}) {
|
||||
// null = pending, undefined = could not be fetched, a list = loaded. Until loaded, the select is
|
||||
// disabled and says why, rather than sitting empty as though there were no downloaders.
|
||||
const [downloaders, setDownloaders] = React.useState(null);
|
||||
const [downloaders, setDownloaders] = React.useState([]);
|
||||
|
||||
const fetchDownloaders = async () => {
|
||||
const result = await getDownloaders();
|
||||
if (!result || !Array.isArray(result.downloaders)) {
|
||||
setDownloaders(undefined);
|
||||
return;
|
||||
}
|
||||
setDownloaders(result.downloaders.map((i) => {
|
||||
let {downloaders: downloaders_} = await getDownloaders();
|
||||
downloaders_ = downloaders_.map((i) => {
|
||||
return {key: i.name, text: i.pretty_name || i.name, value: i.name}
|
||||
}));
|
||||
})
|
||||
setDownloaders(downloaders_);
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchDownloaders();
|
||||
}, []);
|
||||
|
||||
const placeholder = downloaders === null ? 'Loading downloaders…'
|
||||
: downloaders === undefined ? 'Could not fetch downloaders'
|
||||
: 'Select a downloader';
|
||||
|
||||
return <SelectField
|
||||
form={form}
|
||||
name={name}
|
||||
path={path}
|
||||
required
|
||||
label='Downloader'
|
||||
placeholder={placeholder}
|
||||
options={downloaders || []}
|
||||
disabled={!Array.isArray(downloaders)}
|
||||
placeholder='Select a downloader'
|
||||
options={downloaders}
|
||||
/>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import React from 'react';
|
||||
import {render, screen, waitFor} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import {DepthInputForm, DestinationForm} from './Download';
|
||||
import {useForm} from '../hooks/useForm';
|
||||
import {createTestForm, renderWithProviders} from '../test-utils';
|
||||
import {DestinationForm} from './Download';
|
||||
import {createTestForm} from '../test-utils';
|
||||
|
||||
// Mock DirectorySearch component - simplified to avoid useState/useEffect warnings
|
||||
jest.mock('./Common', () => {
|
||||
|
|
@ -252,26 +251,3 @@ describe('DestinationForm', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('NumberField', () => {
|
||||
// Renders a NumberField against a real useForm: the Mantine NumberInput passes a value (not an
|
||||
// event) to onChange, which used to reach `e.preventDefault()` and throw while typing.
|
||||
function DepthHarness({onData}) {
|
||||
const form = useForm({defaultFormData: {settings: {depth: ''}}, submitter: jest.fn()});
|
||||
onData(form.formData);
|
||||
return <DepthInputForm form={form}/>;
|
||||
}
|
||||
|
||||
it('typing into a NumberField updates the form without throwing', async () => {
|
||||
let latest = null;
|
||||
renderWithProviders(<DepthHarness onData={(d) => latest = d}/>);
|
||||
|
||||
const input = screen.getByRole('textbox', {name: /Depth/});
|
||||
await userEvent.type(input, '3');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(latest.settings.depth).toBe(3);
|
||||
});
|
||||
expect(input).toHaveValue('3');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,43 +0,0 @@
|
|||
import React from 'react';
|
||||
import {act, render, screen} from '../test-utils';
|
||||
import {createTestForm} from '../test-utils';
|
||||
import {DownloaderSelector} from './Download';
|
||||
import {getDownloaders} from '../api';
|
||||
|
||||
// The RSS form's Downloader select is filled from the API. Until the list arrives, or if it could
|
||||
// not be fetched, the select must say so rather than sit empty as though there were no downloaders.
|
||||
|
||||
jest.mock('../api', () => ({
|
||||
...jest.requireActual('../api'),
|
||||
getDownloaders: jest.fn(),
|
||||
}));
|
||||
|
||||
const makeForm = () => createTestForm({}, {
|
||||
overrides: {getSelectionProps: () => [{value: null, onChange: jest.fn(), disabled: false, error: null}]},
|
||||
});
|
||||
|
||||
beforeEach(() => getDownloaders.mockReset());
|
||||
|
||||
test('pending: the select says it is loading and is disabled', () => {
|
||||
getDownloaders.mockReturnValue(new Promise(() => {
|
||||
}));
|
||||
render(<DownloaderSelector form={makeForm()}/>);
|
||||
const input = screen.getByPlaceholderText('Loading downloaders…');
|
||||
expect(input).toBeDisabled();
|
||||
});
|
||||
|
||||
test('failed: the select says the downloaders could not be fetched', async () => {
|
||||
getDownloaders.mockResolvedValue(undefined);
|
||||
render(<DownloaderSelector form={makeForm()}/>);
|
||||
await act(async () => {
|
||||
});
|
||||
expect(screen.getByPlaceholderText('Could not fetch downloaders')).toBeDisabled();
|
||||
});
|
||||
|
||||
test('loaded: the select offers the downloaders', async () => {
|
||||
getDownloaders.mockResolvedValue({downloaders: [{name: 'video', pretty_name: 'Videos'}]});
|
||||
render(<DownloaderSelector form={makeForm()}/>);
|
||||
await act(async () => {
|
||||
});
|
||||
expect(screen.getByPlaceholderText('Select a downloader')).toBeEnabled();
|
||||
});
|
||||
|
|
@ -8,14 +8,12 @@ import {CbzViewer} from "./CbzViewer";
|
|||
import {StlViewer} from "react-stl-viewer";
|
||||
import {Button, ButtonGroup, Header, Icon, IconButton, MediaGate, Menu, Modal, toast} from "./ui";
|
||||
import {useOneQuery} from "../hooks/customHooks";
|
||||
import {useEpubProgress, useMediaProgress} from "../hooks/useFileProgress";
|
||||
import {ShareButton} from "./Share";
|
||||
import {AddToPlaylistButton} from "./AddToPlaylist";
|
||||
import {pathDirectory} from "./FileBrowser";
|
||||
import {InlineErrorBoundary} from "./ErrorBoundary";
|
||||
import {useLocation} from "react-router";
|
||||
import {ThemeContext} from "../contexts/contexts";
|
||||
import {isMarkdownFile, MarkdownPreview} from "./MarkdownPreview";
|
||||
|
||||
// Routes where file views should not be tracked
|
||||
const EXCLUDED_TRACKING_ROUTES = [
|
||||
|
|
@ -102,7 +100,7 @@ function getEpubViewerURL(previewFile) {
|
|||
* A component rather than part of `getIframePreviewModal` because it reads the theme: the
|
||||
* modal builders are plain functions called from an event handler, where hooks cannot run.
|
||||
*/
|
||||
export function IframePreview({url, gatePdf, iframeRef}) {
|
||||
export function IframePreview({url, gatePdf}) {
|
||||
const {isDark, mediaFilterEnabled} = React.useContext(ThemeContext);
|
||||
|
||||
/*
|
||||
|
|
@ -118,7 +116,7 @@ export function IframePreview({url, gatePdf, iframeRef}) {
|
|||
*/
|
||||
return <MediaGate key={url} gated={!!gatePdf && !!mediaFilterEnabled}>
|
||||
<div className='preview-fit'>
|
||||
<iframe title='textModal' src={url} ref={iframeRef}
|
||||
<iframe title='textModal' src={url}
|
||||
style={{
|
||||
height: '100%', width: '100%', border: 'none',
|
||||
// A same-origin document has no styles of its own; it renders with the
|
||||
|
|
@ -139,19 +137,6 @@ function getIframePreviewModal(previewFile, url, {gatePdf = false} = {}) {
|
|||
</Modal.Content>
|
||||
}
|
||||
|
||||
/** The EPUB viewer, opened where the user left off. */
|
||||
function EpubPreview({previewFile}) {
|
||||
const path = previewFile.primary_path ?? previewFile.path;
|
||||
const {iframeRef, resumeCfi} = useEpubProgress(path, previewFile);
|
||||
let url = getEpubViewerURL(previewFile);
|
||||
if (resumeCfi) {
|
||||
url = `${url}&cfi=${encodeURIComponent(resumeCfi)}`;
|
||||
}
|
||||
return <Modal.Content>
|
||||
<IframePreview url={url} iframeRef={iframeRef}/>
|
||||
</Modal.Content>
|
||||
}
|
||||
|
||||
function getImagePreviewModal(previewFile) {
|
||||
const path = previewFile.primary_path ?? previewFile.path;
|
||||
const url = getMediaPathURL(previewFile);
|
||||
|
|
@ -171,14 +156,6 @@ function getImagePreviewModal(previewFile) {
|
|||
</React.Fragment>
|
||||
}
|
||||
|
||||
/** A <video> or <audio> which resumes where the user left off, and saves how far they got. */
|
||||
function PreviewMediaPlayer({as: Element, previewFile, path, style, children}) {
|
||||
const progressHandlers = useMediaProgress(path, previewFile);
|
||||
return <Element controls autoPlay={true} id="player" playsInline={true} style={style} {...progressHandlers}>
|
||||
{children}
|
||||
</Element>
|
||||
}
|
||||
|
||||
function getVideoPreviewModal(previewFile) {
|
||||
const url = getMediaPathURL(previewFile);
|
||||
const path = previewFile.primary_path ?? previewFile.path;
|
||||
|
|
@ -190,10 +167,14 @@ function getVideoPreviewModal(previewFile) {
|
|||
<Modal.Content>
|
||||
<Header as='h5'>{path['path']}</Header>
|
||||
<div className='preview-fit'>
|
||||
<PreviewMediaPlayer as='video' previewFile={previewFile} path={path}
|
||||
style={{maxHeight: '100%', maxWidth: '100%'}}>
|
||||
<video controls
|
||||
autoPlay={true}
|
||||
id="player"
|
||||
playsInline={true}
|
||||
style={{maxHeight: '100%', maxWidth: '100%'}}
|
||||
>
|
||||
<source src={url}/>
|
||||
</PreviewMediaPlayer>
|
||||
</video>
|
||||
</div>
|
||||
</Modal.Content>
|
||||
</React.Fragment>
|
||||
|
|
@ -210,10 +191,14 @@ function getAudioPreviewModal(previewFile) {
|
|||
{name}
|
||||
</Modal.Header>
|
||||
<Modal.Content>
|
||||
<PreviewMediaPlayer as='audio' previewFile={previewFile} path={path}
|
||||
style={{width: '90%', maxWidth: '95%'}}>
|
||||
<audio controls
|
||||
autoPlay={true}
|
||||
id="player"
|
||||
playsInline={true}
|
||||
style={{width: '90%', maxWidth: '95%'}}
|
||||
>
|
||||
<source src={url} type={type}/>
|
||||
</PreviewMediaPlayer>
|
||||
</audio>
|
||||
</Modal.Content>
|
||||
</React.Fragment>
|
||||
}
|
||||
|
|
@ -456,9 +441,6 @@ export function FilePreviewProvider({children}) {
|
|||
const [previewQuery, setPreviewQuery] = useOneQuery('preview');
|
||||
const [errorModalOpen, setErrorModalOpen] = React.useState(false);
|
||||
const location = useLocation();
|
||||
// The path of the open preview that has been marked viewed. One opening is one view, however
|
||||
// many times previewFile is replaced while it is open (re-fetching after tagging, for example).
|
||||
const trackedPathRef = React.useRef(null);
|
||||
|
||||
// Determine if tracking should be skipped based on current route
|
||||
const skipTracking = shouldSkipTracking(location.pathname);
|
||||
|
|
@ -488,8 +470,7 @@ export function FilePreviewProvider({children}) {
|
|||
const initPreviewFile = async () => {
|
||||
// Get simple information about the file, preview the file.
|
||||
try {
|
||||
// Not a view; the preview effect marks the file viewed once it is displayed.
|
||||
const file = await getFile(previewQuery, true);
|
||||
const file = await getFile(previewQuery, skipTracking);
|
||||
setPreviewFile(file);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
|
@ -514,7 +495,7 @@ export function FilePreviewProvider({children}) {
|
|||
// Get the file again with its Tags.
|
||||
const {path, primary_path} = previewFile;
|
||||
try {
|
||||
const file = await getFile(primary_path ?? path, true);
|
||||
const file = await getFile(primary_path ?? path, skipTracking);
|
||||
setPreviewFile(file);
|
||||
} finally {
|
||||
await handleCallbacks();
|
||||
|
|
@ -604,9 +585,6 @@ export function FilePreviewProvider({children}) {
|
|||
|
||||
React.useEffect(() => {
|
||||
setPreviewModal(null);
|
||||
if (_.isEmpty(previewFile)) {
|
||||
trackedPathRef.current = null;
|
||||
}
|
||||
if (previewFile && !_.isObject(previewFile)) {
|
||||
console.error(`Unknown previewFile type: ${typeof previewFile}`);
|
||||
toast({
|
||||
|
|
@ -629,9 +607,6 @@ export function FilePreviewProvider({children}) {
|
|||
if (mimetype.startsWith('text/') && size > MAXIMUM_TEXT_SIZE) {
|
||||
// Large text files should be downloaded.
|
||||
window.open(downloadURL);
|
||||
} else if (isMarkdownFile(mimetype, lowerPath)) {
|
||||
setModalContent(<Modal.Content><MarkdownPreview url={url}/></Modal.Content>, url, downloadURL, path,
|
||||
taggable);
|
||||
} else if (mimetype.startsWith('text/') || mimetype.startsWith('application/json')) {
|
||||
setModalContent(getIframePreviewModal(previewFile), url, downloadURL, path, taggable);
|
||||
} else if (mimetype.startsWith('video/')) {
|
||||
|
|
@ -640,7 +615,7 @@ export function FilePreviewProvider({children}) {
|
|||
setModalContent(getAudioPreviewModal(previewFile), url, downloadURL, path, taggable);
|
||||
} else if (mimetype.startsWith('application/epub')) {
|
||||
const viewerURL = getEpubViewerURL(previewFile);
|
||||
setModalContent(<EpubPreview previewFile={previewFile}/>, viewerURL, downloadURL, path, taggable);
|
||||
setModalContent(getIframePreviewModal(previewFile, viewerURL), viewerURL, downloadURL, path, taggable);
|
||||
} else if (mimetype.startsWith('application/pdf')) {
|
||||
setModalContent(getIframePreviewModal(previewFile, null, {gatePdf: true}), url, downloadURL, path, taggable);
|
||||
} else if (mimetype.startsWith('image/')) {
|
||||
|
|
@ -662,9 +637,7 @@ export function FilePreviewProvider({children}) {
|
|||
<OpenInSlicerButton previewFile={previewFile}/>);
|
||||
} else if (mimetype.includes('cbz') || mimetype.includes('cbr') || mimetype.includes('comicbook+zip') || mimetype.includes('comicbook-rar')
|
||||
|| lowerPath.endsWith('.cbz') || lowerPath.endsWith('.cbr') || lowerPath.endsWith('.cbt') || lowerPath.endsWith('.cb7')) {
|
||||
setModalContent(<Modal.Content>
|
||||
<CbzViewer path={path} progressPath={path} fileGroup={previewFile}/>
|
||||
</Modal.Content>, null, downloadURL, path, taggable);
|
||||
setModalContent(<Modal.Content><CbzViewer path={path}/></Modal.Content>, null, downloadURL, path, taggable);
|
||||
} else if (isSupportedArchive(mimetype, lowerPath)) {
|
||||
setModalContent(<ArchivePreviewContent previewFile={previewFile}/>, null, downloadURL, path, taggable);
|
||||
} else {
|
||||
|
|
@ -673,8 +646,7 @@ export function FilePreviewProvider({children}) {
|
|||
}
|
||||
|
||||
// Trigger tracking for the file view (unless on an excluded route)
|
||||
if (!skipTracking && trackedPathRef.current !== path) {
|
||||
trackedPathRef.current = path;
|
||||
if (!skipTracking) {
|
||||
getFile(path).catch(e => {
|
||||
console.error(e);
|
||||
console.error('Failed to get file to set FileGroup.viewed');
|
||||
|
|
|
|||
|
|
@ -101,26 +101,6 @@ describe('preview gate remounting (source contract)', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('markdown preview routing (source contract)', () => {
|
||||
/*
|
||||
* Markdown is text/*, so the generic text branch would also accept it and show it raw in an
|
||||
* iframe. The markdown branch has to be tested first for the formatted view to be reachable.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const source = fs.readFileSync(path.join(__dirname, 'FilePreview.js'), 'utf8');
|
||||
|
||||
test('markdown is routed to MarkdownPreview before the generic text preview', () => {
|
||||
const markdownBranch = source.indexOf('} else if (isMarkdownFile(mimetype, lowerPath)) {');
|
||||
const textBranch = source.indexOf("} else if (mimetype.startsWith('text/') || mimetype.startsWith('application/json')) {");
|
||||
|
||||
expect(markdownBranch).toBeGreaterThan(-1);
|
||||
expect(textBranch).toBeGreaterThan(-1);
|
||||
expect(markdownBranch).toBeLessThan(textBranch);
|
||||
expect(source.slice(markdownBranch, textBranch)).toContain('<MarkdownPreview url={url}/>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('IframePreview color scheme', () => {
|
||||
/*
|
||||
* The previewed file is a bare document with no styles of its own, so it renders with the
|
||||
|
|
|
|||
|
|
@ -1,80 +0,0 @@
|
|||
import React from 'react';
|
||||
import {act, waitFor} from '@testing-library/react';
|
||||
import {renderWithProviders} from '../test-utils';
|
||||
import {FilePreviewContext, FilePreviewProvider} from './FilePreview';
|
||||
import {getFile} from '../api';
|
||||
|
||||
jest.mock('../api', () => ({
|
||||
...jest.requireActual('../api'),
|
||||
getFile: jest.fn(),
|
||||
tagFileGroup: jest.fn(),
|
||||
untagFileGroup: jest.fn(),
|
||||
}));
|
||||
|
||||
const imageFile = {primary_path: 'pictures/cat.png', mimetype: 'image/png', size: 100, taggable: true, tags: []};
|
||||
|
||||
/** getFile calls that would mark the file viewed (skipTracking not set). */
|
||||
const trackedCalls = () => getFile.mock.calls.filter(([, skipTracking]) => !skipTracking);
|
||||
|
||||
/** Exposes the provider's context so a test can open a preview the way PreviewLink does. */
|
||||
let context;
|
||||
const ContextSpy = () => {
|
||||
context = React.useContext(FilePreviewContext);
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderProvider = (preview = null) => renderWithProviders(
|
||||
<FilePreviewProvider><ContextSpy/></FilePreviewProvider>,
|
||||
{
|
||||
route: '/files',
|
||||
query: {searchParams: new URLSearchParams(preview ? {preview} : {}), updateQuery: jest.fn()},
|
||||
},
|
||||
);
|
||||
|
||||
describe('FilePreview view tracking', () => {
|
||||
beforeEach(() => {
|
||||
getFile.mockReset();
|
||||
getFile.mockResolvedValue(imageFile);
|
||||
context = null;
|
||||
});
|
||||
|
||||
test('opening a preview from the URL marks the file viewed once', async () => {
|
||||
renderProvider(imageFile.primary_path);
|
||||
|
||||
await waitFor(() => expect(getFile).toHaveBeenCalled());
|
||||
await waitFor(() => expect(context.previewModal).not.toBeNull());
|
||||
expect(trackedCalls()).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('opening a preview from a link marks the file viewed once', async () => {
|
||||
renderProvider();
|
||||
|
||||
act(() => context.setPreviewFile(imageFile));
|
||||
|
||||
await waitFor(() => expect(trackedCalls()).toHaveLength(1));
|
||||
expect(getFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('refreshing the open file (after tagging) is not another view', async () => {
|
||||
renderProvider();
|
||||
act(() => context.setPreviewFile(imageFile));
|
||||
await waitFor(() => expect(trackedCalls()).toHaveLength(1));
|
||||
|
||||
// localFetchFile replaces previewFile with the re-fetched file after a tag change.
|
||||
act(() => context.setPreviewFile({...imageFile, tags: ['animals']}));
|
||||
await waitFor(() => expect(context.previewFile.tags).toEqual(['animals']));
|
||||
|
||||
expect(trackedCalls()).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('closing and reopening the same file is another view', async () => {
|
||||
renderProvider();
|
||||
act(() => context.setPreviewFile(imageFile));
|
||||
await waitFor(() => expect(trackedCalls()).toHaveLength(1));
|
||||
|
||||
act(() => context.setPreviewFile(null));
|
||||
act(() => context.setPreviewFile(imageFile));
|
||||
|
||||
await waitFor(() => expect(trackedCalls()).toHaveLength(2));
|
||||
});
|
||||
});
|
||||
|
|
@ -3,7 +3,7 @@ import {Route, Routes} from "react-router";
|
|||
import {ESPLoader, Transport} from "esptool-js";
|
||||
import {useDropzone} from "react-dropzone";
|
||||
import _ from "lodash";
|
||||
import {encodeMediaPath, ErrorMessage, humanFileSize, PageContainer, useTitle} from "./Common";
|
||||
import {encodeMediaPath, humanFileSize, PageContainer, useTitle} from "./Common";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
|
|
@ -17,7 +17,6 @@ import {
|
|||
Message,
|
||||
Modal,
|
||||
Panel,
|
||||
Placeholder,
|
||||
Progress,
|
||||
Select,
|
||||
Stack,
|
||||
|
|
@ -270,8 +269,7 @@ export function FlasherPage() {
|
|||
|
||||
// Media-directory firmware picker.
|
||||
const [mediaFilter, setMediaFilter] = useState('');
|
||||
// null = not searched yet, undefined = the search failed, a list = the server's results.
|
||||
const [mediaResults, setMediaResults] = useState(null);
|
||||
const [mediaResults, setMediaResults] = useState([]);
|
||||
const [mediaLoading, setMediaLoading] = useState(false);
|
||||
// When set (e.g. "ESP32-S2"), the picker only shows firmware for that chip (detected from the device).
|
||||
const [deviceChip, setDeviceChip] = useState(null);
|
||||
|
|
@ -299,12 +297,8 @@ export function FlasherPage() {
|
|||
// Which firmware-source tab is active (controlled so a file drop can switch to "Add from computer").
|
||||
const [activeTab, setActiveTab] = useState(TAB_SAVED);
|
||||
|
||||
// Saved firmware configurations (flasher.yaml). null = pending, undefined = fetch failed,
|
||||
// [] = none saved; the "No saved configurations yet." message is only for the last.
|
||||
const [savedConfigs, setSavedConfigs] = useState(null);
|
||||
// Only the newest configs request may write state: a save or delete refetches while the mount
|
||||
// request may still be in flight.
|
||||
const savedConfigsGen = useRef(0);
|
||||
// Saved firmware configurations (flasher.yaml).
|
||||
const [savedConfigs, setSavedConfigs] = useState([]);
|
||||
const [saveModalOpen, setSaveModalOpen] = useState(false);
|
||||
const [saveName, setSaveName] = useState('');
|
||||
|
||||
|
|
@ -394,15 +388,14 @@ export function FlasherPage() {
|
|||
const fetchMediaFirmware = React.useCallback(async (pathFilter, chip) => {
|
||||
setMediaLoading(true);
|
||||
try {
|
||||
const result = await flasherSearch(chip || null, pathFilter || null);
|
||||
// The api helper returns undefined (and toasts) on a non-OK response.
|
||||
setMediaResults(result ? result[0] || [] : undefined);
|
||||
const [fileGroups] = await flasherSearch(chip || null, pathFilter || null);
|
||||
setMediaResults(fileGroups || []);
|
||||
} catch (e) {
|
||||
// Surface connectivity errors instead of showing the empty "no firmware found" state, which would
|
||||
// be indistinguishable from genuinely having no firmware.
|
||||
setError(e && e.message ? e.message : String(e));
|
||||
setBootHint(false);
|
||||
setMediaResults(undefined);
|
||||
setMediaResults([]);
|
||||
} finally {
|
||||
setMediaLoading(false);
|
||||
}
|
||||
|
|
@ -431,19 +424,7 @@ export function FlasherPage() {
|
|||
|
||||
// Saved firmware configurations.
|
||||
const fetchSavedConfigs = React.useCallback(async () => {
|
||||
const gen = ++savedConfigsGen.current;
|
||||
try {
|
||||
const configs = await getFlasherConfigs();
|
||||
if (gen === savedConfigsGen.current) {
|
||||
// The api helper returns undefined on a non-OK response.
|
||||
setSavedConfigs(configs ?? undefined);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (gen === savedConfigsGen.current) {
|
||||
setSavedConfigs(undefined);
|
||||
}
|
||||
}
|
||||
setSavedConfigs(await getFlasherConfigs());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -769,10 +750,8 @@ export function FlasherPage() {
|
|||
onChange={(e) => handleMediaFilterChange(e.currentTarget.value)}
|
||||
/>
|
||||
<div style={{marginTop: '1em', overflowX: 'auto'}}>
|
||||
{mediaLoading || mediaResults === null
|
||||
{mediaLoading
|
||||
? <Loading/>
|
||||
: mediaResults === undefined
|
||||
? <ErrorMessage>Could not search firmware.</ErrorMessage>
|
||||
: (mediaResults.length === 0
|
||||
? <p style={{opacity: 0.7}}>
|
||||
No <code>.bin</code> firmware found
|
||||
|
|
@ -833,9 +812,7 @@ export function FlasherPage() {
|
|||
Configure the firmware files and offsets, then save them as a named set to re-flash later
|
||||
(e.g. a Meshtastic T-Deck: firmware at <code>0x0</code>, littlefs at <code>0xc90000</code>).
|
||||
</p>
|
||||
{savedConfigs === null && <Placeholder lines={2}/>}
|
||||
{savedConfigs === undefined && <ErrorMessage>Could not fetch saved configurations.</ErrorMessage>}
|
||||
{Array.isArray(savedConfigs) && (savedConfigs.length > 0
|
||||
{savedConfigs.length > 0
|
||||
? <Panel style={{padding: 0}}>
|
||||
{savedConfigs.map((configuration, index) => <div
|
||||
key={configuration.name}
|
||||
|
|
@ -869,7 +846,7 @@ export function FlasherPage() {
|
|||
onClick={() => handleDeleteConfig(configuration.name)}/>
|
||||
</div>)}
|
||||
</Panel>
|
||||
: <p style={{opacity: 0.7}}>No saved configurations yet.</p>)}
|
||||
: <p style={{opacity: 0.7}}>No saved configurations yet.</p>}
|
||||
</>;
|
||||
|
||||
return <div {...getRootProps()}>
|
||||
|
|
|
|||
|
|
@ -265,43 +265,6 @@ describe('FlasherPage', () => {
|
|||
}
|
||||
});
|
||||
|
||||
// The Saved Firmwares tab is the default tab. "No saved configurations yet." may only appear once the
|
||||
// server confirmed there are none; a pending or failed fetch must not read as an empty list.
|
||||
it('shows a placeholder, not "no saved configurations", while the configs load', () => {
|
||||
Object.defineProperty(global.navigator, 'serial', {value: {}, configurable: true});
|
||||
getFlasherConfigs.mockReturnValue(new Promise(() => {
|
||||
}));
|
||||
const {container} = render(<FlasherPage/>);
|
||||
expect(container.querySelector('.wrolpi-placeholder')).toBeInTheDocument();
|
||||
expect(screen.queryAllByText('No saved configurations yet.')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('shows an error, not "no saved configurations", when the configs fetch failed', async () => {
|
||||
Object.defineProperty(global.navigator, 'serial', {value: {}, configurable: true});
|
||||
getFlasherConfigs.mockResolvedValue(undefined);
|
||||
render(<FlasherPage/>);
|
||||
expect((await screen.findAllByText(/Could not fetch saved configurations/)).length).toBeGreaterThan(0);
|
||||
expect(screen.queryAllByText('No saved configurations yet.')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('shows "no saved configurations" once the server confirmed there are none', async () => {
|
||||
Object.defineProperty(global.navigator, 'serial', {value: {}, configurable: true});
|
||||
render(<FlasherPage/>);
|
||||
expect((await screen.findAllByText('No saved configurations yet.')).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// "No .bin firmware found" may only appear once the server searched and found none.
|
||||
it('shows an error, not "no firmware found", when the firmware search failed', async () => {
|
||||
Object.defineProperty(global.navigator, 'serial', {value: {}, configurable: true});
|
||||
// The api helper returns undefined on a non-OK response.
|
||||
flasherSearch.mockResolvedValue(undefined);
|
||||
render(<FlasherPage/>);
|
||||
await waitFor(() => expect(flasherSearch).toHaveBeenCalled());
|
||||
switchTab('Choose from your WROLPi');
|
||||
expect((await screen.findAllByText(/Could not search firmware/)).length).toBeGreaterThan(0);
|
||||
expect(screen.queryAllByText(/firmware found/)).toHaveLength(0);
|
||||
});
|
||||
|
||||
// PageContainer renders both the mobile and desktop breakpoints (fresnel Media), so each label appears twice.
|
||||
it('warns when Web Serial is unavailable', () => {
|
||||
delete global.navigator.serial;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, {useEffect, useRef, useState} from "react";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {
|
||||
APIButton,
|
||||
ErrorMessage,
|
||||
|
|
@ -20,7 +20,6 @@ import {Media, StatusContext} from "../contexts/contexts";
|
|||
import {MAP_VIEWER_URI} from "./Vars";
|
||||
import MapViewer from "./MapViewer";
|
||||
import {AddToPlaylistButton} from "./AddToPlaylist";
|
||||
import {solarCalculatorPath} from "./calculators/solar";
|
||||
import maplibregl from "maplibre-gl";
|
||||
import {Protocol} from "pmtiles";
|
||||
import layers from "protomaps-themes-base";
|
||||
|
|
@ -285,15 +284,11 @@ function MapCatalogRow({item, subscribedRegions, fetchData}) {
|
|||
|
||||
function ManageMap() {
|
||||
const [files, setFiles] = useState(null);
|
||||
// null = pending, undefined = fetch failed, [] = no regions in the catalog.
|
||||
const [catalog, setCatalog] = useState(null);
|
||||
const [catalog, setCatalog] = useState([]);
|
||||
const [subscriptions, setSubscriptions] = useState([]);
|
||||
const [allRegionsOpen, setAllRegionsOpen] = useState(false);
|
||||
const {status} = React.useContext(StatusContext);
|
||||
const searchBuilding = status?.flags?.map_search_building;
|
||||
// Only the newest subscriptions request may write state: a subscribe or unsubscribe refetches
|
||||
// while the mount request may still be in flight.
|
||||
const subscriptionsGen = useRef(0);
|
||||
|
||||
const fetchFiles = async () => {
|
||||
try {
|
||||
|
|
@ -308,24 +303,14 @@ function ManageMap() {
|
|||
};
|
||||
|
||||
const fetchSubscriptionData = async () => {
|
||||
const gen = ++subscriptionsGen.current;
|
||||
try {
|
||||
const data = await fetchMapSubscriptions();
|
||||
if (gen !== subscriptionsGen.current) {
|
||||
return;
|
||||
}
|
||||
if (data) {
|
||||
setCatalog(data.catalog || []);
|
||||
setSubscriptions(data.subscriptions || []);
|
||||
} else {
|
||||
// The api helper already toasted the non-OK response.
|
||||
setCatalog(undefined);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (gen === subscriptionsGen.current) {
|
||||
setCatalog(undefined);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -412,8 +397,6 @@ function ManageMap() {
|
|||
</>;
|
||||
}
|
||||
|
||||
const catalogLoaded = Array.isArray(catalog);
|
||||
|
||||
/*
|
||||
* A fragment, not a `PageContainer`. `MapRoute` already provides the page chrome -- a
|
||||
* `wrolpi-stack` wrapper with its own top margin -- so a second one put this page a further
|
||||
|
|
@ -436,26 +419,18 @@ function ManageMap() {
|
|||
|
||||
<div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5em'}}>
|
||||
<Header as='h3'>Map Subscriptions</Header>
|
||||
{/* A pending or failed catalog is not "zero regions": the preview waits for one. The
|
||||
modal is mounted only then, so it receives the state array itself and its map is
|
||||
not rebuilt by a fresh `[]` on every status tick. */}
|
||||
<Button size='xs' icon='globe' disabled={!catalogLoaded} onClick={() => setAllRegionsOpen(true)}>
|
||||
View All Regions
|
||||
</Button>
|
||||
<Button size='xs' icon='globe' onClick={() => setAllRegionsOpen(true)}>View All Regions</Button>
|
||||
</div>
|
||||
|
||||
{catalogLoaded && <AllRegionsPreviewModal catalog={catalog} open={allRegionsOpen}
|
||||
onClose={() => setAllRegionsOpen(false)}/>}
|
||||
<AllRegionsPreviewModal catalog={catalog} open={allRegionsOpen} onClose={() => setAllRegionsOpen(false)}/>
|
||||
|
||||
{catalog === undefined
|
||||
? <ErrorMessage>Could not fetch map subscriptions</ErrorMessage>
|
||||
: <SortableTable
|
||||
tableHeaders={catalogHeaders}
|
||||
data={catalog}
|
||||
rowFunc={catalogRowFunc}
|
||||
rowKey='region'
|
||||
defaultSortColumn='name'
|
||||
/>}
|
||||
<SortableTable
|
||||
tableHeaders={catalogHeaders}
|
||||
data={catalog}
|
||||
rowFunc={catalogRowFunc}
|
||||
rowKey='region'
|
||||
defaultSortColumn='name'
|
||||
/>
|
||||
|
||||
<InfoMessage storageName='hint_map_subscribe'>
|
||||
<div style={{fontWeight: 600, marginBottom: 4}}>How to get map files</div>
|
||||
|
|
@ -522,29 +497,17 @@ function PinEditRow({pin, onSave, onCancel}) {
|
|||
}
|
||||
|
||||
function MapPins() {
|
||||
// null = pending, undefined = fetch failed, [] = no pins. The empty row invites the user to
|
||||
// add one, so it may only appear for a confirmed empty list.
|
||||
const [pins, setPins] = useState(null);
|
||||
const [pins, setPins] = useState([]);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const navigate = useNavigate();
|
||||
// Only the newest pins request may write state: an edit or delete refetches while the mount
|
||||
// request may still be in flight.
|
||||
const pinsGen = useRef(0);
|
||||
|
||||
const fetchPins = async () => {
|
||||
const gen = ++pinsGen.current;
|
||||
try {
|
||||
const data = await getMapPins();
|
||||
if (gen === pinsGen.current) {
|
||||
// The api helper returns undefined on a non-OK response.
|
||||
setPins(data ? data.pins || [] : undefined);
|
||||
}
|
||||
if (data) setPins(data.pins || []);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (gen === pinsGen.current) {
|
||||
setPins(undefined);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -568,9 +531,6 @@ function MapPins() {
|
|||
navigate(`/map?lat=${pin.lat}&lon=${pin.lon}&z=14`);
|
||||
};
|
||||
|
||||
const solarButton = (pin) => <Button size='xs' icon='sun' aria-label={`Solar estimate for ${pin.label}`}
|
||||
onClick={() => navigate(solarCalculatorPath(pin.lat, pin.lon))}/>;
|
||||
|
||||
// --- Desktop headers and row ---
|
||||
const fullHeaders = [
|
||||
{key: 'color', text: '', sortBy: i => i.color},
|
||||
|
|
@ -604,11 +564,10 @@ function MapPins() {
|
|||
</Table.Cell>
|
||||
<Table.Cell>{pin.created}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{/* See PinEditRow: a Group so the controls line up and are spaced. */}
|
||||
{/* See PinEditRow: a Group so the three controls line up and are spaced. */}
|
||||
<Group gap='xs' wrap='nowrap'>
|
||||
<Button size='xs' icon='edit' aria-label={`Edit ${pin.label}`}
|
||||
onClick={() => setEditingId(pin.id)}/>
|
||||
{solarButton(pin)}
|
||||
<AddToPlaylistButton
|
||||
size='xs'
|
||||
icon='list'
|
||||
|
|
@ -651,18 +610,15 @@ function MapPins() {
|
|||
<div style={{fontSize: '0.6875rem', color: 'var(--muted)'}}>{pin.lat.toFixed(4)}, {pin.lon.toFixed(4)}</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Group gap='xs' wrap='nowrap'>
|
||||
{solarButton(pin)}
|
||||
<APIButton
|
||||
size='xs'
|
||||
role='danger'
|
||||
icon='trash'
|
||||
aria-label={`Delete ${pin.label}`}
|
||||
confirmContent='Delete this pin?'
|
||||
confirmButton='Delete'
|
||||
onClick={() => handleDelete(pin.id)}
|
||||
/>
|
||||
</Group>
|
||||
<APIButton
|
||||
size='xs'
|
||||
role='danger'
|
||||
icon='trash'
|
||||
aria-label={`Delete ${pin.label}`}
|
||||
confirmContent='Delete this pin?'
|
||||
confirmButton='Delete'
|
||||
onClick={() => handleDelete(pin.id)}
|
||||
/>
|
||||
</Table.Cell>
|
||||
</Table.Row>;
|
||||
};
|
||||
|
|
@ -671,13 +627,8 @@ function MapPins() {
|
|||
<Table.Cell colSpan={5}>No pins. Right-click on the map to add one.</Table.Cell>
|
||||
</Table.Row>;
|
||||
|
||||
if (pins === undefined) {
|
||||
return <ErrorMessage>Could not fetch map pins</ErrorMessage>;
|
||||
}
|
||||
|
||||
const lowerFilter = filter.toLowerCase();
|
||||
// Pending (null) passes through so SortableTable shows its placeholder.
|
||||
const filteredPins = lowerFilter && pins
|
||||
const filteredPins = lowerFilter
|
||||
? pins.filter(p =>
|
||||
(p.label || '').toLowerCase().includes(lowerFilter) ||
|
||||
`${p.lat.toFixed(4)}, ${p.lon.toFixed(4)}`.includes(lowerFilter)
|
||||
|
|
|
|||
|
|
@ -1,63 +0,0 @@
|
|||
import {poiPopupHtml} from './mapPoiPopup';
|
||||
|
||||
// Clicking a POI on the map opens a popup built from attributes already in the pmtiles
|
||||
// file: the name, its category (kind), sub-category (kind_detail), and elevation.
|
||||
|
||||
const feature = (properties) => ({properties});
|
||||
|
||||
test('name, kind, detail and elevation are all shown, humanized', () => {
|
||||
const html = poiPopupHtml([feature({
|
||||
name: 'Flattop Mountain', kind: 'peak', kind_detail: 'volcano', elevation: 1070,
|
||||
})]);
|
||||
expect(html).toContain('Flattop Mountain');
|
||||
expect(html).toContain('Peak');
|
||||
expect(html).toContain('Volcano');
|
||||
expect(html).toContain('1070 m');
|
||||
expect(html).toContain('3510 ft');
|
||||
});
|
||||
|
||||
test('missing attributes are left out rather than shown as undefined', () => {
|
||||
const html = poiPopupHtml([feature({name: 'Franz', kind: 'bakery'})]);
|
||||
expect(html).toContain('Franz');
|
||||
expect(html).toContain('Bakery');
|
||||
expect(html).not.toContain('undefined');
|
||||
expect(html).not.toContain('Elevation');
|
||||
});
|
||||
|
||||
test('kind_detail of "none" is not shown', () => {
|
||||
const html = poiPopupHtml([feature({name: 'West High', kind: 'school', kind_detail: 'none'})]);
|
||||
expect(html).not.toContain('none');
|
||||
expect(html).not.toContain('None');
|
||||
});
|
||||
|
||||
test('the same feature at several min_zoom levels appears once', () => {
|
||||
const html = poiPopupHtml([
|
||||
feature({name: 'Westchester Lagoon', kind: 'water', min_zoom: 12}),
|
||||
feature({name: 'Westchester Lagoon', kind: 'water', min_zoom: 14}),
|
||||
]);
|
||||
expect(html.match(/Westchester Lagoon/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('feature properties are HTML escaped', () => {
|
||||
const html = poiPopupHtml([feature({name: '<img src=x onerror=alert(1)>', kind: 'a&b'})]);
|
||||
expect(html).not.toContain('<img');
|
||||
expect(html).toContain('<img');
|
||||
expect(html).toContain('A&b');
|
||||
});
|
||||
|
||||
test('a point feature shows its own coordinates, lat then lon', () => {
|
||||
const html = poiPopupHtml([{
|
||||
properties: {name: 'Peak Two', kind: 'peak'},
|
||||
geometry: {type: 'Point', coordinates: [-149.67301, 61.08571]},
|
||||
}], {lng: -149.5, lat: 61.0});
|
||||
expect(html).toContain('61.08571, -149.67301');
|
||||
});
|
||||
|
||||
test('a feature without a point falls back to the clicked location', () => {
|
||||
const html = poiPopupHtml([feature({name: 'Lagoon', kind: 'water'})], {lng: -149.5, lat: 61.0});
|
||||
expect(html).toContain('61.00000, -149.50000');
|
||||
});
|
||||
|
||||
test('no features gives no popup', () => {
|
||||
expect(poiPopupHtml([])).toBeNull();
|
||||
});
|
||||
|
|
@ -2,7 +2,7 @@ import React, {useEffect, useState} from "react";
|
|||
import {Link, useNavigate} from "react-router";
|
||||
import {getMapSearchStatus, searchMap} from "../api";
|
||||
import {Card, CardGroup, Header, Loading, Panel} from "./ui";
|
||||
import {ErrorMessage, Paginator} from "./Common";
|
||||
import {Paginator} from "./Common";
|
||||
import {useLatestRequest, usePages} from "../hooks/customHooks";
|
||||
import {QueryContext} from "../contexts/contexts";
|
||||
import {MAP_VIEWER_URI} from "./Vars";
|
||||
|
|
@ -68,24 +68,16 @@ export function MapSearchView() {
|
|||
const {offset, limit, activePage, setPage, totalPages, setTotal} = usePages(12);
|
||||
// useLatestRequest discards stale responses and catches errors, so rapid pagination
|
||||
// can't show out-of-order results and a network failure can't leave the spinner stuck.
|
||||
// Loading from the first render: a query of two or more characters always sends a request (below),
|
||||
// so this settles, and the shorter-query branches render before the loading check.
|
||||
const {data, sendRequest, loading} = useLatestRequest(300, true);
|
||||
// null = unknown (pending, or the status could not be fetched), otherwise whether any map is
|
||||
// installed. Only a real status may claim that no maps are installed.
|
||||
const {data, sendRequest, loading} = useLatestRequest(300);
|
||||
const [hasMaps, setHasMaps] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const status = await getMapSearchStatus();
|
||||
if (!cancelled && status) {
|
||||
const installed = (status.indexed || []).length + (status.missing || []).length;
|
||||
setHasMaps(installed > 0);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
const status = await getMapSearchStatus();
|
||||
if (!cancelled) {
|
||||
const installed = (status.indexed || []).length + (status.missing || []).length;
|
||||
setHasMaps(installed > 0);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
|
|
@ -114,22 +106,12 @@ export function MapSearchView() {
|
|||
</Panel>;
|
||||
}
|
||||
|
||||
if (!searchStr) {
|
||||
return <Panel><Header as="h4">Enter a search query to find map locations.</Header></Panel>;
|
||||
}
|
||||
|
||||
if (searchStr.length < 2) {
|
||||
return <Panel><Header as="h4">Enter at least two characters to find map locations.</Header></Panel>;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <Panel><Loading/></Panel>;
|
||||
}
|
||||
|
||||
// Settled with no data: the request failed (the api helper returns undefined on a non-OK
|
||||
// response, and useLatestRequest leaves null when it throws).
|
||||
if (data == null) {
|
||||
return <Panel><ErrorMessage>Could not search the map.</ErrorMessage></Panel>;
|
||||
if (!searchStr) {
|
||||
return <Panel><Header as="h4">Enter a search query to find map locations.</Header></Panel>;
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
|
|
|
|||
|
|
@ -1,61 +0,0 @@
|
|||
import React from 'react';
|
||||
import {render, screen} from '../test-utils';
|
||||
import {MapSearchView} from './MapSearchView';
|
||||
import {getMapSearchStatus, searchMap} from '../api';
|
||||
|
||||
// The map search tab says "No map locations found" only when the server searched and found none,
|
||||
// and "No maps are installed" only when the server said so. A failed request is an error.
|
||||
|
||||
jest.mock('../api', () => ({
|
||||
...jest.requireActual('../api'),
|
||||
getMapSearchStatus: jest.fn(),
|
||||
searchMap: jest.fn(),
|
||||
}));
|
||||
|
||||
const NOT_FOUND = /No map locations found/;
|
||||
const NO_MAPS = /No maps are installed/;
|
||||
const FAILED = /Could not search the map/;
|
||||
|
||||
const renderAt = (q) => render(<MapSearchView/>, {query: {searchParams: new URLSearchParams({q})}});
|
||||
|
||||
beforeEach(() => {
|
||||
getMapSearchStatus.mockReset();
|
||||
searchMap.mockReset();
|
||||
getMapSearchStatus.mockResolvedValue({indexed: ['utah'], missing: []});
|
||||
});
|
||||
|
||||
test('found nothing: says so', async () => {
|
||||
searchMap.mockResolvedValue({results: [], total: 0});
|
||||
renderAt('camp');
|
||||
expect(await screen.findByText(NOT_FOUND)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a failed search shows an error, not "No map locations found"', async () => {
|
||||
searchMap.mockResolvedValue(undefined);
|
||||
renderAt('camp');
|
||||
expect(await screen.findByText(FAILED)).toBeInTheDocument();
|
||||
expect(screen.queryByText(NOT_FOUND)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a failed status check does not claim no maps are installed', async () => {
|
||||
getMapSearchStatus.mockResolvedValue(undefined);
|
||||
searchMap.mockResolvedValue({results: [], total: 0});
|
||||
renderAt('camp');
|
||||
expect(await screen.findByText(NOT_FOUND)).toBeInTheDocument();
|
||||
expect(screen.queryByText(NO_MAPS)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a thrown status check does not claim no maps are installed', async () => {
|
||||
getMapSearchStatus.mockRejectedValue(new Error('down'));
|
||||
searchMap.mockResolvedValue({results: [], total: 0});
|
||||
renderAt('camp');
|
||||
expect(await screen.findByText(NOT_FOUND)).toBeInTheDocument();
|
||||
expect(screen.queryByText(NO_MAPS)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('the server saying no maps are installed is shown', async () => {
|
||||
getMapSearchStatus.mockResolvedValue({indexed: [], missing: []});
|
||||
searchMap.mockResolvedValue({results: [], total: 0});
|
||||
renderAt('camp');
|
||||
expect(await screen.findByText(NO_MAPS)).toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
import React from 'react';
|
||||
import {act, fireEvent, screen} from '@testing-library/react';
|
||||
import {Route, Routes} from 'react-router';
|
||||
import {renderWithProviders as render} from '../test-utils';
|
||||
import {MapRoute} from './Map';
|
||||
|
||||
// What the map's Pins and Manage tabs show while their lists are still loading, when the
|
||||
// fetch failed, and when the server confirmed the list is empty. The empty messages invite
|
||||
// the user to act ("Right-click on the map to add one"), so they may only appear for a
|
||||
// confirmed empty list.
|
||||
|
||||
jest.mock('maplibre-gl', () => ({
|
||||
__esModule: true,
|
||||
default: {Map: class {
|
||||
}, addProtocol: jest.fn(), Marker: class {
|
||||
}},
|
||||
}));
|
||||
jest.mock('pmtiles', () => ({
|
||||
__esModule: true, Protocol: class {
|
||||
tile = jest.fn();
|
||||
},
|
||||
}));
|
||||
jest.mock('protomaps-themes-base', () => ({__esModule: true, default: () => []}));
|
||||
jest.mock('./MapViewer', () => ({__esModule: true, default: () => null}));
|
||||
|
||||
jest.mock('../api', () => ({
|
||||
...jest.requireActual('../api'),
|
||||
getMapFiles: jest.fn(),
|
||||
fetchMapSubscriptions: jest.fn(),
|
||||
getMapPins: jest.fn(),
|
||||
}));
|
||||
|
||||
const api = require('../api');
|
||||
|
||||
const pending = () => new Promise(() => {
|
||||
});
|
||||
const NO_PINS = /No pins\. Right-click/;
|
||||
const NO_RESULTS = /^No results$/;
|
||||
|
||||
// The pins page renders one table per breakpoint through `Media`, so the real media provider is
|
||||
// needed; the queries below tolerate either variant being in the tree.
|
||||
const renderAt = (route) => render(
|
||||
<Routes><Route path='/map/*' element={<MapRoute/>}/></Routes>,
|
||||
{route, withMedia: true},
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// The media provider reads matchMedia; the global stub's implementation is reset between
|
||||
// tests, so give it one here. Only the computer breakpoint (1024px) matches, so the
|
||||
// provider reports a desktop viewport and the pins page renders its desktop table.
|
||||
window.matchMedia = jest.fn().mockImplementation(query => ({
|
||||
matches: query.includes('1024'), media: query, onchange: null,
|
||||
addListener: jest.fn(), removeListener: jest.fn(),
|
||||
addEventListener: jest.fn(), removeEventListener: jest.fn(), dispatchEvent: jest.fn(),
|
||||
}));
|
||||
api.getMapFiles.mockResolvedValue({files: []});
|
||||
api.fetchMapSubscriptions.mockResolvedValue({catalog: [], subscriptions: []});
|
||||
api.getMapPins.mockResolvedValue({pins: []});
|
||||
});
|
||||
|
||||
describe('the Pins tab', () => {
|
||||
test('pending: shows a placeholder, not "No pins"', () => {
|
||||
api.getMapPins.mockReturnValue(pending());
|
||||
const {container} = renderAt('/map/pins');
|
||||
expect(container.querySelector('.wrolpi-placeholder')).toBeInTheDocument();
|
||||
expect(screen.queryAllByText(NO_PINS)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('failed: shows an error, not "No pins"', async () => {
|
||||
// The api helper returns undefined on a non-OK response.
|
||||
api.getMapPins.mockResolvedValue(undefined);
|
||||
renderAt('/map/pins');
|
||||
await act(async () => {
|
||||
});
|
||||
expect(screen.getByText(/Could not fetch map pins/)).toBeInTheDocument();
|
||||
expect(screen.queryAllByText(NO_PINS)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('empty: shows "No pins"', async () => {
|
||||
renderAt('/map/pins');
|
||||
await act(async () => {
|
||||
});
|
||||
expect(screen.getAllByText(NO_PINS).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('loaded: lists the pins', async () => {
|
||||
api.getMapPins.mockResolvedValue({pins: [{id: 1, lat: 40.1, lon: -111.2, label: 'Camp', color: '#f00'}]});
|
||||
renderAt('/map/pins');
|
||||
await act(async () => {
|
||||
});
|
||||
expect(screen.getAllByText('Camp').length).toBeGreaterThan(0);
|
||||
expect(screen.queryAllByText(NO_PINS)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('a pin opens the Solar calculator at its location', async () => {
|
||||
api.getMapPins.mockResolvedValue({pins: [{id: 1, lat: 40.12346, lon: -111.2, label: 'Camp', color: '#f00'}]});
|
||||
renderAt('/map/pins');
|
||||
await act(async () => {
|
||||
});
|
||||
fireEvent.click(screen.getAllByRole('button', {name: 'Solar estimate for Camp'})[0]);
|
||||
expect(window.location.pathname).toBe('/more/calculators');
|
||||
expect(window.location.search).toBe('?calc=solar&lat=40.1235&lon=-111.2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Manage tab subscriptions table', () => {
|
||||
test('pending: shows a placeholder, not "No results"', async () => {
|
||||
api.fetchMapSubscriptions.mockReturnValue(pending());
|
||||
const {container} = renderAt('/map/manage');
|
||||
await act(async () => {
|
||||
});
|
||||
// The files table has loaded (empty); the subscriptions table is still pending.
|
||||
expect(container.querySelector('.wrolpi-placeholder')).toBeInTheDocument();
|
||||
expect(screen.queryByText(NO_RESULTS)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('failed: shows an error, not "No results"', async () => {
|
||||
api.fetchMapSubscriptions.mockResolvedValue(undefined);
|
||||
renderAt('/map/manage');
|
||||
await act(async () => {
|
||||
});
|
||||
expect(screen.getByText(/Could not fetch map subscriptions/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(NO_RESULTS)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('empty: shows "No results"', async () => {
|
||||
renderAt('/map/manage');
|
||||
await act(async () => {
|
||||
});
|
||||
expect(screen.getByText(NO_RESULTS)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The "View All Regions" preview draws the catalog; a pending or failed catalog must not be
|
||||
// previewed as zero regions.
|
||||
test('pending: the regions preview is disabled', async () => {
|
||||
api.fetchMapSubscriptions.mockReturnValue(pending());
|
||||
renderAt('/map/manage');
|
||||
await act(async () => {
|
||||
});
|
||||
expect(screen.getByRole('button', {name: 'View All Regions'})).toBeDisabled();
|
||||
});
|
||||
|
||||
test('failed: the regions preview is disabled', async () => {
|
||||
api.fetchMapSubscriptions.mockResolvedValue(undefined);
|
||||
renderAt('/map/manage');
|
||||
await act(async () => {
|
||||
});
|
||||
expect(screen.getByRole('button', {name: 'View All Regions'})).toBeDisabled();
|
||||
});
|
||||
|
||||
test('loaded: the regions preview is enabled', async () => {
|
||||
renderAt('/map/manage');
|
||||
await act(async () => {
|
||||
});
|
||||
expect(screen.getByRole('button', {name: 'View All Regions'})).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import React, {useCallback, useContext, useEffect, useRef, useState} from "react";
|
||||
import {useNavigate, useSearchParams} from "react-router";
|
||||
import {useSearchParams} from "react-router";
|
||||
import maplibregl from "maplibre-gl";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
import {Protocol} from "pmtiles";
|
||||
|
|
@ -10,8 +10,6 @@ import {MAP_VIEWER_URI} from "./Vars";
|
|||
import {Button, Checkbox, Header, Icon, Panel, TextInput, Toggle} from "./ui";
|
||||
import {SettingsContext, ThemeContext} from "../contexts/contexts";
|
||||
import {mapFlavor, mapSprite} from "../themes/names";
|
||||
import {poiPopupHtml} from "./mapPoiPopup";
|
||||
import {solarCalculatorPath} from "./calculators/solar";
|
||||
|
||||
// Terrain DEM file prefix for hillshade and contours.
|
||||
const TERRAIN_PREFIX = "terrain-";
|
||||
|
|
@ -423,13 +421,10 @@ function MapSearch({map}) {
|
|||
lon = center.lng;
|
||||
}
|
||||
|
||||
// A failed search (undefined) shows no dropdown, same as finding nothing: this is an
|
||||
// as-you-type suggestion list, not a results page.
|
||||
searchMap(q, 8, 0, lat, lon).then(data => {
|
||||
const found = data?.results || [];
|
||||
setResults(found);
|
||||
setShowResults(found.length > 0);
|
||||
}).catch(e => console.error(e));
|
||||
setResults(data.results || []);
|
||||
setShowResults((data.results || []).length > 0);
|
||||
});
|
||||
}, [map]);
|
||||
|
||||
const handleInput = useCallback((e) => {
|
||||
|
|
@ -532,7 +527,6 @@ export default function MapViewer() {
|
|||
const [mapReady, setMapReady] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [contextMenu, setContextMenu] = useState(null);
|
||||
const navigate = useNavigate();
|
||||
const [addingPin, setAddingPin] = useState(null); // {lat, lon} when pin dialog is open
|
||||
const markersRef = useRef([]);
|
||||
const pinsVisibleRef = useRef(true);
|
||||
|
|
@ -678,29 +672,6 @@ export default function MapViewer() {
|
|||
map.on("click", () => setContextMenu(null));
|
||||
map.on("movestart", () => setContextMenu(null));
|
||||
|
||||
// Click a POI to see the details the pmtiles file already has (kind, detail, elevation).
|
||||
const poiLayerIds = () => (map.getStyle()?.layers || [])
|
||||
.map(l => l.id)
|
||||
.filter(id => id === "pois" || id.endsWith(":pois"))
|
||||
.filter(id => map.getLayer(id));
|
||||
map.on("click", (e) => {
|
||||
if (destroyed) return;
|
||||
const layers = poiLayerIds();
|
||||
if (!layers.length) return;
|
||||
const html = poiPopupHtml(map.queryRenderedFeatures(e.point, {layers}), e.lngLat);
|
||||
if (!html) return;
|
||||
new maplibregl.Popup({offset: 8, maxWidth: "260px", className: "wrolpi-map-popup"})
|
||||
.setLngLat(e.lngLat)
|
||||
.setHTML(html)
|
||||
.addTo(map);
|
||||
});
|
||||
map.on("mousemove", (e) => {
|
||||
if (destroyed) return;
|
||||
const layers = poiLayerIds();
|
||||
const hit = layers.length && map.queryRenderedFeatures(e.point, {layers}).length;
|
||||
map.getCanvas().style.cursor = hit ? "pointer" : "";
|
||||
});
|
||||
|
||||
// Long-press context menu (mobile).
|
||||
let longPressTimer = null;
|
||||
let touchStartPos = null;
|
||||
|
|
@ -774,16 +745,12 @@ export default function MapViewer() {
|
|||
}
|
||||
mapRef.current = null;
|
||||
setMapReady(false);
|
||||
// Clean up map params from URL so they don't leak to other pages. Only while the URL is
|
||||
// still a map page: after navigating away, lat/lon belong to the new page (the Solar
|
||||
// calculator, for one).
|
||||
// Clean up map params from URL so they don't leak to other pages.
|
||||
const url = new URL(window.location);
|
||||
if (url.pathname.startsWith("/map")) {
|
||||
url.searchParams.delete("lat");
|
||||
url.searchParams.delete("lon");
|
||||
url.searchParams.delete("z");
|
||||
window.history.replaceState(null, '', url.pathname + url.search);
|
||||
}
|
||||
url.searchParams.delete("lat");
|
||||
url.searchParams.delete("lon");
|
||||
url.searchParams.delete("z");
|
||||
window.history.replaceState(null, '', url.pathname + url.search);
|
||||
};
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
|
|
@ -929,12 +896,6 @@ export default function MapViewer() {
|
|||
setContextMenu(null);
|
||||
}, [contextMenu]);
|
||||
|
||||
const handleSolar = useCallback(() => {
|
||||
if (!contextMenu) return;
|
||||
setContextMenu(null);
|
||||
navigate(solarCalculatorPath(contextMenu.lat, contextMenu.lon));
|
||||
}, [contextMenu, navigate]);
|
||||
|
||||
const handleSetDefaultLocation = useCallback(async () => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
|
@ -1011,14 +972,6 @@ export default function MapViewer() {
|
|||
>
|
||||
Add Pin Here
|
||||
</div>
|
||||
<div
|
||||
style={{padding: "0.5rem 0.75rem", cursor: "pointer", userSelect: "none"}}
|
||||
onMouseEnter={e => e.target.style.background = "#f0f0f0"}
|
||||
onMouseLeave={e => e.target.style.background = "transparent"}
|
||||
onClick={handleSolar}
|
||||
>
|
||||
Calculate Solar Performance
|
||||
</div>
|
||||
<div
|
||||
style={{padding: "0.5rem 0.75rem", cursor: "pointer", userSelect: "none"}}
|
||||
onMouseEnter={e => e.target.style.background = "#f0f0f0"}
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
import React from 'react';
|
||||
import {renderWithProviders as render} from '../test-utils';
|
||||
import MapViewer from './MapViewer';
|
||||
|
||||
// MapLibre needs WebGL, which jsdom lacks; these tests only cover what the viewer does to the URL.
|
||||
jest.mock('maplibre-gl', () => ({
|
||||
__esModule: true,
|
||||
default: {Map: class {
|
||||
}, addProtocol: jest.fn(), Marker: class {
|
||||
}, Popup: class {
|
||||
}},
|
||||
}));
|
||||
jest.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}));
|
||||
jest.mock('pmtiles', () => ({
|
||||
__esModule: true, Protocol: class {
|
||||
tile = jest.fn();
|
||||
},
|
||||
}));
|
||||
jest.mock('protomaps-themes-base', () => ({__esModule: true, default: () => []}));
|
||||
jest.mock('@acalcutt/maplibre-contour-pmtiles', () => ({__esModule: true, default: {}}));
|
||||
jest.mock('../api', () => ({
|
||||
...jest.requireActual('../api'),
|
||||
// Never resolves: the map never initializes, which these tests do not need.
|
||||
getMapFiles: jest.fn(() => new Promise(() => {
|
||||
})),
|
||||
getMapPins: jest.fn(() => new Promise(() => {
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('the map position in the URL', () => {
|
||||
test('is removed when the map closes', () => {
|
||||
const {unmount} = render(<MapViewer/>, {route: '/map?lat=40&lon=-105&z=8&layer=a'});
|
||||
unmount();
|
||||
expect(window.location.pathname).toBe('/map');
|
||||
expect(window.location.search).toBe('?layer=a');
|
||||
});
|
||||
|
||||
test('is not removed from the page the map navigated to', () => {
|
||||
// "Calculate Solar Performance" navigates to a page whose own lat/lon must survive the map closing.
|
||||
const {unmount} = render(<MapViewer/>, {route: '/map?lat=40&lon=-105&z=8'});
|
||||
window.history.pushState({}, '', '/more/calculators?calc=solar&lat=40.0154&lon=-105.2705');
|
||||
unmount();
|
||||
expect(window.location.search).toBe('?calc=solar&lat=40.0154&lon=-105.2705');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,170 +0,0 @@
|
|||
import React from "react";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import DOMPurify from "dompurify";
|
||||
import {useLocalStorage} from "./Common";
|
||||
import {Button, ButtonGroup, Loading, Message} from "./ui";
|
||||
|
||||
/*
|
||||
* Raw HTML in the file is escaped, not parsed: markdown files arrive with downloads, and the preview
|
||||
* renders on the API's origin. `validateLink` (on by default) refuses javascript:, vbscript:, file:,
|
||||
* and non-image data: URLs. DOMPurify runs anyway, so neither guard is the only one.
|
||||
*/
|
||||
const markdown = new MarkdownIt({html: false, linkify: true});
|
||||
|
||||
// A URL with a scheme (https:, mailto:, ...) points somewhere other than the media directory.
|
||||
const URL_WITH_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
|
||||
|
||||
/**
|
||||
* Resolve a URL written in a markdown file against the file's own media URL, the way a browser
|
||||
* would resolve it against a document in that directory.
|
||||
*
|
||||
* Returns the URL unchanged if it is not relative, or null if it climbs out of the media directory;
|
||||
* only media is served relative to a file.
|
||||
*/
|
||||
function resolveRelativeURL(url, fileURL) {
|
||||
if (!url || URL_WITH_SCHEME.test(url) || url.startsWith('/') || url.startsWith('#')) {
|
||||
return url;
|
||||
}
|
||||
const resolved = new URL(url, new URL(fileURL, window.location.origin));
|
||||
if (!resolved.pathname.startsWith('/media/')) {
|
||||
return null;
|
||||
}
|
||||
return resolved.pathname + resolved.search + resolved.hash;
|
||||
}
|
||||
|
||||
function rewriteURL(token, attr, fileURL) {
|
||||
const url = token.attrGet(attr);
|
||||
if (url === null) {
|
||||
return;
|
||||
}
|
||||
const resolved = resolveRelativeURL(url, fileURL);
|
||||
if (resolved === null) {
|
||||
token.attrs = token.attrs.filter(([name]) => name !== attr);
|
||||
} else {
|
||||
token.attrSet(attr, resolved);
|
||||
}
|
||||
}
|
||||
|
||||
function openInNewTab(token) {
|
||||
const href = token.attrGet('href');
|
||||
// Following a link would close the preview; a fragment stays within the document.
|
||||
if (href && !href.startsWith('#')) {
|
||||
token.attrSet('target', '_blank');
|
||||
token.attrSet('rel', 'noopener noreferrer');
|
||||
}
|
||||
}
|
||||
|
||||
/** An image the preview may load: one served by this WROLPi, or embedded in the file. */
|
||||
function isLocalImage(src) {
|
||||
return !src || src.startsWith('data:') || (src.startsWith('/') && !src.startsWith('//'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace an image from another site with a link to it. Loading it would tell that site who is
|
||||
* reading the file and when (a tracking pixel), so the reader chooses whether to follow it.
|
||||
*
|
||||
* Inside a link (a README badge) the image becomes the outer link's text; links cannot nest.
|
||||
*/
|
||||
function replaceRemoteImage(state, image, insideLink) {
|
||||
const src = image.attrGet('src');
|
||||
const text = new state.Token('text', '', 0);
|
||||
text.content = image.content || src;
|
||||
if (insideLink) {
|
||||
return [text];
|
||||
}
|
||||
const linkOpen = new state.Token('link_open', 'a', 1);
|
||||
linkOpen.attrSet('href', src);
|
||||
openInNewTab(linkOpen);
|
||||
return [linkOpen, text, new state.Token('link_close', 'a', -1)];
|
||||
}
|
||||
|
||||
markdown.core.ruler.push('wrolpi_media_urls', (state) => {
|
||||
const {fileURL} = state.env;
|
||||
for (const token of state.tokens) {
|
||||
if (!token.children) {
|
||||
continue;
|
||||
}
|
||||
let insideLink = false;
|
||||
token.children = token.children.flatMap(child => {
|
||||
if (child.type === 'image') {
|
||||
rewriteURL(child, 'src', fileURL);
|
||||
return isLocalImage(child.attrGet('src')) ? [child] : replaceRemoteImage(state, child, insideLink);
|
||||
} else if (child.type === 'link_open') {
|
||||
insideLink = true;
|
||||
rewriteURL(child, 'href', fileURL);
|
||||
openInNewTab(child);
|
||||
} else if (child.type === 'link_close') {
|
||||
insideLink = false;
|
||||
}
|
||||
return [child];
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/** Render markdown to sanitized HTML. Relative URLs resolve against `fileURL`, the file's /media/ URL. */
|
||||
export function renderMarkdown(text, fileURL) {
|
||||
const html = markdown.render(text, {fileURL});
|
||||
return DOMPurify.sanitize(html, {ADD_ATTR: ['target']});
|
||||
}
|
||||
|
||||
export function isMarkdownFile(mimetype, lowerPath) {
|
||||
// By suffix too, so a markdown file whose stored type is still text/plain (or a language libmagic
|
||||
// guessed) is not shown raw; the 2026_10_05_1200 migration retags those rows, a refresh does not.
|
||||
return mimetype.startsWith('text/markdown')
|
||||
|| (mimetype.startsWith('text/') && (lowerPath.endsWith('.md') || lowerPath.endsWith('.markdown')));
|
||||
}
|
||||
|
||||
/** Preview a markdown file, formatted or as its raw source. */
|
||||
export function MarkdownPreview({url}) {
|
||||
const [formatted, setFormatted] = useLocalStorage('markdownPreviewFormatted', true);
|
||||
const [text, setText] = React.useState(null);
|
||||
const [error, setError] = React.useState(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setText(null);
|
||||
setError(null);
|
||||
fetch(url, {signal: controller.signal})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status} ${response.statusText}`);
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.then(setText)
|
||||
.catch(e => {
|
||||
if (e.name !== 'AbortError') {
|
||||
console.error(e);
|
||||
setError(e.message);
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [url]);
|
||||
|
||||
const html = React.useMemo(() => text === null ? null : renderMarkdown(text, url), [text, url]);
|
||||
|
||||
let body;
|
||||
if (error) {
|
||||
body = <Message kind='error' title='Could not load file'>{error}</Message>;
|
||||
} else if (text === null) {
|
||||
body = <Loading/>;
|
||||
} else if (formatted) {
|
||||
body = <div className='markdown-body' dangerouslySetInnerHTML={{__html: html}}/>;
|
||||
} else {
|
||||
body = <pre className='markdown-raw' data-testid='markdown-raw'>{text}</pre>;
|
||||
}
|
||||
|
||||
return <>
|
||||
<ButtonGroup>
|
||||
<Button size='small' variant={formatted ? 'filled' : 'default'} onClick={() => setFormatted(true)}>
|
||||
Formatted
|
||||
</Button>
|
||||
<Button size='small' variant={formatted ? 'default' : 'filled'} onClick={() => setFormatted(false)}>
|
||||
Raw
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<div className='markdown-preview'>
|
||||
{body}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
|
@ -1,198 +0,0 @@
|
|||
import React from 'react';
|
||||
import {fireEvent, screen, waitFor} from '@testing-library/react';
|
||||
import {isMarkdownFile, MarkdownPreview, renderMarkdown} from './MarkdownPreview';
|
||||
import {renderUI} from '../test-utils';
|
||||
|
||||
const FILE_URL = '/media/notes/water/storage.md';
|
||||
|
||||
/** Render markdown into a detached element so assertions read the DOM, not the HTML string. */
|
||||
const renderToDOM = (text, fileURL = FILE_URL) => {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = renderMarkdown(text, fileURL);
|
||||
return div;
|
||||
};
|
||||
|
||||
describe('renderMarkdown', () => {
|
||||
test('formats headings, emphasis, lists, code, and tables', () => {
|
||||
const div = renderToDOM([
|
||||
'# Water',
|
||||
'',
|
||||
'Rotate *every* barrel.',
|
||||
'',
|
||||
'- one',
|
||||
'- two',
|
||||
'',
|
||||
'```',
|
||||
'boil 1 minute',
|
||||
'```',
|
||||
'',
|
||||
'| a | b |',
|
||||
'|---|---|',
|
||||
'| 1 | 2 |',
|
||||
].join('\n'));
|
||||
|
||||
expect(div.querySelector('h1').textContent).toBe('Water');
|
||||
expect(div.querySelector('em').textContent).toBe('every');
|
||||
expect(div.querySelectorAll('ul li')).toHaveLength(2);
|
||||
expect(div.querySelector('pre code').textContent).toBe('boil 1 minute\n');
|
||||
expect(div.querySelectorAll('table td')).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('raw HTML in the file is shown as text, never parsed', () => {
|
||||
const div = renderToDOM('<script>alert(1)</script>\n\n<img src=x onerror="alert(2)">');
|
||||
|
||||
expect(div.querySelector('script')).toBeNull();
|
||||
expect(div.querySelector('img')).toBeNull();
|
||||
expect(div.textContent).toContain('<script>alert(1)</script>');
|
||||
});
|
||||
|
||||
test('script URLs are not turned into links', () => {
|
||||
const div = renderToDOM('[click](javascript:alert(1))');
|
||||
|
||||
expect(div.querySelector('a')).toBeNull();
|
||||
});
|
||||
|
||||
test('relative images resolve against the markdown file directory', () => {
|
||||
const div = renderToDOM('\n\n');
|
||||
|
||||
const [pump, tank] = div.querySelectorAll('img');
|
||||
expect(pump.getAttribute('src')).toBe('/media/notes/water/images/hand%20pump.png');
|
||||
expect(tank.getAttribute('src')).toBe('/media/notes/tank.jpg');
|
||||
});
|
||||
|
||||
test('relative links resolve against the markdown file directory and open a new tab', () => {
|
||||
const a = renderToDOM('[filters](filters.md#ceramic)').querySelector('a');
|
||||
|
||||
expect(a.getAttribute('href')).toBe('/media/notes/water/filters.md#ceramic');
|
||||
expect(a.getAttribute('target')).toBe('_blank');
|
||||
});
|
||||
|
||||
test('a relative URL that climbs out of the media directory is dropped', () => {
|
||||
const div = renderToDOM('[api](../../../api/status)\n\n');
|
||||
|
||||
expect(div.querySelector('a').hasAttribute('href')).toBe(false);
|
||||
expect(div.querySelector('img').hasAttribute('src')).toBe(false);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['https://tracker.example/pixel.gif'],
|
||||
['http://tracker.example/pixel.gif'],
|
||||
// Protocol-relative: starts with a slash, but loads from another host.
|
||||
['//tracker.example/pixel.gif'],
|
||||
])('an image from another site (%s) is not loaded, it becomes a link', (src) => {
|
||||
const div = renderToDOM(``);
|
||||
|
||||
expect(div.querySelector('img')).toBeNull();
|
||||
const a = div.querySelector('a');
|
||||
expect(a.getAttribute('href')).toBe(src);
|
||||
expect(a.textContent).toBe('pixel');
|
||||
expect(a.getAttribute('target')).toBe('_blank');
|
||||
expect(a.getAttribute('rel')).toBe('noopener noreferrer');
|
||||
});
|
||||
|
||||
test('an image from another site with no alt text is labeled by its URL', () => {
|
||||
const a = renderToDOM('').querySelector('a');
|
||||
|
||||
expect(a.textContent).toBe('https://example.com/a.png');
|
||||
});
|
||||
|
||||
test('an image from another site inside a link becomes the link text, not a nested link', () => {
|
||||
// The badge pattern common in READMEs.
|
||||
const div = renderToDOM('[](https://ci.example/project)');
|
||||
|
||||
expect(div.querySelector('img')).toBeNull();
|
||||
const links = div.querySelectorAll('a');
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0].getAttribute('href')).toBe('https://ci.example/project');
|
||||
expect(links[0].textContent).toBe('build status');
|
||||
});
|
||||
|
||||
test('media and embedded data images still load', () => {
|
||||
const [media, data] = renderToDOM('\n\n')
|
||||
.querySelectorAll('img');
|
||||
|
||||
expect(media.getAttribute('src')).toBe('/media/x/a.png');
|
||||
expect(data.getAttribute('src')).toBe('data:image/png;base64,AAAA');
|
||||
});
|
||||
|
||||
test('external links open in a new tab without an opener', () => {
|
||||
const a = renderToDOM('<https://example.com/page>').querySelector('a');
|
||||
|
||||
expect(a.getAttribute('href')).toBe('https://example.com/page');
|
||||
expect(a.getAttribute('target')).toBe('_blank');
|
||||
expect(a.getAttribute('rel')).toBe('noopener noreferrer');
|
||||
});
|
||||
|
||||
test('absolute and fragment URLs are left alone', () => {
|
||||
const [root, fragment] = renderToDOM('[root](/files)\n\n[top](#top)').querySelectorAll('a');
|
||||
|
||||
expect(root.getAttribute('href')).toBe('/files');
|
||||
expect(fragment.getAttribute('href')).toBe('#top');
|
||||
expect(fragment.hasAttribute('target')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarkdownPreview', () => {
|
||||
const SOURCE = '# Water storage\n\nRotate *every* six months.\n';
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
global.fetch = jest.fn(() => Promise.resolve({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(SOURCE),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete global.fetch;
|
||||
});
|
||||
|
||||
test('fetches the file and shows it formatted', async () => {
|
||||
renderUI(<MarkdownPreview url={FILE_URL}/>);
|
||||
|
||||
const heading = await screen.findByRole('heading', {name: 'Water storage'});
|
||||
expect(heading.tagName).toBe('H1');
|
||||
expect(global.fetch).toHaveBeenCalledWith(FILE_URL, expect.anything());
|
||||
});
|
||||
|
||||
test('Raw shows the unformatted source, and the choice is remembered', async () => {
|
||||
const {unmount} = renderUI(<MarkdownPreview url={FILE_URL}/>);
|
||||
await screen.findByRole('heading', {name: 'Water storage'});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {name: 'Raw'}));
|
||||
|
||||
expect(screen.queryByRole('heading')).toBeNull();
|
||||
expect(screen.getByTestId('markdown-raw').textContent).toBe(SOURCE);
|
||||
|
||||
// The next preview opens in the mode the reader chose last.
|
||||
unmount();
|
||||
renderUI(<MarkdownPreview url={FILE_URL}/>);
|
||||
await waitFor(() => expect(screen.getByTestId('markdown-raw').textContent).toBe(SOURCE));
|
||||
expect(screen.queryByRole('heading')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {name: 'Formatted'}));
|
||||
expect(screen.getByRole('heading', {name: 'Water storage'})).toBeTruthy();
|
||||
});
|
||||
|
||||
test('a failed fetch shows an error instead of loading forever', async () => {
|
||||
global.fetch = jest.fn(() => Promise.resolve({ok: false, status: 404, statusText: 'Not Found'}));
|
||||
|
||||
renderUI(<MarkdownPreview url={FILE_URL}/>);
|
||||
|
||||
expect(await screen.findByText(/404 Not Found/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMarkdownFile', () => {
|
||||
test.each([
|
||||
['text/markdown', 'notes/a.md', true],
|
||||
// A stored type from before text/markdown existed; refresh does not retag unchanged files.
|
||||
['text/plain', 'notes/a.md', true],
|
||||
['text/plain', 'notes/a.markdown', true],
|
||||
['text/plain', 'notes/a.txt', false],
|
||||
// A suffix alone is not enough; a binary file named .md is not markdown.
|
||||
['application/octet-stream', 'notes/a.md', false],
|
||||
])('%s %s -> %s', (mimetype, lowerPath, expected) => {
|
||||
expect(isMarkdownFile(mimetype, lowerPath)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
|
@ -21,7 +21,6 @@ import {HELP_VIEWER_URI, NAME, navColorHexMap} from "./Vars";
|
|||
import {useOverflowNav} from "../hooks/useOverflowNav";
|
||||
import {defaultNavColor, navBarStyle, useNavColors} from "../themes/navColors";
|
||||
import _ from "lodash";
|
||||
import {BookmarksMenuContents, EDIT_BOOKMARKS_PATH} from "./Bookmarks";
|
||||
|
||||
function updateFavicon(colorName) {
|
||||
// Fall back to violet if invalid color
|
||||
|
|
@ -61,23 +60,17 @@ const allLinks = [
|
|||
{text: 'Files', to: '/files', key: 'files'},
|
||||
{text: 'Playlists', to: '/playlists', key: 'playlists'},
|
||||
{text: 'Zim', to: '/zim', key: 'zim'},
|
||||
{text: 'Repos', to: '/repos', key: 'repos'},
|
||||
{text: 'Inventory', to: '/inventory', key: 'inventory'},
|
||||
{text: 'AI', to: '/ai', key: 'ai'},
|
||||
// No `end` on these two: a tab stays current for the pages beneath it, and `end` would
|
||||
// drop the mark the moment either grows a sub-page.
|
||||
{to: '/more/calculators', text: 'Calculators', key: 'calculators'},
|
||||
// The user's bookmarks, read from BookmarksContext when rendered. No `to`: it is a
|
||||
// menu, and its items are not sections of the app.
|
||||
{text: 'Bookmarks', key: 'bookmarks', bookmarks: true},
|
||||
{text: 'Flasher', to: '/flasher', key: 'flasher'},
|
||||
{to: '/more/statistics', text: 'Statistics', key: 'statistics'},
|
||||
];
|
||||
|
||||
function MenuLink({link}) {
|
||||
// A top-level tab rendered directly in the bar (not inside a dropdown).
|
||||
if (link.bookmarks) {
|
||||
return <BookmarksDropdown link={link}/>
|
||||
}
|
||||
if (link.links) {
|
||||
return <DropdownLinks link={link}/>
|
||||
}
|
||||
|
|
@ -93,31 +86,13 @@ function MenuLink({link}) {
|
|||
</NavLink>
|
||||
}
|
||||
|
||||
function DropdownMenuItem({link, nested = true}) {
|
||||
function DropdownMenuItem({link}) {
|
||||
// An item inside a Menu.Dropdown: the mobile hamburger menu, or the desktop
|
||||
// "More" overflow menu. `nested` allows flyout submenus, which the hamburger
|
||||
// declines: a flyout opens on hover, and a phone has none.
|
||||
if (link.bookmarks) {
|
||||
if (nested) {
|
||||
return <Menu.Sub>
|
||||
<Menu.Sub.Target>
|
||||
<Menu.Sub.Item>{link.text}</Menu.Sub.Item>
|
||||
</Menu.Sub.Target>
|
||||
<Menu.Sub.Dropdown>
|
||||
<BookmarksMenuContents nested/>
|
||||
</Menu.Sub.Dropdown>
|
||||
</Menu.Sub>
|
||||
}
|
||||
return <React.Fragment>
|
||||
<Menu.Divider/>
|
||||
<Menu.Label>{link.text}</Menu.Label>
|
||||
<BookmarksMenuContents nested={false}/>
|
||||
<Menu.Divider/>
|
||||
</React.Fragment>
|
||||
}
|
||||
// "More" overflow menu.
|
||||
if (link.links) {
|
||||
// A labelled, indented group stands in for a submenu here. No current data uses
|
||||
// this path (no allLinks entry has its own `links`).
|
||||
// Mantine's Menu does not nest submenus without extra plumbing; a labelled,
|
||||
// indented group stands in for one. No current data uses this path (no
|
||||
// allLinks entry has its own `links`).
|
||||
return <React.Fragment>
|
||||
<Menu.Label>{link.text}</Menu.Label>
|
||||
{link.links.map(l => <DropdownMenuItem key={l.key} link={l}/>)}
|
||||
|
|
@ -167,11 +142,6 @@ NavDropdownTrigger.displayName = 'NavDropdownTrigger';
|
|||
* the hooks that would answer it cannot be called in a loop.
|
||||
*/
|
||||
export const isLinkActive = (pathname, link) => {
|
||||
// The Bookmarks menu owns only its editor page: a bookmark pointing at /videos does
|
||||
// not make the menu own the Videos section.
|
||||
if (link.bookmarks) {
|
||||
return pathname === EDIT_BOOKMARKS_PATH || pathname.startsWith(`${EDIT_BOOKMARKS_PATH}/`);
|
||||
}
|
||||
// An off-site link (Help) is never the current section.
|
||||
if (!link.to || /^[a-z]+:\/\//i.test(link.to)) return false;
|
||||
const to = link.to.length > 1 ? link.to.replace(/\/+$/, '') : link.to;
|
||||
|
|
@ -206,24 +176,6 @@ function DropdownLinks({link}) {
|
|||
</Menu>
|
||||
}
|
||||
|
||||
/**
|
||||
* The Bookmarks tab: a dropdown that opens on hover as well as on click, holding the
|
||||
* user's bookmarks and the link to edit them. Marked current on the editor page only
|
||||
* (see isLinkActive).
|
||||
*/
|
||||
function BookmarksDropdown({link}) {
|
||||
const {pathname} = useLocation();
|
||||
const holdsCurrent = isLinkActive(pathname, link);
|
||||
return <Menu position='bottom-start' withinPortal trigger='click-hover' openDelay={100} closeDelay={200}>
|
||||
<Menu.Target>
|
||||
<NavDropdownTrigger text={link.text} className={holdsCurrent ? 'active' : undefined}/>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<BookmarksMenuContents nested/>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
}
|
||||
|
||||
function MobileMenu({links}) {
|
||||
// The mobile hamburger menu: an icon-only trigger holding every link.
|
||||
return <Menu position='bottom-end' withinPortal>
|
||||
|
|
@ -231,7 +183,7 @@ function MobileMenu({links}) {
|
|||
<IconButton icon={IconMenu2} label='Menu' variant='subtle'/>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{links.map(link => <DropdownMenuItem key={link.key} link={link} nested={false}/>)}
|
||||
{links.map(link => <DropdownMenuItem key={link.key} link={link}/>)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -231,91 +231,3 @@ describe('NavBar current tab', () => {
|
|||
expect(ruleBody('\\.mantine-Menu-item\\.active')).toMatch(/background:|font-weight:/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NavBar bookmarks', () => {
|
||||
const {BookmarksContext} = require('../contexts/BookmarksContext');
|
||||
const bookmarks = [
|
||||
{id: 1, name: 'Channels', url: '/videos/channel', new_tab: false},
|
||||
{id: 2, name: 'Services', children: [{id: 3, name: 'Jellyfin', url: ':8096/', new_tab: true}]},
|
||||
];
|
||||
const renderWithBookmarks = (route, nodes) => render(<NavBar/>, {
|
||||
route,
|
||||
withMedia: true,
|
||||
contexts: [
|
||||
[FileWorkerStatusContext, fileWorkerStatus],
|
||||
[BookmarksContext, {bookmarks: nodes, error: null, refresh: jest.fn()}],
|
||||
],
|
||||
});
|
||||
|
||||
it('sits between Calculators and Flasher', () => {
|
||||
renderNav('/');
|
||||
const texts = [...document.querySelectorAll('#global_navbar .wrolpi-navbar-link')]
|
||||
.map(el => el.textContent.trim());
|
||||
const at = texts.indexOf('Bookmarks');
|
||||
expect(at).toBeGreaterThan(0);
|
||||
expect(texts[at - 1]).toBe('Calculators');
|
||||
expect(texts[at + 1]).toBe('Flasher');
|
||||
});
|
||||
|
||||
it('opens on click with the bookmarks, a directory flyout, and the edit link', async () => {
|
||||
useRealRects();
|
||||
renderWithBookmarks('/', bookmarks);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', {name: /Bookmarks/}));
|
||||
|
||||
const channels = await screen.findByRole('menuitem', {name: 'Channels'});
|
||||
expect(channels).toHaveAttribute('href', '/videos/channel');
|
||||
expect(channels).not.toHaveAttribute('target');
|
||||
// A directory is a submenu; its bookmarks appear once it is hovered.
|
||||
expect(screen.queryByRole('menuitem', {name: 'Jellyfin'})).not.toBeInTheDocument();
|
||||
await userEvent.hover(screen.getByRole('menuitem', {name: 'Services'}));
|
||||
// `hidden`: the flyout is placed by floating-ui, which jsdom cannot lay out, so it
|
||||
// stays hidden to accessibility queries even though it has rendered.
|
||||
const jellyfin = await screen.findByRole('menuitem', {name: 'Jellyfin', hidden: true});
|
||||
expect(jellyfin).toHaveAttribute('href', `${window.location.protocol}//${window.location.hostname}:8096/`);
|
||||
expect(jellyfin).toHaveAttribute('target', '_blank');
|
||||
expect(screen.getByRole('menuitem', {name: 'Edit bookmarks', hidden: true})).toHaveAttribute('href', '/more/bookmarks');
|
||||
});
|
||||
|
||||
it('offers the edit link even with no bookmarks, so the feature can be found', async () => {
|
||||
useRealRects();
|
||||
renderWithBookmarks('/', []);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', {name: /Bookmarks/}));
|
||||
|
||||
expect(await screen.findByRole('menuitem', {name: 'Edit bookmarks'})).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('menuitem')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('is marked current on the editor page, and never for a bookmark pointing at a section', () => {
|
||||
renderWithBookmarks('/more/bookmarks', bookmarks);
|
||||
expect(screen.getByRole('button', {name: /Bookmarks/})).toHaveClass('active');
|
||||
expect(currentTab()).toEqual([]);
|
||||
});
|
||||
|
||||
it('marks More when the editor page is folded inside it', () => {
|
||||
useBarWidth(200);
|
||||
renderWithBookmarks('/more/bookmarks', bookmarks);
|
||||
expect(screen.getByRole('button', {name: /More/})).toHaveClass('active');
|
||||
});
|
||||
|
||||
it('does not own the section a bookmark points at', () => {
|
||||
renderWithBookmarks('/videos/channel', bookmarks);
|
||||
expect(screen.getByRole('button', {name: /Bookmarks/})).not.toHaveClass('active');
|
||||
expect(currentTab()).toEqual(['Videos']);
|
||||
});
|
||||
|
||||
it('lists bookmarks as a labelled group in the mobile menu', async () => {
|
||||
useViewport('mobile');
|
||||
useRealRects();
|
||||
renderWithBookmarks('/', bookmarks);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', {name: 'Menu'}));
|
||||
|
||||
expect(await screen.findByRole('menuitem', {name: 'Channels'})).toBeInTheDocument();
|
||||
// No flyout on a phone: the directory's bookmarks are listed directly.
|
||||
expect(screen.getByRole('menuitem', {name: 'Jellyfin'})).toBeInTheDocument();
|
||||
expect(screen.getByText('Services')).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', {name: 'Edit bookmarks'})).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,174 +0,0 @@
|
|||
import React from 'react';
|
||||
import {act, render, screen} from '../test-utils';
|
||||
import {OtherSearchView} from './Search';
|
||||
import {searchChannels, searchCollections, searchRepos} from '../api';
|
||||
|
||||
// What the search page's Other tab shows for each state of useSearchChannels. "No Channels"
|
||||
// must only appear once the server has confirmed there are none.
|
||||
|
||||
jest.mock('../api', () => ({
|
||||
...jest.requireActual('../api'),
|
||||
searchChannels: jest.fn(),
|
||||
searchCollections: jest.fn(),
|
||||
searchRepos: jest.fn(),
|
||||
}));
|
||||
|
||||
const NO_CHANNELS = /No Channels/;
|
||||
|
||||
beforeEach(() => {
|
||||
searchChannels.mockReset();
|
||||
searchRepos.mockReset();
|
||||
searchRepos.mockResolvedValue({repos: [], total: 0});
|
||||
searchCollections.mockReset();
|
||||
searchCollections.mockResolvedValue({collections: []});
|
||||
});
|
||||
|
||||
test('pending: shows a loader, not "No Channels"', () => {
|
||||
searchChannels.mockReturnValue(new Promise(() => {
|
||||
}));
|
||||
const {container} = render(<OtherSearchView loading={false}/>);
|
||||
expect(container.querySelector('.wrolpi-loading')).toBeInTheDocument();
|
||||
expect(screen.queryByText(NO_CHANNELS)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('failed: shows an error, not "No Channels"', async () => {
|
||||
searchChannels.mockResolvedValue(undefined);
|
||||
const spy = jest.spyOn(console, 'error').mockImplementation(() => {
|
||||
});
|
||||
try {
|
||||
render(<OtherSearchView loading={false}/>);
|
||||
await act(async () => {
|
||||
});
|
||||
expect(screen.getByText(/Could not fetch the channels/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(NO_CHANNELS)).not.toBeInTheDocument();
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('empty: shows "No Channels"', async () => {
|
||||
searchChannels.mockResolvedValue({channels: []});
|
||||
render(<OtherSearchView loading={false}/>);
|
||||
await act(async () => {
|
||||
});
|
||||
expect(screen.getByText(NO_CHANNELS)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('loaded: lists the channels', async () => {
|
||||
searchChannels.mockResolvedValue({channels: [{id: 1, name: 'Cooking'}]});
|
||||
render(<OtherSearchView loading={false}/>);
|
||||
await act(async () => {
|
||||
});
|
||||
expect(screen.getByText('Cooking')).toBeInTheDocument();
|
||||
expect(screen.queryByText(NO_CHANNELS)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('Repos', () => {
|
||||
test('pending: the Repos section loads while Channels are shown', async () => {
|
||||
searchChannels.mockResolvedValue({channels: []});
|
||||
searchRepos.mockReturnValue(new Promise(() => {
|
||||
}));
|
||||
const {container} = render(<OtherSearchView loading={false}/>);
|
||||
await act(async () => {
|
||||
});
|
||||
expect(screen.getByText(NO_CHANNELS)).toBeInTheDocument();
|
||||
expect(container.querySelector('.wrolpi-loading')).toBeInTheDocument();
|
||||
expect(screen.queryByText('No Repos')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('empty: shows "No Repos"', async () => {
|
||||
searchChannels.mockResolvedValue({channels: []});
|
||||
render(<OtherSearchView loading={false}/>);
|
||||
await act(async () => {
|
||||
});
|
||||
expect(screen.getByText('No Repos')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('failed: shows an error only in the Repos section', async () => {
|
||||
searchChannels.mockResolvedValue({channels: [{id: 1, name: 'Cooking'}]});
|
||||
searchRepos.mockRejectedValue(new Error('boom'));
|
||||
const spy = jest.spyOn(console, 'error').mockImplementation(() => {
|
||||
});
|
||||
try {
|
||||
render(<OtherSearchView loading={false}/>);
|
||||
await act(async () => {
|
||||
});
|
||||
expect(screen.getByText('Cooking')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Could not fetch the repos/)).toBeInTheDocument();
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('loaded: lists repos by the search, with their README headline', async () => {
|
||||
searchChannels.mockResolvedValue({channels: []});
|
||||
searchRepos.mockResolvedValue({
|
||||
total: 1, repos: [{
|
||||
id: 7, name: 'kiwix-tools', tag_name: null,
|
||||
// The server escapes the README; only the highlight is markup.
|
||||
readme_headline: 'Serve <b>Zim</b> files <script>',
|
||||
}],
|
||||
});
|
||||
const searchParams = new URLSearchParams('q=zim&tag=Software');
|
||||
const {container} = render(<OtherSearchView loading={false}/>, {query: {searchParams}});
|
||||
await act(async () => {
|
||||
});
|
||||
expect(searchRepos).toHaveBeenCalledWith('zim', ['Software']);
|
||||
expect(screen.getByText('kiwix-tools').closest('a')).toHaveAttribute('href', '/repos/7');
|
||||
expect(container.querySelector('u').textContent).toBe('Zim');
|
||||
expect(container.querySelector('script')).toBeNull();
|
||||
expect(screen.getByText(/files <script>/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// Domains and Playlists are Collections searched by kind.
|
||||
describe.each([
|
||||
['domain', 'Domains', 'example.com', '/archives?domain=example.com'],
|
||||
['playlist', 'Playlists', 'Road Trip', '/playlists/3'],
|
||||
])('%s Collections', (kind, title, name, href) => {
|
||||
const collectionsOf = (wanted, collections) => (k) => Promise.resolve({collections: k === wanted ? collections : []});
|
||||
|
||||
test(`empty: shows "No ${title}"`, async () => {
|
||||
searchChannels.mockResolvedValue({channels: []});
|
||||
render(<OtherSearchView loading={false}/>, {query: {searchParams: new URLSearchParams('q=nothing')}});
|
||||
await act(async () => {
|
||||
});
|
||||
expect(screen.getByText(`No ${title}`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('nothing to search by: shows none without fetching', async () => {
|
||||
searchChannels.mockResolvedValue({channels: []});
|
||||
render(<OtherSearchView loading={false}/>);
|
||||
await act(async () => {
|
||||
});
|
||||
expect(screen.getByText(`No ${title}`)).toBeInTheDocument();
|
||||
expect(searchCollections).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test(`failed: shows an error only in the ${title} section`, async () => {
|
||||
searchChannels.mockResolvedValue({channels: [{id: 1, name: 'Cooking'}]});
|
||||
searchCollections.mockImplementation(k => k === kind ? Promise.reject(new Error('boom')) : Promise.resolve({collections: []}));
|
||||
const spy = jest.spyOn(console, 'error').mockImplementation(() => {
|
||||
});
|
||||
try {
|
||||
render(<OtherSearchView loading={false}/>, {query: {searchParams: new URLSearchParams('q=x')}});
|
||||
await act(async () => {
|
||||
});
|
||||
expect(screen.getByText('Cooking')).toBeInTheDocument();
|
||||
expect(screen.getByText(new RegExp(`Could not fetch the ${title.toLowerCase()}`))).toBeInTheDocument();
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('loaded: lists them by the search and tags, linked', async () => {
|
||||
searchChannels.mockResolvedValue({channels: []});
|
||||
searchCollections.mockImplementation(collectionsOf(kind, [{id: 3, name, kind, tag_name: null}]));
|
||||
const searchParams = new URLSearchParams('q=road&tag=News');
|
||||
render(<OtherSearchView loading={false}/>, {query: {searchParams}});
|
||||
await act(async () => {
|
||||
});
|
||||
expect(searchCollections).toHaveBeenCalledWith(kind, ['News'], 'road');
|
||||
expect(screen.getByText(name).closest('a')).toHaveAttribute('href', href);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,394 +0,0 @@
|
|||
import React from 'react';
|
||||
import {Route, Routes} from 'react-router';
|
||||
import {act, fireEvent, render, screen, waitFor, within} from '../test-utils';
|
||||
import {
|
||||
RepoEditPage,
|
||||
RepoImportPage,
|
||||
repoNameFromURL,
|
||||
RepoNewPage,
|
||||
ReposPage,
|
||||
RepoViewPage,
|
||||
safeRepoHref,
|
||||
} from './Repos';
|
||||
import {
|
||||
createRepo, deleteRepo, fetchRepos, getFile, getRepo, getRepoLog, getRepoTree, importRepo, inspectRepoImport,
|
||||
updateRepo,
|
||||
} from '../api';
|
||||
import {FilePreviewContext} from './FilePreview';
|
||||
|
||||
jest.mock('../api', () => ({
|
||||
...jest.requireActual('../api'),
|
||||
createRepo: jest.fn(),
|
||||
deleteRepo: jest.fn(),
|
||||
fetchRepos: jest.fn(),
|
||||
getFile: jest.fn(),
|
||||
getRepo: jest.fn(),
|
||||
getRepoLog: jest.fn(),
|
||||
getRepoTree: jest.fn(),
|
||||
importRepo: jest.fn(),
|
||||
inspectRepoImport: jest.fn(),
|
||||
updateRepo: jest.fn(),
|
||||
}));
|
||||
|
||||
// The directory picker searches the server; picking a directory is all these specs need.
|
||||
jest.mock('./Common', () => ({
|
||||
...jest.requireActual('./Common'),
|
||||
DirectorySearch: ({onSelect}) => <button type='button' onClick={() => onSelect('code/kiwix-tools')}>
|
||||
Pick directory
|
||||
</button>,
|
||||
}));
|
||||
|
||||
// The README is fetched from the media server; its rendering is MarkdownPreview's own spec.
|
||||
jest.mock('./MarkdownPreview', () => ({
|
||||
...jest.requireActual('./MarkdownPreview'),
|
||||
MarkdownPreview: ({url}) => <div data-testid='markdown-preview'>{url}</div>,
|
||||
}));
|
||||
|
||||
const repoFixture = (overrides = {}) => ({
|
||||
id: 1, collection_id: 10, name: 'kiwix-tools', tag_name: null, description: null,
|
||||
url: 'https://github.com/kiwix/kiwix-tools', mode: 'full', branch: null, default_branch: 'main',
|
||||
head_sha: '3f9c2a1e7b', head_message: 'Fix the mimetype', head_date: '2026-09-30T14:02:00Z',
|
||||
size: 1000, directory: 'repos/kiwix-tools', frequency: 604800, download_error: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const treeFixture = {
|
||||
path: '',
|
||||
readme_path: 'README.md',
|
||||
entries: [
|
||||
{name: 'src', path: 'src', is_dir: true, size: null},
|
||||
{name: 'README.md', path: 'README.md', is_dir: false, size: 120},
|
||||
],
|
||||
};
|
||||
|
||||
const flush = () => act(async () => {
|
||||
});
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
describe('safeRepoHref', () => {
|
||||
it('only links https URLs', () => {
|
||||
expect(safeRepoHref('https://github.com/a/b')).toBe('https://github.com/a/b');
|
||||
expect(safeRepoHref('javascript:alert(1)')).toBeNull();
|
||||
expect(safeRepoHref('http://github.com/a/b')).toBeNull();
|
||||
expect(safeRepoHref('file:///etc/passwd')).toBeNull();
|
||||
expect(safeRepoHref('not a url')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('repoNameFromURL', () => {
|
||||
it('is the last path segment, without .git or a branch', () => {
|
||||
expect(repoNameFromURL('https://github.com/kiwix/kiwix-tools.git')).toBe('kiwix-tools');
|
||||
expect(repoNameFromURL('https://github.com/kiwix/kiwix-tools/tree/dev')).toBe('kiwix-tools');
|
||||
expect(repoNameFromURL('https://gitlab.com/group/sub/project/')).toBe('project');
|
||||
expect(repoNameFromURL('nope')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReposPage', () => {
|
||||
it('lists repos, linking each to its page', async () => {
|
||||
fetchRepos.mockResolvedValue([
|
||||
repoFixture(),
|
||||
repoFixture({id: 2, name: 'gone', download_error: 'git ls-remote failed', head_sha: null}),
|
||||
]);
|
||||
render(<ReposPage/>, {route: '/repos'});
|
||||
await flush();
|
||||
expect(screen.getAllByText('kiwix-tools')[0].closest('a')).toHaveAttribute('href', '/repos/1');
|
||||
expect(screen.getAllByText('gone')[0].closest('a')).toHaveAttribute('href', '/repos/2');
|
||||
expect(screen.getByText('New Repo').closest('a')).toHaveAttribute('href', '/repos/new');
|
||||
});
|
||||
|
||||
it('shows a repo which is never updated', async () => {
|
||||
fetchRepos.mockResolvedValue([repoFixture({frequency: 0})]);
|
||||
render(<ReposPage/>, {route: '/repos'});
|
||||
await flush();
|
||||
expect(screen.getAllByText('Never').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('explains an empty list', async () => {
|
||||
fetchRepos.mockResolvedValue([]);
|
||||
render(<ReposPage/>, {route: '/repos'});
|
||||
await flush();
|
||||
expect(screen.getByText(/No repos yet/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
function renderViewPage({repo, tree = treeFixture, query = {}, setPreviewFile = jest.fn()} = {}) {
|
||||
getRepo.mockResolvedValue(repo);
|
||||
getRepoTree.mockResolvedValue(tree);
|
||||
return render(<Routes><Route path='/repos/:repoId' element={<RepoViewPage/>}/></Routes>, {
|
||||
route: '/repos/1',
|
||||
query,
|
||||
contexts: [[FilePreviewContext, {setPreviewFile}]],
|
||||
});
|
||||
}
|
||||
|
||||
describe('RepoViewPage', () => {
|
||||
it('shows the latest commit, the files, and the README', async () => {
|
||||
renderViewPage({repo: repoFixture()});
|
||||
await flush();
|
||||
expect(getRepoTree).toHaveBeenCalledWith('1', '');
|
||||
expect(screen.getByText('3f9c2a1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Fix the mimetype')).toBeInTheDocument();
|
||||
expect(screen.getByText('main')).toBeInTheDocument();
|
||||
expect(screen.getByText('(default)')).toBeInTheDocument();
|
||||
expect(screen.getByText('https://github.com/kiwix/kiwix-tools').closest('a'))
|
||||
.toHaveAttribute('href', 'https://github.com/kiwix/kiwix-tools');
|
||||
expect(screen.getByText('src')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('markdown-preview')).toHaveTextContent('/media/repos/kiwix-tools/README.md');
|
||||
});
|
||||
|
||||
it('opens a directory through the URL, and a file in the preview', async () => {
|
||||
const updateQuery = jest.fn();
|
||||
const setPreviewFile = jest.fn();
|
||||
const file = {path: 'repos/kiwix-tools/README.md', mimetype: 'text/markdown', size: 120};
|
||||
getFile.mockResolvedValue(file);
|
||||
renderViewPage({repo: repoFixture(), query: {updateQuery}, setPreviewFile});
|
||||
await flush();
|
||||
|
||||
fireEvent.click(screen.getByText('src'));
|
||||
expect(updateQuery).toHaveBeenCalledWith({path: 'src'}, false);
|
||||
|
||||
fireEvent.click(screen.getAllByText('README.md')[0]);
|
||||
await flush();
|
||||
expect(getFile).toHaveBeenCalledWith('repos/kiwix-tools/README.md', true);
|
||||
expect(setPreviewFile).toHaveBeenCalledWith(file);
|
||||
});
|
||||
|
||||
it('lists the directory in the URL, with a way back up', async () => {
|
||||
const tree = {path: 'src/lib', readme_path: null, entries: [{name: 'a.c', path: 'src/lib/a.c', is_dir: false}]};
|
||||
const updateQuery = jest.fn();
|
||||
renderViewPage({repo: repoFixture(), tree, query: {searchParams: new URLSearchParams('path=src/lib'), updateQuery}});
|
||||
await flush();
|
||||
expect(getRepoTree).toHaveBeenCalledWith('1', 'src/lib');
|
||||
expect(screen.queryByTestId('markdown-preview')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('..'));
|
||||
expect(updateQuery).toHaveBeenCalledWith({path: 'src'}, false);
|
||||
// The breadcrumb's root.
|
||||
fireEvent.click(screen.getAllByText('kiwix-tools').find(i => i.tagName === 'BUTTON'));
|
||||
expect(updateQuery).toHaveBeenCalledWith({path: ''}, false);
|
||||
});
|
||||
|
||||
it('offers a ZIP of the files', async () => {
|
||||
renderViewPage({repo: repoFixture()});
|
||||
await flush();
|
||||
const link = screen.getByText('Download ZIP').closest('a');
|
||||
expect(link).toHaveAttribute('href', expect.stringMatching(/\/api\/repos\/1\/archive\.zip$/));
|
||||
expect(link).toHaveAttribute('download');
|
||||
});
|
||||
|
||||
it('shows the history instead of the files', async () => {
|
||||
getRepoLog.mockResolvedValue({
|
||||
total: 51,
|
||||
commits: [{sha: 'abcdef1234', author: 'Ada', date: '2026-09-30T14:02:00Z', message: 'Fix the mimetype'}],
|
||||
});
|
||||
const updateQuery = jest.fn();
|
||||
renderViewPage({repo: repoFixture(), query: {searchParams: new URLSearchParams('view=history'), updateQuery}});
|
||||
await flush();
|
||||
|
||||
expect(getRepoLog).toHaveBeenCalledWith(1, 0, 50);
|
||||
expect(getRepoTree).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('51 commits')).toBeInTheDocument();
|
||||
expect(screen.getByText('abcdef1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Ada')).toBeInTheDocument();
|
||||
|
||||
// The next page is appended.
|
||||
getRepoLog.mockResolvedValue({
|
||||
total: 51,
|
||||
commits: [{sha: '0123456789', author: 'Ben', date: '2026-09-01T00:00:00Z', message: 'Older'}],
|
||||
});
|
||||
fireEvent.click(screen.getByText('Load more'));
|
||||
await flush();
|
||||
expect(getRepoLog).toHaveBeenLastCalledWith(1, 1, 50);
|
||||
expect(screen.getByText('abcdef1')).toBeInTheDocument();
|
||||
expect(screen.getByText('0123456')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('Files'));
|
||||
expect(updateQuery).toHaveBeenCalledWith({view: null}, false);
|
||||
});
|
||||
|
||||
it('explains a repo that is not downloaded yet', async () => {
|
||||
renderViewPage({repo: repoFixture({head_sha: null, head_message: null, head_date: null})});
|
||||
await flush();
|
||||
expect(screen.getByText('Not downloaded yet')).toBeInTheDocument();
|
||||
expect(getRepoTree).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText('Download ZIP')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('History')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps showing the files when the last update failed', async () => {
|
||||
renderViewPage({repo: repoFixture({download_error: 'Traceback...\nDownloadError: git ls-remote failed (128)'})});
|
||||
await flush();
|
||||
expect(screen.getByText('The last update failed')).toBeInTheDocument();
|
||||
expect(screen.getByText(/git ls-remote failed/)).toBeInTheDocument();
|
||||
expect(screen.getByText('src')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('says a repo which is never updated is only updated on request', async () => {
|
||||
renderViewPage({repo: repoFixture({frequency: 0, download_error: 'git ls-remote failed (128)'})});
|
||||
await flush();
|
||||
expect(screen.getByText('The last update failed')).toBeInTheDocument();
|
||||
expect(screen.getByText(/never updated automatically/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/will be tried again/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('never links an upstream that is not https', async () => {
|
||||
renderViewPage({repo: repoFixture({url: 'javascript:alert(1)'})});
|
||||
await flush();
|
||||
expect(screen.queryByText('javascript:alert(1)')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RepoNewPage', () => {
|
||||
it('adds a repo, then opens it', async () => {
|
||||
createRepo.mockResolvedValue(repoFixture({id: 5}));
|
||||
render(<Routes>
|
||||
<Route path='/repos/new' element={<RepoNewPage/>}/>
|
||||
<Route path='/repos/:repoId' element={<div>repo page</div>}/>
|
||||
</Routes>, {route: '/repos/new'});
|
||||
|
||||
const add = screen.getByText('Add Repo').closest('button');
|
||||
expect(add).toBeDisabled();
|
||||
fireEvent.change(screen.getByLabelText(/URL/), {target: {value: 'git@github.com:a/b'}});
|
||||
expect(add).toBeDisabled();
|
||||
expect(screen.getByText('Must be an https:// URL')).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/URL/), {target: {value: 'https://github.com/kiwix/kiwix-tools'}});
|
||||
expect(screen.getByPlaceholderText('kiwix-tools')).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText('Branch'), {target: {value: ' dev '}});
|
||||
fireEvent.click(screen.getByText('Pick directory'));
|
||||
fireEvent.click(add);
|
||||
await flush();
|
||||
|
||||
expect(createRepo).toHaveBeenCalledWith({
|
||||
url: 'https://github.com/kiwix/kiwix-tools', name: null, tag_name: null, frequency: 604800,
|
||||
mode: 'full', branch: 'dev', submodules: false, description: null, directory: 'code/kiwix-tools',
|
||||
});
|
||||
expect(screen.getByText('repo page')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RepoEditPage', () => {
|
||||
function renderEditPage(repo) {
|
||||
getRepo.mockResolvedValue(repo);
|
||||
return render(<Routes>
|
||||
<Route path='/repos/:repoId/edit' element={<RepoEditPage/>}/>
|
||||
<Route path='/repos' element={<div>repos page</div>}/>
|
||||
</Routes>, {route: '/repos/1/edit'});
|
||||
}
|
||||
|
||||
it('saves the settings', async () => {
|
||||
updateRepo.mockResolvedValue(repoFixture());
|
||||
renderEditPage(repoFixture({branch: 'dev', description: 'Tools'}));
|
||||
await flush();
|
||||
expect(screen.getByLabelText('Branch')).toHaveValue('dev');
|
||||
|
||||
expect(screen.getByLabelText('Include submodules')).not.toBeChecked();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Branch'), {target: {value: ''}});
|
||||
fireEvent.click(screen.getByLabelText('Include submodules'));
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
await flush();
|
||||
expect(updateRepo).toHaveBeenCalledWith('1', {
|
||||
description: 'Tools', frequency: 604800, mode: 'full', branch: '', submodules: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a repo which is never updated', async () => {
|
||||
updateRepo.mockResolvedValue(repoFixture({frequency: 0}));
|
||||
renderEditPage(repoFixture({frequency: 0}));
|
||||
await flush();
|
||||
expect(screen.getByDisplayValue('Never')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
await flush();
|
||||
expect(updateRepo).toHaveBeenCalledWith('1', expect.objectContaining({frequency: 0}));
|
||||
});
|
||||
|
||||
it('deletes the repo, and its files only when asked', async () => {
|
||||
deleteRepo.mockResolvedValue(undefined);
|
||||
renderEditPage(repoFixture());
|
||||
await flush();
|
||||
|
||||
fireEvent.click(screen.getByText('Delete'));
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
fireEvent.click(within(dialog).getByLabelText(/Also delete its files/));
|
||||
fireEvent.click(within(dialog).getByText('Delete'));
|
||||
await flush();
|
||||
expect(deleteRepo).toHaveBeenCalledWith('1', true);
|
||||
expect(screen.getByText('repos page')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RepoImportPage', () => {
|
||||
const inspection = (overrides = {}) => ({
|
||||
directory: 'code/kiwix-tools', origin: 'git@github.com:kiwix/kiwix-tools.git',
|
||||
url: 'https://github.com/kiwix/kiwix-tools', name: 'kiwix-tools', branch: 'main',
|
||||
head_sha: 'abcdef1234', head_message: 'Local work', head_date: '2026-09-30T14:02:00Z', local_commits: 2,
|
||||
shallow: false, ignored: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
function renderImportPage() {
|
||||
return render(<Routes>
|
||||
<Route path='/repos/import' element={<RepoImportPage/>}/>
|
||||
<Route path='/repos/:repoId' element={<div>repo page</div>}/>
|
||||
</Routes>, {route: '/repos/import'});
|
||||
}
|
||||
|
||||
it('inspects the directory, warns about local commits, and imports once confirmed', async () => {
|
||||
inspectRepoImport.mockResolvedValue({inspection: inspection()});
|
||||
importRepo.mockResolvedValue(repoFixture({id: 9}));
|
||||
renderImportPage();
|
||||
|
||||
fireEvent.click(screen.getByText('Pick directory'));
|
||||
expect(await screen.findByText('2 commits are not on the remote')).toBeInTheDocument();
|
||||
expect(inspectRepoImport).toHaveBeenCalledWith('code/kiwix-tools', '', null, '');
|
||||
// Inspected again with the URL the import will use.
|
||||
await waitFor(() => expect(inspectRepoImport).toHaveBeenCalledWith('code/kiwix-tools', '', null,
|
||||
'https://github.com/kiwix/kiwix-tools'));
|
||||
expect(screen.getByText('git@github.com:kiwix/kiwix-tools.git')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Its files are indexed/)).toBeInTheDocument();
|
||||
// The URL comes from the origin.
|
||||
expect(screen.getByLabelText(/^URL/)).toHaveValue('https://github.com/kiwix/kiwix-tools');
|
||||
|
||||
const button = screen.getByText('Import').closest('button');
|
||||
expect(button).toBeDisabled();
|
||||
fireEvent.click(screen.getByLabelText(/I understand/));
|
||||
expect(button).toBeEnabled();
|
||||
fireEvent.click(button);
|
||||
await flush();
|
||||
|
||||
expect(importRepo).toHaveBeenCalledWith({
|
||||
directory: 'code/kiwix-tools', confirm: true, url: 'https://github.com/kiwix/kiwix-tools', name: null,
|
||||
tag_name: null, frequency: 604800, mode: 'full', branch: null, submodules: false,
|
||||
});
|
||||
expect(screen.getByText('repo page')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('asks for the URL when the clone has no https origin', async () => {
|
||||
inspectRepoImport.mockResolvedValue({inspection: inspection({origin: null, url: null, local_commits: 0})});
|
||||
renderImportPage();
|
||||
fireEvent.click(screen.getByText('Pick directory'));
|
||||
expect(await screen.findByText(/no https:\/\/ origin/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByLabelText(/I understand/));
|
||||
expect(screen.getByText('Import').closest('button')).toBeDisabled();
|
||||
fireEvent.change(screen.getByLabelText(/^URL/), {target: {value: 'https://github.com/kiwix/kiwix-tools'}});
|
||||
expect(screen.getByText('Import').closest('button')).toBeEnabled();
|
||||
});
|
||||
|
||||
it('explains a directory which cannot be imported', async () => {
|
||||
inspectRepoImport.mockResolvedValue({error: 'code/kiwix-tools is not a git clone'});
|
||||
renderImportPage();
|
||||
fireEvent.click(screen.getByText('Pick directory'));
|
||||
expect(await screen.findByText('code/kiwix-tools is not a git clone')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Import')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('says when the files are not indexed', async () => {
|
||||
inspectRepoImport.mockResolvedValue({inspection: inspection({ignored: true})});
|
||||
renderImportPage();
|
||||
fireEvent.click(screen.getByText('Pick directory'));
|
||||
expect(await screen.findByText(/Its files are not indexed/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,27 +1,17 @@
|
|||
import React, {useState} from "react";
|
||||
import {Link, Route, Routes, useLocation, useNavigate} from "react-router";
|
||||
import {FilesSearchView, SearchViewButton} from "./Files";
|
||||
import {
|
||||
useLatestRequest,
|
||||
usePages,
|
||||
useSearchChannels,
|
||||
useSearchDate,
|
||||
useSearchDeep,
|
||||
useSearchFilter,
|
||||
useSearchCollections,
|
||||
useSearchRepos,
|
||||
} from "../hooks/customHooks";
|
||||
import {useLatestRequest, usePages, useSearchChannels, useSearchDate, useSearchDeep, useSearchFilter} from "../hooks/customHooks";
|
||||
import {ShortcutHint} from "./ShortcutHint";
|
||||
import {ZimSearchView} from "./Zim";
|
||||
import {MapSearchView} from "./MapSearchView";
|
||||
import {searchEstimateFiles, searchEstimateMap, searchEstimateOthers, searchEstimateZims, searchSuggestions} from "../api";
|
||||
import {ErrorMessage, filterToMimetypes, fuzzyMatch, normalizeEstimate, SearchResultsInput, TabLinks} from "./Common";
|
||||
import {filterToMimetypes, fuzzyMatch, normalizeEstimate, SearchResultsInput, TabLinks} from "./Common";
|
||||
import _ from "lodash";
|
||||
import {TagsContext} from "../Tags";
|
||||
import {Accordion, Header, Icon, Label, Loading, Panel, Stack} from "./ui";
|
||||
import {QueryContext} from "../contexts/contexts";
|
||||
import {KeyboardShortcutsContext} from "../contexts/KeyboardShortcutsContext";
|
||||
import {HeadlineText} from "./Headline";
|
||||
|
||||
const SUGGESTED_APPS = [
|
||||
{location: '/admin', title: 'Downloads', description: 'View and control your downloads'},
|
||||
|
|
@ -141,15 +131,12 @@ export const useSearch = (defaultLimit = 48, totalPages = 0, emptySearch = false
|
|||
}
|
||||
|
||||
export function useSuggestions(searchStr, tagNames, filter, anyTag) {
|
||||
// The general groups are null until the suggestion request returns: an empty list means the
|
||||
// server found none, and the dropdown may say "No results" only for that.
|
||||
const defaultSuggestions = {
|
||||
fileGroups: [],
|
||||
channels: null,
|
||||
domains: null,
|
||||
authors: null,
|
||||
subjects: null,
|
||||
repos: null,
|
||||
channels: [],
|
||||
domains: [],
|
||||
authors: [],
|
||||
subjects: [],
|
||||
zimsEstimates: [],
|
||||
}
|
||||
const [suggestions, setSuggestions] = React.useState(defaultSuggestions);
|
||||
|
|
@ -174,7 +161,7 @@ export function useSuggestions(searchStr, tagNames, filter, anyTag) {
|
|||
// The files estimate always uses deep search server-side, so users know results are possible.
|
||||
sendFilesRequest(async () => await searchEstimateFiles(searchStr, tagNames, mimetypes, months, dateRange, anyTag));
|
||||
sendZimRequest(async () => await searchEstimateZims(searchStr, tagNames));
|
||||
sendOtherRequest(async () => await searchEstimateOthers(tagNames, searchStr));
|
||||
sendOtherRequest(async () => await searchEstimateOthers(tagNames));
|
||||
}
|
||||
}, [
|
||||
searchStr,
|
||||
|
|
@ -196,7 +183,6 @@ export function useSuggestions(searchStr, tagNames, filter, anyTag) {
|
|||
domains: generalData.domains,
|
||||
authors: generalData.authors,
|
||||
subjects: generalData.subjects,
|
||||
repos: generalData.repos,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -324,13 +310,6 @@ export function useSearchSuggestions(defaultSearchStr, defaultTagNames, anyTag)
|
|||
})
|
||||
}
|
||||
}
|
||||
if (newSuggestions.repos && newSuggestions.repos.length > 0) {
|
||||
results.repos = {
|
||||
name: 'Repos', results: newSuggestions.repos.map(i => {
|
||||
return {type: 'repo', title: i.name, id: i.id, location: `/repos/${i.id}`}
|
||||
})
|
||||
}
|
||||
}
|
||||
if (newSuggestions.authors && newSuggestions.authors.length > 0) {
|
||||
results.authors = {
|
||||
name: 'Authors', results: newSuggestions.authors.map(i => {
|
||||
|
|
@ -422,11 +401,10 @@ export function useSearchSuggestions(defaultSearchStr, defaultTagNames, anyTag)
|
|||
zims: zimSum,
|
||||
otherSum: otherSum,
|
||||
mapPlaces: newSuggestions.mapPlaces || 0,
|
||||
channels: newSuggestions.channels?.length,
|
||||
domains: newSuggestions.domains?.length,
|
||||
channels: newSuggestions.channels.length,
|
||||
domains: newSuggestions.domains.length,
|
||||
authors: newSuggestions.authors?.length,
|
||||
subjects: newSuggestions.subjects?.length,
|
||||
repos: newSuggestions.repos?.length,
|
||||
tags: matchingTags?.length,
|
||||
apps: matchingApps?.length,
|
||||
});
|
||||
|
|
@ -563,99 +541,31 @@ function SearchChannelPreview({channel}) {
|
|||
</div>
|
||||
}
|
||||
|
||||
function SearchCollectionPreview({collection, to}) {
|
||||
const {SingleTag} = React.useContext(TagsContext);
|
||||
return <div>
|
||||
<span style={{display: 'inline-flex', alignItems: 'center', gap: 8, flexWrap: 'wrap'}}>
|
||||
<Link to={to}>{collection.name}</Link>
|
||||
{collection.tag_name && <SingleTag name={collection.tag_name}/>}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
function SearchRepoPreview({repo}) {
|
||||
const {SingleTag} = React.useContext(TagsContext);
|
||||
return <div>
|
||||
<span style={{display: 'inline-flex', alignItems: 'center', gap: 8, flexWrap: 'wrap'}}>
|
||||
<Link to={`/repos/${repo.id}`}>{repo.name}</Link>
|
||||
{repo.tag_name && <SingleTag name={repo.tag_name}/>}
|
||||
</span>
|
||||
{/* The server escapes the README; only its <b> highlights are markup. */}
|
||||
{repo.readme_headline && <div><small><HeadlineText headline={repo.readme_headline}/></small></div>}
|
||||
</div>
|
||||
}
|
||||
|
||||
function OtherSearchSection({value, title, items, preview, emptyText, errorText}) {
|
||||
let body;
|
||||
if (items === null) {
|
||||
body = <Loading/>;
|
||||
} else if (items === undefined) {
|
||||
body = <ErrorMessage>{errorText}</ErrorMessage>;
|
||||
} else {
|
||||
body = <Stack gap='xs'>{!_.isEmpty(items) ? items.map(preview) : emptyText}</Stack>;
|
||||
}
|
||||
return <Accordion.Item value={value}>
|
||||
<Accordion.Control>
|
||||
<span style={{display: 'inline-flex', alignItems: 'center', gap: 8}}>
|
||||
<Header as='h3' style={{margin: 0}}>{title}</Header>
|
||||
{Array.isArray(items) && <Label>{normalizeEstimate(items.length)}</Label>}
|
||||
</span>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>{body}</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
}
|
||||
|
||||
// Each section's items follow useSearchChannels, useSearchCollections, or useSearchRepos: null = pending,
|
||||
// undefined = fetch failed, [] = none.
|
||||
export function OtherSearchView({loading}) {
|
||||
function OtherSearchView({loading}) {
|
||||
const {searchParams} = React.useContext(QueryContext);
|
||||
const [activeValues, setActiveValues] = React.useState(['channels', 'domains', 'playlists', 'repos']);
|
||||
const [activeValue, setActiveValue] = React.useState('channels');
|
||||
const activeTags = searchParams.getAll('tag');
|
||||
const searchStr = searchParams.get('q') || '';
|
||||
const {channels, loading: channelsLoading} = useSearchChannels(activeTags, searchStr);
|
||||
const {collections: domains} = useSearchCollections('domain', searchStr, activeTags);
|
||||
const {collections: playlists} = useSearchCollections('playlist', searchStr, activeTags);
|
||||
const {repos} = useSearchRepos(searchStr, activeTags);
|
||||
const {channels, loading: channelsLoading} = useSearchChannels(activeTags);
|
||||
|
||||
if (loading || channelsLoading || channels === null) {
|
||||
if (loading || channelsLoading) {
|
||||
return <Panel><Loading/></Panel>
|
||||
} else if (channels === undefined) {
|
||||
return <Panel><ErrorMessage>Could not fetch the channels.</ErrorMessage></Panel>
|
||||
}
|
||||
|
||||
return <Accordion multiple value={activeValues} onChange={setActiveValues}>
|
||||
<OtherSearchSection
|
||||
value='channels'
|
||||
title='Channels'
|
||||
items={channels}
|
||||
preview={i => <SearchChannelPreview key={i.id} channel={i}/>}
|
||||
emptyText='No Channels'
|
||||
errorText='Could not fetch the channels.'
|
||||
/>
|
||||
<OtherSearchSection
|
||||
value='domains'
|
||||
title='Domains'
|
||||
items={domains}
|
||||
preview={i => <SearchCollectionPreview key={i.id} collection={i}
|
||||
to={`/archives?domain=${encodeURIComponent(i.name)}`}/>}
|
||||
emptyText='No Domains'
|
||||
errorText='Could not fetch the domains.'
|
||||
/>
|
||||
<OtherSearchSection
|
||||
value='playlists'
|
||||
title='Playlists'
|
||||
items={playlists}
|
||||
preview={i => <SearchCollectionPreview key={i.id} collection={i} to={`/playlists/${i.id}`}/>}
|
||||
emptyText='No Playlists'
|
||||
errorText='Could not fetch the playlists.'
|
||||
/>
|
||||
<OtherSearchSection
|
||||
value='repos'
|
||||
title='Repos'
|
||||
items={repos}
|
||||
preview={i => <SearchRepoPreview key={i.id} repo={i}/>}
|
||||
emptyText='No Repos'
|
||||
errorText='Could not fetch the repos.'
|
||||
/>
|
||||
return <Accordion value={activeValue} onChange={setActiveValue}>
|
||||
<Accordion.Item value='channels'>
|
||||
<Accordion.Control>
|
||||
<span style={{display: 'inline-flex', alignItems: 'center', gap: 8}}>
|
||||
<Header as='h3' style={{margin: 0}}>Channels</Header>
|
||||
<Label>{normalizeEstimate(channels.length)}</Label>
|
||||
</span>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap='xs'>
|
||||
{!_.isEmpty(channels) ?
|
||||
channels.map(i => <SearchChannelPreview key={i.id} channel={i}/>)
|
||||
: 'No Channels'}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,139 +0,0 @@
|
|||
import React from 'react';
|
||||
import {act, render, screen} from '../test-utils';
|
||||
import {useSearchSuggestions} from './Search';
|
||||
import {TagsContext} from '../Tags';
|
||||
import {tagsContextFixture} from '../test-fixtures';
|
||||
import {
|
||||
searchEstimateFiles, searchEstimateMap, searchEstimateOthers, searchEstimateZims, searchSuggestions,
|
||||
} from '../api';
|
||||
|
||||
// The global search dropdown's groups are built from useSuggestions. A "Channels: No results" row
|
||||
// may only appear once the suggestion request has come back empty, never before the user has
|
||||
// typed, and never while the request is pending.
|
||||
|
||||
jest.mock('../api', () => ({
|
||||
...jest.requireActual('../api'),
|
||||
searchSuggestions: jest.fn(),
|
||||
searchEstimateFiles: jest.fn(),
|
||||
searchEstimateMap: jest.fn(),
|
||||
searchEstimateOthers: jest.fn(),
|
||||
searchEstimateZims: jest.fn(),
|
||||
}));
|
||||
|
||||
const DEBOUNCE = 500;
|
||||
const pending = () => new Promise(() => {
|
||||
});
|
||||
|
||||
let hook;
|
||||
|
||||
function Probe() {
|
||||
hook = useSearchSuggestions('', [], false);
|
||||
return <pre data-testid='results'>{JSON.stringify(hook.suggestionsResults)}</pre>;
|
||||
}
|
||||
|
||||
const results = () => JSON.parse(screen.getByTestId('results').textContent);
|
||||
|
||||
const renderProbe = () => render(<Probe/>, {
|
||||
contexts: [[TagsContext, tagsContextFixture({fuzzyMatchTagsByName: () => []})]],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
searchEstimateFiles.mockReturnValue(pending());
|
||||
searchEstimateMap.mockReturnValue(pending());
|
||||
searchEstimateOthers.mockReturnValue(pending());
|
||||
searchEstimateZims.mockReturnValue(pending());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('no Channels group before anything has been searched', () => {
|
||||
searchSuggestions.mockReturnValue(pending());
|
||||
renderProbe();
|
||||
expect(results().channels).toBeUndefined();
|
||||
});
|
||||
|
||||
test('no Channels group while the suggestion request is pending', () => {
|
||||
searchSuggestions.mockReturnValue(pending());
|
||||
renderProbe();
|
||||
act(() => {
|
||||
hook.setSearchStr('foo');
|
||||
});
|
||||
expect(results().channels).toBeUndefined();
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(DEBOUNCE);
|
||||
});
|
||||
expect(searchSuggestions).toHaveBeenCalledWith('foo');
|
||||
expect(results().channels).toBeUndefined();
|
||||
expect(hook.loading).toBe(true);
|
||||
});
|
||||
|
||||
test('a Channels "No results" row once the server said there are none', async () => {
|
||||
let resolve;
|
||||
searchSuggestions.mockReturnValue(new Promise(res => resolve = res));
|
||||
renderProbe();
|
||||
act(() => {
|
||||
hook.setSearchStr('foo');
|
||||
});
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(DEBOUNCE);
|
||||
});
|
||||
await act(async () => {
|
||||
resolve({channels: [], domains: [], authors: [], subjects: []});
|
||||
});
|
||||
expect(results().channels.results).toEqual([{title: 'No results'}]);
|
||||
});
|
||||
|
||||
test('Channels rows once the server found some', async () => {
|
||||
let resolve;
|
||||
searchSuggestions.mockReturnValue(new Promise(res => resolve = res));
|
||||
renderProbe();
|
||||
act(() => {
|
||||
hook.setSearchStr('foo');
|
||||
});
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(DEBOUNCE);
|
||||
});
|
||||
await act(async () => {
|
||||
resolve({channels: [{id: 3, name: 'Food'}], domains: [], authors: [], subjects: []});
|
||||
});
|
||||
expect(results().channels.results.map(i => i.title)).toEqual(['Food']);
|
||||
});
|
||||
|
||||
test('Repos rows link to each repo, and the Other estimate gets the search', async () => {
|
||||
let resolve;
|
||||
searchSuggestions.mockReturnValue(new Promise(res => resolve = res));
|
||||
renderProbe();
|
||||
act(() => {
|
||||
hook.setSearchStr('kiwix');
|
||||
});
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(DEBOUNCE);
|
||||
});
|
||||
await act(async () => {
|
||||
resolve({channels: [], domains: [], authors: [], subjects: [], repos: [{id: 4, name: 'kiwix-tools'}]});
|
||||
});
|
||||
expect(results().repos.results).toEqual([{type: 'repo', title: 'kiwix-tools', id: 4, location: '/repos/4'}]);
|
||||
// Repos count toward the Other tab by their name and README, not only by tag.
|
||||
expect(searchEstimateOthers).toHaveBeenCalledWith([], 'kiwix');
|
||||
});
|
||||
|
||||
test('no Repos group when none match', async () => {
|
||||
let resolve;
|
||||
searchSuggestions.mockReturnValue(new Promise(res => resolve = res));
|
||||
renderProbe();
|
||||
act(() => {
|
||||
hook.setSearchStr('foo');
|
||||
});
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(DEBOUNCE);
|
||||
});
|
||||
await act(async () => {
|
||||
resolve({channels: [], domains: [], authors: [], subjects: [], repos: []});
|
||||
});
|
||||
expect(results().repos).toBeUndefined();
|
||||
});
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
import React from 'react';
|
||||
import {act, render, screen} from '../test-utils';
|
||||
import {StatisticsPage} from './Apps';
|
||||
import {getStatistics} from '../api';
|
||||
|
||||
// What the Statistics page shows while the numbers are still loading, when the fetch failed, and
|
||||
// once they arrive. "Failed to fetch statistics" may only appear for a failed fetch.
|
||||
|
||||
jest.mock('../api', () => ({
|
||||
...jest.requireActual('../api'),
|
||||
getStatistics: jest.fn(),
|
||||
}));
|
||||
|
||||
const FAILED = /Failed to fetch statistics/;
|
||||
const STATS = {
|
||||
global_statistics: {db_size: 1024},
|
||||
file_statistics: {
|
||||
archive_count: 1, audio_count: 2, ebook_count: 3, image_count: 4, pdf_count: 5, total_count: 21,
|
||||
video_count: 6, zip_count: 0, total_size: 4096, tagged_files: 7, tagged_zims: 0, tags_count: 2,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => getStatistics.mockReset());
|
||||
|
||||
test('pending: shows a loader, not the error', () => {
|
||||
getStatistics.mockReturnValue(new Promise(() => {
|
||||
}));
|
||||
const {container} = render(<StatisticsPage/>);
|
||||
expect(container.querySelector('.wrolpi-loading')).toBeInTheDocument();
|
||||
expect(screen.queryByText(FAILED)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('failed: shows the error', async () => {
|
||||
// The api helper toasts and returns undefined on a non-OK response.
|
||||
getStatistics.mockResolvedValue(undefined);
|
||||
render(<StatisticsPage/>);
|
||||
await act(async () => {
|
||||
});
|
||||
expect(screen.getByText(FAILED)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('loaded: shows the numbers', async () => {
|
||||
getStatistics.mockResolvedValue(STATS);
|
||||
render(<StatisticsPage/>);
|
||||
await act(async () => {
|
||||
});
|
||||
expect(screen.getByText('All Files')).toBeInTheDocument();
|
||||
expect(screen.queryByText(FAILED)).not.toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
import React from 'react';
|
||||
import {render, screen} from '../test-utils';
|
||||
import {DisableDownloadsToggle, HotspotToggle} from './Common';
|
||||
import {SettingsPage, UpgradeSegment} from './admin/Settings';
|
||||
|
||||
// The status and settings contexts start empty and are filled by the first poll. Until then a
|
||||
// consumer must not claim a negative ("Downloading Disabled", "not supported", "up to date") or
|
||||
// render a form over blank values; `loaded` on each context says whether the first poll landed.
|
||||
|
||||
jest.mock('../api', () => ({
|
||||
...jest.requireActual('../api'),
|
||||
getConfigs: jest.fn(() => new Promise(() => {
|
||||
})),
|
||||
}));
|
||||
|
||||
const unloadedStatus = {status: {}, loaded: false};
|
||||
|
||||
describe('DisableDownloadsToggle before status has loaded', () => {
|
||||
test('is disabled and does not claim downloading is disabled', () => {
|
||||
render(<DisableDownloadsToggle/>, {status: unloadedStatus});
|
||||
expect(screen.getByTestId('toggle')).toBeDisabled();
|
||||
expect(screen.queryByText('Downloading Disabled')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('once loaded, reads the downloads state', () => {
|
||||
render(<DisableDownloadsToggle/>, {status: {status: {downloads: {disabled: true, stopped: false}}, loaded: true}});
|
||||
expect(screen.getByText('Downloading Disabled')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('toggle')).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('HotspotToggle before status has loaded', () => {
|
||||
test('is disabled without claiming the hotspot is unsupported', () => {
|
||||
render(<HotspotToggle/>, {status: unloadedStatus});
|
||||
expect(screen.getByTestId('toggle')).toBeDisabled();
|
||||
expect(screen.queryByTestId('subsystem-popup')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('once loaded without a hotspot field, is unsupported', () => {
|
||||
render(<HotspotToggle/>, {status: {status: {}, loaded: true}});
|
||||
expect(screen.getByTestId('toggle')).toBeDisabled();
|
||||
expect(screen.getByTestId('subsystem-popup')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UpgradeSegment before status has loaded', () => {
|
||||
test('does not claim the WROLPi is up to date', () => {
|
||||
render(<UpgradeSegment/>, {status: unloadedStatus});
|
||||
expect(screen.queryByText(/up to date/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('once loaded with no update, says so', () => {
|
||||
render(<UpgradeSegment/>, {status: {status: {update_available: false, version: '1.0'}, loaded: true}});
|
||||
expect(screen.getByText(/up to date/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SettingsPage before settings have loaded', () => {
|
||||
test('shows a loader, not a form over blank values', () => {
|
||||
const {container} = render(<SettingsPage/>, {settings: {settings: {}, loaded: false, failed: false}});
|
||||
expect(container.querySelector('.wrolpi-loading')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Any changes will be written/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows an error when the settings could not be fetched', () => {
|
||||
render(<SettingsPage/>, {settings: {settings: {}, loaded: false, failed: true}});
|
||||
expect(screen.getByText('Unable to fetch settings')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -45,13 +45,11 @@ import {
|
|||
toast,
|
||||
} from './ui';
|
||||
import {paletteColorNames} from '../themes/mantine';
|
||||
import {contrastingColor, ViewProgress} from './Common';
|
||||
import {contrastingColor} from './Common';
|
||||
import {AiModelRow} from './Ai';
|
||||
import {ChatMessage} from './AiChat';
|
||||
import {navBarStyle, navColorNames, useNavColors} from '../themes/navColors';
|
||||
import {IconMenu2} from '@tabler/icons-react';
|
||||
import {BookmarkAddForms, BookmarkMenuItems, BookmarkTable} from './Bookmarks';
|
||||
import {
|
||||
RepoBreadcrumb, RepoHistoryTable, RepoImportSummary, RepoStatusIcon, RepoSummary, RepoTreeTable,
|
||||
} from './Repos';
|
||||
|
||||
/*
|
||||
* A gallery of every component in the library, in the current theme.
|
||||
|
|
@ -160,41 +158,6 @@ export const NavBarSample = ({color}) => {
|
|||
// press adds a toast rather than replacing the first. Module scope, so it survives re-renders.
|
||||
let toastCounter = 0;
|
||||
|
||||
// Sample data for the bookmarks section; the editor's actions do nothing here.
|
||||
const sampleBookmarks = [
|
||||
{id: 1, name: 'Channels', url: '/videos/channel', new_tab: false},
|
||||
{id: 2, name: 'Services', children: [
|
||||
{id: 3, name: 'Jellyfin', url: ':8096/', new_tab: true},
|
||||
{id: 4, name: 'Radio', children: [
|
||||
{id: 5, name: 'OpenWebRX', url: 'http://:8073/', new_tab: true},
|
||||
]},
|
||||
]},
|
||||
{id: 6, name: 'WROLPi.org', url: 'https://wrolpi.org', new_tab: true},
|
||||
];
|
||||
const noBookmarkActions = {edit: () => null, move: () => null, remove: () => null};
|
||||
|
||||
const sampleRepo = {
|
||||
id: 1, name: 'kiwix-tools', tag_name: null, url: 'https://github.com/kiwix/kiwix-tools',
|
||||
description: 'Command line tools for Kiwix and Zim files.', mode: 'full', branch: null, default_branch: 'main',
|
||||
head_sha: '3f9c2a1e7b', head_message: 'Serve Zim files with the correct mimetype', head_date: '2026-09-30T14:02:00Z',
|
||||
size: 18_400_000, directory: 'repos/kiwix-tools', download_error: null,
|
||||
};
|
||||
const sampleFailedRepo = {
|
||||
...sampleRepo, name: 'gone-upstream', branch: 'release', mode: 'snapshot',
|
||||
download_error: "git ls-remote failed (128): remote: Repository not found.\nfatal: repository " +
|
||||
"'https://github.com/example/gone-upstream/' not found",
|
||||
};
|
||||
const sampleRepoCommits = [
|
||||
{sha: '3f9c2a1e7b', author: 'Ada Example', date: '2026-09-30T14:02:00Z',
|
||||
message: 'Serve Zim files with the correct mimetype'},
|
||||
{sha: '91b0d44c02', author: 'Ben Example', date: '2026-09-12T09:15:00Z', message: 'Merge pull request #42'},
|
||||
];
|
||||
const sampleRepoEntries = [
|
||||
{name: 'docs', path: 'src/docs', is_dir: true, size: null},
|
||||
{name: 'kiwix-serve.cpp', path: 'src/kiwix-serve.cpp', is_dir: false, size: 48_210},
|
||||
{name: 'README.md', path: 'src/README.md', is_dir: false, size: 2_104},
|
||||
];
|
||||
|
||||
export function ThemeSamplePage() {
|
||||
const {theme, mediaFilterEnabled} = useContext(ThemeContext);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
|
|
@ -764,6 +727,51 @@ export function ThemeSamplePage() {
|
|||
</Table>
|
||||
</Section>
|
||||
|
||||
<Section label='AI model catalog'>
|
||||
{/* The real AiModelRow from the AI page's Manage tab, in both of its states. */}
|
||||
<Table>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>Model</Table.HeaderCell>
|
||||
<Table.HeaderCell>Tier</Table.HeaderCell>
|
||||
<Table.HeaderCell>Size</Table.HeaderCell>
|
||||
<Table.HeaderCell/>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
<AiModelRow model={{
|
||||
name: 'Qwen3-1.7B-Q4_K_M.gguf', tier: 'small', size: 1_200_000_000,
|
||||
description: 'The default assistant for 4GB devices (Pi 4/5).',
|
||||
downloaded: true, active: true,
|
||||
}} onDownload={() => {}}/>
|
||||
<AiModelRow model={{
|
||||
name: 'Qwen3-4B-Instruct-2507-Q4_K_M.gguf', tier: 'medium', size: 2_600_000_000,
|
||||
description: 'The default assistant for 8GB+ devices.',
|
||||
downloaded: false, active: false,
|
||||
}} onDownload={() => {}}/>
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</Section>
|
||||
|
||||
<Section label='AI chat'>
|
||||
{/* The real ChatMessage from the AI Chat tab, in each of its states. */}
|
||||
<ChatMessage message={{role: 'user', content: 'Find videos about canning'}}/>
|
||||
<ChatMessage message={{
|
||||
role: 'assistant',
|
||||
content: 'I found one video: Pressure Canning Basics — /videos/123',
|
||||
activity: [{tool: 'search_files', pending: false, success: true}],
|
||||
}}/>
|
||||
<ChatMessage message={{
|
||||
role: 'assistant', content: '',
|
||||
activity: [{tool: 'search_files', pending: true}], streaming: true,
|
||||
}}/>
|
||||
<ChatMessage message={{
|
||||
role: 'assistant', content: '',
|
||||
activity: [{tool: 'read_content', pending: false, success: false}],
|
||||
error: 'The AI failed to answer. Check the logs.',
|
||||
}}/>
|
||||
</Section>
|
||||
|
||||
<Section label='Form controls'>
|
||||
<Panel>
|
||||
<div style={{display: 'grid', gridTemplateColumns: '2fr 1fr 1fr', gap: 12}}>
|
||||
|
|
@ -1032,31 +1040,6 @@ export function ThemeSamplePage() {
|
|||
rather than upscaled.
|
||||
</p>
|
||||
|
||||
<Header as='h4'>Viewing progress</Header>
|
||||
<CardGroup>
|
||||
{[
|
||||
['Part way through', 0.4],
|
||||
['Nearly done', 0.9],
|
||||
['Finished', 1],
|
||||
['Not started', null],
|
||||
].map(([label, progress]) => <Card
|
||||
key={label}
|
||||
title={label}
|
||||
meta={progress ? `${Math.round(progress * 100)}%` : 'no bar'}
|
||||
color='blue'
|
||||
media={<div className='wrolpi-card-poster'>
|
||||
<div className='wrolpi-card-poster-frame'>
|
||||
<img alt='' src={samplePoster(1280, 720)}/>
|
||||
<ViewProgress progress={progress}/>
|
||||
</div>
|
||||
</div>}
|
||||
/>)}
|
||||
</CardGroup>
|
||||
<p style={{fontSize: '0.75rem', color: 'var(--muted)', marginTop: 10}}>
|
||||
How far the user is through a video, audio file, ebook or comic, along the bottom
|
||||
of its poster. A full bar is finished; a file not yet started has none.
|
||||
</p>
|
||||
|
||||
<Header as='h4'>Meta, body and actions</Header>
|
||||
<CardGroup>
|
||||
{[
|
||||
|
|
@ -1142,71 +1125,6 @@ export function ThemeSamplePage() {
|
|||
</Panel>
|
||||
</Section>
|
||||
|
||||
<Section label='Bookmarks'>
|
||||
<Panel>
|
||||
<p style={{fontSize: '0.75rem', color: 'var(--muted)', marginTop: 0}}>
|
||||
The Bookmarks menu in the navigation bar, and the editor under More. A
|
||||
directory is a flyout that opens beside the menu. In the mobile menu it is a
|
||||
labelled, indented run of items instead.
|
||||
</p>
|
||||
<Row>
|
||||
<Menu position='bottom-start' withinPortal trigger='click-hover'>
|
||||
<Menu.Target>
|
||||
<Button iconAfter='dropdown'>Bookmarks</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<BookmarkMenuItems nodes={sampleBookmarks}/>
|
||||
<Menu.Divider/>
|
||||
<Menu.Item leftSection={<Icon name='edit' size='small'/>}>Edit bookmarks</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
<Menu position='bottom-start' withinPortal>
|
||||
<Menu.Target>
|
||||
<Button iconAfter='dropdown'>As the mobile menu shows it</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>Bookmarks</Menu.Label>
|
||||
<BookmarkMenuItems nodes={sampleBookmarks} nested={false}/>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Row>
|
||||
<div style={{marginTop: 12}}>
|
||||
<BookmarkTable nodes={sampleBookmarks} actions={noBookmarkActions}/>
|
||||
<BookmarkAddForms nodes={sampleBookmarks} onAddBookmark={() => null} onAddDirectory={() => null}/>
|
||||
</div>
|
||||
</Panel>
|
||||
</Section>
|
||||
|
||||
<Section label='Repos'>
|
||||
<Panel>
|
||||
<p style={{fontSize: '0.75rem', color: 'var(--muted)', marginTop: 0}}>
|
||||
A git repo's page: its summary, then its files. A repo whose last update failed keeps
|
||||
its files and explains why. The status icons appear beside a repo's name in the Repos
|
||||
table: <RepoStatusIcon repo={sampleFailedRepo}/> failed,{' '}
|
||||
<RepoStatusIcon repo={{...sampleRepo, head_sha: null}}/> not downloaded yet.
|
||||
</p>
|
||||
<RepoSummary repo={sampleRepo}/>
|
||||
<div style={{marginTop: 12}}>
|
||||
<RepoBreadcrumb name={sampleRepo.name} path='src' onNavigate={() => null}/>
|
||||
<RepoTreeTable path='src' entries={sampleRepoEntries} onOpenDirectory={() => null}
|
||||
onOpenFile={() => null}/>
|
||||
</div>
|
||||
<div style={{marginTop: 12}}>
|
||||
<RepoHistoryTable commits={sampleRepoCommits}/>
|
||||
</div>
|
||||
<div style={{marginTop: 12}}>
|
||||
<RepoImportSummary inspection={{
|
||||
origin: 'git@github.com:kiwix/kiwix-tools.git', branch: 'main', head_sha: '3f9c2a1e7b',
|
||||
head_message: 'Local work', head_date: '2026-09-30T14:02:00Z', local_commits: 2,
|
||||
shallow: true, directory: 'code/kiwix-tools', ignored: false,
|
||||
}}/>
|
||||
</div>
|
||||
<div style={{marginTop: 12}}>
|
||||
<RepoSummary repo={sampleFailedRepo}/>
|
||||
</div>
|
||||
</Panel>
|
||||
</Section>
|
||||
|
||||
<Section label='Danger zone'>
|
||||
<Panel danger>
|
||||
<h2 style={{color: 'var(--danger)', fontSize: '0.875rem', margin: '0 0 4px'}}>Danger zone</h2>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ export const VIDEOS_API = `${API_URI}/videos`;
|
|||
export const ARCHIVES_API = `${API_URI}/archive`;
|
||||
export const COLLECTIONS_API = `${API_URI}/collections`;
|
||||
export const ZIM_API = `${API_URI}/zim`;
|
||||
export const REPOS_API = `${API_URI}/repos`;
|
||||
export const DEFAULT_LIMIT = 20;
|
||||
export const NAME = process.env && process.env.REACT_APP_NAME ? process.env.REACT_APP_NAME : null;
|
||||
|
||||
|
|
@ -169,14 +168,11 @@ export const transcodeVideoCodecOptions = [
|
|||
{value: 'h264', label: 'h264 (avc1)'},
|
||||
{value: TRANSCODE_REMOVE_VIDEO, label: 'Remove video (audio only)'},
|
||||
];
|
||||
// REMOVE_AUDIO in modules/videos/transcode.py: drop the audio stream, the result is a silent video.
|
||||
export const TRANSCODE_REMOVE_AUDIO = 'none';
|
||||
export const transcodeAudioCodecOptions = [
|
||||
{value: TRANSCODE_COPY, label: 'Keep (copy)'},
|
||||
{value: 'aac', label: 'aac'},
|
||||
{value: 'opus', label: 'opus'},
|
||||
{value: 'mp3', label: 'mp3'},
|
||||
{value: TRANSCODE_REMOVE_AUDIO, label: 'Remove audio (silent video)'},
|
||||
];
|
||||
// Containers for output with a video stream ...
|
||||
export const transcodeContainerOptions = [
|
||||
|
|
@ -198,6 +194,7 @@ export function audioContainerForCodec(codec) {
|
|||
}
|
||||
|
||||
export const Downloaders = {
|
||||
AiModel: 'ai_model',
|
||||
Archive: 'archive',
|
||||
File: 'file',
|
||||
KiwixCatalog: 'kiwix_catalog',
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import {
|
|||
import {
|
||||
audioContainerForCodec,
|
||||
TRANSCODE_COPY,
|
||||
TRANSCODE_REMOVE_AUDIO,
|
||||
TRANSCODE_REMOVE_VIDEO,
|
||||
transcodeAudioCodecOptions,
|
||||
transcodeAudioContainerOptions,
|
||||
|
|
@ -69,41 +68,6 @@ export function containerFor({audioOutput, audioCodec, video}) {
|
|||
return transcodeContainerOptions.some(i => i.value === current) ? current : 'mp4';
|
||||
}
|
||||
|
||||
/**
|
||||
* The video codec choices: Remove video is withdrawn once the audio is being removed (nothing
|
||||
* would remain).
|
||||
*/
|
||||
export function videoCodecOptionsFor({removeAudio}) {
|
||||
return removeAudio ? transcodeVideoCodecOptions.filter(i => i.value !== TRANSCODE_REMOVE_VIDEO)
|
||||
: transcodeVideoCodecOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* The audio codec choices: Remove audio is only for a video whose video stream is kept; an audio
|
||||
* file has nothing else, and removing the video already chose the audio as the result.
|
||||
*/
|
||||
export function audioCodecOptionsFor({audioOnly, removeVideo}) {
|
||||
return (audioOnly || removeVideo) ? transcodeAudioCodecOptions.filter(i => i.value !== TRANSCODE_REMOVE_AUDIO)
|
||||
: transcodeAudioCodecOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* The codec pair after one select changes. Both streams can never be removed (nothing would
|
||||
* remain): choosing Remove for one stream puts the other back to Keep.
|
||||
*/
|
||||
export function chooseCodecs({videoCodec, audioCodec}, {video, audio}) {
|
||||
let nextVideo = video !== undefined ? (video || TRANSCODE_COPY) : videoCodec;
|
||||
let nextAudio = audio !== undefined ? (audio || TRANSCODE_COPY) : audioCodec;
|
||||
if (nextVideo === TRANSCODE_REMOVE_VIDEO && nextAudio === TRANSCODE_REMOVE_AUDIO) {
|
||||
if (video !== undefined) {
|
||||
nextAudio = TRANSCODE_COPY;
|
||||
} else {
|
||||
nextVideo = TRANSCODE_COPY;
|
||||
}
|
||||
}
|
||||
return {videoCodec: nextVideo, audioCodec: nextAudio};
|
||||
}
|
||||
|
||||
const statusKind = (status) => {
|
||||
if (status === 'complete') return 'complete';
|
||||
if (status === 'running') return 'active';
|
||||
|
|
@ -250,7 +214,6 @@ export function TranscodeModal({
|
|||
|
||||
const current = currentCodecs(video);
|
||||
const removeVideo = videoCodec === TRANSCODE_REMOVE_VIDEO;
|
||||
const removeAudio = !audioOnly && !removeVideo && audioCodec === TRANSCODE_REMOVE_AUDIO;
|
||||
// An audio file has no video stream to keep or remove; removing the video makes an audio file.
|
||||
const audioOutput = audioOnly || removeVideo;
|
||||
|
||||
|
|
@ -287,20 +250,18 @@ export function TranscodeModal({
|
|||
};
|
||||
}, [open]);
|
||||
|
||||
// `chooseCodecs` keeps Remove video and Remove audio from ever being chosen together; the
|
||||
// other select also withdraws its Remove option (see `videoCodecOptionsFor`/`audioCodecOptionsFor`).
|
||||
const applyCodecs = (change) => {
|
||||
const next = chooseCodecs({videoCodec, audioCodec}, change);
|
||||
setVideoCodec(next.videoCodec);
|
||||
setAudioCodec(next.audioCodec);
|
||||
setContainer(containerFor({
|
||||
audioOutput: audioOnly || next.videoCodec === TRANSCODE_REMOVE_VIDEO, audioCodec: next.audioCodec, video,
|
||||
}));
|
||||
const changeVideoCodec = (value) => {
|
||||
const next = value || TRANSCODE_COPY;
|
||||
setVideoCodec(next);
|
||||
setContainer(containerFor({audioOutput: audioOnly || next === TRANSCODE_REMOVE_VIDEO, audioCodec, video}));
|
||||
};
|
||||
const changeAudioCodec = (value) => {
|
||||
const next = value || TRANSCODE_COPY;
|
||||
setAudioCodec(next);
|
||||
setContainer(containerFor({audioOutput, audioCodec: next, video}));
|
||||
};
|
||||
const changeVideoCodec = (value) => applyCodecs({video: value});
|
||||
const changeAudioCodec = (value) => applyCodecs({audio: value});
|
||||
|
||||
const remuxOnly = !removeVideo && !removeAudio && videoCodec === TRANSCODE_COPY && audioCodec === TRANSCODE_COPY;
|
||||
const remuxOnly = !removeVideo && videoCodec === TRANSCODE_COPY && audioCodec === TRANSCODE_COPY;
|
||||
const sameContainer = container === currentContainer(video);
|
||||
const containerOptions = audioOutput ? transcodeAudioContainerOptions : transcodeContainerOptions;
|
||||
// Fragments are an mp4 concept (m4a is an mp4).
|
||||
|
|
@ -311,8 +272,7 @@ export function TranscodeModal({
|
|||
try {
|
||||
const id = await transcodeVideo(fileGroupId, {
|
||||
video_codec: videoCodec === TRANSCODE_COPY ? null : videoCodec,
|
||||
audio_codec: audioCodec === TRANSCODE_COPY || (removeVideo && audioCodec === TRANSCODE_REMOVE_AUDIO)
|
||||
? null : audioCodec,
|
||||
audio_codec: audioCodec === TRANSCODE_COPY ? null : audioCodec,
|
||||
container,
|
||||
fragmented: canFragment && fragmented,
|
||||
});
|
||||
|
|
@ -332,9 +292,6 @@ export function TranscodeModal({
|
|||
if (defaultsLoaded && removeVideo) {
|
||||
hint = `The video stream is removed; the result is an audio file (.${container}). `
|
||||
+ (audioCodec === TRANSCODE_COPY ? 'The audio is copied without re-encoding.' : `The audio is converted to ${audioCodec}.`);
|
||||
} else if (defaultsLoaded && removeAudio) {
|
||||
hint = `The audio stream is removed; the result is a silent video (.${container}). `
|
||||
+ (videoCodec === TRANSCODE_COPY ? 'The video is copied without re-encoding.' : `The video is converted to ${videoCodec}.`);
|
||||
} else if (defaultsLoaded && remuxOnly) {
|
||||
const kept = audioOnly ? 'The audio is kept' : 'Both streams are kept';
|
||||
if (sameContainer && canFragment && fragmented) {
|
||||
|
|
@ -346,7 +303,7 @@ export function TranscodeModal({
|
|||
hint = `${kept}: only the container changes to ${container} (a quick remux, no quality loss).`;
|
||||
}
|
||||
}
|
||||
const buttonLabel = removeVideo ? 'Extract Audio' : removeAudio ? 'Remove Audio' : remuxOnly ? 'Remux' : 'Transcode';
|
||||
const buttonLabel = removeVideo ? 'Extract Audio' : remuxOnly ? 'Remux' : 'Transcode';
|
||||
|
||||
return <Modal open={open} onClose={onClose} size='small'>
|
||||
<Modal.Header>{audioOnly ? 'Transcode Audio' : 'Transcode Video'}</Modal.Header>
|
||||
|
|
@ -362,7 +319,7 @@ export function TranscodeModal({
|
|||
{!showJob && <>
|
||||
{!audioOnly && <Select
|
||||
label='Video codec'
|
||||
data={videoCodecOptionsFor({removeAudio})}
|
||||
data={transcodeVideoCodecOptions}
|
||||
value={videoCodec}
|
||||
onChange={changeVideoCodec}
|
||||
disabled={!defaultsLoaded}
|
||||
|
|
@ -370,7 +327,7 @@ export function TranscodeModal({
|
|||
/>}
|
||||
<Select
|
||||
label='Audio codec'
|
||||
data={audioCodecOptionsFor({audioOnly, removeVideo})}
|
||||
data={transcodeAudioCodecOptions}
|
||||
value={audioCodec}
|
||||
onChange={changeAudioCodec}
|
||||
disabled={!defaultsLoaded}
|
||||
|
|
@ -422,27 +379,14 @@ export function EditVideoModal({open, onClose, fileGroupId, videoFile, descripti
|
|||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
// The description last copied into the textarea; the user has typed when the field differs.
|
||||
const syncedDescription = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// Start from the current details on every open, discarding an abandoned edit.
|
||||
setTitle(videoFile?.title || '');
|
||||
setDescription(currentDescription || '');
|
||||
syncedDescription.current = currentDescription || '';
|
||||
}
|
||||
}, [open, videoFile?.title]);
|
||||
|
||||
useEffect(() => {
|
||||
// The description may still be loading when the dialog opens (null), and it is refetched
|
||||
// after a save. Adopt what arrives only while the user has not typed, so a draft written
|
||||
// over the placeholder is never wiped by the empty or failed value that follows it.
|
||||
if (open && currentDescription != null && description === syncedDescription.current) {
|
||||
setDescription(currentDescription);
|
||||
syncedDescription.current = currentDescription;
|
||||
}
|
||||
}, [currentDescription]);
|
||||
}, [open, videoFile?.title, currentDescription]);
|
||||
|
||||
const trimmed = title.trim();
|
||||
const titleChanged = trimmed !== (videoFile?.title || '');
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@ import React from 'react';
|
|||
import {act, fireEvent, screen, waitFor} from '@testing-library/react';
|
||||
import {renderWithProviders} from '../test-utils';
|
||||
import {
|
||||
audioCodecOptionsFor, chooseCodecs, containerFor, currentCodecs, currentContainer, EditVideoModal, JobProgress, preferredTarget,
|
||||
TranscodeModal, useJob, VideoEditMenu, videoCodecOptionsFor,
|
||||
containerFor, currentCodecs, currentContainer, EditVideoModal, JobProgress, preferredTarget, TranscodeModal,
|
||||
useJob, VideoEditMenu,
|
||||
} from './VideoEditMenu';
|
||||
import {
|
||||
audioContainerForCodec, TRANSCODE_COPY, TRANSCODE_REMOVE_AUDIO, TRANSCODE_REMOVE_VIDEO, transcodeAudioCodecOptions,
|
||||
audioContainerForCodec, TRANSCODE_COPY, TRANSCODE_REMOVE_VIDEO, transcodeAudioCodecOptions,
|
||||
transcodeVideoCodecOptions,
|
||||
} from './Vars';
|
||||
|
||||
|
|
@ -221,35 +221,6 @@ describe('TranscodeModal', () => {
|
|||
// Selecting it is exercised through containerFor (Mantine's dropdown is not driveable in jsdom).
|
||||
});
|
||||
|
||||
test('the Remove audio option is offered for videos, not audio files, and never with Remove video', () => {
|
||||
expect(transcodeAudioCodecOptions.some(i => i.value === TRANSCODE_REMOVE_AUDIO)).toBe(true);
|
||||
const has = (options, value) => options.some(i => i.value === value);
|
||||
expect(has(audioCodecOptionsFor({audioOnly: false, removeVideo: false}), TRANSCODE_REMOVE_AUDIO)).toBe(true);
|
||||
expect(has(audioCodecOptionsFor({audioOnly: true, removeVideo: false}), TRANSCODE_REMOVE_AUDIO)).toBe(false);
|
||||
expect(has(audioCodecOptionsFor({audioOnly: false, removeVideo: true}), TRANSCODE_REMOVE_AUDIO)).toBe(false);
|
||||
// The real codecs and Keep are never withdrawn.
|
||||
expect(audioCodecOptionsFor({audioOnly: true, removeVideo: false}).map(i => i.value))
|
||||
.toEqual([TRANSCODE_COPY, 'aac', 'opus', 'mp3']);
|
||||
expect(has(videoCodecOptionsFor({removeAudio: false}), TRANSCODE_REMOVE_VIDEO)).toBe(true);
|
||||
expect(has(videoCodecOptionsFor({removeAudio: true}), TRANSCODE_REMOVE_VIDEO)).toBe(false);
|
||||
expect(has(videoCodecOptionsFor({removeAudio: true}), 'h264')).toBe(true);
|
||||
});
|
||||
|
||||
test('Remove video and Remove audio can never be chosen together', () => {
|
||||
const keepBoth = {videoCodec: TRANSCODE_COPY, audioCodec: TRANSCODE_COPY};
|
||||
// Removing one stream after the other was chosen puts the other back to Keep.
|
||||
expect(chooseCodecs({videoCodec: TRANSCODE_REMOVE_VIDEO, audioCodec: 'aac'}, {audio: TRANSCODE_REMOVE_AUDIO}))
|
||||
.toEqual({videoCodec: TRANSCODE_COPY, audioCodec: TRANSCODE_REMOVE_AUDIO});
|
||||
expect(chooseCodecs({videoCodec: 'h264', audioCodec: TRANSCODE_REMOVE_AUDIO}, {video: TRANSCODE_REMOVE_VIDEO}))
|
||||
.toEqual({videoCodec: TRANSCODE_REMOVE_VIDEO, audioCodec: TRANSCODE_COPY});
|
||||
// Ordinary choices are untouched; a cleared select means Keep.
|
||||
expect(chooseCodecs(keepBoth, {video: 'h264'})).toEqual({videoCodec: 'h264', audioCodec: TRANSCODE_COPY});
|
||||
expect(chooseCodecs({videoCodec: 'h264', audioCodec: 'aac'}, {audio: null}))
|
||||
.toEqual({videoCodec: 'h264', audioCodec: TRANSCODE_COPY});
|
||||
expect(chooseCodecs(keepBoth, {audio: TRANSCODE_REMOVE_AUDIO}))
|
||||
.toEqual({videoCodec: TRANSCODE_COPY, audioCodec: TRANSCODE_REMOVE_AUDIO});
|
||||
});
|
||||
|
||||
test('WROL Mode disables starting a transcode', async () => {
|
||||
renderWithProviders(
|
||||
<TranscodeModal open={true} onClose={jest.fn()} fileGroupId={7} video={video}/>,
|
||||
|
|
@ -298,26 +269,6 @@ describe('EditVideoModal', () => {
|
|||
await waitFor(() => expect(updateVideo).toHaveBeenCalledWith(7, {description: ''}));
|
||||
});
|
||||
|
||||
test('a draft typed while the description was still loading survives its arrival', () => {
|
||||
// The page passes null while the description is pending, then '' when the server has none.
|
||||
const {rerender} = renderWithProviders(
|
||||
<EditVideoModal open={true} onClose={jest.fn()} fileGroupId={7} videoFile={videoFile} description={null}/>,
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText('Description'), {target: {value: 'my new words'}});
|
||||
rerender(<EditVideoModal open={true} onClose={jest.fn()} fileGroupId={7} videoFile={videoFile} description=''/>);
|
||||
expect(screen.getByLabelText('Description')).toHaveValue('my new words');
|
||||
});
|
||||
|
||||
test('a description that arrives while the field is untouched is adopted', () => {
|
||||
const {rerender} = renderWithProviders(
|
||||
<EditVideoModal open={true} onClose={jest.fn()} fileGroupId={7} videoFile={videoFile} description={null}/>,
|
||||
);
|
||||
expect(screen.getByLabelText('Description')).toHaveValue('');
|
||||
rerender(<EditVideoModal open={true} onClose={jest.fn()} fileGroupId={7} videoFile={videoFile}
|
||||
description='From the server'/>);
|
||||
expect(screen.getByLabelText('Description')).toHaveValue('From the server');
|
||||
});
|
||||
|
||||
test('WROL Mode disables saving', async () => {
|
||||
renderWithProviders(
|
||||
<EditVideoModal open={true} onClose={jest.fn()} fileGroupId={7} videoFile={videoFile}/>,
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
import React from 'react';
|
||||
import {render, screen} from '../test-utils';
|
||||
import {Comments, VideoDescription} from './VideoPlayer';
|
||||
|
||||
// What the video page shows for each state of useVideoExtras. The states are null (pending),
|
||||
// undefined (fetch failed), and an empty value (loaded, nothing to show). "No comments have been
|
||||
// downloaded" carries a Refresh button that re-downloads the video, so it must only appear once
|
||||
// the server has confirmed there are none.
|
||||
|
||||
const video = {id: 7, url: 'https://example.com/v'};
|
||||
const NO_COMMENTS = /No comments have been/;
|
||||
const NO_DESCRIPTION = /No description available/;
|
||||
|
||||
describe('Comments', () => {
|
||||
test('pending: shows a placeholder, not "no comments" or an error', () => {
|
||||
const {container} = render(<Comments comments={null} video={video}/>);
|
||||
expect(container.querySelector('.wrolpi-placeholder')).toBeInTheDocument();
|
||||
expect(screen.queryByText(NO_COMMENTS)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Could not/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', {name: 'Refresh'})).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('failed: shows an error, not "no comments"', () => {
|
||||
render(<Comments comments={undefined} video={video}/>);
|
||||
expect(screen.getByText(/Could not fetch the comments/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(NO_COMMENTS)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('empty: shows "no comments" with the Refresh button', () => {
|
||||
render(<Comments comments={[]} video={video}/>);
|
||||
expect(screen.getByText(NO_COMMENTS)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', {name: 'Refresh'})).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('loaded: renders the comments', () => {
|
||||
render(<Comments comments={[{id: 'c1', parent: 'root', text: 'first!', author: 'a'}]} video={video}/>);
|
||||
expect(screen.getByText('first!')).toBeInTheDocument();
|
||||
expect(screen.queryByText(NO_COMMENTS)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('VideoDescription', () => {
|
||||
test('pending: shows a placeholder, not "no description" or an error', () => {
|
||||
const {container} = render(<VideoDescription description={null} setVideoTime={() => null}/>);
|
||||
expect(container.querySelector('.wrolpi-placeholder')).toBeInTheDocument();
|
||||
expect(screen.queryByText(NO_DESCRIPTION)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Could not/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('failed: shows an error, not "no description"', () => {
|
||||
render(<VideoDescription description={undefined} setVideoTime={() => null}/>);
|
||||
expect(screen.getByText(/Could not fetch the description/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(NO_DESCRIPTION)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('empty: shows "no description"', () => {
|
||||
render(<VideoDescription description='' setVideoTime={() => null}/>);
|
||||
expect(screen.getByText(NO_DESCRIPTION)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('loaded: renders the description', () => {
|
||||
render(<VideoDescription description='A fine video.' setVideoTime={() => null}/>);
|
||||
expect(screen.getByText('A fine video.')).toBeInTheDocument();
|
||||
expect(screen.queryByText(NO_DESCRIPTION)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -8,7 +8,6 @@ import {
|
|||
BackButton,
|
||||
DirectoryLink,
|
||||
encodeMediaPath,
|
||||
ErrorMessage,
|
||||
humanFileSize,
|
||||
humanNumber,
|
||||
isoDatetimeToAgoPopup,
|
||||
|
|
@ -19,9 +18,8 @@ import {
|
|||
useTitle
|
||||
} from "./Common";
|
||||
import {VideoPlaceholder} from "./Placeholder";
|
||||
import {useChannel, useOneQuery, useVideoCaptions, useVideoExtras} from "../hooks/customHooks";
|
||||
import {useMediaProgress} from "../hooks/useFileProgress";
|
||||
import {Button, Grid, Header, Icon, Label, Loading, Panel, Placeholder, Tabs, TextInput} from "./ui";
|
||||
import {useChannel, useVideoCaptions, useVideoExtras} from "../hooks/customHooks";
|
||||
import {Button, Grid, Header, Icon, Label, Loading, Panel, Tabs, TextInput} from "./ui";
|
||||
import {VideoCard} from "./Videos";
|
||||
import {TagsSelector} from "../Tags";
|
||||
import {AddToPlaylistButton} from "./AddToPlaylist";
|
||||
|
|
@ -172,14 +170,8 @@ const VideoComment = ({comment, children}) => {
|
|||
}
|
||||
|
||||
|
||||
// `comments` follows useVideoExtras: null = pending, undefined = fetch failed, [] = none downloaded.
|
||||
// Only the last of those may offer the Refresh button.
|
||||
export const Comments = ({comments, video}) => {
|
||||
if (comments === null) {
|
||||
return <Placeholder lines={3}/>
|
||||
} else if (comments === undefined) {
|
||||
return <ErrorMessage>Could not fetch the comments.</ErrorMessage>
|
||||
} else if (_.isEmpty(comments)) {
|
||||
const Comments = ({comments, video}) => {
|
||||
if (!comments || _.isEmpty(comments)) {
|
||||
return <NoComments video={video}/>
|
||||
}
|
||||
|
||||
|
|
@ -231,12 +223,6 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
|
|||
video = videoFile.video;
|
||||
}
|
||||
|
||||
// `?t=120` starts at 2:00, rather than where the user left off.
|
||||
const [startTimeQuery] = useOneQuery('t');
|
||||
const startSeconds = startTimeQuery && Number.isFinite(parseFloat(startTimeQuery))
|
||||
? parseFloat(startTimeQuery) : null;
|
||||
const progressHandlers = useMediaProgress(videoFile?.primary_path, videoFile, {startSeconds});
|
||||
|
||||
// Seeks to the `seconds` on video player.
|
||||
const setVideoTime = (seconds) => {
|
||||
videoRef.current.currentTime = seconds;
|
||||
|
|
@ -357,6 +343,12 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
|
|||
caption_files.filter(i => i['mimetype'] === 'text/vtt').map(i => `${MEDIA_PATH}/${encodeMediaPath(i['path'])}`)
|
||||
: [];
|
||||
|
||||
let description = 'No description available.';
|
||||
if (videoDescription) {
|
||||
// Only replace empty description if there is one available.
|
||||
description = formatVideoDescription(videoDescription, setVideoTime)
|
||||
}
|
||||
|
||||
const getFile = (suffix) => videoFile.files.find(i => i.path.toLowerCase().endsWith(suffix));
|
||||
const fileSize = (file) => file && file.size
|
||||
? <span style={{marginLeft: '1em', color: 'var(--muted)'}}>({humanFileSize(file.size)})</span>
|
||||
|
|
@ -463,7 +455,6 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
|
|||
id="player"
|
||||
style={{width: '100%', marginTop: posterUrl ? '0.5em' : 0}}
|
||||
ref={videoRef}
|
||||
{...progressHandlers}
|
||||
>
|
||||
{videoSource}
|
||||
</audio>
|
||||
|
|
@ -475,7 +466,6 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
|
|||
playsInline={true}
|
||||
style={{maxWidth: '100%'}}
|
||||
ref={videoRef}
|
||||
{...progressHandlers}
|
||||
>
|
||||
{videoSource}
|
||||
{/* Only WebVTT captions can be displayed. */}
|
||||
|
|
@ -560,7 +550,9 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
|
|||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value='description' style={{paddingTop: '1em'}}>
|
||||
<VideoDescription description={videoDescription} setVideoTime={setVideoTime}/>
|
||||
<pre className="wrap-text">
|
||||
{description}
|
||||
</pre>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value='files' style={{paddingTop: '1em'}}>
|
||||
|
|
@ -725,18 +717,6 @@ export default VideoPage;
|
|||
|
||||
const chapterRegex = new RegExp('^(\\(?(?:((\\d?\\d):)?(?:(\\d?\\d):(\\d\\d)))\\)?)\.?\\s+(.*)$', 'i');
|
||||
|
||||
// `description` follows useVideoExtras: null = pending, undefined = fetch failed, '' = none.
|
||||
export const VideoDescription = ({description, setVideoTime}) => {
|
||||
if (description === null) {
|
||||
return <Placeholder lines={3}/>
|
||||
} else if (description === undefined) {
|
||||
return <ErrorMessage>Could not fetch the description.</ErrorMessage>
|
||||
}
|
||||
return <pre className="wrap-text">
|
||||
{description ? formatVideoDescription(description, setVideoTime) : 'No description available.'}
|
||||
</pre>
|
||||
}
|
||||
|
||||
function formatVideoDescription(description, setVideoTime) {
|
||||
// Convert timestamps to links which change the video's playback location. Change hashtags to search links.
|
||||
if (description && description.length > 0) {
|
||||
|
|
|
|||
|
|
@ -1,43 +0,0 @@
|
|||
import React from 'react';
|
||||
import {render, screen} from '../../test-utils';
|
||||
import {BackupsModal} from './Configs';
|
||||
import {getConfigBackups} from '../../api';
|
||||
|
||||
// The config backups modal says "No backups available" only once the server confirmed there are
|
||||
// none; a failed fetch is an error, and a thrown one must not leave the loader up forever.
|
||||
|
||||
jest.mock('../../api', () => ({
|
||||
...jest.requireActual('../../api'),
|
||||
getConfigBackups: jest.fn(),
|
||||
postConfigBackupPreview: jest.fn(() => new Promise(() => {
|
||||
})),
|
||||
}));
|
||||
|
||||
const NONE = /No backups available/;
|
||||
const FAILED = /Could not fetch backups/;
|
||||
|
||||
const renderModal = () => render(
|
||||
<BackupsModal open fileName='wrolpi.yaml' onClose={jest.fn()} fetchConfigs={jest.fn()}/>,
|
||||
);
|
||||
|
||||
beforeEach(() => getConfigBackups.mockReset());
|
||||
|
||||
test('empty: says there are no backups', async () => {
|
||||
getConfigBackups.mockResolvedValue({dates: []});
|
||||
renderModal();
|
||||
expect(await screen.findByText(NONE)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('failed: shows an error, not "No backups available"', async () => {
|
||||
getConfigBackups.mockResolvedValue(undefined);
|
||||
renderModal();
|
||||
expect(await screen.findByText(FAILED)).toBeInTheDocument();
|
||||
expect(screen.queryByText(NONE)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('thrown: shows an error, not a loader forever', async () => {
|
||||
getConfigBackups.mockRejectedValue(new Error('down'));
|
||||
renderModal();
|
||||
expect(await screen.findByText(FAILED)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Loading backups/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import {APIButton, ErrorMessage} from "../Common";
|
||||
import {APIButton} from "../Common";
|
||||
import {Button, Header, Icon, IconButton, Loader, Loading, Modal, Placeholder, Table, Tooltip} from "../ui";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {getConfigText, getConfigBackups, postConfigBackupImport, postConfigBackupPreview} from "../../api";
|
||||
|
|
@ -117,11 +117,9 @@ function BackupDateRow({date, previews, previewsLoaded, onSelect}) {
|
|||
</Table.Row>;
|
||||
}
|
||||
|
||||
export function BackupsModal({open, onClose, fileName, fetchConfigs}) {
|
||||
function BackupsModal({open, onClose, fileName, fetchConfigs}) {
|
||||
const [dates, setDates] = useState([]);
|
||||
const [datesLoading, setDatesLoading] = useState(true);
|
||||
// The backups could not be fetched; distinct from the server confirming there are none.
|
||||
const [datesFailed, setDatesFailed] = useState(false);
|
||||
const [previews, setPreviews] = useState({});
|
||||
const [previewsLoaded, setPreviewsLoaded] = useState({});
|
||||
const [selectedAction, setSelectedAction] = useState(null); // {date, mode, preview}
|
||||
|
|
@ -132,30 +130,17 @@ export function BackupsModal({open, onClose, fileName, fetchConfigs}) {
|
|||
if (!open) return;
|
||||
let cancelled = false;
|
||||
setDatesLoading(true);
|
||||
setDatesFailed(false);
|
||||
setDates([]);
|
||||
setPreviews({});
|
||||
setPreviewsLoaded({});
|
||||
setSelectedAction(null);
|
||||
setApplying(false);
|
||||
|
||||
getConfigBackups(fileName)
|
||||
.then(result => {
|
||||
if (cancelled) return;
|
||||
// The api helper returns undefined on a non-OK response.
|
||||
if (result) {
|
||||
setDates(result.dates || []);
|
||||
} else {
|
||||
setDatesFailed(true);
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(e);
|
||||
if (!cancelled) setDatesFailed(true);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setDatesLoading(false);
|
||||
});
|
||||
getConfigBackups(fileName).then(result => {
|
||||
if (cancelled) return;
|
||||
setDates(result?.dates || []);
|
||||
setDatesLoading(false);
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [open, fileName]);
|
||||
|
|
@ -268,8 +253,6 @@ export function BackupsModal({open, onClose, fileName, fetchConfigs}) {
|
|||
<Modal.Content>
|
||||
{datesLoading
|
||||
? <Loading>Loading backups...</Loading>
|
||||
: datesFailed
|
||||
? <ErrorMessage>Could not fetch backups for this config.</ErrorMessage>
|
||||
: dates.length === 0
|
||||
? <p>No backups available for this config.</p>
|
||||
: <div style={{maxHeight: '400px', overflowY: 'auto'}}>
|
||||
|
|
|
|||
|
|
@ -101,8 +101,8 @@ export function ShutdownButton() {
|
|||
</APIButton>
|
||||
}
|
||||
|
||||
export function UpgradeSegment() {
|
||||
const {status, fetchStatus, loaded} = React.useContext(StatusContext);
|
||||
function UpgradeSegment() {
|
||||
const {status, fetchStatus} = React.useContext(StatusContext);
|
||||
const dockerized = useDockerized();
|
||||
const [upgrading, setUpgrading] = React.useState(false);
|
||||
const [checking, setChecking] = React.useState(false);
|
||||
|
|
@ -144,14 +144,6 @@ export function UpgradeSegment() {
|
|||
</Panel>;
|
||||
}
|
||||
|
||||
// Nothing is known until the first status poll lands; "up to date" is a claim about the server.
|
||||
if (!loaded) {
|
||||
return <Panel id='upgrade'>
|
||||
<Header as='h3'>System Upgrade</Header>
|
||||
<Loading/>
|
||||
</Panel>;
|
||||
}
|
||||
|
||||
// No update available
|
||||
if (!status?.update_available) {
|
||||
return <Panel id='upgrade'>
|
||||
|
|
@ -333,9 +325,7 @@ export function SettingsPage() {
|
|||
const [isDevMode, setIsDevMode] = React.useState(false);
|
||||
|
||||
// Get current settings from the API.
|
||||
const {
|
||||
settings, saveSettings, fetchSettings, loaded: settingsLoaded, failed: settingsFailed,
|
||||
} = React.useContext(SettingsContext);
|
||||
const {settings, saveSettings, fetchSettings} = React.useContext(SettingsContext);
|
||||
const {status} = React.useContext(StatusContext);
|
||||
// Used to track changes to the settings form between saves/loads.
|
||||
const [state, setState] = React.useState({});
|
||||
|
|
@ -473,8 +463,7 @@ export function SettingsPage() {
|
|||
|
||||
React.useEffect(() => {
|
||||
console.debug('settings changed, replacing state...');
|
||||
// true = show the form, false = still loading, undefined = could not fetch.
|
||||
setReady(settingsLoaded ? true : settingsFailed ? undefined : false);
|
||||
setReady(settings ? true : undefined);
|
||||
setState({
|
||||
// Only these settings can be changed on the SettingsPage.
|
||||
archive_destination: settings.archive_destination,
|
||||
|
|
@ -499,10 +488,9 @@ export function SettingsPage() {
|
|||
videos_destination: settings.videos_destination,
|
||||
zims_destination: settings.zims_destination,
|
||||
playlists_destination: settings.playlists_destination,
|
||||
repos_destination: settings.repos_destination,
|
||||
save_ffprobe_json: settings.save_ffprobe_json,
|
||||
});
|
||||
}, [JSON.stringify(settings), settingsLoaded, settingsFailed]);
|
||||
}, [JSON.stringify(settings)]);
|
||||
|
||||
const handleInputChange = async (name, value) => {
|
||||
setState({...state, [name]: value});
|
||||
|
|
@ -850,32 +838,6 @@ export function SettingsPage() {
|
|||
onChange={e => handleInputChange('playlists_destination', e.currentTarget.value)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{base: 12, sm: 6}}>
|
||||
<PathInput
|
||||
label={<>
|
||||
Repos Directory
|
||||
<InfoPopup
|
||||
content={<>
|
||||
<p>Variables:</p>
|
||||
<ul>
|
||||
<li><code>%(repo_name)s</code> - Repo name</li>
|
||||
<li><code>%(repo_tag)s</code> - Tag name for the repo (empty if no tag)
|
||||
</li>
|
||||
<li><code>%(repo_owner)s</code> - Owner from the repo URL</li>
|
||||
<li><code>%(repo_host)s</code> - Host from the repo URL</li>
|
||||
</ul>
|
||||
<p>Must start with a fixed directory (like <code>repos/</code>), which
|
||||
is never indexed, and contain <code>%(repo_name)s</code>.</p>
|
||||
</>}
|
||||
position='top'
|
||||
/>
|
||||
</>}
|
||||
prefix={mediaDirectoryLabel}
|
||||
value={state.repos_destination}
|
||||
disabled={!editSpecialDirectories}
|
||||
onChange={e => handleInputChange('repos_destination', e.currentTarget.value)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<Toggle
|
||||
label='Edit Directories'
|
||||
|
|
|
|||
|
|
@ -1,625 +0,0 @@
|
|||
import React from "react";
|
||||
import {useSearchParams} from "react-router";
|
||||
import {
|
||||
Accordion,
|
||||
Button,
|
||||
Grid,
|
||||
Group,
|
||||
Header,
|
||||
IconButton,
|
||||
Loader,
|
||||
Message,
|
||||
NumberInput,
|
||||
Panel,
|
||||
Select,
|
||||
Statistic,
|
||||
StatisticGroup,
|
||||
Table,
|
||||
Tabs,
|
||||
TextInput,
|
||||
Toggle,
|
||||
} from "../ui";
|
||||
import {InfoPopup, roundDigits} from "../Common";
|
||||
import {
|
||||
DEFAULT_LOSSES,
|
||||
defaultAzimuth,
|
||||
estimateProduction,
|
||||
formatMonthly,
|
||||
INVERTER_NOMINAL_EFFICIENCY,
|
||||
LOSS_CATEGORIES,
|
||||
MODULE_TYPES,
|
||||
MONTH_NAMES,
|
||||
MOUNTINGS,
|
||||
optimalTilt,
|
||||
parseCoordinate,
|
||||
parseMonthly,
|
||||
totalLoss,
|
||||
TRACKING,
|
||||
} from "./solar";
|
||||
import {
|
||||
CLIMATOLOGY_ACKNOWLEDGMENT,
|
||||
CLIMATOLOGY_CHANGES,
|
||||
CLIMATOLOGY_DATA_REFERENCE,
|
||||
CLIMATOLOGY_LICENSE,
|
||||
lookupClimatology,
|
||||
useClimatology,
|
||||
} from "./solarData";
|
||||
import {SolarOffGrid} from "./SolarOffGrid";
|
||||
|
||||
// Every input lives in the URL query so the Share button (which shares window.location.href) shares
|
||||
// the whole configuration. Values equal to their default are left out to keep the URL, and its QR
|
||||
// code, small. Keys are short and must stay stable; shared links depend on them.
|
||||
const DEFAULT_PANEL_WATTS = 400;
|
||||
const DEFAULT_PANEL_COUNT = 1;
|
||||
|
||||
const COMPASS = [
|
||||
{value: '0', label: 'N'}, {value: '45', label: 'NE'}, {value: '90', label: 'E'}, {value: '135', label: 'SE'},
|
||||
{value: '180', label: 'S'}, {value: '225', label: 'SW'}, {value: '270', label: 'W'}, {value: '315', label: 'NW'},
|
||||
];
|
||||
|
||||
const fToC = f => (f - 32) * 5 / 9;
|
||||
const cToF = c => c * 9 / 5 + 32;
|
||||
|
||||
const numberOr = (value, fallback) => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return fallback;
|
||||
}
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
};
|
||||
|
||||
const fmt = (value, digits = 2) => (Number.isFinite(value) ? `${roundDigits(value, digits)}` : '—');
|
||||
|
||||
// Read and write the calculator's query parameters. Writes replace the history entry, so typing does
|
||||
// not fill the Back button with one entry per keystroke.
|
||||
export function useSolarParams() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const get = key => searchParams.get(key);
|
||||
|
||||
// `updates` maps keys to values; null, undefined and '' remove the key.
|
||||
const set = updates => setSearchParams(prev => {
|
||||
const next = new URLSearchParams(prev);
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.set(key, `${value}`);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
}, {replace: true});
|
||||
|
||||
// Store a number, or remove it when it equals the default.
|
||||
const setNumber = (key, value, fallback) =>
|
||||
set({[key]: value === '' || Number(value) === fallback ? null : value});
|
||||
|
||||
return {searchParams, get, set, setNumber};
|
||||
}
|
||||
|
||||
// Turn the query parameters into the model's inputs. Invalid values fall back to defaults, so a
|
||||
// hand-edited or truncated link never crashes the page.
|
||||
export function systemFromParams(get) {
|
||||
const latitude = parseCoordinate(get('lat'), 'lat');
|
||||
const longitude = parseCoordinate(get('lon'), 'lon');
|
||||
const fahrenheit = get('tu') === 'f';
|
||||
|
||||
// Own sunlight data replaces the NASA data when present.
|
||||
const ownData = get('ghi') !== null;
|
||||
const ghi = parseMonthly(get('ghi')).map(v => (v !== null && v >= 0 ? v : null));
|
||||
const temperatureInput = parseMonthly(get('temp'));
|
||||
const temperature = temperatureInput.map(v => (v === null ? null : (fahrenheit ? fToC(v) : v)));
|
||||
|
||||
const panelWatts = Math.max(0, numberOr(get('pw'), DEFAULT_PANEL_WATTS));
|
||||
const panelCount = Math.max(0, Math.round(numberOr(get('pn'), DEFAULT_PANEL_COUNT)));
|
||||
const kwp = panelWatts * panelCount / 1000;
|
||||
|
||||
const tracking = TRACKING[get('track')] ? get('track') : 'fixed';
|
||||
const mounting = MOUNTINGS[get('mount')] ? get('mount') : 'open';
|
||||
const gamma = numberOr(get('gamma'), MODULE_TYPES.standard.gamma);
|
||||
const defaultTilt = latitude === null ? 0 : Math.round(Math.abs(latitude));
|
||||
const tilt = Math.min(90, Math.max(0, numberOr(get('tilt'), defaultTilt)));
|
||||
const azimuth = numberOr(get('az'), defaultAzimuth(latitude ?? 0));
|
||||
// Null means "use the monthly albedo from the data", which includes seasonal snow.
|
||||
const albedoInput = numberOr(get('alb'), null);
|
||||
const albedo = albedoInput === null ? null : Math.min(1, Math.max(0, albedoInput));
|
||||
|
||||
const lossValues = parseMonthly(get('losses'));
|
||||
const losses = LOSS_CATEGORIES.map((c, i) => {
|
||||
const v = lossValues[i];
|
||||
return v !== null && v >= 0 && v <= 100 ? v : c.default;
|
||||
});
|
||||
|
||||
const inverterOn = get('inv') === '1';
|
||||
const acRating = numberOr(get('iac'), roundDigits(kwp / 1.2, 2));
|
||||
const efficiency = numberOr(get('ieff'), INVERTER_NOMINAL_EFFICIENCY);
|
||||
|
||||
return {
|
||||
latitude, longitude, fahrenheit, ownData, ghi, temperatureInput, temperature, panelWatts, panelCount,
|
||||
kwp, tracking, mounting, gamma, defaultTilt, tilt, azimuth, albedo, losses, inverterOn, acRating,
|
||||
efficiency,
|
||||
};
|
||||
}
|
||||
|
||||
// The sunlight inputs for the model: the user's own values, or the NASA data at the location.
|
||||
export function sunlightFor(s, grid) {
|
||||
if (s.ownData) {
|
||||
return {ghi: s.ghi, diffuse: null, temperature: s.temperature, albedo: null};
|
||||
}
|
||||
if (!grid || s.latitude === null || s.longitude === null) {
|
||||
return null;
|
||||
}
|
||||
return lookupClimatology(grid, s.latitude, s.longitude);
|
||||
}
|
||||
|
||||
// What stops an estimate, or an empty list when it can run.
|
||||
export function missingInputs(s, sunlight) {
|
||||
const missing = [];
|
||||
if (s.latitude === null) {
|
||||
missing.push('a latitude');
|
||||
}
|
||||
if (!s.ownData && s.longitude === null) {
|
||||
missing.push('a longitude');
|
||||
}
|
||||
if (s.ownData && s.ghi.some(v => v === null)) {
|
||||
missing.push('sunlight for all 12 months');
|
||||
}
|
||||
if (!(s.kwp > 0)) {
|
||||
missing.push('panel watts and count');
|
||||
}
|
||||
if (missing.length === 0 && (!sunlight || sunlight.ghi.some(v => v === null))) {
|
||||
missing.push('a location with sunlight data');
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
const modelInputs = (s, sunlight) => ({
|
||||
latitude: s.latitude,
|
||||
ghi: sunlight.ghi,
|
||||
diffuse: sunlight.diffuse,
|
||||
temperature: sunlight.temperature,
|
||||
albedo: s.albedo ?? sunlight.albedo ?? undefined,
|
||||
kwp: s.kwp,
|
||||
tilt: s.tilt,
|
||||
azimuth: s.azimuth,
|
||||
tracking: s.tracking,
|
||||
mounting: s.mounting,
|
||||
gamma: s.gamma,
|
||||
losses: s.losses,
|
||||
inverter: s.inverterOn ? {acRating: s.acRating, efficiency: s.efficiency} : null,
|
||||
});
|
||||
|
||||
const compassLabel = azimuth => {
|
||||
const exact = COMPASS.find(c => Number(c.value) === azimuth);
|
||||
return exact ? exact.label : `${azimuth}°`;
|
||||
};
|
||||
|
||||
// Small explanatory text at the top of a collapsed section.
|
||||
const Hint = ({children}) => <p style={{fontSize: '0.9em', opacity: 0.75, marginTop: 0}}>{children}</p>;
|
||||
|
||||
// NASA POWER's requested acknowledgment, the license, and how WROLPi changed the data (CC BY 4.0).
|
||||
function DataAttribution() {
|
||||
return <section aria-labelledby='solar-data-attribution'
|
||||
style={{marginTop: '2em', fontSize: '0.85em', opacity: 0.75}}>
|
||||
<Header as='h4' id='solar-data-attribution'>Data attribution</Header>
|
||||
<p>{CLIMATOLOGY_ACKNOWLEDGMENT} {CLIMATOLOGY_DATA_REFERENCE}</p>
|
||||
<p>License: {CLIMATOLOGY_LICENSE}</p>
|
||||
<p>{CLIMATOLOGY_CHANGES}</p>
|
||||
</section>;
|
||||
}
|
||||
|
||||
const ESTIMATE_INFO = 'A typical-year estimate from monthly averages. Real years vary; expect roughly ±10–15% '
|
||||
+ 'over a year and more in any single month. Shading from trees and buildings is not modeled beyond the '
|
||||
+ 'Shading loss.';
|
||||
|
||||
// Browsers only offer geolocation to pages served over HTTPS.
|
||||
export const geolocationAvailable = () => Boolean(window.isSecureContext && navigator.geolocation);
|
||||
|
||||
const GEOLOCATION_ERRORS = {
|
||||
1: 'Location permission was denied. Allow it in your browser\'s settings for this site.',
|
||||
2: 'Your device could not find its location. Phones with GPS work offline; most computers need the Internet '
|
||||
+ 'for this.',
|
||||
3: 'Finding your location took too long. Phones with GPS work offline; most computers need the Internet for '
|
||||
+ 'this.',
|
||||
};
|
||||
|
||||
function LocationSection({get, set, s, climatology}) {
|
||||
const [locating, setLocating] = React.useState(false);
|
||||
const [geoError, setGeoError] = React.useState(null);
|
||||
const latError = get('lat') && s.latitude === null ? 'Latitude must be between -90 and 90' : null;
|
||||
const lonError = get('lon') && s.longitude === null ? 'Longitude must be between -180 and 180' : null;
|
||||
|
||||
const locate = () => {
|
||||
setLocating(true);
|
||||
setGeoError(null);
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
({coords}) => {
|
||||
setLocating(false);
|
||||
// 4 decimals (about 11 m) is far finer than the 1° data and keeps the shared URL short.
|
||||
set({lat: Number(coords.latitude.toFixed(4)), lon: Number(coords.longitude.toFixed(4))});
|
||||
},
|
||||
error => {
|
||||
setLocating(false);
|
||||
setGeoError(GEOLOCATION_ERRORS[error.code] || error.message);
|
||||
},
|
||||
{enableHighAccuracy: false, timeout: 20000, maximumAge: 10 * 60 * 1000},
|
||||
);
|
||||
};
|
||||
|
||||
// The data's attribution is at the bottom of the page; this line only reports the data's state.
|
||||
let source = null;
|
||||
if (s.ownData) {
|
||||
source = 'Using your own sunlight data.';
|
||||
} else if (climatology.loading) {
|
||||
source = <Loader size='xs' label='Loading sunlight data'/>;
|
||||
} else if (climatology.error) {
|
||||
source = <Message kind='warning' title='Sunlight data could not be loaded'>
|
||||
Enter your own monthly sunlight under "Use my own sunlight data" below.
|
||||
</Message>;
|
||||
}
|
||||
|
||||
return <>
|
||||
<Header as='h3'>1. Location</Header>
|
||||
<Grid align='flex-end'>
|
||||
<Grid.Col span='content'>
|
||||
<IconButton icon='current location' label='Use my current location' size='input-sm'
|
||||
loading={locating} disabled={!geolocationAvailable()} onClick={locate}/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span='auto'>
|
||||
<TextInput label='Latitude' placeholder='40.015 or 40°0′54″N' name='lat'
|
||||
value={get('lat') || ''} error={latError}
|
||||
onChange={e => set({lat: e.target.value})}/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{base: 12, xs: 'auto'}}>
|
||||
<TextInput label='Longitude' placeholder='-105.27 or 105°16′12″W' name='lon'
|
||||
value={get('lon') || ''} error={lonError}
|
||||
onChange={e => set({lon: e.target.value})}/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
{geoError && <Message kind='warning' title='Could not get your location'>{geoError}</Message>}
|
||||
{source && <div style={{fontSize: '0.85em', opacity: 0.75, marginTop: '0.5em'}}>{source}</div>}
|
||||
</>;
|
||||
}
|
||||
|
||||
function PanelsSection({setNumber, s}) {
|
||||
return <>
|
||||
<Header as='h3' style={{marginTop: '1em'}}>2. Your panels</Header>
|
||||
<Grid>
|
||||
<Grid.Col span={{base: 6, sm: 3}}>
|
||||
<NumberInput label='Watts per panel' name='pw' value={s.panelWatts} min={0} step={10}
|
||||
onChange={v => setNumber('pw', v, DEFAULT_PANEL_WATTS)}/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{base: 6, sm: 3}}>
|
||||
<NumberInput label='Panels' name='pn' value={s.panelCount} min={0} step={1}
|
||||
onChange={v => setNumber('pn', v, DEFAULT_PANEL_COUNT)}/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{base: 12, sm: 6}} style={{display: 'flex', alignItems: 'flex-end'}}>
|
||||
<span style={{opacity: 0.75, paddingBottom: '0.5em'}}>
|
||||
{fmt(s.kwp * 1000, 0)} W total ({fmt(s.kwp)} kW)
|
||||
</span>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</>;
|
||||
}
|
||||
|
||||
function Results({result, s}) {
|
||||
const unit = s.inverterOn ? 'kWh AC' : 'kWh DC';
|
||||
const rows = result.months.map(m => <Table.Row key={m.month}>
|
||||
<Table.Cell>{m.month}</Table.Cell>
|
||||
<Table.Cell>{fmt(m.energyDaily)}</Table.Cell>
|
||||
<Table.Cell>{fmt(m.energy, 0)}</Table.Cell>
|
||||
<Table.Cell>{fmt(m.poa)}</Table.Cell>
|
||||
<Table.Cell>{fmt(m.dayLength, 1)} h</Table.Cell>
|
||||
</Table.Row>);
|
||||
|
||||
return <Panel style={{marginTop: '1em'}}>
|
||||
<Header as='h3'>Estimate <InfoPopup content={ESTIMATE_INFO}/></Header>
|
||||
<StatisticGroup>
|
||||
<Statistic value={fmt(result.annual, 0)} label={`${unit} per year`}/>
|
||||
<Statistic value={fmt(result.annual / 365, 1)} label={`${unit} per day, average`}/>
|
||||
<Statistic value={fmt(result.worst.energyDaily, 1)} label={`${unit} per day in ${result.worst.month}`}/>
|
||||
<Statistic value={fmt(result.specificYield, 0)} label='kWh per year per kW of panels'/>
|
||||
</StatisticGroup>
|
||||
<Table>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>Month</Table.HeaderCell>
|
||||
<Table.HeaderCell>{unit}/day</Table.HeaderCell>
|
||||
<Table.HeaderCell>{unit}/month</Table.HeaderCell>
|
||||
<Table.HeaderCell>Sun hours on panel</Table.HeaderCell>
|
||||
<Table.HeaderCell>Day length</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>{rows}</Table.Body>
|
||||
</Table>
|
||||
</Panel>;
|
||||
}
|
||||
|
||||
function AngleSection({setNumber, s, sunlight, ready}) {
|
||||
const compass = COMPASS.find(c => Number(c.value) === s.azimuth)?.value || null;
|
||||
const applyOptimal = goal => setNumber('tilt', optimalTilt(modelInputs(s, sunlight), goal), s.defaultTilt);
|
||||
const azimuthDefault = defaultAzimuth(s.latitude ?? 0);
|
||||
|
||||
if (s.tracking !== 'fixed') {
|
||||
return <Hint>
|
||||
A tracking array turns to follow the sun, so it has no fixed angle. Change Tracking under Panel details
|
||||
to set one.
|
||||
</Hint>;
|
||||
}
|
||||
|
||||
return <>
|
||||
<Hint>
|
||||
Leave these alone unless your panels are already mounted. The default (tilted to your latitude, facing
|
||||
the equator) is close to the best for the year. "Best for winter" suits off-grid systems that must get
|
||||
through the darkest month.
|
||||
</Hint>
|
||||
<Grid>
|
||||
<Grid.Col span={{base: 12, sm: 6}}>
|
||||
<NumberInput label='Tilt (degrees from flat)' name='tilt' value={s.tilt} min={0} max={90} step={1}
|
||||
onChange={v => setNumber('tilt', v, s.defaultTilt)}/>
|
||||
<Group gap='xs' style={{marginTop: '0.25em'}}>
|
||||
<Button type='button' size='small' disabled={!ready} onClick={() => applyOptimal('annual')}>
|
||||
Best for the year
|
||||
</Button>
|
||||
<Button type='button' size='small' disabled={!ready} onClick={() => applyOptimal('winter')}>
|
||||
Best for winter
|
||||
</Button>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{base: 12, sm: 6}}>
|
||||
<Group gap='xs' wrap='nowrap' align='flex-end'>
|
||||
<NumberInput label='Facing (compass degrees)' name='az' value={s.azimuth} min={0} max={359}
|
||||
step={5} style={{flex: 1}} onChange={v => setNumber('az', v, azimuthDefault)}/>
|
||||
<Select aria-label='Compass direction' data={COMPASS} value={compass} placeholder='—'
|
||||
style={{width: '5.5em'}} allowDeselect={false}
|
||||
onChange={v => setNumber('az', v, azimuthDefault)}/>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</>;
|
||||
}
|
||||
|
||||
function DetailsSection({set, setNumber, s}) {
|
||||
const moduleType = Object.entries(MODULE_TYPES).find(([, m]) => m.gamma === s.gamma)?.[0] || 'custom';
|
||||
return <>
|
||||
<Hint>
|
||||
The defaults suit a typical ground or pole mounted array of ordinary panels. The temperature coefficient
|
||||
is on your panel's datasheet; panels lose this much power for each °C they run above 25 °C.
|
||||
</Hint>
|
||||
<Grid>
|
||||
<Grid.Col span={{base: 12, sm: 6}}>
|
||||
<Select label='Panel type'
|
||||
data={[
|
||||
...Object.entries(MODULE_TYPES).map(([value, m]) => ({value, label: m.label})),
|
||||
{value: 'custom', label: 'Custom (from datasheet)'},
|
||||
]}
|
||||
value={moduleType} allowDeselect={false}
|
||||
onChange={v => v !== 'custom' && setNumber('gamma', MODULE_TYPES[v].gamma,
|
||||
MODULE_TYPES.standard.gamma)}/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{base: 12, sm: 6}}>
|
||||
<NumberInput label='Temperature coefficient of power (%/°C)' name='gamma' value={s.gamma}
|
||||
max={0} step={0.01} decimalScale={3}
|
||||
onChange={v => setNumber('gamma', v, MODULE_TYPES.standard.gamma)}/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{base: 12, sm: 6}}>
|
||||
<Select label='Mounting' data={Object.entries(MOUNTINGS).map(([value, m]) => ({value, label: m.label}))}
|
||||
value={s.mounting} allowDeselect={false}
|
||||
onChange={v => set({mount: v === 'open' ? null : v})}/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{base: 12, sm: 6}}>
|
||||
<Select label='Tracking' data={Object.entries(TRACKING).map(([value, t]) => ({value, label: t.label}))}
|
||||
value={s.tracking} allowDeselect={false}
|
||||
onChange={v => set({track: v === 'fixed' ? null : v})}/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{base: 12, sm: 6}}>
|
||||
<NumberInput label='Ground reflectance (albedo)' name='alb' value={s.albedo ?? ''}
|
||||
placeholder='From the data (monthly, includes snow)' min={0} max={1} step={0.05}
|
||||
decimalScale={2} onChange={v => set({alb: v === '' ? null : v})}/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</>;
|
||||
}
|
||||
|
||||
function LossesSection({set, s}) {
|
||||
const setLoss = (index, value) => {
|
||||
const next = [...s.losses];
|
||||
next[index] = value === '' ? LOSS_CATEGORIES[index].default : Number(value);
|
||||
const isDefault = next.every((v, i) => v === DEFAULT_LOSSES[i]);
|
||||
set({losses: isDefault ? null : formatMonthly(next)});
|
||||
};
|
||||
|
||||
return <>
|
||||
<Hint>
|
||||
Real systems lose some power to dirt, wiring, shading and more. These are PVWatts' defaults. Raise Shading
|
||||
if trees or buildings shade your panels, or Snow if snow sits on them for weeks.
|
||||
</Hint>
|
||||
<Grid>
|
||||
{LOSS_CATEGORIES.map((c, i) => <Grid.Col key={c.key} span={{base: 6, sm: 4}}>
|
||||
<NumberInput label={`${c.label} (%)`} name={`loss-${c.key}`} value={s.losses[i]}
|
||||
min={0} max={100} step={0.5} onChange={v => setLoss(i, v)}/>
|
||||
</Grid.Col>)}
|
||||
</Grid>
|
||||
<Button type='button' size='small' style={{marginTop: '0.5em'}} onClick={() => set({losses: null})}>
|
||||
Reset to PVWatts defaults
|
||||
</Button>
|
||||
</>;
|
||||
}
|
||||
|
||||
function InverterSection({set, setNumber, s}) {
|
||||
return <>
|
||||
<Hint>
|
||||
Leave this off if your panels charge batteries through a charge controller. Turn it on to see AC output
|
||||
from a grid-tie or hybrid inverter.
|
||||
</Hint>
|
||||
<Toggle label='Include an inverter (AC output)' checked={s.inverterOn}
|
||||
onChange={e => set({inv: e.currentTarget.checked ? '1' : null})}/>
|
||||
{s.inverterOn && <Grid style={{marginTop: '0.5em'}}>
|
||||
<Grid.Col span={{base: 6}}>
|
||||
<NumberInput label='AC rating (kW)' name='iac' value={s.acRating} min={0} step={0.1} decimalScale={2}
|
||||
onChange={v => setNumber('iac', v, roundDigits(s.kwp / 1.2, 2))}/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{base: 6}}>
|
||||
<NumberInput label='Efficiency (%)' name='ieff' value={s.efficiency} min={50} max={100} step={0.5}
|
||||
onChange={v => setNumber('ieff', v, INVERTER_NOMINAL_EFFICIENCY)}/>
|
||||
</Grid.Col>
|
||||
</Grid>}
|
||||
</>;
|
||||
}
|
||||
|
||||
function OwnDataSection({set, s, nasa}) {
|
||||
const unit = s.fahrenheit ? '°F' : '°C';
|
||||
|
||||
// Start from the NASA values, when available, so the user edits rather than types all 24.
|
||||
const enable = on => {
|
||||
if (!on) {
|
||||
set({ghi: null, temp: null});
|
||||
return;
|
||||
}
|
||||
const ghi = nasa ? nasa.ghi.map(v => (v === null ? '' : roundDigits(v, 2))) : Array(12).fill('');
|
||||
const temp = nasa
|
||||
? nasa.temperature.map(v => (v === null ? '' : roundDigits(s.fahrenheit ? cToF(v) : v, 1)))
|
||||
: Array(12).fill('');
|
||||
// An all-blank list is still written for sunlight, so the section stays on for typing.
|
||||
set({ghi: formatMonthly(ghi), temp: nasa ? formatMonthly(temp) : null});
|
||||
};
|
||||
|
||||
// Convert the temperatures with the unit, so they stay the same physical temperatures.
|
||||
const switchUnits = toFahrenheit => {
|
||||
const temps = s.temperatureInput.map(v => (v === null ? null
|
||||
: roundDigits(toFahrenheit ? cToF(v) : fToC(v), 1)));
|
||||
set({tu: toFahrenheit ? 'f' : null, temp: temps.some(v => v !== null) ? formatMonthly(temps) : null});
|
||||
};
|
||||
|
||||
const setMonth = (key, values, index, value) => {
|
||||
const next = [...values];
|
||||
next[index] = value === '' ? null : value;
|
||||
set({[key]: formatMonthly(next)});
|
||||
};
|
||||
|
||||
const rows = MONTH_NAMES.map((name, i) => <Table.Row key={name}>
|
||||
<Table.Cell>{name}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<NumberInput name={`ghi-${i}`} aria-label={`${name} sunlight`} value={s.ghi[i] ?? ''} min={0}
|
||||
step={0.1} hideControls onChange={v => setMonth('ghi', s.ghi, i, v)}/>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<NumberInput name={`temp-${i}`} aria-label={`${name} temperature`}
|
||||
placeholder={s.fahrenheit ? '68' : '20'} value={s.temperatureInput[i] ?? ''} step={1}
|
||||
hideControls onChange={v => setMonth('temp', s.temperatureInput, i, v)}/>
|
||||
</Table.Cell>
|
||||
</Table.Row>);
|
||||
|
||||
return <>
|
||||
<Hint>
|
||||
Only needed if you have better local measurements than the built-in data, for example in mountains, where
|
||||
a 1° grid cell (about 69 miles across) can average very different terrain. Sunlight is global horizontal
|
||||
irradiance in kWh/m²/day, the same as "peak sun hours" on flat ground. Blank temperatures use 20 °C.
|
||||
</Hint>
|
||||
<Group gap='lg'>
|
||||
<Toggle label='Use my own sunlight data' checked={s.ownData}
|
||||
onChange={e => enable(e.currentTarget.checked)}/>
|
||||
{s.ownData && <Toggle label='Fahrenheit' checked={s.fahrenheit}
|
||||
onChange={e => switchUnits(e.currentTarget.checked)}/>}
|
||||
</Group>
|
||||
{s.ownData && <Table style={{marginTop: '0.5em'}}>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>Month</Table.HeaderCell>
|
||||
<Table.HeaderCell>Sunlight (kWh/m²/day)</Table.HeaderCell>
|
||||
<Table.HeaderCell>Avg. temp ({unit})</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>{rows}</Table.Body>
|
||||
</Table>}
|
||||
</>;
|
||||
}
|
||||
|
||||
export function SolarCalculator() {
|
||||
const {searchParams, get, set, setNumber} = useSolarParams();
|
||||
const climatology = useClimatology();
|
||||
const queryString = searchParams.toString();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const s = React.useMemo(() => systemFromParams(get), [queryString]);
|
||||
const nasa = React.useMemo(
|
||||
() => (climatology.grid && s.latitude !== null && s.longitude !== null
|
||||
? lookupClimatology(climatology.grid, s.latitude, s.longitude) : null),
|
||||
[climatology.grid, s.latitude, s.longitude]);
|
||||
const sunlight = s.ownData ? sunlightFor(s, null) : nasa;
|
||||
const missing = missingInputs(s, sunlight);
|
||||
const ready = missing.length === 0;
|
||||
const result = React.useMemo(
|
||||
() => (ready ? estimateProduction(modelInputs(s, sunlight)) : null),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[s, sunlight, ready]);
|
||||
// Off-grid sizing works from DC production per kW of panels, whatever the inverter setting.
|
||||
const worstPerKwp = React.useMemo(
|
||||
() => (ready ? estimateProduction({...modelInputs(s, sunlight), kwp: 1, inverter: null}).worst : null),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[s, sunlight, ready]);
|
||||
const tab = get('tab') === 'offgrid' ? 'offgrid' : 'estimate';
|
||||
|
||||
const angleTitle = s.tracking === 'fixed'
|
||||
? `Panel angle: ${s.tilt}° tilt, facing ${compassLabel(s.azimuth)}`
|
||||
: 'Panel angle: follows the sun';
|
||||
const detailsTitle = `Panel details: ${TRACKING[s.tracking].label.toLowerCase()}, `
|
||||
+ `${MOUNTINGS[s.mounting].label.toLowerCase()}`;
|
||||
const waitingForData = climatology.loading && !s.ownData;
|
||||
|
||||
return <div>
|
||||
<Header as='h1'>Solar</Header>
|
||||
<p>
|
||||
Estimate what solar panels will produce over a typical year. Enter a location and your panels; everything
|
||||
else has sensible defaults. Settings are saved in the page address, so you can bookmark or share them.
|
||||
</p>
|
||||
|
||||
<LocationSection get={get} set={set} s={s} climatology={climatology}/>
|
||||
<PanelsSection setNumber={setNumber} s={s}/>
|
||||
|
||||
<Tabs value={tab} onChange={v => set({tab: v === 'offgrid' ? v : null})} keepMounted={false}
|
||||
style={{marginTop: '1.5em'}}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value='estimate'>Estimate</Tabs.Tab>
|
||||
<Tabs.Tab value='offgrid'>Off-grid sizing</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Tabs.Panel value='estimate'>
|
||||
{ready && <Results result={result} s={s}/>}
|
||||
{!ready && !waitingForData && <div style={{marginTop: '1em'}}>
|
||||
<Message kind='info' title='Almost there'>Enter {missing.join(', ')} to see an estimate.</Message>
|
||||
</div>}
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value='offgrid'>
|
||||
<SolarOffGrid get={get} set={set} setNumber={setNumber} s={s} worst={worstPerKwp}
|
||||
temperatures={sunlight?.temperature} loading={waitingForData}/>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<Header as='h3' style={{marginTop: '1.5em'}}>Fine-tune (optional)</Header>
|
||||
<Accordion multiple>
|
||||
<Accordion.Item value='angle'>
|
||||
<Accordion.Control>{angleTitle}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<AngleSection setNumber={setNumber} s={s} sunlight={sunlight} ready={ready}/>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value='details'>
|
||||
<Accordion.Control>{detailsTitle}</Accordion.Control>
|
||||
<Accordion.Panel><DetailsSection set={set} setNumber={setNumber} s={s}/></Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value='losses'>
|
||||
<Accordion.Control>System losses: {fmt(totalLoss(s.losses))}%</Accordion.Control>
|
||||
<Accordion.Panel><LossesSection set={set} s={s}/></Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value='inverter'>
|
||||
<Accordion.Control>Inverter: {s.inverterOn ? 'on (AC output)' : 'off (DC output)'}</Accordion.Control>
|
||||
<Accordion.Panel><InverterSection set={set} setNumber={setNumber} s={s}/></Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value='own'>
|
||||
<Accordion.Control>Use my own sunlight data: {s.ownData ? 'on' : 'off'}</Accordion.Control>
|
||||
<Accordion.Panel><OwnDataSection set={set} s={s} nasa={nasa}/></Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
|
||||
<DataAttribution/>
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -1,256 +0,0 @@
|
|||
import React from "react";
|
||||
import {fireEvent, screen, within} from "@testing-library/react";
|
||||
import {renderWithProviders} from "../../test-utils";
|
||||
import {missingInputs, SolarCalculator, sunlightFor, systemFromParams} from "./SolarCalculator";
|
||||
import {CalculatorsPage} from "../Calculators";
|
||||
import {DEFAULT_LOSSES, estimateProduction, MODULE_TYPES} from "./solar";
|
||||
import {decodeClimatology, forgetClimatology, lookupClimatology} from "./solarData";
|
||||
import {mockClimatologyFetch, SUNNY_GHI, uniformClimatology} from "./solarData.fixtures";
|
||||
import {roundDigits} from "../Common";
|
||||
|
||||
const BASE = '/more/calculators?calc=solar';
|
||||
const DENVER = `${BASE}&lat=40&lon=-105`;
|
||||
|
||||
const params = query => {
|
||||
const searchParams = new URLSearchParams(query);
|
||||
return key => searchParams.get(key);
|
||||
};
|
||||
|
||||
const currentParams = () => new URLSearchParams(window.location.search);
|
||||
|
||||
// The estimate appears once the data file has loaded and decoded, which can be slow on a busy machine.
|
||||
const findEstimate = (unit = 'DC') => screen.findByText(`kWh ${unit} per year`, {}, {timeout: 5000});
|
||||
|
||||
// The annual estimate the page should show for a location, from the same uniform test data.
|
||||
const expectedAnnual = (overrides = {}) => {
|
||||
const grid = decodeClimatology(uniformClimatology());
|
||||
const sunlight = lookupClimatology(grid, 40, -105);
|
||||
return estimateProduction({latitude: 40, kwp: 0.4, tilt: 40, azimuth: 180, ...sunlight, ...overrides}).annual;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
forgetClimatology();
|
||||
mockClimatologyFetch();
|
||||
});
|
||||
|
||||
describe('systemFromParams', () => {
|
||||
test('defaults when the URL is empty', () => {
|
||||
const s = systemFromParams(params(''));
|
||||
expect(s.latitude).toBeNull();
|
||||
expect(s.kwp).toBeCloseTo(0.4, 6);
|
||||
expect(s.tracking).toBe('fixed');
|
||||
expect(s.mounting).toBe('open');
|
||||
expect(s.gamma).toBe(MODULE_TYPES.standard.gamma);
|
||||
expect(s.losses).toEqual(DEFAULT_LOSSES);
|
||||
expect(s.inverterOn).toBe(false);
|
||||
expect(s.ownData).toBe(false);
|
||||
expect(s.albedo).toBeNull();
|
||||
});
|
||||
|
||||
test('tilt and azimuth default from the latitude', () => {
|
||||
expect(systemFromParams(params('lat=40.4')).tilt).toBe(40);
|
||||
expect(systemFromParams(params('lat=40.4')).azimuth).toBe(180);
|
||||
expect(systemFromParams(params('lat=-33.9')).tilt).toBe(34);
|
||||
expect(systemFromParams(params('lat=-33.9')).azimuth).toBe(0);
|
||||
});
|
||||
|
||||
test('invalid values fall back instead of crashing', () => {
|
||||
const s = systemFromParams(params('lat=999&track=bogus&mount=x&tilt=abc&losses=1,2,abc&alb=7&pn=-3'));
|
||||
expect(s.latitude).toBeNull();
|
||||
expect(s.tracking).toBe('fixed');
|
||||
expect(s.mounting).toBe('open');
|
||||
expect(s.tilt).toBe(0);
|
||||
expect(s.losses.slice(0, 3)).toEqual([1, 2, DEFAULT_LOSSES[2]]);
|
||||
expect(s.albedo).toBe(1);
|
||||
expect(s.kwp).toBe(0);
|
||||
});
|
||||
|
||||
test('own Fahrenheit temperatures are converted to Celsius', () => {
|
||||
const s = systemFromParams(params('ghi=5&tu=f&temp=32,212'));
|
||||
expect(s.ownData).toBe(true);
|
||||
expect(s.temperature[0]).toBeCloseTo(0, 6);
|
||||
expect(s.temperature[1]).toBeCloseTo(100, 6);
|
||||
expect(s.temperatureInput[0]).toBe(32);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sunlight and missing inputs', () => {
|
||||
const grid = decodeClimatology(uniformClimatology());
|
||||
|
||||
test('the NASA data is used unless the user gave their own', () => {
|
||||
const nasa = sunlightFor(systemFromParams(params('lat=40&lon=-105')), grid);
|
||||
expect(nasa.ghi[5]).toBeCloseTo(SUNNY_GHI[5], 1);
|
||||
expect(nasa.diffuse[5]).toBeCloseTo(SUNNY_GHI[5] * 0.3, 1);
|
||||
|
||||
const own = sunlightFor(systemFromParams(params(`lat=40&lon=-105&ghi=${Array(12).fill(1).join(',')}`)), grid);
|
||||
expect(own.ghi).toEqual(Array(12).fill(1));
|
||||
expect(own.diffuse).toBeNull();
|
||||
});
|
||||
|
||||
test('a location is enough with the NASA data', () => {
|
||||
const s = systemFromParams(params('lat=40&lon=-105'));
|
||||
expect(missingInputs(s, sunlightFor(s, grid))).toEqual([]);
|
||||
const noLon = systemFromParams(params('lat=40'));
|
||||
expect(missingInputs(noLon, sunlightFor(noLon, grid))).toEqual(['a longitude']);
|
||||
});
|
||||
|
||||
test('own data needs all 12 months but no longitude', () => {
|
||||
const s = systemFromParams(params('lat=40&ghi=1,2,3'));
|
||||
expect(missingInputs(s, sunlightFor(s, grid))).toEqual(['sunlight for all 12 months']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SolarCalculator', () => {
|
||||
test('is linked from the Engineering group of the calculators page', () => {
|
||||
renderWithProviders(<CalculatorsPage/>, {route: '/more/calculators'});
|
||||
const engineering = screen.getByRole('heading', {name: 'Engineering'}).closest('.wrolpi-header').parentElement;
|
||||
const link = within(engineering).getByRole('link', {name: /Solar/});
|
||||
expect(link).toHaveAttribute('href', '/more/calculators?calc=solar');
|
||||
});
|
||||
|
||||
test('asks for a location', async () => {
|
||||
renderWithProviders(<SolarCalculator/>, {route: BASE});
|
||||
expect(await screen.findByText('Enter a latitude, a longitude to see an estimate.', {}, {timeout: 5000}))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a location alone gives an estimate from the built-in data', async () => {
|
||||
renderWithProviders(<SolarCalculator/>, {route: DENVER});
|
||||
expect(await findEstimate()).toBeInTheDocument();
|
||||
expect(screen.getByText(`${roundDigits(expectedAnnual(), 0)}`)).toBeInTheDocument();
|
||||
// The attribution is only at the bottom of the page.
|
||||
expect(screen.queryByText(/Sunlight and temperature/)).not.toBeInTheDocument();
|
||||
expect(global.fetch).toHaveBeenCalledWith(expect.stringMatching(/\/data\/solar-climatology\.bin$/));
|
||||
});
|
||||
|
||||
test('the data attribution is at the bottom of the page', async () => {
|
||||
renderWithProviders(<SolarCalculator/>, {route: DENVER});
|
||||
await findEstimate();
|
||||
const heading = screen.getByRole('heading', {name: 'Data attribution'});
|
||||
const section = screen.getByRole('region', {name: 'Data attribution'});
|
||||
expect(within(section).getByText(/Prediction Of Worldwide Energy Resources/)).toBeInTheDocument();
|
||||
expect(within(section).getByText(/WROLPi modified the data/)).toBeInTheDocument();
|
||||
expect(within(section).getByText(/CC BY 4\.0/)).toBeInTheDocument();
|
||||
// After everything else, including the last Fine-tune section.
|
||||
const lastControl = screen.getByText('Use my own sunlight data: off');
|
||||
expect(lastControl.compareDocumentPosition(heading) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
test('typing writes the URL and leaves defaults out', async () => {
|
||||
renderWithProviders(<SolarCalculator/>, {route: BASE});
|
||||
fireEvent.change(screen.getByLabelText('Latitude'), {target: {value: '40°0\'54"N'}});
|
||||
expect(currentParams().get('lat')).toBe('40°0\'54"N');
|
||||
expect(currentParams().get('calc')).toBe('solar');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Panels'), {target: {value: '4'}});
|
||||
expect(currentParams().get('pn')).toBe('4');
|
||||
fireEvent.change(screen.getByLabelText('Panels'), {target: {value: '1'}});
|
||||
expect(currentParams().has('pn')).toBe(false);
|
||||
});
|
||||
|
||||
describe('use my current location', () => {
|
||||
const withGeolocation = getCurrentPosition => {
|
||||
Object.defineProperty(window, 'isSecureContext', {value: true, configurable: true});
|
||||
Object.defineProperty(navigator, 'geolocation', {value: {getCurrentPosition}, configurable: true});
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
delete window.isSecureContext;
|
||||
delete navigator.geolocation;
|
||||
});
|
||||
|
||||
test('fills in the location, rounded to 4 decimals', async () => {
|
||||
withGeolocation(success => success({coords: {latitude: 39.7392358, longitude: -104.990251}}));
|
||||
renderWithProviders(<SolarCalculator/>, {route: BASE});
|
||||
fireEvent.click(screen.getByRole('button', {name: 'Use my current location'}));
|
||||
expect(currentParams().get('lat')).toBe('39.7392');
|
||||
expect(currentParams().get('lon')).toBe('-104.9903');
|
||||
expect(screen.getByLabelText('Latitude')).toHaveValue('39.7392');
|
||||
expect(await findEstimate()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('explains a denied permission', () => {
|
||||
withGeolocation((success, failure) => failure({code: 1, message: 'User denied Geolocation'}));
|
||||
renderWithProviders(<SolarCalculator/>, {route: BASE});
|
||||
fireEvent.click(screen.getByRole('button', {name: 'Use my current location'}));
|
||||
expect(screen.getByText('Could not get your location')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Location permission was denied/)).toBeInTheDocument();
|
||||
expect(currentParams().has('lat')).toBe(false);
|
||||
});
|
||||
|
||||
test('is disabled without HTTPS', () => {
|
||||
Object.defineProperty(window, 'isSecureContext', {value: false, configurable: true});
|
||||
renderWithProviders(<SolarCalculator/>, {route: BASE});
|
||||
expect(screen.getByRole('button', {name: 'Use my current location'})).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
test('an invalid latitude is flagged', () => {
|
||||
renderWithProviders(<SolarCalculator/>, {route: `${BASE}&lat=95`});
|
||||
expect(screen.getByText('Latitude must be between -90 and 90')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('the inverter switches the results to AC', async () => {
|
||||
renderWithProviders(<SolarCalculator/>, {route: DENVER});
|
||||
await findEstimate();
|
||||
fireEvent.click(screen.getByText('Inverter: off (DC output)'));
|
||||
fireEvent.click(screen.getByLabelText('Include an inverter (AC output)'));
|
||||
expect(currentParams().get('inv')).toBe('1');
|
||||
expect(screen.getByText('kWh AC per year')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('best-for-the-year replaces a poor tilt', async () => {
|
||||
renderWithProviders(<SolarCalculator/>, {route: `${DENVER}&tilt=10`});
|
||||
await findEstimate();
|
||||
expect(screen.getByText('Panel angle: 10° tilt, facing S')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('Panel angle: 10° tilt, facing S'));
|
||||
fireEvent.click(await screen.findByRole('button', {name: 'Best for the year'}));
|
||||
// A result of 40 equals the default (the latitude), so it is left out of the URL.
|
||||
const tilt = Number(currentParams().get('tilt') ?? 40);
|
||||
expect(tilt).toBeGreaterThanOrEqual(30);
|
||||
expect(tilt).toBeLessThanOrEqual(44);
|
||||
});
|
||||
|
||||
test('own sunlight data starts from the built-in values and replaces them', async () => {
|
||||
renderWithProviders(<SolarCalculator/>, {route: DENVER});
|
||||
await findEstimate();
|
||||
fireEvent.click(screen.getByText('Use my own sunlight data: off'));
|
||||
fireEvent.click(screen.getByLabelText('Use my own sunlight data'));
|
||||
|
||||
const ghi = currentParams().get('ghi').split(',').map(Number);
|
||||
ghi.forEach((v, i) => expect(v).toBeCloseTo(SUNNY_GHI[i], 1));
|
||||
expect(screen.getByText('Using your own sunlight data.')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Jun sunlight')).toHaveValue(`${ghi[5]}`);
|
||||
|
||||
// Halving June's sunlight lowers the estimate.
|
||||
fireEvent.change(screen.getByLabelText('Jun sunlight'), {target: {value: `${ghi[5] / 2}`}});
|
||||
expect(currentParams().get('ghi').split(',')[5]).toBe(`${ghi[5] / 2}`);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Use my own sunlight data'));
|
||||
expect(currentParams().has('ghi')).toBe(false);
|
||||
expect(currentParams().has('temp')).toBe(false);
|
||||
});
|
||||
|
||||
test('switching to Fahrenheit converts your temperatures instead of relabeling them', async () => {
|
||||
const temps = '0,10,,,,,,,,,,';
|
||||
renderWithProviders(<SolarCalculator/>,
|
||||
{route: `${DENVER}&ghi=${SUNNY_GHI.join(',')}&temp=${temps}`});
|
||||
await findEstimate();
|
||||
fireEvent.click(screen.getByText('Use my own sunlight data: on'));
|
||||
fireEvent.click(screen.getByLabelText('Fahrenheit'));
|
||||
expect(currentParams().get('tu')).toBe('f');
|
||||
expect(currentParams().get('temp')).toBe('32,50,,,,,,,,,,');
|
||||
expect(screen.getByLabelText('Jan temperature')).toHaveValue('32');
|
||||
fireEvent.click(screen.getByLabelText('Fahrenheit'));
|
||||
expect(currentParams().has('tu')).toBe(false);
|
||||
expect(currentParams().get('temp')).toBe(temps);
|
||||
});
|
||||
|
||||
test('a failed download explains how to continue', async () => {
|
||||
forgetClimatology();
|
||||
global.fetch = jest.fn(() => Promise.resolve({ok: false, status: 404}));
|
||||
renderWithProviders(<SolarCalculator/>, {route: DENVER});
|
||||
expect(await screen.findByText('Sunlight data could not be loaded', {}, {timeout: 5000})).toBeInTheDocument();
|
||||
expect(screen.getByText('Enter a location with sunlight data to see an estimate.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,350 +0,0 @@
|
|||
import React from "react";
|
||||
import {Button, Grid, Group, Header, IconButton, Loader, Message, NumberInput, Panel, Select, Statistic,
|
||||
StatisticGroup, Table, TextInput, Toggle} from "../ui";
|
||||
import {roundDigits} from "../Common";
|
||||
import {
|
||||
BATTERIES,
|
||||
batteryWarnings,
|
||||
CONTROLLERS,
|
||||
controllerSizing,
|
||||
dailyLoadWh,
|
||||
defaultColdest,
|
||||
formatLoads,
|
||||
leadAcidCapacityFactor,
|
||||
loadWh,
|
||||
parseLoads,
|
||||
requiredKwp,
|
||||
sizeBattery,
|
||||
SYSTEM_VOLTAGES,
|
||||
} from "./offgrid";
|
||||
|
||||
// Defaults are typical of a 400 W residential panel; the user should copy their panel's label.
|
||||
const PANEL_DEFAULTS = {voc: 37, vmp: 31, isc: 13.8, bvoc: -0.27};
|
||||
const DEFAULT_LOAD_INVERTER_EFFICIENCY = 90;
|
||||
const DEFAULT_DAYS = 2;
|
||||
const DEFAULT_CONTROLLER_MAX_VOLTAGE = 100;
|
||||
// Above this, a higher system voltage needs a much smaller controller and thinner wire.
|
||||
const HIGH_CURRENT = 60;
|
||||
|
||||
const numberOr = (value, fallback) => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return fallback;
|
||||
}
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
};
|
||||
|
||||
const fmt = (value, digits = 0) => (Number.isFinite(value) ? `${roundDigits(value, digits)}` : '—');
|
||||
|
||||
const Hint = ({children}) => <p style={{fontSize: '0.9em', opacity: 0.75, marginTop: 0}}>{children}</p>;
|
||||
|
||||
// The off-grid inputs, read from the URL like the rest of the calculator. Invalid values fall back.
|
||||
export function offGridFromParams(get, monthlyTemperatures) {
|
||||
const battery = BATTERIES[get('bat')] ? get('bat') : 'lfp';
|
||||
const systemVoltage = SYSTEM_VOLTAGES.includes(Number(get('sv'))) ? Number(get('sv')) : 12;
|
||||
const controller = CONTROLLERS[get('ctl')] ? get('ctl') : 'mppt';
|
||||
const coldestMonthly = (monthlyTemperatures || []).filter(Number.isFinite).length
|
||||
? Math.min(...monthlyTemperatures.filter(Number.isFinite)) : null;
|
||||
const defaultTmin = defaultColdest(monthlyTemperatures);
|
||||
return {
|
||||
loads: parseLoads(get('load')),
|
||||
acLoads: get('ac') !== '0',
|
||||
loadInverterEfficiency: numberOr(get('leff'), DEFAULT_LOAD_INVERTER_EFFICIENCY),
|
||||
battery,
|
||||
systemVoltage,
|
||||
dod: numberOr(get('dod'), BATTERIES[battery].dod),
|
||||
days: Math.max(0, numberOr(get('aut'), DEFAULT_DAYS)),
|
||||
outdoors: get('bout') === '1',
|
||||
controller,
|
||||
voc: numberOr(get('voc'), PANEL_DEFAULTS.voc),
|
||||
vmp: numberOr(get('vmp'), PANEL_DEFAULTS.vmp),
|
||||
isc: numberOr(get('isc'), PANEL_DEFAULTS.isc),
|
||||
// Always negative for silicon panels; a missing minus sign must not hide the cold-weather rise.
|
||||
betaVoc: -Math.abs(numberOr(get('bvoc'), PANEL_DEFAULTS.bvoc)),
|
||||
series: Math.max(1, Math.round(numberOr(get('ns'), 1))),
|
||||
controllerMaxVoltage: numberOr(get('cmax'), DEFAULT_CONTROLLER_MAX_VOLTAGE),
|
||||
coldestMonthly,
|
||||
defaultTmin,
|
||||
coldest: numberOr(get('tmin'), defaultTmin),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the off-grid tab shows, from its inputs.
|
||||
*
|
||||
* @param {Object} o offGridFromParams()
|
||||
* @param {Object} worst the worst month for a 1 kW array: {month, energyDaily}
|
||||
* @param {number} panelWatts
|
||||
* @param {number} panelCount
|
||||
*/
|
||||
export function offGridResults(o, worst, panelWatts, panelCount) {
|
||||
const devicesWh = dailyLoadWh(o.loads);
|
||||
// AC devices draw through an inverter, so the batteries supply more than the devices use.
|
||||
const batteryLoadWh = o.acLoads && o.loadInverterEfficiency > 0
|
||||
? devicesWh / (o.loadInverterEfficiency / 100) : devicesWh;
|
||||
const capacityFactor = o.outdoors && o.battery === 'lead' ? leadAcidCapacityFactor(o.coldestMonthly) : 1;
|
||||
const bank = sizeBattery({
|
||||
loadWh: batteryLoadWh, days: o.days, dod: o.dod, voltage: o.systemVoltage, capacityFactor,
|
||||
});
|
||||
|
||||
const sizing = controllerSizing({
|
||||
panelCount, panelWatts, series: o.series, voc: o.voc, vmp: o.vmp, isc: o.isc, betaVoc: o.betaVoc,
|
||||
coldest: o.coldest, controller: o.controller, controllerMaxVoltage: o.controllerMaxVoltage,
|
||||
systemVoltage: o.systemVoltage,
|
||||
});
|
||||
|
||||
// A PWM controller wastes part of every panel's output, so more panels are needed.
|
||||
const worstPerKwp = worst ? worst.energyDaily * sizing.pwmFraction : null;
|
||||
const kwp = requiredKwp(batteryLoadWh, worstPerKwp, BATTERIES[o.battery].efficiency);
|
||||
const panelsNeeded = kwp && panelWatts > 0 ? Math.ceil(kwp * 1000 / panelWatts) : null;
|
||||
const yourArrayWh = worstPerKwp !== null
|
||||
? worstPerKwp * (panelCount * panelWatts / 1000) * 1000 * BATTERIES[o.battery].efficiency / 100 : null;
|
||||
|
||||
return {
|
||||
devicesWh, batteryLoadWh, bank, kwp, panelsNeeded, sizing,
|
||||
coverage: batteryLoadWh > 0 && yourArrayWh !== null ? yourArrayWh / batteryLoadWh : null,
|
||||
warnings: batteryWarnings({battery: o.battery, outdoors: o.outdoors, coldestMonthly: o.coldestMonthly}),
|
||||
};
|
||||
}
|
||||
|
||||
function LoadsSection({o, r, set, setNumber}) {
|
||||
const rows = o.loads.length ? o.loads : [{name: '', watts: '', hours: ''}];
|
||||
const write = next => set({load: next.some(row => row.name || row.watts || row.hours) ? formatLoads(next) : null});
|
||||
const update = (index, field, value) => write(rows.map((row, i) => (i === index ? {...row, [field]: value} : row)));
|
||||
const remove = index => write(rows.filter((_, i) => i !== index));
|
||||
|
||||
return <>
|
||||
<Header as='h3'>Your devices</Header>
|
||||
<Hint>
|
||||
List what you will run and for how long each day. The watts are on the device's label or power supply;
|
||||
a refrigerator cycles on and off, so use about a third of its rating for 24 hours.
|
||||
</Hint>
|
||||
<Table>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>Device</Table.HeaderCell>
|
||||
<Table.HeaderCell>Watts</Table.HeaderCell>
|
||||
<Table.HeaderCell>Hours per day</Table.HeaderCell>
|
||||
<Table.HeaderCell>Wh per day</Table.HeaderCell>
|
||||
<Table.HeaderCell/>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{rows.map((row, i) => <Table.Row key={i}>
|
||||
<Table.Cell>
|
||||
<TextInput aria-label={`Device ${i + 1} name`} placeholder='Refrigerator' value={row.name}
|
||||
onChange={e => update(i, 'name', e.target.value)}/>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<NumberInput aria-label={`Device ${i + 1} watts`} placeholder='50' value={row.watts} min={0}
|
||||
hideControls onChange={v => update(i, 'watts', v)}/>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<NumberInput aria-label={`Device ${i + 1} hours`} placeholder='24' value={row.hours} min={0}
|
||||
max={24} hideControls onChange={v => update(i, 'hours', v)}/>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{fmt(loadWh(row))}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<IconButton icon='trash' label={`Remove device ${i + 1}`} onClick={() => remove(i)}/>
|
||||
</Table.Cell>
|
||||
</Table.Row>)}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
<Group gap='md' style={{marginTop: '0.5em'}}>
|
||||
<Button type='button' size='small' icon='add'
|
||||
onClick={() => write([...rows, {name: '', watts: '', hours: ''}])}>
|
||||
Add device
|
||||
</Button>
|
||||
<span><b>{fmt(r.devicesWh)} Wh</b> per day</span>
|
||||
</Group>
|
||||
<Group gap='lg' align='flex-end' style={{marginTop: '0.75em'}}>
|
||||
<Toggle label='Devices run on AC, through an inverter' checked={o.acLoads}
|
||||
onChange={e => set({ac: e.currentTarget.checked ? null : '0'})}/>
|
||||
{o.acLoads && <NumberInput label='Inverter efficiency (%)' name='leff' value={o.loadInverterEfficiency}
|
||||
min={50} max={100} step={1} style={{width: '12em'}}
|
||||
onChange={v => setNumber('leff', v, DEFAULT_LOAD_INVERTER_EFFICIENCY)}/>}
|
||||
</Group>
|
||||
</>;
|
||||
}
|
||||
|
||||
function BatterySection({o, set, setNumber}) {
|
||||
return <>
|
||||
<Header as='h3' style={{marginTop: '1.5em'}}>Batteries</Header>
|
||||
<Hint>
|
||||
Days without sun is how long the batteries must carry your devices through cloudy weather. Two or three
|
||||
days is typical. Larger systems (over about 1,500 W of panels) usually use 24 or 48 volts.
|
||||
</Hint>
|
||||
<Grid>
|
||||
<Grid.Col span={{base: 12, sm: 6}}>
|
||||
<Select label='Battery type'
|
||||
data={Object.entries(BATTERIES).map(([value, b]) => ({value, label: b.label}))}
|
||||
value={o.battery} allowDeselect={false}
|
||||
onChange={v => set({bat: v === 'lfp' ? null : v, dod: null})}/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{base: 6, sm: 3}}>
|
||||
<Select label='System voltage' data={SYSTEM_VOLTAGES.map(v => ({value: `${v}`, label: `${v} V`}))}
|
||||
value={`${o.systemVoltage}`} allowDeselect={false}
|
||||
onChange={v => set({sv: v === '12' ? null : v})}/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{base: 6, sm: 3}}>
|
||||
<NumberInput label='Days without sun' name='aut' value={o.days} min={0} step={0.5}
|
||||
onChange={v => setNumber('aut', v, DEFAULT_DAYS)}/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{base: 12, sm: 6}}>
|
||||
<NumberInput label='Usable depth of discharge (%)' name='dod' value={o.dod} min={10} max={100} step={5}
|
||||
onChange={v => setNumber('dod', v, BATTERIES[o.battery].dod)}/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{base: 12, sm: 6}} style={{display: 'flex', alignItems: 'flex-end'}}>
|
||||
<Toggle label='Batteries are outdoors or unheated' checked={o.outdoors}
|
||||
onChange={e => set({bout: e.currentTarget.checked ? '1' : null})}/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</>;
|
||||
}
|
||||
|
||||
function NeedsSection({o, r, worst, s, setNumber}) {
|
||||
if (!(r.devicesWh > 0)) {
|
||||
return <div style={{marginTop: '1em'}}>
|
||||
<Message kind='info' title='Add your devices'>List your devices above to size the system.</Message>
|
||||
</div>;
|
||||
}
|
||||
const coverage = r.coverage;
|
||||
return <Panel style={{marginTop: '1.5em'}}>
|
||||
<Header as='h3'>What you need</Header>
|
||||
<StatisticGroup>
|
||||
<Statistic value={fmt(r.panelsNeeded)} label={`${s.panelWatts} W panels for ${worst.month}`}/>
|
||||
<Statistic value={fmt(r.bank?.ah)} label={`Ah of ${o.systemVoltage} V battery`}/>
|
||||
<Statistic value={fmt(r.bank ? r.bank.nominalWh / 1000 : null, 1)} label='kWh of battery'/>
|
||||
<Statistic value={fmt(r.batteryLoadWh)} label='Wh per day from the batteries'/>
|
||||
</StatisticGroup>
|
||||
<p>
|
||||
Sized for {worst.month}, your darkest month, when each kW of panels makes about{' '}
|
||||
{fmt(worst.energyDaily, 1)} kWh a day at the angle chosen under Fine-tune.
|
||||
{o.controller === 'pwm' && r.sizing.pwmFraction > 0 && ` The PWM controller uses only `
|
||||
+ `${fmt(r.sizing.pwmFraction * 100)}% of the panels' power, so more panels are needed.`}
|
||||
{o.controller === 'pwm' && r.sizing.pwmFraction === 0 && ' These panels cannot charge this battery '
|
||||
+ 'through a PWM controller; see the warning below.'}
|
||||
</p>
|
||||
{coverage !== null && <p>
|
||||
Your {s.panelCount} panel{s.panelCount === 1 ? '' : 's'} would supply about{' '}
|
||||
<b>{fmt(Math.min(coverage, 9.99) * 100)}%</b> of your use in {worst.month}.{' '}
|
||||
{r.panelsNeeded !== s.panelCount && r.panelsNeeded > 0 &&
|
||||
<Button type='button' size='small' onClick={() => setNumber('pn', r.panelsNeeded, 1)}>
|
||||
Use {r.panelsNeeded} panels
|
||||
</Button>}
|
||||
</p>}
|
||||
{r.warnings.map(w => <Message key={w} kind='warning' title='Battery'>{w}</Message>)}
|
||||
</Panel>;
|
||||
}
|
||||
|
||||
function ControllerSection({o, r, set, setNumber, temperaturesKnown}) {
|
||||
const z = r.sizing;
|
||||
const panelField = (key, label, value, fallback, step, max) => <Grid.Col span={{base: 6, sm: 3}}>
|
||||
<NumberInput label={label} name={key} value={value} step={step} decimalScale={3} max={max}
|
||||
onChange={v => setNumber(key, v, fallback)}/>
|
||||
</Grid.Col>;
|
||||
|
||||
return <>
|
||||
<Header as='h3' style={{marginTop: '1.5em'}}>Charge controller and wiring</Header>
|
||||
<Hint>
|
||||
Copy these from the label on the back of your panel. Panels make a higher voltage when cold; too many in
|
||||
series can exceed the controller's limit on a cold morning and destroy it. The coldest morning starts
|
||||
25 °C below your coldest monthly average, because averages hide cold snaps; enter your area's record
|
||||
low if you know it.
|
||||
</Hint>
|
||||
<Grid>
|
||||
{panelField('voc', 'Open-circuit voltage, Voc (V)', o.voc, PANEL_DEFAULTS.voc, 0.1)}
|
||||
{panelField('vmp', 'Max-power voltage, Vmp (V)', o.vmp, PANEL_DEFAULTS.vmp, 0.1)}
|
||||
{panelField('isc', 'Short-circuit current, Isc (A)', o.isc, PANEL_DEFAULTS.isc, 0.1)}
|
||||
{panelField('bvoc', 'Voc temperature coefficient (%/°C)', o.betaVoc, PANEL_DEFAULTS.bvoc, 0.01, 0)}
|
||||
<Grid.Col span={{base: 6, sm: 3}}>
|
||||
<Select label='Controller type'
|
||||
data={Object.entries(CONTROLLERS).map(([value, c]) => ({value, label: c.label}))}
|
||||
value={o.controller} allowDeselect={false}
|
||||
onChange={v => set({ctl: v === 'mppt' ? null : v})}/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{base: 6, sm: 3}}>
|
||||
<NumberInput label='Controller max PV voltage (V)' name='cmax' value={o.controllerMaxVoltage} min={0}
|
||||
step={10} onChange={v => setNumber('cmax', v, DEFAULT_CONTROLLER_MAX_VOLTAGE)}/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{base: 6, sm: 3}}>
|
||||
<NumberInput label='Panels in series' name='ns' value={o.series} min={1} step={1}
|
||||
onChange={v => setNumber('ns', v, 1)}/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{base: 6, sm: 3}}>
|
||||
<NumberInput label='Coldest morning (°C)' name='tmin' value={o.coldest} step={1}
|
||||
onChange={v => setNumber('tmin', v, o.defaultTmin)}/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
{/* The cold-morning limit depends on the site's temperatures; do not show a placeholder's verdict. */}
|
||||
{!temperaturesKnown && <div style={{marginTop: '1em'}}><Loader size='xs' label='Loading temperatures'/></div>}
|
||||
{temperaturesKnown && <>
|
||||
<Table style={{marginTop: '1em'}}>
|
||||
<Table.Body>
|
||||
<Table.Row>
|
||||
<Table.Cell>Strings</Table.Cell>
|
||||
<Table.Cell>{z.strings} of {o.series} panel{o.series === 1 ? '' : 's'} in series</Table.Cell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.Cell>String voltage on the coldest morning</Table.Cell>
|
||||
<Table.Cell>{fmt(z.stringColdVoc, 1)} V (most panels in series: {z.maxSeries})</Table.Cell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.Cell>Controller current rating, at least</Table.Cell>
|
||||
<Table.Cell>{fmt(z.current)} A</Table.Cell>
|
||||
</Table.Row>
|
||||
</Table.Body>
|
||||
</Table>
|
||||
{z.overVoltage && <Message kind='error' title='Too much voltage for the controller'>
|
||||
{o.series} panels in series reach {fmt(z.stringColdVoc, 1)} V on a {o.coldest} °C morning,
|
||||
over the controller's {o.controllerMaxVoltage} V limit. Use at most {z.maxSeries} in series, or a
|
||||
controller rated for more voltage.
|
||||
</Message>}
|
||||
{z.lowVoltage && <Message kind='warning' title='Too little voltage to charge'>
|
||||
{o.controller === 'pwm' && z.pwmFraction === 0
|
||||
? `${o.series} panel${o.series === 1 ? '' : 's'} in series cannot reach the ${o.systemVoltage} V `
|
||||
+ 'battery\'s charging voltage, so a PWM controller cannot charge it at all.'
|
||||
: `On a hot day ${o.series} panel${o.series === 1 ? '' : 's'} in series may not stay far enough `
|
||||
+ 'above the battery\'s charging voltage for the controller to charge well.'}
|
||||
{' '}Put more panels in series, or use a lower system voltage.
|
||||
</Message>}
|
||||
{z.unevenStrings && <Message kind='warning' title='Uneven strings'>
|
||||
Your panel count does not divide evenly into strings of {o.series}. Every string should have the same
|
||||
number of panels.
|
||||
</Message>}
|
||||
{o.controller === 'mppt' && z.current > HIGH_CURRENT && o.systemVoltage < 48 &&
|
||||
<Message kind='info' title='High current'>
|
||||
{fmt(z.current)} A needs a large controller and thick wire. A higher system voltage would cut the
|
||||
current.
|
||||
</Message>}
|
||||
</>}
|
||||
</>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The off-grid sizing tab.
|
||||
*
|
||||
* @param {Object} worst the worst month for a 1 kW DC array, or null when there is no estimate
|
||||
* @param {number[]} temperatures monthly average temperatures at the site, °C
|
||||
* @param {boolean} loading the built-in sunlight and temperature data is still downloading
|
||||
*/
|
||||
export function SolarOffGrid({get, set, setNumber, s, worst, temperatures, loading}) {
|
||||
const o = offGridFromParams(get, temperatures);
|
||||
const r = offGridResults(o, worst, s.panelWatts, s.panelCount);
|
||||
|
||||
return <div style={{marginTop: '1em'}}>
|
||||
<Hint>
|
||||
Size a battery system to run your devices through the darkest month. Production here is DC; the
|
||||
Inverter section under Fine-tune is for grid-tie systems and does not apply.
|
||||
</Hint>
|
||||
<LoadsSection o={o} r={r} set={set} setNumber={setNumber}/>
|
||||
<BatterySection o={o} set={set} setNumber={setNumber}/>
|
||||
{worst && <NeedsSection o={o} r={r} worst={worst} s={s} setNumber={setNumber}/>}
|
||||
{!worst && loading && <div style={{marginTop: '1em'}}><Loader size='xs' label='Loading sunlight data'/></div>}
|
||||
{!worst && !loading && <div style={{marginTop: '1em'}}>
|
||||
<Message kind='info' title='Almost there'>Enter a location above to size the system.</Message>
|
||||
</div>}
|
||||
<ControllerSection o={o} r={r} set={set} setNumber={setNumber}
|
||||
temperaturesKnown={!loading || get('tmin') !== null}/>
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -1,173 +0,0 @@
|
|||
import React from "react";
|
||||
import {fireEvent, screen} from "@testing-library/react";
|
||||
import {renderWithProviders} from "../../test-utils";
|
||||
import {offGridFromParams, offGridResults} from "./SolarOffGrid";
|
||||
import {SolarCalculator} from "./SolarCalculator";
|
||||
import {forgetClimatology} from "./solarData";
|
||||
import {mockClimatologyFetch} from "./solarData.fixtures";
|
||||
|
||||
const params = query => {
|
||||
const searchParams = new URLSearchParams(query);
|
||||
return key => searchParams.get(key);
|
||||
};
|
||||
const currentParams = () => new URLSearchParams(window.location.search);
|
||||
const TEMPS = [-1, 0, 5, 10, 15, 20, 24, 22, 18, 10, 4, -1];
|
||||
const DEC = {month: 'Dec', energyDaily: 3};
|
||||
|
||||
describe('offGridFromParams', () => {
|
||||
test('defaults', () => {
|
||||
const o = offGridFromParams(params(''), TEMPS);
|
||||
expect(o.loads).toEqual([]);
|
||||
expect(o.acLoads).toBe(true);
|
||||
expect(o.battery).toBe('lfp');
|
||||
expect(o.dod).toBe(80);
|
||||
expect(o.systemVoltage).toBe(12);
|
||||
expect(o.days).toBe(2);
|
||||
expect(o.controller).toBe('mppt');
|
||||
expect(o.series).toBe(1);
|
||||
expect(o.coldestMonthly).toBe(-1);
|
||||
expect(o.coldest).toBe(-26);
|
||||
});
|
||||
|
||||
test('the depth of discharge follows the battery type unless set', () => {
|
||||
expect(offGridFromParams(params('bat=lead'), TEMPS).dod).toBe(50);
|
||||
expect(offGridFromParams(params('bat=lead&dod=60'), TEMPS).dod).toBe(60);
|
||||
});
|
||||
|
||||
test('a Voc coefficient without its minus sign is read as negative, even from a shared link', () => {
|
||||
expect(offGridFromParams(params('bvoc=0.27'), TEMPS).betaVoc).toBe(-0.27);
|
||||
expect(offGridFromParams(params('bvoc=-0.3'), TEMPS).betaVoc).toBe(-0.3);
|
||||
});
|
||||
|
||||
test('invalid values fall back', () => {
|
||||
const o = offGridFromParams(params('bat=x&sv=13&ctl=y&ns=0&aut=-1'), null);
|
||||
expect(o.battery).toBe('lfp');
|
||||
expect(o.systemVoltage).toBe(12);
|
||||
expect(o.controller).toBe('mppt');
|
||||
expect(o.series).toBe(1);
|
||||
expect(o.days).toBe(0);
|
||||
expect(o.coldest).toBe(-20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('offGridResults', () => {
|
||||
// 1000 Wh/day of AC devices through a 90% inverter, LiFePO4 at 80%, 2 days, 12 V.
|
||||
const o = offGridFromParams(params('load=Stuff~100~10'), TEMPS);
|
||||
|
||||
test('battery and panels for the worst month', () => {
|
||||
const r = offGridResults(o, DEC, 400, 1);
|
||||
expect(r.devicesWh).toBe(1000);
|
||||
expect(r.batteryLoadWh).toBeCloseTo(1111.1, 1);
|
||||
expect(r.bank.nominalWh).toBeCloseTo(2777.8, 1);
|
||||
expect(r.bank.ah).toBeCloseTo(231.5, 1);
|
||||
// 1.111 kWh / (3 kWh/kWp x 95%) = 0.390 kW: one 400 W panel.
|
||||
expect(r.kwp).toBeCloseTo(0.3899, 4);
|
||||
expect(r.panelsNeeded).toBe(1);
|
||||
// One 400 W panel: 3 x 0.4 x 95% = 1.14 kWh for 1.111 kWh of use.
|
||||
expect(r.coverage).toBeCloseTo(1.026, 3);
|
||||
});
|
||||
|
||||
test('DC devices skip the inverter loss', () => {
|
||||
const dc = offGridResults(offGridFromParams(params('load=Stuff~100~10&ac=0'), TEMPS), DEC, 400, 1);
|
||||
expect(dc.batteryLoadWh).toBe(1000);
|
||||
});
|
||||
|
||||
test('a PWM controller needs more panels', () => {
|
||||
const pwm = offGridResults(offGridFromParams(params('load=Stuff~100~10&ctl=pwm'), TEMPS), DEC, 400, 1);
|
||||
// Only 14.4 / 31 of each panel is used: 0.390 / 0.4645 = 0.839 kW, three panels.
|
||||
expect(pwm.panelsNeeded).toBe(3);
|
||||
});
|
||||
|
||||
test('an outdoor lead-acid bank is enlarged for the cold', () => {
|
||||
const indoor = offGridResults(offGridFromParams(params('load=Stuff~100~10&bat=lead'), TEMPS), DEC, 400, 1);
|
||||
const outdoor = offGridResults(offGridFromParams(params('load=Stuff~100~10&bat=lead&bout=1'), TEMPS),
|
||||
DEC, 400, 1);
|
||||
// Coldest month -1 °C: 79% of rated capacity.
|
||||
expect(outdoor.bank.nominalWh).toBeCloseTo(indoor.bank.nominalWh / 0.79, 1);
|
||||
expect(outdoor.warnings).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('nothing to size without devices or an estimate', () => {
|
||||
const none = offGridResults(offGridFromParams(params(''), TEMPS), DEC, 400, 1);
|
||||
expect(none.bank).toBeNull();
|
||||
expect(none.panelsNeeded).toBeNull();
|
||||
expect(offGridResults(o, null, 400, 1).panelsNeeded).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the off-grid tab', () => {
|
||||
const OFFGRID = '/more/calculators?calc=solar&lat=40&lon=-105&tab=offgrid';
|
||||
|
||||
beforeEach(() => {
|
||||
forgetClimatology();
|
||||
mockClimatologyFetch();
|
||||
});
|
||||
|
||||
const findNeeds = () => screen.findByText('What you need', {}, {timeout: 5000});
|
||||
|
||||
test('devices are added to the URL and the system is sized', async () => {
|
||||
renderWithProviders(<SolarCalculator/>, {route: OFFGRID});
|
||||
expect(await screen.findByText('Add your devices', {}, {timeout: 5000})).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText('Device 1 name'), {target: {value: 'Fridge'}});
|
||||
fireEvent.change(screen.getByLabelText('Device 1 watts'), {target: {value: '50'}});
|
||||
fireEvent.change(screen.getByLabelText('Device 1 hours'), {target: {value: '24'}});
|
||||
expect(currentParams().get('load')).toBe('Fridge~50~24');
|
||||
expect(await findNeeds()).toBeInTheDocument();
|
||||
expect(screen.getByText('1200 Wh')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {name: 'Add device'}));
|
||||
expect(screen.getByLabelText('Device 2 name')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', {name: 'Remove device 2'}));
|
||||
expect(screen.queryByLabelText('Device 2 name')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('the suggested panel count can be applied', async () => {
|
||||
renderWithProviders(<SolarCalculator/>, {route: `${OFFGRID}&load=Heater~500~10`});
|
||||
await findNeeds();
|
||||
const button = screen.getByRole('button', {name: /^Use \d+ panels$/});
|
||||
const suggested = button.textContent.match(/\d+/)[0];
|
||||
fireEvent.click(button);
|
||||
expect(currentParams().get('pn')).toBe(suggested);
|
||||
});
|
||||
|
||||
test('too many panels in series warns before it destroys the controller', async () => {
|
||||
renderWithProviders(<SolarCalculator/>, {route: `${OFFGRID}&load=Fridge~50~24&ns=3&pn=3`});
|
||||
await findNeeds();
|
||||
expect(screen.getByText('Too much voltage for the controller')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('PWM panels that cannot reach a 48 V battery say so instead of sizing a system', async () => {
|
||||
renderWithProviders(<SolarCalculator/>, {route: `${OFFGRID}&load=Fridge~50~24&ctl=pwm&sv=48`});
|
||||
await findNeeds();
|
||||
expect(screen.getByText('Too little voltage to charge')).toBeInTheDocument();
|
||||
expect(screen.getByText(/a PWM controller cannot charge it at all/)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', {name: /^Use \d+ panels$/})).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('while the data downloads, it shows a loader and no cold-morning verdict', async () => {
|
||||
forgetClimatology();
|
||||
global.fetch = jest.fn(() => new Promise(() => {
|
||||
}));
|
||||
renderWithProviders(<SolarCalculator/>, {route: `${OFFGRID}&load=Fridge~50~24`});
|
||||
// One beside the location, one in place of the sizing.
|
||||
expect(screen.getAllByLabelText('Loading sunlight data')).toHaveLength(2);
|
||||
expect(screen.queryByText('Almost there')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('String voltage on the coldest morning')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a coldest morning the user typed is checked even while the data downloads', async () => {
|
||||
forgetClimatology();
|
||||
global.fetch = jest.fn(() => new Promise(() => {
|
||||
}));
|
||||
renderWithProviders(<SolarCalculator/>, {route: `${OFFGRID}&load=Fridge~50~24&tmin=-30`});
|
||||
expect(screen.getByText('String voltage on the coldest morning')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('the tab is remembered in the URL', async () => {
|
||||
renderWithProviders(<SolarCalculator/>, {route: '/more/calculators?calc=solar&lat=40&lon=-105'});
|
||||
fireEvent.click(screen.getByRole('tab', {name: 'Off-grid sizing'}));
|
||||
expect(currentParams().get('tab')).toBe('offgrid');
|
||||
fireEvent.click(screen.getByRole('tab', {name: 'Estimate'}));
|
||||
expect(currentParams().has('tab')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,210 +0,0 @@
|
|||
// Pure calculation functions for the Solar calculator's off-grid sizing. No React here.
|
||||
//
|
||||
// Off-grid systems are sized for the worst month, not the yearly average: the battery has to carry the
|
||||
// loads through a run of cloudy days, and the panels have to refill it in the darkest month.
|
||||
//
|
||||
// Energy is Wh, power W, voltage V, current A, temperature °C, percentages 0-100.
|
||||
|
||||
const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Loads
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Devices are stored in the URL as "name~watts~hours" joined by "|".
|
||||
const ROW_SEPARATOR = '|';
|
||||
const FIELD_SEPARATOR = '~';
|
||||
|
||||
export function parseLoads(text) {
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
return text.split(ROW_SEPARATOR).map(row => {
|
||||
const [name = '', watts = '', hours = ''] = row.split(FIELD_SEPARATOR);
|
||||
return {name, watts, hours};
|
||||
});
|
||||
}
|
||||
|
||||
export function formatLoads(rows) {
|
||||
// The separators cannot appear inside a name.
|
||||
const clean = value => `${value ?? ''}`.replace(/[|~]/g, ' ');
|
||||
return rows.map(r => [clean(r.name), clean(r.watts), clean(r.hours)].join(FIELD_SEPARATOR)).join(ROW_SEPARATOR);
|
||||
}
|
||||
|
||||
// Watt-hours a device uses per day; blank or invalid values count as nothing.
|
||||
export function loadWh({watts, hours}) {
|
||||
const w = Number(watts), h = Number(hours);
|
||||
return w > 0 && h > 0 ? w * Math.min(h, 24) : 0;
|
||||
}
|
||||
|
||||
export function dailyLoadWh(rows) {
|
||||
return rows.reduce((acc, row) => acc + loadWh(row), 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Battery
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const BATTERIES = {
|
||||
lfp: {
|
||||
label: 'Lithium iron phosphate (LiFePO4)',
|
||||
// Usable depth of discharge, and round-trip efficiency.
|
||||
dod: 80, efficiency: 95,
|
||||
},
|
||||
lead: {
|
||||
label: 'Lead-acid (flooded or AGM)',
|
||||
// Discharging below half shortens a lead-acid battery's life sharply.
|
||||
dod: 50, efficiency: 85,
|
||||
},
|
||||
};
|
||||
|
||||
export const SYSTEM_VOLTAGES = [12, 24, 48];
|
||||
|
||||
// Charging voltage of a battery bank, used to judge PWM controllers and MPPT headroom.
|
||||
export const chargeVoltage = systemVoltage => systemVoltage * 1.2;
|
||||
|
||||
// Share of rated capacity a cold lead-acid battery delivers: about 80% at 0 °C and 60% at -20 °C, as on
|
||||
// typical manufacturer curves. Lithium batteries are not derated here; they must not be CHARGED below
|
||||
// 0 °C, which is a warning instead (see batteryWarnings).
|
||||
export function leadAcidCapacityFactor(temperature) {
|
||||
if (!Number.isFinite(temperature) || temperature >= 20) {
|
||||
return 1;
|
||||
}
|
||||
return clamp(1 - 0.01 * (20 - temperature), 0.5, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Battery bank for `days` of the daily load with no sun.
|
||||
*
|
||||
* @returns {{usableWh: number, nominalWh: number, ah: number}}
|
||||
*/
|
||||
export function sizeBattery({loadWh: daily, days, dod, voltage, capacityFactor = 1}) {
|
||||
if (!(daily > 0) || !(days > 0) || !(dod > 0) || !(voltage > 0)) {
|
||||
return null;
|
||||
}
|
||||
const usableWh = daily * days;
|
||||
const nominalWh = usableWh / (dod / 100) / capacityFactor;
|
||||
return {usableWh, nominalWh, ah: nominalWh / voltage};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Array
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* kW of panels needed to supply the daily load in the worst month.
|
||||
*
|
||||
* @param {number} loadWh daily energy the battery bank must supply
|
||||
* @param {number} worstDailyPerKwp DC kWh per day per kW of panels in the worst month
|
||||
* @param {number} batteryEfficiency round-trip efficiency, %; conservatively applied to all energy
|
||||
*/
|
||||
export function requiredKwp(loadWh, worstDailyPerKwp, batteryEfficiency) {
|
||||
if (!(loadWh > 0) || !(worstDailyPerKwp > 0) || !(batteryEfficiency > 0)) {
|
||||
return null;
|
||||
}
|
||||
return (loadWh / 1000) / (worstDailyPerKwp * batteryEfficiency / 100);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Charge controller and strings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const CONTROLLERS = {
|
||||
mppt: {label: 'MPPT'},
|
||||
pwm: {label: 'PWM'},
|
||||
};
|
||||
|
||||
// A PWM controller connects a string of `series` panels straight to the battery, holding it at the battery's
|
||||
// charging voltage instead of its best voltage (Vmp), so only this share of its power is used:
|
||||
// - charging voltage at or above the string's Voc: no current flows, nothing is delivered;
|
||||
// - between Vmp and Voc: the current falls from its maximum toward zero at Voc (taken as linear);
|
||||
// - below Vmp: full current at the lower voltage, charging voltage / Vmp of the power.
|
||||
export function pwmUsableFraction(vmp, voc, series, systemVoltage) {
|
||||
const stringVmp = vmp * series, stringVoc = voc * series;
|
||||
if (!(stringVmp > 0) || !(stringVoc > 0)) {
|
||||
return 1;
|
||||
}
|
||||
const charge = chargeVoltage(systemVoltage);
|
||||
if (charge >= stringVoc) {
|
||||
return 0;
|
||||
}
|
||||
if (charge > stringVmp) {
|
||||
return (charge / stringVmp) * (stringVoc - charge) / (stringVoc - stringVmp);
|
||||
}
|
||||
return charge / stringVmp;
|
||||
}
|
||||
|
||||
// Panel open-circuit voltage rises as it gets colder. `betaVoc` is its temperature coefficient, %/°C, which is
|
||||
// negative for every silicon panel; a coefficient typed without its minus sign is treated as negative, because
|
||||
// a positive one would hide the cold-weather rise that destroys controllers.
|
||||
export function coldVoc(voc, betaVoc, temperature) {
|
||||
return voc * (1 - (Math.abs(betaVoc) / 100) * (temperature - 25));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wiring and controller checks for an array of `panelCount` panels, `series` of them per string.
|
||||
*/
|
||||
export function controllerSizing({
|
||||
panelCount, panelWatts, series, voc, vmp, isc, betaVoc, coldest,
|
||||
controller, controllerMaxVoltage, systemVoltage,
|
||||
}) {
|
||||
const strings = series > 0 ? Math.ceil(panelCount / series) : 0;
|
||||
const arrayWatts = panelCount * panelWatts;
|
||||
const stringColdVoc = coldVoc(voc, betaVoc, coldest) * series;
|
||||
const maxSeries = Math.floor(controllerMaxVoltage / coldVoc(voc, betaVoc, coldest));
|
||||
|
||||
let current;
|
||||
if (controller === 'pwm') {
|
||||
// Panel current passes straight through, plus the usual 25% margin.
|
||||
current = isc * strings * 1.25;
|
||||
} else {
|
||||
current = arrayWatts / systemVoltage * 1.25;
|
||||
}
|
||||
return {
|
||||
strings,
|
||||
arrayWatts,
|
||||
stringColdVoc,
|
||||
maxSeries,
|
||||
current,
|
||||
overVoltage: stringColdVoc > controllerMaxVoltage,
|
||||
// An MPPT controller needs the string's voltage, which sags about 15% in summer heat, to stay above the
|
||||
// battery's charging voltage with some headroom. A PWM controller loses power as soon as the string's
|
||||
// Vmp is below the charging voltage, and all of it once its Voc is.
|
||||
lowVoltage: controller === 'mppt'
|
||||
? vmp * series * 0.85 < chargeVoltage(systemVoltage) + 5
|
||||
: vmp * series < chargeVoltage(systemVoltage),
|
||||
unevenStrings: series > 0 && panelCount % series !== 0,
|
||||
pwmFraction: controller === 'pwm' ? pwmUsableFraction(vmp, voc, series, systemVoltage) : 1,
|
||||
};
|
||||
}
|
||||
|
||||
// Default for the coldest morning when the user has not entered one: well below the coldest monthly
|
||||
// average, because monthly averages hide cold snaps. Errs cold, since underestimating the cold is what
|
||||
// destroys controllers.
|
||||
export const COLD_MARGIN = 25;
|
||||
|
||||
export function defaultColdest(monthlyTemperatures) {
|
||||
const known = (monthlyTemperatures || []).filter(Number.isFinite);
|
||||
if (known.length === 0) {
|
||||
return -20;
|
||||
}
|
||||
return Math.round(Math.min(...known) - COLD_MARGIN);
|
||||
}
|
||||
|
||||
// Advice about the battery for the site's climate. `coldestMonthly` is the coldest monthly average, °C.
|
||||
export function batteryWarnings({battery, outdoors, coldestMonthly}) {
|
||||
const warnings = [];
|
||||
if (!outdoors || !Number.isFinite(coldestMonthly)) {
|
||||
return warnings;
|
||||
}
|
||||
if (battery === 'lfp' && coldestMonthly < 5) {
|
||||
warnings.push('Lithium (LiFePO4) batteries are damaged by charging below 0 °C. Keep them indoors, or buy '
|
||||
+ 'batteries with built-in heaters or low-temperature charge protection.');
|
||||
}
|
||||
if (battery === 'lead' && coldestMonthly < 20) {
|
||||
warnings.push(`Cold lead-acid batteries hold less: the bank is enlarged to deliver ` +
|
||||
`${Math.round(leadAcidCapacityFactor(coldestMonthly) * 100)}% of its rating in the coldest month. ` +
|
||||
'Discharged lead-acid batteries can also freeze and crack; keep them charged.');
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
import {
|
||||
BATTERIES,
|
||||
batteryWarnings,
|
||||
chargeVoltage,
|
||||
coldVoc,
|
||||
controllerSizing,
|
||||
dailyLoadWh,
|
||||
defaultColdest,
|
||||
formatLoads,
|
||||
leadAcidCapacityFactor,
|
||||
loadWh,
|
||||
parseLoads,
|
||||
pwmUsableFraction,
|
||||
requiredKwp,
|
||||
sizeBattery,
|
||||
} from "./offgrid";
|
||||
|
||||
describe('loads', () => {
|
||||
test('round-trip through the URL format', () => {
|
||||
const rows = [{name: 'Fridge', watts: '60', hours: '24'}, {name: 'Lights', watts: '20', hours: '5'}];
|
||||
expect(formatLoads(rows)).toBe('Fridge~60~24|Lights~20~5');
|
||||
expect(parseLoads(formatLoads(rows))).toEqual(rows);
|
||||
expect(parseLoads('')).toEqual([]);
|
||||
expect(parseLoads(null)).toEqual([]);
|
||||
});
|
||||
|
||||
test('separators are removed from names', () => {
|
||||
expect(parseLoads(formatLoads([{name: 'A|B~C', watts: '1', hours: '1'}]))[0].name).toBe('A B C');
|
||||
});
|
||||
|
||||
test('daily energy is watts times hours, ignoring blanks and capping at 24 hours', () => {
|
||||
expect(loadWh({watts: '60', hours: '24'})).toBe(1440);
|
||||
expect(loadWh({watts: '60', hours: '30'})).toBe(1440);
|
||||
expect(loadWh({watts: '', hours: '5'})).toBe(0);
|
||||
expect(loadWh({watts: '-5', hours: '5'})).toBe(0);
|
||||
expect(dailyLoadWh(parseLoads('Fridge~60~24|Lights~20~5|~~'))).toBe(1540);
|
||||
});
|
||||
});
|
||||
|
||||
describe('battery', () => {
|
||||
test('a bank for 2 days of 1 kWh at 50% depth of discharge holds 4 kWh', () => {
|
||||
const bank = sizeBattery({loadWh: 1000, days: 2, dod: 50, voltage: 12});
|
||||
expect(bank.usableWh).toBe(2000);
|
||||
expect(bank.nominalWh).toBe(4000);
|
||||
expect(bank.ah).toBeCloseTo(333.3, 1);
|
||||
});
|
||||
|
||||
test('a cold lead-acid bank must be bigger', () => {
|
||||
expect(leadAcidCapacityFactor(25)).toBe(1);
|
||||
expect(leadAcidCapacityFactor(0)).toBeCloseTo(0.8, 6);
|
||||
expect(leadAcidCapacityFactor(-20)).toBeCloseTo(0.6, 6);
|
||||
expect(leadAcidCapacityFactor(-60)).toBe(0.5);
|
||||
const warm = sizeBattery({loadWh: 1000, days: 1, dod: 50, voltage: 12});
|
||||
const cold = sizeBattery({loadWh: 1000, days: 1, dod: 50, voltage: 12, capacityFactor: 0.8});
|
||||
expect(cold.nominalWh).toBeCloseTo(warm.nominalWh / 0.8, 6);
|
||||
});
|
||||
|
||||
test('lithium needs a smaller bank than lead-acid for the same load', () => {
|
||||
const lfp = sizeBattery({loadWh: 1000, days: 2, dod: BATTERIES.lfp.dod, voltage: 24});
|
||||
const lead = sizeBattery({loadWh: 1000, days: 2, dod: BATTERIES.lead.dod, voltage: 24});
|
||||
expect(lfp.nominalWh).toBeLessThan(lead.nominalWh);
|
||||
});
|
||||
|
||||
test('no bank without a load', () => {
|
||||
expect(sizeBattery({loadWh: 0, days: 2, dod: 50, voltage: 12})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('array', () => {
|
||||
test('panels needed for the worst month', () => {
|
||||
// 2 kWh/day, 3 kWh/kWp/day in the worst month, 95% battery efficiency: 2 / 2.85 = 0.702 kW.
|
||||
expect(requiredKwp(2000, 3, 95)).toBeCloseTo(0.7018, 4);
|
||||
expect(requiredKwp(2000, 0, 95)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('charge controller', () => {
|
||||
test('a PWM controller wastes most of a high-voltage panel on a 12 V battery', () => {
|
||||
expect(chargeVoltage(12)).toBeCloseTo(14.4, 6);
|
||||
// 14.4 V / 31 V: under half the panel's power.
|
||||
expect(pwmUsableFraction(31, 37, 1, 12)).toBeCloseTo(0.4645, 3);
|
||||
// A "12 V" panel (Vmp ~18 V) suits PWM much better.
|
||||
expect(pwmUsableFraction(18, 22, 1, 12)).toBeCloseTo(0.8, 6);
|
||||
});
|
||||
|
||||
test('PWM uses the string voltage, so panels in series waste even more', () => {
|
||||
// Two 31 V panels make a 62 V string: 14.4 / 62.
|
||||
expect(pwmUsableFraction(31, 37, 2, 12)).toBeCloseTo(0.2323, 3);
|
||||
});
|
||||
|
||||
test('PWM delivers nothing when the string cannot reach the charging voltage', () => {
|
||||
// One 37 Voc panel against a 48 V bank's 57.6 V.
|
||||
expect(pwmUsableFraction(31, 37, 1, 48)).toBe(0);
|
||||
expect(pwmUsableFraction(10, 12, 1, 12)).toBe(0);
|
||||
});
|
||||
|
||||
test('PWM between Vmp and Voc delivers part of the power as current falls toward Voc', () => {
|
||||
// 14.4 V sits just above a 14 V Vmp: (14.4 / 14) x (21 - 14.4) / (21 - 14).
|
||||
expect(pwmUsableFraction(14, 21, 1, 12)).toBeCloseTo(0.9698, 3);
|
||||
});
|
||||
|
||||
test('open-circuit voltage rises in the cold', () => {
|
||||
// -0.27 %/°C at -25 °C (50 °C below the 25 °C rating): +13.5%.
|
||||
expect(coldVoc(37, -0.27, -25)).toBeCloseTo(41.995, 3);
|
||||
expect(coldVoc(37, -0.27, 25)).toBe(37);
|
||||
});
|
||||
|
||||
test('a Voc coefficient typed without its minus sign still errs safe', () => {
|
||||
expect(coldVoc(37, 0.27, -25)).toBeCloseTo(coldVoc(37, -0.27, -25), 9);
|
||||
const sizing = controllerSizing({...typical, series: 3, betaVoc: 0.27});
|
||||
expect(sizing.overVoltage).toBe(true);
|
||||
expect(sizing.maxSeries).toBe(2);
|
||||
});
|
||||
|
||||
const typical = {
|
||||
panelCount: 4, panelWatts: 400, series: 2, voc: 37, vmp: 31, isc: 13.8, betaVoc: -0.27, coldest: -25,
|
||||
controller: 'mppt', controllerMaxVoltage: 100, systemVoltage: 24,
|
||||
};
|
||||
|
||||
test('two 400 W panels in series on a 100 V MPPT controller are safe', () => {
|
||||
const result = controllerSizing(typical);
|
||||
expect(result.strings).toBe(2);
|
||||
expect(result.stringColdVoc).toBeCloseTo(83.99, 2);
|
||||
expect(result.overVoltage).toBe(false);
|
||||
expect(result.maxSeries).toBe(2);
|
||||
// 1600 W / 24 V x 1.25.
|
||||
expect(result.current).toBeCloseTo(83.33, 2);
|
||||
expect(result.lowVoltage).toBe(false);
|
||||
expect(result.unevenStrings).toBe(false);
|
||||
});
|
||||
|
||||
test('three in series exceed 100 V on a cold morning', () => {
|
||||
const result = controllerSizing({...typical, series: 3});
|
||||
expect(result.overVoltage).toBe(true);
|
||||
expect(result.unevenStrings).toBe(true);
|
||||
});
|
||||
|
||||
test('one panel per string is too little voltage to charge 48 V through MPPT', () => {
|
||||
expect(controllerSizing({...typical, series: 1, systemVoltage: 48}).lowVoltage).toBe(true);
|
||||
});
|
||||
|
||||
test('PWM current is the panels\' short-circuit current plus 25%', () => {
|
||||
const result = controllerSizing({...typical, controller: 'pwm', series: 1, systemVoltage: 12});
|
||||
expect(result.current).toBeCloseTo(13.8 * 4 * 1.25, 6);
|
||||
expect(result.pwmFraction).toBeCloseTo(0.4645, 3);
|
||||
expect(result.lowVoltage).toBe(false);
|
||||
});
|
||||
|
||||
test('PWM on a battery above the string\'s voltage warns and delivers nothing', () => {
|
||||
const result = controllerSizing({...typical, controller: 'pwm', series: 1, systemVoltage: 48});
|
||||
expect(result.pwmFraction).toBe(0);
|
||||
expect(result.lowVoltage).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultColdest', () => {
|
||||
test('is well below the coldest monthly average', () => {
|
||||
expect(defaultColdest([-1, 0, 5, 10, 15, 20, 24, 22, 18, 10, 4, -1])).toBe(-26);
|
||||
expect(defaultColdest([null, null])).toBe(-20);
|
||||
expect(defaultColdest(null)).toBe(-20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('batteryWarnings', () => {
|
||||
test('warn about charging lithium in the cold, only when the batteries are outdoors', () => {
|
||||
expect(batteryWarnings({battery: 'lfp', outdoors: true, coldestMonthly: -5})[0]).toMatch(/below 0 °C/);
|
||||
expect(batteryWarnings({battery: 'lfp', outdoors: false, coldestMonthly: -5})).toEqual([]);
|
||||
expect(batteryWarnings({battery: 'lfp', outdoors: true, coldestMonthly: 15})).toEqual([]);
|
||||
});
|
||||
|
||||
test('explain the cold lead-acid enlargement', () => {
|
||||
expect(batteryWarnings({battery: 'lead', outdoors: true, coldestMonthly: 0})[0]).toMatch(/80% of its rating/);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,481 +0,0 @@
|
|||
// Pure calculation functions for the Solar calculator. No React here; SolarCalculator.js is the UI.
|
||||
//
|
||||
// The model estimates a typical year from 12 monthly averages, the standard approach when hourly
|
||||
// typical-year weather files are not available:
|
||||
//
|
||||
// 1. Sun geometry for Klein's representative day of each month (Duffie & Beckman ch. 1).
|
||||
// 2. Monthly global horizontal irradiation (GHI) is split into diffuse and beam (Erbs monthly
|
||||
// correlation), then spread across the day (Collares-Pereira & Rabl for total, Liu & Jordan
|
||||
// for diffuse).
|
||||
// 3. Each step is transposed onto the panel with the HDKR sky model, for a fixed, single-axis or
|
||||
// dual-axis array.
|
||||
// 4. PVWatts' models turn plane-of-array irradiance into energy: ASHRAE angle-of-incidence loss,
|
||||
// SAPM cell temperature, linear temperature-corrected DC power, multiplicative system losses,
|
||||
// and an optional inverter efficiency curve with clipping.
|
||||
//
|
||||
// Angles are in degrees at the API boundary. Azimuths are compass bearings (0 = north, 90 = east,
|
||||
// 180 = south, 270 = west); internally the Duffie & Beckman convention is used (0 = south, east
|
||||
// negative, west positive). Energy is kWh, irradiation kWh/m², irradiance W/m², power kW.
|
||||
|
||||
export const SOLAR_CONSTANT = 1361; // W/m²
|
||||
|
||||
const RAD = Math.PI / 180;
|
||||
const sin = deg => Math.sin(deg * RAD);
|
||||
const cos = deg => Math.cos(deg * RAD);
|
||||
const tan = deg => Math.tan(deg * RAD);
|
||||
const acos = x => Math.acos(Math.min(1, Math.max(-1, x))) / RAD;
|
||||
const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x));
|
||||
|
||||
export const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
export const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
|
||||
// Klein's recommended average day of each month: the day whose extraterrestrial radiation is
|
||||
// closest to the month's mean. Day of the year, 1-365.
|
||||
export const KLEIN_DAYS = [17, 47, 75, 105, 135, 162, 198, 228, 258, 288, 318, 344];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sun geometry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Solar declination (Cooper), degrees.
|
||||
export function declination(dayOfYear) {
|
||||
return 23.45 * sin(360 * (284 + dayOfYear) / 365);
|
||||
}
|
||||
|
||||
// Sunset hour angle on a horizontal surface, degrees. 0 during polar night, 180 during polar day.
|
||||
export function sunsetHourAngle(latitude, decl) {
|
||||
return acos(-tan(latitude) * tan(decl));
|
||||
}
|
||||
|
||||
// Orbit eccentricity correction to the solar constant.
|
||||
export function eccentricity(dayOfYear) {
|
||||
return 1 + 0.033 * cos(360 * dayOfYear / 365);
|
||||
}
|
||||
|
||||
// Daily extraterrestrial irradiation on a horizontal surface, kWh/m²/day (D&B eq. 1.10.3).
|
||||
export function extraterrestrialDaily(latitude, dayOfYear) {
|
||||
const decl = declination(dayOfYear);
|
||||
const ws = sunsetHourAngle(latitude, decl);
|
||||
const wh = (24 / Math.PI) * SOLAR_CONSTANT * eccentricity(dayOfYear)
|
||||
* (cos(latitude) * cos(decl) * sin(ws) + (Math.PI * ws / 180) * sin(latitude) * sin(decl));
|
||||
return Math.max(0, wh / 1000);
|
||||
}
|
||||
|
||||
// Day length in hours.
|
||||
export function dayLength(latitude, decl) {
|
||||
return 2 * sunsetHourAngle(latitude, decl) / 15;
|
||||
}
|
||||
|
||||
// Sun elevation at solar noon, degrees. Negative means the sun does not rise.
|
||||
export function noonElevation(latitude, decl) {
|
||||
return 90 - Math.abs(latitude - decl);
|
||||
}
|
||||
|
||||
// Sun position for an hour angle (degrees, 15° per hour from solar noon, afternoon positive).
|
||||
// Returns cosine of the zenith angle and the azimuth in the D&B convention (0 = south).
|
||||
export function sunPosition(latitude, decl, hourAngle) {
|
||||
const cosZenith = cos(latitude) * cos(decl) * cos(hourAngle) + sin(latitude) * sin(decl);
|
||||
const sinZenith = Math.sqrt(Math.max(0, 1 - cosZenith * cosZenith));
|
||||
let azimuth = 0;
|
||||
if (sinZenith > 1e-9 && Math.abs(cos(latitude)) > 1e-9) {
|
||||
azimuth = acos((cosZenith * sin(latitude) - sin(decl)) / (sinZenith * cos(latitude)));
|
||||
azimuth = hourAngle < 0 ? -azimuth : azimuth;
|
||||
}
|
||||
return {cosZenith, zenith: acos(cosZenith), azimuth};
|
||||
}
|
||||
|
||||
// Cosine of the angle of incidence on a surface of tilt `tilt` and D&B azimuth `surfaceAzimuth`.
|
||||
export function cosIncidence(zenith, sunAzimuth, tilt, surfaceAzimuth) {
|
||||
return cos(zenith) * cos(tilt) + sin(zenith) * sin(tilt) * cos(sunAzimuth - surfaceAzimuth);
|
||||
}
|
||||
|
||||
// Compass bearing (0 = north) to the D&B azimuth convention (0 = south, east negative).
|
||||
export function compassToSolarAzimuth(compass) {
|
||||
let a = ((compass % 360) + 360) % 360 - 180;
|
||||
return a === -180 ? 180 : a;
|
||||
}
|
||||
|
||||
// Default panel azimuth: face the equator.
|
||||
export function defaultAzimuth(latitude) {
|
||||
return latitude < 0 ? 0 : 180;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Monthly irradiation to sub-daily steps
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Monthly average diffuse fraction Hd/H from the clearness index (Erbs, Klein & Duffie 1982).
|
||||
// The correlation was fit for 0.3 <= KT <= 0.8, so KT is clamped to that range.
|
||||
export function erbsMonthlyDiffuseFraction(kt, sunsetAngle) {
|
||||
const k = clamp(kt, 0.3, 0.8);
|
||||
if (sunsetAngle <= 81.4) {
|
||||
return 1.391 - 3.560 * k + 4.189 * k * k - 2.137 * k * k * k;
|
||||
}
|
||||
return 1.311 - 3.022 * k + 3.427 * k * k - 1.821 * k * k * k;
|
||||
}
|
||||
|
||||
// Ratio of hourly to daily total irradiation at hour angle `w` (Collares-Pereira & Rabl).
|
||||
export function totalHourlyRatio(w, sunsetAngle) {
|
||||
if (Math.abs(w) >= sunsetAngle) {
|
||||
return 0;
|
||||
}
|
||||
const a = 0.409 + 0.5016 * sin(sunsetAngle - 60);
|
||||
const b = 0.6609 - 0.4767 * sin(sunsetAngle - 60);
|
||||
return diffuseHourlyRatio(w, sunsetAngle) * (a + b * cos(w));
|
||||
}
|
||||
|
||||
// Ratio of hourly to daily diffuse irradiation at hour angle `w` (Liu & Jordan).
|
||||
export function diffuseHourlyRatio(w, sunsetAngle) {
|
||||
if (Math.abs(w) >= sunsetAngle) {
|
||||
return 0;
|
||||
}
|
||||
const denominator = sin(sunsetAngle) - (Math.PI * sunsetAngle / 180) * cos(sunsetAngle);
|
||||
return (Math.PI / 24) * (cos(w) - cos(sunsetAngle)) / denominator;
|
||||
}
|
||||
|
||||
// Number of evenly spaced steps between sunrise and sunset. Steps adapt to the day length, so a
|
||||
// short polar-edge day is still sampled.
|
||||
export const STEPS_PER_DAY = 48;
|
||||
|
||||
// The average day of a month as a list of steps, each with its hour angle, duration (hours), and
|
||||
// the horizontal irradiances (W/m²) for global, beam and diffuse. The two hourly ratios are
|
||||
// normalized over the day so the steps add back up to exactly the monthly GHI and diffuse given.
|
||||
export function averageDaySteps(latitude, monthIndex, ghi, diffuse = null) {
|
||||
const n = KLEIN_DAYS[monthIndex];
|
||||
const decl = declination(n);
|
||||
const ws = sunsetHourAngle(latitude, decl);
|
||||
const h0 = extraterrestrialDaily(latitude, n);
|
||||
if (!(ghi > 0) || !(ws > 0) || !(h0 > 0)) {
|
||||
return {decl, sunsetAngle: ws, kt: null, diffuseFraction: null, steps: []};
|
||||
}
|
||||
|
||||
const kt = ghi / h0;
|
||||
const hd = diffuse != null && diffuse >= 0
|
||||
? Math.min(diffuse, ghi)
|
||||
: ghi * erbsMonthlyDiffuseFraction(kt, ws);
|
||||
|
||||
const dw = 2 * ws / STEPS_PER_DAY;
|
||||
const dt = dw / 15;
|
||||
const raw = [];
|
||||
let totalSum = 0, diffuseSum = 0;
|
||||
for (let i = 0; i < STEPS_PER_DAY; i++) {
|
||||
const w = -ws + (i + 0.5) * dw;
|
||||
const rt = totalHourlyRatio(w, ws);
|
||||
const rd = diffuseHourlyRatio(w, ws);
|
||||
raw.push({w, rt, rd});
|
||||
totalSum += rt * dt;
|
||||
diffuseSum += rd * dt;
|
||||
}
|
||||
|
||||
const steps = raw.map(({w, rt, rd}) => {
|
||||
// kWh/m² per hour is numerically kW/m², so x1000 gives the step's mean irradiance in W/m².
|
||||
const global = totalSum > 0 ? (rt / totalSum) * ghi * 1000 : 0;
|
||||
const diff = Math.min(global, diffuseSum > 0 ? (rd / diffuseSum) * hd * 1000 : 0);
|
||||
return {hourAngle: w, hours: dt, global, diffuse: diff, beam: global - diff};
|
||||
});
|
||||
return {decl, sunsetAngle: ws, kt, diffuseFraction: hd / ghi, steps};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Panel orientation and plane-of-array irradiance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const TRACKING = {
|
||||
fixed: {label: 'Fixed'},
|
||||
single: {label: 'Single-axis (N–S axis)'},
|
||||
dual: {label: 'Dual-axis'},
|
||||
};
|
||||
|
||||
// Single-axis trackers are limited to this rotation either side of flat.
|
||||
export const SINGLE_AXIS_LIMIT = 60;
|
||||
|
||||
// Panel tilt and D&B azimuth for a step. `tilt`/`azimuth` are only used for fixed arrays.
|
||||
export function panelOrientation(tracking, sun, tilt, azimuth) {
|
||||
if (tracking === 'dual') {
|
||||
return {tilt: sun.zenith, azimuth: sun.azimuth};
|
||||
}
|
||||
if (tracking === 'single') {
|
||||
// Horizontal north-south axis rotating east-west (Marion & Dobos ideal rotation).
|
||||
const rotation = clamp(
|
||||
Math.atan(Math.tan(sun.zenith * RAD) * Math.sin(sun.azimuth * RAD)) / RAD,
|
||||
-SINGLE_AXIS_LIMIT, SINGLE_AXIS_LIMIT);
|
||||
return {tilt: Math.abs(rotation), azimuth: rotation >= 0 ? 90 : -90};
|
||||
}
|
||||
return {tilt, azimuth};
|
||||
}
|
||||
|
||||
// Plane-of-array irradiance for one step with the HDKR (Hay-Davies-Klucher-Reindl) sky model.
|
||||
// Returns the beam (direct plus circumsolar), sky diffuse and ground-reflected parts in W/m², and
|
||||
// the angle of incidence.
|
||||
export function poaIrradiance({global, beam, diffuse}, sun, orientation, albedo, dayOfYear) {
|
||||
const {tilt, azimuth} = orientation;
|
||||
const cosTheta = cosIncidence(sun.zenith, sun.azimuth, tilt, azimuth);
|
||||
|
||||
// Beam normal irradiance, capped at the extraterrestrial value; near sunrise the division by a
|
||||
// tiny cos(zenith) would otherwise blow up.
|
||||
const extraNormal = SOLAR_CONSTANT * eccentricity(dayOfYear);
|
||||
const cosZ = Math.max(sun.cosZenith, 0.01745);
|
||||
const dni = Math.min(beam / cosZ, extraNormal);
|
||||
const rb = Math.max(0, cosTheta) / cosZ;
|
||||
|
||||
const anisotropy = clamp(dni / extraNormal, 0, 1);
|
||||
const horizonFactor = global > 0 ? Math.sqrt(beam / global) : 0;
|
||||
const beamPoa = dni * Math.max(0, cosTheta) + diffuse * anisotropy * rb;
|
||||
const skyPoa = diffuse * (1 - anisotropy) * ((1 + cos(tilt)) / 2)
|
||||
* (1 + horizonFactor * Math.pow(sin(tilt / 2), 3));
|
||||
const groundPoa = global * albedo * (1 - cos(tilt)) / 2;
|
||||
return {beam: beamPoa, sky: skyPoa, ground: groundPoa, total: beamPoa + skyPoa + groundPoa, cosTheta};
|
||||
}
|
||||
|
||||
// ASHRAE incidence angle modifier for the glass cover; applied to the beam part.
|
||||
export function ashraeIam(cosTheta, b0 = 0.05) {
|
||||
if (cosTheta <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return clamp(1 - b0 * (1 / cosTheta - 1), 0, 1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Temperature, DC power, losses, inverter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Sandia (SAPM) cell temperature coefficients by mounting.
|
||||
export const MOUNTINGS = {
|
||||
open: {label: 'Open rack (ground or pole)', a: -3.56, b: -0.075, deltaT: 3},
|
||||
roof: {label: 'Close roof mount', a: -2.98, b: -0.0471, deltaT: 1},
|
||||
flush: {label: 'Insulated back / flush', a: -2.81, b: -0.0455, deltaT: 0},
|
||||
};
|
||||
|
||||
// PVWatts v8 temperature coefficients of power, %/°C.
|
||||
export const MODULE_TYPES = {
|
||||
standard: {label: 'Standard (crystalline)', gamma: -0.37},
|
||||
premium: {label: 'Premium (high efficiency)', gamma: -0.35},
|
||||
thinFilm: {label: 'Thin film', gamma: -0.32},
|
||||
};
|
||||
|
||||
export const DEFAULT_WIND_SPEED = 1; // m/s; deliberately low, so cell temperatures are not optimistic.
|
||||
|
||||
// SAPM cell temperature, °C.
|
||||
export function cellTemperature(poa, ambient, mounting = 'open', windSpeed = DEFAULT_WIND_SPEED) {
|
||||
const m = MOUNTINGS[mounting] || MOUNTINGS.open;
|
||||
const moduleTemp = poa * Math.exp(m.a + m.b * windSpeed) + ambient;
|
||||
return moduleTemp + (poa / 1000) * m.deltaT;
|
||||
}
|
||||
|
||||
// Daily air temperature swing, used when only a monthly mean is known: the mean +/- this amount.
|
||||
export const DEFAULT_DAILY_SWING = 5;
|
||||
|
||||
// Air temperature at an hour angle, peaking mid-afternoon (3 pm).
|
||||
export function ambientTemperature(mean, hourAngle, swing = DEFAULT_DAILY_SWING) {
|
||||
return mean + swing * cos(hourAngle - 45);
|
||||
}
|
||||
|
||||
// DC power, kW, for an array of `kwp` kilowatts (STC rating) and temperature coefficient `gamma` (%/°C).
|
||||
export function dcPower(effectiveIrradiance, cellTemp, kwp, gamma) {
|
||||
return Math.max(0, kwp * (effectiveIrradiance / 1000) * (1 + (gamma / 100) * (cellTemp - 25)));
|
||||
}
|
||||
|
||||
// PVWatts system loss categories and their default percentages.
|
||||
export const LOSS_CATEGORIES = [
|
||||
{key: 'soiling', label: 'Soiling', default: 2},
|
||||
{key: 'shading', label: 'Shading', default: 3},
|
||||
{key: 'snow', label: 'Snow', default: 0},
|
||||
{key: 'mismatch', label: 'Mismatch', default: 2},
|
||||
{key: 'wiring', label: 'Wiring', default: 2},
|
||||
{key: 'connections', label: 'Connections', default: 0.5},
|
||||
{key: 'lid', label: 'Light-induced degradation', default: 1.5},
|
||||
{key: 'nameplate', label: 'Nameplate rating', default: 1},
|
||||
{key: 'age', label: 'Age', default: 0},
|
||||
{key: 'availability', label: 'Availability', default: 3},
|
||||
];
|
||||
|
||||
export const DEFAULT_LOSSES = LOSS_CATEGORIES.map(c => c.default);
|
||||
|
||||
// Combine loss percentages multiplicatively, as PVWatts does. Returns a percentage.
|
||||
export function totalLoss(percentages) {
|
||||
const kept = percentages.reduce((acc, p) => acc * (1 - clamp(Number(p) || 0, 0, 100) / 100), 1);
|
||||
return 100 * (1 - kept);
|
||||
}
|
||||
|
||||
export const INVERTER_NOMINAL_EFFICIENCY = 96;
|
||||
const INVERTER_REFERENCE_EFFICIENCY = 0.9637;
|
||||
|
||||
// AC power, kW, from the PVWatts inverter model: part-load efficiency curve, clipped at the AC rating.
|
||||
export function inverterOutput(pdc, acRating, nominalEfficiency = INVERTER_NOMINAL_EFFICIENCY) {
|
||||
if (!(pdc > 0) || !(acRating > 0)) {
|
||||
return 0;
|
||||
}
|
||||
const etaNom = nominalEfficiency / 100;
|
||||
const pdc0 = acRating / etaNom;
|
||||
const zeta = pdc / pdc0;
|
||||
const eta = (etaNom / INVERTER_REFERENCE_EFFICIENCY) * (-0.0162 * zeta - 0.0059 / zeta + 0.9858);
|
||||
return Math.min(Math.max(0, eta * pdc), acRating);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The whole model
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const DEFAULT_ALBEDO = 0.2;
|
||||
|
||||
/**
|
||||
* Estimate monthly and annual production.
|
||||
*
|
||||
* @param {Object} system
|
||||
* @param {number} system.latitude degrees, north positive
|
||||
* @param {number[]} system.ghi 12 monthly average GHI values, kWh/m²/day
|
||||
* @param {number[]} [system.diffuse] 12 monthly diffuse values, kWh/m²/day; estimated when absent
|
||||
* @param {number[]} [system.temperature] 12 monthly mean air temperatures, °C; 20 when absent
|
||||
* @param {number[]} [system.albedo] ground reflectance, one value or 12 monthly values
|
||||
* @param {number} system.kwp array STC rating, kW
|
||||
* @param {number} [system.tilt] degrees from horizontal (fixed arrays)
|
||||
* @param {number} [system.azimuth] compass degrees (fixed arrays)
|
||||
* @param {string} [system.tracking] 'fixed' | 'single' | 'dual'
|
||||
* @param {string} [system.mounting] key of MOUNTINGS
|
||||
* @param {number} [system.gamma] temperature coefficient of power, %/°C
|
||||
* @param {number[]} [system.losses] system loss percentages, see LOSS_CATEGORIES
|
||||
* @param {Object} [system.inverter] {acRating (kW), efficiency (%)}; null for DC output
|
||||
*/
|
||||
export function estimateProduction({
|
||||
latitude,
|
||||
ghi,
|
||||
diffuse = null,
|
||||
temperature = null,
|
||||
albedo = DEFAULT_ALBEDO,
|
||||
kwp,
|
||||
tilt = 0,
|
||||
azimuth = defaultAzimuth(latitude),
|
||||
tracking = 'fixed',
|
||||
mounting = 'open',
|
||||
gamma = MODULE_TYPES.standard.gamma,
|
||||
losses = DEFAULT_LOSSES,
|
||||
inverter = null,
|
||||
}) {
|
||||
const lossFactor = 1 - totalLoss(losses) / 100;
|
||||
const surfaceAzimuth = compassToSolarAzimuth(azimuth);
|
||||
|
||||
const months = MONTH_NAMES.map((name, m) => {
|
||||
const n = KLEIN_DAYS[m];
|
||||
const givenAlbedo = Array.isArray(albedo) ? albedo[m] : albedo;
|
||||
const monthAlbedo = Number.isFinite(givenAlbedo) ? givenAlbedo : DEFAULT_ALBEDO;
|
||||
const meanTemp = temperature && Number.isFinite(temperature[m]) ? temperature[m] : 20;
|
||||
const day = averageDaySteps(latitude, m, ghi[m], diffuse ? diffuse[m] : null);
|
||||
|
||||
let poaDaily = 0, energyDaily = 0;
|
||||
for (const step of day.steps) {
|
||||
const sun = sunPosition(latitude, day.decl, step.hourAngle);
|
||||
if (sun.cosZenith <= 0) {
|
||||
continue;
|
||||
}
|
||||
const orientation = panelOrientation(tracking, sun, tilt, surfaceAzimuth);
|
||||
const poa = poaIrradiance(step, sun, orientation, monthAlbedo, n);
|
||||
const effective = poa.beam * ashraeIam(poa.cosTheta) + poa.sky + poa.ground;
|
||||
const tc = cellTemperature(poa.total, ambientTemperature(meanTemp, step.hourAngle), mounting);
|
||||
let power = dcPower(effective, tc, kwp, gamma) * lossFactor;
|
||||
if (inverter) {
|
||||
power = inverterOutput(power, inverter.acRating, inverter.efficiency);
|
||||
}
|
||||
poaDaily += poa.total * step.hours / 1000;
|
||||
energyDaily += power * step.hours;
|
||||
}
|
||||
|
||||
return {
|
||||
month: name,
|
||||
ghi: ghi[m],
|
||||
kt: day.kt,
|
||||
diffuseFraction: day.diffuseFraction,
|
||||
// Plane-of-array kWh/m²/day: the honest "peak sun hours" for this panel.
|
||||
poa: poaDaily,
|
||||
energyDaily,
|
||||
energy: energyDaily * DAYS_IN_MONTH[m],
|
||||
dayLength: dayLength(latitude, day.decl),
|
||||
noonElevation: noonElevation(latitude, day.decl),
|
||||
};
|
||||
});
|
||||
|
||||
const annual = months.reduce((acc, m) => acc + m.energy, 0);
|
||||
const worst = months.reduce((a, b) => (b.energyDaily < a.energyDaily ? b : a));
|
||||
const best = months.reduce((a, b) => (b.energyDaily > a.energyDaily ? b : a));
|
||||
return {
|
||||
months,
|
||||
annual,
|
||||
specificYield: kwp > 0 ? annual / kwp : null,
|
||||
capacityFactor: kwp > 0 ? annual / (kwp * 8760) : null,
|
||||
worst,
|
||||
best,
|
||||
totalLoss: totalLoss(losses),
|
||||
};
|
||||
}
|
||||
|
||||
// Sweep fixed tilts 0-90° at the given azimuth and return the best one. `goal` is 'annual' (most
|
||||
// energy in a year) or 'winter' (most energy in the worst month, which sizes off-grid systems).
|
||||
export function optimalTilt(system, goal = 'annual') {
|
||||
let best = {tilt: 0, value: -1};
|
||||
for (let tilt = 0; tilt <= 90; tilt++) {
|
||||
const result = estimateProduction({...system, tilt, tracking: 'fixed'});
|
||||
const value = goal === 'winter' ? result.worst.energyDaily : result.annual;
|
||||
if (value > best.value + 1e-9) {
|
||||
best = {tilt, value};
|
||||
}
|
||||
}
|
||||
return best.tilt;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Parse latitude/longitude text: decimal ("40.015", "-105.27") or degrees-minutes-seconds
|
||||
// ("40°0'54\"N", "105 16 12 W"). Returns null when unparseable or out of range.
|
||||
export function parseCoordinate(text, kind = 'lat') {
|
||||
if (text === null || text === undefined) {
|
||||
return null;
|
||||
}
|
||||
const s = `${text}`.trim().toUpperCase();
|
||||
if (!s) {
|
||||
return null;
|
||||
}
|
||||
const hemisphere = s.match(/[NSEW]/);
|
||||
const parts = s.replace(/[NSEW]/g, ' ').match(/-?\d+(\.\d+)?/g);
|
||||
if (!parts || parts.length > 3) {
|
||||
return null;
|
||||
}
|
||||
const [d, m = 0, sec = 0] = parts.map(Number);
|
||||
if (m < 0 || m >= 60 || sec < 0 || sec >= 60) {
|
||||
return null;
|
||||
}
|
||||
let value = Math.abs(d) + m / 60 + sec / 3600;
|
||||
if (d < 0 || s.trim().startsWith('-')) {
|
||||
value = -value;
|
||||
}
|
||||
if (hemisphere) {
|
||||
const h = hemisphere[0];
|
||||
if ((kind === 'lat' && (h === 'E' || h === 'W')) || (kind === 'lon' && (h === 'N' || h === 'S'))) {
|
||||
return null;
|
||||
}
|
||||
if (h === 'S' || h === 'W') {
|
||||
value = -Math.abs(value);
|
||||
}
|
||||
}
|
||||
const limit = kind === 'lat' ? 90 : 180;
|
||||
return Math.abs(value) <= limit ? value : null;
|
||||
}
|
||||
|
||||
// Parse a comma separated list of 12 monthly numbers. Blank entries become null.
|
||||
export function parseMonthly(text) {
|
||||
const values = (text || '').split(',').map(v => (v.trim() === '' ? null : Number(v)));
|
||||
return MONTH_NAMES.map((_, i) => (Number.isFinite(values[i]) ? values[i] : null));
|
||||
}
|
||||
|
||||
export function formatMonthly(values) {
|
||||
return values.map(v => (v === null || v === undefined || v === '' ? '' : `${v}`)).join(',');
|
||||
}
|
||||
|
||||
// Link to the Solar calculator for a location, e.g. from the map. 4 decimals (about 11 m) is far finer
|
||||
// than the 1° data and keeps the shared URL short. MapLibre reports longitudes past ±180° once the map
|
||||
// has been panned around the world, so those are wrapped back.
|
||||
export function solarCalculatorPath(latitude, longitude) {
|
||||
const lon = longitude >= -180 && longitude <= 180 ? longitude : ((longitude + 180) % 360 + 360) % 360 - 180;
|
||||
const round = value => Number(value.toFixed(4));
|
||||
return `/more/calculators?calc=solar&lat=${round(latitude)}&lon=${round(lon)}`;
|
||||
}
|
||||
|
|
@ -1,324 +0,0 @@
|
|||
import {
|
||||
ambientTemperature,
|
||||
ashraeIam,
|
||||
averageDaySteps,
|
||||
cellTemperature,
|
||||
compassToSolarAzimuth,
|
||||
DAYS_IN_MONTH,
|
||||
dcPower,
|
||||
declination,
|
||||
DEFAULT_LOSSES,
|
||||
defaultAzimuth,
|
||||
diffuseHourlyRatio,
|
||||
erbsMonthlyDiffuseFraction,
|
||||
estimateProduction,
|
||||
extraterrestrialDaily,
|
||||
formatMonthly,
|
||||
inverterOutput,
|
||||
KLEIN_DAYS,
|
||||
optimalTilt,
|
||||
parseCoordinate,
|
||||
parseMonthly,
|
||||
poaIrradiance,
|
||||
solarCalculatorPath,
|
||||
sunPosition,
|
||||
sunsetHourAngle,
|
||||
totalHourlyRatio,
|
||||
totalLoss,
|
||||
} from "./solar";
|
||||
|
||||
// A sunny 40°N GHI profile, kWh/m²/day, with a clearness index of about 0.6 year-round (Denver-like).
|
||||
const GHI_40N = [2.6, 3.5, 4.7, 5.8, 6.5, 7.2, 7.0, 6.2, 5.2, 4.0, 2.9, 2.3];
|
||||
const TEMP_40N = [0, 2, 6, 10, 15, 21, 24, 23, 18, 11, 4, 0];
|
||||
|
||||
const system = (overrides = {}) => ({
|
||||
latitude: 40,
|
||||
ghi: GHI_40N,
|
||||
temperature: TEMP_40N,
|
||||
kwp: 1,
|
||||
tilt: 40,
|
||||
azimuth: 180,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const integrate = (ratio, ws) => {
|
||||
// Midpoint rule over the day, ratios are per hour.
|
||||
const n = 2000, dw = 2 * ws / n;
|
||||
let sum = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
sum += ratio(-ws + (i + 0.5) * dw, ws) * dw / 15;
|
||||
}
|
||||
return sum;
|
||||
};
|
||||
|
||||
describe('sun geometry', () => {
|
||||
test('declination matches Cooper at the solstices and equinox', () => {
|
||||
expect(declination(172)).toBeCloseTo(23.45, 1); // June 21
|
||||
expect(declination(355)).toBeCloseTo(-23.45, 1); // December 21
|
||||
expect(Math.abs(declination(81))).toBeLessThan(0.5); // March 22
|
||||
// Klein's February day (D&B Table 1.6.1 lists -13.0).
|
||||
expect(declination(KLEIN_DAYS[1])).toBeCloseTo(-12.95, 1);
|
||||
});
|
||||
|
||||
test('sunset hour angle is 90° at the equator and handles polar day and night', () => {
|
||||
expect(sunsetHourAngle(0, 20)).toBeCloseTo(90, 5);
|
||||
expect(sunsetHourAngle(40, 0)).toBeCloseTo(90, 5);
|
||||
expect(sunsetHourAngle(80, 20)).toBe(180);
|
||||
expect(sunsetHourAngle(80, -20)).toBe(0);
|
||||
});
|
||||
|
||||
test('extraterrestrial daily irradiation matches Duffie & Beckman Example 1.10.1', () => {
|
||||
// 43°N on April 15: H0 = 33.8 MJ/m² = 9.39 kWh/m².
|
||||
expect(extraterrestrialDaily(43, 105)).toBeCloseTo(33.8 / 3.6, 1);
|
||||
expect(extraterrestrialDaily(80, 355)).toBe(0); // polar night
|
||||
});
|
||||
|
||||
test('sun is due south at solar noon and east in the morning (northern hemisphere)', () => {
|
||||
const noon = sunPosition(40, 0, 0);
|
||||
expect(noon.azimuth).toBeCloseTo(0, 5);
|
||||
expect(noon.zenith).toBeCloseTo(40, 5);
|
||||
expect(sunPosition(40, 0, -45).azimuth).toBeLessThan(0);
|
||||
expect(sunPosition(40, 0, 45).azimuth).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('compass azimuth converts to the Duffie & Beckman convention', () => {
|
||||
expect(compassToSolarAzimuth(180)).toBe(0);
|
||||
expect(compassToSolarAzimuth(90)).toBe(-90);
|
||||
expect(compassToSolarAzimuth(270)).toBe(90);
|
||||
expect(compassToSolarAzimuth(0)).toBe(180);
|
||||
expect(compassToSolarAzimuth(360)).toBe(180);
|
||||
expect(defaultAzimuth(40)).toBe(180);
|
||||
expect(defaultAzimuth(-33)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('monthly irradiation to steps', () => {
|
||||
test('Erbs monthly diffuse fraction follows both branches and clamps KT', () => {
|
||||
expect(erbsMonthlyDiffuseFraction(0.5, 80)).toBeCloseTo(0.3911, 4);
|
||||
expect(erbsMonthlyDiffuseFraction(0.5, 100)).toBeCloseTo(1.311 - 1.511 + 0.85675 - 0.227625, 4);
|
||||
expect(erbsMonthlyDiffuseFraction(0.95, 80)).toBeCloseTo(erbsMonthlyDiffuseFraction(0.8, 80), 10);
|
||||
expect(erbsMonthlyDiffuseFraction(0.1, 80)).toBeCloseTo(erbsMonthlyDiffuseFraction(0.3, 80), 10);
|
||||
});
|
||||
|
||||
test('Liu & Jordan diffuse ratio integrates to one day', () => {
|
||||
for (const ws of [60, 90, 120, 180]) {
|
||||
expect(integrate(diffuseHourlyRatio, ws)).toBeCloseTo(1, 3);
|
||||
}
|
||||
});
|
||||
|
||||
test('Collares-Pereira & Rabl total ratio integrates to about one day', () => {
|
||||
// The correlation is empirical, so it only approximately conserves the daily total; the
|
||||
// model normalizes it, but a large error here would mean a transcription mistake.
|
||||
for (const ws of [60, 90, 120]) {
|
||||
expect(Math.abs(integrate(totalHourlyRatio, ws) - 1)).toBeLessThan(0.03);
|
||||
}
|
||||
});
|
||||
|
||||
test('average day steps add back up to the monthly GHI and diffuse', () => {
|
||||
const day = averageDaySteps(40, 5, 7.2);
|
||||
const sum = key => day.steps.reduce((acc, s) => acc + s[key] * s.hours, 0) / 1000;
|
||||
expect(sum('global')).toBeCloseTo(7.2, 6);
|
||||
expect(sum('diffuse')).toBeCloseTo(7.2 * day.diffuseFraction, 6);
|
||||
expect(day.steps.every(s => s.beam >= 0 && s.diffuse >= 0)).toBe(true);
|
||||
// Peak at solar noon.
|
||||
const peak = day.steps.reduce((a, b) => (b.global > a.global ? b : a));
|
||||
expect(Math.abs(peak.hourAngle)).toBeLessThan(5);
|
||||
});
|
||||
|
||||
test('a supplied diffuse value is used instead of the Erbs estimate', () => {
|
||||
expect(averageDaySteps(40, 5, 7.2, 1.8).diffuseFraction).toBeCloseTo(0.25, 6);
|
||||
});
|
||||
|
||||
test('polar night and missing GHI produce no steps', () => {
|
||||
expect(averageDaySteps(80, 11, 0.1).steps).toEqual([]);
|
||||
expect(averageDaySteps(40, 5, null).steps).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('plane of array', () => {
|
||||
test('a flat panel receives exactly the horizontal irradiance', () => {
|
||||
const day = averageDaySteps(40, 5, 7.2);
|
||||
const decl = day.decl;
|
||||
for (const step of day.steps) {
|
||||
const sun = sunPosition(40, decl, step.hourAngle);
|
||||
if (sun.zenith > 85) continue; // the DNI cap applies right at sunrise
|
||||
const poa = poaIrradiance(step, sun, {tilt: 0, azimuth: 0}, 0.2, KLEIN_DAYS[5]);
|
||||
expect(poa.total).toBeCloseTo(step.global, 6);
|
||||
}
|
||||
});
|
||||
|
||||
test('a vertical panel sees half the ground', () => {
|
||||
const step = {global: 500, beam: 0, diffuse: 500};
|
||||
const sun = sunPosition(40, 0, 0);
|
||||
const poa = poaIrradiance(step, sun, {tilt: 90, azimuth: 0}, 0.2, 81);
|
||||
expect(poa.ground).toBeCloseTo(500 * 0.2 / 2, 6);
|
||||
});
|
||||
|
||||
test('ASHRAE incidence angle modifier', () => {
|
||||
expect(ashraeIam(1)).toBe(1);
|
||||
expect(ashraeIam(0.5)).toBeCloseTo(0.95, 6); // 60°
|
||||
expect(ashraeIam(0)).toBe(0);
|
||||
expect(ashraeIam(0.01)).toBe(0); // never negative
|
||||
});
|
||||
});
|
||||
|
||||
describe('temperature, power, losses, inverter', () => {
|
||||
test('SAPM cell temperature, open rack glass/polymer', () => {
|
||||
// 1000 * exp(-3.56 - 0.075) + 25 = 51.38, plus deltaT 3.
|
||||
expect(cellTemperature(1000, 25, 'open', 1)).toBeCloseTo(54.38, 2);
|
||||
expect(cellTemperature(1000, 25, 'flush', 1)).toBeGreaterThan(cellTemperature(1000, 25, 'open', 1));
|
||||
expect(cellTemperature(0, 10, 'open')).toBe(10);
|
||||
});
|
||||
|
||||
test('air temperature peaks at 3 pm and averages to the mean', () => {
|
||||
expect(ambientTemperature(20, 45)).toBeCloseTo(25, 6);
|
||||
expect(ambientTemperature(20, -135)).toBeCloseTo(15, 6);
|
||||
});
|
||||
|
||||
test('DC power scales with irradiance and loses power when hot', () => {
|
||||
expect(dcPower(1000, 25, 1, -0.37)).toBeCloseTo(1, 6);
|
||||
expect(dcPower(500, 25, 2, -0.37)).toBeCloseTo(1, 6);
|
||||
expect(dcPower(1000, 35, 1, -0.37)).toBeCloseTo(0.963, 6);
|
||||
expect(dcPower(0, 25, 1, -0.37)).toBe(0);
|
||||
});
|
||||
|
||||
test('PVWatts default losses combine to 14.08%', () => {
|
||||
expect(totalLoss(DEFAULT_LOSSES)).toBeCloseTo(14.08, 2);
|
||||
expect(totalLoss([])).toBe(0);
|
||||
expect(totalLoss([50, 50])).toBeCloseTo(75, 6);
|
||||
});
|
||||
|
||||
test('PVWatts inverter is at nominal efficiency at full load and clips', () => {
|
||||
// At zeta = 1 the curve evaluates to exactly the nominal efficiency.
|
||||
const ac = 1, pdc0 = ac / 0.96;
|
||||
expect(inverterOutput(pdc0, ac) / pdc0).toBeCloseTo(0.96, 4);
|
||||
expect(inverterOutput(5, ac)).toBe(ac);
|
||||
expect(inverterOutput(0, ac)).toBe(0);
|
||||
// Part load is less efficient.
|
||||
expect(inverterOutput(pdc0 * 0.05, ac) / (pdc0 * 0.05)).toBeLessThan(0.96);
|
||||
});
|
||||
});
|
||||
|
||||
describe('estimateProduction', () => {
|
||||
test('a 1 kWp south-facing array at 40°N gives a plausible annual yield', () => {
|
||||
const result = estimateProduction(system());
|
||||
// A clear 40°N site yields roughly 1500-1900 kWh/kWp DC after 14% losses.
|
||||
expect(result.annual).toBeGreaterThan(1500);
|
||||
expect(result.annual).toBeLessThan(1900);
|
||||
// Performance ratio (energy over plane-of-array sun-hours): losses plus temperature.
|
||||
const poaAnnual = result.months.reduce((acc, m, i) => acc + m.poa * DAYS_IN_MONTH[i], 0);
|
||||
expect(result.annual / poaAnnual).toBeGreaterThan(0.75);
|
||||
expect(result.annual / poaAnnual).toBeLessThan(0.88);
|
||||
expect(result.specificYield).toBeCloseTo(result.annual, 6);
|
||||
expect(result.months).toHaveLength(12);
|
||||
expect(result.totalLoss).toBeCloseTo(14.08, 2);
|
||||
});
|
||||
|
||||
test('tilting toward the equator gains in winter and loses in summer', () => {
|
||||
const flat = estimateProduction(system({tilt: 0}));
|
||||
const tilted = estimateProduction(system({tilt: 40}));
|
||||
expect(tilted.months[11].poa).toBeGreaterThan(flat.months[11].poa * 1.5);
|
||||
expect(tilted.months[5].poa).toBeLessThan(flat.months[5].poa);
|
||||
expect(flat.months[5].poa).toBeCloseTo(GHI_40N[5], 1);
|
||||
});
|
||||
|
||||
test('tracking beats fixed, and dual-axis beats single-axis', () => {
|
||||
const fixed = estimateProduction(system()).annual;
|
||||
const single = estimateProduction(system({tracking: 'single'})).annual;
|
||||
const dual = estimateProduction(system({tracking: 'dual'})).annual;
|
||||
expect(single).toBeGreaterThan(fixed);
|
||||
expect(dual).toBeGreaterThan(single);
|
||||
// Dual-axis is commonly 30-45% over a well-tilted fixed array.
|
||||
expect(dual / fixed).toBeLessThan(1.6);
|
||||
});
|
||||
|
||||
test('facing away from the equator produces less', () => {
|
||||
const south = estimateProduction(system()).annual;
|
||||
const north = estimateProduction(system({azimuth: 0})).annual;
|
||||
expect(north).toBeLessThan(south * 0.7);
|
||||
});
|
||||
|
||||
test('the southern hemisphere mirrors the northern', () => {
|
||||
// Same GHI shifted 6 months, panel facing north.
|
||||
const shifted = [...GHI_40N.slice(6), ...GHI_40N.slice(0, 6)];
|
||||
const north = estimateProduction(system({temperature: null}));
|
||||
const south = estimateProduction(system({latitude: -40, ghi: shifted, azimuth: 0, temperature: null}));
|
||||
expect(south.annual / north.annual).toBeGreaterThan(0.97);
|
||||
expect(south.annual / north.annual).toBeLessThan(1.03);
|
||||
});
|
||||
|
||||
test('hot weather reduces output', () => {
|
||||
const cool = estimateProduction(system({temperature: Array(12).fill(0)})).annual;
|
||||
const hot = estimateProduction(system({temperature: Array(12).fill(35)})).annual;
|
||||
expect(hot).toBeLessThan(cool * 0.95);
|
||||
});
|
||||
|
||||
test('the inverter costs a few percent and clips an oversized array', () => {
|
||||
const dc = estimateProduction(system()).annual;
|
||||
const ac = estimateProduction(system({inverter: {acRating: 1, efficiency: 96}})).annual;
|
||||
expect(ac / dc).toBeGreaterThan(0.93);
|
||||
expect(ac / dc).toBeLessThan(0.97);
|
||||
const clipped = estimateProduction(system({inverter: {acRating: 0.3, efficiency: 96}})).annual;
|
||||
expect(clipped).toBeLessThan(ac * 0.7);
|
||||
});
|
||||
|
||||
test('polar night months produce nothing without errors', () => {
|
||||
const result = estimateProduction(system({latitude: 75, tilt: 75}));
|
||||
expect(result.months[11].energy).toBe(0);
|
||||
expect(Number.isFinite(result.annual)).toBe(true);
|
||||
});
|
||||
|
||||
test('missing months count as zero', () => {
|
||||
const ghi = [...GHI_40N];
|
||||
ghi[0] = null;
|
||||
expect(estimateProduction(system({ghi})).months[0].energy).toBe(0);
|
||||
});
|
||||
|
||||
test('the best annual tilt is near the latitude; the best winter tilt is steeper', () => {
|
||||
const annual = optimalTilt(system());
|
||||
const winter = optimalTilt(system(), 'winter');
|
||||
expect(annual).toBeGreaterThanOrEqual(30);
|
||||
expect(annual).toBeLessThanOrEqual(44);
|
||||
expect(winter).toBeGreaterThan(annual + 8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('input parsing', () => {
|
||||
test('decimal and DMS coordinates', () => {
|
||||
expect(parseCoordinate('40.015')).toBeCloseTo(40.015, 6);
|
||||
expect(parseCoordinate('-105.27', 'lon')).toBeCloseTo(-105.27, 6);
|
||||
expect(parseCoordinate('40°0\'54"N')).toBeCloseTo(40.015, 6);
|
||||
expect(parseCoordinate('105 16 12 W', 'lon')).toBeCloseTo(-105.27, 6);
|
||||
expect(parseCoordinate('33.9 S')).toBeCloseTo(-33.9, 6);
|
||||
expect(parseCoordinate('-0.5')).toBeCloseTo(-0.5, 6);
|
||||
});
|
||||
|
||||
test('invalid coordinates are null', () => {
|
||||
expect(parseCoordinate('')).toBeNull();
|
||||
expect(parseCoordinate(null)).toBeNull();
|
||||
expect(parseCoordinate('91')).toBeNull();
|
||||
expect(parseCoordinate('181', 'lon')).toBeNull();
|
||||
expect(parseCoordinate('40 E')).toBeNull();
|
||||
expect(parseCoordinate('abc')).toBeNull();
|
||||
expect(parseCoordinate('40 75 0')).toBeNull();
|
||||
});
|
||||
|
||||
test('monthly lists round-trip and tolerate blanks', () => {
|
||||
expect(parseMonthly('1,2,,4')).toEqual([1, 2, null, 4, null, null, null, null, null, null, null, null]);
|
||||
expect(parseMonthly(null)).toHaveLength(12);
|
||||
expect(parseMonthly(formatMonthly(GHI_40N))).toEqual(GHI_40N);
|
||||
});
|
||||
});
|
||||
|
||||
describe('solarCalculatorPath', () => {
|
||||
test('links to the calculator with the location rounded to 4 decimals', () => {
|
||||
expect(solarCalculatorPath(40.0153812, -105.2705456))
|
||||
.toBe('/more/calculators?calc=solar&lat=40.0154&lon=-105.2705');
|
||||
});
|
||||
|
||||
test('wraps a longitude from a map that has been panned around the world', () => {
|
||||
expect(solarCalculatorPath(10, 190)).toBe('/more/calculators?calc=solar&lat=10&lon=-170');
|
||||
expect(solarCalculatorPath(10, -540.5)).toBe('/more/calculators?calc=solar&lat=10&lon=179.5');
|
||||
expect(solarCalculatorPath(10, 180)).toBe('/more/calculators?calc=solar&lat=10&lon=180');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
import {cellIndex, COLUMNS, PARAMETERS, ROWS} from "./solarData";
|
||||
|
||||
// Build a climatology file for tests, the same format scripts/build_solar_climatology.py writes.
|
||||
// `valueAt(key, month, latitude, longitude)` returns the value for a cell center, or null for no data.
|
||||
export function encodeClimatology(valueAt) {
|
||||
const header = 8;
|
||||
const bytes = new Uint8Array(header + PARAMETERS.length * 12 * ROWS * COLUMNS);
|
||||
bytes.set([...'WSOL'].map(c => c.charCodeAt(0)), 0);
|
||||
bytes[4] = 1;
|
||||
bytes[5] = PARAMETERS.length;
|
||||
PARAMETERS.forEach(({key, offset, step}, p) => {
|
||||
for (let month = 0; month < 12; month++) {
|
||||
for (let row = 0; row < ROWS; row++) {
|
||||
for (let column = 0; column < COLUMNS; column++) {
|
||||
const value = valueAt(key, month, row - 89.5, column - 179.5);
|
||||
bytes[header + cellIndex(p, month, row, column)] = value === null
|
||||
? 255
|
||||
: Math.max(0, Math.min(254, Math.round((value + offset) / step)));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
// A plain climatology: the same sunny 40°N profile everywhere.
|
||||
export const SUNNY_GHI = [2.6, 3.5, 4.7, 5.8, 6.5, 7.2, 7.0, 6.2, 5.2, 4.0, 2.9, 2.3];
|
||||
export const MILD_TEMPERATURE = [0, 2, 6, 10, 15, 21, 24, 23, 18, 11, 4, 0];
|
||||
|
||||
// Encoding the 3 MB file is slow, so it is built once and shared; nothing writes to it.
|
||||
let uniform = null;
|
||||
export const uniformClimatology = () => {
|
||||
if (!uniform) {
|
||||
uniform = encodeClimatology((key, month) => ({
|
||||
ghi: SUNNY_GHI[month],
|
||||
diffuse: SUNNY_GHI[month] * 0.3,
|
||||
albedo: 0.2,
|
||||
temperature: MILD_TEMPERATURE[month],
|
||||
})[key]);
|
||||
}
|
||||
return uniform;
|
||||
};
|
||||
|
||||
// Make `fetch` return a climatology file.
|
||||
export function mockClimatologyFetch(buffer = uniformClimatology()) {
|
||||
global.fetch = jest.fn(() => Promise.resolve({ok: true, arrayBuffer: () => Promise.resolve(buffer)}));
|
||||
return global.fetch;
|
||||
}
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
import React from "react";
|
||||
|
||||
// Offline solar climatology for the Solar calculator: NASA POWER's 2001-2020 monthly averages on a
|
||||
// 1° grid, built by scripts/build_solar_climatology.py. The format is described in that script and
|
||||
// must match PARAMETERS below. The file is only fetched when the calculator opens.
|
||||
|
||||
export const CLIMATOLOGY_URL = `${process.env.PUBLIC_URL || ''}/data/solar-climatology.bin`;
|
||||
|
||||
// Attribution, as NASA asks (https://power.larc.nasa.gov/docs/referencing/) and CC BY 4.0 requires. The
|
||||
// build script writes the same text, with the API version and download date, to
|
||||
// public/data/solar-climatology.LICENSE.txt; a test keeps the two in step.
|
||||
export const CLIMATOLOGY_ACKNOWLEDGMENT = 'The data was obtained from National Aeronautics and Space '
|
||||
+ 'Administration (NASA) Langley Research Center\'s Prediction Of Worldwide Energy Resources (POWER) project '
|
||||
+ 'funded through the NASA Earth Science Division.';
|
||||
export const CLIMATOLOGY_DATA_REFERENCE = 'The data was obtained from the POWER Project\'s POWER Climatology API '
|
||||
+ 'v2.10.0 version on 2026/10/04.';
|
||||
export const CLIMATOLOGY_LICENSE = 'Creative Commons Attribution 4.0 International (CC BY 4.0), '
|
||||
+ 'https://creativecommons.org/licenses/by/4.0/';
|
||||
export const CLIMATOLOGY_CHANGES = 'WROLPi modified the data: values are rounded to fit one byte each '
|
||||
+ '(0.04 kWh/m²/day for irradiance, 0.004 for albedo, 0.5 °C for temperature), temperatures are averaged from '
|
||||
+ 'NASA\'s 0.5° x 0.625° grid into 1° cells, and the calculator interpolates between cells.';
|
||||
|
||||
const MAGIC = 'WSOL';
|
||||
const VERSION = 1;
|
||||
const HEADER_BYTES = 8;
|
||||
export const ROWS = 180;
|
||||
export const COLUMNS = 360;
|
||||
const MISSING = 255;
|
||||
|
||||
// Stored as round((value + offset) / step), in this order.
|
||||
export const PARAMETERS = [
|
||||
{key: 'ghi', offset: 0, step: 0.04},
|
||||
{key: 'diffuse', offset: 0, step: 0.04},
|
||||
{key: 'albedo', offset: 0, step: 0.004},
|
||||
{key: 'temperature', offset: 70, step: 0.5},
|
||||
];
|
||||
|
||||
const PARAMETER_BYTES = 12 * ROWS * COLUMNS;
|
||||
|
||||
// Validate a downloaded file and return its value grid.
|
||||
export function decodeClimatology(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const magic = String.fromCharCode(...bytes.slice(0, 4));
|
||||
if (magic !== MAGIC) {
|
||||
throw new Error('Not a solar climatology file');
|
||||
}
|
||||
if (bytes[4] !== VERSION || bytes[5] !== PARAMETERS.length) {
|
||||
throw new Error(`Unsupported solar climatology version ${bytes[4]}`);
|
||||
}
|
||||
if (bytes.length !== HEADER_BYTES + PARAMETERS.length * PARAMETER_BYTES) {
|
||||
throw new Error('Solar climatology file is truncated');
|
||||
}
|
||||
return bytes.subarray(HEADER_BYTES);
|
||||
}
|
||||
|
||||
// Index of a stored value.
|
||||
export const cellIndex = (parameter, month, row, column) =>
|
||||
parameter * PARAMETER_BYTES + (month * ROWS + row) * COLUMNS + column;
|
||||
|
||||
/**
|
||||
* Monthly averages at a location, bilinearly interpolated between the four surrounding cell centers.
|
||||
* Cells with no data are skipped. Longitude wraps across the antimeridian; latitude is clamped at the
|
||||
* outermost cell centers.
|
||||
*
|
||||
* @returns {{ghi: number[], diffuse: number[], albedo: number[], temperature: number[]}} 12 values each,
|
||||
* null for a month with no data
|
||||
*/
|
||||
export function lookupClimatology(grid, latitude, longitude) {
|
||||
const y = Math.min(ROWS - 1, Math.max(0, latitude + 90 - 0.5));
|
||||
const row0 = Math.floor(y);
|
||||
const row1 = Math.min(ROWS - 1, row0 + 1);
|
||||
const fy = y - row0;
|
||||
|
||||
const x = longitude + 180 - 0.5;
|
||||
const xFloor = Math.floor(x);
|
||||
const fx = x - xFloor;
|
||||
const column0 = ((xFloor % COLUMNS) + COLUMNS) % COLUMNS;
|
||||
const column1 = (column0 + 1) % COLUMNS;
|
||||
|
||||
const corners = [
|
||||
[row0, column0, (1 - fy) * (1 - fx)],
|
||||
[row0, column1, (1 - fy) * fx],
|
||||
[row1, column0, fy * (1 - fx)],
|
||||
[row1, column1, fy * fx],
|
||||
];
|
||||
|
||||
const result = {};
|
||||
PARAMETERS.forEach(({key, offset, step}, p) => {
|
||||
result[key] = Array.from({length: 12}, (_, month) => {
|
||||
let sum = 0, weight = 0;
|
||||
for (const [row, column, w] of corners) {
|
||||
const raw = grid[cellIndex(p, month, row, column)];
|
||||
if (raw !== MISSING && w > 0) {
|
||||
sum += (raw * step - offset) * w;
|
||||
weight += w;
|
||||
}
|
||||
}
|
||||
return weight > 0 ? sum / weight : null;
|
||||
});
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
let climatologyPromise = null;
|
||||
|
||||
// Fetch and decode the file once per page load. A failed fetch is not cached, so it can be retried.
|
||||
export function loadClimatology() {
|
||||
if (!climatologyPromise) {
|
||||
climatologyPromise = fetch(CLIMATOLOGY_URL)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Could not load solar data (${response.status})`);
|
||||
}
|
||||
return response.arrayBuffer();
|
||||
})
|
||||
.then(decodeClimatology)
|
||||
.catch(error => {
|
||||
climatologyPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return climatologyPromise;
|
||||
}
|
||||
|
||||
// Drop the loaded file, so the next load fetches again. Tests use this to simulate a failed download.
|
||||
export function forgetClimatology() {
|
||||
climatologyPromise = null;
|
||||
}
|
||||
|
||||
// The decoded grid, or null while loading. `error` is set when the file could not be loaded.
|
||||
export function useClimatology() {
|
||||
const [grid, setGrid] = React.useState(null);
|
||||
const [error, setError] = React.useState(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
loadClimatology()
|
||||
.then(g => !cancelled && setGrid(g))
|
||||
.catch(e => !cancelled && setError(e));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {grid, error, loading: !grid && !error};
|
||||
}
|
||||
|
|
@ -1,167 +0,0 @@
|
|||
import fs from "fs";
|
||||
import path from "path";
|
||||
import {
|
||||
CLIMATOLOGY_ACKNOWLEDGMENT,
|
||||
CLIMATOLOGY_CHANGES,
|
||||
CLIMATOLOGY_DATA_REFERENCE,
|
||||
CLIMATOLOGY_LICENSE,
|
||||
decodeClimatology,
|
||||
lookupClimatology,
|
||||
} from "./solarData";
|
||||
import {estimateProduction} from "./solar";
|
||||
import {encodeClimatology, uniformClimatology} from "./solarData.fixtures";
|
||||
|
||||
describe('decodeClimatology', () => {
|
||||
test('accepts a valid file', () => {
|
||||
expect(decodeClimatology(uniformClimatology()).length).toBe(4 * 12 * 180 * 360);
|
||||
});
|
||||
|
||||
test('rejects a file that is not ours, a newer version, or truncated', () => {
|
||||
const good = new Uint8Array(uniformClimatology());
|
||||
const wrongMagic = good.slice();
|
||||
wrongMagic[0] = 'X'.charCodeAt(0);
|
||||
expect(() => decodeClimatology(wrongMagic.buffer)).toThrow('Not a solar climatology file');
|
||||
|
||||
const newer = good.slice();
|
||||
newer[4] = 2;
|
||||
expect(() => decodeClimatology(newer.buffer)).toThrow('Unsupported');
|
||||
|
||||
expect(() => decodeClimatology(good.slice(0, 1000).buffer)).toThrow('truncated');
|
||||
});
|
||||
});
|
||||
|
||||
describe('lookupClimatology', () => {
|
||||
// A field that varies with longitude, so interpolation is visible: GHI = 3 + lon/100.
|
||||
const sloped = decodeClimatology(encodeClimatology((key, month, lat, lon) => ({
|
||||
ghi: 3 + lon / 100,
|
||||
diffuse: 1,
|
||||
albedo: lat > 60 ? 0.8 : 0.2,
|
||||
temperature: -10 + month,
|
||||
})[key]));
|
||||
|
||||
test('returns a cell center value exactly', () => {
|
||||
const values = lookupClimatology(sloped, 40.5, -104.5);
|
||||
expect(values.ghi[0]).toBeCloseTo(3 - 1.045, 1);
|
||||
expect(values.temperature.map(v => Math.round(v))).toEqual([-10, -9, -8, -7, -6, -5, -4, -3, -2, -1, 0, 1]);
|
||||
expect(values.diffuse[6]).toBeCloseTo(1, 1);
|
||||
});
|
||||
|
||||
test('interpolates between cell centers', () => {
|
||||
// Halfway between the cells centered on -105.5 and -104.5.
|
||||
const mid = lookupClimatology(sloped, 40.5, -105).ghi[0];
|
||||
const west = lookupClimatology(sloped, 40.5, -105.5).ghi[0];
|
||||
const east = lookupClimatology(sloped, 40.5, -104.5).ghi[0];
|
||||
expect(mid).toBeCloseTo((west + east) / 2, 6);
|
||||
// Interpolates in latitude too: halfway between albedo 0.2 and 0.8 cells.
|
||||
expect(lookupClimatology(sloped, 60, 0).albedo[0]).toBeCloseTo(0.5, 2);
|
||||
});
|
||||
|
||||
test('wraps across the antimeridian', () => {
|
||||
// Between the cells centered on 179.5 (ghi 4.795) and -179.5 (ghi 1.205).
|
||||
expect(lookupClimatology(sloped, 0.5, 180).ghi[0]).toBeCloseTo((4.795 + 1.205) / 2, 1);
|
||||
expect(lookupClimatology(sloped, 0.5, -180).ghi[0]).toBeCloseTo((4.795 + 1.205) / 2, 1);
|
||||
});
|
||||
|
||||
test('clamps at the poles', () => {
|
||||
expect(lookupClimatology(sloped, 90, 0.5).ghi[0]).toBeCloseTo(3.005, 1);
|
||||
expect(lookupClimatology(sloped, -90, 0.5).ghi[0]).toBeCloseTo(3.005, 1);
|
||||
});
|
||||
|
||||
test('skips cells with no data and returns null when all are missing', () => {
|
||||
const holes = decodeClimatology(encodeClimatology((key, month, lat, lon) =>
|
||||
(lon > 0 ? null : {ghi: 5, diffuse: 1, albedo: 0.2, temperature: 10}[key])));
|
||||
// Straddling lon 0: only the western cells have data.
|
||||
expect(lookupClimatology(holes, 10.5, 0).ghi[0]).toBeCloseTo(5, 1);
|
||||
expect(lookupClimatology(holes, 10.5, 90).ghi[0]).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// The real data file, when it has been built. The expected values come from NASA POWER's point API
|
||||
// (2001-2020 climatology), so these confirm the build script and decoder agree end to end.
|
||||
const REAL_FILE = path.join(__dirname, '../../../public/data/solar-climatology.bin');
|
||||
const describeReal = fs.existsSync(REAL_FILE) ? describe : describe.skip;
|
||||
|
||||
describeReal('the built climatology file', () => {
|
||||
let grid;
|
||||
beforeAll(() => {
|
||||
grid = decodeClimatology(fs.readFileSync(REAL_FILE).buffer.slice(0));
|
||||
});
|
||||
|
||||
// NASA POWER's monthly GHI for two cells, copied from its API (2001-2020 climatology). A cell
|
||||
// center is returned exactly, give or take the file's 0.04 kWh/m²/day step.
|
||||
const expectCell = (actual, expected) => actual.forEach((v, i) => expect(Math.abs(v - expected[i]))
|
||||
.toBeLessThanOrEqual(0.021));
|
||||
|
||||
test('matches NASA POWER at the cell near Denver', () => {
|
||||
const values = lookupClimatology(grid, 40.5, -104.5);
|
||||
expectCell(values.ghi,
|
||||
[2.4089, 3.2969, 4.6241, 5.6225, 6.3463, 7.2588, 7.0428, 6.2326, 5.2351, 3.7486, 2.6717, 2.0921]);
|
||||
expect(values.diffuse.every((v, i) => v > 0 && v < values.ghi[i])).toBe(true);
|
||||
expect(values.temperature[6]).toBeGreaterThan(values.temperature[0] + 15);
|
||||
});
|
||||
|
||||
test('matches NASA POWER at the cell near Sydney, with the seasons reversed', () => {
|
||||
const values = lookupClimatology(grid, -33.5, 151.5);
|
||||
expectCell(values.ghi,
|
||||
[6.4908, 5.6611, 4.8137, 3.8191, 2.9803, 2.357, 2.772, 3.6725, 4.8242, 5.6698, 6.2074, 6.5594]);
|
||||
});
|
||||
|
||||
// PVWatts v8 results for a 1 kW standard-module, open-rack, fixed array with the default 14.08%
|
||||
// losses, DC output in kWh, fetched from developer.nlr.gov on 2026-10-04. PVWatts uses NREL's NSRDB
|
||||
// typical-year weather, not NASA POWER, so the two agree only within the data sets' differences.
|
||||
const PVWATTS = {
|
||||
Phoenix: {
|
||||
lat: 33.45, lon: -112.07, tilt: 33, azimuth: 180,
|
||||
monthly: [139.7, 139.1, 166.3, 175.4, 175.8, 165.2, 152.4, 155.2, 157.6, 157.8, 144.8, 140.3],
|
||||
},
|
||||
Seattle: {
|
||||
lat: 47.61, lon: -122.33, tilt: 48, azimuth: 180,
|
||||
monthly: [51.9, 67.3, 89.6, 115.5, 127.1, 116.7, 130.3, 141.0, 111.9, 82.0, 48.6, 42.8],
|
||||
},
|
||||
Anchorage: {
|
||||
lat: 61.22, lon: -149.90, tilt: 61, azimuth: 180,
|
||||
monthly: [19.6, 45.0, 119.2, 117.4, 124.2, 118.0, 109.4, 109.4, 87.8, 52.0, 23.3, 12.6],
|
||||
},
|
||||
};
|
||||
|
||||
test.each(Object.entries(PVWATTS))('agrees with PVWatts in %s', (name, site) => {
|
||||
const sunlight = lookupClimatology(grid, site.lat, site.lon);
|
||||
const result = estimateProduction({
|
||||
latitude: site.lat, kwp: 1, tilt: site.tilt, azimuth: site.azimuth, ...sunlight,
|
||||
});
|
||||
const annual = site.monthly.reduce((a, b) => a + b, 0);
|
||||
expect(result.annual / annual).toBeGreaterThan(0.9);
|
||||
expect(result.annual / annual).toBeLessThan(1.1);
|
||||
// Dark months are small and the two data sets disagree most there; check the months that matter.
|
||||
const meanMonth = annual / 12;
|
||||
result.months.forEach((m, i) => {
|
||||
if (site.monthly[i] >= 0.6 * meanMonth) {
|
||||
expect(m.energy / site.monthly[i]).toBeGreaterThan(0.75);
|
||||
expect(m.energy / site.monthly[i]).toBeLessThan(1.25);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('has polar night', () => {
|
||||
expect(lookupClimatology(grid, 80, 15).ghi[11]).toBeLessThan(0.05);
|
||||
});
|
||||
});
|
||||
|
||||
describe('attribution', () => {
|
||||
// The build script writes this notice beside the data; the page shows the same text.
|
||||
const notice = fs.readFileSync(path.join(__dirname, '../../../public/data/solar-climatology.LICENSE.txt'), 'utf8');
|
||||
|
||||
test('the notice and the page credit NASA identically', () => {
|
||||
expect(notice).toContain(CLIMATOLOGY_ACKNOWLEDGMENT);
|
||||
expect(notice).toContain(CLIMATOLOGY_DATA_REFERENCE);
|
||||
expect(notice).toContain(CLIMATOLOGY_LICENSE);
|
||||
expect(notice).toContain(CLIMATOLOGY_CHANGES);
|
||||
});
|
||||
|
||||
test('the acknowledgment is NASA POWER\'s requested wording', () => {
|
||||
// https://power.larc.nasa.gov/docs/referencing/
|
||||
expect(CLIMATOLOGY_ACKNOWLEDGMENT).toBe('The data was obtained from National Aeronautics and Space '
|
||||
+ 'Administration (NASA) Langley Research Center\'s Prediction Of Worldwide Energy Resources (POWER) '
|
||||
+ 'project funded through the NASA Earth Science Division.');
|
||||
});
|
||||
});
|
||||
|
|
@ -77,9 +77,7 @@ function renderRow(collection, columns, routes, SingleTag, onRowClick) {
|
|||
}
|
||||
|
||||
// Format the value based on column configuration
|
||||
if (col.render) {
|
||||
value = col.render(collection);
|
||||
} else if (col.format === 'bytes') {
|
||||
if (col.format === 'bytes') {
|
||||
value = humanFileSize(value);
|
||||
} else if (col.format === 'frequency') {
|
||||
value = formatFrequency(value);
|
||||
|
|
@ -136,9 +134,7 @@ function MobileCollectionRow({collection, mobileColumns, routes}) {
|
|||
.filter(col => col.type !== 'actions' && col.key !== primaryColumn.key && col.key !== 'tag_name')
|
||||
.map(col => {
|
||||
let value = collection[col.key];
|
||||
if (col.render) {
|
||||
value = col.render(collection);
|
||||
} else if (col.format === 'bytes') {
|
||||
if (col.format === 'bytes') {
|
||||
value = humanFileSize(value);
|
||||
} else if (col.format === 'frequency') {
|
||||
value = formatFrequency(value);
|
||||
|
|
@ -161,8 +157,7 @@ function MobileCollectionRow({collection, mobileColumns, routes}) {
|
|||
* Reusable table component for displaying collections (Domains, Channels, etc).
|
||||
*
|
||||
* @param {Array} collections - Array of collection objects
|
||||
* @param {Array} columns - Column configurations for the table. A column's optional `render(collection)` displays
|
||||
* the cell; the column still sorts by `collection[key]`.
|
||||
* @param {Array} columns - Column configurations for the table
|
||||
* @param {Object} routes - Routes configuration for navigation (edit, search, etc.)
|
||||
* @param {String} searchStr - Search filter string (managed by parent)
|
||||
* @param {Function} onRowClick - Optional callback when a row is clicked
|
||||
|
|
|
|||
|
|
@ -68,27 +68,6 @@ const DOMAIN_ROUTES = {
|
|||
describe('CollectionTable', () => {
|
||||
const mockCollections = createMockDomains(3);
|
||||
|
||||
describe('Column render', () => {
|
||||
it('displays what a column renders, in the desktop and mobile rows', () => {
|
||||
const columns = [
|
||||
{key: 'name', label: 'Name', render: (c) => <em data-testid='rendered-name'>{c.name}!</em>},
|
||||
{key: 'branch', label: 'Branch', render: (c) => c.branch || c.default_branch},
|
||||
];
|
||||
render(<CollectionTable
|
||||
collections={[{id: 1, name: 'kiwix-tools', branch: null, default_branch: 'main'}]}
|
||||
columns={columns}
|
||||
routes={{search: '/repos/:id', id_field: 'id'}}
|
||||
/>);
|
||||
|
||||
// Desktop: the rendered name is still the link to the collection.
|
||||
expect(screen.getByTestId('rendered-name')).toHaveTextContent('kiwix-tools!');
|
||||
expect(screen.getByTestId('rendered-name').closest('a')).toHaveAttribute('href', '/repos/1');
|
||||
expect(screen.getByText('main')).toBeInTheDocument();
|
||||
// Mobile lists the other columns by label.
|
||||
expect(screen.getByText('Branch: main')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Loading and Error States', () => {
|
||||
it('renders loading placeholder when collections is null', () => {
|
||||
const {container} = render(
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ function ItemNameList({items}) {
|
|||
* `onChanged` refreshes the page's inventory state after a successful action.
|
||||
*/
|
||||
export function InventoryImportModal({open, onClose, slug, name, onChanged}) {
|
||||
const [dates, setDates] = useState(null); // null while loading, undefined if the fetch failed
|
||||
const [dates, setDates] = useState(null); // null while loading
|
||||
const [reimporting, setReimporting] = useState(false);
|
||||
const [selected, setSelected] = useState(null); // {date, mode, preview} confirmation view
|
||||
const [previewing, setPreviewing] = useState(null); // `${date}:${mode}` currently loading a preview
|
||||
|
|
@ -41,19 +41,11 @@ export function InventoryImportModal({open, onClose, slug, name, onChanged}) {
|
|||
setReimporting(false);
|
||||
setApplying(false);
|
||||
setPreviewing(null);
|
||||
getInventoryBackups(slug)
|
||||
.then(result => {
|
||||
if (!cancelled) {
|
||||
// The api helper returns undefined on a non-OK response.
|
||||
setDates(result === undefined ? undefined : result || []);
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(e);
|
||||
if (!cancelled) {
|
||||
setDates(undefined);
|
||||
}
|
||||
});
|
||||
getInventoryBackups(slug).then(result => {
|
||||
if (!cancelled) {
|
||||
setDates(result || []);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
|
|
@ -161,8 +153,6 @@ export function InventoryImportModal({open, onClose, slug, name, onChanged}) {
|
|||
</p>
|
||||
{dates === null
|
||||
? <Loading>Loading backups…</Loading>
|
||||
: dates === undefined
|
||||
? <p style={{color: 'var(--danger)'}}>Could not fetch backups for this inventory.</p>
|
||||
: dates.length === 0
|
||||
? <p>No backups yet. A backup is saved automatically whenever this inventory changes.</p>
|
||||
: <div style={{maxHeight: '320px', overflowY: 'auto'}}>
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue