Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 65 additions & 2 deletions .github/workflows/flutter_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ on:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
test-group: [
"core",
"services",
"step-tests",
"ui-widgets"
]
fail-fast: false

steps:
- uses: actions/checkout@v4
Expand All @@ -30,5 +39,59 @@ jobs:
- name: Install dependencies
run: flutter pub get

- name: Run tests
run: flutter test
- name: Run core tests
if: matrix.test-group == 'core'
run: |
flutter test \
test/config_service_pets_rarity_fallback_test.dart \
test/decimal_tap_gain_test.dart \
test/decimal_utils_test.dart \
test/game_clock_test.dart \
test/meme_points_precision_test.dart \
test/persistence_test.dart \
test/secure_save_fast_cache_test.dart \
test/secure_save_service_test.dart

- name: Run services tests
if: matrix.test-group == 'services'
run: |
flutter test \
test/checkin_collect_accum_test.dart \
test/checkin_service_integration_test.dart \
test/daily_mission_freeze_target_test.dart \
test/daily_mission_service_test.dart \
test/daily_tap_service_test.dart \
test/equipment_dependencies_test.dart \
test/equipment_service_default_levels_test.dart \
test/equipment_service_test.dart \
test/gacha_ad_service_test.dart \
test/gacha_animation_test.dart \
test/gacha_pending_batch_test.dart \
test/gacha_service_test.dart \
test/karaoke_service_test.dart \
test/localization_service_test.dart \
test/main_quest_service_test.dart \
test/offline_reward_service_test.dart \
test/page_manager_test.dart \
test/pet_model_test.dart \
test/pet_service_test.dart \
test/pet_ticket_quest_service_test.dart \
test/quest_generation_test.dart \
test/quest_instantiation_test.dart \
test/tap_service_test.dart \
test/titles_persistence_test.dart

- name: Run step tests
if: matrix.test-group == 'step-tests'
run: |
flutter test \
test/step*

- name: Run UI and widget tests
if: matrix.test-group == 'ui-widgets'
run: |
flutter test \
test/*_widget_test.dart \
test/*_page_test.dart \
test/ui/ \
test/widget_test.dart
57 changes: 57 additions & 0 deletions .windsurf/rules/ci-update.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
trigger: always_on
---

---
trigger: always_on
---

# CI 測試配置更新規則

## 🎯 核心原則

每次實作完測試檔案後,**必須**檢查並更新 [.github/workflows/flutter_test.yml](cci:7://file:///Users/kais/WS/idle_hippo/.github/workflows/flutter_test.yml:0:0-0:0) 中的測試分組配置。

## 📋 執行步驟

### 1. 測試檔案分類判斷

根據測試檔案的性質,判斷應歸類到哪個 `test-group`:

#### **core** 組別
- 配置服務測試(`config_service_*.dart`)
- 數值計算測試(`decimal_*.dart`、`meme_points_*.dart`)
- 持久化核心測試([persistence_test.dart](cci:7://file:///Users/kais/WS/idle_hippo/test/persistence_test.dart:0:0-0:0)、`secure_save_*.dart`)
- 遊戲時鐘測試([game_clock_test.dart](cci:7://file:///Users/kais/WS/idle_hippo/test/game_clock_test.dart:0:0-0:0))

#### **services** 組別
- 各種遊戲服務測試(`*_service_test.dart`)
- 功能邏輯測試(`checkin_*.dart`、`daily_*.dart`、`gacha_*.dart` 等)
- 模型測試(`*_model_test.dart`)
- 任務相關測試(`quest_*.dart`、`main_quest_*.dart`)

### **step-tests** 組別 - 自動匹配
- `test/step*` ✅ **完全自動化**
- 所有 `step` 開頭的檔案或目錄都會自動包含

#### **ui-widgets** 組別
- Widget 測試(`*_widget_test.dart`)
- UI 頁面測試(`*_page_test.dart`、`*_screen_*.dart`)
- `test/ui/` 目錄下的所有測試

## 🔍 何時需要手動更新 CI?

### ✅ 不需要更新的情況
- 新增 `test/stepXX*/` 任何檔案或目錄

### ⚠️ 需要手動更新的情況

在對應的測試組別中新增測試檔案路徑:

```yaml
- name: Run [GROUP_NAME] tests
if: matrix.test-group == '[GROUP_NAME]'
run: |
flutter test \
[existing_tests...] \
[NEW_TEST_FILE_PATH]
45 changes: 35 additions & 10 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import java.io.FileInputStream
import java.util.Properties

val keystoreProperties = Properties().apply {
val propsFile = rootProject.file("key.properties")
if (propsFile.exists()) {
FileInputStream(propsFile).use { load(it) }
}
}

plugins {
id("com.android.application")
id("kotlin-android")
id("org.jetbrains.kotlin.android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
Expand All @@ -21,20 +31,35 @@ android {

defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.idle_hippo"
applicationId = "com.kais.idle_hippo"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
minSdk = 21
targetSdk = 35
versionCode = 2
versionName = "0.0.23-2-1"
}

signingConfigs {
create("release") {
val storeFilePath = keystoreProperties["storeFile"] as String?
if (storeFilePath != null && storeFilePath.isNotBlank()) {
storeFile = file(storeFilePath)
}
storePassword = keystoreProperties["storePassword"] as String?
keyAlias = keystoreProperties["keyAlias"] as String?
keyPassword = keystoreProperties["keyPassword"] as String?
}
}

buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
getByName("release") {
val rel = signingConfigs.getByName("release")
if (rel.storeFile != null) {
signingConfig = rel
}
isMinifyEnabled = false
isShrinkResources = false
}
}
}
Expand Down
37 changes: 37 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,43 @@ assets/config/
- 名稱與描述可直接使用本檔 `name`/`desc`(含多語對應值);若未提供對應語言,會回退到 `en` 或 `zh`。
- 僅提供 UI 所需欄位;購買/權益等邏輯將於後續階段實作。

### SKU 對照層(Step27-1)

`SkuMapper` 服務提供 storeId 到 skuId 的轉換功能,用於 Google Play IAP 整合:

#### 功能說明

- **單向轉換**:將商店資料用的 `storeId`(如 `store.card_click_perm`)轉換為 IAP 用的 `skuId`(如 `card_click_perm`)
- **轉換規則**:去除 `store.` 前綴
- **錯誤處理**:若 `storeId` 不存在於 `items` 中,拋出 `SkuMappingNotFound` 例外

#### 使用方式

```dart
final skuMapper = SkuMapper.instance;
await skuMapper.init();

// 成功轉換
final skuId = skuMapper.getSkuId('store.card_click_perm'); // 回傳 'card_click_perm'

// 失敗情況
try {
skuMapper.getSkuId('store.not_exists');
} catch (e) {
print(e); // SkuMappingNotFound: store.not_exists
}

// 驗證所有 tabs 中的 storeId 都能成功轉換
final isValid = skuMapper.validateAllTabsStoreIds(); // 回傳 true/false
```

#### API 方法

- `Future<void> init()`: 初始化,載入 store.json 配置
- `String getSkuId(String storeId)`: 轉換 storeId 為 skuId
- `List<String> getAllStoreIds()`: 取得所有有效的 storeId 列表
- `bool validateAllTabsStoreIds()`: 驗證所有 tabs 中的 storeId 都能成功轉換

---

## 📋 quests.json - 主線任務配置
Expand Down
28 changes: 28 additions & 0 deletions docs/step27-1/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# 階段 27-1|Google 審核前 SKU 對照層(StoreId ↔ SkuId)

## 目標

建立一個純前端的「字串鍵位對照層」,把 **商店資料用的 `storeId`**(如 `store.card_click_perm`)按規則轉為 **IAP 用的 `skuId`**(如 `card_click_perm`)。

* 單向最低限度:`storeId -> skuId`
* 若 `storeId` 無對應,回傳明確錯誤(不可 fallback)。

### 規格

* 新增 `SkuMapper`(純同步函式,不做 IO):
* `String getSkuId(String storeId)`
* 對應表來源:你現有的 store JSON(`items` 的 key → 去掉 `store.` 前綴即為 `skuId`)
* 例:`store.card_click_perm` → `card_click_perm`

## 驗收實例化需求

1. **對照成功**
* Given:`store.card_click_perm`
* When:呼叫 `getSkuId`
* Then:回傳 `card_click_perm`
2. **對照失敗(不存在)**
* Given:`store.not_exists`
* When:呼叫 `getSkuId`
* Then:丟出錯誤 `SkuMappingNotFound(storeId)`
3. **全域一致性檢查(靜態)**
* 遍歷 `tabs.*[].items` 所有 `storeId`,皆能 `getSkuId` 成功。
97 changes: 97 additions & 0 deletions lib/services/sku_mapper.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import 'dart:convert';
import 'package:flutter/services.dart';

/// 自定義例外:當 storeId 無法對應到 skuId 時拋出
class SkuMappingNotFound implements Exception {
final String storeId;

const SkuMappingNotFound(this.storeId);

@override
String toString() => 'SkuMappingNotFound: $storeId';
}

/// SKU 對照服務:將 storeId 轉換為 skuId
class SkuMapper {
static SkuMapper? _instance;
static SkuMapper get instance => _instance ??= SkuMapper._();

SkuMapper._();

Map<String, dynamic>? _storeData;
bool _initialized = false;

/// 初始化:載入 store.json 配置
Future<void> init() async {
if (_initialized) return;

try {
final String jsonString = await rootBundle.loadString('assets/config/store.json');
_storeData = json.decode(jsonString);
_initialized = true;
} catch (e) {
throw Exception('Failed to load store.json: $e');
}
}

/// 將 storeId 轉換為 skuId
/// 規則:去掉 "store." 前綴
/// 例:store.card_click_perm -> card_click_perm
String getSkuId(String storeId) {
if (!_initialized) {
throw StateError('SkuMapper not initialized. Call init() first.');
}

// 檢查 storeId 是否存在於 store.json 的 items 中
final items = _storeData?['items'] as Map<String, dynamic>?;
if (items == null || !items.containsKey(storeId)) {
throw SkuMappingNotFound(storeId);
}

// 去掉 "store." 前綴
if (storeId.startsWith('store.')) {
return storeId.substring(6); // 移除 "store." (6 個字元)
}

// 如果不是以 "store." 開頭,也視為無效
throw SkuMappingNotFound(storeId);
}

/// 取得所有有效的 storeId 列表
List<String> getAllStoreIds() {
if (!_initialized) {
throw StateError('SkuMapper not initialized. Call init() first.');
}

final items = _storeData?['items'] as Map<String, dynamic>?;
return items?.keys.toList() ?? [];
}

/// 驗證所有 tabs 中的 storeId 都能成功轉換
bool validateAllTabsStoreIds() {
if (!_initialized) {
throw StateError('SkuMapper not initialized. Call init() first.');
}

final tabs = _storeData?['tabs'] as Map<String, dynamic>?;
if (tabs == null) return false;

try {
// 遍歷所有 tabs
for (final tabEntry in tabs.entries) {
final tabSections = tabEntry.value as List<dynamic>;
for (final section in tabSections) {
final items = section['items'] as List<dynamic>?;
if (items != null) {
for (final storeId in items) {
getSkuId(storeId as String); // 會拋出例外如果轉換失敗
}
}
}
}
return true;
} catch (e) {
return false;
}
}
}
Loading