feat: harden stale and failure handling (#20)Unverified
* feat: harden stale and failure handling Bound useful stale departures independently from the fresh cache TTL, expose credential-free quota counts, and lock failure, key lifecycle, rapid navigation, and soak behavior with tests. * ci: deploy configuration site to Bunny * ci: deploy preview tags to Bunny * ci: parallelize Bunny uploads * feat: build configuration page without catalog * ci: deploy configuration page independently * fix: prioritize rail stops in catalog search * fix: ignore French join words in catalog search * fix: accept decoded Android settings responses * fix: show planned downtime for empty departures * style: strengthen departure detail hierarchy * fix: use supported countdown font * fix: add watch app menu icon * style: increase launcher icon contrast * style: preserve launcher icon contrast * style: use dark launcher icon * fix: fill overview rows at list boundaries * style: balance departure countdown spacing * ci: deploy releases from version tags * ci: publish GitHub releases after deployment * ci: categorize generated release notes * fix: detect blocking traffic disruptions * fix: treat omitted traffic lines as normal * fix: label bus services by actual terminus * chore: stop tracking agent instructions
835b18b parent: 2799b0f added
.github/workflows/deploy-bunny.yml +220 -0 | new file mode 100644 | ||
| @@ -0,0 +1,220 @@ | ||
| 1 | +name: Deploy configuration site to Bunny | |
| 2 | + | |
| 3 | +on: | |
| 4 | + push: | |
| 5 | + tags: ["v*", "pre-v*"] | |
| 6 | + workflow_dispatch: | |
| 7 | + inputs: | |
| 8 | + refresh_catalog: | |
| 9 | + description: Rebuild and publish the IDFM catalog | |
| 10 | + required: true | |
| 11 | + default: false | |
| 12 | + type: boolean | |
| 13 | + | |
| 14 | +permissions: | |
| 15 | + contents: read | |
| 16 | + | |
| 17 | +concurrency: | |
| 18 | + group: deploy-bunny-production | |
| 19 | + # Never interrupt a production upload between its immutable files and the | |
| 20 | + # manifest publication. A later deployment waits for the current one. | |
| 21 | + cancel-in-progress: false | |
| 22 | + | |
| 23 | +jobs: | |
| 24 | + test: | |
| 25 | + name: Run quality checks | |
| 26 | + runs-on: ubuntu-latest | |
| 27 | + steps: | |
| 28 | + - uses: actions/checkout@v4 | |
| 29 | + | |
| 30 | + - uses: actions/setup-node@v4 | |
| 31 | + with: | |
| 32 | + node-version-file: .node-version | |
| 33 | + cache: npm | |
| 34 | + | |
| 35 | + - run: npm ci | |
| 36 | + | |
| 37 | + - name: Validate release tag | |
| 38 | + if: ${{ github.event_name == 'push' }} | |
| 39 | + env: | |
| 40 | + RELEASE_TAG: ${{ github.ref_name }} | |
| 41 | + run: | | |
| 42 | + production='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' | |
| 43 | + preview='^pre-v(0|[1-9][0-9]*)-(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' | |
| 44 | + [[ "$RELEASE_TAG" =~ $production || "$RELEASE_TAG" =~ $preview ]] | |
| 45 | + | |
| 46 | + - run: npm test | |
| 47 | + | |
| 48 | + deploy: | |
| 49 | + name: Build and deploy configuration site | |
| 50 | + needs: test | |
| 51 | + if: ${{ startsWith(github.ref_name, 'v') || (github.event_name == 'workflow_dispatch' && inputs.refresh_catalog) }} | |
| 52 | + runs-on: ubuntu-latest | |
| 53 | + environment: production | |
| 54 | + steps: | |
| 55 | + - uses: actions/checkout@v4 | |
| 56 | + | |
| 57 | + - uses: actions/setup-node@v4 | |
| 58 | + with: | |
| 59 | + node-version-file: .node-version | |
| 60 | + cache: npm | |
| 61 | + | |
| 62 | + - run: npm ci | |
| 63 | + | |
| 64 | + - name: Refresh production catalog | |
| 65 | + if: ${{ github.event_name == 'workflow_dispatch' && inputs.refresh_catalog }} | |
| 66 | + env: | |
| 67 | + IDFM_DATASET_TOKEN: ${{ secrets.IDFM_DATASET_TOKEN }} | |
| 68 | + run: npm run catalog:refresh | |
| 69 | + | |
| 70 | + - name: Build static configuration site | |
| 71 | + if: ${{ github.event_name == 'workflow_dispatch' && inputs.refresh_catalog }} | |
| 72 | + run: npm run build:config-site | |
| 73 | + | |
| 74 | + - name: Build configuration page | |
| 75 | + if: ${{ github.event_name != 'workflow_dispatch' || inputs.refresh_catalog != true }} | |
| 76 | + run: npm run build:config-page | |
| 77 | + | |
| 78 | + - name: Upload static site to Bunny Storage | |
| 79 | + shell: bash | |
| 80 | + env: | |
| 81 | + BUNNY_STORAGE_ENDPOINT: ${{ vars.BUNNY_STORAGE_ENDPOINT }} | |
| 82 | + BUNNY_STORAGE_PASSWORD: ${{ secrets.BUNNY_STORAGE_PASSWORD }} | |
| 83 | + run: | | |
| 84 | + set -euo pipefail | |
| 85 | + | |
| 86 | + test -n "$BUNNY_STORAGE_ENDPOINT" | |
| 87 | + test -n "$BUNNY_STORAGE_PASSWORD" | |
| 88 | + endpoint="${BUNNY_STORAGE_ENDPOINT%/}" | |
| 89 | + case "$endpoint" in | |
| 90 | + https://*) ;; | |
| 91 | + *) echo "BUNNY_STORAGE_ENDPOINT must use HTTPS" >&2; exit 1 ;; | |
| 92 | + esac | |
| 93 | + | |
| 94 | + upload_file() { | |
| 95 | + local file="$1" | |
| 96 | + local relative_path="${file#var/config-site/}" | |
| 97 | + local mime_type | |
| 98 | + case "$relative_path" in | |
| 99 | + *[!A-Za-z0-9._/-]* | /* | *../*) | |
| 100 | + echo "Refusing unsafe deployment path: $relative_path" >&2 | |
| 101 | + exit 1 | |
| 102 | + ;; | |
| 103 | + esac | |
| 104 | + mime_type="$(file --brief --mime-type "$file")" | |
| 105 | + echo "Uploading $relative_path" | |
| 106 | + curl --fail --silent --show-error \ | |
| 107 | + --retry 3 --retry-all-errors \ | |
| 108 | + --request PUT \ | |
| 109 | + --header "AccessKey: $BUNNY_STORAGE_PASSWORD" \ | |
| 110 | + --header "Content-Type: $mime_type" \ | |
| 111 | + --upload-file "$file" \ | |
| 112 | + "$endpoint/$relative_path" | |
| 113 | + } | |
| 114 | + | |
| 115 | + export endpoint BUNNY_STORAGE_PASSWORD | |
| 116 | + export -f upload_file | |
| 117 | + find var/config-site -type f ! -path 'var/config-site/catalog/manifest.json' -print0 \ | |
| 118 | + | xargs -0 --no-run-if-empty --max-args=1 --max-procs=32 \ | |
| 119 | + bash -c 'upload_file "$1"' _ | |
| 120 | + | |
| 121 | + # Publish the catalog pointer only after every immutable catalog file. | |
| 122 | + if [[ -f var/config-site/catalog/manifest.json ]]; then | |
| 123 | + upload_file var/config-site/catalog/manifest.json | |
| 124 | + fi | |
| 125 | + | |
| 126 | + - name: Purge Bunny CDN cache | |
| 127 | + env: | |
| 128 | + BUNNY_API_KEY: ${{ secrets.BUNNY_API_KEY }} | |
| 129 | + BUNNY_PULL_ZONE_ID: ${{ vars.BUNNY_PULL_ZONE_ID }} | |
| 130 | + run: | | |
| 131 | + set -euo pipefail | |
| 132 | + test -n "$BUNNY_API_KEY" | |
| 133 | + test -n "$BUNNY_PULL_ZONE_ID" | |
| 134 | + curl --fail --silent --show-error \ | |
| 135 | + --retry 3 --retry-all-errors \ | |
| 136 | + --request POST \ | |
| 137 | + --header "AccessKey: $BUNNY_API_KEY" \ | |
| 138 | + "https://api.bunny.net/pullzone/$BUNNY_PULL_ZONE_ID/purgeCache" | |
| 139 | + | |
| 140 | + publish: | |
| 141 | + name: Publish GitHub release notes | |
| 142 | + needs: [test, deploy] | |
| 143 | + if: ${{ startsWith(github.ref_name, 'v') }} | |
| 144 | + runs-on: ubuntu-latest | |
| 145 | + permissions: | |
| 146 | + contents: write | |
| 147 | + steps: | |
| 148 | + - name: Check out tagged commit | |
| 149 | + uses: actions/checkout@v4 | |
| 150 | + with: | |
| 151 | + ref: ${{ github.sha }} | |
| 152 | + fetch-depth: 0 | |
| 153 | + persist-credentials: false | |
| 154 | + | |
| 155 | + - name: Generate categorized release notes | |
| 156 | + shell: bash | |
| 157 | + run: | | |
| 158 | + set -euo pipefail | |
| 159 | + previous_tag="$(git describe --tags --match 'v[0-9]*' --abbrev=0 "$GITHUB_SHA^" 2>/dev/null || true)" | |
| 160 | + | |
| 161 | + if [[ -n "$previous_tag" ]]; then | |
| 162 | + range="$previous_tag..$GITHUB_SHA" | |
| 163 | + changelog_url="https://github.com/$GITHUB_REPOSITORY/compare/$previous_tag...$GITHUB_REF_NAME" | |
| 164 | + else | |
| 165 | + range="$GITHUB_SHA" | |
| 166 | + changelog_url="https://github.com/$GITHUB_REPOSITORY/commits/$GITHUB_REF_NAME" | |
| 167 | + fi | |
| 168 | + | |
| 169 | + RANGE="$range" CHANGELOG_URL="$changelog_url" node --input-type=module <<'EOF' | |
| 170 | + import { execFileSync } from "node:child_process"; | |
| 171 | + import { writeFileSync } from "node:fs"; | |
| 172 | + | |
| 173 | + const groups = [ | |
| 174 | + { key: "feat", title: "Features", commits: [] }, | |
| 175 | + { key: "change", title: "Changes", commits: [] }, | |
| 176 | + { key: "fix", title: "Fixes", commits: [] }, | |
| 177 | + { key: "ci", title: "CI", commits: [] }, | |
| 178 | + ]; | |
| 179 | + const byKey = new Map(groups.map((group) => [group.key, group])); | |
| 180 | + const fields = execFileSync( | |
| 181 | + "git", | |
| 182 | + ["log", "--first-parent", "-z", "--format=%s%x00%h", process.env.RANGE], | |
| 183 | + { encoding: "utf8" }, | |
| 184 | + ).split("\0").filter(Boolean); | |
| 185 | + | |
| 186 | + for (let index = 0; index < fields.length; index += 2) { | |
| 187 | + const subject = fields[index].trim(); | |
| 188 | + const hash = fields[index + 1].trim(); | |
| 189 | + const type = subject.match(/^([a-z]+)(?:\([^)]*\))?!?:\s+/i)?.[1].toLowerCase(); | |
| 190 | + const key = type === "feat" | |
| 191 | + ? "feat" | |
| 192 | + : type === "fix" | |
| 193 | + ? "fix" | |
| 194 | + : type === "ci" | |
| 195 | + ? "ci" | |
| 196 | + : "change"; | |
| 197 | + | |
| 198 | + byKey.get(key).commits.push(`- ${subject} (\`${hash}\`)`); | |
| 199 | + } | |
| 200 | + | |
| 201 | + const notes = []; | |
| 202 | + for (const group of groups) { | |
| 203 | + if (group.commits.length === 0) continue; | |
| 204 | + notes.push(`## ${group.title}`, "", ...group.commits, ""); | |
| 205 | + } | |
| 206 | + notes.push(`**Full changelog**: ${process.env.CHANGELOG_URL}`, ""); | |
| 207 | + writeFileSync("release-notes.md", notes.join("\n")); | |
| 208 | + EOF | |
| 209 | + | |
| 210 | + - name: Create release with categorized notes | |
| 211 | + env: | |
| 212 | + GH_TOKEN: ${{ github.token }} | |
| 213 | + shell: bash | |
| 214 | + run: | | |
| 215 | + set -euo pipefail | |
| 216 | + gh release create "$GITHUB_REF_NAME" \ | |
| 217 | + --repo "$GITHUB_REPOSITORY" \ | |
| 218 | + --title "Lapin Futé $GITHUB_REF_NAME" \ | |
| 219 | + --notes-file release-notes.md \ | |
| 220 | + --verify-tag | |
| new file mode 100644 | |||
| @@ -0,0 +1,220 @@ | |||
| 1 | +name: Deploy configuration site to Bunny | ||
| 2 | + | ||
| 3 | +on: | ||
| 4 | + push: | ||
| 5 | + tags: ["v*", "pre-v*"] | ||
| 6 | + workflow_dispatch: | ||
| 7 | + inputs: | ||
| 8 | + refresh_catalog: | ||
| 9 | + description: Rebuild and publish the IDFM catalog | ||
| 10 | + required: true | ||
| 11 | + default: false | ||
| 12 | + type: boolean | ||
| 13 | + | ||
| 14 | +permissions: | ||
| 15 | + contents: read | ||
| 16 | + | ||
| 17 | +concurrency: | ||
| 18 | + group: deploy-bunny-production | ||
| 19 | + # Never interrupt a production upload between its immutable files and the | ||
| 20 | + # manifest publication. A later deployment waits for the current one. | ||
| 21 | + cancel-in-progress: false | ||
| 22 | + | ||
| 23 | +jobs: | ||
| 24 | + test: | ||
| 25 | + name: Run quality checks | ||
| 26 | + runs-on: ubuntu-latest | ||
| 27 | + steps: | ||
| 28 | + - uses: actions/checkout@v4 | ||
| 29 | + | ||
| 30 | + - uses: actions/setup-node@v4 | ||
| 31 | + with: | ||
| 32 | + node-version-file: .node-version | ||
| 33 | + cache: npm | ||
| 34 | + | ||
| 35 | + - run: npm ci | ||
| 36 | + | ||
| 37 | + - name: Validate release tag | ||
| 38 | + if: ${{ github.event_name == 'push' }} | ||
| 39 | + env: | ||
| 40 | + RELEASE_TAG: ${{ github.ref_name }} | ||
| 41 | + run: | | ||
| 42 | + production='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' | ||
| 43 | + preview='^pre-v(0|[1-9][0-9]*)-(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' | ||
| 44 | + [[ "$RELEASE_TAG" =~ $production || "$RELEASE_TAG" =~ $preview ]] | ||
| 45 | + | ||
| 46 | + - run: npm test | ||
| 47 | + | ||
| 48 | + deploy: | ||
| 49 | + name: Build and deploy configuration site | ||
| 50 | + needs: test | ||
| 51 | + if: ${{ startsWith(github.ref_name, 'v') || (github.event_name == 'workflow_dispatch' && inputs.refresh_catalog) }} | ||
| 52 | + runs-on: ubuntu-latest | ||
| 53 | + environment: production | ||
| 54 | + steps: | ||
| 55 | + - uses: actions/checkout@v4 | ||
| 56 | + | ||
| 57 | + - uses: actions/setup-node@v4 | ||
| 58 | + with: | ||
| 59 | + node-version-file: .node-version | ||
| 60 | + cache: npm | ||
| 61 | + | ||
| 62 | + - run: npm ci | ||
| 63 | + | ||
| 64 | + - name: Refresh production catalog | ||
| 65 | + if: ${{ github.event_name == 'workflow_dispatch' && inputs.refresh_catalog }} | ||
| 66 | + env: | ||
| 67 | + IDFM_DATASET_TOKEN: ${{ secrets.IDFM_DATASET_TOKEN }} | ||
| 68 | + run: npm run catalog:refresh | ||
| 69 | + | ||
| 70 | + - name: Build static configuration site | ||
| 71 | + if: ${{ github.event_name == 'workflow_dispatch' && inputs.refresh_catalog }} | ||
| 72 | + run: npm run build:config-site | ||
| 73 | + | ||
| 74 | + - name: Build configuration page | ||
| 75 | + if: ${{ github.event_name != 'workflow_dispatch' || inputs.refresh_catalog != true }} | ||
| 76 | + run: npm run build:config-page | ||
| 77 | + | ||
| 78 | + - name: Upload static site to Bunny Storage | ||
| 79 | + shell: bash | ||
| 80 | + env: | ||
| 81 | + BUNNY_STORAGE_ENDPOINT: ${{ vars.BUNNY_STORAGE_ENDPOINT }} | ||
| 82 | + BUNNY_STORAGE_PASSWORD: ${{ secrets.BUNNY_STORAGE_PASSWORD }} | ||
| 83 | + run: | | ||
| 84 | + set -euo pipefail | ||
| 85 | + | ||
| 86 | + test -n "$BUNNY_STORAGE_ENDPOINT" | ||
| 87 | + test -n "$BUNNY_STORAGE_PASSWORD" | ||
| 88 | + endpoint="${BUNNY_STORAGE_ENDPOINT%/}" | ||
| 89 | + case "$endpoint" in | ||
| 90 | + https://*) ;; | ||
| 91 | + *) echo "BUNNY_STORAGE_ENDPOINT must use HTTPS" >&2; exit 1 ;; | ||
| 92 | + esac | ||
| 93 | + | ||
| 94 | + upload_file() { | ||
| 95 | + local file="$1" | ||
| 96 | + local relative_path="${file#var/config-site/}" | ||
| 97 | + local mime_type | ||
| 98 | + case "$relative_path" in | ||
| 99 | + *[!A-Za-z0-9._/-]* | /* | *../*) | ||
| 100 | + echo "Refusing unsafe deployment path: $relative_path" >&2 | ||
| 101 | + exit 1 | ||
| 102 | + ;; | ||
| 103 | + esac | ||
| 104 | + mime_type="$(file --brief --mime-type "$file")" | ||
| 105 | + echo "Uploading $relative_path" | ||
| 106 | + curl --fail --silent --show-error \ | ||
| 107 | + --retry 3 --retry-all-errors \ | ||
| 108 | + --request PUT \ | ||
| 109 | + --header "AccessKey: $BUNNY_STORAGE_PASSWORD" \ | ||
| 110 | + --header "Content-Type: $mime_type" \ | ||
| 111 | + --upload-file "$file" \ | ||
| 112 | + "$endpoint/$relative_path" | ||
| 113 | + } | ||
| 114 | + | ||
| 115 | + export endpoint BUNNY_STORAGE_PASSWORD | ||
| 116 | + export -f upload_file | ||
| 117 | + find var/config-site -type f ! -path 'var/config-site/catalog/manifest.json' -print0 \ | ||
| 118 | + | xargs -0 --no-run-if-empty --max-args=1 --max-procs=32 \ | ||
| 119 | + bash -c 'upload_file "$1"' _ | ||
| 120 | + | ||
| 121 | + # Publish the catalog pointer only after every immutable catalog file. | ||
| 122 | + if [[ -f var/config-site/catalog/manifest.json ]]; then | ||
| 123 | + upload_file var/config-site/catalog/manifest.json | ||
| 124 | + fi | ||
| 125 | + | ||
| 126 | + - name: Purge Bunny CDN cache | ||
| 127 | + env: | ||
| 128 | + BUNNY_API_KEY: ${{ secrets.BUNNY_API_KEY }} | ||
| 129 | + BUNNY_PULL_ZONE_ID: ${{ vars.BUNNY_PULL_ZONE_ID }} | ||
| 130 | + run: | | ||
| 131 | + set -euo pipefail | ||
| 132 | + test -n "$BUNNY_API_KEY" | ||
| 133 | + test -n "$BUNNY_PULL_ZONE_ID" | ||
| 134 | + curl --fail --silent --show-error \ | ||
| 135 | + --retry 3 --retry-all-errors \ | ||
| 136 | + --request POST \ | ||
| 137 | + --header "AccessKey: $BUNNY_API_KEY" \ | ||
| 138 | + "https://api.bunny.net/pullzone/$BUNNY_PULL_ZONE_ID/purgeCache" | ||
| 139 | + | ||
| 140 | + publish: | ||
| 141 | + name: Publish GitHub release notes | ||
| 142 | + needs: [test, deploy] | ||
| 143 | + if: ${{ startsWith(github.ref_name, 'v') }} | ||
| 144 | + runs-on: ubuntu-latest | ||
| 145 | + permissions: | ||
| 146 | + contents: write | ||
| 147 | + steps: | ||
| 148 | + - name: Check out tagged commit | ||
| 149 | + uses: actions/checkout@v4 | ||
| 150 | + with: | ||
| 151 | + ref: ${{ github.sha }} | ||
| 152 | + fetch-depth: 0 | ||
| 153 | + persist-credentials: false | ||
| 154 | + | ||
| 155 | + - name: Generate categorized release notes | ||
| 156 | + shell: bash | ||
| 157 | + run: | | ||
| 158 | + set -euo pipefail | ||
| 159 | + previous_tag="$(git describe --tags --match 'v[0-9]*' --abbrev=0 "$GITHUB_SHA^" 2>/dev/null || true)" | ||
| 160 | + | ||
| 161 | + if [[ -n "$previous_tag" ]]; then | ||
| 162 | + range="$previous_tag..$GITHUB_SHA" | ||
| 163 | + changelog_url="https://github.com/$GITHUB_REPOSITORY/compare/$previous_tag...$GITHUB_REF_NAME" | ||
| 164 | + else | ||
| 165 | + range="$GITHUB_SHA" | ||
| 166 | + changelog_url="https://github.com/$GITHUB_REPOSITORY/commits/$GITHUB_REF_NAME" | ||
| 167 | + fi | ||
| 168 | + | ||
| 169 | + RANGE="$range" CHANGELOG_URL="$changelog_url" node --input-type=module <<'EOF' | ||
| 170 | + import { execFileSync } from "node:child_process"; | ||
| 171 | + import { writeFileSync } from "node:fs"; | ||
| 172 | + | ||
| 173 | + const groups = [ | ||
| 174 | + { key: "feat", title: "Features", commits: [] }, | ||
| 175 | + { key: "change", title: "Changes", commits: [] }, | ||
| 176 | + { key: "fix", title: "Fixes", commits: [] }, | ||
| 177 | + { key: "ci", title: "CI", commits: [] }, | ||
| 178 | + ]; | ||
| 179 | + const byKey = new Map(groups.map((group) => [group.key, group])); | ||
| 180 | + const fields = execFileSync( | ||
| 181 | + "git", | ||
| 182 | + ["log", "--first-parent", "-z", "--format=%s%x00%h", process.env.RANGE], | ||
| 183 | + { encoding: "utf8" }, | ||
| 184 | + ).split("\0").filter(Boolean); | ||
| 185 | + | ||
| 186 | + for (let index = 0; index < fields.length; index += 2) { | ||
| 187 | + const subject = fields[index].trim(); | ||
| 188 | + const hash = fields[index + 1].trim(); | ||
| 189 | + const type = subject.match(/^([a-z]+)(?:\([^)]*\))?!?:\s+/i)?.[1].toLowerCase(); | ||
| 190 | + const key = type === "feat" | ||
| 191 | + ? "feat" | ||
| 192 | + : type === "fix" | ||
| 193 | + ? "fix" | ||
| 194 | + : type === "ci" | ||
| 195 | + ? "ci" | ||
| 196 | + : "change"; | ||
| 197 | + | ||
| 198 | + byKey.get(key).commits.push(`- ${subject} (\`${hash}\`)`); | ||
| 199 | + } | ||
| 200 | + | ||
| 201 | + const notes = []; | ||
| 202 | + for (const group of groups) { | ||
| 203 | + if (group.commits.length === 0) continue; | ||
| 204 | + notes.push(`## ${group.title}`, "", ...group.commits, ""); | ||
| 205 | + } | ||
| 206 | + notes.push(`**Full changelog**: ${process.env.CHANGELOG_URL}`, ""); | ||
| 207 | + writeFileSync("release-notes.md", notes.join("\n")); | ||
| 208 | + EOF | ||
| 209 | + | ||
| 210 | + - name: Create release with categorized notes | ||
| 211 | + env: | ||
| 212 | + GH_TOKEN: ${{ github.token }} | ||
| 213 | + shell: bash | ||
| 214 | + run: | | ||
| 215 | + set -euo pipefail | ||
| 216 | + gh release create "$GITHUB_REF_NAME" \ | ||
| 217 | + --repo "$GITHUB_REPOSITORY" \ | ||
| 218 | + --title "Lapin Futé $GITHUB_REF_NAME" \ | ||
| 219 | + --notes-file release-notes.md \ | ||
| 220 | + --verify-tag | ||
modified
README.md +1 -1 | @@ -40,7 +40,7 @@ The name nods to Bison Futé, the French road-traffic information service, and t | ||
| 40 | 40 | - **Six favorites, five transport modes.** Choose any stop, line, and direction across Métro, RER, Transilien, Tram, and Bus. Favorites are renamed and reordered on the phone page, which shows official line badges and colors. |
| 41 | 41 | - **Departures at a glance.** The favorites list already shows the next departures for each stop; Select opens a full board for one favorite. Countdowns keep ticking on the watch between refreshes. |
| 42 | 42 | - **Traffic view.** Select again on a departure board to page through the current service messages for that line. |
| 43 | -- **Refresh when needed.** Open the app, switch favorite, choose Refresh all, or hold Select to request departures. Recent results are reused for 60 seconds; there is no periodic network polling. | |
| 43 | +- **Refresh when needed.** Open the app, switch favorite, choose Refresh all, or hold Select to request departures. Recent results are reused for 60 seconds; after a failed refresh, the last complete result remains visibly stale for at most 15 minutes. There is no periodic network polling. | |
| 44 | 44 | - **Clear data states.** The app distinguishes real-time departures, mixed real-time and scheduled results, updates in progress, and missing cached data. |
| 45 | 45 | - **English and French.** The watch interface and the settings page are both bilingual. |
| 46 | 46 | - **Settings that fit your phone.** Light and dark themes follow your system preference. Collapsible sections keep favorite editing, PRIM access, and watch synchronization easy to reach. |
| @@ -40,7 +40,7 @@ The name nods to Bison Futé, the French road-traffic information service, and t | |||
| 40 | - **Six favorites, five transport modes.** Choose any stop, line, and direction across Métro, RER, Transilien, Tram, and Bus. Favorites are renamed and reordered on the phone page, which shows official line badges and colors. | 40 | - **Six favorites, five transport modes.** Choose any stop, line, and direction across Métro, RER, Transilien, Tram, and Bus. Favorites are renamed and reordered on the phone page, which shows official line badges and colors. |
| 41 | - **Departures at a glance.** The favorites list already shows the next departures for each stop; Select opens a full board for one favorite. Countdowns keep ticking on the watch between refreshes. | 41 | - **Departures at a glance.** The favorites list already shows the next departures for each stop; Select opens a full board for one favorite. Countdowns keep ticking on the watch between refreshes. |
| 42 | - **Traffic view.** Select again on a departure board to page through the current service messages for that line. | 42 | - **Traffic view.** Select again on a departure board to page through the current service messages for that line. |
| 43 | -- **Refresh when needed.** Open the app, switch favorite, choose Refresh all, or hold Select to request departures. Recent results are reused for 60 seconds; there is no periodic network polling. | 43 | +- **Refresh when needed.** Open the app, switch favorite, choose Refresh all, or hold Select to request departures. Recent results are reused for 60 seconds; after a failed refresh, the last complete result remains visibly stale for at most 15 minutes. There is no periodic network polling. |
| 44 | - **Clear data states.** The app distinguishes real-time departures, mixed real-time and scheduled results, updates in progress, and missing cached data. | 44 | - **Clear data states.** The app distinguishes real-time departures, mixed real-time and scheduled results, updates in progress, and missing cached data. |
| 45 | - **English and French.** The watch interface and the settings page are both bilingual. | 45 | - **English and French.** The watch interface and the settings page are both bilingual. |
| 46 | - **Settings that fit your phone.** Light and dark themes follow your system preference. Collapsible sections keep favorite editing, PRIM access, and watch synchronization easy to reach. | 46 | - **Settings that fit your phone.** Light and dark themes follow your system preference. Collapsible sections keep favorite editing, PRIM access, and watch synchronization easy to reach. |
modified
package.json +1 -0 | @@ -11,6 +11,7 @@ | ||
| 11 | 11 | }, |
| 12 | 12 | "scripts": { |
| 13 | 13 | "build": "node scripts/build.mjs", |
| 14 | + "build:config-page": "node scripts/build-config-site.mjs --page-only", | |
| 14 | 15 | "build:config-site": "node scripts/build-config-site.mjs", |
| 15 | 16 | "catalog:probe": "node scripts/probe-catalog.mjs", |
| 16 | 17 | "catalog:refresh": "node scripts/refresh-catalog.mjs", |
| @@ -11,6 +11,7 @@ | |||
| 11 | }, | 11 | }, |
| 12 | "scripts": { | 12 | "scripts": { |
| 13 | "build": "node scripts/build.mjs", | 13 | "build": "node scripts/build.mjs", |
| 14 | + "build:config-page": "node scripts/build-config-site.mjs --page-only", | ||
| 14 | "build:config-site": "node scripts/build-config-site.mjs", | 15 | "build:config-site": "node scripts/build-config-site.mjs", |
| 15 | "catalog:probe": "node scripts/probe-catalog.mjs", | 16 | "catalog:probe": "node scripts/probe-catalog.mjs", |
| 16 | "catalog:refresh": "node scripts/refresh-catalog.mjs", | 17 | "catalog:refresh": "node scripts/refresh-catalog.mjs", |
modified
packages/catalog/src/catalog-import.ts +1 -1 | @@ -1062,7 +1062,7 @@ function deriveCandidates(database: DatabaseSync): void { | ||
| 1062 | 1062 | zone.zdaname AS stop_label, zone.zdatown AS locality_label, |
| 1063 | 1063 | line_mode.id_line AS id_line, line_mode.line_label AS line_label, |
| 1064 | 1064 | line_mode.line_color AS line_color, line_mode.line_text_color AS line_text_color, |
| 1065 | - CASE WHEN line_mode.mode IN ('RER', 'TRANSILIEN') THEN trip.terminal_stop_name | |
| 1065 | + CASE WHEN line_mode.mode IN ('BUS', 'RER', 'TRANSILIEN') THEN trip.terminal_stop_name | |
| 1066 | 1066 | ELSE coalesce(nullif(trim(trip.trip_headsign), ''), trip.terminal_stop_name) |
| 1067 | 1067 | END AS destination_label, |
| 1068 | 1068 | CASE WHEN line_mode.mode IN ('RER', 'TRANSILIEN') |
| @@ -1062,7 +1062,7 @@ function deriveCandidates(database: DatabaseSync): void { | |||
| 1062 | zone.zdaname AS stop_label, zone.zdatown AS locality_label, | 1062 | zone.zdaname AS stop_label, zone.zdatown AS locality_label, |
| 1063 | line_mode.id_line AS id_line, line_mode.line_label AS line_label, | 1063 | line_mode.id_line AS id_line, line_mode.line_label AS line_label, |
| 1064 | line_mode.line_color AS line_color, line_mode.line_text_color AS line_text_color, | 1064 | line_mode.line_color AS line_color, line_mode.line_text_color AS line_text_color, |
| 1065 | - CASE WHEN line_mode.mode IN ('RER', 'TRANSILIEN') THEN trip.terminal_stop_name | 1065 | + CASE WHEN line_mode.mode IN ('BUS', 'RER', 'TRANSILIEN') THEN trip.terminal_stop_name |
| 1066 | ELSE coalesce(nullif(trim(trip.trip_headsign), ''), trip.terminal_stop_name) | 1066 | ELSE coalesce(nullif(trim(trip.trip_headsign), ''), trip.terminal_stop_name) |
| 1067 | END AS destination_label, | 1067 | END AS destination_label, |
| 1068 | CASE WHEN line_mode.mode IN ('RER', 'TRANSILIEN') | 1068 | CASE WHEN line_mode.mode IN ('RER', 'TRANSILIEN') |
modified
packages/catalog/test/catalog-import.test.ts +56 -0 | @@ -134,6 +134,62 @@ test("import covers five modes, gates perimeter and unsupported modes, preserves | ||
| 134 | 134 | assert.equal(busServices.some((entry) => entry.destinationLabel === "Wrong perimeter destination"), false); |
| 135 | 135 | }); |
| 136 | 136 | |
| 137 | +test("bus services use their actual terminal to distinguish partial trips with the same headsign", async (t) => { | |
| 138 | + const directory = workspace(t); | |
| 139 | + const candidate = join(directory, "candidate.sqlite"); | |
| 140 | + const sources = sourcesFor("fixture-2026-08-a"); | |
| 141 | + await buildCatalogCandidateFromRecords({ | |
| 142 | + candidatePath: candidate, | |
| 143 | + sourceRevision: "fixture-bus-partial-trip", | |
| 144 | + sources: { | |
| 145 | + ...sources, | |
| 146 | + stops: [ | |
| 147 | + ...sources.stops, | |
| 148 | + { stop_id: "IDFM:TERM-BUS-PARTIAL", stop_name: "Porte de Paris", parent_station: "" }, | |
| 149 | + ], | |
| 150 | + trips: [ | |
| 151 | + ...sources.trips, | |
| 152 | + { | |
| 153 | + route_id: "IDFM:C100", | |
| 154 | + trip_id: "trip-bus-partial", | |
| 155 | + trip_headsign: "Hôpital Européen", | |
| 156 | + direction_id: "0", | |
| 157 | + }, | |
| 158 | + ], | |
| 159 | + stopTimes: [ | |
| 160 | + ...sources.stopTimes, | |
| 161 | + { trip_id: "trip-bus-partial", stop_id: "IDFM:ART-BUS-1", stop_sequence: "1", pickup_type: "0" }, | |
| 162 | + { trip_id: "trip-bus-partial", stop_id: "IDFM:TERM-BUS-PARTIAL", stop_sequence: "2", pickup_type: "1" }, | |
| 163 | + ], | |
| 164 | + objectCodes: [ | |
| 165 | + ...sources.objectCodes, | |
| 166 | + { | |
| 167 | + object_type: "StopPoint", | |
| 168 | + object_id: "IDFM:TERM-BUS-PARTIAL", | |
| 169 | + object_system: "source", | |
| 170 | + object_code: "DEST:BUS-PARTIAL", | |
| 171 | + }, | |
| 172 | + ], | |
| 173 | + }, | |
| 174 | + }); | |
| 175 | + | |
| 176 | + const manager = new CatalogManager(); | |
| 177 | + manager.reload(candidate); | |
| 178 | + const bus = place(manager, "gare du nord", "BUS", "Paris"); | |
| 179 | + const services = manager.listServices(bus.placeId) ?? []; | |
| 180 | + assert.deepEqual( | |
| 181 | + services.map((entry) => entry.destinationLabel).sort(), | |
| 182 | + ["Hôpital Européen", "Porte de Paris"], | |
| 183 | + ); | |
| 184 | + assert.notEqual(services[0]?.serviceId, services[1]?.serviceId); | |
| 185 | + assert.deepEqual( | |
| 186 | + new Set(services.map((entry) => manager.resolveService(entry.serviceId)) | |
| 187 | + .filter((entry) => entry.status === "RESOLVED") | |
| 188 | + .map((entry) => entry.destinationRef)), | |
| 189 | + new Set(["STIF:StopPoint:Q:TERM-BUS-1:", "STIF:StopPoint:Q:TERM-BUS-PARTIAL:"]), | |
| 190 | + ); | |
| 191 | +}); | |
| 192 | + | |
| 137 | 193 | test("GTFS quay and monomodal stops join SIRI perimeter namespaces without rewriting routing references", async (t) => { |
| 138 | 194 | const directory = workspace(t); |
| 139 | 195 | const sources = sourcesFor("fixture-2026-08-a"); |
| @@ -134,6 +134,62 @@ test("import covers five modes, gates perimeter and unsupported modes, preserves | |||
| 134 | assert.equal(busServices.some((entry) => entry.destinationLabel === "Wrong perimeter destination"), false); | 134 | assert.equal(busServices.some((entry) => entry.destinationLabel === "Wrong perimeter destination"), false); |
| 135 | }); | 135 | }); |
| 136 | 136 | ||
| 137 | +test("bus services use their actual terminal to distinguish partial trips with the same headsign", async (t) => { | ||
| 138 | + const directory = workspace(t); | ||
| 139 | + const candidate = join(directory, "candidate.sqlite"); | ||
| 140 | + const sources = sourcesFor("fixture-2026-08-a"); | ||
| 141 | + await buildCatalogCandidateFromRecords({ | ||
| 142 | + candidatePath: candidate, | ||
| 143 | + sourceRevision: "fixture-bus-partial-trip", | ||
| 144 | + sources: { | ||
| 145 | + ...sources, | ||
| 146 | + stops: [ | ||
| 147 | + ...sources.stops, | ||
| 148 | + { stop_id: "IDFM:TERM-BUS-PARTIAL", stop_name: "Porte de Paris", parent_station: "" }, | ||
| 149 | + ], | ||
| 150 | + trips: [ | ||
| 151 | + ...sources.trips, | ||
| 152 | + { | ||
| 153 | + route_id: "IDFM:C100", | ||
| 154 | + trip_id: "trip-bus-partial", | ||
| 155 | + trip_headsign: "Hôpital Européen", | ||
| 156 | + direction_id: "0", | ||
| 157 | + }, | ||
| 158 | + ], | ||
| 159 | + stopTimes: [ | ||
| 160 | + ...sources.stopTimes, | ||
| 161 | + { trip_id: "trip-bus-partial", stop_id: "IDFM:ART-BUS-1", stop_sequence: "1", pickup_type: "0" }, | ||
| 162 | + { trip_id: "trip-bus-partial", stop_id: "IDFM:TERM-BUS-PARTIAL", stop_sequence: "2", pickup_type: "1" }, | ||
| 163 | + ], | ||
| 164 | + objectCodes: [ | ||
| 165 | + ...sources.objectCodes, | ||
| 166 | + { | ||
| 167 | + object_type: "StopPoint", | ||
| 168 | + object_id: "IDFM:TERM-BUS-PARTIAL", | ||
| 169 | + object_system: "source", | ||
| 170 | + object_code: "DEST:BUS-PARTIAL", | ||
| 171 | + }, | ||
| 172 | + ], | ||
| 173 | + }, | ||
| 174 | + }); | ||
| 175 | + | ||
| 176 | + const manager = new CatalogManager(); | ||
| 177 | + manager.reload(candidate); | ||
| 178 | + const bus = place(manager, "gare du nord", "BUS", "Paris"); | ||
| 179 | + const services = manager.listServices(bus.placeId) ?? []; | ||
| 180 | + assert.deepEqual( | ||
| 181 | + services.map((entry) => entry.destinationLabel).sort(), | ||
| 182 | + ["Hôpital Européen", "Porte de Paris"], | ||
| 183 | + ); | ||
| 184 | + assert.notEqual(services[0]?.serviceId, services[1]?.serviceId); | ||
| 185 | + assert.deepEqual( | ||
| 186 | + new Set(services.map((entry) => manager.resolveService(entry.serviceId)) | ||
| 187 | + .filter((entry) => entry.status === "RESOLVED") | ||
| 188 | + .map((entry) => entry.destinationRef)), | ||
| 189 | + new Set(["STIF:StopPoint:Q:TERM-BUS-1:", "STIF:StopPoint:Q:TERM-BUS-PARTIAL:"]), | ||
| 190 | + ); | ||
| 191 | +}); | ||
| 192 | + | ||
| 137 | test("GTFS quay and monomodal stops join SIRI perimeter namespaces without rewriting routing references", async (t) => { | 193 | test("GTFS quay and monomodal stops join SIRI perimeter namespaces without rewriting routing references", async (t) => { |
| 138 | const directory = workspace(t); | 194 | const directory = workspace(t); |
| 139 | const sources = sourcesFor("fixture-2026-08-a"); | 195 | const sources = sourcesFor("fixture-2026-08-a"); |
modified
packages/companion/src/configuration.js +9 -5 | @@ -675,21 +675,25 @@ function applyConfigurationUpdate(current, update) { | ||
| 675 | 675 | |
| 676 | 676 | function parseCloseFragment(response) { |
| 677 | 677 | var fragment; |
| 678 | - var hashIndex; | |
| 679 | 678 | var parsed; |
| 679 | + var closePrefix = "pebblejs://close#"; | |
| 680 | 680 | if (typeof response !== "string" |
| 681 | 681 | || response.length === 0 |
| 682 | 682 | || response.length > MAX_CLOSE_RESPONSE_LENGTH |
| 683 | 683 | || response === "CANCELLED") return null; |
| 684 | - hashIndex = response.indexOf("#"); | |
| 685 | - fragment = hashIndex === -1 ? response : response.slice(hashIndex + 1); | |
| 684 | + fragment = response.indexOf(closePrefix) === 0 | |
| 685 | + ? response.slice(closePrefix.length) | |
| 686 | + : response; | |
| 686 | 687 | if (fragment.charAt(0) === "#") fragment = fragment.slice(1); |
| 687 | 688 | if (fragment.length === 0 || fragment === "CANCELLED") return null; |
| 688 | 689 | try { |
| 689 | - fragment = decodeURIComponent(fragment); | |
| 690 | 690 | parsed = JSON.parse(fragment); |
| 691 | 691 | } catch (ignored) { |
| 692 | - return null; | |
| 692 | + try { | |
| 693 | + parsed = JSON.parse(decodeURIComponent(fragment)); | |
| 694 | + } catch (alsoIgnored) { | |
| 695 | + return null; | |
| 696 | + } | |
| 693 | 697 | } |
| 694 | 698 | if (!isConfigurationUpdate(parsed)) return null; |
| 695 | 699 | return { |
| @@ -675,21 +675,25 @@ function applyConfigurationUpdate(current, update) { | |||
| 675 | 675 | ||
| 676 | function parseCloseFragment(response) { | 676 | function parseCloseFragment(response) { |
| 677 | var fragment; | 677 | var fragment; |
| 678 | - var hashIndex; | ||
| 679 | var parsed; | 678 | var parsed; |
| 679 | + var closePrefix = "pebblejs://close#"; | ||
| 680 | if (typeof response !== "string" | 680 | if (typeof response !== "string" |
| 681 | || response.length === 0 | 681 | || response.length === 0 |
| 682 | || response.length > MAX_CLOSE_RESPONSE_LENGTH | 682 | || response.length > MAX_CLOSE_RESPONSE_LENGTH |
| 683 | || response === "CANCELLED") return null; | 683 | || response === "CANCELLED") return null; |
| 684 | - hashIndex = response.indexOf("#"); | 684 | + fragment = response.indexOf(closePrefix) === 0 |
| 685 | - fragment = hashIndex === -1 ? response : response.slice(hashIndex + 1); | 685 | + ? response.slice(closePrefix.length) |
| 686 | + : response; | ||
| 686 | if (fragment.charAt(0) === "#") fragment = fragment.slice(1); | 687 | if (fragment.charAt(0) === "#") fragment = fragment.slice(1); |
| 687 | if (fragment.length === 0 || fragment === "CANCELLED") return null; | 688 | if (fragment.length === 0 || fragment === "CANCELLED") return null; |
| 688 | try { | 689 | try { |
| 689 | - fragment = decodeURIComponent(fragment); | ||
| 690 | parsed = JSON.parse(fragment); | 690 | parsed = JSON.parse(fragment); |
| 691 | } catch (ignored) { | 691 | } catch (ignored) { |
| 692 | - return null; | 692 | + try { |
| 693 | + parsed = JSON.parse(decodeURIComponent(fragment)); | ||
| 694 | + } catch (alsoIgnored) { | ||
| 695 | + return null; | ||
| 696 | + } | ||
| 693 | } | 697 | } |
| 694 | if (!isConfigurationUpdate(parsed)) return null; | 698 | if (!isConfigurationUpdate(parsed)) return null; |
| 695 | return { | 699 | return { |
modified
packages/companion/src/contracts.js +2 -0 | @@ -16,6 +16,7 @@ var LIMITS = Object.freeze({ | ||
| 16 | 16 | trafficTextUtf8Bytes: 384 |
| 17 | 17 | }); |
| 18 | 18 | var CACHE_FRESH_SECONDS = 60; |
| 19 | +var USEFUL_STALE_SECONDS = 15 * 60; | |
| 19 | 20 | var FAVORITE_SETTLE_MS = 500; |
| 20 | 21 | var APP_MESSAGE_INBOX_BYTES = 768; |
| 21 | 22 | var APP_MESSAGE_OUTBOX_BYTES = 192; |
| @@ -694,6 +695,7 @@ module.exports = { | ||
| 694 | 695 | SCHEMA_VERSION: SCHEMA_VERSION, |
| 695 | 696 | LIMITS: LIMITS, |
| 696 | 697 | CACHE_FRESH_SECONDS: CACHE_FRESH_SECONDS, |
| 698 | + USEFUL_STALE_SECONDS: USEFUL_STALE_SECONDS, | |
| 697 | 699 | FAVORITE_SETTLE_MS: FAVORITE_SETTLE_MS, |
| 698 | 700 | APP_MESSAGE_INBOX_BYTES: APP_MESSAGE_INBOX_BYTES, |
| 699 | 701 | APP_MESSAGE_OUTBOX_BYTES: APP_MESSAGE_OUTBOX_BYTES, |
| @@ -16,6 +16,7 @@ var LIMITS = Object.freeze({ | |||
| 16 | trafficTextUtf8Bytes: 384 | 16 | trafficTextUtf8Bytes: 384 |
| 17 | }); | 17 | }); |
| 18 | var CACHE_FRESH_SECONDS = 60; | 18 | var CACHE_FRESH_SECONDS = 60; |
| 19 | +var USEFUL_STALE_SECONDS = 15 * 60; | ||
| 19 | var FAVORITE_SETTLE_MS = 500; | 20 | var FAVORITE_SETTLE_MS = 500; |
| 20 | var APP_MESSAGE_INBOX_BYTES = 768; | 21 | var APP_MESSAGE_INBOX_BYTES = 768; |
| 21 | var APP_MESSAGE_OUTBOX_BYTES = 192; | 22 | var APP_MESSAGE_OUTBOX_BYTES = 192; |
| @@ -694,6 +695,7 @@ module.exports = { | |||
| 694 | SCHEMA_VERSION: SCHEMA_VERSION, | 695 | SCHEMA_VERSION: SCHEMA_VERSION, |
| 695 | LIMITS: LIMITS, | 696 | LIMITS: LIMITS, |
| 696 | CACHE_FRESH_SECONDS: CACHE_FRESH_SECONDS, | 697 | CACHE_FRESH_SECONDS: CACHE_FRESH_SECONDS, |
| 698 | + USEFUL_STALE_SECONDS: USEFUL_STALE_SECONDS, | ||
| 697 | FAVORITE_SETTLE_MS: FAVORITE_SETTLE_MS, | 699 | FAVORITE_SETTLE_MS: FAVORITE_SETTLE_MS, |
| 698 | APP_MESSAGE_INBOX_BYTES: APP_MESSAGE_INBOX_BYTES, | 700 | APP_MESSAGE_INBOX_BYTES: APP_MESSAGE_INBOX_BYTES, |
| 699 | APP_MESSAGE_OUTBOX_BYTES: APP_MESSAGE_OUTBOX_BYTES, | 701 | APP_MESSAGE_OUTBOX_BYTES: APP_MESSAGE_OUTBOX_BYTES, |
modified
packages/companion/src/display-copy.json +2 -2 | @@ -56,8 +56,8 @@ | ||
| 56 | 56 | }, |
| 57 | 57 | { |
| 58 | 58 | "token": "noDepartures", |
| 59 | - "en": "No departures announced", | |
| 60 | - "fr": "Aucun départ annoncé" | |
| 59 | + "en": "No departures planned", | |
| 60 | + "fr": "Aucun départ prévu" | |
| 61 | 61 | }, |
| 62 | 62 | { |
| 63 | 63 | "token": "departed", |
| @@ -56,8 +56,8 @@ | |||
| 56 | }, | 56 | }, |
| 57 | { | 57 | { |
| 58 | "token": "noDepartures", | 58 | "token": "noDepartures", |
| 59 | - "en": "No departures announced", | 59 | + "en": "No departures planned", |
| 60 | - "fr": "Aucun départ annoncé" | 60 | + "fr": "Aucun départ prévu" |
| 61 | }, | 61 | }, |
| 62 | { | 62 | { |
| 63 | "token": "departed", | 63 | "token": "departed", |
modified
packages/companion/src/index.js +17 -2 | @@ -71,11 +71,20 @@ function normalizeIncomingPayload(payload) { | ||
| 71 | 71 | return aliasCount > 0 ? normalized : null; |
| 72 | 72 | } |
| 73 | 73 | |
| 74 | +function cacheAge(timestamp, storedAt, now) { | |
| 75 | + return now - Math.min(timestamp * 1000, storedAt); | |
| 76 | +} | |
| 77 | + | |
| 74 | 78 | function cacheFresh(timestamp, storedAt, now) { |
| 75 | - var age = now - Math.min(timestamp * 1000, storedAt); | |
| 79 | + var age = cacheAge(timestamp, storedAt, now); | |
| 76 | 80 | return age >= 0 && age < contracts.CACHE_FRESH_SECONDS * 1000; |
| 77 | 81 | } |
| 78 | 82 | |
| 83 | +function cacheUseful(timestamp, storedAt, now) { | |
| 84 | + var age = cacheAge(timestamp, storedAt, now); | |
| 85 | + return age >= 0 && age < contracts.USEFUL_STALE_SECONDS * 1000; | |
| 86 | +} | |
| 87 | + | |
| 79 | 88 | function sameFavoriteContentSet(left, right) { |
| 80 | 89 | if (left.length !== right.length) return false; |
| 81 | 90 | return left.every(function (favorite) { |
| @@ -159,6 +168,7 @@ function Companion(options) { | ||
| 159 | 168 | cacheMisses: 0, |
| 160 | 169 | successes: 0, |
| 161 | 170 | failures: 0, |
| 171 | + rateLimitedResponses: 0, | |
| 162 | 172 | totalLatencyMs: 0, |
| 163 | 173 | lastLatencyMs: 0 |
| 164 | 174 | }; |
| @@ -550,7 +560,8 @@ Companion.prototype._favoriteView = function (favorite, flightErrors, aggregateE | ||
| 550 | 560 | if (entry !== null && entry.serviceId !== favorite.serviceId) entry = null; |
| 551 | 561 | var code = aggregateError || (flightErrors && flightErrors[favorite.serviceId]) || null; |
| 552 | 562 | if (!code && entry && entry.refreshError) code = entry.refreshError.code; |
| 553 | - var hasData = entry !== null && hasOwn.call(entry, "result"); | |
| 563 | + var hasData = entry !== null && hasOwn.call(entry, "result") | |
| 564 | + && cacheUseful(entry.result.fetchedAt, entry.resultStoredAt, this._clock.now()); | |
| 554 | 565 | if (!hasData && cacheOnly && code !== "API_KEY_REQUIRED" && code !== "API_KEY_INVALID") code = "NO_CACHED_DATA"; |
| 555 | 566 | return { |
| 556 | 567 | hasData: hasData, |
| @@ -853,6 +864,9 @@ Companion.prototype._storeTraffic = function (favorite, language, data, requestI | ||
| 853 | 864 | |
| 854 | 865 | Companion.prototype._acceptOutcome = function (flight, outcome) { |
| 855 | 866 | if (flight.generation !== this._lifecycleGeneration) return false; |
| 867 | + if (outcome.status === "UNAVAILABLE" && outcome.error.code === "RATE_LIMITED") { | |
| 868 | + this._metrics.rateLimitedResponses += 1; | |
| 869 | + } | |
| 856 | 870 | if (outcome.status !== "UNAVAILABLE" || outcome.error.code !== "API_KEY_INVALID") return true; |
| 857 | 871 | var requests = this._requests.slice(); |
| 858 | 872 | this._metrics.failures += 1; |
| @@ -1093,6 +1107,7 @@ Companion.prototype.metrics = function () { | ||
| 1093 | 1107 | cacheMisses: this._metrics.cacheMisses, |
| 1094 | 1108 | successes: this._metrics.successes, |
| 1095 | 1109 | failures: this._metrics.failures, |
| 1110 | + rateLimitedResponses: this._metrics.rateLimitedResponses, | |
| 1096 | 1111 | totalLatencyMs: this._metrics.totalLatencyMs, |
| 1097 | 1112 | lastLatencyMs: this._metrics.lastLatencyMs |
| 1098 | 1113 | }; |
| @@ -71,11 +71,20 @@ function normalizeIncomingPayload(payload) { | |||
| 71 | return aliasCount > 0 ? normalized : null; | 71 | return aliasCount > 0 ? normalized : null; |
| 72 | } | 72 | } |
| 73 | 73 | ||
| 74 | +function cacheAge(timestamp, storedAt, now) { | ||
| 75 | + return now - Math.min(timestamp * 1000, storedAt); | ||
| 76 | +} | ||
| 77 | + | ||
| 74 | function cacheFresh(timestamp, storedAt, now) { | 78 | function cacheFresh(timestamp, storedAt, now) { |
| 75 | - var age = now - Math.min(timestamp * 1000, storedAt); | 79 | + var age = cacheAge(timestamp, storedAt, now); |
| 76 | return age >= 0 && age < contracts.CACHE_FRESH_SECONDS * 1000; | 80 | return age >= 0 && age < contracts.CACHE_FRESH_SECONDS * 1000; |
| 77 | } | 81 | } |
| 78 | 82 | ||
| 83 | +function cacheUseful(timestamp, storedAt, now) { | ||
| 84 | + var age = cacheAge(timestamp, storedAt, now); | ||
| 85 | + return age >= 0 && age < contracts.USEFUL_STALE_SECONDS * 1000; | ||
| 86 | +} | ||
| 87 | + | ||
| 79 | function sameFavoriteContentSet(left, right) { | 88 | function sameFavoriteContentSet(left, right) { |
| 80 | if (left.length !== right.length) return false; | 89 | if (left.length !== right.length) return false; |
| 81 | return left.every(function (favorite) { | 90 | return left.every(function (favorite) { |
| @@ -159,6 +168,7 @@ function Companion(options) { | |||
| 159 | cacheMisses: 0, | 168 | cacheMisses: 0, |
| 160 | successes: 0, | 169 | successes: 0, |
| 161 | failures: 0, | 170 | failures: 0, |
| 171 | + rateLimitedResponses: 0, | ||
| 162 | totalLatencyMs: 0, | 172 | totalLatencyMs: 0, |
| 163 | lastLatencyMs: 0 | 173 | lastLatencyMs: 0 |
| 164 | }; | 174 | }; |
| @@ -550,7 +560,8 @@ Companion.prototype._favoriteView = function (favorite, flightErrors, aggregateE | |||
| 550 | if (entry !== null && entry.serviceId !== favorite.serviceId) entry = null; | 560 | if (entry !== null && entry.serviceId !== favorite.serviceId) entry = null; |
| 551 | var code = aggregateError || (flightErrors && flightErrors[favorite.serviceId]) || null; | 561 | var code = aggregateError || (flightErrors && flightErrors[favorite.serviceId]) || null; |
| 552 | if (!code && entry && entry.refreshError) code = entry.refreshError.code; | 562 | if (!code && entry && entry.refreshError) code = entry.refreshError.code; |
| 553 | - var hasData = entry !== null && hasOwn.call(entry, "result"); | 563 | + var hasData = entry !== null && hasOwn.call(entry, "result") |
| 564 | + && cacheUseful(entry.result.fetchedAt, entry.resultStoredAt, this._clock.now()); | ||
| 554 | if (!hasData && cacheOnly && code !== "API_KEY_REQUIRED" && code !== "API_KEY_INVALID") code = "NO_CACHED_DATA"; | 565 | if (!hasData && cacheOnly && code !== "API_KEY_REQUIRED" && code !== "API_KEY_INVALID") code = "NO_CACHED_DATA"; |
| 555 | return { | 566 | return { |
| 556 | hasData: hasData, | 567 | hasData: hasData, |
| @@ -853,6 +864,9 @@ Companion.prototype._storeTraffic = function (favorite, language, data, requestI | |||
| 853 | 864 | ||
| 854 | Companion.prototype._acceptOutcome = function (flight, outcome) { | 865 | Companion.prototype._acceptOutcome = function (flight, outcome) { |
| 855 | if (flight.generation !== this._lifecycleGeneration) return false; | 866 | if (flight.generation !== this._lifecycleGeneration) return false; |
| 867 | + if (outcome.status === "UNAVAILABLE" && outcome.error.code === "RATE_LIMITED") { | ||
| 868 | + this._metrics.rateLimitedResponses += 1; | ||
| 869 | + } | ||
| 856 | if (outcome.status !== "UNAVAILABLE" || outcome.error.code !== "API_KEY_INVALID") return true; | 870 | if (outcome.status !== "UNAVAILABLE" || outcome.error.code !== "API_KEY_INVALID") return true; |
| 857 | var requests = this._requests.slice(); | 871 | var requests = this._requests.slice(); |
| 858 | this._metrics.failures += 1; | 872 | this._metrics.failures += 1; |
| @@ -1093,6 +1107,7 @@ Companion.prototype.metrics = function () { | |||
| 1093 | cacheMisses: this._metrics.cacheMisses, | 1107 | cacheMisses: this._metrics.cacheMisses, |
| 1094 | successes: this._metrics.successes, | 1108 | successes: this._metrics.successes, |
| 1095 | failures: this._metrics.failures, | 1109 | failures: this._metrics.failures, |
| 1110 | + rateLimitedResponses: this._metrics.rateLimitedResponses, | ||
| 1096 | totalLatencyMs: this._metrics.totalLatencyMs, | 1111 | totalLatencyMs: this._metrics.totalLatencyMs, |
| 1097 | lastLatencyMs: this._metrics.lastLatencyMs | 1112 | lastLatencyMs: this._metrics.lastLatencyMs |
| 1098 | }; | 1113 | }; |
modified
packages/companion/src/prim-departures.js +5 -2 | @@ -127,6 +127,7 @@ function normalizePrimDepartureResponse(value, routing, fetchedAt) { | ||
| 127 | 127 | var recordedAt; |
| 128 | 128 | var matched = []; |
| 129 | 129 | var departures = []; |
| 130 | + var visitCount = 0; | |
| 130 | 131 | var realtimeCount = 0; |
| 131 | 132 | var departure; |
| 132 | 133 | var interval; |
| @@ -155,6 +156,7 @@ function normalizePrimDepartureResponse(value, routing, fetchedAt) { | ||
| 155 | 156 | for (offset = 0; offset < visits.length; offset += 1) { |
| 156 | 157 | visit = visits[offset]; |
| 157 | 158 | if (!contracts.isObject(visit)) fail(); |
| 159 | + visitCount += 1; | |
| 158 | 160 | recordedAt = epochSeconds(visit.RecordedAtTime); |
| 159 | 161 | journey = matchedJourney(visit, routing); |
| 160 | 162 | if (typeof journey === "undefined") continue; |
| @@ -162,7 +164,7 @@ function normalizePrimDepartureResponse(value, routing, fetchedAt) { | ||
| 162 | 164 | insertBounded(matched, parseMatchedVisit(journey)); |
| 163 | 165 | } |
| 164 | 166 | } |
| 165 | - if (matched.length === 0) fail(); | |
| 167 | + if (matched.length === 0 && visitCount > 0) fail(); | |
| 166 | 168 | for (index = 0; index < matched.length; index += 1) { |
| 167 | 169 | visit = matched[index]; |
| 168 | 170 | if (visit.hasExpected) realtimeCount += 1; |
| @@ -185,7 +187,8 @@ function normalizePrimDepartureResponse(value, routing, fetchedAt) { | ||
| 185 | 187 | } |
| 186 | 188 | result = { |
| 187 | 189 | fetchedAt: fetchedAt, |
| 188 | - freshness: realtimeCount === matched.length ? "REALTIME" : realtimeCount === 0 ? "SCHEDULED" : "MIXED", | |
| 190 | + freshness: matched.length === 0 ? "SCHEDULED" | |
| 191 | + : realtimeCount === matched.length ? "REALTIME" : realtimeCount === 0 ? "SCHEDULED" : "MIXED", | |
| 189 | 192 | departures: Object.freeze(departures) |
| 190 | 193 | }; |
| 191 | 194 | if (typeof sourceUpdatedAt !== "undefined") result.sourceUpdatedAt = sourceUpdatedAt; |
| @@ -127,6 +127,7 @@ function normalizePrimDepartureResponse(value, routing, fetchedAt) { | |||
| 127 | var recordedAt; | 127 | var recordedAt; |
| 128 | var matched = []; | 128 | var matched = []; |
| 129 | var departures = []; | 129 | var departures = []; |
| 130 | + var visitCount = 0; | ||
| 130 | var realtimeCount = 0; | 131 | var realtimeCount = 0; |
| 131 | var departure; | 132 | var departure; |
| 132 | var interval; | 133 | var interval; |
| @@ -155,6 +156,7 @@ function normalizePrimDepartureResponse(value, routing, fetchedAt) { | |||
| 155 | for (offset = 0; offset < visits.length; offset += 1) { | 156 | for (offset = 0; offset < visits.length; offset += 1) { |
| 156 | visit = visits[offset]; | 157 | visit = visits[offset]; |
| 157 | if (!contracts.isObject(visit)) fail(); | 158 | if (!contracts.isObject(visit)) fail(); |
| 159 | + visitCount += 1; | ||
| 158 | recordedAt = epochSeconds(visit.RecordedAtTime); | 160 | recordedAt = epochSeconds(visit.RecordedAtTime); |
| 159 | journey = matchedJourney(visit, routing); | 161 | journey = matchedJourney(visit, routing); |
| 160 | if (typeof journey === "undefined") continue; | 162 | if (typeof journey === "undefined") continue; |
| @@ -162,7 +164,7 @@ function normalizePrimDepartureResponse(value, routing, fetchedAt) { | |||
| 162 | insertBounded(matched, parseMatchedVisit(journey)); | 164 | insertBounded(matched, parseMatchedVisit(journey)); |
| 163 | } | 165 | } |
| 164 | } | 166 | } |
| 165 | - if (matched.length === 0) fail(); | 167 | + if (matched.length === 0 && visitCount > 0) fail(); |
| 166 | for (index = 0; index < matched.length; index += 1) { | 168 | for (index = 0; index < matched.length; index += 1) { |
| 167 | visit = matched[index]; | 169 | visit = matched[index]; |
| 168 | if (visit.hasExpected) realtimeCount += 1; | 170 | if (visit.hasExpected) realtimeCount += 1; |
| @@ -185,7 +187,8 @@ function normalizePrimDepartureResponse(value, routing, fetchedAt) { | |||
| 185 | } | 187 | } |
| 186 | result = { | 188 | result = { |
| 187 | fetchedAt: fetchedAt, | 189 | fetchedAt: fetchedAt, |
| 188 | - freshness: realtimeCount === matched.length ? "REALTIME" : realtimeCount === 0 ? "SCHEDULED" : "MIXED", | 190 | + freshness: matched.length === 0 ? "SCHEDULED" |
| 191 | + : realtimeCount === matched.length ? "REALTIME" : realtimeCount === 0 ? "SCHEDULED" : "MIXED", | ||
| 189 | departures: Object.freeze(departures) | 192 | departures: Object.freeze(departures) |
| 190 | }; | 193 | }; |
| 191 | if (typeof sourceUpdatedAt !== "undefined") result.sourceUpdatedAt = sourceUpdatedAt; | 194 | if (typeof sourceUpdatedAt !== "undefined") result.sourceUpdatedAt = sourceUpdatedAt; |
modified
packages/companion/src/prim-traffic.js +12 -3 | @@ -286,12 +286,17 @@ function trafficForLine(envelope, lineId, evaluatedAtMilliseconds, checkedAtMill | ||
| 286 | 286 | checkedAt: Math.floor(checkedAtMilliseconds / 1000) |
| 287 | 287 | }; |
| 288 | 288 | if (!contracts.uint32(result.checkedAt)) fail(); |
| 289 | - if (typeof line === "undefined") return result; | |
| 289 | + if (typeof line === "undefined") { | |
| 290 | + // The bulk endpoint lists impacted lines only. A valid catalog line omitted | |
| 291 | + // from a successfully normalized response therefore has normal traffic. | |
| 292 | + if (typeof lineId === "string" && exactMatch(CANONICAL_LINE, lineId) !== null) result.state = "NORMAL"; | |
| 293 | + return result; | |
| 294 | + } | |
| 290 | 295 | for (index = 0; index < line.length; index += 1) { |
| 291 | 296 | disruption = envelope.disruptions[line[index]]; |
| 292 | 297 | if (!active(disruption, localNow) || disruption.severity === "INFORMATION") continue; |
| 293 | - priority = disruption.severity === "PERTURBEE" | |
| 294 | - && typeof disruption.title !== "undefined" && typeof disruption.text !== "undefined" ? 1 : 2; | |
| 298 | + if (typeof disruption.title === "undefined" || typeof disruption.text === "undefined") priority = 2; | |
| 299 | + else priority = disruption.severity === "BLOQUANTE" ? 3 : 1; | |
| 295 | 300 | if (typeof best === "undefined" || priority > bestPriority |
| 296 | 301 | || (priority === bestPriority && disruption.id < best.id)) { |
| 297 | 302 | best = disruption; |
| @@ -303,6 +308,10 @@ function trafficForLine(envelope, lineId, evaluatedAtMilliseconds, checkedAtMill | ||
| 303 | 308 | result.state = "DELAYED"; |
| 304 | 309 | result.title = best.title; |
| 305 | 310 | result.text = best.text; |
| 311 | + } else if (bestPriority === 3) { | |
| 312 | + result.state = "STOPPED"; | |
| 313 | + result.title = best.title; | |
| 314 | + result.text = best.text; | |
| 306 | 315 | } |
| 307 | 316 | return result; |
| 308 | 317 | } |
| @@ -286,12 +286,17 @@ function trafficForLine(envelope, lineId, evaluatedAtMilliseconds, checkedAtMill | |||
| 286 | checkedAt: Math.floor(checkedAtMilliseconds / 1000) | 286 | checkedAt: Math.floor(checkedAtMilliseconds / 1000) |
| 287 | }; | 287 | }; |
| 288 | if (!contracts.uint32(result.checkedAt)) fail(); | 288 | if (!contracts.uint32(result.checkedAt)) fail(); |
| 289 | - if (typeof line === "undefined") return result; | 289 | + if (typeof line === "undefined") { |
| 290 | + // The bulk endpoint lists impacted lines only. A valid catalog line omitted | ||
| 291 | + // from a successfully normalized response therefore has normal traffic. | ||
| 292 | + if (typeof lineId === "string" && exactMatch(CANONICAL_LINE, lineId) !== null) result.state = "NORMAL"; | ||
| 293 | + return result; | ||
| 294 | + } | ||
| 290 | for (index = 0; index < line.length; index += 1) { | 295 | for (index = 0; index < line.length; index += 1) { |
| 291 | disruption = envelope.disruptions[line[index]]; | 296 | disruption = envelope.disruptions[line[index]]; |
| 292 | if (!active(disruption, localNow) || disruption.severity === "INFORMATION") continue; | 297 | if (!active(disruption, localNow) || disruption.severity === "INFORMATION") continue; |
| 293 | - priority = disruption.severity === "PERTURBEE" | 298 | + if (typeof disruption.title === "undefined" || typeof disruption.text === "undefined") priority = 2; |
| 294 | - && typeof disruption.title !== "undefined" && typeof disruption.text !== "undefined" ? 1 : 2; | 299 | + else priority = disruption.severity === "BLOQUANTE" ? 3 : 1; |
| 295 | if (typeof best === "undefined" || priority > bestPriority | 300 | if (typeof best === "undefined" || priority > bestPriority |
| 296 | || (priority === bestPriority && disruption.id < best.id)) { | 301 | || (priority === bestPriority && disruption.id < best.id)) { |
| 297 | best = disruption; | 302 | best = disruption; |
| @@ -303,6 +308,10 @@ function trafficForLine(envelope, lineId, evaluatedAtMilliseconds, checkedAtMill | |||
| 303 | result.state = "DELAYED"; | 308 | result.state = "DELAYED"; |
| 304 | result.title = best.title; | 309 | result.title = best.title; |
| 305 | result.text = best.text; | 310 | result.text = best.text; |
| 311 | + } else if (bestPriority === 3) { | ||
| 312 | + result.state = "STOPPED"; | ||
| 313 | + result.title = best.title; | ||
| 314 | + result.text = best.text; | ||
| 306 | } | 315 | } |
| 307 | return result; | 316 | return result; |
| 308 | } | 317 | } |
modified
packages/companion/test/companion.test.js +122 -0 | @@ -199,6 +199,21 @@ function closeWith(target, action, favorites, value, forceFull) { | ||
| 199 | 199 | if (forceFull) update.forceFullSync = true; |
| 200 | 200 | target.Pebble.emit("webviewclosed", { response: "pebblejs://close#" + encodeURIComponent(JSON.stringify(update)) }); |
| 201 | 201 | } |
| 202 | + | |
| 203 | +test("Core Android decoded close responses preserve favorites with line colors", function () { | |
| 204 | + var target = harness({ storage: configuredStorage([]) }); | |
| 205 | + var update = { | |
| 206 | + schemaVersion: 1, | |
| 207 | + favorites: [Object.assign({}, FIRST, { sortOrder: 0 })], | |
| 208 | + apiKeyUpdate: { schemaVersion: 1, action: "KEEP" } | |
| 209 | + }; | |
| 210 | + | |
| 211 | + target.Pebble.emit("webviewclosed", { response: JSON.stringify(update) }); | |
| 212 | + | |
| 213 | + assert.deepEqual(configuration.loadConfiguration(target.storage).favorites, update.favorites); | |
| 214 | + target.companion.stop(); | |
| 215 | +}); | |
| 216 | + | |
| 202 | 217 | function serviceRow(favorite) { |
| 203 | 218 | return { serviceId: favorite.serviceId, stopLabel: favorite.stopLabel, lineLabel: favorite.lineLabel, |
| 204 | 219 | destinationLabel: favorite.destinationLabel, lineMode: favorite.lineMode, |
| @@ -343,6 +358,95 @@ test("overview and detail CACHE_ONLY finish misses and stale hits without PRIM o | ||
| 343 | 358 | target.companion.stop(); |
| 344 | 359 | }); |
| 345 | 360 | |
| 361 | +test("complete cached results are stale only before the fifteen-minute useful boundary", function () { | |
| 362 | + var boundary = contracts.USEFUL_STALE_SECONDS * 1000; | |
| 363 | + [ | |
| 364 | + { age: boundary - 1, hasData: true, error: 0 }, | |
| 365 | + { age: boundary, hasData: false, error: 6 }, | |
| 366 | + { age: boundary + 1, hasData: false, error: 6 } | |
| 367 | + ].forEach(function (expected) { | |
| 368 | + var clock = new fakes.FakeClock(); | |
| 369 | + var storage = configuredStorage(); | |
| 370 | + seedOverview(storage, [FIRST], clock); | |
| 371 | + clock.advance(expected.age); | |
| 372 | + var target = harness({ storage: storage, clock: clock }); | |
| 373 | + ready(target); | |
| 374 | + sendDetail(target, "cached", FIRST, contracts.REQUEST_TRIGGER.CACHE_ONLY); | |
| 375 | + drain(target); | |
| 376 | + var cached = rows(target, "cached")[0]; | |
| 377 | + assert.equal(cached.hasData, expected.hasData); | |
| 378 | + assert.equal(cached.stale, true); | |
| 379 | + assert.equal(cached.error, expected.error); | |
| 380 | + assert.equal(requests(target, "departures").length, 0); | |
| 381 | + assert.equal(requests(target, "traffic").length, 0); | |
| 382 | + }); | |
| 383 | +}); | |
| 384 | + | |
| 385 | +test("429 is terminal for one event, preserves useful stale data, and increments only a safe counter", function () { | |
| 386 | + var clock = new fakes.FakeClock(); | |
| 387 | + var storage = configuredStorage(); | |
| 388 | + seedOverview(storage, [FIRST], clock); | |
| 389 | + clock.advance(contracts.CACHE_FRESH_SECONDS * 1000); | |
| 390 | + var target = harness({ storage: storage, clock: clock }); | |
| 391 | + ready(target); | |
| 392 | + sendDetail(target, "quota", FIRST, contracts.REQUEST_TRIGGER.MANUAL_SELECT); | |
| 393 | + var departure = requests(target, "departures")[0]; | |
| 394 | + departure.respond(429, "", { "Retry-After": "17" }); | |
| 395 | + target.clock.advance(0); | |
| 396 | + resolveProduction(target, [FIRST]); | |
| 397 | + drain(target); | |
| 398 | + var quota = rows(target, "quota").at(-1); | |
| 399 | + assert.equal(quota.hasData, true); | |
| 400 | + assert.equal(quota.stale, true); | |
| 401 | + assert.equal(quota.error, 5); | |
| 402 | + assert.equal(target.companion.metrics().rateLimitedResponses, 1); | |
| 403 | + assert.doesNotMatch(JSON.stringify(target.companion.metrics()), /test-personal|favorite|IDFM|apikey|Authorization/); | |
| 404 | + target.clock.advance(contracts.LIMITS.httpTimeoutMs * 2); | |
| 405 | + assert.equal(requests(target, "departures").length, 1); | |
| 406 | + assert.equal(requests(target, "traffic").length, 1); | |
| 407 | +}); | |
| 408 | + | |
| 409 | +test("the failure table deterministically projects unconfigured, stale, or unavailable", function () { | |
| 410 | + [ | |
| 411 | + { name: "missing key", keyStatus: contracts.KEY_STATUS.MISSING, error: 1, state: "UNCONFIGURED" }, | |
| 412 | + { name: "invalid key", keyStatus: contracts.KEY_STATUS.INVALID, error: 3, state: "UNCONFIGURED" }, | |
| 413 | + { name: "revoked key", status: 401, error: 3, state: "UNCONFIGURED" }, | |
| 414 | + { name: "disconnect", status: 0, error: 7, state: "STALE" }, | |
| 415 | + { name: "source failure", status: 503, error: 7, state: "STALE" }, | |
| 416 | + { name: "rate limit", status: 429, error: 5, state: "STALE" }, | |
| 417 | + { name: "malformed response", status: 200, body: {}, error: 7, state: "STALE" }, | |
| 418 | + { name: "partial response", status: 200, body: { Siri: {} }, error: 7, state: "STALE" }, | |
| 419 | + { name: "timeout without cache", timeout: true, noCache: true, error: 7, state: "UNAVAILABLE" } | |
| 420 | + ].forEach(function (failure) { | |
| 421 | + var clock = new fakes.FakeClock(); | |
| 422 | + var storage; | |
| 423 | + if (failure.keyStatus === contracts.KEY_STATUS.MISSING) { | |
| 424 | + storage = new fakes.FakeStorage(); | |
| 425 | + assert.equal(configuration.saveConfiguration(storage, { | |
| 426 | + schemaVersion: 1, favorites: [FIRST], primApiKey: null, keyStatus: contracts.KEY_STATUS.MISSING | |
| 427 | + }), true); | |
| 428 | + } else storage = configuredStorage([FIRST], failure.keyStatus); | |
| 429 | + if (!failure.noCache) seedOverview(storage, [FIRST], clock); | |
| 430 | + clock.advance(contracts.CACHE_FRESH_SECONDS * 1000); | |
| 431 | + var target = harness({ storage: storage, clock: clock }); | |
| 432 | + ready(target); | |
| 433 | + sendDetail(target, failure.name, FIRST, contracts.REQUEST_TRIGGER.MANUAL_SELECT); | |
| 434 | + if (typeof failure.status === "number" || failure.timeout) { | |
| 435 | + respond(target, requests(target, "traffic")[0], 200, trafficBody([FIRST])); | |
| 436 | + if (failure.timeout) target.clock.advance(contracts.LIMITS.httpTimeoutMs); | |
| 437 | + else respond(target, requests(target, "departures")[0], failure.status, | |
| 438 | + typeof failure.body === "undefined" ? "" : failure.body); | |
| 439 | + } | |
| 440 | + drain(target); | |
| 441 | + var record = rows(target, failure.name).at(-1); | |
| 442 | + var state = record.error === 1 || record.error === 3 | |
| 443 | + ? "UNCONFIGURED" : record.hasData ? "STALE" : "UNAVAILABLE"; | |
| 444 | + assert.equal(record.error, failure.error, failure.name); | |
| 445 | + assert.equal(state, failure.state, failure.name); | |
| 446 | + target.companion.stop(); | |
| 447 | + }); | |
| 448 | +}); | |
| 449 | + | |
| 346 | 450 | test("an opening overview completes after cache-only detail entry and local Back", function () { |
| 347 | 451 | var target = harness(); ready(target); sendOverview(target, "opening-overview"); |
| 348 | 452 | sendDetail(target, "empty-detail", FIRST, contracts.REQUEST_TRIGGER.CACHE_ONLY); |
| @@ -672,6 +776,24 @@ test("credential replacement ignores saved callbacks from the canceled lifecycle | ||
| 672 | 776 | target.companion.stop(); |
| 673 | 777 | }); |
| 674 | 778 | |
| 779 | +test("credential removal erases the key, retains favorites, and blocks PRIM until replacement", function () { | |
| 780 | + var storage = configuredStorage([FIRST, SECOND]); | |
| 781 | + var target = harness({ storage: storage }); | |
| 782 | + ready(target); | |
| 783 | + closeWith(target, "REMOVE", [FIRST, SECOND]); | |
| 784 | + acknowledgeConfiguration(target, 0); | |
| 785 | + var saved = configuration.loadConfiguration(storage); | |
| 786 | + assert.equal(saved.primApiKey, null); | |
| 787 | + assert.equal(saved.keyStatus, contracts.KEY_STATUS.MISSING); | |
| 788 | + assert.deepEqual(saved.favorites, [FIRST, SECOND]); | |
| 789 | + assert.doesNotMatch(JSON.stringify(storage.values), new RegExp(TEST_KEY)); | |
| 790 | + sendDetail(target, "removed-key", FIRST, contracts.REQUEST_TRIGGER.MANUAL_SELECT); | |
| 791 | + assert.equal(rows(target, "removed-key")[0].error, 1); | |
| 792 | + assert.equal(requests(target, "departures").length, 0); | |
| 793 | + assert.equal(requests(target, "traffic").length, 0); | |
| 794 | + target.companion.stop(); | |
| 795 | +}); | |
| 796 | + | |
| 675 | 797 | test("interrupted same-key replacement never clears durable invalid-key authority", function () { |
| 676 | 798 | ["marker-removal", "configured-write"].forEach(function (failure) { |
| 677 | 799 | var storage = configuredStorage(), target = harness({ storage: storage }), write = storage.setItem, remove = storage.removeItem; |
| @@ -199,6 +199,21 @@ function closeWith(target, action, favorites, value, forceFull) { | |||
| 199 | if (forceFull) update.forceFullSync = true; | 199 | if (forceFull) update.forceFullSync = true; |
| 200 | target.Pebble.emit("webviewclosed", { response: "pebblejs://close#" + encodeURIComponent(JSON.stringify(update)) }); | 200 | target.Pebble.emit("webviewclosed", { response: "pebblejs://close#" + encodeURIComponent(JSON.stringify(update)) }); |
| 201 | } | 201 | } |
| 202 | + | ||
| 203 | +test("Core Android decoded close responses preserve favorites with line colors", function () { | ||
| 204 | + var target = harness({ storage: configuredStorage([]) }); | ||
| 205 | + var update = { | ||
| 206 | + schemaVersion: 1, | ||
| 207 | + favorites: [Object.assign({}, FIRST, { sortOrder: 0 })], | ||
| 208 | + apiKeyUpdate: { schemaVersion: 1, action: "KEEP" } | ||
| 209 | + }; | ||
| 210 | + | ||
| 211 | + target.Pebble.emit("webviewclosed", { response: JSON.stringify(update) }); | ||
| 212 | + | ||
| 213 | + assert.deepEqual(configuration.loadConfiguration(target.storage).favorites, update.favorites); | ||
| 214 | + target.companion.stop(); | ||
| 215 | +}); | ||
| 216 | + | ||
| 202 | function serviceRow(favorite) { | 217 | function serviceRow(favorite) { |
| 203 | return { serviceId: favorite.serviceId, stopLabel: favorite.stopLabel, lineLabel: favorite.lineLabel, | 218 | return { serviceId: favorite.serviceId, stopLabel: favorite.stopLabel, lineLabel: favorite.lineLabel, |
| 204 | destinationLabel: favorite.destinationLabel, lineMode: favorite.lineMode, | 219 | destinationLabel: favorite.destinationLabel, lineMode: favorite.lineMode, |
| @@ -343,6 +358,95 @@ test("overview and detail CACHE_ONLY finish misses and stale hits without PRIM o | |||
| 343 | target.companion.stop(); | 358 | target.companion.stop(); |
| 344 | }); | 359 | }); |
| 345 | 360 | ||
| 361 | +test("complete cached results are stale only before the fifteen-minute useful boundary", function () { | ||
| 362 | + var boundary = contracts.USEFUL_STALE_SECONDS * 1000; | ||
| 363 | + [ | ||
| 364 | + { age: boundary - 1, hasData: true, error: 0 }, | ||
| 365 | + { age: boundary, hasData: false, error: 6 }, | ||
| 366 | + { age: boundary + 1, hasData: false, error: 6 } | ||
| 367 | + ].forEach(function (expected) { | ||
| 368 | + var clock = new fakes.FakeClock(); | ||
| 369 | + var storage = configuredStorage(); | ||
| 370 | + seedOverview(storage, [FIRST], clock); | ||
| 371 | + clock.advance(expected.age); | ||
| 372 | + var target = harness({ storage: storage, clock: clock }); | ||
| 373 | + ready(target); | ||
| 374 | + sendDetail(target, "cached", FIRST, contracts.REQUEST_TRIGGER.CACHE_ONLY); | ||
| 375 | + drain(target); | ||
| 376 | + var cached = rows(target, "cached")[0]; | ||
| 377 | + assert.equal(cached.hasData, expected.hasData); | ||
| 378 | + assert.equal(cached.stale, true); | ||
| 379 | + assert.equal(cached.error, expected.error); | ||
| 380 | + assert.equal(requests(target, "departures").length, 0); | ||
| 381 | + assert.equal(requests(target, "traffic").length, 0); | ||
| 382 | + }); | ||
| 383 | +}); | ||
| 384 | + | ||
| 385 | +test("429 is terminal for one event, preserves useful stale data, and increments only a safe counter", function () { | ||
| 386 | + var clock = new fakes.FakeClock(); | ||
| 387 | + var storage = configuredStorage(); | ||
| 388 | + seedOverview(storage, [FIRST], clock); | ||
| 389 | + clock.advance(contracts.CACHE_FRESH_SECONDS * 1000); | ||
| 390 | + var target = harness({ storage: storage, clock: clock }); | ||
| 391 | + ready(target); | ||
| 392 | + sendDetail(target, "quota", FIRST, contracts.REQUEST_TRIGGER.MANUAL_SELECT); | ||
| 393 | + var departure = requests(target, "departures")[0]; | ||
| 394 | + departure.respond(429, "", { "Retry-After": "17" }); | ||
| 395 | + target.clock.advance(0); | ||
| 396 | + resolveProduction(target, [FIRST]); | ||
| 397 | + drain(target); | ||
| 398 | + var quota = rows(target, "quota").at(-1); | ||
| 399 | + assert.equal(quota.hasData, true); | ||
| 400 | + assert.equal(quota.stale, true); | ||
| 401 | + assert.equal(quota.error, 5); | ||
| 402 | + assert.equal(target.companion.metrics().rateLimitedResponses, 1); | ||
| 403 | + assert.doesNotMatch(JSON.stringify(target.companion.metrics()), /test-personal|favorite|IDFM|apikey|Authorization/); | ||
| 404 | + target.clock.advance(contracts.LIMITS.httpTimeoutMs * 2); | ||
| 405 | + assert.equal(requests(target, "departures").length, 1); | ||
| 406 | + assert.equal(requests(target, "traffic").length, 1); | ||
| 407 | +}); | ||
| 408 | + | ||
| 409 | +test("the failure table deterministically projects unconfigured, stale, or unavailable", function () { | ||
| 410 | + [ | ||
| 411 | + { name: "missing key", keyStatus: contracts.KEY_STATUS.MISSING, error: 1, state: "UNCONFIGURED" }, | ||
| 412 | + { name: "invalid key", keyStatus: contracts.KEY_STATUS.INVALID, error: 3, state: "UNCONFIGURED" }, | ||
| 413 | + { name: "revoked key", status: 401, error: 3, state: "UNCONFIGURED" }, | ||
| 414 | + { name: "disconnect", status: 0, error: 7, state: "STALE" }, | ||
| 415 | + { name: "source failure", status: 503, error: 7, state: "STALE" }, | ||
| 416 | + { name: "rate limit", status: 429, error: 5, state: "STALE" }, | ||
| 417 | + { name: "malformed response", status: 200, body: {}, error: 7, state: "STALE" }, | ||
| 418 | + { name: "partial response", status: 200, body: { Siri: {} }, error: 7, state: "STALE" }, | ||
| 419 | + { name: "timeout without cache", timeout: true, noCache: true, error: 7, state: "UNAVAILABLE" } | ||
| 420 | + ].forEach(function (failure) { | ||
| 421 | + var clock = new fakes.FakeClock(); | ||
| 422 | + var storage; | ||
| 423 | + if (failure.keyStatus === contracts.KEY_STATUS.MISSING) { | ||
| 424 | + storage = new fakes.FakeStorage(); | ||
| 425 | + assert.equal(configuration.saveConfiguration(storage, { | ||
| 426 | + schemaVersion: 1, favorites: [FIRST], primApiKey: null, keyStatus: contracts.KEY_STATUS.MISSING | ||
| 427 | + }), true); | ||
| 428 | + } else storage = configuredStorage([FIRST], failure.keyStatus); | ||
| 429 | + if (!failure.noCache) seedOverview(storage, [FIRST], clock); | ||
| 430 | + clock.advance(contracts.CACHE_FRESH_SECONDS * 1000); | ||
| 431 | + var target = harness({ storage: storage, clock: clock }); | ||
| 432 | + ready(target); | ||
| 433 | + sendDetail(target, failure.name, FIRST, contracts.REQUEST_TRIGGER.MANUAL_SELECT); | ||
| 434 | + if (typeof failure.status === "number" || failure.timeout) { | ||
| 435 | + respond(target, requests(target, "traffic")[0], 200, trafficBody([FIRST])); | ||
| 436 | + if (failure.timeout) target.clock.advance(contracts.LIMITS.httpTimeoutMs); | ||
| 437 | + else respond(target, requests(target, "departures")[0], failure.status, | ||
| 438 | + typeof failure.body === "undefined" ? "" : failure.body); | ||
| 439 | + } | ||
| 440 | + drain(target); | ||
| 441 | + var record = rows(target, failure.name).at(-1); | ||
| 442 | + var state = record.error === 1 || record.error === 3 | ||
| 443 | + ? "UNCONFIGURED" : record.hasData ? "STALE" : "UNAVAILABLE"; | ||
| 444 | + assert.equal(record.error, failure.error, failure.name); | ||
| 445 | + assert.equal(state, failure.state, failure.name); | ||
| 446 | + target.companion.stop(); | ||
| 447 | + }); | ||
| 448 | +}); | ||
| 449 | + | ||
| 346 | test("an opening overview completes after cache-only detail entry and local Back", function () { | 450 | test("an opening overview completes after cache-only detail entry and local Back", function () { |
| 347 | var target = harness(); ready(target); sendOverview(target, "opening-overview"); | 451 | var target = harness(); ready(target); sendOverview(target, "opening-overview"); |
| 348 | sendDetail(target, "empty-detail", FIRST, contracts.REQUEST_TRIGGER.CACHE_ONLY); | 452 | sendDetail(target, "empty-detail", FIRST, contracts.REQUEST_TRIGGER.CACHE_ONLY); |
| @@ -672,6 +776,24 @@ test("credential replacement ignores saved callbacks from the canceled lifecycle | |||
| 672 | target.companion.stop(); | 776 | target.companion.stop(); |
| 673 | }); | 777 | }); |
| 674 | 778 | ||
| 779 | +test("credential removal erases the key, retains favorites, and blocks PRIM until replacement", function () { | ||
| 780 | + var storage = configuredStorage([FIRST, SECOND]); | ||
| 781 | + var target = harness({ storage: storage }); | ||
| 782 | + ready(target); | ||
| 783 | + closeWith(target, "REMOVE", [FIRST, SECOND]); | ||
| 784 | + acknowledgeConfiguration(target, 0); | ||
| 785 | + var saved = configuration.loadConfiguration(storage); | ||
| 786 | + assert.equal(saved.primApiKey, null); | ||
| 787 | + assert.equal(saved.keyStatus, contracts.KEY_STATUS.MISSING); | ||
| 788 | + assert.deepEqual(saved.favorites, [FIRST, SECOND]); | ||
| 789 | + assert.doesNotMatch(JSON.stringify(storage.values), new RegExp(TEST_KEY)); | ||
| 790 | + sendDetail(target, "removed-key", FIRST, contracts.REQUEST_TRIGGER.MANUAL_SELECT); | ||
| 791 | + assert.equal(rows(target, "removed-key")[0].error, 1); | ||
| 792 | + assert.equal(requests(target, "departures").length, 0); | ||
| 793 | + assert.equal(requests(target, "traffic").length, 0); | ||
| 794 | + target.companion.stop(); | ||
| 795 | +}); | ||
| 796 | + | ||
| 675 | test("interrupted same-key replacement never clears durable invalid-key authority", function () { | 797 | test("interrupted same-key replacement never clears durable invalid-key authority", function () { |
| 676 | ["marker-removal", "configured-write"].forEach(function (failure) { | 798 | ["marker-removal", "configured-write"].forEach(function (failure) { |
| 677 | var storage = configuredStorage(), target = harness({ storage: storage }), write = storage.setItem, remove = storage.removeItem; | 799 | var storage = configuredStorage(), target = harness({ storage: storage }), write = storage.setItem, remove = storage.removeItem; |
modified
packages/companion/test/prim-client.test.js +4 -2 | @@ -411,8 +411,10 @@ test("transport errors and malformed traffic are unavailable, not successful UNK | ||
| 411 | 411 | h.transport.instances[1].respond(503, {}); |
| 412 | 412 | h.clock.advance(0); |
| 413 | 413 | assertError(result.values[1], "SOURCE_UNAVAILABLE", TRAFFIC_MS); |
| 414 | - requestTraffic(h, "IDFM:C300", result); | |
| 415 | - h.transport.instances[2].respond(200, trafficFixture()); | |
| 414 | + requestTraffic(h, "IDFM:C400", result); | |
| 415 | + var uncertain = trafficFixture(); | |
| 416 | + delete uncertain.disruptions[3].message; | |
| 417 | + h.transport.instances[2].respond(200, uncertain); | |
| 416 | 418 | h.clock.advance(0); |
| 417 | 419 | assert.deepEqual(result.values[2], { status: "AVAILABLE", data: { schemaVersion: 1, state: "UNKNOWN", checkedAt: TRAFFIC_MS / 1000 } }); |
| 418 | 420 | }); |
| @@ -411,8 +411,10 @@ test("transport errors and malformed traffic are unavailable, not successful UNK | |||
| 411 | h.transport.instances[1].respond(503, {}); | 411 | h.transport.instances[1].respond(503, {}); |
| 412 | h.clock.advance(0); | 412 | h.clock.advance(0); |
| 413 | assertError(result.values[1], "SOURCE_UNAVAILABLE", TRAFFIC_MS); | 413 | assertError(result.values[1], "SOURCE_UNAVAILABLE", TRAFFIC_MS); |
| 414 | - requestTraffic(h, "IDFM:C300", result); | 414 | + requestTraffic(h, "IDFM:C400", result); |
| 415 | - h.transport.instances[2].respond(200, trafficFixture()); | 415 | + var uncertain = trafficFixture(); |
| 416 | + delete uncertain.disruptions[3].message; | ||
| 417 | + h.transport.instances[2].respond(200, uncertain); | ||
| 416 | h.clock.advance(0); | 418 | h.clock.advance(0); |
| 417 | assert.deepEqual(result.values[2], { status: "AVAILABLE", data: { schemaVersion: 1, state: "UNKNOWN", checkedAt: TRAFFIC_MS / 1000 } }); | 419 | assert.deepEqual(result.values[2], { status: "AVAILABLE", data: { schemaVersion: 1, state: "UNKNOWN", checkedAt: TRAFFIC_MS / 1000 } }); |
| 418 | }); | 420 | }); |
modified
packages/companion/test/prim-normalization.test.js +29 -7 | @@ -124,6 +124,13 @@ test("delays, cancelled visits and scheduled-only visits retain their distinct s | ||
| 124 | 124 | ])); |
| 125 | 125 | }); |
| 126 | 126 | |
| 127 | +test("a valid response without visits reports no scheduled departures", function () { | |
| 128 | + var payload = fixture("metro"); | |
| 129 | + payload.Siri.ServiceDelivery.StopMonitoringDelivery[0].MonitoredStopVisit = []; | |
| 130 | + assert.deepEqual(departures.normalizePrimDepartureResponse(payload, routing(2001), FETCHED_AT), | |
| 131 | + snapshot("SCHEDULED", [])); | |
| 132 | +}); | |
| 133 | + | |
| 127 | 134 | test("foreign stop, line and destination visits do not contaminate departures or source freshness", function () { |
| 128 | 135 | var payload = fixture("partial"); |
| 129 | 136 | var list = visits(payload); |
| @@ -223,8 +230,8 @@ test("upstream routing may exceed watch ID lengths without leaking into the snap | ||
| 223 | 230 | test("traffic distinguishes observed normal, useful disruption and source uncertainty", function () { |
| 224 | 231 | var payload = trafficFixture(); |
| 225 | 232 | var expected = { |
| 226 | - C100: "NORMAL", C200: "DELAYED", C300: "UNKNOWN", C400: "UNKNOWN", | |
| 227 | - C500: "NORMAL", C600: "NORMAL", C999: "UNKNOWN" | |
| 233 | + C100: "NORMAL", C200: "DELAYED", C300: "STOPPED", C400: "STOPPED", | |
| 234 | + C500: "NORMAL", C600: "NORMAL", C999: "NORMAL" | |
| 228 | 235 | }; |
| 229 | 236 | Object.keys(expected).forEach(function (line) { |
| 230 | 237 | var result = trafficDetail(payload, "IDFM:" + line); |
| @@ -241,13 +248,28 @@ test("traffic distinguishes observed normal, useful disruption and source uncert | ||
| 241 | 248 | }); |
| 242 | 249 | }); |
| 243 | 250 | |
| 244 | -test("BLOQUANTE never invents STOPPED and outranks usable PERTURBEE details", function () { | |
| 251 | +test("lines omitted from the disruption-only bulk feed have normal traffic", function () { | |
| 245 | 252 | var payload = trafficFixture(); |
| 246 | - payload.disruptions[2].severity.effect = "NO_SERVICE"; | |
| 247 | - payload.lines[1].impactedObjects[0].disruptionIds = ["delay-active", "stopped-active"]; | |
| 248 | - assert.equal(trafficDetail(payload, "IDFM:C200").state, "UNKNOWN"); | |
| 253 | + payload.lines = payload.lines.filter(function (line) { return line.id !== "line:IDFM:C100"; }); | |
| 254 | + assert.equal(trafficDetail(payload, "IDFM:C100").state, "NORMAL"); | |
| 255 | + assert.equal(trafficDetail(payload, "invalid-line").state, "UNKNOWN"); | |
| 256 | +}); | |
| 257 | + | |
| 258 | +test("BLOQUANTE produces STOPPED and outranks usable PERTURBEE details", function () { | |
| 259 | + var payload = trafficFixture(); | |
| 260 | + payload.lines[1].impactedObjects[0].disruptionIds = ["delay-active", "blocking-unclassified"]; | |
| 261 | + assert.deepEqual(trafficDetail(payload, "IDFM:C200"), { | |
| 262 | + schemaVersion: 1, state: "STOPPED", checkedAt: TRAFFIC_MS / 1000, | |
| 263 | + title: "Tram T3 : incident", text: "Une perturbation est en cours." | |
| 264 | + }); | |
| 249 | 265 | payload.lines[1].impactedObjects[0].disruptionIds.reverse(); |
| 250 | - assert.equal(trafficDetail(payload, "IDFM:C200").state, "UNKNOWN"); | |
| 266 | + assert.equal(trafficDetail(payload, "IDFM:C200").state, "STOPPED"); | |
| 267 | +}); | |
| 268 | + | |
| 269 | +test("an active disruption without complete details remains UNKNOWN", function () { | |
| 270 | + var payload = trafficFixture(); | |
| 271 | + delete payload.disruptions[3].message; | |
| 272 | + assert.equal(trafficDetail(payload, "IDFM:C400").state, "UNKNOWN"); | |
| 251 | 273 | }); |
| 252 | 274 | |
| 253 | 275 | test("Paris periods are inclusive at start and exclusive at end, including summer time", function () { |
| @@ -124,6 +124,13 @@ test("delays, cancelled visits and scheduled-only visits retain their distinct s | |||
| 124 | ])); | 124 | ])); |
| 125 | }); | 125 | }); |
| 126 | 126 | ||
| 127 | +test("a valid response without visits reports no scheduled departures", function () { | ||
| 128 | + var payload = fixture("metro"); | ||
| 129 | + payload.Siri.ServiceDelivery.StopMonitoringDelivery[0].MonitoredStopVisit = []; | ||
| 130 | + assert.deepEqual(departures.normalizePrimDepartureResponse(payload, routing(2001), FETCHED_AT), | ||
| 131 | + snapshot("SCHEDULED", [])); | ||
| 132 | +}); | ||
| 133 | + | ||
| 127 | test("foreign stop, line and destination visits do not contaminate departures or source freshness", function () { | 134 | test("foreign stop, line and destination visits do not contaminate departures or source freshness", function () { |
| 128 | var payload = fixture("partial"); | 135 | var payload = fixture("partial"); |
| 129 | var list = visits(payload); | 136 | var list = visits(payload); |
| @@ -223,8 +230,8 @@ test("upstream routing may exceed watch ID lengths without leaking into the snap | |||
| 223 | test("traffic distinguishes observed normal, useful disruption and source uncertainty", function () { | 230 | test("traffic distinguishes observed normal, useful disruption and source uncertainty", function () { |
| 224 | var payload = trafficFixture(); | 231 | var payload = trafficFixture(); |
| 225 | var expected = { | 232 | var expected = { |
| 226 | - C100: "NORMAL", C200: "DELAYED", C300: "UNKNOWN", C400: "UNKNOWN", | 233 | + C100: "NORMAL", C200: "DELAYED", C300: "STOPPED", C400: "STOPPED", |
| 227 | - C500: "NORMAL", C600: "NORMAL", C999: "UNKNOWN" | 234 | + C500: "NORMAL", C600: "NORMAL", C999: "NORMAL" |
| 228 | }; | 235 | }; |
| 229 | Object.keys(expected).forEach(function (line) { | 236 | Object.keys(expected).forEach(function (line) { |
| 230 | var result = trafficDetail(payload, "IDFM:" + line); | 237 | var result = trafficDetail(payload, "IDFM:" + line); |
| @@ -241,13 +248,28 @@ test("traffic distinguishes observed normal, useful disruption and source uncert | |||
| 241 | }); | 248 | }); |
| 242 | }); | 249 | }); |
| 243 | 250 | ||
| 244 | -test("BLOQUANTE never invents STOPPED and outranks usable PERTURBEE details", function () { | 251 | +test("lines omitted from the disruption-only bulk feed have normal traffic", function () { |
| 245 | var payload = trafficFixture(); | 252 | var payload = trafficFixture(); |
| 246 | - payload.disruptions[2].severity.effect = "NO_SERVICE"; | 253 | + payload.lines = payload.lines.filter(function (line) { return line.id !== "line:IDFM:C100"; }); |
| 247 | - payload.lines[1].impactedObjects[0].disruptionIds = ["delay-active", "stopped-active"]; | 254 | + assert.equal(trafficDetail(payload, "IDFM:C100").state, "NORMAL"); |
| 248 | - assert.equal(trafficDetail(payload, "IDFM:C200").state, "UNKNOWN"); | 255 | + assert.equal(trafficDetail(payload, "invalid-line").state, "UNKNOWN"); |
| 256 | +}); | ||
| 257 | + | ||
| 258 | +test("BLOQUANTE produces STOPPED and outranks usable PERTURBEE details", function () { | ||
| 259 | + var payload = trafficFixture(); | ||
| 260 | + payload.lines[1].impactedObjects[0].disruptionIds = ["delay-active", "blocking-unclassified"]; | ||
| 261 | + assert.deepEqual(trafficDetail(payload, "IDFM:C200"), { | ||
| 262 | + schemaVersion: 1, state: "STOPPED", checkedAt: TRAFFIC_MS / 1000, | ||
| 263 | + title: "Tram T3 : incident", text: "Une perturbation est en cours." | ||
| 264 | + }); | ||
| 249 | payload.lines[1].impactedObjects[0].disruptionIds.reverse(); | 265 | payload.lines[1].impactedObjects[0].disruptionIds.reverse(); |
| 250 | - assert.equal(trafficDetail(payload, "IDFM:C200").state, "UNKNOWN"); | 266 | + assert.equal(trafficDetail(payload, "IDFM:C200").state, "STOPPED"); |
| 267 | +}); | ||
| 268 | + | ||
| 269 | +test("an active disruption without complete details remains UNKNOWN", function () { | ||
| 270 | + var payload = trafficFixture(); | ||
| 271 | + delete payload.disruptions[3].message; | ||
| 272 | + assert.equal(trafficDetail(payload, "IDFM:C400").state, "UNKNOWN"); | ||
| 251 | }); | 273 | }); |
| 252 | 274 | ||
| 253 | test("Paris periods are inclusive at start and exclusive at end, including summer time", function () { | 275 | test("Paris periods are inclusive at start and exclusive at end, including summer time", function () { |
modified
packages/config-page/index.html +6 -6 | @@ -152,13 +152,13 @@ | ||
| 152 | 152 | <p><strong>Publication director:</strong> Anthodev.</p> |
| 153 | 153 | <p>Public contact: <a href="https://github.com/Anthodev/lapin-fute/issues" target="_blank" rel="noopener noreferrer">GitHub Issues</a>.</p> |
| 154 | 154 | <h3>Hosting</h3> |
| 155 | - <p>GitHub, Inc., 88 Colin P. Kelly Jr. St., San Francisco, CA 94107, USA. Electronic support: <a href="https://support.github.com/contact" target="_blank" rel="noopener noreferrer">GitHub Support</a>.</p> | |
| 155 | + <p>BunnyWay d.o.o., Dunajska cesta 165, 1000 Ljubljana, Slovenia. Electronic support: <a href="https://support.bunny.net/" target="_blank" rel="noopener noreferrer">bunny.net Support</a>.</p> | |
| 156 | 156 | <h3>Privacy and data flow</h3> |
| 157 | 157 | <p>This app page sets no cookies and uses no analytics or telemetry.</p> |
| 158 | - <p>Your saved personal PRIM key stays in your phone’s localStorage. When you request departures, the phone sends the key over HTTPS to the Lapin Futé backend. The backend relays it transiently to PRIM and does not store it.</p> | |
| 158 | + <p>Your saved personal PRIM key stays in your phone’s localStorage. On a permitted refresh event, the phone sends it directly to PRIM over HTTPS; Lapin Futé has no hosted backend and the key never reaches this configuration host.</p> | |
| 159 | 159 | <p>The UUID-scoped phone record is plaintext and survives app updates and uninstall/reinstall. Uninstalling the watch app erases only its offline favorites and key-status copy; remove the key here or clear the phone’s browser storage to erase the phone record.</p> |
| 160 | 160 | <p>When you search for a stop, the search terms are sent to the same-origin Lapin Futé catalog endpoint solely to return matching stops. The app does not persist them; hosting infrastructure may log requested URLs and other connection metadata.</p> |
| 161 | - <p>GitHub Pages hosts this page. GitHub may process connection data and requested URLs under its <a href="https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement" target="_blank" rel="noopener noreferrer">General Privacy Statement</a>.</p> | |
| 161 | + <p>bunny.net hosts this page and its static catalog. BunnyWay may process connection data and requested URLs under its <a href="https://bunny.net/privacy/" target="_blank" rel="noopener noreferrer">Privacy & Data Policy</a>.</p> | |
| 162 | 162 | <h3>Data and credits</h3> |
| 163 | 163 | <p>Transport line names and colors come from the Île-de-France Mobilités <a href="https://data.iledefrance-mobilites.fr/explore/dataset/referentiel-des-lignes/" target="_blank" rel="noopener noreferrer">Référentiel des lignes</a> database, licensed under <a href="https://opendatacommons.org/licenses/odbl/1-0/" target="_blank" rel="noopener noreferrer">ODbL 1.0</a>. Official line pictograms are © Île-de-France Mobilités and are reused subject to the <a href="https://prim.iledefrance-mobilites.fr/fr/conditions-utilisation" target="_blank" rel="noopener noreferrer">IDFM/PRIM terms of use</a>.</p> |
| 164 | 164 | <p>Real-time departures come from the Île-de-France Mobilités <a href="https://prim.iledefrance-mobilites.fr/" target="_blank" rel="noopener noreferrer">PRIM platform</a> using your personal key and remain subject to the terms and licences published by PRIM.</p> |
| @@ -174,13 +174,13 @@ | ||
| 174 | 174 | <p><strong>Directeur de la publication :</strong> Anthodev.</p> |
| 175 | 175 | <p>Contact public : <a href="https://github.com/Anthodev/lapin-fute/issues" target="_blank" rel="noopener noreferrer">GitHub Issues</a>.</p> |
| 176 | 176 | <h3>Hébergement</h3> |
| 177 | - <p>GitHub, Inc., 88 Colin P. Kelly Jr. St., San Francisco, CA 94107, États-Unis. Assistance électronique : <a href="https://support.github.com/contact" target="_blank" rel="noopener noreferrer">support GitHub</a>.</p> | |
| 177 | + <p>BunnyWay d.o.o., Dunajska cesta 165, 1000 Ljubljana, Slovénie. Assistance électronique : <a href="https://support.bunny.net/" target="_blank" rel="noopener noreferrer">support bunny.net</a>.</p> | |
| 178 | 178 | <h3>Vie privée et circulation des données</h3> |
| 179 | 179 | <p>Cette page de l’application ne dépose aucun cookie et n’utilise ni mesure d’audience, ni analyse comportementale, ni télémétrie.</p> |
| 180 | - <p>Votre clé PRIM personnelle enregistrée reste dans le localStorage de votre téléphone. Lors d’une demande de départs, le téléphone transmet la clé en HTTPS au serveur Lapin Futé. Le serveur la relaie temporairement vers PRIM sans la conserver.</p> | |
| 180 | + <p>Votre clé PRIM personnelle enregistrée reste dans le localStorage de votre téléphone. Lors d’un événement de rafraîchissement autorisé, le téléphone l’envoie directement à PRIM en HTTPS ; Lapin Futé n’a aucun serveur applicatif hébergé et la clé n’atteint jamais cet hébergeur de configuration.</p> | |
| 181 | 181 | <p>L’enregistrement en clair du téléphone, associé à l’UUID de l’application, survit aux mises à jour ainsi qu’à une désinstallation suivie d’une réinstallation. Désinstaller l’application de la montre n’efface que sa copie hors ligne des favoris et de l’état de la clé ; supprimez la clé ici ou effacez le stockage du navigateur du téléphone pour supprimer l’enregistrement du téléphone.</p> |
| 182 | 182 | <p>Lors d’une recherche d’arrêt, les termes saisis sont transmis au point d’accès catalogue de Lapin Futé sur la même origine, uniquement pour retourner les arrêts correspondants. L’application ne les conserve pas ; l’infrastructure d’hébergement peut journaliser les URL demandées et d’autres métadonnées de connexion.</p> |
| 183 | - <p>Cette page est hébergée par GitHub Pages. GitHub peut traiter des données de connexion et les URL demandées selon sa <a href="https://docs.github.com/fr/site-policy/privacy-policies/github-general-privacy-statement" target="_blank" rel="noopener noreferrer">déclaration de confidentialité générale</a>.</p> | |
| 183 | + <p>Cette page et son catalogue statique sont hébergés par bunny.net. BunnyWay peut traiter les données de connexion et les URL demandées selon sa <a href="https://bunny.net/privacy/" target="_blank" rel="noopener noreferrer">politique de confidentialité et de traitement des données</a>.</p> | |
| 184 | 184 | <h3>Données et crédits</h3> |
| 185 | 185 | <p>Les noms et couleurs des lignes proviennent de la base <a href="https://data.iledefrance-mobilites.fr/explore/dataset/referentiel-des-lignes/" target="_blank" rel="noopener noreferrer">Référentiel des lignes</a> d’Île-de-France Mobilités, sous licence <a href="https://opendatacommons.org/licenses/odbl/1-0/" target="_blank" rel="noopener noreferrer">ODbL 1.0</a>. Les pictogrammes officiels des lignes sont © Île-de-France Mobilités et réutilisés selon les <a href="https://prim.iledefrance-mobilites.fr/fr/conditions-utilisation" target="_blank" rel="noopener noreferrer">conditions d’utilisation IDFM/PRIM</a>.</p> |
| 186 | 186 | <p>Les départs en temps réel proviennent de la plateforme <a href="https://prim.iledefrance-mobilites.fr/" target="_blank" rel="noopener noreferrer">PRIM</a> d’Île-de-France Mobilités au moyen de votre clé personnelle. Ils restent soumis aux conditions et licences publiées par PRIM.</p> |
| @@ -152,13 +152,13 @@ | |||
| 152 | <p><strong>Publication director:</strong> Anthodev.</p> | 152 | <p><strong>Publication director:</strong> Anthodev.</p> |
| 153 | <p>Public contact: <a href="https://github.com/Anthodev/lapin-fute/issues" target="_blank" rel="noopener noreferrer">GitHub Issues</a>.</p> | 153 | <p>Public contact: <a href="https://github.com/Anthodev/lapin-fute/issues" target="_blank" rel="noopener noreferrer">GitHub Issues</a>.</p> |
| 154 | <h3>Hosting</h3> | 154 | <h3>Hosting</h3> |
| 155 | - <p>GitHub, Inc., 88 Colin P. Kelly Jr. St., San Francisco, CA 94107, USA. Electronic support: <a href="https://support.github.com/contact" target="_blank" rel="noopener noreferrer">GitHub Support</a>.</p> | 155 | + <p>BunnyWay d.o.o., Dunajska cesta 165, 1000 Ljubljana, Slovenia. Electronic support: <a href="https://support.bunny.net/" target="_blank" rel="noopener noreferrer">bunny.net Support</a>.</p> |
| 156 | <h3>Privacy and data flow</h3> | 156 | <h3>Privacy and data flow</h3> |
| 157 | <p>This app page sets no cookies and uses no analytics or telemetry.</p> | 157 | <p>This app page sets no cookies and uses no analytics or telemetry.</p> |
| 158 | - <p>Your saved personal PRIM key stays in your phone’s localStorage. When you request departures, the phone sends the key over HTTPS to the Lapin Futé backend. The backend relays it transiently to PRIM and does not store it.</p> | 158 | + <p>Your saved personal PRIM key stays in your phone’s localStorage. On a permitted refresh event, the phone sends it directly to PRIM over HTTPS; Lapin Futé has no hosted backend and the key never reaches this configuration host.</p> |
| 159 | <p>The UUID-scoped phone record is plaintext and survives app updates and uninstall/reinstall. Uninstalling the watch app erases only its offline favorites and key-status copy; remove the key here or clear the phone’s browser storage to erase the phone record.</p> | 159 | <p>The UUID-scoped phone record is plaintext and survives app updates and uninstall/reinstall. Uninstalling the watch app erases only its offline favorites and key-status copy; remove the key here or clear the phone’s browser storage to erase the phone record.</p> |
| 160 | <p>When you search for a stop, the search terms are sent to the same-origin Lapin Futé catalog endpoint solely to return matching stops. The app does not persist them; hosting infrastructure may log requested URLs and other connection metadata.</p> | 160 | <p>When you search for a stop, the search terms are sent to the same-origin Lapin Futé catalog endpoint solely to return matching stops. The app does not persist them; hosting infrastructure may log requested URLs and other connection metadata.</p> |
| 161 | - <p>GitHub Pages hosts this page. GitHub may process connection data and requested URLs under its <a href="https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement" target="_blank" rel="noopener noreferrer">General Privacy Statement</a>.</p> | 161 | + <p>bunny.net hosts this page and its static catalog. BunnyWay may process connection data and requested URLs under its <a href="https://bunny.net/privacy/" target="_blank" rel="noopener noreferrer">Privacy & Data Policy</a>.</p> |
| 162 | <h3>Data and credits</h3> | 162 | <h3>Data and credits</h3> |
| 163 | <p>Transport line names and colors come from the Île-de-France Mobilités <a href="https://data.iledefrance-mobilites.fr/explore/dataset/referentiel-des-lignes/" target="_blank" rel="noopener noreferrer">Référentiel des lignes</a> database, licensed under <a href="https://opendatacommons.org/licenses/odbl/1-0/" target="_blank" rel="noopener noreferrer">ODbL 1.0</a>. Official line pictograms are © Île-de-France Mobilités and are reused subject to the <a href="https://prim.iledefrance-mobilites.fr/fr/conditions-utilisation" target="_blank" rel="noopener noreferrer">IDFM/PRIM terms of use</a>.</p> | 163 | <p>Transport line names and colors come from the Île-de-France Mobilités <a href="https://data.iledefrance-mobilites.fr/explore/dataset/referentiel-des-lignes/" target="_blank" rel="noopener noreferrer">Référentiel des lignes</a> database, licensed under <a href="https://opendatacommons.org/licenses/odbl/1-0/" target="_blank" rel="noopener noreferrer">ODbL 1.0</a>. Official line pictograms are © Île-de-France Mobilités and are reused subject to the <a href="https://prim.iledefrance-mobilites.fr/fr/conditions-utilisation" target="_blank" rel="noopener noreferrer">IDFM/PRIM terms of use</a>.</p> |
| 164 | <p>Real-time departures come from the Île-de-France Mobilités <a href="https://prim.iledefrance-mobilites.fr/" target="_blank" rel="noopener noreferrer">PRIM platform</a> using your personal key and remain subject to the terms and licences published by PRIM.</p> | 164 | <p>Real-time departures come from the Île-de-France Mobilités <a href="https://prim.iledefrance-mobilites.fr/" target="_blank" rel="noopener noreferrer">PRIM platform</a> using your personal key and remain subject to the terms and licences published by PRIM.</p> |
| @@ -174,13 +174,13 @@ | |||
| 174 | <p><strong>Directeur de la publication :</strong> Anthodev.</p> | 174 | <p><strong>Directeur de la publication :</strong> Anthodev.</p> |
| 175 | <p>Contact public : <a href="https://github.com/Anthodev/lapin-fute/issues" target="_blank" rel="noopener noreferrer">GitHub Issues</a>.</p> | 175 | <p>Contact public : <a href="https://github.com/Anthodev/lapin-fute/issues" target="_blank" rel="noopener noreferrer">GitHub Issues</a>.</p> |
| 176 | <h3>Hébergement</h3> | 176 | <h3>Hébergement</h3> |
| 177 | - <p>GitHub, Inc., 88 Colin P. Kelly Jr. St., San Francisco, CA 94107, États-Unis. Assistance électronique : <a href="https://support.github.com/contact" target="_blank" rel="noopener noreferrer">support GitHub</a>.</p> | 177 | + <p>BunnyWay d.o.o., Dunajska cesta 165, 1000 Ljubljana, Slovénie. Assistance électronique : <a href="https://support.bunny.net/" target="_blank" rel="noopener noreferrer">support bunny.net</a>.</p> |
| 178 | <h3>Vie privée et circulation des données</h3> | 178 | <h3>Vie privée et circulation des données</h3> |
| 179 | <p>Cette page de l’application ne dépose aucun cookie et n’utilise ni mesure d’audience, ni analyse comportementale, ni télémétrie.</p> | 179 | <p>Cette page de l’application ne dépose aucun cookie et n’utilise ni mesure d’audience, ni analyse comportementale, ni télémétrie.</p> |
| 180 | - <p>Votre clé PRIM personnelle enregistrée reste dans le localStorage de votre téléphone. Lors d’une demande de départs, le téléphone transmet la clé en HTTPS au serveur Lapin Futé. Le serveur la relaie temporairement vers PRIM sans la conserver.</p> | 180 | + <p>Votre clé PRIM personnelle enregistrée reste dans le localStorage de votre téléphone. Lors d’un événement de rafraîchissement autorisé, le téléphone l’envoie directement à PRIM en HTTPS ; Lapin Futé n’a aucun serveur applicatif hébergé et la clé n’atteint jamais cet hébergeur de configuration.</p> |
| 181 | <p>L’enregistrement en clair du téléphone, associé à l’UUID de l’application, survit aux mises à jour ainsi qu’à une désinstallation suivie d’une réinstallation. Désinstaller l’application de la montre n’efface que sa copie hors ligne des favoris et de l’état de la clé ; supprimez la clé ici ou effacez le stockage du navigateur du téléphone pour supprimer l’enregistrement du téléphone.</p> | 181 | <p>L’enregistrement en clair du téléphone, associé à l’UUID de l’application, survit aux mises à jour ainsi qu’à une désinstallation suivie d’une réinstallation. Désinstaller l’application de la montre n’efface que sa copie hors ligne des favoris et de l’état de la clé ; supprimez la clé ici ou effacez le stockage du navigateur du téléphone pour supprimer l’enregistrement du téléphone.</p> |
| 182 | <p>Lors d’une recherche d’arrêt, les termes saisis sont transmis au point d’accès catalogue de Lapin Futé sur la même origine, uniquement pour retourner les arrêts correspondants. L’application ne les conserve pas ; l’infrastructure d’hébergement peut journaliser les URL demandées et d’autres métadonnées de connexion.</p> | 182 | <p>Lors d’une recherche d’arrêt, les termes saisis sont transmis au point d’accès catalogue de Lapin Futé sur la même origine, uniquement pour retourner les arrêts correspondants. L’application ne les conserve pas ; l’infrastructure d’hébergement peut journaliser les URL demandées et d’autres métadonnées de connexion.</p> |
| 183 | - <p>Cette page est hébergée par GitHub Pages. GitHub peut traiter des données de connexion et les URL demandées selon sa <a href="https://docs.github.com/fr/site-policy/privacy-policies/github-general-privacy-statement" target="_blank" rel="noopener noreferrer">déclaration de confidentialité générale</a>.</p> | 183 | + <p>Cette page et son catalogue statique sont hébergés par bunny.net. BunnyWay peut traiter les données de connexion et les URL demandées selon sa <a href="https://bunny.net/privacy/" target="_blank" rel="noopener noreferrer">politique de confidentialité et de traitement des données</a>.</p> |
| 184 | <h3>Données et crédits</h3> | 184 | <h3>Données et crédits</h3> |
| 185 | <p>Les noms et couleurs des lignes proviennent de la base <a href="https://data.iledefrance-mobilites.fr/explore/dataset/referentiel-des-lignes/" target="_blank" rel="noopener noreferrer">Référentiel des lignes</a> d’Île-de-France Mobilités, sous licence <a href="https://opendatacommons.org/licenses/odbl/1-0/" target="_blank" rel="noopener noreferrer">ODbL 1.0</a>. Les pictogrammes officiels des lignes sont © Île-de-France Mobilités et réutilisés selon les <a href="https://prim.iledefrance-mobilites.fr/fr/conditions-utilisation" target="_blank" rel="noopener noreferrer">conditions d’utilisation IDFM/PRIM</a>.</p> | 185 | <p>Les noms et couleurs des lignes proviennent de la base <a href="https://data.iledefrance-mobilites.fr/explore/dataset/referentiel-des-lignes/" target="_blank" rel="noopener noreferrer">Référentiel des lignes</a> d’Île-de-France Mobilités, sous licence <a href="https://opendatacommons.org/licenses/odbl/1-0/" target="_blank" rel="noopener noreferrer">ODbL 1.0</a>. Les pictogrammes officiels des lignes sont © Île-de-France Mobilités et réutilisés selon les <a href="https://prim.iledefrance-mobilites.fr/fr/conditions-utilisation" target="_blank" rel="noopener noreferrer">conditions d’utilisation IDFM/PRIM</a>.</p> |
| 186 | <p>Les départs en temps réel proviennent de la plateforme <a href="https://prim.iledefrance-mobilites.fr/" target="_blank" rel="noopener noreferrer">PRIM</a> d’Île-de-France Mobilités au moyen de votre clé personnelle. Ils restent soumis aux conditions et licences publiées par PRIM.</p> | 186 | <p>Les départs en temps réel proviennent de la plateforme <a href="https://prim.iledefrance-mobilites.fr/" target="_blank" rel="noopener noreferrer">PRIM</a> d’Île-de-France Mobilités au moyen de votre clé personnelle. Ils restent soumis aux conditions et licences publiées par PRIM.</p> |
modified
packages/config-page/src/catalog-client.js +8 -3 | @@ -4,7 +4,11 @@ import { | ||
| 4 | 4 | isPlaceSearchItem, |
| 5 | 5 | isServiceOption, |
| 6 | 6 | } from "./config-core.js"; |
| 7 | -import { catalogSearchBucket, normalizeCatalogSearchText } from "./search-text.js"; | |
| 7 | +import { | |
| 8 | + catalogSearchBucket, | |
| 9 | + normalizeCatalogSearchQuery, | |
| 10 | + normalizeCatalogSearchText, | |
| 11 | +} from "./search-text.js"; | |
| 8 | 12 | |
| 9 | 13 | export const SEARCH_DEBOUNCE_MS = 300; |
| 10 | 14 | |
| @@ -12,6 +16,7 @@ const PLACE_ID = /^plc_[A-Za-z0-9_-]{43}$/u; | ||
| 12 | 16 | const SERVICE_ID = /^svc_[A-Za-z0-9_-]{43}$/u; |
| 13 | 17 | const REVISION = /^[a-f0-9]{64}$/u; |
| 14 | 18 | const MANIFEST_FIELDS = ["schemaVersion", "revision", "sourceRevision", "createdAt", "attribution"]; |
| 19 | +const MODE_RANK = Object.freeze({ METRO: 0, RER: 1, TRANSILIEN: 2, TRAM: 3, BUS: 4 }); | |
| 15 | 20 | |
| 16 | 21 | export class CatalogClientError extends Error { |
| 17 | 22 | constructor(code) { |
| @@ -173,7 +178,7 @@ export function createCatalogClient({ | ||
| 173 | 178 | const candidate = { |
| 174 | 179 | place, |
| 175 | 180 | rank: searchText === normalized || stop === normalized ? 0 : searchText.startsWith(normalized) ? 1 : 2, |
| 176 | - order: [stop, normalizeCatalogSearchText(place.localityLabel ?? ""), place.mode, place.placeId], | |
| 181 | + order: [stop, normalizeCatalogSearchText(place.localityLabel ?? ""), MODE_RANK[place.mode], place.placeId], | |
| 177 | 182 | }; |
| 178 | 183 | const duplicate = best.findIndex((match) => match.place.placeId === place.placeId); |
| 179 | 184 | if (duplicate !== -1) { |
| @@ -196,7 +201,7 @@ export function createCatalogClient({ | ||
| 196 | 201 | const length = Array.from(trimmed).length; |
| 197 | 202 | if (length < LIMITS.catalogQueryMinCharacters) return Promise.resolve([]); |
| 198 | 203 | if (length > LIMITS.catalogQueryMaxCharacters) return Promise.reject(new CatalogClientError("INVALID_QUERY")); |
| 199 | - const normalized = normalizeCatalogSearchText(trimmed); | |
| 204 | + const normalized = normalizeCatalogSearchQuery(trimmed); | |
| 200 | 205 | if (normalized === "") return Promise.reject(new CatalogClientError("INVALID_QUERY")); |
| 201 | 206 | const controller = new AbortController(); |
| 202 | 207 | searchController = controller; |
| @@ -4,7 +4,11 @@ import { | |||
| 4 | isPlaceSearchItem, | 4 | isPlaceSearchItem, |
| 5 | isServiceOption, | 5 | isServiceOption, |
| 6 | } from "./config-core.js"; | 6 | } from "./config-core.js"; |
| 7 | -import { catalogSearchBucket, normalizeCatalogSearchText } from "./search-text.js"; | 7 | +import { |
| 8 | + catalogSearchBucket, | ||
| 9 | + normalizeCatalogSearchQuery, | ||
| 10 | + normalizeCatalogSearchText, | ||
| 11 | +} from "./search-text.js"; | ||
| 8 | 12 | ||
| 9 | export const SEARCH_DEBOUNCE_MS = 300; | 13 | export const SEARCH_DEBOUNCE_MS = 300; |
| 10 | 14 | ||
| @@ -12,6 +16,7 @@ const PLACE_ID = /^plc_[A-Za-z0-9_-]{43}$/u; | |||
| 12 | const SERVICE_ID = /^svc_[A-Za-z0-9_-]{43}$/u; | 16 | const SERVICE_ID = /^svc_[A-Za-z0-9_-]{43}$/u; |
| 13 | const REVISION = /^[a-f0-9]{64}$/u; | 17 | const REVISION = /^[a-f0-9]{64}$/u; |
| 14 | const MANIFEST_FIELDS = ["schemaVersion", "revision", "sourceRevision", "createdAt", "attribution"]; | 18 | const MANIFEST_FIELDS = ["schemaVersion", "revision", "sourceRevision", "createdAt", "attribution"]; |
| 19 | +const MODE_RANK = Object.freeze({ METRO: 0, RER: 1, TRANSILIEN: 2, TRAM: 3, BUS: 4 }); | ||
| 15 | 20 | ||
| 16 | export class CatalogClientError extends Error { | 21 | export class CatalogClientError extends Error { |
| 17 | constructor(code) { | 22 | constructor(code) { |
| @@ -173,7 +178,7 @@ export function createCatalogClient({ | |||
| 173 | const candidate = { | 178 | const candidate = { |
| 174 | place, | 179 | place, |
| 175 | rank: searchText === normalized || stop === normalized ? 0 : searchText.startsWith(normalized) ? 1 : 2, | 180 | rank: searchText === normalized || stop === normalized ? 0 : searchText.startsWith(normalized) ? 1 : 2, |
| 176 | - order: [stop, normalizeCatalogSearchText(place.localityLabel ?? ""), place.mode, place.placeId], | 181 | + order: [stop, normalizeCatalogSearchText(place.localityLabel ?? ""), MODE_RANK[place.mode], place.placeId], |
| 177 | }; | 182 | }; |
| 178 | const duplicate = best.findIndex((match) => match.place.placeId === place.placeId); | 183 | const duplicate = best.findIndex((match) => match.place.placeId === place.placeId); |
| 179 | if (duplicate !== -1) { | 184 | if (duplicate !== -1) { |
| @@ -196,7 +201,7 @@ export function createCatalogClient({ | |||
| 196 | const length = Array.from(trimmed).length; | 201 | const length = Array.from(trimmed).length; |
| 197 | if (length < LIMITS.catalogQueryMinCharacters) return Promise.resolve([]); | 202 | if (length < LIMITS.catalogQueryMinCharacters) return Promise.resolve([]); |
| 198 | if (length > LIMITS.catalogQueryMaxCharacters) return Promise.reject(new CatalogClientError("INVALID_QUERY")); | 203 | if (length > LIMITS.catalogQueryMaxCharacters) return Promise.reject(new CatalogClientError("INVALID_QUERY")); |
| 199 | - const normalized = normalizeCatalogSearchText(trimmed); | 204 | + const normalized = normalizeCatalogSearchQuery(trimmed); |
| 200 | if (normalized === "") return Promise.reject(new CatalogClientError("INVALID_QUERY")); | 205 | if (normalized === "") return Promise.reject(new CatalogClientError("INVALID_QUERY")); |
| 201 | const controller = new AbortController(); | 206 | const controller = new AbortController(); |
| 202 | searchController = controller; | 207 | searchController = controller; |
modified
packages/config-page/src/search-text.js +8 -0 | @@ -1,3 +1,4 @@ | ||
| 1 | +const FRENCH_JOIN_WORDS = new Set(["d", "de", "du", "des", "l", "la", "le", "les"]); | |
| 1 | 2 | |
| 2 | 3 | export function normalizeCatalogSearchText(value) { |
| 3 | 4 | return value |
| @@ -9,6 +10,13 @@ export function normalizeCatalogSearchText(value) { | ||
| 9 | 10 | .replace(/\s+/gu, " "); |
| 10 | 11 | } |
| 11 | 12 | |
| 13 | +export function normalizeCatalogSearchQuery(value) { | |
| 14 | + return normalizeCatalogSearchText(value) | |
| 15 | + .split(" ") | |
| 16 | + .filter((token) => !FRENCH_JOIN_WORDS.has(token)) | |
| 17 | + .join(" "); | |
| 18 | +} | |
| 19 | + | |
| 12 | 20 | export function catalogSearchBucket(token) { |
| 13 | 21 | return Array.from(token).slice(0, 2).map((character) => |
| 14 | 22 | character.codePointAt(0).toString(16)).join("_"); |
| @@ -1,3 +1,4 @@ | |||
| 1 | +const FRENCH_JOIN_WORDS = new Set(["d", "de", "du", "des", "l", "la", "le", "les"]); | ||
| 1 | 2 | ||
| 2 | export function normalizeCatalogSearchText(value) { | 3 | export function normalizeCatalogSearchText(value) { |
| 3 | return value | 4 | return value |
| @@ -9,6 +10,13 @@ export function normalizeCatalogSearchText(value) { | |||
| 9 | .replace(/\s+/gu, " "); | 10 | .replace(/\s+/gu, " "); |
| 10 | } | 11 | } |
| 11 | 12 | ||
| 13 | +export function normalizeCatalogSearchQuery(value) { | ||
| 14 | + return normalizeCatalogSearchText(value) | ||
| 15 | + .split(" ") | ||
| 16 | + .filter((token) => !FRENCH_JOIN_WORDS.has(token)) | ||
| 17 | + .join(" "); | ||
| 18 | +} | ||
| 19 | + | ||
| 12 | export function catalogSearchBucket(token) { | 20 | export function catalogSearchBucket(token) { |
| 13 | return Array.from(token).slice(0, 2).map((character) => | 21 | return Array.from(token).slice(0, 2).map((character) => |
| 14 | character.codePointAt(0).toString(16)).join("_"); | 22 | character.codePointAt(0).toString(16)).join("_"); |
modified
packages/config-page/styles/config-page.css +2 -2 | @@ -238,7 +238,7 @@ button:disabled { cursor: default; opacity: 0.35; } | ||
| 238 | 238 | .choice-list { gap: 0; margin-top: 0.5rem; } |
| 239 | 239 | .choice { display: flex; align-items: center; justify-content: space-between; gap: 1rem; width: 100%; padding: 0.875rem 0.25rem; border-bottom-color: var(--line); border-radius: 0; background: transparent; color: var(--heading); text-align: left; } |
| 240 | 240 | .choice::after { content: ""; flex: 0 0 0.45rem; width: 0.45rem; height: 0.45rem; margin-right: 0.375rem; border-top: 1.5px solid var(--muted); border-right: 1.5px solid var(--muted); transform: rotate(45deg); } |
| 241 | -.choice-labels { display: grid; gap: 0.25rem; min-width: 0; } | |
| 241 | +.choice-labels { display: grid; flex: 1 1 auto; gap: 0.25rem; min-width: 0; } | |
| 242 | 242 | .choice-labels strong { font-size: 0.875rem; font-weight: 700; } |
| 243 | 243 | .choice-context { color: var(--muted); font-size: 0.75rem; font-weight: 400; } |
| 244 | 244 | /* Search rows repeat the official badges as a compact wrapping strip; only |
| @@ -249,7 +249,7 @@ button:disabled { cursor: default; opacity: 0.35; } | ||
| 249 | 249 | * Official raster pictograms with black lettering sit on a small white plate |
| 250 | 250 | * to stay legible on the dark surface; fallback SVGs keep their official |
| 251 | 251 | * colors and get no plate. */ |
| 252 | -.choice-lines { display: flex; flex-wrap: wrap; gap: 0.25rem; min-width: 0; } | |
| 252 | +.choice-lines { display: flex; flex-wrap: wrap; gap: 0.25rem; width: 100%; min-width: 0; } | |
| 253 | 253 | .choice-lines .line-badge { min-width: 1.75rem; min-height: 1.75rem; } |
| 254 | 254 | .choice-lines .line-badge img, .choice-lines .line-badge svg { height: 1.75rem; max-width: 8rem; } |
| 255 | 255 | .choice-lines .line-badge img { padding: 0.0625rem; border-radius: 0.25rem; background: #ffffff; } |
| @@ -238,7 +238,7 @@ button:disabled { cursor: default; opacity: 0.35; } | |||
| 238 | .choice-list { gap: 0; margin-top: 0.5rem; } | 238 | .choice-list { gap: 0; margin-top: 0.5rem; } |
| 239 | .choice { display: flex; align-items: center; justify-content: space-between; gap: 1rem; width: 100%; padding: 0.875rem 0.25rem; border-bottom-color: var(--line); border-radius: 0; background: transparent; color: var(--heading); text-align: left; } | 239 | .choice { display: flex; align-items: center; justify-content: space-between; gap: 1rem; width: 100%; padding: 0.875rem 0.25rem; border-bottom-color: var(--line); border-radius: 0; background: transparent; color: var(--heading); text-align: left; } |
| 240 | .choice::after { content: ""; flex: 0 0 0.45rem; width: 0.45rem; height: 0.45rem; margin-right: 0.375rem; border-top: 1.5px solid var(--muted); border-right: 1.5px solid var(--muted); transform: rotate(45deg); } | 240 | .choice::after { content: ""; flex: 0 0 0.45rem; width: 0.45rem; height: 0.45rem; margin-right: 0.375rem; border-top: 1.5px solid var(--muted); border-right: 1.5px solid var(--muted); transform: rotate(45deg); } |
| 241 | -.choice-labels { display: grid; gap: 0.25rem; min-width: 0; } | 241 | +.choice-labels { display: grid; flex: 1 1 auto; gap: 0.25rem; min-width: 0; } |
| 242 | .choice-labels strong { font-size: 0.875rem; font-weight: 700; } | 242 | .choice-labels strong { font-size: 0.875rem; font-weight: 700; } |
| 243 | .choice-context { color: var(--muted); font-size: 0.75rem; font-weight: 400; } | 243 | .choice-context { color: var(--muted); font-size: 0.75rem; font-weight: 400; } |
| 244 | /* Search rows repeat the official badges as a compact wrapping strip; only | 244 | /* Search rows repeat the official badges as a compact wrapping strip; only |
| @@ -249,7 +249,7 @@ button:disabled { cursor: default; opacity: 0.35; } | |||
| 249 | * Official raster pictograms with black lettering sit on a small white plate | 249 | * Official raster pictograms with black lettering sit on a small white plate |
| 250 | * to stay legible on the dark surface; fallback SVGs keep their official | 250 | * to stay legible on the dark surface; fallback SVGs keep their official |
| 251 | * colors and get no plate. */ | 251 | * colors and get no plate. */ |
| 252 | -.choice-lines { display: flex; flex-wrap: wrap; gap: 0.25rem; min-width: 0; } | 252 | +.choice-lines { display: flex; flex-wrap: wrap; gap: 0.25rem; width: 100%; min-width: 0; } |
| 253 | .choice-lines .line-badge { min-width: 1.75rem; min-height: 1.75rem; } | 253 | .choice-lines .line-badge { min-width: 1.75rem; min-height: 1.75rem; } |
| 254 | .choice-lines .line-badge img, .choice-lines .line-badge svg { height: 1.75rem; max-width: 8rem; } | 254 | .choice-lines .line-badge img, .choice-lines .line-badge svg { height: 1.75rem; max-width: 8rem; } |
| 255 | .choice-lines .line-badge img { padding: 0.0625rem; border-radius: 0.25rem; background: #ffffff; } | 255 | .choice-lines .line-badge img { padding: 0.0625rem; border-radius: 0.25rem; background: #ffffff; } |
modified
packages/config-page/test/catalog-client.test.js +32 -0 | @@ -109,6 +109,20 @@ test("search rows carry precomputed lines without extra requests and reject malf | ||
| 109 | 109 | } |
| 110 | 110 | }); |
| 111 | 111 | |
| 112 | +test("equally relevant stops put rail modes before tram and bus", async () => { | |
| 113 | + const modes = ["BUS", "TRAM", "TRANSILIEN", "RER", "METRO"]; | |
| 114 | + const entries = modes.map((mode, index) => ({ | |
| 115 | + ...place(index, "Saint-Denis - Université", "Saint-Denis"), | |
| 116 | + mode, | |
| 117 | + })); | |
| 118 | + const { client } = fixtureClient((url) => url === "catalog/manifest.json" ? response(manifest) | |
| 119 | + : response({ schemaVersion: 1, revision, page: 0, nextPage: null, places: entries })); | |
| 120 | + | |
| 121 | + const results = await client.searchPlaces("universite saint denis"); | |
| 122 | + | |
| 123 | + assert.deepEqual(results.map((entry) => entry.mode), ["METRO", "RER", "TRANSILIEN", "TRAM", "BUS"]); | |
| 124 | +}); | |
| 125 | + | |
| 112 | 126 | test("static search uses AND token prefixes, Unicode normalization and one-codepoint buckets", async () => { |
| 113 | 127 | const match = place(1, "Rue Saint-Denis Châtelet"); |
| 114 | 128 | const noChatelet = place(2, "Saint-Denis"); |
| @@ -123,6 +137,24 @@ test("static search uses AND token prefixes, Unicode normalization and one-codep | ||
| 123 | 137 | await assert.rejects(client.searchPlaces("--"), (error) => error.code === "INVALID_QUERY"); |
| 124 | 138 | }); |
| 125 | 139 | |
| 140 | +test("search ignores French join words absent from an official stop label", async () => { | |
| 141 | + const mairie = place(1, "Mairie / Pelletier", "Stains"); | |
| 142 | + mairie.lines = [ | |
| 143 | + { lineLabel: "252", lineColor: "#ff0000", lineTextColor: "#000000" }, | |
| 144 | + { lineLabel: "253", lineColor: "#ffbe00", lineTextColor: "#000000" }, | |
| 145 | + { lineLabel: "255", lineColor: "#6e6e00", lineTextColor: "#ffffff" }, | |
| 146 | + { lineLabel: "N43", lineColor: "#ff5a00", lineTextColor: "#ffffff" }, | |
| 147 | + ]; | |
| 148 | + const { client, requests } = fixtureClient((url) => url === "catalog/manifest.json" ? response(manifest) | |
| 149 | + : response({ schemaVersion: 1, revision, page: 0, nextPage: null, places: [mairie] })); | |
| 150 | + | |
| 151 | + const results = await client.searchPlaces("Mairie de Stains"); | |
| 152 | + | |
| 153 | + assert.deepEqual(results.map((entry) => entry.placeId), [mairie.placeId]); | |
| 154 | + assert.equal(requests[1], `catalog/${revision}/search/6d_61/0.json`); | |
| 155 | + await assert.rejects(client.searchPlaces("de la"), (error) => error.code === "INVALID_QUERY"); | |
| 156 | +}); | |
| 157 | + | |
| 126 | 158 | test("missing initial search bucket is empty but a missing continuation is unavailable", async () => { |
| 127 | 159 | const missing = fixtureClient((url) => url === "catalog/manifest.json" ? response(manifest) : response(null, 404)); |
| 128 | 160 | assert.deepEqual(await missing.client.searchPlaces("absent"), []); |
| @@ -109,6 +109,20 @@ test("search rows carry precomputed lines without extra requests and reject malf | |||
| 109 | } | 109 | } |
| 110 | }); | 110 | }); |
| 111 | 111 | ||
| 112 | +test("equally relevant stops put rail modes before tram and bus", async () => { | ||
| 113 | + const modes = ["BUS", "TRAM", "TRANSILIEN", "RER", "METRO"]; | ||
| 114 | + const entries = modes.map((mode, index) => ({ | ||
| 115 | + ...place(index, "Saint-Denis - Université", "Saint-Denis"), | ||
| 116 | + mode, | ||
| 117 | + })); | ||
| 118 | + const { client } = fixtureClient((url) => url === "catalog/manifest.json" ? response(manifest) | ||
| 119 | + : response({ schemaVersion: 1, revision, page: 0, nextPage: null, places: entries })); | ||
| 120 | + | ||
| 121 | + const results = await client.searchPlaces("universite saint denis"); | ||
| 122 | + | ||
| 123 | + assert.deepEqual(results.map((entry) => entry.mode), ["METRO", "RER", "TRANSILIEN", "TRAM", "BUS"]); | ||
| 124 | +}); | ||
| 125 | + | ||
| 112 | test("static search uses AND token prefixes, Unicode normalization and one-codepoint buckets", async () => { | 126 | test("static search uses AND token prefixes, Unicode normalization and one-codepoint buckets", async () => { |
| 113 | const match = place(1, "Rue Saint-Denis Châtelet"); | 127 | const match = place(1, "Rue Saint-Denis Châtelet"); |
| 114 | const noChatelet = place(2, "Saint-Denis"); | 128 | const noChatelet = place(2, "Saint-Denis"); |
| @@ -123,6 +137,24 @@ test("static search uses AND token prefixes, Unicode normalization and one-codep | |||
| 123 | await assert.rejects(client.searchPlaces("--"), (error) => error.code === "INVALID_QUERY"); | 137 | await assert.rejects(client.searchPlaces("--"), (error) => error.code === "INVALID_QUERY"); |
| 124 | }); | 138 | }); |
| 125 | 139 | ||
| 140 | +test("search ignores French join words absent from an official stop label", async () => { | ||
| 141 | + const mairie = place(1, "Mairie / Pelletier", "Stains"); | ||
| 142 | + mairie.lines = [ | ||
| 143 | + { lineLabel: "252", lineColor: "#ff0000", lineTextColor: "#000000" }, | ||
| 144 | + { lineLabel: "253", lineColor: "#ffbe00", lineTextColor: "#000000" }, | ||
| 145 | + { lineLabel: "255", lineColor: "#6e6e00", lineTextColor: "#ffffff" }, | ||
| 146 | + { lineLabel: "N43", lineColor: "#ff5a00", lineTextColor: "#ffffff" }, | ||
| 147 | + ]; | ||
| 148 | + const { client, requests } = fixtureClient((url) => url === "catalog/manifest.json" ? response(manifest) | ||
| 149 | + : response({ schemaVersion: 1, revision, page: 0, nextPage: null, places: [mairie] })); | ||
| 150 | + | ||
| 151 | + const results = await client.searchPlaces("Mairie de Stains"); | ||
| 152 | + | ||
| 153 | + assert.deepEqual(results.map((entry) => entry.placeId), [mairie.placeId]); | ||
| 154 | + assert.equal(requests[1], `catalog/${revision}/search/6d_61/0.json`); | ||
| 155 | + await assert.rejects(client.searchPlaces("de la"), (error) => error.code === "INVALID_QUERY"); | ||
| 156 | +}); | ||
| 157 | + | ||
| 126 | test("missing initial search bucket is empty but a missing continuation is unavailable", async () => { | 158 | test("missing initial search bucket is empty but a missing continuation is unavailable", async () => { |
| 127 | const missing = fixtureClient((url) => url === "catalog/manifest.json" ? response(manifest) : response(null, 404)); | 159 | const missing = fixtureClient((url) => url === "catalog/manifest.json" ? response(manifest) : response(null, 404)); |
| 128 | assert.deepEqual(await missing.client.searchPlaces("absent"), []); | 160 | assert.deepEqual(await missing.client.searchPlaces("absent"), []); |
modified
packages/config-page/test/config-page.test.ts +12 -1 | @@ -863,7 +863,9 @@ test("bilingual About copy exposes the required legal, privacy, attribution, and | ||
| 863 | 863 | "Publication director:", |
| 864 | 864 | "Directeur de la publication :", |
| 865 | 865 | "Anthodev", |
| 866 | - "88 Colin P. Kelly Jr. St.", | |
| 866 | + "BunnyWay d.o.o.", | |
| 867 | + "Dunajska cesta 165", | |
| 868 | + "https://bunny.net/privacy/", | |
| 867 | 869 | "localStorage", |
| 868 | 870 | "no analytics or telemetry", |
| 869 | 871 | "aucun cookie", |
| @@ -876,6 +878,9 @@ test("bilingual About copy exposes the required legal, privacy, attribution, and | ||
| 876 | 878 | "ODbL 1.0", |
| 877 | 879 | "github.com/Anthodev/lapin-fute", |
| 878 | 880 | ]) assert.equal(html.includes(fact), true, fact); |
| 881 | + assert.doesNotMatch(html, /GitHub Pages|Lapin Futé backend|serveur Lapin Futé/iu); | |
| 882 | + assert.match(html, /phone sends it directly to PRIM over HTTPS/u); | |
| 883 | + assert.match(html, /téléphone l’envoie directement à PRIM en HTTPS/u); | |
| 879 | 884 | assert.doesNotMatch(html, /software licen[cs]e|licence du logiciel/iu); |
| 880 | 885 | assert.equal((html.match(/target="_blank"/gu) ?? []).length, (html.match(/rel="noopener noreferrer"/gu) ?? []).length); |
| 881 | 886 | assert.match(controller, /lineBadgeAssetUrl\(service\.lineMode, service\.lineLabel\)/u); |
| @@ -913,6 +918,12 @@ test("favorites without presentation fields resolve no official image and use an | ||
| 913 | 918 | assert.match(controller, /svg\.setAttribute\("aria-label", service\.lineLabel\)/u); |
| 914 | 919 | }); |
| 915 | 920 | |
| 921 | +test("search result labels use the available row width and wrap line badges", () => { | |
| 922 | + const css = readFileSync(join(here, "../styles/config-page.css"), "utf8"); | |
| 923 | + assert.match(css, /\.choice-labels \{[^}]*flex: 1 1 auto;[^}]*min-width: 0;/u); | |
| 924 | + assert.match(css, /\.choice-lines \{[^}]*flex-wrap: wrap;[^}]*width: 100%;[^}]*min-width: 0;/u); | |
| 925 | +}); | |
| 926 | + | |
| 916 | 927 | test("mirrored place search validation matches the canonical lines contract", () => { |
| 917 | 928 | const line = { lineLabel: "13", lineColor: "#82c8e6", lineTextColor: "#000000" }; |
| 918 | 929 | const placeId = `plc_${"0".repeat(43)}`; |
| @@ -863,7 +863,9 @@ test("bilingual About copy exposes the required legal, privacy, attribution, and | |||
| 863 | "Publication director:", | 863 | "Publication director:", |
| 864 | "Directeur de la publication :", | 864 | "Directeur de la publication :", |
| 865 | "Anthodev", | 865 | "Anthodev", |
| 866 | - "88 Colin P. Kelly Jr. St.", | 866 | + "BunnyWay d.o.o.", |
| 867 | + "Dunajska cesta 165", | ||
| 868 | + "https://bunny.net/privacy/", | ||
| 867 | "localStorage", | 869 | "localStorage", |
| 868 | "no analytics or telemetry", | 870 | "no analytics or telemetry", |
| 869 | "aucun cookie", | 871 | "aucun cookie", |
| @@ -876,6 +878,9 @@ test("bilingual About copy exposes the required legal, privacy, attribution, and | |||
| 876 | "ODbL 1.0", | 878 | "ODbL 1.0", |
| 877 | "github.com/Anthodev/lapin-fute", | 879 | "github.com/Anthodev/lapin-fute", |
| 878 | ]) assert.equal(html.includes(fact), true, fact); | 880 | ]) assert.equal(html.includes(fact), true, fact); |
| 881 | + assert.doesNotMatch(html, /GitHub Pages|Lapin Futé backend|serveur Lapin Futé/iu); | ||
| 882 | + assert.match(html, /phone sends it directly to PRIM over HTTPS/u); | ||
| 883 | + assert.match(html, /téléphone l’envoie directement à PRIM en HTTPS/u); | ||
| 879 | assert.doesNotMatch(html, /software licen[cs]e|licence du logiciel/iu); | 884 | assert.doesNotMatch(html, /software licen[cs]e|licence du logiciel/iu); |
| 880 | assert.equal((html.match(/target="_blank"/gu) ?? []).length, (html.match(/rel="noopener noreferrer"/gu) ?? []).length); | 885 | assert.equal((html.match(/target="_blank"/gu) ?? []).length, (html.match(/rel="noopener noreferrer"/gu) ?? []).length); |
| 881 | assert.match(controller, /lineBadgeAssetUrl\(service\.lineMode, service\.lineLabel\)/u); | 886 | assert.match(controller, /lineBadgeAssetUrl\(service\.lineMode, service\.lineLabel\)/u); |
| @@ -913,6 +918,12 @@ test("favorites without presentation fields resolve no official image and use an | |||
| 913 | assert.match(controller, /svg\.setAttribute\("aria-label", service\.lineLabel\)/u); | 918 | assert.match(controller, /svg\.setAttribute\("aria-label", service\.lineLabel\)/u); |
| 914 | }); | 919 | }); |
| 915 | 920 | ||
| 921 | +test("search result labels use the available row width and wrap line badges", () => { | ||
| 922 | + const css = readFileSync(join(here, "../styles/config-page.css"), "utf8"); | ||
| 923 | + assert.match(css, /\.choice-labels \{[^}]*flex: 1 1 auto;[^}]*min-width: 0;/u); | ||
| 924 | + assert.match(css, /\.choice-lines \{[^}]*flex-wrap: wrap;[^}]*width: 100%;[^}]*min-width: 0;/u); | ||
| 925 | +}); | ||
| 926 | + | ||
| 916 | test("mirrored place search validation matches the canonical lines contract", () => { | 927 | test("mirrored place search validation matches the canonical lines contract", () => { |
| 917 | const line = { lineLabel: "13", lineColor: "#82c8e6", lineTextColor: "#000000" }; | 928 | const line = { lineLabel: "13", lineColor: "#82c8e6", lineTextColor: "#000000" }; |
| 918 | const placeId = `plc_${"0".repeat(43)}`; | 929 | const placeId = `plc_${"0".repeat(43)}`; |
added
packages/config-page/test/config-site-build.test.ts +79 -0 | new file mode 100644 | ||
| @@ -0,0 +1,79 @@ | ||
| 1 | +import test from "node:test"; | |
| 2 | +import assert from "node:assert/strict"; | |
| 3 | +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; | |
| 4 | +import { tmpdir } from "node:os"; | |
| 5 | +import { join } from "node:path"; | |
| 6 | +import { buildConfigSite } from "../../../scripts/build-config-site.mjs"; | |
| 7 | + | |
| 8 | +function fixture() { | |
| 9 | + const root = mkdtempSync(join(tmpdir(), "lapin-fute-config-site-")); | |
| 10 | + const pageSource = join(root, "page"); | |
| 11 | + const staticPath = join(root, "catalog"); | |
| 12 | + const outputPath = join(root, "output"); | |
| 13 | + mkdirSync(pageSource); | |
| 14 | + writeFileSync(join(pageSource, "index.html"), "<h1>Lapin Futé</h1>"); | |
| 15 | + for (const directory of ["src", "styles", "assets"]) { | |
| 16 | + mkdirSync(join(pageSource, directory)); | |
| 17 | + writeFileSync(join(pageSource, directory, "fixture.txt"), directory); | |
| 18 | + } | |
| 19 | + return { root, pageSource, staticPath, outputPath }; | |
| 20 | +} | |
| 21 | + | |
| 22 | +test("page-only configuration build neither requires nor publishes a catalog", () => { | |
| 23 | + const paths = fixture(); | |
| 24 | + try { | |
| 25 | + buildConfigSite({ ...paths, includeCatalog: false }); | |
| 26 | + assert.equal(readFileSync(join(paths.outputPath, "index.html"), "utf8"), "<h1>Lapin Futé</h1>"); | |
| 27 | + assert.equal(existsSync(join(paths.outputPath, "catalog")), false); | |
| 28 | + } finally { | |
| 29 | + rmSync(paths.root, { recursive: true, force: true }); | |
| 30 | + } | |
| 31 | +}); | |
| 32 | + | |
| 33 | +test("full configuration build still requires and copies a published catalog", () => { | |
| 34 | + const paths = fixture(); | |
| 35 | + try { | |
| 36 | + assert.throws( | |
| 37 | + () => buildConfigSite(paths), | |
| 38 | + /no published static catalog/u, | |
| 39 | + ); | |
| 40 | + | |
| 41 | + mkdirSync(join(paths.staticPath, "revision"), { recursive: true }); | |
| 42 | + writeFileSync(join(paths.staticPath, "manifest.json"), '{"revision":"fixture"}'); | |
| 43 | + writeFileSync(join(paths.staticPath, "revision", "0.json"), "{}"); | |
| 44 | + buildConfigSite(paths); | |
| 45 | + | |
| 46 | + assert.equal( | |
| 47 | + readFileSync(join(paths.outputPath, "catalog", "manifest.json"), "utf8"), | |
| 48 | + '{"revision":"fixture"}', | |
| 49 | + ); | |
| 50 | + assert.equal(existsSync(join(paths.outputPath, "catalog", "revision", "0.json")), true); | |
| 51 | + } finally { | |
| 52 | + rmSync(paths.root, { recursive: true, force: true }); | |
| 53 | + } | |
| 54 | +}); | |
| 55 | + | |
| 56 | +test("Bunny releases deploy production tags while preview tags stop after quality checks", () => { | |
| 57 | + const workflow = readFileSync( | |
| 58 | + new URL("../../../.github/workflows/deploy-bunny.yml", import.meta.url), | |
| 59 | + "utf8", | |
| 60 | + ); | |
| 61 | + assert.doesNotMatch(workflow, /branches:\s*\[develop\]/u); | |
| 62 | + assert.match(workflow, /tags:\s*\["v\*", "pre-v\*"\]/u); | |
| 63 | + assert.match(workflow, /startsWith\(github\.ref_name, 'v'\)/u); | |
| 64 | + assert.match(workflow, /publish:\s+name: Publish GitHub release notes[\s\S]+needs: \[test, deploy\]/u); | |
| 65 | + assert.match(workflow, /\["log", "--first-parent", "-z", "--format=%s%x00%h"/u); | |
| 66 | + for (const title of ["Features", "Changes", "Fixes", "CI"]) { | |
| 67 | + assert.match(workflow, new RegExp(`title: "${title}"`, "u")); | |
| 68 | + } | |
| 69 | + assert.match(workflow, /gh release create "\$GITHUB_REF_NAME"[\s\S]+--notes-file release-notes\.md[\s\S]+--verify-tag/u); | |
| 70 | + assert.doesNotMatch(workflow, /--generate-notes/u); | |
| 71 | + assert.match(workflow, /github\.event_name == 'workflow_dispatch' && inputs\.refresh_catalog/u); | |
| 72 | + assert.match(workflow, /refresh_catalog:\s+description:[\s\S]+type: boolean/u); | |
| 73 | + assert.match(workflow, /inputs\.refresh_catalog != true[\s\S]+npm run build:config-page/u); | |
| 74 | + assert.match(workflow, /inputs\.refresh_catalog \}\}[\s\S]+npm run catalog:refresh/u); | |
| 75 | + assert.match( | |
| 76 | + workflow, | |
| 77 | + /if \[\[ -f var\/config-site\/catalog\/manifest\.json \]\]; then\s+upload_file/u, | |
| 78 | + ); | |
| 79 | +}); | |
| new file mode 100644 | |||
| @@ -0,0 +1,79 @@ | |||
| 1 | +import test from "node:test"; | ||
| 2 | +import assert from "node:assert/strict"; | ||
| 3 | +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; | ||
| 4 | +import { tmpdir } from "node:os"; | ||
| 5 | +import { join } from "node:path"; | ||
| 6 | +import { buildConfigSite } from "../../../scripts/build-config-site.mjs"; | ||
| 7 | + | ||
| 8 | +function fixture() { | ||
| 9 | + const root = mkdtempSync(join(tmpdir(), "lapin-fute-config-site-")); | ||
| 10 | + const pageSource = join(root, "page"); | ||
| 11 | + const staticPath = join(root, "catalog"); | ||
| 12 | + const outputPath = join(root, "output"); | ||
| 13 | + mkdirSync(pageSource); | ||
| 14 | + writeFileSync(join(pageSource, "index.html"), "<h1>Lapin Futé</h1>"); | ||
| 15 | + for (const directory of ["src", "styles", "assets"]) { | ||
| 16 | + mkdirSync(join(pageSource, directory)); | ||
| 17 | + writeFileSync(join(pageSource, directory, "fixture.txt"), directory); | ||
| 18 | + } | ||
| 19 | + return { root, pageSource, staticPath, outputPath }; | ||
| 20 | +} | ||
| 21 | + | ||
| 22 | +test("page-only configuration build neither requires nor publishes a catalog", () => { | ||
| 23 | + const paths = fixture(); | ||
| 24 | + try { | ||
| 25 | + buildConfigSite({ ...paths, includeCatalog: false }); | ||
| 26 | + assert.equal(readFileSync(join(paths.outputPath, "index.html"), "utf8"), "<h1>Lapin Futé</h1>"); | ||
| 27 | + assert.equal(existsSync(join(paths.outputPath, "catalog")), false); | ||
| 28 | + } finally { | ||
| 29 | + rmSync(paths.root, { recursive: true, force: true }); | ||
| 30 | + } | ||
| 31 | +}); | ||
| 32 | + | ||
| 33 | +test("full configuration build still requires and copies a published catalog", () => { | ||
| 34 | + const paths = fixture(); | ||
| 35 | + try { | ||
| 36 | + assert.throws( | ||
| 37 | + () => buildConfigSite(paths), | ||
| 38 | + /no published static catalog/u, | ||
| 39 | + ); | ||
| 40 | + | ||
| 41 | + mkdirSync(join(paths.staticPath, "revision"), { recursive: true }); | ||
| 42 | + writeFileSync(join(paths.staticPath, "manifest.json"), '{"revision":"fixture"}'); | ||
| 43 | + writeFileSync(join(paths.staticPath, "revision", "0.json"), "{}"); | ||
| 44 | + buildConfigSite(paths); | ||
| 45 | + | ||
| 46 | + assert.equal( | ||
| 47 | + readFileSync(join(paths.outputPath, "catalog", "manifest.json"), "utf8"), | ||
| 48 | + '{"revision":"fixture"}', | ||
| 49 | + ); | ||
| 50 | + assert.equal(existsSync(join(paths.outputPath, "catalog", "revision", "0.json")), true); | ||
| 51 | + } finally { | ||
| 52 | + rmSync(paths.root, { recursive: true, force: true }); | ||
| 53 | + } | ||
| 54 | +}); | ||
| 55 | + | ||
| 56 | +test("Bunny releases deploy production tags while preview tags stop after quality checks", () => { | ||
| 57 | + const workflow = readFileSync( | ||
| 58 | + new URL("../../../.github/workflows/deploy-bunny.yml", import.meta.url), | ||
| 59 | + "utf8", | ||
| 60 | + ); | ||
| 61 | + assert.doesNotMatch(workflow, /branches:\s*\[develop\]/u); | ||
| 62 | + assert.match(workflow, /tags:\s*\["v\*", "pre-v\*"\]/u); | ||
| 63 | + assert.match(workflow, /startsWith\(github\.ref_name, 'v'\)/u); | ||
| 64 | + assert.match(workflow, /publish:\s+name: Publish GitHub release notes[\s\S]+needs: \[test, deploy\]/u); | ||
| 65 | + assert.match(workflow, /\["log", "--first-parent", "-z", "--format=%s%x00%h"/u); | ||
| 66 | + for (const title of ["Features", "Changes", "Fixes", "CI"]) { | ||
| 67 | + assert.match(workflow, new RegExp(`title: "${title}"`, "u")); | ||
| 68 | + } | ||
| 69 | + assert.match(workflow, /gh release create "\$GITHUB_REF_NAME"[\s\S]+--notes-file release-notes\.md[\s\S]+--verify-tag/u); | ||
| 70 | + assert.doesNotMatch(workflow, /--generate-notes/u); | ||
| 71 | + assert.match(workflow, /github\.event_name == 'workflow_dispatch' && inputs\.refresh_catalog/u); | ||
| 72 | + assert.match(workflow, /refresh_catalog:\s+description:[\s\S]+type: boolean/u); | ||
| 73 | + assert.match(workflow, /inputs\.refresh_catalog != true[\s\S]+npm run build:config-page/u); | ||
| 74 | + assert.match(workflow, /inputs\.refresh_catalog \}\}[\s\S]+npm run catalog:refresh/u); | ||
| 75 | + assert.match( | ||
| 76 | + workflow, | ||
| 77 | + /if \[\[ -f var\/config-site\/catalog\/manifest\.json \]\]; then\s+upload_file/u, | ||
| 78 | + ); | ||
| 79 | +}); | ||
modified
packages/contracts/src/index.ts +3 -0 | @@ -17,6 +17,9 @@ export const LIMITS = { | ||
| 17 | 17 | |
| 18 | 18 | export const PRIM_ORIGIN = "https://prim.iledefrance-mobilites.fr" as const; |
| 19 | 19 | export const CACHE_FRESH_SECONDS = 60 as const; |
| 20 | +// A complete departure result may bridge a short source outage, but after | |
| 21 | +// fifteen minutes its times are no longer useful enough to show as data. | |
| 22 | +export const USEFUL_STALE_SECONDS = 15 * 60; | |
| 20 | 23 | export const FAVORITE_SETTLE_MS = 500 as const; |
| 21 | 24 | export const APP_MESSAGE_INBOX_BYTES = 768 as const; |
| 22 | 25 | export const APP_MESSAGE_OUTBOX_BYTES = 192 as const; |
| @@ -17,6 +17,9 @@ export const LIMITS = { | |||
| 17 | 17 | ||
| 18 | export const PRIM_ORIGIN = "https://prim.iledefrance-mobilites.fr" as const; | 18 | export const PRIM_ORIGIN = "https://prim.iledefrance-mobilites.fr" as const; |
| 19 | export const CACHE_FRESH_SECONDS = 60 as const; | 19 | export const CACHE_FRESH_SECONDS = 60 as const; |
| 20 | +// A complete departure result may bridge a short source outage, but after | ||
| 21 | +// fifteen minutes its times are no longer useful enough to show as data. | ||
| 22 | +export const USEFUL_STALE_SECONDS = 15 * 60; | ||
| 20 | export const FAVORITE_SETTLE_MS = 500 as const; | 23 | export const FAVORITE_SETTLE_MS = 500 as const; |
| 21 | export const APP_MESSAGE_INBOX_BYTES = 768 as const; | 24 | export const APP_MESSAGE_INBOX_BYTES = 768 as const; |
| 22 | export const APP_MESSAGE_OUTBOX_BYTES = 192 as const; | 25 | export const APP_MESSAGE_OUTBOX_BYTES = 192 as const; |
modified
packages/contracts/test/contracts.test.ts +3 -0 | @@ -19,6 +19,7 @@ import { | ||
| 19 | 19 | SCHEMA_VERSION, |
| 20 | 20 | TRAFFIC_STATE, |
| 21 | 21 | TRANSPORT_MODE, |
| 22 | + USEFUL_STALE_SECONDS, | |
| 22 | 23 | cstringBytes, |
| 23 | 24 | dictionaryBytes, |
| 24 | 25 | isApiKeyUpdate, |
| @@ -428,6 +429,8 @@ test("catalog contracts freeze transport modes, error codes, and limits", () => | ||
| 428 | 429 | assert.equal(LIMITS.catalogQueryMinCharacters, 2); |
| 429 | 430 | assert.equal(LIMITS.catalogQueryMaxCharacters, 100); |
| 430 | 431 | assert.equal(LIMITS.catalogSearchResults, 20); |
| 432 | + assert.equal(USEFUL_STALE_SECONDS, 15 * 60); | |
| 433 | + assert.equal(companion.USEFUL_STALE_SECONDS, USEFUL_STALE_SECONDS); | |
| 431 | 434 | }); |
| 432 | 435 | |
| 433 | 436 | test("place lines require complete display metadata within the existing label and color bounds", () => { |
| @@ -19,6 +19,7 @@ import { | |||
| 19 | SCHEMA_VERSION, | 19 | SCHEMA_VERSION, |
| 20 | TRAFFIC_STATE, | 20 | TRAFFIC_STATE, |
| 21 | TRANSPORT_MODE, | 21 | TRANSPORT_MODE, |
| 22 | + USEFUL_STALE_SECONDS, | ||
| 22 | cstringBytes, | 23 | cstringBytes, |
| 23 | dictionaryBytes, | 24 | dictionaryBytes, |
| 24 | isApiKeyUpdate, | 25 | isApiKeyUpdate, |
| @@ -428,6 +429,8 @@ test("catalog contracts freeze transport modes, error codes, and limits", () => | |||
| 428 | assert.equal(LIMITS.catalogQueryMinCharacters, 2); | 429 | assert.equal(LIMITS.catalogQueryMinCharacters, 2); |
| 429 | assert.equal(LIMITS.catalogQueryMaxCharacters, 100); | 430 | assert.equal(LIMITS.catalogQueryMaxCharacters, 100); |
| 430 | assert.equal(LIMITS.catalogSearchResults, 20); | 431 | assert.equal(LIMITS.catalogSearchResults, 20); |
| 432 | + assert.equal(USEFUL_STALE_SECONDS, 15 * 60); | ||
| 433 | + assert.equal(companion.USEFUL_STALE_SECONDS, USEFUL_STALE_SECONDS); | ||
| 431 | }); | 434 | }); |
| 432 | 435 | ||
| 433 | test("place lines require complete display metadata within the existing label and color bounds", () => { | 436 | test("place lines require complete display metadata within the existing label and color bounds", () => { |
deleted
packages/watch/.lock-waf_linux_build +0 -10 | deleted file mode 100644 | ||
| @@ -1,10 +0,0 @@ | ||
| 1 | -argv = ['/home/anthodev/.local/share/pebble-sdk/SDKs/current/sdk-core/pebble/waf', 'configure'] | |
| 2 | -config_cmd = 'configure' | |
| 3 | -environ = {'ABBR_TIPS_PROMPT': '\\n\U0001f4a1 \\e[1m{{ .abbr }}\\e[0m => {{ .cmd }}', 'ABBR_TIPS_REGEXES': '(^(\\w+\\s+)+(-{1,2})\\w+)(\\s\\S+) (^(\\s?(\\w-?)+){3}).* (^(\\s?(\\w-?)+){2}).* (^(\\s?(\\w-?)+){1}).*', 'AGENT': '1', 'ATUIN_SESSION': '01a094d690ec7453a5369e821194a5d8', 'ATUIN_SHLVL': '2', 'ATUIN_TMUX_POPUP': 'false', 'AWS_PAGER': '', 'BAT_PAGER': 'cat', 'CARGO_TERM_PROGRESS_WHEN': 'never', 'CI': 'true', 'CLAUDECODE': '1', 'CLOUDSDK_CORE_DISABLE_PROMPTS': '1', 'COLORTERM': 'truecolor', 'COMPOSER_NO_INTERACTION': '1', 'CONDA_PROMPT_MODIFIER': 'false', 'DBUS_SESSION_BUS_ADDRESS': 'unix:path=/run/user/1000/bus', 'DEBIAN_FRONTEND': 'noninteractive', 'DEBUGINFOD_IMA_CERT_PATH': '/etc/keys/ima:', 'DEBUGINFOD_URLS': 'ima:enforcing https://debuginfod.fedoraproject.org/ ima:ignore ', 'DELTA_PAGER': 'cat', 'DESKTOP_SESSION': '/usr/share/wayland-sessions/plasma.desktop', 'DISPLAY': ':0', 'DOCKER_USER': '1000:1000', 'EDITOR': 'true', 'GDK_CORE_DEVICE_EVENTS': '1', 'GHOSTTY_BIN_DIR': '/usr/bin', 'GHOSTTY_RESOURCES_DIR': '/usr/share/ghostty', 'GHOSTTY_SHELL_FEATURES': 'path,sudo,title', 'GH_PAGER': 'cat', 'GH_PROMPT_DISABLED': '1', 'GID': '1000', 'GIT_EDITOR': 'true', 'GIT_PAGER': 'cat', 'GIT_TERMINAL_PROMPT': '0', 'GLAB_PAGER': 'cat', 'GPG_TTY': 'not a tty', 'GTK2_RC_FILES': '/etc/gtk-2.0/gtkrc:/home/anthodev/.gtkrc-2.0:/home/anthodev/.config/gtkrc-2.0', 'GTK_RC_FILES': '/etc/gtk/gtkrc:/home/anthodev/.gtkrc:/home/anthodev/.config/gtkrc', 'HERDR_BIN_PATH': '/home/linuxbrew/.linuxbrew/Cellar/herdr/0.9.0/bin/herdr', 'HERDR_ENV': '1', 'HERDR_PANE_ID': 'w6:p2', 'HERDR_SOCKET_PATH': '/home/anthodev/.config/herdr/herdr.sock', 'HERDR_STARTUP_CWD': '/home/anthodev', 'HERDR_TAB_ID': 'w6:t2', 'HERDR_WORKSPACE_ID': 'w6', 'HISTCONTROL': 'ignoredups', 'HISTSIZE': '1000', 'HOME': '/home/anthodev', 'HOMEBREW_CELLAR': '/home/linuxbrew/.linuxbrew/Cellar', 'HOMEBREW_PAGER': 'cat', 'HOMEBREW_PREFIX': '/home/linuxbrew/.linuxbrew', 'HOMEBREW_REPOSITORY': '/home/linuxbrew/.linuxbrew/Homebrew', 'ICEAUTHORITY': '/run/user/1000/iceauth_tVtQwG', 'IMSETTINGS_INTEGRATE_DESKTOP': 'yes', 'IMSETTINGS_MODULE': 'none', 'INFOPATH': '/home/linuxbrew/.linuxbrew/share/info:', 'KDEDIRS': '/usr', 'KDE_APPLICATIONS_AS_SCOPE': '1', 'KDE_FULL_SESSION': 'true', 'KDE_SESSION_UID': '1000', 'KDE_SESSION_VERSION': '6', 'KWIN_DISABLE_TONEMAPPING': '1', 'LANG': 'fr_FR.UTF-8', 'LC_ALL': 'C.UTF-8', 'LESS': 'FRX', 'LESSOPEN': '||/usr/bin/lesspipe.sh %s', 'LOGNAME': 'anthodev', 'LS_COLORS': '', 'MAIL': '/var/spool/mail/anthodev', 'MANAGERPID': '2337', 'MANAGERPIDFDID': '467', 'MANPAGER': 'cat', 'MEMORY_PRESSURE_WATCH': '/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/app-com.mitchellh.ghostty.service/memory.pressure', 'MEMORY_PRESSURE_WRITE': 'c29tZSAyMDAwMDAgMjAwMDAwMAA=', 'MYSQL_PAGER': 'cat', 'NO_COLOR': '1', 'NVM_CD_FLAGS': '', 'NVM_DIR': '/home/anthodev/.nvm', 'OMPCODE': '1', 'PAGER': 'cat', 'PAM_KWALLET5_LOGIN': '/run/user/1000/kwallet5.socket', 'PATH': '/var/tmp/pebble-sdk/SDKs/4.33.1/toolchain/moddable-tools:/home/anthodev/.local/share/pebble-sdk/SDKs/4.33.1/toolchain/arm-none-eabi/bin:/home/anthodev/.local/share/uv/tools/pebble-tool/lib64/python3.14/site-packages/pebble_tool/commands/sdk:/home/anthodev/.local/share/pebble-sdk/SDKs/4.33.1/toolchain/bin:/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:/home/anthodev/.local/bin:/home/anthodev/bin:/home/anthodev/.spicetify:/home/anthodev/.atuin/bin:/home/anthodev/.local/bin:/home/anthodev/.bun/bin:/home/anthodev/go/bin:/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:/usr/local/bin:/usr/bin:/sbin:/home/anthodev/.npm-global/bin:/home/anthodev/.local/share/pnpm/bin:/home/anthodev/.cargo/bin:/usr/lib64/ccache:/bin', 'PIP_DISABLE_PIP_VERSION_CHECK': '1', 'PIP_NO_INPUT': '1', 'PNPM_DISABLE_SELF_UPDATE_CHECK': 'true', 'PNPM_HOME': '/home/anthodev/.local/share/pnpm', 'PNPM_UPDATE_NOTIFIER': 'false', 'POSH_CONFIG': '/home/anthodev/.config/.zsh/anthodev.omp.json', 'POSH_SESSION_ID': 'aed4658f-12ea-4aca-a036-894bd3ef6bd0', 'POSH_SHELL': 'fish', 'POSH_SHELL_VERSION': '4.9.3', 'POWERLINE_COMMAND': 'oh-my-posh', 'PSQL_PAGER': 'cat', 'PWD': '/home/anthodev/dev/perso/js/lapin-fute', 'PYTHONUNBUFFERED': '1', 'QT_WAYLAND_RECONNECT': '1', 'SDL_VIDEO_MINIMIZE_ON_FOCUS_LOSS': '0', 'SESSION_MANAGER': 'local/unix:@/tmp/.ICE-unix/2974,unix/unix:/tmp/.ICE-unix/2974', 'SHLVL': '1', 'SSH_ASKPASS': '/usr/bin/false', 'SSH_AUTH_SOCK': '/home/anthodev/.1password/agent.sock', 'STEAM_FRAME_FORCE_CLOSE': '1', 'SYSTEMD_EXEC_PID': '26807', 'SYSTEMD_PAGER': 'cat', 'TERM': 'dumb', 'TERMINFO': '/usr/share/terminfo', 'TERM_PROGRAM': 'ghostty', 'TERM_PROGRAM_VERSION': '1.3.1-4.fc44', 'TF_INPUT': '0', 'TF_IN_AUTOMATION': '1', 'USER': 'anthodev', 'VISUAL': 'true', 'WAYLAND_DISPLAY': 'wayland-0', 'XAUTHORITY': '/run/user/1000/xauth_hwUhbA', 'XDG_CONFIG_DIRS': '/home/anthodev/.config/kdedefaults:/etc/xdg:/usr/share/kde-settings/kde-profile/default/xdg', 'XDG_CURRENT_DESKTOP': 'KDE', 'XDG_DATA_DIRS': '/home/anthodev/.local/share/flatpak/exports/share:/var/lib/flatpak/exports/share:/usr/local/share:/usr/share', 'XDG_MENU_PREFIX': 'plasma-', 'XDG_RUNTIME_DIR': '/run/user/1000', 'XDG_SEAT': 'seat0', 'XDG_SEAT_PATH': '/org/freedesktop/DisplayManager/Seat0', 'XDG_SESSION_CLASS': 'user', 'XDG_SESSION_DESKTOP': 'KDE', 'XDG_SESSION_ID': '2', 'XDG_SESSION_PATH': '/org/freedesktop/DisplayManager/Session1', 'XDG_SESSION_TYPE': 'wayland', 'XDG_VTNR': '2', 'XKB_DEFAULT_LAYOUT': 'us', 'XKB_DEFAULT_MODEL': 'pc105', 'XKB_DEFAULT_VARIANT': 'intl', 'XMODIFIERS': '@im=none', 'YARN_ENABLE_PROGRESS_BARS': '0', 'YARN_ENABLE_TELEMETRY': '0', '_': '/usr/bin/node', '_JAVA_AWT_WM_NONREPARENTING': '1', '__ABBR_TIPS_KEYS': 'a__gr2 a__grc a__grd a__gtc a__helium-browser a__j a__xl', '__ABBR_TIPS_VALUES': 'git rebase -i HEAD~2 git rebase --continue git rebase develop gitmoji -c helium-browser --disable-features=WaylandWpColorManagerV1 just eza -al --icons', '__PI_NATIVE_VARIANT_CACHE': 'modern', '_fish_env_initialized': '1', 'npm_config_audit': 'false', 'npm_config_fund': 'false', 'npm_config_progress': 'false', 'npm_config_update_notifier': 'false', 'npm_config_yes': 'true', 'MODDABLE': '/var/tmp/pebble-sdk/SDKs/4.33.1/toolchain/moddable', 'PYTHONHOME': '/home/anthodev/.local/share/pebble-sdk/SDKs/current/.venv', 'PYTHONPATH': '/home/anthodev/.local/share/uv/tools/pebble-tool/bin:/usr/lib64/python314.zip:/usr/lib64/python3.14:/usr/lib64/python3.14/lib-dynload:/home/anthodev/.local/share/uv/tools/pebble-tool/lib64/python3.14/site-packages:/home/anthodev/.local/share/uv/tools/pebble-tool/lib/python3.14/site-packages', 'NODE_PATH': '/home/anthodev/.local/share/pebble-sdk/SDKs/current/sdk-core/../node_modules', 'NOCLIMB': '1'} | |
| 4 | -files = ['/home/anthodev/dev/perso/js/lapin-fute/packages/watch/wscript'] | |
| 5 | -hash = b'\xc6\xb0\xec\xd9\xadu[\xb0m\xd2\xe0\x84N\xf01%' | |
| 6 | -launch_dir = '/home/anthodev/dev/perso/js/lapin-fute/packages/watch' | |
| 7 | -options = {'colors': 'auto', 'jobs': 8, 'keep': 0, 'verbose': 0, 'zones': '', 'profile': 0, 'pdb': 0, 'whelp': 0, 'version': False, 'out': '', 'top': '', 'no_lock_in_run': '', 'no_lock_in_out': '', 'no_lock_in_top': '', 'prefix': '/usr/local', 'bindir': None, 'libdir': None, 'progress_bar': 0, 'targets': '', 'files': '', 'destdir': '', 'force': False, 'distcheck_args': None, 'debug': False, 'no_groups': False, 'sandbox': False, 'timestamp': None} | |
| 8 | -out_dir = '/home/anthodev/dev/perso/js/lapin-fute/packages/watch/build' | |
| 9 | -run_dir = '/home/anthodev/dev/perso/js/lapin-fute/packages/watch' | |
| 10 | -top_dir = '/home/anthodev/dev/perso/js/lapin-fute/packages/watch' | |
| deleted file mode 100644 | |||
| @@ -1,10 +0,0 @@ | |||
| 1 | -argv = ['/home/anthodev/.local/share/pebble-sdk/SDKs/current/sdk-core/pebble/waf', 'configure'] | ||
| 2 | -config_cmd = 'configure' | ||
| 3 | -environ = {'ABBR_TIPS_PROMPT': '\\n\U0001f4a1 \\e[1m{{ .abbr }}\\e[0m => {{ .cmd }}', 'ABBR_TIPS_REGEXES': '(^(\\w+\\s+)+(-{1,2})\\w+)(\\s\\S+) (^(\\s?(\\w-?)+){3}).* (^(\\s?(\\w-?)+){2}).* (^(\\s?(\\w-?)+){1}).*', 'AGENT': '1', 'ATUIN_SESSION': '01a094d690ec7453a5369e821194a5d8', 'ATUIN_SHLVL': '2', 'ATUIN_TMUX_POPUP': 'false', 'AWS_PAGER': '', 'BAT_PAGER': 'cat', 'CARGO_TERM_PROGRESS_WHEN': 'never', 'CI': 'true', 'CLAUDECODE': '1', 'CLOUDSDK_CORE_DISABLE_PROMPTS': '1', 'COLORTERM': 'truecolor', 'COMPOSER_NO_INTERACTION': '1', 'CONDA_PROMPT_MODIFIER': 'false', 'DBUS_SESSION_BUS_ADDRESS': 'unix:path=/run/user/1000/bus', 'DEBIAN_FRONTEND': 'noninteractive', 'DEBUGINFOD_IMA_CERT_PATH': '/etc/keys/ima:', 'DEBUGINFOD_URLS': 'ima:enforcing https://debuginfod.fedoraproject.org/ ima:ignore ', 'DELTA_PAGER': 'cat', 'DESKTOP_SESSION': '/usr/share/wayland-sessions/plasma.desktop', 'DISPLAY': ':0', 'DOCKER_USER': '1000:1000', 'EDITOR': 'true', 'GDK_CORE_DEVICE_EVENTS': '1', 'GHOSTTY_BIN_DIR': '/usr/bin', 'GHOSTTY_RESOURCES_DIR': '/usr/share/ghostty', 'GHOSTTY_SHELL_FEATURES': 'path,sudo,title', 'GH_PAGER': 'cat', 'GH_PROMPT_DISABLED': '1', 'GID': '1000', 'GIT_EDITOR': 'true', 'GIT_PAGER': 'cat', 'GIT_TERMINAL_PROMPT': '0', 'GLAB_PAGER': 'cat', 'GPG_TTY': 'not a tty', 'GTK2_RC_FILES': '/etc/gtk-2.0/gtkrc:/home/anthodev/.gtkrc-2.0:/home/anthodev/.config/gtkrc-2.0', 'GTK_RC_FILES': '/etc/gtk/gtkrc:/home/anthodev/.gtkrc:/home/anthodev/.config/gtkrc', 'HERDR_BIN_PATH': '/home/linuxbrew/.linuxbrew/Cellar/herdr/0.9.0/bin/herdr', 'HERDR_ENV': '1', 'HERDR_PANE_ID': 'w6:p2', 'HERDR_SOCKET_PATH': '/home/anthodev/.config/herdr/herdr.sock', 'HERDR_STARTUP_CWD': '/home/anthodev', 'HERDR_TAB_ID': 'w6:t2', 'HERDR_WORKSPACE_ID': 'w6', 'HISTCONTROL': 'ignoredups', 'HISTSIZE': '1000', 'HOME': '/home/anthodev', 'HOMEBREW_CELLAR': '/home/linuxbrew/.linuxbrew/Cellar', 'HOMEBREW_PAGER': 'cat', 'HOMEBREW_PREFIX': '/home/linuxbrew/.linuxbrew', 'HOMEBREW_REPOSITORY': '/home/linuxbrew/.linuxbrew/Homebrew', 'ICEAUTHORITY': '/run/user/1000/iceauth_tVtQwG', 'IMSETTINGS_INTEGRATE_DESKTOP': 'yes', 'IMSETTINGS_MODULE': 'none', 'INFOPATH': '/home/linuxbrew/.linuxbrew/share/info:', 'KDEDIRS': '/usr', 'KDE_APPLICATIONS_AS_SCOPE': '1', 'KDE_FULL_SESSION': 'true', 'KDE_SESSION_UID': '1000', 'KDE_SESSION_VERSION': '6', 'KWIN_DISABLE_TONEMAPPING': '1', 'LANG': 'fr_FR.UTF-8', 'LC_ALL': 'C.UTF-8', 'LESS': 'FRX', 'LESSOPEN': '||/usr/bin/lesspipe.sh %s', 'LOGNAME': 'anthodev', 'LS_COLORS': '', 'MAIL': '/var/spool/mail/anthodev', 'MANAGERPID': '2337', 'MANAGERPIDFDID': '467', 'MANPAGER': 'cat', 'MEMORY_PRESSURE_WATCH': '/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/app-com.mitchellh.ghostty.service/memory.pressure', 'MEMORY_PRESSURE_WRITE': 'c29tZSAyMDAwMDAgMjAwMDAwMAA=', 'MYSQL_PAGER': 'cat', 'NO_COLOR': '1', 'NVM_CD_FLAGS': '', 'NVM_DIR': '/home/anthodev/.nvm', 'OMPCODE': '1', 'PAGER': 'cat', 'PAM_KWALLET5_LOGIN': '/run/user/1000/kwallet5.socket', 'PATH': '/var/tmp/pebble-sdk/SDKs/4.33.1/toolchain/moddable-tools:/home/anthodev/.local/share/pebble-sdk/SDKs/4.33.1/toolchain/arm-none-eabi/bin:/home/anthodev/.local/share/uv/tools/pebble-tool/lib64/python3.14/site-packages/pebble_tool/commands/sdk:/home/anthodev/.local/share/pebble-sdk/SDKs/4.33.1/toolchain/bin:/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:/home/anthodev/.local/bin:/home/anthodev/bin:/home/anthodev/.spicetify:/home/anthodev/.atuin/bin:/home/anthodev/.local/bin:/home/anthodev/.bun/bin:/home/anthodev/go/bin:/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:/usr/local/bin:/usr/bin:/sbin:/home/anthodev/.npm-global/bin:/home/anthodev/.local/share/pnpm/bin:/home/anthodev/.cargo/bin:/usr/lib64/ccache:/bin', 'PIP_DISABLE_PIP_VERSION_CHECK': '1', 'PIP_NO_INPUT': '1', 'PNPM_DISABLE_SELF_UPDATE_CHECK': 'true', 'PNPM_HOME': '/home/anthodev/.local/share/pnpm', 'PNPM_UPDATE_NOTIFIER': 'false', 'POSH_CONFIG': '/home/anthodev/.config/.zsh/anthodev.omp.json', 'POSH_SESSION_ID': 'aed4658f-12ea-4aca-a036-894bd3ef6bd0', 'POSH_SHELL': 'fish', 'POSH_SHELL_VERSION': '4.9.3', 'POWERLINE_COMMAND': 'oh-my-posh', 'PSQL_PAGER': 'cat', 'PWD': '/home/anthodev/dev/perso/js/lapin-fute', 'PYTHONUNBUFFERED': '1', 'QT_WAYLAND_RECONNECT': '1', 'SDL_VIDEO_MINIMIZE_ON_FOCUS_LOSS': '0', 'SESSION_MANAGER': 'local/unix:@/tmp/.ICE-unix/2974,unix/unix:/tmp/.ICE-unix/2974', 'SHLVL': '1', 'SSH_ASKPASS': '/usr/bin/false', 'SSH_AUTH_SOCK': '/home/anthodev/.1password/agent.sock', 'STEAM_FRAME_FORCE_CLOSE': '1', 'SYSTEMD_EXEC_PID': '26807', 'SYSTEMD_PAGER': 'cat', 'TERM': 'dumb', 'TERMINFO': '/usr/share/terminfo', 'TERM_PROGRAM': 'ghostty', 'TERM_PROGRAM_VERSION': '1.3.1-4.fc44', 'TF_INPUT': '0', 'TF_IN_AUTOMATION': '1', 'USER': 'anthodev', 'VISUAL': 'true', 'WAYLAND_DISPLAY': 'wayland-0', 'XAUTHORITY': '/run/user/1000/xauth_hwUhbA', 'XDG_CONFIG_DIRS': '/home/anthodev/.config/kdedefaults:/etc/xdg:/usr/share/kde-settings/kde-profile/default/xdg', 'XDG_CURRENT_DESKTOP': 'KDE', 'XDG_DATA_DIRS': '/home/anthodev/.local/share/flatpak/exports/share:/var/lib/flatpak/exports/share:/usr/local/share:/usr/share', 'XDG_MENU_PREFIX': 'plasma-', 'XDG_RUNTIME_DIR': '/run/user/1000', 'XDG_SEAT': 'seat0', 'XDG_SEAT_PATH': '/org/freedesktop/DisplayManager/Seat0', 'XDG_SESSION_CLASS': 'user', 'XDG_SESSION_DESKTOP': 'KDE', 'XDG_SESSION_ID': '2', 'XDG_SESSION_PATH': '/org/freedesktop/DisplayManager/Session1', 'XDG_SESSION_TYPE': 'wayland', 'XDG_VTNR': '2', 'XKB_DEFAULT_LAYOUT': 'us', 'XKB_DEFAULT_MODEL': 'pc105', 'XKB_DEFAULT_VARIANT': 'intl', 'XMODIFIERS': '@im=none', 'YARN_ENABLE_PROGRESS_BARS': '0', 'YARN_ENABLE_TELEMETRY': '0', '_': '/usr/bin/node', '_JAVA_AWT_WM_NONREPARENTING': '1', '__ABBR_TIPS_KEYS': 'a__gr2 a__grc a__grd a__gtc a__helium-browser a__j a__xl', '__ABBR_TIPS_VALUES': 'git rebase -i HEAD~2 git rebase --continue git rebase develop gitmoji -c helium-browser --disable-features=WaylandWpColorManagerV1 just eza -al --icons', '__PI_NATIVE_VARIANT_CACHE': 'modern', '_fish_env_initialized': '1', 'npm_config_audit': 'false', 'npm_config_fund': 'false', 'npm_config_progress': 'false', 'npm_config_update_notifier': 'false', 'npm_config_yes': 'true', 'MODDABLE': '/var/tmp/pebble-sdk/SDKs/4.33.1/toolchain/moddable', 'PYTHONHOME': '/home/anthodev/.local/share/pebble-sdk/SDKs/current/.venv', 'PYTHONPATH': '/home/anthodev/.local/share/uv/tools/pebble-tool/bin:/usr/lib64/python314.zip:/usr/lib64/python3.14:/usr/lib64/python3.14/lib-dynload:/home/anthodev/.local/share/uv/tools/pebble-tool/lib64/python3.14/site-packages:/home/anthodev/.local/share/uv/tools/pebble-tool/lib/python3.14/site-packages', 'NODE_PATH': '/home/anthodev/.local/share/pebble-sdk/SDKs/current/sdk-core/../node_modules', 'NOCLIMB': '1'} | ||
| 4 | -files = ['/home/anthodev/dev/perso/js/lapin-fute/packages/watch/wscript'] | ||
| 5 | -hash = b'\xc6\xb0\xec\xd9\xadu[\xb0m\xd2\xe0\x84N\xf01%' | ||
| 6 | -launch_dir = '/home/anthodev/dev/perso/js/lapin-fute/packages/watch' | ||
| 7 | -options = {'colors': 'auto', 'jobs': 8, 'keep': 0, 'verbose': 0, 'zones': '', 'profile': 0, 'pdb': 0, 'whelp': 0, 'version': False, 'out': '', 'top': '', 'no_lock_in_run': '', 'no_lock_in_out': '', 'no_lock_in_top': '', 'prefix': '/usr/local', 'bindir': None, 'libdir': None, 'progress_bar': 0, 'targets': '', 'files': '', 'destdir': '', 'force': False, 'distcheck_args': None, 'debug': False, 'no_groups': False, 'sandbox': False, 'timestamp': None} | ||
| 8 | -out_dir = '/home/anthodev/dev/perso/js/lapin-fute/packages/watch/build' | ||
| 9 | -run_dir = '/home/anthodev/dev/perso/js/lapin-fute/packages/watch' | ||
| 10 | -top_dir = '/home/anthodev/dev/perso/js/lapin-fute/packages/watch' | ||
modified
packages/watch/package.json +8 -1 | @@ -46,7 +46,14 @@ | ||
| 46 | 46 | "DISPLAY_EPOCH": 45 |
| 47 | 47 | }, |
| 48 | 48 | "resources": { |
| 49 | - "media": [] | |
| 49 | + "media": [ | |
| 50 | + { | |
| 51 | + "menuIcon": true, | |
| 52 | + "type": "png", | |
| 53 | + "name": "IMAGE_MENU_ICON", | |
| 54 | + "file": "images/menu-icon.png" | |
| 55 | + } | |
| 56 | + ] | |
| 50 | 57 | } |
| 51 | 58 | } |
| 52 | 59 | } |
| @@ -46,7 +46,14 @@ | |||
| 46 | "DISPLAY_EPOCH": 45 | 46 | "DISPLAY_EPOCH": 45 |
| 47 | }, | 47 | }, |
| 48 | "resources": { | 48 | "resources": { |
| 49 | - "media": [] | 49 | + "media": [ |
| 50 | + { | ||
| 51 | + "menuIcon": true, | ||
| 52 | + "type": "png", | ||
| 53 | + "name": "IMAGE_MENU_ICON", | ||
| 54 | + "file": "images/menu-icon.png" | ||
| 55 | + } | ||
| 56 | + ] | ||
| 50 | } | 57 | } |
| 51 | } | 58 | } |
| 52 | } | 59 | } |
modified
packages/watch/src/embeddedjs/ui.js +16 -7 | @@ -19,6 +19,12 @@ function draw(port, text, style, color, x, y, width, height, align = 0) { | ||
| 19 | 19 | const left = align < 0 ? x : align > 0 ? x + width - used : x + Math.floor((width-used)/2); |
| 20 | 20 | port.drawString(text, fonts[style], color, left, y + ((height - measure.height) >> 1), used < measure.width ? used : 0); |
| 21 | 21 | } |
| 22 | +function drawBottom(port, text, style, color, x, bottom, width) { | |
| 23 | + if (!text || width <= 0) return; | |
| 24 | + const measure = port.measureString(text, fonts[style]); | |
| 25 | + const used = Math.min(width, Math.ceil(measure.width)); | |
| 26 | + port.drawString(text, fonts[style], color, x, bottom-measure.height, used < measure.width ? used : 0); | |
| 27 | +} | |
| 22 | 28 | function lines(port, text, style, color, x, y, width, height) { |
| 23 | 29 | let start = 0, count = 0; |
| 24 | 30 | while (start <= text.length && text.length) { |
| @@ -104,7 +110,9 @@ function header(port,r,now,isStale,updating) { | ||
| 104 | 110 | } |
| 105 | 111 | } |
| 106 | 112 | function overview(port,r,now) { |
| 107 | - const start = Math.max(0,r.focus-1), end = Math.min(r.records.length,r.focus+1), round = r.profile === 1; | |
| 113 | + const round = r.profile === 1, last = r.records.length; | |
| 114 | + const start = round ? Math.max(0,r.focus-1) : Math.max(0,Math.min(r.focus-1,last-2)); | |
| 115 | + const end = round ? Math.min(last,r.focus+1) : Math.min(last,start+2); | |
| 108 | 116 | for (let i = start; i <= end; i++) { |
| 109 | 117 | const relative = i-r.focus, compact = round && relative !== 0, focused = relative === 0; |
| 110 | 118 | const x = round ? compact ? 54 : 30 : 6, y = round ? relative<0 ? 54 : relative>0 ? 176 : 92 : 34+(i-start)*62; |
| @@ -138,8 +146,8 @@ function departures(port,r,now) { | ||
| 138 | 146 | let x=round?46:8,y=round?52:34,width=round?168:184,height=round?40:35; |
| 139 | 147 | chip(port,record,2,x,y+6,44,27); |
| 140 | 148 | draw(port,clipped(record,8),1,INK,x+52,y,width-52,20,-1); |
| 141 | - draw(port,">",0,MUTED,x+52,y+20,10,18,-1); | |
| 142 | - draw(port,clipped(record,11),0,MUTED,x+62,y+20,width-62,18,-1); | |
| 149 | + draw(port,">",1,MUTED,x+52,y+20,10,18,-1); | |
| 150 | + draw(port,clipped(record,11),1,MUTED,x+62,y+20,width-62,18,-1); | |
| 143 | 151 | port.fillColor(RULE,x,y+height-1,width,1); |
| 144 | 152 | x=round?42:8;y=round?92:70;width=round?176:184;height=round?68:66; |
| 145 | 153 | if (!count) { |
| @@ -147,10 +155,11 @@ function departures(port,r,now) { | ||
| 147 | 155 | lines(port,text,1,INK,x,y+Math.max(0,Math.floor((height-lineCount(text)*16)/2)),width,16); |
| 148 | 156 | } else { |
| 149 | 157 | const status=hex(data,31,1), value=countdown(r,data,0,now,true), unit=status!==2 && Math.ceil((hex(data,23,8)*1000-now)/60000)>0; |
| 150 | - const style=value==="-"?2:3, valueWidth=Math.min(width-36,Math.ceil(port.measureString(value,fonts[style]).width)); | |
| 151 | - const left=x+Math.floor((width-valueWidth-(unit?34:0))/2); | |
| 152 | - draw(port,value,style,INK,left,y,valueWidth,height-18); | |
| 153 | - if(unit)draw(port,"min",1,INK,left+valueWidth+3,y+20,31,height-30,-1); | |
| 158 | + const style=value==="-"?2:3, unitWidth=unit?Math.ceil(port.measureString("min",fonts[2]).width):0; | |
| 159 | + const gap=unit?4:0, valueWidth=Math.min(width-unitWidth-gap,Math.ceil(port.measureString(value,fonts[style]).width)); | |
| 160 | + const left=x+Math.floor((width-valueWidth-unitWidth-gap)/2), bottom=y+height-(round?17:14); | |
| 161 | + drawBottom(port,value,style,INK,left,bottom,valueWidth); | |
| 162 | + if(unit)drawBottom(port,"min",2,INK,left+valueWidth+gap,bottom,unitWidth); | |
| 154 | 163 | if(status)draw(port,copy(r.profile,r.language,13+status),1,INK,x,y+height-19,width,18); |
| 155 | 164 | } |
| 156 | 165 | const rows=Math.min(round?2:3,Math.max(0,count-1)); |
| @@ -19,6 +19,12 @@ function draw(port, text, style, color, x, y, width, height, align = 0) { | |||
| 19 | const left = align < 0 ? x : align > 0 ? x + width - used : x + Math.floor((width-used)/2); | 19 | const left = align < 0 ? x : align > 0 ? x + width - used : x + Math.floor((width-used)/2); |
| 20 | port.drawString(text, fonts[style], color, left, y + ((height - measure.height) >> 1), used < measure.width ? used : 0); | 20 | port.drawString(text, fonts[style], color, left, y + ((height - measure.height) >> 1), used < measure.width ? used : 0); |
| 21 | } | 21 | } |
| 22 | +function drawBottom(port, text, style, color, x, bottom, width) { | ||
| 23 | + if (!text || width <= 0) return; | ||
| 24 | + const measure = port.measureString(text, fonts[style]); | ||
| 25 | + const used = Math.min(width, Math.ceil(measure.width)); | ||
| 26 | + port.drawString(text, fonts[style], color, x, bottom-measure.height, used < measure.width ? used : 0); | ||
| 27 | +} | ||
| 22 | function lines(port, text, style, color, x, y, width, height) { | 28 | function lines(port, text, style, color, x, y, width, height) { |
| 23 | let start = 0, count = 0; | 29 | let start = 0, count = 0; |
| 24 | while (start <= text.length && text.length) { | 30 | while (start <= text.length && text.length) { |
| @@ -104,7 +110,9 @@ function header(port,r,now,isStale,updating) { | |||
| 104 | } | 110 | } |
| 105 | } | 111 | } |
| 106 | function overview(port,r,now) { | 112 | function overview(port,r,now) { |
| 107 | - const start = Math.max(0,r.focus-1), end = Math.min(r.records.length,r.focus+1), round = r.profile === 1; | 113 | + const round = r.profile === 1, last = r.records.length; |
| 114 | + const start = round ? Math.max(0,r.focus-1) : Math.max(0,Math.min(r.focus-1,last-2)); | ||
| 115 | + const end = round ? Math.min(last,r.focus+1) : Math.min(last,start+2); | ||
| 108 | for (let i = start; i <= end; i++) { | 116 | for (let i = start; i <= end; i++) { |
| 109 | const relative = i-r.focus, compact = round && relative !== 0, focused = relative === 0; | 117 | const relative = i-r.focus, compact = round && relative !== 0, focused = relative === 0; |
| 110 | const x = round ? compact ? 54 : 30 : 6, y = round ? relative<0 ? 54 : relative>0 ? 176 : 92 : 34+(i-start)*62; | 118 | const x = round ? compact ? 54 : 30 : 6, y = round ? relative<0 ? 54 : relative>0 ? 176 : 92 : 34+(i-start)*62; |
| @@ -138,8 +146,8 @@ function departures(port,r,now) { | |||
| 138 | let x=round?46:8,y=round?52:34,width=round?168:184,height=round?40:35; | 146 | let x=round?46:8,y=round?52:34,width=round?168:184,height=round?40:35; |
| 139 | chip(port,record,2,x,y+6,44,27); | 147 | chip(port,record,2,x,y+6,44,27); |
| 140 | draw(port,clipped(record,8),1,INK,x+52,y,width-52,20,-1); | 148 | draw(port,clipped(record,8),1,INK,x+52,y,width-52,20,-1); |
| 141 | - draw(port,">",0,MUTED,x+52,y+20,10,18,-1); | 149 | + draw(port,">",1,MUTED,x+52,y+20,10,18,-1); |
| 142 | - draw(port,clipped(record,11),0,MUTED,x+62,y+20,width-62,18,-1); | 150 | + draw(port,clipped(record,11),1,MUTED,x+62,y+20,width-62,18,-1); |
| 143 | port.fillColor(RULE,x,y+height-1,width,1); | 151 | port.fillColor(RULE,x,y+height-1,width,1); |
| 144 | x=round?42:8;y=round?92:70;width=round?176:184;height=round?68:66; | 152 | x=round?42:8;y=round?92:70;width=round?176:184;height=round?68:66; |
| 145 | if (!count) { | 153 | if (!count) { |
| @@ -147,10 +155,11 @@ function departures(port,r,now) { | |||
| 147 | lines(port,text,1,INK,x,y+Math.max(0,Math.floor((height-lineCount(text)*16)/2)),width,16); | 155 | lines(port,text,1,INK,x,y+Math.max(0,Math.floor((height-lineCount(text)*16)/2)),width,16); |
| 148 | } else { | 156 | } else { |
| 149 | const status=hex(data,31,1), value=countdown(r,data,0,now,true), unit=status!==2 && Math.ceil((hex(data,23,8)*1000-now)/60000)>0; | 157 | const status=hex(data,31,1), value=countdown(r,data,0,now,true), unit=status!==2 && Math.ceil((hex(data,23,8)*1000-now)/60000)>0; |
| 150 | - const style=value==="-"?2:3, valueWidth=Math.min(width-36,Math.ceil(port.measureString(value,fonts[style]).width)); | 158 | + const style=value==="-"?2:3, unitWidth=unit?Math.ceil(port.measureString("min",fonts[2]).width):0; |
| 151 | - const left=x+Math.floor((width-valueWidth-(unit?34:0))/2); | 159 | + const gap=unit?4:0, valueWidth=Math.min(width-unitWidth-gap,Math.ceil(port.measureString(value,fonts[style]).width)); |
| 152 | - draw(port,value,style,INK,left,y,valueWidth,height-18); | 160 | + const left=x+Math.floor((width-valueWidth-unitWidth-gap)/2), bottom=y+height-(round?17:14); |
| 153 | - if(unit)draw(port,"min",1,INK,left+valueWidth+3,y+20,31,height-30,-1); | 161 | + drawBottom(port,value,style,INK,left,bottom,valueWidth); |
| 162 | + if(unit)drawBottom(port,"min",2,INK,left+valueWidth+gap,bottom,unitWidth); | ||
| 154 | if(status)draw(port,copy(r.profile,r.language,13+status),1,INK,x,y+height-19,width,18); | 163 | if(status)draw(port,copy(r.profile,r.language,13+status),1,INK,x,y+height-19,width,18); |
| 155 | } | 164 | } |
| 156 | const rows=Math.min(round?2:3,Math.max(0,count-1)); | 165 | const rows=Math.min(round?2:3,Math.max(0,count-1)); |
modified
packages/watch/test/runtime.test.js +54 -0 | @@ -221,6 +221,60 @@ test("obsolete work is cancelled without becoming the new favorite's failure", ( | ||
| 221 | 221 | assert.equal(f6.timers.size, 0, "clock change cancels pre-BEGIN traffic expectations"); |
| 222 | 222 | }); |
| 223 | 223 | |
| 224 | +test("rapid detail navigation settles once on the final favorite after 500 ms", () => { | |
| 225 | + const target = harness(); | |
| 226 | + const records = appearances(6); | |
| 227 | + configure(target, records); | |
| 228 | + data(target, 0, records.map(() => departure())); | |
| 229 | + target.runtime.button("select"); | |
| 230 | + data(target, 1, [departure()]); | |
| 231 | + const before = target.out.filter((message) => message.get(1) === 1).length; | |
| 232 | + target.runtime.button("down"); | |
| 233 | + target.runtime.button("down"); | |
| 234 | + target.runtime.button("up"); | |
| 235 | + target.runtime.button("down"); | |
| 236 | + assert.equal(target.r.active, 2); | |
| 237 | + assert.equal(target.timers.size, 1); | |
| 238 | + assert.equal(target.out.filter((message) => message.get(1) === 1).length, before); | |
| 239 | + const [timer] = target.timers.values(); | |
| 240 | + assert.equal(timer.delay, 500); | |
| 241 | + target.timers.clear(); | |
| 242 | + timer.fn(); | |
| 243 | + const requests = target.out.filter((message) => message.get(1) === 1); | |
| 244 | + assert.equal(requests.length, before + 1); | |
| 245 | + assert.equal(requests.at(-1).get(3), field(records[2], 0)); | |
| 246 | + assert.equal(requests.at(-1).get(24), 1); | |
| 247 | +}); | |
| 248 | + | |
| 249 | +test("fifty refresh and rapid-navigation cycles keep durable and timer state bounded", () => { | |
| 250 | + const target = harness(); | |
| 251 | + const records = appearances(6); | |
| 252 | + configure(target, records); | |
| 253 | + data(target, 0, records.map(() => departure())); | |
| 254 | + target.runtime.button("select"); | |
| 255 | + data(target, 1, [departure()]); | |
| 256 | + const durableKeys = [...target.store.values.keys()].sort(); | |
| 257 | + const committedRecords = target.r.records; | |
| 258 | + for (let cycle = 0; cycle < 50; cycle++) { | |
| 259 | + target.runtime.button("selectLong"); | |
| 260 | + data(target, 1, [departure()]); | |
| 261 | + target.runtime.button(cycle % 2 ? "up" : "down"); | |
| 262 | + target.runtime.button(cycle % 2 ? "down" : "up"); | |
| 263 | + assert.equal(target.timers.size, 1); | |
| 264 | + const [timer] = target.timers.values(); | |
| 265 | + target.timers.clear(); | |
| 266 | + timer.fn(); | |
| 267 | + data(target, 1, [departure()]); | |
| 268 | + assert.equal(target.r.pending, 0); | |
| 269 | + assert.equal(target.r.candidate, null); | |
| 270 | + } | |
| 271 | + assert.equal(target.r.records, committedRecords); | |
| 272 | + assert.deepEqual([...target.store.values.keys()].sort(), durableKeys); | |
| 273 | + assert.equal(target.timers.size, 0); | |
| 274 | + assert.equal(target.r.requests.length, 3); | |
| 275 | + assert.equal(target.r.requests.filter(Boolean).length, 2); | |
| 276 | +}); | |
| 277 | + | |
| 224 | 278 | test("enqueue failures belong to the attempted request, never unrelated sync", () => { |
| 225 | 279 | const f7 = harness(); |
| 226 | 280 | readyDetail(f7); |
| @@ -221,6 +221,60 @@ test("obsolete work is cancelled without becoming the new favorite's failure", ( | |||
| 221 | assert.equal(f6.timers.size, 0, "clock change cancels pre-BEGIN traffic expectations"); | 221 | assert.equal(f6.timers.size, 0, "clock change cancels pre-BEGIN traffic expectations"); |
| 222 | }); | 222 | }); |
| 223 | 223 | ||
| 224 | +test("rapid detail navigation settles once on the final favorite after 500 ms", () => { | ||
| 225 | + const target = harness(); | ||
| 226 | + const records = appearances(6); | ||
| 227 | + configure(target, records); | ||
| 228 | + data(target, 0, records.map(() => departure())); | ||
| 229 | + target.runtime.button("select"); | ||
| 230 | + data(target, 1, [departure()]); | ||
| 231 | + const before = target.out.filter((message) => message.get(1) === 1).length; | ||
| 232 | + target.runtime.button("down"); | ||
| 233 | + target.runtime.button("down"); | ||
| 234 | + target.runtime.button("up"); | ||
| 235 | + target.runtime.button("down"); | ||
| 236 | + assert.equal(target.r.active, 2); | ||
| 237 | + assert.equal(target.timers.size, 1); | ||
| 238 | + assert.equal(target.out.filter((message) => message.get(1) === 1).length, before); | ||
| 239 | + const [timer] = target.timers.values(); | ||
| 240 | + assert.equal(timer.delay, 500); | ||
| 241 | + target.timers.clear(); | ||
| 242 | + timer.fn(); | ||
| 243 | + const requests = target.out.filter((message) => message.get(1) === 1); | ||
| 244 | + assert.equal(requests.length, before + 1); | ||
| 245 | + assert.equal(requests.at(-1).get(3), field(records[2], 0)); | ||
| 246 | + assert.equal(requests.at(-1).get(24), 1); | ||
| 247 | +}); | ||
| 248 | + | ||
| 249 | +test("fifty refresh and rapid-navigation cycles keep durable and timer state bounded", () => { | ||
| 250 | + const target = harness(); | ||
| 251 | + const records = appearances(6); | ||
| 252 | + configure(target, records); | ||
| 253 | + data(target, 0, records.map(() => departure())); | ||
| 254 | + target.runtime.button("select"); | ||
| 255 | + data(target, 1, [departure()]); | ||
| 256 | + const durableKeys = [...target.store.values.keys()].sort(); | ||
| 257 | + const committedRecords = target.r.records; | ||
| 258 | + for (let cycle = 0; cycle < 50; cycle++) { | ||
| 259 | + target.runtime.button("selectLong"); | ||
| 260 | + data(target, 1, [departure()]); | ||
| 261 | + target.runtime.button(cycle % 2 ? "up" : "down"); | ||
| 262 | + target.runtime.button(cycle % 2 ? "down" : "up"); | ||
| 263 | + assert.equal(target.timers.size, 1); | ||
| 264 | + const [timer] = target.timers.values(); | ||
| 265 | + target.timers.clear(); | ||
| 266 | + timer.fn(); | ||
| 267 | + data(target, 1, [departure()]); | ||
| 268 | + assert.equal(target.r.pending, 0); | ||
| 269 | + assert.equal(target.r.candidate, null); | ||
| 270 | + } | ||
| 271 | + assert.equal(target.r.records, committedRecords); | ||
| 272 | + assert.deepEqual([...target.store.values.keys()].sort(), durableKeys); | ||
| 273 | + assert.equal(target.timers.size, 0); | ||
| 274 | + assert.equal(target.r.requests.length, 3); | ||
| 275 | + assert.equal(target.r.requests.filter(Boolean).length, 2); | ||
| 276 | +}); | ||
| 277 | + | ||
| 224 | test("enqueue failures belong to the attempted request, never unrelated sync", () => { | 278 | test("enqueue failures belong to the attempted request, never unrelated sync", () => { |
| 225 | const f7 = harness(); | 279 | const f7 = harness(); |
| 226 | readyDetail(f7); | 280 | readyDetail(f7); |
modified
packages/watch/test/ui.test.js +36 -0 | @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import test from "node:test"; |
| 2 | 2 | import assert from "node:assert/strict"; |
| 3 | +import { readFileSync } from "node:fs"; | |
| 3 | 4 | import { nativeHost } from "./native-host.js"; |
| 4 | 5 | import { watchModule } from "./xs-host.js"; |
| 5 | 6 | import { |
| @@ -8,6 +9,9 @@ import { | ||
| 8 | 9 | } from "./d2-records.js"; |
| 9 | 10 | |
| 10 | 11 | const { field } = await watchModule("packed"); |
| 12 | +const supportedFonts = new Set(JSON.parse(readFileSync( | |
| 13 | + new URL("../../companion/src/display-font-metrics.json", import.meta.url) | |
| 14 | +)).roles); | |
| 11 | 15 | |
| 12 | 16 | async function renderHarness(t, profile = 0) { |
| 13 | 17 | nativeHost(t, profile); |
| @@ -105,6 +109,38 @@ for (const profile of [0, 1]) { | ||
| 105 | 109 | }); |
| 106 | 110 | } |
| 107 | 111 | |
| 112 | +for (const profile of [0, 1]) { | |
| 113 | + test(`profile ${profile}: detail emphasizes the destination and aligns the primary countdown`, async (t) => { | |
| 114 | + const h = await renderHarness(t, profile); | |
| 115 | + configure(h, appearances(2, "fav", { profile })); | |
| 116 | + data(h, 0, [departure(), departure()]); | |
| 117 | + h.runtime.button("select"); | |
| 118 | + data(h, 1, [departure()]); | |
| 119 | + const rows = h.show(); | |
| 120 | + const destination = rows.find((row) => row.text === "Vers 0"); | |
| 121 | + const value = rows.find((row) => row.text === "5"); | |
| 122 | + const unit = rows.find((row) => row.text === "min"); | |
| 123 | + assert(rows.every((row) => supportedFonts.has(row.font))); | |
| 124 | + assert.equal(destination.font, "bold 14px Gothic"); | |
| 125 | + assert.equal(value.font, "bold 36px Gothic"); | |
| 126 | + assert.equal(unit.font, "bold 18px Gothic"); | |
| 127 | + assert.equal(value.y, unit.y); | |
| 128 | + assert.equal(value.y, profile === 1 ? 129 : 108); | |
| 129 | + }); | |
| 130 | +} | |
| 131 | + | |
| 132 | +test("rectangular overview fills the first three rows", async (t) => { | |
| 133 | + const h = await renderHarness(t); | |
| 134 | + configure(h, appearances(2)); | |
| 135 | + data(h, 0, [departure(), departure()]); | |
| 136 | + const firstRows = h.show(); | |
| 137 | + assert(firstRows.some((row) => row.text === "Tout actualiser"), JSON.stringify(firstRows)); | |
| 138 | + | |
| 139 | + configure(h, appearances(3, "next")); | |
| 140 | + data(h, 0, [departure(), departure(), departure()]); | |
| 141 | + assert(h.show().some((row) => row.text === "Arrêt 2")); | |
| 142 | +}); | |
| 143 | + | |
| 108 | 144 | test("retained credential failures outrank traffic loading until matching success", async (t) => { |
| 109 | 145 | const h = await renderHarness(t); |
| 110 | 146 | readyDetail(h); |
| @@ -1,5 +1,6 @@ | |||
| 1 | import test from "node:test"; | 1 | import test from "node:test"; |
| 2 | import assert from "node:assert/strict"; | 2 | import assert from "node:assert/strict"; |
| 3 | +import { readFileSync } from "node:fs"; | ||
| 3 | import { nativeHost } from "./native-host.js"; | 4 | import { nativeHost } from "./native-host.js"; |
| 4 | import { watchModule } from "./xs-host.js"; | 5 | import { watchModule } from "./xs-host.js"; |
| 5 | import { | 6 | import { |
| @@ -8,6 +9,9 @@ import { | |||
| 8 | } from "./d2-records.js"; | 9 | } from "./d2-records.js"; |
| 9 | 10 | ||
| 10 | const { field } = await watchModule("packed"); | 11 | const { field } = await watchModule("packed"); |
| 12 | +const supportedFonts = new Set(JSON.parse(readFileSync( | ||
| 13 | + new URL("../../companion/src/display-font-metrics.json", import.meta.url) | ||
| 14 | +)).roles); | ||
| 11 | 15 | ||
| 12 | async function renderHarness(t, profile = 0) { | 16 | async function renderHarness(t, profile = 0) { |
| 13 | nativeHost(t, profile); | 17 | nativeHost(t, profile); |
| @@ -105,6 +109,38 @@ for (const profile of [0, 1]) { | |||
| 105 | }); | 109 | }); |
| 106 | } | 110 | } |
| 107 | 111 | ||
| 112 | +for (const profile of [0, 1]) { | ||
| 113 | + test(`profile ${profile}: detail emphasizes the destination and aligns the primary countdown`, async (t) => { | ||
| 114 | + const h = await renderHarness(t, profile); | ||
| 115 | + configure(h, appearances(2, "fav", { profile })); | ||
| 116 | + data(h, 0, [departure(), departure()]); | ||
| 117 | + h.runtime.button("select"); | ||
| 118 | + data(h, 1, [departure()]); | ||
| 119 | + const rows = h.show(); | ||
| 120 | + const destination = rows.find((row) => row.text === "Vers 0"); | ||
| 121 | + const value = rows.find((row) => row.text === "5"); | ||
| 122 | + const unit = rows.find((row) => row.text === "min"); | ||
| 123 | + assert(rows.every((row) => supportedFonts.has(row.font))); | ||
| 124 | + assert.equal(destination.font, "bold 14px Gothic"); | ||
| 125 | + assert.equal(value.font, "bold 36px Gothic"); | ||
| 126 | + assert.equal(unit.font, "bold 18px Gothic"); | ||
| 127 | + assert.equal(value.y, unit.y); | ||
| 128 | + assert.equal(value.y, profile === 1 ? 129 : 108); | ||
| 129 | + }); | ||
| 130 | +} | ||
| 131 | + | ||
| 132 | +test("rectangular overview fills the first three rows", async (t) => { | ||
| 133 | + const h = await renderHarness(t); | ||
| 134 | + configure(h, appearances(2)); | ||
| 135 | + data(h, 0, [departure(), departure()]); | ||
| 136 | + const firstRows = h.show(); | ||
| 137 | + assert(firstRows.some((row) => row.text === "Tout actualiser"), JSON.stringify(firstRows)); | ||
| 138 | + | ||
| 139 | + configure(h, appearances(3, "next")); | ||
| 140 | + data(h, 0, [departure(), departure(), departure()]); | ||
| 141 | + assert(h.show().some((row) => row.text === "Arrêt 2")); | ||
| 142 | +}); | ||
| 143 | + | ||
| 108 | test("retained credential failures outrank traffic loading until matching success", async (t) => { | 144 | test("retained credential failures outrank traffic loading until matching success", async (t) => { |
| 109 | const h = await renderHarness(t); | 145 | const h = await renderHarness(t); |
| 110 | readyDetail(h); | 146 | readyDetail(h); |
modified
scripts/build-config-site.mjs +41 -22 | @@ -17,54 +17,73 @@ const OUTPUT_PATH = join(PROJECT_ROOT, "var/config-site"); | ||
| 17 | 17 | const PAGE_ENTRIES = ["index.html", "src", "styles", "assets"]; |
| 18 | 18 | |
| 19 | 19 | function fail(message) { |
| 20 | - console.error(`build:config-site failed: ${message}`); | |
| 21 | - process.exit(1); | |
| 20 | + throw new Error(`build:config-site failed: ${message}`); | |
| 22 | 21 | } |
| 23 | 22 | |
| 24 | -function copyCatalog() { | |
| 25 | - const manifest = join(STATIC_PATH, "manifest.json"); | |
| 23 | +function copyCatalog(staticPath, outputPath) { | |
| 24 | + const manifest = join(staticPath, "manifest.json"); | |
| 26 | 25 | if (!existsSync(manifest)) { |
| 27 | - fail(`no published static catalog at ${STATIC_PATH} (manifest.json missing). Run "npm run catalog:refresh" first.`); | |
| 26 | + fail(`no published static catalog at ${staticPath} (manifest.json missing). Run "npm run catalog:refresh" first.`); | |
| 28 | 27 | } |
| 29 | 28 | |
| 30 | - const catalogTarget = join(OUTPUT_PATH, "catalog"); | |
| 29 | + const catalogTarget = join(outputPath, "catalog"); | |
| 31 | 30 | mkdirSync(catalogTarget, { recursive: true }); |
| 32 | - for (const entry of readdirSync(STATIC_PATH)) { | |
| 33 | - cpSync(join(STATIC_PATH, entry), join(catalogTarget, entry), { recursive: true }); | |
| 31 | + for (const entry of readdirSync(staticPath)) { | |
| 32 | + cpSync(join(staticPath, entry), join(catalogTarget, entry), { recursive: true }); | |
| 34 | 33 | } |
| 35 | 34 | if (!existsSync(join(catalogTarget, "manifest.json"))) { |
| 36 | 35 | fail("catalog copy did not produce manifest.json"); |
| 37 | 36 | } |
| 38 | 37 | } |
| 39 | 38 | |
| 40 | -function copyPage() { | |
| 39 | +function copyPage(pageSource, outputPath) { | |
| 41 | 40 | for (const entry of PAGE_ENTRIES) { |
| 42 | - const source = join(PAGE_SOURCE, entry); | |
| 41 | + const source = join(pageSource, entry); | |
| 43 | 42 | if (!existsSync(source)) fail(`missing config page source: ${entry}`); |
| 44 | 43 | if (statSync(source).isDirectory()) { |
| 45 | - cpSync(source, join(OUTPUT_PATH, entry), { recursive: true }); | |
| 44 | + cpSync(source, join(outputPath, entry), { recursive: true }); | |
| 46 | 45 | } else { |
| 47 | - cpSync(source, join(OUTPUT_PATH, entry)); | |
| 46 | + cpSync(source, join(outputPath, entry)); | |
| 48 | 47 | } |
| 49 | 48 | } |
| 50 | 49 | } |
| 51 | 50 | |
| 52 | -function build() { | |
| 53 | - if (!existsSync(STATIC_PATH)) { | |
| 54 | - fail(`no published static catalog at ${STATIC_PATH}. Run "npm run catalog:refresh" first.`); | |
| 51 | +export function buildConfigSite({ | |
| 52 | + pageSource = PAGE_SOURCE, | |
| 53 | + staticPath = STATIC_PATH, | |
| 54 | + outputPath = OUTPUT_PATH, | |
| 55 | + includeCatalog = true, | |
| 56 | +} = {}) { | |
| 57 | + if (includeCatalog && !existsSync(staticPath)) { | |
| 58 | + fail(`no published static catalog at ${staticPath}. Run "npm run catalog:refresh" first.`); | |
| 55 | 59 | } |
| 56 | 60 | |
| 57 | - rmSync(OUTPUT_PATH, { recursive: true, force: true }); | |
| 58 | - mkdirSync(OUTPUT_PATH, { recursive: true }); | |
| 61 | + rmSync(outputPath, { recursive: true, force: true }); | |
| 62 | + mkdirSync(outputPath, { recursive: true }); | |
| 59 | 63 | try { |
| 60 | - copyPage(); | |
| 61 | - copyCatalog(); | |
| 64 | + copyPage(pageSource, outputPath); | |
| 65 | + if (includeCatalog) copyCatalog(staticPath, outputPath); | |
| 62 | 66 | } catch (error) { |
| 63 | - rmSync(OUTPUT_PATH, { recursive: true, force: true }); | |
| 67 | + rmSync(outputPath, { recursive: true, force: true }); | |
| 64 | 68 | throw error; |
| 65 | 69 | } |
| 66 | 70 | |
| 67 | - console.log(`config site ready: ${OUTPUT_PATH} (catalog revision in catalog/manifest.json)`); | |
| 71 | + console.log(includeCatalog | |
| 72 | + ? `config site ready: ${outputPath} (catalog revision in catalog/manifest.json)` | |
| 73 | + : `config page ready: ${outputPath} (existing hosted catalog left unchanged)`); | |
| 68 | 74 | } |
| 69 | 75 | |
| 70 | -if (process.argv[1] === fileURLToPath(import.meta.url)) build(); | |
| 76 | +if (process.argv[1] === fileURLToPath(import.meta.url)) { | |
| 77 | + const arguments_ = process.argv.slice(2); | |
| 78 | + if (arguments_.some((argument) => argument !== "--page-only") || arguments_.length > 1) { | |
| 79 | + console.error('build:config-site failed: the only supported option is "--page-only"'); | |
| 80 | + process.exitCode = 1; | |
| 81 | + } else { | |
| 82 | + try { | |
| 83 | + buildConfigSite({ includeCatalog: arguments_[0] !== "--page-only" }); | |
| 84 | + } catch (error) { | |
| 85 | + console.error(error instanceof Error ? error.message : error); | |
| 86 | + process.exitCode = 1; | |
| 87 | + } | |
| 88 | + } | |
| 89 | +} | |
| @@ -17,54 +17,73 @@ const OUTPUT_PATH = join(PROJECT_ROOT, "var/config-site"); | |||
| 17 | const PAGE_ENTRIES = ["index.html", "src", "styles", "assets"]; | 17 | const PAGE_ENTRIES = ["index.html", "src", "styles", "assets"]; |
| 18 | 18 | ||
| 19 | function fail(message) { | 19 | function fail(message) { |
| 20 | - console.error(`build:config-site failed: ${message}`); | 20 | + throw new Error(`build:config-site failed: ${message}`); |
| 21 | - process.exit(1); | ||
| 22 | } | 21 | } |
| 23 | 22 | ||
| 24 | -function copyCatalog() { | 23 | +function copyCatalog(staticPath, outputPath) { |
| 25 | - const manifest = join(STATIC_PATH, "manifest.json"); | 24 | + const manifest = join(staticPath, "manifest.json"); |
| 26 | if (!existsSync(manifest)) { | 25 | if (!existsSync(manifest)) { |
| 27 | - fail(`no published static catalog at ${STATIC_PATH} (manifest.json missing). Run "npm run catalog:refresh" first.`); | 26 | + fail(`no published static catalog at ${staticPath} (manifest.json missing). Run "npm run catalog:refresh" first.`); |
| 28 | } | 27 | } |
| 29 | 28 | ||
| 30 | - const catalogTarget = join(OUTPUT_PATH, "catalog"); | 29 | + const catalogTarget = join(outputPath, "catalog"); |
| 31 | mkdirSync(catalogTarget, { recursive: true }); | 30 | mkdirSync(catalogTarget, { recursive: true }); |
| 32 | - for (const entry of readdirSync(STATIC_PATH)) { | 31 | + for (const entry of readdirSync(staticPath)) { |
| 33 | - cpSync(join(STATIC_PATH, entry), join(catalogTarget, entry), { recursive: true }); | 32 | + cpSync(join(staticPath, entry), join(catalogTarget, entry), { recursive: true }); |
| 34 | } | 33 | } |
| 35 | if (!existsSync(join(catalogTarget, "manifest.json"))) { | 34 | if (!existsSync(join(catalogTarget, "manifest.json"))) { |
| 36 | fail("catalog copy did not produce manifest.json"); | 35 | fail("catalog copy did not produce manifest.json"); |
| 37 | } | 36 | } |
| 38 | } | 37 | } |
| 39 | 38 | ||
| 40 | -function copyPage() { | 39 | +function copyPage(pageSource, outputPath) { |
| 41 | for (const entry of PAGE_ENTRIES) { | 40 | for (const entry of PAGE_ENTRIES) { |
| 42 | - const source = join(PAGE_SOURCE, entry); | 41 | + const source = join(pageSource, entry); |
| 43 | if (!existsSync(source)) fail(`missing config page source: ${entry}`); | 42 | if (!existsSync(source)) fail(`missing config page source: ${entry}`); |
| 44 | if (statSync(source).isDirectory()) { | 43 | if (statSync(source).isDirectory()) { |
| 45 | - cpSync(source, join(OUTPUT_PATH, entry), { recursive: true }); | 44 | + cpSync(source, join(outputPath, entry), { recursive: true }); |
| 46 | } else { | 45 | } else { |
| 47 | - cpSync(source, join(OUTPUT_PATH, entry)); | 46 | + cpSync(source, join(outputPath, entry)); |
| 48 | } | 47 | } |
| 49 | } | 48 | } |
| 50 | } | 49 | } |
| 51 | 50 | ||
| 52 | -function build() { | 51 | +export function buildConfigSite({ |
| 53 | - if (!existsSync(STATIC_PATH)) { | 52 | + pageSource = PAGE_SOURCE, |
| 54 | - fail(`no published static catalog at ${STATIC_PATH}. Run "npm run catalog:refresh" first.`); | 53 | + staticPath = STATIC_PATH, |
| 54 | + outputPath = OUTPUT_PATH, | ||
| 55 | + includeCatalog = true, | ||
| 56 | +} = {}) { | ||
| 57 | + if (includeCatalog && !existsSync(staticPath)) { | ||
| 58 | + fail(`no published static catalog at ${staticPath}. Run "npm run catalog:refresh" first.`); | ||
| 55 | } | 59 | } |
| 56 | 60 | ||
| 57 | - rmSync(OUTPUT_PATH, { recursive: true, force: true }); | 61 | + rmSync(outputPath, { recursive: true, force: true }); |
| 58 | - mkdirSync(OUTPUT_PATH, { recursive: true }); | 62 | + mkdirSync(outputPath, { recursive: true }); |
| 59 | try { | 63 | try { |
| 60 | - copyPage(); | 64 | + copyPage(pageSource, outputPath); |
| 61 | - copyCatalog(); | 65 | + if (includeCatalog) copyCatalog(staticPath, outputPath); |
| 62 | } catch (error) { | 66 | } catch (error) { |
| 63 | - rmSync(OUTPUT_PATH, { recursive: true, force: true }); | 67 | + rmSync(outputPath, { recursive: true, force: true }); |
| 64 | throw error; | 68 | throw error; |
| 65 | } | 69 | } |
| 66 | 70 | ||
| 67 | - console.log(`config site ready: ${OUTPUT_PATH} (catalog revision in catalog/manifest.json)`); | 71 | + console.log(includeCatalog |
| 72 | + ? `config site ready: ${outputPath} (catalog revision in catalog/manifest.json)` | ||
| 73 | + : `config page ready: ${outputPath} (existing hosted catalog left unchanged)`); | ||
| 68 | } | 74 | } |
| 69 | 75 | ||
| 70 | -if (process.argv[1] === fileURLToPath(import.meta.url)) build(); | 76 | +if (process.argv[1] === fileURLToPath(import.meta.url)) { |
| 77 | + const arguments_ = process.argv.slice(2); | ||
| 78 | + if (arguments_.some((argument) => argument !== "--page-only") || arguments_.length > 1) { | ||
| 79 | + console.error('build:config-site failed: the only supported option is "--page-only"'); | ||
| 80 | + process.exitCode = 1; | ||
| 81 | + } else { | ||
| 82 | + try { | ||
| 83 | + buildConfigSite({ includeCatalog: arguments_[0] !== "--page-only" }); | ||
| 84 | + } catch (error) { | ||
| 85 | + console.error(error instanceof Error ? error.message : error); | ||
| 86 | + process.exitCode = 1; | ||
| 87 | + } | ||
| 88 | + } | ||
| 89 | +} | ||