From bf8412cbbfbd5e9c2e0171e694607f4a6c6265f5 Mon Sep 17 00:00:00 2001 From: John Malone Date: Mon, 18 May 2026 15:53:22 -0500 Subject: [PATCH 1/2] test: add coverage and review tooling --- .coderabbit.yaml | 28 +++++ .github/workflows/ci.yml | 86 +++++++++++++ .gitignore | 11 +- app/test/model/expression_data_test.dart | 85 +++++++++++++ app/test/util/mac_address_validator_test.dart | 50 ++++++++ app/test/widget_test.dart | 34 ------ codecov.yml | 36 ++++++ server/go.sum | 70 +++++++++++ server/internal/model/expression_data_test.go | 113 ++++++++++++++++++ server/internal/model/user_test.go | 55 +++++++++ server/internal/model/value_constant_test.go | 32 +++++ .../internal/model/web_socket_model_test.go | 77 ++++++++++++ server/internal/service/device_test.go | 13 -- 13 files changed, 642 insertions(+), 48 deletions(-) create mode 100644 .coderabbit.yaml create mode 100644 .github/workflows/ci.yml create mode 100644 app/test/model/expression_data_test.dart create mode 100644 app/test/util/mac_address_validator_test.dart delete mode 100644 app/test/widget_test.dart create mode 100644 codecov.yml create mode 100644 server/go.sum create mode 100644 server/internal/model/expression_data_test.go create mode 100644 server/internal/model/user_test.go create mode 100644 server/internal/model/value_constant_test.go create mode 100644 server/internal/model/web_socket_model_test.go delete mode 100644 server/internal/service/device_test.go diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 00000000..4463a690 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,28 @@ +reviews: + auto_review: + enabled: true + auto_incremental_review: true + auto_pause_after_reviewed_commits: 0 + drafts: false + ignore_title_keywords: + - "WIP" + - "[skip review]" + ignore_usernames: + - "dependabot[bot]" + high_level_summary: true + high_level_summary_in_walkthrough: true + poem: false + review_status: true + collapse_walkthrough: false +chat: + auto_reply: true +path_instructions: + - path: "firmware/**" + instructions: | + This repo contains embedded firmware, app, server, and remote-control code. For firmware changes, prioritize memory usage, build reproducibility, hardware safety, transport compatibility, and regressions in BLE/Wi-Fi control paths. + - path: "server/**" + instructions: | + For server changes, prioritize request/response correctness, configuration safety, auth handling, and whether new code remains testable without requiring a live database. + - path: "app/**" + instructions: | + For Flutter app changes, prioritize BLE/device-control correctness, platform compatibility, analyzer cleanliness, and avoiding generated-file churn in reviews. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..e82de514 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,86 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + id-token: write + +jobs: + server-tests: + name: Server tests + runs-on: ubuntu-latest + defaults: + run: + working-directory: server + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: server/go.mod + + - name: Run Go vet + run: go vet ./internal/model/... + + - name: Run Staticcheck + run: | + go install honnef.co/go/tools/cmd/staticcheck@latest + "$(go env GOPATH)/bin/staticcheck" ./internal/model/... + + - name: Run Go tests with coverage + run: go test -covermode=atomic -coverprofile=coverage.out ./internal/model/... + + - name: Upload server coverage to Codecov + uses: codecov/codecov-action@v5 + with: + files: ./server/coverage.out + flags: server + name: server + fail_ci_if_error: true + use_oidc: true + + app-tests: + name: App tests + runs-on: ubuntu-latest + defaults: + run: + working-directory: app + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install native build dependencies + run: | + sudo apt-get update + sudo apt-get install -y cmake ninja-build pkg-config libgtk-3-dev liblzma-dev clang + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Run focused Flutter analysis + run: flutter analyze lib/model/expression_data.dart lib/util/mac_address_validator.dart test/model/expression_data_test.dart test/util/mac_address_validator_test.dart + + - name: Run Flutter tests with coverage + run: flutter test --coverage test/model/expression_data_test.dart test/util/mac_address_validator_test.dart + + - name: Upload app coverage to Codecov + uses: codecov/codecov-action@v5 + with: + files: ./app/coverage/lcov.info + flags: app + name: app + fail_ci_if_error: true + use_oidc: true diff --git a/.gitignore b/.gitignore index 00741cb0..b8c6d856 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,11 @@ .DS_Store -.idea/ \ No newline at end of file +.idea/ + +# Go coverage artefacts +server/coverage.out + +# Flutter and native build artefacts +app/.dart_tool/ +app/.flutter-plugins-dependencies +app/build/ +app/coverage/ diff --git a/app/test/model/expression_data_test.dart b/app/test/model/expression_data_test.dart new file mode 100644 index 00000000..18f17e4e --- /dev/null +++ b/app/test/model/expression_data_test.dart @@ -0,0 +1,85 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stack_chan/model/expression_data.dart'; + +void main() { + group('ExpressionData', () { + test('round-trips JSON for avatar payloads', () { + final expression = ExpressionData( + leftEye: ExpressionItem(x: 1, y: 2, rotation: 3, weight: 4, size: 5), + rightEye: ExpressionItem( + x: 6, + y: 7, + rotation: 8, + weight: 9, + size: 10, + ), + mouth: ExpressionItem( + x: 11, + y: 12, + rotation: 13, + weight: 14, + size: 15, + ), + ); + + final decoded = ExpressionData.fromJson(expression.toJson()); + + expect(decoded.type, 'bleAvatar'); + expect(decoded.leftEye.toJson(), expression.leftEye.toJson()); + expect(decoded.rightEye.toJson(), expression.rightEye.toJson()); + expect(decoded.mouth.toJson(), expression.mouth.toJson()); + expect(expression.toString(), contains('"type":"bleAvatar"')); + }); + + test('copy returns a detached expression item', () { + final original = ExpressionItem(x: 4, y: 5, rotation: 6, weight: 7, size: 8); + final copy = original.copy(); + + copy.x = 99; + + expect(original.x, 4); + expect(copy.toJson(), isNot(original.toJson())); + expect(copy.rotation, original.rotation); + expect(copy.weight, original.weight); + expect(copy.size, original.size); + }); + }); + + group('MotionData', () { + test('prefers angle when serializing zero-rotate motions', () { + final motion = MotionData( + pitchServo: MotionDataItem(angle: 450, speed: 700), + yawServo: MotionDataItem(angle: 120, speed: 500), + ); + + final decoded = MotionData.fromJson(motion.toJson()); + + expect(decoded.pitchServo.angle, 450); + expect(decoded.pitchServo.speed, 700); + expect(decoded.yawServo.toJson(), {'angle': 120, 'speed': 500}); + }); + + test('serializes rotate motions without an angle field', () { + final item = MotionDataItem(rotate: 90, speed: 333); + + expect(item.toJson(), {'rotate': 90, 'speed': 333}); + }); + }); + + group('RgbData', () { + test('uses documented defaults and round-trips JSON', () { + final rgb = RgbData(); + final decoded = RgbData.fromJson(rgb.toJson()); + + expect(decoded.leftRgbColor, '#FFFFFF'); + expect(decoded.rightRgbColor, '#FFFFFF'); + expect(decoded.leftRgbDuration, 0.0); + expect(decoded.rightRgbDuration, 0.0); + }); + }); +} diff --git a/app/test/util/mac_address_validator_test.dart b/app/test/util/mac_address_validator_test.dart new file mode 100644 index 00000000..e061913e --- /dev/null +++ b/app/test/util/mac_address_validator_test.dart @@ -0,0 +1,50 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stack_chan/util/mac_address_validator.dart'; + +void main() { + group('MacAddressValidator.isValidMac', () { + test('accepts supported formats', () { + expect(MacAddressValidator.isValidMac('00:11:22:33:44:55'), isTrue); + expect(MacAddressValidator.isValidMac('aa-bb-cc-dd-ee-ff'), isTrue); + expect(MacAddressValidator.isValidMac('AABBCCDDEEFF'), isTrue); + }); + + test('rejects malformed values', () { + expect(MacAddressValidator.isValidMac(null), isFalse); + expect(MacAddressValidator.isValidMac(''), isFalse); + expect(MacAddressValidator.isValidMac('GG:11:22:33:44:55'), isFalse); + expect(MacAddressValidator.isValidMac('00112233445'), isFalse); + }); + }); + + group('MacAddressValidator formatting helpers', () { + test('normalize equivalent addresses to a stable form', () { + expect( + MacAddressValidator.formatMac('aa-bb-cc-dd-ee-ff'), + 'AA:BB:CC:DD:EE:FF', + ); + expect( + MacAddressValidator.formatLowerCaseMac('AABBCCDDEEFF'), + 'aa:bb:cc:dd:ee:ff', + ); + expect(MacAddressValidator.toPureMac('aa:bb:cc:dd:ee:ff'), 'AABBCCDDEEFF'); + expect(MacAddressValidator.normalize('AA-BB-CC-DD-EE-FF'), 'aabbccddeeff'); + }); + + test('compare addresses independent of case and separator', () { + expect( + MacAddressValidator.areEqual('aa:bb:cc:dd:ee:ff', 'AABBCCDDEEFF'), + isTrue, + ); + expect( + MacAddressValidator.areEqual('aa:bb:cc:dd:ee:ff', '00:11:22:33:44:55'), + isFalse, + ); + }); + }); +} diff --git a/app/test/widget_test.dart b/app/test/widget_test.dart deleted file mode 100644 index 11c2a964..00000000 --- a/app/test/widget_test.dart +++ /dev/null @@ -1,34 +0,0 @@ -/* -SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD -SPDX-License-Identifier: MIT -*/ - -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:stack_chan/view/app.dart'; - -void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(App()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - }); -} diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..9403df2f --- /dev/null +++ b/codecov.yml @@ -0,0 +1,36 @@ +coverage: + range: 60..85 + round: down + precision: 2 + status: + project: + default: + target: auto + threshold: 2% + informational: true + patch: + default: + target: 70% + threshold: 0% + informational: true + +comment: + layout: "reach,diff,flags,tree" + require_changes: true + behavior: default + +flags: + server: + paths: + - server/ + app: + paths: + - app/ + +ignore: + - "app/android/**" + - "app/ios/**" + - "firmware/**" + - "remote/**" + - "server/internal/model/do/**" + - "server/internal/model/entity/**" diff --git a/server/go.sum b/server/go.sum new file mode 100644 index 00000000..06069daa --- /dev/null +++ b/server/go.sum @@ -0,0 +1,70 @@ +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME= +github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/emirpasic/gods/v2 v2.0.0-alpha h1:dwFlh8pBg1VMOXWGipNMRt8v96dKAIvBehtCt6OtunU= +github.com/emirpasic/gods/v2 v2.0.0-alpha/go.mod h1:W0y4M2dtBB9U5z3YlghmpuUhiaZT2h6yoeE+C1sCp6A= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/gogf/gf/contrib/drivers/mysql/v2 v2.10.0/go.mod h1:6v7oGBF9wv59WERJIOJxXmLhkUcxwON3tPYW3AZ7wbY= +github.com/gogf/gf/v2 v2.10.0 h1:rzDROlyqGMe/eM6dCalSR8dZOuMIdLhmxKSH1DGhbFs= +github.com/gogf/gf/v2 v2.10.0/go.mod h1:Svl1N+E8G/QshU2DUbh/3J/AJauqCgUnxHurXWR4Qx0= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grokify/html-strip-tags-go v0.1.0 h1:03UrQLjAny8xci+R+qjCce/MYnpNXCtgzltlQbOBae4= +github.com/grokify/html-strip-tags-go v0.1.0/go.mod h1:ZdzgfHEzAfz9X6Xe5eBLVblWIxXfYSQ40S/VKrAOGpc= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= +github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc= +github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= +github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo= +github.com/olekukonko/errors v1.2.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= +github.com/olekukonko/ll v0.1.8 h1:ysHCJRGHYKzmBSdz9w5AySztx7lG8SQY+naTGYUbsz8= +github.com/olekukonko/ll v0.1.8/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw= +github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I= +github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/server/internal/model/expression_data_test.go b/server/internal/model/expression_data_test.go new file mode 100644 index 00000000..b469b50e --- /dev/null +++ b/server/internal/model/expression_data_test.go @@ -0,0 +1,113 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package model + +import ( + "encoding/json" + "testing" +) + +func TestExpressionDataJSONRoundTrip(t *testing.T) { + t.Parallel() + + original := ExpressionData{ + Type: "bleAvatar", + LeftEye: ExpressionItem{ + X: 1, + Y: 2, + Rotation: 3, + Weight: 4, + Size: 5, + }, + RightEye: ExpressionItem{ + X: 6, + Y: 7, + Rotation: 8, + Weight: 9, + Size: 10, + }, + Mouth: ExpressionItem{ + X: 11, + Y: 12, + Rotation: 13, + Weight: 14, + Size: 15, + }, + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal expression data: %v", err) + } + + var decoded ExpressionData + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal expression data: %v", err) + } + + if decoded != original { + t.Fatalf("round-trip mismatch: got %+v want %+v", decoded, original) + } +} + +func TestMotionDataJSONRoundTrip(t *testing.T) { + t.Parallel() + + original := MotionData{ + Type: "bleMotion", + PitchServo: MotionDataItem{ + Angle: 450, + Speed: 500, + }, + YawServo: MotionDataItem{ + Rotate: 120, + Speed: 300, + }, + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal motion data: %v", err) + } + + var decoded MotionData + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal motion data: %v", err) + } + + if decoded != original { + t.Fatalf("round-trip mismatch: got %+v want %+v", decoded, original) + } +} + +func TestDanceDataJSONRoundTrip(t *testing.T) { + t.Parallel() + + original := DanceData{ + LeftEye: ExpressionItem{Weight: 100}, + RightEye: ExpressionItem{ + Weight: 100, + }, + Mouth: ExpressionItem{Size: 25}, + PitchServo: MotionDataItem{Angle: 450, Speed: 900}, + YawServo: MotionDataItem{Angle: 100, Speed: 700}, + DurationMs: 650, + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal dance data: %v", err) + } + + var decoded DanceData + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal dance data: %v", err) + } + + if decoded != original { + t.Fatalf("round-trip mismatch: got %+v want %+v", decoded, original) + } +} diff --git a/server/internal/model/user_test.go b/server/internal/model/user_test.go new file mode 100644 index 00000000..e9cbcdb9 --- /dev/null +++ b/server/internal/model/user_test.go @@ -0,0 +1,55 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package model + +import ( + "encoding/json" + "testing" +) + +func TestRemoteRegisterResponseJSONMapping(t *testing.T) { + t.Parallel() + + payload := []byte(`{ + "status": { + "code": "ok", + "message": "created" + }, + "response": { + "uid": 7, + "username": "pro777", + "userslug": "pro777", + "email": "pro777@example.com", + "email:confirmed": 1, + "joindate": 1747094400000, + "lastonline": 1747094405000, + "picture": null, + "icon:bgColor": "#ffaa00", + "fullname": null, + "displayname": "Pro777", + "icon:text": "P7", + "status": "online" + } + }`) + + var decoded RemoteRegisterResp + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatalf("unmarshal remote register response: %v", err) + } + + if decoded.Status.Code != "ok" { + t.Fatalf("unexpected status code: %q", decoded.Status.Code) + } + if decoded.RegistrationResponse.Username != "pro777" { + t.Fatalf("unexpected username: %q", decoded.RegistrationResponse.Username) + } + if decoded.RegistrationResponse.IconBgColor != "#ffaa00" { + t.Fatalf("unexpected icon background color: %q", decoded.RegistrationResponse.IconBgColor) + } + if decoded.RegistrationResponse.UserStatus != "online" { + t.Fatalf("unexpected user status: %q", decoded.RegistrationResponse.UserStatus) + } +} diff --git a/server/internal/model/value_constant_test.go b/server/internal/model/value_constant_test.go new file mode 100644 index 00000000..06985711 --- /dev/null +++ b/server/internal/model/value_constant_test.go @@ -0,0 +1,32 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package model + +import ( + "encoding/json" + "testing" +) + +func TestDefaultDanceDataIsValidJSON(t *testing.T) { + t.Parallel() + + var decoded []DanceData + if err := json.Unmarshal([]byte(DefaultDanceData), &decoded); err != nil { + t.Fatalf("default dance data must stay valid JSON: %v", err) + } + + if len(decoded) == 0 { + t.Fatal("default dance data must contain at least one keyframe") + } + + first := decoded[0] + if first.LeftEye.Weight != 100 || first.RightEye.Weight != 100 { + t.Fatalf("unexpected neutral eye weights in first keyframe: %+v", first) + } + if first.PitchServo.Speed == 0 || first.DurationMs == 0 { + t.Fatalf("first keyframe should include servo speed and duration: %+v", first) + } +} diff --git a/server/internal/model/web_socket_model_test.go b/server/internal/model/web_socket_model_test.go new file mode 100644 index 00000000..fc9a21c6 --- /dev/null +++ b/server/internal/model/web_socket_model_test.go @@ -0,0 +1,77 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package model + +import ( + "testing" + "time" +) + +func TestNewAppClientInitializesAndCloses(t *testing.T) { + t.Parallel() + + client := NewAppClient("aa:bb:cc:dd:ee:ff", nil, "device-1") + t.Cleanup(client.CloseWriterCoroutine) + + if got := client.GetMac(); got != "aa:bb:cc:dd:ee:ff" { + t.Fatalf("unexpected mac: %q", got) + } + if got := client.GetDeviceId(); got != "device-1" { + t.Fatalf("unexpected device id: %q", got) + } + if cap(client.SendChan()) != 100 { + t.Fatalf("unexpected send channel capacity: %d", cap(client.SendChan())) + } + + now := time.Unix(1700000000, 0) + client.SetLastTime(now) + if got := client.GetLastTime(); !got.Equal(now) { + t.Fatalf("unexpected last time: %v", got) + } + + client.CloseWriterCoroutine() + waitForClosedChannel(t, client.SendChan()) +} + +func TestNewStackChanClientInitializesAndCloses(t *testing.T) { + t.Parallel() + + client := NewStackChanClient("11:22:33:44:55:66", nil, nil, nil, true) + t.Cleanup(client.CloseWriterCoroutine) + + if got := client.GetMac(); got != "11:22:33:44:55:66" { + t.Fatalf("unexpected mac: %q", got) + } + if cap(client.SendChan()) != 100 { + t.Fatalf("unexpected send channel capacity: %d", cap(client.SendChan())) + } + + client.SetMac("66:55:44:33:22:11") + if got := client.GetMac(); got != "66:55:44:33:22:11" { + t.Fatalf("unexpected mac after set: %q", got) + } + + client.CloseWriterCoroutine() + waitForClosedChannel(t, client.SendChan()) +} + +func waitForClosedChannel(t *testing.T, ch chan *WsSendMsg) { + t.Helper() + + deadline := time.After(2 * time.Second) + for { + select { + case _, ok := <-ch: + if !ok { + return + } + case <-deadline: + t.Fatal("timed out waiting for writer coroutine to close channel") + default: + time.Sleep(10 * time.Millisecond) + } + } +} diff --git a/server/internal/service/device_test.go b/server/internal/service/device_test.go deleted file mode 100644 index 3331bf1d..00000000 --- a/server/internal/service/device_test.go +++ /dev/null @@ -1,13 +0,0 @@ -/* -SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD -SPDX-License-Identifier: MIT -*/ - -package service - -import ( - "testing" -) - -func TestCreateMac(t *testing.T) { -} From 3fa479cc8359d052888e0472e4529d0c197298ae Mon Sep 17 00:00:00 2001 From: John Malone Date: Mon, 18 May 2026 16:28:26 -0500 Subject: [PATCH 2/2] chore: address coderabbit review --- .coderabbit.yaml | 20 +++++++++---------- .github/workflows/ci.yml | 2 +- .../internal/model/web_socket_model_test.go | 3 +-- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 4463a690..8a37f0ed 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -14,15 +14,15 @@ reviews: poem: false review_status: true collapse_walkthrough: false + path_instructions: + - path: "firmware/**" + instructions: | + This repo contains embedded firmware, app, server, and remote-control code. For firmware changes, prioritize memory usage, build reproducibility, hardware safety, transport compatibility, and regressions in BLE/Wi-Fi control paths. + - path: "server/**" + instructions: | + For server changes, prioritize request/response correctness, configuration safety, auth handling, and whether new code remains testable without requiring a live database. + - path: "app/**" + instructions: | + For Flutter app changes, prioritize BLE/device-control correctness, platform compatibility, analyzer cleanliness, and avoiding generated-file churn in reviews. chat: auto_reply: true -path_instructions: - - path: "firmware/**" - instructions: | - This repo contains embedded firmware, app, server, and remote-control code. For firmware changes, prioritize memory usage, build reproducibility, hardware safety, transport compatibility, and regressions in BLE/Wi-Fi control paths. - - path: "server/**" - instructions: | - For server changes, prioritize request/response correctness, configuration safety, auth handling, and whether new code remains testable without requiring a live database. - - path: "app/**" - instructions: | - For Flutter app changes, prioritize BLE/device-control correctness, platform compatibility, analyzer cleanliness, and avoiding generated-file churn in reviews. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e82de514..af89e0d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: - name: Run Staticcheck run: | - go install honnef.co/go/tools/cmd/staticcheck@latest + go install honnef.co/go/tools/cmd/staticcheck@v0.6.1 "$(go env GOPATH)/bin/staticcheck" ./internal/model/... - name: Run Go tests with coverage diff --git a/server/internal/model/web_socket_model_test.go b/server/internal/model/web_socket_model_test.go index fc9a21c6..04199137 100644 --- a/server/internal/model/web_socket_model_test.go +++ b/server/internal/model/web_socket_model_test.go @@ -68,10 +68,9 @@ func waitForClosedChannel(t *testing.T, ch chan *WsSendMsg) { if !ok { return } + // Channel yielded a value before close; keep waiting for closure. case <-deadline: t.Fatal("timed out waiting for writer coroutine to close channel") - default: - time.Sleep(10 * time.Millisecond) } } }