feat: harden stale and failure handling #20
Merged@github-anthodev wants to merge
feature/harden-failures/reliability/8 into developMerged as
835b18ba053b.30 files changed
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 Large diff collapsed to keep the page fast — load it when you need it.
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 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/config-page/test/config-page.test.ts +12 -1 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/config-page/test/config-site-build.test.ts +79 -0 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/contracts/src/index.ts +3 -0 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/contracts/test/contracts.test.ts +3 -0 Large diff collapsed to keep the page fast — load it when you need it.
deleted
packages/watch/.lock-waf_linux_build +0 -10 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/watch/package.json +8 -1 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/watch/src/embeddedjs/ui.js +16 -7 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/watch/test/runtime.test.js +54 -0 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/watch/test/ui.test.js +36 -0 Large diff collapsed to keep the page fast — load it when you need it.
modified
scripts/build-config-site.mjs +41 -22 Large diff collapsed to keep the page fast — load it when you need it.