refactor: SakeItemのdisplayData setter危険性を排除し、テストを追加

- SakeItem.applyUpdates()を追加(displayData/hiddenSpecsを1回のsave()でアトミックに更新)
- displayData/hiddenSpecs setterに@Deprecatedを付与(save()忘れによるデータ消失防止)
- sakenowa_auto_matching_service.dartをapplyUpdates()に移行(setterの直接使用を撲滅)
- SakeAnalysisResult.fromJson()のユニットテストを新規追加(tasteStatsクランプ・欠損補完等)
- SakeItem.ensureMigrated()のユニットテストを追加

https://claude.ai/code/session_01DWQpnqrQWwxVKKWSL9kDPp
This commit is contained in:
Claude 2026-04-16 23:40:48 +00:00
parent aa933cf1e3
commit 8ebd233305
No known key found for this signature in database
4 changed files with 230 additions and 15 deletions

View File

@ -157,12 +157,20 @@ class SakeItem extends HiveObject {
); );
} }
// Allow setting for UI updates ( await sakeItem.save() ) /// displayData/hiddenSpecs Hiveへ保存する
/// setterを直接使わずこのメソッドを使うこと
Future<void> applyUpdates({
DisplayData? displayData,
HiddenSpecs? hiddenSpecs,
}) async {
if (displayData != null) _displayData = displayData;
if (hiddenSpecs != null) _hiddenSpecs = hiddenSpecs;
await save();
}
@Deprecated('Use applyUpdates() instead to ensure save() is always called.')
set displayData(DisplayData val) { set displayData(DisplayData val) {
_displayData = val; _displayData = val;
// save() setter await
// unawaited save()
// sakenowa_auto_matching_service.dart await save()
} }
HiddenSpecs get hiddenSpecs { HiddenSpecs get hiddenSpecs {
@ -176,7 +184,7 @@ class SakeItem extends HiveObject {
); );
} }
// Allow setting for auto-matching @Deprecated('Use applyUpdates() instead to ensure save() is always called.')
set hiddenSpecs(HiddenSpecs val) { set hiddenSpecs(HiddenSpecs val) {
_hiddenSpecs = val; _hiddenSpecs = val;
} }

View File

@ -159,12 +159,10 @@ class SakenowaAutoMatchingService {
sakenowaFlavorChart: flavorChartMap, sakenowaFlavorChart: flavorChartMap,
); );
// SakeItem更新 await sakeItem.applyUpdates(
sakeItem.displayData = updatedDisplayData; displayData: updatedDisplayData,
sakeItem.hiddenSpecs = updatedHiddenSpecs; hiddenSpecs: updatedHiddenSpecs,
);
// Hiveに保存
await sakeItem.save();
debugPrint(' [SakenowaAutoMatching] 適用完了!'); debugPrint(' [SakenowaAutoMatching] 適用完了!');
debugPrint(' displayName: ${sakeItem.displayData.displayName}'); debugPrint(' displayName: ${sakeItem.displayData.displayName}');
@ -226,10 +224,10 @@ class SakenowaAutoMatchingService {
sakenowaFlavorChart: null, sakenowaFlavorChart: null,
); );
sakeItem.displayData = clearedDisplayData; await sakeItem.applyUpdates(
sakeItem.hiddenSpecs = clearedHiddenSpecs; displayData: clearedDisplayData,
hiddenSpecs: clearedHiddenSpecs,
await sakeItem.save(); );
debugPrint(' [SakenowaAutoMatching] クリア完了'); debugPrint(' [SakenowaAutoMatching] クリア完了');
} }

View File

@ -137,6 +137,57 @@ void main() {
}); });
}); });
group('SakeItem - ensureMigrated', () {
test('レガシーフィールドからdisplayDataを構築する', () {
final sake = SakeItem(
id: 'legacy-001',
legacyName: '出羽桜',
legacyBrand: '出羽桜酒造',
legacyPrefecture: '山形県',
legacyCatchCopy: '花と夢と',
legacyImagePaths: ['/path/legacy.jpg'],
);
// displayData未設定の状態でgetterがlegacyから返す
expect(sake.displayData.name, '出羽桜');
expect(sake.displayData.brewery, '出羽桜酒造');
expect(sake.displayData.prefecture, '山形県');
// ensureMigratedで新構造に昇格
final migrated = sake.ensureMigrated();
expect(migrated, true); //
// 2falseを返す
final again = sake.ensureMigrated();
expect(again, false);
});
test('displayDataが既に設定されている場合はfalseを返す', () {
final sake = SakeItem(
id: 'new-001',
displayData: DisplayData(
name: '新政',
brewery: '新政酒造',
prefecture: '秋田県',
imagePaths: [],
),
);
final migrated = sake.ensureMigrated();
expect(migrated, false);
});
test('legacyNameがnullの場合はUnknownにフォールバックする', () {
final sake = SakeItem(id: 'legacy-002');
sake.ensureMigrated();
expect(sake.displayData.name, 'Unknown');
expect(sake.displayData.brewery, 'Unknown');
expect(sake.displayData.prefecture, 'Unknown');
});
});
group('SakeItem - HiddenSpecs / TasteStats', () { group('SakeItem - HiddenSpecs / TasteStats', () {
test('should return SakeTasteStats from tasteStats map', () { test('should return SakeTasteStats from tasteStats map', () {
final sake = SakeItem( final sake = SakeItem(

View File

@ -0,0 +1,158 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ponshu_room_lite/services/gemini_service.dart';
void main() {
group('SakeAnalysisResult.fromJson', () {
test('正常なJSONから全フィールドを正しくパースする', () {
final json = {
'name': '獺祭',
'brand': '旭酒造',
'prefecture': '山口県',
'type': '純米大吟醸',
'description': 'フルーティーで華やかな香り',
'catchCopy': '磨きその先へ',
'confidenceScore': 92,
'flavorTags': ['フルーティー', '辛口'],
'tasteStats': {'aroma': 5, 'sweetness': 2, 'acidity': 3, 'bitterness': 2, 'body': 3},
'alcoholContent': 16.0,
'polishingRatio': 23,
'sakeMeterValue': 3.0,
'riceVariety': '山田錦',
'yeast': 'きょうかい9号',
'manufacturingYearMonth': '2024.01',
};
final result = SakeAnalysisResult.fromJson(json);
expect(result.name, '獺祭');
expect(result.brand, '旭酒造');
expect(result.prefecture, '山口県');
expect(result.type, '純米大吟醸');
expect(result.confidenceScore, 92);
expect(result.flavorTags, ['フルーティー', '辛口']);
expect(result.alcoholContent, 16.0);
expect(result.polishingRatio, 23);
expect(result.riceVariety, '山田錦');
expect(result.manufacturingYearMonth, '2024.01');
expect(result.isFromCache, false);
});
test('フィールドが全てnullの場合にデフォルト値が設定される', () {
final result = SakeAnalysisResult.fromJson({});
expect(result.name, isNull);
expect(result.brand, isNull);
expect(result.prefecture, isNull);
expect(result.flavorTags, isEmpty);
expect(result.isFromCache, false);
});
test('tasteStats: 範囲外の値(0, 6)が1〜5にクランプされる', () {
final json = {
'tasteStats': {
'aroma': 0,
'sweetness': 6,
'acidity': -1,
'bitterness': 10,
'body': 3,
},
};
final result = SakeAnalysisResult.fromJson(json);
expect(result.tasteStats['aroma'], 1);
expect(result.tasteStats['sweetness'], 5);
expect(result.tasteStats['acidity'], 1);
expect(result.tasteStats['bitterness'], 5);
expect(result.tasteStats['body'], 3);
});
test('tasteStats: 一部キーが欠損していると3で補完される', () {
final json = {
'tasteStats': {
'aroma': 5,
// sweetness, acidity, bitterness, body
},
};
final result = SakeAnalysisResult.fromJson(json);
expect(result.tasteStats['aroma'], 5);
expect(result.tasteStats['sweetness'], 3);
expect(result.tasteStats['acidity'], 3);
expect(result.tasteStats['bitterness'], 3);
expect(result.tasteStats['body'], 3);
});
test('tasteStats: nullまたは不正な型の場合は全キーが3になる', () {
final json = {'tasteStats': null};
final result = SakeAnalysisResult.fromJson(json);
expect(result.tasteStats['aroma'], 3);
expect(result.tasteStats['sweetness'], 3);
expect(result.tasteStats['body'], 3);
});
test('alcoholContent: intで渡された場合もdoubleとして取得できる', () {
final json = {'alcoholContent': 15};
final result = SakeAnalysisResult.fromJson(json);
expect(result.alcoholContent, 15.0);
});
test('flavorTags: nullの場合は空リストになる', () {
final json = {'flavorTags': null};
final result = SakeAnalysisResult.fromJson(json);
expect(result.flavorTags, isEmpty);
});
});
group('SakeAnalysisResult.asCached', () {
test('asCached()はisFromCache=trueを返す', () {
final original = SakeAnalysisResult(name: '獺祭', brand: '旭酒造');
final cached = original.asCached();
expect(cached.isFromCache, true);
expect(cached.name, '獺祭');
expect(cached.brand, '旭酒造');
});
test('元のインスタンスはisFromCache=falseを維持する', () {
final original = SakeAnalysisResult(name: '久保田');
original.asCached();
expect(original.isFromCache, false);
});
});
group('SakeAnalysisResult.toJson / fromJson 往復', () {
test('toJson → fromJson で値が保持される', () {
final original = SakeAnalysisResult(
name: '八海山',
brand: '八海醸造',
prefecture: '新潟県',
type: '特別本醸造',
confidenceScore: 80,
flavorTags: ['辛口', 'すっきり'],
tasteStats: {'aroma': 2, 'sweetness': 2, 'acidity': 3, 'bitterness': 3, 'body': 3},
alcoholContent: 15.5,
polishingRatio: 55,
);
final json = original.toJson();
final restored = SakeAnalysisResult.fromJson(json);
expect(restored.name, original.name);
expect(restored.brand, original.brand);
expect(restored.prefecture, original.prefecture);
expect(restored.confidenceScore, original.confidenceScore);
expect(restored.flavorTags, original.flavorTags);
expect(restored.alcoholContent, original.alcoholContent);
expect(restored.polishingRatio, original.polishingRatio);
});
test('toJson に isFromCache は含まれない', () {
final result = SakeAnalysisResult(name: 'テスト').asCached();
final json = result.toJson();
expect(json.containsKey('isFromCache'), false);
});
});
}