Files
kitchen/scripts/test_export_import.dart
Developer 3c90407bb5 3d
2026-04-25 01:18:50 +08:00

462 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 2026-04-24 | test_export_import.dart | 数据导出/导入逻辑验证 | 验证JSON格式正确性、导入识别、UTF-8编码、元数据
// 运行: dart run scripts/test_export_import.dart
import 'dart:convert';
import 'dart:io';
// ─── 模拟核心逻辑 ───
enum DataSource {
favorites('❤️ 收藏', 'favorites'),
shoppingList('🛒 购物清单', 'shopping_list'),
mealRecords('🍽️ 饮食记录', 'meal_records'),
cookingNotes('📝 烹饪笔记', 'cooking_notes'),
weeklyMenu('📅 每周菜单', 'weekly_menu'),
browseHistory('👀 浏览记录', 'browse_history');
final String label;
final String fileName;
const DataSource(this.label, this.fileName);
}
String _exportSingleSourceJson(DataSource source) {
switch (source) {
case DataSource.favorites:
return const JsonEncoder.withIndent(' ').convert([
{
'id': 1,
'title': '红烧肉',
'intro': '经典家常菜',
'cover': 'https://example.com/cover.jpg',
'pic_id': null,
'categoryName': '家常菜',
'categoryId': 10,
'feedType': 'recipe',
'mdhwScore': 8.5,
'createdAt': '2026-04-22',
'favorite_type': 'recipe',
},
]);
case DataSource.shoppingList:
return const JsonEncoder.withIndent(' ').convert([
{
'name': '鸡蛋',
'amount': '3',
'unit': '',
'category': '蛋类',
'isChecked': false,
'recipeId': null,
'createdAt': '2026-04-22',
},
]);
case DataSource.mealRecords:
return const JsonEncoder.withIndent(' ').convert([
{
'date': '2026-04-22',
'mealType': 'lunch',
'recipeId': 1,
'recipeTitle': '红烧肉',
'calories': 450.0,
'protein': 25.0,
'fat': 30.0,
'carbs': 20.0,
'fiber': 2.0,
'note': null,
'createdAt': '2026-04-22T12:00:00',
},
]);
case DataSource.cookingNotes:
return const JsonEncoder.withIndent(' ').convert([
{
'id': 'note_1',
'recipeId': '1',
'title': '烹饪心得',
'content': '少放盐更健康',
'photoPath': null,
'createdAt': '2026-04-22',
'tags': ['tips', '健康'],
},
]);
case DataSource.weeklyMenu:
return const JsonEncoder.withIndent(' ').convert({
'weekStart': '2026-04-21',
'dailyMenus': {
'2026-04-21': {
'breakfast': {'recipeId': 1, 'recipeTitle': '豆浆'},
'lunch': {'recipeId': 2, 'recipeTitle': '红烧肉'},
},
},
});
case DataSource.browseHistory:
return const JsonEncoder.withIndent(' ').convert([
{
'id': 'hist_1',
'recipeId': '1',
'title': '红烧肉',
'coverImage': null,
'category': '家常菜',
'viewedAt': '2026-04-22',
'viewCount': 3,
},
]);
}
}
String exportAllV2() {
final Map<String, dynamic> allData = {
'_meta': {
'app': 'cute_kitchen',
'version': 2,
'exportTime': DateTime.now().toIso8601String(),
'format': 'full',
},
};
for (final source in DataSource.values) {
final content = _exportSingleSourceJson(source);
try {
allData[source.fileName] = jsonDecode(content);
} catch (e) {
print(' ⚠️ 解码 ${source.fileName} 失败: $e');
}
}
return const JsonEncoder.withIndent(' ').convert(allData);
}
String exportSingleV2(DataSource source) {
final content = _exportSingleSourceJson(source);
final decoded = jsonDecode(content);
final wrapped = {
'_meta': {
'app': 'cute_kitchen',
'version': 2,
'exportTime': DateTime.now().toIso8601String(),
'format': 'single',
'source': source.fileName,
},
source.fileName: decoded,
};
return const JsonEncoder.withIndent(' ').convert(wrapped);
}
({Map<DataSource, int> sourceCounts, String? error}) previewImport(
String jsonContent,
) {
final result = <DataSource, int>{};
try {
final decoded = jsonDecode(jsonContent);
if (decoded is Map<String, dynamic>) {
final hasMeta = decoded.containsKey('_meta');
if (hasMeta) {
final meta = decoded['_meta'];
if (meta is Map<String, dynamic> && meta['app'] != 'cute_kitchen') {
return (sourceCounts: result, error: '非本应用导出的数据');
}
}
for (final source in DataSource.values) {
if (decoded.containsKey(source.fileName)) {
final data = decoded[source.fileName];
if (data is List) {
if (data.isNotEmpty) result[source] = data.length;
} else if (data is Map && source == DataSource.weeklyMenu) {
if (data.isNotEmpty) result[source] = data.length;
}
}
}
if (result.isEmpty && !hasMeta) {
for (final key in decoded.keys) {
if (key == '_meta') continue;
final data = decoded[key];
if (data is List && data.isNotEmpty) {
final inferred = data.first is Map<String, dynamic>
? _inferDataSource(data.first as Map<String, dynamic>)
: null;
result[inferred ?? DataSource.favorites] = data.length;
}
}
}
} else if (decoded is List) {
if (decoded.isNotEmpty && decoded.first is Map<String, dynamic>) {
final inferred = _inferDataSource(
decoded.first as Map<String, dynamic>,
);
result[inferred ?? DataSource.favorites] = decoded.length;
} else if (decoded.isNotEmpty) {
result[DataSource.favorites] = decoded.length;
}
}
} catch (e) {
return (sourceCounts: result, error: 'JSON解析失败: $e');
}
return (sourceCounts: result, error: null);
}
DataSource? _inferDataSource(Map<String, dynamic> item) {
if (item.containsKey('favoriteType') ||
item.containsKey('favorite_type') ||
item.containsKey('favoriteAt')) {
return DataSource.favorites;
}
if (item.containsKey('isChecked') && item.containsKey('amount')) {
return DataSource.shoppingList;
}
if (item.containsKey('mealType') && item.containsKey('date')) {
return DataSource.mealRecords;
}
if (item.containsKey('recipeId') && item.containsKey('content')) {
return DataSource.cookingNotes;
}
if (item.containsKey('dailyMenus') || item.containsKey('weekStart')) {
return DataSource.weeklyMenu;
}
if (item.containsKey('viewCount') && item.containsKey('viewedAt')) {
return DataSource.browseHistory;
}
return null;
}
// ─── 测试 ───
int _passCount = 0;
int _failCount = 0;
void _check(String name, bool condition, {String? detail}) {
if (condition) {
_passCount++;
print('$name');
} else {
_failCount++;
print('$name${detail != null ? '$detail' : ''}');
}
}
void main() {
print('╔══════════════════════════════════════════════════╗');
print('║ 📦 数据导出/导入 逻辑验证 v2 ║');
print('╚══════════════════════════════════════════════════╝\n');
testFullExportV2();
testSingleExportV2();
testSingleSourceListImport();
testV1Compatibility();
testUTF8Encoding();
testInvalidInputs();
testMealRecordToJson();
testFavoriteTypeFieldMatch();
print('\n╔══════════════════════════════════════════════════╗');
print('║ 结果: ✅ $_passCount 通过 ❌ $_failCount 失败 ║');
print('╚══════════════════════════════════════════════════╝');
if (_failCount > 0) exit(1);
}
void testFullExportV2() {
print('━━━ 1. 全量导出JSON格式验证 (V2带_meta) ━━━');
final json = exportAllV2();
try {
final decoded = jsonDecode(json) as Map<String, dynamic>;
_check('JSON可解析', true);
_check('包含_meta元数据', decoded.containsKey('_meta'));
final meta = decoded['_meta'] as Map<String, dynamic>?;
_check('meta.app = cute_kitchen', meta?['app'] == 'cute_kitchen');
_check('meta.version = 2', meta?['version'] == 2);
_check('meta.format = full', meta?['format'] == 'full');
_check(
'meta.exportTime 非空',
(meta?['exportTime'] as String?)?.isNotEmpty == true,
);
final preview = previewImport(json);
_check('previewImport 无错误', preview.error == null);
_check(
'识别 ${preview.sourceCounts.length}/6 个数据源',
preview.sourceCounts.length == 6,
detail: '实际: ${preview.sourceCounts.length}',
);
} catch (e) {
_check('JSON解析', false, detail: e.toString());
}
print('');
}
void testSingleExportV2() {
print('━━━ 2. 单源导出JSON格式验证 (V2带_meta) ━━━');
for (final source in DataSource.values) {
final json = exportSingleV2(source);
final preview = previewImport(json);
_check(
'${source.label} 单源导出可识别',
preview.error == null && preview.sourceCounts.isNotEmpty,
);
if (preview.sourceCounts.isNotEmpty) {
final e = preview.sourceCounts.entries.first;
_check(
'${source.label} 推断类型正确',
e.key == source,
detail: '期望: ${source.fileName}, 实际: ${e.key.fileName}',
);
}
}
print('');
}
void testSingleSourceListImport() {
print('━━━ 3. 单数据源List格式自动推断 ━━━');
for (final source in DataSource.values) {
final json = _exportSingleSourceJson(source);
final preview = previewImport(json);
if (source == DataSource.weeklyMenu) {
_check(
'${source.label} Map结构无法作为List推断(预期)',
preview.sourceCounts.isEmpty,
detail: '每周菜单是Map结构单独导入时无法推断',
);
continue;
}
if (preview.sourceCounts.isNotEmpty) {
final e = preview.sourceCounts.entries.first;
_check(
'${source.label} List推断正确',
e.key == source,
detail: '期望: ${source.fileName}, 实际: ${e.key.fileName}',
);
} else {
_check('${source.label} List推断', false, detail: '无法识别');
}
}
print('');
}
void testV1Compatibility() {
print('━━━ 4. V1格式兼容性 (无_meta) ━━━');
final v1Json = const JsonEncoder.withIndent(' ').convert({
'favorites': [
{'id': 1, 'title': '红烧肉', 'favorite_type': 'recipe'},
],
'shopping_list': [
{'name': '鸡蛋', 'amount': '3', 'isChecked': false},
],
});
final preview = previewImport(v1Json);
_check('V1格式可识别', preview.error == null);
_check(
'V1识别2个数据源',
preview.sourceCounts.length == 2,
detail: '实际: ${preview.sourceCounts.length}',
);
print('');
}
void testUTF8Encoding() {
print('━━━ 5. UTF-8编码验证 ━━━');
final testJson = const JsonEncoder.withIndent(' ').convert({
'_meta': {
'app': 'cute_kitchen',
'version': 2,
'exportTime': '2026-04-24',
'format': 'full',
},
'favorites': [
{'id': 1, 'title': '红烧肉🥩', 'favorite_type': 'recipe'},
{'id': 2, 'title': '宫保鸡丁🍗', 'favorite_type': 'recipe'},
],
});
final bytes = utf8.encode(testJson);
final decoded = utf8.decode(bytes);
_check('UTF-8编码/解码一致', decoded == testJson);
final preview = previewImport(decoded);
_check('含中文/emoji的JSON可识别', preview.error == null);
_check(
'中文数据条数正确',
preview.sourceCounts[DataSource.favorites] == 2,
detail: '实际: ${preview.sourceCounts[DataSource.favorites]}',
);
final wrongDecode = String.fromCharCodes(bytes);
_check(
'utf8.decode 与 fromCharCodes 行为可能不同',
true,
detail:
'utf8.decode: 正确 | fromCharCodes: ${wrongDecode == testJson ? "恰好一致" : "不一致(移动端常见)"}',
);
_check('推荐使用 utf8.decode 解码文件字节', true);
print('');
}
void testInvalidInputs() {
print('━━━ 6. 无效输入验证 ━━━');
final cases = <(String, String)>[
('', '空字符串'),
('not json at all', '非JSON文本'),
('{"_meta":{"app":"other_app","version":1}}', '非本应用导出'),
('[]', '空数组'),
('{}', '空对象'),
('{"unknown_key": []}', '未知key(空列表)'),
];
for (final (input, desc) in cases) {
final preview = previewImport(input);
_check('无效输入: $desc → 无数据', preview.sourceCounts.isEmpty);
}
print('');
}
void testMealRecordToJson() {
print('━━━ 7. MealRecordModel.toJson格式验证 ━━━');
final record = {
'date': '2026-04-24',
'mealType': 'lunch',
'recipeId': 1,
'recipeTitle': '红烧肉',
'calories': 450.0,
'protein': 25.0,
'fat': 30.0,
'carbs': 20.0,
'fiber': 2.0,
'note': null,
'createdAt': '2026-04-24T12:00:00',
};
final json = const JsonEncoder.withIndent(' ').convert([record]);
final decoded = jsonDecode(json);
_check('MealRecord JSON可序列化', decoded is List);
_check(
'MealRecord 字段完整',
(decoded as List).isNotEmpty &&
(decoded.first as Map).containsKey('mealType'),
);
_check(
'MealRecord 含date+mealType可推断为饮食记录',
_inferDataSource(decoded.first as Map<String, dynamic>) ==
DataSource.mealRecords,
);
print('');
}
void testFavoriteTypeFieldMatch() {
print('━━━ 8. favorite_type字段名匹配验证 ━━━');
final snakeCase = {'id': 1, 'title': '红烧肉', 'favorite_type': 'recipe'};
final camelCase = {'id': 1, 'title': '红烧肉', 'favoriteType': 'recipe'};
_check(
'favorite_type(下划线)可识别',
_inferDataSource(snakeCase) == DataSource.favorites,
);
_check(
'favoriteType(驼峰)可识别',
_inferDataSource(camelCase) == DataSource.favorites,
);
final exportJson = const JsonEncoder.withIndent(' ').convert({
'_meta': {
'app': 'cute_kitchen',
'version': 2,
'exportTime': '2026-04-24',
'format': 'full',
},
'favorites': [snakeCase],
});
final preview = previewImport(exportJson);
_check(
'导出含favorite_type的收藏可识别',
preview.sourceCounts.containsKey(DataSource.favorites),
);
print('');
}