462 lines
14 KiB
Dart
462 lines
14 KiB
Dart
// 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('');
|
||
}
|