diff --git a/.github/workflows/tennis-watch-ci.yml b/.github/workflows/tennis-watch-ci.yml
new file mode 100644
index 0000000..411d1c9
--- /dev/null
+++ b/.github/workflows/tennis-watch-ci.yml
@@ -0,0 +1,119 @@
+name: Tennis Watch CI
+
+on:
+ push:
+ branches: [main]
+ paths: [tennis-watch/**, .github/workflows/tennis-watch-ci.yml, .github/workflows/tennis-watch-release.yml]
+ pull_request:
+ paths: [tennis-watch/**, .github/workflows/tennis-watch-ci.yml, .github/workflows/tennis-watch-release.yml]
+ workflow_dispatch:
+
+permissions:
+ actions: read
+ contents: read
+
+jobs:
+ release-version:
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ permissions:
+ contents: write
+ uses: ./.github/workflows/semantic-release-version.yml
+ with:
+ application-directory: tennis-watch
+ tag-prefix: tennis-watch-v
+ secrets: inherit
+
+ build-test-apk:
+ needs: release-version
+ if: always() && (needs.release-version.result == 'success' || needs.release-version.result == 'skipped')
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ defaults:
+ run:
+ working-directory: tennis-watch
+ env:
+ CI: true
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ fetch-depth: 0
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
+ - uses: actions/setup-dotnet@v5
+ with:
+ global-json-file: tennis-watch/global.json
+ - uses: actions/setup-dotnet@v5
+ with:
+ global-json-file: tennis-watch/src/TennisWatch/global.json
+ - uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: 17
+ - name: Install Android workload
+ working-directory: tennis-watch/src/TennisWatch
+ run: dotnet workload install maui-android --skip-manifest-update
+ - name: Build, test, and package
+ env:
+ RELEASE_VERSION: ${{ needs.release-version.outputs.version }}
+ run: |
+ if [[ -n "$RELEASE_VERSION" ]]; then
+ ./build.sh --release-version "$RELEASE_VERSION"
+ else
+ ./build.sh
+ fi
+ - name: Detect covered input changes
+ id: covered
+ if: github.event_name == 'pull_request'
+ env:
+ TARGET_SHA: ${{ github.event.pull_request.base.sha }}
+ run: |
+ if git cat-file -e "$TARGET_SHA:tennis-watch/tests/TennisWatch.Tests/TennisWatch.Tests.csproj" 2>/dev/null &&
+ ! git diff --quiet "$TARGET_SHA"...HEAD -- src/TennisWatch.Core tests Directory.Build.props Directory.Packages.props global.json; then
+ echo "changed=true" >> "$GITHUB_OUTPUT"
+ fi
+ - name: Fetch base coverage
+ id: base-coverage
+ if: steps.covered.outputs.changed == 'true'
+ continue-on-error: true
+ env:
+ CRAP_SCORE_API_URL: ${{ github.api_url }}
+ CRAP_SCORE_GITHUB_TOKEN: ${{ github.token }}
+ run: >-
+ dotnet run --project ../tools/CrapScore/CrapScore.csproj -c Release --no-build --
+ download-artifact ${{ github.repository }} ${{ github.event.pull_request.base.sha }}
+ ../artifacts/tennis-watch-base-coverage tennis-watch-crap-score
+ - uses: actions/checkout@v7
+ if: steps.covered.outputs.changed == 'true' && steps.base-coverage.outcome == 'failure'
+ with:
+ ref: ${{ github.event.pull_request.base.sha }}
+ path: artifacts/tennis-watch-base
+ - uses: actions/setup-dotnet@v5
+ if: steps.covered.outputs.changed == 'true' && steps.base-coverage.outcome == 'failure'
+ with:
+ global-json-file: artifacts/tennis-watch-base/tennis-watch/global.json
+ - name: Measure base coverage
+ if: steps.covered.outputs.changed == 'true' && steps.base-coverage.outcome == 'failure'
+ working-directory: artifacts/tennis-watch-base/tennis-watch
+ run: >-
+ dotnet test tests/TennisWatch.Tests/TennisWatch.Tests.csproj -c Release
+ --collect:"XPlat Code Coverage"
+ --results-directory ${{ github.workspace }}/artifacts/tennis-watch-base-coverage
+ - name: Check CRAP reduction
+ if: steps.covered.outputs.changed == 'true'
+ run: >-
+ dotnet run --project ../tools/CrapScore/CrapScore.csproj -c Release --no-build --
+ artifacts/test-results --base-reports ../artifacts/tennis-watch-base-coverage --target 5
+ - uses: actions/upload-artifact@v7
+ if: always()
+ with:
+ name: tennis-watch-build
+ path: |
+ tennis-watch/artifacts/TennisWatch.apk
+ tennis-watch/artifacts/coverage/crap-score.md
+ tennis-watch/artifacts/release-version.txt
+ if-no-files-found: error
+ - uses: actions/upload-artifact@v7
+ if: always()
+ with:
+ name: tennis-watch-crap-score-${{ github.event.pull_request.head.sha || github.sha }}
+ path: tennis-watch/artifacts/test-results/**/coverage.cobertura.xml
+ if-no-files-found: error
diff --git a/.github/workflows/tennis-watch-release.yml b/.github/workflows/tennis-watch-release.yml
new file mode 100644
index 0000000..729557f
--- /dev/null
+++ b/.github/workflows/tennis-watch-release.yml
@@ -0,0 +1,60 @@
+name: Tennis Watch release
+
+on:
+ workflow_run:
+ workflows:
+ - Tennis Watch CI
+ types:
+ - completed
+
+concurrency:
+ group: tennis-watch-release
+ cancel-in-progress: false
+
+permissions:
+ contents: read
+
+jobs:
+ release:
+ name: Publish Tennis Watch APK
+ if: >-
+ github.event.workflow_run.conclusion == 'success' &&
+ github.event.workflow_run.event == 'push' &&
+ github.event.workflow_run.head_branch == 'main'
+ runs-on: ubuntu-latest
+ permissions:
+ actions: read
+ contents: write
+
+ steps:
+ - name: Check out tested commit
+ uses: actions/checkout@v7
+ with:
+ fetch-depth: 0
+ ref: ${{ github.event.workflow_run.head_sha }}
+
+ - name: Download tested Tennis Watch build
+ uses: actions/download-artifact@v7
+ with:
+ name: tennis-watch-build
+ path: tennis-watch/artifacts
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ run-id: ${{ github.event.workflow_run.id }}
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v7
+ with:
+ node-version: 24
+
+ - name: Install locked release tooling
+ run: npm ci --prefix .github/release
+
+ - name: Bootstrap Tennis Watch version history
+ run: .github/release/bootstrap-version-tag.sh tennis-watch-v
+
+ - name: Publish release
+ working-directory: tennis-watch
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ SEMANTIC_RELEASE_BIN: ${{ github.workspace }}/.github/release/node_modules/.bin/semantic-release
+ run: '"$SEMANTIC_RELEASE_BIN"'
diff --git a/tennis-watch/.editorconfig b/tennis-watch/.editorconfig
new file mode 100644
index 0000000..6632f94
--- /dev/null
+++ b/tennis-watch/.editorconfig
@@ -0,0 +1,11 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+indent_style = space
+indent_size = 4
+
+[*.{json,yml}]
+indent_size = 2
diff --git a/tennis-watch/.gitignore b/tennis-watch/.gitignore
new file mode 100644
index 0000000..82777c7
--- /dev/null
+++ b/tennis-watch/.gitignore
@@ -0,0 +1,3 @@
+artifacts/
+.impeccable/review/
+.aspire/
diff --git a/tennis-watch/.impeccable/design.json b/tennis-watch/.impeccable/design.json
new file mode 100644
index 0000000..e43fa02
--- /dev/null
+++ b/tennis-watch/.impeccable/design.json
@@ -0,0 +1,165 @@
+{
+ "schemaVersion": 2,
+ "generatedAt": "2026-09-12T17:14:09Z",
+ "title": "Design System: Tennis Watch",
+ "extensions": {
+ "colorMeta": {
+ "background": {
+ "role": "neutral",
+ "displayName": "OLED black",
+ "tonalRamp": [
+ "oklch(15% 0.0000 0.0)",
+ "oklch(26% 0.0000 0.0)",
+ "oklch(38% 0.0000 0.0)",
+ "oklch(49% 0.0000 0.0)",
+ "oklch(61% 0.0000 0.0)",
+ "oklch(72% 0.0000 0.0)",
+ "oklch(84% 0.0000 0.0)",
+ "oklch(95% 0.0000 0.0)"
+ ]
+ },
+ "score": {
+ "role": "neutral",
+ "displayName": "Score off-white",
+ "tonalRamp": [
+ "oklch(15% 0.0057 128.5)",
+ "oklch(26% 0.0057 128.5)",
+ "oklch(38% 0.0057 128.5)",
+ "oklch(49% 0.0057 128.5)",
+ "oklch(61% 0.0057 128.5)",
+ "oklch(72% 0.0057 128.5)",
+ "oklch(84% 0.0057 128.5)",
+ "oklch(95% 0.0057 128.5)"
+ ]
+ },
+ "control-fill": {
+ "role": "neutral",
+ "displayName": "Graphite fill",
+ "tonalRamp": [
+ "oklch(15% 0.0111 156.4)",
+ "oklch(26% 0.0111 156.4)",
+ "oklch(38% 0.0111 156.4)",
+ "oklch(49% 0.0111 156.4)",
+ "oklch(61% 0.0111 156.4)",
+ "oklch(72% 0.0111 156.4)",
+ "oklch(84% 0.0111 156.4)",
+ "oklch(95% 0.0111 156.4)"
+ ]
+ },
+ "secondary": {
+ "role": "neutral",
+ "displayName": "History gray",
+ "tonalRamp": [
+ "oklch(15% 0.0145 152.5)",
+ "oklch(26% 0.0145 152.5)",
+ "oklch(38% 0.0145 152.5)",
+ "oklch(49% 0.0145 152.5)",
+ "oklch(61% 0.0145 152.5)",
+ "oklch(72% 0.0145 152.5)",
+ "oklch(84% 0.0145 152.5)",
+ "oklch(95% 0.0145 152.5)"
+ ]
+ },
+ "separator": {
+ "role": "neutral",
+ "displayName": "Separator gray",
+ "tonalRamp": [
+ "oklch(15% 0.0127 153.5)",
+ "oklch(26% 0.0127 153.5)",
+ "oklch(38% 0.0127 153.5)",
+ "oklch(49% 0.0127 153.5)",
+ "oklch(61% 0.0127 153.5)",
+ "oklch(72% 0.0127 153.5)",
+ "oklch(84% 0.0127 153.5)",
+ "oklch(95% 0.0127 153.5)"
+ ]
+ },
+ "divider": {
+ "role": "neutral",
+ "displayName": "Divider gray",
+ "tonalRamp": [
+ "oklch(15% 0.0085 159.6)",
+ "oklch(26% 0.0085 159.6)",
+ "oklch(38% 0.0085 159.6)",
+ "oklch(49% 0.0085 159.6)",
+ "oklch(61% 0.0085 159.6)",
+ "oklch(72% 0.0085 159.6)",
+ "oklch(84% 0.0085 159.6)",
+ "oklch(95% 0.0085 159.6)"
+ ]
+ }
+ },
+ "typographyMeta": {
+ "display": {
+ "displayName": "Point score",
+ "purpose": "Native condensed bold text; uniform fitting from 24sp to the display size in 1sp steps."
+ },
+ "games": {
+ "displayName": "Current games",
+ "purpose": "Medium weight and score color distinguish the current set."
+ },
+ "separator": {
+ "displayName": "Point separator",
+ "purpose": "Regular multiplication sign between point scores."
+ },
+ "completed-set": {
+ "displayName": "Completed set",
+ "purpose": "Regular secondary text precedes current games."
+ }
+ }
+ },
+ "components": [
+ {
+ "name": "Point control",
+ "kind": "button",
+ "refersTo": "point-control",
+ "description": "Native SVG in a 48dp touch area. Pressed opacity is 0.55. The outline on keyboard focus is for this browser preview.",
+ "html": "
",
+ "css": ".ds-control { display: block; width: 48px; height: 48px; padding: 0; border: 0; background: transparent; cursor: pointer; } .ds-control svg { display: block; } .ds-control:focus-visible { outline: 1px solid #F4F6F2; outline-offset: 2px; } .ds-control:active { opacity: 0.55; } .ds-point-preview { width: 48px; background: #000000; } .ds-point:hover { opacity: 1; } .ds-point:active { opacity: 0.55; }"
+ },
+ {
+ "name": "Undo control",
+ "kind": "button",
+ "refersTo": "undo-control",
+ "description": "Enabled and disabled native undo artwork. Opacity is 0.8 enabled, 0.55 pressed, and 0.3 disabled. The focus outline belongs to the browser preview.",
+ "html": "",
+ "css": ".ds-control { display: block; width: 48px; height: 48px; padding: 0; border: 0; background: transparent; cursor: pointer; } .ds-control svg { display: block; } .ds-control:focus-visible { outline: 1px solid #F4F6F2; outline-offset: 2px; } .ds-control:active { opacity: 0.55; } .ds-undo-preview { display: flex; gap: 8px; width: max-content; background: #000000; } .ds-undo { opacity: 0.8; } .ds-undo:hover { opacity: 0.8; } .ds-undo:active { opacity: 0.55; } .ds-undo:disabled { opacity: 0.3; cursor: default; }"
+ },
+ {
+ "name": "Round scoring face",
+ "kind": "custom",
+ "description": "Layout preview at 225px across with completed-set history. Browser fonts approximate Android; native text fitting and font padding follow DESIGN.md. Preview buttons show control states without changing the score.",
+ "html": "15\u00d7306 \u00d7 00 \u00d7 1
",
+ "css": ".ds-control { display: block; width: 48px; height: 48px; padding: 0; border: 0; background: transparent; cursor: pointer; } .ds-control svg { display: block; } .ds-control:focus-visible { outline: 1px solid #F4F6F2; outline-offset: 2px; } .ds-control:active { opacity: 0.55; } .ds-face { position: relative; width: 225px; height: 225px; overflow: hidden; border-radius: 50%; background: #000000; color: #F4F6F2; font-family: sans-serif; } .ds-face .ds-control { position: absolute; top: 43%; transform: translate(-50%, -50%); } .ds-face .ds-left-plus { left: 12.5%; } .ds-face .ds-right-plus { left: 87.5%; } .ds-face .ds-undo { left: 50%; top: 82.5%; opacity: 0.8; } .ds-face .ds-control:active { opacity: 0.55; } .ds-face .ds-label { position: absolute; display: flex; align-items: center; justify-content: center; transform: translate(-50%, -50%); white-space: nowrap; font-variant-numeric: tabular-nums; font-feature-settings: \"tnum\"; } .ds-face .ds-points { top: 43%; width: 25%; height: 66px; font-family: \"Roboto Condensed\", \"Arial Narrow\", sans-serif; font-stretch: condensed; font-size: 48px; font-weight: 700; line-height: 1; } .ds-face .ds-left-points { left: 33.2%; } .ds-face .ds-right-points { left: 66.8%; } .ds-face .ds-separator { left: 50%; top: 43%; width: 7.5%; height: 40px; color: #8B938D; font-size: 13px; font-weight: 400; } .ds-face .ds-divider { position: absolute; left: 25%; top: calc(57.5% - 0.375px); width: 50%; height: 0.75px; background: #343936; } .ds-face .ds-games { left: 50%; top: 65.5%; width: 76%; height: 32px; gap: 8px; font-size: 18px; font-weight: 500; } .ds-face .ds-completed { color: #929B94; font-size: 12px; font-weight: 400; }"
+ }
+ ],
+ "narrative": {
+ "northStar": "Monochrome sports instrument",
+ "overview": "Condensed bold point scores and filled graphite plus controls lead a black watch face. Current games, completed sets, and a small gray undo arrow sit below. The composition supports a quick glance and a tap on court.",
+ "keyCharacteristics": [
+ "Condensed point numerals hold the strongest visual emphasis.",
+ "Fixed touch areas surround compact artwork, leaving the upper watch face open."
+ ],
+ "rules": [
+ {
+ "name": "The score hierarchy rule",
+ "body": "Points lead; current games are larger and brighter than completed sets. Keep the multiplication separator smaller and dimmer than the points.",
+ "section": "typography"
+ },
+ {
+ "name": "The touch area rule",
+ "body": "Keep the fixed control dimensions in the frontmatter when adjusting screen proportions. The artwork is smaller than its touch area.",
+ "section": "layout"
+ }
+ ],
+ "dos": [
+ "Do preserve the filled plus controls and condensed bold points as the scoring screen's main row.",
+ "Do retain native text fitting, tabular figures, and fixed touch targets when adjusting proportions.",
+ "Do keep secondary scoring and undo visually quieter than the points."
+ ],
+ "donts": [
+ "Don't add names, menus, banners, extra controls, or a completion screen to this scoring view.",
+ "Don't draw a hardware bezel inside the app."
+ ]
+ }
+}
diff --git a/tennis-watch/.releaserc.json b/tennis-watch/.releaserc.json
new file mode 100644
index 0000000..d053004
--- /dev/null
+++ b/tennis-watch/.releaserc.json
@@ -0,0 +1,34 @@
+{
+ "branches": [
+ "main"
+ ],
+ "tagFormat": "tennis-watch-v${version}",
+ "plugins": [
+ [
+ "../.github/release/application-semantic-release.mjs",
+ {
+ "preset": "conventionalcommits",
+ "scope": "tennis-watch"
+ }
+ ],
+ [
+ "@semantic-release/exec",
+ {
+ "verifyReleaseCmd": "if [ -n \"$NEXT_RELEASE_VERSION_FILE\" ]; then printf '%s\\n' '${nextRelease.version}' > \"$NEXT_RELEASE_VERSION_FILE\"; else bash ../.github/release/verify-application-assets.sh '${nextRelease.version}' artifacts/release-version.txt artifacts/TennisWatch.apk; fi"
+ }
+ ],
+ [
+ "@semantic-release/github",
+ {
+ "assets": [
+ {
+ "path": "artifacts/TennisWatch.apk",
+ "label": "Tennis Watch Android APK"
+ }
+ ],
+ "successComment": false,
+ "failComment": false
+ }
+ ]
+ ]
+}
diff --git a/tennis-watch/DESIGN.md b/tennis-watch/DESIGN.md
new file mode 100644
index 0000000..578941b
--- /dev/null
+++ b/tennis-watch/DESIGN.md
@@ -0,0 +1,116 @@
+---
+name: Tennis Watch
+description: Bold, glanceable tennis scoring for a round Wear OS watch.
+colors:
+ background: "#000000"
+ score: "#F4F6F2"
+ control-fill: "#252B27"
+ secondary: "#929B94"
+ separator: "#8B938D"
+ divider: "#343936"
+typography:
+ display:
+ fontFamily: "sans-serif-condensed"
+ fontSize: "48sp"
+ fontWeight: 700
+ fontFeature: "tnum"
+ games:
+ fontFamily: "sans-serif-medium"
+ fontSize: "18sp"
+ fontWeight: 500
+ fontFeature: "tnum"
+ separator:
+ fontFamily: "sans-serif"
+ fontSize: "13sp"
+ fontWeight: 400
+ fontFeature: "tnum"
+ completed-set:
+ fontFamily: "sans-serif"
+ fontSize: "12sp"
+ fontWeight: 400
+ fontFeature: "tnum"
+rounded:
+ point-fill: "16dp"
+spacing:
+ score-entry: "8dp"
+components:
+ point-control:
+ textColor: "{colors.score}"
+ width: "48dp"
+ height: "48dp"
+ padding: "0dp"
+ undo-control:
+ textColor: "{colors.secondary}"
+ width: "48dp"
+ height: "48dp"
+ padding: "0dp"
+---
+
+# Design System: Tennis Watch
+
+## Overview
+
+**Creative North Star: "Monochrome sports instrument"**
+
+Condensed bold point scores and filled graphite plus controls lead a black watch face. Current games, completed sets, and a small gray undo arrow sit below. The composition supports a quick glance and a tap on court.
+
+**Key Characteristics:**
+
+- Condensed point numerals hold the strongest visual emphasis.
+- Fixed touch areas surround compact artwork, leaving the upper watch face open.
+
+## Colors
+
+The palette uses off-white and green-tinted grays on an OLED black ground.
+
+### Neutral
+
+| Token | Use |
+| --- | --- |
+| `background` | Watch face and surrounding screen |
+| `score` | Point numerals, current games, and plus strokes |
+| `control-fill` | Filled graphite shape behind each plus |
+| `secondary` | Completed sets and undo stroke |
+| `separator` | Small multiplication sign between point scores |
+| `divider` | Thin line between points and games |
+
+## Typography
+
+Android's condensed bold display face gives points their width and weight. Current games use the medium face; the separator and completed sets use regular sans-serif. The frontmatter defines each role's size and weight.
+
+Labels are centered, single-line, and support system font scaling. All labels request tabular figures and disable native font padding. Point labels use native uniform text fitting from 24sp to the display size in 1sp increments, so advantage text and multi-digit tiebreak scores fit their fixed bounds.
+
+**The score hierarchy rule.** Points lead; current games are larger and brighter than completed sets. Keep the multiplication separator smaller and dimmer than the points.
+
+## Layout
+
+The layout centers a square whose side is the shorter display dimension. Coordinates below are relative to that square. Geometry uses dp; type uses sp.
+
+The point row shares a center at 43% height. Plus controls sit at 12.5% and 87.5% width. Point labels sit at 33.2% and 66.8%, each in a box 25% of the square's width and 66dp high. The separator sits at 50% width in a box 7.5% wide and 40dp high.
+
+The centered divider spans 50% of the square at 57.5% height and is 0.75dp thick. The games row sits at 65.5% height in a box 76% wide and 32dp high, with the score-entry gap between labels. Undo sits at 50% width and 82.5% height. The space above the point row remains open.
+
+**The touch area rule.** Keep the fixed control dimensions in the frontmatter when adjusting screen proportions. The artwork is smaller than its touch area.
+
+## Elevation & Depth
+
+The screen is flat, without shadows. Graphite fills give the plus controls a visible body; text brightness and spacing separate points from match history.
+
+## Shapes
+
+Each plus sits on a filled rounded rectangle, 38dp wide and 42dp high, centered inside its control with the point-fill radius. The plus uses rounded 2.3dp strokes. Undo is an open curved arrow with rounded 1.8dp strokes and joins.
+
+## Components
+
+- The left and right plus controls share the filled SVG and have separate accessible descriptions. Pressing sets opacity to 0.55; release restores it to 1. Native button backgrounds are removed so the SVG defines the visible control.
+- Point labels retain equal bounds and native fitting. Their accessible descriptions identify the side and current point text.
+- The games row shows the latest two completed sets in the smaller secondary style, followed by current games in the brighter medium face. Accessible descriptions distinguish completed sets from current games.
+- Undo uses the same touch dimensions as the plus controls. Enabled opacity is 0.8, pressed opacity is 0.55, and disabled opacity is 0.3. Release restores 0.8.
+
+## Do's and Don'ts
+
+- Do preserve the filled plus controls and condensed bold points as the scoring screen's main row.
+- Do retain native text fitting, tabular figures, and fixed touch targets when adjusting proportions.
+- Do keep secondary scoring and undo visually quieter than the points.
+- Don't add names, menus, banners, extra controls, or a completion screen to this scoring view.
+- Don't draw a hardware bezel inside the app.
diff --git a/tennis-watch/Directory.Build.props b/tennis-watch/Directory.Build.props
new file mode 100644
index 0000000..3d720f6
--- /dev/null
+++ b/tennis-watch/Directory.Build.props
@@ -0,0 +1,9 @@
+
+
+ preview
+ enable
+ enable
+ true
+ true
+
+
diff --git a/tennis-watch/Directory.Packages.props b/tennis-watch/Directory.Packages.props
new file mode 100644
index 0000000..f919dc2
--- /dev/null
+++ b/tennis-watch/Directory.Packages.props
@@ -0,0 +1,10 @@
+
+ true
+
+
+
+
+
+
+
+
diff --git a/tennis-watch/PRODUCT.md b/tennis-watch/PRODUCT.md
new file mode 100644
index 0000000..e9677ce
--- /dev/null
+++ b/tennis-watch/PRODUCT.md
@@ -0,0 +1,20 @@
+# Tennis Watch
+
+
+## Platform
+Native Android app for Wear OS. Target device: Samsung Galaxy Watch 5 Pro.
+
+## Purpose
+A tennis player records points during a match by tapping the left or right plus button on their watch.
+
+## Scope
+The supplied sketch defines the single screen: two point scores, one plus button per side, a divider, a small games/set score, and undo beneath. No player names, match completion screen, menus, settings, or other features. Swipe right creates a new match; swipe left opens the previous match. Match scoring and undo history persist locally.
+
+## Appearance
+Preserve the sketch’s composition with a minimal black-and-white finish. Points and the plus controls lead; the games row and undo have less visual emphasis. The scoring screen must remain legible during a quick glance on court.
+
+## Scoring assumption
+Standard advantage games and a seven-point tiebreak at six games each. The small row shows games in the current set alongside completed set scores. Matches continue until the user starts another one. The curved arrow undoes the last point.
+
+## Stack
+.NET 10 MAUI for the watch’s 32-bit Android runtime, .NET 11 for tests and the development host, a pure C# scoring library, xUnit tests, and an Aspire AppHost for development launching.
diff --git a/tennis-watch/README.md b/tennis-watch/README.md
new file mode 100644
index 0000000..2514211
--- /dev/null
+++ b/tennis-watch/README.md
@@ -0,0 +1,41 @@
+# Tennis Watch
+
+A single-screen tennis scorer for Samsung Galaxy Watch 5 Pro, built with .NET MAUI for Wear OS.
+
+Tap either **+** to award that side a point. The arrow undoes the last point, including a point that ended a game or set. Swipe right to create a new match. Swipe left to open the previous match. Swiping right always appends a fresh match, including when viewing an older one.
+
+Points use 0, 15, 30, 40 and AD. Games require a two-point lead. Sets require six games with a two-game lead, with a first-to-seven, win-by-two tiebreak at 6–6. The small row shows current games in white and the two most recent completed sets in gray. Scoring continues after each set. Every match and its undo history are saved locally after each action.
+
+## Build and install
+
+Install the .NET SDKs pinned by `global.json` and `src/TennisWatch/global.json`, Java 17, the Android SDK, and the MAUI Android workload. The watch project uses .NET 10 to include `armeabi-v7a`, the [Watch 5 Pro ABI measured in this device study](https://arxiv.org/abs/2308.09092). .NET 11 no longer builds Android apps with the Mono runtime needed for that ABI.
+
+```sh
+cd src/TennisWatch
+dotnet workload install maui-android --skip-manifest-update
+cd ../..
+./build.sh
+```
+
+The installable package is `artifacts/TennisWatch.apk`. It contains 32-bit ARM, 64-bit ARM, and x86-64 emulator libraries. Enable ADB debugging and wireless debugging on the watch, then use the pairing and connection addresses shown by the watch. [Samsung's connection guide](https://developer.samsung.com/sdp/blog/en/2024/04/30/connect-galaxy-watch-to-android-studio-over-wi-fi) explains where to find them.
+
+```sh
+adb pair WATCH_IP:PAIRING_PORT
+adb connect WATCH_IP:DEBUG_PORT
+adb -s WATCH_IP:DEBUG_PORT install -r artifacts/TennisWatch.apk
+```
+
+Open **Tennis** from the watch's app list. No phone companion is needed. The hardware Back button exits the app; horizontal swipes stay inside match history.
+
+## Development
+
+`./test.sh` runs the same scoring, persistence and gesture tests used by CI, then writes the repository's CRAP report to `artifacts/coverage/crap-score.md`.
+
+```sh
+aspire start --apphost src/TennisWatch.AppHost/TennisWatch.AppHost.csproj
+aspire resource tennis-watch start
+```
+
+The AppHost launches `run-android.sh`, which builds, installs, opens the app and streams its logs from a connected watch or emulator. Set `ANDROID_SERIAL` before starting Aspire when more than one device is connected. Use `aspire stop` when finished.
+
+App-specific CI builds the APK and runs tests. Releases publish the artifact from successful main CI under `tennis-watch-v` tags.
diff --git a/tennis-watch/TennisWatch.slnx b/tennis-watch/TennisWatch.slnx
new file mode 100644
index 0000000..a51ef1f
--- /dev/null
+++ b/tennis-watch/TennisWatch.slnx
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/tennis-watch/build.sh b/tennis-watch/build.sh
new file mode 100755
index 0000000..cf3f18e
--- /dev/null
+++ b/tennis-watch/build.sh
@@ -0,0 +1,85 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+configuration="${CONFIGURATION:-Release}"
+artifact_root="$repo_root/artifacts"
+test_results="$artifact_root/test-results"
+coverage_root="$artifact_root/coverage"
+android_output="$artifact_root/android"
+release_version=""
+release_msbuild_args=()
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --release-version)
+ if [[ $# -lt 2 || -z "$2" ]]; then
+ echo "--release-version requires a value." >&2
+ exit 2
+ fi
+ release_version="$2"
+ shift 2
+ ;;
+ *)
+ echo "Unknown argument: $1" >&2
+ exit 2
+ ;;
+ esac
+done
+
+if [[ -n "$release_version" ]]; then
+ if [[ ! "$release_version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
+ echo "Release version must be an exact stable semantic version." >&2
+ exit 2
+ fi
+
+ version_major="${BASH_REMATCH[1]}"
+ version_minor="${BASH_REMATCH[2]}"
+ version_patch="${BASH_REMATCH[3]}"
+ if (( version_minor > 999 || version_patch > 999 )); then
+ echo "Release version minor and patch components must not exceed 999." >&2
+ exit 2
+ fi
+
+ android_version_code=$((version_major * 1000000 + version_minor * 1000 + version_patch))
+ if (( android_version_code < 1 || android_version_code > 2100000000 )); then
+ echo "Release version produces an Android version code outside 1..2100000000." >&2
+ exit 2
+ fi
+
+ release_msbuild_args=(
+ "-p:VersionPrefix=$release_version"
+ "-p:ApplicationDisplayVersion=$release_version"
+ "-p:ApplicationVersion=$android_version_code"
+ )
+fi
+
+rm -rf "$test_results" "$coverage_root" "$android_output"
+rm -f "$artifact_root/TennisWatch.apk" "$artifact_root/release-version.txt"
+mkdir -p "$test_results" "$coverage_root" "$android_output"
+
+cd "$repo_root"
+./test.sh
+
+dotnet build "$repo_root/src/TennisWatch.AppHost/TennisWatch.AppHost.csproj" --configuration "$configuration"
+
+cd "$repo_root/src/TennisWatch"
+dotnet publish TennisWatch.csproj \
+ --configuration "$configuration" --framework net10.0-android \
+ -p:AndroidPackageFormat=apk -p:PublishDir="$android_output/" \
+ "${release_msbuild_args[@]}"
+
+apk_path="$(find "$android_output" -maxdepth 1 -type f -name '*-Signed.apk' -print -quit)"
+if [[ -z "$apk_path" ]]; then
+ apk_path="$(find "$android_output" -maxdepth 1 -type f -name '*.apk' -print -quit)"
+fi
+if [[ -z "$apk_path" ]]; then
+ echo "Android publish produced no APK." >&2
+ exit 1
+fi
+
+cp "$apk_path" "$artifact_root/TennisWatch.apk"
+if [[ -n "$release_version" ]]; then
+ printf '%s\n' "$release_version" > "$artifact_root/release-version.txt"
+fi
+echo "APK: $artifact_root/TennisWatch.apk"
diff --git a/tennis-watch/docs/screenshots/empty.png b/tennis-watch/docs/screenshots/empty.png
new file mode 100644
index 0000000..353a1d4
Binary files /dev/null and b/tennis-watch/docs/screenshots/empty.png differ
diff --git a/tennis-watch/docs/screenshots/large-text.png b/tennis-watch/docs/screenshots/large-text.png
new file mode 100644
index 0000000..448e869
Binary files /dev/null and b/tennis-watch/docs/screenshots/large-text.png differ
diff --git a/tennis-watch/docs/screenshots/score.png b/tennis-watch/docs/screenshots/score.png
new file mode 100644
index 0000000..8ce410f
Binary files /dev/null and b/tennis-watch/docs/screenshots/score.png differ
diff --git a/tennis-watch/docs/screenshots/tiebreak.png b/tennis-watch/docs/screenshots/tiebreak.png
new file mode 100644
index 0000000..c913cbf
Binary files /dev/null and b/tennis-watch/docs/screenshots/tiebreak.png differ
diff --git a/tennis-watch/docs/verification.md b/tennis-watch/docs/verification.md
new file mode 100644
index 0000000..41b8b99
--- /dev/null
+++ b/tennis-watch/docs/verification.md
@@ -0,0 +1,16 @@
+# Verification
+
+The redesign preview was built with `dotnet build TennisWatch.csproj -c Debug -t:SignAndroidPackage` and installed in a Wear OS 5 emulator with a round 450 × 450 display at 320 dpi. The screenshots use controlled match data and come directly from that installed APK.
+
+| State | Capture |
+| --- | --- |
+| 15–30, games 0–1 | [Score](screenshots/score.png) |
+| Advantage with two completed sets, system font scale 1.3 | [Large text](screenshots/large-text.png) |
+| 11–10 tiebreak, games 6–6 | [Tiebreak](screenshots/tiebreak.png) |
+| New match, undo disabled | [Empty](screenshots/empty.png) |
+
+On the installed preview build, ADB touch input verified adding a point, undoing it, starting a match with a right swipe, returning with a left swipe, the oldest-match boundary, and cancellation of horizontal and vertical drags that start on a plus button. Restarting the app restored both the selected match and the ability to undo.
+
+The 54 data-driven unit tests cover advantage scoring, set boundaries, extended tiebreaks, continued scoring, undo across boundaries, independent match histories, swipe thresholds, file persistence, and an interrupted temporary write. Coverage excludes generated JSON serialization code.
+
+The APK contains `armeabi-v7a`, `arm64-v8a`, and `x86_64` native libraries. Physical Galaxy Watch 5 Pro testing remains to be done.
diff --git a/tennis-watch/global.json b/tennis-watch/global.json
new file mode 100644
index 0000000..0ea47bd
--- /dev/null
+++ b/tennis-watch/global.json
@@ -0,0 +1 @@
+{"sdk":{"version":"11.0.100-preview.7.26381.103","rollForward":"latestPatch","allowPrerelease":true}}
diff --git a/tennis-watch/run-android.sh b/tennis-watch/run-android.sh
new file mode 100755
index 0000000..517d06f
--- /dev/null
+++ b/tennis-watch/run-android.sh
@@ -0,0 +1,13 @@
+#!/usr/bin/env bash
+set -euo pipefail
+cd "$(dirname "${BASH_SOURCE[0]}")"
+android_serial="${ANDROID_SERIAL:-}"
+adb_args=()
+if [[ -n "$android_serial" ]]; then adb_args=(-s "$android_serial"); fi
+adb "${adb_args[@]}" get-state >/dev/null
+cd src/TennisWatch
+dotnet build TennisWatch.csproj -c Debug -t:SignAndroidPackage
+adb "${adb_args[@]}" install -r bin/Debug/net10.0-android/dev.danielreis.tenniswatch-Signed.apk
+android_activity="$(adb "${adb_args[@]}" shell cmd package resolve-activity --brief dev.danielreis.tenniswatch | tail -1 | tr -d '\r')"
+adb "${adb_args[@]}" shell am start -W -n "$android_activity"
+exec adb "${adb_args[@]}" logcat --pid="$(adb "${adb_args[@]}" shell pidof dev.danielreis.tenniswatch | tr -d '\r')"
diff --git a/tennis-watch/src/TennisWatch.AppHost/AppHost.cs b/tennis-watch/src/TennisWatch.AppHost/AppHost.cs
new file mode 100644
index 0000000..b05c2a7
--- /dev/null
+++ b/tennis-watch/src/TennisWatch.AppHost/AppHost.cs
@@ -0,0 +1,6 @@
+var builder = DistributedApplication.CreateBuilder(args);
+
+builder.AddExecutable("tennis-watch", "bash", "../..", "./run-android.sh")
+ .WithExplicitStart();
+
+builder.Build().Run();
diff --git a/tennis-watch/src/TennisWatch.AppHost/Properties/launchSettings.json b/tennis-watch/src/TennisWatch.AppHost/Properties/launchSettings.json
new file mode 100644
index 0000000..8ec22e6
--- /dev/null
+++ b/tennis-watch/src/TennisWatch.AppHost/Properties/launchSettings.json
@@ -0,0 +1,29 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:17078;http://localhost:15240",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21249",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22161"
+ }
+ },
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:15240",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19116",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20210"
+ }
+ }
+ }
+}
diff --git a/tennis-watch/src/TennisWatch.AppHost/TennisWatch.AppHost.csproj b/tennis-watch/src/TennisWatch.AppHost/TennisWatch.AppHost.csproj
new file mode 100644
index 0000000..4cb12b8
--- /dev/null
+++ b/tennis-watch/src/TennisWatch.AppHost/TennisWatch.AppHost.csproj
@@ -0,0 +1,12 @@
+
+
+
+ Exe
+ net11.0
+ enable
+ enable
+ true
+ aaab66f1-5249-47e5-8f82-f2d06d28e196
+
+
+
diff --git a/tennis-watch/src/TennisWatch.AppHost/appsettings.Development.json b/tennis-watch/src/TennisWatch.AppHost/appsettings.Development.json
new file mode 100644
index 0000000..0c208ae
--- /dev/null
+++ b/tennis-watch/src/TennisWatch.AppHost/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/tennis-watch/src/TennisWatch.AppHost/appsettings.json b/tennis-watch/src/TennisWatch.AppHost/appsettings.json
new file mode 100644
index 0000000..31c092a
--- /dev/null
+++ b/tennis-watch/src/TennisWatch.AppHost/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Aspire.Hosting.Dcp": "Warning"
+ }
+ }
+}
diff --git a/tennis-watch/src/TennisWatch.AppHost/aspire.config.json b/tennis-watch/src/TennisWatch.AppHost/aspire.config.json
new file mode 100644
index 0000000..24b2e23
--- /dev/null
+++ b/tennis-watch/src/TennisWatch.AppHost/aspire.config.json
@@ -0,0 +1,5 @@
+{
+ "appHost": {
+ "path": "TennisWatch.AppHost.csproj"
+ }
+}
diff --git a/tennis-watch/src/TennisWatch.Core/Matches/MatchBook.cs b/tennis-watch/src/TennisWatch.Core/Matches/MatchBook.cs
new file mode 100644
index 0000000..007fde5
--- /dev/null
+++ b/tennis-watch/src/TennisWatch.Core/Matches/MatchBook.cs
@@ -0,0 +1,60 @@
+using TennisWatch.Core.Scoring;
+
+namespace TennisWatch.Core.Matches;
+
+public sealed class MatchBook
+{
+ private readonly List> matches;
+
+ public MatchBook() : this(new MatchBookSnapshot { SelectedMatch = 0, Matches = [[]] }) { }
+
+ public MatchBook(MatchBookSnapshot snapshot)
+ {
+ ArgumentNullException.ThrowIfNull(snapshot);
+ if (snapshot.Matches is not { Length: > 0 } ||
+ snapshot.SelectedMatch < 0 || snapshot.SelectedMatch >= snapshot.Matches.Length ||
+ snapshot.Matches.Any(match => match is null || match.Any(side => !Enum.IsDefined(side))))
+ throw new ArgumentException("Invalid match history.", nameof(snapshot));
+
+ matches = snapshot.Matches.Select(match => match.ToList()).ToList();
+ SelectedMatch = snapshot.SelectedMatch;
+ Score = MatchScore.Replay(matches[SelectedMatch]);
+ }
+
+ public int SelectedMatch { get; private set; }
+ public int Count => matches.Count;
+ public MatchScore Score { get; private set; }
+ public bool CanUndo => matches[SelectedMatch].Count > 0;
+
+ public void Award(Side side)
+ {
+ Score = Score.Award(side);
+ matches[SelectedMatch].Add(side);
+ }
+
+ public void Undo()
+ {
+ if (!CanUndo) return;
+ matches[SelectedMatch].RemoveAt(matches[SelectedMatch].Count - 1);
+ Score = MatchScore.Replay(matches[SelectedMatch]);
+ }
+
+ public void NewMatch()
+ {
+ matches.Add([]);
+ SelectedMatch = matches.Count - 1;
+ Score = new MatchScore();
+ }
+
+ public void PreviousMatch()
+ {
+ SelectedMatch = Math.Max(0, SelectedMatch - 1);
+ Score = MatchScore.Replay(matches[SelectedMatch]);
+ }
+
+ public MatchBookSnapshot Snapshot() => new()
+ {
+ SelectedMatch = SelectedMatch,
+ Matches = matches.Select(match => match.ToArray()).ToArray()
+ };
+}
diff --git a/tennis-watch/src/TennisWatch.Core/Matches/MatchBookSnapshot.cs b/tennis-watch/src/TennisWatch.Core/Matches/MatchBookSnapshot.cs
new file mode 100644
index 0000000..2f1ed47
--- /dev/null
+++ b/tennis-watch/src/TennisWatch.Core/Matches/MatchBookSnapshot.cs
@@ -0,0 +1,15 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json.Serialization;
+using TennisWatch.Core.Scoring;
+
+namespace TennisWatch.Core.Matches;
+
+public sealed record MatchBookSnapshot
+{
+ public required int SelectedMatch { get; init; }
+ public required Side[][] Matches { get; init; }
+}
+
+[JsonSerializable(typeof(MatchBookSnapshot))]
+[ExcludeFromCodeCoverage]
+internal partial class MatchJsonContext : JsonSerializerContext;
diff --git a/tennis-watch/src/TennisWatch.Core/Matches/MatchFile.cs b/tennis-watch/src/TennisWatch.Core/Matches/MatchFile.cs
new file mode 100644
index 0000000..3d8a35e
--- /dev/null
+++ b/tennis-watch/src/TennisWatch.Core/Matches/MatchFile.cs
@@ -0,0 +1,21 @@
+using System.Text.Json;
+
+namespace TennisWatch.Core.Matches;
+
+public sealed class MatchFile(string path)
+{
+ public MatchBook Load()
+ {
+ if (!File.Exists(path)) return new MatchBook();
+ var snapshot = JsonSerializer.Deserialize(File.ReadAllText(path), MatchJsonContext.Default.MatchBookSnapshot)
+ ?? throw new JsonException("Match history is empty.");
+ return new MatchBook(snapshot);
+ }
+
+ public void Save(MatchBook book)
+ {
+ var temporaryPath = path + ".tmp";
+ File.WriteAllText(temporaryPath, JsonSerializer.Serialize(book.Snapshot(), MatchJsonContext.Default.MatchBookSnapshot));
+ File.Move(temporaryPath, path, overwrite: true);
+ }
+}
diff --git a/tennis-watch/src/TennisWatch.Core/Matches/MatchSwipe.cs b/tennis-watch/src/TennisWatch.Core/Matches/MatchSwipe.cs
new file mode 100644
index 0000000..8ed5b43
--- /dev/null
+++ b/tennis-watch/src/TennisWatch.Core/Matches/MatchSwipe.cs
@@ -0,0 +1,13 @@
+namespace TennisWatch.Core.Matches;
+
+public enum SwipeAction { None, NewMatch, PreviousMatch }
+
+public static class MatchSwipe
+{
+ public static SwipeAction Recognize(float deltaX, float deltaY, float density)
+ {
+ if (Math.Abs(deltaX) < 48 * density || Math.Abs(deltaY) > Math.Abs(deltaX) * 0.6f)
+ return SwipeAction.None;
+ return deltaX > 0 ? SwipeAction.NewMatch : SwipeAction.PreviousMatch;
+ }
+}
diff --git a/tennis-watch/src/TennisWatch.Core/Scoring/MatchScore.cs b/tennis-watch/src/TennisWatch.Core/Scoring/MatchScore.cs
new file mode 100644
index 0000000..3c1c55c
--- /dev/null
+++ b/tennis-watch/src/TennisWatch.Core/Scoring/MatchScore.cs
@@ -0,0 +1,38 @@
+using System.Collections.Immutable;
+using System.Globalization;
+
+namespace TennisWatch.Core.Scoring;
+
+public sealed record MatchScore
+{
+ public ScorePair Points { get; init; }
+ public ScorePair Games { get; init; }
+ public ImmutableArray CompletedSets { get; init; } = [];
+ public bool IsTiebreak => Games is { Left: 6, Right: 6 };
+
+ public string PointText(Side side)
+ {
+ var points = side == Side.Left ? Points.Left : Points.Right;
+ if (IsTiebreak) return points.ToString(CultureInfo.InvariantCulture);
+ return points switch { 0 => "0", 1 => "15", 2 => "30", 3 => "40", _ => "AD" };
+ }
+
+ public MatchScore Award(Side side)
+ {
+ var points = Points.Award(side);
+ if (!points.HasWinner(IsTiebreak ? 7 : 4))
+ {
+ if (!IsTiebreak && points is { Left: >= 4, Right: >= 4 })
+ points = new ScorePair { Left = 3, Right = 3 };
+ return this with { Points = points };
+ }
+
+ var games = Games.Award(side);
+ return IsTiebreak || games.HasWinner(6)
+ ? new MatchScore { CompletedSets = CompletedSets.Add(games) }
+ : this with { Points = default, Games = games };
+ }
+
+ public static MatchScore Replay(IEnumerable points) =>
+ points.Aggregate(new MatchScore(), (score, side) => score.Award(side));
+}
diff --git a/tennis-watch/src/TennisWatch.Core/Scoring/ScorePair.cs b/tennis-watch/src/TennisWatch.Core/Scoring/ScorePair.cs
new file mode 100644
index 0000000..a07bc57
--- /dev/null
+++ b/tennis-watch/src/TennisWatch.Core/Scoring/ScorePair.cs
@@ -0,0 +1,18 @@
+namespace TennisWatch.Core.Scoring;
+
+public readonly record struct ScorePair
+{
+ public int Left { get; init; }
+ public int Right { get; init; }
+
+ public ScorePair Award(Side side) => side switch
+ {
+ Side.Left => this with { Left = Left + 1 },
+ Side.Right => this with { Right = Right + 1 },
+ _ => throw new ArgumentOutOfRangeException(nameof(side))
+ };
+
+ public bool HasWinner(int minimum) => Math.Max(Left, Right) >= minimum && Math.Abs(Left - Right) >= 2;
+
+ public override string ToString() => $"{Left} × {Right}";
+}
diff --git a/tennis-watch/src/TennisWatch.Core/Scoring/Side.cs b/tennis-watch/src/TennisWatch.Core/Scoring/Side.cs
new file mode 100644
index 0000000..a18f166
--- /dev/null
+++ b/tennis-watch/src/TennisWatch.Core/Scoring/Side.cs
@@ -0,0 +1,3 @@
+namespace TennisWatch.Core.Scoring;
+
+public enum Side { Left, Right }
diff --git a/tennis-watch/src/TennisWatch.Core/TennisWatch.Core.csproj b/tennis-watch/src/TennisWatch.Core/TennisWatch.Core.csproj
new file mode 100644
index 0000000..b760144
--- /dev/null
+++ b/tennis-watch/src/TennisWatch.Core/TennisWatch.Core.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
diff --git a/tennis-watch/src/TennisWatch/App.cs b/tennis-watch/src/TennisWatch/App.cs
new file mode 100644
index 0000000..770313d
--- /dev/null
+++ b/tennis-watch/src/TennisWatch/App.cs
@@ -0,0 +1,6 @@
+namespace TennisWatch;
+
+public sealed class App : Application
+{
+ protected override Window CreateWindow(IActivationState? activationState) => new(new Scoring.ScorePage());
+}
diff --git a/tennis-watch/src/TennisWatch/MauiProgram.cs b/tennis-watch/src/TennisWatch/MauiProgram.cs
new file mode 100644
index 0000000..3d819dd
--- /dev/null
+++ b/tennis-watch/src/TennisWatch/MauiProgram.cs
@@ -0,0 +1,19 @@
+namespace TennisWatch;
+
+public static class MauiProgram
+{
+ public static MauiApp CreateMauiApp()
+ {
+ Microsoft.Maui.Handlers.ImageButtonHandler.Mapper.AppendToMapping("PlainWatchControl", (handler, _) =>
+ handler.PlatformView.Background = null);
+ Microsoft.Maui.Handlers.LabelHandler.Mapper.AppendToMapping("FitWatchScore", (handler, label) =>
+ {
+ handler.PlatformView.SetIncludeFontPadding(false);
+ handler.PlatformView.FontFeatureSettings = "tnum";
+ if (label is Label { AutomationId: "left-points" or "right-points" })
+ handler.PlatformView.SetAutoSizeTextTypeUniformWithConfiguration(24, Scoring.ScorePage.PointFontSize, 1,
+ (int)Android.Util.ComplexUnitType.Sp);
+ });
+ return MauiApp.CreateBuilder().UseMauiApp().Build();
+ }
+}
diff --git a/tennis-watch/src/TennisWatch/Platforms/Android/AndroidManifest.xml b/tennis-watch/src/TennisWatch/Platforms/Android/AndroidManifest.xml
new file mode 100644
index 0000000..114a28f
--- /dev/null
+++ b/tennis-watch/src/TennisWatch/Platforms/Android/AndroidManifest.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/tennis-watch/src/TennisWatch/Platforms/Android/MainActivity.cs b/tennis-watch/src/TennisWatch/Platforms/Android/MainActivity.cs
new file mode 100644
index 0000000..92c95b3
--- /dev/null
+++ b/tennis-watch/src/TennisWatch/Platforms/Android/MainActivity.cs
@@ -0,0 +1,73 @@
+using Android.App;
+using Android.Content.PM;
+using Android.OS;
+using Android.Views;
+using AndroidX.Core.View;
+using TennisWatch.Core.Matches;
+using TennisWatch.Scoring;
+
+namespace TennisWatch;
+
+[Activity(Theme = "@style/TennisTheme", MainLauncher = true, Exported = true,
+ LaunchMode = LaunchMode.SingleTop,
+ ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode |
+ ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
+public sealed class MainActivity : MauiAppCompatActivity
+{
+ private float startX;
+ private float startY;
+ private bool swiping;
+ private bool moved;
+ private bool multiplePointers;
+
+ protected override void OnCreate(Bundle? savedInstanceState)
+ {
+ base.OnCreate(savedInstanceState);
+ if (Window is not { } window) return;
+ WindowCompat.SetDecorFitsSystemWindows(window, false);
+ new WindowInsetsControllerCompat(window, window.DecorView).Hide(WindowInsetsCompat.Type.SystemBars());
+ }
+
+ public override bool DispatchTouchEvent(MotionEvent? e)
+ {
+ if (e is null) return base.DispatchTouchEvent(e);
+ var density = Resources?.DisplayMetrics?.Density ?? 1;
+ var dx = e.GetX() - startX;
+ var dy = e.GetY() - startY;
+ switch (e.ActionMasked)
+ {
+ case MotionEventActions.Down:
+ startX = e.GetX();
+ startY = e.GetY();
+ swiping = false;
+ moved = false;
+ multiplePointers = false;
+ break;
+ case MotionEventActions.PointerDown:
+ multiplePointers = true;
+ break;
+ case MotionEventActions.Cancel:
+ moved = true;
+ swiping = false;
+ break;
+ case MotionEventActions.Move:
+ if (!moved && Math.Max(Math.Abs(dx), Math.Abs(dy)) > 12 * density)
+ {
+ moved = true;
+ using var cancel = MotionEvent.Obtain(e) ?? throw new InvalidOperationException("Cannot cancel touch.");
+ cancel.Action = MotionEventActions.Cancel;
+ base.DispatchTouchEvent(cancel);
+ }
+ swiping |= MatchSwipe.Recognize(dx, dy, density) != SwipeAction.None;
+ if (moved) return true;
+ break;
+ case MotionEventActions.Up:
+ if (!multiplePointers && swiping &&
+ Microsoft.Maui.Controls.Application.Current?.Windows.FirstOrDefault()?.Page is ScorePage page)
+ page.Navigate(MatchSwipe.Recognize(dx, dy, density));
+ if (moved || multiplePointers) return true;
+ break;
+ }
+ return base.DispatchTouchEvent(e);
+ }
+}
diff --git a/tennis-watch/src/TennisWatch/Platforms/Android/MainApplication.cs b/tennis-watch/src/TennisWatch/Platforms/Android/MainApplication.cs
new file mode 100644
index 0000000..51dd68b
--- /dev/null
+++ b/tennis-watch/src/TennisWatch/Platforms/Android/MainApplication.cs
@@ -0,0 +1,15 @@
+using Android.App;
+using Android.Runtime;
+
+namespace TennisWatch;
+
+[Application]
+public class MainApplication : MauiApplication
+{
+ public MainApplication(IntPtr handle, JniHandleOwnership ownership)
+ : base(handle, ownership)
+ {
+ }
+
+ protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
+}
diff --git a/tennis-watch/src/TennisWatch/Platforms/Android/Resources/values/colors.xml b/tennis-watch/src/TennisWatch/Platforms/Android/Resources/values/colors.xml
new file mode 100644
index 0000000..69ecee9
--- /dev/null
+++ b/tennis-watch/src/TennisWatch/Platforms/Android/Resources/values/colors.xml
@@ -0,0 +1,6 @@
+
+
+ #000000
+ #000000
+ #FFFFFF
+
diff --git a/tennis-watch/src/TennisWatch/Platforms/Android/Resources/values/styles.xml b/tennis-watch/src/TennisWatch/Platforms/Android/Resources/values/styles.xml
new file mode 100644
index 0000000..924419c
--- /dev/null
+++ b/tennis-watch/src/TennisWatch/Platforms/Android/Resources/values/styles.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/tennis-watch/src/TennisWatch/Resources/AppIcon/appicon.svg b/tennis-watch/src/TennisWatch/Resources/AppIcon/appicon.svg
new file mode 100644
index 0000000..6d911c0
--- /dev/null
+++ b/tennis-watch/src/TennisWatch/Resources/AppIcon/appicon.svg
@@ -0,0 +1,7 @@
+
diff --git a/tennis-watch/src/TennisWatch/Resources/Images/plus.svg b/tennis-watch/src/TennisWatch/Resources/Images/plus.svg
new file mode 100644
index 0000000..f89e13c
--- /dev/null
+++ b/tennis-watch/src/TennisWatch/Resources/Images/plus.svg
@@ -0,0 +1,4 @@
+
diff --git a/tennis-watch/src/TennisWatch/Resources/Images/undo.svg b/tennis-watch/src/TennisWatch/Resources/Images/undo.svg
new file mode 100644
index 0000000..e890f94
--- /dev/null
+++ b/tennis-watch/src/TennisWatch/Resources/Images/undo.svg
@@ -0,0 +1,3 @@
+
diff --git a/tennis-watch/src/TennisWatch/Resources/Splash/splash.svg b/tennis-watch/src/TennisWatch/Resources/Splash/splash.svg
new file mode 100644
index 0000000..6126ec6
--- /dev/null
+++ b/tennis-watch/src/TennisWatch/Resources/Splash/splash.svg
@@ -0,0 +1 @@
+
diff --git a/tennis-watch/src/TennisWatch/Scoring/ScorePage.cs b/tennis-watch/src/TennisWatch/Scoring/ScorePage.cs
new file mode 100644
index 0000000..6fdb1c4
--- /dev/null
+++ b/tennis-watch/src/TennisWatch/Scoring/ScorePage.cs
@@ -0,0 +1,134 @@
+using TennisWatch.Core.Matches;
+using TennisWatch.Core.Scoring;
+
+namespace TennisWatch.Scoring;
+
+public sealed class ScorePage : ContentPage
+{
+ internal const int PointFontSize = 48;
+
+ private readonly MatchFile storage = new(Path.Combine(FileSystem.AppDataDirectory, "matches.json"));
+ private readonly MatchBook book;
+ private readonly AbsoluteLayout face = new() { BackgroundColor = Colors.Black };
+ private readonly Label leftPoints = PointLabel();
+ private readonly Label rightPoints = PointLabel();
+ private readonly Label separator = ScoreLabel(13);
+ private readonly HorizontalStackLayout sets = new() { Spacing = 8, HorizontalOptions = LayoutOptions.Center };
+ private readonly BoxView line = new() { Color = Color.FromArgb("#343936") };
+ private readonly ImageButton leftPlus;
+ private readonly ImageButton rightPlus;
+ private readonly ImageButton undo;
+
+ public ScorePage()
+ {
+ BackgroundColor = Colors.Black;
+ Padding = 0;
+ SafeAreaEdges = SafeAreaEdges.None;
+ NavigationPage.SetHasNavigationBar(this, false);
+ book = storage.Load();
+ leftPlus = Control("plus.png", "Add point to left side", "left-plus", () => book.Award(Side.Left));
+ rightPlus = Control("plus.png", "Add point to right side", "right-plus", () => book.Award(Side.Right));
+ undo = Control("undo.png", "Undo last point", "undo", book.Undo);
+ leftPoints.AutomationId = "left-points";
+ rightPoints.AutomationId = "right-points";
+ separator.Text = "×";
+ separator.TextColor = Color.FromArgb("#8B938D");
+ foreach (var view in new View[] { leftPlus, leftPoints, separator, rightPoints, rightPlus, line, sets, undo })
+ face.Add(view);
+ Content = face;
+ face.SizeChanged += (_, _) => Arrange();
+ Refresh();
+ }
+
+ public void Navigate(SwipeAction action)
+ {
+ switch (action)
+ {
+ case SwipeAction.NewMatch: Change(book.NewMatch); break;
+ case SwipeAction.PreviousMatch: Change(book.PreviousMatch); break;
+ }
+ }
+
+ private ImageButton Control(string image, string description, string id, Action action)
+ {
+ var button = new ImageButton
+ {
+ Source = image, BackgroundColor = Colors.Transparent, Padding = 0,
+ Aspect = Aspect.AspectFit, AutomationId = id, WidthRequest = 48, HeightRequest = 48
+ };
+ SemanticProperties.SetDescription(button, description);
+ button.Clicked += (_, _) => Change(action);
+ button.Pressed += (_, _) => button.Opacity = 0.55;
+ button.Released += (_, _) => button.Opacity = id == "undo" ? 0.8 : 1;
+ return button;
+ }
+
+ private void Change(Action action)
+ {
+ action();
+ storage.Save(book);
+ Refresh();
+ }
+
+ private void Refresh()
+ {
+ leftPoints.Text = book.Score.PointText(Side.Left);
+ rightPoints.Text = book.Score.PointText(Side.Right);
+ SemanticProperties.SetDescription(leftPoints, $"Left points {leftPoints.Text}");
+ SemanticProperties.SetDescription(rightPoints, $"Right points {rightPoints.Text}");
+ sets.Clear();
+ foreach (var score in book.Score.CompletedSets.TakeLast(2))
+ {
+ var label = ScoreLabel(12);
+ label.Text = score.ToString();
+ label.TextColor = Color.FromArgb("#929B94");
+ SemanticProperties.SetDescription(label, $"Completed set, {score.Left} to {score.Right}");
+ sets.Add(label);
+ }
+ var current = ScoreLabel(18);
+ current.FontFamily = "sans-serif-medium";
+ current.Text = book.Score.Games.ToString();
+ current.AutomationId = "current-set";
+ SemanticProperties.SetDescription(current, $"Current set games, {book.Score.Games.Left} to {book.Score.Games.Right}");
+ sets.Add(current);
+ undo.IsEnabled = book.CanUndo;
+ undo.Opacity = book.CanUndo ? 0.8 : 0.3;
+ Arrange();
+ }
+
+ private void Arrange()
+ {
+ var size = Math.Min(face.Width, face.Height);
+ if (size <= 0) return;
+ var offsetX = (face.Width - size) / 2;
+ var offsetY = (face.Height - size) / 2;
+ void Place(View view, double centerX, double centerY, double width, double height) =>
+ AbsoluteLayout.SetLayoutBounds(view, new Rect(offsetX + size * centerX - width / 2,
+ offsetY + size * centerY - height / 2, width, height));
+
+ Place(leftPlus, 0.125, 0.43, 48, 48);
+ Place(leftPoints, 0.332, 0.43, size * 0.25, 66);
+ Place(separator, 0.5, 0.43, size * 0.075, 40);
+ Place(rightPoints, 0.668, 0.43, size * 0.25, 66);
+ Place(rightPlus, 0.875, 0.43, 48, 48);
+ Place(line, 0.5, 0.575, size * 0.5, 0.75);
+ Place(sets, 0.5, 0.655, size * 0.76, 32);
+ Place(undo, 0.5, 0.825, 48, 48);
+ }
+
+ private static Label PointLabel()
+ {
+ var label = ScoreLabel(PointFontSize);
+ label.FontFamily = "sans-serif-condensed";
+ label.FontAttributes = FontAttributes.Bold;
+ return label;
+ }
+
+ private static Label ScoreLabel(double fontSize) => new()
+ {
+ FontFamily = "sans-serif", FontSize = fontSize, TextColor = Color.FromArgb("#F4F6F2"),
+ HorizontalTextAlignment = TextAlignment.Center, VerticalTextAlignment = TextAlignment.Center,
+ VerticalOptions = LayoutOptions.Center, MaxLines = 1,
+ LineBreakMode = LineBreakMode.NoWrap, FontAutoScalingEnabled = true
+ };
+}
diff --git a/tennis-watch/src/TennisWatch/TennisWatch.csproj b/tennis-watch/src/TennisWatch/TennisWatch.csproj
new file mode 100644
index 0000000..b8825a5
--- /dev/null
+++ b/tennis-watch/src/TennisWatch/TennisWatch.csproj
@@ -0,0 +1,27 @@
+
+
+ net10.0-android
+ android-arm;android-arm64;android-x64
+ Exe
+ TennisWatch
+ true
+ true
+ false
+ true
+ true
+ Tennis
+ dev.danielreis.tenniswatch
+ 0.1.0
+ 1
+ 30.0
+ false
+ false
+
+
+
+
+
+
+
+
+
diff --git a/tennis-watch/src/TennisWatch/global.json b/tennis-watch/src/TennisWatch/global.json
new file mode 100644
index 0000000..50baaf1
--- /dev/null
+++ b/tennis-watch/src/TennisWatch/global.json
@@ -0,0 +1,6 @@
+{
+ "sdk": {
+ "version": "10.0.300",
+ "rollForward": "latestPatch"
+ }
+}
diff --git a/tennis-watch/test.sh b/tennis-watch/test.sh
new file mode 100755
index 0000000..1ed40a2
--- /dev/null
+++ b/tennis-watch/test.sh
@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+set -euo pipefail
+cd "$(dirname "${BASH_SOURCE[0]}")"
+rm -rf artifacts/test-results
+dotnet test tests/TennisWatch.Tests/TennisWatch.Tests.csproj --configuration Release \
+ --collect:"XPlat Code Coverage" --results-directory artifacts/test-results
+dotnet run --project ../tools/CrapScore/CrapScore.csproj --configuration Release -- \
+ artifacts/test-results --output artifacts/coverage/crap-score.md
diff --git a/tennis-watch/tests/TennisWatch.Tests/MatchBookTests.cs b/tennis-watch/tests/TennisWatch.Tests/MatchBookTests.cs
new file mode 100644
index 0000000..565a51a
--- /dev/null
+++ b/tennis-watch/tests/TennisWatch.Tests/MatchBookTests.cs
@@ -0,0 +1,82 @@
+using TennisWatch.Core.Matches;
+using TennisWatch.Core.Scoring;
+
+namespace TennisWatch.Tests;
+
+public sealed class MatchBookTests
+{
+ [Theory]
+ [InlineData(0)]
+ [InlineData(4)]
+ [InlineData(24)]
+ public void Undo_restores_point_game_and_set_boundaries(int points)
+ {
+ var book = new MatchBook();
+ for (var i = 0; i < points; i++) book.Award(Side.Left);
+ book.Undo();
+ var expected = MatchScore.Replay(Enumerable.Repeat(Side.Left, Math.Max(0, points - 1)));
+ Assert.Equal(expected.Points, book.Score.Points);
+ Assert.Equal(expected.Games, book.Score.Games);
+ Assert.Equal(expected.CompletedSets.ToArray(), book.Score.CompletedSets.ToArray());
+ Assert.Equal(points > 1, book.CanUndo);
+ }
+
+ [Theory]
+ [InlineData("LLLRRRL")]
+ [InlineData("LLLRRRLR")]
+ [InlineData("LLLRRRLL")]
+ public void Undo_preserves_deuce_and_advantage(string points)
+ {
+ var book = new MatchBook();
+ foreach (var side in ScoringTests.Sides(points)) book.Award(side);
+ book.Undo();
+ var expected = MatchScore.Replay(ScoringTests.Sides(points[..^1]));
+ Assert.Equal(expected.Points, book.Score.Points);
+ Assert.Equal(expected.Games, book.Score.Games);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(2)]
+ public void Match_navigation_keeps_each_history(int extraPreviousSwipes)
+ {
+ var book = new MatchBook();
+ book.Award(Side.Left);
+ book.NewMatch();
+ book.Award(Side.Right);
+ book.PreviousMatch();
+ for (var i = 0; i < extraPreviousSwipes; i++) book.PreviousMatch();
+ Assert.Equal(0, book.SelectedMatch);
+ Assert.Equal("15", book.Score.PointText(Side.Left));
+ book.Undo();
+ book.NewMatch();
+ Assert.Equal(3, book.Count);
+ Assert.False(book.CanUndo);
+ book.PreviousMatch();
+ Assert.Equal("15", book.Score.PointText(Side.Right));
+ book.PreviousMatch();
+ Assert.Equal("0", book.Score.PointText(Side.Left));
+ }
+
+ [Theory]
+ [InlineData(48, 0, 1, SwipeAction.NewMatch)]
+ [InlineData(-48, 0, 1, SwipeAction.PreviousMatch)]
+ [InlineData(47, 0, 1, SwipeAction.None)]
+ [InlineData(96, 0, 2, SwipeAction.NewMatch)]
+ [InlineData(-96, 0, 2, SwipeAction.PreviousMatch)]
+ [InlineData(95, 0, 2, SwipeAction.None)]
+ [InlineData(100, 80, 1, SwipeAction.None)]
+ [InlineData(5, 100, 1, SwipeAction.None)]
+ public void Only_deliberate_horizontal_swipes_navigate(float dx, float dy, float density, SwipeAction expected) =>
+ Assert.Equal(expected, MatchSwipe.Recognize(dx, dy, density));
+
+ [Theory]
+ [InlineData(-1, 0)]
+ [InlineData(1, 0)]
+ [InlineData(0, 99)]
+ public void Invalid_saved_history_is_rejected(int selected, int side) =>
+ Assert.Throws(() => new MatchBook(new MatchBookSnapshot
+ {
+ SelectedMatch = selected, Matches = [[(Side)side]]
+ }));
+}
diff --git a/tennis-watch/tests/TennisWatch.Tests/PersistenceTests.cs b/tennis-watch/tests/TennisWatch.Tests/PersistenceTests.cs
new file mode 100644
index 0000000..c4e024e
--- /dev/null
+++ b/tennis-watch/tests/TennisWatch.Tests/PersistenceTests.cs
@@ -0,0 +1,53 @@
+using TennisWatch.Core.Matches;
+using TennisWatch.Core.Scoring;
+
+namespace TennisWatch.Tests;
+
+public sealed class PersistenceTests : IDisposable
+{
+ private readonly string directory = Directory.CreateTempSubdirectory("tennis-watch-tests-").FullName;
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void Restart_restores_selected_match_and_undo_history(bool viewPrevious)
+ {
+ var file = new MatchFile(Path.Combine(directory, "matches.json"));
+ var book = file.Load();
+ foreach (var side in ScoringTests.Games("LRLRLRLRLRLR").Concat(ScoringTests.Sides("LLLLLLL"))) book.Award(side);
+ book.NewMatch();
+ book.Award(Side.Right);
+ if (viewPrevious) book.PreviousMatch();
+ file.Save(book);
+ var restored = file.Load();
+ Assert.Equal(book.SelectedMatch, restored.SelectedMatch);
+ Assert.Equal(2, restored.Count);
+ restored.Undo();
+ if (viewPrevious)
+ {
+ Assert.True(restored.Score.IsTiebreak);
+ Assert.Equal("6", restored.Score.PointText(Side.Left));
+ Assert.Empty(restored.Score.CompletedSets);
+ }
+ else
+ Assert.False(restored.CanUndo);
+ file.Save(restored);
+ Assert.Equal(restored.Score.Points, file.Load().Score.Points);
+ }
+
+ [Theory]
+ [InlineData("{")]
+ [InlineData("")]
+ public void Unfinished_write_does_not_replace_saved_match(string temporaryContent)
+ {
+ var path = Path.Combine(directory, "matches.json");
+ var file = new MatchFile(path);
+ var book = file.Load();
+ book.Award(Side.Left);
+ file.Save(book);
+ File.WriteAllText(path + ".tmp", temporaryContent);
+ Assert.Equal("15", file.Load().Score.PointText(Side.Left));
+ }
+
+ public void Dispose() => Directory.Delete(directory, recursive: true);
+}
diff --git a/tennis-watch/tests/TennisWatch.Tests/ScoringTests.cs b/tennis-watch/tests/TennisWatch.Tests/ScoringTests.cs
new file mode 100644
index 0000000..da5abd1
--- /dev/null
+++ b/tennis-watch/tests/TennisWatch.Tests/ScoringTests.cs
@@ -0,0 +1,90 @@
+using TennisWatch.Core.Scoring;
+
+namespace TennisWatch.Tests;
+
+public sealed class ScoringTests
+{
+ [Theory]
+ [InlineData("", "0", "0", 0, 0)]
+ [InlineData("L", "15", "0", 0, 0)]
+ [InlineData("LRR", "15", "30", 0, 0)]
+ [InlineData("LLL", "40", "0", 0, 0)]
+ [InlineData("LLLL", "0", "0", 1, 0)]
+ [InlineData("RRRR", "0", "0", 0, 1)]
+ [InlineData("LLLRRR", "40", "40", 0, 0)]
+ [InlineData("LLLRRRL", "AD", "40", 0, 0)]
+ [InlineData("LLLRRRR", "40", "AD", 0, 0)]
+ [InlineData("LLLRRRLR", "40", "40", 0, 0)]
+ [InlineData("LLLRRRRL", "40", "40", 0, 0)]
+ [InlineData("LLLRRRLL", "0", "0", 1, 0)]
+ [InlineData("LLLRRRRR", "0", "0", 0, 1)]
+ [InlineData("LLLRRRLRLRLRRR", "0", "0", 0, 1)]
+ public void Points_follow_advantage_scoring(string points, string left, string right, int leftGames, int rightGames)
+ {
+ var score = MatchScore.Replay(Sides(points));
+ Assert.Equal(left, score.PointText(Side.Left));
+ Assert.Equal(right, score.PointText(Side.Right));
+ Assert.Equal(new ScorePair { Left = leftGames, Right = rightGames }, score.Games);
+ Assert.Empty(score.CompletedSets);
+ }
+
+ [Theory]
+ [InlineData("LLLLLL", 6, 0)]
+ [InlineData("RRRRRR", 0, 6)]
+ [InlineData("LLRRLLRRLL", 6, 4)]
+ [InlineData("LLLLLRRRRRLL", 7, 5)]
+ [InlineData("RRRRRLLLLLRR", 5, 7)]
+ public void Set_requires_six_games_and_two_game_lead(string games, int left, int right)
+ {
+ var score = MatchScore.Replay(Games(games));
+ Assert.Equal(new ScorePair { Left = left, Right = right }, Assert.Single(score.CompletedSets));
+ Assert.Equal(default, score.Games);
+ Assert.Equal(default, score.Points);
+ }
+
+ [Theory]
+ [InlineData("LLLLLRRRRRL", 6, 5, false)]
+ [InlineData("LLLLLRRRRRR", 5, 6, false)]
+ [InlineData("LRLRLRLRLRLR", 6, 6, true)]
+ public void Close_sets_continue(string games, int left, int right, bool tiebreak)
+ {
+ var score = MatchScore.Replay(Games(games));
+ Assert.Empty(score.CompletedSets);
+ Assert.Equal(new ScorePair { Left = left, Right = right }, score.Games);
+ Assert.Equal(tiebreak, score.IsTiebreak);
+ }
+
+ [Theory]
+ [InlineData("LLLLLL", "6", "0", false, 0, 0)]
+ [InlineData("LLLLLLL", "0", "0", true, 7, 6)]
+ [InlineData("RRRRRRR", "0", "0", true, 6, 7)]
+ [InlineData("LRLRLRLRLRLRL", "7", "6", false, 0, 0)]
+ [InlineData("LRLRLRLRLRLRLL", "0", "0", true, 7, 6)]
+ [InlineData("LRLRLRLRLRLRRR", "0", "0", true, 6, 7)]
+ [InlineData("LRLRLRLRLRLRLRLRLRLRL", "11", "10", false, 0, 0)]
+ public void Tiebreak_requires_seven_points_and_two_point_lead(string points, string left, string right,
+ bool finishedSet, int leftGames, int rightGames)
+ {
+ var score = MatchScore.Replay(Games("LRLRLRLRLRLR").Concat(Sides(points)));
+ Assert.Equal(left, score.PointText(Side.Left));
+ Assert.Equal(right, score.PointText(Side.Right));
+ Assert.Equal(!finishedSet, score.IsTiebreak);
+ if (finishedSet)
+ Assert.Equal(new ScorePair { Left = leftGames, Right = rightGames }, Assert.Single(score.CompletedSets));
+ else
+ Assert.Empty(score.CompletedSets);
+ }
+
+ [Theory]
+ [InlineData(3)]
+ [InlineData(6)]
+ public void Scoring_continues_after_any_number_of_sets(int count)
+ {
+ var score = MatchScore.Replay(Games(new string('L', count * 6)).Concat(Sides("R")));
+ Assert.Equal(count, score.CompletedSets.Length);
+ Assert.Equal("15", score.PointText(Side.Right));
+ }
+
+ internal static IEnumerable Sides(string points) => points.Select(point => point == 'L' ? Side.Left : Side.Right);
+ internal static IEnumerable Games(string games) => Sides(games).SelectMany(side => Enumerable.Repeat(side, 4));
+}
diff --git a/tennis-watch/tests/TennisWatch.Tests/TennisWatch.Tests.csproj b/tennis-watch/tests/TennisWatch.Tests/TennisWatch.Tests.csproj
new file mode 100644
index 0000000..b7343c9
--- /dev/null
+++ b/tennis-watch/tests/TennisWatch.Tests/TennisWatch.Tests.csproj
@@ -0,0 +1,11 @@
+
+ net11.0falsetrue
+
+
+
+
+
+
+
+
+